老实说,很多人把Python的进程、线程、协程放在一起研究时,最先感受到的不是“强大”,而是“混乱”。我在群里见过不少新手说“多线程一定更快”“协程能替代进程”,结果真拿一段业务代码去压测,反而更慢、死锁、CPU被占满,最后只能重启。问题不在于技术本身,而是没想清楚自己遇到的瓶颈到底是什么。
这篇文章不打算做概念搬运,我会把三种模型放在同一台电脑上,用同一段模拟任务跑一遍,顺便把常见问题和排查思路一起讲透。适合刚接触Python并发编程的同学,也适合那些用了asyncio却总觉得“不对劲”的朋友。看完之后,至少遇到网络请求、批量计算、高并发接口时,能很快判断该用进程、线程还是协程。
1. 先想清楚一个问题:你到底需要“并发”还是“并行”
很多教程一上来就甩定义,但我觉得最关键的分岔点是:你要解决的是“等太久”还是“算太慢”。
如果你的程序大量时间在等待磁盘、网络、数据库返回,这叫I/O密集。等待的过程中CPU基本是闲着,这种场景你需要的是并发,也就是让多个任务在等待时穿插执行,把空闲时间利用起来。如果你的程序是在疯狂做计算,比如图像处理、数值模拟,这叫CPU密集,你需要的是并行,让多个CPU核心同时干活。方向不对,后面用什么都是白费。
1.1 进程:隔离最彻底的运行单位
进程是操作系统里“独立王国”式的存在。每个进程有独立的地址空间、独立的文件描述符、独立的环境变量,你在这个进程里改一个全局变量,别的进程完全不知道。多个进程就像几家独立的店铺,食材、账本、员工各管各的,彼此之间要沟通只能通过电话、快递这类外部渠道,对应到编程里就是管道、队列、共享内存等IPC机制。
正因为隔离彻底,一个进程崩溃通常不会拖垮别的进程,安全性很高。代价是创建进程的开销比较大,启动一次要复制内存映射、初始化运行时环境,进程间切换也要内核参与,成本比线程高一个量级。所以它适合CPU密集任务和需要强隔离的服务,比如用multiprocessing跑机器学习训练脚本、批量处理文件等。
1.2 线程:在同一个进程里抢着干活的“人手”
线程是进程内部的执行流。一个进程可以包含多个线程,它们共享进程的内存、文件句柄、全局变量等资源。还是用开店来类比:进程是店,线程是店里的服务员,大家公用同一个菜单和后厨,某个人改了菜单,其他人立刻看得见。这种共享带来两个结果:数据传递方便;但一旦有多个线程同时修改同一份数据,就容易出乱子,需要加锁。
线程的创建开销比进程小得多,多个线程由操作系统调度,但线程之间切换依旧需要内核介入。它非常适用于I/O密集场景,比如爬虫并发请求网页、Web服务同时处理多个连接。需要注意的是,在CPython里面,多线程并不能让纯计算并行,这一点后面会单独展开。
1.3 协程:线程内部的“软切换”
协程是更轻量级的任务,它不依赖操作系统调度,而是由Python解释器在代码层面手动切换。你可以把它理解成一个线程内部自己协调的“小帮手”:A任务等网络响应时,主动让出控制权,B任务开始执行;B等I/O时,又切回A。这一切切换成本极低,因为它只是保存/恢复函数调用栈里的局部状态,不涉及内核级调度。
Python里最常见的协程实现就是async/await和asyncio库。协程比较适合高并发I/O,比如同时维持几万个TCP连接、大量短时请求的场景。它的隐藏前提是:你必须有足够的await点让出控制权。如果函数里全是同步阻塞操作,协程并不会帮你省时间。
2. 用同一段业务逻辑,把三种模型跑一遍
概念说了半天,不如直接跑代码。我默认环境是Python 3.10以上,8核CPU,Windows/Linux都行。先准备一个模拟I/O密集任务的函数,用time.sleep(1)模拟等待网络或磁盘返回1秒。
2.1 I/O密集场景:同步、线程、进程、协程的差距
同步执行8次:
import time def io_task(): time.sleep(1) return 1 start = time.perf_counter() for _ in range(8): io_task() print("同步耗时:", time.perf_counter() - start)这段代码老老实实等8秒,因为每次调用都阻塞到sleep结束才继续。
多线程版本:
import threading def io_task(): time.sleep(1) return 1 start = time.perf_counter() threads = [threading.Thread(target=io_task) for _ in range(8)] for t in threads: t.start() for t in threads: t.join() print("线程耗时:", time.perf_counter() - start)多进程版本:
import multiprocessing as mp import time def io_task(_): time.sleep(1) return 1 if __name__ == "__main__": start = time.perf_counter() with mp.Pool(8) as pool: results = pool.map(io_task, range(8)) print("进程耗时:", time.perf_counter() - start, results)协程版本:
import asyncio import time async def io_task(): await asyncio.sleep(1) return 1 async def main(): return await asyncio.gather(*[io_task() for _ in range(8)]) if __name__ == "__main__": start = time.perf_counter() asyncio.run(main()) print("协程耗时:", time.perf_counter() - start)我在本地跑的结果很有意思:同步约8秒,多线程约1秒,多进程约1秒,协程约1秒。I/O等待型任务,后三者都能把8个“等1秒”的操作重叠起来,所以总耗时无限接近1秒。这里要留意一个细节:进程版创建8个进程的开销实际上有几百毫秒,只是sleep时间太长看不出来,如果是每个任务只等10毫秒,进程的启动成本就会非常刺眼。
2.2 CPU密集场景:多线程反而更慢,多进程才是正解
把任务换成纯计算:
def cpu_task(): total = 0 for _ in range(10_000_000): total += 1 return total同步跑8次,假设耗时是X秒。换成多线程跑8次,我在大多数电脑上看到的不是更快,而是和同步相近,有时候甚至慢10%~20%。原因是CPython解释器里有一把全局锁叫GIL(Global Interpreter Lock),它保证同一时刻只有一个线程能执行Python字节码。线程虽然都启动了,但计算资源没法真正并行,线程调度本身还有额外开销。
换成多进程后,8个任务会分给8个CPU核心,理想情况下耗时接近X/8。因为每个进程有独立的解释器实例,各干各的,没用GIL互相拖后腿。用ProcessPoolExecutor也一样:
from concurrent.futures import ProcessPoolExecutor def cpu_task(i): total = 0 for _ in range(10_000_000): total += 1 return total if __name__ == "__main__": with ProcessPoolExecutor(max_workers=8) as executor: list(executor.map(cpu_task, range(8)))我之前有个误区:以为多线程无论如何都比同步好。后来用CPU密集任务一对比才发现,线程适合“等待多”的任务,进程适合“计算多”的任务。判断标准很简单:CPU占用率高不高。高就用进程,低就优先线程或协程。
2.3 asyncio到底快在哪
很多刚接触asyncio的人会误以为它“能并行计算”,其实协程世界里只有一个线程在跑。它快在“不等待”:执行await asyncio.sleep(1)时,事件循环会把当前协程挂起,继续跑别的协程,等1秒到了再回来。这样CPU在等待期间没有闲着,而是在处理其他任务,所以总耗时短。
同样的道理,await asyncio.open_connection()发起网络请求时,操作系统返回数据之前,事件循环可以处理成百上千个其他连接。这也是为什么异步框架能轻松扛住高并发长连接,而多线程模型却要依赖线程数。
但asyncio有一个非常现实的限制:=“阻塞”是在协程里就是“原罪”。如果你在async函数里用time.sleep(2)或者直接调用requests.get(),整个事件循环都会被卡住,其他所有协程都动不了。因为协程让出控制权的唯一方式是遇到await,而同步阻塞函数根本不会触发await。
3. GIL、锁和线程安全,这些坑必须提前知道
三个模型跑完,很多人会开始纠结“那GIL到底能不能无视”。我的建议是:既然用CPython,就得把它当成一个客观规则来理解。
3.1 GIL不是“不能多线程”,而是“不能并行计算”
GIL是CPython解释器级别的全局锁。它规定解释器同一时刻只能执行一条字节码指令。因此,在单个Python进程里,多线程不能让CPU多核并行跑Python计算。为什么Python还要保留GIL?因为Python对象的内存管理本身不是线程安全的,去掉GIL后需要对所有对象操作加锁,反而会让单线程性能大幅下降。这属于设计取舍,不是简单“删掉”就万事大吉。
那多线程在Python里还有什么用?答案是I/O。线程在执行read()、write()、sleep()等阻塞调用时会释放GIL,操作系统把你线程挂起,另一个线程就能拿过GIL继续跑。于是我们看到:8个线程都在等待网络返回时,等待过程可以重叠。GIL的切换粒度大概是几毫秒一次,对I/O密集任务影响不大。
有个例外要注意:如果你在C扩展库里写并行计算并手动释放GIL,线程也能并行。但那是另一套技术栈,日常业务代码基本接触不到。
3.2 Lock、RLock和死锁是怎么发生的
多线程共享数据时,最简单也最危险的操作是“读改写”。比如多个线程同时对同一个变量执行count += 1,在Python字节码层面这其实分成好几条指令:读取count、计算加1、写回count。两个线程可能同时读到同一个旧值,然后各自加1写回,最终count只加了1。解决方式就是加锁:
import threading lock = threading.Lock() count = 0 def increment(): global count for _ in range(100000): with lock: count += 1加锁之后,同一时刻只有一个线程能进入临界区,数据就不会交叉。但锁用不好就会死锁。最经典的死锁是“互相等待”:线程A持有锁1想拿锁2,线程B持有锁2想拿锁1,两边谁都不放手。我在实际项目里踩过一次,当时两个线程分别更新订单和库存,更新订单时先拿订单锁再拿库存锁,另一个线程反过来,线上服务挂了一整晚。
后来总结出几条规定:
- 所有需要多个锁的代码,严格按照同一个顺序获取锁,比如都先拿订单锁再拿库存锁;
- 能用一把锁就尽量不搞两把,锁越少死锁面越窄;
- 等待锁时给超时,比如
lock.acquire(timeout=2),宁可超时重试,也别无限等待; - 如果同一个线程需要重复获取同一把锁,用
threading.RLock代替Lock,否则会把自己锁死; - 高并发场景优先考虑
queue.Queue这种线程安全容器,让多个线程只通过队列交换数据,少直接操作共同可变状态。
3.3 线程池和进程池的参数到底怎么定
concurrent.futures的ThreadPoolExecutor和ProcessPoolExecutor是最常用的池化组件。选型并不复杂:I/O密集用ThreadPoolExecutor,CPU密集用ProcessPoolExecutor。
max_workers的参数得想清楚。很多新手直接填100,因为“并发越高越好”,结果线程切换开销巨大,反而拖慢任务。线程池经验值一般是CPU核心数的5~10倍,比如8核机器开max_workers=40左右,具体得用你真实任务跑一次观察。如果是协程,根本不需要开这么多线程,直接用asyncio。
进程池的max_workers最好不超过os.cpu_count(),否则系统调度会频繁切换进程,性能不升反降。还要考虑每个进程的内存占用,如果每个worker要加载200MB模型,那4个进程就可能吃掉近1GB内存,机器扛不住就得减并发。首次创建进程池时,子进程要重新导入主模块,所以多进程代码必须放在if __name__ == "__main__":保护之下,否则在Windows上会无限递归启动子进程。
4. 实战中经常碰到的排查和避坑记录
讲完原理,说几个我实际遇到过的“疑难杂症”。这些内容平时写教程很少提,但对真正写代码的人特别有用。
4.1 程序“卡死”了,怎么快速区分是死锁还是性能问题
程序界面没反应、请求不返回,第一反应别乱猜。先找到进程PID,在Linux下用ps -ef | grep python,Windows下用任务管理器查PID。然后交互式看线程在干什么:
pip install py-spy py-spy top --pid <PID>py-spy会打印当前Python线程的调用栈,一眼就能看出是卡在lock.acquire()等待锁,还是卡在某个循环里疯狂计算。我之前遇到一次诡异卡顿,就是用py-spy看到线程卡在requests.get()上,根本不是死锁,而是某个外部接口超时时间设成了无限长。
如果线上不方便安装py-spy,可以在代码里临时加一段:
import threading for th in threading.enumerate(): print(th.name, th.ident, th.daemon)这样至少能看到线程名和存活状态。但线程名默认是“Thread-1”这种,没有业务含义。建议创建线程时显式传入name参数,比如Thread(target=download, name="download-worker"),这样排查日志时会感激自己。
还有一个容易被忽视的“软死锁”:某个线程拿到锁后抛了异常但没释放锁,其他线程永远等下去。所以临界区代码务必用with lock:上下文管理器,不要在临界区里写裸try/finally以外的逻辑。
4.2 子进程/子线程多了,怎么定位和优雅结束
子进程创建之后不销毁,会让系统里残留一堆僵尸进程。尽量避免用裸Process启动任务,而是交给进程池统一管理,进程池退出时会回收子进程。必须手动起进程时,至少要在finally中调用terminate()和join():
p = Process(target=worker) p.start() try: p.join(timeout=10) finally: if p.is_alive(): p.terminate() p.join()想查看正在跑的Python进程,用psutil非常方便:
import psutil for proc in psutil.process_iter(['pid', 'name', 'cmdline']): if 'python' in proc.info['name']: print(proc.info)有些服务启动后,希望进程名不再是python而是一个业务名,方便监控识别。Linux下可以用setproctitle库修改:
pip install setproctitlefrom setproctitle import setproctitle setproctitle("my-data-worker")这样在ps、htop里显示的就是my-data-worker,而不是一串模糊的Python命令。需要注意的是,新进程在主进程的基础上改名,不要在if __name__ == "__main__"之前调用,避免影响多进程启动逻辑。
4.3 asyncio的几个隐蔽坑
用asyncio写业务时,我踩过最多的坑就是“异步函数里混进同步阻塞”。典型错误:
import asyncio import time async def demo(): print("start") time.sleep(1) # 错误示范!这会阻塞整个事件循环 print("end")正确做法是把它丢到独立线程里等:
async def demo(): await asyncio.to_thread(time.sleep, 1)asyncio.to_thread会把普通函数扔到线程池执行,主事件循环不会被卡住。同理,网络请求尽量用httpx.AsyncClient或aiohttp,别用同步的requests库。
还有两个细节很容易被忽略。第一,asyncio.create_task()创建的任务如果没人await,既不会报错也不一定会执行完即被垃圾回收,建议用列表保存所有task,最后统一await。第二,多个协程共享同一个可变对象时,同样存在数据安全问题。协程虽然是单线程,但await处可能切换协程,两个协程可能在“读改写”中间互相打断。这时要用asyncio.Lock:
async_lock = asyncio.Lock() async def update(): async with async_lock: shared_value += 14.4 进程通信不是“共享变量”,而是队列和管道
多进程之间不能像线程那样直接用全局变量,因为每个进程的内存空间是独立的。最常用的方式是multiprocessing.Queue:
import multiprocessing as mp def worker(q): q.put("done") if __name__ == "__main__": q = mp.Queue() p = mp.Process(target=worker, args=(q,)) p.start() print(q.get()) p.join()需要注意,mp.Queue在Windows下依赖pickle序列化,传进去的对象必须能被pickle。比较大的数据(比如DataFrame)用队列传递会明显变慢,可以改用共享内存(如multiprocessing.shared_memory)或把数据写到临时文件再传路径。另一个常见坑是:子进程往队列里放数据,但主进程还没取完就退出,容易导致数据丢失。正确姿势是先join()子进程,再用循环把队列取空,或者通过Queue的qsize做兜底判断。
最后谈一点个人体会
经常有人问我,这三种模型到底要学到什么程度才算“会了”。我的标准很简单:给你的业务写一个基准脚本,分别用线程池、进程池和协程跑一遍,然后盯着CPU和内存曲线看两三分钟,你自然会记住它们各自的脾气。我自己用这套方法踩平了很多弯路,也逐步总结出一个套路:I/O多就上协程或线程,计算多就用进程,实在拿不准就先用线程池搭起一个版本,再根据压测结果去迁移。进程、线程、协程并不是非此即彼的关系,很多大型项目是三者混用的——主业务跑asyncio,遇到阻塞操作丢给线程池,遇到重型计算再抛给进程池。把握住这个分层思想,比你死记硬背任何并发模型都管用。