为什么回调是“丑陋的”?

时间:2013-08-30 22:42:52

标签: python asynchronous io callback coroutine

最近我听了Guido van Rossum关于Python3中异步I / O的演讲。我对开发人员“讨厌”回调的概念感到惊讶,据说是因为丑陋。我还发现了一个协程的概念,并开始阅读David Beazley的协程教程。到目前为止,对于我来说,协同程序看起来仍然非常深奥 - 一种过于模糊和难以推理的方式,而不是那些“讨厌”的回调。

现在我试图找出为什么有些人认为回调很难看。确实,通过回调,程序不再像线性代码,执行单个算法。但是,好吧,它不是 - 只要它有异步I / O--并且假装它是没有好处的。相反,我认为这样的程序是事件驱动的 - 你可以通过定义它对相关事件的反应来编写它。

或者除了使程序“非线性”之外,关于协同程序的其他内容被认为是坏的吗?

1 个答案:

答案 0 :(得分:9)

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

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是解决问题的唯一方法。基本上,你编写线程代码,它实际上由一个协作调度程序运行,它会在你每次进行阻塞调用时中断你的代码。问题是这涉及monkeypatching所有已知的阻塞调用,并希望你安装的库没有添加任何新的。

协程可以解决这个问题。如果通过在它之前删除yield from来明确标记每个阻塞函数调用,则没有人需要对任何东西进行monkeypatch。您仍然需要具有异步兼容的函数来调用,但是不再可能在不期望的情况下阻塞整个服务器,并且从您的代码中可以更清楚地了解正在发生的事情。缺点是封面下的反应堆代码必须更复杂......但这是你写过一次(或者更好,零次,因为它出现在框架或stdlib中)。

使用回调,您编写的代码最终将与协同程序完全相同,但现在复杂性在您的协议代码中。你必须有效地控制内部的控制流程。相比之下,最明显的翻译非常可怕:

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,使用带有方法的类而不是使用与非本地语句相反的顺序定义的一堆本地闭包,等等。

但关键是,没有办法以类似于同步版本的方式编写它。控制流本质上是中心的,协议逻辑是次要的。使用协同程序,因为控制流总是“向后”,所以在代码中根本不明确,协议逻辑就是读写所有内容。


话虽如此,有很多地方用回调来编写内容的最好方法是更好而不是coroutine(或同步)版本,因为代码的整个点是链接异步事件在一起。

如果你仔细阅读Twisted教程,你会发现让这两种机制很好地协同工作并不难。如果您在Deferreds周围编写所有内容,则可以自由使用Deferred-composition函数,显式回调和@inlineCallbacks样式协同程序。在代码的某些部分,控制流很重要,逻辑很简单;在其他部分,逻辑是复杂的,你不希望它被控制流遮挡。因此,您可以使用在每种情况下都有意义的任何一个。


事实上,将generator-as-coroutines与generator-as-iterator进行比较是值得的。考虑:

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调用之间的迭代器状态在任何地方都不明确;它隐含在生成器函数的本地框架中。每次执行yield时,整个程序的状态可能会在yield返回之前发生变化。然而,第一个版本显然更清晰,更简单,因为除了产生N个方格的操作的实际逻辑之外,几乎没有什么可读的。

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