Python 多线程使用和场景

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.Futureasyncio.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/内存/连接池调。


九、常见坑大全

  1. requests 能线程安全?能用但 Session/Adapter 要懂:Per-thread Session 最稳;共享池要测。
  2. max_workers 1000 很蠢:线程栈/调度/连接池/文件描述符/GC 全炸,asyncio 万连接轻、1000 OS 线程就重。
  3. list(pool.map) 顺序等:要"谁先好先处理"用 as_completed
  4. Future 互等互锁 :A Future 等 B、B 等 A、线程池空 → deadlock;1 worker 里 result() 另一 Future → deadlock,官方 concurrent.futures 写了。
  5. asyncio 里裸 requests.get() 是犯罪 :它阻塞事件循环,得 asyncio.to_thread/run_in_executor 或换 aiohttp。
  6. Thread dump JSON/CSV/IPC 也要锁:print 不一定 atom,写同一 csv writer 多条线程要 Lock,或 queue → 单 writer。
  7. 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。

相关推荐
梦想的初衷~1 小时前
【完整教程】基于Python的无人机多光谱-点云融合生态三维建模:从Metashape重建到碳储量/生物量/LULC预测
python·无人机·三维重建·多光谱·无人机遥感·碳储量·生态建模
红糖奶茶2 小时前
Python 中 while 循环计数异常的常见原因分析与正确搓搓
开发语言·python
lzqrzpt2 小时前
LED驱动电源行业知名厂家技术特点与市场格局梳理
python·物联网
AI科技星2 小时前
乖乖数学·全域超复数统一场论:五大核心门槛与全套标准定量数据
人工智能·python·算法·金融·全域数学
ganbingfenxiang2 小时前
太原干冰定制
大数据·python
REDcker2 小时前
Android 15 16KB 内存页适配详解
android·开发语言·python
爱吃苹果的梨叔3 小时前
KVM和分布式坐席参数怎么看?端口、通道、节点都是什么意思
python
量化吞吐机3 小时前
近期AI量化开发,先跑最小流程再谈复杂功能
人工智能·python
杰佛史彦明13 小时前
PDF 表格转 CSV:Python 实现方法与代码解析
开发语言·python·pdf