我最早接触Python里的多任务,是在一个下载任务把程序卡到完全没法看的时候。当时要抓一批图片,差不多两百来张,一张一张串行下载,每张零点几秒,全程下来差不多一分钟。这个时间说长不长,但在终端里干等着非常折磨人。后来我把下载逻辑改成多线程,同样的任务压缩到几秒钟。也就是从那次开始,我意识到Python里的进程、线程、协程,并不是什么高深的术语,而是实实在在能改变程序体验的工具。
这篇文章我打算把这三兄弟一次讲透。适合刚学完Python基础、开始接触并发编程的人,也适合写过一点多线程但总感觉哪里没搞明白的读者。我会从并发到底解决什么问题讲起,然后是三个层次的原理差异,再说GIL这个绕不开的坑,最后分别用完整例子演示进程、线程、协程的典型用法,以及它们在真实项目中如何组合、有哪些让我排查到怀疑人生的坑。
1. 从串行到并发:先搞清楚多任务到底在解决什么问题
很多人一提到多任务,第一个反应是"我可以用它让程序跑得更快"。这个理解对了一半。多任务确实能减少总耗时,但它减少的并不是"计算时间",而是"等待时间"。
1.1 一次下载任务暴露出来的等待浪费
回到开头说的下载图片场景。假设每张图片从发出请求到保存完毕需要0.5秒,其中真正由CPU干活的时间可能只有0.01秒,剩下的0.49秒都在等网络返回。如果串行处理200张图,总耗时就是200乘以0.5秒,约100秒。
这段时间里CPU其实一直在闲着。串行程序的流程是:发出请求,然后干等网络返回,收到结果后保存,再发下一个请求。等于程序员让CPU像个只会列队前进的士兵一样,每一秒里只踏实干了一小会儿活,其余时间都在原地踏步。
并发要做的事情,就是把这个"等待时间"利用起来。在等A图片返回的过程中,顺手把B图片的请求发出去,再顺手处理C图片的逻辑。这样同一时刻屏幕上虽然还在等,但幕后已经同时有多个任务在推进了。
1.2 三种并发路线的基本印象
Python提供的并发手段正好对应三个层次:进程、线程、协程。你可以把它们理解为三种不同的"并行策略":
- 进程:在操作系统层面开多个独立程序,每个进程有自己独立的内存空间,让多个CPU核心分别干活。
- 线程:在同一个进程内开辟多个执行流,共享内存,由操作系统负责调度。
- 协程:在单个线程内部,由程序自己控制任务之间的切换,不依赖操作系统。
我先把结论放在这里,后面再慢慢展开:如果是纯计算任务,进程最有优势;如果是网络IO、文件读写这类等待型任务,线程和协程都能有很好的效果,其中协程在超高并发下优势更明显。
1.3 用快餐店类比理解三者关系
我经常用快餐店来给朋友解释这三者的区别。进程像是开了很多家分店,每家有独立的厨房和仓库,互不干扰,开分店的成本很高。线程像是在同一家店里安排多个服务员,厨房、仓库都是公用的,服务员之间要配合,但也容易抢东西。协程则更像是一个能力极强的服务员,手里拿着十几个订单,在等待某个菜品出锅的时候,并不会干瞪眼,而是立刻转身去接待另一个客人,把等餐时间全用起来了。
这个类比虽然粗糙,但用来建立直觉非常管用。后面讲具体细节时你会反复体会到这个比喻的价值。
2. 三个层次的并发模型:进程、线程、协程的职责划分
要想正确使用这三种手段,就必须清楚它们在计算机体系里到底处于什么位置。这一节我会稍微讲一点底层原理,但尽量不涉及太深的内核细节,因为对日常写业务代码来说,理解到"谁负责调度"和"切换成本多大"这两点就够了。
2.1 进程:操作系统分配资源的基本单位
进程是操作系统层面的概念。每个进程都拥有独立的内存地址空间、文件描述符表、信号处理器等资源。进程与进程之间默认不共享任何数据,如果想让它们协作,必须通过专门的进程间通信机制,比如管道、共享内存、消息队列。
由于进程之间隔离性强,一个进程崩溃通常不会拖垮其他进程。但也因为隔离性强,创建进程的开销比较大,进程间切换的成本也比线程高不少。在Python里,多进程的典型应用场景是CPU密集型计算,因为只有多个进程才能真正让多核CPU的每个核心都被用上。
2.2 线程:进程内部的执行单元
线程是进程内部派生的执行流。同一个进程的所有线程共享同一块内存空间,因此线程之间通信很方便,直接用全局变量就行,但这也带来了数据竞争的问题,需要加锁保护共享数据。线程的创建成本比进程低不少,切换成本也相对更低。
线程是由操作系统内核调度的。当一个线程因为IO阻塞时,操作系统会调度另一个线程去占用CPU,这个机制让线程非常适合处理网络请求、文件读写等IO密集型任务。不过Python有一个特殊的限制,也就是后面要重点讲的GIL,它让线程在有CPU计算需求时并不能真正并行。
2.3 协程:用户态自行调度的并发单元
协程完全不依赖操作系统调度。它运行在单线程内部,由事件循环(Event Loop)来安排哪个协程继续执行。协程的执行方式是协作式的,也就是说,一个协程必须主动让出控制权(比如在等待IO时使用await语句),其他协程才有机会运行。
因为协程的切换不涉及操作系统的内核态切换,只是修改一下程序内部的状态,所以它的切换成本极低,可以轻轻松松创建成千上万个协程。这一点在需要维护海量网络连接的场景里特别有优势,因为线程数量一旦大到上千,操作系统调度就会变得吃力,而协程不会。
2.4 一张表看明白三个层次的定位
我整理了一张对比表,把进程、线程、协程的核心差异放在一起:
| 对比维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 调度者 | 操作系统 | 操作系统 | 程序自身(事件循环) |
| 资源开销 | 高 | 中等 | 极低 |
| 数据共享 | 默认不共享,需IPC | 共享内存,需加锁 | 单线程内共享,无需加锁 |
| 切换成本 | 高 | 中 | 极低 |
| 适合场景 | CPU密集型 | IO密集型 | IO密集型超高并发 |
| Python中的模块 | multiprocessing | threading | asyncio |
这张表可以作为选型时的速查卡。注意最后一行,Python提供了对应的标准库,但实际项目中我更推荐使用它们的高级封装,比如concurrent.futures,而不是总是直接操作原始接口,后面会解释原因。
2.5 为什么协程不依赖多核也能"快"
有些人会疑惑:协程明明只在一个线程里运行,为什么还能提高性能?答案在于性能瓶颈不同。对IO密集型任务来说,程序的瓶颈是"等待",而不是"计算"。CPU在等待网络数据时基本是空闲的,协程的任务切换让CPU在等待间隙里也能处理其他任务,所以从用户视角看,总体耗时大幅下降。
这就好比你一个人坐在工位上处理三台电脑的需求:电脑A在加载页面时,你不会盯着进度条发呆,而是去电脑B上把文件复制出来,再去电脑C上写几行回复。你的双手并没有变多,只是别闲着。
3. GIL才是Python多线程的命门:CPU密集与IO密集的分水岭
如果你去搜索"Python多线程为什么慢",一定会看到一个词:GIL。全称是Global Interpreter Lock,全局解释器锁。这个锁是CPython解释器为了保证内存管理安全而设置的。理解GIL,是分清楚什么时候用进程、什么时候用线程的前提。
3.1 GIL到底锁住了什么
CPython在管理对象的时候,会为每个对象维护一个引用计数。当某个对象的引用计数归零时,这个对象占用的内存就会被回收。如果两个线程同时操作同一个对象,引用计数就可能出错,最终导致内存泄漏甚至崩溃。为了避免这个问题,CPython规定:任意时刻,只允许一个线程执行Python字节码,执行前必须先拿到那个全局锁。
这个设计牺牲了CPU密集任务的多线程并行能力,换来了实现上的简单和内存安全。于是Python多线程就呈现出一个非常特殊的现象:线程能并发,但不能并行。它们可以轮流获得GIL快速切换执行,但同一瞬间只有一条线程在真正运行Python代码。
3.2 实测:计算密集任务里多线程反而更慢
为了直观感受GIL的影响,我写了一个最经典的计算测试,让多个线程去计算斐波那契数列:
import threading import time def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2) def run_single(): start = time.perf_counter() for _ in range(8): fib(32) print(f"单线程耗时: {time.perf_counter() - start:.2f}s") def run_multi_thread(): start = time.perf_counter() threads = [] for _ in range(8): t = threading.Thread(target=fib, args=(32,)) t.start() threads.append(t) for t in threads: t.join() print(f"多线程耗时: {time.perf_counter() - start:.2f}s")实测结果往往让人大跌眼镜:多线程版本不仅没有比单线程快,反而可能慢上一些。原因就是8个线程都在争抢GIL,浪费了大量时间在锁的获取和释放上,真正用来算出结果的CPU时间并没有增加。
3.3 IO密集场景里多线程为什么有效
同样是8个线程,如果把任务换成网络请求,结果就完全不同了。原因是线程在发起网络请求后,大部分时间处于IO等待状态。而一个线程在等待IO时,会主动释放GIL,让其他线程趁这个空闲期去执行自己的Python代码。GIL被释放的比例非常高,所以多个IO线程几乎可以像真正的并行程序一样工作。
这也是为什么我说GIL是"分水岭":CPU密集任务就放弃多线程吧,该上进程就上进程;而IO密集任务,多线程依然是个合理的选择,只不过后面我会说,协程在这个场景下表现更好。
3.4 不要试图绕过GIL
有些人会尝试写C扩展来绕过GIL,或者用其他技巧强制并行。对大多数业务场景来说,这些做法完全没有必要,还会引入严重的维护负担。如果CPU密集任务的数量真的很大,正确姿势就是使用多进程。进程之间各自有独立的解释器,每个进程都有自己的GIL,多核CPU才能真正被利用起来。
关于GIL还有一个已经持续多年的讨论,就是它会不会被移除。Python社区确实在推进相关的方向,但那是以后的事。在现阶段,你写的代码依然要围绕GIL来设计选型。
4. 进程之路:multiprocessing把多核CPU真正用起来
当你需要跑满所有CPU核心时,multiprocessing就是主力工具。它的API和threading非常像,但底层会通过fork或者spawn创建全新的Python解释器实例,所以各个进程的内存是完全独立的。
4.1 最基本的Process用法
使用multiprocessing启动新进程的写法如下:
from multiprocessing import Process import os def worker(name): print(f"子进程 {name} 正在运行,PID: {os.getpid()}") if __name__ == "__main__": p = Process(target=worker, args=("A",)) p.start() p.join() print(f"主进程PID: {os.getpid()}")这里有一个非常关键的习惯:代码一定要写在if __name__ == "__main__":保护块里。在Windows上,multiprocessing创建子进程时会把当前脚本再执行一遍,如果没有保护,就会无限递归地创建进程,直到系统资源耗尽。这个坑我当年踩过一次,程序瞬间把整台机器拖垮,只能强制重启。
4.2 用Pool管理一批子进程
真实项目里你很少会一个个手动管理进程,更常用的做法是使用进程池。比如要把32个计算任务分给8个进程去跑:
from multiprocessing import Pool import time def fib(n): return n if n < 2 else fib(n - 1) + fib(n - 2) if __name__ == "__main__": start = time.perf_counter() with Pool(processes=8) as pool: results = pool.map(fib, [32] * 32) print(f"多进程耗时: {time.perf_counter() - start:.2f}s")进程池的核心思想是:有8个固定数量的子进程常驻,map方法把任务分批交给它们,哪个进程空闲了就接下一个任务。这样可以避免频繁创建和销毁进程带来的开销。我在八核机器上测过,同样的32个斐波那契任务,单线程需要二十多秒,用8进程能降到三四秒左右,加速比接近核心数。
4.3 进程间通信的正确姿势
前面说了进程之间内存不共享,所以不能像线程那样用全局变量传数据。multiprocessing提供了自己的Queue和Pipe来实现跨进程通信:
from multiprocessing import Process, Queue def worker(q, name): q.put(f"{name} 完成了任务") if __name__ == "__main__": q = Queue() p1 = Process(target=worker, args=(q, "A")) p2 = Process(target=worker, args=(q, "B")) p1.start() p2.start() p1.join() p2.join() while not q.empty(): print(q.get())这个Queue是multiprocessing包装过的,能够在进程之间传递数据,底层通常依赖管道和加锁。要注意的是,它绝对不能用普通的queue.Queue替代,普通队列只在线程之间有效。还有一点,如果使用Pool,传Queue时要格外小心,我在文章末尾的踩坑实录会专门展开。
4.4 进程的代价:创建与通信成本
虽然进程能享受多核,但它的代价也不小。每启动一个进程,解释器都要重新初始化一遍内存和资源,所以创建成本比线程高得多。进程间通信也要经过序列化和反序列化,数据量大的时候这部分开销很惊人。
因此我的经验是:进程适合那种任务量较大、相互之间几乎不需要通信的场景。比如批量图片转码、批量解析大文件、大规模数学计算。如果任务之间需要频繁沟通协作,进程反而会成为瓶颈,这时候就得重新考虑设计。
5. 线程之路:从threading到ThreadPoolExecutor的规范用法
线程是IO密集型任务最常用的工具,入门简单,排查起来也比较直观。但我在看了不少别人的项目之后发现,很多人还在用最原始的threading.Thread一把梭,手动管理线程的创建和回收。这样写小脚本还行,稍微复杂一点就很容易出问题,更规范的做法是用concurrent.futures.ThreadPoolExecutor。
5.1 threading的基本用法和锁
先看最基础的线程例子:
import threading import time counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(100000): with lock: counter += 1 threads = [threading.Thread(target=increment) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(counter)这里必须加lock,因为多个线程同时执行counter += 1,三个操作(读取、加一、写回)之间会被线程切换打断,最终结果一定小于400000。with lock的作用就是保证这三个操作一组执行完之前,其他线程进不来。
线程共享全局变量的便利性,同时也会带来安全性的麻烦。所以我现在写项目的原则就是:能不共享可变状态就别共享,用消息传递或者返回值来传递结果;实在必须共享,就老老实实加锁。
5.2 用ThreadPoolExecutor替代手动管理线程
手动管理线程的麻烦主要有几个:线程数不好控制,任务太多时操作系统调度压力大;线程的异常处理很麻烦;代码里到处都是join操作,逻辑被拆得很散。而ThreadPoolExecutor把这些都封装好了:
from concurrent.futures import ThreadPoolExecutor import time def download_one(url): time.sleep(0.3) # 模拟网络请求 return len(url) # 模拟数据大小 urls = [f"https://example.com/img/{i}" for i in range(200)] start = time.perf_counter() with ThreadPoolExecutor(max_workers=16) as executor: results = list(executor.map(download_one, urls)) print(f"线程池耗时: {time.perf_counter() - start:.2f}s")executor.map会把urls列表里的每个元素分别传给download_one,然后按顺序返回结果。使用with的好处是退出时自动调用shutdown,线程池的资源会被自动回收。
实测下来,16个线程并发下载200张模拟图片,耗时从串行的60秒降到大约4秒,这个提升幅度是相当可观的。
5.3 线程数量怎么选
线程数不是越多越好。线程太多了,操作系统要把大量时间花在上下文切换上,反而会拖慢整体速度。对网络IO任务来说,我一般从16个线程起步,观察CPU利用率和响应时间再调整。如果任务主要是等待数据库或第三方接口,几十个线程通常就能吃得饱。
另外不要忘了,线程池的任务队列在内存里也是有开销的。如果你有几百万个小任务要处理,一次性全塞进去也可能导致内存压力,这时候可以考虑分批提交。
5.4 线程里处理异常的注意事项
线程内部的异常默认不会直接抛到主线程里来。如果无视这一点,你的程序会静默失败,排查起来非常头疼。使用ThreadPoolExecutor的一个好处是,executor.submit()返回的Future对象在调用.result()时会把异常抛出来,这方便我们定位问题:
def bad_job(): raise ValueError("某个线程任务失败了") with ThreadPoolExecutor(max_workers=2) as executor: future = executor.submit(bad_job) try: result = future.result() except ValueError as e: print(f"捕获到异常: {e}")这个特性很重要。多线程最怕的不是报错,而是任务暗地里不执行、主程序也不报错。我建议在线程任务函数里无论是成功还是失败,都显式记录日志,否则生产环境里出了问题,连从哪里开始查都不知道。
6. 协程之路:asyncio在单线程里榨干IO吞吐的正确方式
协程是Python里最年轻也最有潜力的并发模型。它的语法一度让很多初学者犯迷糊,但一旦理解了事件循环的运行逻辑,你会发现它其实比线程更简单、更可控。
6.1 事件循环和async/await的运作逻辑
协程程序的运行核心是一个事件循环。你可以把所有要执行的协程任务都丢给事件循环,它会挨个执行。一个协程在执行到await时,会把控制权交还给事件循环,事件循环立刻去执行下一个能运行的协程,等IO结果返回后,再回来继续刚才的协程。
一个最简单的asyncio程序长这样:
import asyncio async def hello(name): print(f"开始执行 {name}") await asyncio.sleep(1) print(f"完成 {name}") async def main(): await asyncio.gather(hello("A"), hello("B"), hello("C")) asyncio.run(main())这里asyncio.sleep(1)是模拟IO等待。三个协程的print("开始执行")会立即按顺序打出来,然后各自等待1秒,最终总耗时约为1秒而不是3秒。asyncio.gather负责把多个协程组合起来,让它们并发执行。
需要特别注意的是,asyncio.sleep是异步版本的睡眠,它会让出控制权;如果你在协程里写成time.sleep(1),那就是在阻塞整个事件循环,其他协程全部都会卡住,程序会从"并发"退回"串行"。
6.2 协程并发下载的完整示例
协程在真实网络请求里最有代表性。下面是用aiohttp做并发下载的框架:
import asyncio import aiohttp async def fetch(session, url): async with session.get(url) as response: return response.status async def main(): urls = [f"https://example.com/data/{i}" for i in range(200)] async with aiohttp.ClientSession() as session: tasks = [fetch(session, url) for url in urls] results = await asyncio.gather(*tasks) print(f"一共请求了 {len(results)} 个地址") if __name__ == "__main__": asyncio.run(main())注意这里列表推导式生成的不是真正的任务,而是一个个协程对象。asyncio.gather把它们包装成任务丢进事件循环,事件循环在遇到IO等待时自动切换,200个请求同时并发。用线程实现同样规模的并发也能跑,但协程的内存开销要小得多。
6.3 协程、线程、进程的并发上限对比
我实际上跑过一次并发上限的简单对比。在线程场景,我尝试开启5000个线程下载小文件,结果程序创建到一千多个就开始频繁报错,系统资源消耗非常夸张。而协程场景,我一口气创建了一万个协程做异步请求,程序依然稳稳运行,内存占用也很低。
这个对比不谈线程的绝对上限在哪里,不同的系统和版本会有差异,但它很清楚地说明了协程的设计目标:用极小的开销管理海量网络连接。因此,如果你的程序要处理大量短小、互不依赖的网络请求,协程永远是我的第一选择。
6.4 协程的局限:不能吃满多核
协程也不能解决所有问题。因为它本质是单线程模型,CPU密集计算在协程里反而会成为灾难:一个计算密集的协程会卡住事件循环,让其他协程完全无法推进。所以协程只适合IO密集。如果你的任务里既有大量计算又有网络等待,常规做法是把计算部分交给进程,或者用线程池去承接阻塞型调用。
我在真实项目里最常用的组合是:主流程用asyncio管理整体并发,遇到CPU密集的小任务,用loop.run_in_executor(None, func)把它丢给线程池去执行,避免阻塞事件循环。这也就是下一节要讲的混合架构。
7. 混合架构与选型原则:进程内跑线程、线程里挂协程
很多讲并发编程的教程喜欢把进程、线程、协程分开讲,但真实项目里它们往往不是互斥关系,而是配合使用。我在维护一个数据处理服务时,就采用了"进程池 + 线程池 + 协程"三层组合的架构,效果很不错。
7.1 判断瓶颈是CPU还是IO
选型的第一步,永远是判断程序的主要瓶颈在哪:
- 如果任务花大量时间在CPU计算上,那就是CPU密集型,考虑用多进程。
- 如果任务花大量时间在网络、磁盘、数据库等待上,那就是IO密集型,优先考虑协程,需要和旧代码库、第三方SDK混用时考虑线程。
- 如果任务两者都有,通常拆成两个阶段,分别用合适的手段去处理。
我习惯在写代码前先计时分析一下,确定各阶段耗时。没有数据支撑的选型都是凭感觉,凭感觉就会走弯路。
7.2 一个实际项目的分层结构
之前我做过一个批量处理任务:抓取数百个网页,提取正文,再做关键词统计。这三部分的耗时特征完全不一样:
- 抓取网页:网络IO密集,用协程,一次性发起数百个请求。
- 提取正文:CPU有一定计算量,文本解析不轻,用进程池做并行解析。
- 关键词统计和结果入库:IO和计算混合,放在主流程里串行或少量线程处理。
大致的代码骨架是这样的:
import asyncio from concurrent.futures import ProcessPoolExecutor async def fetch_all(urls): # 协程抓取,返回原始网页列表 ... def parse_one(html): # 进程池里执行的纯CPU解析 ... async def main(urls): htmls = await fetch_all(urls) loop = asyncio.get_running_loop() with ProcessPoolExecutor(max_workers=4) as pool: parsed = await loop.run_in_executor(pool, lambda: list(map(parse_one, htmls))) # 继续处理 parsed这个结构的关键在于:协程负责等待,进程负责计算,两者通过asyncio的run_in_executor接口衔接。这样网络等待不会阻塞解析,CPU计算也不会阻塞网络请求。
7.3 简单项目不建议一上来就混合
混合架构确实强大,但也意味着复杂度成倍上升。如果你只是写一个几十行的小脚本,下载几十个文件再本地做个简单处理,完全不需要进程池和协程混用,一个ThreadPoolExecutor就足够了。
我见过不少项目,明明任务量不大,却把进程、线程、协程全部用上,结果调试起来难度陡增。最后为了排查一个数据竞争问题,花费的时间远超项目本身节省的时间。多任务架构是手段,不是目的,永远以需求和实际测得的瓶颈为准。
7.4 清晰的决策速查表
我给自己总结过一张简单的选型速查表:
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 计算密集 + 任务独立 | 多进程Pool | 绕开GIL,真正用多核 |
| IO密集 + 代码主体能改成异步 | asyncio协程 | 开销最小,并发上限高 |
| IO密集 + 第三方库不支持异步 | 线程池ThreadPoolExecutor | 无需改动阻塞库 |
| 数据量大 + 阶段拆分明 | 协程抓取 + 进程解析 | 各用所长,互不阻塞 |
| 快速写脚本 | 简单线程池 | 代码少,排查容易 |
8. 踩坑实录:进程、线程、协程混用时的几个真实事故
写多任务的代码,最难的不是写出来,而是排查那些"看起来没错但行为怪异"的问题。下面这几个坑都是我在实际开发中踩过的,每一个都花了不少时间才定位到根因,分享出来希望大家少走点弯路。
8.1 进程池里想传Queue,结果直接报错
有一个任务需要多个进程干活,每个进程完成后要把结果回传给主进程。我一开始图省事,在初始化Pool之前创建了一个multiprocessing.Queue,然后通过initializer传给每个worker。结果子进程一往队列里写数据,程序就报错,错误信息大致是队列对象只能通过继承传递。
根因在于:进程池默认用fork或者spawn生成子进程,子进程拿到的是队列的某个状态副本,多进程同时写一个管道对象时,内部状态产生了冲突。正确做法是使用multiprocessing.Manager().Queue(),或者让每个worker返回结果,由主进程汇总。如果确实要用Pool共享队列,就必须通过initializer里的全局变量来注册,而不是直接传参。
这个坑的教训是:进程池里的每个worker都不是你手动创建的Process对象,它的参数传递方式有额外限制。凡是遇到队列相关报错,先想清楚队列的所有权和传递方式。
8.2 在线程池任务里调用asyncio.run()
有一段时间我在写一个小型爬虫,入口用的是ThreadPoolExecutor,其中一个任务函数里直接调用了asyncio.run()来跑异步逻辑。第一次运行一切正常,第二次运行就开始报错,提示事件循环已经关闭,有时还会报无法从正在运行的事件循环中调用asyncio.run。
原因是asyncio.run每次都会创建一个新的事件循环,如果它在线程里被调用,且该线程已经有存活的事件循环在运行,就会冲突。更麻烦的是,同一个线程里反复调用asyncio.run也可能因为事件循环关闭不彻底而出问题。
正确做法是拆开线程和协程的层级:要么整个流程都用asyncio,让协程通过asyncio.to_thread去跑阻塞逻辑;要么坚持线程模型,在线程任务里不用任何事件循环。千万不要混搭成一个四不像。
8.3 协程函数里混进了阻塞调用,并发瞬间失效
有一次我在异步代码里整理数据,顺手用time.sleep()做限速,结果整个抓取速度突然变得很慢。最开始我以为是目标网站变慢了,后来才发现是自己在协程里写了一段阻塞限速逻辑。time.sleep()一旦执行,整个事件循环都会被卡住,其他协程全都在排队等待。程序表面上还是"并发"的代码,实际上已经退化成串行。
解决办法很简单:异步代码里统一使用await asyncio.sleep(),任何可能阻塞的调用都要想办法异步化。如果需要调用一个不支持异步的第三方库,可以用await asyncio.to_thread(func, ...)把它丢到线程池里执行,让事件循环继续保持活跃。
8.4 在Windows上运行multiprocessing脚本,机器直接卡死
这是我印象最深的一次事故。当时我在Windows机器上运行一个用multiprocessing写的小工具,脚本一启动,系统资源占用立刻冲到100%,完全失去响应。原因就是我当时没写if __name__ == "__main__"保护,Windows的spawn方式会把整个文件从头执行一遍,而执行到创建进程的代码时又触发新的spawn,无限递归,直到资源耗尽。
这个锅不能甩给操作系统,纯粹是我对这个机制不了解。后来我养成了习惯,不论在什么平台上写multiprocessing代码,一律把入口代码放进if __name__ == "__main__"保护块里,顺带也能让代码作为模块导入时不触发副作用。这个习惯救了我很多次。
8.5 共享变量在进程里"消失"了
还有一次,我写多进程代码时想在子进程里更新主进程的全局字典,用来记录进度。测试时发现子进程里更新完了,主进程的字典始终是空的。这让我困惑了很久,因为同样的代码在threading里明明能生效。
这个问题的本质就是进程内存隔离:每个子进程都有自己独立的一份全局变量副本,子进程对字典的所有修改只对它那一个进程可见。想要跨进程共享状态,必须使用Manager或者Queue这些显式的通信机制。我在意识到这一点后才真正理解"进程默认不共享数据"这句话不是概念,而是写代码时每时每刻要遵守的规则。
多任务编程说白了就是一门"时间管理"的艺术。你得知道哪部分代码在等待、哪部分代码在计算、哪部分代码在共享状态,然后才能把进程、线程、协程放到它们各自最能发挥优势的位置。我最初也是被GIL、事件循环、进程隔离这些概念绕得晕头转向,直到一个个小实验把性能数据测出来,才算真正建立起直觉。
最后再分享一个我一直在用的习惯:无论是写多线程、多进程还是协程,我都会把关键的计时结果和并发参数打日志记录。比如同时跑了多少个任务、每个阶段耗时多久、任务失败率多少。下一次想优化程序时,这些数据比任何理论都管用。并发编程的很多结论在技术社区里吵来吵去,但你自己机器上的实测数据,才是最有说服力的答案。