为什么回调是"丑陋的"?

rin*_*ind 5 python io asynchronous callback coroutine

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

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

或者除了使程序"非线性"之外,还有其他关于协同程序的东西,它被认为是坏的?

aba*_*ert 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
Run Code Online (Sandbox Code Playgroud)

显然,如果你想一次处理多个用户,你就不能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)
Run Code Online (Sandbox Code Playgroud)

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

但问题是,没有办法以类似于同步版本的方式编写它.控制流本质上是中心的,协议逻辑是次要的.使用协同程序,因为控制流总是"向后",所以在代码中根本没有显式,协议逻辑就是所有可读写的.


话虽这么说,有很多地方用回调来编写内容的最佳方法比协程(或同步)版本更好,因为代码的整个点是将异步事件链接在一起.

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


事实上,将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)
Run Code Online (Sandbox Code Playgroud)

第一个版本隐藏了很多"魔法" - next调用之间的迭代器状态在任何地方都不明确; 它隐含在生成器函数的本地框架中.每次你做一个yield,整个程序的状态可能会在yield返回之前发生变化.然而,第一个版本显然更加清晰和简单,因为除了产生N个方格的操作的实际逻辑之外几乎没有什么可读的.

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