环境说明:本文代码基于 Python 3.11+ 编写(这也是目前生产环境用得最多的版本区间),涉及 3.13/3.14 的新变化会单独标注。
一、先别急着写代码,这几组概念必须掰扯清楚
很多人学了半年 Python,"同步/异步"、"阻塞/非阻塞"、"并发/并行"还是一锅粥。这几组词不搞清楚,后面全是白学。我用烧水的例子一次讲明白。
1.1 同步 vs 异步:事情的结果谁来通知你
你在烧水:
- 同步:你站在水壶旁边盯着,水不开你不走。水开了你自己知道。
- 异步:你定了个闹钟(回调),然后去看电视了。水开了闹钟响,你再回来处理。
关键点在于:发起一件事之后,你是原地等结果,还是先去干别的、等结果来找你。
放到代码里:
python
# 同步:这一行不执行完,下面的代码永远轮不到
data = requests.get("https://example.com") # 网络慢,整个程序就在这干等
print("请求完了")
# 异步:发起请求后,程序可以去干别的,结果好了再回来
async with session.get("https://example.com") as resp:
data = await resp.text() # await 期间,事件循环可以调度其他任务
1.2 阻塞 vs 非阻塞:你这个人能不能动
还是烧水:
- 阻塞:你就站在那,啥也干不了(人被"挂起"了)。
- 非阻塞:你还能去切菜、回消息,随时回来看一眼水开没。
所以这两个维度是可以组合的:
| 组合 | 状态 | 例子 |
|---|---|---|
| 同步阻塞 | 站着等水开 | requests.get() |
| 同步非阻塞 | 边切菜边每隔几秒看一眼水壶 | 轮询 + setblocking(False) |
| 异步阻塞 | 理论上存在,实际很少用 | --- |
| 异步非阻塞 | 定好闹钟去看电视,水开自动叫你 | asyncio + await |
日常开发里,你只需要记住"同步"和"异步"两个词就够了,阻塞/非阻塞是帮你理解底层的。
1.3 并发 vs 并行:同时"做"和同时"在处理"
- 并发(Concurrent):一个 CPU 核心,在多个任务之间快速切换,看起来像是同时在跑。一个厨师同时炒三个菜,颠两下这个锅、翻两下那个锅。
- 并行(Parallel):多个 CPU 核心,每个核心跑一个任务,真·同时。三个厨师各炒一个菜。
并发是逻辑上的同时,并行是物理上的同时。 这个区别直接决定了你后面该用线程还是进程。
二、进程、线程、协程:一个工厂讲明白
2.1 用工厂来理解
把计算机想象成一个工业园区:
- 进程 = 一间独立的厂房。有自己的地盘(内存空间)、自己的电表水表(系统资源),两间厂房互不相干,一间着火了另一间没事。开一间新厂房成本很高(创建进程开销大)。
- 线程 = 厂房里的工人。同一间厂房的工人共享厂房里的所有东西(共享内存),配合默契,但人多了容易打架(抢同一个扳手 = 竞态条件)。多招个工人成本低,但一间厂房里塞太多人也会乱。
- 协程 = 一个工人自己管理自己的工作节奏 。他手头一件事在等结果(比如等快递送零件),他不傻等,先去做另一件事,零件到了再回来。注意:协程不是一个真实存在的"执行单元",它就是一个函数,只是这个函数可以中途挂起、稍后再继续,而且挂起/恢复的切换完全由代码自己控制,操作系统都不知道这回事。
2.2 核心对比表
| 维度 | 进程 | 线程 | 协程 |
|---|---|---|---|
| 创建/切换开销 | 大(MB 级内存,系统调用) | 中(KB~MB 级,系统调度) | 极小(一个函数,用户态切换) |
| 内存 | 独立空间 | 共享所在进程的空间 | 共享所在线程的空间 |
| 数据共享 | 麻烦(要 IPC) | 直接(但要注意线程安全) | 直接(单线程内天然安全) |
| 能用满多核 CPU 吗 | 能 | 不能(有 GIL,见下文) | 不能(单线程内切换) |
| 崩溃影响 | 一个挂了不影响别人 | 一个挂了整个进程完蛋 | 单个协程异常不会直接搞死事件循环,但处理不当会连累同循环的其他任务 |
| 适合场景 | CPU 密集型 | IO 密集型(中等并发) | IO 密集型(高并发) |
| 典型数量 | 几个到几十个 | 几十到几百个 | 几千到上万个 |
2.3 一张图记住它们的关系
操作系统
└── 进程 A(独立内存) 进程 B(独立内存)
├── 线程 1 └── 线程 1
│ ├── 协程 a ├── 协程 a
│ └── 协程 b └── 协程 b
└── 线程 2
└── 协程 a
协程跑在线程里,线程跑在进程里,进程跑在操作系统上。
2.4 三者的生命周期
并发编程一半的 bug 来自"没搞懂它什么时候生、什么时候死"。逐个过一遍:
进程 :创建(fork/spawn,开销大)→ 运行 → 结束(函数 return、抛异常、或被 kill)→ 回收。注意最后一步 :子进程结束后,内核会留着它的"尸体"等父进程来收(join/wait),父进程不收,它就变成僵尸进程 占着进程号。用 Pool 的 with 或记得 join() 就是干这个的。
线程 :Thread(target=f) 只是创建了个对象,start() 才真正启动 → 在就绪/运行/阻塞三个状态之间被操作系统来回调度 → 目标函数 return 或抛异常,线程结束 → 主线程 join() 回收。线程没办法从外部"杀死",想让它停只能靠标志位让它自己退出,这也是设计线程时要留好"退出开关"的原因。
协程 :这是新手最容易懵的一个------调用 async def 函数并不会执行它,只是创建了一个协程对象:
python
async def work():
print("干活")
c = work() # 什么都没打印!只是创建了个协程对象
协程必须交给事件循环才会跑:await c、asyncio.create_task(c)、或 asyncio.gather(c)。跑起来之后,遇到 await 挂起、结果就绪后恢复,直到函数 return 结束。创建协程对象却从不 await 它,Python 会甩你一个 RuntimeWarning: coroutine was never awaited。
一句话对比:进程和线程的调度归操作系统管,你控制不了切换时机;协程的调度归你自己的代码管,await 点就是唯一的切换点。
三、GIL:Python 并发绕不开的那把大锁
3.1 GIL 是什么
GIL(Global Interpreter Lock,全局解释器锁)是 CPython 解释器(就是我们平时用的 Python)里的一把全局锁。同一时刻,一个进程里只允许一个线程执行 Python 字节码。
注意三个关键点:
- 它是 CPython 的实现细节,不是 Python 语言的特性。Jython、IronPython 没有 GIL,但我们基本只关心 CPython。
- 它的存在是为了让内存管理(引用计数)实现起来简单且线程安全。
- 后果就是:Python 的多线程无法利用多核 CPU 做计算。你开 8 个线程在 8 核机器上算数学题,效果和 1 个线程差不多,甚至更慢(多了切换开销)。
3.2 那为什么多线程还有用?
因为线程在等待 IO(网络、磁盘、数据库)的时候,会主动释放 GIL,让别的线程干活。
所以记住这句结论,面试和干活都够用:
IO 密集型任务用多线程有效,CPU 密集型任务用多线程无效(甚至负优化)。
- IO 密集型:爬虫、文件读写、调接口、查数据库。程序大部分时间都在"等",等的时候 GIL 释放了,其他线程能干活。
- CPU 密集型:图像处理、加密解密、大规模数值计算。程序一直在"算",GIL 一直攥在某一个线程手里。
3.3 2026 年了,GIL 的现状要更新一下
这个知识点很多老教程都过时了,务必更新认知:
- Python 3.13 (2024 年 10 月):引入了实验性的 free-threading 构建(编译时加
--disable-gil),可以关掉 GIL。 - Python 3.14 (2025 年 10 月):free-threading 摘掉"实验"标签,成为官方支持的可选构建(依据 PEP 779)。单线程性能损耗从早期的约 40% 降到 5%~10% 区间。
也就是说,"Python 多线程是假并行"这句话已经开始松动了 。但要注意:截至现在,free-threading 仍不是默认构建,需要安装专门版本(版本号带 t,比如 3.14t),而且不是所有第三方 C 扩展都适配了。生产环境主流依然是带 GIL 的常规构建,所以本文后面的所有内容仍以常规 GIL 版本为准------这套方法论在未来几年内依然是主流答案。
3.4 关键误区:GIL 保护的是解释器,不是你的变量
很多人有个错觉:"既然有 GIL 全局锁,那我的代码天然线程安全,不用加锁了吧?"------大错特错。
GIL 保护的只是解释器自己的内部状态 (比如对象的引用计数),保证 CPython 自己不会崩。它对你的业务变量一概不管。
为什么?因为 GIL 的粒度是字节码级别:线程执行若干条字节码(或满一个时间片)就可能被切换。你的 count += 1 编译出来是"读取 → 加 1 → 写回"好几条字节码,执行到一半线程被切走,另一个线程插进来读了个旧值------4.2 节的竞态就是这么来的。
所以记住:GIL 在,锁照样要加。GIL 管解释器不死,Lock 管你的数据不错。 两者保护的完全不是一回事。
3.5 怎么判断我的任务是 IO 密集还是 CPU 密集?
这是选型第一步,方法比想象简单:
方法一:跑起来看 CPU 占用。 程序跑的时候 CPU 占用率很低、大部分时间在干等(等网络、等磁盘、等数据库返回)→ IO 密集;CPU 直接拉满 100% → CPU 密集。
方法二:看代码在干嘛。 时间主要花在 requests.get、数据库查询、文件读写、sleep 上 → IO 密集;花在循环计算、加解密、图像处理、正则大文本上 → CPU 密集。
顺便解答一个底层疑问:Python 凭什么知道 requests 在等 IO、从而释放 GIL?
答案是:不是 Python"知道",是 C 层代码主动放的 。requests 底层走 urllib3 → socket,socket 的收发数据是 C 实现的系统调用,C 代码在进入阻塞式系统调用之前,会主动调用释放 GIL 的机制(Py_BEGIN_ALLOW_THREADS),调用返回后再拿回来。同理,time.sleep、文件读写、主流数据库驱动、numpy 的大块矩阵运算,底层都是 C,都会主动释放 GIL。
反过来,纯 Python 写的计算循环,全程没有 C 层调用,哪怕是用到了计算的函数(底层再去执行C代码),底层也是不会释放GIL的。GIL 从始到终攥在一个线程手里------这就是 CPU 密集任务多线程提速无效的根本原因。所以"区分 IO 型还是计算型"不靠 Python 帮你分,靠的是看你的时间花在"等"还是"算"。
四、多线程 threading:从会写到写好
4.1 最基础的用法
python
import threading
import time
def download(name, seconds):
print(f"{name} 开始下载")
time.sleep(seconds) # 模拟网络IO
print(f"{name} 下载完成")
t1 = threading.Thread(target=download, args=("文件A", 2))
t2 = threading.Thread(target=download, args=("文件B", 3))
t1.start()
t2.start()
t1.join() # 等t1干完
t2.join() # 等t2干完
print("全部下载完成")
总耗时约 3 秒(取最长的那个),而不是 2+3=5 秒------这就是并发的效果。
几个常用参数和方法:
daemon=True:守护线程,主线程结束时它跟着被杀掉。适合跑"有最好没有也行"的后台任务。踩坑点:守护线程被杀时不会执行 finally 里的清理逻辑,写文件、关连接这类活别交给它。join(timeout=5):最多等 5 秒,超时不等了。threading.current_thread().name:排查日志时区分是哪个线程在说话。
4.2 踩坑第一课:共享变量竞态
这是多线程最经典的坑,必踩一次才长记性:
python
import threading
count = 0
def add():
global count
for _ in range(100_000):
count += 1
threads = [threading.Thread(target=add) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(count) # 你以为输出 500000?大概率不是,每次运行结果都不一样
为什么?count += 1 看着是一行,实际是分三步走的:读 count → 加 1 → 写回 count。线程 A 刚读完还没来得及写,线程 B 也读了同一个旧值,两个人的"加 1"就互相覆盖了。
解决:加锁。
python
import threading
count = 0
lock = threading.Lock()
def add():
global count
for _ in range(100_000):
with lock: # 推荐用with,自动释放,不怕忘
count += 1
threads = [threading.Thread(target=add) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(count) # 这次稳定输出 500000
4.3 threading 里的同步工具箱
除了 Lock,还有几样常用的,知道什么场景用哪个就行:
RLock(可重入锁):同一个线程可以重复 acquire 多次。Lock 不行------同一线程连续拿两次 Lock 直接死锁。函数嵌套调用、每层都要加锁时用 RLock。
Semaphore(信号量):不是"一个人进",而是"最多 N 个人进"。控制并发数用:
python
import threading
sema = threading.Semaphore(3) # 最多3个线程同时执行受保护代码
def work(i):
with sema:
print(f"任务{i} 进入")
# 干活...
threads = [threading.Thread(target=work, args=(i,)) for i in range(10)]
for t in threads:
t.start()
Event(事件):一个线程发信号,其他线程等信号。比如"等配置加载完再开始干活":
python
import threading
ready = threading.Event()
def worker():
ready.wait() # 阻塞,直到事件被set
print("收到信号,开工")
def preparer():
# 做准备工作...
ready.set() # 发信号
Condition(条件变量) :Event 的升级版,可以按条件等待,经典用途是生产者-消费者。不过说实话,生产者-消费者直接用 queue.Queue 更香:
python
from queue import Queue
import threading
q = Queue(maxsize=100)
def producer():
for i in range(10):
q.put(i) # 队列满了自动阻塞,天然限流
def consumer():
while True:
item = q.get() # 队列空了自动阻塞
print(f"消费了 {item}")
q.task_done()
Queue 内部已经处理好锁了,是线程安全 的。记住一个原则:线程之间传数据,优先用 Queue,而不是共享变量 + 手动加锁,出 bug 的概率直线下降。
4.4 生产环境写法:别手动 new Thread,用线程池
手动管理线程的 start/join 只适合学习。真实项目里用 concurrent.futures.ThreadPoolExecutor:
python
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
def fetch(url):
resp = requests.get(url, timeout=10)
return url, resp.status_code, len(resp.text)
urls = [f"https://example.com/page/{i}" for i in range(50)]
with ThreadPoolExecutor(max_workers=10) as pool:
# submit 方式:拿结果更灵活
future_to_url = {pool.submit(fetch, url): url for url in urls}
for future in as_completed(future_to_url): # 谁先完成先处理谁
try:
url, status, length = future.result()
print(f"{url}: {status}, {length} 字节")
except Exception as e:
print(f"{future_to_url[url]} 出错了: {e}")
线程池踩坑点:
max_workers开多少?IO 密集型可以开大一些(几十甚至上百,看下游扛不扛得住),不是越多越好------线程切换有成本,请求太猛还会把对方服务打挂。Python 3.8+ 默认值是min(32, cpu核心数 + 4)。future.result()会重新抛出子线程里的异常。不调用 result(),线程里的异常就被吞了 ,排查问题时怀疑人生。所以一定要遍历结果,或者用pool.map(它会保证异常在取结果时抛出)。- 线程池里的任务如果用了同一批资源(比如同一个 requests.Session、同一个数据库连接),要确认那个对象是不是线程安全的。
requests.Session官方说法是"不确定,最好别共享",稳妥做法是threading.local()给每个线程各存一份。
4.5 主线程、子线程、守护线程
一个 Python 进程启动时,自带一个主线程 (main thread)------你的代码从第一行开始就是在主线程里跑的。你用 threading.Thread 创建的都叫子线程。
子线程分两种:
- 非守护线程(默认,
daemon=False) :主线程的代码跑完后,进程不会马上退出,而是等所有非守护子线程干完才真正结束。这经常坑到人:明明 print 都打完了,程序就是不退出------因为还有个非守护线程活着。 - 守护线程(
daemon=True) :主线程结束、且没有非守护线程活着时,守护线程直接被掐死,不管它干到一半没有。
python
import threading
import time
def background():
while True:
print("后台心跳")
time.sleep(1)
t = threading.Thread(target=background, daemon=True) # 守护线程
t.start()
time.sleep(3)
print("主线程结束")
# 程序到这里直接退出,守护线程被强杀,不会无限打印下去
# 如果把 daemon=True 去掉,这个程序永远不会退出
再强调一遍 4.1 说过的坑:守护线程被强杀时不会执行 finally 和清理逻辑,所以它只配干"随时死掉也无所谓"的活(心跳、日志上报),写文件、关数据库连接这种事交给它,迟早丢数据。
4.6 线程/进程在哪创建?------入口,永远是入口
这是个工程问题,很多教程不讲,线上经常炸。
Python 模块被 import 时,模块顶层的代码会被执行一次 (之后有 sys.modules 缓存,同一进程内不会重复执行)。所以:
python
# bad_module.py ------ 反面教材
import threading
def work():
...
t = threading.Thread(target=work) # 写在模块顶层
t.start() # 谁 import 这个模块,谁就莫名多了一个线程在跑!
把线程创建写在模块顶层,意味着只要 import 这个模块,线程就被创建并启动了 ------好在同一进程内有 sys.modules 缓存,重复 import 不会重复执行,但测试进程 import 一次起一个、服务进程起一个、Celery worker 进程又起一个,每个进程都莫名多出线程。更根本的问题是:import 一个模块不该产生副作用,线程在什么时候、以什么参数启动,完全脱离了调用方的控制。
对进程来说后果更严重:spawn/forkserver 方式下子进程会重新 import 主模块 (5.1 节讲 if __name__ == "__main__": 时说过),顶层写 Process().start() 就是无限递归炸进程的配方。
正确姿势:创建逻辑写进函数,调用只放在程序入口:
python
# good_module.py
import threading
def start_background_worker():
t = threading.Thread(target=work, daemon=True)
t.start()
return t
# main.py ------ 程序入口
if __name__ == "__main__":
start_background_worker()
# ... 主逻辑
Web 项目里"入口"就是应用启动钩子(FastAPI 的 lifespan、Flask 的应用工厂、Celery 的 worker 启动信号),总之:资源创建收拢在启动阶段,业务模块只提供函数,不产生副作用。
4.7 生产环境:线程池怎么建、建在哪?
三个要点:
- 建一次,全局复用。 线程池是"池",意义就在于复用。最忌讳的写法是在请求处理函数里
with ThreadPoolExecutor()临时建池------来一个请求建一次池,建池开销全吃了,还完全失去并发上限控制,流量一大线程数爆炸。 - 建在应用启动时。 和 4.6 一个原则:程序入口(或启动钩子)里创建一个全局池,业务代码从池里 submit:
python
# pool.py ------ 全局唯一的池
from concurrent.futures import ThreadPoolExecutor
io_pool = ThreadPoolExecutor(max_workers=20, thread_name_prefix="io-")
# 业务代码里直接用
from pool import io_pool
future = io_pool.submit(fetch, url)
- 想好退出。 程序关闭时
pool.shutdown(wait=True)等任务收尾(用with或框架的关闭钩子做这件事)。thread_name_prefix建议加上,排查日志时能一眼看出是哪个池的线程。
有读者会问:4.6 刚说模块顶层别搞副作用,这里怎么又把池建在模块顶层?区别在于:
ThreadPoolExecutor的构造函数本身不创建线程 ,第一次submit时才懒创建。所以模块级定义池是安全的,import 它不会有任何线程副作用------这和直接在顶层t.start()是两回事。但注意进程池不适用这条经验(见 5.3)。
五、多进程 multiprocessing:真正的多核并行
线程被 GIL 卡着脖子,CPU 密集型任务想吃满多核,上进程。
5.1 进程池一把梭
python
from multiprocessing import Pool
import os
def heavy_calc(n):
# 模拟CPU密集计算
total = 0
for i in range(10_000_000):
total += i * n
return total
if __name__ == "__main__":
with Pool(processes=os.cpu_count()) as pool:
results = pool.map(heavy_calc, range(8))
print(results)
if __name__ == "__main__": 必须写 ,这是个血泪坑:子进程用 spawn 方式启动时(Windows、macOS 一直如此;Linux 在 Python 3.14 之前默认是 fork,3.14 起默认改成了 forkserver,行为上和 spawn 一样),会重新 import 你的主模块。不写这句保护,子进程会再次执行创建子进程的代码,无限递归,程序直接炸掉。换句话说,任何平台、任何新版本 Python,这句保护都不能省。
也可以用和线程池几乎一模一样的 ProcessPoolExecutor:
python
from concurrent.futures import ProcessPoolExecutor
if __name__ == "__main__":
with ProcessPoolExecutor(max_workers=8) as pool:
results = list(pool.map(heavy_calc, range(8)))
ThreadPoolExecutor 和 ProcessPoolExecutor 接口完全一致,这也是官方的设计意图:业务代码不变,换个 Pool 类名就能在"线程并发"和"进程并行"之间切换,方便你做性能对比。
5.2 进程间通信(IPC)
进程之间内存不共享,传数据得走管道。数据要能被 pickle 序列化(这是硬性要求,传不了的类型会直接报错):
python
from multiprocessing import Process, Queue
def worker(q):
q.put({"result": 42}) # 往队列放结果
if __name__ == "__main__":
q = Queue()
p = Process(target=worker, args=(q,))
p.start()
print(q.get()) # {'result': 42}
p.join()
常用 IPC 方式:
| 方式 | 特点 | 适合 |
|---|---|---|
multiprocessing.Queue |
进程安全队列,最常用 | 任务分发、结果收集 |
Pipe |
双向管道,点对点 | 两个进程之间通信 |
Manager |
共享 dict/list 等,带锁 | 少量共享状态 |
Value / Array |
共享内存,快 | 简单数值 |
shared_memory(3.8+) |
零拷贝共享内存 | 大数据(配 numpy 神器) |
踩坑点:
- 传参成本 。传给子进程的每个参数都要 pickle 一遍,返回结果也要 pickle 回来。如果你给每个子任务传一个 2GB 的 DataFrame,光序列化的时间就够你喝一壶了。大数据要么用
shared_memory,要么传文件路径让子进程自己读。 - 子进程挂了你不知道 。
pool.map里某个任务抛异常,异常会打包传回主进程,在取结果时才抛出。上线前记得想好任务失败的重试和兜底策略。 - 注意僵尸进程:创建了
Process就要负责join(),用Pool的上下文管理器(with)会自动处理。
5.3 生产环境:进程池怎么建、建在哪?
原则和线程池一致(见 4.6/4.7),但进程有额外的讲究:
-
必须在
if __name__ == "__main__":保护的入口里建池。 5.1 已经说过原因(子进程会重新 import 主模块),这里再补一刀:不止Process(),Pool()也一样会触发子进程创建,所以Pool对象不能当模块级全局变量随手建,必须放在入口保护里,或放在只在入口调用的初始化函数里。 -
大小 = CPU 核数(1+任务阻塞系数)别拍脑袋。 *
os.cpu_count()拿核数。Web 服务器上跑常驻计算服务时,还要给其他进程留核。 -
常驻服务别自己裸管进程。 如果你的需求是"7×24 小时跑着的任务处理服务",手写 Process 管理(崩溃重启、任务分发、结果回收)很折磨人,直接用成熟方案:Celery(任务队列)或 supervisord + 多进程。进程池适合自己管生命周期的一次性批量计算(脚本、数据处理任务),常驻调度交给专门工具。
六、协程与 asyncio:高并发 IO 的正确姿势
线程池开到几百个线程,内存和切换开销就开始感人了。要扛上万并发连接(爬虫、IM、网关、微服务调用),靠线程不现实,这就是协程的主场。
6.1 先理解一件事:协程是"协作式"的
线程的切换是操作系统强制的(抢占式),协程的切换是代码自己让出来的 (协作式)。await 就是那个"让出"的动作------"我这里要等 IO,先让别人跑"。
这带来一个铁律,也是协程最大的坑:
协程里一旦出现同步阻塞调用(time.sleep、requests.get、普通文件读写),整个事件循环就卡死了,所有协程一起陪葬。
python
import asyncio
async def bad():
time.sleep(1) # ❌ 整个事件循环卡住1秒
await asyncio.sleep(1) # ✅ 正确,等待期间别人能跑
6.2 最小可用示例
python
import asyncio
async def fetch(name, seconds):
print(f"{name} 开始")
await asyncio.sleep(seconds) # 模拟IO等待
print(f"{name} 完成")
return f"{name}的数据"
async def main():
# Python 3.11+ 推荐写法:TaskGroup,自动等待所有任务,异常处理更干净
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("任务A", 2))
t2 = tg.create_task(fetch("任务B", 3))
print(t1.result(), t2.result())
asyncio.run(main()) # 程序入口,启动事件循环
3.11 之前的版本用 asyncio.gather:
python
async def main():
results = await asyncio.gather(
fetch("任务A", 2),
fetch("任务B", 3),
)
print(results)
总耗时 3 秒而不是 5 秒------单线程内就实现了并发,这就是协程的精髓。
6.3 实战:aiohttp 并发爬虫
注意:asyncio 生态要用配套库。HTTP 请求用 aiohttp(不是 requests),数据库用 asyncpg/aiomysql,文件用 aiofiles。
python
import asyncio
import aiohttp
async def fetch_one(session, url, sema):
async with sema: # 限制并发数,别把对方网站打挂
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as resp:
text = await resp.text()
return url, resp.status, len(text)
except Exception as e:
return url, "ERROR", str(e)
async def main():
urls = [f"https://example.com/page/{i}" for i in range(1000)]
sema = asyncio.Semaphore(50) # 最多50个并发
connector = aiohttp.TCPConnector(limit=100)
async with aiohttp.ClientSession(connector=connector) as session:
tasks = [fetch_one(session, url, sema) for url in urls]
results = await asyncio.gather(*tasks)
ok = sum(1 for _, status, _ in results if status == 200)
print(f"成功 {ok} / {len(results)}")
asyncio.run(main())
1000 个请求,50 并发,单线程搞定。同样的活线程池得开 50 个线程,协程这边只是 50 个挂起的函数,内存开销天差地别。
6.4 asyncio 高频踩坑点(都是血换的)
坑 1:在协程里用了同步库。
排查方法很简单:看到代码里 await 的链路中夹着 requests.、time.sleep、普通 open(),就是问题。实在避不开(比如某个库没有异步版),用 asyncio.to_thread 把它扔到线程里跑:
python
import asyncio
import requests
async def fetch_with_sync_lib(url):
# 把同步函数丢进线程执行,不阻塞事件循环
resp = await asyncio.to_thread(requests.get, url, timeout=10)
return resp.status_code
坑 2:asyncio.run() 只能调一次,且不能在已有事件循环里调。
在 Jupyter 里直接 asyncio.run() 会报 RuntimeError: asyncio.run() cannot be called from a running event loop,因为 Jupyter 自己跑着一个循环。Jupyter 里直接 await main() 即可。项目代码里,asyncio.run(main()) 放 if __name__ == "__main__": 下面,整个程序只出现一次。
坑 3:拿不到任务结果。
asyncio.create_task(coro()) 之后如果不保存返回值、不 await 它,任务是"发了就忘"------而且任务里的异常可能被悄悄吞掉(只在 GC 时打个警告)。要么存下来后面 await task,要么用 TaskGroup/gather 托管。
坑 4:以为协程等于线程安全。
协程虽然没有线程那种随时被抢占的问题,但 await 点就是"切换点"。两个协程交替修改同一个全局变量,中间隔着 await,照样出逻辑错乱。原则是:两个 await 之间的代码是原子的,跨 await 的共享状态修改要小心。
坑 5:CPU 密集代码放进协程。
协程只有一个线程在跑,你在里面做数学计算,其他协程全得排队。CPU 密集的部分要么挪出去,要么 await asyncio.to_thread(...),要么干脆上进程池(asyncio 提供 loop.run_in_executor 可以接 ProcessPoolExecutor)。
6.5 生产环境:协程怎么用、事件循环建在哪?
脚本场景 :和线程/进程同一个原则------事件循环只在程序入口建一次,asyncio.run(main()) 全程序只出现一次,所有并发逻辑都收拢在 main() 协程里往下分派。并发上限用 asyncio.Semaphore 控(6.3 节的写法),它扮演的就是"协程池"的角色。
Web 服务场景 :你根本不需要自己写 asyncio.run。FastAPI/Starlette 这类框架由 uvicorn 启动,事件循环是 uvicorn 帮你建好并托管的:
bash
# 单进程:一个事件循环
uvicorn main:app
# 多进程吃满多核:gunicorn 管进程,每个进程里 uvicorn 各跑一个事件循环
gunicorn main:app -k uvicorn.workers.UvicornWorker -w 4
这就是 8 章提到的经典架构:N 个进程(吃 N 核)× 每进程一个事件循环(扛高并发 IO) ,进程管理和协程调度各归各的,你只管写 async def 视图函数。
协程里要调同步库 :别自己开线程,用 await asyncio.to_thread(...),它背后就是框架帮你管好的线程池。
七、终极问题:到底该用哪个?
7.1 一张决策表
| 场景 | 类型 | 推荐方案 |
|---|---|---|
| 定时任务、脚本里顺便并行下载几个文件 | IO 密集,并发量小 | 手动 Thread 或 ThreadPoolExecutor |
| 爬虫,几百上千个 URL | IO 密集,高并发 | asyncio + aiohttp |
| Web 后端、API 网关 | IO 密集,高并发 | asyncio(FastAPI / aiohttp) |
| 调用第三方服务做聚合(一个请求里调10个接口) | IO 密集 | asyncio.gather |
| 图片批处理、视频转码、数据计算 | CPU 密集 | ProcessPoolExecutor / multiprocessing.Pool |
| 科学计算(numpy/pandas) | CPU 密集 | 先别急着并发,numpy 底层 C 实现很多操作本来就释放 GIL,先试多线程;不行再上进程 |
| 任务间要频繁共享大量数据 | --- | 优先线程/协程,进程的 IPC 成本可能吃光收益 |
| 要求极高的稳定性,一个任务崩了不能影响别人 | --- | 进程(隔离性最好) |
7.2 一句口诀
IO 密集看并发量:量小用线程,量大用协程;CPU 密集一律上进程。
再补三句实战经验:
- 先写同步版本,跑通了再加并发。 90% 的脚本轮不到并发出场,别过度设计。
- 不确定是 IO 密集还是 CPU 密集,就两个都写一版,拿 time 计时跑一遍。 ThreadPoolExecutor 和 ProcessPoolExecutor 接口一样,切换成本极低,用数据说话。
- 新项目做高并发 IO 服务,直接 FastAPI(asyncio 生态)起步,这是目前 Python 社区的主流答案。
7.3 三者各自擅长处理什么?(一句话版)
- 线程擅长:IO 密集 + 并发量中等(几十到几百)+ 想保持同步代码写法。比如批量下载文件、小规模爬虫、脚本里并行调几个接口、给同步代码提速。门槛低,不用改代码风格。
- 进程擅长 :CPU 密集的并行计算(图像处理、数据计算、视频转码),以及需要崩溃隔离的场景------一个任务崩了不连累别人。代价是内存大、传数据贵。
- 协程擅长 :IO 密集 + 超高并发(成千上万连接)。高并发爬虫、WebSocket 长连接服务、API 网关、微服务间调用聚合。代价是要进入 async 生态,全链路的库都得是异步的。
还有一层隐藏区分:线程和协程解决的是"等待的浪费",进程解决的是"算力的不够"。 你的瓶颈是等还是算,答案直接二分。
八、面试 & 开发高频问题快问快答
Q:Python 多线程真的没用吗?
看场景。IO 密集型(爬虫、下载、查库)多线程非常有效,因为等 IO 时 GIL 会释放。只有 CPU 密集型才"没用"。
Q:有了 asyncio 还要线程吗?
要。asyncio 生态不全,很多库没有异步版本,这时候要么换库要么 asyncio.to_thread 兜底。而且简单脚本开线程池远比搭 asyncio 简单。
Q:进程和协程能混用吗?
能,而且是高性能服务的经典架构:开 N 个进程(吃满 N 核),每个进程里跑一个 asyncio 事件循环(扛高并发 IO)。Gunicorn 的 uvicorn worker 模式就是这么干的。
Q:为什么我的多线程程序开了锁反而更慢?
锁的粒度太大了。把整段代码都包在锁里,等于变回串行。锁只保护"真正读写共享数据"的那几行。
Q:守护线程(daemon)什么时候用?
比如后台日志线程、心跳线程------主程序退出时它们没必要活着。但凡是需要"善始善终"的任务(写文件、提交事务)不要用 daemon。
Q:Python 3.14 的 no-GIL 现在能上生产吗?
可以关注、可以试,但注意它目前还是可选构建,需要专门的安装包,且部分 C 扩展还没适配。现有生产代码的方法论(IO 用线程/协程,CPU 用进程)在相当长一段时间内依然是最稳的选择。
九、总结
把全文浓缩成几句话:
- 同步是"等结果",异步是"结果来找我";异步的核心收益是等待期间能干别的活。
- 进程开销大但真并行,线程轻量但被 GIL 限制,协程极轻量但只适合 IO 等待。
- IO 密集:并发量小用线程池,量大用 asyncio;CPU 密集:用进程池。 这一条能解决 90% 的选型问题。
- 多线程的坑在共享变量加锁 、异常被吞 ;多进程的坑在
if __name__ == '__main__'保护 和序列化成本 ;协程的坑在误用同步阻塞调用卡死整个事件循环。 - GIL 正在被官方逐步松绑(3.13 实验、3.14 转正),但主流方法论不变,学会现在这套,未来无缝衔接。
觉得有用的话点个赞吧~