Atom

思考、故事和创意

Linux

对 Google TCP BBR 的浅薄认识

关于 Google 发布的新的 TCP 拥塞控制算法 BBR (Bottleneck Bandwidth and RTT) 的一些记录,仍然在学习中,难免有误解。之后会持续修正和完善。

网络链路上的包比较少时,道路很通畅,这个阶段,对于一个 TCP 连接来说,它的速度由这个连接两端之间的距离决定,也可以说是由 RTT 决定。当发包速率变大,把道路基本上填满了之后,这个阶段,带宽的大小决定了这个连接的速度,这时两端之间可能就会有包要排队,延迟时间除了 RTT 还有排队时间。

BBR 目的是要尽量跑满带宽,并且尽量不要有排队的情况。

cwnd 是普通的拥塞控制算法里最终要求得的一个值,用来控制发包速率。BBR 也要求到这个值,但是它不是最主要的控制发包速率的变量,主要的变量是 pacing_rate

这两个变量都由探测到的带宽值和 RTT 值得到,整个过程都围绕着这两个值。在 BBR 算法中,有四种状态,几种状态可能会有如下的转换:

            |
            V
   +---> STARTUP  ----+
   |        |         |
   |        V         |
   |      DRAIN   ----+
   |        |         |
   |        V         |
   +---> PROBE_BW ----+
   |      ^    |      |
   |      |    |      |
   |      +----+      |
   |                  |
   +---- PROBE_RTT <--+

另外还有一个重要变量,是在不同状态,不同阶段取的不同增益系数,比如有时需要多发包探测到最大瓶颈带宽,有时需要把发包量降下来探测较为准确的 RTT。

STARTUP 类似与普通拥塞控制里的慢启动,增益系数是 2ln2,每一个来回都以这个系数增大发包速率,估测到带宽满了就进入 DRAIN 状态 —— 连续三个来回,测得的最大瓶颈带宽没有比上一轮增大 25% 以上,就算做带宽满了。

进入 DRAIN 状态,增益系数小于 1,也就降速了。一个包来回,把 STARTUP 状态中产生的队列“抽干”,怎么样测算到队列空了?发出去还没有 ACK 的包量 inflight,与 BDP (带宽延迟积)进行比较,inflight < BDP 说明空了,道路不那么满了,如果 inflght > BDP 说明还不能到下一个状态,继续 DRAIN。

PROBEBW 是稳定状态,这时已经测出来一个最大瓶颈带宽,而且尽量不会产生排队现象。之后的每个来回,在 PROBEBW 状态循环(除非要进入下面提到的 PROBERTT 状态),轮询下面这些增益系数,5/4, 3/4, 1, 1, 1, 1, 1, 1,如此,最大瓶颈带宽就会在其停止增长的地方上下徘徊。大部分时间都应该处于 PROBEBW 状态。

前面三种状态,都可能进入 PROBERTT 状态。超过十秒没有估测到更小的 RTT 值,这时进入 PROBERTT 状态,把发包量降低,空出道路来比较准确得测一个 RTT 值,至少 200ms 或一个包的来回之后退出这个状态。检查带宽是否是满的,进入不同的状态:如果不满,进入 STARTUP 状态,如果满,进入 PROBEBW 状态。

利用 ipset 封禁大量 IP

使用 iptables 封 IP,是一种比较简单的应对网络攻击的方式,也算是比较常见。有时候可能会封禁成千上万个 IP,如果添加成千上万条规则,在一台注重性能的服务器或者本身性能就很差的设备上,这就是个问题了。ipset 就是为了避免这个问题而生的。

关于 iptables,要知道这两点。

  • iptables 包含几个表,每个表由链组成。默认的是 filter 表,最常用的也是 filter 表,另一个比较常用的是 nat 表。一般封 IP 就是在 filter 表的 INPUT 链添加规则。
  • 在进行规则匹配时,是从规则列表中从头到尾一条一条进行匹配。

这像是在链表中搜索指定节点费力。ipset 提供了把这个 O(n) 的操作变成 O(1) 的方法:就是把要处理的 IP 放进一个集合,对这个集合设置一条 iptables 规则。像 iptable 一样,IP sets 是 Linux 内核中的东西,ipset 这个命令是对它进行操作的一个工具。

简单的流程

可以用这几条命令概括使用 ipset 和 iptables 进行 IP 封禁的流程

ipset create vader hash:ip
iptables -I INPUT -m set --match-set vader src -j DROP
ipset add vader 4.5.6.7
ipset add vader 1.2.3.4
ipset add vader ...
ipset list vader # 查看 vader 集合的内容

下面分别对各条命令进行描述。

创建一个集合

ipset create vader hash:ip

这条命令创建了名为 vader 的集合,以 hash 方式存储,存储内容是 IP 地址。

添加 iptables 规则

iptables -I INPUT -m set --match-set vader src -j DROP

如果源地址(src)属于 vader 这个集合,就进行 DROP 操作。这条命令中,vader 是作为黑名单的,如果要把某个集合作为白名单,添加一个 ‘!’ 符号就可以。

iptables -I INPUT -m set ! --match-set yoda src -j DROP

到现在虽然创建了集合,添加了过滤规则,但是现在集合还是空的,需要往集合里加内容。

找出“坏” IP

找出要封禁的 IP,这是封禁过程中重要的步骤,不过不是这里的重点。简要说明一下两种方法思路。

netstat -ntu | tail -n +3 | awk '{print $5}' | sort | uniq -c | sort -nr

直接通过 netstat 的信息,把与本地相关的各种状态的 IP 都计数,排序列出来。

或者从 nginx 或者其他 web server 的日志里找请求数太多的 IP

awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -nr

后半部分,排序,去重,再按次数进行逆向排序的操作,跟上面命令是一样的。

找出“坏” IP,往之前创建的集合里添加就可以了。

ipset add vader 4.5.6.7

有多少“坏” IP,就添加多少 IP,因为针对这些封禁的 IP 只需要一条 iptables 规则,而这些 IP 是以 hash 方式存储,所以封禁大量的 IP 也不会影响性能,这也是 ipset 存在的最大目的。

You shall not pass!

ipset 更多的用法

存储类型

前面例子中的 vader 这个集合是以 hash 方式存储 IP 地址,也就是以 IP 地址为 hash 的键。除了 IP 地址,还可以是网络段,端口号(支持指定 TCP/UDP 协议),mac 地址,网络接口名称,或者上述各种类型的组合。

比如指定 hash:ip,port就是 IP 地址和端口号共同作为 hash 的键。查看 ipset 的帮助文档可以看到它支持的所有类型。

下面以两个例子说明。

hash:net

ipset create r2d2 hash:net
ipset add r2d2 1.2.3.0/24
ipset add r2d2 1.2.3.0/30 nomatch
ipset add r2d2 6.7.8.9
ipset test r2d2 1.2.3.2

hash:net 指定了可以往 r2d2 这个集合里添加 IP 段或 IP 地址。

第三条命令里的 nomatch 的作用简单来说是把 1.2.3.0/301.2.3.0/24 这一范围相对更大的段里“剥离”了出来,也就是说执行完 ipset add r2d2 1.2.3.0/24 只后1.2.3.0/24 这一段 IP 是属于 r2d2 集合的,执行了 ipset add r2d2 1.2.3.0/30 nomatch 之后,1.2.3.0/24 里 1.2.3.0/30 这部分,就不属于 r2d2 集合了。执行 ipset test r2d2 1.2.3.2 就会得到结果 1.2.3.2 is NOT in set r2d2.

hash:ip,port

ipset create c-3po hash:ip,port
ipset add c-3po 3.4.5.6,80
ipset add c-3po 5.6.7.8,udp:53
ipset add c-3po 1.2.3.4,80-86

第二条命令添加的是 IP 地址为 3.4.5.6,端口号是 80 的项。没有注明协议,默认就是 TCP,下面一条命令则是指明了是 UDP 的 53 端口。最后一条命令指明了一个 IP 地址和一个端口号范围,这也是合法的命令。

自动过期,解封

ipset 支持 timeout 参数,这就意味着,如果一个集合是作为黑名单使用,通过 timeout 参数,就可以到期自动从黑名单里删除内容。

ipset create obiwan hash:ip timeout 300
ipset add obiwan 1.2.3.4
ipset add obiwan 6.6.6.6 timeout 60

上面第一条命令创建了名为 obiwan 的集合,后面多加了 timeout 参数,值为 300,往集合里添加条目的默认 timeout 时间就是 300。第三条命令在向集合添加 IP 时指定了一个不同于默认值的 timeout 值 60,那么这一条就会在 60 秒后自动删除。

隔几秒执行一次 ipset list obiwan 可以看到这个集合里条目的 timeout 一直在随着时间变化,标志着它们在多少秒之后会被删除。

如果要重新为某个条目指定 timeout 参数,要使用 -exit 这一选项。

ipset -exist add obiwan 1.2.3.4 timeout 100

这样 1.2.3.4 这一条数据的 timeout 值就变成了 100,如果这里设置 300,那么它的 timeout,也就是存活时间又重新变成 300。

如果在创建集合是没有指定 timeout,那么之后添加条目也就不支持 timeout 参数,执行 add 会收到报错。想要默认条目不会过期(自动删除),又需要添加某些条目时加上 timeout 参数,可以在创建集合时指定 timeout 为 0。

ipset create luke hash:ip
ipset add luke 5.5.5.5 timeout 100
# 得到报错信息 kernel error received: Unknown error -1

更大!

hashsize, maxelem 这两个参数分别指定了创建集合时初始的 hash 大小,和最大存储的条目数量。

ipset create yoda hash:ip,port hashsize 4096 maxelem 1000000
ipset add yoda 3.4.5.6,3306

这样创建了名为 yoda 的集合,初始 hash 大小是 4096,如果满了,这个 hash 会自动扩容为之前的两倍。最大能存储的数量是 100000 个。

如果没有指定,hashsize 的默认值是 1024,maxelem 的默认值是 65536。

另外几条常用命令

ipset del yoda x.x.x.x    # 从 yoda 集合中删除内容
ipset list yoda           # 查看 yoda 集合内容
ipset list                # 查看所有集合的内容
ipset flush yoda          # 清空 yoda 集合
ipset flush               # 清空所有集合
ipset destroy yoda        # 销毁 yoda 集合
ipset destroy             # 销毁所有集合
ipset save yoda           # 输出 yoda 集合内容到标准输出
ipset save                # 输出所有集合内容到标准输出
ipset restore             # 根据输入内容恢复集合内容

还有……

  • 如果创建集合是指定的存储内容包含 ip, 例如 hash:iphash:ip,port ,在添加条目时,可以填 IP 段,但是仍然是以单独一个个 IP 的方式来存。
  • 上面所有的例子都是用 hash 的方式进行存储,实际上 ipset 还可以以 bitmap 或者 link 方式存储,用这两种方式创建的集合大小,是固定的。
  • 通过 man upsetipset —help 可以查到更多的内容,包括各种选项,支持的类型等等。

好好写 Shell 脚本

shell 种类众多,并且语法各异,如果自己又不熟悉任何一种 shell 的话,就会经常感觉语法怪异,而且似乎不够严谨,甚至有时候要边搜边写,这就使得一些脚本成为了一些勉强可用的语句的拼凑,几乎不可维护。即便是一些所谓的“技术大牛”,各种高大上的词都能吹得天花乱坠的,也写不了像样的脚本,这是个蛮尴尬的事情,固然是术业有专攻,但是写个 Linux Shell 脚本,应该算是个基础(其实有可能他们连链表怎么实现都不知道)。这里主要是说最为通用的 bash,以下是几条 bash “代码规范”。

1. 及早退出

脚本的开头,#! 语句之后,加上这几行

set -o errexit
set -o pipefail

第一行语句的作用是,在脚本执行过程中,如果有错误,就退出脚本,不再继续下去。如果一条语句执行完,返回值不是 0,就是错误。

第二行语句的作用是,如果脚本中有一行命令是由一个或多个管道连起来的多个命令,如果其中有一条或者多条命令出现错误(返回非 0 值),这一整行命令返回的结果就是最后那条返回失败结果的命令的返回值。

如果预期一条语句可能会产生失败,就自己做好判断以及之后的处理,可以使用 if 语句或者 &&||。例如:

nc -z 8.8.8.8 53 && echo "OK" || echo "FAIL"

如果能连上 8.8.8.8 的 53 端口,则结果为真,输出 OK,如果连接不上,也就是最开始的语句结果为假,echo "OK" 就被“短路”了,最后输出了 FAIL。

2. 使用函数

在 bash 里这样定义和调用函数

func_name()
{
    echo "do something"
}

func_name

在函数中 $1, $2 等等就是第一个参数,第二个参数等。

  • func_name { ... } 跟上面的区别是函数名后直接是函数内容,这种定义函数的方法已经被废弃
  • 一个脚本要有一个 main 函数,直接写 main 进行调用,或者 main "$@" 把命令行参数传给它

使用函数的好处跟其他语言中使用函数的好处一样,但是很长时间以来,都不是很习惯在 shell 脚本里用函数,或许是最开始受“shell 脚本就是命令的堆积”这样一个指示的影响。

3. 更好地使用全局变量、局部变量

这跟使用函数一样是放之四海皆准的规范:尽量避免使用全局变量。除非是些全局用的常量,定义这种变量时,用 readonly 对变量进行修饰。在函数内部使用局部变量,用 local 修饰变量。

为什么要显式地去声明一个变量是局部变量?注意一个天大的误会,不像 C 语言,在一个函数里的变量就是局部变量,在 bash 里,函数里的变量如果没有用 local 修饰,那么它是一个全局变量!或许这是认为 bash 比较怪异的一个原因。

如果要在一个函数内定义一个只读的全局变量,使用 declare -r var_name 或者 local -r var_name,在函数内通过 declarelocal 声明的变量都是局部变量,-r 参数使变量只读。

declarelocal 作用非常类似,不过 local 只能在函数内使用,declare 在函数内外都可以使用。在函数内用 declare 声明的是局部变量,在函数外使用 declare 的变量自然是全局变量。

4. 其他细节

  • 使用 [[ ... ]] 而不是使用 [ ... ] 或者 test 进行判断
  • 使用 $( ... ) 代替丑恶的反引号 ```
  • 不要有光秃秃的 $ 符号,把变量都放进双引号,例如 "$var"
  • 大多数情况下都使用双引号,除非有强有力的原因
  • 简单的条件判断不需要用 if,用 &&|| 即可,就像开头 nc -z 8.8.8.8 53 && echo "OK" || echo "FAIL" 这个例子
  • then, do 等等,放到 if, for 等关键字的同一行

free 命令 buffers 和 cached

大多数 Linux 用户应该都知道通过 free 命令或者 cat /proc/meminfo 查看内存使用情况。

下面是 free 命令结果的一个示例:

             total      used     free    shared    buffers    cached
Mem:       2049916   1139976   909940       680     105628    381768
-/+ buffers/cache:    652580  1397336
Swap:            0         0        0

第一行 Mem

  • 第一列值 2049916 表示总共有多少物理内存 (total)
  • 第二列 1139976 表示已使用了多少内存 (used)
  • 第三列表示未使用的内存 (free)

第二列和第三列,即 used 和 free 的值相加,等于 total 的值。第四列表示共享内存大小。第五列和第六列分别是 buffers 和 cached,都是和缓存有关的值,为了分清二者区别,需要仔细的理一下相关的知识。

第二行 -/+ buffers/cache

  • 第一列值 652580 在 used 项的下面,它表示的是第一行 used 的值减去第一行的 buffers 和 cached 的值 (2049916 - 105628 - 381768),这意味着系统除缓存外真实占用的内存有 652580KB,因为缓存相关的内存区域在内存紧张时系统会释放掉。
  • 第二行在 free 项的值 1397336 是第一行 free 的值加上第一行 buffers 和 cached 的值 (909940 + 105628 + 381768),也就是指真正能够使用的内存,空闲内存+用于缓存的内存。

第三行就是 swap 空间的总共、已用、空闲的大小。

最主要的问题是 buffers 和 cached 分别代表什么,它们的区别是什么。为了搞清楚,需要从缓存“两端”的磁盘、内存说起。

block

块设备(block device)是可以对其中的数据进行随机寻址的硬件设备,最常见的块设备就是磁盘,另外软盘、闪存等也都是块设备。读取字符设备的数据需要一个字节一个字节按顺序以数据流的方式访问,键盘就属于字符设备。

块设备最小的可寻址单元是 sector,它的大小为 2 的 N 次方,最常见的是 512 字节。sector 大小是硬件设备的物理属性。

对于软件系统来说,逻辑最小可寻址单元是 block,block 是文件系统的一个抽象,文件系统只能以 block 的倍数的大小来访问。虽然硬件设备在 sector 级别进行寻址,但是 Linux 内核执行的所有的磁盘操作都是 block 级别。

block 的大小

  • 不能小于 sector (硬件设备的最小可寻址单元)的大小
  • 不能大于内存页(physical page, 内存管理的基本单元)的大小,这是为了简化内核而做的人为限制
  • 是 2 的 N 次方

当一个 block 存储在内存里时,这块内存被称为 buffer。每个 buffer 关联一个 block,这个 buffer 代表在内存中的磁盘块。一个内存页可能包含一个或多个 block。buffer 在内核中由 buffer head (一个名为 buffer_head 的结构体)来描述。

page cache

Linux 内核的磁盘缓存叫做 page cache,把数据存储在物理内存中最大程度上减少磁盘 IO。page cache 里是 RAM 的内存页(physical page)。page cache 的大小是动态的,可能消耗掉所有未使用的内存,也可能减小以缓解机器的内存压力。

内核进行读操作时(比如某进程调用 read()),首先会检查 page cache 中的数据,如果数据在缓存中,就直接从 RAM 中读取这些数据,这是“缓存命中”。如果数据不在缓存中,就必须对磁盘进行阻塞读取操作,从磁盘读完数据后,内核会把这些数据放到 page cache,之后的读操作可能会用到。

Linux 采用 write-back 的写缓存策略,写操作直接在 page cache 上进行,不会立刻就刷新到磁盘上。写了数据的内存页标记为 dirty,放在一个列表里,这个列表里的内存页被周期性地写回到磁盘,这些内存页也就不再被标记为 dirty。

buffer cache, page cache

磁盘 block 通过 buffer 映射到内存页,这样内存页的缓存 page cache 也缓存了磁盘 block,这个缓存叫做 buffer cache,现在它基本是 page cache 的一部分。

2.4 内核之前 buffer cache 和 page cache 是分开的,前者缓存 buffer(一个磁盘 block 对应 一个 buffer),后者缓存内存页,这样就导致了磁盘 block 可能即缓存在 buffer cache 里,同时又在 page cache 里,两个 cache 可能就需要通过复制来进行同步,并且因为重复的缓存浪费了内存。对于这个情况,现在只有 page cache 了,内核用 buffer 来表示在内存中的磁盘 block,把 block 映射到内存页,而内存页在 page cache 中。

有些表示磁盘 block 的 buffer 不在 page cache 中,这里的 buffer 包含的不是文件的数据内容。例如,保存文件的元数据信息、原始的磁盘 block IO,会用到这样的 buffer。Linux 的 free 命令中 buffers 表示的就是这种 buffer 占用的内存大小。

总结起来就是,page cache 里是内存页,大部分磁盘 block(主要是常规文件) 由 buffer 映射到内存页,这部分是 buffer cache,属于 page cache 的一部分。有一小部分 buffer cache 不属于 page cache。

free

最后回到 free 命令来。

free 命令 cached 表示的是物理内存中的 page cache 占用的内存大小(page cache 可能还有部分是 swap cache,swap cache 的大小没有计算在内)。buffers 表示不属于 page cache 的那部分 buffer cache 占用的内存大小,可以想得到, buffers 的值一般都要比 cached 的值小很多。


参考:

Linux 信号处理

使用 sigaction 绑定信号

比较早的时候,使用 signal,现在正在逐渐被抛弃,sigaction 是更好的选择。主要是因为如下原因:

  • signal 在不同系统的行为可能不一致,如果自定义了信号处理函数,进入信号处理函数时,对当前信号的操作可能变为默认,也可能屏蔽该信号。只有设置信号处理方式是 SIG_IGN(忽略)、SIG_DFL(默认)是可移植的。
  • signal 不能设置在信号处理函数执行过程中屏蔽其他信号
  • 在多线程的进程中 signal 的效果不确定

signal 接口最主要的问题还是不可移植,其实现在 Linux 上的 signal 是对 sigaction 的一个封装。sigaction 有更灵活和强大的功能,现在通常使用 sigaction 绑定自己定义的信号处理函数,改变处理信号默认方式,当然用起来比 signal 也要复杂一点。

常见用法如下:

void handler(int sig, siginfo_t *siginfo, void *arg)
{
    printf("recv signal: %d\n", sig);
    sleep(2);   //假装在工作...
    printf("%s\n", "done");
}

int main(int argc, char *argv[])
{
    struct sigaction sa;
    memset(&sa, 0, sizeof(struct sigaction));

    //sigemptyset(&sa.sa_mask);
    //sigaddset(&sa.sa_mask, SIGQUIT);

    sa.sa_flags = SA_SIGINFO;
    sa.sa_sigaction = handler;

    if (sigaction(SIGINT, &sa, NULL) == -1) {
        perror("sigaction error");
        return -1;
    }

    while (1) {
        sleep(10);
    }
    return 0;
}

当进程接收到 SIGINT 信号(CTRL-C),触发 handler 函数。

如果把上面注释的两行打开,在 sa.sa_mask 添加了 SIGQUIT 信号(CTRL-\),在 handler 函数执行过程中,进程屏蔽 SIGQUIT 信号。此外还默认屏蔽与 handler 绑定的 SIGINT 信号,除非设置 sa_flagsSA_NODEFER 改变此默认行为,这样在 handler 执行过程中不会屏蔽触发 handler 的信号。

handler 执行完毕之后,会恢复进程在执行 handler 之前屏蔽的信号集,所以 SIGQUIT 不再被屏蔽。

进程屏蔽信号

更多情况下,我们是要进程/线程主动去屏蔽些信号的。需要使用上面提到的 sigprocmask,多线程环境要使用 pthread_sigmask

sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGUSR1);
// sidaddset(&set, xxxx);
sigprocmask(SIG_BLOCK, &set, NULL);

这样进程就屏蔽了 SIGINT, SIGUSR1 信号,通过 sigaddset 添加信号到一个集合里,然后调用 sigprocmask 指定屏蔽该集合里的信号。
因为 SIGINT 信号被屏蔽,也就不会触发 handler 函数。

另外,如果没有屏蔽 SIGINT,然后在 handler 内调用 sigprocmask 屏蔽 SIGINT 信号,要注意出了 handler 函数这个屏蔽作用就失效了,因为 handler 执行完,进程要屏蔽的信号集就复原了。如果要在 handler 执行过程中屏蔽某信号,建议在调用 sagaction 之前通过 sa.sa_mask 设置要在这个过程中屏蔽的信号,而不是在 handler 里通过 sigprocmask 去屏蔽,一是避免产生认为”会在整个进程执行过程中都屏蔽该信号”这样的误会,更重要的是如果进到 handler 里才去屏蔽,有可能跟预期结果不符,例如想在 handler 函数执行过程中屏蔽 SIGUSR1 信号,由于在进到 handler 函数和调用 sigprocmask 之间还有一段时间,这期间 SIGUSR1 信号是没有被屏蔽的。

屏蔽?忽略?

上面说的屏蔽信号,并不是忽略信号(或许叫做“阻塞”信号更合适)。如果一个信号被屏蔽,会被内核放在一个队列里等候着,等到进程不屏蔽它的时候,进程就会收到该信号(不再阻塞)。例如前面通过 sa.sa_mask 设定了在 handler 执行过程中屏蔽的 SIGQUIT 信号,向进程发送 SIGINT 信号触发 handler 之后,立刻又发送 SIGQUIT 信号,在 handler 执行过程中,SIGQUIT 信号被屏蔽,在 handler 执行完之后,进程就会收到该信号。

使用 sigwait 可以等待信号到来,如果队列里有等着的信号,会被取出来(先进先出的顺序)。

sigwaitsigaction 是处理信号的两种截然不同的方式,sigwait 阻塞进程/线程的执行,等待信号。

int sig_num;
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigprocmask(SIG_BLOCK, &set, NULL);
while (sigwait(&set, &sig_num) == 0) {
    printf("caught sig: %d\n", sig_num);
    sleep(2);    // 假装在工作...
}

不要忘了用 sigprocmask 屏蔽了 SIGINT 信号,之后再用 sigwait 接收 SIGINT 信号,如果没有屏蔽 SIGINT 信号的话,进程收到 SIGINT,会进行默认操作,一般就是退出程序。

被忽略的

如果在 handler 执行过程中(没设置 SA_NODEFER),发送了很多次 SIGINThandler 执行完之后,进程只会收到一个 SIGINT 信号,而不是发了多少 SIGINT,之后 handler 就被触发多少次。

sigwait 等到 SIGINT 来之后进行处理,处理过程中如果向进程发送了多个 SIGINT,也是只会收到一个 SIGINTsigwait 后的处理也只有一次。

上面两个例子是说,如果进程执行过程中有信号要“等候处理”的时候,多个相同的普通信号只会有一个入队,其余的被忽略。

实时信号

实时信号是不同于普通信号的。

SIGRTMINSIGRTMAX 之间的信号属于实时信号,不会被忽略。

在上面代码段里加一行sigaddset(&amp;set, SIGRTMIN),把 SIGRTMIN 信号的处理也接手过来,然后在 sleep(2) 的过程中,向进程发送多个 SIGINT,然后再试试发送多个 SIGRTMIN 就可以看出区别:

int sig_num;
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGRTMIN);
sigprocmask(SIG_BLOCK, &set, NULL);
while (sigwait(&set, &sig_num) == 0) {
    printf("caught sig: %d\n", sig_num);
    sleep(2);    // 假装在工作...
}
  • "caught sig: 2" (SIGINT) 出现两次,第一次收到时输出一次,剩下多个 SIGINT 只接收到一个又输出一次;
  • "caught sig: 34" 出现多次,发送了多少 SIGRTMIN 信号,就会每隔两秒输出一次。

新东西

sigaction 时,需要小心翼翼地写异步信号安全函数(Async-Signal-Safe Function)。sigwait 处理信号相比 sigaction安全、方便,因为什么时候处理信号在自己掌握之中(同步方式),不会打断进程正在执行的操作,但是 sigwait 阻塞来等待信号可能并不满足一些场景,比如一个工作忙碌的线程,又可能需要处理某个信号。

在 2.6.22 版本的内核之后,Linux 系统提供了 signalfd 接口,一个可以替代sigactionsigwait的新方法,给这个接口一个信号集合作为参数,返回一个文件描述符,调用 read 获取信号。手册里有一个例子:

#include <sys/signalfd.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#define handle_error(msg) \
    do { perror(msg); exit(EXIT_FAILURE); } while (0)

int main(int argc, char *argv[])
{
    sigset_t mask;
    int sfd;
    struct signalfd_siginfo fdsi;
    ssize_t s;

    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGQUIT);

    /* Block signals so that they aren't handled
       according to their default dispositions */

    if (sigprocmask(SIG_BLOCK, &mask, NULL) == -1)
        handle_error("sigprocmask");

    sfd = signalfd(-1, &mask, 0);
    if (sfd == -1)
        handle_error("signalfd");

    for (;;) {
        s = read(sfd, &fdsi, sizeof(struct signalfd_siginfo));
        if (s != sizeof(struct signalfd_siginfo))
            handle_error("read");

        if (fdsi.ssi_signo == SIGINT) {
            printf("Got SIGINT\n");
        } else if (fdsi.ssi_signo == SIGQUIT) {
            printf("Got SIGQUIT\n");
            exit(EXIT_SUCCESS);
        } else {
            printf("Read unexpected signal\n");
        }
    }
}

使用signalfd很大的优势是,可以把这个文件描述符交给 select, poll, epoll进行检测,就像打开普通文件的普通描述符和网络套接字一样,当select这类函数通知你该文件描述符可读时再去读取、处理信号。

跟使用 sigwait 一样,不要忘记在调用 signalfd 之前用 sigprocmask 阻塞信号让它们不被按照默认方式处理。另外在使用完 signalfd 之后不要忘记 close 这个文件描述符。