当线程调用fork
时,就为子进程创建了整个进程地址空间的副本。回忆8.3节中讨论的写时复制,子进程与父进程是完全不同的进程,只要两者都没有对内存内容做出改动,父进程和子进程之间还可以共享内存页的副本。
子进程通过继承整个地址空间的副本,还从父进程那儿继承了每个互斥量、读写锁和条件变量的状态。如果父进程包含一个以上的线程,子进程在fork
返回以后,如果紧接着不是马上调用exec
的话,就需要清理锁状态。
在子进程内部,只存在一个线程,它是由父进程中调用fork
的线程的副本构成的。如果父进程中的线程占有锁,子进程将同样占有这些锁。问题是子进程并不包含占有锁的线程的副本,所以子进程没有办法知道它占有了哪些锁、需要释放哪些锁。
如果子进程从fork
返回以后马上调用其中一函数,就可以避免这样的问题。这种情况下,旧的地址空间就被丢弃,所以锁的状态无关紧要。但如果子进程需要继续做处理工作的话,这种策略就行不通,还需要使用其他的策略。
在多线程的进程中,为了避免不一致状态的问题,POSIX.1声明,在fork
返回和子进程调用其中一个exec
函数之间,子进程只能调用异步信号安全的函数。这就限制了在调用exec
之前子进程能做什么,但不涉及子进程中锁状态的问题。
要清除锁状态,可以通过调用pthread_atfork
函数建立fork
处理程序。
#include <pthread.h>
int pthread_atfork(void (*prepare)(void), void (*parent)(void),void (*child)(void));
//返回值:若成功,返回0;否则,返回错误编号
用pthread_atfork
函数最多可以安装3个帮助清理锁的函数。prepare fork
处理程序由父进程在fork
创建子进程前调用。这个fork
处理程序的任务是获取父进程定义的所有锁。parentfork
处理程序是在fork
创建子进程以后、返回之前在父进程上下文中调用的。这个fork
处理程序的任务是对prepare fork
处理程序获取的所有锁进行解锁。child fork
处理程序在fork
返回之前在子进程上下文中调用。与parent fork
处理程序一样,child fork
处理程序也必须释放prepare fork
处理程序获取的所有锁。
注意,不会出现加锁一次解锁两次的情况,虽然看起来也许会出现。子进程地址空间在创建时就得到了父进程定义的所有锁的副本。因为prepare fork
处理程序获取了所有的锁,父进程中的内存和子进程中的内存内容在开始的时候是相同的。当父进程和子进程对它们锁的副本进程解锁的时候,新的内存是分配给子进程的,父进程的内存内容是复制到子进程的内存中(写时复制),所以我们就会陷入这样的假象,看起来父进程对它所有的锁的副本进行了加锁,子进程对它所有的锁的副本进行了加锁。父进程和子进程对在不同内存单元的重复的锁都进行了解锁操作,就好像出现了下列事件序列。
- 父进程获取所有的锁。
- 子进程获取所有的锁。
- 父进程释放它的锁。
- 子进程释放它的锁。
可以多次调用pthread_atfork
函数从而设置多套fork
处理程序。如果不需要使用其中某个处理程序,可以给特定的处理程序参数传入空指针,它就不会起任何作用了。使用多个fork处理程序时,处理程序的调用顺序并不相同。parent和child fork处理程序是以它们注册时的顺序进行调用的,而prepare fork 处理程序的调用顺序与它们注册时的顺序相反。这样可以允许多个模块注册它们自己的fork处理程序,而且可以保持锁的层次。
例如,假设模块A调用模块B中的函数,而且每个模块有自己的一套锁。如果锁的层次是A在B之前,模块B必须在模块A之前设置它的fork
处理程序。当父进程调用fork
时,就会执行以下的步骤,假设子进程在父进程之前运行:
- 调用模块A的
prepare fork
处理程序获取模块A的所有锁。 - 调用模块B的
prepare fork
处理程序获取模块B的所有锁。 - 创建子进程。
- 调用模块B中的
child fork
处理程序释放子进程中模块B的所有锁。 - 调用模块A中的
child fork
处理程序释放子进程中模块A的所有锁。 fork
函数返回到子进程。- 调用模块B中的
parent fork
处理程序释放父进程中模块B的所有锁。 - 调用模块A中的
parent fork
处理程序来释放父进程中模块A的所有锁。 fork
函数返回到父进程。
如果fork
处理程序是用来清理锁状态的,那么又由谁来负责清理条件变量的状态呢?在有些操作系统的实现中,条件变量可能并不需要做任何清理。但是有些操作系统实现把锁作为条件变量实现的一部分,这种情况下的条件变量就需要清理。问题是目前不存在允许清理锁状态的接口。如果锁是嵌入到条件变量的数据结构中的,那么在调用fork
之后就不能使用条件变量,因为还没有可移植的方法对锁进行状态清理。另外,如果操作系统的实现是使用全局锁保护进程中所有的条件变量数据结构,那么操作系统实现本身可以在fork
库例程中做清理锁的工作,但是应用程序不应该依赖操作系统实现中类似这样的细节。
实例
图12-17中的程序描述了如何使用pthread_atfork
和fork
处理程序。
#include "apue.h"
#include <pthread.h>
pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;
void
prepare(void)
{
int err;
printf("preparing locks...\n");
if ((err = pthread_mutex_lock(&lock1)) != 0)
err_cont(err, "can't lock lock1 in prepare handler");
if ((err = pthread_mutex_lock(&lock2)) != 0)
err_cont(err, "can't lock lock2 in prepare handler");
}
void
parent(void)
{
int err;
printf("parent unlocking locks...\n");
if ((err = pthread_mutex_unlock(&lock1)) != 0)
err_cont(err, "can't unlock lock1 in parent handler");
if ((err = pthread_mutex_unlock(&lock2)) != 0)
err_cont(err, "can't unlock lock2 in parent handler");
}
void
child(void)
{
int err;
printf("child unlocking locks...\n");
if ((err = pthread_mutex_unlock(&lock1)) != 0)
err_cont(err, "can't unlock lock1 in child handler");
if ((err = pthread_mutex_unlock(&lock2)) != 0)
err_cont(err, "can't unlock lock2 in child handler");
}
void *
thr_fn(void *arg)
{
printf("thread started...\n");
pause();
return(0);
}
int
main(void)
{
int err;
pid_t pid;
pthread_t tid;
if ((err = pthread_atfork(prepare, parent, child)) != 0)
err_exit(err, "can't install fork handlers");
if ((err = pthread_create(&tid, NULL, thr_fn, 0)) != 0)
err_exit(err, "can't create thread");
sleep(2);
printf("parent about to fork...\n");
if ((pid = fork()) < 0)
err_quit("fork failed");
else if (pid == 0) /* child */
printf("child returned from fork\n");
else /* parent */
printf("parent returned from fork\n");
exit(0);
}
图12-17 pthread_atfork
实例
图12-17中定义了两个互斥量,lock1
和lock2
,preparefork
处理程序获取这两把锁,child fork
处理程序在子进程上下文中释放它们,parent fork
处理程序在父进程上下文中释放它们。
运行该程序,得到如下输出:
$ ./a.out
thread started...
parent about to fork...
preparing locks...
child unlocking locks...
child returned from fork
parent unlocking locks...
parent returned from fork
可以看到,prepare fork
处理程序在调用fork
以后运行,child fork
处理程序在fork
调用返回到子进程之前运行,parent fork
处理程序在fork
调用返回给父进程之前运行。
虽然pthread_atfork
机制的意图是使fork
之后的锁状态保持一致,但它还是存在一些不足之处,只能在有限情况下可用。
- 没有很好的办法对较复杂的同步对象(如条件变量或者屏障)进行状态的重新初始化。
- 某些错误检查的互斥量实现在
child fork
处理程序试图对被父进程加锁的互斥量进行解锁时会产生错误。 - 递归互斥量不能在
child fork
处理程序中清理,因为没有办法确定该互斥量被加锁的次数。 - 如果子进程只允许调用异步信号安全的函数,
child fork
处理程序就不可能清理同步对象,因为用于操作清理的所有函数都不是异步信号安全的。实际的问题是同步对象在某个线程调用fork时
可能处于中间状态,除非同步对象处于一致状态,否则无法被清理。 - 如果应用程序在信号处理程序中调用了
fork
(这是合法的,因为fork
本身是异步信号安全的),pthread_atfork
注册的fork
处理程序只能调用异步信号安全的函数,否则结果将是未定义的。