【Linux】学习-进程信号

进程信号

信号入门

生活角度的信号

  • 你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临时,你该怎么处理快递。也就是你能“识别快递”,也就是你意识里是知道如果这时候快递员送来了你的包裹,你知道该如何处理这些包裹
  • 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。你可以暂时将包裹搁置,等到有空的时候再处理。
  • 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你“记住了有一个快递要去取”
  • 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(幸福的打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你你的女朋友)3. 忽略快递(快递拿上来之后,扔掉床头,继续开一把游戏)
  • 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话

技术应用角度的信号

什么是Linux信号?

  • 信号是进程之间事件异步通知的一种方式,属于软中断

  • 信号本质是一种通知机制,用户or操作系统通过发送一定的信号,通知进程,某些事件已经发生,进程可以根据信号的种类来进行相关处理、

结合生活信号,对信号的一些小结论

  • 进程要处理信号,必须具备信号的“识别”能力,和此能力是程序员给进程植入的,可以理解成出生就自带的
  • 信号种类繁多,因此进程收到信号后,有可能是立即执行的,也有可能是后续处理的
  • 信号能够被进程所保存,因此也就具备后续处理的能力
  • 一般而言,信号的产生相对于进程而言是异步的,也就是说,信号发送的时候,进程是正在干自己的事情的,随时都有可能收到信号,具备识别能力但不代表提前得知

先简单理解一下信号被进程保存

  • 进程内部有相关的数据结构位图,信号每次被发送给进程时,此时信号处于未决状态,那么用来保存信号的字段表中信号编号对应的位置就会由0变1

    信号处理的三种方式

  • 默认处理

  • 忽略

  • 自定义捕捉

产生信号

通过终端按键产生信号

  • 我们平常在写代码时,其实经常都有在跟信号接触,看以下代码:

    #include 
    #include 
    int main()
    {
        while(1){
        printf("I am a process, I am waiting signal!\n");
        sleep(1);
     }
    }
    

    【Linux】学习-进程信号_第1张图片

由于没有退出条件,因此进程一直处于死循环,此时我们在键盘里键入:Ctrl+c 能够将进程终止,其实按下组合键后本质就是向当前在前台运行的进程发送了终止信号,进程也立马对此信号做出了反应,终止掉了进程。这就是产生信号的其中一种方式

常见信号

  • 信号一共有61个信号,32,33,0号信号是不存在的,而1-31的信号称为普通信号,32-64为实时信号,我们只学习普通信号:

    【Linux】学习-进程信号_第2张图片

  • 关于信号的详细信息我们可以用man手册查看:man 7 signal

    【Linux】学习-进程信号_第3张图片

  • 每个信号的编号都是被宏定义好的:

    /* Signals.  */
    #define	SIGHUP		1	/* Hangup (POSIX).  */
    #define	SIGINT		2	/* Interrupt (ANSI).  */
    #define	SIGQUIT		3	/* Quit (POSIX).  */
    #define	SIGILL		4	/* Illegal instruction (ANSI).  */
    #define	SIGTRAP		5	/* Trace trap (POSIX).  */
    #define	SIGABRT		6	/* Abort (ANSI).  */
    #define	SIGIOT		6	/* IOT trap (4.2 BSD).  */
    #define	SIGBUS		7	/* BUS error (4.2 BSD).  */
    #define	SIGFPE		8	/* Floating-point exception (ANSI).  */
    #define	SIGKILL		9	/* Kill, unblockable (POSIX).  */
    #define	SIGUSR1		10	/* User-defined signal 1 (POSIX).  */
    #define	SIGSEGV		11	/* Segmentation violation (ANSI).  */
    #define	SIGUSR2		12	/* User-defined signal 2 (POSIX).  */
    #define	SIGPIPE		13	/* Broken pipe (POSIX).  */
    #define	SIGALRM		14	/* Alarm clock (POSIX).  */
    #define	SIGTERM		15	/* Termination (ANSI).  */
    #define	SIGSTKFLT	16	/* Stack fault.  */
    #define	SIGCLD		SIGCHLD	/* Same as SIGCHLD (System V).  */
    #define	SIGCHLD		17	/* Child status has changed (POSIX).  */
    #define	SIGCONT		18	/* Continue (POSIX).  */
    #define	SIGSTOP		19	/* Stop, unblockable (POSIX).  */
    #define	SIGTSTP		20	/* Keyboard stop (POSIX).  */
    #define	SIGTTIN		21	/* Background read from tty (POSIX).  */
    #define	SIGTTOU		22	/* Background write to tty (POSIX).  */
    #define	SIGURG		23	/* Urgent condition on socket (4.2 BSD).  */
    #define	SIGXCPU		24	/* CPU limit exceeded (4.2 BSD).  */
    #define	SIGXFSZ		25	/* File size limit exceeded (4.2 BSD).  */
    #define	SIGVTALRM	26	/* Virtual alarm clock (4.2 BSD).  */
    #define	SIGPROF		27	/* Profiling alarm clock (4.2 BSD).  */
    #define	SIGWINCH	28	/* Window size change (4.3 BSD, Sun).  */
    #define	SIGPOLL		SIGIO	/* Pollable event occurred (System V).  */
    #define	SIGIO		29	/* I/O now possible (4.2 BSD).  */
    #define	SIGPWR		30	/* Power failure restart (System V).  */
    #define SIGSYS		31	/* Bad system call.  */
    #define SIGUNUSED	31
    
  • 其中,Ctrl+c 组合键对应的就是2号信号SIGINT,它所对应的Action行为是Term行为:Terminate终止动作

注意

  1. Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
  2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
  3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。

如何理解组合键变信号?

  • 键盘的工作方式是通过中断方式进行的,因此操作系统能读取并解析组合键,解析完毕后会查找进程列表中在前台运行的进程,并把对于的信号写入进程!

初始信号捕捉

我们前面提到过,信号被写入进程后,进程对此信号的处理有三种方式,我们先来简单介绍一下自定义捕捉方式

介绍signal函数

  • 【Linux】学习-进程信号_第4张图片

    功能:能够捕捉指定的信号编号为signum的信号,然后将此信号的处理方式使用我们自定义的方式handler

  • 其中:sighandler_t是一个函数指针,以一个的函数指针作为另一个函数的参数并在函数内使用函数指针调用对应指向的函数,此函数称为回调函数,自定义捕捉时,我们实现自定义函数,并将函数作为参数传给signal,通过回调的方式,来修改对应信号的捕捉方法。返回值是自定义捕捉前的函数的地址。

通过信号捕捉的方式,验证Ctrl+c组合键是SIGINT信号

  • #include 
    #include 
    #include 
    using namespace std;
    void cathSig(int signum)
    {
        cout<<"i catch you! SIGINT!"<

【Linux】学习-进程信号_第5张图片

我们可以观察到,按下Ctrl-c后进程并不会像之前一样退出,而是执行了我们自己的代码!得以验证以下两个结论:

  • Ctrl-c对应的信号为SIGINT信号
  • Ctrl-c对应的信号处理方式为终止进程,自定义捕捉方式后,原先的处理方法就被舍弃了

注意

  • signal函数一般写在最前面,原理类似于我们要先买票,才能对信号进行自定义捕捉,后面讲阻塞信号时会解释原理!

Core Dump

我们先来比较两个信号

  • image-20231008171758567

    这两个信号都是从键盘敲组合键来向进程发送信号,其中:

    • Ctrl-c 对应的是2号SIGINT
    • Ctrl-\ 对应的是3号信号SIGQUIT
  • 他们两个的作用都能够用来终止进程,但不同的是:

    2号对应的行为是Term:Terminate

    3号对应的行为是Core:Core Dump

  • 简单验证:【Linux】学习-进程信号_第6张图片

什么是Core Dump?

  • Core Dump,又叫核心转储,当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部 保存到磁盘上,文件名通常是core,这叫做Core Dump。进程异常终止通常是因为有Bug,比如非法内存访问导致段错误,事后可以用调试器检查core文件以查清错误原因,这叫做Post-mortem Debug(事后调试)。一个进程允许产生多大的core文件取决于进程的Resource Limit(这个信息保存 在PCB中)。
  • 默认是不允许产生core文件的(一般而言,云服务器-生产环境下,核心转储的功能是被关闭的!),因为core文件中可能包含用户密码等敏感信息,不安全。
  • 在开发调试阶段可以用ulimit命令改变这个限制,允许产生core文件。 首先用ulimit命令改变Shell进程的Resource Limit,允许core文件最大为1024K: $ ulimit -c1024

使用命令ulimit命令查看并修改相关资源配置

  • image-20231008173314127

显示0时,代表核心转储不被允许产生,我们需要自行开启:

【Linux】学习-进程信号_第7张图片

此时已显示core文件允许的最大内存为1024K,但仅仅在当前会话生效,退出会话后就失效了

  • ulimit命令改变了Shell进程的Resource Limit,test进程的PCB由Shell进程复制而来,所以也具 有和Shell进程相同的Resource Limit值,这样就可以产生Core Dump了。

验证Ctrl-\ 发送的SIGQUIT能够产生core文件的功能

  • 先写一个死循环程序:

    int main()
    {
    while(1)
    {
        printf("my pid:%d\n",getpid());
        sleep(1);
    }
    }
    

    运行并用Ctrl-c命令终止后发现并没有产生任何文件:

    【Linux】学习-进程信号_第8张图片

  • 再次运行并使用Ctrl-\ 命令终止:

    【Linux】学习-进程信号_第9张图片

    这次我们发现产生了core.27213文件,并且后缀就是以进程的pid来命名的,代表此文件就是此进程出现某种异常时,由os将此进程在内核中相关的核心数据转存到磁盘中

核心转储有什么用?

  • 主要是用来进行调试,有了核心转储的数据,调试时会方便很多

利用核心转储进行调试

  • 除了SIGQUIT信号的行为是core,还有其他很多命令也有core行为,下面我们使用SIGFPE信号来验证使用核心转储调试时是否会方便image-20231009103130248

    FPE:Floating point exception指的是浮点数错误,一般指除0错误

  • 首先编写程序,并且编译时,带上-g选项,生成debug文件

    int main()
    {
        while (1)
        {
            printf("my pid:%d\n", getpid());
            sleep(1);
            int a=100;
            a/=0;
            printf("run here...\n");
        }
    }
    

    image-20231009103412230

  • 重新运行程序,此时也随之生成了core dump文件

    【Linux】学习-进程信号_第10张图片

  • 启用gdb调试程序,输入core-file core.pid(core文件名)

    【Linux】学习-进程信号_第11张图片

此时我们能发现,在core dump文件的帮助下,能自动帮我们定位在哪一行代码收到了什么信号,比如这里,我们在29行除0错误处收到了8号信号,而8号信号就是对应的SIGFPE信号

core dump标记位

  • 其实我们在进程间控制时就已经和Core Dump有过一面之缘了,我们来看这张图:

【Linux】学习-进程信号_第12张图片

在进程控制一篇提到过,子进程退出时,父进程可以通过进程等待的方式,收集子进程的退出信息,以防子进程变成僵尸进程,其中进程等待可以使用wait和waitpid系统调用【Linux】学习-进程信号_第13张图片

其中我们也提到过status参数是一个输出型参数,其中如果子进程是被信号所杀的话,此参数的低七位会被填入终止信号的编号,而第八位则是显示是否有生成core dump文件,我们也可以通过代码来验证一下:

用子进程验证core dump标记位

  • int main()
    {
    	pid_t id = fork();
        if (id == 0)
        {
            // child
            sleep(1);
            int a = 100;
            a /= 0;
            exit(0);
        }
        int status = 0;
        waitpid(id, &status, 0);
        cout 
        << "father:" << getpid()<<" " 
        << "child:" << id <<" "
        << "exit sig:" << (status & 0x7F) <<" "
        << "is core:" << ((status >> 7) & 1) << " "
        << 
        endl;
        return 0;
    }
    
  • 【Linux】学习-进程信号_第14张图片

观察发现,core dump标记位为1,且也产生了子进程的core dump文件,对应的退出信号是8号信号

为什么生产环境下core dump文件默认会被关闭?

  • 由于生产环境下存在大量正在测试中的服务,服务器多的情况下若此时大量测试都异常挂掉的话,则磁盘会存在大量的core dump文件,也有可能直接把磁盘占满了。

调用系统函数发送信号

除了可以通过组合键的方式向前台运行的进程发送命令之外,我们还可以通过命令的方式向前台或者后台运行的进程发送命令

kill命令

【Linux】学习-进程信号_第15张图片

  • 将死循环程序放前台运行,Ctrl+c或者kill -9 命令都能直接杀死
  • 将死循环程序放后台运行,Ctrl+c命令杀不死,必须要用kill -9 命令才能杀死

image-20231009120332331

9号信号能够直接杀死进程

  • kill -信号编号/-信号名字 进程pid 能够向对应进程发送信号

kill函数

  • kill命令能够向对应进程发送信号的本质是通过kill函数系统调用实现的

    【Linux】学习-进程信号_第16张图片

能够向对应pid的进程发送sig信号,若成功发送则返回0,失败则返回-1.

image-20231009121035756

通过kill函数实现一个命令行mykill命令

static void Usage(string proc)
{
    cout<<"Usage:\r\n\t"<<proc<<" signumber processid"<<endl;
}
int main(int argc,char* argv[])
{
    if(argc!=3)
    {
        Usage(argv[0]);
        exit(1);
    }
    int signumber=atoi(argv[1]);
    int procid=atoi(argv[2]);

    kill(procid,signumber);
    return 0;
}

【Linux】学习-进程信号_第17张图片

通过自己写的kill命令也能够发送信号杀死进程。

raise函数

  • raise函数也是可以用来发送信号的系统接口,但是其相较于kill函数它只能给自己的进程发送信号

    【Linux】学习-进程信号_第18张图片

    int main()
    {
        while(1)
        {
            cout<<"start:"<<"my pid:"<<getpid()<<endl;;
            sleep(1); 
            raise(9);
        }
        return 0;
    }
    

【Linux】学习-进程信号_第19张图片

abort函数

  • abort函数能够向当前进程发送确定的6号信号

    【Linux】学习-进程信号_第20张图片

  • 通常情况下abort用来进行终止进程,由于没有参数也没有返回值,用法类似于exit,但不同的是abort是属于发送6号信号来终止的并且还自带核心转储:

    image-20231009142324400

int main()
{
    while(1)
    {
        cout<<"start:"<<"my pid:"<<getpid()<<endl;;
        sleep(1); 
        abort();
    }
    return 0;
}

【Linux】学习-进程信号_第21张图片

软件条件产生信号

我们在进程间通信就接触过软件条件产生的信号,我们在使用”管道“进行进程间通信时,当读端进程退出时,写端仍不断在向管道内写数据,此时系统就会向管道发送SIGPIPE信号终止写端程序

image-20231009193253232

验证SIGPIPE信号

  • 编码,其中创建管道文件,让父进程做为读端读取管道文件,子进程作为写端向管道内写文件

  • 两种方法验证:

    • 利用父进程能够回收子进程退出信号,打印退出信号验证

      int main()
      {
      
          int pipefd[2]={0};
          int n=pipe(pipefd);
          assert(n==0);
          pid_t id= fork();
          if(id==0)
          {
              //child
              close(pipefd[0]);
              const char* buffer="i am child ...";
              while(1)
              {
                  write(pipefd[1],buffer,strlen(buffer));
                  sleep(1);
              }       
          }
          //father
          close(pipefd[1]);
          int count=5;
          char buf[128];
          while(count--)
          {
              read(pipefd[0],buf,sizeof(buf)-1);
              cout<<buf<<endl;
              sleep(1);
          }
          cout<<"now close read..."<<endl;
          close(pipefd[0]);
          int status=0;
          waitpid(id,&status,0);
          cout<<"exit signal:"<<(status&0x7F)<<endl;
          return 0;
      }
      

      【Linux】学习-进程信号_第22张图片

    • 通过信号捕捉直接捕捉SIGPIPE信号验证

      void handler(int signum)
      {
          cout<<"catch signal:"<<signum<<endl;
          exit(0);
      }
      int main()
      {
      
          int pipefd[2]={0};
          int n=pipe(pipefd);
          assert(n==0);
          pid_t id= fork();
          if(id==0)
          {
              //child
             // signal(SIGPIPE,handler);
              close(pipefd[0]);
              const char* buffer="i am child ...";
              while(1)
              {
                  write(pipefd[1],buffer,strlen(buffer));
                  sleep(1);
              }       
          }
          //father
          close(pipefd[1]);
          int count=5;
          char buf[128];
          while(count--)
          {
              read(pipefd[0],buf,sizeof(buf)-1);
              cout<<buf<<endl;
              sleep(1);
          }
          cout<<"now close read..."<<endl;
          close(pipefd[0]);
          return 0;
      }
      

      【Linux】学习-进程信号_第23张图片

alarm函数

调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。

image-20231009194517771

【Linux】学习-进程信号_第24张图片

返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某人要小睡一觉,设定闹钟为30分钟之后响,20分钟后被人吵醒了,还想多睡一会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。

利用alarm测试服务器算力

  • 我们可以设定一个1秒的闹钟,查看cpu能在1s内把一个变量累加到多少
int main()
{
    int count=0;
    alarm(1);
    while(1)
    {
        count++;
        cout<<count<<endl;
    }
    return 0;
}

image-20231009201015097

执行结果是能在1s内将变量累加到十万级别,但这并不是cpu的真正算力。

  • 由于我们在计算过程中还加入了打印功能,这其中涉及了io操作,并且我们实在云服务器上测试的,还有网络发送的影响,因此我们想算真正的算力,需要通过一直加到信号递达时,我们通过自定义捕捉信号来查看此时累加到了多少,这样可以避免io和网络的影响。

    int count=0;
    void handler(int signum)
    {
        cout<<"catch signal: "<<signum<<" count:"<<count<<endl;
        exit(1);
    }
    int main()
    {
        signal(SIGALRM,handler);
        
        alarm(1);
        while(1)
        {
            count++;
        }
        return 0;
    }
    

    image-20231009201030303

此时我们发现,累加的结果已达到亿级别,可见io带上网络之后,效率是非常低的!

如何理解软件条件给系统发送信号

  • OS先识别到某种软件条件出触发或者不满足
  • OS构建信号,发送给指定的进程

硬件异常产生信号

  • 硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以0的指令,CPU的运算单元会产生异常,内核将这个异常解释 为SIGFPE信号发送给进程。再比如当前进程访问了非法内存地址,MMU(和页表协同建立虚拟地址与物理内存间映射的硬件)(会产生异常,内核将这个异常解释为SIGSEGV信号发送给进程。

cpu是如何知道哪个进程出现了异常?

  • CPU内部,有很多寄存器,其中有一个寄存器叫做状态寄存器,其原理是跟位图一样,各种异常都会有对应的标记位,如溢出标记位等,cpu在计算完毕后,OS会自动进行检测状态标记位,如果识别到有被置为1的标记位,此时cpu的数据正好是当前进程正在运行的上下文数据,其中就有指向进程控制块task_struct 的指针,只要通过此指针提取进程pid,OS就能向进程发送相应的信号,进程便会在合适的时候进行处理。

    出现了硬件异常,进程一定会退出吗?

  • 不一定!只是进程对硬件异常的默认处理方式是退出,但是即便我们不退出,我们也无法做其他动作,因为状态寄存器会一直标记有此异常,会不断发送信号!我们通过代码来验证一下:

    野指针问题时,对硬件信号进行捕捉但进程并不退出会发生什么?

    void handler(int signum)
    {
        cout<<"receive signal:"<<signum<<endl;
        sleep(1);
    }
    int main()
    {
        signal(SIGSEGV,handler);
        int *p=NULL;
        *p=100;
        while(1);
        return 0;
    }
    

    【Linux】学习-进程信号_第25张图片

  • 通常的,野指针或者越界问题都是需要通过地址来找到对应的值,而我们语言上所涉及的地址都是虚拟地址,是需要通过映射来转化成物理地址的,而转化地址需要用到页表和MMU(Memory Manager Unit),MMU属于硬件,我们需要知道,不仅仅是CPU才具有寄存器的,几乎任何外设都可以存在寄存器,因此MMU内部也存在状态寄存器!

  • MMU检测到我们所使用的野指针和越界访问是属于非法地址!因此会将状态寄存器中的段错误标记为置为1,而OS会自动进行检测,识别被置为1的标记位后,就会向此进程发送相应的SIGSEGV(段错误)—11号信号,进程收到信号后,由于我们进行了信号捕捉,并且没有进行进程的退出,因此不会执行默认处理,而此时我们的代码也存在死循环,因此进程一直都不会退出,此时CPU仍进行正常调度,调度后当前进程就会被切换,上下文数据也会被保护起来,包括状态寄存器内的异常标记位,等到下一次又调度到此进程时,上下文数据重新被恢复,因此OS检测后又会发送信号,造成了不断执行我们的自定义捕捉代码的困境

  • 所以说即使我们不退出,我们也做不了什么,因此捕捉信号并打印错误信息后,直接退出。

阻塞信号

引入问题

  • 我们一直说,信号处理是在合适的时候,那么什么时候是合适的?
  • 如果并不是立即处理的,信号是否需要暂时被记录下来?记录在哪里?
  • 如果没有对信号做捕捉,信号怎么知道作何处理?
  • OS向进程发送信号的过程是怎么样的呢?

信号其他相关常见概念

  • 实际执行信号的处理动作称为信号递达(Delivery)
  • 信号从产生到递达之间的状态,称为信号未决(Pending)。
  • 进程可以选择阻塞 (Block )某个信号
  • 被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作.
  • 注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

内核表示

信号在内核中的示意图

【Linux】学习-进程信号_第26张图片

解释示意图

  • 每个信号都有两个标志位分别表示阻塞(block)未决(pending),还有一个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图的例子中,SIGHUP信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
  • SIGQUIT信号未产生过,一旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数sighandler。 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信号一次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。本章不讨论实时信号

解释引入的问题

  • 合适的时候:阻塞标志位为0,代表此信号进程可以立即递达

  • 需要被记录下来,未决标记为就是记录此信号尚未被处理

  • 信号被递达后,会执行默认处理动作,若信号被捕捉,则会将用户空间的handler函数填入handler表中,handler表本质是一个函数指针数组,数组下标为信号编号,数组内容便是处理动作

  • 进程收到信号时,进程控制块内的pending表中对应的信号会先被设置成1,表示此信号现在处于未决状态,系统会定时检查未决状态表处于未决状态的信号,若发现此时有一个未决信号的阻塞状态是0,即此信号没有被阻塞,则就执行对应的处理方法,如果是检查到是被阻塞了,则不做处理

    默认处理动作

handler表中信号的处理动作有三种:

  • 默认动作SIG_DFL,做默认的动作
  • 忽略动作SIG_IGN,代表做了动作了,但是动作为忽略
  • 用户自定义动作

sigset_t类型

从上图来看,每个信号只有一个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。 阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。

  • 省流:可以理解为pending表和blocking表都是位图结构,但不是我们传统的0和1来代表无和有,而是操作系统自己提供的数据类型来代表无效和有效。
  • 此类型不允许用户自己进行位操作,OS给我们提供了对应的信号集操作函数来操作位图
  • 使用此类型时和用内置类型无区别

信号集操作函数

sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_ t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印sigset_t变量是没有意义的。

接口介绍

#include 
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset (sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
int sigismember(const sigset_t *set, int signo);
  • 函数sigemptyset初始化set所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含 任何有效信号
  • 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号。
  • 注意,在使用sigset_ t类型的变量之前,一定要用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
  • 这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种 信号,若包含则返回1,不包含则返回0,出错返回-1。

关于使用,我们在介绍完其他接口后一起使用。

sigprocmask阻塞信号集

调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集)。

#include 
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 
返回值:若成功则为0,若出错则为-1
  • 第三个参数oset是输出型参数,相当于保存修改前的信号屏蔽字,如果不需要则可以设置为空

  • 第二个参数set是将现有的信号屏蔽字传进去,并根据第一个参数how的指示具体来更改,how的方法有以下三种:我们假设现有信号屏蔽字sigset_t mask

    【Linux】学习-进程信号_第27张图片

**注意:**如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。

sigpending读取未决信号集

#include 
int sigpending(sigset_t *set);

作用:获取当前调用进程的pending信号集,并填入参数set中,调用成功返回1,出错则返回0。

实验———测试信号集操作函数

  • 在内核宏定义中,9号和19号信号是不可被阻塞的信号:

    #define	SIGKILL		9	/* Kill, unblockable (POSIX).  */
    #define	SIGSTOP		19	/* Stop, unblockable (POSIX).  */
    
  • 我们先测试一下,将2号信号阻塞,并不断打印当前进程的pending信号集,接着向进程发送2号信号并观察现象

    void handler(int signum)
    {
      cout<<"catch a signal: "<<signum<<endl;
    }
    static void showPending(sigset_t &pending)
    {
        for(int sig=1;sig<=31;sig++)
        {
            if(sigismember(&pending,sig)){
                cout<<'1';
            }
            else{
                cout<<'0';
            }
        }
        cout<<endl;
    }
    int main()
    {
        signal(2,handler);
        //定义
        sigset_t obset,bset,pending;
        //初始化
        sigemptyset(&bset);
        sigemptyset(&obset);
        sigemptyset(&pending);
        //添加要屏蔽的信号
        sigaddset(&bset,SIGINT);
        //设置到当前进程内核
        int n=sigprocmask(SIG_BLOCK,&bset,&obset);
        assert(n==0);
        (void)n;
    
        cout<<"阻塞2号信号成功...,pid: "<<getpid()<<endl;
        
        //不断打印pending信号集
        int count=0;
        while(true)
        {
            sigpending(&pending);
            showPending(pending);
            sleep(1);
            count++;
            if(count==20)
            {
                int n=sigprocmask(SIG_SETMASK,&obset,nullptr);
                assert(n==0);
                (void)n;
                cout<<"解除对2号信号阻塞成功"<<endl;
            }
        }
        return 0;
    }
    
  • 现象:

【Linux】学习-进程信号_第28张图片

首先我们对2号信号做阻塞处理,在前10秒时,我们通过Ctrl-c发送2号信号,可以观察到pending信号集2号信号处标志位被标记成了有效,由‘0’ 变为了 ‘1’ 代表此信号尚未被处理,10秒过后,我们对2号信号解开阻塞,此时会先有一次处理动作,然后pending标记位也变成了无效,由‘1’ 变为了 ‘0’,此后再发送2号信号都是捕捉成功。

  • 现在我们知道了若我们对某一个信号进行阻塞,并发送信号时,此信号的pending标记位会从无效变有效,其实就是告诉操作系统这还有个信号处于未决状态尚未处理,我们现在来测试一下若对所有的信号都进行阻塞,并发送每一个信号,会是什么样的效果。

    • 我们先写一个脚本文件,能够一直不断给signal对应的pid从1-31号发送信号

      【Linux】学习-进程信号_第29张图片

      注意,此时我们没有忽略无法被阻塞的9号和19号信号,也就是9号信号也会被发送,但是我们对他也进行了阻塞处理,我们观察是否真的无法被阻塞。

    • static void showPending(sigset_t &pending)
      {
          for(int sig=1;sig<=31;sig++)
          {
              if(sigismember(&pending,sig)){
                  cout<<'1';
              }
              else{
                  cout<<'0';
              }
          }
          cout<<endl;
      }
      static void blockSig(int signum)
      {
          sigset_t bset;
          sigemptyset(&bset);
          sigaddset(&bset,signum);
          int n=sigprocmask(SIG_BLOCK,&bset,nullptr);
          assert(n==0);
          (void)n;
      }
      int main()
      {
          //block all signal
          for(int sig=1;sig<=31;++sig)
          {
              blockSig(sig);
          }
      
          sigset_t pending;
          sigemptyset(&pending);
          while(true)
          {
              sigpending(&pending);
              showPending(pending);
              sleep(1);
          }
          return 0;
      }
      

【Linux】学习-进程信号_第30张图片

  • 我们观察到,就算9号信号被阻塞了,我们发送9号信号时,照样会将进程杀死,因此9号信号是无法被阻塞的!而9号信号之前的信号,pending表相应的位置都被置为了有效,和第一个测试一样。

  • 接下来,我们跳过9号和19号这两个无法被阻塞的信号,再次观察

    【Linux】学习-进程信号_第31张图片

    脚本:跳过9号和19号信号

    【Linux】学习-进程信号_第32张图片

  • 结论:9号和19号信号不可阻塞!

捕捉信号

内核捕捉本质

我们在前面有初步的学习了一下信号的捕捉signal函数,他的功能是能对某一个信号的handler表进行修改,更改为用户自定义的处理动作函数,在此信号抵达时就会调用用户自定义的函数,这就叫信号捕捉。那么问题来了:内核是如何实现信号的捕捉的呢?

内核如何实现信号的捕捉?

  • 由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。
  • 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
  • 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函 数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。
  • sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。

有点复杂,没关系,我们一点一点来认识:

内核级页表

【Linux】学习-进程信号_第33张图片

  • 我们平常所说的页表,还分为用户级页表和内核级页表
  • 用户级页表是每个进程都独立有一份的且每个进程的用户级页表都不一样,他的作用是映射用户空间的数据到物理内存
  • 内核级页表是所有进程共享的,也就是各个进程的内核空间的数据都是通过用一个页表映射到物理内存的
  • 进入内核空间是需要有权限的!普通用户不能访问内核空间的数据,只有内核状态下才可以

用户态和内核态

  • 用户态是一个受管控的状态,是我们平常写完代码后,在执行普通用户代码时候的状态
  • 内核态是一个操作系统执行系统级别代码时候的一个状态,也就是我们平常在使用系统调用的时候,系统调用内有内置代码将此时的用户态切换到内核态并执行代码,具备非常高的优先级

举个例子来理解,当我们自己写的代码中有诸如open这类的系统调用接口,我们首先在用户态执行其他的普通代码,执行到系统调用时,由于open系统调用内置有从用户态切换到内核态的代码,所以此时对应的状态也变成了内核态,所以此时我们有了访问内核空间的权限,内核态下就会到内核空间中找到关于open系统调用的相关数据,并通过内核级页表映射找到物理内存中内核空间的open系统调用的实现,并执行相关操作。

简单理解如何切换到内核态执行系统调用

  • 我们提到系统调用中内置有从用户态切换到内核态的动作,其实,这个动作就是汇编命令:int80
  • CPU有两套寄存器,一套是可见的,另一套是不可见的
  • int80 汇编命令能够将CPU中不可见寄存器CR3用户态切换成CR1内核态
  • 在进程地址空间中代码区执行系统调用时,会先执行int80 切换状态,随后跳到内核空间进行身份审查便能顺利通过,再通过内核级页表映射到物理内存的内核空间,找到对应的代码实现成功执行

什么时候会进行状态切换?

除了刚刚提到的调用系统调用时会从用户态切换为内核态,还有别的情况下也会进行状态切换

  • 产生异常缺陷陷阱时
  • 进程切换时

内核态切换为用户态:

  • 异常缺陷等处理完毕
  • 进程切换完毕
  • 系统调用结束返回

了解完这些,我们说回信号

系统什么时候对信号进行检测?内核又是如何实现信号捕捉的?

  • 每一次从内核态切换回用户态的时候,操作系统都会在切换回去之前进行信号的检测和处理,具体检测便是检测pending表中是否有处于未决的信号需要进行递达
  • 要注意的是,pending表,block表和handler表等信号相关的字段都是在进程控制块内部的,而这都是属于内核范畴了,因此我们在返回用户态之前,也就是内核态,是有权限进行这些检查的,这也是在返回前检查的原因。
  • 若此时对其中一个被用户进行自定义捕捉的处于未决状态的信号进行递达时,处理该信号时,会先返回用户态后,在用户态中执行对应的自定义处理动作,然后再返回内核态中将pending表中的有效位置于无效,再对信号进行检测,确保没有信号需要处理之后才返回用户态继续执行主控制流程的代码,这就是实现信号捕捉的全过程!

【Linux】学习-进程信号_第34张图片

【Linux】学习-进程信号_第35张图片

我们可以用这样 “ 8 ” 来形象的表示状态的切换,8与直线的交点代表切换了多少次,而8中间的交点则代表从内核返回用户态时做的信号检测

为什么执行用户自定义捕捉动作时,要切回用户态?

  • 由于内核态是具有很高权限且优先级也很高的状态,所以为了防止对内核的数据进行修改等操作,不能用内核态直接去执行用户区的代码,因为无法保证用户区的代码为合法代码,操作系统并不信任任何用户,因此会先切回用户态。

sigaction

我们在前面介绍了信号捕捉函数signal,除了signal之外,我们还可以使用sigaction函数来进行信号捕捉

#include 
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
  • 【Linux】学习-进程信号_第36张图片
  • sigaction结构体内容大致如图上所示
  • sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非 空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体,且act是一个输入型参数,oact是一个输出型参数
  • 成员变量:sa_handler,若将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函 数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
  • 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。与其相关的成员变量为:sa_mask
  • 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
  • sa_flags字段包含一些选项,本章的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,本章不详细解释这两个字段,有兴趣的同学可以在了解一下。

测试sigaction函数

  • 使用sigaction函数捕捉2号信号

  • 自定义捕捉函数可以在倒计时10s内打印本进程的pending字段

  • 我们分别两次向进程发送2号信号,观察现象

  • void handler(int signum)
    {
        cout<<"catch a signal: "<<signum<<endl;
        cout<<"catch a signal: "<<signum<<endl;
        cout<<"catch a signal: "<<signum<<endl;
        cout<<"catch a signal: "<<signum<<endl;
    
        sigset_t pending;
        int count=10;
        while(count)
        {
            sigpending(&pending);
            showPending(pending);
            count--;
            sleep(1);
        }
    
    }
    int main()
    {       
        struct sigaction act,oact;
        act.sa_flags=0;
        sigemptyset(&act.sa_mask);
        act.sa_handler=handler;
    
        sigaction(2,&act,&oact);
        cout<<"default action: "<<(int)(oact.sa_handler)<<endl;
    
        while(true) sleep(1);
        return 0;
    }
    
  • 【Linux】学习-进程信号_第37张图片

可重入函数

【Linux】学习-进程信号_第38张图片

观察上图函数的执行:

  • main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2

  • 插入操作的 两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续 往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后 向链表中插入两个节点,而最后只有一个节点真正插入链表中了

像上例这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。

如果一个函数符合以下条件之一则是不可重入的

  • 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
  • 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。

volatile

作用

  • volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

什么意思呢?我们通过代码来看看:

int flag=0;
void handler(int signum)
{
    (void)signum;
    cout<<"change flag:"<<flag;
    flag=1;
    cout<<"-> "<<flag<<endl;
}
int main()
{
    signal(2,handler);
    while(!flag);
    cout<<"exit->normally"<<endl; 
    return 0;
}

image-20231017173434636

  • 我们通过捕捉2号信号将flag修改之后时main函数中依靠flag的死循环退出并正常退出程序,但若我们在编译时带上 -O3 选项

    image-20231017172224906

  • 再次执行代码

【Linux】学习-进程信号_第39张图片

  • 我们发现再次发送2号选项已经无法将此进程退出了,也就就算flag被修改成了1也无法退出main函数中的死循环了,这是为什么呢?

-O3优化代码

我们在编译的时候,带上了 -O3 选项之后,就出现了上述情况,是因为此选项是在告诉编译器,编译此份代码时将其优化到最高级别的优化状态,而编译器也照做了,在其检查代码的时候,发现main函数对flag变量的使用仅仅是判断语句,没有任何语句是用来修改flag的,因此编译器就会将flag此时的值直接放入到寄存器中,这样一到判断语句时,cpu便直接在寄存器中拿flag的值来进行判断,而不再去访问内存中来检测,这样子大大提高了速度和效率,但是若flag此全局变量的值若在外部被修改,cpu也不会知道,一直用寄存器的值检测。这样就出现了死循环不退出的情况。

【Linux】学习-进程信号_第40张图片

而volatile关键字正是为了避免这种情况,被volatile关键字修饰的变量,就算代码被优化成最高级别,cpu也照样会去内存中查看此变量的值,这就叫保证了内存的可见性。

image-20231017173620193

image-20231017173554843

就算被优化了也能够正常退出

SIGCHLD

进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略(若父进程没有进行等待,且默认处理动作为忽略,则子进程会变成僵尸进程!),父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

测试SIGCHLD

void handler(int signum)
{
    int id;
    while((id=waitpid(-1,nullptr,WNOHANG))>0)
    {
        printf("wait child %d successfully!\n",id);
    }
}
int main()
{
    signal(SIGCHLD,handler);
    pid_t id=fork();
    if(id==0)
    {
        printf("child pid: %d\n",getpid());
        sleep(3);
        exit(1);
    }
    while(1)
    {
        printf("father are doing something!\n");
        sleep(1);
    }
    return 0;
}

4dZqx-1707561889261)]

而volatile关键字正是为了避免这种情况,被volatile关键字修饰的变量,就算代码被优化成最高级别,cpu也照样会去内存中查看此变量的值,这就叫保证了内存的可见性。

[外链图片转存中…(img-1K8RsTCl-1707561889261)]

[外链图片转存中…(img-op7B67FO-1707561889261)]

就算被优化了也能够正常退出

SIGCHLD

进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。

其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略(若父进程没有进行等待,且默认处理动作为忽略,则子进程会变成僵尸进程!),父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。

测试SIGCHLD

void handler(int signum)
{
    int id;
    while((id=waitpid(-1,nullptr,WNOHANG))>0)
    {
        printf("wait child %d successfully!\n",id);
    }
}
int main()
{
    signal(SIGCHLD,handler);
    pid_t id=fork();
    if(id==0)
    {
        printf("child pid: %d\n",getpid());
        sleep(3);
        exit(1);
    }
    while(1)
    {
        printf("father are doing something!\n");
        sleep(1);
    }
    return 0;
}

【Linux】学习-进程信号_第41张图片

你可能感兴趣的:(linux,学习,服务器)