IPC - Unix 信号

@高效码农  November 28, 2023

在上一篇文章中,我们介绍了Unix Socket以及如何使用它进行进程间通信。本文讨论一种不同且有限的 IPC 形式。

在我们研究过的 IPC 机制和大多数其他机制中,当应用程序进程向另一个应用程序进程发送消息时,接收进程将根据收到的消息采取操作。该消息很可能是一个字节或一组字节。需要解析和检查这些字节以确定要采取的适当操作。要采取的操作可能是调用函数或执行程序表达式。有时,由于应用程序进程收到了不适合它的消息,因此无需采取任何操作。然而,需要解析该消息才能采取任何操作。让操作系统进行解析以确定我们的进程是否需要忽略怎么样?让我们更进一步;如果我们想让操作系统在应用程序进程中执行一个函数怎么办?

Unix Signal 提供了这一点以及更多。但在我们进一步讨论之前,我们需要更多地了解 Unix 进程。

流程和流程组

打开你的终端并输入以下内容:

ps

上面的命令将打印出所有具有控制终端的进程。这是我的:

PID   TTY        TIME    CMD
10891 ttys044    0:00.74 /bin/zsh -il
15468 ttys056    0:00.33 /bin/zsh -il
16837 ttys056    0:00.00 sleep 2000

上面的输出列出了 pid、终端类型、CPU 时间以及进程命令及其参数。pid我们主要关心的。每当我们启动一个进程时,操作系统都会给它一个唯一的pid 。大多数情况下,在操作系统正常运行期间,新的pid会大于先前分配的pid [1]。在数据库术语中,pid类似于进程的主键。[](#footer-note-1)

现在我们了解了pids,接下来我们来谈谈进程组。根据维基百科,进程组是一个或多个进程的集合。每个进程都属于一个进程组,每个进程组都有一个名为pgid的 id 。缺省情况下,该ps命令不显示进程的pgid 。但是,我们可以ps通过键入命令将其包含在输出中ps -o "pid,tty,time,command,pgid"。这是我的终端输出:

PID   TTY        TIME    CMD          PGID
10891 ttys044    0:00.87 /bin/zsh -il 10891
15468 ttys056    0:00.41 /bin/zsh -il 15468
19198 ttys057    0:00.24 /bin/zsh -il 19198

眼尖的读者会注意到进程的pgid与其 相同pid;这是设计使然的。在大多数情况 下,当一个新进程启动时,它属于一个成员(它自己)的进程组。这个进程组被分配了一个id,方便的是新进程的id。如果一个新进程总是创建一个最大成员数为 1 的新进程组,那么多个进程如何属于一个组呢?这里有一个问题。如果我们一次启动一个进程,并且该进程有自己的组,那么如何将多个进程作为一组启动?答案是使用一种常见的方法来自动化系统管理任务,即 shell 脚本。这是一个非常简单的:

    sleep 1000 &
    sleep 500

这非常简单;两个睡眠进程同时启动。一个睡一千秒,另一个睡五百秒。这是我的终端的输出:

PID   TTY        TIME    CMD          PGID
---some processes---
20648 ttys002    0:00.30 /bin/zsh -il 20648
20815 ttys002    0:00.01 sh run.sh    20815
20816 ttys002    0:00.00 sleep 1000   20815
20817 ttys002    0:00.00 sleep 500    20815
---some processes---

您可以看到输出中的四个进程中的三个共享相同的进程组。它们是 shell 进程和两个睡眠进程。发生这种情况是因为每当您运行 shell 脚本时,它都会将其进程组分配给其所有子进程。我认为,到目前为止,您已经对流程组及其创建方式有了很好的了解。

让我们继续看一个已有 50 年历史的命令/系统调用。

kill

kill命令的基本形式只是执行其含义:终止进程。我们可以使用进程的pid来终止该进程。这是ps我的终端中的输出:

PID   TTY        TIME    CMD
20608 ttys000    0:00.19 -zsh
20648 ttys002    0:00.31 /bin/zsh -il
21195 ttys002    0:00.00 sleep 700
20820 ttys004    0:00.22 /bin/zsh -il

这是我通过键入以下命令终止睡眠进程后输出的内容kill 21195

PID   TTY        TIME    CMD
20608 ttys000    0:00.19 -zsh
20648 ttys002    0:00.32 /bin/zsh -il
20820 ttys004    0:00.23 /bin/zsh -il

正如你所看到的,它已经不复存在了;它一直 …。被杀了。该kill命令还可以一起杀死多个进程;您所要做的就是列出您想要终止的进程的 pid。当您在终端中键入内容时kill 12983 17838 19983,它会杀死列出 pid 的所有进程。

除了杀死列出 pid 的多个进程之外,还可以杀死进程组中的所有进程。这可以通过将 pid 参数设置为 0 来实现。

kill命令还接受以连字符为前缀的数字或名称。现在,就将其视为被杀的理由吧。让我们看一些包含此前缀数字或名称的示例以及它们的一些效果。我将在 shell 脚本中按顺序启动多个睡眠程序,并尝试在不同的终端中以稍微不同的方式终止它们,从秒数最少的程序开始。这是 shell 脚本:

sleep 100
sleep 200
sleep 300
sleep 400
sleep 500
sleep 600

这是我输入后原始终端的输出kill -1 21862

run.sh: line 1: 21862 Hangup: 1               sleep 100

它已经死了,下一个以 pid 21880 运行。我将使用 杀死它kill -4 21880。这是输出

run.sh: line 2: 21880 Illegal instruction: 4  sleep 200

死了,到下一个以 pid 22063 运行的程序。我将使用 杀死它kill -5 22063,输出是

run.sh: line 3: 22063 Trace/BPT trap: 5       sleep 300

下一个是 pid 22099。我用 杀死它kill -6 22099。输出

run.sh: line 4: 22099 Abort trap: 6           sleep 400

下一个是 pid 22131。我用 杀死它kill -8 22131,输出是

run.sh: line 5: 22131 Floating point exception: 8   sleep 500

最后一个 pid 22163。我只需运行kill 22163. 这是输出

run.sh: line 6: 22163 Terminated: 15          sleep 600

可以看到,对于每一个睡眠进程,其被杀死的原因都是不同的。每个原因都有两部分:字符串和数字。字符串输出是供人消费的,但数量更重要。如果你仔细观察这些数字,你会发现它们与我们的kill命令中的前缀数字相对应,除了最后一个。事实证明,当你跑步时kill pid,你真的在​​跑步kill -15 pid

还有一件事。您可以用带前缀的字符串替换这些带前缀的数字。这些字符串是唯一的,并且可以被kill 命令理解。每个字符串值都映射到一个数字,因此 runningkill -special_string pid与 running 相同kill -special_number pid。例如,kill -fpe pid相当于kill -8 pid. 它们都会导致浮点异常,从而导致进程终止。

到目前为止,我敢打赌您一定很好奇这些前缀数字或字符串代表什么。别担心,我有你:-)。它们被称为信号。让我们深入研究一下它们。

信号

信号是操作系统发送到进程的标准化消息。这些消息的列表数量非常有限,并且在每个现代 POSIX 兼容系统中都有定义。有些操作系统可能较多,有些较少,但有些通用操作系统适用于所有基于 UNIX 的操作系统。以下是维基百科#POSIX_signals)上的它们及其含义的列表。

这些消息具有高优先级,因此,必须中断进程的正常流程来处理它们。它们具有高优先级的主要原因是因为许多进程错误是使用信号传递给进程的。例如,您可以从kill上面的输出中看到,尽管进程没有错误,但某些原因看起来像错误消息。

现在,我知道将信号发送到进程的两种方式。它们是raise函数和kill命令/系统调用。可能还有其他方法,但我对它们一无所知。该raise函数只是一个向自身发送信号的进程。我们已经研究了该kill命令,我将很快讨论其等效的系统调用函数。操作系统内核还可以通过直接操作进程结构来发送信号。

每个信号在进程中都必须有一个处理函数。每当进程收到信号时就会执行此函数。该函数可以在内核或用户级代码中定义。当操作系统启动一个新的应用程序进程时,它会为其每个信号对象分配默认处理程序。某些信号的默认处理程序会终止该进程。其他一些默认处理程序不执行任何操作,即信号被忽略。信号的默认处理程序可以用常量引用SIG_DFL您可以在此处查看默认信号操作的列表。

信号默认处理程序可以更改为不同的处理程序。该处理程序可以是定义的函数或SIG_IGN. SIG_IGN告诉进程忽略该信号。我们使用 或signal()函数设置信号处理程序sigaction。我们可能希望处理一次信号并立即重置默认处理程序。我们可以通过将信号的处理函数设置到SIG_DFL我们定义的处理程序内部来做到这一点。

说够了;让我们演示一个简单的例子。下面是一个执行无限循环的简单 Python 脚本:

    import signal

    def fpe_bulletproof(signum, frame):
        print("You can't kill me, I'm bulletproof")


    def run():
        signal.signal(signal.SIGFPE, fpe_bulletproof)
        while True: pass

    run()

在终端中运行此脚本。打开第二个终端并使用 查找脚本进程的 pid ps。在第二个终端中运行kill -8 script_pid或。kill -fpe script_pid现在,转到运行脚本进程的第一个终端;您应该会在控制台上看到“你不能杀我,我是防弹的”。发生的事情是我们用该函数替换了默认处理fpe_bulletproof程序。每次运行该kill -8 ...命令时,都会执行处理函数,并将该语句打印到终端控制台。现在尝试运行kill -1 script_pid,您将看到脚本已终止。这是因为仅设置了处理程序SIGFPE而不是其他信号。

请注意,处理函数必须有两个参数:符号和框架。

几乎所有信号的处理程序都可以更改,除了SIGKILLSIGSTOP信号之外。这些信号不能被停止或忽略。您可以通过更改SIGFPESIGKILL然后重新运行脚本来尝试此操作。抛出“无效参数”异常。这就是为什么当您想从终端强制终止进程时,请键入kill -9 process_pid。9代表信号SIGKILL

警告注意,您必须小心处理程序函数内执行的内容。由处理函数直接和间接调用的函数必须是异步安全的。在处理函数中调用异步不安全函数可能会调用未定义的行为。可以在此处找到异步安全函数的列表。

这与 IPC 有何关系?

我知道我知道。这一切与 IPC 有何关系?以下是每当kill pid在终端中运行时,kill就会启动一个进程的方式。该进程向其 pid 包含在命令参数中的进程发送信号。如果你仔细观察,就会发现该kill进程向另一个进程发送了一条消息。这不就是进程间通信吗?我们能拥有这样的力量kill吗?

幸运的是,我们可以做kill 所做的事情。那是因为kill进程调用了kill()系统调用。我们可以通过使用其pid和信号执行系统调用来向另一个应用程序进程发送消息。

使用 时,可以很容易地从一个独立进程到另一个进程进行单向通信kill()。我们所要做的就是运行接收进程,获取其 pid,然后使用 pid 运行发送进程。让两个进程互相了解 pid 需要其他 IPC 机制来传达它们的 pid。如果我们不想这样做怎么办?kill如果我们希望两个进程在不知道彼此 pid 的情况下调用该函数怎么办?

我们可以用“进程组”两个词来回答上面的问题。我们可以使用相同的进程组启动进程,并使用 相互发送信号kill(0, signum)。这样,就不需要进行 pid 交换,并且可以在 pid 无知的情况下进行 IPC。

显示代码

以下是两个 Python 进程与信号进行通信的演示。这是第一个:

    import os
    import signal

    i = 0
    ROUNDS = 100

    def print_pong(signum, frame):
        global i
        os.write(1, b"Client: pong\n")
        i += 1


    def run():
        signal.signal(signal.SIGUSR1, print_pong)
        while i < ROUNDS:
            os.kill(0, signal.SIGUSR2)
        os.kill(0, signal.SIGQUIT)

    run()

上面的代码为SIGUSR1信号设置了一个处理函数。该函数使用 i 并递增 i 将语句打印到控制台os.write。我们使用write而不是print因为它print不是异步安全的。SIGUSR2然后它在 while 循环中发送信号。当它收到一百个SIGUSR1信号时,循环结束,并SIGQUIT发送一个信号。

这是第二个过程:

    import os
    import signal

    should_end = False

    def end(signum, frame):
        global should_end
        os.write(1, b"End\n")
        should_end = True

    def print_ping(signum, frame):
        os.write(1, b"Server: ping\n")
        os.kill(0, signal.SIGUSR1)


    def run():
        signal.signal(signal.SIGUSR2, handler=print_ping)
        signal.signal(signal.SIGQUIT, end)

        while not should_end:
            pass
    
    run()

这个处理 2 个信号,SIGUSR2并且SIGQUIT. 每个信号都有其处理函数。接收到SIGUSR2信号后,进程打印出一条语句并发送一个SIGUSR信号。当它接收到SIGQUIT信号时,进程将布尔变量设置should_end为 True。这将结束无限循环并确保我们的程序退出。

一个处理函数可以处理多个信号。我们可以根据 的值以不同的方式处理每个信号signum

您会注意到两个程序都将 pid 参数设置为os.kill0。这是有效的,因为两个进程都在同一进程组中运行。以下是用于运行进程的 shell 脚本:

    trap "" USR1 USR2 QUIT
    python3 server.py & python3 client.py

我们使用 trap 指令来处理两个 Python 进程发送的所有信号。这是因为kill(0, sig)向进程组中的所有进程发送信号,并且shell进程与其默认处理程序(终止)位于同一进程组中。我们不希望这样,这就是为什么我们用空语句来处理它们。

表现

信号速度很快。Cloudflare对每秒 404,844 条消息进行了基准测试[2]。这可以满足大多数性能需求。

演示代码

您可以在GitHub上的 UDS 上找到我的代码。

结论

Unix 信号对于 IPC 来说是一种简单但有限的机制。它们比 IPC 能做更多的事情,例如设置警报、处理错误。使用过程中会出现一些问题,请谨慎使用。

下一篇文章将介绍一种直到最近我才知道其存在的机制,称为消息队列。在那之前,照顾好自己并保持水分!✌



评论已关闭