1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123
|
Consider the following assignment: design a program that offers a function
tt(next) returning the next fibonacci number at subsequent
calls.footnote(Fibonacci numbers start with 0 and 1. The next fibonacci number
is the sum of the last two fibonacci numbers. The sequence, therefore, starts
with 0, 1, 1, 2, 3, 5, etc. In this and the following examples fibonacci
sequences are frequently used for illustration, and not because they are
inherently related to coroutines.)
Here is an example of how such a program could be designed: it defines a class
tt(Fibo) and in tt(main Fibo::next) is called to compute the next fibonacci
number (for brevity the program uses a single source file):
verbinsert(-as4 demo/fibo/main.cc)
Clearly the tt(next) member isn't all that complicated. But when it's called
several actions are performed which themselves are unrelated to computing
fibonacci numbers:
itemization(
it() Calling a function boils down to (ignoring
the complications when copying and destroying value type argument
objects and value type local objects):
itemization(
it() pushing its arguments on the stack (note that tt(next) is a
member function, so it em(does) have an argument: the address of
the object for which tt(next) is called);
it() pushing the number of bytes required for those arguments on the
stack;
it() pushing the location of the next instruction to execute after
completing the tt(next) function call on the stack;
it() pushing the location of the current function's stack frame on the
stack;
it() copying the current stack pointer's value to a register (commonly
called the em(base pointer)), which register is then used to
access tt(next's) arguments and local variables;
it() continuing execution at tt(next's) first instruction;
it() which first makes room on the stack for all of tt(next's)
local variables.
)
it() once tt(next's return) statement has been executed:
itemization(
it() the stack pointer is reset to the value stored in the base
pointer, thus removing local variables from the stack;
it() popping the base pointer, thus restoring access to the caller's
stack frame;
it() popping the value of the caller's next instruction to execute
from the stack;
it() reducing the size of the stack by the value currently at the top
of the stack, holding the size of the function's arguments.
)
)
These steps, although they look like a lot, in practice don't take that much
time, because most of them are performed by very fast register operations, and
the computer' architecture is usually highly optimized for these steps.
Nonetheless, in situations where the functions themselves are short and simple
(like tt(Fibo::next)) these steps, requiring stack- and register
manipulations, might be considered unwelcome, raising the question whether
they may be avoided.
bf(C++) em(coroutines) allow us to avoid executing the steps that are required
when calling ordinary functions. The upcoming sections cover coroutines in
depth, but here is, for starters, the coroutine's equivalent of
tt(Fibo::next):
label(FIBOCORO)
verbinsert(-ans4 demo/fibocoro/fibocoro.cc)
Already now we can observe some characteristics of the tt(fiboCoro) coroutine:
itemization(
it() although coroutines em(can) be defined as class members, this
particular coroutine is not. It's a free function, which therefore
doesn't have a (hidden) pointer pointing to the object calling the
coroutine;
it() more importantly: it defines local variables (especially those in
lines 5, 6), which em(keep their values) between successive
calls. Such local variables are like static variables, but they're
not. Each coroutine-call receives its own instances of its local
variables;
it() Lines 8 thru 16 define a continuous loop where each cycle computes
the next fibonacci number;
it() Once a fibonacci number is available the coroutine is em(suspended)
(at line 15) without loosing the just computed values, passing tt(ret)
to the coroutine's caller (the keyword ti(co_yield) is one of three
new keywords which can be used by coroutines; the other two being
ti(co_await) and ti(co_return)).
)
While tt(fiboCoro) lays the foundation for obtaining a sequence of
fibonacci numbers, resuming a coroutine doesn't mean calling a function the
way the above member tt(Fibo::next) is called: there are no arguments; there
is no preparation of local variables; and there is no stack
handling. Instead there's merely a direct jump to the instruction just beyond
the coroutine's suspension point. When the coroutine's code encounters the
next suspension point (which occurs in tt(fiboCoro) at it's next cycle,
when it again reaches its tt(co_yield) statement) then the program's execution
simply jumps back to the instruction following the instruction that resumed
the coroutine.
The tt(main) function using the coroutine looks very similar to the tt(main)
function using the class tt(Fibo):
verbinsert(-ans4 demo/fibocoro/main.cc)
At line 5 tt(fiboCoro) is called, returning an (as yet not covered)
tt(fibo) thing. This tt(fibo) thing is called a hi(coroutine handle)
em(coroutine handler), and when (in line 14) tt(fibo.next()) is called, then
em(that) call resumes tt(fiboCoro), which is then again suspended at its
tt(co_yield) statement, returning the next available value through the
handler's tt(next) function.
The core feature of coroutines is thus that they may suspend their execution,
while keeping their current state (values of variables, location of the next
instruction to be executed, etc.). Normally, once suspended, the coroutine's
caller resumes its work beyond the instruction that returned the coroutine's
next value, so those two functions (the coroutine and its caller) closely
cooperate to complete the implemented algorithm. Co-routines are therefore
also known as
emi(cooperating routines), which should not be confused with
em(concurrent) (multi-threaded) routines.
How this whole process works, and what its characteristics are, is covered in
the upcoming sections.
|