Skip to content
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

Support Cython-based async functions #550

Closed
touilleMan opened this issue Jun 26, 2018 · 16 comments · Fixed by #1192
Closed

Support Cython-based async functions #550

touilleMan opened this issue Jun 26, 2018 · 16 comments · Fixed by #1192

Comments

@touilleMan
Copy link
Member

The title say it all, some projects re-implement their own iscoroutine function by looking for CO_COROUTINE and CO_ITERABLE_COROUTINE flags (for instance) to address this...

This is more of a CPython issue of course, but this currently means the coroutine generated by cython are not compatible with trio:

import trio

async def trio_main():
    print('hello...')
    await trio.sleep(1)
    print(' world !')

def main():
    trio.run(trio_main)

if __name__ == '__main__':
    main()
$ python cython_test.py 
hello...
 world !
$ cythonize -a -i cython_test.py 
Compiling /home/emmanuel/projects/parsec-lrw/cython_test.py because it changed.
[1/1] Cythonizing /home/emmanuel/projects/parsec-lrw/cython_test.py
running build_ext
building 'cython_test' extension
creating /home/emmanuel/projects/parsec-lrw/tmp0p_t5pyt/home
creating /home/emmanuel/projects/parsec-lrw/tmp0p_t5pyt/home/emmanuel
creating /home/emmanuel/projects/parsec-lrw/tmp0p_t5pyt/home/emmanuel/projects
creating /home/emmanuel/projects/parsec-lrw/tmp0p_t5pyt/home/emmanuel/projects/parsec-lrw
gcc -pthread -Wno-unused-result -Wsign-compare -DNDEBUG -g -fwrapv -O3 -Wall -Wstrict-prototypes -fPIC -I/home/emmanuel/projects/cpython/build-install/include/python3.6m -c /home/emmanuel/projects/parsec-lrw/cython_test.c -o /home/emmanuel/projects/parsec-lrw/tmp0p_t5pyt/home/emmanuel/projects/parsec-lrw/cython_test.o
gcc -pthread -shared /home/emmanuel/projects/parsec-lrw/tmp0p_t5pyt/home/emmanuel/projects/parsec-lrw/cython_test.o -o /home/emmanuel/projects/parsec-lrw/cython_test.cpython-36m-x86_64-linux-gnu.so
$ python 
Python 3.6.3 (v3.6.3:2c5fed8, Feb  6 2018, 17:05:14) 
[GCC 5.4.1 20160904] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import cython_test
>>> cython_test.main()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "cython_test.py", line 10, in cython_test.main
    trio.run(trio_main)
  File "/home/emmanuel/projects/parsec-lrw/venv/lib/python3.6/site-packages/trio/_core/_run.py", line 1238, in run
    return result.unwrap()
  File "/home/emmanuel/projects/parsec-lrw/venv/lib/python3.6/site-packages/trio/_core/_result.py", line 119, in unwrap
    raise self.error
  File "/home/emmanuel/projects/parsec-lrw/venv/lib/python3.6/site-packages/trio/_core/_run.py", line 1347, in run_impl
    msg = task.context.run(task.coro.send, next_send)
  File "/home/emmanuel/projects/parsec-lrw/venv/lib/python3.6/site-packages/contextvars/__init__.py", line 38, in run
    return callable(*args, **kwargs)
  File "/home/emmanuel/projects/parsec-lrw/venv/lib/python3.6/site-packages/trio/_core/_run.py", line 934, in init
    async_fn, args, system_nursery, None
  File "/home/emmanuel/projects/parsec-lrw/venv/lib/python3.6/site-packages/trio/_core/_run.py", line 805, in spawn_impl
    .format(getattr(async_fn, "__qualname__", async_fn))
TypeError: trio expected an async function, but 'trio_main' appears to be synchronous

I didn't dig into CPython internal so my understanding on why inspect.iscoroutine != asyncio.iscoroutine on this is fairly limited, but I guess we should do something on our own to fix this anyway ;-)

@sorcio
Copy link
Contributor

sorcio commented Jun 26, 2018

Not sure how relevant but Cython changelog for 0.23.0 says:

When generators are used in a Cython module and the module imports the modules “inspect” and/or “asyncio”, Cython enables interoperability by patching these modules during the import to recognise Cython’s internal generator and coroutine types. This can be disabled by C compiling the module with “-D CYTHON_PATCH_ASYNCIO=0” or “-D CYTHON_PATCH_INSPECT=0”

So maybe the difference is with vanilla vs patched inspect.iscoroutine()?

(Did you mean "Cython issue", not "CPython issue", right?)

@touilleMan
Copy link
Member Author

(Did you mean "Cython issue", not "CPython issue", right?)

From my point of view the trouble comes from the fact CPython provide two iscoroutine functions giving different results depending on how a coroutine is created.

Cython just choose the "wrong" coroutine style for inspect.iscoroutine which is unfortunate for Trio.

So maybe the difference is with vanilla vs patched inspect.iscoroutine()?

Really interesting point, this seems to indicate Cython patched both inspect and asyncio's iscoroutine, so both should work which is of course no the case... Maybe it's a Cython bug after all ^^

@njsmith
Copy link
Member

njsmith commented Jul 3, 2018

@scoder Do you have any insights here? It sounds like the issue is that inspect.iscoroutine is returning False for a Cython coroutine – is that expected? Would using isinstance(obj, abc.Coroutine) work better than inspect.iscoroutine(obj)?

@scoder
Copy link

scoder commented Jul 3, 2018

So maybe the difference is with vanilla vs patched inspect.iscoroutine()?

As the docs say, Cython only patches these if the compiled module itself imports them directly. Since the compiled module only imports trio and neither asyncio nor inspect, there is no monkey patching going on here. (Although in a real deployment, other Cython compiled modules might have done the patching for you. That's the usual problem with this kind of monkey patching.)

inspect.iscoroutine is returning False for a Cython coroutine – is that expected?

Yes. The inspect module is based on type checks, not protocols.

Would using isinstance(obj, abc.Coroutine) work better than inspect.iscoroutine(obj)?

It's probably what you want. The only reason to require exactly Python coroutine functions is when you depend on their C-level details or other implementation specifics. In all other cases, any object that supports the coroutine protocol should be enough.

@njsmith
Copy link
Member

njsmith commented Jul 3, 2018

Cython only patches these if the compiled module itself imports them directly

Ah, that is pretty confusing. I feel like I would either never patch, or else always patch inspect in any program where cython coroutines can occur. (I believe asyncio uses inspect these days, and inspect is a much lighter import than asyncio.) You've obviously thought about this a lot more than me though, so there might be some downside that I'm missing.

The only reason to require exactly Python coroutine functions is when you depend on their C-level details or other implementation specifics

We don't care about the C-level details, but in general I think we assume that coroutine objects really implement the full coroutine interface, including the cr_* attributes. Technically the cr_* attributes are not part of the collections.abc.Coroutine interface. Do Cython coroutines aim to expose the full coroutine interface, or just the collections.abc.Coroutine interface?

@scoder
Copy link

scoder commented Jul 4, 2018

that is pretty confusing

I prefer the word non-obvious, but yes. Any kind of stdlib monkey patching has the downside of being unexpected for some code. I'd rather not patch at all.

Do Cython coroutines aim to expose the full coroutine interface

As much as they can. They don't have actual byte code, obviously, nor a frame, but they have a valid cr_code, cr_running and cr_await. I doubt that you need more than that.

njsmith added a commit to njsmith/trio that referenced this issue Jul 16, 2018
This is important because Cython has its own generator/coroutine
types that are interchangeable with the ones built into the
interpreter, but not (always) detectable by the inspect module.

There's some more checks in trio/_core/_ki.py that still use the
inspect module. Unfortunately, they're using the inspect.is*function
forms, which are not easily replaceable by ABC checks. Fortunately,
usage of @enable_ki_protection and @disable_ki_protection are very
rare, so hopefully this won't affect too much?

Fixes python-triogh-550.
@njsmith
Copy link
Member

njsmith commented Jul 16, 2018

Proposed fix in #560. I haven't actually tested it though :-). @touilleMan, would it be easy for you to try it out?

@njsmith
Copy link
Member

njsmith commented Jul 17, 2018

Well, crud. @touilleMan tested my proposed fix, and it doesn't work (traceback and reproducer are here).

The problem is that for functions passed to trio.run or nursery.start_soon – i.e., any function that's at the top of a task's call stack – Trio actually requires there be a cr_frame, and as @scoder said, cython coroutines don't have a frame. That Trio requires a frame is obviously gross, but it's working around a really gross part of how Python works so I'm not sure whether it's actually fixable.

The problem arises as part of Trio's control-C handling: when a SIGINT arrives, the signal handler needs to figure out whether it's inside user code where it's safe to raise KeyboardInterrupt, or inside Trio's internals where it needs to defer handling the SIGINT until later. The only way to do this is by introspecting the stack to check what frame we're in, and then somehow figuring out whether this frame belongs to user code or to Trio code. Currently the way we "mark" these frames is by injecting a special hidden value into their locals dict, but that part isn't so crucial -- you can imagine providing other ways to associate some value with a frame. But right now Cython doesn't give us a frame at all, so there's nothing we can do. In fact, say you have code like:

# Pretend this is regular Python code
def regular_python():
    return 1 + 1

# Pretend this is Cython code
async def cython():
    return regular_python()

trio.run(cython)

Now let's assume the user hits control-C while the interpreter is in the middle of executing the body of regular_python. Our signal handler runs, and tries to figure out whether it's inside user code or Trio code. But the function cython is invisible to stack introspection, so there is no way to distinguish between the case where regular_python is called from a user task, versus when it's called directly by Trio's internals.

:-(

Now, this problem only happens for the top call inside a trio task. The thing you pass to trio.run or nursery.start_soon has to be a Python async function, but it can freely call Cython async functions. So as a workaround you could do something like:

from cython_module import cython_main

async def python_main(*args, **kwargs):
    return await cython_main(*args, **kwargs)

trio.run(python_main)

You could even wrap this in a decorator:

# In a Python file:
def make_trio_friendly(cython_async_fn):
    @functools.wraps(cython_async_fn)
    async def wrapper(*args, **kwargs):
        return await cython_async_fn(*args, **kwargs)
    return wrapper

# In Cython:
from whatever import make_trio_friendly

@make_trio_friendly
async def main():
    ...

For a real solution, then we would need some alternative way for Trio's SIGINT handler to detect whether the signal arrived in user code or Trio code. Some ideas:

Cython could start creating stack frames for coroutines and making them visible to stack introspection. (Doesn't matter if Cython actually uses them for anything, they'd just need to exist.)

We could somehow stash this information somewhere else? The tricky thing is that when we resume a user task, the "are we in user mode" state has to change atomically with the call to coro.send. I guess in 3.7 this ... is actually possible, assuming that Context.run is atomic WRT signal handling? (In earlier versions of Python, the contextvars backport is written in Python, so Context.run is definitely not atomic WRT signal handling.)

We could use some ad hoc thread local, and write an extension function in C/Cython whose only purpose is to toggle the thread local state and then invoke coro.send without processing signals in between? (That would absolutely suck for PyPy though.)

.....mayyybe the contextvars trick is the best option, and we would just tell people that they need to upgrade to 3.7 if they want to use Cython async functions?

I'm not thinking of a simple easy solution here.

njsmith added a commit to njsmith/trio that referenced this issue Jul 28, 2018
This should never happen in normal use, but sometimes there are bugs
that can cause the init task to crash. (For example, I hit this while
trying to debug python-triogh-550.) Previously, this would trigger an assertion
failure, and the original exception would be lost. Let's preserve the
original exception to make debugging easier.
@njsmith njsmith changed the title Cannot use Cython because inspect.iscoroutine != asyncio.iscoroutine Support Cython-based async functions Jul 28, 2018
@njsmith
Copy link
Member

njsmith commented Jul 28, 2018

The contextvars idea actually has some problems. We can't use a ContextVar to track whether we're inside a protected generator frame, because PEP 568 isn't implemented -- and that's pretty important for using @enable_ki_protection on @(async)contextmanagers. And we might someday be able to toggle the frame protection atomically when entering a protect frame (which is important for __(a)exit__ methods), but I don't see how we could ever do that via ContextVars. (I guess maybe if PEP 568 was implemented and we somehow disabled signal processing in @(async)contextmanager and we didn't care about non-generator-based __(a)exit__? It's a mess.)

But... I guess we could detect when we've hit the top of a task stack, and at that point switch to checking a ContextVar?

njsmith added a commit to njsmith/trio that referenced this issue Jul 31, 2018
This is important because Cython has its own generator/coroutine
types that are interchangeable with the ones built into the
interpreter, but not (always) detectable by the inspect module.

There's some more checks in trio/_core/_ki.py that still use the
inspect module. Unfortunately, they're using the inspect.is*function
forms, which are not easily replaceable by ABC checks. Fortunately,
usage of @enable_ki_protection and @disable_ki_protection are very
rare, so hopefully this won't affect too much?

Unfortunately, this is not sufficient to support Cython – we also need
to do something about how we handle KI protection. See python-triogh-550 for
details. But this is still a good idea on its own.
@kawing-chiu
Copy link

kawing-chiu commented Dec 18, 2018

I think I just hit this bug (on cython 0.29.2 and trio 0.9.0). Trio now raises a TrioInternalError. Is there any recommended workaround for now? I've tried importing the inspect module, but it does not help.

@njsmith
Copy link
Member

njsmith commented Dec 18, 2018

@kawing-chiu sorry to hear that! If you search the page for the string "workaround", there is one buried in the middle of one of the messages up above. It would be great to hear if it works for you.

@kawing-chiu
Copy link

Great! I was tired enough to miss that last night. And the workaround does work. Thanks very much!

@njsmith
Copy link
Member

njsmith commented Jan 19, 2019

Looking at this again, I suppose we could, like, automatically apply the workaround when this happens?

@xueyoo
Copy link

xueyoo commented Apr 28, 2021

environment: cython:0.29.23 and trio:0.18.0
issue: 'NoneType' object has no attribute 'f_locals'

fix code:
trio/_core/_run.py
if not hasattr(coro, "cr_frame")
change to
if not hasattr(coro, "cr_frame") or coro.cr_frame is None:

@scoder
Copy link

scoder commented Apr 28, 2021

@xueyoo, I backported the implementation of cr_frame to Cython 0.29.24 that was previously only available in 3.0. Feel free to use 3.0 (alpha), though.

@pquentin
Copy link
Member

@scoder Thank you! This helped @didimelli in chat: https://gitter.im/python-trio/general?at=60a23cd601edef5b504d292f

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

7 participants