C 语言,Bash, Java 如何响应 Linux Signal

本文初衷是要专注在 Docker 内的应用进程如何与外部发过来的 Linux 信号进行响应。具体应用在当运行为一个 ECS 的 Docker 容器时,对 ECS 的 AutoScaling 以及部署时如何让应用能正确收到相应的信号。可是一提及这一话题,思维就产生了极大的发散,很想好好捋一捋进程与 Linux 信号之间关系和相关的概念。 关于 Linux 的 Signal 请参考 Wikipedia 的 Signal(IPC).

本文以渐进的方式,从信号的简介,本地 C, Bash, Java 程序与信号,然后将在下一篇学习本地 Docker 容器内进程与信号的处理,ECS 的容器与 ASG,部署行为的信号响应。

关于 Linux/Unix 的信号

这里所说的信号,就是在 Linux 用 kill -ltrap -l 能列出的那些信号,就是像我们对一个进程的 kill, ctrl+c, ctrl+z 产生的信号。 以 Bash 的 trap -l 列表为例,其中列出所有的信号

1bash-3.2$ trap -l
2 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL
3 5) SIGTRAP	 6) SIGABRT	 7) SIGEMT	 8) SIGFPE
4 9) SIGKILL	10) SIGBUS	11) SIGSEGV	12) SIGSYS
513) SIGPIPE	14) SIGALRM	15) SIGTERM	16) SIGURG
617) SIGSTOP	18) SIGTSTP	19) SIGCONT	20) SIGCHLD
721) SIGTTIN	22) SIGTTOU	23) SIGIO	24) SIGXCPU
825) SIGXFSZ	26) SIGVTALRM	27) SIGPROF	28) SIGWINCH
929) SIGINFO	30) SIGUSR1	31) SIGUSR2

每个信号都有一个编译,例如我们用 kill <pid> 会向应用程序发送 SIGTERM 信号。kill -9 <pid> 发送 SIGKILL, 该信号直接被内核处理掉。 ctrl+c 产生的是 SIGINT 中断信号,ctrl+z 产生 SIGTSTP 信号。程序正常退出(exit(0)) 不会产生任何信号。

更多 kill 发送信号的方式

1kill -15 <pid>
2kill -s SIGINT <pid>
3kill -ABRT <pid>

C 语言处理信号

前一节讲了如何用键盘和 kill 命令发送信号,那么进程在收到信号之后,能否识别是何种类型的信号,并作出相应的反应。用低一级别的汇编或 C 语言应对信号的能力肯定要强, 下面以 C 为例,只捕获收到的信号并输出信号 ID 及相应的名称。

test-sig.c

 1#include <stdio.h>
 2#include <signal.h>
 3#include <unistd.h>
 4#include <string.h>
 5
 6void handler(int signum) {
 7    printf("received signal: %d: %s\n", signum, strsignal(signum));
 8}
 9
10int main() {
11    for (int i = 1; i < 32; i++) {
12        signal(i, handler);  // register handler for signal 1~31
13    }
14
15    printf("PID: %d\n", getpid());
16    while (1) pause();
17}

编译并运行

1gcc -o test-sig test-sig.c
2./test-sig
3PID: 28095

测试它,在另一个终端执行

1export PID=28095
2kill -TERM $PID
3kill $PID
4kill -s SIGHUP $PID
5kill -30 $PID
6kill -2 $PID

然后在 ./test-sig 所在终端按依次按 ctrl+cctrl+z, 最后用 kill -9 $PID 强制结束它. 在 ./test-sig 终端的输出为

1received signal: 15: Terminated: 15
2received signal: 15: Terminated: 15
3received signal: 1: Hangup: 1
4received signal: 30: User defined signal 1: 30
5received signal: 2: Interrupt: 2
6^Creceived signal: 2: Interrupt: 2
7^Zreceived signal: 18: Suspended: 18
8Killed: 9

trap 命令处理信号

Linux 的 trap 命令也能对部分信号进行监听处理,用法为

1trap <命令> <空格分开的信号列表,名称或数字>

如 test.sh

 1trap 'echo "received signal: SIGUP"' SIGHUP
 2
 3cleanup() {
 4    echo "do cleanup"
 5    exit 0
 6}
 7
 8trap cleanup SIGTERM SIGINT 18
 9
10echo "PID: $$"
11
12while true; do
13    sleep 1
14done

执行 bash test.sh 产生的 PID 是 32799. 然后执行

1kill -HUP 32799
2kill -18 32799

就能看到 bash test.sh 的输出

1bash-3.2$ bash a.sh
2PID: 32799
3received signal: SIGUP
4do cleanup

但是 ctrl+z 却无法结束它,并且之后再用 kill -18 <pid> 也不能结束。trap - SIGTERM 还能恢复针对该信号的默认行为。

Java 处理信号

Java 离操作系统还隔着一层虚拟机,所以它接收处理信号的能力较弱。高层的 API 接口只有一个,而且还不能分辨是哪种类型的信号,只知收到了信号

1Runtime.getRuntime().addShutdownHook(new Thread(() -> {
2    System.out.println("Received signal");
3}));

下面用一段 Java 代码来演示运行着服务,并注册了 ShutdownHook, 测试它能捕获到什么信号

Test.java

 1public class Test {
 2    static {
 3        Runtime.getRuntime().addShutdownHook(new Thread(() -> {
 4            System.out.println("start execute shutdown hook");
 5            try { Thread.sleep(3000); } catch (InterruptedException ignored) {}
 6            System.out.println("finished execute shutdown hook");
 7        }));
 8    }
 9
10    public static void main(String[] args) throws InterruptedException {
11        Thread worker = new Thread(() -> {
12            while (true) {
13                try {
14                    System.out.println("service is running ...");
15                    Thread.sleep(500);
16                } catch (InterruptedException e) {
17                    System.out.println("service interrupted!");
18                    return;
19                }
20            }
21        });
22        worker.start();
23
24        System.out.println("PID: " + ProcessHandle.current().pid());
25        Thread.sleep(5 * 60 * 1000);
26        System.out.println("main thread end");
27    }
28}

启动它

1java Test.java
2service is running ...
3PID: 42209
4service is running ...
5service is running ...
6......

打印出进程 ID, 并持续输出模拟服务的运行。

测试 ctrl+c

1^Cstart execute shutdown hook
2service is running ...
3service is running ...
4service is running ...
5service is running ...
6service is running ...
7service is running ...
8finished execute shutdown hook
9bash-3.2$

主线程从当前位置结束,信号可被捕获到并成功退出程序,ShutdownHook 执行期间服务仍然运行,至到 ShutdownHook 结束并终止程序。

测试 kill <pid>

行为与 ctrl+c 一致,除了在控制台中不会看到 ^C 之外。

测试 kill -9 <pid>

1service is running ...
2service is running ...
3Killed: 9

直接退出进程,ShutdownHook 得不到执行。

测试程序正常结束

因为主线程是 Sleep 五分钟,观察五分钟后主线程结束时的输出。

1service is running ...
2service is running ...
3main thread end
4service is running ...
5service is running ...
6........

子线程一直在运行,程序得不到退出,所以也不会触发 ShutdownHook. 原因是子线程的 Daemon 为 false. 代码中加上

1worker.setDaemon(true);

再自然运行五分钟,这时候的输出为

 1service is running ...
 2service is running ...
 3main thread end
 4start execute shutdown hook
 5service is running ...
 6service is running ...
 7service is running ...
 8service is running ...
 9service is running ...
10service is running ...
11finished execute shutdown hook
12bash-3.2$

非守护线程会阻止进程的退出,主线程结束,在没有非守护子线程时触发 ShutdownHook 捕捉到,ShutdownHook 执行期间,子线程仍然活跃。

kill -s <pid> SIGQUIT

对 Java 进程执行 kill -s <pid> 只会在控制台打印线程栈信息,不管是否注册了 ShutdownHook. 并且 kill -3SIGQUIT 信号不会被 ShutdownHook 捕获到, 更不会让程序退出。

小结一下,Java 的 ShutdownHook 可以捕获到的信号只有 SIGTERMSIGINT,或是在程序自然退出时触发。SIGTERM, SIGINT 的触发过程是

  1. 让主线程在当前位置终止
  2. 转而开始执行 ShutdownHook
  3. ShudownHook 执行期,子线程服务仍在运行
  4. ShudownHook 结束前,子线程服务结束
  5. ShutdownHook 也退出
  6. 整个进程退出

SIGTERMSIGINT 也在乎其他子线程是否是守护线程,保证要退出程序。

另外在 ShutdownHook 本身是一个线程,如果在其中再启动子线程的话,还是能够得到执行,无论其是否 Daemon.

 1    static {
 2    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
 3        System.out.println("start execute shutdown hook");
 4        try { Thread.sleep(3000); } catch (InterruptedException ignored) {}
 5        System.out.println("finished execute shutdown hook");
 6
 7        var thread = new Thread(()->{
 8            System.out.println("xxxxxxxx");
 9        });
10        thread.setDaemon(true); // 有无这行都没关系
11        thread.start();
12    }));
13}

ctrl+c 的终止效果为

 1service is running ...
 2service is running ...
 3^Cstart execute shutdown hook
 4service is running ...
 5service is running ...
 6service is running ...
 7service is running ...
 8service is running ...
 9service is running ...
10finished execute shutdown hook
11xxxxxxxx
12bash-3.2$

Java 的 ShutdownHook 也没有超时,如果在其中做太多事情,退不出来的话程序就卡在 ShutdownHook 当中,一般来说只在 ShutdownHook 做些清理操作。

对于 SpringBoot 应用,有一个属性

1spring.lifecycle.timeout-per-shutdown-phase: 30s

并且它的默认值就是 30 秒,也就是你的 ShutdownHook 执行超过 30s 的话,程序将被强行终止。

Java 低级别的 Signal API

下面的 Java 代码使用了自 1.2 引入的 sun.misc.Signal,它能支持的信号基本就这些。

 1import sun.misc.Signal;
 2
 3public class JSignal {
 4
 5    public static void main(String[] args) throws Exception {
 6        String[] signals = new String[]{"HUP", "INT", "TRAP", "ABRT", "EMT", "SYS",
 7            "PIPE", "ALRM", "TERM", "URG", "TSTP", "CONT", "CHLD", "TTIN", "TTOU", 
 8            "IO", "XCPU", "XFSZ", "VTALRM", "PROF", "WINCH", "INFO", "USR1", "USR2"};
 9
10        for (String signal : signals) {
11            Signal.handle(new Signal(signal), signalName -> {
12                System.out.println("received " + signalName);
13            });
14        }
15
16        System.in.read();
17    }
18}

对于不能注册的信号,启动时会提示错误,例如

1Exception in thread "main" java.lang.IllegalArgumentException: Signal already used by VM or OS: SIGSEGV
2	at java.base/jdk.internal.misc.Signal.handle(Signal.java:172)
3	at jdk.unsupported/sun.misc.Signal.handle(Signal.java:157)

上面代码的执行行为与最前面的 C 代码差不多。

执行端

1bash-3.2$ java JSignal.java
2^Creceived SIGINT
3^Zreceived SIGTSTP
4received SIGHUP
5received SIGUSR1
6received SIGTERM
7Killed: 9
8bash-3.2$

以上信息是由 ctrl+c, ctrl+z 产生的 SIGINTSIGTSTP 和下面 kill 命令造成的

1kill -1 59838
2kill -30 59838
3kill  59838
4kill -9 59838

假定 java JSignal.java 的进程 ID 是 59838.

永久链接 https://yanbin.blog/c-bash-java-react-to-ipc-signal/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。