Browse Source
bpo-33530: Implement Happy Eyeballs in asyncio, v2 (GH-7237)
bpo-33530: Implement Happy Eyeballs in asyncio, v2 (GH-7237)
Added two keyword arguments, `delay` and `interleave`, to `BaseEventLoop.create_connection`. Happy eyeballs is activated if `delay` is specified. We now have documentation for the new arguments. `staggered_race()` is in its own module, but not exported to the main asyncio package. https://bugs.python.org/issue33530pull/13101/head
committed by
Miss Islington (bot)
5 changed files with 264 additions and 38 deletions
-
24Doc/library/asyncio-eventloop.rst
-
125Lib/asyncio/base_events.py
-
3Lib/asyncio/events.py
-
147Lib/asyncio/staggered.py
-
3Misc/NEWS.d/next/Library/2018-05-29-18-34-53.bpo-33530._4Q_bi.rst
@ -0,0 +1,147 @@ |
|||
"""Support for running coroutines in parallel with staggered start times.""" |
|||
|
|||
__all__ = 'staggered_race', |
|||
|
|||
import contextlib |
|||
import typing |
|||
|
|||
from . import events |
|||
from . import futures |
|||
from . import locks |
|||
from . import tasks |
|||
|
|||
|
|||
async def staggered_race( |
|||
coro_fns: typing.Iterable[typing.Callable[[], typing.Awaitable]], |
|||
delay: typing.Optional[float], |
|||
*, |
|||
loop: events.AbstractEventLoop = None, |
|||
) -> typing.Tuple[ |
|||
typing.Any, |
|||
typing.Optional[int], |
|||
typing.List[typing.Optional[Exception]] |
|||
]: |
|||
"""Run coroutines with staggered start times and take the first to finish. |
|||
|
|||
This method takes an iterable of coroutine functions. The first one is |
|||
started immediately. From then on, whenever the immediately preceding one |
|||
fails (raises an exception), or when *delay* seconds has passed, the next |
|||
coroutine is started. This continues until one of the coroutines complete |
|||
successfully, in which case all others are cancelled, or until all |
|||
coroutines fail. |
|||
|
|||
The coroutines provided should be well-behaved in the following way: |
|||
|
|||
* They should only ``return`` if completed successfully. |
|||
|
|||
* They should always raise an exception if they did not complete |
|||
successfully. In particular, if they handle cancellation, they should |
|||
probably reraise, like this:: |
|||
|
|||
try: |
|||
# do work |
|||
except asyncio.CancelledError: |
|||
# undo partially completed work |
|||
raise |
|||
|
|||
Args: |
|||
coro_fns: an iterable of coroutine functions, i.e. callables that |
|||
return a coroutine object when called. Use ``functools.partial`` or |
|||
lambdas to pass arguments. |
|||
|
|||
delay: amount of time, in seconds, between starting coroutines. If |
|||
``None``, the coroutines will run sequentially. |
|||
|
|||
loop: the event loop to use. |
|||
|
|||
Returns: |
|||
tuple *(winner_result, winner_index, exceptions)* where |
|||
|
|||
- *winner_result*: the result of the winning coroutine, or ``None`` |
|||
if no coroutines won. |
|||
|
|||
- *winner_index*: the index of the winning coroutine in |
|||
``coro_fns``, or ``None`` if no coroutines won. If the winning |
|||
coroutine may return None on success, *winner_index* can be used |
|||
to definitively determine whether any coroutine won. |
|||
|
|||
- *exceptions*: list of exceptions returned by the coroutines. |
|||
``len(exceptions)`` is equal to the number of coroutines actually |
|||
started, and the order is the same as in ``coro_fns``. The winning |
|||
coroutine's entry is ``None``. |
|||
|
|||
""" |
|||
# TODO: when we have aiter() and anext(), allow async iterables in coro_fns. |
|||
loop = loop or events.get_running_loop() |
|||
enum_coro_fns = enumerate(coro_fns) |
|||
winner_result = None |
|||
winner_index = None |
|||
exceptions = [] |
|||
running_tasks = [] |
|||
|
|||
async def run_one_coro( |
|||
previous_failed: typing.Optional[locks.Event]) -> None: |
|||
# Wait for the previous task to finish, or for delay seconds |
|||
if previous_failed is not None: |
|||
with contextlib.suppress(futures.TimeoutError): |
|||
# Use asyncio.wait_for() instead of asyncio.wait() here, so |
|||
# that if we get cancelled at this point, Event.wait() is also |
|||
# cancelled, otherwise there will be a "Task destroyed but it is |
|||
# pending" later. |
|||
await tasks.wait_for(previous_failed.wait(), delay) |
|||
# Get the next coroutine to run |
|||
try: |
|||
this_index, coro_fn = next(enum_coro_fns) |
|||
except StopIteration: |
|||
return |
|||
# Start task that will run the next coroutine |
|||
this_failed = locks.Event() |
|||
next_task = loop.create_task(run_one_coro(this_failed)) |
|||
running_tasks.append(next_task) |
|||
assert len(running_tasks) == this_index + 2 |
|||
# Prepare place to put this coroutine's exceptions if not won |
|||
exceptions.append(None) |
|||
assert len(exceptions) == this_index + 1 |
|||
|
|||
try: |
|||
result = await coro_fn() |
|||
except Exception as e: |
|||
exceptions[this_index] = e |
|||
this_failed.set() # Kickstart the next coroutine |
|||
else: |
|||
# Store winner's results |
|||
nonlocal winner_index, winner_result |
|||
assert winner_index is None |
|||
winner_index = this_index |
|||
winner_result = result |
|||
# Cancel all other tasks. We take care to not cancel the current |
|||
# task as well. If we do so, then since there is no `await` after |
|||
# here and CancelledError are usually thrown at one, we will |
|||
# encounter a curious corner case where the current task will end |
|||
# up as done() == True, cancelled() == False, exception() == |
|||
# asyncio.CancelledError. This behavior is specified in |
|||
# https://bugs.python.org/issue30048 |
|||
for i, t in enumerate(running_tasks): |
|||
if i != this_index: |
|||
t.cancel() |
|||
|
|||
first_task = loop.create_task(run_one_coro(None)) |
|||
running_tasks.append(first_task) |
|||
try: |
|||
# Wait for a growing list of tasks to all finish: poor man's version of |
|||
# curio's TaskGroup or trio's nursery |
|||
done_count = 0 |
|||
while done_count != len(running_tasks): |
|||
done, _ = await tasks.wait(running_tasks) |
|||
done_count = len(done) |
|||
# If run_one_coro raises an unhandled exception, it's probably a |
|||
# programming error, and I want to see it. |
|||
if __debug__: |
|||
for d in done: |
|||
if d.done() and not d.cancelled() and d.exception(): |
|||
raise d.exception() |
|||
return winner_result, winner_index, exceptions |
|||
finally: |
|||
# Make sure no tasks are left running if we leave this function |
|||
for t in running_tasks: |
|||
t.cancel() |
|||
@ -0,0 +1,3 @@ |
|||
Implemented Happy Eyeballs in `asyncio.create_connection()`. Added two new |
|||
arguments, *happy_eyeballs_delay* and *interleave*, |
|||
to specify Happy Eyeballs behavior. |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue