..

Linux Kernel: Signals

信号最早在 Unix 系统中被引入,用于在用户态进程间通信;内核也用信号通知进程系统所发生的事件

1-信号的作用

信号(signal)是很短的消息,可以被发送到一个进程或者一组进程。发送给进程的唯一信息通常是一个数,以此来标识信号。

使用信号有两个目的:

  1. 让进程知道已经发生了一个特定的事件
  2. 强迫进程执行其自己代码中的信号处理程序

这两个目的并不互斥

上图是 Linux 中前 31 个常规信号(regular signal)。除了常规信号,POSIX 标准还引入了新的信号,被称为实时信号(real-time signal):在 Linux 中的编码范围为 32~64。

  • 实时信号必须排队以便发送的多个信号能够被接收到
  • 同种类型的常规信号并不排队:如果一个常规信号被连续发送多次,那么只有其中一个信号能够发送到接收进程

Linux 内核并不使用实时信号,但还是通过几个特定的系统调用完全实现了 POSIX 标准

许多系统调用允许应用进程发送信号,并决定应用进程如何响应所接收到的信号。下图是一些与信号相关的重要系统调用。

信号的一个比较重要的特点:信号可以随时被发送给状态不可预知的进程

  • 发送给非运行进程的信号必须由内核保存,直到进程恢复执行

  • 阻塞一个信号要求信号的传递拖延,直到阻塞解除

    使得信号产生一段时间之后才能对其传递这一问题变得更加严重

内核将信号传递分为两个阶段:

  1. 信号产生

    内核更新目标进程的数据结构以表示一个新信号已被发送

  2. 信号传递

    内核强迫目标进程对信号做出反应:要么改变目标进程的执行状态,要么开始执行一个特定的信号处理程序,要么两者都是

每个产生的信号至多被传递一次

信号是可消费资源,一旦已经被传递出去,那么进程描述符中有关这个信号的所有信息都被取消

挂起信号(pending signal):已经产生但还没有被传递的信号。任何时候,一个进程仅存在给定类型的一个挂起信号,同一进程同种类型的其他信号不会被排队,只是简单地丢弃。但是,实时信号有所不同:同种类型的挂起信号可以有多个。

挂起信号(pending signal):已经产生但还没有被传递的信号。任何时候,一个进程仅存在给定类型的一个挂起信号,同一进程同种类型的其他信号不会被排队,只是简单地丢弃。但是,实时信号有所不同:同种类型的挂起信号可以有多个。

信号可以保留不可预知的挂起时间

为了实现信号,内核需要做到:

  1. 记住每个进程阻塞哪些信号
  2. 当从内核态切换到用户态时,对每一个进程都需要检查是否有一个信号已到达

    几乎发生在每个定时中断中(每毫秒)

  3. 确定是否可以忽略信号:当且仅当以下条件都满足时可以忽略信号:
    • 目标进程没有被另一个进程跟踪
    • 信号没有被目标进程阻塞
    • 信号被目标进程忽略
  4. 处理信号:信号可能在进程运行期间的任意时刻请求把进程切换到一个信号处理函数,并在该函数返回后恢复原来执行的上下文

1-1 传递信号之前的操作

进程可以对信号做出如下应答:

  1. 显示地忽略信号
  2. 执行与信号相关的缺省操作

    由内核定义的缺省操作有:

    • Terminate:进程被终止(kill)
    • Dump:进程被终止,并且如何可能的话,创建包含进程执行上下文的核心转储文件
    • Ignore:信号被忽略
    • Stop:进程被停止,将进程设置为 TASK_STOPPED 状态
    • Continue:如果进程被停止(TASK_STOPPED),则将其设置为 TASK_RUNNING 状态
  3. 通过调用相应的信号处理函数捕获信号

需要注意的是,对信号的阻塞与忽略是不同的:

  • 如果信号被阻塞,则不会被传递,直到信号解除阻塞之后才传递该信号
  • 被忽略的信号总是被传递,只不过没有进一步的操作而已

另外,SIGKILL & SIGSTOP 信号不可以被显示地忽略,捕获或者阻塞,通常必须执行它们的缺省操作。

允许有适当特权的用户通过 SIGKILLSIGSTOP 终止并停止任何进程(除了进程 0 & 1),不管程序采取怎样的防御措施

1-2 POSIX 信号与多线程应用

POSIX 标准对多线程应用的信号处理有一些严格的要求:

  • 信号处理程序必须在多线程应用的所有线程之间共享
  • 每个发送给多线程应用的信号仅传送给一个线程,这个线程是由内核在从不会阻塞该信号的线程中任意挑选的
  • 如果向多线程应用发送一个致命信号,那么内核将杀死该应用的所有线程,而不仅仅是杀死接收信号的那个线程

为了实现 POSIX 标准,Linux 把多线程应用实现为一组属于同一个线程组的轻量级进程

如果一个挂起信号被发送给某个特定进程,那么这个信号是私有的;如果发送给整个进程组,则其是共享的。

1-3 与信号相关的数据结构

与信号相关的数据结构如下图:

  • task_struct: 进程描述符
  • signal_sturct: 信号描述符
  • sighand_struct: 信号处理程序描述符
  • shared_pending: 共享挂起信号队列

其中进程描述符中与信号处理相关的字段如下图:

  • pending: 私有挂起信号队列
  • signal: 信号描述符指针
  • block: 存放进程当前所屏蔽的信号

1-3-1 信号描述符与信号处理程序描述符

进程描述符的 signal 字段指向信号描述符(signal descriptor):用来跟踪共享挂起信号。

信号描述符被属于同一线程组的所有进程共享,即被调用 clone() 系统调用创建的所有进程共享。

信号描述符中的相关字段:

除了信号描述符,每个进程还引用一个信号处理程序描述符(signal handler descroptor):用来描述每个信号必须怎样被线程组处理。

信号处理程序描述符相关字段:

在 POSIX 多线程应用中,线程组中的所有轻量级进程都引用相同的信号描述符与信号处理程序描述符

1-3-2 挂起信号队列

为了跟踪进程当前的挂起信号,内核把两个挂起信号队列与每个进程相关联:

  • 共享挂起信号队列信号描述符shared_pending 字段,存放整个进程组的挂起信号
  • 私有挂起信号队列进程描述符pending 字段,存放特定进程(轻量级进程)的挂起信号

挂起信号队列由 sigpending 数据结构组成:

struct sigpending{
	struct list_head list; // 包含 sigqueue 数据结构的双向链表头
	sigset_t signal; // 指定挂起信号的位掩码
}

sigqueue 结构如下

siginfo_t 字段存放特定信号的信息,其中包含:

  • si_signo: 信号编号
  • si_errno: 引起信号产生指令的出错码
  • si_code: 信号发送者代码

    上图是比较重要的信号发送者代码:SI_KERNEL 一般内核函数;SI_TIMER 定时器到期;SI_ASYNCIO 异步 IO 完成。

2-产生信号

很多内核函数都会产生信号:完成信号处理的第一步,即根据需要更新一个或者多个进程的描述符。但是,并不直接执行第二步信号的传递操作,而是可能根据信号的类型和目标进程的状态唤醒一些进程,并促使这些进程接收信号

当发送给某个进程信号时,该信号可能来自内核,可能来自另一个进程。内核通过对以下内核函数调用而产生信号:

上述所有内核函数在结束时都调用 specific_send_sig_info() 函数

同样,发送给整个进程组时,该信号可能来自内核,也可能来自另一个进程。内核通过调用为线程组产生信号的内核函数产生这类信号:

2-1 specific_send_sig_info() 函数

specific_send_sig_info() 函数用于向指定进程发送信号,主要参数有:

  • sig:信号编号
  • info:要么是 siginfo_t 表的地址,要么是三个特殊值中的一个
    • 0:信号是由用户态进程发送的
    • 1: 信号是由内核发送的
    • 2: 由内核发送的 SIGSTOP or SIGKILL 信号
  • t:指向目标进程描述符的指针

该函数执行以下步骤:

  1. 检查进程是否忽略信号。当以下条件全部满足时,信号就会被忽略:
    • 进程没有被跟踪(t→ptrace
    • 信号没有被阻塞
    • 显示忽略信号(t→singhand→action[sig-1]),或者隐含地忽略信号
  2. 检查信号是否是非实时的(sig<32),并且进程的私有挂起信号队列上是否已经有另一个相同的挂起信号,如果存在,则不需要做任何事,直接返回 0
  3. 调用 send_signal() 函数把该信号添加到进程的(私有)挂起信号集合中
  4. 如果 send_signal() 函数成功调用,并且信号不被阻塞,则调用 signal_wake_up() 函数通知进程有新的挂起信号
  5. 函数返回,此时已成功产生信号

2-2 signal_wake_up() 函数

signal_wake_up() 函数执行以下步骤:

  1. t→thread_info→flags 中的 TIF_SIGPENDING 标志置位
  2. 如果进程处于 TASK_INTERUPTIBLETASK_STOPPED 状态,而且信号是 SIGKILL,则调用 try_to_wake_up()
  3. 如果 try_to_wake_up() 返回 0,则说明进程是可运行的。此时,会检查进程是否在另一个 CPU 上运行,则向那个 CPU 发送一个处理器间中断,以强制当前进程的重新调度

因为从调度函数返回时,每个进程都检查是否存在挂起信号,因此处理器间中断保证了目标进程能够很快注意到新的挂起信号

2-3 send_signal() 函数

send_signal() 函数在挂起信号队列中插入一个新元素。函数执行步骤如下:

  1. 如果参数 info 的值是 2,则该信号是 SIGKILLSIGSTOP,此时内核强制执行与这些信号相关的操作,不需要把信号添加到挂起队列中(流程结束)
  2. 递增进程拥有者挂起信号的数量
  3. 在挂起信号队列 signals 增加 sigqueue 数据结构
  4. sigqueue 结构中填充 siginfo_t
  5. 把队列位掩码中与信号相对应的位置为 1
  6. 如果第 5 步成功,则说明信号已经被成功追加到挂起信号队列中;如果失败,则不再向信号挂起队列中增加元素,可能是有太多的挂起信号,或者没有可以分配给 sigqueue 数据结构的空闲空间,或者信号已经由内核强制立即发送

有些情况,即使挂起队列没有空间存放相应挂起信号,目标进程也要能接收信号;如内核必须保证 kill() 系统调用能够成功执行。

2-4 group_send_sig_info() 函数

该函数用于向整个进程组发送信号。

3-传递信号

假设内核已经注意到信号的到来,并调用上述函数为接收此信号的进程准备相关信息。但是,如果该进程此时并没有在 CPU 上运行,内核则会延迟传递信号的任务。那么,为了确保进程的挂起信号能够被处理,内核应该如何操作?

  • 内核在允许进程恢复用户态下的执行之前,检查进程 TIF_SIGPENDING 标志。每当内核处理完一个中断或者异常时,就检查是否存在挂起信号

内核调用 do_signal() 函数处理非阻塞的信号(通常是在 CPU 要返回到用户态时才调用该函数)。

  • do_signal() 函数重复调用 dequeue_signal() 函数,直到私有挂起队列与共享挂起信号队列中都没有非阻塞的挂起信号时,循环结束
  • 对于要处理的信号可以执行三种操作:忽略信号,执行缺省操作,执行信号处理程序

3-1 执行信号的缺省操作

  1. 对于缺省操作为 Ignore 的信号直接忽略
  2. 对于缺省操作为 Stop 的信号可能停止线程组中的所有进程:把进程状态都置为 TASK_STOPPED,并调用 schedule() 函数
  3. 缺省操作为 Dump 的信号会在进程的工作目录中创建一个转储文件
  4. 缺省操作为 Terminate 的信号会 kill 线程组

3-2 捕获信号

如果信号有一个专门的处理程序,那么 do_signal() 函数必须强迫该处理程序执行。

信号处理程序是用户态进程定义的函数,并包含在用户态的代码段中。do_signal() 函数运行在内核态,而信号处理程序运行在用户态。针对这种情况,Linux 采用的方法是把保存在内核态堆栈中的硬件上下文拷贝到当前进程的用户态堆栈中用户态进程在信号处理程序终止时,自动调用 sigreturn() 系统调用将硬件上下文拷贝回内核态堆栈中,并恢复用户态堆栈中原来的内容

捕获信号的执行流程如下:

  1. 一个非阻塞信号发送给进程
  2. 当中断或者异常发生时,内核切换到内核态,在返回到用户态前,内核执行 do_signal() 函数,该函数依次处理信号和建立用户态堆栈
  3. 当进程切换回用户态时,开始执行信号处理程序
  4. 处理程序终止时,调用 sigreturn() 系统调用,恢复内核态与用户态堆栈信息
  5. 系统调用结束时,用户态进程恢复正常执行

4-与信号处理相关的系统调用

用户态进程可以发送和接收信号,可以通过一些系统调用来完成。

  1. kill():能够发送任何信号
  2. tkill() & tgkill():向线程组中指定进程发送信号
  3. sigpending():允许进程检查挂起的阻塞信号集合
  4. sigsuspend():把进程置为 TASK_INTERRUPTIBLE 状态