【Linux系统编程】第四十弹---深入理解操作系统:信号捕捉、可重入函数、volatile关键字与SIGCHLD信号解析
✨个人主页: 熬夜学编程的小林
💗系列专栏: 【C语言详解】 【数据结构详解】【C++详解】【Linux系统编程】
目录
1、捕捉信号
1.1、内核如何实现信号的捕捉
1.2、内核态与用户态
1.3.1、用户态(User Space)
1.3.2、内核态(Kernel Space)
1.3.3、用户态与内核态的交互
1.3.4、再谈地址空间
1.3、键盘输入数据过程
1.4、OS如何正常的运行
1.5、sigaction
2、可重入函数
3、volatile
4、SIGCHLD信号
1、捕捉信号
1.1、内核如何实现信号的捕捉
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
用户程序注册了SIGQUIT信号的处理函数sighandler。
- 1、当前正在执行main函数,这时发生中断或异常切换到内核态。
- 2、在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
- 3、内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。
- 4、sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
信号可能不会被立即处理(信号被阻塞),而是在合适的时候处理。
进程从内核态返回到用户态的时候进行处理。
在内核态切回用户态时,进行信号的处理与检测。
1.2、内核态与用户态
1.3.1、用户态(User Space)
-
定义:用户态是应用程序运行的空间。当用户进程运行时,它会在用户态执行大部分的操作。
-
权限:用户态的代码具有较低的权限,不能直接访问硬件资源,也不能执行特权指令(如修改内存保护、执行I/O操作等)。这些操作必须通过系统调用(System Call)来请求内核来完成。
-
稳定性与安全性:由于用户态的代码权限较低,即使发生错误(如段错误、缓冲区溢出等),也不会对整个系统造成致命的影响。
-
示例:常见的运行在用户态的程序包括文本编辑器、浏览器、数据库等。
1.3.2、内核态(Kernel Space)
-
定义:内核态是操作系统内核运行的空间。内核是操作系统的核心部分,负责管理硬件、内存、进程、文件系统、网络等系统资源。
-
权限:内核态的代码具有较高的权限,可以直接访问硬件资源,执行特权指令。这使得内核能够执行各种底层操作,如设备驱动、中断处理、内存管理等。
-
稳定性与安全性:由于内核态的代码权限较高,如果内核代码出现错误(如内核崩溃、漏洞等),可能会导致整个系统崩溃或受到攻击。因此,内核代码需要格外小心地进行编写和测试。
-
系统调用接口:内核通过提供系统调用接口(System Call Interface, SCI)来与用户态程序进行交互。用户态程序通过系统调用请求内核执行特权操作。
-
示例:内核态的主要组成部分包括进程调度器、内存管理器、设备驱动程序、网络堆栈等。
1.3.3、用户态与内核态的交互
-
系统调用:当用户态程序需要执行特权操作时,它会通过系统调用接口请求内核完成该操作。系统调用是一种从用户态切换到内核态的机制。
-
中断和异常:除了系统调用外,中断和异常也是用户态与内核态交互的重要方式。例如,硬件中断(如I/O完成中断)会触发内核代码的执行,而异常(如除零异常)则可能导致内核接管并处理错误。
-
上下文切换:当用户态程序执行系统调用时,CPU会从用户态切换到内核态,并保存用户态的上下文(如寄存器值、堆栈指针等)。当系统调用完成后,CPU会恢复用户态的上下文并继续执行用户态程序。
1.3.4、再谈地址空间
基本认知:
1、无论进程如何切换,我们总能找到OS
2、我们访问的OS,实际上还是在我们的地址空间中进行的,和我们访问库函数没区别
3、OS不相信任何用户,因此用户访问[3,4]G的地址空间(内核空间)时,要收到一定的约束,只能使用系统调用
4、内核级页表只需要维护一份
1.3、键盘输入数据过程
键盘输入的过程是一个涉及硬件、驱动程序、操作系统以及用户界面的复杂交互过程。
1.4、OS如何正常的运行
1.5、sigaction
sigaction - 检查和改变信号行为
#include <signal.h>
int sigaction(int signum, const struct sigaction *act,struct sigaction *oldact);
参数:
signum:指定要设置的信号编号。这个参数可以是除SIGKILL及SIGSTOP
外的任何一个特定有效的信号。为这两个信号定义自己的处理函数,将导致信号安装错误。
act:指向struct sigaction结构体的指针,该结构体中指定了对特定信号的处理方式。
如果为NULL,则进程会以缺省方式对信号处理。
oldact:如果不为NULL,则保存原来对相应信号的处理方式。
如果不需要保存旧的处理方式,可以将其设置为NULL。
struct sigaction {
void (*sa_handler)(int); // 或 union 中的 _sa_handler
void (*sa_sigaction)(int, siginfo_t *, void *); // 三参数信号处理函数
sigset_t sa_mask; // 信号屏蔽字
int sa_flags; // 标志位
// 以下成员已过时,POSIX不支持,不应再使用
void (*sa_restorer)(void);
};
sa_handler:这是一个指向信号处理函数的指针,与signal()函数的handler参数类似。
当接收到指定信号时,将调用此函数。但请注意,如果设置了SA_SIGINFO标志位,
则应使用sa_sigaction而不是sa_handler。
sa_sigaction:这是一个三参数信号处理函数,当设置了SA_SIGINFO标志位时,
将使用此函数处理信号。它提供了关于信号的更多信息,如信号编号、信号来源等。
sa_mask:定义了一组信号,在调用由sa_handler或sa_sigaction所定义的处理器程序时,
将阻塞这些信号,防止它们中断处理器程序的执行。
sa_flags:位掩码,指定用于控制信号处理过程的各种选项。常用的标志位包括:
SA_NODEFER:捕获该信号时,不会在执行处理器程序时将该信号自动添加到进程掩码中。
SA_ONSTACK:针对此信号调用处理器函数时,使用了由sigaltstack()安装的备选栈。
SA_RESETHAND:当捕获该信号时,会在调用处理器函数之前将信号处置重置为默认值(即SIG_IGN)。
SA_SIGINFO:调用信号处理器程序时携带了额外参数,其中提供了关于信号的深入信息。
SA_RESTART:执行信号处理后自动重启动先前中断的系统调用。
struct sigaction
结构体通常包含以下字段:
sa_handler
或sa_sigaction
:一个指向信号处理函数的指针,或者是SIG_IGN
(忽略信号)或SIG_DFL
(采用默认行为)。sa_mask
:一个信号集,指定在信号处理函数执行期间应该阻塞哪些信号。sa_flags
:一组标志,用于修改sigaction
的行为。例如,SA_RESTART
标志指示被信号中断的系统调用应该自动重启。sa_restorer
:(已废弃)用于恢复旧的信号处理机制,现代代码中不应使用。
代码演示
打印pending表
void Print(sigset_t& pending)
{
for(int sig = 31;sig >= 1;sig--)
{
if(sigismember(&pending,sig))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << "\n";
}
自定义捕捉方法
void handler(int signum)
{
std::cout << "get a sig: " << signum << std::endl;
while(true)
{
sigset_t pending;
sigpending(&pending);
Print(pending);
sleep(1);
//break;
}
}
主函数
int main()
{
struct sigaction act,oact;
act.sa_handler = handler;
sigemptyset(&act.sa_mask); // 初始化信号集
//sigaddset(&act.sa_mask,3); // 将3号信号添加到阻塞信号集
// 将2号信号添加到阻塞信号集,如果进程再次接收到2号信号,它将被阻塞
// 直到handler函数执行完毕或信号被解除阻塞
sigaddset(&act.sa_mask,2);
act.sa_flags = 0;
// 使用sigaction为1到31号的每个信号设置了一个相同的处理函数handler
for(int i=1;i<=31;i++)
sigaction(i,&act,&oact);
//sigaction(2,&act,&oact);
while(true)
{
std::cout << "I am a process,pid: " << getpid() << std::endl;
sleep(1);
}
return 0;
}
2、可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了。
像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。想一下,为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱?
由于函数调用栈的独立性、线程隔离、递归调用的独立性以及作用域和生命周期的限制,两个不同的控制流程调用同一个函数时,访问其同一个局部变量或参数不会造成错乱。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
3、volatile
- 该关键字在C当中我们已经有所涉猎,今天我们站在信号的角度重新理解一下
代码演示
int gflag = 0;
void changedata(int sig)
{
std::cout << "get a sig: " << sig << ",change gflag 0->1 " << std::endl;
gflag = 1;
}
int main()
{
signal(2,changedata);
while(!gflag); // 不需要其他代码
std::cout << "process quit formal!" << std::endl;
return 0;
}
分析代码
从上面现象我们可以看到,如果对该程序编译进行优化,就会一直循环,为了解决该问题,可以使用volatile关键字(保持内存可见性)修饰全局变量
- volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
volatile int gflag = 0; // 保持内存可见性,一直从内存加载到CPU
运行结果
4、SIGCHLD信号
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
代码演示
void notice(int sig)
{
std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
}
void DoOtherThing()
{
std::cout << "DoOtherThing()~" << std::endl;
}
int main()
{
signal(SIGCHLD,notice);
pid_t id = fork();
if(id == 0)
{
// child
std::cout << "I am child process,pid: " << getpid() << std::endl;
sleep(3);
exit(1);
}
// father
while(true)
{
DoOtherThing();
sleep(1);
}
return 0;
}
运行结果
子进程变僵尸进程
请编写一个程序完成以下功能:父进程fork出子进程,子进程调用exit(2)终止,父进程自定 义SIGCHLD信号的处理函数,在其中调用wait获得子进程的退出状态并打印。
修改上面验证子进程在终止时会给父进程发SIGCHLD信号中的notice函数代码即可。
void notice(int sig)
{
std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
pid_t rid = waitpid(-1,nullptr,0); // 阻塞
if(rid > 0)
{
std::cout << "wait child success pid: " << getpid() << std::endl;
}
else if(rid < 0)
{
std::cout << "wait child failed!!!" << std::endl;
}
}
waitpid(-1,nullptr,0); -1表示等待任何子进程
运行结果
问题1: 如果一共有10个子进程,且同时退出呢?
在回收子进程的时候,打一个死循环即可,有进程就回收!
代码演示
void notice(int sig)
{
std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
while(true)
{
pid_t rid = waitpid(-1,nullptr,0); // 阻塞
if(rid > 0)
{
std::cout << "wait child success pid: " << getpid() << std::endl;
}
else if(rid < 0)
{
std::cout << "wait child failed!!!" << std::endl;
break;
}
}
}
void DoOtherThing()
{
std::cout << "DoOtherThing()~" << std::endl;
}
int main()
{
signal(SIGCHLD,notice);
for(int i=0;i<10;i++)
{
pid_t id = fork();
if(id == 0)
{
// child
std::cout << "I am child process,pid: " << getpid() << std::endl;
sleep(3);
exit(1);
}
}
// father
while(true)
{
DoOtherThing();
sleep(1);
}
return 0;
}
运行结果
问题2: 如果一共有10个子进程, 5个退出,5个永远不退出呢?
回收需要退出的子进程即可,使用非阻塞等待子进程。
代码演示
void notice(int sig)
{
std::cout << "get a sig: " << sig << ",pid: " << getpid() << std::endl;
while(true)
{
pid_t rid = waitpid(-1,nullptr,WNOHANG); // 阻塞 -> 非阻塞
if(rid > 0)
{
std::cout << "wait child success pid: " << getpid() << std::endl;
}
else if(rid < 0)
{
std::cout << "wait child failed!!!" << std::endl;
break;
}
else
{
std::cout << "wait child success done " << std::endl;
break;
}
}
}
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可 用。
int main()
{
signal(SIGCHLD, SIG_IGN); // 收到设置对SIGCHLD进行忽略即可
pid_t id = fork();
if (id == 0)
{
int cnt = 5;
while (cnt)
{
std::cout << "child running" << std::endl;
cnt--;
sleep(1);
}
exit(1);
}
while (true)
{
std::cout << "father running" << std::endl;
sleep(1);
}
}