Skip to content

Python 协程:一个线程里的"多任务魔法"

引言:为什么一个厨师能比十个厨师还快?

想象一家餐厅只有一个厨师,却要同时做三道菜:炖汤、炒菜、烤肉。

笨办法(同步模式):先花 40 分钟盯着汤炖好,再炒菜,再烤肉。客人等了一个小时才吃上饭。

聪明办法(协程模式):把汤放上火(需要 40 分钟,但不用盯着),立刻去炒菜;菜炒到一半要等油温升高,转身去看看烤肉;烤肉要烤 20 分钟,又回来翻两下菜……一个厨师在多个任务之间灵活切换,把"等待时间"全部利用起来

这就是**协程(Coroutine)**的核心思想:在一个线程内,多个任务主动让出执行权,互相协作,把等待的时间用来干别的事

协程又被称为微线程纤程。它的概念早在上世纪就被提出来了,但直到最近几年才在 Lua、Python、Go、JavaScript 等语言中大放异彩,成为高并发编程的主流方案。

这一篇,我们从"子程序调用"讲起,说清楚协程到底是什么、比多线程强在哪、Python 如何用生成器(generator)实现协程,最后通过生产者-消费者模型的完整实战和常见误区解析,让你彻底掌握这个异步编程的基石。


一、先搞清楚:子程序调用是"一条道走到黑"

1.1 普通的函数调用:像坐电梯

在所有编程语言中,函数(也叫子程序)的调用是层级式的:

A 调用 B,B 调用 C,C 执行完返回 B,B 执行完返回 A。

这就像坐电梯:从 1 楼上到 2 楼,再上到 3 楼,然后必须一层一层下楼返回,调用顺序是明确的、不可逆的

底层原理是栈(Stack):每调用一个函数,就把当前状态"压栈",函数返回时"弹栈"。一个线程就是执行一串子程序调用

普通函数调用的特点:一个入口,一次返回。进去了就必须执行完才能出来。

1.2 协程调用:像多个任务的"接力赛"

协程看起来也是函数,但它在执行过程中可以主动"暂停"自己,转而去执行别的函数,之后在适当的时候再回来接着执行

注意,这里的"暂停去执行别的"不是函数调用,更像 CPU 的中断——当前任务说"我先让一下",把执行权交出去。

举个例子:

python
def A():
    print('1')
    print('2')
    print('3')

def B():
    print('x')
    print('y')
    print('z')

如果用协程方式执行,A 打印到一半可能停下来去执行 B,B 执行一半又切回 A,结果可能是:

1
2
x
y
3
z

注意:A 中并没有调用 B!是 A 主动让出执行权,B 才有机会运行。这就是协程比函数调用难理解的地方——执行权的转移是"协商"出来的,不是"调用"出来的

1.3 生活化对比

模式类比
普通函数调用打电话:你打给 A,A 打给 B,B 挂断后 A 才能挂,A 挂断你才能挂
多线程多个厨师各做各的菜,互不干扰,但厨房要多雇人
协程一个厨师在多口锅之间来回切换,哪口锅不用盯就去忙别的

二、协程 vs 多线程:看起来像,本质完全不同

2.1 表面相似,内核迥异

A、B 交替执行看起来很像多线程,但协程有一个本质特征:全程只有一个线程在执行

对比项多线程协程
切换方式操作系统强制切换(抢占式)程序自己主动让出(协作式)
切换开销大(要保存/恢复寄存器、切换栈)极小(就是一次函数跳转)
线程数量多个只有一个
共享资源需要加锁,否则数据会混乱不需要锁,单线程不存在写冲突
能利用多核单个协程线程不能(需配合多进程)

2.2 协程的两大核心优势

优势一:极高的执行效率

线程切换是操作系统干的,每次切换要保存当前线程的全部状态(寄存器、栈指针等),再加载另一个线程的状态,开销很大。而协程切换是程序自己控制的,就是一次普通的函数跳转,开销几乎可以忽略

线程越多,切换越频繁,协程的性能优势就越明显。一台普通服务器开几千个线程可能就卡死了,但跑几万个协程依然轻松。

优势二:不需要锁机制

多线程最头疼的问题是共享数据冲突:两个线程同时改一个变量,结果不可预料,必须加锁。锁用不好就是死锁、性能瓶颈。

协程只有一个线程,不存在"同时写"的问题。控制共享资源只需判断状态就好,代码简单,效率又高。

生活化理解:多线程像多个工人共用一个工具箱,必须排队上锁取工具;协程像一个人干活,工具随手拿,永远不用抢。

2.3 那多核 CPU 怎么办?

协程只有一个线程,天然只能用一个 CPU 核。解决办法很简单:多进程 + 协程

  • 开多个进程,每个进程跑在一个 CPU 核上(利用多核);
  • 每个进程内部跑大量协程(发挥协程高效率)。

这种组合能获得极高的性能,是 Nginx、Node.js、Go 等高并发服务的经典架构思路。


三、Python 如何支持协程:从生成器(generator)说起

3.1 生成器是协程的"前身"

Python 对协程的支持是通过 生成器(generator) 实现的。

生成器最基本的用法:用 for 循环迭代,或者用 next() 逐个取值:

python
def countdown(n):
    while n > 0:
        yield n
        n -= 1

for i in countdown(3):
    print(i)  # 输出 3、2、1

yield 关键字的作用:让函数执行到这里时"暂停",把值交出去,下次再从这里继续执行。这个"暂停-恢复"的能力,正是协程的根基。

3.2 yield 还能"收快递":send() 方法

普通的 yield 只能"往外送值",但 Python 的 yield 还能接收调用者传进来的参数——通过生成器的 send() 方法:

python
def echo():
    while True:
        received = yield          # 暂停,等待外面 send 进来的值
        print('收到了:', received)

g = echo()
next(g)                           # 必须先"启动"生成器,执行到第一个 yield
g.send('你好')                    # 输出:收到了: 你好
g.send('协程')                    # 输出:收到了: 协程

关键细节:

  1. 第一次必须先调用 next(g)g.send(None),让生成器执行到第一个 yield 处"就位",这叫激活(启动)生成器
  2. 之后每次 g.send(x),会把 x 作为 yield 表达式的返回值传进去,生成器从上次暂停处继续执行;
  3. 执行到下一个 yield 时再次暂停,等待下一次 send

生活化理解:生成器像一个收发室。yield 是窗口——既能往外递包裹(返回值),也能收外面塞进来的包裹(send 的值)。


四、实战:用协程重写生产者-消费者模型

4.1 传统做法的痛点

生产者-消费者模型是并发编程的经典问题:生产者不断产生消息,消费者不断处理消息。

传统多线程做法:

  • 一个线程生产消息,放入队列;
  • 另一个线程从队列取消息消费;
  • 控制队列的读写,用条件变量控制等待。

问题:一不小心就死锁,代码复杂,调试困难。

4.2 协程版:无锁、单线程、高效率

改用协程:生产者生产消息后,直接通过 send() 跳到消费者执行;消费者处理完,通过 yield 把结果传回,又跳回生产者。全程一个线程,无锁:

python
def consumer():
    r = ''
    while True:
        n = yield r              # 暂停,等生产者 send 消息进来;r 是回传给生产者的结果
        if not n:
            return
        print('[CONSUMER] 正在消费 %s...' % n)
        r = '200 OK'             # 模拟消费结果

def produce(c):
    c.send(None)                 # 第 1 步:启动消费者,让它执行到第一个 yield 就位
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] 正在生产 %s...' % n)
        r = c.send(n)            # 第 2 步:把消息发给消费者,同时切换过去执行
        print('[PRODUCER] 消费者返回: %s' % r)
    c.close()                    # 第 3 步:不生产了,关闭消费者

c = consumer()
produce(c)

执行结果:

[PRODUCER] 正在生产 1...
[CONSUMER] 正在消费 1...
[PRODUCER] 消费者返回: 200 OK
[PRODUCER] 正在生产 2...
[CONSUMER] 正在消费 2...
[PRODUCER] 消费者返回: 200 OK
[PRODUCER] 正在生产 3...
[CONSUMER] 正在消费 3...
[PRODUCER] 消费者返回: 200 OK
[PRODUCER] 正在生产 4...
[CONSUMER] 正在消费 4...
[PRODUCER] 消费者返回: 200 OK
[PRODUCER] 正在生产 5...
[CONSUMER] 正在消费 5...
[PRODUCER] 消费者返回: 200 OK

4.3 执行流程逐步拆解

consumer 是一个生成器。把 c 传入 produce 后:

  1. c.send(None):启动生成器,consumer 从头执行到 n = yield r 处暂停(此时还没收到消息,n 未被赋值);
  2. c.send(n):生产者把消息 n 传给消费者,yield 表达式返回 n,消费者继续执行:判断、打印、设置结果 r = '200 OK',然后循环回到 n = yield r 再次暂停,把结果 r 通过 yield 传回给生产者
  3. 生产者拿到 send() 的返回值(即 r),继续生产下一条消息;
  4. c.close():生产者决定收工,关闭生成器,consumer 中的 yield 抛出 GeneratorExit,函数直接结束。

整个流程无锁、单线程,生产者和消费者"你一步我一步"协作完成任务——这就是"协程"名字的由来:协作式的程序,而不是线程那种"抢占式"的多任务。

生活化理解:就像两个人打乒乓球,球(执行权)在两人之间来回传递,每次只有一个人击球,节奏完全由双方协商,不需要裁判(操作系统)强行干预。


五、从生成器协程到 async/await:协程的进化史

上面的 yield 协程写法是 Python 2/3 早期的"手工时代"。Python 3.5 之后,协程有了正式的语法:async defawait

python
import asyncio

async def consumer():
    while True:
        n = await get_message()   # 等待消息,让出执行权
        print('消费', n)

# 现代协程由事件循环(asyncio)统一调度,不再需要手动 send

演进脉络:

阶段写法特点
生成器协程yield + send()手动切换,理解原理用
@asyncio.coroutineyield from过渡语法(Python 3.4,已淘汰)
原生协程async def + awaitPython 3.5+,标准写法

async/await 底层依然是生成器那一套"暂停-恢复"机制,只是把调度交给了**事件循环(Event Loop)**自动完成,不用再手写 send()理解了本文的 yield 协程,再学 asyncio 会顺理成章


六、常见误区解析

误区一:协程就是线程的一种

错误。协程和线程是两种完全不同的东西。线程由操作系统调度,是抢占式的;协程由程序自己调度,是协作式的。一个线程里可以跑成千上万个协程,但协程不是"更小的线程",它的切换不涉及操作系统。

误区二:协程能让 CPU 计算变快

错误。协程的优势在于消除等待(IO 等待、网络等待),让单线程在等待时去干别的活。如果是纯 CPU 计算(比如算圆周率),任务没有"等待"可言,协程毫无用武之地,甚至比普通串行还慢(多了切换开销)。

正确认知:协程是 IO 密集型任务的利器(爬虫、Web 服务、数据库操作),不是 CPU 密集型任务的救星(后者请用多进程)。

误区三:直接对生成器 send 数据就能跑

错误。新生成的生成器必须先"激活"——调用一次 next(g)g.send(None),让它执行到第一个 yield 处就位,之后才能 send 真正的数据。直接 g.send(1) 会报错:

TypeError: can't send non-None value to a just-started generator

误区四:协程里可以随便调用普通阻塞函数

错误。协程高效的前提是:任务在等待时会主动让出执行权。如果在协程里调用了普通的阻塞函数(比如 time.sleep()、同步的 requests.get()),整个线程会被卡住,所有协程一起停摆。

正确做法:协程中要使用异步版本的库——asyncio.sleep() 替代 time.sleep()aiohttp 替代 requests

误区五:协程不需要考虑并发安全问题

不完全对。协程确实不用加锁,但不代表数据绝对安全。如果在两个 await(或 yield)之间,共享变量的状态被其他协程修改,依然可能出问题。只是这种"交错"点很明确(只在 await/yield 处),比多线程的"随时可能被打断"容易分析得多。


七、小结

  • 协程是一个线程内的协作式多任务:任务在执行中可以主动暂停,让出执行权,之后再恢复;
  • 对比多线程,协程有两大优势:切换开销极小无需锁机制;代价是单线程无法利用多核,需配合多进程
  • Python 通过生成器实现协程:yield 负责暂停和传出结果,send() 负责传入数据和恢复执行,close() 负责关闭;
  • 生产者-消费者的协程版全程单线程、无锁,是理解"协作式调度"的最佳案例;
  • 现代 Python 用 async def / await + asyncio 事件循环写协程,但底层原理正是本文的"暂停-恢复"机制;
  • 记住适用边界:IO 密集用协程,CPU 密集用多进程

最后,借用计算机科学大师 Donald Knuth 的一句话总结协程的地位:

"子程序就是协程的一种特例。"

普通函数是"进去必须出来"的死板执行,而协程是"随时可以暂停和恢复"的自由执行——函数只是协程从不暂停的特殊情况。理解了协程,你就拿到了通往异步 IO 和 asyncio 大门的钥匙。