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"?

rincewind
  • 1,103
  • 8
  • 29
  • Callbacks aren't "ugly"; even if coroutines are "cool". But they *both represent the same (well, a very similar) thing* - a *continuation of state*. An example of a "callback" system that shows this well is [Promises/A](http://wiki.commonjs.org/wiki/Promises/A) in JavaScript which is a generalized concept. – user2246674 Aug 30 '13 at 22:43
  • If you google "callback hell", you'll find a lot of rants, proposed solutions, etc. Most of them are Javascript-specific, but a lot of the same ideas apply to Python—in fact, even worse so, because of the fact that Python doesn't have multiline anonymous functions. – abarnert Aug 30 '13 at 22:44
  • I believe callbacks are equivalent to delimited continuations. – Marcin Łoś Aug 30 '13 at 22:46
  • @abarnert Even in JavaScript I often find myself creating "named local functions" for callbacks (i.e. when using it as an actual "callback" and not a simple argument/evaluator to a method). When methods are well modularized it generally seems to flow well - and bonus "documentation" to boot! – user2246674 Aug 30 '13 at 22:47
  • @user2246674: In JS, you get to choose between putting things back-to-front, or nesting off the right edge of the screen. Usually, you name the non-trivial ones and inline the trivial ones, but that's a matter of style. In Python, you don't get the choice. (On the other hand, you don't need all the silly `.bind(this)` stuff…) – abarnert Aug 30 '13 at 23:01
  • @MarcinŁoś: Sure, and sequencing two statements after each other or just evaluating more than one argument to pass to a function is equivalent to passing continuations around, but that doesn't mean you want to write everything in CPS. – abarnert Aug 30 '13 at 23:18

1 Answers1

11

Consider this code for reading a protocol header:

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

Obviously, if you want to handle more than one user at a time, you can't loop over blocking recv calls like that. So what can you do?

If you use threads, you don't have to do anything to this code; just run each client on a separate thread, and everything is fine. It's like magic. The problem with threads is that you can't run 5000 of them at the same time without slowing your scheduler to a crawl, allocating so much stack space that you go into swap hell, etc. So, the question is, how do we get the magic of threads without the problems?

Implicit greenlets are one answer to the problem. Basically, you write threaded code, it's actually run by a cooperative scheduler which interrupts you code every time you make a blocking call. The problem is that this involves monkeypatching all the known blocking calls, and hoping no libraries you install add any new ones.

Coroutines are an answer to that problem. If you explicitly mark each blocking function call by dropping a yield from before it, nobody needs to monkeypatch anything. You do still need to have async-compatible functions to call, but it's no longer possible to block the whole server without expecting it, and it's much clearer from your code what's going on. The disadvantage is that the reactor code under the covers has to be more complicated… but that's something you write once (or, better, zero times, because it comes in a framework or the stdlib).

With callbacks, the code you write will ultimately do exactly the same thing as with coroutines, but the complexity is now inside your protocol code. You have to effectively turn the flow of control inside out. The most obvious translation is pretty horrible by comparison:

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)

Now, obviously, in real life, anyone who writes the callback code that way should be shot; there are much better ways to organize it, like using Futures or Deferreds, using a class with methods instead of a bunch of local closures defined in reverse order with nonlocal statements, and so on.

But the point is, there is no way to write it in a way that looks even remotely like the synchronous version. The flow of control is inherently central, and the protocol logic is secondary. With coroutines, because the flow of control is always "backward", it isn't explicit in your code at all, and the protocol logic is all there is to read and write.


That being said, there are plenty of places where the best way to write something with callbacks is better than the coroutine (or synchronous) version, because the whole point of the code is chaining asynchronous events together.

If you read through the Twisted tutorial, you'll see that it's not that hard to make the two mechanisms play nicely together. If you write everything around Deferreds, you can freely use Deferred-composition functions, explicit callbacks, and @inlineCallbacks-style coroutines. In some parts of your code, the flow of control is important and the logic is trivial; in other parts, the logic is complex and you don't want it obscured by the flow of control. So, you can use whichever one makes sense in each case.


In fact, it's worth comparing generators-as-coroutines with generators-as-iterators. Consider:

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)

The first version hides a lot of "magic"—the state of the iterator between next calls isn't explicit anywhere; it's implicit in the local frame of the generator function. And every time you do a yield, the state of the entire program could have changed before the yield returns. And yet, the first version is obviously much clearer and simpler, because there's almost nothing to read except the actual logic of the operation of yielding N squares.

Obviously you wouldn't want to put all the state in every program you ever write into a generator. But refusing to use generators at all because they hide the state transitions would be like refusing to use a for loop because it hides the program-counter jumps. And it's exactly the same case with coroutines.

abarnert
  • 354,177
  • 51
  • 601
  • 671
  • I'm not sold on "callbacks are ugly", but I think this is illustrates a very good difference. – user2246674 Aug 30 '13 at 23:03
  • @user2246674: Yes, like most dogmas, "callbacks are ugly" contains a kernel of truth, but a whole lot of FUD. I figured illustrating the kernel of truth would be more useful here; if you want a traditional anti-callback rant, they're all over the net. – abarnert Aug 30 '13 at 23:08
  • The callbacks version is horrible only because you're trying to implement a synchronous-looking readmsg(). Why do you need it? Instead, I can imagine a read callback, which accumulates a buffer each time a new data is received, and as soon as it can parse a message - calls whatever it needs to call. There's just no need for readmsg(). As to the coroutine version - that "yield from" magic is exactly what bothers me. If I understand correctly, the state of a program before and after this magic call might change. Such a "call" is too subtle, not explicit enough to me. – rincewind Aug 30 '13 at 23:31
  • @rincewind: Why do you need it? Because it's much simpler to understand. The code to parse a network protocol is exactly the same as the code to parse an equivalent file format. The benefit there is obvious. – abarnert Aug 30 '13 at 23:40
  • @rincewind: And meanwhile, of course the state of a program before and after the `yield from` can change. But the same thing is true even for simple generators—in fact, it's the whole _point_ of them. And if you think those are too magic, you're really using the wrong language. – abarnert Aug 30 '13 at 23:41
  • @abarnert: with generators it's fairly isolated. With coroutines, it appears, you have to write your entire program with them in mind. – rincewind Aug 31 '13 at 00:04
  • @abarnert: as to the "obvious" benefits of having readmsg() - well, I guess it's subjective, and there's no much point in arguing about this. How I see it: if you want to write your program in data-processing-centric fashion, I guess there's value in making protocol parsing appear like file parsing. The price you pay - you're obscuring control flow. If you want to make control flow explicit - then callbacks is a better option. Somehow, I tend to prefer being explicit about control flow. – rincewind Aug 31 '13 at 00:05
  • @rincewind: There's obviously a tradeoff either way; the point is to understand the tradeoffs of both alternatives, so you can make a good choice in each situation. When the control flow is complicated and hard to read unless you make it explicit, that's a problem. When the control flow ends up being 80% of your code and obscuring the real logic, that's a problem too. And sometimes, you end up with both, and it's a tough choice. But no dogma—whether it's "callbacks are ugly" or "make control flow explicit"—will help you make that choice intelligently. – abarnert Aug 31 '13 at 00:24
  • @abarnert: makes sense. I guess one's experience affects one's bias - I'm yet to work on a project where data processing logic is more complicated than control flow. At least, my initial guess about why some consider callbacks "ugly" appears to be correct, there's no new drawbacks to it I wasn't aware of. Thanks! – rincewind Aug 31 '13 at 00:52