Moving borrowed values into a forked child

I ran into some ownership issues working with Unix fork (through the nix crate). I wanted to move a value into the child but keep it borrowed in the parent.

I wrote this abstraction which I think is safe. I'd appreciate if someone could confirm the safety of it, or help me understand why it migh not be.

fn fork<T, C: FnOnce(T)/* -> ! */>(data_ref: &T, child: C) -> Pid {
    match nix::unistd::fork().unwrap() {
        ForkResult::Child => {
            let data = unsafe { (data_ref as *const T).read() };
            child(data);
            unreachable!();
        },

        ForkResult::Parent { child } => child,
    }
}

Note that the child closure shouldn't return, but -> ! isn't available yet so I just threw in unreachable!.

One point which I'm not sure about is read vs read_unaligned. I assumed that Rust references are always aligned.

It would look like

fork(&data, |data| {
    // do something with data

    // do something that doesn't return control flow
    // e.g. cmd.exec(), or a loop, or a panic
})

Regarding diverging closures (your -> ! requirement), see this internals thread: Set ‘static lifetime for local variables of diverging functions?:

enum Diverging {}

fn fork<T, F> (data_ref: &T, child: F) -> Pid
where
    F : FnOnce(&T) -> Diverging,
{
    match ::nix::unistd::fork().unwrap() {
        ForkResult::Child => {
            match child(&data) {} // : !
        },

        ForkResult::Parent { child } => child,
    }
}

But generally, using unix's fork with shared pointers does not lead to the intuitive behavior you'd expect, and I am pretty sure it is not compatible with Rust guarantees: shared global memory (shm_* family, mmap) becomes inherently aliased and thus unsafe to mutate; the other memory is, AFAIK, entirely copied (copy-on-write for perfomance), so there is no real "sharing", here. Finally, the child process can survive its parent's death and thus outlive it, so careful with that too.

2 Likes

But aliasing is inherent to those APIs, right? I don't see how forking makes it particularly unsafe.

Yep, hence mutation with those APIs is inherently racy, leading to potential UB unless it is synchronized.

The following C program, for instance, attempts to increment many times and in parallel from both the parent and the forked child a shared (through mmap) uint64_t, and leads to the given shared integer having around 52% of the total expected value:

C code
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

#define assert(condition) \
  if (!(condition)) { \
    fputs("Assertion failed: '" #condition "'.", stderr); \
    if (child != 0) { kill(child, SIGKILL); } \
    exit(EXIT_FAILURE); \
  }

#define log(fmt, ...) fprintf(stderr, "[pid %ld] " fmt "\n", (long) getpid(), ##__VA_ARGS__)

static pid_t parent = 0;
static pid_t child = 0;

void wait_child_exit ()
{
    assert(child != 0);
    log("Waiting for child %ld to end...", (long) child);
    int status;
    waitpid(child, &status, 0);
    if (WIFEXITED(status)) {
        int exit_status = WEXITSTATUS(status);
        log("Child %ld exited with status %d", (long) child, exit_status);
        if (exit_status != EXIT_SUCCESS) {
            exit(exit_status);
        }
    } else {
        log("Unexpected status.");
        kill(child, SIGKILL);
        exit(EXIT_FAILURE);
    }
}

#define N 50000000

int main (void)
{
    size_t * shared_int;
    shared_int = mmap(NULL, sizeof(*shared_int), PROT_READ | PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0);
    if (shared_int == MAP_FAILED) {
        perror("mmap");
        exit(EXIT_FAILURE);
    }
    *shared_int = 0;
    parent = getpid();
    child = fork();
    switch (child) {
        // child
        case 0:
            child = getpid();
            for (size_t i = 0; (i++) < N; ++(*shared_int));
            log("Done.");
        exit(EXIT_SUCCESS);

        // parent (fork failed)
        case -1:
            perror("fork");
        exit(EXIT_FAILURE);

        // parent (no error)
        default:
            for (size_t i = 0; (i++) < N; ++(*shared_int));
            log("Done.");
            wait_child_exit();
        break;
    }
    printf("shared_int = %zu (expected %zu).\n", *shared_int, 2 * N);
    munmap(shared_int, sizeof(*shared_int));
    return EXIT_SUCCESS;
}
[pid 20881] Done.
[pid 20880] Done.
[pid 20880] Waiting for child 20881 to end...
[pid 20880] Child 20881 exited with status 0
shared_int = 52272791 (expected 100000000).
1 Like

You could use Infallible until ! is stabilized (it should even be no problem to use Infallible, because it will be a type alias soon.

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.