[Python-Dev] PEP 3156 - Asynchronous IO Support Rebooted (original) (raw)

Guido van Rossum guido at python.org
Fri Dec 21 19:57:12 CET 2012


Dear python-dev and python-ideas,

I am posting PEP 3156 here for early review and discussion. As you can see from the liberally sprinkled TBD entries it is not done, but I am about to disappear on vacation for a few weeks and I am reasonably happy with the state of things so far. (Of course feedback may change this. :-) Also, there has already been some discussion on python-ideas (and even on Twitter) so I don't want python-dev to feel out of the loop -- this is a proposal for a new standard library module. (But no, I haven't picked the module name yet. :-)

There's an -- also incomplete -- reference implementation at http://code.google.com/p/tulip/ -- unlike the first version of tulip, this version actually has (some) unittests.

Let the bikeshedding begin!

(Oh, happy holidays too. :-)

-- --Guido van Rossum (python.org/~guido) -------------- next part -------------- PEP: 3156 Title: Asynchronous IO Support Rebooted Version: RevisionRevisionRevision Last-Modified: DateDateDate Author: Guido van Rossum <guido at python.org> Status: Draft Type: Standards Track Content-Type: text/x-rst Created: 12-Dec-2012 Post-History: TBD

Abstract

This is a proposal for asynchronous I/O in Python 3, starting with Python 3.3. Consider this the concrete proposal that is missing from PEP 3153. The proposal includes a pluggable event loop API, transport and protocol abstractions similar to those in Twisted, and a higher-level scheduler based on yield from (PEP 380). A reference implementation is in the works under the code name tulip.

Introduction

The event loop is the place where most interoperability occurs. It should be easy for (Python 3.3 ports of) frameworks like Twisted, Tornado, or ZeroMQ to either adapt the default event loop implementation to their needs using a lightweight wrapper or proxy, or to replace the default event loop implementation with an adaptation of their own event loop implementation. (Some frameworks, like Twisted, have multiple event loop implementations. This should not be a problem since these all have the same interface.)

It should even be possible for two different third-party frameworks to interoperate, either by sharing the default event loop implementation (each using its own adapter), or by sharing the event loop implementation of either framework. In the latter case two levels of adaptation would occur (from framework A's event loop to the standard event loop interface, and from there to framework B's event loop). Which event loop implementation is used should be under control of the main program (though a default policy for event loop selection is provided).

Thus, two separate APIs are defined:

An event loop implementation may provide additional methods and guarantees.

The event loop interface does not depend on yield from. Rather, it uses a combination of callbacks, additional interfaces (transports and protocols), and Futures. The latter are similar to those defined in PEP 3148, but have a different implementation and are not tied to threads. In particular, they have no wait() method; the user is expected to use callbacks.

For users (like myself) who don't like using callbacks, a scheduler is provided for writing asynchronous I/O code as coroutines using the PEP 380 yield from expressions. The scheduler is not pluggable; pluggability occurs at the event loop level, and the scheduler should work with any conforming event loop implementation.

For interoperability between code written using coroutines and other async frameworks, the scheduler has a Task class that behaves like a Future. A framework that interoperates at the event loop level can wait for a Future to complete by adding a callback to the Future. Likewise, the scheduler offers an operation to suspend a coroutine until a callback is called.

Limited interoperability with threads is provided by the event loop interface; there is an API to submit a function to an executor (see PEP 3148) which returns a Future that is compatible with the event loop.

Non-goals

Interoperability with systems like Stackless Python or greenlets/gevent is not a goal of this PEP.

Specification

Dependencies

Python 3.3 is required. No new language or standard library features beyond Python 3.3 are required. No third-party modules or packages are required.

Module Namespace

The specification here will live in a new toplevel package. Different components will live in separate submodules of that package. The package will import common APIs from their respective submodules and make them available as package attributes (similar to the way the email package works).

The name of the toplevel package is currently unspecified. The reference implementation uses the name 'tulip', but the name will change to something more boring if and when the implementation is moved into the standard library (hopefully for Python 3.4).

Until the boring name is chosen, this PEP will use 'tulip' as the toplevel package name. Classes and functions given without a module name are assumed to be accessed via the toplevel package.

Event Loop Policy: Getting and Setting the Event Loop

To get the current event loop, use get_event_loop(). This returns an instance of the EventLoop class defined below or an equivalent object. It is possible that get_event_loop() returns a different object depending on the current thread, or depending on some other notion of context.

To set the current event loop, use set_event_loop(event_loop), where event_loop is an instance of the EventLoop class or equivalent. This uses the same notion of context as get_event_loop().

For the benefit of unit tests and other special cases there's a third policy function: init_event_loop(), which creates a new EventLoop instance and calls set_event_loop() with it. TBD: Maybe we should have a create_default_event_loop_instance() function instead?

To change the way the above three functions work (including their notion of context), call set_event_loop_policy(policy), where policy is an event loop policy object. The policy object can be any object that has methods get_event_loop(), set_event_loop(event_loop) and init_event_loop() behaving like the functions described above. The default event loop policy is an instance of the class DefaultEventLoopPolicy. The current event loop policy object can be retrieved by calling get_event_loop_policy().

An event loop policy may but does not have to enforce that there is only one event loop in existence. The default event loop policy does not enforce this, but it does enforce that there is only one event loop per thread.

Event Loop Interface

(A note about times: as usual in Python, all timeouts, intervals and delays are measured in seconds, and may be ints or floats. The accuracy and precision of the clock are up to the implementation; the default implementation uses time.monotonic().)

A conforming event loop object has the following methods:

Some methods in the standard conforming interface return Futures:

TBD: Some platforms may not be interested in implementing all of these, e.g. start_serving() may be of no interest to mobile apps. (Although, there's a Minecraft server on my iPad...)

The following methods for registering callbacks for file descriptors are optional. If they are not implemented, accessing the method (without calling it) returns AttributeError. The default implementation provides them but the user normally doesn't use these directly -- they are used by the transport implementations exclusively. Also, on Windows these may be present or not depending on whether a select-based or IOCP-based event loop is used. These take integer file descriptors only, not objects with a fileno() method. The file descriptor should represent something pollable -- i.e. no disk files.

TBD: What about multiple callbacks per fd? The current semantics is that add_reader()/add_writer() replace a previously registered callback. Change this to raise an exception if a callback is already registered.

The following methods for doing async I/O on sockets are optional. They are alternative to the previous set of optional methods, intended for transport implementations on Windows using IOCP (if the event loop supports it). The socket argument has to be a non-blocking socket.

TBD: Optional methods are not so good. Perhaps these should be required? It may still depend on the platform which set is more efficient.

Callback Sequencing

When two callbacks are scheduled for the same time, they are run in the order in which they are registered. For example::

ev.call_soon(foo) ev.call_soon(bar)

guarantees that foo() is called before bar().

If call_soon() is used, this guarantee is true even if the system clock were to run backwards. This is also the case for call_later(0, callback, *args). However, if call_later() is used with a nonzero delay, all bets are off if the system clock were to runs backwards. (A good event loop implementation should use time.monotonic() to avoid problems when the clock runs backward. See PEP 418.)

Context

All event loops have a notion of context. For the default event loop implementation, the context is a thread. An event loop implementation should run all callbacks in the same context. An event loop implementation should run only one callback at a time, so callbacks can assume automatic mutual exclusion with other callbacks scheduled in the same event loop.

Exceptions

There are two categories of exceptions in Python: those that derive from the Exception class and those that derive from BaseException. Exceptions deriving from Exception will generally be caught and handled appropriately; for example, they will be passed through by Futures, and they will be logged and ignored when they occur in a callback.

However, exceptions deriving only from BaseException are never caught, and will usually cause the program to terminate with a traceback. (Examples of this category include KeyboardInterrupt and SystemExit; it is usually unwise to treat these the same as most other exceptions.)

The Handler Class

The various methods for registering callbacks (e.g. call_later()) all return an object representing the registration that can be used to cancel the callback. For want of a better name this object is called a Handler, although the user never needs to instantiate instances of this class. There is one public method:

Read-only public attributes:

Note that some callbacks (e.g. those registered with call_later()) are meant to be called only once. Others (e.g. those registered with add_reader()) are meant to be called multiple times.

TBD: An API to call the callback (encapsulating the exception handling necessary)? Should it record how many times it has been called? Maybe this API should just be __call__()? (But it should suppress exceptions.)

TBD: Public attribute recording the realtime value when the callback is scheduled? (Since this is needed anyway for storing it in a heap.)

Futures

The tulip.Future class here is intentionally similar to the concurrent.futures.Future class specified by PEP 3148, but there are slight differences. The supported public API is as follows, indicating the differences with PEP 3148:

The internal methods defined in PEP 3148 are not supported. (TBD: Maybe we do need to support these, in order to make it easy to write user code that returns a Future?)

A tulip.Future object is not acceptable to the wait() and as_completed() functions in the concurrent.futures package.

A tulip.Future object is acceptable to a yield from expression when used in a coroutine. This is implemented through the __iter__() interface on the Future. See the section "Coroutines and the Scheduler" below.

When a Future is garbage-collected, if it has an associated exception but neither result() nor exception() nor __iter__() has ever been called (or the latter hasn't raised the exception yet -- details TBD), the exception should be logged. TBD: At what level?

In the future (pun intended) we may unify tulip.Future and concurrent.futures.Future, e.g. by adding an __iter__() method to the latter that works with yield from. To prevent accidentally blocking the event loop by calling e.g. result() on a Future that's not don yet, the blocking operation may detect that an event loop is active in the current thread and raise an exception instead. However the current PEP strives to have no dependencies beyond Python 3.3, so changes to concurrent.futures.Future are off the table for now.

Transports

A transport is an abstraction on top of a socket or something similar (for example, a UNIX pipe or an SSL connection). Transports are strongly influenced by Twisted and PEP 3153. Users rarely implement or instantiate transports -- rather, event loops offer utility methods to set up transports.

Transports work in conjunction with protocols. Protocols are typically written without knowing or caring about the exact type of transport used, and transports can be used with a wide variety of protocols. For example, an HTTP client protocol implementation may be used with either a plain socket transport or an SSL transport. The plain socket transport can be used with many different protocols besides HTTP (e.g. SMTP, IMAP, POP, FTP, IRC, SPDY).

Most connections have an asymmetric nature: the client and server usually have very different roles and behaviors. Hence, the interface between transport and protocol is also asymmetric. From the protocol's point of view, writing data is done by calling the write() method on the transport object; this buffers the data and returns immediately. However, the transport takes a more active role in reading data: whenever some data is read from the socket (or other data source), the transport calls the protocol's data_received() method.

Transports have the following public methods:

TBD: Provide flow control the other way -- the transport may need to suspend the protocol if the amount of data buffered becomes a burden. Proposal: let the transport call protocol.pause() and protocol.resume() if they exist; if they don't exist, the protocol doesn't support flow control. (Perhaps different names to avoid confusion between protocols and transports?)

Protocols

Protocols are always used in conjunction with transports. While a few common protocols are provided (e.g. decent though not necessarily excellent HTTP client and server implementations), most protocols will be implemented by user code or third-party libraries.

A protocol must implement the following methods, which will be called by the transport. Consider these callbacks that are always called by the event loop in the right context. (See the "Context" section above.)

Here is a chart indicating the order and multiplicity of calls:

  1. connection_made() -- exactly once
  2. data_received() -- zero or more times
  3. eof_received() -- at most once
  4. connection_lost() -- exactly once

TBD: Discuss whether user code needs to do anything to make sure that protocol and transport aren't garbage-collected prematurely.

Callback Style

Most interfaces taking a callback also take positional arguments. For instance, to arrange for foo("abc", 42) to be called soon, you call ev.call_soon(foo, "abc", 42). To schedule the call foo(), use ev.call_soon(foo). This convention greatly reduces the number of small lambdas required in typical callback programming.

This convention specifically does not support keyword arguments. Keyword arguments are used to pass optional extra information about the callback. This allows graceful evolution of the API without having to worry about whether a keyword might be significant to a callee somewhere. If you have a callback that must be called with a keyword argument, you can use a lambda or functools.partial. For example::

ev.call_soon(functools.partial(foo, "abc", repeat=42))

Choosing an Event Loop Implementation

TBD. (This is about the choice to use e.g. select vs. poll vs. epoll, and how to override the choice. Probably belongs in the event loop policy.)

Coroutines and the Scheduler

This is a separate toplevel section because its status is different from the event loop interface. Usage of coroutines is optional, and it is perfectly fine to write code using callbacks only. On the other hand, there is only one implementation of the scheduler/coroutine API, and if you're using coroutines, that's the one you're using.

Coroutines

A coroutine is a generator that follows certain conventions. For documentation purposes, all coroutines should be decorated with @tulip.coroutine, but this cannot be strictly enforced.

Coroutines use the yield from syntax introduced in PEP 380, instead of the original yield syntax.

The word "coroutine", like the word "generator", is used for two different (though related) concepts:

Things a coroutine can do:

Calling a coroutine does not start its code running -- it is just a generator, and the coroutine object returned by the call is really a generator object, which doesn't do anything until you iterate over it. In the case of a coroutine object, there are two basic ways to start it running: call yield from coroutine from another coroutine (assuming the other coroutine is already running!), or convert it to a Task.

Coroutines can only run when the event loop is running.

Tasks

A Task is an object that manages an independently running coroutine. The Task interface is the same as the Future interface. The task becomes done when its coroutine returns or raises an exception; if it returns a result, that becomes the task's result, if it raises an exception, that becomes the task's exception.

Cancelling a task that's not done yet prevents its coroutine from completing; in this case an exception is thrown into the coroutine that it may catch to further handle cancellation, but it doesn't have to (this is done using the standard close() method on generators, described in PEP 342).

The par() function described above runs coroutines in parallel by converting them to Tasks. (Arguments that are already Tasks or Futures are not converted.)

Tasks are also useful for interoperating between coroutines and callback-based frameworks like Twisted. After converting a coroutine into a Task, callbacks can be added to the Task.

You may ask, why not convert all coroutines to Tasks? The @tulip.coroutine decorator could do this. This would slow things down considerably in the case where one coroutine calls another (and so on), as switching to a "bare" coroutine has much less overhead than switching to a Task.

The Scheduler

The scheduler has no public interface. You interact with it by using yield from future and yield from task. In fact, there is no single object representing the scheduler -- its behavior is implemented by the Task and Future classes using only the public interface of the event loop, so it will work with third-party event loop implementations, too.

Sleeping

TBD: yield sleep(seconds). Can use sleep(0) to suspend to poll for I/O.

Wait for First

TBD: Need an interface to wait for the first of a collection of Futures.

Coroutines and Protocols

The best way to use coroutines to implement protocols is probably to use a streaming buffer that gets filled by data_received() and can be read asynchronously using methods like read(n) and readline() that return a Future. When the connection is closed, read() should return a Future whose result is b'', or raise an exception if connection_closed() is called with an exception.

To write, the write() method (and friends) on the transport can be used -- these do not return Futures. A standard protocol implementation should be provided that sets this up and kicks off the coroutine when connection_made() is called.

TBD: Be more specific.

Cancellation

TBD. When a Task is cancelled its coroutine may see an exception at any point where it is yielding to the scheduler (i.e., potentially at any yield from operation). We need to spell out which exception is raised.

Also TBD: timeouts.

Open Issues

Acknowledgments

Apart from PEP 3153, influences include PEP 380 and Greg Ewing's tutorial for yield from, Twisted, Tornado, ZeroMQ, pyftpdlib, tulip (the author's attempts at synthesis of all these), wattle (Steve Dower's counter-proposal), numerous discussions on python-ideas from September through December 2012, a Skype session with Steve Dower and Dino Viehland, email exchanges with Ben Darnell, an audience with Niels Provos (original author of libevent), and two in-person meetings with several Twisted developers, including Glyph, Brian Warner, David Reid, and Duncan McGreggor. Also, the author's previous work on async support in the NDB library for Google App Engine was an important influence.

Copyright

This document has been placed in the public domain.

.. Local Variables: mode: indented-text indent-tabs-mode: nil sentence-end-double-space: t fill-column: 70 coding: utf-8 End:



More information about the Python-Dev mailing list