<div>[This is a lengthy mail; I apologize in advance!]</div><div><br></div><div>Hi,</div><div><br></div><div>I've been following this discussion with great interest, and would like</div><div>to put forward a suggestion that might simplify some of the questions</div>
<div>that are up in the air.</div><div><br></div><div>There are several key point being considered: what exactly constitutes a</div><div>"coroutine" or "tasklet", what the precise semantics of "yield" and</div>
<div>"yield from" should be, how the stdlib can support different event loops</div><div>and reactors, and how exactly Futures, Deferreds, and other APIs fit</div><div>into the whole picture.</div><div><br></div>
<div>This mail is mostly about the first point: I think everyone agrees</div><div>roughly what a coroutine-style generator is, but there's enough</div><div>variation in how they are used, both historically and presently, that</div>
<div>the concept isn't as precise as it should be. This makes them hard to</div><div>think and reason about (failing the "BDFL gets headaches" test), and</div><div>makes it harder to define the behavior of all the parts that they</div>
<div>interact with, too.</div><div><br></div><div>This is a sketch of an attempt to define what constitutes a</div><div>generator-based task or coroutine more rigorously: I think that the</div><div>essential behavior can be captured in a small protocol, building on the</div>
<div>generator and iterator protocols. If anyone else thinks this is a good</div><div>idea, maybe something like this could work its way into a PEP?</div><div><br></div><div>(For the sake of this mail, I will use the term "generator task" or</div>
<div>"task" as a straw man term, but feel free to substitute "coroutine", or</div><div>whatever the preferred name ends up being.)</div><div><br></div><div><br></div><div>Definition</div><div>==========</div>
<div><br></div><div>Very informally: A "generator task" is what you get if you take a normal</div><div>Python function and replace its blocking calls with "yield from" calls</div><div>to equivalent subtasks.</div>
<div><br></div><div>More formally, a "generator task" is a generator that implements an</div><div>incremental, multi-step computation, and is intended to be externally</div><div>driven to completion by a runner, or "scheduler", until it delivers a</div>
<div>final result.</div><div><br></div><div>This driving process happens as follows:</div><div><br></div><div>1. A generator task is iterated by its scheduler to yield a series of</div><div>   intermediate "step" values.</div>
<div><br></div><div>2. Each value yielded as a "step" represents a scheduling instruction,</div><div>   or primitive, to be interpreted by the task's scheduler.</div><div><br></div><div>   This scheduling instruction can be None ("just resume this task</div>
<div>   later"), or a variety of other primitives, such as Futures ("resume</div><div>   this task with the result of this Future"); see below for more.</div><div><br></div><div>3. The scheduler is responsible for interpreting each "step" instruction</div>
<div>   as appropriate, and sending the instruction's result, if any, back to</div><div>   the task using send() or throw().</div><div><br></div><div>   A scheduler may run a single task to completion, or may multiplex</div>
<div>   execution between many tasks: generator tasks should assume that</div><div>   other tasks may have executed while the task was yielding.</div><div><br></div><div>4. The generator task completes by successfully returning (raising</div>
<div>   StopIteration), or by raising an exception. The task's caller</div><div>   receives this result.</div><div><br></div><div>(For the sake of discussion, I use "the scheduler" to refer to whoever</div><div>
calls the generator task's next/send/throw methods, and "the task's</div><div>caller" to refer to whoever receives the task's final result, but this</div><div>is not important to the protocol: a task should not care who drives it</div>
<div>or consumes its result, just like an iterator should not.)</div><div><br></div><div><br></div><div>Scheduling instructions / primitives</div><div>====================================</div><div><br></div><div>(This could probably use a better name.)</div>
<div><br></div><div>The protocol is intentionally agnostic about the implementation of</div><div>schedulers, event loops, or reactors: as long as they implement the same</div><div>set of scheduling primitives, code should work across them.</div>
<div><br></div><div>There multiple ways to accomplish this, but one possibility is to have a</div><div>set common, generic instructions in a standard library module such as</div><div>"tasklib" (which could also contain things like default scheduler</div>
<div>implementations, helper functions, and so on).</div><div><br></div><div>A partial list of possible primitives (the names are all made up, not</div><div>serious suggestions):</div><div><br></div><div>1. None: The most basic "do nothing" instruction. This just instructs</div>
<div>   the scheduler to resume the yielding task later.</div><div><br></div><div>2. Futures: Instruct the scheduler to resume with the future's result.</div><div><br></div><div>   Similar types in third-party libraries, such Deferreds, could</div>
<div>   potentially be implemented either natively by a scheduler that</div><div>   supports it, or using a wait_for_deferred(d) helper task, or using</div><div>   the idea of a "adapter" scheduler (see below).</div>
<div><br></div><div>3. Control primitives: spawn, sleep, etc.</div><div><br></div><div>   - Spawn a new (independent) task: yield tasklib.spawn(task())</div><div>   - Wait for multiple tasks: (x, y) = yield tasklib.par(foo(), bar())</div>
<div>   - Delay execution: yield tasklib.sleep(seconds)</div><div>   - etc.</div><div><br></div><div>   These could be simple marker objects, leaving it up to the underlying</div><div>   scheduler to actually recognize and implement them; some could also</div>
<div>   be implemented in terms of simpler operations (e.g.  sleep(), in</div><div>   terms of lower-level suspend and resume operations).</div><div><br></div><div>4. I/O operations</div><div><br></div><div>   This could be anything from low-level "yield fd_readable(sock)" style</div>
<div>   requests, or any of the higher-level APIs being discussed elsewhere.</div><div><br></div><div>   Whatever the exact API ends up being, the scheduler should implement</div><div>   these primitives by waiting for the I/O (or condition), and resuming</div>
<div>   the task with the result, if any.</div><div><br></div><div>5. Cooperative concurrency primitives, for working with locks, condition</div><div>   variables, and so on. (If useful?)</div><div><br></div><div>6. Custom, scheduler-specific instructions: Since a generator task can</div>
<div>   potentially yield anything as a scheduler instruction, it's not</div><div>   inconceivable for specialized schedulers to support specialized</div><div>   instructions. (Code that relies on such special instructions won't</div>
<div>   work on other schedulers, but that would be the point.)</div><div><br></div><div>A question open to debate is what a scheduler should do when faced with</div><div>an unrecognized scheduling instruction.</div><div>
<br></div><div>Raising TypeError or NotImplementedError back into the task is probably</div><div>a reasonable action, and would allow code like:</div><div><br></div><div>    def task():</div><div>        try:</div><div>            yield fancy_magic_instruction()</div>
<div>        except NotImplementedError:</div><div>            yield from boring_fallback()</div><div>        ...</div><div><br></div><div><br></div><div>Generator tasks as schedulers, and vice versa</div><div>=============================================</div>
<div><br></div><div>Note that there is a symmetry to the protocol when a generator task</div><div>calls another using "yield from":</div><div><br></div><div>    def task()</div><div>        spam = yield from subtask()</div>
<div><br></div><div>Here, task() is both a generator task, and the effective scheduler for</div><div>subtask(): it "implements" subtask()'s scheduling instructions by</div><div>delegating them to its own scheduler.</div>
<div><br></div><div>This is a plain observation on its own, however, it raises one or two</div><div>interesting possibilities for more interesting schedulers implemented as</div><div>generator tasks themselves, including:</div>
<div><br></div><div>- Specialized sub-schedulers that run as a normal task within their</div><div>  parent scheduler, but implement for example weighted or priority</div><div>  queuing of their subtasks, or similar features.</div>
<div><br></div><div>- "Adapter" schedulers that intercept special scheduler instructions</div><div>  (say, Deferreds or other library-specific objects), and implement them</div><div>  using more generic instructions to the underlying scheduler.</div>
<div><br></div><div><br></div><div>-- </div><div>Piet Delport</div>