Python并发编程:线程、多进程、协程与线程池实战
title: “Python并发编程:线程、多进程、协程与线程池实战”
date: 2026-04-28
tags:
- Python
- 并发编程
- 多线程
- 多进程
- 协程
- 爬虫
- FastAPI
description: “一篇从概念到实战的 Python 并发编程入门博客,覆盖线程、多进程、协程、线程池、进程池、GIL、爬虫加速、生产者消费者模型、线程安全和 Web 服务加速。”
Python并发编程:线程、多进程、协程与线程池实战
很多人学 Python 时都会遇到一个问题:
代码明明没写错,为什么还是慢?
如果你的程序里有大量网络请求、磁盘读写、数据库访问,或者需要同时处理很多任务,那么瓶颈往往不在语法本身,而在于你是不是用对了并发模型。
这篇文章尽量把 Python 并发编程里最核心、最常用、也最容易混淆的部分讲清楚:
- 什么是线程、多进程、协程
- 什么是线程池、进程池
- CPU 密集任务和 IO 密集任务有什么区别
- 怎样选择多线程、多进程、多协程
- 为什么 Python 会被 GIL 限制
- 为什么多线程能把爬虫加速到接近 10 倍
- 如何用生产者消费者模型写一个更像样的爬虫
- 什么是线程安全,如何解决线程安全问题
- 为什么
ThreadPoolExecutor很好用 - 如何在 Web 服务中用线程池为阻塞任务提速
如果你刚开始接触 Python 并发,这篇文章适合作为一个完整的入门框架。
一、先搞清楚:并发不等于并行
很多资料一上来就讲 API,但真正容易搞混的是概念。
1. 并发是什么
并发指的是:
在一段时间内同时处理多个任务。
它不一定要求多个任务“同一时刻”真的一起执行,而是强调多个任务交替推进,让整体吞吐更高。
2. 并行是什么
并行指的是:
多个任务在同一时刻真的同时执行。
并行通常依赖多个 CPU 核心,或者多个进程分别占用不同的核心。
3. 一个直观类比
- 并发:一个服务员轮流给很多桌点单、上菜、结账
- 并行:多个服务员同时服务多桌
在 Python 里:
- 多线程更偏“并发”
- 多进程更容易做到“并行”
- 协程是单线程里的高效并发

二、Python并发编程简介
Python 里最常见的三种并发方案是:
| 方案 | 核心单位 | 是否共享内存 | 切换成本 | 适合场景 |
|---|---|---|---|---|
| 多线程 | 线程 | 共享 | 低 | IO 密集 |
| 多进程 | 进程 | 默认不共享 | 高 | CPU 密集 |
| 协程 | 协程任务 | 共享同一线程 | 很低 | 高并发 IO |
除此之外,工程里还经常会用到:
ThreadPoolExecutor:线程池,适合大量短小的 IO 任务ProcessPoolExecutor:进程池,适合 CPU 密集型计算任务asyncio:Python 官方异步编程框架
可以先记住一句最重要的话:
Python 并发编程不是“哪种技术更高级”,而是“哪种技术更适合当前任务”。
三、线程、多进程、协程分别是什么
1. 线程
线程是进程中的执行单元。一个进程里可以有多个线程,这些线程共享同一块内存空间,所以通信方便,但也更容易产生数据竞争。
线程的优点:
- 创建和切换成本相对较低
- 共享内存,传数据方便
- 适合网络请求、文件读写、数据库访问等 IO 密集场景
线程的缺点:
- 共享数据会带来线程安全问题
- 在 CPython 中受 GIL 限制,CPU 密集任务无法真正并行
2. 多进程
进程是操作系统分配资源的基本单位。不同进程之间默认隔离,互不影响。
多进程的优点:
- 能绕过 GIL,利用多核 CPU
- 进程彼此隔离,稳定性更高
多进程的缺点:
- 创建成本更高
- 进程间通信更复杂
- 内存占用更大

3. 协程
协程可以理解为用户态的轻量级任务。它通常运行在一个线程里,由程序员通过 async/await 主动让出执行权。
协程的优点:
- 切换开销很小
- 适合海量网络连接
- 非常适合高并发 IO 场景
协程的缺点:
- 写法和普通同步代码不同,需要适应异步思维
- 遇到阻塞代码时,整个事件循环可能被卡住
- CPU 密集任务同样不适合协程本身处理
四、CPU 密集和 IO 密集任务的区别
这是决定并发方案的第一原则。
1. CPU 密集任务
CPU 密集任务的特点是:
程序大部分时间都在做计算,CPU 几乎一直忙着干活。
常见例子:
- 图像处理
- 视频编码
- 大量数学计算
- 模型训练
- 数据压缩和解压
这类任务的瓶颈是 CPU。
2. IO 密集任务
IO 密集任务的特点是:
程序大部分时间都在等待外部资源返回结果。
常见例子:
- 爬虫发 HTTP 请求
- 读写文件
- 访问数据库
- 调第三方 API
- 网络服务聚合多个下游接口
这类任务的瓶颈通常不是 CPU,而是等待。
3. 两者的选择建议
| 任务类型 | 推荐方案 | 原因 |
|---|---|---|
| CPU 密集 | 多进程 / 进程池 | 能绕过 GIL,利用多核 |
| IO 密集 | 多线程 / 协程 / 线程池 | 等待期间可以切换去做别的任务 |
一句话总结:
- 计算多,用多进程
- 等待多,用多线程或协程
五、怎样选择多线程、多进程、多协程
很多初学者的问题不是“不会用”,而是“不会选”。
可以按下面这个思路判断。
1. 如果任务是 IO 密集
优先考虑:
- 任务数量不大,代码要简单:多线程
- 任务数量很多,且依赖支持异步:协程
- 想少写线程管理代码:线程池
2. 如果任务是 CPU 密集
优先考虑:
- 多进程
- 进程池
不要优先考虑:
- 多线程
- 协程
因为它们都不能从根本上解决 CPU 被 GIL 卡住的问题。
3. 一个实用决策表
| 场景 | 建议 |
|---|---|
| 爬虫、批量请求接口 | 多线程或协程 |
| Web 服务里调用阻塞 SDK | 线程池 |
| 大规模高并发网络服务 | 协程 |
| 图像处理、批量计算 | 多进程或进程池 |
| 代码新手、先求稳定 | 线程池 |
4. 选择顺序建议
如果你是 Python 初学者,我建议这样掌握:
- 先学多线程
- 再学锁、队列、线程池
- 再学多进程和进程池
- 最后系统学
asyncio
原因很简单:线程和线程池更贴近日常业务,协程虽然高效,但认知门槛更高。
六、Python速度慢的罪魁祸首:全局解释器锁 GIL
讲 Python 并发,绕不开 GIL。
1. GIL 是什么
GIL 全称是 Global Interpreter Lock,即全局解释器锁。
在 CPython 解释器里,同一个进程中的多个线程,同一时刻只有一个线程可以执行 Python 字节码。
这意味着:
- 多线程在 IO 密集场景下仍然有用
- 但在 CPU 密集场景下,多线程很难真正利用多核并行计算
2. 为什么会有 GIL
GIL 的历史原因很多,最核心的一点是:
它简化了 CPython 的内存管理实现。
代价就是多线程计算受限。
3. GIL 带来的直接影响
下面这句话很重要:
Python 多线程不是没用,而是“对 CPU 密集任务没那么有用”。
比如:
- 爬虫请求网页:有用
- 并发读写文件:有用
- 调很多外部 API:有用
- 计算一亿次循环:通常没用
4. 关于 GIL 最常见的误区
误区一:有 GIL,所以 Python 不能并发。
错。Python 完全可以并发,尤其擅长 IO 并发。
误区二:多线程一定比单线程快。
错。只有在任务大量等待 IO 时,多线程才通常更快。
误区三:协程能解决所有性能问题。
错。协程擅长高并发 IO,不擅长 CPU 密集计算。
七、使用多线程,Python爬虫为什么能被加速 10 倍
这是理解“IO 密集任务适合多线程”的最好例子。
假设你要爬 10 个网页,每个网页请求耗时约 1 秒:
- 单线程:一个一个爬,总耗时大约 10 秒
- 10 个线程并发爬:总耗时接近 1 秒到 2 秒
这就是为什么很多 Python 爬虫一上多线程,速度立刻明显提升。
本质上不是 Python 变快了,而是等待网络返回的时间被重叠了。
1. 单线程和多线程爬虫示例
import threading
import time
import requests
urls = [f"https://www.cnblogs.com/#p{i}" for i in range(1, 11)]
def craw(url):
response = requests.get(url, timeout=10)
print(url, len(response.text))
return response.text
def single_thread():
for url in urls:
craw(url)
def multi_thread():
threads = []
for url in urls:
t = threading.Thread(target=craw, args=(url,))
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
if __name__ == "__main__":
start = time.time()
single_thread()
print("single:", time.time() - start)
start = time.time()
multi_thread()
print("multi:", time.time() - start)
2. 为什么这个例子能加速这么明显
因为 requests.get() 的大部分时间并不是在做计算,而是在等待:
- 等待 DNS 解析
- 等待建立 TCP 连接
- 等待服务器响应
- 等待数据传输回来
等待期间,CPU 并没有真正忙起来。
如果只有一个线程,这段等待时间就被白白浪费了。
如果有多个线程,一个线程在等网络,另一个线程就可以继续发请求。
3. 什么时候加速达不到 10 倍
如果出现下面这些情况,加速会打折:
- 网站本身响应很快,请求延迟不明显
- 线程数过大,带来调度和上下文切换开销
- 目标网站限流
- 本地带宽不足
- 解析逻辑本身很耗 CPU
所以“10 倍”不是铁律,但在高等待比的 IO 场景中,多线程的收益通常非常明显。
八、Python实现生产者消费者爬虫
当爬虫稍微复杂一点,你就会发现“所有线程都做同一件事”不够优雅。
更常见的做法是把流水线拆成两段:
- 生产者:负责抓取网页
- 消费者:负责解析网页、提取数据、写入文件或数据库
这就是经典的生产者消费者模型。
1. 为什么这个模型好用
它的优点很明显:
- 解耦抓取和解析
- 可以分别调整抓取线程数和解析线程数
- 使用队列后,线程之间通信更安全
- 容易扩展成更复杂的数据处理流水线
2. 一个更完整的实现
import queue
import threading
import requests
from bs4 import BeautifulSoup
urls = [f"https://www.cnblogs.com/#p{i}" for i in range(1, 11)]
url_queue = queue.Queue()
html_queue = queue.Queue()
write_lock = threading.Lock()
def craw(url):
response = requests.get(url, timeout=10)
return response.text
def parse(html):
soup = BeautifulSoup(html, "html.parser")
links = soup.find_all("a", class_="post-item-title")
return [(link["href"], link.get_text(strip=True)) for link in links]
def producer():
while True:
url = url_queue.get()
if url is None:
url_queue.task_done()
break
html = craw(url)
html_queue.put(html)
print(threading.current_thread().name, "crawled", url)
url_queue.task_done()
def consumer(output):
while True:
html = html_queue.get()
if html is None:
html_queue.task_done()
break
items = parse(html)
with write_lock:
for item in items:
output.write(f"{item}\n")
print(threading.current_thread().name, "parsed one page")
html_queue.task_done()
def main():
for url in urls:
url_queue.put(url)
producers = [
threading.Thread(target=producer, name=f"producer-{i}")
for i in range(3)
]
for t in producers:
t.start()
with open("spider_result.txt", "w", encoding="utf-8") as output:
consumers = [
threading.Thread(target=consumer, args=(output,), name=f"consumer-{i}")
for i in range(2)
]
for t in consumers:
t.start()
url_queue.join()
for _ in producers:
url_queue.put(None)
for t in producers:
t.join()
html_queue.join()
for _ in consumers:
html_queue.put(None)
for t in consumers:
t.join()
if __name__ == "__main__":
main()
3. 这里最值得学的不是爬虫,而是两个点
第一,queue.Queue 是线程安全的。
这意味着多个线程可以安全地从队列里取数据、放数据。
第二,职责拆分很重要。
抓取慢,不一定要改解析线程;解析慢,也不一定要改抓取线程。
这比把所有逻辑塞进一个线程函数里更可维护。
九、Python线程安全问题以及解决方案
只要多个线程共享同一份数据,线程安全问题就可能出现。
1. 什么是线程安全问题
多个线程并发读写共享变量时,如果执行顺序不确定,就可能得到错误结果。
比如两个线程同时取钱:
import threading
import time
lock = threading.Lock()
class Account:
def __init__(self, balance):
self.balance = balance
def draw(account, amount):
with lock:
if account.balance >= amount:
time.sleep(0.01)
account.balance -= amount
print(threading.current_thread().name, "取钱成功,余额", account.balance)
else:
print(threading.current_thread().name, "取钱失败,余额不足")
if __name__ == "__main__":
account = Account(1000)
t1 = threading.Thread(target=draw, args=(account, 800), name="t1")
t2 = threading.Thread(target=draw, args=(account, 800), name="t2")
t1.start()
t2.start()
t1.join()
t2.join()
如果不加锁,两个线程可能都判断“余额够”,最终把账户扣成负数或者逻辑错乱。
2. 常见解决方案
方案一:加锁
最常见的是:
threading.Lockthreading.RLock
适用场景:
- 多线程修改共享变量
- 临界区逻辑必须串行执行
缺点:
- 容易降低并发度
- 用不好会死锁
方案二:尽量不共享数据
这是最稳妥的思路。
比如:
- 线程只处理自己的局部变量
- 用消息传递代替共享对象
- 用队列传数据,而不是共享列表随便改
方案三:用线程安全容器
例如:
queue.Queue
它天然适合生产者消费者模型。
方案四:把 CPU 密集任务改成多进程
如果共享状态本来就很多,还要频繁加锁,程序会越来越难维护。
这时候可以考虑改成多进程,让进程天然隔离。
3. 一个原则
能不用共享状态,就别共享。
必须共享时,再加锁。
需要传递任务时,优先用队列。
十、Python好用的线程池:ThreadPoolExecutor
如果你已经写过原始 threading.Thread,大概率会有这种感觉:
- 线程对象要自己创建
- 线程数要自己管
- 结果收集要自己处理
- 异常处理也麻烦
这时候线程池就很有价值。
concurrent.futures.ThreadPoolExecutor 是 Python 标准库里非常实用的线程池工具。
1. 为什么线程池更好用
它帮你解决了很多底层管理问题:
- 复用线程,避免频繁创建销毁
- 控制最大并发数
- 统一提交任务
- 可以方便地拿到返回值
- 更容易处理异常
2. 基本用法
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
urls = [f"https://www.cnblogs.com/#p{i}" for i in range(1, 11)]
def craw(url):
response = requests.get(url, timeout=10)
return url, len(response.text)
with ThreadPoolExecutor(max_workers=5) as pool:
futures = [pool.submit(craw, url) for url in urls]
for future in as_completed(futures):
url, size = future.result()
print(url, size)
3. map 和 submit 怎么选
map 适合:
- 一批参数结构相似的任务
- 按提交顺序取结果
submit 适合:
- 任务更灵活
- 需要逐个拿
future - 需要配合
as_completed谁先完成先处理谁
4. 线程池最适合的场景
- 爬虫
- 批量下载
- 调多个第三方 API
- 批量文件读写
- Web 服务中调用阻塞型库
十一、进程池也很重要:什么时候用 ProcessPoolExecutor
既然有线程池,就也要知道进程池。
对于 CPU 密集任务,推荐使用:
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
total = 0
for i in range(n):
total += i * i
return total
with ProcessPoolExecutor(max_workers=4) as pool:
results = list(pool.map(cpu_heavy, [10**7, 10**7, 10**7, 10**7]))
print(results)
如果把上面这个计算任务换成线程池,通常不会有理想的提升,因为它受 GIL 影响明显。
所以:
- IO 密集:线程池
- CPU 密集:进程池
这条规则非常实用。
十二、Python使用线程池在Web服务中实现加速
这是业务里非常常见的场景。
假设你在写一个 FastAPI 服务,接口里需要调用一个阻塞式 SDK,或者使用 requests 去请求第三方服务。
如果你在异步接口里直接执行阻塞代码,会发生什么?
- 当前请求线程被卡住
- 事件循环被拖慢
- 整个服务吞吐下降
这时候常见做法是:
把阻塞任务丢到线程池里执行。
1. 一个典型例子
import asyncio
from concurrent.futures import ThreadPoolExecutor
import requests
from fastapi import FastAPI
app = FastAPI()
pool = ThreadPoolExecutor(max_workers=20)
def fetch_user_profile(user_id: int):
url = f"https://api.example.com/users/{user_id}"
response = requests.get(url, timeout=10)
return response.json()
@app.get("/users/{user_id}")
async def get_user(user_id: int):
loop = asyncio.get_running_loop()
data = await loop.run_in_executor(pool, fetch_user_profile, user_id)
return {"data": data}
2. 这段代码解决了什么问题
接口本身仍然是异步的,但阻塞的网络请求被放进线程池执行了。
这样:
- 主事件循环不会被长时间卡住
- 同时处理多个请求的能力更强
- 旧的阻塞库不需要立刻重写成异步版本
3. 什么时候线程池特别适合 Web 服务
- 接第三方平台,只提供同步 SDK
- 调用老系统 HTTP 接口,只能用阻塞式客户端
- 需要兼容一批已有同步代码
4. 什么时候不要滥用线程池
如果你的依赖本身已经支持异步,比如:
httpx.AsyncClient- 异步数据库驱动
那通常优先直接使用纯异步方案,而不是“异步外壳 + 内部线程池”。
因为线程池本质上是一种兼容和过渡手段,不是所有场景下的第一选择。
十三、多线程、多进程、多协程的最终选择建议
如果你只想记住一个结论,可以记这个版本:
1. 先看任务类型
- 主要在等网络、磁盘、数据库:IO 密集
- 主要在做大量计算:CPU 密集
2. 再做技术选择
- IO 密集且想快速上手:多线程
- IO 密集且追求高并发:协程
- CPU 密集:多进程
- 不想手动管理线程/进程:线程池或进程池
3. 工程上的推荐顺序
- 小项目、脚本、爬虫:先线程池
- 高并发网络服务:优先协程
- 数据处理、批量计算:优先进程池
- 混合场景:异步主流程 + 线程池承接阻塞任务
十四、常见误区汇总
1. 多线程不是万能加速器
只有在 IO 等待明显时,多线程收益才通常很高。
2. 协程不是“更高级的线程”
协程不是抢占式调度,而是合作式调度。
你必须理解 async/await,并保证内部不要乱写阻塞代码。
3. 线程越多不一定越快
线程太多会带来:
- 上下文切换开销
- 内存占用增加
- 目标服务限流压力
4. 加锁不是最优雅的解法
很多时候,减少共享状态比拼命加锁更重要。
十五、总结
Python 并发编程最核心的不是 API,而是判断任务性质。
你可以把全文压缩成下面几句话:
- 并发的目标是提高吞吐,不一定等于并行
- IO 密集任务更适合多线程、协程、线程池
- CPU 密集任务更适合多进程、进程池
- GIL 限制了多线程对 CPU 计算的加速能力
- 爬虫这类高等待任务,用多线程通常会明显提速
- 生产者消费者模型适合构建可扩展的抓取流水线
- 线程安全问题的核心是共享状态,能少共享就少共享
ThreadPoolExecutor是 Python 里非常实用的并发工具- 在 Web 服务里,线程池适合承接阻塞型任务
如果你准备继续深入,建议下一步按这个顺序练习:
- 手写一个多线程爬虫
- 用
queue.Queue改造成生产者消费者模型 - 用
ThreadPoolExecutor重构线程管理 - 对比
ThreadPoolExecutor和ProcessPoolExecutor - 再系统学习
asyncio和FastAPI的异步生态
到这一步,你对 Python 并发编程的理解就已经超过“只会背概念”的阶段了。
更多推荐


所有评论(0)