Skip to content

gh-136157: Optimize asyncio.to_thread to avoid contextvars.copy_context() overhead for empty contexts #136159

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
7 changes: 5 additions & 2 deletions Lib/asyncio/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,8 @@ async def to_thread(func, /, *args, **kwargs):
"""
loop = events.get_running_loop()
ctx = contextvars.copy_context()
func_call = functools.partial(ctx.run, func, *args, **kwargs)
return await loop.run_in_executor(None, func_call)
if not ctx:
callback = functools.partial(func, *args, **kwargs)
else:
callback = functools.partial(ctx.run, func, *args, **kwargs)
Comment on lines +24 to +27
Copy link
Member

Choose a reason for hiding this comment

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

Hm, couldn't we alternatively add a fast path to Context.run?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think so. Please see the issue for details.

return await loop.run_in_executor(None, callback)
36 changes: 36 additions & 0 deletions Lib/test/test_asyncio/test_threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import unittest
import functools

from contextvars import ContextVar
from unittest import mock
Expand Down Expand Up @@ -61,6 +62,41 @@ def get_ctx():

self.assertEqual(result, 'parrot')

@mock.patch('asyncio.base_events.BaseEventLoop.run_in_executor')
async def test_to_thread_optimization_path(self, run_in_executor):
# This test ensures that `to_thread` uses the correct execution path
# based on whether the context is empty or not.

# `to_thread` awaits the future returned by `run_in_executor`.
# We need to provide a completed future as a return value for the mock.
fut = asyncio.Future()
fut.set_result(None)
run_in_executor.return_value = fut

def myfunc():
pass

# Test with an empty context (optimized path)
await asyncio.to_thread(myfunc)
run_in_executor.assert_called_once()

callback = run_in_executor.call_args.args[1]
self.assertIsInstance(callback, functools.partial)
self.assertIs(callback.func, myfunc)
run_in_executor.reset_mock()

# Test with a non-empty context (standard path)
var = ContextVar('var')
var.set('value')

await asyncio.to_thread(myfunc)
run_in_executor.assert_called_once()

callback = run_in_executor.call_args.args[1]
self.assertIsInstance(callback, functools.partial)
self.assertIsNot(callback.func, myfunc) # Should be ctx.run
self.assertIs(callback.args[0], myfunc)


if __name__ == "__main__":
unittest.main()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Optimized :func:`asyncio.to_thread` to avoid unnecessary performance overhead from calling :meth:`contextvars.Context.run` when the context is empty.
Loading