i ran into [the "no calling run inside run" error message] while testing with pytest-trio. i'm launching another process that is using trio using multiprocessing, but since i'm launching it from an async fixture, i get this error. i suppose the issue is that multiprocessing forks, so the context remains
Originally posted by @oakkitten in #608 (comment)
It would certainly be nicer if this worked! (Though note, there is a workaround for multiprocessing in the mean time: use set_start_method("spawn") or set_start_method("forkserver").)
So, what how can Trio survive if you call os.fork() inside a trio.run()? Which breaks down into two questions really: (1) how do we detect when a fork has happened? (2) once we've detected it, how do we clean things up in the nicest way possible?
Detection
The simplest approach is to save the value of os.getpid() when the runner is created, and then whenever we access the runner we check whether os.getpid() == the_saved_value. If our PID changes, then we're in a new process. This is what asyncio does.
Doing this everywhere that we access GLOBAL_RUN_STATE does add some overhead. (Contrary to what you may have heard, with recent glibc on Linux getpid() always does a full syscall, and syscalls have gotten more expensive in these days of meltdown/spectre/etc.)
The other option is to use os.register_at_fork. This totally removes the overhead. The downsides are that it's 3.7+ only. And, in theory, it's not quite as robust (it relies on the code that calls fork() also informing the Python interpreter that it has called fork()). But these limitations aren't too terrible. 3.6 is at the trailing edge of our support window, and we'll drop support within the next few years; in the mean time, telling folks who need fork() survival to upgrade to 3.7+ seems reasonable. And there are a lot of reasons why calling fork and then trying to continue running Python code will go badly if you don't tell the Python interpreter what you're doing; it's not just Trio that can break.
So my feeling is we should probably start with os.register_at_fork. And maybe consider adding a getpid() check at the top of trio.run as a just-in-case belt-and-suspenders kind of thing?
Cleaning up afterwards
So the obvious thing we need to do is to clear the child's GLOBAL_RUN_CONTEXT, so that Trio functions in the child won't try to mess with the parent's state.
There's also a question about what to do with the old run state. We could try to clean it up, by closing things. We could drop references to it, and let the GC try to clean it up. Or we could intentionally leak it.
Of these, I think leaking is really the only viable option... there are a few bits of state that we could plausibly clean up (e.g. closing the epoll/kqueue/IOCP handle), but the big thing is all the tasks and their stacks and any __del__ methods they might have. Trying to do explicit clean up on these will have unpredictable results, since it requires running arbitrary user code, that's definitely not expecting to be run inside a forked child. (I guess in theory we could reclaim the memory while avoiding running any destructors, but this would still leak all the descriptors etc., and anyway Python doesn't give us any way to do that.)
This is also why if you use fork() in a process with multiple threads, all the other thread stacks are just leaked: there's nothing else you can reasonably do with them.
So I guess what we want to do is just stash the old run state into a global somewhere, so it's pinned in memory until the process exits?
I'm actually not 100% sure how to reliably convince Python to never garbage collect an object graph. During shutdown, Python does try to collect module globals, and I'm not finding any great docs on that...
Maybe spawn a daemon thread that holds the pinned references on its stack, and then have it sleep forever? Weird gross hack but might work...
Originally posted by @oakkitten in #608 (comment)
It would certainly be nicer if this worked! (Though note, there is a workaround for multiprocessing in the mean time: use
set_start_method("spawn")orset_start_method("forkserver").)So, what how can Trio survive if you call
os.fork()inside atrio.run()? Which breaks down into two questions really: (1) how do we detect when aforkhas happened? (2) once we've detected it, how do we clean things up in the nicest way possible?Detection
The simplest approach is to save the value of
os.getpid()when the runner is created, and then whenever we access the runner we check whetheros.getpid() == the_saved_value. If our PID changes, then we're in a new process. This is what asyncio does.Doing this everywhere that we access
GLOBAL_RUN_STATEdoes add some overhead. (Contrary to what you may have heard, with recent glibc on Linuxgetpid()always does a full syscall, and syscalls have gotten more expensive in these days of meltdown/spectre/etc.)The other option is to use
os.register_at_fork. This totally removes the overhead. The downsides are that it's 3.7+ only. And, in theory, it's not quite as robust (it relies on the code that callsfork()also informing the Python interpreter that it has calledfork()). But these limitations aren't too terrible. 3.6 is at the trailing edge of our support window, and we'll drop support within the next few years; in the mean time, telling folks who needfork()survival to upgrade to 3.7+ seems reasonable. And there are a lot of reasons why calling fork and then trying to continue running Python code will go badly if you don't tell the Python interpreter what you're doing; it's not just Trio that can break.So my feeling is we should probably start with
os.register_at_fork. And maybe consider adding agetpid()check at the top oftrio.runas a just-in-case belt-and-suspenders kind of thing?Cleaning up afterwards
So the obvious thing we need to do is to clear the child's
GLOBAL_RUN_CONTEXT, so that Trio functions in the child won't try to mess with the parent's state.There's also a question about what to do with the old run state. We could try to clean it up, by closing things. We could drop references to it, and let the GC try to clean it up. Or we could intentionally leak it.
Of these, I think leaking is really the only viable option... there are a few bits of state that we could plausibly clean up (e.g. closing the epoll/kqueue/IOCP handle), but the big thing is all the tasks and their stacks and any
__del__methods they might have. Trying to do explicit clean up on these will have unpredictable results, since it requires running arbitrary user code, that's definitely not expecting to be run inside a forked child. (I guess in theory we could reclaim the memory while avoiding running any destructors, but this would still leak all the descriptors etc., and anyway Python doesn't give us any way to do that.)This is also why if you use
fork()in a process with multiple threads, all the other thread stacks are just leaked: there's nothing else you can reasonably do with them.So I guess what we want to do is just stash the old run state into a global somewhere, so it's pinned in memory until the process exits?
I'm actually not 100% sure how to reliably convince Python to never garbage collect an object graph. During shutdown, Python does try to collect module globals, and I'm not finding any great docs on that...
Maybe spawn a daemon thread that holds the pinned references on its stack, and then have it sleep forever? Weird gross hack but might work...