【Linux】进程信号(质变)—— 信号捕捉 | 中断 | 内核态

🌈欢迎来到Linux专栏 ~~ 进程信号

Linux 信号

🔥捕捉信号

处理信号,立即处理吗?我可能正在做优先级更高的事情,不会立即处理?什么时候?
适的时候。

🎉信号捕捉的流程

正面回答,信号什么时候处理? 怎么处理 ??

  • 进程调度 的时候,从内核态返回用户态的时候,会进行信号的检测和处理

那用户态和内核态是什么?

  • 用户态 :进程执行代码,访问数据,都在访问[0,3G]地址空间的时候,就是访问用户自己的代码,自己的数据
  • 内核态 :都在访问[3G,4G]地址空间的时候,就是访问OS的过程,内核态的权限级别更高,通过系统调用访问

上述如果是执行自定义函数,会执行do_signal(),根据handler表去跳转到指定的函数处

那么执行信号捕捉方法的时候,是以什么身份去执行的呢?

  • 用户态!! 理论上内核态是可以的,但是系统不允许你这样做。因为内核态的权限更高,如果自定义函数里有excel("rm"),会导致越权操作(出现安全漏洞);所以必须以用户态身份去执行

大体流程:

通过系统调用 ,由用户态进入到内核态,内核态把系统调用执行完,返回时会做信号检查 ,查进程的三张表(发现信号没有被block,需要被递达的自定义捕捉),跳转到用户空间 (用内核态转变成用户态)执行handler方法,执行完毕调用sigreturn进入内核,最后返回到用户态曾经执行的位置继续执行。

信号自定义捕捉的速记流程如下:

有四个点代表 状态的转变 ,其中信号检查 在直线下

🎉sigaction

sigaction 也可以用户自定义动作,比 signal 功能更丰富

cpp 复制代码
#include <signal.h>

int sigaction(int signum, const struct sigaction *act,
                     struct sigaction *oldact);

struct sigaction 
{
	void     (*sa_handler)(int);	//自定义动作 ~ 类似与handler表
	void     (*sa_sigaction)(int, siginfo_t *, void *);	//实时信号相关,不用管
	sigset_t   sa_mask;	//待屏蔽的信号集
	int        sa_flags;	//一些选项,一般设为 0
	void     (*sa_restorer)(void);	//实时信号相关,不用管
};

返回值:成功返回 0,失败返回 -1 并将错误码设置

参数1:待操作的信号

参数2:sigaction 结构体,具体成员如上所示

参数3:保存修改前进程的 老sigaction 结构体信息

重点落在sigaction 结构体 上,其中部分字段不需要管,因为那些是与 实时信号 相关的,我们这里不讨论;重点可以看看 sa_mask 字段

sa_mask:当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字

PS:如果要做测试,尽量将struct sigaction都做好初始化,否则会出现未定义错误

那如何证明呢?

为什么要这样做

🎉穿插话题-操作系统是怎么运行的

先来回顾一下冯诺依曼:外设和cpu之间会存在控制信号与中断进行直接交互的

🌋硬件中断

中断 = CPU在正常执行程序过程中,被一个事件打断,转去执行特定处理程序的一种机制

  • 外设 → 中断控制器 → CPU(得知,获取中断号,保护现场) → 查表(IDT)→ 执行中断服务程序 → 恢复现场

cpu的背面:

真实情况: 当键盘被按下时,会产生一个电信号 发送到中断控制器 ,中断控制器对多个中断源进行管理,并向CPU发出中断请求信号(只用一根线通知CPU:有中断了! )。CPU响应后,再由中断控制器提供中断号,CPU根据中断号查表确定具体是哪个设备。

我们可以把中断向量表(IDT)想象成一个函数指针数组 :必然存在下标(这里我们"认为"中断号 == 下标)

cpp 复制代码
typedef void(*handler_t)(void)
handler_t IDT[NUMS];
  • 当我们要处理中断任务时,也是需要cpu内部的寄存器资源的,所以出现中断时,cpu要把当前进程的寄存器数据保护起来(压入当前进程的栈) ------ 现场保护
  • 当把中断任务完成后,把曾经保存进程的上下文数据弹栈,恢复到各个寄存器内 ------ 恢复现场 继续执行之前的工作

硬件中断 :暂停cpu正在执行的任务,处理硬件突发时间,结合中断号(硬件提供) 和中断向量表(OS提供)

上诉中断是否与信号的handler表很相似?

在现实世界中,先有硬件中断,由硬件完成处理,后来发现,进程也需要类似的机制,发明了信号机制

信号机制,是用纯软件的方式,模拟中断完成特定的任务处理的

信号、硬件中断 原理类似,但是本质完全不同

👇🏻结论:

  • 中断向量表就是操作系统的一部分,启动就加载到内存中了
  • 通过外部硬件中断(外设准备好,通知OS),操作系统就不需要对外设进行任何周期性的检测或者轮询
  • 由外部设备触发的,中断系统运行流程,叫做硬件中断
cpp 复制代码
void trap_init(void)
{
	int i;
	set_trap_gate(5,&键盘处理);//中断:键盘处理的方法绑定到中断号5
	set_trap_gate(0,&divide_error);
	// 设置除操作出错的中断向量值。以下雷同。
	set_trap_gate(1,&debug); 
	set_trap_gate(2,&nmi);
	set_system_gate(3,&int3);
	set_system_gate(4,&overflow);
}

可以把中断向量表理解成:每个 16 字节被拆成多个字段。

cpp 复制代码
unsigned long long desc_table[256];

因为中断向量表不单单要保存地址,还有可能保存很多设定(中断是否开启、中断优先级等等)

⛳时钟中断

✅时钟中断是硬件中断中最特殊、最核心的一种它周期性地向 CPU 发送中断请求,是 OS 唯一能主动、定期从用户进程手中夺回 CPU 控制权的机制。

外设有没有可能以固定的频率(1ms)给CPU发送属于它自己的中断 ,cpu要每一段时间固定的执行对应的方法do_timer()

cpp 复制代码
do_timer()
{
	/执行进程调度、时间片检测、切换、调度等动作!
}

这样是不是就每隔1ms,执行调度工作? ------ 这活不是OS干的活吗? ------ OS也是软件!是谁让OS运行的?

  • 中断本身属于OS的一部分 ,当给定固定频率触发中断时(外部晶振 ),cpu固定执行对应的do_timer() ~ 相当于操作系统的入口 ,就是定期执行操作系统
  • 也就说IDT属于操作系统的一部分,所以cpu可以通过中断的方式,定期的执行IDT中指定的方法
  • 相当于每隔一定的时间,就会叫OS跑起来
cpp 复制代码
// Linux 内核0.11
// main.c
sched_init();// 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)

// 调度程序的初始化⼦程序。
void sched_init(void)
{
	...
	set_intr_gate(0x20, &timer_interrupt);
	// 修改中断控制器屏蔽码,允许时钟中断。
	outb(inb_p(0x21) & ~0x01, 0x21);
	// 设置系统调⽤中断⻔。
	set_system_gate(0x80, &system_call);
	...
}
// system_call.s
_timer_interrupt:
...
;
// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
	call _do_timer ;
// 'do_timer(long CPL)' does everything from
// 调度⼊⼝
void do_timer(long cpl)
{
	...
	schedule();
}
void schedule(void)
{
	...
	switch_to(next);// 切换到任务号为next 的任务,并运⾏之。
}

其逻辑线是

下面先回答以下的问题:

1️⃣什么叫做时间片?

在我们进程中的定义:时间片本质就是一个计数器 ,当计数器为0时,就会进行调度schedule()

  • 因为时钟中断是固定时间间隔的(1ms),假如把计数器设为10,只要计数器不为 0,都只是对计数器进行--,直到减到 0,执行调度函数
cpp 复制代码
if((--current -> counter) > 0) //时间片没有被耗完 ------ return
	return;
current -> counter = 0;	
schedule();

此时我们要区分清楚中断处理进程运行 的区别

2️⃣什么叫做时间片耗尽?

不就是当前进程的counter计数器变为0了,接着调度其他进程

3️⃣为什么OS能计算时间?

前提是触发滴答的频率是一样的 ~ 如 1ms

  • 我们把触发一次时钟晶振,叫做时钟滴答long long tickts = 0;,所以可以把开机后触发了多少次的时钟中断次数记录下来
  • 电脑关机后,仍有纽扣电池持续的给主板上的计时单元供电,所以开机后是可以知道现在的时间的(可转化成时间戳
  • 开机后每隔1ms就记录一次,不就可以计算时间了吗?开机起始时间 + 滴答时间 = 现在的时间

4️⃣OS凭什么执行它的调度算法?
答:固定时间间隔的时钟中断!

整个操作系统最核心的是时钟中断 ,正是有了它,才可以让进程在合理的时间片中跑,检测时间片 ------ 做调度;只要进程调度起来了,进程才可以调用系统调用去把OS的功能调用起来

  • OS之所以能执行调度算法,是因为CPU在中断或系统调用时被硬件强制切换到内核态(更多权限),并跳转到内核代码,从而让OS获得执行权。

小细节:为什么要把时间源放在外设呢?太慢了------ 走很多硬件电路

  • 当代计算机把时钟源集成到cpu的内部,走线变短了
😈死循环

操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中

断向量表里面添加方法即可,操作系统的本质:就是⼀个死循环!

cpp 复制代码
void main(void)
	/* 这⾥确实是void,并没错。 */
	{
	/* 在startup 程序(head.s)中就是这样假设的。 */
	...
	/*
	* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
	* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
	* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
	* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
	* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
	*/
	for (;;) //死循环
		pause();
}
  • OS是如何运行的? ------ 时间源 + 时钟中断 = 硬件中断
  • OS 本质上是一个 "中断驱动" 的系统,而周期性的时钟中断,是 OS 能主动掌控 CPU、实现多任务管理和资源调度的唯一核心抓手,稳定的时间源则是这一切的硬件基础。
  • 所以,什么是时间片?CPU为什么会有主频?为什么主频越快,CPU越快?主频可以作为OS调度执行速度的参考之⼀

我们经常听到cpu的主频:也就是时钟晶振的振荡频率 ,当然频率越高,cpu调度的频率就越高,调度进程的时间更加精细

📌软中断

上述外部硬件中断,需要硬件设备触发。

  • 有没有可能,因为软件原因,也触发上面的逻辑?有!cpu内置了一些特殊的软件指令集
  • 为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int或者syscall),可以让CPU内部触发中断逻辑。
  • 那这样程序员是不是可以写这个汇编,汇编(syscall)触发中断 ------ 不由外部硬件,而是由语言、代码、软件触发的中断 :软中断

1️⃣为什么要有软中断?

软中断就是为了:让用户程序 "申请进内核",而不是 "直接闯进去"。

它是用户态 ↔ 内核态之间唯一合法的门。其中实现系统调用就是软中断最经典的案例

问题:

• 用户层怎么把系统调用号给操作系统?- 寄存器(比如EAX)

• 操作系统怎么把返回值给用户?- 内核把返回值写进约定好的寄存器,然后执行从内核态返回用户态指令,用户态代码(C 库)直接从那个寄存器里拿走值。

• 系统调用的过程,其实就是先int0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个方法会根据系统调用号,自动查表,执行对应的方法

• 系统调用号的本质:数组下标!

在外面操作系统的内部,所有的系统调用都被放在一张表里:sys_call_table,里面维护都是各种系统调用的函数地址

  • 在内核层面,每一个系统调用------> 都有一个系统调用号 (数组下标)
cpp 复制代码
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};

在调用系统调用是,如read()

  • 把fd,xxx等参数分别放进不同的寄存器ebx、ecx等
  • 把系统调用号3放进eax
  • 接着触发软中断;直接读取系统调用号,接着去系统调用表查表,执行系统调用

💯细节1:谁来做,软中断之前的工作??

  • 真正调用系统调用 ------ mov eax 3, syscall / int 0x80 ,os只给我们系统调用号传递系统调用的寄存器!
  • 上面几行的参数入参,返回值的获取是 c标准库做的

可是为什么我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall 呢?都是直接调用

上层的函数的啊?

  • 那是因为Linux的gnuC标准库,给我们把几乎所有的系统调用全部封装了。
  • 64位下用syscall,32位下用int 0x80

系统调用的核心是:

cpp 复制代码
_system_call:
	call[_sys_call_table + eax*4]
	push eax; //把返回结果入栈

应用程序运行在用户态,没有权限直接进入内核;

进入内核的方式(系统调用)是内核 ABI,不稳定、复杂、不跨平台;
C 标准库就是用户态与内核态之间的 "翻译官 + 门卫"。

细节2:内部系统调用怎么知道有多少个参数?分别是谁?

  • 把参数记录在寄存器里,再约定一个新寄存器,把参数的个数传递进来 ,如果参数多,就用栈空间来传

2️⃣还有其他中断形式吗?如何进一步理解OS?

📌缺页中断?内存碎片处理?除零野指针错误?

硬件中断 :时钟中断、外设中断
软中断 :系统调用的实现

除了上述两大中断还有 缺页中断、内存碎片处理、除零野指针错误等等,这些都称为异常中断

  • 缺页中断 :访问虚拟内存地址,当前不在物理内存里,CPU 发现后立刻触发的异常中断,MMU硬件报错!
  • 内存碎片处理 :申请内存空间失败,内存硬件报错
cpp 复制代码
void trap_init(void)
{
	int i;
	set_trap_gate(0,&divide_error);
	// 设置除操作出错的中断向量值。以下雷同。
	set_trap_gate(1,&debug);
	set_trap_gate(2,&nmi);
	set_system_gate(3,&int3);
	/* int3-5 can be called from all */
	set_system_gate(4,&overflow);
	set_system_gate(5,&bounds);
	set_trap_gate(6,&invalid_op);
	set_trap_gate(7,&device_not_available);
	set_trap_gate(8,&double_fault);
	set_trap_gate(9,&coprocessor_segment_overrun);
	set_trap_gate(10,&invalid_TSS);
	set_trap_gate(11,&segment_not_present);
	set_trap_gate(12,&stack_segment);
	set_trap_gate(13,&general_protection);
	set_trap_gate(14,&page_fault);
	set_trap_gate(15,&reserved);
	set_trap_gate(16,&coprocessor_error);// 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱⻔。
	for (i=17;i<48;i++)
		set_trap_gate(i,&reserved);
	set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。
	outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。
	outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。
	set_trap_gate(39,&parallel_interrupt);// 设置并⾏⼝的陷阱⻔。
}
  • 缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断,然后走中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。

📌 所以:
操作系统就是一个基于中断处理的软件集合!! ,CPU内部的软中断,比如int0x80或者syscall,我们叫做陷阱 ;CPU内部的软中断,比如除零/野指针等,我们叫做异常。(所以,能理解"缺页异常"为什么这么叫了吗?)

☀️操作系统demo

下面是mini版本的操作系统demo

操作系统每隔 1 秒产生一次时钟中断 → 减少当前进程时间片 → 时间片用完就随机切换另一个进程 → 循环调度。

  • 模拟了 时钟中断 + 进程调度!
cpp 复制代码
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <vector>

int current = 0; //当前进程指针
//进程描述符
struct task_struct
{
private:
    int pid;
    int status;
    int counter;
public:
    task_struct(int p):pid(p), counter(5)
    {}
    void desc()
    {
        counter--;
    }
    void run()
    {
        std::cout << "process" << pid << "running" << std::endl;
    }
    bool Expired()
    {
        return counter <= 0;
    }
    int Pid()
    {
        return pid;
    }
    void Reset()
    {
        counter = 5;
    }
    ~task_struct(){}
    
};

std::vector<task_struct> tasks;

void do_timer(int signo)
{
    //时钟一到
    tasks[current].desc(); //时间片-1
    if(tasks[current].Expired())
    {
        std::cout << tasks[current].Pid() << "过期了,重新选择进程调度" << std::endl;
        //选择一个进程运行即可
        current = rand()%tasks.size();
        tasks[current].Reset();
    }
    else
    {
        tasks[current].run();
    }
    alarm(1);
}

int main()
{
    alarm(1);
    signal(SIGALRM, do_timer);
    srand(time(nullptr));

    tasks.emplace_back(1);
    tasks.emplace_back(2);
    tasks.emplace_back(3);
    tasks.emplace_back(4);
    tasks.emplace_back(5);

    for(;;)
    {
        // std::cout << "OS 被中断唤醒 \n" << std::endl;
        pause(); //暂停,直到收到一个信号 ~ 内核进入休眠
    }

    return 0;
}

🥸如何理解内核态和用户态 ~ 重谈地址空间

首先简单回顾下 进程地址空间 的相关知识:

  • 进程地址空间 是虚拟的,依靠 页表+MMU机制 与真实的地址空间建立映射关系
  • 每个进程都有自己的 进程地址空间,不同 进程地址空间 中地址可能冲突,但实际上地址是独立的
  • 进程地址空间 可以让进程以统一的视角看待自己的代码和数据

关于 进程地址空间 的相关知识详见 《Linux 虚拟地址空间

不难发现,在 进程地址空间 中,存在 1 GB 的 内核空间,每个进程都有,而这 1 GB 的空间中存储的就是 操作系统 相关 代码 和 数据 ,并且这块区域采用 内核级页表真实地址空间 进行映射

一个进程会把OS相关的内存资源通过内核页表映射 到自己的[3g,4g]空间

细节:

  1. 进程的所有函数调用,都是在自己的虚拟地址空间内完成的 (scanf ------ read() ------ 触发软中断 进入内核空间)
  2. 每个进程有自己的用户级页表,但是内核级页表只有一份,被所有进程共享!!
  3. 进程在任何时候进行调度的时候,想找到OS,随时可以找到

原则上我们可以在代码区直接跳转到内核区去访问,但是这是具有安全风险的!

为了禁止用户直接用指针访问内核空间,就必须设置执行级别:用户态和内核态

  • 当前进程处于用户态 时,只能访问用户空间【0,3G】
  • 处于内核态 时,才能访问内核空间【3G,4G】

为什么要区分 用户态 与 内核态?

  • 内核空间中存储的可是操作系统的代码和数据,权限非常高,绝不允许随便一个进程对其造成影响 (直接进银行钱库取钱的例子❌)
  • 区域的合理划分也是为了更好的进行管理

那我们怎么知道进程处于什么状态呢? 在Linux中会有很多地方要进行权限管理!

  • 进程的cpu中,包含了大量的寄存器值(统称 进程硬件的上下文
  • 内核态和用户态,需要硬件级支持 ,也是cpu的两种执行级别
  • 比如cs寄存器code segment)中有两个比特位(低两位 ):0011CPL),分别对应 0 和 3,处于0时,对应的是内核态,处于3 对应用户态


还有细节:

  • 页表也有自己的标志位DPL):用户设置为 3,内核设置为 0
  • ✅当访问内核地址时,直接帮忙查页表:MMU自动对比CPL与DPL是否相等,如果不相等就直接报错
  • 如果调用系统调用,首先不是跳转到内核态,而是先把cs段寄存器中的11改成00,再触发中断,再拿系统调用号查内核页表

有个小误区:即便我们修改cpu状态进入了内核态 ,也只是允许你调用系统调用而已,而不是随便拿指针进行访问

cpp 复制代码
#include<stdio.h>
int main()
{
	while(1);
}

上面的死循环,什么都没做(没调用系统调用)也一样会被 ctrl + c 中断

  • 答案是:时钟中断! 虽然程序在死循环,但硬件时钟中断(几微秒)是一直存在的;CPU 立即从用户态切换到内核态,暂停执行当前的死循环代码,转而去执行内核的中断处理程序;内核处理完时钟事务后,进行信号检查!发现了一个 SIGINT没有被阻塞,但处于未决)信号在排队。
  • 内核不会再返回那个死循环了,而是直接按照信号的默认处理动作(终止进程)来执行。

🎉信号捕捉的流程再补充

情况:当前信号的执行动作为 用户自定义

这种情况就比较麻烦了,用户自定义的动作位于 用户态中,也就是说,需要先切回 用户态,把动作完成了,重新坠入 内核态,最后才能带着进程的上下文相关数据,返回 用户态

1️⃣在 内核态 中,也可以直接执行自定义动作,为什么还要切回 用户态 执行自定义动作?

  • 因为在 内核态 可以访问操作系统的代码和数据自定义动作可能干出危害操作系统的事
  • 用户态 中可以减少影响,并且可以做到溯源

2️⃣为什么不在执行完自定义动作直接后返回进程?

  • 当进程因中断或系统调用进入内核态时,内核会将进程上下文数据都保存在内核栈中。 所以需要先坠入内核态,才能正确返回用户态
  • 进程在执行自定义信号处理函数时,使用的是用户态的栈,权限不够

🌈信号部分小结

截至目前,信号 处理的所有过程已经全部学习完毕了

信号产生阶段:有四种产生方式,包括 键盘键入、系统调用、软件条件、硬件异常

信号保存阶段:内核中存在三张表,blcok 表、pending 表以及 handler 表,信号在产生之后,存储在 pending 表中

信号处理阶段:信号在 内核态 切换回 用户态 时,才会被处理

🌈可重入函数

可以被重复进入的函数称为 可重入函数

比如单链表头插的场景中,节点 node1 还未完成插入时,node2 也进行了头插,最终导致 节点 node2 丢失,造成 内存泄漏

导致 内存泄漏 的罪魁祸首:对于 node1node2 来说,操作的单链表 是同一个,同时进行并发访问(重入)会出现问题的 ,因为此时的单链表是临界资源

我们学过的函数中,90% 都是 不可重入的

函数是否可重入是一个特性,而非缺点,需要正确看待

不可重入的条件:

  • 调用了内存管理相关函数malloc / free
  • 调用了标准 I/O 库函数(printf 向全局缓冲区输出),因为其中很多实现都以不可重入的方式使用数据结构

🌈volatile关键字

volatile 关键字可以避免 编译器 的优化,保证内存的可见性

比如在下面这个例子中

借助全局变量 flag 设计一个死循环的场景,在此之前将 2 号信号进行自定义动作捕捉 ,具体动作为:将 flag 改为 1,可以终止 main 函数中的循环体

cpp 复制代码
#include <stdio.h>
#include <signal.h>

int flag = 0; //一开始为假

void handler(int signo)
{
    flag = 1;
    printf("change flag: 0 -> 1\n");
}

int main()
{
    signal(2, handler);
    
    while(!flag);
    printf("进程正在退出\n");

    return 0;
}

结果符合预期,2 号信号发出后,循环结束,程序正常退出

建议性关键字register ------ 给编译器提个"建议 ":把这个变量直接存在 CPU 的寄存器里,而不是内存里。省去了加载的io

这段代码能符合我们预期般的正确运行是因为 当前编译器默认的优化级别很低,没有出现意外情况

通过指令查询 gcc 优化级别的相关信息

c 复制代码
man gcc
: /O1

其中数字越大,优化级别越高,理论上编译出来的程序性能会更好

果真如此吗?

让我们重新编译上面的程序 ,并指定优化级别为 O1

c 复制代码
	gcc -o $@ $^ -O1

此时得到了不一样的结果:2 号信号发出后,对于 flag 变量的修改似乎失效了

将优化级别设为更高是一样的结果,如果设为 O0会符合预期般的运行 ,说明我们当前的编译器默认的优化级别是 O0

那么我们这段代码哪个地方被优化了呢?

  • 答案是: while 循环判断,把flag数据读入了寄存器,后续再也不检测内存中flag的值了
  • 也就是寄存器覆盖了内存,让内存不可见了

首先要明白:

  • 对于程序中的数据 ,需要先被 loadCPU 中的 寄存器
  • 判断语句所需要的数据(比如 flag),在进行判断时,是从 寄存器 中拿取并判断
  • 根据判断的结果,判断代码的下一步该如何执行(通过 PC 指针指向具体的代码执行语句)

所以程序在优化级别为 O0 或更低时,是这样执行的:

此时是就是"负优化"了,volatile修饰flag即可,告诉编译器:不要对flag做任何内存级的优化 ------ 必须在内存中做检测再加载到内存中

clike 复制代码
volatile int flag = 0; //保持flag变量的内存可见性

🌈SIGCHLD

进程控制 学习时期,我们明白了一个事实:父进程必须等待子进程退出并回收,并为其 "收尸",避免变成 "僵尸进程" 占用系统资源、造成内存泄漏

那么 父进程是如何知道子进程退出了呢?

  • 在之前的场景中,父进程要么就是设置为 阻塞式专心等待 ,要么就是 设置为 WNOHANG 非阻塞式等待 ,这两种方法都需要 父进程 主动去检测 子进程 的状态

如今学习了 进程信号 相关知识后,可以思考一下:子进程真的是安安静静的退出的吗?

  • 答案当然不是,子进程在退出后,会给父进程发送 SIGCHLD 信号

可以通过 SIGCHLD 信号 通知 父进程,子进程 要退出了 ,这样可以解放 父进程 ,不必再去 主动检测 ,而是 子进程 要退出的时候才通知其来 "收尸"


SIGCHLD 信号比较特殊,默认动作 SIG_DEF 是 什么都不做

首先通过程序证明一下子进程会发出 SIGCHLD 信号

clike 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>

void handler(int signo)
{
    printf("父进程获取信号:%d, pid: %d\n", signo, getpid());
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    if(id == 0)
    {
        printf("子进程退出, pid: %d\n", getpid());
        exit(10);
    }

    while(1)
    {
        printf("父进程在running, pid: %d\n", getpid());
        sleep(1);
    }
    return 0;
}

因此可以证明 SIGCHLD 是被子进程真实发出的,当然,我们可以自定义捕捉动作为 回收子进程,让父进程不再主动检测子进程的状态,可以自己忙自己的事

cpp 复制代码
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

void handler(int signo)
{
    printf("父进程获取信号:%d, pid: %d\n", signo, getpid());
    int status = 0;
    waitpid(-1, &status, 0);
    printf("status code : %d\n", WEXITSTATUS(status));
}

int main()
{
    signal(SIGCHLD, handler);
    pid_t id = fork();
    if(id == 0)
    {
        int n = 5;
        while(n)
        {
            printf("子进程剩余退出时间:%d, pid: %d\n", n--, getpid());
            sleep(1);
        }
        exit(10);
    }

    // 父进程很忙的话,可以去做自己的事
    while(1)
    {
        //todo
        printf("父进程在忙, pid: %d\n", getpid());
        sleep(1);
    }
    return 0;
}

那么这种方法就一定对吗?

  • 答案是不一定,在只有一个子进程的场景中,这个代码没问题 ,但如果是涉及多个子进程回收时,这个代码就有问题了

📢写在最后

相关推荐
要做一个小太阳2 小时前
blockbox配置文件详解与优化
运维·网络·prometheus
佩洛君2 小时前
如何在Ubuntu22.04中安装ROS2-Humble
c++·python·ros2
m0_738120722 小时前
渗透基础知识ctfshow——Web应用安全与防护(第六 七章)
服务器·前端·安全
一只鼠标猴2 小时前
甲方安全运营:漏洞整改推动实操指南
运维·安全·网络安全·安全架构·安全运营·漏洞整改
DianSan_ERP2 小时前
淘宝订单接口集成中如何正确处理消费者敏感信息的安全与合规问题?
大数据·运维·网络·人工智能·安全·servlet
蚰蜒螟2 小时前
深入浅出:从JVM线程创建到Linux内核clone系统调用
linux·运维·jvm
IMPYLH2 小时前
Linux 的 sha256sum 命令
linux·运维·服务器·网络·bash·哈希算法
chimooing2 小时前
OpenClaw 浏览器自动化:Playwright 深度集成
运维·自动化
KKKlucifer2 小时前
安全智能体:数据安全运营自动化与自主决策的技术突破
运维·安全·自动化