news 2026/10/6 14:01:14

Python并发编程三剑客:进程、线程、协程如何选?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python并发编程三剑客:进程、线程、协程如何选?

老实说,很多人把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 setproctitle
from 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 += 1

4.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,遇到阻塞操作丢给线程池,遇到重型计算再抛给进程池。把握住这个分层思想,比你死记硬背任何并发模型都管用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 13:59:50

AI数字员工解决方案:从三层层架构到落地实践全解析

简介&#xff1a;面向金融机构数字化转型的《AI数字员工解决方案》PDF文档&#xff0c;系统梳理以RPA与AI为核心的“数字员工”体系&#xff0c;覆盖行业背景、核心能力组件、典型应用场景、技术架构与发展前景&#xff0c;适合金融科技从业者、企业数字化负责人及相关技术人员…

作者头像 李华
网站建设 2026/10/6 13:59:01

Agent-Reach:重构Agent工具触达与能力范围管理

做Agent开发时间久了&#xff0c;你一定会遇到这种瞬间&#xff1a;Agent明明已经接了十多个工具&#xff0c;可真到用的时候&#xff0c;要么它选错工具&#xff0c;要么它压根没意识到某个工具存在&#xff0c;你把它能调用的函数全塞进prompt里&#xff0c;费了半天的token&…

作者头像 李华
网站建设 2026/10/6 13:56:25

从零搭建Coze智能体对话页面:鉴权、流式输出与工作流对接

简介&#xff1a;一套基于HTML的Coze智能体对话页面搭建方案&#xff0c;适合需要快速集成智能对话能力的前端开发者与API调用场景。方案覆盖完整Coze API调用流程&#xff0c;支持流式输出、图片直显、多轮对话记忆及Markdown解析&#xff0c;开发者只需替换COZE_API_TOKEN与C…

作者头像 李华
网站建设 2026/10/6 13:56:12

Python网络编程核心实战:socket、TCP与粘包问题详解

1. 内容整体设计与思路拆解 1.1 网络编程到底在解决什么问题 先说个我经常在答疑时碰到的场景&#xff1a;很多人学网络编程&#xff0c;教材翻了厚厚一本&#xff0c;词儿都认识——socket、TCP、UDP、端口、协议栈&#xff0c;可真要让他自己写一个聊天程序或者传个文件&…

作者头像 李华
网站建设 2026/10/6 13:55:28

晶振布局布线实操指南:从寄生参数到EMI排查一次讲透

做硬件这么多年&#xff0c;我越来越觉得一句话说得在理&#xff1a;画板子的人很多&#xff0c;但能把晶振这块方寸之地画明白的人&#xff0c;真不多。晶振这东西&#xff0c;看起来就两个或者四个引脚&#xff0c;电路也简单&#xff0c;可它偏偏就是整个系统的“心跳源”。…

作者头像 李华
网站建设 2026/10/6 13:54:00

用Python实现壁纸自动下载:爬虫、去重与定时任务全攻略

最近我又把桌面壁纸看腻了。换壁纸这件事看着小&#xff0c;真操作起来很烦&#xff1a;先打开浏览器翻图库&#xff0c;一张张预览&#xff0c;遇到高清大图还得右键另存为&#xff0c;兴冲冲切回桌面一看&#xff0c;不是分辨率不对就是构图不行&#xff0c;只能再来一轮。反…

作者头像 李华