Now asyncio uses lazy task factory by default; it starts executing task’s coroutine at the next loop iteration.
Optional asyncio.eager_task_factory() was added in Python 3.12. It generally makes the code a little faster, but eager factories are not 100% compatible with lazy ones, especially if the test relies on deferred execution.
Add create_lazy_task_factory() and lazy_task_factory() functions optionally switch to the previous behavior if needed.
Python test suite works with eager tasks, but a test or test case could install lazy factory if making the test green requires a lot of changes.
Third parties could be affected if their test suite relies on lazy behavior; they could also use lazy_task_factory if needed.
The change is safe; the current code keeps working as is in 99.99% of cases. Only very specific scenarios could show the difference between lazy and eager behavior.
As a side effect, new defaults are better understandable by users. I recall discussions for code like the following:
task = asyncio.create_task(f())
task.cancel()
With lazy tasks f() is not executed before it is cancelled, try/except block inside this async function doesn’t catch CancelledError. To solve this confusing behavior, we either suggest installing the eager factory or putting await asyncio.sleep(0) just after create_task() call to give f() a chance to start its execution.
I believe that changing the default behavior could avoid this misunderstanding for asyncio users.
The issue is not knowing if it will be eager or lazy in advance, so not knowing if you should delay or not. We also want to keep the passed in coro as the coro of the task to keep current_task.get_coro() working
We don’t want to call the task factory directly, we still want to go via the set task factory on the loop because there could be other side effects there. It’s used to add extra features to tasks or add extra debug information and we want to keep supporting that. We just want the eagerness toggleable on create
Why? What is your use case? Could it be solved by await asyncio.sleep(0)?
Asyncio supports pluggable tasks, third-party event loop could provide own tasks that are not inherited from asyncio.Task. IIRC tornado used to support custom tasks, maybe some library does it as well. These custom tasks could not accept eager_start argument. I see this as the main barrier for adding this feature to asyncio.create_task(). Maybe we can do it without breaking backward compatibility, I don’t know. Let’s eat an elephant piece by piece.
I definitely agree that eager tasks are the future and eventually we should just deprecate and then remove the option to use lazy tasks.
But I think it’s too soon to turn it on by default in 3.14. I would want a super simple way for users to decide whether to use lazy or eager, probably at event loop creation (maybe a new flag to asyncio.run()?)flag,
We can then advertise this flag, and do a careful deprecation cycle.
The docs currently recommend setting the task factory. How would such a super-simple way interact with other task factories?
(I’ve never used custom task types so I have no idea what the consequences would be. All I know is, that one-liner from the docs does indeed make Python behave the same way other languages do.)
BTW, I’ve switched all the CPython to eager tasks locally.
Tests were failed, it was expected.
To make the test suite green again I have added a dozen of await asyncio.sleep(0) lines in ./Lib/test folder.
The only thing was really broken: asyncio repl (./python -m asyncio).
It depends on very exact procedure of contextvars setup, one-line fix makes the repl working again. The fix is backward compatible, it can work with both lazy and eager tasks safely.
So, I think that the flip to eager tasks is safe, more or less.
But I agree with the migration plan proposed by @guido, it is much safer for the community.
the usecase is anyio - we want the start_soon behaviour to match trio’s. We don’t want to introduce an await asyncio.sleep(0) to the user’s coroutine because we want asyncio.current_task().get_coro() to return that coroutine. We also don’t want to always introduce a sleep(0) because on lazy tasks we get two sleep 0s when we only want exactly 1
I think it would be good to start by switching on eager tasks with the uvicorn, fastapi, starlette, litestar, aiohttp and httpx, jupyter, tornado etc test suites (we’re working on it for anyio but it looks like a big job) and once they’re all working start the deprecation
I agree that in future eager tasks should become the default and like the idea of adding a new eager_tasks flag to asyncio.run.
From a performance standpoint, in very large applications, eager tasks make cause performance issues because if all tasks by default use it, it will put lot of pressure on the GC because the eager_tasks set is currently a strong set
This makes me realize that global collections anywhere in asyncio are probably a bad idea under free-threading. This is off-topic here, but I think this is a strong argument for replacing global collections with per-loop collections (IIRC @yselivanov was resisting that in a PR somewhere).
Looks like we have a consensus that global collections should be moved into the loop instance.
But we cannot just move; for the backward compatibility we should keep globals as the last resort if a loop implementation doesn’t support it, right?