多线程环境下,调用fork函数创建子进程时,子进程完全继承了父进程的整个内存地址空间。

父进程中的互斥锁、多个线程在子进程中是如何处理的呢?

由于父子进程之间采用了写时复制技术,在子进程未改变互斥锁之前,父子进程对锁的状态是相同的,此时如何处理同步状态?

父子进程内存空间

当父进程中的线程调用fork()函数时,系统就会为子进程创建与父进程完全相同的整个虚拟内存空间的副本,并且父子进程的虚拟内存空间会映射到相同的物理内存页,即父子进程共享物理内存页。

父子进程中的任何一个尝试修改共享物理内存页对应的虚拟内存空间内容时,写时复制机制就会被触发,会执行以下流程:

  1. 操作系统会为该进程创建一个新的物理内存页,并将原来的共享物理内存页中的内容复制到新的物理页中。
  2. 操作系统会更新该进程的页表,使其虚拟地址指向新的物理页。
  3. 父子进程就拥有了各自的独立的物理内存空间。

多线程环境下创建子进程

由于子进程复制了父进程的整个虚拟内存空间,所以从父进程中继承了每个互斥量、读写锁和条件变量的状态。换句话说,如果父进程的线程占有锁,那么子进程也将占有同样的锁。

但是子进程中仅包含一个线程,即调用fork()函数的线程

那么问题来了,如果父进程中调用fork()函数的线程并非占有锁的线程,那么子进程的状态是既占有锁但又没有占有锁的线程副本。此时子进程就无法知道它占有了哪些锁、需要释放哪些锁。

针对子进程中不知道锁状态的情况,具有两种解决方案:

方案一:调用exec函数

如果子进程在fork()函数返回之后,需要调用exec()函数执行其他进程,那么就不存在锁状态的问题。因为exec()函数会加载新的程序,替换整个进程的虚拟内存空间

但是在子进程中fork()函数返回之后到调用exec()函数之间,子进程仅能调用异步信号安全函数。这是因为异步信号安全函数不涉及到子进程锁状态的问题。

方案二:fork返回之前清理锁状态

pthread_atfork()函数可以注册fork处理程序,在fork()函数创建子进程返回之前执行,可用于清理锁状态。

#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
// 返回值:成功返回0,失败返回错误编号

处理程序的调用时机以及作用如下:

fork处理程序调用时间作用
prepare父进程在fork创建子进程前调用一般用来获取父进程定义的所有锁(获取锁
parentfork创建子进程之后,返回父进程之前,在父进程上下文中调用一般用于对prepare处理程序获取的锁进行解锁(释放锁
childfork创建子进程之后,返回子进程之前,在子进程上下文中调用用于子进程释放prepare处理程序获取到的锁(释放锁

注意:不会出现加锁一次解锁两次的情况,因为调用prepare时子进程还未创建,所以是父进程获取了所有锁,之后创建子进程,然后父子进程此时都获取到所有锁。之后父子进程分别在各自上下文中解锁,解锁时由于写时复制机制,所以父子进程解锁的分别是它们虚拟内存空间中各自的锁,因此不存在对同一个锁解锁两次。

一句话总结就是调用prepare获取所有锁,然后父子进程共享锁的状态,之后由于写时复制机制,父子进程解除各自的锁

可以通过多次调用pthread_atfork()函数来设置多组fork处理程序。如果不需要使用其中某个处理程序,传入空指针即可。

使用多组fork处理程序时,处理程序的调用顺序并不相同。parentchild处理程序与设置的顺序一致,prepare处理程序与设置的顺序相反。

这样就可以满足多模块注册自己的fork处理程序,并保持锁的层次。考虑以下情况:

  1. 模块A加锁。
  2. 模块A调用模块B中对外暴露的接口。
  3. 模块B中对外暴露的接口中加锁。

此时模块B必须在模块A之前设置其fork处理程序(模块A的prepare处理函数先于模块Bprepare处理函数执行),否则模块B的prepare处理函数先执行会导致加锁顺序与模块A正常调用模块B接口的加锁顺序相反,会造成死锁

如果模块B在模块A之前设置其fork处理程序,那么执行顺序流如下:

  1. 执行模块A的prepare处理程序,获取模块A中的所有锁。
  2. 执行模块B的prepare处理程序,获取模块B中的所有锁。
  3. 执行fork()函数。
  4. 执行模块B中的child处理程序,释放子进程中模块B中的所有锁。
  5. 执行模块A中的child处理程序,释放子进程中模块A中的所有锁。
  6. fork()函数返回到子进程。
  7. 执行模块B中的parent处理程序,释放父进程中模块B中的所有锁。
  8. 执行模块A中的parent处理程序,释放父进程中模块A中的所有锁。
  9. fork()函数返回到父进程。

方案二:存在的问题

pthread_atfork()函数的作用是使fork()函数调用之后的锁状态保持一致,但仍然具有一些问题。

  1. 无法处理复杂的线程同步对象,例如条件变量、屏障等。
  2. 特殊互斥量例如错误检查互斥量、递归互斥量也无法处理。递归互斥量在解锁时不能确定加锁的次数。
  3. 如果在信号处理程序中调用fork()函数,那么pthread_atfork定义的处理程序只能调用异步信号安全函数,否则结果不可预期。

示例

在下面的例子中,通过两次调用pthread_atfork()函数设置两组fork处理程序,然后验证两组fork处理程序的执行顺序,可以看到prepare处理程序的调用顺序与设置顺序相反,childparent处理程序的调用顺序与设置顺序相同。

同时定义了两个锁lock1lock2,在创建的其他线程中先对lock1进行加锁再对lock2进行加锁,那么就需要先调用获取lock1prepare处理程序(prepare1),再调用获取lock2prepare处理程序(prepare2)。

这是为了避免加锁顺序不一致,导致调用prepare处理程序与其他线程产生死锁。例如如果先调用prepare2再调用prepare1处理程序,就可能会发生线程处于持有lock1阻塞在获取lock2的状态,而主线程prepare2处理程序执行完(持有lock2)调用prepare1处理程序(阻塞在获取lock1

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

pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;

static void prepare1()
{
    int err;
    printf("preparing lock1..\n");
    if ((err = pthread_mutex_lock(&lock1)) != 0)
        printf("can't lock lock1 in prepare handler");
}
static void prepare2()
{
    int err;
    printf("preparing lock2..\n");
    if ((err = pthread_mutex_lock(&lock2)) != 0)
        printf("can't lock lock2 in prepare handler");
}

static void parent1()
{
    int err;
    printf("parenet unlocking lock1..\n");
    if ((err = pthread_mutex_unlock(&lock1)) != 0)
        printf("can't lock lock1 in parent handler");
}
static void parent2()
{
    int err;
    printf("parenet unlocking lock2..\n");
    if ((err = pthread_mutex_unlock(&lock1)) != 0)
        printf("can't lock lock2 in parent handler");
}

static void child1()
{
    int err;
    printf("child unlocking lock1..\n");
    if ((err = pthread_mutex_unlock(&lock1)) != 0)
        printf("can't lock lock1 in child handler");
}
static void child2()
{
    int err;
    printf("child unlocking lock2..\n");
    if ((err = pthread_mutex_unlock(&lock1)) != 0)
        printf("can't lock lock2 in child handler");
}

void* thr_fn(void* arg)
{
    // 由于在线程中先加锁lock1再加锁lock2,那么就需要先调用prepare1再调用prepare2,
    // 否则加锁顺序不一致可能出现死锁
    pthread_mutex_lock(&lock1);
    pthread_mutex_lock(&lock2);
    printf("thread start...\n");
    pthread_mutex_unlock(&lock2);
    pthread_mutex_unlock(&lock1);
    pause();
    return (0);
}

int main()
{
    int err;
    pid_t pid;
    pthread_t tid;

    if ((err = pthread_atfork(prepare2, parent2, child2) != 0))
    {
        printf("can't install fork handlers\n");
        exit(-1);
    }

    if ((err = pthread_atfork(prepare1, parent1, child1) != 0))
    {
        printf("can't install fork handlers\n");
        exit(-1);
    }

    if ((err = pthread_create(&tid, NULL, thr_fn, 0)) != 0)
    {
        printf("%s\n", strerror(err));
        exit(-1);
    }

    sleep(2);
    printf("parent about to fork...\n");
    if ((pid = fork()) < 0)
    {
        perror("fork");
        exit(-1);
    }
    else if (pid == 0)
        printf("child returned from fork\n");
    else
        printf("parent returned from fork\n");

    return 0;
}

// 执行结果如下:
// thread start...
// parent about to fork...
// preparing lock1..
// preparing lock2..
// parenet unlocking lock2..
// parenet unlocking lock1..
// parent returned from fork
// child unlocking lock2..
// child unlocking lock1..
// child returned from fork