✨✨✨学习的道路很枯燥,希望我们能并肩走下来!
文章目录
前言
本篇详细介绍了进一步介绍Linux的进程间通信,让使用者有更加深刻的认知,而不是仅仅停留在表面,更好的模拟,为了更好的使用. 文章可能出现错误,如有请在评论区指正,让我们一起交流,共同进步!
一 信号快速认识
1.1 生活角度的信号
• 你在⽹上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临 时,你该怎么处理快递。也就是你能“识别快递”
• 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。 那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的⾏为 并不是⼀定要⽴即执⾏,可以理解成“在合适的时候去取”。
• 在收到通知,再到你拿到快递期间,是有⼀个时间窗⼝的,在这段时间,你并没有拿到快递,但是 你知道有⼀个快递已经来了。本质上是你“记住了有⼀个快递要去取”
• 当你时间合适,顺利拿到快递之后,就要开始处理快递了。⽽处理快递⼀般⽅式有三种:1.执⾏默 认动作(幸福的打开快递,使⽤商品)2.执⾏⾃定义动作(快递是零⻝,你要送给你你的⼥朋友) 3. 忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)
• 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
1.2 技术应用角度的信号
1.2.1 样例代码
// sig.cc
#include <iostream>
#include <unistd.h>
int main()
{
while (true) {
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
$ g++ sig.cc - o sig
$ . / sig
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C
• ⽤⼾输⼊命令,在Shell下启动⼀个前台进程
• ⽤⼾按下 程 Ctrl+C ,这个键盘输⼊产⽣⼀个硬件中断,被OS获取,解释成信号,发送给目标前台进程
• 前台进程因为收到信号,进⽽引起进程退出(退出前台进程)
1.2.2 系统函数signal
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
std::cout << "我是: " << getpid() << ",我获得了⼀个信号: " << signumber << endl;
}
int main()
{
std::cout << "我是进程: " << getpid() << std::endl;
signal(SIGINT/*2*/, handler);
while (true) {
std::cout << "I am a process, I am waiting signal!" << std::endl;
sleep(1);
}
}
注意:
**• 要注意的是,signal函数仅仅是设置了特定信号的捕捉⾏为处理⽅式,并不是直接调⽤处 理动作。如果后续特定信号没有产⽣,设置的捕捉函数永远也不会被调⽤!! **
• Ctrl+C 产⽣的信号只能发给前台进程。⼀个命令后⾯加个&可以放到后台运⾏,这样 Shell不必等待进程结束就可以接受新的命令,启动新的进程。
• Shell可以同时运⾏⼀个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl+C 这种控制键产⽣的信号。
• 前台进程在运⾏过程中⽤⼾随时可能按下 Ctrl-C Ctrl-C ⽽产⽣⼀个信号,也就是说该进程的⽤⼾空间代码执⾏到任何地⽅都有可能收到 SIGINT 信号⽽终⽌,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
• 关于进程间关系,我们在⽹络部分会专⻔来讲,现在就了解即可。
• 可以渗透&和nohup
1.3 信号概念
**信号是进程之间事件异步通知的⼀种⽅式,属于软中断。 **
1.3.1 查看信号
每个信号都有⼀个编号和⼀个宏定义名称,这些宏定义可以在signal.h中找到,例如其中有定义
#define SIGINT 2
编号34以上的是实时信号,本章只讨论编号34以下的信号,不讨论实时信号。这些信号各⾃在什么条件 下产⽣,默认的处理动作是什么,在signal(7)中都有详细说明: man 7 signal
1.3.2 信号处理
(sigaction函数稍后详细介绍),可选的处理动作有以下三种:
1. 忽略此信号
2. 执⾏该信号的默认处理动作
**3. 提供⼀个信号处理函数,要求内核在处理该信号时切换到⽤⼾态执⾏这个处理函数,这种⽅式称为⾃定义捕捉(Catch)⼀个信号。 **
注意看源码:
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
/* Type of a signal handler. */
typedef void (*__sighandler_t) (int);
// 其实SIG_DFL和SIG_IGN就是把0,1强转为函数指针类型
二 产生信号
2.1 通过终端按键产⽣信号
2.1.1 基本操作
• Ctrl+C(SIGINT) 已经验证过,这⾥不再重复
• Ctrl+\(SIGQUIT)可以发送终⽌信号 并⽣成coredump⽂件,⽤于事后调试(后⾯详谈)
• Ctrl+Z(SIGTSTP)可以发送停⽌信号,将当前前台进程挂起到后台等。
2.1.2 理解OS如何得知键盘有数据
** 2.1.3 初步理解信号起源**
注意:
• 信号其实是从纯软件⻆度,模拟硬件中断的⾏为
• 只不过硬件中断是发给CPU,⽽信号是发给进程
• 两者有相似性,但是层级不同,这点我们后⾯的感觉会更加明显
2.2 调⽤系统命令向进程发信号
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
while (true) {
sleep(1);
}
}
首先在后台执⾏死循环程序,然后⽤kill命令给它发SIGSEGV信号。
2.3 使⽤函数产⽣信号
2.3.1 kill
kill命令是调用kill函数执行的,kill函数可以给⼀个指定的进程发送指定的信号。
2.3.2 raise
**raise 函数可以给当前进程发送指定的信号(自己给自己发信号)。 **
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handler(int signumber)
{
// 整个代码就只有这⼀处打印
std::cout << "获取了⼀个信号: " << signumber << std::endl;
}
int main()
{
signal(2, handler); // 先对2号信号进⾏捕捉
// 每隔1S,⾃⼰给⾃⼰发送2号信号
while (true)
{
sleep(1);
raise(2);
}
}
2.3.3 abort
**abort 函数使当前进程接收到信号⽽异常终⽌。 **
2.4 由软件条件产⽣信号
SIGPIPE 是⼀种由软件条件产⽣的信号,在“管道”中已经介绍过了。本节主要介绍 alarm 函数 和 SIGALRM 信号。
2.4.1 基本alarm验证-体会IO效率问题
程序的作⽤是1秒钟之内不停地数数,1秒钟到了就被SIGALRM信号终⽌。
必要的时候,对SIGALRM信号进⾏捕捉
// IO 多
#include <iostream>
#include <unistd.h>
#include <signal.h>
int main()
{
int count = 0;
alarm(1);
while (true)
{
std::cout << "count : "<< count << std::endl;
count++;
}
return 0;
}
// IO 少
#include <iostream>
#include <unistd.h>
#include <signal.h>
int count = 0;
void handler(int signumber)
{
std::cout << "count : " <<
count << std::endl;
exit(0);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (true)
{
count++;
}
return 0;
}
2.4.2 设置重复闹钟
代码样例
#include <iostream>
#include <unistd.h>
#include <signal.h>
#include <vector>
#include <functional>
using func_t = std::function<void()>;
int gcount = 0;
std::vector<func_t> gfuncs;
// 把信号更换成为硬件中断
void hanlder(int signo)
{
for (auto& f : gfuncs)
{
f();
}
std::cout << "gcount : " << gcount << std::endl;
int n = alarm(1); // 重设闹钟,会返回上⼀次闹钟的剩余时间
std::cout << "剩余时间: " << n << std::endl;
}
int main()
{
//gfuncs.push_back([](){ std::cout << "我是⼀个内核刷新操作" << std::endl; });
//gfuncs.push_back([](){ std::cout << "我是⼀个检测进程时间⽚的操作,如果时间⽚到了,我会切换进程" << std::endl; });
//gfuncs.push_back([](){ std::cout << "我是⼀个内存管理操作,定期清理操作系统内部的内存碎⽚" << std::endl; });
alarm(1); // ⼀次性的闹钟,超时alarm会⾃动被取消
signal(SIGALRM, hanlder);
while (true)
{
pause(); //接收到信号开始停止暂停
std::cout << "我醒来了..." << std::endl;
gcount++;
}
}
2.4.3 如何理解软件条件
在操作系统中,信号的软件条件指的是由软件内部状态或特定软件操作触发的信号产⽣机制。这些条件包括但不限于定时器超时(如alarm函数设定的时间到达)、软件异常(如向已关闭的管道写数据 产⽣的SIGPIPE信号)等。当这些软件条件满⾜时,操作系统会向相关进程发送相应的信号,以通知 进程进⾏相应的处理。简⽽⾔之,**软件条件是因操作系统内部或外部软件操作⽽触发的信号产⽣。 **
2.4.4 如何简单快速理解系统闹钟
系统闹钟,其实本质是OS必须⾃⾝具有定时功能,并能让⽤⼾设置这种定时功能,才可能实现闹钟这样的技术。
现代Linux是提供了定时功能的,定时器也要被管理:先描述,在组织。内核中的定时器数据结构是:
struct timer_list
{
struct list_head entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
struct tvec_t_base_s* base;
};
2.5 硬件异常产⽣信号
硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前 进程执⾏了除以0的指令,CPU的运算单元会产⽣异常,内核将这个异常解释为SIGFPE信号发送给进 程。再⽐如当前进程访问了⾮法内存地址,MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送 给进程。
2.5.1 除以0
2.5.2 野指针
虚拟到物理的映射出错误
**由此可以确认,我们在C/C++当中除零,内存越界等异常,在系统层⾯上,是被当成信号处理的。 **
2.5.3 ⼦进程退出core dump
2.5.4 Core Dump
2-6 总结思考
上⾯所说的所有信号产⽣,最终都要有OS来进⾏执⾏,为什么?OS是进程的管理者
信号的处理是否是⽴即处理的?在合适的时候
信号如果不是被⽴即处理,那么信号是否需要暂时被进程记录下来?记录在哪⾥最合适呢?
⼀个进程在没有收到信号的时候,能否能知道,⾃⼰应该对合法信号作何处理呢?
如何理解OS向进程发送信号?能否描述⼀下完整的发送处理过程?
三 保存信号
3.1 信号其他相关常⻅概念
3.2 在内核中的表示
3.3 sigset_t
从上图来看,每个信号只有⼀个bit的未决标志,⾮0即1,不记录该信号产⽣了多少次,阻塞标志也是这样 表⽰的。因此,未决和阻塞标志可以⽤相同的数据类型sigset_t来存储, **sigset_t称为信号集** , 这个类型 可以表⽰每个信号的“有效”或“⽆效”状态,在阻塞信号集中“有效”和“⽆效”的含义是该信号 是否被阻塞,⽽在未决信号集中“有效”和“⽆效”的含义是该信号是否处于未决状态。下⼀节将详 细介绍信号集的各种操作。阻塞信号集也叫做当前进程的 **信号屏蔽字(SignalMask),** 应该理解为阻塞⽽不是忽略。
3.4 信号集操作函数
sigset_t类型对于每种信号⽤⼀个bit表⽰“有效”或“⽆效”状态,⾄于这个类型内部如何存储这些 bit则依赖于系统实现,从使⽤者的⻆度是不必关⼼的,使⽤者只能调⽤以下函数来操作sigset_t变量, ⽽不应该对它的内部数据做任何解释,⽐如⽤printf直接打印sigset_t变量是没有意义的。
函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表⽰该信号集不包含任何有效信号。 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表⽰该信号集的有效信号 包括系统⽀持的所有信号。 **注意,在使⽤sigset_t类型的变量之前,⼀定要调⽤sigemptyset或sigfillset做初始化,使信号集处于确定的状态**。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删 除某种有效信号。
**这四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有效信 号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。 **
3.4.1 sigprocmask
调⽤函数sigprocmask可以读取或更改进程的**信号屏蔽字(阻塞信号集)。——block **
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
返回值: 若成功则为0,若出错则为-1
如果oset是⾮空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是⾮空指针,则更改进程的信号屏蔽字,参数how指⽰如何更改。如果oset和set都是⾮空指针,则先将原来的信号屏蔽字 备份到oset⾥,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了 how参数的可选值
如果调⽤sigprocmask解除了对当前若⼲个未决信号的阻塞,则在sigprocmask返回前,⾄少将其中⼀个信号递达。
3.4.2 sigpending
四 捕捉信号
4-1 信号捕捉的流程
** 如果信号的处理动作是⽤⼾⾃定义函数,在信号递达时就调⽤这个函数,这称为捕捉信号**。
由于信号处理函数的代码是在⽤⼾空间的,处理过程⽐较复杂,举例如下:
• ⽤⼾程序注册了 SIGQUIT 信号的处理函数 sighandler 。
• 当前正在执⾏ main 函数,这时发⽣中断或异常切换到内核态。
• 在中断处理完毕后要返回⽤⼾态的 main 函数之前检查到有信号SIGQUIT 递达。
• 内核决定返回⽤⼾态后不是恢复 main 函数的上下⽂继续执⾏,⽽是执⾏ sighandler 函数, , sighandler 和 main 函数使⽤不同的堆栈空间,它们之间不存在调⽤和被调⽤的关系,是两个 独⽴的控制流程。
• sighandler 函数返回后⾃动执⾏特殊的系统调⽤ sigreturn 再次进⼊内核态。
• 如果没有新的信号要递达,这次再返回⽤⼾态就是恢复 main 函数的上下⽂继续执⾏了。
4.2 sigaction
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
当某个信号的处理函数被调⽤时,内核⾃动将当前信号加⼊进程的信号屏蔽字,当信号处理函数返回时⾃ 动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产⽣,那么它会被阻塞到 当前处理结束为⽌。如果在调⽤信号处理函数时,除了当前信号被⾃动屏蔽之外,还希望⾃动屏蔽另外⼀ 些信号,则⽤sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时⾃动恢复原来的信号屏蔽字。sa_flags字段包含⼀些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函 数,本章不详细解释这两个字段,有兴趣的同学可以在了解⼀下。
4-3 穿插话题-操作系统是怎么运⾏的
4-3-1 硬件中断
• 中断向量表就是操作系统的⼀部分,启动就加载到内存中了
• 通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询
• 由外部设备触发的,中断系统运⾏流程,叫做硬件中断
4.3.2 时钟中断
问题:
• 进程可以在操作系统的指挥下,被调度,被执⾏,那么操作系统⾃⼰被谁指挥,被谁推动执⾏呢?
• 外部设备可以触发硬件中断,但是这个是需要⽤⼾或者设备⾃⼰触发,有没有⾃⼰可以定期触发的 设备?
这样,操作系统不就在硬件的推动下,⾃动调度了么!!!
4.3.3 死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统⾃⼰不做任何事情,需要什么功能,就向中 断向量表⾥⾯添加⽅法即可.**操作系统的本质:就是⼀个死循环! **
这样,操作系统,就可以在硬件时钟的推动下,⾃动调度了.
所以,什么是时间⽚?CPU为什么会有主频?为什么主频越快,CPU越快?
主频:发送多少次时钟中断
4.3.4 软中断
上述外部硬件中断,需要硬件设备触发。
有没有可能,因为软件原因,也触发上⾯的逻辑?有!
为了让操作系统⽀持进⾏系统调⽤,**CPU也设计了对应的汇编指令(int或者syscall),可以让CPU内 部触发中断逻辑。 **
cpu获取中断号找到系统调用,再根据系统调用号找到对应的方法
问题:
• ⽤⼾层怎么把系统调⽤号给操作系统?-寄存器(⽐如EAX)
• 操作系统怎么把返回值给⽤⼾?-寄存器或者⽤⼾传⼊的缓冲区地址
• 系统调⽤的过程,其实就是先int0x80、syscall陷⼊内核,本质就是触发软中断,CPU就会⾃动执 ⾏系统调⽤的处理⽅法,⽽这个⽅法会根据系统调⽤号,⾃动查表,执⾏对应的⽅法
• 系统调⽤号的本质:数组下标!
可是为什么我们⽤的系统调⽤,从来没有⻅过什么** int 0x80** 或者 syscall呢?都是直接调⽤上层的函数的啊?
那是因为Linux的gnuC标准库,给我们把⼏乎所有的系统调⽤全部封装了。
4.3.5 缺⻚中断?内存碎⽚处理?除零野指针错误?
** 缺⻚中断?内存碎⽚处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后⾛中断处理例程,完成所有处理。**有的是进⾏申请内存,填充⻚表,进⾏映射的。有的是⽤来 处理内存碎⽚的,有的是⽤来给⽬标进⾏发送信号,杀掉进程等等。
**所以: **
• 操作系统就是躺在中断处理例程上的代码块!
• CPU内部的软中断,⽐如int0x80或者syscall,我们叫做陷阱
• CPU内部的软中断,⽐如除零/野指针等,我们叫做异常。(所以,能理解“缺⻚异常” 为什么这么叫了吗?)
4.4 如何理解内核态和用户态
结论:
• 操作系统⽆论怎么切换进程,都能找到同⼀个操作系统!换句话说操作系统系统调⽤⽅法的执⾏, 是在进程的地址空间中执⾏的!
• 关于特权级别,涉及到段,段描述符,段选择⼦,DPL,CPL,RPL等概念,⽽现在芯⽚为了保证 兼容性,已经⾮常复杂了,进⽽导致OS也必须得照顾它的复杂性,这块我们不做深究了。
• ⽤⼾态就是执⾏⽤⼾[0,3]GB时所处的状态
• 内核态就是执⾏内核[3,4]GB时所处的状态
• 区分就是按照CPU内的CPL决定,CPL的全称是CurrentPrivilegeLevel,即当前特权级别。
• ⼀般执⾏ int 0x80 或者 syscall 软中断,CPL会在校验之后⾃动变更
总结:
**1. 用户如何进入内核态呢? **
**2. 操作系统是如何运行的 **
操作系统刚启动时,没有任务的话,操作系统就在自己的死循环里pause,整个系统就暂停了,cpu也处于闲置的状态,当操作系统内有进程时,且进程不退出,操作系统停止死循环,因为CPU一直在调度,如果有系统调用,就通过中断的方式,进入当前进程的3到4GB,进行系统调用,执行完返回再调度,如果有很多进程,操作系统忙着一直调度其他进程,不会进入到自己的死循环
操作系统会定期进行扫描,刷新内核缓冲区等等,操作系统在创建的时候,会创建一批子进程,这些进程的任务就是定期把操作系统内的各种数据刷新,检测各种文件的缓冲区是否要刷新,如果有,操作系统调度它来刷新,这些进程被称为内核固定历程
五 可重入函数
• main函数调⽤insert函数向⼀个链表head中插⼊节点node1,插⼊操作分为两步,刚做完第⼀步的 时候,因为硬件中断使进程切换到内核,再次回⽤⼾态之前检查到有信号待处理,于是切换到 sighandler函数,sighandler也调⽤insert函数向同⼀个链表head中插⼊节点node2,插⼊操作的 两步都做完之后从sighandler返回内核态,再次回到⽤⼾态就从main函数调⽤的insert函数中继续 往下执⾏,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后向 链表中插⼊两个节点,⽽最后只有⼀个节点真正插⼊链表中了。
• 像上例这样,insert函数被不同的控制流程调⽤,有可能在第⼀次调⽤还没返回时就再次进⼊该函 数,这称为重⼊,insert函数访问⼀个全局链表,有可能因为重⼊⽽造成错乱,像这样的函数称为不可 重⼊函数,反之,如果⼀个函数只访问⾃⼰的局部变量或参数,则称为可重⼊(Reentrant)函数。想⼀ 下,为什么两个不同的控制流程调⽤同⼀个函数,访问它的同⼀个局部变量或参数就不会造成错乱?
**如果⼀个函数符合以下条件之⼀则是不可重⼊的: **
• 调⽤了malloc或free,因为malloc也是用全局链表来管理堆的。
• 调⽤了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
ps:
使用全局资源的为不可重入函数
可重入函数:如果一个函数不使用任何全局资源,所使用的变量资源全都是函数内部临时资源的
_r 一般可代表可重入
六 volatile
• volatile 作⽤:保持内存的可⻅性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进⾏操作
七 SIGCHLD信号(了解)
进程⼀章讲过⽤wait和waitpid函数清理僵⼫进程,⽗进程可以阻塞等待⼦进程结束,也可以⾮阻塞地查 询是否有⼦进程结束等待清理(也就是轮询的⽅式)。采⽤第⼀种⽅式,⽗进程阻塞了就不能处理⾃⼰的 ⼯作了;采⽤第⼆种⽅式,⽗进程在处理⾃⼰的⼯作的同时还要记得时不时地轮询⼀下,程序实现复杂。
其实,⼦进程在终⽌时会给⽗进程发SIGCHLD信号,该信号的默认处理动作是忽略,⽗进程可以⾃定义 SIGCHLD信号的处理函数,这样⽗进程只需专⼼处理⾃⼰的⼯作,不必关⼼⼦进程了,⼦进程终⽌时会通 知⽗进程,⽗进程在信号处理函数中调⽤wait清理⼦进程即可。
事实上,由于UNIX的历史原因,要想不产⽣僵⼫进程还有另外⼀种办法**:⽗进程调⽤sigaction将 SIGCHLD的处理动作置为SIG_IGN,这样fork出来的⼦进程在终⽌时会⾃动清理掉,不会产⽣僵⼫进程,也不会通知⽗进程**。系统默认的忽略动作和⽤⼾⽤sigaction函数⾃定义的忽略通常是没有区别的,但这 是⼀个特例。此⽅法对于Linux可⽤,但不保证在其它UNIX系统上都可⽤。
总结
✨✨✨各位读友,本篇分享到内容是否更好的让你理解进程间信号,如果对你有帮助给个👍赞鼓励一下吧!!
🎉🎉🎉世上没有绝望的处境,只有对处境绝望的人。
感谢每一位一起走到这的伙伴,我们可以一起交流进步!!!一起加油吧!!
版权归原作者 Chris-zz 所有, 如有侵权,请联系我们删除。