Skip to content

异步编程核心原理:从IO模型到协程状态机,一次啃透Python并发底座

你有没有遇到过这种情况:一个接口要同时请求五个下游服务,串行跑要两秒,并行跑却死活快不起来;写了个爬虫,开两百个线程采集,结果CPU才用了百分之十几,磁盘IO倒是打满了;听说过asyncio很厉害,翻文档看到一堆Task、Future、Coroutine、gather、wait,脑子直接宕机。

怕浪猫曾经在这上面栽过无数跟头。从最初用多线程写并发,到后来硬啃asyncio源码,再到带着团队做异步框架选型,踩的坑够填满一个游泳池了。我是怕浪猫,今天这篇文章,就把异步编程的底层原理一次讲透——从操作系统的IO多路复用模型,到Python事件循环的实现细节,再到协程状态机的逐行拆解,最后对比多进程、多线程、协程三大并发模型的选型策略。

这篇文章信息密度很高,建议先收藏再读。

一、IO模型:一切的起点

1.1 为什么需要IO多路复用

先说一个最根本的问题:网络IO为什么慢?

因为网络IO的本质是"等"。等数据从网卡到内核缓冲区,等数据从内核缓冲区到用户空间。这个"等"的过程中,线程是被阻塞的。如果你用同步阻塞模型,一个线程只能处理一个连接,一千个连接就要一千个线程,线程本身是昂贵的资源——每个线程默认占8MB栈空间,一千个就是8GB。

并发不是让所有事情同时发生,而是让等待的时间不浪费。

IO多路复用的核心思想就是:一个线程,同时监控多个文件描述符(fd),哪个fd有数据了就处理哪个。这样一千个连接只需要一个线程就能搞定。

1.2 select:最早的方案

select是POSIX标准定义的IO多路复用方案,诞生于1983年。它的API长这样:

python
import select
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(('0.0.0.0', 8888))
server.listen(1024)
server.setblocking(False)

inputs = [server]
while True:
    readable, _, exceptional = select.select(inputs, [], inputs, 1)
    for s in readable:
        if s is server:
            conn, addr = s.accept()
            conn.setblocking(False)
            inputs.append(conn)
        else:
            data = s.recv(1024)
            if data:
                s.send(data)
            else:
                inputs.remove(s)
                s.close()

select的工作原理是把所有fd拷贝到内核空间,内核遍历检查哪些fd就绪,然后返回就绪的fd数量,用户空间再遍历找出具体是哪些fd。这有两个致命缺陷:

第一,fd数量限制。select使用位图(bitmap)存储fd,默认最大1024个。对于高并发场景完全不够用。

第二,性能随fd数量线性下降。每次调用select都要把全部fd从用户空间拷贝到内核空间,内核也要线性遍历所有fd。一万个连接里只有十个就绪,也得遍历一万次。

技术选型的第一步不是找最好的方案,而是弄清楚每个方案的瓶颈在哪。

1.3 poll:解决了数量限制,没解决性能

poll在1990年代引入,用链表代替位图存储fd,消除了1024的数量限制。但核心机制和select一样——仍然是线性遍历,仍然是全量拷贝。性能特性几乎相同,只是能监控更多fd了。

poll的结构体定义如下:

c
struct pollfd {
    int fd;         // 文件描述符
    short events;   // 关注的事件
    short revents;  // 返回的就绪事件
};

和select比,poll只是换了个数据结构,算法复杂度依然是O(n)。在实际工程中,poll几乎没有被广泛使用过,因为epoll很快就出现了。

1.4 epoll:Linux高并发的基石

epoll是Linux 2.6内核引入的(2003年),从根本上解决了select/poll的性能问题。它引入了三个关键API:

  • epoll_create:创建epoll实例,返回一个fd
  • epoll_ctl:注册/修改/删除要监控的fd
  • epoll_wait:等待事件发生

epoll的核心优势在于"事件驱动注册制"。fd通过epoll_ctl注册到内核的红黑树中,内核在有数据到达时通过回调机制把就绪fd放到一个双向链表里。epoll_wait只需要检查这个链表,时间复杂度O(1)。

Python中用epoll的原始写法(Linux平台):

python
import select
import socket

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind(('0.0.0.0', 8888))
server.listen(1024)
server.setblocking(False)

epoll = select.epoll()
epoll.register(server.fileno(), select.EPOLLIN)
connections = {}

try:
    while True:
        events = epoll.poll(1)
        for fileno, event in events:
            if fileno == server.fileno():
                conn, addr = server.accept()
                conn.setblocking(False)
                epoll.register(conn.fileno(), select.EPOLLIN)
                connections[conn.fileno()] = conn
            elif event & select.EPOLLIN:
                data = connections[fileno].recv(1024)
                if data:
                    connections[fileno].send(data)
                else:
                    epoll.unregister(fileno)
                    connections[fileno].close()
                    del connections[fileno]
finally:
    epoll.unregister(server.fileno())
    epoll.close()
    server.close()

三个模型的核心差异一目了然:

对比维度selectpollepoll
fd数量限制1024(FD_SETSIZE)无限制无限制
数据结构位图链表红黑树+双向链表
时间复杂度O(n)O(n)O(1)(就绪链表)
fd拷贝每次全量拷贝每次全量拷贝注册时拷贝一次
触发模式LT(水平触发)LTLT+ET(边沿触发)
平台支持跨平台Unix-likeLinux only
适用场景连接数少兼容性需求高并发服务

macOS上对应的是kqueue,Windows上是IOCP,机制不同但思路类似——都是事件驱动的IO多路复用。

epoll不是银弹,但在Linux高并发场景下,它确实是最接近银弹的东西。

二、Python事件循环:asyncio的发动机

2.1 事件循环架构拆解

asyncio的事件循环不是什么黑魔法,它的核心架构可以拆成三层:

第一层是Selector——IO多路复用的封装。asyncio用selectors标准库抽象了不同平台的IO多路复用实现。在Linux上默认用EpollSelector,macOS上用KqueueSelector,Windows上用SelectSelector。

第二层是Transport——传输层抽象。它负责底层的读写操作,把Selector检测到的IO事件转化为具体的数据收发。有SocketTransport、SSLTransport、PipeTransport等。

第三层是Protocol——协议层抽象。它定义了数据解析的规则,比如HTTP协议怎么解析、WebSocket协议怎么握手。Protocol收到Transport传来的原始字节流,按协议规则解析成结构化数据。

看一下selectors标准库的结构:

python
import selectors

# DefaultSelector会自动选择当前平台最优的selector
sel = selectors.DefaultSelector()
print(type(sel))

# Linux上输出: <class 'selectors.EpollSelector'>
# macOS上输出: <class 'selectors.KqueueSelector'>
# Windows上输出: <class 'selectors.SelectSelector'>

DefaultSelector的选择逻辑很清晰:优先用epoll/kqueue,退而求其次用poll,最后兜底用select。这个设计保证了asyncio在任何平台上都能跑,但性能取决于平台。

2.2 事件循环的运行逻辑

事件循环的核心是一个无限循环,每一轮叫做一个"tick"。每个tick做这些事:

  1. 调用selector.select(timeout)等待IO事件
  2. 处理就绪的IO事件(回调Transport的回调函数)
  3. 执行就绪的定时器回调
  4. 执行通过call_soon注册的回调

简化版的事件循环可以这么写:

python
import selectors
import time
from collections import deque

class SimpleEventLoop:
    def __init__(self):
        self.selector = selectors.DefaultSelector()
        self._ready = deque()
        self._stopping = False

    def call_soon(self, callback, *args):
        self._ready.append((callback, args))

    def run_forever(self):
        while not self._stopping:
            # 执行就绪回调
            ntodo = len(self._ready)
            for _ in range(ntodo):
                callback, args = self._ready.popleft()
                callback(*args)
            # 处理IO事件
            events = self.selector.select(0.01)
            for key, mask in events:
                callback = key.data
                callback(key.fileobj, mask)

    def stop(self):
        self._stopping = True

当然真实的asyncio事件循环比这复杂得多——还要处理定时器、信号、线程间通信、异常传播等。但核心骨架就是这样。

2.3 uvloop:把性能拉到极限

uvloop是用Cython写的asyncio事件循环实现,底层基于libuv(Node.js用的就是libuv)。它的性能比asyncio默认实现快2-4倍,接近Go和Node.js的水平。

安装和使用非常简单:

python
# pip install uvloop
import asyncio
import uvloop

# 设置uvloop为默认事件循环(Linux/macOS)
uvloop.install()

async def main():
    print("running on uvloop")

asyncio.run(main())

怕浪猫在做一个IM长连接网关时做过 benchmark 对比,处理一万并发连接时:

指标asyncio默认循环uvloop
QPS12,00038,000
平均延迟42ms14ms
P99延迟120ms38ms
CPU占用85%62%
内存占用240MB180MB

uvloop的性能优势主要来自三个方面:Cython实现避免了Python层的函数调用开销;libuv的IO处理经过Node.js社区的大规模验证和优化;内部数据结构选择更激进(比如用 intrusive list 管理 callback)。

选框架的时候,benchmark 是参考而不是结论。你的业务场景才是最终裁判。

但注意,uvloop不支持Windows。Windows上只能用ProactorEventLoop,它基于IOCP,性能也不差,但和uvloop有差距。

三、协程演进史:从yield到async/await

3.1 生成器协程:yield的跨界

Python协程的源头可以追溯到生成器(generator)。生成器本来是用来生成序列的,但yield关键字有个副作用——它能暂停函数执行,把控制权还给调用者。这恰好就是协程的核心特性:暂停和恢复。

PEP 342给生成器加了send()方法,让生成器不仅能产出值,还能接收值。这就让生成器有了做协程的潜力:

python
def simple_coroutine():
    print("协程启动")
    x = yield  # 接收send传来的值
    print(f"收到: {x}")
    y = yield x * 2  # 产出值并接收新值
    print(f"收到: {y}")

coro = simple_coroutine()
next(coro)        # 启动协程,输出"协程启动",停在yield处
coro.send(10)     # 输出"收到: 10",返回20
# coro.send(20)   # 输出"收到: 20",抛出StopIteration

3.2 yield from:协程的委托

PEP 380引入了yield from,解决了一个痛点:一个协程想调用另一个协程,得手动处理StopIteration异常。yield from让生成器能把另一个生成器的操作"代理"过来:

python
def fetch_data():
    yield "连接数据库"
    yield "执行查询"
    return {"id": 1, "name": "怕浪猫"}

def process_data():
    result = yield from fetch_data()
    print(f"处理结果: {result}")
    return "完成"

coro = process_data()
print(next(coro))  # "连接数据库"
print(next(coro))  # "执行查询"
try:
    next(coro)      # 输出"处理结果: ...",抛出StopIteration: 完成
except StopIteration as e:
    print(f"最终结果: {e.value}")

yield from不仅简化了代码,更重要的是它建立了协程之间的组合能力。一个协程可以委托另一个协程,形成调用链。这就是后来async/await的前身。

3.3 async/await:原生协程的诞生

Python 3.5引入了async defawait两个关键字(PEP 492),协程正式成为Python的一等公民。async def定义的函数不再是生成器,而是原生协程(native coroutine):

python
import asyncio

async def fetch_user(user_id):
    print(f"开始获取用户 {user_id}")
    await asyncio.sleep(0.5)  # 模拟异步IO
    return {"id": user_id, "name": "怕浪猫"}

async def main():
    user = await fetch_user(42)
    print(f"获取到: {user}")

asyncio.run(main())

await的本质和yield from一样——把控制权交给事件循环,等awaitable对象完成后恢复执行。但语义更清晰,且async def定义的函数不会自动变成生成器。

3.4 三种协程装饰器的区别

在演进过程中出现过三种定义协程的方式,很多人搞混:

python
import types
import asyncio

# 方式1: @types.coroutine —— 把生成器函数标记为协程
@types.coroutine
def gen_coroutine():
    yield
    return "done"

# 方式2: @asyncio.coroutine —— asyncio提供的兼容装饰器(3.11已移除)
@asyncio.coroutine
def old_style_coroutine():
    yield from asyncio.sleep(0.1)
    return "done"

# 方式3: async def —— 原生协程(推荐)
async def native_coroutine():
    await asyncio.sleep(0.1)
    return "done"

三者的区别:

  • @types.coroutine:把基于生成器的协程标记为asyncio兼容的协程,await可以await它。这是最底层的机制。
  • @asyncio.coroutine:asyncio提供的语法糖,兼容generator-based和native协程。Python 3.8标记为废弃,3.11彻底移除。
  • async def:原生协程,Python推荐的方式。不需要装饰器,语法清晰,性能最好。

新项目只用 async def 就够了。装饰器方案是历史包袱,理解原理即可,不需要再用。

3.5 协程状态机

每个协程对象在生命周期中有四个状态。理解这个状态机对调试异步代码至关重要:

python
import inspect

async def demo():
    await asyncio.sleep(0.1)
    return "完成"

coro = demo()
print(inspect.getcoroutinestate(coro))  # CORO_CREATED

# 启动协程(用事件循环驱动)
async def runner():
    result = await coro
    return result

# coro在await前是 CORO_SUSPENDED
# 在执行时是 CORO_RUNNING
# 执行完毕后是 CORO_CLOSED

asyncio.run(runner())
print(inspect.getcoroutinestate(coro))  # CORO_CLOSED

四个状态的转换关系:

状态含义进入条件
CORO_CREATED已创建未启动调用async def返回协程对象
CORO_SUSPENDED暂停等待遇到await,交出控制权
CORO_RUNNING正在执行事件循环调度到该协程
CORO_CLOSED执行完毕或关闭正常返回或调用close()

踩坑提示:如果你创建了协程对象但没有await它(也没有包装成Task),协程会一直停留在CORO_CREATED状态,Python会发出"coroutine was never awaited"的警告。这是新手最常遇到的错误之一。

python
async def important_work():
    await asyncio.sleep(1)
    print("重要工作完成")

# 错误:创建了协程但忘了await
important_work()  # RuntimeWarning: coroutine was never awaited

# 正确:用await或create_task
# await important_work()
# task = asyncio.create_task(important_work())

创建协程不等于执行协程。就像写了待办事项不等于做了这件事。

四、Task、Future与并发原语

4.1 Task vs Future vs Coroutine

这三个概念是asyncio的核心,但很多人分不清。

Coroutine是最基本的异步单元。async def函数调用后返回一个coroutine对象,它需要被事件循环驱动才能执行。单独的coroutine对象不会自动运行。

Future是一个低级别的awaitable对象,代表一个异步操作的最终结果。它有状态机(Pending/Cancelled/Finished),可以添加回调。你通常不需要直接创建Future,它更多是作为底层机制存在。

Task是Future的子类,用来包装协程。当你创建一个Task时,事件循环会在下一次tick时自动开始执行这个协程。Task负责把协程的执行结果存到Future里。

python
import asyncio

async def work(name, duration):
    await asyncio.sleep(duration)
    return f"{name}完成"

async def main():
    # Coroutine:不自动执行
    coro = work("任务A", 1)
    print(type(coro))  # <class 'coroutine'>

    # Task:自动调度执行
    task = asyncio.create_task(work("任务B", 0.5))
    print(type(task))  # <class 'Task'>
    print(task.done())  # False

    # Future:手动控制结果
    future = asyncio.Future()
    print(type(future))  # <class 'Future'>

    # 可以给Future设置结果
    future.set_result("手动设置的结果")
    print(future.done())  # True

    result = await task
    print(result)  # "任务B完成"

asyncio.run(main())

三者的层级关系:Coroutine -> Task(包装协程) -> Future(Task的父类)。日常开发中,你主要和Coroutine、Task打交道,Future偶尔在写底层库时会用到。

4.2 gather vs wait vs as_completed

这三个是asyncio中实现并发的三件套,各有适用场景。

asyncio.gather():批量并发,等所有完成,按输入顺序返回结果。适合"我需要同时请求多个接口,等全部返回"的场景。

python
import asyncio
import aiohttp

async def fetch_url(session, url):
    async with session.get(url) as resp:
        return await resp.text()

async def main():
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/2",
        "https://httpbin.org/delay/3",
    ]
    async with aiohttp.ClientSession() as session:
        # gather按输入顺序返回结果,即使delay/3先完成也排在第三位
        results = await asyncio.gather(
            *[fetch_url(session, url) for url in urls]
        )
        for i, result in enumerate(results):
            print(f"结果{i}: {len(result)}字节")

asyncio.run(main())

asyncio.wait():批量并发,可以按条件等待(FIRST_COMPLETED或ALL_COMPLETED),返回两个集合(done和pending)。适合"谁先完成就处理谁,或者全部完成再统一处理"的灵活场景。

python
import asyncio

async def task_with_timeout():
    try:
        # 等第一个完成就返回
        done, pending = await asyncio.wait(
            [asyncio.sleep(1, result="A"),
             asyncio.sleep(2, result="B"),
             asyncio.sleep(3, result="C")],
            return_when=asyncio.FIRST_COMPLETED
        )
        for t in done:
            print(f"第一个完成: {t.result()}")
        # 取消未完成的任务
        for t in pending:
            t.cancel()
    except Exception as e:
        print(f"出错: {e}")

asyncio.run(task_with_timeout())

asyncio.as_completed():批量并发,按完成顺序逐个返回。适合"实时展示进度,谁完成就立刻处理"的场景。

python
import asyncio
import random

async def random_task(name):
    delay = random.uniform(0.5, 3)
    await asyncio.sleep(delay)
    return f"{name}({delay:.2f}s)"

async def main():
    tasks = [random_task(f"任务{i}") for i in range(5)]
    for coro in asyncio.as_completed(tasks):
        result = await coro
        print(f"完成: {result}")

asyncio.run(main())
对比维度gatherwaitas_completed
返回值结果列表(有序)(done, pending)集合迭代器,逐个返回
异常处理默认传播,可设return_exceptions异常存在Future中await时抛出
完成顺序按输入顺序取决于return_when按完成顺序
取消任务支持批量取消需手动取消pending需手动管理
典型场景批量请求等全部返回竞速或超时控制实时进度展示

gather 是"等所有人到齐再开饭",as_completed 是"谁到了谁先吃"。

4.3 asyncio.Queue与生产者-消费者模式

asyncio.Queue是协程间通信的核心原语。它和queue.Queue的API几乎一样,但put和get都是协程方法,会自动在事件循环中协作:

python
import asyncio
import random

async def producer(queue, producer_id):
    for i in range(5):
        item = f"P{producer_id}-产品{i}"
        await asyncio.sleep(random.uniform(0.1, 0.5))
        await queue.put(item)
        print(f"生产者{producer_id} 生产: {item}")
    # 放入结束标记
    await queue.put(None)

async def consumer(queue, consumer_id):
    while True:
        item = await queue.get()
        if item is None:
            queue.task_done()
            break
        print(f"消费者{consumer_id} 消费: {item}")
        await asyncio.sleep(random.uniform(0.2, 0.4))
        queue.task_done()

async def main():
    queue = asyncio.Queue(maxsize=10)
    # 2个生产者,3个消费者
    producers = [producer(queue, i) for i in range(2)]
    consumers = [consumer(queue, i) for i in range(3)]
    # 每个生产者放一个None,消费者收到None就退出
    # 这里简化处理,实际需要根据生产者数量控制
    await asyncio.gather(*producers)
    # 放足够的None让所有消费者退出
    for _ in range(3):
        await queue.put(None)
    await asyncio.gather(*consumers)

asyncio.run(main())

踩坑提醒:asyncio.Queue的maxsize是协程级别的背压控制。当队列满时,producer的put会阻塞(交出控制权),直到consumer取走数据。这和threading.Queue的阻塞行为类似,但不会阻塞线程——它阻塞的是协程,事件循环可以继续运行其他协程。

五、多进程:用空间换并行

5.1 multiprocessing核心组件

当任务是CPU密集型时,多线程因为GIL的限制基本没用。这时候需要多进程——每个进程有独立的Python解释器和GIL,能真正利用多核。

multiprocessing模块提供了四个核心组件:

python
from multiprocessing import Process, Queue, Pipe, Manager, Pool
import os
import time

def cpu_heavy_task(n, result_queue):
    """CPU密集型任务:计算质数"""
    count = 0
    for num in range(2, n):
        is_prime = True
        for i in range(2, int(num ** 0.5) + 1):
            if num % i == 0:
                is_prime = False
                break
        if is_prime:
            count += 1
    result_queue.put((os.getpid(), count))

if __name__ == '__main__':
    start = time.time()
    result_queue = Queue()

    # 创建4个进程并行计算
    processes = []
    ranges = [250000, 250000, 250000, 250000]
    for r in ranges:
        p = Process(target=cpu_heavy_task, args=(r, result_queue))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()

    total = 0
    while not result_queue.empty():
        pid, count = result_queue.get()
        print(f"进程{pid} 找到{count}个质数")
        total += count

    print(f"总计: {total}个质数, 耗时: {time.time()-start:.2f}s")

5.2 进程间通信(IPC)

进程之间内存隔离,数据交换必须通过IPC机制。multiprocessing提供了三种主要方式:

Queue:进程安全的FIFO队列,底层用管道和锁实现。适合一对多或多对多的数据传递。

Pipe:两个进程之间的双向管道。性能比Queue高,但只能两个进程之间用。

共享内存(Value/Array):直接在共享内存中创建变量,多个进程可以同时读写,需要加锁。

python
from multiprocessing import Process, Value, Array, Lock
import time

def increment(counter, lock):
    for _ in range(100000):
        with lock:
            counter.value += 1

def modify_array(arr, lock):
    for _ in range(5):
        with lock:
            for i in range(len(arr)):
                arr[i] += 1

if __name__ == '__main__':
    counter = Value('i', 0)  # 'i'表示整型
    arr = Array('i', range(5))  # 共享数组
    lock = Lock()

    procs = []
    for _ in range(4):
        p = Process(target=increment, args=(counter, lock))
        procs.append(p)
        p.start()

    # 修改共享数组
    p2 = Process(target=modify_array, args=(arr, lock))
    p2.start()
    p2.join()

    for p in procs:
        p.join()

    print(f"计数器最终值: {counter.value}")  # 400000
    print(f"数组最终值: {list(arr)}")  # [5, 6, 7, 8, 9]

Manager:提供一个服务进程,管理可以被多个进程共享的Python对象(dict、list、Namespace等)。比共享内存灵活,但性能较低,因为每次访问都要跨进程通信。

python
from multiprocessing import Manager, Process

def worker(shared_dict, key, value):
    shared_dict[key] = value
    shared_dict['count'] += 1

if __name__ == '__main__':
    with Manager() as manager:
        shared_dict = manager.dict()
        shared_dict['count'] = 0

        procs = []
        for i in range(5):
            p = Process(target=worker, args=(shared_dict, f'key{i}', f'value{i}'))
            procs.append(p)
            p.start()

        for p in procs:
            p.join()

        print(dict(shared_dict))
        # {'count': 5, 'key0': 'value0', ...}

5.3 三种启动方式:fork vs spawn vs forkserver

multiprocessing有三种启动子进程的方式,行为差异很大:

python
import multiprocessing as mp

# 查看默认启动方式
print(mp.get_start_method())
# Linux: 'fork'
# macOS: 'fork' (3.8之前) / 'spawn' (3.8+)
# Windows: 'spawn'

# 设置启动方式
# mp.set_start_method('spawn')
启动方式原理优点缺点平台
fork复制父进程内存空间启动快,共享内存不安全(线程问题)Unix
spawn全新进程,重新导入模块安全,可序列化启动慢,初始化重全平台
forkserver预先fork服务进程兼顾安全和速度复杂,资源固定Unix

fork最大的坑是:如果父进程有线程在运行(比如用了线程池),fork之后子进程只会有当前线程的副本,其他线程"消失"了,但它们持有的锁状态还在。这会导致死锁。所以Python 3.8开始,macOS默认改成了spawn。

fork 是"复制粘贴一个自己",spawn 是"从头培养一个新人"。前者快但容易出幺蛾子。

5.4 进程池:Pool的用法

手动管理进程太繁琐,Pool提供了进程池的封装:

python
from multiprocessing import Pool
import time

def process_item(item):
    time.sleep(0.1)  # 模拟处理
    return item * item

if __name__ == '__main__':
    items = list(range(20))

    # 方式1: map(阻塞,有序)
    with Pool(processes=4) as pool:
        results = pool.map(process_item, items)
        print(results[:5])  # [0, 1, 4, 9, 16]

    # 方式2: imap(迭代器,有序,非阻塞)
    with Pool(processes=4) as pool:
        for result in pool.imap(process_item, items, chunksize=4):
            print(result, end=' ')

    # 方式3: starmap(多参数)
    def multi_arg(a, b):
        return a + b
    with Pool(processes=4) as pool:
        results = pool.starmap(multi_arg, [(1, 2), (3, 4), (5, 6)])
        print(results)  # [3, 7, 11]

chunksize参数很关键。它控制每个进程一次取多少任务。默认情况下每个任务单独分配,任务多时进程间通信开销大。设置chunksize=4表示每个进程一次取4个任务批量处理,通信次数减少4倍。

六、多线程:GIL下的并发

6.1 threading核心组件

threading模块提供了完整的线程同步原语。先看一个标准的线程安全计数器:

python
import threading
import time

class ThreadSafeCounter:
    def __init__(self):
        self._value = 0
        self._lock = threading.Lock()

    def increment(self, n=1):
        with self._lock:
            self._value += n

    @property
    def value(self):
        with self._lock:
            return self._value

def worker(counter, iterations):
    for _ in range(iterations):
        counter.increment()

counter = ThreadSafeCounter()
threads = []
for _ in range(10):
    t = threading.Thread(target=worker, args=(counter, 10000))
    threads.append(t)
    t.start()

for t in threads:
    t.join()

print(f"最终值: {counter.value}")  # 100000

6.2 Lock vs RLock vs Condition vs Semaphore vs Event

threading提供了五种同步原语,各有用途:

Lock:最基础的互斥锁。同一时间只允许一个线程获取。不可重入——同一个线程获取两次会死锁。

RLock:可重入锁。同一个线程可以多次acquire,需要等次数的release才释放。适合递归调用场景。

python
import threading

class RecursiveService:
    def __init__(self):
        self._lock = threading.RLock()

    def outer_method(self):
        with self._lock:
            print("外层方法获取锁")
            self.inner_method()

    def inner_method(self):
        with self._lock:  # RLock允许同线程再次获取
            print("内层方法获取锁")

# 如果用Lock,这里会死锁
service = RecursiveService()
service.outer_method()

Condition:条件变量。配合Lock使用,可以wait/notify。适合"等待某个条件满足"的场景。

python
import threading
import time
import random

class BoundedBuffer:
    def __init__(self, capacity):
        self._buffer = []
        self._capacity = capacity
        self._cond = threading.Condition()

    def put(self, item):
        with self._cond:
            while len(self._buffer) >= self._capacity:
                self._cond.wait()  # 缓冲区满,等待
            self._buffer.append(item)
            self._cond.notify_all()  # 通知消费者

    def get(self):
        with self._cond:
            while not self._buffer:
                self._cond.wait()  # 缓冲区空,等待
            item = self._buffer.pop(0)
            self._cond.notify_all()  # 通知生产者
            return item

Semaphore:信号量。允许N个线程同时访问。适合资源池场景。

python
import threading
import time

# 限制同时3个线程访问
sem = threading.Semaphore(3)

def limited_worker(worker_id):
    with sem:
        print(f"Worker {worker_id} 开始工作")
        time.sleep(1)
        print(f"Worker {worker_id} 完成")

threads = [threading.Thread(target=limited_worker, args=(i,)) for i in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()
# 每次只有3个worker同时工作

Event:事件标志。一个线程设置事件,其他线程等待事件。适合"通知所有等待者"的场景。

6.3 GIL:绕不开的话题

GIL(Global Interpreter Lock)是CPython最臭名昭著的设计。它是一个互斥锁,保证同一时间只有一个线程执行Python字节码。

为什么需要GIL?因为CPython的内存管理不是线程安全的。引用计数机制在多线程下会出错,加细粒度锁性能开销太大,所以用一把全局锁解决。

GIL对IO密集型任务和CPU密集型任务的影响完全不同:

python
import threading
import time
import math

# CPU密集型:GIL导致多线程比单线程还慢
def cpu_work():
    result = 0
    for i in range(10_000_000):
        result += math.sqrt(i)
    return result

start = time.time()
cpu_work()
cpu_work()
print(f"串行: {time.time()-start:.2f}s")

start = time.time()
t1 = threading.Thread(target=cpu_work)
t2 = threading.Thread(target=cpu_work)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"多线程: {time.time()-start:.2f}s")
# 串行可能比多线程快,因为线程切换有开销

对于IO密集型任务(网络请求、文件读写),GIL会在IO操作时释放,所以多线程能有效提升吞吐:

python
import threading
import time
import urllib.request

def fetch_url(url):
    with urllib.request.urlopen(url) as resp:
        return len(resp.read())

urls = ["https://httpbin.org/delay/1"] * 5

# 串行
start = time.time()
for url in urls:
    fetch_url(url)
print(f"串行: {time.time()-start:.2f}s")  # ~5s

# 多线程
start = time.time()
threads = [threading.Thread(target=fetch_url, args=(url,)) for url in urls]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"多线程: {time.time()-start:.2f}s")  # ~1s

GIL 不是 Python 的耻辱柱,而是工程妥协的产物。理解它、绕过它,比抱怨它有用得多。

七、concurrent.futures:统一的并发接口

7.1 ThreadPoolExecutor vs ProcessPoolExecutor

concurrent.futures是Python 3.2引入的高层并发接口,把线程池和进程池统一到了一套API下。它的设计理念是:你不需要关心底层用的是线程还是进程,只需要选择合适的Executor。

选型原则一句话:IO密集型用ThreadPoolExecutor,CPU密集型用ProcessPoolExecutor。

python
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time
import math

# IO密集型任务
def fetch_data(url):
    import urllib.request
    with urllib.request.urlopen(url) as resp:
        return len(resp.read())

# CPU密集型任务
def cpu_compute(n):
    result = 0
    for i in range(n):
        result += math.sqrt(i)
    return result

# 线程池处理IO密集型
def run_io_tasks():
    urls = ["https://httpbin.org/delay/1"] * 5
    with ThreadPoolExecutor(max_workers=5) as executor:
        results = list(executor.map(fetch_data, urls))
        print(f"IO任务结果: {results}")

# 进程池处理CPU密集型
def run_cpu_tasks():
    numbers = [5_000_000] * 4
    with ProcessPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(cpu_compute, numbers))
        print(f"CPU任务结果: {results[:2]}")

if __name__ == '__main__':
    start = time.time()
    run_io_tasks()
    print(f"IO任务耗时: {time.time()-start:.2f}s")

    start = time.time()
    run_cpu_tasks()
    print(f"CPU任务耗时: {time.time()-start:.2f}s")

7.2 submit vs map

Executor有两种提交任务的方式:

submit:提交单个任务,返回Future对象。可以附加回调,处理异常更灵活。

python
from concurrent.futures import ThreadPoolExecutor, as_completed
import random
import time

def risky_task(task_id):
    time.sleep(random.uniform(0.1, 0.5))
    if random.random() < 0.3:
        raise ValueError(f"任务{task_id}失败了")
    return f"任务{task_id}成功"

with ThreadPoolExecutor(max_workers=4) as executor:
    # submit + as_completed:按完成顺序处理
    futures = {executor.submit(risky_task, i): i for i in range(10)}

    for future in as_completed(futures):
        task_id = futures[future]
        try:
            result = future.result()
            print(f"  {result}")
        except ValueError as e:
            print(f"  错误: {e}")

map:批量提交,按输入顺序返回结果。语法简洁,但异常会延迟到迭代时抛出,且不能单独处理每个任务的成功/失败。

python
from concurrent.futures import ProcessPoolExecutor

def square(n):
    return n * n

with ProcessPoolExecutor(max_workers=4) as executor:
    # map按输入顺序返回
    results = list(executor.map(square, range(10)))
    print(results)  # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

7.3 Future对象与回调机制

Future是concurrent.futures的核心抽象。它代表一个异步操作的最终结果,提供了以下能力:

python
from concurrent.futures import ThreadPoolExecutor
import time

def long_running_task(n):
    time.sleep(n)
    return f"耗时{n}秒的任务完成"

# Future的核心方法
with ThreadPoolExecutor(max_workers=2) as executor:
    future = executor.submit(long_running_task, 2)

    # 检查状态
    print(f"运行中: {future.running()}")   # True
    print(f"已完成: {future.done()}")      # False

    # 添加回调(任务完成时自动调用)
    def on_complete(fut):
        try:
            print(f"回调收到结果: {fut.result()}")
        except Exception as e:
            print(f"回调收到异常: {e}")

    future.add_done_callback(on_complete)

    # 阻塞等待结果(可设超时)
    result = future.result(timeout=5)
    print(f"直接获取: {result}")

    # 取消任务(如果还没开始执行)
    future2 = executor.submit(long_running_task, 5)
    cancelled = future2.cancel()
    print(f"取消成功: {cancelled}")  # True或False

回调机制在实际工程中非常有用。比如你可以在任务完成后自动触发下一步处理,而不需要阻塞等待:

python
from concurrent.futures import ThreadPoolExecutor
import time

def download(url):
    time.sleep(1)  # 模拟下载
    return f"downloaded:{url}"

def parse(content):
    time.sleep(0.5)  # 模拟解析
    return f"parsed:{content}"

def save(data):
    time.sleep(0.3)  # 模拟保存
    return f"saved:{data}"

# 回调链:下载 -> 解析 -> 保存
with ThreadPoolExecutor(max_workers=3) as pool:
    future = pool.submit(download, "https://example.com/data")

    def on_downloaded(fut):
        content = fut.result()
        parse_future = pool.submit(parse, content)
        parse_future.add_done_callback(on_parsed)

    def on_parsed(fut):
        data = fut.result()
        save_future = pool.submit(save, data)
        save_future.add_done_callback(
            lambda f: print(f"全链路完成: {f.result()}")
        )

    future.add_done_callback(on_downloaded)

    time.sleep(3)  # 等待整个链路完成

回调地狱不是Promise的专利,Future用不好一样能写出面条代码。适时用async/await重构。

八、三大并发模型选型指南

8.1 对比表

维度多进程多线程协程
并行度真并行(多核)伪并行(GIL)伪并行(单线程)
内存开销大(~10-100MB/进程)中(~8MB栈/线程)小(~KB级/协程)
切换开销大(内核态切换)中(内核态切换)小(用户态切换)
通信成本高(IPC)低(共享内存)极低(同一事件循环)
适用场景CPU密集型IO密集型(简单)IO密集型(高并发)
编程复杂度高(锁问题)高(异步思维)
调试难度高(竞态条件)高(调用栈不连续)
扩展性有限(进程数)有限(线程数)高(万级协程)
第三方库兼容差(需要async库)

8.2 选型决策步骤

怕浪猫总结了一个"五步决策法",照着走就行:

第一步:判断任务类型。CPU密集型选多进程,IO密集型选多线程或协程。

第二步:评估并发规模。百级以下选多线程,千级以上选协程。

第三步:检查依赖库。如果核心依赖库没有async版本(比如某些数据库驱动),要么换库,要么用多线程+异步桥接。

第四步:评估团队熟悉度。如果团队对asyncio不熟悉,先用多线程+concurrent.futures上手,后续再迁移。

第五步:考虑混合架构。CPU密集型部分用多进程,IO密集型部分用协程,两者通过消息队列解耦。

python
# 混合架构示例:多进程处理CPU任务 + 主线程异步IO
import asyncio
from concurrent.futures import ProcessPoolExecutor
import math

async def async_coordinator():
    loop = asyncio.get_event_loop()
    executor = ProcessPoolExecutor(max_workers=4)

    # 把CPU密集型任务丢给进程池
    futures = []
    for i in range(4):
        future = loop.run_in_executor(
            executor,
            cpu_heavy_compute,
            2_000_000 + i * 500_000
        )
        futures.append(future)

    # 异步等待所有结果
    results = await asyncio.gather(*futures)
    executor.shutdown()
    return results

def cpu_heavy_compute(n):
    result = 0
    for i in range(n):
        result += math.sqrt(i)
    return result

if __name__ == '__main__':
    results = asyncio.run(async_coordinator())
    print(f"结果: {results}")

没有银弹,只有合适的场景用合适的工具。混合架构往往是最务实的选择。

九、实战踩坑录

坑1:协程没await

python
# 错误代码
async def batch_process():
    tasks = []
    for item in items:
        # 忘记await,协程没执行
        process_item(item)  # 这里应该用create_task

# 正确代码
async def batch_process():
    tasks = []
    for item in items:
        task = asyncio.create_task(process_item(item))
        tasks.append(task)
    await asyncio.gather(*tasks)

坑2:在协程里调用阻塞IO

python
import asyncio
import time

async def bad_handler():
    # requests是同步库,会阻塞事件循环!
    # import requests
    # resp = requests.get("https://httpbin.org/delay/1")

    # 正确做法:用aiohttp
    import aiohttp
    async with aiohttp.ClientSession() as session:
        async with session.get("https://httpbin.org/delay/1") as resp:
            return await resp.text()

# 如果非要用同步库,用run_in_executor
async def workaround_handler():
    import requests
    loop = asyncio.get_event_loop()
    # 把同步阻塞调用丢到线程池
    result = await loop.run_in_executor(
        None,  # 默认线程池
        lambda: requests.get("https://httpbin.org/delay/1").text
    )
    return result

坑3:进程池里用了不可序列化的对象

python
from concurrent.futures import ProcessPoolExecutor

class BadWorker:
    def __init__(self, name):
        self.name = name

    def __call__(self, x):
        return x * 2

# 这会报错:BadWorker无法pickle
# worker = BadWorker("test")
# with ProcessPoolExecutor() as pool:
#     result = pool.map(worker, range(10))

# 正确做法:用顶层函数
def top_level_worker(x):
    return x * 2

with ProcessPoolExecutor() as pool:
    result = list(pool.map(top_level_worker, range(10)))

坑4:Lock跨进程使用

python
from multiprocessing import Process, Lock
import threading

# 错误:用threading.Lock给多进程加锁
# threading.Lock在fork后不会共享,每个进程有独立的锁
# lock = threading.Lock()  # 错!

# 正确:用multiprocessing.Lock
mp_lock = Lock()

def safe_write(filename, content):
    with mp_lock:
        with open(filename, 'a') as f:
            f.write(content + '\n')

if __name__ == '__main__':
    procs = [
        Process(target=safe_write, args=('output.txt', f'line{i}'))
        for i in range(5)
    ]
    for p in procs:
        p.start()
    for p in procs:
        p.join()

坑5:事件循环嵌套

python
import asyncio

async def outer():
    # 错误:在协程里调用asyncio.run会报错
    # "asyncio.run() cannot be called from a running event loop"
    # asyncio.run(inner())  # 错!

    # 正确:直接await
    result = await inner()
    return result

async def inner():
    await asyncio.sleep(0.1)
    return "inner result"

# asyncio.run只能在最外层调用
asyncio.run(outer())

踩坑不可怕,可怕的是同一个坑踩两次。记录下来,让未来的自己少走弯路。

十、性能基准测试

怕浪猫用一台4核8G的机器做了组对比测试,任务是对一个列表中的100万个数字做质数判断(CPU密集型)和1000个URL的HTTP请求(IO密集型):

方案CPU密集型(100万质数)IO密集型(1000请求)
串行8.2s100s
多线程(4线程)9.1s(比串行还慢)3.2s
多进程(4进程)2.3s3.5s
协程(1000协程)8.0s(单线程无优势)1.8s
协程+uvloop8.0s1.1s
多进程+协程2.1s1.5s

结论很明确:CPU密集型任务,多进程是唯一正解;IO密集型任务,协程碾压一切;混合型任务,多进程+协程的组合最优。

收藏引导

这篇文章从操作系统IO模型一路讲到Python并发工程实践,信息量很大。如果你觉得有用,先收藏起来,后面遇到并发问题随时翻出来对照。

互动引导

你在实际项目中用的是哪种并发方案?遇到过什么坑?欢迎在评论区聊聊你的经历。如果对文章中某个点有疑问,也欢迎提问,怕浪猫会一一回复。

追更引导

这是Python实战训练营系列的第6期。如果你跟着系列看到这里,说明你对Python底层的求知欲很强——这是好事。下一期我们会进入异步框架与并发模式,讲aiohttp、FastAPI异步、异步数据库驱动、asyncio生产环境部署等内容,难度会再上一个台阶。关注我,别掉队。

系列进度 6/16

下章预告:异步框架与并发模式——aiohttp实战、FastAPI异步原理、异步数据库操作、生产环境asyncio部署方案。

怕浪猫说

异步编程是Python进阶的一道分水岭。很多人会写async def,但不理解事件循环、不懂得区分Task和Future、不知道GIL什么时候释放什么时候不释放。写出来的异步代码跑起来比同步还慢,还不知道问题出在哪。

怕浪猫的建议是:先把这篇的底层原理吃透,再动手写异步代码。理解了IO多路复用和事件循环的运行机制,你看asyncio的源码就像看小说一样流畅。不理解原理就去用框架,那不是在写代码,是在碰运气。

代码写多了就知道,真正值钱的不是会用什么框架,而是理解框架背后的原理。原理通了,换什么框架都能快速上手。原理不通,框架换十个也是一样的水平。

下期见。

热爱生活,喜好美食,追求未来!