Python 进阶——并发编程
一、基本概念
1. 进程、线程与协程
- 进程:进程是操作系统分配资源的基本单位,它包含了一个程序的执行实例及其用到的系统资源(如内存、文件、设备等)。每个进程有自己独立的内存空间,不同的进程通过进程间通信来交换信息。
- 线程:线程是 CPU 调度和执行的基本单位,是包含在进程中(比进程更小)的能独立运行的最小单位,线程又称为轻量级进程。每个进程可以有一个或多个线程,进程中的各个线程共享该进程的内存空间。线程没有独立的内存空间,只拥有运行时必不可少的资源,如程序计数器、寄存器和栈。
- 协程:协程又称微线程,是用户态的轻量级线程,其调度由用户控制。每个协程拥有自己的寄存器上下文和栈。当协程切换时,将寄存器上下文和栈保存起来,协程切回来时,恢复之前保存的寄存器上下文和栈。协程间的切换不涉及内核切换的开销,协程切换比线程切换效率更高。
| 概念 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 资源占用 | 独立的内存地址空间(保存数据、代码等资源) | 共享所属进程的内存地址空间 | 共享所在线程的内存地址空间 |
| 创建开销 | 较高,需要进行大量的资源分配和初始化 | 较低,因为共享进程资源 | 最低,通常由程序内部控制,不需要操作系统介入 |
| 切换开销 | 较高,涉及 CPU 的上下文切换 | 较低,涉及内核线程的上下文切换 | 最低,通常由程序控制,不需要内核上下文切换 |
| 通信方式 | 通过进程间通信 (IPC),如管道、消息队列、共享内存等 | 通过共享内存 | 通过参数传递,或共享数据结构 |
| 应用场景 | 不同的程序或服务 | 多个子任务并发执行 | 异步编程,提高性能 |
| 竞态条件 | 使用锁、信号量等同步机制 | 使用锁、信号量等同步机制 | 通常不用加锁,协程之间没有竞争关系 |
举个通俗点的例子来理解下这些干巴巴的概念:
比如我们要准备一顿晚饭,做饭这项活动就可以视为一个进程,炉灶和食材是这个进程可以利用的资源。
炒菜和煮饭可以视为两个子线程,它们共用炉灶和食材资源,当资源紧缺(比如只有一个炉灶)时,会存在竞争关系。
晚饭计划一菜一汤,当炖汤时,我们可以一边等,一边准备下一道菜的工序,比如摘菜、洗菜、切菜。每项工序完成,我们还要去看看炖汤的进展。这个切换操作就是协程。
2. 并发和并行
- 并发:指在同一时间段内宏观上有多个程序在同时运行,但在单核 CPU 中,这些程序是交替执行的,同一时刻,只有一条指令在执行。
- 并行:指在同一时刻,有多条指令在多个处理器上同时执行,需要有硬件支持,比如多核 CPU、GPU 等,是真正意义上的同时运行。
举个例子,在一个便利店有两队(红队和蓝队)顾客排队买单。
如果只有一个收银员,那这个收银员只能交替给两队顾客结账,在同一时刻,只能有一个顾客在买单,要么来自红队,要么来自蓝队。
如果有两个收银员,那么每个收银员可以对接一队顾客。在同一时刻,蓝队和红队可能同时有顾客在买单。
3. 同步和异步
- 同步:在同步模式下,当程序执行一个操作 A,必须等这个 A 操作返回结果后,才能执行下一个操作 B。当操作 A 需要执行很长时间才能完成时,操作 B 需要陷入长久的等待,程序出现阻塞。
- 异步:在异步模式下,当程序执行一个操作 A,不会等 A 操作完成,而是立即执行下一个操作 B。当 A 操作执行完后,程序通过某种机制(例如回调函数、事件等)接收 A 操作的结果,再进行后续处理。异步使得在等待操作 A 运行期间,程序可以执行其他操作,提高了运行效率。
举个例子,肚子饿了去搞吃的,有两种餐馆:
一种是没有服务员上菜的,只能在窗口排队等着,现点现做,拿到餐品才能去找座位。这种就是同步模式,等取餐了才能去找座位。
另一种是有服务员负责传餐的,点完餐品,可以先去找个位子坐下,等服务员上菜就行。这种就是异步模式,等餐的过程中可以去找座位。
异步和协程的想法很相似,当某一项任务需要花费很长时间时,可以先转去做别的任务,避免干等。
协程可以作为实现异步的一种技术,它提供了一种简洁好用的异步模型,避免了多层嵌套的回调函数(回调地狱)导致代码变得极其复杂,难以理解和维护。
二、Python 并发实践
1. 多进程与进程池
前面说过,进程是操作系统分配资源的基本单位,拥有独立的内存地址空间。多进程可以理解为:多请一个厨师,给每个厨师配一套独立的炉灶和食材,各做各的菜,互不干扰。
Python 标准库中通过multiprocessing模块提供多进程支持,最基本的使用方法为:
- 创建进程对象:调用
Process初始化子进程,target指定要执行的函数,args传入函数所需的参数 - 启动子进程:调用
start(),子进程开始执行,主进程继续往下走,互不阻塞 - 等待结束:调用
join(),主进程等子进程执行完再继续,否则主进程退出时会把没干完活的子进程一起带走
此外,由于进程间内存不共享,进程间通信需要借助 IPC 机制,如管道、消息队列、共享内存等。multiprocessing中提供了Queue、Pipe等通信工具,实现在不同进程间传递数据。
1 | import multiprocessing |
例子中两个做菜的子进程并行执行,总耗时约为其中最慢的一道菜的时间(3 秒),而不是两者之和(5 秒)。代码运行结果如下:
1
2
3
4
5 Process-1 开始做红烧肉
Process-2 开始做番茄汤
红烧肉做好了
番茄汤做好了
总耗时: 3.63秒
进程创建开销大,如果需要反复创建、销毁进程,应该使用进程池来复用进程。multiprocessing.Pool可以创建一组常驻的进程,将任务分发到池中的进程去执行:
1 | import multiprocessing |
pool.map和内置的map用法一致,将任务分配到 4 个进程中并行执行,并保持结果的顺序与输入一致。如果不在意返回顺序,也可以用apply_async异步提交任务,通过回调函数获取结果:
1 | if __name__ == '__main__': |
多进程擅长CPU 密集型任务,如数值计算、图像处理、加解密等。因为 Python 的 GIL(见下一节)限制了多线程的并行能力,多进程是 Python 中利用多核 CPU 的常用手段。其代价是创建、切换开销高,进程间通信也比较麻烦。
2. 多线程与线程池
多线程可以理解为:还是同一个厨师(进程),但可以同时开几个锅(线程),锅共用厨房的炉灶和食材。由于共享资源,多线程配合起来高效,但也要小心锅多了打架。
Python 标准库通过threading模块提供多线程支持,用法与multiprocessing几乎一致:
1 | import threading |
(1)GIL
在深入线程之前,绕不开一个话题——GIL(Global Interpreter Lock,全局解释器锁)。CPython 解释器中有一个全局锁,同一时刻只允许一个线程执行 Python 字节码。这意味着多线程并不能让 CPU 密集型代码在多个核上并行加速,甚至因为切换开销反而更慢。
GIL 存在的主要原因是 Python 的内存管理(引用计数)不是线程安全的,加一个全局大锁是最简单可靠的方案。当线程执行 IO 操作(如网络请求、文件读写、sleep)时,会主动释放 GIL,让其他线程获得执行机会。另外,解释器也会定期(默认约 5 毫秒)强制切换线程,所以 CPU 密集型的多线程程序同样能交替运行,只是仍然无法利用多核,整体性能不会有提升。
因此多线程的定位很明确:适合 IO 密集型任务,在等待 IO 期间切换线程,提高整体的执行效率。这也是为什么上面的例子中两个线程能“并行”执行——time.sleep属于阻塞的 IO 等待,等待期间 GIL 被释放,另一个线程得以运行。
(2)线程安全与锁
多个线程共享进程的内存,同时读写同一份数据时就会产生竞态条件。看下面这个经典例子:
1 | import threading |
直觉上结果应该是 2000000,但实际运行结果往往小于这个值。原因是count += 1并不是原子操作,它拆解成读、加、写三步,两个线程可能同时读到旧值,导致更新丢失。
解决方式是加锁,保证同一时刻只有一个线程执行临界区代码:
1 | import threading |
(3)线程池
和进程池类似,concurrent.futures模块提供了ThreadPoolExecutor线程池,线程复用降低创建销毁的开销:
1 | import time |
submit提交任务后立即返回一个Future对象,代表将来会完成的任务;as_completed按完成顺序迭代这些 Future,哪个任务先完成就先拿到哪个的结果。如果不在意获取结果的先后顺序,也可以直接用executor.map(fetch, urls),它按输入顺序返回结果。
顺带一提,concurrent.futures还提供了ProcessPoolExecutor进程池,接口与ThreadPoolExecutor完全一致,多进程场景下也可以用它替代multiprocessing.Pool。
3. 协程
回到开头做饭的例子:炖汤需要一段时间,期间我们并没有傻等,而是转去做摘菜、洗菜、切菜,时不时再回来看看汤。这就是协程的思想——在任务等待期间,主动让出控制权,切换去执行其他任务。
协程是用户态的,切换完全由程序自己控制,不涉及内核调度,因此切换开销极小,可以轻松创建成千上万个协程。
(1)生成器——协程的雏形
协程的机制和生成器一脉相承。生成器通过yield实现函数的暂停与恢复:
1 | def gen(): |
每次调用next,函数从上次yield的位置恢复执行,这种“暂停 - 恢复”的能力正是协程切换的基础。更进一步,send方法还可以向生成器传入值,实现两个协程之间的数据传递:
1 | def producer(consumer): |
(2)async/await 与 asyncio
生成器虽然能实现协程切换,但写起来繁琐且不直观。Python 从 3.4 开始提供asyncio标准库,3.5 引入async/await语法,让协程编程变得简洁优雅:
1 | import asyncio |
要点解析:
async def定义一个协程函数,调用它并不会立即执行,而是返回一个协程对象。await是挂起点,await asyncio.sleep(seconds)表示等待期间主动让出控制权,事件循环转而去调度其他协程。asyncio.run(main())创建并启动事件循环。事件循环是 asyncio 的调度器,负责收集、调度协程的执行。asyncio.gather同时调度多个协程,等所有协程执行完毕后返回。- 事件循环在单个线程中运行,协程在
await之前不会被抢占,因此访问共享数据通常无需加锁。
从控制流的角度看,await相当于“先干别的,等结果好了再回来”,这正是异步的思想,比回调函数那套直观得多。
4. 异步
理解了协程之后,再来看异步就水到渠成了:协程是实现异步的一种技术手段。借助事件循环,程序在 IO 等待期间切换执行其他协程,充分利用等待时间,这就是异步编程提升效率的本质。
下面用一个模拟爬虫的例子,直观感受异步带来的效率提升。假设抓取一个页面需要 2 秒,要抓取 5 个页面:
同步版本:
1 | import time |
异步版本:
1 | import asyncio |
同步版本串行执行,总耗时约 10 秒;异步版本在每次等待时切换去发起下一个请求,总耗时约 2 秒,性能提升肉眼可见。请求越多、IO 等待越长,异步的优势越明显。
使用异步时有两点需要特别注意:
- 不要在协程中使用阻塞调用。
time.sleep、requests.get这类同步阻塞调用会卡住整个事件循环,让所有协程跟着一起等。要换成对应的异步版本,如asyncio.sleep、aiohttp等异步库。 - 不要忘记
await。如果调用协程函数时忘了await,协程根本不会执行,程序可能什么都不发生就结束了。
最后,回顾一下 Python 并发编程的选型思路:
| 方案 | 适用场景 | 特点 |
|---|---|---|
| 多进程 | CPU 密集型任务,如数值计算 | 能利用多核 CPU,绕开 GIL;开销大、通信麻烦 |
| 多线程 | IO 密集型任务,如网络请求、文件读写 | 开销较小、共享内存;受 GIL 限制,要注意线程安全 |
| 协程 / 异步 | 高并发 IO 场景,如爬虫、Web 服务 | 开销最小、可轻松支撑成千上万并发;不能执行阻塞调用 |
实际开发中,一个程序往往可以组合使用多种方案:比如使用多进程跑多个异步事件循环,榨干多核的同时保持高并发,这也是很多高性能框架的常见做法。
