-
Notifications
You must be signed in to change notification settings - Fork 13.9k
Description
There's a bug in std::sync::Once that makes it so that, under certain conditions, a call to call_once in a process which was forked from another Rust process can block forever.
How Once works
Simplifying a bit (ignoring poisoning), the algorithm employed by Once works as follows: The Once can be in one of three states: INCOMPLETE, COMPLETE, and RUNNING.
- The
Oncestarts off in theINCOMPLETEstate. - When a call to
call_oncebegins, theOncemight be in any of the three states:- If the
Onceis in theINCOMPLETEstate, then it is transitioned to theRUNNINGstate, and the function begins executing. - If the
Onceis in theRUNNINGstate, then some other call tocall_onceis executing the function, so this call puts itself on a list of waiters and goes to sleep. It will be woken back up once the function is done executing in whatever thread is executing it. - If the
Onceis in theCOMPLETEstate, then the function has already been executed, socall_oncereturns immediately without doing anything.
- If the
Finally, when the function's execution completes, the thread doing the execution transitions the Once into the COMPLETE state, and wakes up any waiters that accumulated while it was executing the function.
The issue
This algorithm is broken when forking. In particular, if a Once is in the RUNNING state at the point that the process forks, when the child's memory space (which, by default, is a copy-on-write copy of the parent's) is created, the Once will still be in the RUNNING state. However, in the child process, calls to call_once will fail for two reasons:
- If the call happens while the function is still being executed, the waiter object that is enqueued will not actually be visible to the executor because it will only affect the child's memory space, not the parent's, and so the executor (a member of the parent thread) finishes, it will wake up all of the waiters in the parent process, blissfully ignorant that a thread from the child process is also waiting.
- If the function execution finishes first, the change of the
Once's state fromRUNNINGtoCOMPLETEwill not be reflected in the child's memory space. Thus, a future call tocall_oncewill spuriously find theOncestill in theRUNNINGstate even though it isn't really in that state anymore.
These two problems can be seen in action in two proofs of concept that I wrote: This one demonstrates the first issue, while this one demonstrates the second.
A proposed fix
Joint credit for this proposal goes to @ezrosent.
The idea behind this fix is to record the process' PID when transitioning a Once from INCOMPLETE to RUNNING, and having future accesses that find the Once in the RUNNING state verify that it wasn't transitioned by a parent process. Unfortunately, this doesn't quite work because PIDs can be re-used, so if process A spawns process B, then process A quits, then process B spawns process C, it's possible for A and C to have the same PID.
Instead, we introduce the idea of an "MPID" - a monotonically-increasing PID-like counter that is maintained by the process (e.g. . We increment it every time a process forks, and use it in the Once objects to record which process transitioned an object from INCOMPLETE to RUNNING.
More concretely, here are the components of the proposed solution:
- There is a process-global MPID variable (could be either
usizeoru64) that is initialized to 0 and is incremented immediately afterfork. Note that this does not guarantee that no two processes anywhere in the tree of processes forked from a particular process have the same MPID. In fact, all processes forked by a given process will all have the same MPID. However, it does guarantee that a process will not share an MPID with any of its ancestors, and that is all we need. - The
Onceobject is modified to have anothermpidfield that is initialized to 0. - Each waiter object is modified to have another
mpidfield that is initialized to the MPID of the current process when the object is created. - A modified algorithm for
call_oncelooks roughly like this:- Loop:
- Load the current state. If it is
COMPLETE, return. - If the state is
INCOMPLETE, do then loadmpidand:- If
mpidis equal to the current MPID, then try to CAS the state fromINCOMPLETEtoRUNNING. If it fails, retry the entire loop. If it succeeds, you're responsible for running the function, so do the original algorithm. - If
mpidis not equal to the current MPID, then try to CAS it from its current value to the current MPID. If this succeeds, go to the previous step (wherempidis equal to the current MPID), and if it fails, retry the entire loop.
- If
- If the state is
RUNNING, then loadmpidand:- If
mpidis equal to the current MPID, then the thread that transitioned theOnceinto theRUNNINGstate is in the current process, so do the normal algorithm: wait for it to be done (recording the current MPID in the waiter object). - If
mpidis not equal to the current MPID, then the thread that transitioned theOnceinto theRUNNINGstate is in an ancestor process. Thus, attempt to CASmpidto the current MPID. If it fails, repeat the entire loop. If it succeeds, then it is your responsibility to run the function, so continue as if you had transitioned theOnceinto theRUNNINGstate, with one exception: when waking up waiters, you need to check that they are not waiters in an ancestor process; do this by checking the waiter object'smpidfield, and only waking waiters with anmpidfield equal to the current MPID.
- If
- Load the current state. If it is
- Loop:
One thing to note: It is safe to try to CAS mpid and then separately to transition into RUNNING even though the value of mpid needs to reflect the MPID of the thread that transitioned into RUNNING - a thread that successfully transitions a Once into the RUNNING state will have previously verified that mpid is correct, and thus it will not change forever in the future (at least, not in this process) since the MPID of a process never changes.
Open question
One open question is how to ensure that code is run just after fork (to increment the global MPID variable) and, critically, before any other code runs (especially code that uses Once). pthread provides the pthread_atfork function to register callbacks that run before and after fork calls, but obviously this doesn't address Windows, and I also don't know if there's a good way to ensure that the necessary call to pthread_atfork is made at process initialization time.
Prior art
There's some prior art here. In particular, jemalloc has acknowledged a similar issue, and has a partial fix for it.