#12: Coroutines
Hello! I hope you’re now familiar with the concepts of concurrency and parallelism. But did you know that we can have concurrency without parallelism? Let’s discuss coroutines.
As coders, we are all familiar with the concept of functions, which, in most programming languages, are interchangeable with the concept of subroutines. They run from start to finish, and if they call another function, they wait until that function completes before continuing. For example1:
def foo():
v = bar()
print(v)
def bar():
return 42
Here, foo
calls bar
, and foo
will only continue after bar
returns. Only one function runs at a time, and everything happens in a single call stack2.
Now, let’s talk about coroutines, which are a bit different from regular functions. A coroutine is a special kind of function that can pause and resume its execution. For example:
def coroutine():
yield "foo"
yield "bar"
return "end"
coro = coroutine()
while True:
try:
print(next(coro)) # "foo" then "bar"
except StopIteration as e:
print(e.value) # "end"
break
Breaking it down:
Initialization: When we call the
coroutine
function, it doesn’t run right away. Instead, it returns a generator object (coro
), which we can control.First iteration: Calling
next(coro)
runs thecoroutine()
function until it hits the firstyield
, returning "foo" and pausing the function there.Second iteration: Calling
next(coro)
again resumes from where it left off, returning "bar" this time and pausing once more.Final iteration: On the last call to
next(coro)
, the function hits thereturn
statement, which raises aStopIteration
exception. We catch this to get the final value, "end", and stop the loop.
Some key points to understand with coroutines:
Yield pauses execution: Each time a coroutine reaches a
yield
, it pauses, allowing us to resume its execution later.Separate stack frames: Unlike regular functions, coroutines don’t follow the typical call-and-return pattern. When a coroutine pauses, it saves its state, and we can resume it without re-running previous steps.
Single thread: Coroutines run in the same thread as the main program, so no new threads are created. This allows us to achieve concurrency, but everything still happens within a single thread.
Introducing coroutines is a way to complement yesterday's discussion on concurrency and parallelism. Coroutines are a concept that provides concurrency without parallelism.
What are some use cases for coroutines?
Asynchronous I/O: Coroutines can be used to handle network or file operations, pausing while waiting for data and resuming once the data is ready.
Data pipelines: Coroutines can be used in data pipelines, where one coroutine (the producer) generates data and another coroutine (the consumer) processes it. This works for scenarios like handling logs streaming or real-time sensor data.
Generators: A generator is a simplified form of coroutines designed specifically to produce a sequence of values. Generators are helpful when we don’t want to store the entire sequence in memory, like generating a list of numbers on demand.
In summary, coroutines are lightweight units that allow us to manage multiple tasks efficiently within a single thread. Coroutines enable concurrency by pausing and resuming execution without the need for multiple threads or the complexity of parallelism. They are especially useful for tasks involving waiting, like handling network requests or processing large data streams in smaller chunks.
Tomorrow, we will explore the differences between mutexes and semaphores.
All the examples are in Python.
A call stack is the way most programming languages keep track of function calls. When a function is called, it is pushed to the stack. When it finishes, it is popped from the stack, and the program continues from where it left off.