网上购物网站建设方案,自适应网站建设公司,企业站用什么程序做网站,frog 网站建设写在前面
通过学习信号可以理解进程与进程的一个相对关系#xff0c;还能理解操作系统与进程的关系。要注意的是进程间通信中的信号量与这里的信号没有半毛钱关系#xff0c;就像老婆和老婆饼。
本文要点#xff1a;
掌握 Linux 信号的基本概念掌握信号产生的一般方式理解…写在前面
通过学习信号可以理解进程与进程的一个相对关系还能理解操作系统与进程的关系。要注意的是进程间通信中的信号量与这里的信号没有半毛钱关系就像老婆和老婆饼。
本文要点
掌握 Linux 信号的基本概念掌握信号产生的一般方式理解信号递达和阻塞的概念原理掌握信号捕捉的一般方式重新了解可重入函数的概念了解竞态条件的情景和处理方式了解SIGCHLD信号 重新编写信号处理函数的一般处理机制
一、信号初识 生活信号
在生活中也存在着很多信号如下课铃闹钟红绿灯等等这里就有两个问题。 你为什么能认识红绿灯或者闹钟呢 ❓ 因为曾经有人教过我们红绿灯或闹钟是什么然后我们记住的。 身边没有闹钟时你知不知道闹钟响了之后该怎么办 ❓ 当然知道因为曾经有人教过我们教我们的是它是什么为什么怎么办。这两个问题对应是什么和怎么办而为什么是路上不安全所以要认识红绿灯对于为什么信号这里就不多说了。
所以对于是什么和怎么办这个话题称为人能够识别信号。os 类似社会人就是进程社会中会有很多信号围绕着人去展开而 os 中也会有很多信号围绕着信号去展开所以进程要能够识别非常多的信号。这里只想说明进程能认识信号以及信号不管到没到来进程都知道该怎么做。 linux 信号
在之前我发们也很简单的接触过信号kill -l就可以查看信号可以看到这里不是有 64 种信号因为中间不是连续的。其中 1~31 叫做普通信号而 34~64 叫做实时信号每个实时信号中都包含了 RT 两个字母。要说明的是这里重点谈普通信号实时信号不考虑简单提一下实时信号是一种响应特别强的信号比如着火而普通信号对应早上的闹钟。 生活中的信号有三种生命周期linux 下的信号也是如此所以本文就围绕着它三研究。 测试用例 1 这里开始我们就开始融入 C 语法了在 linux 下C 文件的后缀可以是 .cpp.cxx.cc。可以看到这里的 makefile这样写的好处是如果以后想修改依赖文件或者目标文件那么只需要修改上面的一部分即可。 这段代码就是一段简单的死循环当我们在键盘ctrlc就是向前台进程发送2)SIGINT信号结束进程。当然可以新建 ssh 渠道验证一下这里可以向目标进程发送 2 号信号或者它所对应的宏 SIGINT 对于相当一部分信号而言当进程收到的时候默认的处理动作就是终止当前进程 SIGCONT 和 SIGSTOP 这两个信号我们之前也接触过19)SIGSTOP 用于暂停目标进程18)SIGCONT用于继续目标进程。此时发送 18 号信号后ctrl c 也就是发送第 2 号信号不能结束目标进程因为目标进程被发送 18 号信号后已经变成了一个后台进程 S (ps ajx 可以看到)2 号信号无法结束所以这里可以发送第 39 号信号来结束像这样的后台进程。 宏观理解 所以下面会围绕着这个宏观的理解链来展开。 产生信号 a) kill 命令产生 b) 键盘产生 第 1 点就是 kill -2 pid第 2 点就是 ctrl c。 信号识别 进程收到信号其实不是立即处理的而是在合适的时候再处理。 所谓什么是 “ 合适的时候 ” 会在下面谈“ 不是立即处理 ” 是指比如你点了一个外卖外卖小哥把饭送到楼下发信息告诉你说你饭已经到了但现在敌人已经上水晶了很显然此时你不是立马下去拿饭而是等这波团战结束。那么信号中为什么 “ 不是立即处理 ” 呢 —— 因为信号的产生是在进程运行的任何时间点都可以产生的有可能进程正在做更重要的事情。 信号处理 默认方式 (部分是终止进程部分有特定的功能) 忽略信号 比如说发送了一个信号但是却什么都没做这就是忽略信号它当然也是处理信号。 自定义信号 如果你自己想处理这个信号就叫做自定义信号也叫做 捕捉信号。 信号处理无非就以下 3 种方案。比如早上的闹钟响了然后你就起床了这是默认 闹钟响了然后你继续睡这是忽略闹钟响了然后你起来跳个舞这是自定义捕捉。 信号的本质
当然信号的产生方式还有很多种。我们先探讨一下信号的本质。 信号的本质 从信号识别中我们知道信号不是立即处理的那么就意味着信号需要被保存起来。那么问题就来了。 信号在哪里保存 信号不是给硬件网络发的它是给进程发的所以这个信号一定是在进程的 PCB 下也就是在进程控制块 task_struct 中保存。 信号如何保存 由 kill -l我们知道一共有 31 个普通信号它们都是大写字母构成其实就是一个一个的宏我们可以在系统中查找到。 你点了一个外卖对你而言你当然知道点的是什么而外卖员是男是女多大年纪这不重要。对你而言最重要的是外卖是否到了今天中午吃的是猪脚饭。所以对进程而言最重要的无非是 “ 是否有信号 ” “ 是谁 ”我们知道操作系统提供了 31 个普通信号所以我们采用位图来保存信号也就是说在 task_struct 结构中只要写上一个 unsigned int signals; (00000000 … 00000000) 这样一个字段即可。比特位的位置代表是哪一个信号比特位的内容 0 1 代表是否。 信号是谁发的如何发 至此发送信号的本质就是写对应进程 task_struct 信号位图。因为 OS 是系统资源的管理者所以把数据写到 task_struct 中只有 OS 有资格有义务。所以信号是操作系统发送的通过修改对应进程的信号位图 (0 - 1) 完成信号的发送。所以更朴素点说信号不是 OS 发送的而是写的。 知道了信号的本质后再看信号的产生 (kill键盘)不管信号是如何产生的最后一定要经过 OS再到进程。kill 当然是命令是在 bash 上的也就是在系统调用之上所以 kill 底层一定使用了操作系统某种接口来完成像目标进程写信号的过程。键盘是一种硬件它所产生的各种组合键会产生各种不同的数据OS 作为硬件的管理者键盘上所获得的各种数据一定是先被 OS 拿到。所以现在就可以告诉大家信号的产生五花八门但归根结底所有信号的产生后都是间接或直接由 OS 拿到后向目标进程发信号。 signal 接口 signal 可以对特定的信号自定义方法或忽略信号。signum 是信号编号handler 的类型是 sighandler_t 而它是一个函数指针 void (*sighandler_t)(int)如果 typedef void (*sighandler_t)(int) 就相当于给 void(*)(int) 重命名为 sighandler_t。这是回调函数的机制当 signum 产生时 handler 才调用否则不会调用这就是回调函数一个典型的应用场景。 测试用例 2 测试用例 2.1 这里就可以看到没有信号产生时它就不会执行 signal因为它是回调函数。而一旦 ctrl c 收到信号这里就调用了 handler 函数并获取到信号编号同样命令也是如此。虽然捕捉了 2 号信号 SIGINT但是其它信号并没有被捕捉所以当然可以kill -3或ctrl/。那么问题来了若捕捉完 31 个信号呢。 测试用例 2.2 当我们把全部信号捕捉时操作系统给进程写的任何信号进程只是说知道了知道了然后给你一句话就完了然后继续跑路是不是就意味着写了一个 “ 金刚不坏 ” 的进程呢。Linux 操作系统当然需要考虑这种场景如果允许所有的信号被捕捉那么非法用户就很容易创建了一个非法进程这个进程各种申请资源就是不还并且还把所有的信号全部捕捉或忽略这就导致操作系统知道是这个进程的问题还拿它没办法这就是系统设计上的 bug。所以 Linux 系统中有若干个信号不能被捕捉或自定义最典型的信号就是第 9 号信号 SIGKILL快捷键ctrl\它叫做管理员信号是所有信号中权力最大的。那么忽略信号的现象是什么呢。 测试用例 2.3 可以看到SIG_IGN对应的就是把 1 强制成函数指针类型它依旧是一个回调函数 (这里 grep -ER 在筛选时后面可以 -n 以获取行号在 vim 时也可以在其后 24 以定位所在行号)。此时系统发送信号给进程它一句话也不说继续跑路直接忽略 (不过这里看到 ctrl c 时有反应)我们当然知道它不能对所有信号进行忽略所以发送第 9 号 SIGKILL 杀掉进程。 所以第 9 号进程 SIGKILL 既不能被捕捉也不能被忽略。上面说过进程运行的任何时间点都可以产生信号所以信号产生和进程运行是异步的 (当然也有同步这也就是在上文谈信号量时只谈了异步的原因同步这个名词有不同解释场景不同意思也不同同步和异步有时表示的是执行流的关系有时是进程访问临界资源的问题。后者好比老师在上课过程中烟瘾犯了然后跟学习不好的张三说你去帮我拿包烟我们先休息会等你你回来后我们再开始上课此时课程的进度跟张三回来要同步互相影响这叫做同步还是老师在上课过程中烟瘾犯了然后跟学习好的李四说你去帮我拿包烟然后老师继续上课而李四在跟老板吵着架此时两件事是同时进行的互不影响这叫做异步)。换言之这里想说明的是若两个进程是毫无关系一个进程在执行时可能随时会收到信号而信号是用户还没发准备发已经发所以进程就不等信号了这就是异步。到此我们仅仅是比较粗力度的来谈这三个阶段所以接下来需要更加深入的了解其细节了。
二、产生信号 core dump
在此之前我们要回答在进程等待中遗留 core dump 标志位的问题这个问题和本章不是很强相关由测试用例 3 演示。 测试用例 3 测试用例 3.1 这当然也很正常因为大部分信号都会终止掉进程 设置 core file sizekill -8/11 后发现报错信息中多了一个 (core dumped)且 ll 还发现多了两个 core 文件 在进程等待时我们说过一个概念父进程中 waitpid 可以获取子进程的退出信息其中 status 中低 7 位表示进程退出时的终止信号次低 8 位表示进程退出时的退出码而低 8 位中的最后 1 位还没有谈它表示进程是否 core dumpcore dump 是一个标志位。当一个进程被异常退出时退出码无意义你不仅想知道它的退出信号更想知道的是它在代码的哪一行触发的信号。因为是云服务器默认看不到现象如果是虚拟机就可以看到。所以为了让云服务器能够看到我们需要设置一下。这里ulimit -a查看系统资源其中ulimit -c 1024就设置好了 core file size。 在上面运行报错后有一个 (core dumped)它叫做核心转储也就是说当一个进程崩溃时OS 会将进程运行时的核心数据 dump 到磁盘上方便用户进行调试一旦发生了核心转储core dump 标志位就会被设置 1否则就是 0。一般而言线上环境核心转储是被关闭的因为程序每崩溃一次就会 dump 一次而这一个 core 文件有 56 万多个字节还不说这个文件不大。若线上环境核心转储是打开的在公司项目中有几千台机器那肯定是自动运行的此时若有大量错误时一运行就 dump一 dump 就运行过了一晚你一看服务器都登不上了原因是磁盘已经被大量的 core 文件占用了。 测试用例 3.2 此时我们就可以利用核心转储生成的 core 文件来定位 bug需要 makefile 中 -g 先生成 release 文件。gdb 中直接core-file core 文件即可。以前我们找 bug 是一行一行的调试而现在是不管三七二十一让你先崩掉然后配合 gdb 定位 bug这种调试方案叫做事后调试。 测试用例 3.3 除 0 野指针 这里还有一个细节除 0 异常和 kill -8 报的错误是一样的野指针异常和 kill -11 报的错误也是一样的这就说明的是信号产生的第三种方式是程序异常这里更准确来说应该是硬件异常因为除 0 和野指针都有对应的硬件资源下面会解释。 8)SIGFPE 是指进程在运行时发生了算术异常比如除 0 或者浮点数溢出等。11)SIGSEGV 是段错误指进程在运行时访问了不属于自己的内存地址或者访问已经被释放的内存地址比如野指针。 站在语言的角度这叫做程序崩溃本质应该是进程崩溃因为站在系统的角度这叫做进程收到了信号。换言之一般程序崩溃是因为你的代码有非法操作被 OS 检测到了然后向你的进程发送了信号。当然在语言层也可以使用异常捕捉来进行语言层面上的检测。如果没有信号那么出现野指针等内存问题时OS 作为软硬件资源的管理者设计的健壮性就很差所以信号存在的价值也是为了保护软硬件等资源。 测试用例 3.4 注意看到这里的 core dump 被设置成 1除了进程收到异常信号还要打开 core file size。 图 1这里 50程序没有崩溃这也能理解因为越界不一定报错且在访问的时候有可能这个地址高处还有一些是自己的空间只不过不是数组所有这里还访问了栈空间中一些废弃的数据。随后又对越界的区域进行写入它还是没有报错这有可能是这块地址空间高处还有可能是自己的空间但不是数组所有。然后 500 还是没有报。图 2再 5000终于看到效果了。数组越界和野指针都会收到 第 11 号信号 SIGSEGV。 测试用例 3.5 这就演示了实际在发送信号报错的时候并不是所以信号都会 core dump只有一些与你编写代码强相关的才会 core dump比如除 0野指针越界等。换言之当你的进程触发这些错误时也会由 OS 识别到然后给目标进程发送信号来达到终止进程的目的。 硬件异常产生信号
到此就理解一个进程可以收到信号收到信号后它会捕捉忽略。比如忽略处理完后进程就要退出了然后释放资源这都能理解。但是像除 0野指针/越界这些错误OS 是如何具备识别异常的能力 —— OS 是软硬件资源的管理者好的情况和坏的情况都知道对于除 0野指针/越界在语言上都叫做这几种报错但实际上这它们都对应不同软硬件 —— 除 0对应 CPU 中的状态寄存器 (除 0 就是溢出而状态寄存器用来检测每次计算有无溢出) 野指针/越界对应内存页表内存管理单元 MMU(MMU 是负责的是虚拟地址与物理地址的转换的一种硬件并且提供硬件机制的内存访问授权如果野指针了就会被检测你的这个地址没有权限去访问)。坏的情况下操作系统当然知道是哪个进程干的如果是 CPU 除 0那么当前是哪个进程执行代码就是哪个干的如果是内存野指针/越界当前用的是哪个进程的页表完成是哪个进程的转换那么也就是哪个进程干的。换言之OS 可以知道是哪个进程出错了哪个进程干的所以 OS 当然可以向这个进程发送信号。这里就点到为止了我们毕竟是玩软件的再深入就是具体的硬件了。 软件条件产生信号
软件条件不是错误当某种条件被触发时OS 会向目标进程发送信号。好比你拿了你妈的 100 块钱你妈发现时是你拿的相当于你发了信号给你妈然后你妈检测到异常把你揍了一顿这叫做进程出问题被 OS 检测到然后发信号终止进程。又好比你叫你妈明早叫你起床然后你妈明早就准时叫你起床此时你和你妈之间的交互没有任何硬件单元存在这叫做软件条件产生信号。
实际关于软件条件产生信号我们已经接触过了只是当时没有提。在进程间通信中我发们说过一个场景读端不光不读且把读关闭写端在写的时候就会收到 SIGPIPE 信号进而导致写进程退出。在底层 OS 一定会提供支持所以在写入时OS 一定是设置了你能成功写入的条件比如读端的文件是打开的写端就可以写否则写端再写就会被操作系统发送信号。所以在 OS 层面上这是一种软件条件产生的信号。对于 SIGPIPE 这里就不多演示了这里介绍alarm 接口和14)SIGALRM信号 (alarm 也并不常用只是想通过 alarm 来演示软件条件产生信号仅此而已)。 alarm 调用 alarm 函数可以设定一个闹钟也就是告诉内核在 seconds 秒之后给当前进程发送 SIGALRM 信号该信号的默认处理动作是终止当前进程。当你设置好闹钟后默认结束进程时却被其它原因提前结束进程当你在重新设置 alarm 时就会返回上一次闹钟剩余的时间。如果 second 是 0表示取消以前设定的闹钟函数的返回值仍然是以前设定的闹钟还余下的秒数 。比如 alarm 30 秒但 20 秒时进程结束了于是重新 alarm 15此时就会返回以前设定闹钟时还余下的时间 10 秒。 测试用例 4 这里还要说明一下两个问题验证它收到的是第 14 号信号为什么 1 秒内打出来的值才累加到 20 万左右不应该更多吗。 测试用例 4.1 这里想看到 alarm 时发送的是第 14 号信号可以 signal 捕捉它其中捕捉到后可以让它休眠 3 秒看到结果后再 kill -9也可以直接执行自己的 exit。 测试用例 4.2 如果只是单纯累加它能加到很高是亿级别的。但是涉及到了 I/O 数据传输且这里是云服务器还涉及到了网络数据传输效率就更低了。 补充
注意 abort 和 raise 是立即发送而 alarm 是延时 seconds 秒发abort 只能向自己发第 6 号信号raise 是向自己发第 sig 信号。 abort abort abort是向自己发送6)abort终止进程。 测试用例 5 raise raise raise 是向自己发送 sig 信号。 测试用例 6 kill kill kill是命令也是系统接口。它可以给 pid 进程发 sig 信号。 测试用例 7 这里使用 命令行参数 和 kill 接口来模拟实现一个 kill 命令。使用 atoi 函数把字符类型转换成整型而可直接包含 cstdlib.h因为它包含了 stdlib.h 中所有的功能。sleep 进程需要在后台运行是因为方便我们 ps ajx。 小结
可以看到产生信号的方式有很多如果想验证一下键盘上一些产生信号的其它的组合键就把所有信号都捕捉然后死循环的验证就可以看到各种组合键所对应的信号编号。至此我们回答了
信号产生后为什么都要由 OS 来执行。收到信号后信号是否是立即被处理 (是在合适的时候再处理而所谓合适会在下面捕捉信号谈)。如果不是立即处理那么信号是否需要被保存。进程在没有收到信号时是否知道自己应该对合法信号作何处理。如何理解 OS 向进程发送信号。程序崩溃的本质。
三、阻塞信号 至此信号产生阶段已经完成了开始进入信号识别阶段。对于如何保存上面也仅仅是只谈了个位图还有很多内容没有谈。 信号相关常见概念 实际执行信号的处理动作称为信号递达(Delivery)。 递达的动作无非就是默认忽略自定义捕捉这三种。 信号从产生到递达之间的状态称为信号未决(Pending)。 进程可以选择阻塞(Block)某个信号。 本章中谈的阻塞和之前在 waitpid 上谈的阻塞没有任何关系。这里的阻塞是指进程可以允许某些信号不会被递达直到解除阻塞后方可递达。 被阻塞的信号产生时将保持在未决状态直到进程解除对此信号的阻塞才执行递达的动作。 注意阻塞和忽略是不同的忽略是信号已经递达处理了忽略是递达的一种阻塞是信号根本不会递达此时就暂时保存在未决中直到结束阻塞。 信号内核示意图 实际在 Linux kernel 的 task_struct 中还包含了一些信号相关字段如上图这个图应该横着来看SIGHUP(1)没有收到 pending也没有收到 block所以默认处理是 SIG_DFLSIGINT(2)收到 pending因为也收到了 block所以不会处理 SIG_IGNSIGQUIT(3)没有收到 pending收到了 block如果没有收到对应的信号照样可以阻塞信号所以阻塞更准备的理解是它是一种状态信号的自定义捕捉方法是用户提供的是在用户权限下对应的方法。后续学习信号操作上都是围绕着这三个表来展开。 pending (未决)它是一个无符号整型的位图比特位的位置代表信号的编号比特位的内容 0 1 代表是否收到信号OS 发送信号本质是修改 task_struct ➡ pending 位图的内容。 handler (递达)它是一个函数指针数组它是用信号的编号作为 handler 数组的索引找到该信号编号对应的信号处理方式然后执行对应的方法。 block (阻塞)它是一个无符号整型的位图比特位的位置代表信号的编号比特位的内容 0 1 代表是否阻塞该信号。 sigset_t
你可以理解为了能让我们更好的对上面的三张表操作OS 给我们提供了一种系统级别sigset_t类型这个类型 OS 内部的当然也有定义我们可以使用这个数据类型在用户空间和内核交互此时就一定需要系统接口。
从上图来看每个信号只有一个 bit 的未决标志非 0 即 1不记录该信号产生了多少次阻塞标志也是这样表示的。因此未决和阻塞标志可以用相同的数据类型 sigset_t 来存储sigset_t 称为信号集这个类型可以表示每个信号的 “ 有效 ” 或 “ 无效 ” 状态在阻塞信号集中有效和无效的含义是该信号是否被阻塞而在未决信号集中有效和无效的含义是该信号是否处于未决状态。
再解释一下如果 sigset_t 定义的变量 set 当然是在栈上开辟空间这个栈就是用户栈实际我们在进程地址空间中谈的代码段、数据段、堆区、内存映射段、栈区、命令行参数、环境变量都是在用户空间而将来要把用户空间中的进程信号属性设置到内核所以除了 sigset_t一定还需要系统接口。 信号集操作函数 准备工作 当然光有 sigset_t 这个类型还不够这个类型本身就是一个位图。实际我们不支持或者不建议直接操作 sigset_t因为不同平台甚至不同位数的 OSsigset_t 位图的底层组织结构实现可能是不一样的所以 OS 提供了一些专门针对 sigset_t 的系统接口这些接口会先在用户层把信号相关的位图数据处理好。sigset_t 类型对于每种信号用一个 bit 表示 “ 有效 ” 或 “ 无效 ” 状态至于这个类型内部如何存储这些 bit则依赖于系统的实现从使用者的角度是不必关心的使用者只能调用以下函数来操作 sigset_t 变量而不应该对它的内部数据做任何解释比如 printf 直接打印 sigset_t 变量是没有意义的。 #include signal.h
int sigemptyset(sigset_t* set);//全部置0
int sigfillset(sigset_t* set);//全部置1
int sigaddset(sigset_t* set, int signo);//指定位置置为1
int sigdelset(sigset_t* set, int signo);//指定位置置为0
int sigismember(const sigset_t* set, int signo);//判断特定信号是否已经被设置sigprocmask include signal.h
int sigprocmask(int how, const sigset_t* set, sigset_t* oset);
return 0 onsuccess and -1 on error.how:SIG_BLOCK,添加新的set屏蔽信号,同maskmask|setSIG_UNBLOCK,解除set阻塞的屏蔽信号, 同maskmaskset SIG_SETMASK,设置当前信号屏蔽字为set所指向的值,同maskset
set:输入型参数,由用户层把信号屏蔽字拷贝到内核
oset:输出型参数,把老的信号屏蔽字返回,方便恢复不想保存可设置NULLnotice:这个系统接口是你的进程要执行的所以对应设置的就是你这个进程的信号屏蔽字sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。传入一个 set 信号集设置进程的 block 位图一般把用户空间定义的信号集变量或对象设置成进程 block 位图这样的信号集叫做信号屏蔽字(Signal Mask)阻塞信号集也叫做当前进程的信号屏蔽字这里的屏蔽应该理解为阻塞而不是忽略。 sigpending #include signal.h
int sigpending(sigset_t* set);
return 0 on success and -1 on error.set:输出型参数,获取进程的pending信号位图测试用例 8 1) 屏蔽(阻塞) 2 号信号 2) 不断的获取 pending 信号集并输出 3) 发送 2 号信号给进程 #include iostream
#include signal.h
#include unistd.husing std::cout;
using std::endl;void show_pending(sigset_t* pending)
{for(int i 1; i 31; i){if(sigismember(pending, i)){cout 1; } else{cout 0; }} cout endl;
}int main()
{//usersigset_t in, out;sigemptyset(in)//置0sigemptyset(out);sigaddset(in, 2)//01000...//kernelsigprocmask(SIG_SETMASK, in, out);//屏蔽 new:0100... old:0000...sigset_t pending;while(true){ sigpending(pending);show_pending(pending);sleep(1); }return 0;
}测试用例 9 1) 屏蔽(阻塞) 2 号信号 2) 不断的获取 pending 信号集并输出 3) 发送 2 号信号给进程 4) 20 秒后取消屏蔽 2 号信号 2 号信号经过屏蔽又取消屏蔽如果默认不自定义捕捉那么一取消屏蔽2 号信号立马从未决到递达执行默认方法终止所以就看不到现象所以就需要自定义捕捉(实际我们很少要对信号自定义捕捉)。 #include iostream
#include signal.h
#include unistd.husing std::cout;
using std::endl;void show_pending(sigset_t* pending)
{for(int i 1; i 31; i){if(sigismember(pending, i)){cout 1; } else{cout 0; }} cout endl;
}void handler(int signo)
{cout get a signo: signo endl;
}int main()
{//需要自定义捕捉signal(2, handler);//usersigset_t in, out;sigemptyset(in)//置0sigemptyset(out);sigaddset(in, 2)//01000...//kernelsigprocmask(SIG_SETMASK, in, out);//屏蔽 new:0100... old:0000...int count 0;sigset_t pending;while(true){sigpending(pending);show_pending(pending);sleep(1); if(count 20){sigprocmask(SIG_SETMASK, out, in);//解除屏蔽 new:0000... old:0100...cout my: ;show_pending(in);//虽然有点不合适,但仅仅是做个测试cout recover default: ;show_pending(out); }count;}return 0;
}四、处理信号 至此我们信号识别阶段也已完成了开始进入信号处理中阶段。对于如何处理上面也谈过 signal 接口了如 signal(2, handler)所以对 2 号信号执行 handler 捕捉动作本质是 OS 去 task_struct 通过 2 号信号作索引找到内核中 handler 函数指针数组中对应的方法然后把数组内容改成你自己在用户层传入的 handler 函数指针方法。这里我们要谈的是上面遗留下来的问题 —— 进程收到信号时不是立即处理的而是在合适的时候再处理其中不是立即处理我们已经解释过了而合适的时候是什么时候呢 —— 所谓合适的时候就是进程从内核态返回用户态时尝试进行信号检测与捕捉执行后面我们就会知道内核态切换成用户态时其实是一个非常好检测进程状态的一个时间点后面再谈多线程切换时也是这个时间点当然不仅限于此。 用户态和内核态 对于上图我们一定不陌生早在进程地址空间时就说过。进程如果访问的是用户空间的代码此时的状态就是用户态如果访问的是内核空间此时的状态就是内核态。我们时常需要通过系统调用访问内核系统调用是 OS 提供的方法执行 OS 的方法就可能访问 OS 中的代码和数据普通用户当然没有权限。所以在调用系统接口时系统会自动进行身份切换 user ➡ kernel。而 OS 是怎么知道现在的状态是用户态还是内核态 ? —— 因为 CPU 中有一个状态寄存器或者说权限相关的寄存器它可以表示所处的状态。我们之前说过每个用户进程都有自己的用户级页表还要说的是 OS 中也有且只有一份内核级页表。也就是说诸多进程可以通过权限提升访问同一张内核页表每个进程变成内核态时访问的就是同一份数据。所以 OS 区分是用户态还是内核态除了寄存器保存了权限相关的数据之外还要看你使用的是哪个种类的页表。
什么情况下会触发从用户态到内核态呢 —— 这里有很多种方式比如你自己写的一个 cin 程序一运行就卡在那里你摁了 abc然后程序就会拿到 abc其中本质是键盘在触发的时候被 OS 先识别到然后放在 OS 的缓冲区中而你的程序在从 OS 的缓冲区中读取。其中 OS 是通过一种中断技术这个中断指的是硬件方面的中断如 8259 中断器它是一种芯片用于管理计算机系统中的中断请求通常和 CPU 一起使用。还比如如果了解过汇编可能听说过 int 80它就是传说中系统调用接口的底层原理系统调用的底层原理就是通过指令 int 80 来中断陷入内核。还有一种比较好理解的调用系统接口后就陷入内核然后就可以执行内核代码。当然这个不用太过掌握它只需要知道从用户态到内核态是有很多种方式的。然后当从内核态返回用户态时就更简单了你调用完系统接口就返到用户态了。千言万语这里只想表达用户态到内核态是有诸多方式可以切换的。 其次这里我们只要理解用户态和内核态的权限级别不同决定了能看到的资源是不一样的。内核态的权限级别一定更高但它并不代表内核态能直接访问用户态马上展开。上面又说信号捕捉的时间点是内核态 ➡ 用户态的时候信号被处理叫做信号递达递达有忽略、默认、自定义自定义动作就叫做捕捉动作只要理解了捕捉那么忽略和默认就简单了。上图就是整个信号的捕捉过程在 CPU 执行你的代码时一定会调用系统调用系统调用当然是函数是 OS 提供的也有代码需要被执行那么应该以 “ 什么态 ” 执行 —— 实际上用户态中进程调用系统调用时必须得陷入内核以用户态身份执行执行完毕又返回用户态继续执行用户态中的代码那么问题就是可以直接以内核态的身份去执行用户态中的代码吗马上解释。此时从内核态返回到用户态之前OS 会做一系列的检测捕捉工作它会检测当前进程是否有信号需要处理如果没有就会返回系统调用如果有那就先处理 (具体它会遍历识别位图 假如信号 pending 了且没有被 block那就会执行 handler 方法比如说终止进程那就会释放这个进程如果是暂停那就不用返回系统调用然后把进程 pcb 放在暂停队列中如果是忽略那就把 pending 中对应的比特位由 1 变为 0然后返回系统调用)。所以可以看到比较难处理的是就是自定义捕捉当 3 号信号捕捉时且收到了 pending没有被 block那么就会执行用户空间中的捕捉方法换言之我们因为系统调用而陷入内核执行系统方法执行完方法后做信号检测检测到信号是自定义捕捉那么就会执行自定义捕捉的方法。此时应该以 “ 什么态 ” 执行信号捕捉方法 —— 理论上内核态是绝对可以的因为内核态的权限比用户态的权限高但实际并不能以内核态的身份去执行用户态的代码因为 OS 不相信任何人写的任何代码这样设计就很有可能让恶意用户利用导致系统不安全。所以必须是用户态执行用户空间的代码内核态执行内核空间的代码所以你是用户态要执行内核态的代码你是内核态要执行用户态的代码必须进行状态或者说权限切换。所以说信号捕捉的完整流程就是在用户区中因为中断、异常或系统调用然后切换权限陷入内核执行系统方法然后返回发现有信号需要被捕捉执行然后切换权限去执行捕捉方法然后再执行特殊的系统调用sigretum再次陷入内核然后再执行sys_sigreturn()系统调用返回用户区。注意切换到用户态执行捕捉方法后不能直接返回系统调用因为曾经执行捕捉方法时是由 OS 进入的所以必须得利用系统接口再次陷入内核最后由内核调用系统接口返回用户区。 上面的图和文字都说的太复杂了这里我们简化一下宏观来看信号的捕捉过程就是状态权限切换的过程这里的蓝点表示信号捕捉过程中状态权限切换的次数。其中完整流程就是
调用系统调用陷入内核执行完系统任务进行信号检测执行捕捉代码调用 sigturm 再次陷入内核调用 sys_sigreturn返回到用户区中系统调用点 sigaction
对于修改 handler 表的操作接口我们已经了解过 signal 了这里我们再谈谈 sigactionsigaction 相比 signal 有更多的选项不过我们只要知道它怎么用就行了因为它兼顾了实时信号。
#include signal.h
int sigaction(int signum, const struct sigaction* act, struct sigaction* oldact);
return 0 on success;on error,-1 is returned.signum:指定捕捉信号的编号.
act:如何处理信号,它是一个结构体指针,第2与第5个字段是实时信号相关的,我们不管.struct sigaction{void(*sa_handler)(int);void(*sa_sigaction)(int,siginfo_t*, void*);sigset_t sa_mask;int sa_flags;void(*sa_restorer)(void);}其中:sa_handler 是将来想怎么捕捉signum信号.sa_mask是需要额外屏蔽的信号.sa_flags是屏蔽自己的信号.
oldact:如果需要可以把老的信号捕捉方式保存,不需要则NULL.
注意这里有一个语法概念:我们从没写过函数名和类型名一样的代码出来,系统接口都这样写了,说明是没问题的,不过建议自己写的时候别这么做.sa_mask 和 sa_flags 如果正在进行 2 号信号的捕捉处理此时 OS 又向进程写了一个 2 号信号那么一定不允许在前者处理过程中立即处理后者而应该先把后者 block当把前者处理完毕再取消 block也就是说默认当一个信号在 handler 过程中另一个信号不能被 handler而应该被短暂的 block直到前者处理完毕。配图所释就是收到 1 号信号进行捕捉当捕捉时把 pending 置 0 的同时也把 block 置 1所以即使再收到 1 号信号因为它有 block所以不能被递达而前者调用 sys_sigreturn 返回时再把 block 置 0此时后者就允许被 handler但是现在前者还没返回所以后者只能下次再处理。这也是 OS 为了防止大量信号产生时导致进程频繁处理的一种策略。 当某个信号的处理函数被调用时内核会自动将当前信号加入进程的信号屏蔽字当信号处理函数返回时自动恢复原来的信号屏蔽字这样就保证了在处理某个信号时如果这种信号再次产生那么它就会被阻塞到当前直到当前处理结束为止这是sa_flags。如果在调用信号处理函数时除了当前信号被自动屏蔽之外还希望自动屏蔽另外一些信号则用sa_mask字段说明这些需要被额外屏蔽的信号当信号处理函数返回时自动恢复原来的信号屏蔽字。 通常我们要使用 sigaction理论上只需要 signum其它默认为 0 就足够了这里也可以测试一下。 测试用例 10 此时 sigaction 就同 signal实现最基本的捕捉动作。 #include iostream
#include signal.h
#include unistd.husing std::cout;
using std::endl;void handler(int signo)
{cout get a signo: signo endl;
}int main()
{//初始化结构体struct sigaction act, oact;act.sa_handler handler;act.sa_flags 0;sigemptyset(act.sa_mask);//sa_mask的类型是sigset_t,所以当然可以sigemptyset.//act.sa_restorer nullptr;//实时信号就不管了//act.sa_sigaction nullptr;sigaction(SIGINT, act, oact);while(1){cout process is running...\n endl;sleep(1);}return 0;
}测试用例10.1 测试 sa_mask 以捕捉额外 3 号信号此时运行程序因为 2 号信号被捕捉了所以一发送 2 号信号这里就会死循环的执行这里的自定义方法此时再发送 3 号信号也不会影响因为 3 号信号已经被 sa_mask 然后 sigaddset 了所以一发送 4 号信号直接终止进程。 #include iostream
#include signal.h
#include unistd.husing std::cout;
using std::endl;void handler(int signo)
{while(true){cout get a signo: signo endl;sleep(1);}
}int main()
{//初始化结构体struct sigaction act, oact;act.sa_handler handler;act.sa_flags 0;sigemptyset(act.sa_mask);//sa_mask的类型是sigset_t,所以当然可以sigemptyset.sigaddset(act.sa_mask, 3);//act.sa_restorer nullptr;//实时信号就不管了//act.sa_sigaction nullptr;sigaction(SIGINT, act, oact);while(1){cout process is running...\n endl;sleep(1);}return 0;
}补充
普通信号使用位图来保存如果把 2 号信号 block 了若连续发 10 个 2 号信号pending 位图保存一个后剩余 9 个就丢失了这里只想说明普通信号可能会丢失。而相对应的实时信号不会丢失OS 不仅会维护普通信号还会维护实时信号系统内也会存在大量的实时信号OS 也要将其管理OS 内实时信号是由struct siginfo描述的再用链表将其组织起来所以它不会丢失。实时信号几乎不使用所以就不详谈了。这里只想说普通信号是由位图来维护的允许丢失同时也存在不允许丢失的实时信号。
八、可重入函数 测试用例 11 #include iostream
#include signal.h
#include unistd.husing std::cout;
using std::endl;void show(int signo)
{int i 0;while(i 10){cout get a signo: signo endl;i;sleep(1); }
}void handler(int signo)
{show(signo);
}int main()
{struct sigaction act, oact;act.sa_handler handler;act.sa_flags 0;sigemptyset(act.sa_mask);sigaddset(act.sa_mask, 3);//act.sa_restorer nullptr;//act.sa_sigaction nullptr;sigaction(SIGINT, act, oact);show(9999);return 0;
}可重入函数描述的是一种执行现象。假设 signal 或 sigaction 执行捕捉动作时调用 show 函数而 main 函数内也调用 show 函数就有可能出现 main 函数正在调用 show 函数时10 秒内正好来了个信号然后陷入内核并且捕捉信号然后也执行 show 函数。可以看到现象程序一运行main 函数执行 show 函数发送 2 号信号后执行信号捕捉执行 show 函数然后又回到 main 函数执行 show 函数。在多进程时我们都知道当然有可能一个函数被多个执行流同时进入执行而今天在信号这里main 执行流在执行 show 函数突然捕捉执行流也进到这个函数了此时函数就被多个执行流同时进入的情况叫做重入函数。 这样当然有问题比如下面链表的例子我们把头插封装成 insert 函数。进程中 main 函数刚执行完 insert 函数中 p-next head 时突然收到并执行捕捉信号 sighandler其中又调用 insert 函数执行完代码然后返回 main 函数执行还未执行的 head p本来 head 指向 node2最后 head 指向 node1此时 node2 就会造成内存泄漏。 所以上面的代码当然也有问题所以一旦多个执行流同时执行一个函数时若访问是不安全的叫做不可重入函数相反访问是安全的就叫做可重入函数。也就是说不可重入函数和可重入函数在多执行流下是否会出问题。很不幸我们现在所学的大部分函数都是不可重入的比如 STL 容器等不可重入函数一般都是函数内使用了一些全局的空间比如堆空间等。所以可重入函数是少之又少。
八、volatile
volatile是属于 C 语言中的关键字也叫做易变关键字(被它修饰后的变量就是告诉编译器这个变量是易变的)。不记得也没关系因为在此之前我们也压根没使用它它的作用是保持内存的可见性。 测试用例 12 这里给一个全局标志位 flag利用 flag 让程序死循环执行此时就可以通过信号捕捉在捕捉方法中改变 flag 的值然后结束死循环。可以看到 gcc 和 g 它们的运行现象是一样的。 C #include stdio.h
#include signal.hint flag 0;void handler(int signo)
{flag 1;printf(handler signo: %d, set flag 1\n, signo);
}int main()
{signal(2, handler);while(!flag);printf(process end...\n);return 0;
}C #include iostream
#include signal.hint flag 0;void handler(int signo)
{flag 1;cout handler signo: signo , set flag 1 endl;
}int main()
{signal(2, handler);while(!flag);cout process end... endl;
}上面可以看到 main 函数中没有更改 flag 的任何操作所以可能会被优化所以 flag 一变化不会立马被检测到。这里也看到了默认 gcc 和 g 并没有优化这段代码所以 flag 一变化立马就被检测到。其实 gcc 和 g 中有很多优化级别man gcc 文档筛选后就可以看到 gcc 有 -O0/1/2/3 等优化级别gcc -O0 表示不会优化代码。经过验证(注意这里不同平台结果可能不一样) gcc 在 -O0 时不会作优化处理此时同上默认进程一收到信号进程就终止了。 gcc 在 -O1 等时会作优化处理此时发现 flag 已经置为 1 了但是进程并没有终止。 因为这里主执行流下并没有对 flag 的修改操作所以 gcc -O1 在优化时可能会将局部变量 flag 优化成寄存器变量定义 flag 时一定会在内存开辟空间。此时 gcc 在编译时发现以 flag 作为死循环条件且主执行流中没有对 flag 修改的操作所以就把 flag 优化成寄存器变量。一般默认情况没有优化级时gcc -O0 while 循环检测的是内存中的变量而在优化情况下 gcc -O1 会将内存中的变量优化到寄存器中然后 while 循环检测时只检测寄存器中 flag 的值当执行信号捕捉代码时flag 1 又只会对内存修改而此时 wihle 循环只检测寄存器中的 flag 0。所以短暂的出现了内存数据和寄存器数据不一致的现象然后就出现了好像把 flag 改了但是 while 循环又不退出的现象。因为要减少代码体积和提高效率所以在优化时需要优化成寄存器变量。而这个优化还有些问题。 测试用例 13 所以在 gcc -O1 优化时还需要加上 volatile此时就告诉编译器不要把 flag 优化到寄存器上每次检测必须把 flag 从内存读到寄存器中然后再检测不要因为寄存器而干扰 while 循环的判断。这就叫做保持内存的可见性。所以 volatile 为什么要在系统这里谈当然语言也不是不可以谈无非就是通过反汇编拿到对应的指令做对比有兴趣可以看看 《蛋哥 C 语言深度解剖》。其次这里就不测试 C 下的 volatile 了。 #include stdio.h
#include signal.hvolatile int flag 0;void handler(int signo)
{flag 1;printf(handler signo: %d, set flag 1\n, signo);
}int main()
{signal(2, handler);while(!flag);printf(process end...\n);return 0;
}九、SIGCHLD 信号 - 了解
SIGCHLD 是第 17 号信号。在进程控制中说过子进程退出时父进程可以通过 wait/waitpid 来等待子进程并回收相关资源以免造成僵尸进程而父进程可以通过阻塞或非阻塞轮询来检测子进程的状态前者父进程什么也做不了后者父进程需要不断的去检测两者都比较麻烦且都是父进程主动的。这里要介绍的 SIGCHLD 就是第三种方案其实在子进程退出时会主动向父进程发送17)sigchld因为它的默认动作是什么都不做所以让父进程在这里进行信号捕捉。 测试用例 14 #include iostream
#include signal.h
#include stdlib.h
#include unistd.husing std::cout;
using std::endl;void handler(int signo)
{cout father process... getpid() getppid() signo: signo endl;
}int main()
{signal(SIGCHLD, handler);if(fork() 0){//child int count 7;while(count){cout child process... getpid() getppid() count: count endl;count--;sleep(1);}cout child quit...! endl;exit(0);}//father//sleep(10);int ret sleep(10);cout ret: ret endl;return 0;
}测试用例 15 #include stdio.h
#include stdlib.h
#include signal.hvoid handler(int sig)
{pid_t id;while((id waitpid(-1, NULL, WNOHANG)) 0){printf(wait child success: %d\n, id); } printf(child is quit! %d\n, getpid());
}int main()
{signal(SIGCHLD, handler);pid_t cid;if((cid fork()) 0){//childprintf(child: %d\n, getpid());sleep(3);exit(1); }while(1){printf(father proc is doing some thing!\n);sleep(1); }return 0;
}事实上由于 UNIX 的历史原因要想不产生僵尸进程还有另外一种办法:父进程调用 sigaction 将 SIGCHLD 的处理动作置为 SIG_IGN这样 fork 出来的子进程在终止时会自动清理掉不会产生僵尸进程也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的但这是一个特例。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。