6

Lately I've listened to a talk by Guido van Rossum, about async I/O in Python3. I was surprised by a notion of callbacks being "hated" by developers, supposedly for being ugly. I've also discovered a concept of a coroutine, and started reading a coroutine tutorial by David Beazley. So far, coroutines still look pretty esoteric to me - a way too obscure and hard to reason about, than those "hated" callbacks.

Now I'm trying to find out why some people consider callbacks ugly. True, with callbacks, the program no longer looks like a linear piece of code, executing a single algorithm. But, well, it is not - as soon as it has async I/O in it - and there's no good in pretending it is. Instead, I think about such a program as event-driven - you write it by defining how it reacts to relevant events.

Or there's something else about coroutines, which is considered bad, besides making programs "non-linear"?

4

1 回答 1

10

考虑使用以下代码读取协议标头:

def readn(sock, n):
    buf = ''
    while n > len(buf):
        newbuf = sock.recv(n - len(buf))
        if not newbuf:
            raise something
        buf += newbuf
    return buf

def readmsg(sock):
    msgtype = readn(sock, 4).decode('ascii')
    size = struct.unpack('!I', readn(sock, 4))
    data = readn(sock, size)
    return msgtype, size, data

显然,如果你想一次处理多个用户,你不能recv像这样循环阻塞调用。所以,你可以做什么?

如果你使用线程,你不必对这段代码做任何事情;只需在单独的线程上运行每个客户端,一切都很好。这就像魔术一样。线程的问题是你不能同时运行 5000 个线程而不会使你的调度程序慢下来,分配这么多的堆栈空间以至于你进入交换地狱等等。所以,问题是,我们如何得到没有问题的线程的魔力?

隐式greenlets是解决这个问题的一种方法。基本上,您编写线程代码,它实际上由协作调度程序运行,每次进行阻塞调用时都会中断您的代码。问题在于,这涉及对所有已知的阻塞调用进行猴子修补,并希望您安装的任何库都不会添加任何新的库。

协程就是这个问题的答案。如果您通过在每个阻塞函数调用yield from之前添加一个显式标记它,则没有人需要猴子补丁任何东西。您仍然需要调用与异步兼容的函数,但不再可能在没有预料到的情况下阻塞整个服务器,并且从您的代码中可以更清楚地看到发生了什么。缺点是幕后的反应器代码必须更复杂......但这是你编写一次的东西(或者,更好的是,零次,因为它来自框架或标准库)。

使用回调,您编写的代码最终会做与使用协程完全相同的事情,但复杂性现在在您的协议代码中。您必须有效地将控制流程从内到外。相比之下,最明显的翻译相当可怕:

def readn(sock, n, callback):
    buf = ''
    def on_recv(newbuf):
        nonlocal buf, callback
        if not newbuf:
            callback(None, some error)
            return
        buf += newbuf
        if len(buf) == n:
            callback(buf)
        async_read(sock, n - len(buf), on_recv)
    async_read(sock, n, on_recv)

def readmsg(sock, callback):
    msgtype, size = None, None
    def on_recv_data(buf, err=None):
        nonlocal data
        if err: callback(None, err)
        callback(msgtype, size, buf)
    def on_recv_size(buf, err=None):
        nonlocal size
        if err: callback(None, err)
        size = struct.unpack('!I', buf)
        readn(sock, size, on_recv_data)            
    def on_recv_msgtype(buf, err=None):
        nonlocal msgtype
        if err: callback(None, err)
        msgtype = buf.decode('ascii')
        readn(sock, 4, on_recv_size)
    readn(sock, 4, on_recv_msgtype)

现在,显然,在现实生活中,任何以这种方式编写回调代码的人都应该被枪决;有更好的方法来组织它,比如使用 Futures 或 Deferreds,使用带有方法的类而不是使用 nonlocal 语句以相反顺序定义的一堆局部闭包,等等。

但关键是,没有办法以一种看起来与同步版本相近的方式来编写它。控制流本质上是中心的,协议逻辑是次要的。使用协程,因为控制流总是“向后”的,它在你的代码中根本不明确,协议逻辑就是读写的全部。


话虽如此,有很多地方用回调编写东西的最佳方式比协程(或同步)版本更好,因为代码的重点是将异步事件链接在一起。

如果您通读 Twisted 教程,您会发现让这两种机制很好地协同工作并不难。如果您围绕 Deferred 编写所有内容,则可以自由使用 Deferred 组合函数、显式回调和@inlineCallbacks-style 协程。在代码的某些部分,控制流很重要,逻辑很琐碎;在其他部分,逻辑很复杂,您不希望它被控制流所掩盖。因此,您可以使用在每种情况下都有意义的任何一个。


事实上,将生成器作为协程与生成器作为迭代器进行比较是值得的。考虑:

def squares(n):
    for i in range(n):
        yield i*i

def squares(n):
    class Iterator:
        def __init__(self):
            self.i = 0
        def __iter__(self):
            return self
        def __next__(self):
            i, self.i = self.i, self.i+1
            return i*i
    return Iterator(n)

第一个版本隐藏了很多“魔法”——调用之间的迭代器状态在next任何地方都不是明确的;它隐含在生成器函数的局部框架中。并且每次执行 ayield时,整个程序的状态都可能在yield返回之前发生变化。然而,第一个版本显然更加清晰和简单,因为除了产生 N 个平方的操作的实际逻辑之外几乎没有什么可阅读的。

显然,您不希望将您编写的每个程序中的所有状态都放入生成器中。但是完全拒绝使用生成器,因为它们隐藏了状态转换,就像拒绝使用for循环,因为它隐藏了程序计数器跳转。协程的情况也完全一样。

于 2013-08-30T22:59:49.940 回答