Modify Accessors

It's a complicated question, because people mean different things by "coroutine".

When a function is running, we say there's an execution of that function. That execution can be suspended (in which case it can later be resumed) or ended (in which case that's it, nothing more can happen with that execution).

The most basic model of functions is that they can only suspend their executions to start new executions (a call), and they can only end their executions by resuming the execution that started them (a return). Both of these can include transfers of data, called arguments and results respectively. Note that this creates a simple nesting effect where (1) every execution (except the first) was started by another execution (a caller) that is currently suspended and (2) an execution can only be resumed when every execution that it started (its callees) has ended, as well as every execution those executions may have started transitively. This permits the basic implementation concept of a call stack.

A common extension is to add a second, special kind of resumption: an execution can declare that it is currently resumable in a special way, and a function can end by either resuming its caller in this special way or (if it is not so-resumable) ending it and continuing on to its caller's caller. This is called catch and throw, and when it transfers data, that data is called an exception. This is still compatible with a call stack because it preserves the basic rule that executions are only resumed when they have no callees.

All of that should be familiar; I've covered it just to make you comfortable with using these more formal concepts of an execution, suspension, etc.

A coroutine is a function that can suspend itself to resume an existing execution. So you call a function, it runs for a bit, it resumes back to you, you run for a bit, you resume it, and so on. This one formal concept can be used this to describe generators, cooperative threads, and a number of other things.

Coroutines are not directly compatible with a single call stack and so need a different implementation. One implementation is to give every coroutine its own call stack, but this can be expensive if call stacks need to be pre-allocated, which is typical. Some implementations address this in a targeted way by allowing stacks to dynamically grow, usually at the cost of some extra overhead per call; I believe this is how goroutines are implemented. However, a very common alternative is to allocate space for the execution "elsewhere", in memory that can be reserved for the duration of the coroutine's execution. Often this is combined with a transformation that splits the coroutine function up into separate sub-functions, each of which picks up the coroutine's execution starting from some suspension point (or its beginning) and runs until the coroutine suspends itself again (or it ends).

That splitting is such a well-known implementation technique that a function subject to it is often called a "coroutine" even if it's not really behaving formally like a general coroutine at all. For example, async functions suspend themselves only to perform normal calls or to wait for other async functions to complete, which is essentially just the ordinary function model; however, because async functions are usually subjected to splitting at the implementation level (in order to avoid occupying a call stack long-term), they're often called coroutines anyway. (There is one other way that async functions aren't quite like ordinary routines: a function can start an async execution without suspending itself.)

Coroutines are really more a language concept than a language feature. The most common language features built on coroutines as a concept (rather than just on the implementation technique of function-splitting) are examples of a specific kind of coroutine called a semicoroutine. A semicoroutine is a coroutine that can only suspend itself to resume the last execution that resumed it; this is usually called a yield. (This works well with function splitting. Each resumption just becomes an ordinary function call to the next sub-function, passing the execution record as an argument. That sub-function then returns when it wants to suspend or end the coroutine, somehow reporting back whether the coroutine is still active and (if so) what sub-function should be called next.) The modify accessor is a semicoroutine that can only yield once and then must end when resumed. A generator is a semicoroutine that can yield an arbitrary number of times.

72 Likes