news 2026/11/5 1:56:15

Python并发编程实战:进程、线程、协程的选型与踩坑经验

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python并发编程实战:进程、线程、协程的选型与踩坑经验

在 Python 的并发编程里,很多人最开始都是被“进程、线程、协程”这几个词劝退的。之前我带过一个爬虫项目,最初是单线程循环,跑一批任务要三四个小时;后来盲目加 threading,结果 CPU 没真的跑满,反而频繁出现数据错乱;最后换成 asyncio + 进程池的组合,才把整个任务压进二十分钟以内。这篇文章不打算给你堆概念,而是从进程、线程、协程的实际作用出发,把 Python 并发这条线彻底捋清楚,包括每个方案背后的适用场景、坑点,以及我在生产环境里沉淀下来的经验。如果你是刚开始用 Python 处理并发的新手,或者写过一堆线程代码却总出问题,这篇文章应该会比抽象的理论文档更对胃口。

1. 从并发需求出发:先搞清楚这三种并发单元分别解决什么问题

1.1 为什么需要并发

计算机和人一样,同一段时间内能处理的事情是有限的。在单核时代,CPU 的速度远远快于硬盘和网络,程序大部分时间都在等待外设返回数据,这种等待对算力是巨大的浪费。并发编程的核心思路,不是让 CPU 同一时刻多干活,而是当一个任务在等待 IO 的时候,让另一个任务插进来使用 CPU,把空闲时间补上。后来多核普及,并发又多了另一层含义:真正同时利用多个核心,把一个大任务拆成多个子任务并行计算。

我见过不少新手把并发和并行混为一谈,总问:多线程是不是每个线程占一个核?其实在 Python 里,这个问题要分两层看。如果任务是计算密集型的,比如大量数值运算,那么线程因为受限于 GIL,很难真正并行;但如果是 IO 密集型的,比如读写文件、请求接口、等待数据库返回,那么线程切换的代价远小于等待的时间,并发能带来巨大收益。所以,脱离任务类型谈并发方案,基本等于耍流氓。

1.2 进程、线程、协程的概念类比

用开餐厅来类比会好理解得多。进程就像一家独立的餐厅,自己拥有厨房、餐厅和营业执照,跟其他餐厅物理隔离,互不干扰。线程是餐厅里的多个服务员,共享同一套后厨和餐桌,翻台效率更高,但也容易抢东西,出现脏读、死锁之类的问题。协程更像是服务员在自己的工位上灵活地切换面前的任务,不需要离开工位,调度成本极低,但不能像线程那样被操作系统随意切走。

这个比喻虽然粗糙,但能说明问题:进程之间最安全,线程之间方便,协程切换最轻。三者的资源开销也是递减的,这点很关键。创建一个进程需要申请独立的地址空间,启动成本高;启动一个线程要占用几十 KB 到几 MB 的栈空间;而协程本质上是用户态上下文的切换,内存占用和切换开销是所有方案里最小的。具体到 Python 里,多进程通常靠 multiprocessing 实现,多线程靠 threading,协程靠 asyncio。选择哪个,取决于你手里的任务到底属于哪种类型。

2. 进程:multiprocessing 的落地细节与踩坑记录

2.1 进程池能解决什么问题

在大规模任务拆解时,直接用 for 循环去创建成百上千个进程是极不划算的。进程池(multiprocessing.Pool)就是为了复用进程、降低重复创建销毁的开销。我有一次写数据处理脚本,要处理十万个小文件,最开始用 Process 逐个启动进程,跑到一半就各种卡死;换成 Pool 之后,指定一个合适的进程数,把任务通过 apply_async 丢进去,整体时间直接降了一个量级。这里的原则是“有状态的任务可以用独立进程,无状态的任务尽量交给池”。

选进程数也有讲究。如果是纯 CPU 密集型任务,进程数可以设置为os.cpu_count()甚至稍小的值,因为操作系统本身还要占用资源;如果是 IO 密集型任务,进程数可以适当超出核心数,但也不能无限大。我一般从 4 开始压测,逐步上调到 8、16,找到曲线拐点再定。还有一种常见做法是把 CPU 密集部分和 IO 密集部分拆开,IO 部分用协程或线程,CPU 部分才交给进程池,这样能压榨出更多性能。

import os from multiprocessing import Pool def worker(x): return x * x if __name__ == "__main__": # CPU 密集型任务时,进程数不宜超过核心数 pools = max(1, os.cpu_count() - 1) with Pool(processes=pools) as pool: results = pool.map(worker, range(10000))

2.2 子进程退出状态与等待回收

多进程代码里最容易漏掉的是“子进程的退出状态没人读”。比如用Process.join()可以等待子进程结束,但很多人在主进程退出前没调 join,导致子进程变成孤儿进程;或者没有及时处理子进程的管道输出,导致缓冲区写满,整个程序卡死。我有一次用subprocess.Popen启动一个外部命令,只调了communicate(),没设置 timeout,结果外部命令因为网络卡住,Python 脚本也一起挂死。后来养成了习惯:所有外部命令调用都必须加 timeout,并在异常分支里杀掉残留进程。

进程之间如果要共享数据,不要直接改全局变量,那是不会同步的。正确做法是使用Queue、Pipe或Manager。进程安全的队列本质上是一根管道加一把锁,所以只适合传递可序列化的对象,千万别指望它像普通队列一样随意放各种复杂对象。另外,在 Windows 上跑多进程代码,入口必须写在if __name__ == "__main__"下面,否则子进程会递归执行模块代码,一次就能让新手摸不着头脑。这个坑我在跨平台项目里踩过,添加入口判断后问题立刻消失。

3. 线程:GIL 之下的 threading 真实战斗力

3.1 GIL 到底锁住了什么

很多人对 Python 多线程失望,就是因为 GIL。这把锁保证了同一时刻只有一个线程能执行 Python 字节码,所以计算密集任务在多线程下不仅不加速,还会因为锁竞争变慢。但 GIL 并不是绝对禁止线程并发执行,在执行 IO 操作、sleep、大多数网络库的读写时,锁会被释放,所以 IO 密集型任务用多线程依然有效。换句话说,GIL 锁的是“执行 Python 代码”这个动作,不等于锁住所有系统调用。

这里有个天然的历史原因:Python 的内存管理(引用计数)需要保证同一对象的引用计数操作是原子的,早期设计为了保证解释器的线程安全,干脆加了一把全局锁。这个问题由来已久,Python 3.13 开始有了自由线程的实验性构建,但生产环境暂时还不会大规模切换。站在选型角度,如果你要处理的任务主要是网络请求或文件读写,多线程完全足够;如果任务是纯 CPU 计算,应该选择多进程,或者把热点代码交给 C 扩展、numpy 等能释放 GIL 的库来处理。总之一句话:不是 Python 的线程没用,是你没选对场景。

3.2 线程同步、死锁与线程池配置

谈到线程,绕不开锁。threading.Lock本质上是一个只能装一把钥匙的抽屉,工作线程要先拿到钥匙才能访问共享资源。用不好就会出现死锁:两个线程各自握着一把锁,然后在等对方手里的锁,谁都等不到。死锁虽然经典,但现代代码里更常见的是“可重入与不可重入”的坑。同一线程两次acquire一个普通Lock,会把自己锁死;而使用RLock就不会,因为每个线程自己可以持有重入计数,多次获得同一把锁不会因为自我竞争而卡住。

线程池方面,我从 Python 3.2 开始就用ThreadPoolExecutor,它内部有队列、工作线程和调度逻辑,比手动管理线程要安全得多。配置max_workers时,如果你处理的是几百个请求,开 10 个线程可能优于开 50 个线程,因为线程太多会抢占资源并带来上下文切换开销。阻塞队列的选型也会影响吞吐:无界队列可能让内存暴涨,有界队列配合调用方的超时策略会更稳。常规经验是把max_workers设置为“预期同时阻塞的 IO 任务数”,比如 32 个并发请求,那就开 32 或稍小。

from concurrent.futures import ThreadPoolExecutor, as_completed def fetch(url): # 模拟网络请求 return f"ok-{url}" urls = [f"http://example.com/r/{i}" for i in range(100)] with ThreadPoolExecutor(max_workers=10) as executor: futs = [executor.submit(fetch, u) for u in urls] for fut in as_completed(futs): result = fut.result(timeout=5)

4. 协程:asyncio 的事件循环与异步编程

4.1 事件循环与调度机制

协程在 Python 里的主要载体是 asyncio,它不靠操作系统切换线程,而是在一个线程内通过事件循环调度多个协程。事件循环可以理解成一张待办清单:主线程把一个个异步任务记录在案,遇到 IO 时挂起当前协程,去执行别的协程,等 IO 完成后再通过回调把它唤醒。这个机制让成千上万个任务可以在单线程里共存,最重要的是用户态切换,没有操作系统级的线程上下文切换开销。

从生成器开始,Python 的协程经历了yield、@asyncio.coroutine再到现在的async/await,核心都是“挂起-恢复”两件事。asyncio.run()是最常用的入口,它负责创建事件循环、运行主协程、结束后清理掉资源。代码里用await表示让出控制权,这正是协程的核心动作。但是要注意,在 async 函数里不能直接调用阻塞的time.sleep(),必须用asyncio.sleep(),否则会把整个事件循环卡住,其他协程全部停摆。这跟多线程完全不同,很多刚上手的人会在这一步翻车。

4.2 async/await 常见误区和性能关键点

常见误区一:协程函数调用后不await,它不会执行,只是生成一个协程对象。必须把它丢进事件循环里,调度到才会真正跑。常见误区二:以为协程能并行加速计算任务,实际上协程在单线程内是并发而非并行,写一个死循环照样会阻塞所有任务。真正适合协程的是海量 IO 任务,比如每分钟处理几万个 HTTP 请求的场景,asyncio 的优势非常明显。因为它的内存占用很低,一个协程对象只是一个比较轻量的状态机。

要做成规模的并发任务,建议用Semaphore限制最大并发数,避免一瞬间打爆目标服务。还需要注意超时控制,用asyncio.wait_for给每个任务加上时限,否则任何一个远端卡住都会拖住整个协程组,最终像雪崩一样堆积任务。另一个性能关键点是尽量复用连接而不是每次都新建连接,比如aiohttp.ClientSession,它内部有连接池,复用后吞吐量会有明显提升。我经常看到有人把连接创建写在单个请求里,导致队列反复握手握手,性能反而下降。

import asyncio async def request(sem, url): async with sem: await asyncio.sleep(0.1) # 模拟 IO return url async def main(): sem = asyncio.Semaphore(20) tasks = [request(sem, f"url-{i}") for i in range(1000)] return await asyncio.gather(*tasks) if __name__ == "__main__": asyncio.run(main())

5. IO 模型:为什么并发场景的核心常常是 IO 而不是计算

5.1 阻塞、非阻塞与 IO 多路复用

要真正理解并发,绕不开 IO 模型。阻塞 IO 是最直白的:我读文件时你不返回,我就一直等;非阻塞 IO 是读之前先看数据准备好了没,没有就先干别的,过会儿再来问;IO 多路复用则是用一个线程同时盯着很多个文件描述符,一旦谁有数据就去处理谁。操作系统的select、poll、epoll做的就是这件事,它们让单个线程能处理成百上千的连接,这是 Nginx 和各类异步框架的基石。

最好理解的类比是餐厅点餐:阻塞是一个人盯着厨师做你的菜;非阻塞是你每隔一小会儿去问一次好了没;多路复用是门口只站一个叫号员,谁的小票响了就叫谁来取餐。Linux 的epoll相比select最核心的改进,是不用每次都把所有 fd 重新传给内核,内核会维护一个事件列表,fd 数量大了之后效率是数量级提升。select还有一个让人头疼的限制是默认最大 fd 数量通常是 1024,高并发连接数下根本不够用。

5.2 Python 中各 IO 模型的落点

Python 的 asyncio 在 Linux 上默认使用 epoll 事件循环,本质上是 IO 多路复用加协程调度。用这种方式处理几万个并发连接,比创建几万个线程要现实得多。而ThreadPoolExecutor更适合包装那些内置是阻塞模式的库,用多线程模拟一点伪异步。对于文件读写,asyncio 在不同系统上的实现不太一样,在 Windows 上底层是 IOCP,在 Linux 上则通过线程池模拟,所以“只要用异步,文件读写一定更快”这种结论并不成立。

对普通业务开发者来说,最终选择依据可以简化:如果底层库本身支持 async/await,就优先用 asyncio;如果库是同步阻塞的,并发量又不大,用ThreadPoolExecutor包一层往往更省事。不要为了追求“所有东西都异步”而去重写所有代码,成本很高,收益却不一定好。我见过一些团队把已经很成熟的同步代码硬改异步,结果引入一堆回调问题和超时问题,最后又改回来。正确的思路是保持核心链路清晰,按热点和瓶颈点局部改造。

6. 常见问题与排查技巧实录

6.1 高频并发错误速查表

这里整理一份我经常在社区里看到、也亲自踩过的并发错误表,方便你排查时对号入座:

典型现象可能原因解决方向
进程脚本启动后卡死无输出子进程读管道缓冲区满,没人消费及时读取 stdout/stderr,或设置 flush 策略
线程池任务堆积,内存上涨无界队列 + 生产速度太快改用有界队列,加退避和超时
多个线程同时改变量,结果错乱缺少锁或用了线程不安全的容器加 Lock/RLock,或改用 Queue 传递数据
协程任务一个个卡住不前进async 函数里调用了阻塞 sleep 或同步库换成 asyncio.sleep 或异步网络库
程序退出后有僵尸进程子进程未 join 或未调用 wait()主进程结束前统一回收子进程
CPU 占用 100% 却没拉满并发频繁切换 + 锁竞争严重调整线程数,改用进程池或协程

6.2 真实排查并发问题的手段

遇到并发问题,我一般先画一个“等待-资源-共享数据”的三角图:每一步任务在等什么?资源够不够?共享数据要不要加锁?然后配合 py-spy 或 faulthandler 查看卡住的现场。比如线程池不干活了,先 dump 出当前所有线程的状态,看它们停在哪个调用上;如果停在 socket 连接,再去检查远端超时和连接数。py-spy 的好处是不用改代码,可以直接对运行中的 Python 进程执行 dump,对线上排查特别有用。

另一个关键手段是加日志和监控。我不会在并发代码里频繁打印日志,但会在任务入库、完成、失败回收三个节点各打印一条结构化日志,这样出了问题,可以按任务 ID 去对流程。没有时间戳和任务 ID 的并发日志,排查起来基本靠猜。压测也很重要,先用几倍于预期的流量压测,观察进程数、线程数、协程任务的曲线,找到资源拐点,再回填到配置里。这个习惯能提前暴露绝大多数并发问题。

6.3 并发代码的调试小教训

最后补一条调试教训。我在看别人并发代码时,见过最多的问题是“没有明确的生命周期”。线程启动后就没人管了,协程任务创建后也没人 gather,进程退出时也不回收。造任务的时候很开心,收尾的时候集体失忆。代码写完后,第一件事是检查所有 create、submit、create_task 出来的东西,是否有对应的 join、shutdown、gather 或 await。一个好的并发程序,从创建到回收的生命周期必须是完整闭环的,只要有一个任务漏了回收,后续就可能埋雷。

我现在写很多并发代码时会刻意遵守一个原则:能不给共享变量加锁,就不加锁;能不用全局状态,就不用。多进程用队列传数据,多线程用闭包和局部变量隔离数据,协程则坚持“同一个事件循环里只通过 await 传递结果”。这个习惯帮我避开了大量偶现的诡异问题。你在自己的项目中也可以试试,哪怕只做到一半,查错的时候都能轻松不少。

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

Agent-Reach:构建智能体触达层,让AI Agent真正执行外部系统操作

最近我在折腾 AI Agent 落地,发现一个特别普遍的规律:团队的重心几乎都压在模型能力、提示词工程、知识库这些“大脑”部分,可真要让 Agent 去干一件实事——发一封邮件、提交一个工单、在后台系统里改个配置——却经常卡住。Agent-Reach 这个…

作者头像 李华
网站建设 2026/10/28 7:33:20

双栈算符优先法求解算术表达式:原理、实现与调试避坑指南

简介:《数据结构表达式求值实验报告》是一份面向计算机专业学生的课程设计文档,适用于数据结构课程中栈与队列专题实验、表达式求值算法实现及期末课程设计参考。内容以算术表达式求值为核心,针对运算符优先级和括号处理难题,详细…

作者头像 李华
网站建设 2026/10/28 2:54:49

SpringBoot快递物流仓库管理系统开发与部署全解析

毕业设计做快递物流仓库管理系统,选SpringBoot这套技术栈的同学不在少数。这个标题带着“源码lw部署文档讲解”,一看就是典型的JavaWeb毕设项目。我前前后后帮人调试过不少这类项目,也自己完整从零搭过类似的系统,今天就把这套Spr…

作者头像 李华
网站建设 2026/11/4 10:58:38

增程技术遇拐点:电池降价与充电基建如何终结过渡方案

现在做一个行业观察类的技术复盘:增程技术路线遇到拐点,第二家主打增程的车企出现销量暴跌。这个事件不是孤例,而是技术周期更迭的一个信号。想聊清楚背后的逻辑,得从技术本质、成本曲线、市场选择和用户心理几个维度拆开看&#…

作者头像 李华
网站建设 2026/10/31 2:12:24

Claude Code不再失忆:claude-mem长期记忆接入与配置指南

如果你每天用 Claude Code 写代码,一定经历过这个场景:昨天还在跟它讨论某段逻辑的重构方案,今天开个新会话,它一脸茫然地看着你,好像你们从来没聊过。这不是模型变笨了,而是 Claude Code 默认没有跨会话的…

作者头像 李华
网站建设 2026/11/4 15:50:05

AI Agent协作实战:从需求到部署的全流程经验

最近这两个月,我的开发方式彻底变了——我不再一个人从头扛一个完整项目,而是用一套自己的多AI协作流程,雇了一个AI团队来和我并肩干活。这套流程我管它叫AgentTeams,核心思路很简单:把需求分析、架构设计、编码、测试…

作者头像 李华