更新页面 Python 并发语法
页面 kb/python-concurrency · 修改(rev 14) · · 2026-10-25 23:21
body
@@ -547,12 +547,104 @@ ### 协程是怎么实现的 -`asyncio` 的协程是三样东西拼起来的:Future、Task 和事件循环。下面先用一个小例子看协程怎么停下、怎么接着跑,再从零写一个 40 行的最小事件循环,逐步走一遍。看懂这一节,`await` 底下发生了什么就清楚了。 +`asyncio` 的协程是三样东西拼起来的:Future、Task 和事件循环。协程能「停下、过一会儿再接着跑」,靠的是 Python 的生成器。所以这一节先讲生成器,再看协程怎么停下、怎么接着跑,最后从零写一个 40 行的最小事件循环,逐步走一遍。看懂这一节,`await` 底下发生了什么就清楚了。 + +#### 先认识生成器 + +> [!term] 生成器(generator) +> 调用一个函数体里写了 `yield` 的函数,会得到一个生成器对象,这时函数体一行都还没执行。这个对象能跑一段、停下、交出一个值,下次被要求时,再从停下的地方接着跑。普通函数一旦返回,局部变量就没了。生成器停下时,局部变量和跑到了哪一行,都留在生成器对象里。 + +`yield` 就是生成器里「停在这里,并把后面这个值交给外面」的意思。外面用 `next(g)` 要下一个值,`g` 就从上次停下的地方接着跑,跑到下一个 `yield` 停下: + +```python +def count_to_two(): + print(" 生成器里:开始") + yield 1 + print(" 生成器里:从第一个 yield 后面接着跑") + yield 2 + print(" 生成器里:跑到函数末尾") + +g = count_to_two() # 只建了生成器对象,函数体一行都没执行 +print("外面:建好了", type(g).__name__) +print("外面:拿到", next(g)) # 跑到第一个 yield 停下,把 1 交出来 +print("外面:拿到", next(g)) # 从停下的地方接着跑,到第二个 yield 停下 +try: + next(g) # 接着跑到函数末尾,没有值可交了 +except StopIteration: + print("外面:收到 StopIteration,生成器跑完了") +``` + +实测输出: + +``` +外面:建好了 generator + 生成器里:开始 +外面:拿到 1 + 生成器里:从第一个 yield 后面接着跑 +外面:拿到 2 + 生成器里:跑到函数末尾 +外面:收到 StopIteration,生成器跑完了 +``` + +- `count_to_two()` 只建了生成器对象,函数体一行都没执行,所以「开始」排在「建好了」后面。 +- 第一次 `next(g)` 从函数开头跑,打印「开始」,到 `yield 1` 停下,把 1 交给外面。 +- 第二次 `next(g)` 从 `yield 1` 的下一行接着跑,打印一行,到 `yield 2` 停下,交出 2。 +- 第三次 `next(g)` 接着跑到函数末尾,已经没有值可交了,于是抛出 `StopIteration`。 + +> [!term] StopIteration +> Python 内置的一个异常,意思是「没有下一个值了」。生成器的函数体跑到末尾或者执行了 `return`,再对它调 `next()` 或 `send()`,就会抛出它。`return` 后面的值放在这个异常的 `value` 属性里。它是一个正常的结束信号,不是出错。 + +`for` 循环背后就是这件事:反复调 `next`,收到 `StopIteration` 就结束循环。所以平时用 `for` 遍历生成器,从来看不到这个异常: + +```python +for x in count_to_two(): # for 循环就是反复调 next,收到 StopIteration 就结束 + print("for 拿到", x) +``` + +实测打印「for 拿到 1」「for 拿到 2」,中间夹着生成器里的那几行,然后循环自己结束了。 + +外面除了用 `next` 要值,还能用 `send` 往生成器里传一个值进去。`yield` 本身也是一个表达式:它交出一个值,停下,等外面下次 `send(v)` 时,`v` 就成了这个 `yield` 表达式的值: + +```python +def echo(): + received = yield "第一次交出的值" + print(" 生成器里:yield 表达式的值是", received) + return "函数的返回值" + +g = echo() +print("外面:拿到", g.send(None)) # 第一次只能 send(None),效果和 next(g) 一样 +try: + g.send("你好") # "你好" 成了 yield 表达式的值,然后函数跑到 return +except StopIteration as e: + print("外面:StopIteration,返回值在 e.value 里:", e.value) +``` + +实测:第一次拿到「第一次交出的值」,生成器里打印「yield 表达式的值是 你好」,最后外面收到 `StopIteration`,`e.value` 是「函数的返回值」。第一次只能 `send(None)`,因为生成器还没跑到任何 `yield`,没有地方接收传进来的值,这时 `send(None)` 和 `next(g)` 效果一样。 + +协程的返回值也是这样交出来的: + +```python +async def add_one(): + return 41 + 1 + +coro = add_one() +try: + coro.send(None) # 没有 await,一次就跑到 return +except StopIteration as e: + print("协程的返回值也在 StopIteration 里:", e.value) +``` + +实测打印「协程的返回值也在 StopIteration 里: 42」。`asyncio` 的 Task 就是靠接住这个异常,拿到协程的结果。 #### 协程怎么停下、怎么接着跑 -> [!term] 生成器(generator) -> 函数体里有 `yield` 的函数。调用它不执行函数体,只得到一个生成器对象。每次调它的 `send()`,它就从上次停下的地方接着跑,跑到下一个 `yield` 停下,`yield` 出来的值就是 `send()` 的返回值。函数跑完时,`send()` 抛出 `StopIteration`。 - -协程对象也有 `send()`,用法和生成器一样。`await` 一个对象时,Python 会调用这个对象的 `__await__()`,它通常就是一个生成器,里面的 `yield` 就是协程的暂停点: +协程对象也有 `send()`,用法和生成器一样。区别是协程的函数体里不写 `yield`,它在 `await` 处停下。`await` 后面能写什么,有一个专门的说法: + +> [!term] 可等待对象(awaitable) +> 能写在 `await` 后面的对象:协程、`asyncio` 的 Task 和 Future,或者任何定义了 `__await__` 方法的对象。`__await__` 要返回一个迭代器,通常就是一个生成器。 + +> [!term] 挂起点(suspension point) +> 协程执行到这里会停下、把控制权交给外面的那个位置。在 Python 里,`await x` 会调用 `x.__await__()`,协程就停在这个生成器里的 `yield` 上。`yield` 出来的值穿过协程,成为外面那次 `send()` 的返回值。外面下次再 `send()`,协程就从这个 `yield` 的下一行接着跑。 + +下面的 `Box` 就是一个最小的可等待对象(实测 `isinstance(Box(), collections.abc.Awaitable)` 是 True,`Box().__await__()` 是一个生成器): ```python @@ -562,5 +654,5 @@ def __await__(self): if not self.done: - yield self # 暂停点:把自己交给外面调 send 的人 + yield self # 挂起点:协程停在这里,self 交给外面调 send 的人 return self.value # 恢复以后,这就是 await 表达式的值