DEV Community

Thibault Maekelbergh
Thibault Maekelbergh

Posted on

Explain coroutines like I'm five

I have been using this concept in Javascript's redux-sagas & Python's asyncio but have failed to understand their underlying working. Who can explain to me how coroutines actually work?

Top comments (11)

Collapse
 
idanarye profile image
Idan Arye • Edited

Adrian wrote an answer for a literal 5 year old, but from the OP's response it seems like he was expecting more. I'll go more into details, explaining the actual difference between the terms, while trying to keep it simple enough for a 5 year old to understand - assuming said 5 year old is a computer programmer. Gotta start that early to hit those job requirements...

Async

I think the term "async" is a bit misleading - the asynchronous thing is the underlying mechanism, and all these async frameworks are trying to give you a synchronous API to it.

The problem async trying to solve is that IOs are slow, but suffer very little performance hit when parallelized. So if you have 10 IOs that take one second each, running them synchronously one after the other will take 10 seconds:

for io in ios:  # 10 iterations
    io.start()  # so fast we are not going to measure it
    io.wait()  # takes 1 second
    process_result(io.result)  # so fast we are not going to measure it
# Overall - 10 sconds
Enter fullscreen mode Exit fullscreen mode

But - if you can perform all of them in parallel it will take one second total:

for io in ios:
    io.start()
    pending.add(io)

while pending:
    io = pending.poll_one_that_is_ready()
    process_result(io.result)
Enter fullscreen mode Exit fullscreen mode

The for loop is immediate - all it does is start the IOs(doesn't block) and add them to a data structure. In the second loop, the first iteration will take one second on poll_one_that_is_ready(). But during that second, the other 9 IOs were also running so they are also ready - and in the following iterations poll_one_that_is_ready() will be immediate. Since everything else will also be so much faster than 1 second that it can be considered immediate - the entire thing will run in 1 second.

So, this is what async means - you start an IO and instead of waiting for it to finish you go to do other things(like sending more IOs).

Reactors

The problem with the above snippet is that writing code like this tends to get complex - especially when you have different kinds of IOs you need to send, with various dependencies on the results of previous IOs. That's the job of reactors - to hold many tasks, know which task waits on what, and decide which task to run next.

My pending container and while loop form a super-simplified reactor - real asnyc frameworks will usually have a more complex reactors, that can handle multi-stage tasks and different kinds of IOs - but this is outside the scope of this answer. Another shortcoming of my "reactor" is that it can only deal with one task(with different arguments) - running "process_result" on the result. In reality you'll have different kind of IOs that should be handled differently - which brings us to the next section:

Callback based async

Popularized by AJAX and Node.js, callbacks are a simple way to let the reactor know how to handle your IO. The idea is together with your IO to register a function(the "callback"), and once your IO is ready it'll call your function with it's result:

def single_io_1(io):
    io.start()

    def callback(result):
        process_result_1(result)

    pending.add((io, callback))

for io in ios_1:
    single_io_1(io)

def single_io_2(io):
    io.start()

    def callback(result):
        process_result_2(result)

    pending.add((io, callback))

for io in ios_2:
    single_io_2(io)

while pending:
    io, callback = pending.poll_one_that_is_ready()
    callback(io.result)
Enter fullscreen mode Exit fullscreen mode

The reactor always receives the callback from pending, so it knows how to deal with the different kinds of IOs we register.

This code is easier to manage than directly using the asnyc IO mechanism - but it's still has syntactic overhead compared to synchronous code. To make it look closer to simple synchronous code - we'll need:

Coroutines

Let's step back a bit from async to talk about coroutines. A regular function/procedure("routine") has a simple flow - you call it, it does it's job, and it returns. Let's take this function that gives us (a prefix of) the Fibonacci sequence:

def fib(limit):
    prev = 1
    curr = 1
    result = [prev, curr]

    for _ in range(limit - 2):
        prev, curr = curr, prev + curr
        result.append(curr)

    return result
Enter fullscreen mode Exit fullscreen mode

(please ignore that for limit < 2 you'll get 2 elements)

This is a regular function - it does it's job and then returns. A coroutine, on the other hand, has many points during it's run where it can returns something to it's caller:

def fib():
    prev = 1
    curr = 1
    yield prev
    yield curr

    while True:
        prev, curr = curr, prev + curr
        yield curr
Enter fullscreen mode Exit fullscreen mode

When we call fib(), instead of getting a list we'll get an iterator it. Each time we call next(it) the function will run until the next yield and return that value. So the first two next(it) will return 1, the third(the yield in the first iteartion of the while True: loop) will return 2, the fourth(second iteration) will return 3, the firth will return 5 and so on. The loop is infinite - but it's controlled from the outside(by calling next) so the program doesn't get stuck.

This was originally used for iterators, but it can also be used for:

Async with coroutines(finally!)

Instead of registering a callback function, our "callback" will be the rest of the coroutine after the yield:

def single_io(io):
    io.start()
    yield
    process_result(io.result)

for io in ios:
    coroutine = single_io(io)
    next(coroutine)
    pending.add(coroutine)

while pending:
    coroutine = pending.poll_one_that_is_ready()
    next(coroutine)  # in real Python this can throw a StopIteration!
Enter fullscreen mode Exit fullscreen mode

Now our single_io looks almost like synchronous code - and you just need some syntactic sugar to make it look like this:

async def single_io(io):
    result = await io.run()
    process_result(result)

for io in ios:
    reactor.add(single_io(io))
Enter fullscreen mode Exit fullscreen mode
Collapse
 
thibmaek profile image
Thibault Maekelbergh

Yes, this was exactly the reply I was looking for! All very clear, and I def. got a better idea of how they work now in combination with async ops.

Collapse
 
didiert profile image
Didier Trosset

Within the last but one code snippet, I was wonderingโ€ฆ
Why is there this call to next(coroutine) in the for io in ios loop?

Collapse
 
idanarye profile image
Idan Arye

Because Python generators don't do anything when you call the function. They only execute code when you call next (or send) on them (or when you do something that calls their next - like iterating on them with a for loop or making a list out of them)

In [1]: def single_io():
   ...:     print('starting the IO')
   ...:     yield
   ...:     print('processing the IO result')

In [2]: coroutine = single_io()

In [3]: # The coroutine did not actually start yet

In [4]: # It will only start here:

In [5]: next(coroutine)
starting the IO

In [6]:
Enter fullscreen mode Exit fullscreen mode
Collapse
 
bgadrian profile image
Adrian B.G. • Edited

Async, concurrecy, coroutines or goroutines, from 10.000 feets in the air they are synonyms, the devil is in the detail but you haven't reached that stage I presume.

You are 5Y old, but you are already using a tablet. It's evening and you are in a hurry, you have to finish your homework (on the tablet).
Also a your sister asked you to help her in a game.
Tonight is also some live streaming cartoons.
You open all the apps on the tablet, and juggle between them. You cannot do 2 things at once, you can only do 1 thing at a time (homework, watch cartoons or play the game).

Your attention == 1 core CPU
The current app == the current task/function
The tablet == running tasks

This is concurrency/async, you will finish all 3 tasks, in the same time span (this evening), by running a bit of each for a few minutes and then switch. From an outsider perspective (the User/programmer), you do all the 3 tasks in paralell.

The CPU does the switching so fast that it seems paralel, but is not. If your attention is disturbed, and instead of that 3minutes you stay 10min on a specific tasks, the entire thread is blocked. The best example in JS is if you block the main thread with a heavy operation, the page will freeze from the user points of view (because the user actions triggered events which are not handled because the thread is busy).

You can have multiple threads/tasks, but if you only have 1 Core, there is only 1 processator, so all of them will wait in line to be processed.


Now, paralelism ... is when you call your friend, with his own tablet , and put him to do your homework, and you remain with only 2 tasks (play and watch). Now you have 2 paralel tasks (threads) on 2 CPU cores, working side by side.

I made a collection of more advanced JavaScript topics, one of them is concurrency and async, I recommend check them out (especially the Aridanm and Event loop videos).

Collapse
 
idanarye profile image
Idan Arye • Edited

I think your explanation is missing the main appeal of async - that you don't need to block on IO.

To build on your story, I'd put it something like this:

  • You start watching the cartoon, but it's the intro.
  • Instead of watching the intro you switch to the game and enter the online lobby - but it needs 3 players and only you and your sister are in it.
  • Instead of waiting for another player to join you switch to your homework, and answer the first question.
  • The second question has a link to a YouTube video you need to watch. You open it - and it starts loading.
  • Instead of waiting for it to load, you switch back to the cartoon. The intro is over, so you can watch.
  • Now there are commercials - but meanwhile a third player has joined so you switch to the game

And so on...

The idea is that you don't just switch the tasks really fast to make it look like you are doing everything at once. You utilize the time you are waiting for something to happen(IO) to do other things that do require your direct attention.

Collapse
 
thibmaek profile image
Thibault Maekelbergh

Thank you, this was clear enough to grasp it a bit better. I thought coroutines were a specific pattern for async operations though, but from above comments, it seems like they're roughly the same thing.

Thread Thread
 
idanarye profile image
Idan Arye

No - your were right at first. Coroutines are one way to do async - there are other ways. There are also other uses for coroutines. I've written a detailed reply to the main post.

Collapse
 
tifflabs profile image
tiff

This. This makes so much sense. Really great explanation.

Collapse
 
bgadrian profile image
Adrian B.G.

True, your completion explain how they should be used, in a correct way.

Collapse
 
berserker1 profile image
Aaryan Bhagat

So couroutine is code based context switching, where you write the code and you write the code for context switching (where a function will stop and pass the execution to the caller).