1.进程地址空间内核区
我们之前都是谈进程地址空间的用户区,接下来我们谈谈内核区。
进程地址空间中的内核区是操作系统内核在进程地址空间中所占据的特定区域。
- 一般情况下,一个操作系统只有一个内核区以及一个内核级页表。而进程可以有多个页表。
- 不论进程怎么切换,进程中的内核区数据都是相同的(当然,可能由于权限问题看到的东西不同)
- 进程调用系统接口,就是调用内核区的代码和数据,相当于在自己的进程地址空间调用。
- 操作系统启动后,每时每刻都有进程在执行。所以用户可以调用系统接口并利用系统接口随时访问(前提有权限)操作系统的代码和数据。
内核态和用户态的切换过程:
- 保存现场:无论是通过系统调用、硬件中断还是异常触发切换,首先要做的就是保存当前用户态程序的执行上下文,包括程序计数器(PC)、寄存器状态、栈指针等,以便在切换回用户态时能够恢复程序的执行。
- 切换模式:CPU 通过修改其状态寄存器中的模式位,将当前执行模式从用户态切换到内核态。
- 跳转执行:根据不同的触发方式,CPU 会跳转到相应的内核代码入口点执行。对于系统调用,会跳转到系统调用处理程序入口;对于硬件中断,会根据中断向量表找到对应的中断处理程序入口;对于异常,会跳转到相应的异常处理程序入口。
- 执行内核代码:在内核态下,操作系统内核执行相应的代码来处理系统调用请求、中断事件或异常情况。在执行过程中,内核可以访问和操作系统的所有资源,包括硬件设备、内核数据结构等。
- 恢复现场:当内核代码执行完毕后,需要将之前保存的用户态程序的执行上下文恢复,包括将寄存器的值还原、栈指针复位、程序计数器指向原来的下一条指令等。然后,CPU 将执行模式从内核态切换回用户态,继续执行用户程序。
来源:用户态和内核态是怎么切换的
2.简单谈谈操作系统的执行逻辑
操作系统启动,在内核中相当于执行一个死循环。为什么这么说呢?
因为OS是硬件和软件的管理者,这个管理的动作是持续的,就相当于是一个循环,不断的对各种资源和信号进行处理,处理完又进入这个循环。
我们拿进程调度举例,操作系统需要在多个进程之间进行调度,以实现 CPU 资源的合理分配。在启动后,操作系统会不断循环执行进程调度算法,根据进程的优先级、状态等因素,选择一个就绪进程并将 CPU 分配给它执行。当该进程的时间片用完或者因为等待资源等原因暂停时,操作系统会再次进行调度,选择下一个就绪进程执行,如此反复循环。
3.内核如何实现信号的捕捉
当我们对“内核”以及操作系统的运行状态、执行逻辑有了简单的了解后,就可以粗略的了解一下“内核如何实现信号的捕捉”。
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
- 用户程序注册了SIGQUIT信号的处理函数sighandler。当前正在执行main函数,这时发生中断或异常切换到内核态。在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
- 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
- sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
- 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
注意:信号自定义行为被处理的真正时间,是进程从内核态返回用户态。
4.sigaction()
https://man.cx/sigaction
sigaction函数,可以检查或修改一个信号的动作。
4.1 参数:
- signum表示目标信号(SIGKILL和SIGSTOP除外)
- act是一个输入型参数,old是一个输出型参数。
- 若act指针非空,则根据act修改该信号的处理动作。
- 若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体:
- sa_handler表示信号捕捉方法,
- sa_sigaction是实时信号的处理函数,
- sa_mask表示某个信号的处理方式正在被调用时,想屏蔽的信号集。
- sa_flags字段包含一选项,本章的代码都把sa_flags设为0。
- sa_restorer:一般不使用,已过时。
4.2 返回值
调用成功则返回0,出错则返回- 1。
4.3 样例
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>using namespace std;void handler(int signo)
{cout << "catch a signal,signal number:" << signo << endl;
}
int main()
{struct sigaction act,oact;memset(&act,0,sizeof(act));memset(&oact,0,sizeof(oact));act.sa_handler = handler;int ret = sigaction(2,&act,&oact);if(ret == -1) perror("sigaction");while(true){cout << "I am process" << endl;sleep(1);}return 0;
}
SIGINT就被捕获了。
5. 未决信号集中“1何时变为0”
现在我们有一个问题。当信号产生后,pending信号集对应的比特位由0置1,当信号处理后,该比特位又要从1置0,那么,是什么时候将该比特位从1置回0的呢?
我们可以在捕捉函数(handler)中打印未决信号集,如果打印结果显示“2号比特位”是0,则说明是在信号处理前就置回0了,反之是处理后。我们测试一下,
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>using namespace std;void Printpending()
{sigset_t pset;sigpending(&pset);for(int i = 31; i >= 1; --i){if(sigismember(&pset,i) ){cout << 1;}else{cout << 0;}}cout << "\n";
}
void handler(int signo)
{Printpending();cout << "catch a signal,signal number:" << signo << endl;
}
int main()
{struct sigaction act,oact;memset(&act,0,sizeof(act));memset(&oact,0,sizeof(oact));act.sa_handler = handler;int ret = sigaction(2,&act,&oact);if(ret == -1) perror("sigaction");while(true){cout << "I am process" << endl;sleep(1);}return 0;
}
编译运行
这说明,再执行捕捉方法前,未决信号集中的1就被置回0了。
6.OS不允许嵌套调用同个信号的捕捉函数
在一个进程内,当一个信号的处理/捕捉函数正在被调用时,OS会将该信号屏蔽,当调用结束后,会解除屏蔽。
那么如何证明这一点呢?
如果一个信号的捕捉函数正在被调用时,进程不会屏蔽该信号,按照“未决信号集中‘1变为0’是在递达前”,此时我们不断的向该进程发送同种信号,未决信号集对应的比特位应该一直是0,如果出现1了,说明该信号产生了但没有递达,也就是被阻塞了。
我们可以将handler函数写成死循环,在循环中不断打印pending信号集,并持续发送同种信号,观察未决信号集的变化,如果一直是0,则说明该信号没有被阻塞,如果打印的第一次是0并在我们发送第二次信号后变为1,就说明该信号被阻塞了。
我们修改handler函数
void handler(int signo)
{cout << "catch a signal,signal number:" << signo << endl;while(1){Printpending();sleep(1);}
}
重新编译运行,
打印结果说明,当进程的某个信号的处理方式正在被调用时,进程会屏蔽该信号。
如果我们想在2号信号被递达时,除了系统默认屏蔽的2号信号还想屏蔽1号、3号和4号信号,我们可以将这些信号加入sa_mask,
sigaddset(&act.sa_mask, 1);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
注意:我们这里说“嵌套调用”,可能有些不恰当,因为信号的递达和main()函数的调用是两个不同的执行流,而嵌套一般是指在同一个执行流内。
7. 可重入函数与不可重入函数
定义
- 可重入函数:是指一个可以被多个任务或线程同时调用,而不会产生数据错误或程序异常的函数。在函数执行过程中,无论被调用多少次,其执行结果都只与输入参数有关,而与调用的顺序和时机无关。
- 不可重入函数:是指在多任务或多线程环境下,一个函数在被多个任务或线程同时调用时,可能会导致数据错误或程序异常的函数。
特点
- 可重入函数
- 无共享资源依赖:通常不依赖于全局变量或静态变量等共享资源,即使使用了全局变量,也会通过适当的方式进行保护,确保在多个调用之间不会产生冲突。
- 函数状态独立:函数内部的状态是独立的,每次调用都不会影响其他调用的结果,也不会依赖于之前的调用状态。
- 原子操作:其操作通常是原子的,即一个操作在执行过程中不会被中断,或者即使被中断,也能保证在重新执行时不会产生错误。
- 不可重入函数
- 共享资源访问冲突:通常会对全局变量、静态变量或其他共享资源进行读写操作,且在访问这些共享资源时没有采取适当的保护措施。
- 函数内部状态依赖:其执行结果可能依赖于函数内部的静态状态或全局状态,而这些状态在多次调用之间可能会被修改。
- 非原子操作问题:可能包含一些非原子操作,即一个操作在执行过程中可能会被中断,然后由其他任务或线程继续执行,从而导致数据不一致或程序逻辑错误。
使用场景
- 可重入函数:适用于多任务或多线程并发执行的环境,如操作系统内核、服务器程序、多线程应用程序等。在这些环境中,多个任务或线程可能会同时调用同一个函数,使用可重入函数可以保证程序的正确性和稳定性。
- 不可重入函数:通常适用于单任务环境,或者在多任务环境中对函数的调用进行了严格的同步控制,确保同一时刻只有一个任务或线程能够调用该函数。
示例
- 可重入函数
int add(int a, int b) {return a + b;
}
这个函数只依赖于输入参数a
和b
,不依赖于任何全局变量或静态变量,也没有对共享资源进行读写操作,因此是一个可重入函数。
- 不可重入函数
int count = 0;
void increment_count() {count++;
}
这个函数对全局变量count
进行了自增操作,在多任务或多线程环境下,如果多个任务或线程同时调用这个函数,就会导致count
的值被错误地修改,因此是一个不可重入函数。
来源:https://www.doubao.com/chat/
总结
如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
8.关于G++编译器的优化
我们编写一段测试代码,
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>using namespace std;int flag = 0;
void handler(int signo)
{cout << "catch a signal,signal number:" << signo << endl;flag = 1;
}
int main()
{signal(2,handler);while(!flag);cout << "process exit normally" << endl;return 0;
}
编译运行,
当我们不发送2号信号时,“!flag”一直为真,该进程会一直运行;当我们发送2号信号后,flag会变为0,程序跳出循环,打印“print exit normally”。
但是对于一些编译器,可能会对flag进行优化,因为flag只进行了判断,没有进行修改,判断是逻辑计算在CPU中进行,判断的结果可能会被优化,直接保存在CPU的寄存器中,我们可以做一下测试,
下面是G++编译器编译时优化的选项,(参考:https://www.doubao.com/chat/)
-O(优化级别)系列选项
- -O0(默认选项)
- 这是不进行优化的级别。在这个级别下,编译器会以最快的速度生成目标代码,代码的编译速度相对较快。但生成的可执行程序可能会比较大,执行效率也相对较低。主要用于调试阶段,因为在调试时,我们通常希望代码的执行顺序和变量的存储方式等尽可能地接近源代码,这样更便于追踪错误。
- -O1
- 这是基本的优化级别。编译器会进行一些简单的优化,如删除未使用的代码、简化表达式、调整指令顺序等。这种优化可以在一定程度上提高程序的执行效率,同时对编译时间的影响相对较小。例如,它可能会将一些简单的常量表达式在编译时就计算出结果,而不是在运行时计算。
- -O2
- 这是比较常用的优化级别,它会进行更多的优化操作。除了包含 -O1的优化外,还会进行诸如函数内联(将一些小型函数的代码直接嵌入到调用它的地方,减少函数调用的开销)、循环优化(例如循环展开,将循环体中的代码复制多次,减少循环控制的开销,但可能会增加代码大小)等。这个级别可以显著提高程序的性能,但可能会使编译时间变长,并且在某些情况下可能会导致调试信息变得不太准确。
- -O3
- 这是最高级别的优化选项。它会在 -O2的基础上进行更多激进的优化,比如更广泛的内联、更多的循环优化以及对指令调度的进一步优化等。虽然可以使程序执行效率更高,但也可能会导致程序体积大幅增加,编译时间明显变长,并且在一些复杂的代码结构中可能会引入难以发现的错误。例如,过度的内联可能会导致代码缓存命中率下降,或者使程序的栈空间需求超出预期。
测试结果,
不同的编译器,不同的版本,优化结果都可能不一样。我们只讨论O1优化与默认优化中,为什么发送2号信号后运行结果不同。
因为编译器检测到(当前执行流)后续代码没有对flag进行修改,便将flag第一次逻辑计算的结果保存到寄存器中,当对flag进行逻辑计算时,直接从寄存器中获取结果(1),当我们向进程发送2号信号后,信号递达修改的是内存中flag的值,不影响寄存器中的结果,所以进程继续陷入死循环。
为了防止编译器的优化,我们可以用关键字volatile修饰flag。
9.volatile
参考:https://www.doubao.com/chat/
-
基本定义与作用
- 在编程语言中,
volatile
是一个关键字,用于修饰变量。它告诉编译器该变量的值可能会在程序执行过程中被意外地改变,这种改变不是由程序本身的逻辑所控制的。例如,在多线程环境下,一个变量可能会被其他线程修改;或者在与硬件交互时,硬件设备可能随时更新变量的值。 - 编译器在处理
volatile
修饰的变量时,不会对涉及该变量的操作进行某些常规的优化。通常情况下,编译器可能会将变量的值缓存在寄存器中,以提高访问速度。但对于volatile
变量,编译器每次都要从内存中读取其实际值,以确保程序使用的是变量的最新状态。
- 在编程语言中,
-
适用场景
- 多线程编程
- 在多线程环境中,多个线程可能会共享和修改同一个变量。如果一个变量没有被声明为
volatile
,编译器可能会进行优化,导致一个线程无法察觉到另一个线程对变量的修改。例如,在一个线程修改了共享变量后,另一个线程可能由于编译器的优化而继续使用寄存器中缓存的旧值。使用volatile
可以避免这种情况,保证每个线程都能获取到变量的最新值。
- 在多线程环境中,多个线程可能会共享和修改同一个变量。如果一个变量没有被声明为
- 硬件交互
- 当程序与硬件设备(如寄存器、传感器等)进行交互时,硬件设备可能会随时更新内存中的变量值。例如,在嵌入式系统中,一个表示传感器数据的变量可能会被传感器硬件不断更新。将这个变量声明为
volatile
,可以确保程序每次访问该变量时都能获取到硬件更新后的最新值,而不是使用编译器缓存的旧值。
- 当程序与硬件设备(如寄存器、传感器等)进行交互时,硬件设备可能会随时更新内存中的变量值。例如,在嵌入式系统中,一个表示传感器数据的变量可能会被传感器硬件不断更新。将这个变量声明为
- 多线程编程
-
与其他关键字的对比
- 与
const
关键字对比:const
用于表示变量的值是常量,在程序执行过程中不能被修改。而volatile
则强调变量的值是可变的,并且这种变化可能是不可预测的。一个变量可以同时被声明为const volatile
,这表示该变量的值不能被程序本身修改(如通过赋值语句),但可能会被外部因素(如硬件设备)改变。 - 与普通变量对比:普通变量在编译器优化过程中可能会被缓存,其访问效率可能会提高,但存在获取不到最新值的风险。而
volatile
变量通过牺牲一定的访问效率(每次从内存读取),保证了变量值的准确性和及时性。
- 与
10. SIGCHLD
10.1 基于信号的等待回收
子进程在退出时,会向父进程发送17号信号(SIGCHLD),如果证明这一点呢?
我们可以捕获17号信号,编写一段父进程创建子进程且子进程会退出的代码,看看是否会执行信号的捕获方法(自定义行为函数)。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>using namespace std;void handler(int signo)
{cout << "I am process:" << getpid() << "catch a signal,signal number:" << signo << endl;
}int main()
{signal(17,handler);pid_t pid = fork();if(pid == 0){while(true){cout << "I am child process:" << getpid() << ",getppid:" << getppid() << endl;sleep(1);break;}exit(0);}//fatherwhile(true){cout << "I am father process:" << getpid() << endl;sleep(1);}return 0;
}
编译运行,
所以父进程在“等待”时,我们可以采用“异步”的方式,也就是基于信号进行等待,把等待函数写入信号的捕捉函数中。
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>using namespace std;void handler(int signo)
{sleep(3);pid_t rid = waitpid(-1,nullptr,0);cout << "I am process:" << getpid() << "catch a signal,signal number:" << signo << "child process quit:" << rid << endl;
}int main()
{signal(17,handler);pid_t pid = fork();if(pid == 0){while(true){cout << "I am child process:" << getpid() << ",getppid:" << getppid() << endl;sleep(3);break;}exit(0);}//fatherwhile(true){cout << "I am father process:" << getpid() << endl;sleep(1);}return 0;
}
编译运行,
注意:子进程创建成功后,子进程和父进程的调度是由调度器控制的,一般调度算法偏向父进程。
10.2 问题1——进程全部退出
上面只是一个子进程退出,如果是10个子进程呢?
我们知道当一个信号的捕获函数正在被调用时,进程会屏蔽该信号。也就是说,当有10个子进程要同时退出时,注意是同时退出,就会造成部分子进程发送的17号信号被屏蔽,怎么解决这个问题呢?我们可以在捕捉函数写一个循环,循环等待子进程。
void handler(int signo)
{sleep(3);pid_t rid;while ((rid = waitpid(-1, nullptr, 0)) > 0){cout << "I am process:" << getpid() << "catch a signal,signal number:"<< signo << "child process quit:" << rid << endl;}
}int main()
{signal(17, handler);for (int i = 0; i < 10; ++i){pid_t pid = fork();if (pid == 0){while (true){cout << "I am child process:" << getpid() << ",getppid:" << getppid() << endl;sleep(2);break;}exit(0);}}// fatherwhile (true){cout << "I am father process:" << getpid() << endl;sleep(1);}return 0;
}
编译运行,
10.3 问题2——进程随机退出
但还有问题,如果一共有10个子进程,有5个子进程要同时退出,其余五个不退出,那么在捕捉函数中,第6次循环就会卡住,waitpid会阻塞等待第6个子进程退出。
所以父进程要以非阻塞的方式等待。
示例:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <ctime>
using namespace std;void handler(int signo)
{sleep(3);pid_t rid;while ((rid = waitpid(-1, nullptr, 0)) > 0){cout << "I am process:" << getpid() << "catch a signal,signal number:"<< signo << "child process quit:" << rid << endl;}
}int main()
{srand(time(nullptr));signal(17, handler);for (int i = 0; i < 10; ++i){pid_t pid = fork();if (pid == 0){while (true){cout << "I am child process:" << getpid() << ",getppid:" << getppid() << endl;sleep(3);break;}// 每隔一段时间退出exit(0);}sleep(rand() % 5 + 3);}// fatherwhile (true){cout << "I am father process:" << getpid() << endl;sleep(1);}return 0;
}
10.4 问题4:必须要用wait回收子进程吗?
父进程必须要通过wait或waitpid回收子进程吗?
上面代码的运行中,我们都是通过waitpid函数回收僵尸进程,有没有什么办法不产生僵尸进程,也就是子进程退出就“退出”了,父进程也不用“用wait/waitpid回收子进程”。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction或signal(任意一个信号捕捉的系统调用接口)将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
示例:
#include <iostream>
#include <cstring>
#include <unistd.h>
#include <signal.h>
using namespace std;int main()
{signal(SIGCHLD, SIG_IGN);//把17号信号忽略//只要将17号信号忽略,那么子进程退出的时候,父进程什么都不用管//子进程会自动被系统释放资源for (int i = 0; i < 10; ++i){pid_t pid = fork();if (pid == 0){while (true){cout << "I am child process:" << getpid() << ",getppid:" << getppid() << endl;sleep(3);break;}cout << "child quit" << endl;exit(0);}sleep(1);// sleep(rand() % 5 + 3);}// fatherwhile (true){cout << "I am father process:" << getpid() << endl;sleep(1);}return 0;
}
编译运行,
进程退出后没有进入僵尸状态,直接被回收。
所以以后当父进程不需要知道子进程的退出信息时,可以直接将17号信号的处理方式设为SIG_IGN。
那么我们没讲信号之前,子进程被回收时处理的方式是什么呢?
输入“man 7 signal”,往下查找,
我们可以找到17号信号的默认动作就是“忽略”。奇怪了,我们没有自定义17号信号的动作前,其默认动作是忽略,但还是会出现僵尸进程,为什么我们显示捕捉17号信号并将其动作设为忽略,就不会出现僵尸呢?同样都是忽略,结果却不同,我们该怎么理解这种现象呢?
其实UNIX对于17号信号的默认处理方式是SIG_DFL(signal(17,SIG_DFL),而SIG_DFL对应的action是IGN,所以UNIX对17号信号的默认处理方式并不是SIG_IGN。