async python 完全理解 - 上

很快昂, 昨天我们成功攻略了大书库[1], 那么今天就来研究一下 python 里的异步吧.

主要最近还是没有什么时间, 看看现在有时间不是已经第一时间赶来敷衍你们了吗. (瞎说什么大实话呢)

没这么可爱(

协程

在开始之前, 先来了解一下各个语言都是如何实现自己的异步的吧.

对于现代的编程语言, 大致有四种不同的流派来实现异步, (绝对不是因为我只见过这四种).

  1. 单线程事件循环协程. 一个典型的例子就是 JavaScript 了, 使用 "宏任务/微任务" 来进行调度. Python 也属于这类.

  2. 轻量线程. go 语言大名鼎鼎的 Goroutine 就是用的轻量线程, 使用少量的 OS 线程来调度大量的轻量线程.

  3. 响应式. 使用 "发布-订阅" 模式来实现异步. 感觉有点少见, 一般在没有内置异步的编程语言里可以看到.

  4. 零成本抽象. Rust 特有的奇葩异步方式, 直接把 async 编译成状态机来进行无栈协程, 相比 Goroutine, 进一步砍掉了 2KiB 栈开销. (但是异步 Rust 写过的都懂, 当生命周期注解遇上异步, 那简直就不像是人类能看懂的东西. 再加上本人因长期接触 Python 导致的目光呆滞, 口齿不清, 智力退化... 现在看到这个就害怕.)

Python 使用的这个单线程异步的模型相较而言是比较简单的, 核心部分就是一个死循环一直重复的从要执行的任务队列里取任务来执行, 倒是跟 Celery 这样的任务队列一模一样.

不过嘛, 这个模型简单是简单, 但是功能就相对而言比较简陋了, 因为他并不是抢占式的, 事件循环并不会强行挂起某个耗时任务来给其他的任务让路 (比如说 Linux 内核的调度就是抢占式的, 就算你写了个死循环 Linux 内核在发现他运行了这么久之后, 会主动打断他, 把 CPU 让给其他的程序), 所有的异步操作都需要主动让出控制权才不会导致整个事件循环卡死.

对于 JavaScript 这样的天生就是纯异步的语言来说倒是没什么, 但是对于设计之初就是同步的 Python 来说就不是这样的了. 同步的操作并不会打断自己, 就算只是在干等也会导致其他的异步操作卡住.

事件循环

asyncio

Python 的官方异步实现叫 asyncio, 如果你使用的是正经的 Python 解释器 (C 语言写的 CPython, Python 写的 PyPy (什么叫我实现了我自己?), Rust 写的 RustPython 等), 那么他应该是直接在标准库里的.

虽说这个是异步 Python 的参考实现, 但是他的性能其实还是不太行的, 在高性能场景一般会用 uvloop 来替代这个标准库 (在 Python 里追求高性能? 是不是弄错了些什么?). 他的 asyncio.AbstractEventLoop 接口完全是使用 CPython (一个把 Python 编译成 C 语言的编译器) 实现的, 并且底层还是 Node.js 同款的 libuv. 某著名 web 框架, FastAPI 的标准依赖就包含这个库. (实际上对于 Python 的现代的 web 框架而言, 大部分链路都是 C/Rust 接管的, 只有业务逻辑层才是 Python)

虽说跑不快, 但是总归还是标准库, 并且接口设计也非常符合直觉. (这个时候... 算了, 不点名了)

其实 asyncio 是面向实用性的, 他大概有 15k 行的 Python 和 4k 行的 C. 对于学习目的的话, David Beazley 大佬 (Python Cookbook 的作者, 很多人的 Python 启蒙书, 虽说我也没看过) 写的 Curio 感觉会更加合适, 只用 600 行就实现了整个事件循环.

那...确实

于 async/await 之前

直接上手看 asyncio 的代码可能会非常的困惑, 那么不妨先来看看 "古人" 是怎么写异步 Python 的.

async/await 语法首次出现在 Python 3.4, 那么在此之前呢? 没错, 是 Python 2.5 的生成器语法 yield 关键字, 在 Python 2.5 之后的 yield 不仅可以恢复上次 yield 时的上下文, 还有了 .send(), .throw() 等方法, 可以往里面传入数据或者是异常.

那么再往前一点呢? 没错, 回调函数. 因为 Python "万物皆对象" (Everything is an object), Python 里所有的类型都源自底层的 PyObject C 语言结构体. 所以在 Python 里函数天生就是 "第一类对象", 可以随便找个容器把函数存起来, 也可以随便修改某个函数的属性. 除此之外, 类和 type 本身也是对象的一个子类, 他们被称为 Metaclass (元类), 是 "可以生成类的类", type 是所有类的元类, 同时 type 也是 type 类的一个实例. (怎么这么绕?)

# 1.py

# 创建类的标准方式
class C1:
    def greet(self, *_, **__):
        print("Ciallo~(∠・ω< )⌒★")


# 从 type 实例化一个类
C2 = type("C2", (), {"greet": lambda *_, **__: print("Ciallo~(∠・ω< )⌒★")})

C1().greet()
C2().greet()

# type 是 type 的实例
print(isinstance(type, type))
python 1.py
Ciallo~(∠・ω< )⌒★
Ciallo~(∠・ω< )⌒★
True

正因如此, Python 也创造了 "一个空 int 占用 28 字节" 的神话. (正如前面说的 int 也是一个对象, int() 实际上会返回 0)

python -c 'import sys; print(sys.getsizeof(int()))'
28

以前的程序员 VS 现在的程序员

扯远了, 总之因为 Python 的函数可以像对象一样随便操作, 于是就有了回调的写法, 类似于下面这样. 但是写过 JS 应该都知道, 滥用回调的后果只有一个, 那就是回调地域.

def fetch_data():
    data = make_network_request()
    date.add_callback(process_data)
    data.add_errback(process_error)

def process_data(result);
    print(result)

def process_error(failure):
    print(failure)

为此, yield 异步作为他的继任者应运而生了. 其实 yield 协程也分为两个阶段, 一个是 Python 2.5 的 yield 协程, 另一个是更加现代的 Python 3.4 yield from 协程. (感觉这个 yield from 协程更像是给 async/await 铺路, 毕竟下一个主要版本就直接引入了这个语法, 所以为什么不直接使用最终形态呢?)

有了 yield 生成器之后, 我们就可以轻松的用几行实现一个交错运行的事件循环:

# 2.py

import time


def task1():
    for _ in range(3):
        data = yield
        do_calculate()
        print(data)


def task2():
    for _ in range(5):
        data = yield
        do_calculate()
        print(data)


def event_loop(t):
    while t:
        # 在 t 的副本上循环
        for i in list(t):
            try:
                d = get_some_data(i)
                i.send(d)
            except StopIteration:
                t.remove(i)


# 模拟取值, 如果输入第一个函数的生成器对象则返回 1145, 否则返回 1919
get_some_data = lambda g: 1145 if g.gi_code is task1.__code__ else 1919
# 模拟耗时任务
do_calculate = lambda: time.sleep(0.1)


# 创建生成器对象 (不是执行函数)
t1 = task1()
# .send(None) 预激这个生成器, 让他停在第一个yield
t1.send(None)
t2 = task2()
t2.send(None)
t = [t1, t2]
event_loop(t)

运行这段代码

python 2.py
1145
1919
1145
1919
1145
1919
1919
1919

突然想起来, "生成器" 这个东西好像是 Python 里比较高级的内容, 还是稍微介绍一下吧.

Python 里的 "生成器"(Generator) 是一种比较特殊的 "迭代器", 这也意味着, 他更普通的迭代器一样, 进行一次迭代才返回一个值.

生成器大致有两种不同的写法, 第一种就是上面写的带有 yield 关键字的函数, 调用这个函数之后返回的不是函数的结果, 而是一个生成器对象, 然后对这个生成器对象进行迭代就可以获得他的值, 比如说下面这样:

def count_down(n):
    while n > 0:
        yield n  # 迭代器运行到这里就会被暂停, 同时返回 n
        n -= 1


generator = count_down(3)
# 使用 next() 方法手动推进迭代器 (其实就是调用迭代器的 __iter__() 方法, 听不懂就算了吧)
print(next(generator))  # 3
print(next(generator))  # 2
print(next(generator))  # 1
# print(next(generator))  # 抛出 StopIteration 异常

另一种类似于列表推导式, 只不过从方括号变成了圆括号 (至于列表推导式... 算了就不介绍了):

generator = (i for i in range(3, 0, -1))
print(next(generator))  # 3
print(next(generator))  # 2
print(next(generator))  # 1
# print(next(generator))  # 抛出 StopIteration 异常

当然, 这些只是生成器的普通用法, 在这里的例子里只是单向的向外发送数据, 在 Python 2.5 之后迭代器加入了 .send() 等方法, 可以直接与迭代器进行双向通信, 就像是上面的迷你 eventloop 演示的那样.

(值得一提的是, JS 其实也是有生成器的, 是在 ES6 之后引入的, 代码类似于下面这样. 并且在 ES7 引入的 async/await 语法其实就是生成器的语法糖. 要我说这两个语言指定是私底下有什么不可告人的神秘交易, 不然为什么会这么像, 既然是 Python 年纪比较大, 不如直接叫他 Py 交易吧.)

function* countDown(n) {
  for (let i = n; i > 0; i--) {
    yield i;
  }
}

const generatror = countDown(3);
console.log(generatror.next()); // { value: 3, done: false }
console.log(generatror.next()); // { value: 2, done: false }
console.log(generatror.next()); // { value: 1, done: false }
console.log(generatror.next()); // { value: undefind, done: true }

好了, 接下来再来看看 Python 3.4 的新 yield from, 那么这个新的的语法更原来的有什么区别呢, 其实主要的原因还是原有的 yield 不能直接透传, 比如说 yield another_generator() 会直接返回一个迭代器对象, 而不是迭代器里再嵌套迭代器, 如下:

def another_generator():
    yield 1
    yield 2
    yield 3


def generator():
    yield 0

    yield another_generator()
    # 正确用法:
    # for val in another_generator():
    #     yield val

    yield 4


for val in generator():
    print(val)

# 输出:
# 0
# <generator object another_generator at 0x7f0df3f46800>
# 4

而新的 yield from 可以直接把内部的生成器委托出去, 在 yield another_generator() 的中间加上 from, 结果就变成了正常的 0-4 了. 这种能力对于 async/await 语法来说是必须的. 同时加入的还有 return 返回值和异常传递的功能, 这些就不展开了.

这个时期的异步大概长这样 (是的, 跟现在的语法已经非常像了):

# 3.py

import asyncio


# 用装饰器包装成一个协程对象
@asyncio.coroutine
def child():
    yield from asyncio.sleep(1)
    return "Ciallo~(∠・ω< )⌒★"


@asyncio.coroutine
def parent():
    result = yield from child()
    print(result)


asyncio.run(parent())

不过需要注意的是, 用 @asyncio.coroutine 包装协程的写法在 3.11 之后已经废弃了, 因为生成器对象和协程对象虽然很像 (准确来说是几乎一模一样), 但是在 3.5 之后就引入了 "原生协程", 也就是 C 语言层面的 PyCoroObject, 跟生成器的 PyGenObject 本质是两个不同的东西. 运行上面的代码 (虽然有警告, 但是还是可以正常运行的):

uv run --python 3.10 3.py
/tmp/3.py:7: DeprecationWarning: "@coroutine" decorator is deprecated since Python 3.8, use "async def" instead
  def child():
/tmp/3.py:13: DeprecationWarning: "@coroutine" decorator is deprecated since Python 3.8, use "async def" instead
  def parent():
Ciallo~(∠・ω< )⌒★
import asyncio
import inspect


@asyncio.coroutine
def fn_old():
    yield from asyncio.sleep(1)
    return "Ciallo~(∠・ω< )⌒★"


async def fn_new():
    result = await fn_old()
    print(result)  # Ciallo~(∠・ω< )⌒★


asyncio.run(fn_new())

print(inspect.isgenerator(fn_old()))  # Ture
print(inspect.iscoroutine(fn_old()))  # False
print(inspect.isgenerator(fn_new()))  # False
print(inspect.iscoroutine(fn_new()))  # True

我又让 AI 去翻了下 CPython 的实现, 其实 PyGenObject, PyCotoObject 在结构上是完全一致的, 甚至在消费的时候大多数情况下都看成同一个东西 (函数签名用 PyGenObject *gen, 在需要的时候再强转成 PyCoroObject*, C 语言魔法), 代码.

现代 asyncio

未完待续

写不完了, 咕了, 肯定不是我着急发, 嗯, 是这样的.


唉, 分割商法, 唉...


别难过了, 请你吃月饼(


  1. 魂三梗, 源自某个特别爱拖更的视频博主, 在他的视频里说的昨天其实已经是一年前了. 这样一看我是不是更的挺勤快的 ↩