From 3dcc8dfdb7be4968ca217c15df267d2a9ade9e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 26 May 2024 17:34:25 +0300 Subject: [PATCH 1/5] Added opt-in locking of first detected async backend --- docs/basics.rst | 12 ++++++++++++ docs/versionhistory.rst | 4 ++++ src/anyio/_core/_eventloop.py | 16 ++++++++++++++++ tests/test_pytest_plugin.py | 25 +++++++++++++++++++++++++ 4 files changed, 57 insertions(+) diff --git a/docs/basics.rst b/docs/basics.rst index 86916af4..e18e2750 100644 --- a/docs/basics.rst +++ b/docs/basics.rst @@ -54,10 +54,22 @@ native ``run()`` function of the backend library:: trio.run(main) +Unless you're using trio-asyncio_, you will probably want to reduce the overhead caused +by dynamic backend detection by setting the ``ANYIO_LOCK_DETECTED_BACKEND`` environment +variable to ``1``. This makes AnyIO assume that whichever backend is detected on the +first AnyIO call will always be used going forward. This will not adversely affect the +pytest plugin, as AnyIO detects its presence and then disables backend locking. + .. versionchanged:: 4.0.0 On the ``asyncio`` backend, ``anyio.run()`` now uses a back-ported version of :class:`asyncio.Runner` on Pythons older than 3.11. +.. versionchanged:: 4.4.0 + Added support for locking in the first detected backend via + ``ANYIO_LOCK_DETECTED_BACKEND`` + +.. _trio-asyncio: https://github.com/python-trio/trio-asyncio + .. _backend options: Backend specific options diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 01028693..dfbe38cc 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -5,6 +5,10 @@ This library adheres to `Semantic Versioning 2.0 `_. **UNRELEASED** +- Added an opt-in performance optimization that decreases AnyIO's overhead (compared to + native calls on the selected async backend) by locking in the first automatically + detected async backend, thus always assuming the same backend for future calls by + setting the ``ANYIO_LOCK_DETECTED_BACKEND`` environment variable to ``1`` - Added the ``BlockingPortalProvider`` class to aid with constructing synchronous counterparts to asynchronous interfaces that would otherwise require multiple blocking portals diff --git a/src/anyio/_core/_eventloop.py b/src/anyio/_core/_eventloop.py index 6dcb4589..3530e986 100644 --- a/src/anyio/_core/_eventloop.py +++ b/src/anyio/_core/_eventloop.py @@ -1,6 +1,7 @@ from __future__ import annotations import math +import os import sys import threading from collections.abc import Awaitable, Callable, Generator @@ -26,6 +27,10 @@ threadlocals = threading.local() loaded_backends: dict[str, type[AsyncBackend]] = {} +lock_detected_backend = os.getenv( + "ANYIO_LOCK_DETECTED_BACKEND", "0" +) == "1" and not os.getenv("PYTEST_CURRENT_TEST") +locked_backend: type[AsyncBackend] def run( @@ -152,7 +157,15 @@ def claim_worker_thread( def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]: + global locked_backend + if asynclib_name is None: + if lock_detected_backend: + try: + return locked_backend + except NameError: + pass + asynclib_name = sniffio.current_async_library() # We use our own dict instead of sys.modules to get the already imported back-end @@ -163,4 +176,7 @@ def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]: except KeyError: module = import_module(f"anyio._backends._{asynclib_name}") loaded_backends[asynclib_name] = module.backend_class + if lock_detected_backend: + locked_backend = module.backend_class + return module.backend_class diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 1193aca1..8ef4f71e 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -2,6 +2,7 @@ import pytest from _pytest.logging import LogCaptureFixture +from _pytest.monkeypatch import MonkeyPatch from _pytest.pytester import Pytester from anyio import get_all_backends @@ -418,3 +419,27 @@ async def test_anyio_mark_last_fail(x): result.assert_outcomes( passed=2 * len(get_all_backends()), xfailed=2 * len(get_all_backends()) ) + + +def test_lock_backend(testdir: Pytester, monkeypatch: MonkeyPatch) -> None: + testdir.makepyfile( + """ + import os + import sys + + import pytest + + pytestmark = pytest.mark.anyio + + os.environ["ANYIO_LOCK_DETECTED_BACKEND"] = "1" + del sys.modules['anyio._core._eventloop'] + del sys.modules['anyio'] + import anyio + + async def test_sleep(): + await anyio.sleep(0) + """ + ) + + result = testdir.runpytest(*pytest_args) + result.assert_outcomes(passed=len(get_all_backends())) From 5b311a1e12abc0c2bd57270f753a7195f322335c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Sun, 2 Jun 2024 13:35:47 +0300 Subject: [PATCH 2/5] WIP: use ANYIO_BACKEND instead --- docs/versionhistory.rst | 10 ++++++---- src/anyio/_core/_eventloop.py | 37 +++++++++++++++++++++++------------ tests/test_pytest_plugin.py | 34 ++++++++++++++++++++++---------- 3 files changed, 55 insertions(+), 26 deletions(-) diff --git a/docs/versionhistory.rst b/docs/versionhistory.rst index 395cfbd6..fcded471 100644 --- a/docs/versionhistory.rst +++ b/docs/versionhistory.rst @@ -3,12 +3,14 @@ Version history This library adheres to `Semantic Versioning 2.0 `_. -**4.4.0** +**UNRELEASED** - Added an opt-in performance optimization that decreases AnyIO's overhead (compared to - native calls on the selected async backend) by locking in the first automatically - detected async backend, thus always assuming the same backend for future calls by - setting the ``ANYIO_LOCK_DETECTED_BACKEND`` environment variable to ``1`` + native calls on the selected async backend), used by setting the ``ANYIO_BACKEND`` + environment variable to ``auto``, ``asyncio`` or ``trio`` + +**4.4.0** + - Added the ``BlockingPortalProvider`` class to aid with constructing synchronous counterparts to asynchronous interfaces that would otherwise require multiple blocking portals diff --git a/src/anyio/_core/_eventloop.py b/src/anyio/_core/_eventloop.py index 3530e986..65fe24e0 100644 --- a/src/anyio/_core/_eventloop.py +++ b/src/anyio/_core/_eventloop.py @@ -27,16 +27,14 @@ threadlocals = threading.local() loaded_backends: dict[str, type[AsyncBackend]] = {} -lock_detected_backend = os.getenv( - "ANYIO_LOCK_DETECTED_BACKEND", "0" -) == "1" and not os.getenv("PYTEST_CURRENT_TEST") -locked_backend: type[AsyncBackend] +forced_backend_name = os.getenv("ANYIO_BACKEND") +forced_backend: type[AsyncBackend] def run( func: Callable[[Unpack[PosArgsT]], Awaitable[T_Retval]], *args: Unpack[PosArgsT], - backend: str = "asyncio", + backend: str | None = None, backend_options: dict[str, Any] | None = None, ) -> T_Retval: """ @@ -44,6 +42,11 @@ def run( The current thread must not be already running an event loop. + The backend will be chosen using the following priority list: + * the ``backend`` argument, if not ``None`` + * the ``ANYIO_BACKEND`` environment variable + * ``asyncio`` + :param func: a coroutine function :param args: positional arguments to ``func`` :param backend: name of the asynchronous event loop implementation – currently @@ -63,6 +66,7 @@ def run( else: raise RuntimeError(f"Already running {asynclib_name} in this thread") + backend = backend or os.getenv("ANYIO_BACKEND") or "asyncio" try: async_backend = get_async_backend(backend) except ImportError as exc: @@ -129,7 +133,16 @@ def current_time() -> float: def get_all_backends() -> tuple[str, ...]: - """Return a tuple of the names of all built-in backends.""" + """ + Return a tuple of the names of all built-in backends. + + If the ``ANYIO_BACKEND`` environment variable was set, then the returned tuple will + only contain that backend. + + """ + if forced_backend_name: + return (forced_backend_name,) + return BACKENDS @@ -157,16 +170,16 @@ def claim_worker_thread( def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]: - global locked_backend + global forced_backend if asynclib_name is None: - if lock_detected_backend: + if os.getenv("PYTEST_CURRENT_TEST"): try: - return locked_backend + return forced_backend except NameError: pass - asynclib_name = sniffio.current_async_library() + asynclib_name = forced_backend_name or sniffio.current_async_library() # We use our own dict instead of sys.modules to get the already imported back-end # class because the appropriate modules in sys.modules could potentially be only @@ -176,7 +189,7 @@ def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]: except KeyError: module = import_module(f"anyio._backends._{asynclib_name}") loaded_backends[asynclib_name] = module.backend_class - if lock_detected_backend: - locked_backend = module.backend_class + if asynclib_name == forced_backend_name: + forced_backend = module.backend_class return module.backend_class diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 8ef4f71e..eb9380a2 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -421,25 +421,39 @@ async def test_anyio_mark_last_fail(x): ) -def test_lock_backend(testdir: Pytester, monkeypatch: MonkeyPatch) -> None: - testdir.makepyfile( - """ +@pytest.mark.parametrize("backend_name", get_all_backends()) +def test_lock_backend( + backend_name: str, testdir: Pytester, monkeypatch: MonkeyPatch +) -> None: + testdir.makeconftest( + f""" import os import sys - import pytest + pytest_plugins = ["anyio"] - pytestmark = pytest.mark.anyio - - os.environ["ANYIO_LOCK_DETECTED_BACKEND"] = "1" + os.environ["ANYIO_BACKEND"] = "{backend_name}" del sys.modules['anyio._core._eventloop'] + del sys.modules['anyio.pytest_plugin'] del sys.modules['anyio'] + """ + ) + testdir.makepyfile( + f""" + import os + import sys + + import pytest import anyio - async def test_sleep(): + pytestmark = pytest.mark.anyio + + async def test_sleep(anyio_backend_name): + assert anyio.get_all_backends() == (anyio_backend_name,) + assert anyio_backend_name == {backend_name!r} await anyio.sleep(0) """ ) - result = testdir.runpytest(*pytest_args) - result.assert_outcomes(passed=len(get_all_backends())) + result = testdir.runpytest("-p", "no:anyio") + result.assert_outcomes(passed=1) From 142c485ee123584aa056bee77cf4b8cd848b96f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 4 Jun 2024 11:28:36 +0300 Subject: [PATCH 3/5] Fixed pytest plugin test --- tests/test_pytest_plugin.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index eb9380a2..832fb7dc 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -425,19 +425,6 @@ async def test_anyio_mark_last_fail(x): def test_lock_backend( backend_name: str, testdir: Pytester, monkeypatch: MonkeyPatch ) -> None: - testdir.makeconftest( - f""" - import os - import sys - - pytest_plugins = ["anyio"] - - os.environ["ANYIO_BACKEND"] = "{backend_name}" - del sys.modules['anyio._core._eventloop'] - del sys.modules['anyio.pytest_plugin'] - del sys.modules['anyio'] - """ - ) testdir.makepyfile( f""" import os @@ -455,5 +442,6 @@ async def test_sleep(anyio_backend_name): """ ) - result = testdir.runpytest("-p", "no:anyio") + monkeypatch.setenv("ANYIO_BACKEND", backend_name) + result = testdir.runpytest_subprocess(*pytest_args) result.assert_outcomes(passed=1) From 840f30ce5f733f417bbf5c5be01d2e5a83b0c588 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Tue, 11 Jun 2024 18:01:41 +0300 Subject: [PATCH 4/5] Updated the documentation --- docs/basics.rst | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/docs/basics.rst b/docs/basics.rst index e18e2750..14d63c09 100644 --- a/docs/basics.rst +++ b/docs/basics.rst @@ -55,18 +55,17 @@ native ``run()`` function of the backend library:: trio.run(main) Unless you're using trio-asyncio_, you will probably want to reduce the overhead caused -by dynamic backend detection by setting the ``ANYIO_LOCK_DETECTED_BACKEND`` environment -variable to ``1``. This makes AnyIO assume that whichever backend is detected on the -first AnyIO call will always be used going forward. This will not adversely affect the -pytest plugin, as AnyIO detects its presence and then disables backend locking. +by dynamic backend detection by setting the ``ANYIO_BACKEND`` environment variable to +either ``asyncio`` or ``trio``, depending on your backend of choice. This will enable +AnyIO to avoid checking which flavor of async event loop you're running when you call +one of AnyIO's functions – a check that typically involves at least one system call. .. versionchanged:: 4.0.0 On the ``asyncio`` backend, ``anyio.run()`` now uses a back-ported version of :class:`asyncio.Runner` on Pythons older than 3.11. .. versionchanged:: 4.4.0 - Added support for locking in the first detected backend via - ``ANYIO_LOCK_DETECTED_BACKEND`` + Added support for forcing a specific backend via ``ANYIO_BACKEND`` .. _trio-asyncio: https://github.com/python-trio/trio-asyncio From 7858487df4a473dde2c1dece18a745e44339f3a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Gr=C3=B6nholm?= Date: Thu, 18 Jul 2024 18:28:11 +0300 Subject: [PATCH 5/5] Fixed the logic in get_async_backend() --- src/anyio/_core/_eventloop.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/anyio/_core/_eventloop.py b/src/anyio/_core/_eventloop.py index 65fe24e0..5d807776 100644 --- a/src/anyio/_core/_eventloop.py +++ b/src/anyio/_core/_eventloop.py @@ -28,7 +28,7 @@ threadlocals = threading.local() loaded_backends: dict[str, type[AsyncBackend]] = {} forced_backend_name = os.getenv("ANYIO_BACKEND") -forced_backend: type[AsyncBackend] +forced_backend: type[AsyncBackend] | None = None def run( @@ -172,18 +172,15 @@ def claim_worker_thread( def get_async_backend(asynclib_name: str | None = None) -> type[AsyncBackend]: global forced_backend - if asynclib_name is None: - if os.getenv("PYTEST_CURRENT_TEST"): - try: - return forced_backend - except NameError: - pass - - asynclib_name = forced_backend_name or sniffio.current_async_library() + if forced_backend is not None: + return forced_backend # We use our own dict instead of sys.modules to get the already imported back-end # class because the appropriate modules in sys.modules could potentially be only # partially initialized + asynclib_name = ( + asynclib_name or forced_backend_name or sniffio.current_async_library() + ) try: return loaded_backends[asynclib_name] except KeyError: