【译】IPC - Unix 信号

进程间通信(InterProcess Communication),简称 IPC。传统的 Unix 通信机制 IPC 有很多种方式:管道、有名管道、信号、消息队列、共享内存、套接字,本篇翻译的文章解释信号这一种方式。

原文链接:IPC - Unix Signals IPC

作者:Goodness

在上一篇文章中,我们介绍了 Unix 套接字以及如何将其用于进程间通信。本文讨论一种不同的有限形式的 IPC。

在我们研究过的 IPC 机制和大多数其他机制中,当一个应用程序进程向另一个应用程序进程发送消息时,接收进程会根据收到的消息执行相应的操作。这组消息很可能是一个字节或一组字节。接收进程需要对这些字节进行解析和检查,以确定如何采取的适当操作。这个要执行的操作可能是调用函数,也可能是执行程序表达式。有时,由于应用程序进程收到的消息不是为其发送的,因此无需执行任何操作。

然而,要想不执行任何操作,就少不了对消息进行解析。那么操作系统是如何进行解析,以确定我们的进程是否需要忽略该消息呢?更进一步的说,如果我们想让操作系统在应用程序进程中执行一个函数呢?

Unix Signal(信号/信号量)提供了这一点以及更多功能。但在我们进一步讨论它之前,我们需要更多地了解 Unix 进程。

进程和进程组

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

ps

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

bash 复制代码
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

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

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

bash 复制代码
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 脚本。这是一个非常简单的方法:

bash 复制代码
sleep 1000 &
sleep 500

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

bash 复制代码
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 脚本时,它都会将其进程组分配给其所有子进程。我想,到现在为止,你对进程组及其创建方式已经有了很好的了解。

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

bash 复制代码
kill

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

bash 复制代码
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 终止睡眠过程后输出的内容:

bash 复制代码
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 脚本:

sh 复制代码
sleep 100
sleep 200
sleep 300
sleep 400
sleep 500
sleep 600

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

arduino 复制代码
run.sh: line 1: 21862 Hangup: 1               sleep 100

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

arduino 复制代码
run.sh: line 2: 21880 Illegal instruction: 4  sleep 200

终止了,到下一个用 pid 22063 运行的。我将使用 kill -5 22063 终止它,输出是

arduino 复制代码
run.sh: line 3: 22063 Trace/BPT trap: 5       sleep 300

下一个是 pid 22099。我正在用 kill -6 22099 终止它。输出

arduino 复制代码
run.sh: line 4: 22099 Abort trap: 6           sleep 400

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

arduino 复制代码
run.sh: line 5: 22131 Floating point exception: 8   sleep 500

最后一个带有 pid 22163。我只是简单地运行 kill 22163。这是输出

arduino 复制代码
run.sh: line 6: 22163 Terminated: 15          sleep 600

你可以看到,对于每一个睡眠过程,它被终止的原因都是不同的。被终止的原因都有两部分:字符串和数字。字符串输出供人类使用,但数字更重要。如果你仔细看一下这些数字,你会发现它们与我们的 kill 命令中的前缀数字相对应,除了最后一个。事实证明,当你运行 kill pid 时,你实际上是在运行 kill -15 pid

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

到现在为止,我敢打赌你对这些前缀的数字或字符串代表什么感到好奇。别担心,继续跟着我来:-)。它们被称为信号。让我们深入了解它们。

信号

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

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

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

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

信号默认处理程序可以更改为其他处理程序。此处理程序可以是定义的函数或 SIG_IGNSIG_IGN 告诉进程忽略信号。我们使用 signal()sigaction 函数设置信号处理程序。我们可能希望处理一次信号,然后立即重置默认处理程序。我们可以通过将信号的处理函数设置为在我们定义的处理程序中 SIG_DFL 来做到这一点。

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

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()

在终端中运行此脚本。打开第二个终端,使用 ps 查找脚本进程的 pid。在第二个终端中运行 kill -8 script_pidkill -fpe script_pid。现在,转到运行脚本进程的第一个终端;您应该会在控制台中看到"You can't kill me, I'm bulletproof"。我们已将默认处理程序替换为 fpe_bulletproof 函数。每次运行 kill -8 ... 命令,则执行处理程序函数,并将语句打印到终端控制台。现在尝试运行 kill -1 script_pid,你会看到脚本已经终止。这是由于只为 SIGFPE 设置了一个处理程序,而不是其他信号。

请注意,处理程序函数必须有两个参数:signum 和 frame。

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

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

这与 IPC 有什么关系?

你可能会说我知道了。但这一切与 IPC 有什么关系?以下是每当在终端中运行 kill pid 时,都会启动 kill 进程的方法。此进程向其 pid 包含在命令参数中的进程发送信号。如果你稍微眯着眼睛,看起来 kill 进程向另一个进程发送了一条消息。这难道不是进程间通信吗?我们能拥有终止的力量吗?

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

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

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

Show me the code

下面演示了两个 Python 进程与 Signals 通信。这是第一个进程:

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 信号设置了一个处理函数。此函数使用 os.write 和递增 i 将语句输出到控制台。我们使用写入而不是打印,因为打印不是异步安全的。然后,它在 while 循环中发送 SIGUSR2 信号。当它接收到一百个 SIGUSR1 信号时,循环结束,并发送一个 SIGQUIT 信号。

这是第二个进程:

python 复制代码
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 个信号,SIGUSR2SIGQUIT。这些信号中的每一个都有其处理程序函数。在接收到 SIGUSR2 信号时,该过程会打印出一条语句并发送 SIGUSR 信号。当它收到 SIGQUIT 信号时,进程会将布尔变量 should_end 设置为 True。这将结束无限循环并确保我们的程序退出。

可以对多个信号使用一个处理程序函数。我们可以根据符号的值以不同的方式处理每个信号。

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

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 更多的工作,例如设置警报,处理错误。使用它时存在一些问题,因此请谨慎。

下一篇文章将介绍一种我直到最近才知道存在的机制,称为消息队列。在此之前,请照顾好自己,多喝水!✌🏾

参考文章:

[1] It is possible for pids to hit the maximum limit during uptime, thus causing a wrap around to a smaller value. Some Unix-based OSes give you the ability to enable random PID generation


[2] The table shows performance for sending 1KB messages. This is misleading for Signals. No data is sent via this mechanism.

相关推荐
追逐时光者2 小时前
免费、简单、直观的数据库设计工具和 SQL 生成器
后端·mysql
初晴~2 小时前
【Redis分布式锁】高并发场景下秒杀业务的实现思路(集群模式)
java·数据库·redis·分布式·后端·spring·
盖世英雄酱581362 小时前
InnoDB 的页分裂和页合并
数据库·后端
小_太_阳2 小时前
Scala_【2】变量和数据类型
开发语言·后端·scala·intellij-idea
直裾3 小时前
scala借阅图书保存记录(三)
开发语言·后端·scala
星就前端叭3 小时前
【开源】一款基于Vue3 + WebRTC + Node + SRS + FFmpeg搭建的直播间项目
前端·后端·开源·webrtc
小林coding4 小时前
阿里云 Java 后端一面,什么难度?
java·后端·mysql·spring·阿里云
AI理性派思考者4 小时前
【保姆教程】手把手教你在Linux系统搭建早期alpha项目cysic的验证者&证明者
后端·github·gpu
从善若水5 小时前
【2024】Merry Christmas!一起用Rust绘制一颗圣诞树吧
开发语言·后端·rust