16

I'm trying to understand fork() and process address spaces. I wrote a basic proof of concept program that forks a new process and changes a variable in the new process. My expectation was that when I change a variable in the child, this should cause that variable to get a new address. If I understand correctly, Linux does copy-on-write with fork. So I would expect the variable address in the parent and child to match until I change it in one of them. Then I would expect them to be different. However, that's not what I'm seeing.

Is this because with copy-on-write a new page is allocated from physical memory, but the process address space is unchanged - just remapped to the new page by the TLB? Or am I not understanding this or made a dump mistake in my program?

Proof of concept code:

#include <iostream>
#include <string>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

void describe(const std::string &descr, const int &data) {
    pid_t ppid = getppid();
    pid_t pid = getpid();

    std::cout << "In " << descr << ":\n"
              << "Parent Process ID:  " << ppid
              << "\nMy Process ID:  " << pid
              << "\nValue of data:  " << data
              << "\nAddress of data:  " << &data << "\n\n";
}

void change(int &data) {
    // Should cause data to get new page frame:
    data *= 2;
}

int main () {
    int data = 42;
    int status;

    pid_t pid = fork();

    switch(pid) {
        case -1:
            std::cerr << "Error:  Failed to successfully fork a process.\n";
            exit(1);
            break;
        case 0:
            // In forked child
            describe("Child", data);
            // Lazy way to wait for parent to run describe:
            usleep(1'000);
            break;
        default:
            // In calling parent
            describe("Parent", data);
            // Lazy way to wait for child to run describe:
            usleep(1'000);
    }

    if (pid == 0) {
        std::cout << "Only change data in child...\n";
        change(data);
        describe("Child", data);
    } else {
        // Lazy way to wait for child to change data:
        usleep(1'000);
        describe("Parent", data);
    }

    // Wait for child:
    if (pid != 0) {
        wait(&status);
    }

    return 0;
}

Example run:

ubuntuvm:~$ ./example
In Parent:
Parent Process ID:  265569
My Process ID:  316986
Value of data:  42
Address of data:  0x7fffb63878d4

In Child:
Parent Process ID:  316986
My Process ID:  316987
Value of data:  42
Address of data:  0x7fffb63878d4

Only change data in child...
In Child:
Parent Process ID:  316986
My Process ID:  316987
Value of data:  84
Address of data:  0x7fffb63878d4

In Parent:
Parent Process ID:  265569
My Process ID:  316986
Value of data:  42
Address of data:  0x7fffb63878d4
Acorn
  • 24,970
  • 5
  • 40
  • 69
James S.
  • 475
  • 3
  • 10
  • Hint: Pointer addresses are just identifiers for process memory, and don't actually correspond with anything real in the OS or hardware. – Mooing Duck Sep 10 '20 at 23:39

1 Answers1

16

My expectation was that when I change a variable in the child, this should cause that variable to get a new address.

No, because they are virtual addresses.

If I understand correctly, Linux does copy-on-write with fork. So I would expect the variable address in the parent and child to match until I change it in one of them.

A new physical page will be used somewhere, but the virtual address can (and will) stay the same.

Is this because with copy-on-write a new page is allocated from physical memory, but the process address space is unchanged - just remapped to the new page by the TLB?

Of course. Otherwise it would be way less useful. If it worked as you say, then consider any pointer you had previous to the fork would become invalid suddenly. Think about code as simple as:

int * p = new int;

if (!fork()) {
    // the child

    *p = 42;

    // now `p` is invalid since we wrote to it?!

    // another read or write would segfault!
    *p = 43;
}

In a way, it would be like having a live program on one of those games where the platforms (pages for us) fall down when you step on them once. Quite fun! :)

We could examine fixing the problem by having the operating system or the CPU rewrite (somehow) your pointers with the new address when that happens to keep everything working.

However, even if that were possible, we have more issues. For instance, you need to take care of allocations that cover several pages. Imagine the stack (assuming Linux does CoW for the stack too on fork()). As soon as you wrote anything to the stack you would have to update the stack pointer and copy all the pages, not just the modified one.

Then we have to solve indirect pointers and pointers in data structures that do not point to allocations, etc. It seems impossible to solve without tracking which registers and pointers need to be updated for each possible future write (or having some different implementation for C pointers overall as @R mentions -- same for registers etc.).

Acorn
  • 24,970
  • 5
  • 40
  • 69
  • 4
    To expand on this, it's a *fundamental requirement* of the `fork` interface that "addresses", in the sense of what the C language implementation exposes to the application, from before `fork` continue to be valid and work after `fork`, and that they refer to the child's copy, not the parent's. Normally this requires an MMU; however, if the C implementation implements pointers by applying a "base address" register to them when dereferencing, it's possible to implement `fork` by copying the entire process memory to a new machine address range. – R.. GitHub STOP HELPING ICE Sep 10 '20 at 14:27
  • 2
    However, such an implementation is incompatible with the idea of having shared memory between processes, so it does not suffice for a complete implementation of POSIX. In practice, most POSIX subsets without MMU simply forego having `fork` (which is generally a good choice, because `fork` is bad for lots of reasons). – R.. GitHub STOP HELPING ICE Sep 10 '20 at 14:29
  • @R..GitHubSTOPHELPINGICE Actually, that could be a way to do go around the problems I mentioned, indeed, although the pointers would be fat or the arch have support for that somehow, I guess. – Acorn Sep 10 '20 at 14:39
  • 1
    @Acorn: You don't need fat pointers unless you wanted to make shared memory work. Then there would be an extra bit (thus fat) indicating whether the pointer is absolute (shared) or base-relative (process-local). – R.. GitHub STOP HELPING ICE Sep 10 '20 at 15:01