【Python 并发编程】线程、进程、协程、同步/异步一次说清

环境说明:本文代码基于 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),父进程不收,它就变成僵尸进程 占着进程号。用 Poolwith 或记得 join() 就是干这个的。

线程Thread(target=f) 只是创建了个对象,start() 才真正启动 → 在就绪/运行/阻塞三个状态之间被操作系统来回调度 → 目标函数 return 或抛异常,线程结束 → 主线程 join() 回收。线程没办法从外部"杀死",想让它停只能靠标志位让它自己退出,这也是设计线程时要留好"退出开关"的原因。

协程 :这是新手最容易懵的一个------调用 async def 函数并不会执行它,只是创建了一个协程对象

python 复制代码
async def work():
    print("干活")

c = work()  # 什么都没打印!只是创建了个协程对象

协程必须交给事件循环才会跑:await casyncio.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 字节码。

注意三个关键点:

  1. 它是 CPython 的实现细节,不是 Python 语言的特性。Jython、IronPython 没有 GIL,但我们基本只关心 CPython。
  2. 它的存在是为了让内存管理(引用计数)实现起来简单且线程安全。
  3. 后果就是: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}")

线程池踩坑点:

  1. max_workers 开多少?IO 密集型可以开大一些(几十甚至上百,看下游扛不扛得住),不是越多越好------线程切换有成本,请求太猛还会把对方服务打挂。Python 3.8+ 默认值是 min(32, cpu核心数 + 4)
  2. future.result() 会重新抛出子线程里的异常。不调用 result(),线程里的异常就被吞了 ,排查问题时怀疑人生。所以一定要遍历结果,或者用 pool.map(它会保证异常在取结果时抛出)。
  3. 线程池里的任务如果用了同一批资源(比如同一个 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 生产环境:线程池怎么建、建在哪?

三个要点:

  1. 建一次,全局复用。 线程池是"池",意义就在于复用。最忌讳的写法是在请求处理函数里 with ThreadPoolExecutor() 临时建池------来一个请求建一次池,建池开销全吃了,还完全失去并发上限控制,流量一大线程数爆炸。
  2. 建在应用启动时。 和 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)
  1. 想好退出。 程序关闭时 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)))

ThreadPoolExecutorProcessPoolExecutor 接口完全一致,这也是官方的设计意图:业务代码不变,换个 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 神器)

踩坑点:

  1. 传参成本 。传给子进程的每个参数都要 pickle 一遍,返回结果也要 pickle 回来。如果你给每个子任务传一个 2GB 的 DataFrame,光序列化的时间就够你喝一壶了。大数据要么用 shared_memory,要么传文件路径让子进程自己读。
  2. 子进程挂了你不知道pool.map 里某个任务抛异常,异常会打包传回主进程,在取结果时才抛出。上线前记得想好任务失败的重试和兜底策略。
  3. 注意僵尸进程:创建了 Process 就要负责 join(),用 Pool 的上下文管理器(with)会自动处理。

5.3 生产环境:进程池怎么建、建在哪?

原则和线程池一致(见 4.6/4.7),但进程有额外的讲究:

  1. 必须在 if __name__ == "__main__": 保护的入口里建池。 5.1 已经说过原因(子进程会重新 import 主模块),这里再补一刀:不止 Process()Pool() 也一样会触发子进程创建,所以 Pool 对象不能当模块级全局变量随手建,必须放在入口保护里,或放在只在入口调用的初始化函数里。

  2. 大小 = CPU 核数(1+任务阻塞系数)别拍脑袋。 * os.cpu_count() 拿核数。Web 服务器上跑常驻计算服务时,还要给其他进程留核。

  3. 常驻服务别自己裸管进程。 如果你的需求是"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 密集一律上进程。

再补三句实战经验:

  1. 先写同步版本,跑通了再加并发。 90% 的脚本轮不到并发出场,别过度设计。
  2. 不确定是 IO 密集还是 CPU 密集,就两个都写一版,拿 time 计时跑一遍。 ThreadPoolExecutor 和 ProcessPoolExecutor 接口一样,切换成本极低,用数据说话。
  3. 新项目做高并发 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 用进程)在相当长一段时间内依然是最稳的选择。

九、总结

把全文浓缩成几句话:

  1. 同步是"等结果",异步是"结果来找我";异步的核心收益是等待期间能干别的活。
  2. 进程开销大但真并行,线程轻量但被 GIL 限制,协程极轻量但只适合 IO 等待
  3. IO 密集:并发量小用线程池,量大用 asyncio;CPU 密集:用进程池。 这一条能解决 90% 的选型问题。
  4. 多线程的坑在共享变量加锁异常被吞 ;多进程的坑在 if __name__ == '__main__' 保护序列化成本 ;协程的坑在误用同步阻塞调用卡死整个事件循环
  5. GIL 正在被官方逐步松绑(3.13 实验、3.14 转正),但主流方法论不变,学会现在这套,未来无缝衔接。

觉得有用的话点个赞吧~

相关推荐
程序员杰哥18 分钟前
如何做接口测试?
自动化测试·python·测试工具·职场和发展·测试用例·接口测试·postman
weixin1997010801621 分钟前
[特殊字符]《从0到1:闲鱼开放平台授权登录 + AccessToken 刷新 + 聚石塔部署完整链路》(附Python源码)
java·数据库·python
布局呆星41 分钟前
RAG从0~1,初入~优化
python
王志来137944730081 小时前
场景驱动选型:4U工控机箱如何匹配多元化工业需求
大数据·人工智能·python
2601_962381581 小时前
[Python人工智能] 九.gensim词向量Word2Vec安装及《庆余年》中文短文本相似度计算
人工智能·python·tensorflow·word2vec·文本相似度
️学习的小王1 小时前
Flask实现云栖笔记|开箱即用网页版本地笔记本
笔记·python·flask
quantdash_cc1 小时前
批量请求和循环请求有什么区别?从 API 请求次数看量化数据获取的工程设计
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
码农刚子1 小时前
告别影楼和付费 App,5 分钟本地搭一个证件照自由平台|HivisionIDPhotos 开箱实测
python·图像识别
Tizzy JJ1 小时前
Python + pytest 接口自动化测试框架实战:从零搭建企业级项目骨架
开发语言·python·pytest