☰
Python多任务编程详解:进程、线程与协程的选型与实践
2026/10/14 19:51:33 网站建设 项目流程

你有没有遇到过这种情况:写了一个下载脚本,跑起来 CPU 占用率只有 10%,但等得人想摔键盘;或者处理一批图片,四个核心的电脑只忙活了一个,剩下的三个在看风景。Python 开发者在面对这类问题时,绕不开一个话题——多任务。今天这篇文章就围绕进程、线程、协程三种方案,把它们的原理、适用场景、实操要点一次讲透,顺便把我踩过的坑也一并交代了。

这篇文章适合已经开始写 Python 但被性能问题卡住的朋友,也适合准备面试、想系统梳理并发知识的开发者。不管你是刚接触多任务,还是已经写过 threading 但不太清楚什么时候该上 multiprocessing,都可以从中找到能直接落地的思路和代码。读完之后,你至少能分清"我这段代码到底是 CPU 密集还是 I/O 密集",并据此选对方案。

1. 先弄清楚我们到底在解决什么问题

很多人一上来就纠结进程和线程哪个好,其实顺序反了。第一步应该问自己:程序慢在哪里?

1.1 多任务的核心矛盾:等待与计算

我习惯把程序的工作分成两大类。

一类是"一直在算",比如视频编码、图像滤波、数据处理、数学计算。这类任务的共同特点是 CPU 始终在满负荷运转,瓶颈在计算能力上,我们称之为 CPU 密集型任务。

另一类是"一直在等",比如下载文件、发送 HTTP 请求、读写数据库、读取磁盘大文件。程序运行的大部分时间都花在等待外部设备响应上,CPU 其实闲着。这类叫 I/O 密集型任务。

这两种任务的优化思路完全不同。CPU 密集型的思路是让多个核心同时干活,用上多核并行。I/O 密集型的思路是尽量让 CPU 在等待期间去处理别的任务,提高单位时间内的任务吞吐量。想通这一点,后面的技术选型就顺理成章了。

1.2 程序员的错觉:并发不等于并行

新手最常见的误区,是把"并发"和"并行"混为一谈。并发指的是多个任务在同一个时间段内交替执行,看起来像同时进行,本质上是轮转;并行才是真正的同时执行,需要多个 CPU 核心同时工作。

打个比方:如果我是收银员,只有一个收银台,给两个顾客结账只能交替进行,这是并发;如果增加一个收银台,两个顾客同时结账,这叫并行。Python 里的线程和协程由于共享同一个解释器执行权,多数时候只能实现并发;进程因为各自有独立解释器,才能做到真正的并行。

把概念理清楚之后,我们就可以分别看看三种方案的内部机制了。先从最"硬核"的进程开始。

2. 进程:让多个核心真正跑起来

进程是操作系统分配资源的基本单位,拥有独立的内存空间和独立的 Python 解释器。正因为完全隔离,进程之间天然没有数据竞争的问题,也绕开了 Python 的全局解释器锁。

2.1 全局解释器锁:Python 多线程的紧箍咒

要理解为什么 CPU 密集型任务必须用进程,得先认识 GIL(全局解释器锁)。GIL 是 CPython 解释器的一个机制,它保证同一时间只有一个线程在执行 Python 字节码。这个设计让内存管理变得简单,但也限制了多线程的并行能力。

在多核 CPU 上,Python 的多线程在 CPU 密集型任务里反而可能更慢——线程之间切换本身就有开销,而计算却被串行化。我在一次图像批量处理任务中实测过,4 线程版本的耗时是单线程的 1.3 倍,不仅没加速,反而变慢了。用进程重写之后,4 进程接近线性加速,耗时降到了单线程的 27% 左右。

注意:线程在某些情况下仍然有用,只是不适合 CPU 密集计算。这一点后面会展开。

2.2 multiprocessing 的基本使用方式

Python 的multiprocessing模块提供了和threading类似的接口,上手很快。下面是一个用进程池处理任务的例子:

import multiprocessing as mp import time def compute_square(n): """模拟一个 CPU 密集型计算任务""" total = 0 for i in range(n): total += i * i return total if __name__ == "__main__": numbers = [2_000_000 + i * 100_000 for i in range(8)] # 创建一个有 4 个进程的进程池 with mp.Pool(processes=4) as pool: start = time.time() results = pool.map(compute_square, numbers) elapsed = time.time() - start print(f"计算结果: {results[:2]} ...") print(f"多进程耗时: {elapsed:.4f} 秒")

Pool.map会自动把任务列表拆分给多个进程处理,并将结果按原有顺序返回。对于大多数批量场景,这一行 API 就够用了,不需要自己手动管理进程生命周期。

2.3 进程间通信的常用手段

进程之间不共享内存,所以需要通过特定机制交换数据。我用得最多的是Queue和Pool的回传值。

Queue适合生产者-消费者模式。举个例子,一个进程负责抓取网页链接,另一个进程负责解析内容,中间用队列解耦:

import multiprocessing as mp def producer(q): for i in range(5): q.put(f"数据-{i}") q.put(None) # 结束信号 def consumer(q): while True: item = q.get() if item is None: break print(f"消费者收到: {item}") if __name__ == "__main__": q = mp.Queue() p1 = mp.Process(target=producer, args=(q,)) p2 = mp.Process(target=consumer, args=(q,)) p1.start() p2.start() p1.join() p2.join()

这里有个关键细节:在 Windows 上启动多进程时,程序必须放在if __name__ == "__main__":块内,否则进程会因为递归创建而报错。即使你在 Linux 上开发,也应该养成这个习惯,保证跨平台可运行。

2.4 进程的真正适用场景与代价

进程适合的任务特征很清晰:计算密集、数据可以分片、各子任务之间关联不大。典型如批量图片压缩、数据分析中的并行聚合、科学计算模拟等。

代价也不小。创建进程比创建线程慢得多,每个进程都有独立的内存空间和解释器,内存开销大。我的经验是:当任务规模小、执行时间只有几十毫秒时,进程启动的固定开销可能比任务本身还大,这时候划不来;只有当单个任务超过零点几秒,或者任务量大到可以分摊初始化成本时,进程才划算。

另外要切记,进程之间的数据传递需要序列化,传大数据结构时开销明显。如果数据能拆成小份再传递,尽量拆;最忌讳的是把整个大列表塞进Queue,内存会直接翻倍。

3. 线程:I/O 密集场景下的实用方案

如果说进程是为多核并行而生的,那线程的价值就在于轻量地实现并发。Python 的多线程虽然受 GIL 限制,无法并行执行计算任务,但在 I/O 密集型场景下,它依然是非常有效的工具。

3.1 为什么 I/O 密集任务用线程反而更快

关键在于,I/O 操作(比如网络请求、读写文件)执行时,线程会主动释放 GIL,进入等待状态。此时解释器可以把执行权交给其他线程,让它们去做自己的 I/O 操作。于是多个 I/O 操作在时间上重叠了,总耗时从"串行叠加"变成了"近似取最大值"。

举个例子,我用requests库串行请求 100 个小接口,耗时约 60 秒;改用线程池并发请求后,耗时降到 8 秒左右。这就是因为网络的等待时间被并发重叠了,CPU 本身并没有成为瓶颈。

3.2 线程池的正确打开方式

concurrent.futures.ThreadPoolExecutor是我最推荐的线程入门 API,它比手动管理Thread对象简单得多,还自带任务队列和结果获取功能:

import concurrent.futures as cf import time import requests def fetch_url(url): """模拟一个基础的 HTTP 请求""" response = requests.get(url, timeout=10) return response.status_code urls = [ "https://example.com/api/status" if i % 2 == 0 else "https://example.org/api/health" for i in range(20) ] with cf.ThreadPoolExecutor(max_workers=10) as executor: start = time.time() # map 会按顺序返回结果 status_codes = list(executor.map(fetch_url, urls)) elapsed = time.time() - start print(f"状态码示例: {status_codes[:5]}") print(f"多线程请求耗时: {elapsed:.2f} 秒")

max_workers的选择有讲究。线程数过少,并发度不够;线程数过多,上下文切换和连接开销反而拖慢速度。对于网络 I/O,我常用的经验值是目标服务器并发能力的 2 到 3 倍,或者根据测试逐步上调,找到一个平台期。

提示:不要盲目设置几百个线程。线程越多,调度开销越大,而且请求响应慢时线程会大量堆积,造成资源浪费。

3.3 共享数据的线程安全处理

线程相对进程的好处是共享内存,但也带来了竞争问题。多个线程同时修改一个变量时,结果可能不符合预期。

看这个例子:

import threading counter = 0 def increment(): global counter for _ in range(1_000_000): 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) # 结果大概率小于 4000000

原因是counter += 1其实分三步执行:读取、加法、写回。两个线程可能同时读取到相同的旧值,各自加 1 后再写回,导致加了两次等于加了一次。

解决办法是加锁:

lock = threading.Lock() def increment_safe(): global counter for _ in range(1_000_000): with lock: counter += 1

加锁之后结果就准确了。不过锁会带来性能损耗,而且一不小心容易产生死锁,所以我的建议是:尽量设计成线程之间不需要频繁共享可变数据。能用局部变量就用局部变量,能合并结果就最后统一合并。

3.4 线程的真正适用场景与坑

线程最擅长的场景,我总结为三类:网络请求并发、文件读写并行化、数据库查询的并发提交。这些都是等待型的任务,线程可以有效重叠等待时间。

线程的坑也不少。首先是死锁问题,多个锁的获取顺序不当就可能卡死,我建议尽量只用一个锁,或者按照固定顺序获取多个锁。其次是线程安全的数据结构,比如queue.Queue是线程安全的,但普通的列表不是。最后要记住,线程不能突破 GIL,计算密集型任务绝不要用线程——这是新手最容易踩的坑,也是面试官最爱问的点。

4. 协程:单线程内的异步魔法

如果说线程是靠系统调度实现并发,那协程就是程序自己掌控调度权。协程是 Python 异步编程的核心概念,基于async/await语法,配合事件循环运行。它只有一个线程、一个执行流,但在等待点主动让出控制权,去执行其他协程。

4.1 从回调到 async/await 的演进

Python 的异步生态经历过一段混乱期。早期的asyncio代码用回调函数实现异步逻辑,写起来反人类,层层嵌套让人崩溃。async/await语法出现之后,异步逻辑可以像同步代码一样顺序书写,可读性大幅提升。

用协程实现并发 HTTP 请求,代码非常简洁:

import asyncio async def fetch_page(url): """模拟一个异步网络请求""" # 这里用 asyncio.sleep 模拟 I/O 等待 await asyncio.sleep(1) return f"已获取 {url} 的内容" async def main(): urls = [f"https://example.com/page/{i}" for i in range(5)] tasks = [asyncio.create_task(fetch_page(url)) for url in urls] results = await asyncio.gather(*tasks) for r in results: print(r) if __name__ == "__main__": asyncio.run(main())

asyncio.gather是并发执行多个协程的核心工具。它会让所有协程同时进入事件循环,每个协程遇到await等待时让出执行权,事件循环再切换去跑其他协程,从而实现并发。上面这段代码 5 个请求各等 1 秒,总耗时约 1 秒,而不是 5 秒。

4.2 协程适合做什么

协程最适合的是"大量轻量的 I/O 并发"场景。比如爬虫要请求成千上万个 URL、WebSocket 服务要维护海量长连接、消息队列消费者要并发处理消息。这些场景的特点是任务数量大、单个任务执行时间短、没有太多 CPU 计算量。

一个典型的例子是异步爬虫。我用协程写过一个抓取 3000 个页面的脚本,串行需要 25 分钟,协程并发只需 1 分半钟。这种量级下线程也能做到类似效果,但协程占用的系统资源少得多——几万个协程对象的内存开销非常低,而几万个线程会把服务器资源耗尽。

4.3 协程的代码执行机制

理解协程的关键在于理解事件循环。事件循环可以看作一个协调器,它维护一个任务队列,不断检查哪些任务在等待、哪些任务可以继续执行。

遇到await时,当前协程进入等待状态,事件循环记录这个协程要等什么(通常是等一个 Future 完成)。与此同时,其他协程继续运行。当等待条件满足时,事件循环把协程重新放入可执行队列,继续之前的代码。

import asyncio async def task(name, seconds): print(f"任务 {name} 开始") await asyncio.sleep(seconds) print(f"任务 {name} 完成") return f"{name}-完成" async def main(): # 注意:这里以并发方式启动两个协程 a = asyncio.create_task(task("A", 2)) b = asyncio.create_task(task("B", 1)) await a await b asyncio.run(main())

运行结果是"任务 A 开始"、"任务 B 开始"几乎同时出现,然后 B 先打印完成,A 后打印。这说明两个协程确实是交错执行的,而不是先执行完 A 再执行 B。

4.4 协程的常见误区

协程最大的误区是"在协程里使用同步阻塞库"。如果协程里调用了requests.get()这种同步 I/O 函数,整个事件循环会被卡住,其他协程根本无法执行,并发效果归零。正确做法是使用异步库,比如aiohttp替代requests、asyncpg替代psycopg2。

另一个误区是忘记聚合任务。很多人写await coroutine来调用协程,这其实是同步等待这个协程执行完,并没有实现并发。想并发就必须用asyncio.create_task()创建任务,然后用asyncio.gather()或asyncio.wait()聚合。

5. 三种方案的选择策略与实践心得

现在我们把三种方案放在一起看,形成一个选型决策方法。这个方法我在实际项目中反复使用,每次都能快速定位合适的方案。

5.1 选型决策的简明参考

场景特征推荐方案核心理由
大量计算、数据处理多进程利用多核并行,绕开 GIL
大量网络请求、文件 I/O协程或线程重叠等待时间,提升吞吐
少量并发、代码简单线程够用,上手快
超大规模连接、轻量任务协程资源占用极小,扩展性强
混合型任务进程 + 协程进程负责计算,协程负责 I/O

这个表格只是起点,实际项目中还要结合团队熟悉程度、现有依赖库支持情况来做最终决定。比如你的 I/O 操作都是同步库,强行用协程就得重写整条链路,这时候线程反而是更务实的选择。

5.2 我在项目中的一次选型记录

我参与过一个数据采集项目,需求是定期从几十个数据源拉取文件,然后做格式转换和压缩归档。最初用线程实现了全部逻辑,发现压缩步骤 CPU 密集,线程版速度只有单核水平的 80%,整体耗时很长。

后来我把任务拆成两段:拉取文件用协程并发,压缩归档用进程池并行。改造后整体耗时从 18 分钟降到 4 分钟。关键收获是:复杂任务往往不是选一种方案,而是让不同方案各司其职。

拉取阶段(协程) → 队列 → 压缩阶段(多进程) → 归档

这个管道式结构让每个阶段都按自己的最优方式运行,瓶颈转到了磁盘写入速度上,已经和处理框架无关了。

5.3 线程与协程如何配合使用

线程和协程不是对立关系,实际项目中经常同时出现。一个典型的场景是:程序主逻辑用协程处理异步 I/O,但某一步要调用一个没有异步版本的同步库,直接放在事件循环里会卡住全部协程。

解决办法是用asyncio.to_thread()将同步函数放入线程池执行:

import asyncio import time def sync_heavy_operation(x): time.sleep(1.5) # 模拟阻塞型 I/O return x * 2 async def main(): # 在协程中调用同步阻塞函数 result = await asyncio.to_thread(sync_heavy_operation, 10) print(f"结果: {result}") asyncio.run(main())

这样既享受了协程的轻量,又解决不了同步库的阻塞问题。这个 API 是 Python 3.9 引入的,如果你的环境较旧,也可以手动用loop.run_in_executor实现同样的效果。

5.4 任务拆分的实操方法论

无论选哪个方案,第一步都是任务拆分。我常用三个问题来拆:

第一,这个任务能切分成小的独立单元吗?不能就放弃并行,老老实实优化单线程逻辑。第二,每个单元的计算量和 I/O 占比如何?这决定了选型方向。第三,单元之间需要传大量数据吗?如果数据量很大,就要考虑数据传递开销,必要时重新设计数据结构。

这三个问题想清楚,后面写代码基本就是照着选型填模板了。最大程度避免"写了半天发现方案选错"的返工。

6. 常见问题与性能排查经验

多任务的坑远不止选型,实际运行中的问题往往更隐蔽。这里记录几种我踩过的高频问题,以及对应的排查思路。

6.1 多进程为什么比单进程还慢

这不是 Python 的 bug,而是任务选型错误的典型信号。如果你的任务本身 I/O 居多,多进程的开销——进程创建、数据序列化、进程间通信——会超过并行带来的收益,整体耗时自然变长。

排查办法很简单:用cProfile跑一遍单线程版本,看耗时主要花在哪。如果大部分时间花在等待(比如time.sleep、recv、read),说明任务是 I/O 密集,应该换线程或协程。如果大部分时间花在计算(for循环、数值运算),多进程就该有明显收益。

6.2 协程代码怎么调试

协程的调试比同步代码困难,因为执行顺序被事件循环打乱了。我的经验是三步走:先用print的flush=True参数保证日志即时输出;再用asyncio.run(main(), debug=True)开启事件循环调试模式,它能提示哪些协程长时间未完成、是否有未等待的任务;最后,如果你用 IDE,直接对asyncio.create_task下断点,观察每个协程的状态变化。

调试时要特别注意一点:协程对象如果创建了但没有被await或包装成任务,解释器会弹出 RuntimeWarning,提示有协程未被执行。这种问题常常被忽略,却可能造成逻辑遗漏。用asyncio.gather聚合所有任务,是避免此类问题最稳妥的方式。

6.3 线程池中的异常为什么没有及时暴露

使用ThreadPoolExecutor.submit()时,如果任务抛出异常,调用future.result()才会拿到这个异常。很多人只submit不等result,异常就被静默吞掉了,程序看起来正常运行,但实际任务失败了。

排查思路是:提交任务后一定要遍历所有 future 并调用result(),即使你不关心返回值,也最好这样统一处理异常。代码层面可以这样:

import concurrent.futures as cf with cf.ThreadPoolExecutor(max_workers=4) as executor: futures = {executor.submit(worker, i): i for i in range(10)} for future in futures: try: future.result() except Exception as e: print(f"任务失败: {e}")

顺手总结成两个避坑规则:第一,用submit必配result,否则异常等于被吞;第二,用map时结果迭代到某个元素出错,异常会在迭代时抛出,最好接住。

6.4 内存与资源泄漏的排查

多进程每创建一次都会复制父进程内存,Windows 上使用 spawn 方式时尤其明显。如果进程池不断创建新进程而旧进程不及时回收,内存会持续增长。

排查时用top或任务管理器观察各进程内存曲线。如果内存单调上升而不回落,大概率是进程没有正确退出,或者任务对象被某个全局容器持有了引用。multiprocessing.Pool的terminate()可以用来强制结束,但正常流程更推荐使用上下文管理器(with Pool() as pool:),它会在退出时统一回收资源。

线程层面要关注线程是否真正结束。用了ThreadPoolExecutor后线程会复用,但如果每个线程内部持有大对象且不释放引用,内存池会越来越大。这种情况下可以周期性重启执行器,或者手工把大对象置为None。

6.5 我常用的性能验证思路

性能优化前先定基线,没有基线的优化都是碰运气。我在处理并发代码时,永远会做三件事:先跑单线程版本记录耗时;再跑并发版本记录耗时和资源占用;最后用不同的并发参数多跑几次,观察耗时曲线。

例如线程池就是典型的 U 形曲线:并发太少时效率低,并发太多时资源竞争加剧,耗时反弹。通过这样的实测,我才能确定当前环境下的最优并发数,而不是凭空猜一个。

提示:不要迷信网上所谓的"最优线程数公式"。不同操作系统、不同服务器配置、不同负载模型下的最优值差别很大。最靠谱的方式始终是自己压测。

7. 最后的实操建议

聊到这儿,进程、线程、协程的底层逻辑和使用场景基本上覆盖全了。有几条零零散散但很实用的经验,最后集中分享一下。

第一,写多任务代码前,先画数据流图。不用多复杂,就用箭头标出任务的输入、输出和流转顺序。这一步能帮你尽早发现哪些环节必须串行、哪些能并行,避免写到一半才返工。

第二,花点时间把concurrent.futures吃透。它是进程池和线程池的统一接口,一段代码只改ProcessPoolExecutor/ThreadPoolExecutor就能切换并发模型,非常适合做性能对比实验。配合asyncio.to_thread还能在协程里复用线程池,组合起来非常灵活。

第三,所有并发代码都要预留关闭和清理的入口。无论是进程池、线程池还是事件循环,都要在退出时显式关闭,防止资源残留。尤其在长期运行的服务里,资源泄漏的累积效应比单次崩溃更可怕。

第四,从最简单的方式开始,不要一上来就上协程。如果线程池能满足需求,就先用线程;等规模上来了、线程不够用了,再迁移到协程也不迟。选型不是追求最潮,而是找到当前阶段最可持续的方案。

我在实际开发中的体会是,多任务编程最大的难点从来不是 API 用不熟,而是对任务本质的判断不清晰。把"CPU 密集用多进程、I/O 密集用协程或线程"这条主线刻在脑子里,再辅以上面提到的排查思路,绝大多数并发问题都能迎刃而解。带着这些基础去写代码,你会发现自己逐渐从"会用"走向了"会选"——这中间的区别,正是资深开发者和新手的分水岭。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询