Python 多线程的价值不在"真并行算 CPU",而在"等 I/O 的时候别人能干活" 。CPython 有 GIL,同一时刻只有一个线程跑 Python 字节码,所以纯 Python 循环/压缩/加密/数值计算丢多线程基本白搭;但网络请求、磁盘读写、DB 查询、sleep、等待第三方 API 时会释放 GIL,于是多个线程并发等 I/O,吞吐就上来了。官方 threading 文档也写得明白:线程尤其适合 I/O bound(文件/网络),多 CPU 真并行该 multiprocessing/ProcessPoolExecutor;3.13 free-threaded build 可禁 GIL 但不默认。
下面按"怎么用 → 用哪儿 → threading vs asyncio 实测 → 真实项目案例"一路讲透。
一、Python 多线程到底在干啥
一个进程里多个线程共享同一块内存地址空间。可以理解为:一个程序里冒出多个"执行流",它们看得见同一份全局变量、同一个对象;但同一时刻在 CPython 里只有一个线程跑 Python 字节码。
python
import threading
import time
def worker(name):
print(f"{name} start, tid={threading.get_ident()}")
time.sleep(2) # 模拟网络/IO 等待,会释放 GIL
print(f"{name} done")
threads = []
for i in range(4):
t = threading.Thread(target=worker, args=(f"t{i}",))
threads.append(t)
t.start()
for t in threads:
t.join()
print("all done")
Thread(target=...) 把函数丢给新线程,start() 启动,join() 等它回来。官方文档示例也是这个套路:收集 threads、start、最后 join。
生产里更该用 ThreadPoolExecutor
手写 Thread + queue 能学原理,但项目里更常用 concurrent.futures.ThreadPoolExecutor------管线程池、提交任务、拿 Future、等完成、异常收集。concurrent.futures 提供异步执行可调用对象的高层接口,ThreadPoolExecutor 用线程池,ProcessPoolExecutor 用进程池,同一套 Executor 抽象。
python
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
def fetch(url):
time.sleep(0.5) # 假装 requests.get(url, timeout=5)
return f"{url} ok"
urls = [f"https://example.com/{i}" for i in range(20)]
with ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher") as pool:
futures = {pool.submit(fetch, u): u for u in urls}
for fut in as_completed(futures):
url = futures[fut]
try:
print(url, fet.result())
except Exception as e:
print(url, "err", e)
submit() 返回 Future,as_completed() 谁先好谁先处理------这对爬虫/批量接口特别重要:别因为第 1 个任务卡 30 秒,就把后面 5ms 就完成的结果堵在后面。
二、线程安全:共享内存是把双刃剑
线程共享内存是好事(不用 pickle 传数据),也是坏事(竞态条件)。下面这个看着对,实际错:
python
counter = 0
def inc():
global counter
for _ in range(10000):
v = counter
v += 1
counter = v
threads = [threading.Thread(target=inc) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 经常不到 40000
多个线程同时读/写 counter,读到的旧值互相覆盖。解法:threading.Lock():
python
lock = threading.Lock()
def inc_safe():
global counter
for _ in range(10000):
with lock:
v = counter
v += 1
counter = v
官方 threading 就是这么干的:Lock/RLock/Condition/Event/Semaphore/Barrier 管同步;queue.Queue 是线程安全的,天生适合生产者-消费者。
几个常用同步原语:
| 原语 | 用途 |
|---|---|
Lock / RLock |
保护共享变量/临界区 |
Queue |
线程安全任务队列,生产者-消费者 |
Event |
一个线程喊"准备好了/退出吧" |
Condition |
复杂状态通知,生产者-消费者升级版 |
Semaphore |
限流,比如最多 10 个 DB 连接 |
Timer / local() |
延迟任务 / 线程局部变量 |
threading.local() 很有用------同一个变量名,每个线程各存一份:Session、trace_id、当前用户上下文、DB 连接都能塞进去,requests Session 每个线程一个,比疯狂 new Session 好。
三、使用场景:什么时候上多线程
适合
- 网络 I/O:爬虫请求 URL、调外部 API/LLM API、访问 HTTP/RPC/对象存储、批量发 webhook
- 磁盘/文件 I/O:批量读日志、复制文件、读 CSV/图片/音频、写多文件
- 数据库/消息等待:SQLAlchemy/psycopg2/pymysql 执行慢 SQL 时线程等 socket 返回;消费 Kafka/RabbitMQ 等 I/O
- GUI/后台任务:CLI 后台刷进度、桌面程序后台下载、Web 服务里给同步库加个小线程池
- 包同步阻塞库:有大量同步库(requests、selenium、旧 SDK),不想全改 async,ThreadPoolExecutor 是最小改动方案
这里有个实测依据:有人测 500 个并发 API 调用,asyncio 和 threading 吞吐几乎一样(都等网络),但 500 协程内存 +0.8MB,500 线程 +14.2MB------I/O 天花板一样,开销 asyncio 更小。
不适合 / 要换思路
- 纯 Python CPU 密集:图像像素循环、大 JSON 纯 Python 解析、密码学纯 Python、斐波那契/素数/压缩------GIL 在,多线程≈不加速甚至更慢。官方说想用多核用 multiprocessing/ProcessPoolExecutor;实测线程池 100 个 fib 和串行差不多,ProcessPoolExecutor 才 4 倍。
- 无限 max_workers:线程不是免费,栈/调度/上下文切换/连接池耗尽都要钱。爬虫别开 1000 workers,常 5/10/20/32/CPU×2 起步,再压测。
- CPU 重的同步函数在 asyncio 里裸跑 :asyncio 单线程协作,
time.sleep(1)或重 CPU 会卡全事件循环,得run_in_executor丢线程池,或 ProcessPoolExecutor 甩 CPU。
四、threading vs asyncio
- threading / ThreadPoolExecutor = OS 内核抢占式多线程 :操作系统切线程,阻塞
requests.get()/time.sleep()/conn.execute()时线程睡、GIL 释放、别人上。好处------同步代码/requests/Selenium/旧 SDK 直接能用;坏处------线程内存大、锁复杂、debug 难、万级并发撑不住。 - asyncio = 单线程协作式协程 :一个线程,遇到
await主动让出事件循环。好处------万级连接/低内存/高 I/O 吞吐,aiohttp/async DB driver 时比线程池快还省内存;坏处------代码要 async 全家桶,忘了 await/在事件循环跑重 CPU 就全卡,requests 不能直接 await。
官方 threading 也点明:asyncio 是"不用多个 OS 线程也能任务级并发"的替代路径;实际 benchmark:10k HTTP 请求 asyncio 12.4s、threading 23.1s,asyncio 内存还低------但前提是 asyncio + aiohttp 这种真异步栈。
五、性能测试
下面三个 micro-benchmark 把"纯 I/O / 纯 CPU / 混合"三种脸孔全照出来。
Benchmark 1:I/O 密集------asyncio 赢在内存和调度
python
# bench_io_sync.py
import time
import requests
URL = "https://httpbin.org/delay/0.1"
N = 100
def fetch(i):
r = requests.get(URL, timeout=5)
# print(f"{i} {r.status_code}")
if __name__ == "__main__":
t0 = time.perf_counter()
for i in range(N):
fetch(i)
print("sync", time.perf_counter() - t0)
python
# bench_io_thread.py
import time
from concurrent.futures import ThreadPoolExecutor
import requests
URL = "https://httpbin.org/delay/0.1"
N = 100
def fetch(i):
r = requests.get(URL, timeout=5)
return r.status_code
if __name__ == "__main__":
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=20) as pool:
list(pool.map(fetch, range(N)))
print("thread", time.perf_counter() - t0)
python
# bench_io_async.py
import time
import asyncio
import aiohttp
URL = "https://httpbin.org/delay/0.1"
N = 100
async def fetch(session, i):
async with session.get(URL, timeout=aiohttp.ClientTimeout(total=5)) as r:
return r.status
async def main():
async with aiohttp.ClientSession() as session:
tasks = [fetch(session, i) for i in range(N)]
await asyncio.gather(*tasks)
if __name__ == "__main__":
t0 = time.perf_counter()
asyncio.run(main())
print("async", time.perf_counter() - t0)
预期:sync ≈ N×0.1s/很小并发 ≈ 10s 级;thread 20 workers ≈ 10s/20 ≈ 0.5~1s 级;async+aiohttp 最快/最省内存。asyncio 赢在"没 OS 线程栈、没 GIL 调度焦虑、一个事件循环调度万协程";threading(requests) 赢在"代码不用改 async、库生态同步"。实测 asyncio 10k 请求 12.4s、threading 23.1s、内存差距巨大。
Benchmark 2:CPU 密集------两者都跪,ProcessPool 赢
python
# bench_cpu.py
import time
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def cpu_task(n):
x = 0
for i in range(n):
x += i * i
return x
N = 20
WORK = 5_000_000
if __name__ == "__main__":
t0 = time.perf_counter()
for i in range(N):
cpu_task(WORK)
print("sync", time.perf_counter() - t0)
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as p:
list(p.map(cpu_task, [WORK]*N))
print("thread", time.perf_counter() - t0)
t0 = time.perf_counter()
with ProcessPoolExecutor(max_workers=4) as p:
list(p.map(cpu_task, [WORK]*N))
print("process", time.perf_counter() - t0)
预期:sync 和 thread 差不多,thread 可能还慢点(GIL 抢/上下文切),process 接近 4 核 4×。这就是 GIL:CPU 活不怎么释 GIL,多 OS 线程也一个时间跑 Python 字节码。官方推荐 CPU 多核用 ProcessPoolExecutor;实测 fib/sha/纯算线程帮不上。
Benchmark 3:混合------async + ProcessPool / thread + ProcessPool
真实后端常这样:先 HTTP 拿 JSON,再 JSON loads,再算特征/签名/统计。async 里 CPU 重会卡事件循环;threading 里 CPU 重也抢 GIL;正确是async/thread 管 I/O 编排,ProcessPoolExecutor 管重算:
python
# 思想:async 管 I/O,重 CPU 甩 ProcessPool
import asyncio
from concurrent.futures import ProcessPoolExecutor
def heavy_cpu(data):
# 假设很重
return sum(x*x for x in data)
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pp:
raw = b'{"vals":[1,2,3]}' # 假数据
data = list(range(5_000_000))
r = await loop.run_in_executor(pp, heavy_cpu, data)
print(r)
asyncio.run(main())
CPU 进事件循环 → asyncio 慢;threading 略好(OS 抢占);multiprocessing true parallel 最稳。
六、选型速查表
| 场景 | 推荐 |
|---|---|
| 已有 requests/Selenium/旧 SDK/同步 DB driver | ThreadPoolExecutor |
| 新项目、aiohttp/asyncpg/aiomysql、万连接聊天 WS | asyncio |
| 纯 Python 大计算 | ProcessPoolExecutor |
| 批量图片下载+PIL 压缩 | 下载线程池/进程池;PIL 若 C 释 GIL 好,仍 Python 循环仍小心 |
| Web 框架同步 handler 调慢 SQL/HTTP | 小 ThreadPoolExecutor;长期高并发考虑 async framework |
| GUI 后台任务 | threading + Queue/Signal |
| 超多小 I/O task(10k 连接) | asyncio 内存赢;threading 能扛但内存大 |
一句话拍板:同步阻塞库一大堆 → ThreadPoolExecutor;能从 socket 到 DB 全 async → asyncio;CPU 重 → ProcessPoolExecutor;async I/O + CPU 重 → asyncio + run_in_executor/ProcessPoolExecutor。
七、真实项目案例:批量网页采集 + 结构化入库系统
需求:公司要爬 10 万个商品页/论文页/房源页,拿 HTML → 解析标题/价格/摘要 → 存 PostgreSQL/CSV/S3 → 失败重试/限速/监控/优雅退出。
为什么这事多线程天然对:90% 时间在 DNS + TCP + server wait + read socket + write DB wait,纯 Python 解析占比小,requests 阻塞但 I/O 时释 GIL,ThreadPoolExecutor 一把梭。
生产级架构
URL Producer
│
├── fetcher threads (download html)
│ ↓ put raw_html into parse_queue
├── parser threads (bs4/正则/抽取)
│ ↓ put record into db_queue
└── writer threads (batch INSERT / CSV rotate / S3 upload)
metrics: success/fail/429/timeout/qsize
stop_event: Ctrl+C → graceful shutdown
rate_limiter: Semaphore / token bucket
per_thread Session: threading.local()
比"一个函数 fetch+parse+save 全塞线程池"强在哪?职责拆开 :下载慢归下载、解析吃 CPU 归解析、写库归写库,队列削峰,某站 429 时 fetcher 堵、parser/writer 还能清库存,整体背压可控。
关键代码骨架
python
import sys
import time
import json
import threading
from queue import Queue, Empty
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
stop_event = threading.Event()
metrics = {
"submitted": 0,
"success": 0,
"fail": 0,
"bytes": 0,
}
metrics_lock = threading.Lock()
# 每个线程一个 Session,复用 TCP 连接,避免全局 Session 并发争用
_session_local = threading.local()
def get_session():
if not hasattr(_session_local, "s"):
s = requests.Session()
s.headers.update({"User-Agent": "AcmeBot/1.0"})
_session_local.s = s
return _session_local.s
def fetch_url(url, timeout=8):
s = get_session()
resp = s.get(url, timeout=timeout)
resp.raise_for_status()
return url, resp.text, len(resp.content)
这里 threading.local() 给每线程 Session------requests Session 复用连接很重要,但多线程共享一个大 Session 会引连接池/状态争用,实务 per-thread Session 更稳。
限流:Semaphore 当粗糙令牌桶
python
import random
class RateLimiter:
def __init__(self, max_parallel=10, base_wait=0.2):
self.sem = threading.Semaphore(max_parallel)
self.base_wait = base_wait
def acquire(self):
self.sem.acquire()
time.sleep(self.base_wait * random.uniform(0.8, 1.2))
def release(self):
self.sem.release()
实际更狠得上令牌桶/漏桶/域名级限速/robots.txt/代理轮换/指数退避------爬虫第一死因不是"不够快",是"太快被 403/429/IP封/法务找"。
主采集器:ThreadPoolExecutor + as_completed + stop_event
python
def crawl(urls, max_workers=12, timeout=8, max_retry=3):
with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="crawler") as pool:
# 控制"已提交但未终"任务别无限膨胀
active = 0
MAX_INFLIGHT = max_workers * 3
futures = {}
def submit_one(u, retry=0):
nonlocal active
fut = pool.submit(fetch_url, u, timeout)
futures[fut] = (u, retry)
active += 1
for u in urls:
if stop_event.is_set():
break
# 简单背压:别无限 submit
while active >= MAX_INFLIGHT:
time.sleep(0.01)
if stop_event.is_set():
break
submit_one(u)
for fut in as_completed(futures):
url, retry = futures[fut]
active -= 1
try:
url, html, size = fut.result()
# ===== 这里接 parser / db_queue.put =====
with metrics_lock:
metrics["success"] += 1
metrics["bytes"] += size
print(f"OK {url} {size}")
except Exception as e:
if retry < max_retry and not stop_event.is_set():
print(f"RETRY {url} {retry+1} {e}")
submit_one(url, retry+1)
else:
with metrics_lock:
metrics["fail"] += 1
print(f"FAIL {url} {e}")
if stop_event.is_set():
# 不再新提交;等已提交完或进程退出
pass
crawl([f"https://example.com/{i}" for i in range(100)], max_workers=10)
这段有 4 个工程细节:as_completed 先完先处理 ;MAX_INFLIGHT 防队列爆/内存涨;max_retry+stop_event 做优雅退出;metrics_lock 保护共享计数。concurrent.futures.Future 和 asyncio.Future 不是一回事,官方提醒别混。
graceful shutdown:Ctrl+C 别烂尾
python
import signal
def handle_sigint(sig, frame):
print("\nShutdown requested, finish in-flight...")
stop_event.set()
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigint)
生产再加:print metrics、flush queues、csv writer close、DB conn commit、prometheus push gateway、log error urls、下次断点续传 URL dedup(redis/md5/PG 主键)。
八、这个项目 max_workers 设多少
别信"线程越多越快"。经验值:
| 场景 | 建议 |
|---|---|
| 轻量 HTTP API / LLM 小包 | 10~32 |
| 大文件下载 | 4~8,带宽/磁盘先满 |
| Selenium/Playwright 浏览器自动化 | 2~4/机器内存 |
| DB 写 PG MySQL | DB 连接池大小,别超 max_connections |
| 同站爬虫 | 6~12 + 域名限速,别 DDoS 人家 |
| CPU 解析重 | workers↓ + ProcessPoolExecutor 给解析/指纹/压缩 |
concurrent.futures 早期 max_workers=None 对 ThreadPoolExecutor 默认 cpu * 5------承认它常用于 I/O overlap 而非 CPU,但实际还得按目标站/DB/内存/连接池调。
九、常见坑大全
- requests 能线程安全?能用但 Session/Adapter 要懂:Per-thread Session 最稳;共享池要测。
- max_workers 1000 很蠢:线程栈/调度/连接池/文件描述符/GC 全炸,asyncio 万连接轻、1000 OS 线程就重。
- list(pool.map) 顺序等:要"谁先好先处理"用
as_completed。 - Future 互等互锁 :A Future 等 B、B 等 A、线程池空 → deadlock;1 worker 里
result()另一 Future → deadlock,官方 concurrent.futures 写了。 - asyncio 里裸
requests.get()是犯罪 :它阻塞事件循环,得asyncio.to_thread/run_in_executor或换 aiohttp。 - Thread dump JSON/CSV/IPC 也要锁:print 不一定 atom,写同一 csv writer 多条线程要 Lock,或 queue → 单 writer。
- CPU 3.13 free-threading 别兴奋太早:3.13 free-threaded build 可真并行但非默认,生态 C 扩展未必全 thread-safe,生产先测。
十、最终选型口诀
新项目、全异步栈、万连接 IM/WS/网关/微服务 client → asyncio;旧代码/requests/Selenium/SDK/短平快脚本 → ThreadPoolExecutor;CPU 纯算/加密/压缩/科学算 → ProcessPoolExecutor;async event loop 里重 CPU → run_in_executor/ProcessPoolExecutor。
落到这个爬虫案例:短期上线 ThreadPoolExecutor + per-thread Session + as_completed + retry + Semaphore + Queue pipeline;长期平台化、自研 async adapter、aiohttp/async DB/万任务 → 迁 asyncio;解析模型推理重 → asyncio 编排 + ProcessPoolExecutor 跑 torch/onnx/numpy pipeline。