并发与异步编程基础

在前面的教程中,我们写的所有代码都是从上往下一行行执行的,也就是所谓的"同步"执行。但现实中的程序经常需要同时处理多件事情:一边下载文件一边响应用户操作,一边查数据库一边处理其他请求。

这篇文章就来聊聊 Python 中的并发与异步编程。先把几个容易混淆的概念理清楚,然后分别看 threading 多线程、asyncio 异步编程的用法,最后总结什么场景该用什么方案。

同步、异步、并行、并发

假设你要做三件事:煮饭、炒菜、煲汤。

同步就是最朴素的方式:你站在锅前等饭熟,饭熟了再去炒菜,炒完菜再去煲汤。一件事做完才做下一件,中间有大量时间浪费在"等"上面。

异步就聪明多了:你按下电饭煲的按钮,不等饭熟,转身去炒菜。炒菜的间隙,又顺手把汤锅架上了。从头到尾只有你一个人,但你利用等待的间隙切换任务,三件事交替推进。

这就是异步的核心思想:单线程,利用等待时间切换任务

并行则是另一种思路:你叫了两个帮手,三个人各守一个灶台同时干活。每个人同时在执行各自的任务,这是真正的"同时"。并行需要多个 CPU 核心,一个核心同一时刻只能执行一个任务。

并发是什么?并发其实是一个比较模糊的总称,只要多件事在同一时间段内推进,就可以叫并发。你一个人异步切换是并发,三个人并行执行也是并发。换句话说,异步和并行都是实现并发的手段。

Python 同时支持这几种并发方式:threading 多线程、asyncio 异步协程,以及 multiprocessing 多进程。

但 Python 有一个特殊的限制叫 GIL(全局解释器锁):同一时刻只有一个线程能执行 Python 代码。用刚才的比喻来说,就是你叫了帮手,但厨房的门一次只能进一个人,大家得轮流进去干活。所以 Python 的多线程虽然能并发,但在 CPU 密集型任务上并不能真正并行。这个限制后面会详细讨论。

多线程 threading

Python 的 threading 模块提供了多线程编程的能力。最基本的用法是创建 Thread 对象,传入一个目标函数,然后调用 start() 启动线程:

threading.Thread 接收 target 参数指定线程要执行的函数,args 是传给函数的参数(元组形式)。调用 start() 后线程就开始在后台执行了,主线程会继续往下走。

join() 的作用是让主线程"等待"子线程执行完毕。如果不调用 join(),主线程会直接往下跑,可能在子线程还没完成时就结束了。上面的例子中,线程 A 要 2 秒,线程 B 要 1 秒,但因为它们是同时执行的,总耗时大约 2 秒而不是 3 秒。

来看一个更实际的例子,用多线程模拟并发的网络请求:

同步方式要 2+3+1=6 秒,多线程只要约 3 秒(取决于最慢的那个请求),效果非常明显。这就是多线程在 I/O 密集场景下的优势:一个线程在等待网络响应时,其他线程可以继续执行。

线程安全与锁

多线程虽然好用,但有一个经典问题:多个线程同时修改同一份数据时,可能出错。这叫做"竞态条件"。

来看一个经典的例子:两个线程各自给一个全局变量加 1 一万次,最终结果应该是 20000 对吧?

这段代码存在竞态条件(Race Condition)。counter += 1 看起来是一步,实际上分三步:读取 counter 的值、加 1、写回去。如果两个线程同时读到了同一个值(比如都读到 42),各自加 1 后都写回 43,那就少加了一次。

你可能发现运行结果是正确的 20000,这是因为 CPython 的 GIL(全局解释器锁,后面会详细讲)在一定程度上保护了这个简单操作。但这只是运气好,代码逻辑上仍然是不安全的——换一个 Python 实现(比如 Jython、PyPy)或者操作稍微复杂一点,竞态就会暴露出来。所以只要涉及多线程共享数据,就必须加锁。

解决办法是用 锁(Lock)。锁保证同一时刻只有一个线程能执行被保护的代码:

加了锁之后,结果就一定是 20000 了。lock.acquire() 获取锁,如果锁已经被别的线程占着,就等着。lock.release() 释放锁。Python 还提供了更简洁的写法,用 with 语句自动管理锁的获取和释放。with lock: 会在进入代码块时自动获取锁,离开时自动释放,不用手动写 acquirerelease

with lock:
    counter += 1
# 等价于 lock.acquire() + counter += 1 + lock.release()

不过要注意,加锁虽然保证了安全,但也降低了性能:线程需要排队等锁,就不能真正并发执行被锁保护的那段代码了。所以锁只应该保护"必须互斥访问"的最小代码段。

GIL 的影响

前面提到 Python 有个 GIL(全局解释器锁),让我们来直观感受一下它的影响。

对于 CPU 密集型任务(大量计算),多线程不仅不会更快,甚至可能更慢:

你会发现多线程的耗时和单线程差不多,甚至可能更慢一点(因为线程切换也有开销)。这就是 GIL 的影响:即使你开了多个线程,同一时刻也只有一个线程在执行 Python 代码,其他线程在等着。

但是对于 I/O 密集型任务(网络请求、文件读写、数据库查询等),多线程依然有效。因为 GIL 在线程执行 I/O 操作时会释放,让其他线程有机会执行。前面那个模拟网络请求的例子就验证了这一点。

如果你确实需要并行计算 CPU 密集型任务,应该用 multiprocessing 模块。它会启动真正的子进程,每个进程有自己独立的 Python 解释器和 GIL,可以充分利用多核 CPU:

from multiprocessing import Process

# 用法和 threading.Thread 几乎一样
p1 = Process(target=cpu_work, args=(N,))
p2 = Process(target=cpu_work, args=(N,))
p1.start()
p2.start()
p1.join()
p2.join()

multiprocessing 的 API 和 threading 几乎一模一样,但因为进程之间不共享内存,数据传递会更复杂一些。一般只有在 CPU 密集计算且性能要求高的时候才用它,这里不展开了。

异步编程 asyncio

除了多线程,Python 还有另一种并发方式:异步编程(asyncio)。它用一个线程就能实现并发,靠的是"协程"这个概念。

回到做饭的比喻:异步就是一个人在厨房里,按下电饭煲后不傻等,而是去炒菜。菜炒到一半需要等油热,又去准备汤料。所有任务由一个人在空闲时切换,效率很高,而且不需要"帮手"(多线程),也就不存在竞态条件的问题。

Python 用 async def 定义协程函数,用 await 表示"这里要等一下,等待期间可以去做别的事":

asyncio.run() 是启动异步程序的入口,它会创建一个事件循环并运行传入的协程。上面的例子中,await say_hello("A", 2) 会等 A 完成再执行 B,所以总耗时还是 3 秒,跟同步没什么区别。

要想真正并发执行多个协程,需要用 asyncio.gather()

asyncio.gather() 接收多个协程,同时启动它们。当一个协程遇到 await 进入等待时,事件循环会自动切换到其他协程继续执行。总耗时约 3 秒,只取决于最慢的那个,和多线程效果一样。

asyncio 和多线程有什么区别?最本质的区别是:asyncio 是单线程的,协程在 await 的地方主动让出执行权。没有多线程的竞态条件问题,也不需要加锁。

但你的代码必须是"异步风格"的,普通函数不能直接变成协程,阻塞操作(比如 time.sleep())也不能直接用在协程里(要用 asyncio.sleep())。

什么时候用什么

了解了三种并发方式,什么场景该用哪个?

I/O 密集型任务(网络请求、文件读写、数据库查询):asynciothreading 都行。asyncio 在高并发场景下性能更好(单线程没有线程切换开销),但需要用异步风格编写代码。threading 更直观,现有的同步代码改造成本低。

CPU 密集型任务(大量数值计算、图像处理、加密解密):只有 multiprocessing 能真正并行。threading 受 GIL 限制,对 CPU 密集任务没有加速效果。

简单场景:用 threading 最省心,API 简单直观,几行代码就能用起来。

高并发 I/O 场景:用 asyncio。比如同时发起上百个网络请求,asyncio 的性能比开上百个线程好得多。

小结

这篇介绍了 Python 的并发编程基础。同步是一件一件做,异步是一个人利用等待间隙切换任务,并行是多个人同时干活,并发是这些方式的总称。

threading 多线程用起来最简单,但要注意线程安全问题,共享数据需要加锁。GIL 让 Python 的多线程在 CPU 密集任务上没有优势,但在 I/O 密集任务上依然有效。asyncio 用单线程实现并发,没有竞态条件的烦恼,高并发 I/O 场景下性能最好。如果需要真正的并行计算,就用 multiprocessing

对于大多数应用场景,threadingasyncio 就够用了。选择哪个取决于你的任务类型和代码风格偏好,没有绝对的好坏。