Skip to content

gh-77714: Provide an async iterator version of as_completed - #22491

Merged
serhiy-storchaka merged 20 commits into
python:mainfrom
JustinTArthur:async-for-as-completed
Apr 1, 2024
Merged

gh-77714: Provide an async iterator version of as_completed#22491
serhiy-storchaka merged 20 commits into
python:mainfrom
JustinTArthur:async-for-as-completed

Conversation

@JustinTArthur

@JustinTArthur JustinTArthur commented Oct 2, 2020

Copy link
Copy Markdown
Contributor
  • as_completed returns an object that is both
    • an iterator (of coroutines) like before
    • an async iterator of futures (including any futures passed in) (new)
  • Existing tests have been adjusted to test both the old and new style.
    • test_as_completed_reverse_wait was left alone because it specifically tests for quirks with the older style where multiple awaitables can be yielded prior to awaiting them, but each returned awaitable always represents the next-completed task regardless of the order in which it was returned.
  • New test_as_completed_resume_iterator test added to ensure iterator can be resumed in both styles.
  • New test_as_completed_same_tasks_in_as_out test added to ensure tasks passed to the new style are yielded back as-is.
  • Documentation and what's new updated.

Example:

urls = 'https://www.python.org/', 'https://www.mozilla.org/'
task_urls = {
    asyncio.create_task(http_get(url)): url
    for url in urls
}

async for task in asyncio.as_completed(task_urls.keys()):
    completed_url = task_urls[task]
    print(f'Finished {completed_url}!')
    process_page(await task)

This is similar to #10251, but that PR's implementation does not have as_completed return an iterator, so the object's iteration could not be resumed with subsequent for statements or calls to next(). I don't know how widely this is done, but I wanted to ensure backwards compatibility.

https://bugs.python.org/issue33533

#77714

@JustinTArthur

JustinTArthur commented Oct 3, 2020

Copy link
Copy Markdown
Contributor Author

Replaced the commit to conform the new async iterator to the format originally suggested in bpo-33533, where yielded objects are awaitables (including any of the original Futures passed in). I also added Docs adjustments, What's New, and NEWS.d.

@JustinTArthur JustinTArthur changed the title bpo-33533: Provide an async-generator version of as_completed bpo-33533: Provide an async iterator version of as_completed Oct 3, 2020
* as_completed returns object that is both iterator and async iterator
* Existing tests adjusted to test both the old and new style
* New test to ensure iterator can be resumed
* New test to ensure async iterator yields any passed-in Futures as-is
Follows the revisions made based on @hniksic's feedback.
Comment thread Lib/asyncio/tasks.py
@JustinTArthur

Copy link
Copy Markdown
Contributor Author

@1st1 or @asvetlov if I take the time to resolve the conflicts again, will either of you have time in the next week or two to review? If not, let me know if there's another Core Developer you'd recommend to take a look.

@mbello

mbello commented Apr 23, 2021

Copy link
Copy Markdown

The issue that motivated this PR is almost 3 years old now and it really is something I wish had been merged already.

Can we still hope to have it in for 3.10?

@JustinTArthur

JustinTArthur commented Apr 23, 2021

Copy link
Copy Markdown
Contributor Author

I will likely bring it up to the mailing lists. Other than at-mentioning @asvetlov and @1st1 again, I don't know what else I can do at this point; they are the core developers responsible for asyncio last I checked.

@mbello

mbello commented May 24, 2021

Copy link
Copy Markdown

Is there anything that members of the python community can do to get this issue higher on the priority list of reviewers?

@septatrix

septatrix commented Oct 15, 2021

Copy link
Copy Markdown

Is there anything that members of the python community can do to get this issue higher on the priority list of reviewers?

Probably nothing more than upvoting this PR (which I just did).

@MaxwellDupre

Copy link
Copy Markdown
Contributor

Hi, could you re-target for 3.12 (WhatsNew 3.10), please?

@gvanrossum gvanrossum changed the title bpo-33533: Provide an async iterator version of as_completed gh-77714: Provide an async iterator version of as_completed Jan 10, 2024
@gvanrossum gvanrossum self-assigned this Jan 10, 2024

@gvanrossum gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Sorry, I still need to review the tests. I'll do that after I've received your response on this partial review.)

Comment on lines -864 to +887
Run :ref:`awaitable objects <asyncio-awaitables>` in the *aws*
iterable concurrently. Return an iterator of coroutines.
Each coroutine returned can be awaited to get the earliest next
result from the iterable of the remaining awaitables.
Run :ref:`awaitable objects <asyncio-awaitables>` in the *aws* iterable
concurrently. Returns an :term:`asynchronous iterator` of the next-completed
Tasks or Futures. If Tasks or Futures are supplied, those same objects are
yielded on completion. Other awaitables are scheduled and their implicitly
created Tasks are yielded instead.

Raises :exc:`TimeoutError` if the timeout occurs before
all Futures are done.

Example::

for coro in as_completed(aws):
earliest_result = await coro
async for task in as_completed(aws):
earliest_result = await task
# ...

For backwards compatibility, the object returned by ``as_completed()``
can be iterated as a plain iterator, yielding new coroutines that return
the results or raise the errors of the passed in awaitables as their tasks
finish.::

for aw in as_completed(aws):
earliest_result = await aw
# ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I object against the change to emphasize the async iterator API over the synchronous iterator. Since the two examples are identical except for the added keyword await in the former, it makes more sense to describe both variants as equivalent, and explain the difference carefully. The advantage of using the async version should be clear to those who need it. The disadvantage (no support in 3.12 or before) should also be clear.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do that. This was biased towards deprecating the older format, knowing it could mean replacing this hybrid class with a simpler and faster async generator down the line.

Comment thread Doc/library/asyncio-task.rst Outdated
result from the iterable of the remaining awaitables.
Run :ref:`awaitable objects <asyncio-awaitables>` in the *aws* iterable
concurrently. Returns an :term:`asynchronous iterator` of the next-completed
Tasks or Futures. If Tasks or Futures are supplied, those same objects are

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recommend against capitalizing Tasks and Futures -- depending on what loop.create_future() and loop.create_task(), they may or may not return an actual instance or a duck type equivalent. If you really mean the specific classes they should at least be put in code font, and probably cross-linked.

@JustinTArthur JustinTArthur Jan 23, 2024

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right. We use is_future to decide, which duck-types on presence of ._asyncio_future_blocking. Will lower-case these.

Comment thread Doc/library/asyncio-task.rst Outdated
Comment on lines 874 to 877

for coro in as_completed(aws):
earliest_result = await coro
async for task in as_completed(aws):
earliest_result = await task
# ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can expand the example to include a meaningful result (e.g. some data read from a URL?), so there is even more clarity about how to obtain it? Then update the second example too. Or, even better (but more code) show an example where we actually need a table mapping tasks to results (which can't be done using the synchronous iterator).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've finished the other requested doc changes, but still thinking about this one. I tried to come up with a toy example originally, but they always ended up overly verbose. With URLs, we could invent an example with aiohttp or httpx. If we want to stick to standard libraries, we could instead do something DNS-related (e.g. happy eyeballs IPv4 vs IPv6 race). Open to ideas.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sleep sort.

from asyncio import as_completed, sleep, run
import random

async def sleep_and_return(x):
    await sleep(x)
    return x

async def sleep_sort(data):
    for coro in as_completed(sleep_and_return(x) for x in data):
        res = await coro
        print(res)

run(sleep_sort([random.random() for i in range(10)]))

The same can be used also with asynchronous iteration, but the variant that requires keeping identity of futures:

from asyncio import as_completed, sleep, ensure_future, run
import random

def sleeping_future(x):
    fut = ensure_future(sleep(x))
    fut.x = x
    return fut

async def sleep_sort(data):
    async for fut in as_completed(sleeping_future(x) for x in data):
        await fut
        print(fut.x)

run(sleep_sort([random.random() for i in range(10)]))

Comment thread Doc/whatsnew/3.13.rst Outdated

* :func:`asyncio.as_completed` now returns an :term:`asynchronous iterator` of
awaitables.
The yielded awaitables include Task or Future objects that were passed in,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, don't capitalize task or future.

Comment thread Lib/asyncio/tasks.py Outdated
Comment on lines +568 to +572
"""Doubles as an async iterator of as-completed tasks and futures
from a supplied set of awaitables and a plain iterator of
coroutines that resolve to results from the supplied awaitables as
their underlying tasks or futures complete.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer docstrings that start with a brief one-line summary followed blank line, then you can put what you have here.

Also, this sentence is very long. Try rewriting as several shorter sentences.

Comment thread Lib/asyncio/tasks.py Outdated
their underlying tasks or futures complete.
"""
def __init__(self, aws, timeout):
from .queues import Queue # Import here to avoid circular import problem.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still a problem? I don't see how asyncio/queues.py would import asyncio/tasks.py. The original code that had this is a decade old, likely the problem was solved in another way?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still recommend moving this to the top level imports.

Comment thread Lib/asyncio/tasks.py Outdated
Comment on lines +624 to +626
"""Waits for the next future to be done and returns it unless resolve
is set, in which case it returns either the result of the future or
raises an exception."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be a comment, not a docstring? (Also the use of present tense "Waits ..." makes me queasy -- IIRC we have some kind of guideline against it, preferring the imperative mood instead?)

(Also if you remove the resolve argument as I suggested above this becomes much simpler.)

Comment thread Lib/asyncio/tasks.py
Comment thread Lib/asyncio/tasks.py Outdated
Comment on lines +635 to +636
"""Return an async iterator that yields tasks from the given awaitables
in the order they finish as they finish.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer a single-line summary here as well. And probably more equal treatment of both forms, as I recommended in the docs.

Comment thread Lib/asyncio/tasks.py
Comment on lines +1 to +2
:func:`asyncio.as_completed` now returns an asynchronous iterator. Patch by
Justin Arthur.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe clarify that the iterator is ambidextrous (or polymorphic or whatever :-).

@JustinTArthur

Copy link
Copy Markdown
Contributor Author

Thank you very much for taking a look at this one, @serhiy-storchaka and @gvanrossum; it's great to see activity from leads. I'll take a look at the sync-up code changes and review comments this weekend unless someone else wants to jump in earlier. This code was from 3 years ago, so I'll have to re-immerse myself.

Comment thread Lib/asyncio/tasks.py

@gvanrossum gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JustinTArthur thanks! I have a few nits on the docs and I still think that import should be moved. Otherwise LGTM, but I hope @serhiy-storchaka @serhiy-storchaka will also review this one more time. This is an important thing to get right!

Comment thread Doc/library/asyncio-task.rst Outdated
Comment thread Doc/library/asyncio-task.rst Outdated
Comment on lines +874 to +880
ipv4_connect = create_task(
open_connection("127.0.0.1", 80)
)
ipv6_connect = create_task(
open_connection("::1", 80)
)
tasks = [ipv4_connect, ipv6_connect]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be written more compactly:

Suggested change
ipv4_connect = create_task(
open_connection("127.0.0.1", 80)
)
ipv6_connect = create_task(
open_connection("::1", 80)
)
tasks = [ipv4_connect, ipv6_connect]
tasks = [
create_task(open_connection("127.0.0.1", 80)),
create_task(open_connection("::1", 80)),
]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the ipv6_connect variable for use in the identity check below.

Comment thread Doc/library/asyncio-task.rst Outdated
Comment on lines +874 to +899
for coro in as_completed(aws):
earliest_result = await coro
# ...
for coro in as_completed(aws):
earliest_result = await coro
# ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice if this example used the same variable names as the full example above, to emphasize how similar they are (async for -> for).

for earliest_connect in as_completed(tasks):
    reader, writer = await earliest_connect
    # ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be misleading to name the synchronous for variable earliest_connect, because it is not one of ipv4_connect or ipv6_connect. If you expand # ... to the same code as in the asynchronous iteration example, it will not fail, but work incorrectly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And yet, these two are completely equivalent:

    async for result in as_completed([ipv4_connect, ipv6_connect]):
        r, w = await result

and

    for result in as_completed([ipv4_connect, ipv6_connect]):
        r, w = await result

It's true that result represents something different, but in most cases you just want to await it, and then it's the same.

Maybe we need two examples -- one showing that if you just await the result, there's no difference; another showing that only async for can identify the task that first completed?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True. And note that in the asynchronous iteration example it could not necessary be await, it could be a synchronous operation, like result() or result.get(). await is only used to mimic the old code.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, the result is already 'done' in that case? I had not realized that subtlety. Worth pointing out in the docs then!

Comment thread Doc/library/asyncio-task.rst Outdated
Comment thread Lib/asyncio/tasks.py Outdated
Comment thread Lib/asyncio/tasks.py Outdated
Comment on lines +572 to +574
during asynchronous iteration. plain iterator of
coroutines that resolve to results from the supplied awaitables as
their underlying tasks or futures complete.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latter sentence seems to be missing something (at least a verb)?

Comment thread Lib/asyncio/tasks.py Outdated
their underlying tasks or futures complete.
"""
def __init__(self, aws, timeout):
from .queues import Queue # Import here to avoid circular import problem.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still recommend moving this to the top level imports.

Comment thread Lib/asyncio/tasks.py Outdated
Comment on lines +648 to +654
ipv4_connect = create_task(
open_connection("127.0.0.1", 80)
)
ipv6_connect = create_task(
open_connection("::1", 80)
)
tasks = [ipv4_connect, ipv6_connect]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I suggested in the full docs.

Comment thread Lib/asyncio/tasks.py Outdated
Comment on lines +671 to +673
for coro in as_completed(aws):
earliest_result = await coro
# ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto.

@gvanrossum

Copy link
Copy Markdown
Member

Also please resolve the merge conflict (should be simple, something else was added to the asyncio section in what's new).

Comment thread Doc/library/asyncio-task.rst Outdated
Comment on lines +874 to +880
ipv4_connect = create_task(
open_connection("127.0.0.1", 80)
)
ipv6_connect = create_task(
open_connection("::1", 80)
)
tasks = [ipv4_connect, ipv6_connect]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the ipv6_connect variable for use in the identity check below.

Comment thread Doc/library/asyncio-task.rst Outdated
Comment thread Doc/library/asyncio-task.rst Outdated
Comment on lines +874 to +899
for coro in as_completed(aws):
earliest_result = await coro
# ...
for coro in as_completed(aws):
earliest_result = await coro
# ...

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be misleading to name the synchronous for variable earliest_connect, because it is not one of ipv4_connect or ipv6_connect. If you expand # ... to the same code as in the asynchronous iteration example, it will not fail, but work incorrectly.

JustinTArthur and others added 3 commits February 27, 2024 21:25
Co-authored-by: Guido van Rossum <gvanrossum@gmail.com>
Co-authored-by: Serhiy Storchaka <storchaka@gmail.com>
# Conflicts:
#	Doc/whatsnew/3.13.rst
@JustinTArthur

Copy link
Copy Markdown
Contributor Author

Thanks, all. I don't mind the nits. Synced with HEAD of main and resolved the conflict. I'm definitely OK with us reverting the late import. Were you having issues with the module-level import after your initial sync with main, @serhiy-storchaka? Also let me know if you'd like me to try resolving it.

@gvanrossum and @serhiy-storchaka are you OK with the "tasks or futures" terminology used throughout? It could be simplified as just "futures", though I expect tasks to be the more common in practice working with other high-level operations.

I've updated the examples in the doc and docstring to use the IPv4+IPv6 connection scenario for both async and plain iteration. Let me know if this too verbose. We could consider truncating the task setup from the second example if needed.

The doc and function docstring are now pretty similar to eachother. Let me know if one needs to be trimmed down.

@serhiy-storchaka

Copy link
Copy Markdown
Member

I checked several times (months ago and yesterday), and have not found any issues with moving the Queue import at the module level. It does not create a reference loop and does not add any heavy import. Actually, it does not change the import graph, because asyncio.queues is imported in asyncio which is imported as the parent of asyncio.tasks.

@serhiy-storchaka serhiy-storchaka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

The changes since my last review are documentation-only changes, and I can miss some errors, but I like these changes, they clarify some details very well.

@gvanrossum gvanrossum left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, let's merge.

@serhiy-storchaka
serhiy-storchaka merged commit c741ad3 into python:main Apr 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants