Skip to content

Commit 2557e94

Browse files
authored
Merge pull request #137 from oremanj/sync-with-greenlet
Use greenlets rather than threads for sync loop
2 parents 4268ffa + 32563cf commit 2557e94

File tree

10 files changed

+182
-198
lines changed

10 files changed

+182
-198
lines changed

docs-requirements.txt

+1
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ towncrier
55
trio >= 0.15.0
66
outcome
77
attrs
8+
greenlet

docs/source/principles.rst

+3-2
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,11 @@ this gap by providing two event loop implementations.
168168
installed a custom event loop policy, calling :func:`asyncio.new_event_loop`
169169
(including the implicit call made by the first :func:`asyncio.get_event_loop`
170170
in the main thread) will give you an event loop that transparently runs
171-
in a separate thread in order to support multiple
171+
in a separate greenlet in order to support multiple
172172
calls to :meth:`~asyncio.loop.run_until_complete`,
173173
:meth:`~asyncio.loop.run_forever`, and :meth:`~asyncio.loop.stop`.
174174
Sync loops are intended to allow trio-asyncio to run the existing
175175
test suites of large asyncio libraries, which often call
176176
:meth:`~asyncio.loop.run_until_complete` on the same loop multiple times.
177-
Using them for other purposes is deprecated.
177+
Using them for other purposes is not recommended (it is better to refactor
178+
so you can use an async loop) but will probably work.

docs/source/usage.rst

+13-24
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Asyncio main loop
104104
+++++++++++++++++
105105

106106
Sometimes you instead start with asyncio code which you wish to extend
107-
with some Trio portions. By far the best-supported approach here is to
107+
with some Trio portions. The best-supported approach here is to
108108
wrap your entire asyncio program in a Trio event loop. In other words,
109109
you should transform this code::
110110

@@ -123,27 +123,18 @@ to this::
123123
trio_asyncio.run(trio_asyncio.aio_as_trio(async_main))
124124

125125
If your program makes multiple calls to ``run_until_complete()`` and/or
126-
``run_forever()``, this may be a somewhat challenging transformation.
127-
In theory, you can instead keep the old approach (``get_event_loop()`` +
126+
``run_forever()``, or if the call to :func:`asyncio.run` is hidden inside
127+
a library you're using, then this may be a somewhat challenging transformation.
128+
In such cases, you can instead keep the old approach (``get_event_loop()`` +
128129
``run_until_complete()``) unchanged, and if you've imported ``trio_asyncio``
129130
(and not changed the asyncio event loop policy) you'll still be able to use
130131
:func:`~trio_asyncio.trio_as_aio` to run Trio code from within your
131-
asyncio-flavored functions. In practice, this is not recommended, because:
132-
133-
* It's implemented by running the contents of the loop in an
134-
additional thread, so anything that expects to run on the main
135-
thread (such as a signal handler) won't be happy.
136-
137-
* The implementation is kind of a terrible hack.
138-
139-
For these reasons, obtaining a new Trio-enabled asyncio event loop
140-
using the standard asyncio functions (:func:`asyncio.get_event_loop`,
141-
etc), rather than :func:`trio_asyncio.open_loop`, will raise a
142-
deprecation warning. (Except when running under pytest, because
143-
support for ``run_until_complete()`` is often needed to test asyncio
144-
libraries' test suites against trio-asyncio.) asyncio is transitioning
145-
towards the model of using a single top-level :func:`asyncio.run` call
146-
anyway, so the effort you spend on conversion won't be wasted.
132+
asyncio-flavored functions. This is referred to internally as a "sync loop"
133+
(``SyncTripEventLoop``), as contrasted with the "async loop" that you use
134+
when you start from an existing Trio run. The sync loop is implemented using
135+
the ``greenlet`` library to switch out of a Trio run that has not yet completed,
136+
so it is less well-supported than the approach where you start in Trio.
137+
But as of trio-asyncio 0.14.0, we do think it should generally work.
147138

148139
Compatibility issues
149140
++++++++++++++++++++
@@ -166,7 +157,7 @@ Interrupting the asyncio loop
166157

167158
A trio-asyncio event loop created with :func:`open_loop` does not support
168159
``run_until_complete`` or ``run_forever``. If you need these features,
169-
you might be able to get away with using a (deprecated) "sync loop" as
160+
you might be able to get away with using a "sync loop" as
170161
explained :ref:`above <asyncio-loop>`, but it's better to refactor
171162
your program so all of its async code runs within a single event loop
172163
invocation. For example, you might replace::
@@ -180,7 +171,7 @@ invocation. For example, you might replace::
180171
loop = asyncio.get_event_loop()
181172
loop.run_until_complete(setup)
182173
loop.run_forever()
183-
174+
184175
with::
185176

186177
stopped_event = trio.Event()
@@ -202,9 +193,7 @@ Detecting the current function's flavor
202193

203194
:func:`sniffio.current_async_library` correctly reports "asyncio" or
204195
"trio" when called from a trio-asyncio program, based on the flavor of
205-
function that's calling it. (Some corner cases
206-
might not work on Pythons below 3.7 where asyncio doesn't support
207-
context variables.)
196+
function that's calling it.
208197

209198
However, this feature should generally not be necessary, because you
210199
should know whether each function in your program is asyncio-flavored

newsfragments/137.feature.rst

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
trio-asyncio now implements its :ref:`synchronous event loop <asyncio-loop>`
2+
(which is used when the top-level of your program is an asyncio call such as
3+
:func:`asyncio.run`, rather than a Trio call such as :func:`trio.run`)
4+
using the ``greenlet`` library rather than a separate thread. This provides
5+
some better theoretical grounding and fixes various edge cases around signal
6+
handling and other integrations; in particular, recent versions of IPython
7+
will no longer crash when importing trio-asyncio. Synchronous event loops have
8+
been un-deprecated with this change, though we still recommend using an
9+
async loop (``async with trio_asyncio.open_loop():`` from inside a Trio run)
10+
where possible.

setup.py

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"outcome",
6868
"sniffio >= 1.3.0",
6969
"exceptiongroup >= 1.0.0; python_version < '3.11'",
70+
"greenlet",
7071
],
7172
# This means, just install *everything* you see under trio/, even if it
7273
# doesn't look like a source file, so long as it appears in MANIFEST.in:

tests/conftest.py

-20
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,6 @@ def xfail(rel_id):
123123
def skip(rel_id):
124124
mark(pytest.mark.skip, rel_id)
125125

126-
# This hangs, probably due to the thread shenanigans (it works
127-
# fine with a greenlet-based sync loop)
128-
skip("test_base_events.py::RunningLoopTests::test_running_loop_within_a_loop")
129-
130126
# Remainder of these have unclear issues
131127
if sys.version_info < (3, 8):
132128
xfail(
@@ -201,23 +197,7 @@ def skip(rel_id):
201197
may_be_absent=True,
202198
)
203199

204-
if sys.version_info >= (3, 9):
205-
# This tries to create a new loop from within an existing one,
206-
# which we don't support.
207-
xfail("test_locks.py::ConditionTests::test_ambiguous_loops")
208-
209200
if sys.version_info >= (3, 12):
210-
# This test sets signal handlers from within a coroutine,
211-
# which doesn't work for us because SyncTrioEventLoop runs on
212-
# a non-main thread.
213-
xfail("test_unix_events.py::TestFork::test_fork_signal_handling")
214-
215-
# This test explicitly uses asyncio.tasks._c_current_task,
216-
# bypassing our monkeypatch.
217-
xfail(
218-
"test_tasks.py::CCurrentLoopTests::test_current_task_with_implicit_loop"
219-
)
220-
221201
# These tests assume asyncio.sleep(0) is sufficient to run all pending tasks
222202
xfail(
223203
"test_futures2.py::PyFutureTests::test_task_exc_handler_correct_context"

trio_asyncio/_base.py

+44-41
Original file line numberDiff line numberDiff line change
@@ -492,41 +492,57 @@ def add_reader(self, fd, callback, *args):
492492
self._ensure_fd_no_transport(fd)
493493
return self._add_reader(fd, callback, *args)
494494

495-
def _add_reader(self, fd, callback, *args):
495+
# Local helper to factor out common logic between _add_reader/_add_writer
496+
def _add_io_handler(self, set_handle, wait_ready, fd, callback, args):
496497
self._check_closed()
497498
handle = ScopedHandle(callback, args, self)
498-
reader = self._set_read_handle(fd, handle)
499-
if reader is not None:
500-
reader.cancel()
501-
if self._token is None:
502-
return
503-
self._nursery.start_soon(self._reader_loop, fd, handle)
499+
old_handle = set_handle(fd, handle)
504500

505-
def _set_read_handle(self, fd, handle):
506-
try:
507-
key = self._selector.get_key(fd)
508-
except KeyError:
509-
self._selector.register(fd, EVENT_READ, (handle, None))
501+
if old_handle is not None:
502+
old_handle.cancel()
503+
if self._token is None:
510504
return None
511-
else:
512-
mask, (reader, writer) = key.events, key.data
513-
self._selector.modify(fd, mask | EVENT_READ, (handle, writer))
514-
return reader
505+
self._nursery.start_soon(self._io_task, fd, handle, wait_ready)
506+
return handle
515507

516-
async def _reader_loop(self, fd, handle):
508+
async def _io_task(self, fd, handle, wait_ready):
517509
with handle._scope:
518510
try:
519511
while True:
520512
if handle._cancelled:
521513
break
522-
await _wait_readable(fd)
514+
try:
515+
await wait_ready(fd)
516+
except OSError:
517+
# maybe someone did
518+
# h = add_reader(sock); h.cancel(); sock.close()
519+
# without yielding to the event loop
520+
if handle._cancelled:
521+
break
522+
raise
523523
if handle._cancelled:
524524
break
525525
handle._run()
526526
await self.synchronize()
527527
except Exception as exc:
528528
handle._raise(exc)
529529

530+
def _add_reader(self, fd, callback, *args):
531+
return self._add_io_handler(
532+
self._set_read_handle, _wait_readable, fd, callback, args
533+
)
534+
535+
def _set_read_handle(self, fd, handle):
536+
try:
537+
key = self._selector.get_key(fd)
538+
except KeyError:
539+
self._selector.register(fd, EVENT_READ, (handle, None))
540+
return None
541+
else:
542+
mask, (reader, writer) = key.events, key.data
543+
self._selector.modify(fd, mask | EVENT_READ, (handle, writer))
544+
return reader
545+
530546
# writing to a file descriptor
531547

532548
def add_writer(self, fd, callback, *args):
@@ -546,15 +562,10 @@ def add_writer(self, fd, callback, *args):
546562

547563
# remove_writer: unchanged from asyncio
548564

549-
def _add_writer(self, fd, callback, *args):
550-
self._check_closed()
551-
handle = ScopedHandle(callback, args, self)
552-
writer = self._set_write_handle(fd, handle)
553-
if writer is not None:
554-
writer.cancel()
555-
if self._token is None:
556-
return
557-
self._nursery.start_soon(self._writer_loop, fd, handle)
565+
def _add_writer(self, fd, callback, *args, _defer_start=False):
566+
return self._add_io_handler(
567+
self._set_write_handle, _wait_writable, fd, callback, args
568+
)
558569

559570
def _set_write_handle(self, fd, handle):
560571
try:
@@ -566,20 +577,6 @@ def _set_write_handle(self, fd, handle):
566577
self._selector.modify(fd, mask | EVENT_WRITE, (reader, handle))
567578
return writer
568579

569-
async def _writer_loop(self, fd, handle):
570-
with handle._scope:
571-
try:
572-
while True:
573-
if handle._cancelled:
574-
break
575-
await _wait_writable(fd)
576-
if handle._cancelled:
577-
break
578-
handle._run()
579-
await self.synchronize()
580-
except Exception as exc:
581-
handle._raise(exc)
582-
583580
def autoclose(self, fd):
584581
"""
585582
Mark a file descriptor so that it's auto-closed along with this loop.
@@ -752,6 +749,7 @@ async def _main_loop_exit(self):
752749
# clean core fields
753750
self._nursery = None
754751
self._task = None
752+
self._token = None
755753

756754
def is_running(self):
757755
if self._stopped is None:
@@ -778,6 +776,11 @@ async def wait_stopped(self):
778776
"""
779777
await self._stopped.wait()
780778

779+
def _trio_io_cancel(self, cancel_scope):
780+
"""Called when a ScopedHandle representing an I/O reader or writer
781+
has its cancel() method called."""
782+
cancel_scope.cancel()
783+
781784
def stop(self):
782785
"""Halt the main loop.
783786

trio_asyncio/_handles.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def __init__(self, *args, **kw):
3131

3232
def cancel(self):
3333
super().cancel()
34-
self._scope.cancel()
34+
self._loop._trio_io_cancel(self._scope)
3535

3636
def _repr_info(self):
3737
return super()._repr_info() + ["scope={!r}".format(self._scope)]

trio_asyncio/_loop.py

+12-10
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111

1212
from ._async import TrioEventLoop
1313
from ._util import run_aio_future
14-
from ._deprecate import warn_deprecated
1514

1615
try:
1716
from trio.lowlevel import wait_for_child
@@ -106,26 +105,29 @@ def _in_trio_context():
106105
return True
107106

108107

108+
_sync_loop_task_name = "trio_asyncio sync loop task"
109+
110+
111+
def _in_trio_context_other_than_sync_loop():
112+
try:
113+
return trio.lowlevel.current_task().name != _sync_loop_task_name
114+
except RuntimeError:
115+
return False
116+
117+
109118
class _TrioPolicy(asyncio.events.BaseDefaultEventLoopPolicy):
110119
@staticmethod
111120
def _loop_factory():
112121
raise RuntimeError("Event loop creations shouldn't get here")
113122

114123
def new_event_loop(self):
115-
if _in_trio_context():
124+
if _in_trio_context_other_than_sync_loop():
116125
raise RuntimeError(
117126
"You're within a Trio environment.\n"
118127
"Use 'async with open_loop()' instead."
119128
)
120129
if _faked_policy.policy is not None:
121130
return _faked_policy.policy.new_event_loop()
122-
if "pytest" not in sys.modules:
123-
warn_deprecated(
124-
"Using trio-asyncio outside of a Trio event loop",
125-
"0.10.0",
126-
issue=None,
127-
instead=None,
128-
)
129131

130132
from ._sync import SyncTrioEventLoop
131133

@@ -220,7 +222,7 @@ def _new_policy_get():
220222
def _new_policy_set(new_policy):
221223
if isinstance(new_policy, TrioPolicy):
222224
raise RuntimeError("You can't set the Trio loop policy manually")
223-
if _in_trio_context():
225+
if _in_trio_context_other_than_sync_loop():
224226
raise RuntimeError("You can't change the event loop policy in Trio context")
225227
if new_policy is not None and not isinstance(
226228
new_policy, asyncio.AbstractEventLoopPolicy

0 commit comments

Comments
 (0)