- 1.信号入门
- 2.产生信号
- 2.1 键盘产生信号
- 2.2 程序异常产生信号
- 2.3 使用系统调用发送信号
- 2.4 软件条件产生信号
- 3.保存信号
- 4. 处理信号
- 5.作业控制
在生活中也存在着大量的信号,我们将识别信号的过程分为三个:信号产生前,信号产生时,信号产生后。
信号产生前:当信号还没有产生的时候,对于普通人来讲,我们是知道信号产生后应该怎么做的。这是因为我们能够识别出信号,因为有人曾经给过我们“教育”的过程,让我们将信号特征、如何识别以及对应的处理过程记住了。信号产生的时候,信号和人之间是一种异步关系。
信号产生时:当信号产生的时候,我们不一定立马去处理信号,因为有可能你正在做其他优先级更高的事情。这时信号已经到来但是我们暂时没有处理,我们可以选择在合适的时候处理,所以一定要有某种方式记录信号的产生。
信号产生后:我们开始处理信号了,我们可以选择:a.处理信号的默认行为 b.自定义行为 c.忽略信号(非常规)
而上述的人在系统中就相当于进程:
信号产生前:进程虽然现在没有收到信号,但是进程是知道收到信号之后应该怎么做的,所以进程内部一定要能够识别信号,其实程序员在设计进程的时候,已经内置了处理方案,所以信号属于进程内部特有的特征。
信号产生时:当信号到来的时候,进程可能在处理更加重要的事情,信号可能不会立即被处理,等合适的时候再进行处理。但是信号来了,在信号处理之前,信号必须要暂时被进程保存起来。
信号产生后:进程开始处理信号的方式:a.默认行为(终止进程,暂停,继续运行等) b.自定义行为 c.忽略信号
那么信号是如何发送以及记录的呢?信号的记录是在进程的PCB里,本质是为了记录信号是否产生,用位图这个数据结构来保存数据。那么进程收到信号,本质就是进程内的信号位图被操作系统修改了(只有操作系统才有资格去修改进程内的数据,因为OS是进程的管理者)。本质是OS直接去修改目标进程 task_struct 中的信号位图。信号发送只有OS有资格,但是信号发送的方式却有很多种。
技术应用角度的信号:
一般情况下,当程序运行起来,我们 ctrl + c 就会中止该进程,例如一个死循环用来终止循环。这是因为我们通过键盘让操作系统向进程发出了2号信号,当进程收到2号信号做出了响应,而这种响应是已经在进程里写好了的。 signal 系统调用可以将一个信号对应的进程操作进行编写:
其中,参数2是一个函数指针,通过对回调函数的编写来确定该信号所对应的方法。如果我们将2号信号所对应的方法更改,我们再 ctrl + c 时进程就不会退出了。
注:
1.Ctrl-C 产生的信号只能发给前台进程。一个命令后面加个&可以放到后台运行,这样Shell不必等待进程结束就可以接受新的命令,启动新的进程。
2. Shell可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接到像 Ctrl-C 这种控制键产生的信号。
3. 前台进程在运行过程中用户随时可能按下 Ctrl-C 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步(Asynchronous)的。
4. 信号是进程之间事件异步通知的一种方式,属于软中断。
SIGINT的默认处理动作是终止进程,SIGQUIT的默认处理动作是终止进程并且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 -c 1024。
我们写一段错误的代码,Core Dump核心转储后对core文件进行调试,来查看进程退出的信息:
代码运行中的时候出错了,我们也要有办法判定是什么原因出错了,第一种方法就是调试,第二种方式就是利用核心转储功能。核心转储把进程在内存中的核心数据转储到磁盘上,core.pid就是核心转储文件。一般云服务器的线上生产环境,核心转储功能是默认关闭的(避免有重大bug导致机器进程一直重启一直产生core文件填满磁盘,加大运维的难度)。
2.2 程序异常产生信号为什么C/C++程序会崩溃? 本质就是进程收到了操作系统的信号。所有的错误都一定会在硬件层面上有所表现,进而被操作系统识别到,然后向进程发送相应的信号让进程执行某些操作。
在之前父进程等待子进程是,waitpid 的有一个参数 status 的组成就是:
其中core dump 表示进程退出是否core dump。
捕捉所有信号(有些信号是不能被捕捉的比如9):
DEscriptION
The kill() system call can be used to send any signal to any process group or process.
If pid is positive, then signal sig is sent to the process with the ID specified by pid.
If pid equals 0, then sig is sent to every process in the process group of the calling process.
If pid equals -1, then sig is sent to every process for which the calling process has permission to send signals, except for process 1 (init), but see below.
If pid is less than -1, then sig is sent to every process in the process group whose ID is -pid.
If sig is 0, then no signal is sent, but error checking is still performed; this can be used to check for the existence of a process ID or process group ID.
For a process to have permission to send a signal it must either be privileged (under Linux: have the CAP_KILL capability), or the real or effective user ID of the sending process must equal the real or saved set-user-ID of the target process. In the case of SIGCONT it suffices when the sending and receiving processes belong to the same session.
RETURN VALUE
On success (at least one signal was sent), zero is returned. On error, -1 is returned, and errno is set appropriately.
DEscriptION
The raise() function sends a signal to the calling process or thread. In a single-threaded program it is equivalent to kill(getpid(), sig); In a multithreaded program it is equivalent to pthread_kill(pthread_self(), sig);
If the signal causes a handler to be called, raise() will return only after the signal handler has returned.
RETURN VALUE
raise() returns 0 on success, and nonzero for failure.
DEscriptION
The abort() first unblocks the SIGABRT signal, and then raises that signal for the calling process. This results in the abnormal termination of the process unless the SIGABRT signal is caught and the signal handler does not return (see longjmp(3)).
If the abort() function causes process termination, all open streams are closed and flushed.
If the SIGABRT signal is ignored, or caught by a handler that returns, the abort() function will still terminate the process. It does this by restoring the default disposition for SIGABRT and then raising the signal for a second time.
RETURN VALUE
The abort() function never returns.
调用alarm函数可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
DEscriptION
alarm() arranges for a SIGALRM signal to be delivered to the calling process in seconds seconds.
If seconds is zero, any pending alarm is canceled.
In any event any previously set alarm() is canceled.
上面所说的所有信号产生,最终都要有OS来进行执行,为什么?OS是进程的管理者
信号的处理是否是立即处理的?在合适的时候信号如果不是被立即处理,那么信号是否需要暂时被进程记录下来?记录在哪里最合适呢?一个进程在没有收到信号的时候,能否能知道,自己应该对合法信号作何处理呢?
如何理解OS向进程发送信号?能否描述一下完整的发送处理过程?
实际执行信号的处理动作称为信号递达(Delivery)。
信号从产生到递达之间的状态,称为信号未决(Pending)。
进程可以选择阻塞 (Block ) (可以处理但是不去处理)某个信号。
被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。
信号集与信号集操作函数
因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
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。
sigprocmask
调用函数sigprocmask可以读取或更改进程的信号屏蔽字(阻塞信号集block)。
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则 更改进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了how参数的可选值。如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
sigpending
进程收到信号之后不是立即处理信号的,而是在合适的时候,那么什么是合适的时候呢?当系统从内核态切换到用户态的时候会进行信号处理。
进程无论怎样切换,都能看到操作系统,但是不一定都能访问操作系统。内核态通常用来执行OS代码,是一种权限非常高的状态,用户态是一种用来执行普通用户代码的状态,是一种受监管的普通状态。
用户态切换为内核态:系统调用,时间片到了导致进程切换,异常,中断,陷阱等。
内核态切换为用户态:系统调用返回,进程切换完毕,异常,中断,陷阱处理完毕等。
在CPU上有相应的寄存器去标识相应的状态,当前进程的页表也会被保存到CPU相应的寄存器里,状态切换的本质就是页表的切换(用户级页表和内核页表),状态发生变化。
信号的处理过程:
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下: 用户程序注册了SIGQUIT信号的处理函数sighandler。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 两个独立的控制流程。 sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
信号的自定义捕捉 sigaction
sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回- 1。signum是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oldact指针非空,则通过oact传出该信号原来的处理动作。act和oldact指向sigaction结构体。
将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags字段包含一些选项,这里的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数,
可重入函数
main函数调用insert函数向一个链表head中插入节点node1,插入操作分为两步,刚做完第一步的 时候,因为硬件中断(时间片到了)使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换 到sighandler函数,sighandler也调用insert函数向同一个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第一步之后被打断,现在继续做完第二步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有一个节点真正插入链表中,第二个节点发生了内存泄漏。
像insert这样,insert函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入,insert函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
之前自己调用的函数以及自己写的函数,还有STL各种接口,容器等等基本全部都是“不可重入函数”。如果一个函数符合以下条件之一则是不可重入的:
1.调用了malloc或free,因为malloc也是用全局链表来管理堆的。
2.调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的方式使用全局数据结构。
volatile
如果编译器将main中flag优化到了寄存器里,那么当信号处理时将flag更改,寄存器里的flag是不会更改的,当出现这种情况,上面的程序就会一直死循环。当然我们是不希望这样被优化到寄存器里的,所以我们可以在flag前加上volatile关键字,使得保持内存的可见性,寄存器就会在内存中取新的flag值去更新flag。
SIGCHLD信号
进程讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻 塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不 能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自 定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程 终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略 通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
在bash只允许存在一个前台进程
& 放在后台运行
jobs 查看后台进程列表
fg 提到前台
ctrl z 将前台转到后台
bg 将后台暂停的进程运行
ps -o 指定要查看的列名称



