Why does Rust RwLock behave unexpectedly with fork?

Viewed 349

I'm seeing some behavior I can't explain when I use a RwLock and fork. Basically the child process reports a RwLock as still acquired, while the parent doesn't, even though they both run the same code path. My understanding is that the child process should receive an independent copy of the parent process's memory space, locks included, so it doesn't make sense that they should report different results.

The expected behavior is that the child and parent both report "mutex held: false". Interestingly, this works as expected when a Mutex is used, instead of a RwLock.

Rust Playground link

use libc::fork;
use std::error::Error;
use std::sync::RwLock;

fn main() -> Result<(), Box<dyn Error>> {
    let lock = RwLock::new(());

    let guard = lock.write();
    let res = unsafe { fork() };
    drop(guard);

    match res {
        0 => {
            let held = lock.try_write().is_err();
            println!("CHILD mutex held: {}", held);
        }
        _child_pid => {
            let held = lock.try_write().is_err();
            println!("PARENT mutex held: {}", held);
        }
    }
    Ok(())
}

Output:

PARENT mutex held: false
CHILD mutex held: true
1 Answers

I presume you're running on a Linux system here. Rust does that because glibc does that, and Rust's RwLock is based on glibc's pthreads implementation on glibc-using Linux systems.

You can confirm this behavior with an equivalent C program:

#include <pthread.h>
#include <unistd.h>
#include <stdio.h>

int main(void)
{
    pthread_rwlock_t lock = PTHREAD_RWLOCK_INITIALIZER;

    pthread_rwlock_wrlock(&lock);
    pid_t pid = fork();
    int res = pthread_rwlock_unlock(&lock);
    int res2 = pthread_rwlock_trywrlock(&lock);

    printf("%s unlock_errno=%d trywrlock_errno=%d\n", (pid == 0) ? "child" : "parent", res, res2);
    return 0;
}

which prints the following:

parent unlock_errno=0 trywrlock_errno=0
child unlock_errno=0 trywrlock_errno=16

16 is EBUSY on my system.

The reason that this happens for glibc is that POSIX specifies a single unlock function for rwlocks, and glibc stores the current thread ID to determine whether the lock held by the current thread is a read or write lock. If the current thread ID is equal to the stored value, the thread has a write lock, and otherwise, it has a read lock. So you haven't actually unlocked anything in the child, but you have likely corrupted the reader counter in the lock.

As mentioned in the comments, this is undefined behavior in the child according to POSIX, because the thread unlocking is not the thread holding the lock. In order for this to work, Rust would have to implement its own synchronization primitives like Go does, and that's generally a major portability nightmare.

Related