
老实说很多人把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核CPUWindows/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(targetio_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解释器里有一把全局锁叫GILGlobal 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_workers8) 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(timeout2)宁可超时重试也别无限等待如果同一个线程需要重复获取同一把锁用threading.RLock代替Lock否则会把自己锁死高并发场景优先考虑queue.Queue这种线程安全容器让多个线程只通过队列交换数据少直接操作共同可变状态。3.3 线程池和进程池的参数到底怎么定concurrent.futures的ThreadPoolExecutor和ProcessPoolExecutor是最常用的池化组件。选型并不复杂I/O密集用ThreadPoolExecutorCPU密集用ProcessPoolExecutor。max_workers的参数得想清楚。很多新手直接填100因为“并发越高越好”结果线程切换开销巨大反而拖慢任务。线程池经验值一般是CPU核心数的5~10倍比如8核机器开max_workers40左右具体得用你真实任务跑一次观察。如果是协程根本不需要开这么多线程直接用asyncio。进程池的max_workers最好不超过os.cpu_count()否则系统调度会频繁切换进程性能不升反降。还要考虑每个进程的内存占用如果每个worker要加载200MB模型那4个进程就可能吃掉近1GB内存机器扛不住就得减并发。首次创建进程池时子进程要重新导入主模块所以多进程代码必须放在if __name__ __main__:保护之下否则在Windows上会无限递归启动子进程。4. 实战中经常碰到的排查和避坑记录讲完原理说几个我实际遇到过的“疑难杂症”。这些内容平时写教程很少提但对真正写代码的人特别有用。4.1 程序“卡死”了怎么快速区分是死锁还是性能问题程序界面没反应、请求不返回第一反应别乱猜。先找到进程PID在Linux下用ps -ef | grep pythonWindows下用任务管理器查PID。然后交互式看线程在干什么pip install py-spy py-spy top --pid PIDpy-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(targetdownload, namedownload-worker)这样排查日志时会感激自己。还有一个容易被忽视的“软死锁”某个线程拿到锁后抛了异常但没释放锁其他线程永远等下去。所以临界区代码务必用with lock:上下文管理器不要在临界区里写裸try/finally以外的逻辑。4.2 子进程/子线程多了怎么定位和优雅结束子进程创建之后不销毁会让系统里残留一堆僵尸进程。尽量避免用裸Process启动任务而是交给进程池统一管理进程池退出时会回收子进程。必须手动起进程时至少要在finally中调用terminate()和join()p Process(targetworker) p.start() try: p.join(timeout10) 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.Lockasync_lock asyncio.Lock() async def update(): async with async_lock: shared_value 14.4 进程通信不是“共享变量”而是队列和管道多进程之间不能像线程那样直接用全局变量因为每个进程的内存空间是独立的。最常用的方式是multiprocessing.Queueimport multiprocessing as mp def worker(q): q.put(done) if __name__ __main__: q mp.Queue() p mp.Process(targetworker, 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遇到阻塞操作丢给线程池遇到重型计算再抛给进程池。把握住这个分层思想比你死记硬背任何并发模型都管用。