Skip to content

Latest commit

 

History

History
1395 lines (1014 loc) · 78.5 KB

File metadata and controls

1395 lines (1014 loc) · 78.5 KB

十一、信号——第一部分

信号是 Linux 系统开发人员理解和利用的关键机制。 我们在本书中分两章讨论了这个相当大的主题,这一章和下一章。

在本章中,将向读者介绍什么是信号,为什么它们对系统开发人员有用,当然,最重要的是,开发人员将如何准确地处理和利用信号机制。

我们将在下一章继续这一探索。

在本章中,读者将了解以下内容:

  • 信号到底是什么。
  • 为什么它们是有用的。
  • 可用的信号。
  • 如何准确地处理应用中的信号,这实际上涉及很多事情-阻止或解除阻止信号,编写安全的处理程序,一劳永逸地清除讨厌的僵尸程序,使用信号量很大的应用,等等。

为什么要发信号?

有时,系统程序员要求操作系统提供异步功能-以某种方式让您知道某个事件或条件已经发生。信号将在 Unix/Linux 操作系统上提供这一特性。 进程可以捕获或订阅信号;当这种情况发生时,操作系统将异步通知该进程这一事实,然后将运行函数的代码作为响应:异常信号处理程序。

以以下案例为例:

  • CPU 密集型进程忙于进行科学或数学计算(为了便于理解,我们假设它正在生成素数);回想一下(参见第 3 章资源限制),CPU 使用率有上限,并且已设置为特定值。 如果它被攻破了怎么办? 默认情况下,该进程将被终止。 我们能防止这种情况发生吗?
  • 开发人员想要执行一项常见的任务:设置一个计时器,让它在 1.5 秒后到期。 操作系统将如何通知进程计时器已过期?
  • 在一些 SysV UNIX(通常在企业级服务器上运行)上,如果突然断电怎么办? 事件被广播到所有进程(表示对该事件感兴趣或订阅该事件),通知它们相同的情况:它们可以刷新其缓冲区,并保存其数据。
  • 进程有一个无意的缺陷(Bug);它进行了无效的内存访问。 内存子系统(从技术上讲,是 MMU 和 OS)决定必须杀死它。 它到底会怎么被杀死呢?
  • Linux 的异步 IO(AIO)框架,以及许多其他类似的场景。

所有这些示例场景都由相同的机制提供服务:同步信号。

简而言之,信号机制

内核信号可以定义为传递给目标进程的异步事件。这些信号要么由另一个进程传递给目标进程,要么由操作系统(内核)本身传递给目标进程。

在代码级别,信号只是一个整数值;更准确地说,它是位掩码中的一个位。 重要的是要理解,尽管信号看起来像中断,但它并不是中断。中断是一种硬件功能;信号纯粹是一种软件机制。

好的,让我们尝试一个简单的练习:运行一个进程,将其放入无限循环中,然后通过键盘手动发送信号。 在(ch11/sig1.c)中查找代码:

int main(void)
{
     unsigned long int i=1;
     while(1) {
         printf("Looping, iteration #%02ld ...\n", i++);
         (void)sleep(1);
     }
     exit (EXIT_SUCCESS);
}

Why is the sleep(1);code typecast to (void)? This is our way of informing the compiler (and possibly any static analysis tool) that we are not concerned about its return value. Well, the fact is we should be; there will be more on this later.

它的工作非常明显:让我们构建并运行它,在第三次循环迭代之后,我们按下键盘上的Ctrl+C组合键。

$ ./sig1 
Looping, iteration #01 ...
Looping, iteration #02 ...
Looping, iteration #03 ...
^C
$ 

是的,不出所料,进程终止。 但这到底是怎么发生的呢?

以下是简而言之的答案:发信号。 更详细地说,这是这样发生的(尽管它仍然保持简单):当用户按下Ctrl+C键组合(在输出中显示为^C)时,内核的tty层代码处理该输入,将输入键组合烹调到中,并向 shell 上的前台进程传递信号。

但是,等一下。 记住,信号只是一个整数值。 那么,哪个整数呢? 哪个信号? Ctrl+C键组合被映射到SIGINT信号,整数值2,从而使其被传递到进程。 (下一节开始解释不同的信号;现在,让我们不要对此感到太过紧张)。

所以,好的,SIGINT信号,Value2,被传递到我们的第二个sig1进程。 但是然后呢? 这里有一个关键点:每个信号都与一个函数相关联,以便在交付时运行;该函数称为信号处理程序。 如果我们不更改它,则运行默认信号功能。 那么,这就带来了一个问题:既然我们没有编写任何默认(或其他)信号处理代码,那么是谁提供了这个默认信号处理程序函数呢? 简短的答案是:OS(内核)处理进程接收到应用没有安装任何处理程序的信号的所有情况;换句话说,对于默认情况。

信号处理程序函数或底层内核代码执行的操作决定了信号到达时目标进程将发生什么。 因此,现在我们可以更好地理解:缺省信号处理程序(实际上是内核代码)对SIGINT信号执行的操作是终止进程,实际上是导致接收进程死亡。

我们以图表的形式展示这一点,如下所示:

Signal delivered via keyboard, default handler causes process to die

从该图中,我们可以看到以下步骤:

  1. 进程P激活并运行其代码。

  2. 用户按下^C,实际上使SIGINT信号被发送到进程。

  3. 由于我们尚未设置任何信号处理程序,因此将调用该信号的默认信号处理操作,该信号是操作系统的一部分。

  4. 操作系统中的此默认信号处理代码会导致进程终止。

仅供参考,对于第一种默认情况-即应用开发人员没有安装特定信号处理例程的所有情况(我们将很快了解如何确切地安装我们自己的信号处理程序)-处理这些情况的操作系统代码到底做什么? 根据正在处理的信号,操作系统将执行以下五种可能操作之一(有关详细信息,请参阅下表):

  • 忽略该信号
  • 停止该进程
  • 继续(之前停止的)进程
  • 终止进程
  • 终止该进程并发出内核转储

真正有趣和强大的是:程序员有能力改变-将信号处理重新定向到他们自己的函数! 实际上,我们可以通过使用某些 API 来捕获或捕获信号。 一旦我们这样做了,当信号发生时,控制将不会转到默认的信号处理(OS)代码,而是转到我们希望它转到的函数。 通过这种方式,程序员可以负责并使用强大的信号机制。

当然,还有更多的原因:魔鬼确实存在于细节之中! 继续读下去。

可用信号

Unix/Linux 操作系统总共提供了一组 64 个信号。 它们大致分为两种类型:标准或 Unix 信号和实时信号。 我们会发现,虽然它们有共同的属性,但也有一些重要的区别;在这里,我们将研究 Unix(或标准)信号,稍后再研究后者。

除了键盘键组合(如Ctrl+C)之外,用于从用户空间发出信号的通用通信接口是kill(1)实用程序(因此,也就是kill(2)的系统调用)。

Besides the kill, there are several other APIs that deliver a signal; we shall flesh out more on this in a later section of this chapter.

使用-l命令或列表选项运行kill(1)实用程序会列出平台上的可用信号:

$ kill -l
 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS    34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 
47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 
51) SIGRTMAX-13 52) SIGRTMAX-12  53) SIGRTMAX-11 54) SIGRTMAX-10 
55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX 
$ 

也许“kill(1) ”这个绰号用词不当:“删除”实用程序只是向给定的进程(或作业)发送一个信号。因此(至少根据您的作者而言),“sendsig”这个名称对该实用程序来说可能是更好的选择。

An FAQ: where are the signals numbered 32 and 33? They are internally used by the Linux Pthreads implementation (called NPTL), and are hence unavailable to app developers.

标准或 Unix 信号

从 kill 的输出中可以看到,平台上支持的所有信号都会显示出来;其中的前 31 个信号(在典型的 Linux 机器上)称为标准信号或 Unix 信号。 与随后的实时信号不同,每个标准/Unix 信号都有一个非常具体的名称,正如您可能猜到的那样,还有用途。

(不用担心;我们将在下一章讨论数字 34 到 64 的实时信号)。

您将很快看到的表基本上是从有关信号(7)的手册页复制的,它按以下列顺序总结了标准(Unix)信号:信号的符号名称、整数值、传递到进程时采取的默认操作以及描述信号的注释。

默认操作列有以下类型:*信号处理程序的默认操作是:

  • Terminate:终止进程。
  • 术语&核心:终止进程并发出核心转储。 (核心转储本质上是传递(致命)信号时进程的动态段、数据段和堆栈段的快照)。 此终止和核心转储操作在内核向进程发送致命信号时发生。 这意味着该进程做了一些非法的事情(错误);一个例外是SIGQUIT信号:当SIGQUIT被传递给一个进程时,我们得到一个核心转储。
  • 忽略:忽略信号。
  • 停止:进程进入停止(冻结/挂起)状态(由ps -l)的输出中的T表示)。
  • Continue:继续执行先前停止的进程。

请参阅表标准或 Unix 信号表:

| 信号 | 整数****值 | 发帖主题:Re:Колибри0.7.0操作 | 如何 | | SIGHUP | 1 | 终止 / 使终止 / 以…收尾 / 使结束 | 检测到控制终端挂断或控制进程​死亡 | | SIGINT | 2 | 终止 / 使终止 / 以…收尾 / 使结束 | 键盘中断:**^**C | | SIGQUIT | 3 | 术语和核心 | 退出键盘:**^\** | | SIGILL | 4 | 术语和核心 | 非法指令 | | SIGABRT | 6 | 术语和核心 | 来自 ABORT(3)​的 ABORT 信号 | | SIGFPE | 8 | 术语和核心 | 浮点异常​ | | SIGKILL | 9 | 终止 / 使终止 / 以…收尾 / 使结束 | (硬)终止信号 | | SIGSEGV | 11 | 术语和核心 | 无效的内存引用 | | SIGPIPE | 13 | 终止 / 使终止 / 以…收尾 / 使结束 | 断开的管道:在没有阅读器的情况下写入管道;请参见管道(7) | | SIGALRM | 14 | 终止 / 使终止 / 以…收尾 / 使结束 | 来自报警的定时器信号(2) | | SIGTERM | 15 | 终止 / 使终止 / 以…收尾 / 使结束 | 终止信号(软杀) | | SIGUSR1 | 30,10,16 | 终止 / 使终止 / 以…收尾 / 使结束 | 用户自定义信号 1 | | SIGUSR2 | 31,12,17 | 终止 / 使终止 / 以…收尾 / 使结束 | 用户自定义信号 2 | | SIGCHLD | 20,17,18 | 忽视 / 忽略 / 驳回诉讼 / 不理睬 | 子进程已停止或终止 | | SIGCONT | 19,18,25 | 继续 / 坚持下去 / 恢复 / 留在原处 | 如果已停止,则继续 | | SIGSTOP | 17,19,23 | 结束 / 停止 / 填塞 / 堵塞 | 停止进程 | | SIGTSTP | 18,20,24 | 结束 / 停止 / 填塞 / 堵塞 | 在终端键入的停止:^Z | | SIGTTIN | 21,21,26 | 结束 / 停止 / 填塞 / 堵塞 | 后台进程的终端输入 | | SIGTTOU | 22,22,27 | 结束 / 停止 / 填塞 / 堵塞 | 后台进程的终端输出 |

At times, the second column, the signal's integer value, has three numbers. Well, it's like this: the numbers are architecture-(meaning CPU) dependent; the middle column represents the value for the x86 architecture. Always use the symbolic name of the signal in code (such as SIGSEGV), including scripts, and never the number (such as 11). You can see that the numeric value changes with the CPU, which could lead to non-portable buggy code!

What if the system admin needs to urgently kill a process? Yes, its quite possible that, while logged into an interactive shell, time is very precious and an extra couple of seconds may make a difference. In such cases, typing kill -9 is better than kill -SIGKILL, or even kill -KILL. (The previous point is with regard to writing source code).

Passing the signal number to kill -l causes it to print the signal's symbolic name (albeit in a shorthand notation). For example: $ kill -l 11 SEGV

上表(事实上还有下表)表明,除了两个例外,所有信号都有特殊用途。 扫描注释栏可以看到它。 例外是SIGUSR1SIGUSR2,它们是通用信号;它们的使用完全取决于应用设计人员的想象力。

此外,手册页告诉我们以下信号(如本表所示)较新,并包含在SUSv2POSIX.1-2001标准中:

| 信号 | 整数 | 默认 操作 | 如何 | | SIGBUS | 10,7,10 | 术语和核心 | 总线错误(内存访问错误) | | SIGPOLL | | 终止 / 使终止 / 以…收尾 / 使结束 | 可轮询事件(Sys V)。SIGIO 的同义词 | | SIGPROF | 27,27,29 | 终止 / 使终止 / 以…收尾 / 使结束 | 性能分析计时器已过期 | | SIGSYS | 12,31,12 | 术语和核心 | 错误的系统调用(SVR4);另请参阅 seccomp(2) | | SIGTRAP | 5 | 术语和核心 | 跟踪/断点陷阱 | | SIGURG | 16,23,21 | 忽视 / 忽略 / 驳回诉讼 / 不理睬 | 插座出现紧急情况(4.2BSD) | | SIGVTALRM | 26,26,28 | 终止 / 使终止 / 以…收尾 / 使结束 | 虚拟闹钟(4.2BSD) | | SIGXCPU | 24,24,30 | 术语和核心 | 超过 CPU 时间限制(4.2BSD);请参阅 prLimit(2) | | SIGXFSZ | 25,25,31 | 术语和核心 | 超出文件大小限制(4.2BSD);请参阅 prLimit(2) |

Newer standard or Unix signals

同一手册页(signal(7))进一步提到了一些剩余的(不太常见的)信号。 如果你感兴趣的话,请看一看。

需要注意的是,在所有提到的信号中,只有两个信号是无法捕获、忽略或阻止的:SIGKILLSIGSTOP。这是因为操作系统必须保证有一种方法可以终止和/或停止进程。

处理信号

在本节中,我们将详细讨论应用开发人员如何通过编程(当然是使用 C 代码)准确地处理信号。

回过头来看图 1,您可以看到 OS如何执行缺省信号处理,该缺省信号处理在向进程传递未捕获的信号时运行。 这看起来不错,直到我们意识到,通常情况下,默认操作是简单地终止(或终止)进程。 如果应用要求我们做其他事情怎么办? 或者,实际上,如果应用确实崩溃,而不是突然死亡(可能会使重要文件和其他元数据处于不一致的状态),该怎么办? 也许我们可以通过执行一些所需的清理、刷新缓冲区、关闭打开的文件、记录状态/调试信息等等来使程序进入正常状态,通知用户事件的错误状态(也许可以用一个很好的对话框),然后然后让进程优雅而平静地结束,如果你愿意的话。

捕捉或捕捉信号的能力是实现这些目标的关键。 如前所述,为了调整控制流的方向,使其不是默认的信号处理内核代码,而是我们的自定义信号处理代码,该代码在信号到达时执行。

那么,我们如何做到这一点呢? 通过使用 API 来注册感兴趣的信号,从而处理信号。 一般而言,有三种 API 可用于捕获或捕获信号:

  • sigaction(2)系统调用
  • signal(2)系统调用
  • sigvec(3)库 API

嗯,在这三个 API 中,sigvec现在被认为是不推荐使用的。 此外,除非工作真的过于简单,否则建议您放弃signal(2)API,转而使用sigactionAPI。 有效地,处理信号的强大方法是通过sigaction(2)系统调用;这是我们将深入讨论的方法。

使用 sigaction 系统调用捕获信号

sigaction(2)系统调用是捕获或捕获信号的正确方法;它功能强大,符合 POSIX,可用于极好地磨练应用的信号处理。

在较高级别上,系统调用sigaction用于向给定信号的信号处理程序注册。 如果信号的处理程序函数是foo,那么我们可以使用sigaction函数将其信号处理程序更改为bar。像往常一样,我们还可以指定更多内容,这对信号处理有很大的影响,我们很快就会讲到这一点。 以下是我们的签名:

#include <signal.h>
int sigaction(int signum, const struct sigaction *act,
 struct sigaction *oldact);

Feature Test Macro Requirements for glibc (see feature_test_macros(7)): sigaction()_POSIX_C_SOURCE siginfo_t_POSIX_C_SOURCE >= 199309L

sigaction(2)上的手册页告诉我们(通过 Feature Test Macro Requirements 一节;有关更多详细信息,请参阅更多信息),使用sigaction需要定义_POSIX_C_SOURCE宏;Linux 上的现代代码几乎总是这样。 此外,使用siginfo_t数据结构(将在本章后面介绍)要求您拥有POSIX版本199309L或更高版本。 (格式是YYYYMM;因此,这是 1993 年 9 月的POSIX标准草案;同样,在任何相当现代的 Linux 平台上都会出现这种情况)。

侧边栏-功能测试宏

快速离题:特性测试宏是glibc特性;它们允许开发人员在编译时通过在源代码中定义这些宏来指定确切的特性集。 手册(手册页)总是指定(根据需要)支持特定 API 或功能所需的功能测试宏。

关于这些功能测试宏,在 Ubuntu(17.10)和 Fedora(27)Linux 发行版上,我们已经测试了本书的源代码,_POSIX_C_SOURCE的值是200809L。宏是在头文件<features.h>中定义的,它本身也包含在头文件<unistd.h>中。

本书的 GitHub 源代码树中提供了一个简单的测试程序,用于打印几个重要的功能测试宏:https://github.com/PacktPublishing/Hands-on-System-Programming-with-Linux/tree/master/misc。 为什么不在您的 Linux 平台上试一试呢?

More on feature test macros from the glibc documentation: http://www.gnu.org/software/libc/manual/html_node/Feature-Test-Macros.html.

Sigaction 结构

第一个sigaction(2)系统调用接受三个参数,其中第二个和第三个是相同的数据类型。

第一个参数int signum是要捕获的信号。 这马上揭示了重要的一点:信号应该一次捕获一个信号-您只能通过一次调用sigaction捕获一个信号。不要试图过于聪明,而是一起传递信号的位掩码(按位或);这是一个错误。 当然,您始终可以多次调用sigaction或循环调用。

第二个和第三个参数的数据类型是指向一个结构的指针,该结构也被称为“sigaction”。*sigaction的结构定义如下(来自标题/usr/include/bits/sigaction.h):

/* Structure describing the action to be taken when a signal arrives. */
struct sigaction
  {
    /* Signal handler. */
#ifdef __USE_POSIX199309
    union
      {
        /* Used if SA_SIGINFO is not set. */
        __sighandler_t sa_handler;
        /* Used if SA_SIGINFO is set. */
        void (*sa_sigaction) (int, siginfo_t *, void *); 
      } 
    __sigaction_handler;
# define sa_handler __sigaction_handler.sa_handler
# define sa_sigaction __sigaction_handler.sa_sigaction
#else
    __sighandler_t sa_handler;
#endif

    /* Additional set of signals to be blocked. */
    __sigset_t sa_mask;

    /* Special flags. */
    int sa_flags;

    /* Restore handler. */
    void (*sa_restorer) (void);
  };

第一个成员是函数指针,它引用信号处理程序函数本身。 在现代 Linux 发行版上,确实会定义__USE_POSIX199309宏;因此,可以看到,信号处理程序值是两个元素的并集,这意味着在运行时,将恰好使用其中一个元素。 前面的注释说明了这一点:默认情况下,使用sa_handler原型函数;但是,如果传递了标志SA_SIGINFO(在第三个成员sa_flags中),则使用sa_sigaction样式的函数。 我们将很快用示例代码来说明这一点。

C 库将__sighandler_t指定为:*typedef void (*__sighandler_t) (int);

如前所述,它是指向一个函数的指针,该函数将接收一个参数:整数值(是的,您猜对了:传递的信号)。

在深入研究数据结构之前,编写并试用一个简单的 C 程序来处理几个信号,并使用前面提到的大多数sigaction结构成员的缺省值,这将是有指导意义的。

ch11/sig2.cmain()函数源代码:

int main(void)
{
 unsigned long int i = 1;
 struct sigaction act;

 /* Init sigaction to defaults via the memset,
  * setup 'siggy' as the signal handler function,
  * trap just the SIGINT and SIGQUIT signals.
  */
 memset(&act, 0, sizeof(act));
 act.sa_handler = siggy;
 if (sigaction(SIGINT, &act, 0) < 0)
     FATAL("sigaction on SIGINT failed");
 if (sigaction(SIGQUIT, &act, 0) < 0)
     FATAL("sigaction on SIGQUIT failed");

 while (1) {
     printf("Looping, iteration #%02ld ...\n", i++);
     (void)sleep(1);
 } [...]

我们故意将memset(3)函数结构设置为全零,以便对其进行初始化(初始化在任何情况下都是很好的编码实践!)。 然后,我们将信号处理程序初始化为我们自己的信号处理函数siggy

请注意,要捕获两个信号,我们需要两个sigaction(2)次系统调用。 第二个参数是指向 structsigaction的指针,它将由程序员填充,并被视为信号的新设置。 第三个参数同样是指向 structsigaction的参数指针;但是,它是一个非空的值-结果类型:如果非空并且已分配,内核将用信号的先前设置填充它。 这是一个有用的功能:如果设计要求您执行某些信号处理的保存和恢复,该怎么办。 这里,作为一个简单的例子,我们只将第三个参数设置为NULL,这意味着我们对前面的信号状态不感兴趣。

然后我们进入相同的(如sig1.c)无限循环...。 我们的简单信号处理函数siggy如下所示:

static void siggy(int signum)
{
  const char *str1 = "*** siggy: handled SIGINT ***\n";
  const char *str2 = "*** siggy: handled SIGQUIT ***\n";

  switch (signum) {
  case SIGINT:
    if (write(STDOUT_FILENO, str1, strlen(str1)) < 0)
        WARN("write str1 failed!");
    return;
  case SIGQUIT:
    if (write(STDOUT_FILENO, str2, strlen(str2)) < 0)
        WARN("write str2 failed!");
    return;
  }
}

信号处理程序接收一个整数值作为其参数:导致控件到达此处的信号。 因此,我们可以对多个信号进行多路复用:设置一个公共信号处理程序,并执行一个简单的开关案例操作来处理每个特定信号。

当然,信号处理函数的返回类型是void。 问问自己:它会回到哪里? 这是个未知数。 请记住,信号可以异步到达;但我们不知道处理程序确切的运行时间。

让我们试试看:

$ make sig2
gcc -Wall -c ../common.c -o common.o
gcc -Wall -c -o sig2.o sig2.c
gcc -Wall -o sig2 sig2.c common.o
$ ./sig2
Looping, iteration #01 ...
Looping, iteration #02 ...
Looping, iteration #03 ...
^C*** siggy: handled SIGINT ***
Looping, iteration #04 ...
Looping, iteration #05 ...
^\*** siggy: handled SIGQUIT ***
Looping, iteration #06 ...
Looping, iteration #07 ...
^C*** siggy: handled SIGINT ***
Looping, iteration #08 ...
Looping, iteration #09 ...
^\*** siggy: handled SIGQUIT ***
Looping, iteration #10 ...
Looping, iteration #11 ...
^Z
[1]+ Stopped ./sig2
$ kill %1
[1]+ Terminated ./sig2
$ 

您可以看到,这一次,应用正在处理SIGINT(通过键盘^C)和SIGQUIT(通过键盘**^\**键组合)信号。

那么,我们如何终止这款应用呢? 嗯,一种方法是打开另一个终端窗口,然后通过kill实用程序杀死这个应用。 不过,目前我们使用另一种方法:我们向进程发送SIGTSTP键信号(通过键盘和**^Z**键组合),使其进入停止状态;我们返回 shell。 现在,我们只需通过kill(1)杀死它。 ([1]是进程的当前作业编号;您可以使用jobs命令查看会话中的所有当前作业)。

*我们以图表的形式展示这一点,如下所示:

Figure 2: Handling a Signal

显然,正如我们简单的sig2应用和图 2所演示的那样,一旦信号被捕获(通过系统调用的sigaction(2)(或信号)),当它被传递到进程时,控制现在被重新定向到新的特定于应用的信号处理函数,而不是默认的 OS 信号处理代码。(=

在程序sig2中,一切看起来都很好,除了细心的读者可能已经注意到了一个难题:在 Siggysig2信号处理函数的代码中,为什么不直接使用一个简单的printf(3)函数来发送消息。 为什么要取消write(2)系统调用? 事实上,这背后有一个非常好的原因。 这个,还有更多,都在后面。

Trap all required signals as early as possible, in the application's initialization. This is because signals can arrive at any moment; the sooner we are ready to handle them, the better.

屏蔽信号

当进程正在运行时,如果它想要阻止(或屏蔽)某些信号,该怎么办? 这确实可以通过 API 接口实现;事实上,sigaction(2)结构的第二个成员是信号掩码,它是信号处理程序函数运行时要阻止传递到进程的信号的掩码。 掩码通常暗示信号的按位或运算:

...
/* Additional set of signals to be blocked. */
    __sigset_t sa_mask;
...

一定要注意前面的评论;它暗示一些信号已经被屏蔽了。 是的,确实如此;假设一个进程通过sigaction系统调用捕获信号n。 在稍后的某个时刻,信号 n 被传递给它;当我们的进程处理信号时-即运行其信号处理程序的代码-该信号 n 被阻止传递给进程。 它被阻塞多长时间?直到我们从信号处理程序返回。换句话说,操作系统自动阻塞当前正在处理的信号。 这通常正是我们想要的,而且对我们有利。

使用 sigproc 掩码 API 进行信号屏蔽

如果我们想在执行过程中阻止(或屏蔽)一些其他信号,该怎么办? 例如,在处理关键代码区域时? 系统调用sigprocmask(2)就是为此目的而设计的:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);

信号集 T 实质上是所讨论的信号的位掩码。 集合是要屏蔽的新信号集合,而集合oldset实际上是返回值(参数的返回值-结果类型),或信号掩码的前一个(或当前)值。 参数how确定行为,可以采用以下值:

  • SIG_BLOCK:此外,还可以阻止(屏蔽)信号集合 1 中指定的信号(以及已经屏蔽的信号)
  • SIG_UNBLOCK:取消封锁(取消屏蔽)信号集合中指定的信号
  • SIG_SETMASK:信号集合中指定的信号被屏蔽,覆盖先前的值

查询信号掩码

因此,我们理解您可以在sigaction(2)时(通过 sa_mask成员)或通过*s*igprocmask(2)系统调用(如前所述)设置进程的信号掩码。 但是,您如何准确地查询任意时间点的进程信号掩码的状态呢?

嗯,再一次,通过sigprocmask(2)的系统调用。 但是,从逻辑上讲,这个接口会设置一个掩码,对吧?这就是诀窍:*如果第一个参数设置为NULL,那么第二个参数实际上被忽略了,而在第三个参数oldset中,填充了当前的信号掩码值,因此我们可以在不改变信号掩码的情况下查询信号掩码。

ch11/query_mask程序演示了这一点,代码构建在我们前面的示例sig2.c的基础上。 因此,我们不需要显示整个源代码;我们只需显示相关代码,如图 3main()所示:

[...]
/* Init sigaction:
 * setup 'my_handler' as the signal handler function,
 * trap just the SIGINT and SIGQUIT signals.
 */
 memset(&act, 0, sizeof(act));
 act.sa_handler = my_handler;
 /* This is interesting: we fill the signal mask, implying that
 * _all_ signals are masked (blocked) while the signal handler
 * runs! */
 sigfillset(&act.sa_mask);

 if (sigaction(SIGINT, &act, 0) < 0)
     FATAL("sigaction on SIGINT failed");
 if (sigaction(SIGQUIT, &act, 0) < 0)
     FATAL("sigaction on SIGQUIT failed");
[...]

如您所见,这一次我们使用sigfillset(3)(有用的POSIX信号集操作或sigsetops(3)运算符之一)用全 1 填充信号掩码,这意味着在信号处理程序代码运行时,所有信号都将被屏蔽(阻塞)。

以下是信号处理程序代码的相关部分:

static void my_handler(int signum)
{
    const char *str1 = "*** my_handler: handled SIGINT ***\n";
    const char *str2 = "*** my_handler: handled SIGQUIT ***\n";

    show_blocked_signals();
    switch (signum) {
    [...]

阿!。 在这里,智能在show_blocked_signals函数中;我们在公共代码源文件中有这个函数:../common.c。 下面是函数:

/*
 * Signaling: Prints (to stdout) all signal integer values that are
 * currently in the Blocked (masked) state.
 */
int show_blocked_signals(void)
{
     sigset_t oldset;
     int i, none=1;

     /* sigprocmask: 
      * int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
      * if 'set' is NULL, the 'how' is ignored, but the
      * 'oldset' sigmask value is populated; thus we can query the
      * signal mask without altering it.
      */
      sigemptyset(&oldset);
      if (sigprocmask(SIG_UNBLOCK, 0, &oldset) < 0)
          return -1;

      printf("\n[SigBlk: ");
      for (i=1; i<=64; i++) {
          if (sigismember(&oldset, i)) {
              none=0;
              printf("%d ", i);
            }
      }
      if (none)
          printf("-none-]\n");
      else
          printf("]\n");
      fflush(stdout);
      return 0;
}

这里的关键是:值sigprocmask(2)参数与空的第二个参数(要设置的掩码)一起使用;因此,如前所述,将忽略 How 参数,值结果第三个参数oldset将保存当前过程信号掩码。

我们可以再次使用sigsetops:sigismember(3)方便的方法查询位掩码中的每个信号位。现在要做的就是迭代掩码中的每个位并打印信号号(如果设置了位),或者如果清除了就忽略它。

以下是测试运行的输出:

$ make query_mask 
gcc -Wall -c ../common.c -o common.o
gcc -Wall -c -o query_mask.o query_mask.c
gcc -Wall -o query_mask query_mask.c common.o
$ ./query_mask 
Looping, iteration #01 ...
Looping, iteration #02 ...
Looping, iteration #03 ...
^C
[SigBlk: 1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 17 18 20 21 22 23 24 25 26 27 28 29 30 31 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 ]
*** my_handler: handled SIGINT ***
Looping, iteration #04 ...
Looping, iteration #05 ...
^\
[SigBlk: 1 2 3 4 5 6 7 8 10 11 12 13 14 15 16 17 18 20 21 22 23 24 25 26 27 28 29 30 31 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 ]
*** my_handler: handled SIGQUIT ***
Looping, iteration #06 ...
Looping, iteration #07 ...
^Z
[2]+ Stopped ./query_mask
$ kill %2
[2]+ Terminated ./query_mask
$ 

请注意被阻止的信号是如何打印出来的。 嘿,你能找出丢失的信号吗?

SIGKILL(#9) and SIGSTOP(#19) cannot be masked; also, signals 32 and 33 are internally reserved for and used by the Pthreads implementation.

操作系统内的侧栏控制信号处理-轮询不中断

在这里,我们不打算深入研究 Linux 内核内部信号处理的细节;相反,我们想澄清前面暗示的一个常见误解:处理信号与硬件中断处理完全不同。 信号既不是中断,也不是故障或异常;所有这些-中断、陷阱、异常、故障-都是由计算机上的 PIC/MMU/CPU 硬件引发的。 信号纯粹是一种软件功能。

向进程传递信号意味着在任务的任务结构中设置一些成员(在内核内存中),即所谓的TIF_SIGPENDING位,以及表示任务的sigpending集合中的信号的特定位;这样,内核就知道信号是否以及哪些信号正在等待传递给该进程。(=

实际情况是,在适当的时间点(定期发生),内核代码检查信号是否等待传递,如果是,则传递它,运行或使用进程的信号处理程序(在 Userland 上下文中)。 因此,信号处理被认为更像是一种轮询机制,而不是中断机制。

折返式安全和信号

在信号处理程序中使用重入不安全(也称为异步信号不安全)函数时,在信号处理过程中有一个需要了解的重要问题。

当然,要理解这个问题,你必须首先了解什么是可重入函数,随后,什么是可重入安全函数或异步信号安全函数。

可重入函数

所谓可重入函数是指可以在正在进行的调用仍在运行时重新进入的函数。 它比听起来简单;请查看下面的伪代码片段:

signal_handler(sig) 
{ 
    my_foo();
    < ... > 
}

my_foo()
{
    char mybuf[MAX];
    <...>
}

do_the_work_mate()
{
    my_foo();
    <...>
}

现在想象一下这一系列活动:

  • 函数my_foo()由业务逻辑函数do_the_work_mate()调用;它只在本地缓冲区mybuf上操作

  • 当该进程仍在运行时,会向该进程分派一个信号

  • 信号处理程序代码抢占发生时正在执行的任何内容并运行

    • 它重新调用函数my_foo()

因此,我们可以看到:函数my_foo()重新进入。 就其本身而言,这是可以的;这里重要的问题是:它安全吗?

回想一下(参见我们在第 2 章虚拟内存中的介绍),进程堆栈用于保存函数调用帧,从而保存任何局部变量。 这里,可重入函数my_foo()仅使用局部变量。 它已经被调用了两次;每次调用都是进程堆栈上的一个单独的调用帧。 要点:每次调用my_foo()都会处理本地变量mybuf的一个副本;因此,它是安全的。 因此,它被记录为 Beingreentrant-safe。在信号处理上下文中,它被称为 Beingasync-signal-safe:在前一个调用仍在运行的情况下从信号处理程序内调用函数是安全的。

好的,让我们在前面的伪代码基础上再添加一个细节:将函数my_foo()的局部变量mybuf更改为全局(或静态)变量。现在我们来考虑一下重新进入时会发生什么;这一次,截然不同的堆栈调用帧无法拯救我们。 因为mybuf是全局的,所以它只有一个副本,从第一次函数调用(通过do_the_work_mate())开始,它将处于不一致的状态。 当第二次调用my_foo()时,我们将处理这个不一致的全局mybuf,从而破坏它。 因此,很明显,这是不安全的。

异步信号安全功能

一般来说,只使用局部变量的函数是重入安全的;任何全局或静态数据的使用都会使它们变得不安全。 这是一个关键点:您只能在信号处理程序中调用那些记录为重入安全或非信号异步安全的函数。

signal-safety(7)http://man7.org/linux/man-pages/man7/signal-safety.7.html上的手册页提供了这方面的详细信息。

On Ubuntu, the man page with this name (signal-safety(7)) was installed in recent versions only; it does work on Ubuntu 18.04.

其中,它发布了POSIX.1标准要求实现以保证实现为异步信号安全的函数列表(按字母顺序排序)(参见 2017-03-13 手册页版本 4.12)

因此,底线是:在信号处理程序中,您只能调用以下内容:

  • Signal-security(7)手册页中的 C 库函数或系统调用(请务必查找)
  • 在第三方库中,明确记录的函数是异步信号安全的
  • 您自己的库或其他已显式编写为异步信号安全的函数

此外,不要忘记您的信号处理函数本身必须是重入安全的。 不要访问其中的应用全局变量或静态变量。

在信号处理程序中确保安全的其他方法

如果我们必须在我们的信号处理程序例程中访问某些全局状态,该怎么办? 确实有一些替代方法可以使其信号安全:

  • 此时,您必须访问这些变量,确保所有信号都被阻塞(或屏蔽),并在完成后恢复信号状态(取消屏蔽)。
  • 在访问共享数据的同时对其执行某种锁定操作。
    • 在多进程应用中(我们在这里讨论的情况),(二进制)信号量可以用作锁定机制,以保护跨进程的共享数据。
    • 在多线程应用中,使用适当的锁定机制(可能是互斥锁;当然,我们将在后面的章节中详细介绍这一点)。
  • 如果您的要求是仅对全局整数进行操作(这是信号处理的常见情况!),请使用特殊的数据类型(sig_atomic_t)。 稍后见。

现实情况是,第一种方法(在需要时阻止信号)在复杂项目的实践中很难实现(尽管您当然可以在处理信号时通过将信号掩码设置为全 1 来安排屏蔽所有信号,如上一节查询信号掩码的所演示的那样)。

第二种方法,锁定,虽然对多进程和多线程应用的性能敏感,但很现实。

此时此时此刻,在讨论信号的同时,我们将讨论第三种方法。 另一个原因是,在信号处理程序中处理(查询和/或设置)整数是非常常见的情况。

Within the code we show in this book, there is the occasional use of async-signal- unsafe functions being used within a signal handler (usually one of the [f|s|v]printf(3) family). We stress that this has been done purely for demonstration purposes only; please do not give into temptation and use async-signal-unsafe functions in production code!

信号安全原子整数

可视化多进程应用。 一个进程 A 必须完成一定数量的工作(比方说它必须完成运行一个函数)foo(),并让另一个进程 B 知道它已经这样做了(换句话说,我们希望两个进程之间保持同步;也请参见下一个信息框)。

实现这一点的一种简单方法如下:让进程A发送信号(比如SIGUSR1),然后在进程B达到所需的点时将其发送给进程B。 反过来,进程 B 捕获SIGUSR1,当它到达时,在其信号处理程序中,它为适当的消息字符串设置一个全局缓冲区,以便让应用的其余部分知道我们已经到达这一点。

在下表中,想象时间线垂直(y轴)向下移动。

伪代码-错误的方式:

| 进程 A | 进程 B | | 干活吧 | 设置SIGUSR1的信号处理程序 | | 处理foo() | char gMsg[32];   // global 做工 | | foo()已完成;发送SIGUSR1到进程B | | | | signal_handler()函数异步输入 | | | strncpy(gMsg, "chkpointA", 32); | | [.] | [.] |

这看起来很好,但请注意,消息缓冲区gMsg上的这个全局更新不能保证是原子的。 试图这样做完全有可能导致一场竞赛--在这种情况下,我们无法确切地预测全局变量的最终结果会是什么。 正是这种数据竞赛为一类难以发现和解决的淫秽漏洞提供了完美的滋生地。你必须通过适当的编程实践来避免它们。

解决方案是:从使用全局缓冲区切换到数据类型为**sig_atomic_t**的类似全局整数的变量,重要的是,将其标记为volatile(以便编译器禁用其周围的优化)。

伪代码-正确的方式:

| 进程 A | 进程 B | | 干活吧 | 为SIGUSR1设置信号处理程序 | | 工作foo() | volatile sig_atomic_t gFlag=0; 做功 | | 加入时间:清华大学 2007 年 01 月 25 日下午 3:33 | | | | signal_handler()函数异步输入 | | | gFlag = 1; | | [.] | [.] |

这一次,它将工作得很好,没有任何竞争。 (建议读者作为练习编写前一个程序的完整工作代码)。

It's important to realize that the usage of sig_atomic_t makes an (integer) variable only async-signal safe, not thread-safe. (Thread safety will be covered in detail in later Chapter 14, Multithreading with Pthreads Part I - Essentials).

True process synchronization should be performed using an IPC mechanism appropriate for the purpose. Signals do serve as a primitive IPC mechanism; depending on your project,  other IPC mechanisms (sockets, message queues, shared memory, pipes, and semaphores)  might well be a better way to do so, though.

根据卡内基梅隆大学的软件工程学院(CMU SEI)CERT C 编码标准:

SIG31-C:不访问信号处理程序中的共享对象(https://wiki.sei.cmu.edu/confluence/display/c/SIG31-C.+Do+not+access+shared+objects+in+signal+handlers)

类型sig_atomic_t是对象的整数类型,即使在存在异步中断的情况下也可以作为原子实体进行访问。

其他注意事项:

最后一个链接中提供的代码示例也值得一查。 此外,在相同的上下文中,关于执行信号处理的正确方式,请注意以下几点,即 CMU SEI 的 CERT C 编码标准

  • SIG30-C。 只调用信号处理程序中的异步安全函数。
  • SIG31-C:我不访问信号处理程序中的共享对象。
  • SIG34-C。 不要从可中断信号处理程序内部调用signal()
  • SIG35-C。 不要从计算性异常信号处理程序返回。

最后一个要点可能用POSIX.1委员会的话说得更好:

对于不是由kill(2)sigqueue(3)raise(2)生成的 SIGBUS、SIGFPE、SIGILL 或 SIGSEGV 信号,从信号捕获函数正常返回后,进程的行为未定义。

换句话说,一旦您的进程从 OS 接收到前面提到的任何致命信号,它就可以在它的信号处理程序中执行清理,但随后它必须终止。 (请允许我们开个玩笑:电影中的男主人公大喊“今天不行,死吧!”是不错的,但当 SIGBUS、SIGFPE、SIGILL 或 SIGSEGV 打来电话时,是时候清理干净,优雅地死去了!)。 事实上,我们在下一章中对这一方面进行了非常详细的探讨。

强大的签名旗帜

从上一节的sigaction结构中,回想一下sigaction结构的一个成员如下所示:

/* Special flags. */
    int sa_flags;

这些特殊的旗帜非常有力。 有了它们,开发人员可以精确地指定本来很难或不可能获得的信号语义。 默认值为零表示没有特殊行为。

我们将首先枚举此表中的sa_flags个可能的值,然后继续使用它们:

| sa_flag | 它提供的行为或语义(参见sigaction(2)的手册页)。 | | SA_NOCLDSTOP | 如果signumSIGCHLD,则在子项停止或停止子项继续时不生成SIGCHLD。 | | SA_NOCLDWAIT | (Linux 2.6 及更高版本)如果signumSIGCHLD,则不要在孩子终止时将其转换为僵尸。 | | SA_RESTART | 通过使某些系统调用可跨信号重新启动来提供与 BSD 信号语义兼容的行为。 | | SA_RESETHAND | 进入信号处理程序后,将信号操作恢复为默认值。 | | SA_NODEFER | 不要阻止信号从其自己的信号处理程序中接收。 | | SA_ONSTACK | 在sigaltstack(2)提供的备用信号堆栈上调用信号处理程序。 如果备用堆栈不可用,将使用默认(进程)堆栈。 | | SA_SIGINFO | 信号处理程序接受三个参数,而不是一个。 在这种情况下,应设置sa_sigaction而不是sa_handler。 |

请记住,sa_flags是操作系统解释为位掩码的整数值;对几个标志进行逐位或运算以暗示它们的组合行为确实是常见的做法。

僵尸未被邀请

让我们从旗帜SA_NOCLDWAIT开始。 首先,让我快速地跑题:

正如我们在第 10 章流程创建中了解到的,流程可以分叉,从而产生一个创建行为:一个新的子流程诞生了! 从那一章开始,现在可以回顾一下我们的 Fork规则 7:父进程必须直接或间接地在每个子进程终止(死亡)时等待(阻塞)。

父进程可以通过设置的等待系统调用 API 在子进程终止时等待(阻塞)。 正如我们早先了解到的,这是至关重要的:如果孩子死了,父母没有等待,孩子就会变成僵尸-充其量也就是一种不受欢迎的状态。 在最坏的情况下,它会严重阻塞系统资源。

然而,在子进程(或多个子进程)死亡时,通过等待 API 阻塞会导致父进程变得同步;它会阻塞,因此,在某种意义上,它违背了并行化多处理的全部目的。 当我们的孩子去世时,我们不能得到异步的通知吗? 这样,父级可以继续执行处理,并与其子级并行运行。

阿!。 救援信号:每当其任何子进程终止或进入停止状态时,操作系统都会向父进程发送中断SIGCHLD信号。

注意最后一个细节:即使子进程停止(因此没有死),也会传递SIGCHLD。 如果我们不想那样呢? 换句话说,我们只希望在我们的孩子死亡时向我们发出信号。但这正是SA_NOCLDSTOP标志所做的:没有儿童死亡在停止。 所以,如果你不想被孩子们的叫停欺骗,让他们以为他们已经死了,那就使用这个旗帜吧。 (当停止的孩子随后通过SIGCONT继续时,这也适用)。

没有僵尸!-经典的方式

前面的讨论还应该让您意识到,嘿,我们现在有了一种巧妙的、异步的方法来消除任何讨厌的僵尸:捕获SIGCHLD,并在其信号处理程序中发出等待调用(使用第 9 章流程执行中介绍的任何等待 API),最好使用WNOHANG选项参数,这样我们就可以执行非阻塞等待;因此,我们不会阻塞任何活动的子级,而只是成功地清除了任何

以下是 Unix 清除僵尸的经典方法:

static void child_dies(int signum)
{
    while((pid = wait3(0, WNOHANG, 0)) != -1);
}

深入研究这里只对现代 Linux 有学术意义(在您的作者看来,现代 Linux 是 2.6.0 版及更高版本的 Linux 内核,顺便说一句,它是在 2003 年 12 月 18 日发布的)。

没有僵尸!-现代的方式

因此,在现代 Linux 中,避免僵尸变得容易得多:只需使用sigaction(2)捕获SIGCHLD信号,并在信号标志位掩码中指定SA_NOCLDWAIT位。 就是这样:对僵尸的担忧永远流放! 在 Linux 平台上,SIGCHLD信号仍然传递给父进程--您可以使用它来跟踪子进程,或者您可能想到的任何记账目的。

顺便说一句,POSIX.1标准还指定了另一种去除烦人僵尸的方法:忽略SIGCHLD信号(带SIG_IGN)。 嗯,你可以使用这种方法,但有一个警告,那就是你永远不会知道一个孩子什么时候真的死了(或停了)。

所以,有用的东西:让我们来测试一下我们的新知识:我们组装了一个非常小的多进程应用,它可以生成僵尸,但也可以用现代的方式清除它们,如下所示(ch11/zombies_clear_linux26.c):

For readability, only the relevant parts of the code are displayed; to view and run it, the entire source code is available here: https://github.com/PacktPublishing/Hands-on-System-Programming-with-Linux.

int main(int argc, char **argv)
{
  struct sigaction act;
  int opt=0;

  if (argc != 2)
      usage(argv[0]);

  opt = atoi(argv[1]);
  if (opt != 1 && opt != 2)
      usage(argv[0]);

  memset(&act, 0, sizeof(act));
  if (opt == 1) {
        act.sa_handler = child_dies;
        /* 2.6 Linux: prevent zombie on termination of child(ren)! */
        act.sa_flags = SA_NOCLDWAIT;
  }
  if (opt == 2)
        act.sa_handler = SIG_IGN;
  act.sa_flags |= SA_RESTART | SA_NOCLDSTOP; /* no SIGCHLD on stop of child(ren) */

  if (sigaction(SIGCHLD, &act, 0) == -1)
      FATAL("sigaction failed");

  printf("parent: %d\n", getpid());
  switch (fork()) {
  case -1:
      FATAL("fork failed");
  case 0: // Child
      printf("child: %d\n", getpid());
      DELAY_LOOP('c', 25);
      exit(0);
  default: // Parent
      while (1)
          pause();
  }
  exit(0);
}

(目前,忽略代码中的SA_RESTART标志;我们稍后将对其进行解释)。以下是SIGCHLD的信号处理程序:

#define DEBUG
//#undef DEBUG
/* SIGCHLD handler */
static void child_dies(int signum)
{
#ifdef DEBUG
   printf("\n*** Child dies! ***\n");
#endif
}

请注意,当处于调试模式时,我们如何仅在信号处理程序中发出printf(3)(因为这是异步信号不安全)。

让我们试试看:

$ ./zombies_clear_linux26 
Usage: ./zombies_clear_linux26 {option-to-prevent-zombies}
 1 : (2.6 Linux) using the SA_NOCLDWAIT flag with sigaction(2)
 2 : just ignore the signal SIGCHLD
$ 

好的,首先我们使用选项1进行尝试;也就是说,使用SA_NOCLDWAIT标志:

$ ./zombies_clear_linux26 1 &
[1] 10239
parent: 10239
child: 10241
c $ cccccccccccccccccccccccc
*** Child dies! ***

$ ps 
 PID TTY TIME CMD
 9490 pts/1 00:00:00 bash
10239 pts/1 00:00:00 zombies_clear_l
10249 pts/1 00:00:00 ps
$ 

重要的是,检查ps(1)发现没有僵尸。 现在使用选项2运行它:

$ ./zombies_clear_linux26 2
parent: 10354
child: 10355
ccccccccccccccccccccccccc
^C
$ 

注意,*** Child dies! ***消息(我们在上一次运行中得到的)没有出现,证明我们从未进入SIGCHLD的信号处理程序。 当然不是,我们忽略了这个信号。 虽然这确实阻止了僵尸的出现,但它也阻止了我们知道孩子已经死亡。

SA_NOCLDSTOP 标志

关于SIGCHLD信号,有一个重要的要点需要认识到:默认行为是,无论进程死亡或停止,还是停止的子进程继续执行(通常通过发送给它的SIGCONT信号),内核都会将SIGCHLD信号发送给其父进程。

也许这是有用的。 父母会被告知所有这些事件-孩子的死亡、停止寻呼或继续。 另一方面,也许我们不想被欺骗而认为我们的子进程已经死亡,而实际上它刚刚被停止(或继续)。

对于这种情况,使用SA_NOCLDSTOP标志;它的字面意思是在儿童停止(或恢复)时不使用SIGCHLD。 现在你只能在孩子死亡时得到SIGCHLD

中断的系统调用以及如何使用 SA_RESTART 修复它们

传统(较旧的)Unix 操作系统在处理阻塞系统调用时遇到信号处理问题。

Blocking APIs An API is said to be blocking when, on issuing the API, the calling process (or thread) is put into a sleep state. Why is this? This is because the underlying OS or device driver understands that the event that the caller needs to wait upon has not yet occurred; thus, it must wait for it. Once the event (or condition) arises, the OS or driver wakes up the process; the process now continues to execute its code path.

Examples of blocking APIs are common: read, write, select, wait (and its variants), accept, and so on.

花点时间想象一下这个场景:

  • 一个进程捕获一个信号(比如SIGCHLD)。
  • 稍后,该进程会发出阻塞的系统调用(比如,accept(2)系统调用)。
  • 当它处于睡眠状态时,信号被传递给它。

下面的伪代码说明了同样的情况:

[...]
sigaction(SIGCHLD, &sigact, 0);
[...]
sd = accept( <...> );
[...]

By the way, the accept(2) system call is how a network server process blocks (waits) upon a client connecting to it.

既然信号已经发出,那该怎么办呢? 正确的行为是:进程应该醒来,处理信号(运行其信号处理程序的代码),然后再次进入休眠状态,继续阻塞它正在等待的事件。

在较旧的 UNIX 上(您的作者在旧的 SunOS 4.x 上遇到过这种情况),信号被传递,信号处理程序代码运行,但是之后阻塞系统调用失败,返回-1。 参数errno参数设置为**EINTR**参数,这意味着系统调用被中断。

当然,这被认为是一个错误。 可怜的 Unix 应用开发人员不得不求助于一些临时修复,通常求助于将每个系统调用(在本例中为 foo)包装在一个循环中,如下所示:

while ((foo() == -1) && (errno == EINTR));

这不容易维护*。*

POSIX委员会随后解决了这个问题,要求实现提供一个新的信号 FLAGSA_RESTART。 当使用此标志时,内核将自动重新启动碰巧被一个或多个信号中断的任何阻塞系统调用。

因此,在注册信号处理程序时,只需在您的文件sigaction(2)中使用有用的SA_RESTART标志,这个问题就会消失。

In general, using the SA_RESTART flag when programming the sigaction(2) would be a good idea. Not always, though; the Chapter 13Timers, shows us use cases in which we deliberately keep away from this flag.

只有一次的 SA_RESETHAND 标志

SA_RESETHAND信号标志有点奇怪。 在较旧的 Unix 平台上,有一个错误是这样的:捕获信号(通过signal(2)函数),发送信号,然后进程处理信号。 但是,在进入信号处理程序后,内核现在立即将信号操作重置为原始操作系统的默认处理代码。 因此,信号第二次到达时,默认的处理程序代码就会运行,通常会扼杀交易中的进程。 (同样,Unix 开发人员有时不得不求助于一些糟糕的色情代码来尝试修复此问题)。

因此,信号只会有效地传递一次。在今天的现代 Linux 系统上,信号处理程序保持不变;默认情况下,它可能不会被重置为原始处理程序。 当然,除非你想要这种只有一次的行为,在这种情况下,可以使用SA_RESETHAND标志(你可以想象它不是很受欢迎)。 此外,SA_ONESHOT是同一标志的旧名称,已弃用。

推迟还是不推迟? 使用 SA_NODEFER

让我们回顾一下信号在默认情况下是如何处理的:

  • 进程捕获信号 n。
  • 信号 n 被传送到该进程(由另一个进程或 OS)。
  • 信号处理程序被调度;也就是说,它响应信号而运行。
    • 信号 n 现在被自动屏蔽;也就是说,阻止将其传递到进程。
    • 信号处理完成。
    • 信号 n 现在是自动去屏蔽的,也就是说,能够传递到进程。

这是合理的:在处理特定信号时,该信号被屏蔽。这是默认行为。

但是,如果您正在编写(比方说)嵌入式实时应用,其中的信号传递意味着已经发生了一些真实事件,并且应用必须立即(尽快)对此做出响应,该怎么办? 在这种情况下,我们可能希望禁用信号的自动屏蔽,从而允许信号处理程序在信号到达时立即重新进入。 确切地说,这可以通过使用SA_NODEFER信号标志来实现。

The English word defer means to delay or postpone; to put off until later.

这是默认行为,您可以在指定标志时对其进行更改。

屏蔽时的信号行为

为了更好地理解这一点,让我们举一个虚构的例子:假设我们捕获一个信号 n,信号 n 的信号处理程序的平均执行时间是 55ms(毫秒)。 另外,设想这样一种场景:通过计时器(至少在一段时间内),信号 n 以 10ms 的间隔连续传递给进程。 现在让我们检查一下在默认情况下会发生什么情况,以及我们使用SA_NODEFER标志时会发生什么情况。

情况 1:默认:清除 SA_NODEFER 位

这里,我们使用的是而不是信号标志。 因此,当信号 n 的第一个实例到达时,我们的过程跳转到信号处理代码(这将需要 55ms 才能完成)。 然而,第二个信号将只到达信号处理代码中的 10ms。 但是,等等,它是自动屏蔽的! 因此,我们不会处理它。 事实上,简单的计算将显示,在 55 毫秒的信号处理时间范围内,最多 5 个信号 n 实例将到达我们的进程:

Figure 3: Default behavior: SA_NODEFER bit cleared: no queue, one signal instance pending delivery, no real impact on stack

那么,到底发生了什么? 一旦处理程序完成,这五个信号是否会排队等待发送? 阿!。 这一点很重要:标准或非 Unix 信号不会排队。但是,内核确实知道有一个或多个信号正在等待传递到进程;因此,一旦信号处理完成,就只有一个挂起信号的实例被传递(挂起信号掩码随后被清除)。

因此,在我们的示例中,即使有五个信号等待传递,信号处理程序也只会被调用一次。 换句话说,没有信号排队,但服务了一个信号实例。 这就是默认情况下信号的工作方式。

图 3显示了这种情况:虚线信号箭头表示进入信号处理程序后传递的信号;因此,只有一个实例保持挂起状态。 请注意进程堆栈:信号 n 的信号实例#1(显然)在调用信号处理程序时获得堆栈上的调用帧,仅此而已。

问:如果情况如图所示,但传递了另一个信号m,该怎么办?

答:如果信号 m 已被捕获且当前未被屏蔽,则将立即进行处理;换句话说,它将不会抢占一切,其处理程序将运行。 当然,上下文由操作系统保存,这样,一旦上下文恢复,任何被抢占的内容都可以在以后继续。 这使我们得出以下结论:

  • 信号是两个对等点;它们没有与之关联的优先级。

  • 对于标准信号,如果传递了相同整数值的多个实例,并且该信号当前被屏蔽(阻塞),则只有一个实例保持挂起;没有排队。

情况 2:设置 SA_NODEFER 位

现在让我们重新考虑完全相同的场景,只是这一次我们使用了SA_NODEFER信号标志。因此,当信号 n 的第一个实例到达时,我们的过程跳转到信号处理代码(这将需要 55ms 才能完成)。 和以前一样,第二个信号将在 10 毫秒内到达第一个信号处理代码,但请稍等,这一次它没有被屏蔽;它没有被延迟。因此,我们将立即重新进入信号处理程序功能。 然后,20ms 后(在信号 n 实例#1 首次进入信号处理程序之后),第三个信号实例到达。 同样,我们也将重新进入信号处理函数。 是的,这种情况会发生五次。

图 4 向我们展示了此场景:

Figure 4: SA_NODEFER bit set: no queue; all signal instances processed upon delivery, stack intensive

这看起来不错,但请注意以下几点:

  • 信号处理程序代码本身必须编写为重入安全函数(没有全局或静态变量使用;只调用其中的异步信号安全函数),因为它在此场景中不断被重新输入。
  • 堆栈用法:每次重新进入信号处理程序时,一定要意识到已将一个额外的进程调用帧分配(推入)到进程堆栈上。

第二点值得思考:如果到达的信号太多(同时处理前面的调用),导致我们重载,甚至溢出堆栈,该怎么办? 嗯,灾难。 堆栈溢出是一个糟糕的错误;实际上不可能进行异常处理(我们不能满怀信心地捕获或捕获堆栈溢出问题)。

下面是一个有趣的代码示例ch11/defer_or_not.c,用于演示这两种情况:

For readability, only key parts of the code are displayed; to view the complete source code, build and run it; the entire tree is available for cloning from the book's GitHub repo here: https://github.com/PacktPublishing/Hands-on-System-Programming-with-Linux

static volatile sig_atomic_t s=0, t=0;
[...]
int main(int argc, char **argv)
{
 int flags=0;
 struct sigaction act;
[...]
 flags = SA_RESTART;
 if (atoi(argv[1]) == 2) {
     flags |= SA_NODEFER;
     printf("Running with the SA_NODEFER signal flag Set\n");
 } else {
     printf("Running with the SA_NODEFER signal flag Cleared [default]\n");
 }

 memset(&act, 0, sizeof(act));
 act.sa_handler = sighdlr;
 act.sa_flags = flags;
 if (sigaction(SIGUSR1, &act, 0) == -1)
     FATAL("sigaction failed\n");
 fprintf(stderr, "\nProcess awaiting signals ...\n");

 while (1)
     (void)pause();
 exit(EXIT_SUCCESS);
}

下面是信号处理程序函数:

/* 
 * Strictly speaking, should not use fprintf here as it's not
 * async-signal safe; indeed, it sometimes does not work well!
 */
static void sighdlr(int signum)
{
  int saved;
  fprintf(stderr, "\nsighdlr: signal %d,", signum);
  switch (signum) {
  case SIGUSR1:
 s ++; t ++;
    if (s >= MAX)
          s = 1;
    saved = s;
    fprintf(stderr, " s=%d ; total=%d; stack %p :", s, t, stack());
 DELAY_LOOP(saved+48, 5); /* +48 to get the equivalent ASCII value */
 fprintf(stderr, "*");
    break;
  default:;
  }
}

我们故意让信号处理代码花费相当长的时间(通过使用DELAY_LOOP宏),这样我们就可以模拟同一信号在被处理时被多次传递的情况。在实际的应用中,始终努力使您的信号处理尽可能简短。

内联程序集的 STACK()函数是获取寄存器值的一种有趣的方式。 阅读下面的评论,看看它是如何工作的:

/* 
 * stack(): return the current value of the stack pointer register.
 * The trick/hack: on x86 CPU's, the ABI tells us that the return
 * value is always in the accumulator (EAX/RAX); so we just initialize
 * it to the stack pointer (using inline assembly)!
 */
void *stack(void)
{
 if (__WORDSIZE == 32) {
     __asm__("movl %esp, %eax");
 } else if (__WORDSIZE == 64) {
     __asm__("movq %rsp, %rax");
 }
/* Accumulator holds the return value */
}

The processor ABI - Application Binary Interface—documentation is an important area for the serious systems developer to be conversant with; check out more on this in the Further reading section on the GitHub repository.

为了正确测试这个应用,我们编写了一个小的 shell 脚本bombard_sig.sh,它用(相同的)信号轰炸给定的进程(我们这里使用 SIGUSR1)。 用户需要传递进程 PID 和要发送的信号实例的数量作为参数;如果第二个参数为-1,脚本将持续轰炸进程。 以下是脚本的关键代码:

SIG=SIGUSR1
[...]
NUMSIGS=$2
n=1
if [ ${NUMSIGS} -eq -1 ] ; then
  echo "Sending signal ${SIG} continually to process ${1} ..."
  while [ true ] ; do
    kill -${SIG} $1
    sleep 10e-03    # 10 ms
  done
else
  echo "Sending ${NUMSIGS} instances of signal ${SIG} to process ${1} ..."
  while [ ${n} -le ${NUMSIGS} ] ; do
    kill -${SIG} $1
    sleep 10e-03    # 10 ms
    let n=n+1
  done
fi

运行情况 1-SA_NODEFER 位已清除[默认]

接下来,我们执行清除SA_NODEFER标志的测试用例;这是默认行为:

$ ./defer_or_not 
Usage: ./defer_or_not {option}
option=1 : don't use (clear) SA_NODEFER flag (default sigaction style)
option=2 : use (set) SA_NODEFER flag (will process signal immd)
$ ./defer_or_not 1
PID 3016: running with the SA_NODEFER signal flag Cleared [default]
Process awaiting signals ...

现在,在另一个终端窗口中,我们运行 shell 脚本:

$ ./bombard_sig.sh $(pgrep defer_or_not) 12

The pgrep figures out the PID of the defer_or_not process: useful! Just ensure the following: (a) Only one instance of the process you are sending signals to is alive, or pgrep returns multiple PIDs and the script fails. (b) The name passed to pgrep is 15 characters or less.

脚本一运行,就向进程发出(12)个信号,显示以下输出:

​sighdlr: signal 10, s=1 ; total=1; stack 0x7ffc8d021a70 :11111*
sighdlr: signal 10, s=2 ; total=2; stack 0x7ffc8d021a70 :22222*

研究前面的输出,我们注意到以下几点:

  • SIGUSR1被捕获,其信号处理程序运行;它发出一个数字流(在每个信号实例上递增)。
    • 为了正确执行此操作,我们使用了两个volatile sig_atomic_t全局变量(一个用于在DELAY_LOOP宏中打印的值,另一个用于跟踪传递给进程的信号总数)。
  • 数字末尾的星号字符*表示,当您看到它时,信号处理程序已经完成执行。
  • 虽然传递了 12 个SIGUSR1个信号实例,但当其余 11 个信号到达时,该进程正在处理第一个信号实例;因此,只有一个信号保持等待状态,并在处理程序完成后进行处理。 当然,在不同的系统上,您总是会看到多个信号实例被处理。
  • 最后,请注意,我们在每次信号处理程序调用时都会打印堆栈指针值;当然,它是用户空间的虚拟地址(回想一下我们在第 2 章虚拟内存中的讨论);更重要的是,它是相同的,这意味着信号处理程序函数重用了完全相同的堆栈框架(这种情况经常发生)。

运行案例 2-SA_NODEFER 位集

接下来,我们执行测试用例,其中设置了SA_NODEFER命令标志(首先确保您已经终止了defer_or_not进程的所有旧实例):

$ ./defer_or_not 2 PID 3215: running with the SA_NODEFER signal flag Set
Process awaiting signals ...

现在,在另一个终端窗口中,我们运行 shell 脚本:

$ ./bombard_sig.sh $(pgrep defer_or_not) 12

脚本一运行,就向进程发出(12)个信号,输出如下:

sighdlr: signal 10, s=1 ; total=1; stack 0x7ffe9e17a0b0 :
sighdlr: signal 10, s=2 ; total=2; stack 0x7ffe9e1799b0 :2
sighdlr: signal 10, s=3 ; total=3; stack 0x7ffe9e1792b0 :3
sighdlr: signal 10, s=4 ; total=4; stack 0x7ffe9e178bb0 :4
sighdlr: signal 10, s=5 ; total=5; stack 0x7ffe9e1784b0 :5
sighdlr: signal 10, s=6 ; total=6; stack 0x7ffe9e177db0 :6
sighdlr: signal 10, s=7 ; total=7; stack 0x7ffe9e1776b0 :7
sighdlr: signal 10, s=8 ; total=8; stack 0x7ffe9e176fb0 :8
sighdlr: signal 10, s=9 ; total=9; stack 0x7ffe9e1768b0 :9
sighdlr: signal 10, s=1 ; total=10; stack 0x7ffe9e1761b0 :1
sighdlr: signal 10, s=2 ; total=11; stack 0x7ffe9e175ab0 :22222*1111*9999*8888*7777*6666*5555*4444*3333*2222*11111*
sighdlr: signal 10, s=3 ; total=12; stack 0x7ffe9e17adb0 :33333*

这一次,请注意以下事项:

  • SIGUSR1被捕获,其信号处理程序运行;它发出一个数字流(在每个信号实例上递增)。
    • 要正确做到这一点,我们使用一个全局变量volatile sig_atomic_t(一个用于在函数DELAY_LOOP中打印的值,另一个用于跟踪传递给进程的信号总数)。
  • 数字末尾的星号字符*表示,当您看到它时,信号处理程序已经完成执行;请注意,这一次,*直到很久以后才会出现。
  • 信号SIGUSR1的 12 个实例被相继传送:这一次,每个实例将抢占前一个实例(在进程堆栈上设置一个新的调用帧;请注意堆栈指针地址的唯一性)。
  • 请注意,在处理完所有信号实例后,控制如何恢复到原始上下文;因此,我们确实可以看到堆栈展开。
  • 最后,仔细查看堆栈指针值;它们正在逐渐减小。 当然,这是因为在x86[_64]CPU 上(就像大多数现代 CPU 一样),向下增长的堆栈是它的工作方式。

一定要亲自试试这个程序,看看。它很有趣,功能也很强大,但请记住,这是以非常密集的堆栈为代价的!

它有多昂贵(就堆栈内存使用而言)?我们实际上可以计算每个堆栈(调用)帧的大小;取任何两个不同的实例,从较高的实例中减去较低的实例。 例如,让我们以前面的案例s=6s=5为例:s=5: 0x7ffe9e1784b0s=6: 0x7ffe9e177db0

因此,调用 FRAMEsize =  0x7ffe9e1784b0 - 0x7ffe9e177db0 = 0x700 = 1792字节。

在这里,对于这个特定的应用用例,每个信号处理调用帧占用高达 1792 字节的内存。

现在让我们考虑一个最坏的情况:使用嵌入式实时应用,假设我们在前一个实例正在运行(当然还设置了SA_NODEFER)时非常快速地接收到 5000 个信号怎么办:然后我们将最终在进程堆栈上创建 5000 个额外的调用帧,这将花费大约 5,000 x 1,792=8,960,000=~8.5MB!

为什么不实际测试这个案例呢?(经验主义的价值-尝试事物而不是仅仅假设它们,是至关重要的。 另请参阅第 19 章故障排除和最佳实践)。 我们的做法如下:

$ ./defer_or_not 2
PID 7815: running with the SA_NODEFER signal flag Set
Process awaiting signals ...

在另一个终端窗口中,运行命令bombard_sig.sh脚本,要求它生成 5,000 个信号实例。 请参考以下命令:

$ ./bombard_sig.sh $(pgrep defer_or_not) 5000
Sending 5000 instances of signal SIGUSR1 to process 7815 ...

这是第一个终端窗口中的输出:

<...>
sighdlr: signal 10, s=1 ; total=1; stack 0x7ffe519b3130 :1
sighdlr: signal 10, s=2 ; total=2; stack 0x7ffe519b2a30 :2
sighdlr: signal 10, s=3 ; total=3; stack 0x7ffe519b2330 :3
sighdlr: signal 10, s=4 ; total=4; stack 0x7ffe519b1c30 :4
sighdlr: signal 10, s=5 ; total=5; stack 0x7ffe519b1530 :5
sighdlr: signal 10, s=6 ; total=6; stack 0x7ffe519b0e30 :6
sighdlr: signal 10, s=7 ; total=7; stack 0x7ffe519b0730 :7
sighdlr: signal 10, s=8 ; total=8; stack 0x7ffe519b0030 :8
sighdlr: signal 10, s=9 ; total=9; stack 0x7ffe519af930 :9
sighdlr: signal 10, s=1 ; total=10; stack 0x7ffe519af230 :1
sighdlr: signal 10, s=2 ; total=11; stack 0x7ffe519aeb30 :2

*--snip--*

sighdlr: signal 10, s=8 ; total=2933; stack 0x7ffe513a2d30 :8
sighdlr: signal 10, s=9 ; total=2934; stack 0x7ffe513a2630 :9
sighdlr: signal 10, s=1 ; total=2935; stack 0x7ffe513a1f30 :1
sighdlr: signal 10, s=2 ; total=2936; stack 0x7ffe513a1830 :2
sighdlr: signal 10, s=3 ; total=2937; stack 0x7ffe513a1130 :Segmentation fault
$ 

当然,当它耗尽堆栈空间时,它会崩溃。(同样,在不同的系统上,结果可能会有所不同;如果您没有遇到崩溃,通过堆栈溢出,使用这些数字,请尝试增加通过脚本发送的信号的数量,并查看...)。

正如我们在第 3 章资源限制中了解到的,典型的进程堆栈资源限制是 8MB;因此,我们在这里面临堆栈溢出的真正危险,当然,这将导致致命的突然崩溃。 所以,要小心! 如果您打算使用SA_NODEFER标志,请不厌其烦地在繁重的工作负载下对您的应用进行压力测试,看看堆栈的使用量是否超过了安全范围。

使用备用信号堆栈

请注意,我们前面的测试用例向使用SA_NODEFER集运行的defer_or_not应用发送了 5,000SIGUSR1个信号,导致它崩溃,并出现了分段故障(通常缩写为 Segault)。 当进程进行无效的内存引用时,操作系统向进程发送信号SIGSEGV(分段违规);换句话说,这是一个与内存访问相关的错误。 捕获SIGSEGV可能非常有价值;我们可以获得有关应用如何以及为什么崩溃的信息(实际上,我们将在下一章中确切地介绍这一点)。

然而,仔细想想:在最后的测试用例中(5000 个信号...。 其一),进程崩溃的原因是它的堆栈溢出。 因此,操作系统发送了信号SIGSEGV;我们希望捕获并处理该信号。 但是堆栈上没有空间,那么如何调用信号处理函数本身呢? 这是个问题。

存在一个有趣的解决方案:我们可以为其分配(虚拟)内存空间,并设置一个单独的备用堆栈,仅用于信号处理。 多么?。 通过sigaltstack(2)系统调用。 它用于这样的情况:您需要处理SIGSEGV,但是堆栈空间已用完。 想想我们以前的实时大容量信号处理应用:我们也许可以重新设计它,以便为单独的信号堆栈分配更多的空间,这样它就可以在实践中工作。

使用备用信号堆栈处理大容量信号的实现

下面正是这方面的尝试:ch11/altstack.c代码和运行时测试。 此外,我们还添加了一个巧妙的特性(在以前的版本中:defer_or_not程序):发送 processSIGUSR2信号将使它打印出第一个和最新的堆栈指针地址。 它还将计算并显示增量效应,即应用到目前为止使用的堆栈内存量。

来自ch11/defer_or_not.c的更改:

  • 我们还捕捉信号。
    • SIGUSR2:用于显示第一个和最新的堆栈指针地址以及它们之间的增量。
    • SIGSEGV:这在实际应用中很重要。 捕获segfault使我们能够控制进程崩溃(这里,很可能是因为堆栈溢出),并可能显示(或在真正的应用中,写入日志)相关信息,执行清理,然后调用abort(3)退出。 认识到这一点,毕竟我们还是必须退出的:一旦这个信号从 OS 到达,进程就处于一种全新的未定义的状态。 (请注意,有关处理SIGSEGV的更多详细信息将在下一章中介绍)。
  • 为了避免在输出中产生太多噪音,我们将DELAY_LOOP宏替换为该宏的静默版本。

For readability, only key parts of the code are displayed; to view the complete source code, build, and then run it, the entire tree is available for cloning from GitHub here: https://github.com/PacktPublishing/Hands-on-System-Programming-with-Linux.

ch11/altstack.c:main()中:

<...>
altstacksz = atoi(argv[1])*1024;
setup_altsigstack(altstacksz);
<...>

setup_altsigstack()函数代码如下:

static void setup_altsigstack(size_t stack_sz)
{
     stack_t ss;
     printf("Alt signal stack size = %zu\n", stack_sz);
     ss.ss_sp = malloc(stack_sz);
     if (!ss.ss_sp)
         FATAL("malloc(%zu) for alt sig stack failed\n", stack_sz);
     ss.ss_size = stack_sz;
     ss.ss_flags = 0;
     if (sigaltstack(&ss, NULL) == -1)
         FATAL("sigaltstack for size %zu failed!\n", stack_sz);
}

信号处理代码如下:

static volatile sig_atomic_t s=0, t=0;
static volatile unsigned long stk_start=0, stk=0;

static void sighdlr(int signum)
{
     if (t == 0)
         stk_start = (unsigned long)stack();
     switch (signum) {
     case SIGUSR1:
         stk = (unsigned long)stack();
         s ++; t ++;
         if (s >= MAX)
         s = 1;
         fprintf(stderr, " s=%d ; total=%d; stack %p\n", s, t, stack());
    /* Spend some time inside the signal handler ... */
         DELAY_LOOP_SILENT(5);
         break;
 case SIGUSR2:
         fprintf(stderr, "*** signal %d:: stack@: t0=%lx last=%lx :               delta=%ld ***\n", signum, stk_start, stk, (stk_start-stk));
         break;
 case SIGSEGV:
         fprintf(stderr, "*** signal %d:: stack@: t0=%lx last=%lx :     
          delta=%ld ***\n", signum, stk_start, stk, (stk_start-stk));
         abort();
     }
}

考虑到以下情况,让我们执行一些测试并运行它们。

情况 1-非常小(100 KB)的备用信号堆栈

我们特意为备用信号堆栈分配了非常少量的空间-只有 100 千字节。 不用说,它很快溢出并出现段错误;我们的SIGSEGV处理程序运行,打印出一些统计数据:

$ ./altstack 100
Alt signal stack size = 102400
Running: signal SIGUSR1 flags: SA_NODEFER | SA_ONSTACK | SA_RESTART
Process awaiting signals ...

在另一个终端窗口中,运行 shell 脚本:

$ ./bombard_sig.sh $(pgrep altstack) 120
Sending 120 instances of signal SIGUSR1 to process 12811 ...

现在,原始窗口中的输出:

<...>
 s=1 ; total=1; stack 0xa20ff0
 s=2 ; total=2; stack 0xa208f0
 s=3 ; total=3; stack 0xa201f0

*--snip--*

 s=1 ; total=49; stack 0xa0bff0
 s=2 ; total=50; stack 0xa0b8f0
 s=3 ; total=51; stack 0xa0b1f0
*** signal 11:: stack@: t0=a20ff0 last=a0aaf0 : delta=91392 ***
Aborted
$ 

可以看到,根据我们的指标,在溢出时,备用信号堆栈的总使用量为 91,392 字节,接近 100KB。

Shell 脚本以预期的值结束:

<...>
./bombard_sig.sh: line 30: kill: (12811) - No such process
bombard_sig.sh: kill failed, loop count=53
$

案例 2:一个大型(16 MB)交替信号堆栈

这一次,我们特意为备用信号堆栈分配了大量空间-16 兆字节。 它现在可以处理数千个连续的移动信号。 但是,当然,在某个时候,它也会溢出:

$ ./altstack 16384
Alt signal stack size = 16777216
Running: signal SIGUSR1 flags: SA_NODEFER | SA_ONSTACK | SA_RESTART
Process awaiting signals ...

在另一个终端窗口中,运行 shell 脚本:

$ ./bombard_sig.sh $(pgrep altstack) 12000
Sending 12000 instances of signal SIGUSR1 to process 13325 ...

现在,原始窗口中的输出:

<...>
 s=1 ; total=1; stack 0x7fd7339239b0
 s=2 ; total=2; stack 0x7fd7339232b0
 s=3 ; total=3; stack 0x7fd733922bb0

*--snip--*

 s=2 ; total=9354; stack 0x7fd732927ab0
 s=3 ; total=9355; stack 0x7fd7329273b0
*** signal 11:: stack@: t0=7fd7339239b0 last=7fd732926cb0 : delta=16764160 ***
Aborted
$ 

Shell 脚本以预期的值结束:

./bombard_sig.sh: line 30: kill: (13325) - No such process
bombard_sig.sh: kill failed, loop count=9357
$ 

这一次,它成功地处理了大约 9000 个信号,然后才走出堆栈。 在溢出时,备用信号堆栈的总使用量为 16,764,160 字节,或接近 16MB。

处理大容量信号的不同方法

总而言之,如果您有一个场景,其中大量相同类型的多个信号(以及其他信号)以快速的速度传递到流程,那么如果我们使用通常的方法,就有丢失(或丢弃)信号的风险。 正如我们已经看到的,我们可以通过几种方式成功地处理所有信号,每种方式都有自己的大容量信号处理方法-利弊如下表所示:

| 方法 | 专业 | 缺点/限制 | | 在调用sigaction(2)之前使用sigfillset(3),以确保在处理信号时阻止所有其他信号。 | 简单明了的方法。 | 可能导致处理和/或丢弃信号的显著(不可接受)延迟。 | | 设置SA_NODEFER信号标志,并在信号到达时处理所有信号。 | 简单明了的方法。 | 加载时,堆栈使用率高,有堆栈溢出的危险。 | | 使用备用信号堆栈,设置第一SA_NODEFER个信号标志,并在所有信号到达时对其进行处理。 | 可以根据需要指定备用堆栈大小。 | 要设置的工作更多;必须在负载下仔细测试以确定(最大)要使用的堆栈大小。 | | 使用实时信号 (将在下一章中介绍)。 | 操作系统自动对挂起的信号进行排队,堆栈使用率低,可以对信号进行优先排序。 | 系统范围内对可以排队的最大数量的限制(可以作为 root 进行调整)。 |

简略的 / 概括的 / 简易判罪的 / 简易的

在本章中,读者最初已经了解了 Linux 操作系统上信号的概念,什么是信号,为什么它们有用,然后详细介绍了如何在应用中有效地处理信号。

当然,还有更多的内容,下一章将继续这一重要的讨论。 到时见。*