6.8 KiB
第032课:Python中的并发编程(1)
现如今,我们使用的计算机早已是多CPU或多核的计算机,为此我们使用的操作系统基本都是支持“多任务”的操作系统,这样的操作系统使得我们我们可以同时运行多个程序,也可以将一个程序分解为若干个相对独立的子任务,让多个子任务“齐头并进”的执行,从而缩短程序的执行时间,同时也让用户获得更好的体验。因此,当下不管用什么编程语言进行开发,实现让一个程序同时执行多个任务已经成为程序员的标配技能。为此,我们需要先了解两个重要的概念:多进程和多线程。
线程和进程
我们通过操作系统运行一个程序会创建出一个或多个进程,进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动。简单的说,进程是操作系统分配存储空间的基本单位,每个进程都有自己的地址空间、数据栈以及其他用于跟踪进程执行的辅助数据;操作系统管理所有进程的执行,为它们合理的分配资源。一个进程可以通过fork或spawn的方式创建新的进程来执行其他的任务,不过新的进程也有自己独立的内存空间,因此两个进程如果要共享数据,必须通过进程间通信机制(IPC)来实现,具体的方式包括管道、信号、套接字等。
一个进程还可以拥有多个并发的执行线索,简单的说就是拥有多个可以获得CPU调度的执行单元,这就是所谓的线程。由于线程在同一个进程下,它们可以共享相同的上下文,因此相对于进程而言,线程间的信息共享和通信更加容易。当然在单核CPU系统中,多个线程不可能同时执行,因为在某个时刻只有一个线程能够获得CPU,多个线程通过共享了CPU执行时间的方式来达到并发的效果。在程序中使用多线程技术通常都会带来不言而喻的好处,最主要的体现在提升程序的性能和改善用户体验,今天我们使用的软件几乎都用到了多线程技术,这一点可以利用系统自带的进程监控工具(如macOS中的“活动监视器”、Windows中的“任务管理器”)来证实,如下图所示。
这里,我们还需要跟大家说说另外两个概念:并发(concurrency)和并行(parallel)。并发是指同一时刻只能有一条指令执行,但是多个线程对应的指令被快速轮换地执行。比如一个处理器,它先执行线程 A 的指令一段时间,再执行线程 B 的指令一段时间,再切回到线程 A 执行一段时间。由于处理器执行指令的速度和切换的速度非常非常快,人完全感知不到计算机在这个过程中有多个线程切换上下文执行的操作,这就使得宏观上看起来多个线程在同时运行,但微观上其实只有一个线程在执行。并行是指同一时刻,有多条指令在多个处理器上同时执行,并行必须要依赖于多个处理器,不论是从宏观上还是微观上,多个线程都是在同一时刻一起执行的。在我们的课程中,其实并不用严格区分并发和并行两个词,所以我们把Python中的多线程、多进程以及异步I/O都视为实现并发编程的手段,但实际上前面两者也可以实现并行编程,当然这里还有一个全局解释器锁(GIL)的问题,我们稍后讨论。
多线程编程
Python标准库中threading
模块的Thread
类可以帮助我们非常轻松的实现多线程编程。我们用一个联网下载文件的例子来对比使用多线程和不使用多线程到底有什么区别,代码如下所示。
不使用多线程的下载。
import random
import time
def download(*, filename):
start = time.time()
print(f'开始下载{filename}.')
time.sleep(random.randint(3, 6))
print(f'{filename}下载完成.')
end = time.time()
print(f'下载耗时: {end - start:.3f}秒.')
start = time.time()
download(filename='Python从入门到住院.pdf')
download(filename='MySQL从删库到跑路.avi')
download(filename='Linux从精通到放弃.mp3')
end = time.time()
print(f'总耗时: {end - start:.3f}秒.')
说明:上面的代码并没有真正实现联网下载的功能,而是通过
time.sleep()
休眠指定的时间模拟下载文件需要花费一段时间。
运行上面的代码,可以得到如下所示的运行结果。可以看出,当我们的程序只有一个工作线程时,每个下载任务都需要等待上一个下载任务执行结束才能开始,所以程序执行的总耗时是三个下载任务各自执行时间的总和。
开始下载Python从入门到住院.pdf.
Python从入门到住院.pdf下载完成.
下载耗时: 3.005秒.
开始下载MySQL从删库到跑路.avi.
MySQL从删库到跑路.avi下载完成.
下载耗时: 5.006秒.
开始下载Linux从精通到放弃.mp3.
Linux从精通到放弃.mp3下载完成.
下载耗时: 6.007秒.
总耗时: 14.018秒.
事实上,上面的三个下载任务之间并没有逻辑上的因果关系,三者是可以并发的,没有必要等待上一个下载任务结束,为此,我们可以使用多线程编程来改写上面的代码。
再次运行程序,我们可以发现,整个程序的执行时间几乎等于耗时最长的一个下载任务的执行时间,这也就意味着,三个下载任务是并发执行的,没有一个等一个,这样做很显然提高了程序的执行效率。
通过上面的代码可以看出,直接使用Thread
类的构造器就可以创建线程对象,而线程对象的start()
方法可以启动一个线程。线程启动后会执行target
参数指定的函数,当然前提是获得CPU的调度;如果target
指定的线程要执行的目标函数有参数,需要通过args
参数为其进行指定。
除了上面的代码展示的创建线程的方式外,还可以通过继承Thread
类并重写run()
方法的方式来自定义线程,具体的代码如下所示。
通过上面的例子可以看出,如果程序中有非常耗时的执行单元,而这些耗时的执行单元之间又没有逻辑上的因果关系,即B单元的执行不依赖于A单元的执行结果,那么A和B两个单元就可以放到两个不同的线程中,让他们并发的执行。这样做的好处除了减少程序执行的等待时间,还可以带来更好的用户体验,因为一个单元的阻塞不会造成程序的“假死”,因为程序中还有其他的单元是可以运转的。
温馨提示:学习中如果遇到困难,可以加QQ交流群询问。
付费群:789050736,群一直保留,供大家学习交流讨论问题。
免费群:151669801,仅供入门新手提问,定期清理群成员。