Skip to content

2.2 Running coroutines

Alex Damian edited this page Sep 12, 2018 · 4 revisions

Context switching

As mentioned earlier, all coroutines running in the same thread are executed in sequence until a yield point is encountered. When a yield point is hit, the current coroutine context is saved, the next coroutine context in the thread queue is loaded, and execution resumes at that point. If not yield points are encountered in the coroutine code, it will run to completion.

Coroutine context switch

In the following diagram, three coroutines are run on the same thread. The black slash markers indicate yield points and the blue arrows with yellow dots show context switches between the coroutines. Since code executes downward, we can see that coroutine 3 finishes first, followed by coroutine 1 and then 2. If these coroutines had no yield points, coroutine 1 would complete, followed by coroutine 2 and finally coroutine 3. For pure CPU-bound algorithms, context switching does not bring any advantages vs running to completion as you incur a small penalty for every context switch (even if it's done in user space). However, if coroutines 2 and 3 have additional work to do, such as IO requests/responses, yielding makes the application much more responsive as IO operations are permitted to execute sooner. This is called "cooperation" since all coroutines help each other complete their work items.

What is yielding and how can I do it?

When a coroutine yields, current thread state such as the instruction pointer, the stack pointer and most registers are saved inside a boost::context structure, the thread then loads the next context in the queue (some other coroutine) and execution resumes by restoring all the registers with new values. Since this is all done in user space without any OS calls, the operation is extremely fast.

Typically the user does not have to do anything in order to yield. Yielding is done implicitly (i.e. under the hood) during certain operations such as blocking on an async IO operation via postAsyncIo(), waiting on a future via CoroContext::get() and it's many variations, CoroContext::sleep(), locking a quantum::mutex, waiting on a quantum::condition_variable, etc. Essentially when the coroutine is blocked in some way, the blocking operation yields instead of asking the OS to suspend the thread.

Alternatively, the user can explicitly yield by calling CoroContext::yield() on the current coroutine context passed-in as the first parameter (more on that later). Explicit yielding should be done only in cases when the coroutine may run for a very long time (e.g. large for loops) or during long calculations. It is ultimately the user's discretion if and when to yield.

Coroutine stack sizes

Coroutines have a default stack size which is system dependent. Internally boost uses it via a traits class called boost::context::stack_traits (see here) which is accessed by the coroutine allocators. Although this is sufficient for most cases, these stack traits can be further customized using quantum::StackTraits class which sets application-wide values. The default values (if not set) are those of boost.