gfmio/asyncio-gevent

ctrl-c / KeyboardInterrupt makes event loop exceptions

mguijarr opened this issue · 6 comments

Exiting asyncio.run(<a main function()>) with SIGINT (ctrl-c, KeyboardInterrupt exception) makes bad RuntimeError exceptions. I am affected by this in a prompt-toolkit application relying on gevent.

The minimum code to reproduce the problem:

import gevent
import gevent.monkey; gevent.monkey.patch_all()
import asyncio
import asyncio_gevent
import time

asyncio.set_event_loop_policy(asyncio_gevent.EventLoopPolicy())

async def my_main():
    gevent.sleep(10)

# press CTRL-C to exit the Python program before 10 seconds
asyncio.run(my_main())
python test.py
^CKeyboardInterrupt
2022-11-16T14:00:43Z
Traceback (most recent call last):
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/runners.py", line 43, in run
    return loop.run_until_complete(main)
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/base_events.py", line 574, in run_until_complete
    self.run_forever()
  File "/home/matias/dev/asyncio-gevent/asyncio_gevent/event_loop.py", line 19, in run_forever
    greenlet.join()
  File "src/gevent/greenlet.py", line 833, in gevent._gevent_cgreenlet.Greenlet.join
  File "src/gevent/greenlet.py", line 859, in gevent._gevent_cgreenlet.Greenlet.join
  File "src/gevent/greenlet.py", line 848, in gevent._gevent_cgreenlet.Greenlet.join
  File "src/gevent/_greenlet_primitives.py", line 61, in gevent._gevent_c_greenlet_primitives.SwitchOutGreenletWithLoop.switch
  File "src/gevent/_greenlet_primitives.py", line 61, in gevent._gevent_c_greenlet_primitives.SwitchOutGreenletWithLoop.switch
  File "src/gevent/_greenlet_primitives.py", line 65, in gevent._gevent_c_greenlet_primitives.SwitchOutGreenletWithLoop.switch
  File "src/gevent/_gevent_c_greenlet_primitives.pxd", line 35, in gevent._gevent_c_greenlet_primitives._greenlet_switch
KeyboardInterrupt

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/runners.py", line 46, in run
    _cancel_all_tasks(loop)
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/runners.py", line 62, in _cancel_all_tasks
    tasks.gather(*to_cancel, loop=loop, return_exceptions=True))
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/base_events.py", line 563, in run_until_complete
    self._check_runnung()
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/base_events.py", line 523, in _check_runnung
    raise RuntimeError('This event loop is already running')
RuntimeError: This event loop is already running

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/tmp/bla.py", line 12, in <module>
    asyncio.run(my_main())
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/runners.py", line 50, in run
    loop.close()
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/unix_events.py", line 55, in close
    super().close()
  File "/home/matias/miniconda3/envs/bliss/lib/python3.7/asyncio/selector_events.py", line 88, in close
    raise RuntimeError("Cannot close a running event loop")
RuntimeError: Cannot close a running event loop

gfmio commented

Thanks for reporting, I'll look into this!

gfmio commented

I haven't found a complete solution yet, but the problem appears to be that gevent.sleep pauses the current greenlet, so that nothing in it will execute.

gfmio commented

I've found the reason for this behaviour and implemented a fix in #10. The change has been released as version 0.2.3.

The issue is that the gevent.sleep is causing the running greenlet to pause, which pauses the asyncio event loop by default.

The solution is to run any code that runs gevent.sleep in another greenlet using asyncio.sync_to_async or (equivalently) asyncio_gevent.greenlet_to_future(gevent.spawn(f)). This causes the function to be executed in another greenlet which prevents the main greenlet from getting blocked.

Example:

import gevent.monkey

gevent.monkey.patch_all()

import asyncio
import threading

import gevent

import asyncio_gevent

asyncio.set_event_loop_policy(asyncio_gevent.EventLoopPolicy())


async def f():
    print("f", 1)
    await asyncio.sleep(1)
    print("f", 2)


def g():
    print("g", 1)
    gevent.sleep(2)
    print("g", 2)


async def main():
    await asyncio.gather(f(), asyncio_gevent.sync_to_async(g)())
    # OR equivalently
    # await asyncio.gather(f(), asyncio_gevent.greenlet_to_future(gevent.spawn(g)))


if __name__ == "__main__":
    asyncio.run(main())

The output will be (as expected):

g 1
f 1
f 2
g 2

If gevent.sleep is called inside an async function, then the async function needs to first be wrapped in asyncio.async_to_sync.

import gevent.monkey

gevent.monkey.patch_all()

import asyncio

import gevent

import asyncio_gevent

asyncio.set_event_loop_policy(asyncio_gevent.EventLoopPolicy())


async def f():
    print("f", 1)
    await asyncio.sleep(1)
    print("f", 2)


async def g():
    print("g", 1)
    await asyncio.sleep(1)
    gevent.sleep(1)
    print("g", 2)


async def main():
    await asyncio.gather(
        f(), asyncio_gevent.sync_to_async(asyncio_gevent.async_to_sync(g))()
    )


if __name__ == "__main__":
    asyncio.run(main())

The output will again be (as expected):

g 1
f 1
f 2
g 2

In both cases, you can Ctrl+C anytime and you will receive the standard asyncio behaviour of triggering a CancelledError.

I hope this fixes your issue as well. Please re-open the ticket if you still encounter this issue.

spumer commented

I have a suggestion to optmize this workaround.

Right now we use default executor (None) to join greenlet, and this can produce a lot of threads or can get deadlock if no more threads can be spawned to join greenlet (context switch)

wait_executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)

async def _await_greenlet(
    ...      
        result, _ = await asyncio.gather(
            future, loop.run_in_executor(wait_executor, gevent.sleep, 0)  # or we can use gevent.idle
        )

I was testing it with max_workers=1 and greenlet.join and got stuck on loading django admin. gevent.sleep / gevent.idle allow context switching and instantly free thread. As result no deadlocks and threads overhead and clear traceback at CTR+C

spumer commented

Can i make a PR? :)

gfmio commented

Sure, go ahead :)