目录
- 1. 可重入函数
- 2. volatile
- 3. SIGCHLD 信号
1. 可重入函数
场景:当我们在全局区定义一个链表(不带头结点),然后对链表做头插结点的操作,即插入 node1 结点(如上图所示)。在插入 node1 时需要执行两条语句,一条是修改 node1 的 next 指针的指向,一条是将头结点更新为 node1,而此时进程刚执行完 p->next = head;
,正准备更新头结点的指向时,进程发送了信号中断,进程头也不回,转而执行信号捕捉了,刚好信号处理方法也要执行插入结点(node2),因此就先把 node2 结点头插到链表中了,最后再返回完成 node1 插入时剩下的语句。
此时同一个方法 insert,在调用插入 node1 结点时是 main 函数执行流,后面进程发生中断,在信号捕捉时调用的 insert 插入 node2 结点是 sighandler 执行流。也即,insert 这个方法在 main 函数执行流还没结束时,又被 sighandler 方法重复进入,这种现象称为函数被重复进入,简称函数被重入。
而当进程中断执行信号捕捉,把 node2 结点头插到链表之后,回到 main 函数,依旧要继续执行没有执行完的 node1 的头插,即 node1=head
。问题就来了,本来 head 指针指向的是 node2的,现在转而指向 node1 了,没有指针指向 node2,即 node2 结点丢失,进而导致内存泄漏!
对于该场景下的 insert 函数,被不同执行流重复进入、并且可能发生错误(或已经发生)的情况下,称为不可重入函数!反之称为可重入函数。
对于上述场景,main 函数和 sighandler 方法是两个执行流,其实是一种 “假结论”。因为当没有进程没有收到信号时,sighandler 方法并不会被执行,但是 main 函数是一定会执行的。换言之,sighandler 方法的执行与 main 函数没有任何关系,在操作系统的设计上,当进程收到信号了,要转而执行 sighandler 方法时,main 函数是被暂停的,所以这两股执行流并不是同时执行的。
如果一个函数符合以下条件之一,则是不可重入的:
- 调用了 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
- 调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。
2. volatile
int flag = 0;
void handler(int signo)
{
cout << "process get a signal: " << signo << endl;
flag = 1; // 跳出while循环
}
int main()
{
signal(2, handler);
while(!flag);
cout << "process quit! pid: " << getpid() << endl;
return 0;
}
在这段代码中,while(!flag);
的条件一直为真。当进程运行起来,我们向进程发送一个 2 号信号,由于代码中对 2 号做了信号捕捉,所以 handler 自定义处理方法被调用,然后 flag 被置为 1,while 循环的条件为假,跳出循环,最后进程运行完毕退出。
在这里,while(!flag)
条件判断的本质也是一种计算,计算分为算数运算和逻辑运算,我们对 flag 取反,就是为了让 CPU 将 while 的条件当作运算去执行。当 CPU 进行逻辑运算时,就注定了需要先将 flag 从内存读取到 CPU 的寄存器中,才能够被 CPU 执行运算。而编译器在编译这份代码时发现 flag 这个变量并不会被修改(while 循环也只是一直在读取 flag 变量的内容,并没有对 flag 做写入操作,而因为 hander 方法和 main 函数是属于两个不同的执行流,因此 handler 方法内对 flag 变量做修改,main 执行流并无法感知,所以编译器认为 flag 不会被修改)。结合这两点,因此编译器在编译时可能会对 flag 变量做优化,将 flag 的内容拷贝到 CPU 的寄存器中,让 CPU 在读取 flag 变量时不需要再进行访存,而是直接从寄存器中直接读取。
编译器在编译时,可以加上 -O 选项来进行优化。O0 代表没有优化,O1 - O3 优化层度递增。
g++ test.c -o test -O3 -std=c++11
编译时带上优化后,我们的进程不会退出,即便捕捉信号的处理方法内对 flag 置 1 了。这就涉及到我们刚说的,flag 变量的内容拷贝到了 CPU 的寄存器中,CPU 往后在读取 flag 变量的内容时,不会再进行访存,而是直接从寄存器中读。
而为什么捕捉信号的处理方法内对 flag 置 1 了,进程不会退出呢?
我们需要清楚一点的是,不管编译器如何优化,把变量的内容拷贝到寄存器也好,这个变量的本体都必须在内存都要存在,即这个被优化的变量依旧需要存储在内存中。而 flag 的内容被优化到寄存器这件事,是在编译时就决定的事情 ,进程收到信号,执行 handler 方法,将 flag 变量由 0 置 1,这是进程运行之后才发生的事情。问题就出在,一开始 flag 的内容就已经被拷贝到寄存器了,所以从此以后 CPU 都不在关注内存中 flag 的内容(代码层面上对 flag 做写入,是对内存中的flag 做的写入),只读取寄存器的 flag。所以即便后来内存中的 flag 被改了,但不好意思,CPU 不知道这件事,对于 CPU 来说,flag 一直都是 0,因为寄存器中存储的 flag 的值为 0,这就是为什么当代码被优化之后,flag 即便被修改为 1 了,进程也无法退出的原因。
对于 flag 被优化到寄存器中,可以理解为这个优化导致了内存不可见(对于 CPU 而言)。
而当我们给 flag 带上 volatile 关键词修饰时 volatile int flag = 0;
,进程又能够退出了。
所以我们可以理解为,volatile 关键字起到了防止编译器过度优化的作用,保持内存的可见性! 当 volatile 修饰一个变量时,即向编译器示意,不要将变量优化到寄存器中了,后续 CPU 对变量做检测时,都需要进行访存读取。
3. SIGCHLD 信号
在 进程等待 这篇文章中,我们曾说过,子进程退出后,会陷入僵尸状态,直到父进程对其进行回收。而进程等待的作用就是为了回收子进程,防止系统中的进程僵尸无人回收,导致内存泄漏。但由于父进程并不知道子进程何时会退出,所以父进程就需要一直通过 wait / waitpid 检测子进程的退出情况(采用阻塞式等待或者非阻塞轮询的方式)。
但是,当一个子进程退出时,它并不是 “静悄悄” 地退出走人的,而是会向父进程发送 SIGCHLD(17) 信号,这也是父进程等待检测子进程状态的重要标准,当父进程收到了来自子进程的 SIGCHLD 信号,便知道子进程退出了,然后对其进行回收。
对于所有的普通信号,只有 9 和 19 号信号不可被捕捉,因此我们可以通过捕捉 SIGCHLD(17) 信号 + 自定义信号处理动作,来证明子进程退出时,会向父进程发送 SIGCHLD(17) 信号。
void handler(int signo)
{
cout << "process(" << getpid() << ") get a signal: " << signo << endl;
}
int main()
{
signal(17, handler);
pid_t id = fork();
if(id == 0)
{
cout << "I am a child process, pid: " << getpid() << ", ppid: " << getppid() << endl;
sleep(1); // 子进程一秒后退出。
cout << "child process quit!\n";
exit(0);
}
while(1)
{
cout << "I am a father process, pid: " << getpid() << endl;
sleep(1);
}
return 0;
}
所以,现在我们就知道了,父进程在等待子进程时,可以采用基于信号的方式进行等待。但是还是需要使用 wait / waitpid 对子进程进行回收、释放资源等后续工作。
等待子进程的作用:
- 获取子进程的退出状态,释放子进程的僵尸(如果基于信号等待,而不调用 wait / waitpid,那么子进程就不会被回收,处于僵尸状态)
- 虽然无法得知父子进程谁先允许,但是一定是父进程最后退出
因此,基于信号的方式对子进程进行等待时,可以将 wait / waitpid 作为信号处理的一环,回收子进程。
void handler(int signo)
{
pid_t rid = waitpid(-1, nullptr, 0);
cout << "process(" << getpid() << ") get a signal: " << signo << endl;
}
对于 waitpid(-1, nullptr, 0);
-1 是等待任意一个子进程。现在的问题是,假如父进程创建了 10 个子进程,并且它们同时退出呢?10 个子进程同时退出,同时向父进程发送 SIGCHLD(17) 信号,但是在 信号处理与捕捉 中,我们就已经说过了,当进程处于信号处理期间,是会自动屏蔽正在处理的那种信号的。那这样就导致了剩下的 9 个进程向父进程发送的信号就都被屏蔽了,最终导致只回收了一个进程(cpu速度快的话,最多收到两信号,回收两进程),那么剩下的那些僵尸进程该如何处理呢??
对于多个子进程同时退出的问题,可以采用循环的方式对子进程进行等待;考虑到可能并不是全部的子进程一起同时退出,而是随时退出,可能连着两三个,也可能一个一个退。因此在对子进程等待时应采用非阻塞轮询的方式,等不到子进程退出了,父进程就先返回,直到下一个子进程退出,向父进程发送信号,父进程再次捕捉信号、回收子进程。(如果采用阻塞式一直等待,那么当子进程随机退出时,父进程因等不到全部子进程退出而一直阻塞在信号处理 handler 方法中)
void handler(int signo)
{
sleep(5);
pid_t rid;
while((rid = waitpid(-1, nullptr, WNOHANG)) > 0) // 多进程同时退出也适用
cout << "process(" << getpid() << ") get a signal: " << signo << ", child process quit: " << rid << endl;
}
int main()
{
srand(time(nullptr));
signal(17, handler);
for(int i = 0; i < 10; ++i) // 创建 10 个子进程
{
pid_t id = fork();
if(id == 0) { ... }
sleep(rand() % 5 + 3); // 模拟子进程随时退出
}
....
return 0;
}
其实,子进程在终止时会给父进程发 SIGCHLD 信号,操作系统对该信号的默认处理动作是忽略。同时,父进程也可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait / waitpid 清理子进程即可。
事实上,由于 UNIX 的历史原因,,想不产生僵尸进程还有另外一种办法,即父进程调用 sigaction 将 SIGCHLD 信号的处理动作置为SIG_IGN,即 signal(17, SIG_IGN)
,这样 fork 创建出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例(此方法对于Linux可用,但不保证在其它 UNIX 系统上都可用)。
现象:一次性创建10个子进程,当子进程退出时,并没有呈现僵尸昨天,而是直接被父进程自动回收。
查看 man 手册对 signal 的描述中提及了,SIGCHLD 的默认处理动作确实为 IGN(即忽略)。也即,以前在对子进程退出,即便父进程收到信号这件事,父进程对该信号的默认处理动作就是忽略(什么都不会做)。
但是,在介绍 进程等待 的时候,我们并不知道子进程退出时会给父进程发送信号这一回事,那时候对父进程收到的信号也没捕捉,但这并没有出现任何问题。所以问题就是: 以前没有对 SIGCHLD 信号做捕捉处理的时候,操作系统对它的处理就是忽略啊,那为什么那种忽略会导致进程僵尸了呢??而我们显式的对信号捕捉,处理动作依旧是 IGN signal(17, SIG_IGN)
,那为什么进程又不会僵尸了。所以都是 IGN ,为什么结果不一样呢??
其实,官方手册所描述的 SIGCHLD 信号的处理动作默认是忽略的,展开即是,它的处理动作依旧是 SIG_DFL,只不过这个默认动作里面,什么都不会做(即忽略处理)的意思。而我们显式的对信号捕捉,并将处理动作设置为 IGN,那它就是真正意义上直接跳过这个信号的处理。
如果感觉该篇文章给你带来了收获,可以 点赞👍 + 收藏⭐️ + 关注➕ 支持一下!
感谢各位观看!