高并发引起的网络不通定位实战

背景

最近许多客户投诉设备定时升级失败。和客户沟通后收集了部分的日志,发现其升级失败的共同原因都是connect超时。我是负责维护客户端升级模块的,但短时间内大量曾经正常的用户出相同网络问题,我觉得应该找服务器那头,所以找了服务器那头开始定位扯皮。

分析

客户端错误日志

我们的设备使用HTTPS连接升级服务器,connect失败代表在HTTP、TLS之前,TCP的握手失败了。TCP握手失败并非罕见现象,其可能由网络不稳等多种原因引起,但大量设备多次重试仍然失败,首先需要怀疑的就是服务器本身的网络容量不足了。

connect超时代表客户端发送的SYN包没有响应,服务器既没有正常回应SYN-ACK包,也没有发送RST来拒绝连接。

服务器日志与计数

可能1:半连接队列满

登录服务器,查看运行时间,发现服务器并没有重启过,说明服务器没有因为意外关机而导致不回复TCP握手。查看NGINX日志,并没有发现记录异常。

基于已有信息,我决定先查看TCP半连接队列是否有问题,半连接队列满导致的失败通常不会有任何日志。通过命令sudo sysctl net.ipv4.tcp_syncookies,发现SYN Cookie被启用,此时便排除半连接队列满导致的问题的可能性了,开启了SYN Cookie的情况下,半连接队列满并不会导致无法连接。

可能2:全连接队列满

除了半连接队列满,全连接队列满也是一个问题。全连接队列类似半连接队列,这里存储了已经完成握手,但还尚未被应用accept的TCP连接。与半连接队列不同,全连接队列并没有SYN Cookie这样的机制缓解队列满造成的问题。如果全连接队列满的情况下继续握手,新完成握手的连接就无处存放,所以全连接队列满时Linux内核同样会直接丢弃SYN数据包。不过,由于全连接队列满通常只是暂时的,所以Linux内核也不会发送RST包拒绝连接,从而让客户端能够重试。由于全连接队列满而丢弃的包,也无法被应用的日志记录。

通过netstat -s可以查询内核的网络子系统的计数信息。这之中有三个计数可以反映系统曾因全连接队列满而丢弃过握手包。

1
2
3
4
1398511 times the listen queue of a socket overflowed (TcpExtListenOverflows)
1465234 SYNs to LISTEN sockets dropped (TcpExtListenDrops)

TCPReqQFullDrop: 0

可以看到,TcpExtListenDrops表示大量的握手包被丢弃,且TCPReqQFullDrop为0表示没有因为半连接满而丢弃的。而TcpExtListenOverflows则表示由于全连接队列满而丢弃的握手包。显然,这个系统上出现过大量的全连接队列满导致的握手包丢包,且绝大多数SYN丢包都是全连接队列满导致的。

定时升级通常是国内的凌晨两点到四点,白天几乎没什么用户,只能先列出一些监控点。

导致并发能力不足的瓶颈有很多,包括CPU性能不足、内存不够导致频繁swap、软件设计不好有大循环(NGINX很好,但我司的是openresty,写的lua可能不行)、cgroup配额配置失误、意外的阻塞操作等等。

公司的升级服务器有三台,都是8核16G的,每个服务器开了4个Nginx工作进程,worker_connections为65535,并且没有出现accept失败相关日志,所以我推断并非连接超限或CPU性能不足导致(公司应该也没在中国卖出去过超60万台有升级License的设备)。

通过systemd-cgls查看配额,发现并没有为NGINX的service配置限制。

这是我全程会议上远程同事桌面操作的,卡的一批,很多快捷键都用不了。之所以这么热心,一个是因为这是少见的终端高并发实战经验,另一个是因为解决客户问题算我的OKR。

服务器监控

为了排查问题,我先让同事调整到7个工作进程,然后定时使用ss -ltn来监控全连接队列情况,使用ss -s监控TCP连接总数量,并用 pidstat -u -p $(pgrep -d,) 1监控nginx的CPU使用,通过ps -o pid,state,pcpu,rss,maj_flt,min_flt,comm -p $(pgrep nginx)监控内存占用、缺页与运行状态。

虽然说升级服务器不是以2C标准做的大规模开放的公共服务器,但是好歹也是个大厂,居然菜到连个普罗米修斯之类的监控都没有,我真是无法理解。当然也可能因为我不是他们部门的,所以没接触到。不过考虑到要我跨部门去定位,那我估计他们是没有,或者有也看不懂。

不直接在生产环境下使用perf工具是因为perf工具会trace进程,让本来并发就处理不好的状态雪上加霜。

由于部署在公网云平台上,我也不是管理升级服务器的部门的,所以没法直接从公司内部找个压测工具上压力,不然找个空闲时间,一个ab或者hey打个压力,再perf一下不就搞定了。

由于公司内网有限制,所以接下来我会以我自己的电脑为例,解析核心输出。下面的输出只做输出内容参考,并非我同一时间获得,也非公司真实环境。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# ss -s 的输出
Total: 1430
TCP: 1672 (estab 732, closed 476, orphaned 2, timewait 476)

Transport Total IP IPv6
RAW 0 0 0
UDP 5 4 1
TCP 1196 1196 0
INET 1201 1200 1
FRAG 0 0 0

# ss -ltn 的输出
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 513 511 0.0.0.0:443 0.0.0.0:*
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*

# pidstat -u -p $(pgrep -d, nginx) 1的输出
00:40:22 UID PID %usr %system %guest %wait %CPU CPU Command
00:40:23 0 42216 0.00 0.00 0.00 0.00 0.00 3 nginx
00:40:23 0 52817 0.00 0.00 0.00 0.00 0.00 1 nginx

# ps -o pid,state,pcpu,rss,maj_flt,min_flt,comm -p $(pgrep nginx) 的输出
PID S %CPU RSS MAJFL MINFL COMMAND
956 S 0.0 2028 0 29 nginx
957 D 0.7 20076 6 4096 nginx

由于只有pidstat会自己输出时间,所以其他的命令使用date -Iseconds来显示秒级时间,设计为每五秒执行一次。

ss -s主要看处于estab状态的TCP连接数量,它和worker_connections是强相关的,并占用文件描述符,我这里是732,压力处于正常范围内。

ss -ltn用于观察TCP全连接队列的积压情况,它的Recv-Q代表积压的全连接(即已完成握手,尚未accept的TCP连接)。

pidstat -ur -p pid1,pid2 1用于观察CPU的占用情况,主要观察进程占用的用户态时间和内核态时间,用于确认是否是CPU性能不足导致的问题。

ps -o pid,state,pcpu,rss,maj_flt,min_flt,comm -p $(pgrep nginx)的输出用于观察内存是否超限,是否有过大量缺页导致频繁与磁盘交互,以及观察高并发状态下进程是否处于R状态占用CPU。

分析监控日志

又经过一晚上,发现果然即使将NGINX工作进程数调整到7仍然会有积压,于是写命令匹配时间分析情况。发现在全连接队列满时,NGINX进程的CPU占用率并没有接近100,用户态内核态都不高,且TCP estab状态的连接也仅有几千,完全没有到达上限。

感谢AI,以前要是直接在服务器写命令去处理字符串起码得几十分钟到一个小时才能搜明白,现在只需要让AI写几分钟就能搞定了。

通过检查全连接积压时的CPU和内存占用,发现CPU和内存占用较低,完全处于低负载状态,剩余性能非常充足。但观察ps的输出,发现状态有时为D,这说明NGINX进入了不可中断的休眠。这种休眠通常只有在读硬盘文件时才会出现,此时便定位到了是读文件阻塞导致的。由于磁盘文件的读写无法配置为非阻塞,所以一旦磁盘IO慢,就导致整个NGINX的所有工作都慢。然而通常的磁盘IO是会被内核缓存的,我们的文件也并不是很大,好几个文件加起来才几百MB,设备内存16G,这些文件应该早就进了页缓存了才对。而且我们的磁盘是固态,很难出现长期阻塞的问题。

准确地说,磁盘文件的描述符是可以配置为非阻塞的,但是在绝大多数文件系统上,配置为非阻塞都并没有意义。

具体问题分析

已经定位到磁盘IO慢的问题,接下来就该看看为什么没有被内核缓存,以及具体问题的根因了。给服务器安了个vmtouch工具,看了一下那几个静态文件,发现被缓存的大小为0,手动尝试 cat /mnt/.../update_xxx.zip > /dev/null测试了一下,发现缓存还是0,又vmtouch -f file尝试强制加载,缓存大小仍然为0.

此时我已经觉得是文件系统的问题或挂载的问题了,通过mount命令一看,发现这里挂载的是一个基于FUSE的网络对象存储,每次读该文件都会向网络请求,就导致在并发稍微一高的情况下就出现阻塞占不满CPU的情况。

在这里继续感谢AI,我还是问AI才知道有vmtouch这种可以查看页缓存的工具的。

解决

询问了一下,发现最近把升级包的存放位置从本地磁盘换成了oss的挂载,于是让他们撤了oss的挂载,用本地磁盘解决。过了几天没有再接到相关问题了,问题就此解决。

其实这个oss挂载就是最近换的,就算我不去积极定位,只要多催催他们,估计也就晚个一天两天他们也能发现然后解决,毕竟他们有这个问题的一手信息。

另一个角度,这段时间肯定还会有更多用户提单,其实都能算OKR的,要是我不这么着急去定位,应该还能多出个三两个问题解决数量的OKR。但是现网实战经验这玩意确实是错过了就没有了,如果是2C的大厂,这种问题恐怕根本轮不到我一个开发参与定位,就连接触到公网服务器都不太可能。

ELF文件详解(1)

什么是ELF

ELF,全称Executable and Linkable Format,即可执行可链接格式,是Unix首次提出的一种二进制接口标准,如今已经成为Unix与Linux世界中最重要的文件格式。这些系统中,二进制可执行文件、动态链接库、核心转储、部分编译中间产物均为该格式。

通过学习ELF格式,我们可以了解前人是如何设计通用且高性能的二进制文件格式的,并且这些知识可以帮助我们更快、更好地定位常见C、C++程序中遇到的问题。

若无特殊说明,本文章中涉及的程序X86-64架构,大部分C代码都可以在其他平台上编译。

前期准备

为了更简单地分析ELF文件,我们需要一个Linux环境,并安装gccreadelfobjdumpgcc用于将示例C代码编译为二进制程序,readelfobjdump用于分析ELF文件。Linux系统没有具体要求,无论是物理机、虚拟机还是WSL都可以,如果您是一位有经验的用户,您也可以选择在Windows上分析。下面以Debian/Ubuntu为例。

1
2
3
4
# 安装readelf与objdump
sudo apt install binutils
# 安装gcc及相关工具链
sudo apt install build-essential

从第一个程序开始

让我们从第一个C语言程序开始,看看它编译出的文件是什么样子的。

1
2
3
4
5
#include <stdio.h>
int main() {
printf("Hello World!"); // 这里不加\n,否则有个优化会导致实际调用的并非printf
return 0;
}

将文件保存为hello_world.c,使用gcc hello_world.c -o hello_world来进行编译。

我们知道,一个ELF文件本质上与其他文件并无不同,它们都是硬盘中的一串二进制数据。真正让系统和工具识别ELF文件的是它的各种。文件头就如同一本书的封面和目录,通过一些固定的约定,让这本书即使打印在顺序的纸张上,也能展示出结构化的特征。

ELF文件的三种头

ELF头

ELF头就好像书的封面,它总在文件的最前端,Linux内核通过读取该头,来判断其是否是ELF文件。每个ELF文件都有且仅有一个ELF头,可以使用readelf -h查看ELF头中所包含的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
qxy@mannix:~/test_c$ readelf -h hello_world
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: DYN (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1060
Start of program headers: 64 (bytes into file)
Start of section headers: 13984 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30

让我们从头开始,解析这段信息的内容。

  • Magic:7f 45 4c 46 … ,这是ELF文件的魔术头,这样,即使没有后缀,内核依然可以通过魔术头来识别一个ELF文件。后面的45 4c 46 正好是ASCII码的大写的ELF三个字母。
  • Class 为 ELF64 表明这是一个64位 ELF 文件。而Data则描述了使用的数据格式是补码,小端序。
    ELF支持多种不同的数据格式,为此,需要在头中描述自己所使用的是哪种格式,以便工具可以正确解析。
  • 从 OS/ABI 到 Version 描述了这个文件适用的系统、架构,Version是该文件所遵循的ELF标准的版本号,目前固定为1。
  • Entry point address指明了这个文件的入口点为0x1060,反汇编后会发现这里正好是_start函数所在位置。
  • Flags在X86平台没有什么用处,通常为0。部分平台会在这里放入一些有意义的标志位,用来指示 ABI、浮点支持、指令集等。
  • 剩下的与header有关的字段则是本文的重点,包括程序头、段头与ELF头。

节头

ELF 文件中的 section(节) 是一种逻辑上的数据划分单位,用来标注文件中不同区域的用途。每个节都有自己的名称和属性,用于辅助编译器、调试器等工具对文件进行分析。这些节被整合在了ELF的特定位置(通常是尾部),被称为节头表。类似于书的目录中记录了每个章节的大致内容和它所在的页码。节头表中的节头则标识了ELF文件中不同区域的名称、类型、映射后的虚拟地址、和各种其他属性。

节是面试中的常客,通常八股所谓的代码在text“段”,初始化的数据在data“段”,未初始化的数据在bss“段”,说的就是这些数据位于对应的节(section),将其翻译为段,更多的是一种误译,后面的程序头中的segment更适合翻译为“段”。

一个ELF通常有十几到三十多个节,但也有像libc这样多达六十几个节的文件。使用readelf -SW hello_world来读取程序的节头表,通过节头表,可以看到程序具体有哪些节。不过,节头只对编译器、调试器等分析工具有帮助,真正执行时是不需要节头的。如同一本书可以没有目录,一个完整可执行的ELF文件也可以完全节头表。

readelf中的-W参数告诉程序不要截断输出。不使用该选项会导致一些比较长的内容可能会被截断,有些内容会还被分为两行输出,这对屏幕很宽的现代设备并不友好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
qxy@mannix:~/test_c$ readelf -SW hello_world
There are 31 section headers, starting at offset 0x36b8:

Section Headers:
[Nr] Name Type Address Off Size ES Flg Lk Inf Al
[ 0] NULL 0000000000000000 000000 000000 00 0 0 0
[ 1] .interp PROGBITS 0000000000000318 000318 00001c 00 A 0 0 1
[ 2] .note.gnu.property NOTE 0000000000000338 000338 000030 00 A 0 0 8
[ 3] .note.gnu.build-id NOTE 0000000000000368 000368 000024 00 A 0 0 4
[ 4] .note.ABI-tag NOTE 000000000000038c 00038c 000020 00 A 0 0 4
[ 5] .gnu.hash GNU_HASH 00000000000003b0 0003b0 000024 00 A 6 0 8
[ 6] .dynsym DYNSYM 00000000000003d8 0003d8 0000a8 18 A 7 1 8
[ 7] .dynstr STRTAB 0000000000000480 000480 00008f 00 A 0 0 1
[ 8] .gnu.version VERSYM 0000000000000510 000510 00000e 02 A 6 0 2
[ 9] .gnu.version_r VERNEED 0000000000000520 000520 000030 00 A 7 1 8
[10] .rela.dyn RELA 0000000000000550 000550 0000c0 18 A 6 0 8
[11] .rela.plt RELA 0000000000000610 000610 000018 18 AI 6 24 8
[12] .init PROGBITS 0000000000001000 001000 00001b 00 AX 0 0 4
[13] .plt PROGBITS 0000000000001020 001020 000020 10 AX 0 0 16
[14] .plt.got PROGBITS 0000000000001040 001040 000010 10 AX 0 0 16
[15] .plt.sec PROGBITS 0000000000001050 001050 000010 10 AX 0 0 16
[16] .text PROGBITS 0000000000001060 001060 00010c 00 AX 0 0 16
[17] .fini PROGBITS 000000000000116c 00116c 00000d 00 AX 0 0 4
[18] .rodata PROGBITS 0000000000002000 002000 000011 00 A 0 0 4
[19] .eh_frame_hdr PROGBITS 0000000000002014 002014 000034 00 A 0 0 4
[20] .eh_frame PROGBITS 0000000000002048 002048 0000ac 00 A 0 0 8
[21] .init_array INIT_ARRAY 0000000000003db8 002db8 000008 08 WA 0 0 8
[22] .fini_array FINI_ARRAY 0000000000003dc0 002dc0 000008 08 WA 0 0 8
[23] .dynamic DYNAMIC 0000000000003dc8 002dc8 0001f0 10 WA 7 0 8
[24] .got PROGBITS 0000000000003fb8 002fb8 000048 08 WA 0 0 8
[25] .data PROGBITS 0000000000004000 003000 000010 00 WA 0 0 8
[26] .bss NOBITS 0000000000004020 003010 009c60 00 WA 0 0 32
[27] .comment PROGBITS 0000000000000000 003010 00002b 01 MS 0 0 1
[28] .symtab SYMTAB 0000000000000000 003040 000378 18 29 18 8
[29] .strtab STRTAB 0000000000000000 0033b8 0001e3 00 0 0 1
[30] .shstrtab STRTAB 0000000000000000 00359b 00011a 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)

可以看到,在hello_world这个简单的编译结果中,有多达30个节。这些节会被编译、调试工具(如我们正在使用的readelfgdb等)所使用,用于解析内容、支撑调试。但是在程序运行时是不需要节头表的。即使一个ELF文件完全没有节头,也可以正常运行。

一个节头中包含多种信息,下面是这些头的简单解释。

列名 含义
Nr 节编号(Section Number)
Name 节的名称(如 .text.data 等)
Type 节的类型(如 PROGBITSNOBITSNOTE 等)
Address 节加载到内存中的地址(虚拟地址)
Off 节在文件中的偏移(Offset)
Size 节的大小(以字节为单位)
ES 每个条目的大小(Entry Size),比如 .symtab 中每个符号的大小
Flg 标志(Flags),例如:W 可写,A 可加载到内存,X 可执行
Lk 链接项(Link),具体含义依节的类型而不同,例如符号表关联的字符串节
Inf 附加信息(Info),具体含义也依节类型而定
Al 对齐(Alignment),节在内存中的对齐要求,通常为 4、8、16 等

当然,对节来说,最重要的是每个节具体有什么作用。这里我按照节的作用,分为以下几类。

节名 功能
.text 代码节,这里是真正用于执行的机器指令。
.data .bss .rodata 数据节,其中的ro代表Read Only。
.init .fini .init_array .fini_array 用于存放程序的初始化与清理代码
.plt .got .plt.* .rela.* .dynstr .dynsym .gnu.hash 重定位相关,运行时寻找外部库符号,或其他库寻找本库符号时需要使用这些节。运行时重定位可以说是整个动态链接过程中最复杂的。
.eh_frame_hdr .eh_frame 异常相关的节,用于C++的异常处理功能。
.dynamic 用于动态库的加载,是整个动态库的核心部分。
.comment .symtab .strtab .note.* 通常是一些注释节,对运行没有任何作用。
.shstrtab 节头表,打印节头信息就是通过读取它实现的。

程序头

程序头是ELF在运行时的核心。在上面的节头中,我们看到节头包含了很多信息,但内核和链接器并不需要这么多的信息。并且,节头的名称是以字符串的形式存储的,这会让需要争分夺秒的内核和动态链接器浪费很多时间在处理字符串上。为了让程序更快地运行,ELF文件还有对机器更友好的程序头表(program header table)。通过readelf -l可以读取程序头表中的信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
qxy@mannix:~/test_c$ readelf -lW hello_world

Elf file type is DYN (Position-Independent Executable file)
Entry point 0x1060
There are 13 program headers, starting at offset 64

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R 0x8
INTERP 0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
LOAD 0x000000 0x0000000000000000 0x0000000000000000 0x000628 0x000628 R 0x1000
LOAD 0x001000 0x0000000000001000 0x0000000000001000 0x000179 0x000179 R E 0x1000
LOAD 0x002000 0x0000000000002000 0x0000000000002000 0x0000f4 0x0000f4 R 0x1000
LOAD 0x002db8 0x0000000000003db8 0x0000000000003db8 0x000258 0x009ec8 RW 0x1000
DYNAMIC 0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x0001f0 0x0001f0 RW 0x8
NOTE 0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R 0x8
NOTE 0x000368 0x0000000000000368 0x0000000000000368 0x000044 0x000044 R 0x4
GNU_PROPERTY 0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R 0x8
GNU_EH_FRAME 0x002014 0x0000000000002014 0x0000000000002014 0x000034 0x000034 R 0x4
GNU_STACK 0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW 0x10
GNU_RELRO 0x002db8 0x0000000000003db8 0x0000000000003db8 0x000248 0x000248 R 0x1

Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
03 .init .plt .plt.got .plt.sec .text .fini
04 .rodata .eh_frame_hdr .eh_frame
05 .init_array .fini_array .dynamic .got .data .bss
06 .dynamic
07 .note.gnu.property
08 .note.gnu.build-id .note.ABI-tag
09 .note.gnu.property
10 .eh_frame_hdr
11
12 .init_array .fini_array .dynamic .got

readelf的输出中,上方是程序头表中的程序头,下方则是这些程序头包含的节。与节头不同,程序头的Type(LOAD、NOTE等)部分,并非字符串,而是提前预设的宏值,这避免了字符串操作带来的时间开销。

可以看到,相较于包含三十多个节头的节头表,程序头表就少多了,只有12个。事实上,运行一个程序,内核所需要的信息比我们上方看到的还要少一些。内核只需要知道一个文件的内存如何布局(映射部分、动态分配部分及其权限、对齐),使用哪种动态链接器(或者不使用)以及入口点,就可以执行一个程序了。所以上面的程序头对内核来说,真正必须的部分只有INTERPLOAD两个部分。如果是一个静态链接程序,连INTERP部分也不需要。

如果您注意力惊人,您应该可以注意到.shstrtab并没有程序头表中出现,这也就意味着 存储着节名的.shstrtab并非让程序运行的必要信息。

在计算机发展的早期,CPU的页式内存管理和RWX权限模型尚不完整,栈通常是可执行的。可执行的栈带来了一系列安全问题,GNU_STACK头就是用于解决该问题的机制,通过显式告知内核栈不可执行来提供更好的安全保护。类似地,内核也会尝试读取GNU_PROPERTY头来为ELF启用或停用一些特定功能。但它们并非必须。

当然,大多数程序都是动态链接程序,虽然内核并不需要其他部分,但ld-linux-x86-64.so.2这个动态链接器是需要额外信息来加载库和修改部分内存权限。例如,GNU_EH_FRAME用于C++的异常处理相关,DYNAMIC用于让动态链接器加载其必须信息。

什么是僵尸进程,及其如何处理

僵尸进程是什么

程序的状态

为了方便管理程序,操作系统为程序设置了不同的状态。这些状态代表了程序的运行情况。在Linux中,用户态程序有R S D T t Z X七种状态。每个状态代表的意义如下。

字符 含义
R 正在运行,程序当前正在占用CPU。在topps中,该进程自己就是本状态。
S 可中断的睡眠,程序正在等待某些事件发生,如流量到达、锁状态变更、sleep结束等。
这种状态可以被信号中断,进而从睡眠中恢复运行或结束程序。
D 不可中断的睡眠,通常是正在等待磁盘IO,任何信号(包括kill -9)都无法让该线程恢复,只能等待预期的事件到来。
T 中止(stopped),行为上像是是暂停。程序没有结束,包括内存在内的所有资源均未释放,但操作系统不会再为线程分配CPU时间,直到用户手动恢复。此时常规的信号都会被挂起(pending),直到程序恢复运行。但可以被kill -9杀死,也可以被SIGCONT (kill -17)恢复运行。该状态作用于线程组(一个进程的所有线程),所有线程都会同时进入或退出T状态。
t 调试导致的中止。类似于T,但这种中止是由调试程序(如GDB)引发的。包括kill -9在内的信号都会被挂起,直到调试程序恢复其运行。和T一样,该状态也作用于线程组。
Z 僵尸进程,程序已经结束,其持有的资源也已经释放,但由于父进程编码错误,导致其占用的进程ID无法被回收。今天本文的重点。
X 已死亡,进程已经结束,但可能PID暂未回收,通常不应该看到该状态。

为什么会产生僵尸进程

当我们在ps -auxtop中看到了Z状态的进程,就代表这个进程是一个僵尸进程。与影视作品中经典的僵尸形象类似,僵尸进程本质上是已经死亡(退出)的进程。它们可以是正常退出,也以是因为异常而退出,进程的退出方式并不是其成为僵尸进程的原因,成为僵尸进程的唯一原因就是父进程没有正确处理它们的退出状态

在Linux操作系统中,一个进程可以通过fork()创建子进程,然后使用wait()waitpid()等待子进程执行结束。

每个正在运行的进程都有对应的进程控制块(PCB),若父进程正确等待子进程运行结束,那么子进程结束后,其PCB也会被系统回收。

然而,如果一直到子进程运行结束,父进程也依然没有等待子进程,就会导致子进程变为僵尸状态。由于子进程的任务已经完成,所以内存、文件等绝大部分资源都被系统回收,但由于操作系统不知道父进程何时会尝试通过waitwaitpid收集子进程的退出状态,所以子进程的PID和返回值依然被操作系统保留,直到父进程收集退出状态。

在Unix设计中,系统资源通常以int类型来表示,例如文件描述符是整型,信号量、共享内存ID、进程PID等也是整型。程序就是通过这些整型值来与操作系统进行交互。但PID与文件描述符不同,是全系统共享的,这就导致操作系统不能像保留被删除的文件一样,仅为父进程保留PID,操作系统必须全局保留该PID。

与之相对的,Windows并不使用这套设计规范。Windows中使用句柄(handle)来管理子进程。当子进程退出时,虽然其返回值仍会占用一定系统资源,但PID会被释放,以供其他程序复用。父进程需要通过句柄而非PID来获取子进程的退出状态。所以Windows上是不存在僵尸进程的。

如何处理僵尸进程

  1. 只需要杀死它们的父进程,就可以让这些僵尸进程被1号进程init接管。init会自动收集所有子进程的退出状态。
  2. 若父进程很重要,不能立刻杀死,但又迫切希望解决问题,可以通过gdb等手段在父进程内手动等待这些僵尸进程。
  3. 重启可以解决99%的问题,而僵尸进程就在这99%的范围内。

如何预防僵尸进程产生

比起亡羊补牢,我们当然更希望从一开始就避免僵尸进程的产生。下面介绍几种避免产生僵尸进程的方法。

总是使用waitpid等待子进程结束

这是最正常也是最普遍的方式了。大部分语言都提供了对子进程的进一步包装,可以让使用者直接调用p.wait()来等待子进程结束。

在C语言中,可以使用waitpid来阻塞地等待子进程结束。

1
2
3
4
5
6
7
int pid = fork();
if (pid == 0) {
execve(...);
_exit(1);
}
int status;
(void)waitpid(pid, &status, 0);

显式告知操作系统不会等待子进程退出

如果只想执行程序,但不关心程序的退出情况,那么可以通过信号动作来显式通知操作系统父进程不会等待子进程退出,这时即使父进程不等待,内核也会忽略子进程的退出状态,不会产生僵尸进程。

但这种方式会影响所有该进程产生的子进程,一旦子进程在wait前退出,父进程就无法拿到子进程的退出状态,除非你完全了解程序会启动哪些子进程,否则这可能会造成未知的影响。

使用SIG_IGN忽略

使用SIG_IGN是告知操作系统不等待子进程退出的常见方式,但该方式在小部分内核下可能不起作用。

1
(void)signal(SIGCHLD, SIG_IGN);

这种方式会让程序无法收到SIGCHLD信号,也无法自定义处理。

使用SA_NOCLDWAIT忽略

更现代一些的方式是使用SA_NOCLDWAIT告知操作系统不等待子进程退出,需要使用更现代的sigaction系统调用。

1
2
3
4
5
struct sigaction act = {
.sa_handler = SIG_DFL,
.sa_flags = SA_NOCLDWAIT,
};
(void)sigaction(SIGCHLD, &act, NULL);

这种方式程序仍然可以接收SIGCHLD信号,或者自定义处理程序,但是内核不会为程序保留已退出的子进程状态。

同时这种方式语义明确,不会出现不起作用的情况。

等待与SIGCHLD的关系

在Linux系统中,当子进程结束时,内核会向其父进程发送SIGCHLD信号。父进程可以通过注册SIGCHLD信号处理函数来及时得知子进程的退出,从而调用wait()系列函数进行处理。

网络上流传着一种说法,认为僵尸进程是没有正确处理SIGCHLD信号导致的,这是典型的错误说法。事实上,无论父进程是否处理SIGCHLD,都不会影响僵尸进程的产生。即便父进程没有注册SIGCHLD信号处理函数,甚至干脆屏蔽该信号,也可以通过wait()系列函数正确回收子进程。相反,即使父进程处理了SIGCHLD信号,却不调用wait(),子进程依然会成为僵尸进程。SIGCHLD信号只是一种异步通知机制。想通过信号处理避免僵尸进程,必须要通过上文的SIG_IGNSA_NOCLDWAIT来通知内核忽略。

一次SIGCHLD不代表只有一个子进程退出

SIGCHLD是普通信号,并非实时信号。若多个子进程“几乎同时”退出,父进程有可能只收到一个SIGCHLD信号。所以若只在信号处理中等待子进程退出,那么一定要循环等待,直到没有子进程等待,才能彻底避免僵尸进程的产生。

下面的函数示例来自这个网页

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// from: <https://man7.org/tlpi/code/online/dist/procexec/multi_SIGCHLD.c.html>

static volatile int numLiveChildren = 0;
/* Number of children started but not yet waited on */
static void
sigchldHandler(int sig)
{
int status, savedErrno;
pid_t childPid;

/* UNSAFE: This handler uses non-async-signal-safe functions
(printf(), printWaitStatus(), currTime(); see Section 21.1.2) */

savedErrno = errno; /* In case we modify 'errno' */

printf("%s handler: Caught SIGCHLD\\n", currTime("%T"));

/* Do nonblocking waits until no more dead children are found */

while ((childPid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("%s handler: Reaped child %ld - ", currTime("%T"),
(long) childPid);
printWaitStatus(NULL, status);
numLiveChildren--;
}

if (childPid == -1 && errno != ECHILD)
errMsg("waitpid");

sleep(5); /* Artificially lengthen execution of handler */
printf("%s handler: returning\\n", currTime("%T"));

errno = savedErrno;
}

现代编程语言实践

脱离了C语言的环境,在现代语言中处理子进程问题则简单得多。语言存在多种机制可以让你无需担心忘记等待,记得不要裸调用fork()即可。

1
2
3
with subprocess.Popen(...) as proc:
...
# 此处会自动等待该进程,无需担心
1
2
3
4
5
6
7
8
func RunSubprocess() {
cmd := exec.Command{...}
// 函数本身就直接等待其运行完成
cmd.Run()
// 函数本身不等待,但是可以手动调用Wait
cmd.Start()
defer cmd.Wait()
}

TCP连接出现异常的几种情况

TCP的正常情况

TCP的正常连接情况,本文不再赘述。三次握手与四次挥手有大把八股可以看。这里会放一个TCP状态机的图片,用于在下方出现错误时参考。

Tcp状态机- Andy Niu - 博客园

一些前置知识

TCP状态由内核维护

TCP 协议栈是由操作系统内核负责实现和维护的。内核负责处理连接的建立与关闭、数据包的发送与接收、重传、流量控制、拥塞控制及错误处理等底层逻辑。用户态的应用程序并不直接操作这些细节,而是通过如 socket()connect()send()recv() 等系统调用,借助内核提供的接口与 TCP 协议栈进行交互。这样,用户程序可以专注于业务逻辑,而由内核透明地处理复杂的网络传输细节。

什么是RST报文

TCP 中的 RST(Reset)报文 是一种 强制中断连接 的方式,用于立即终止一个连接或通知对方出现异常。由于RST报文会立刻中断TCP连接,为了防止不在路由上的恶意攻击者随意伪造RST导致连接中断,只有满足一定条件的RST报文才能被处理,此处姑且称之为“有效的RST报文”。

什么是有效的RST报文

  1. IP 层要匹配
    • 源地址和目标地址必须和原连接一致。
    • 源端口和目标端口必须正确(即 TCP 四元组匹配)。
  2. 序列号必须合法
    • 对于一个已建立的连接,RST 报文的 seq(序列号)必须落在接收方的接收窗口范围内,否则接收方会丢弃该报文。
    • 有些情况下(如未建立连接时收到 RST),只要匹配 TCP 四元组即可。
  3. 报文标志位
    • 必须设置 RST 标志。
    • 一般不能同时带有 SYNFIN 标志。

TCP的设计思想

  1. TCP使用RST报文表示连接异常,需要立刻关闭,且任何RST报文均无需回应。所以,一旦RST报文被发送或接收,TCP状态机就会转移到CLOSED状态。相应地内核会销毁相关资源,不再处理后续任何报文。也就是说,对一条TCP连接而言,RST 报文最多只会被处理一次,即使可能收到了很多RST报文。

  2. TCP设计中,错误并非一种状态,而是一个事件。所以在几乎所有主流的(Windows、Linux、类Unix及Unix)TCP协议栈实现中,TCP的错误都是读清 (read and clear)的。读清表示错误一旦被读取就会立刻被清除,直到下一次错误发生。下面的get_error函数就是读清的一种典型实现。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    static int error; //   0 代表没有错误
    int get_error() {
    int err = error;
    error = 0;
    return err;
    }
    int set_error(int err) {
    error = err;
    }

    这里的读取是广义的,不仅包含使用getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len)明确读出的错误,也包含read/recv write/send后返回的错误。当这些错误到达用户态时,错误就被消费了,在新的错误到来之前,无法再读取到错误。

但不同场景下收到RST报文,返回的错误及现象会有不同,通过这些不同的现象,我们可以更进一步定位引起异常的原因。

握手阶段的 RST

SYNC-SENT 状态

这是TCP RST报文中最典型的一种情况,代表对方并没有监听这个端口,或路径上的任一防火墙拒绝了连接请求。此时客户端会返回错误“连接被拒绝”(Linux)或“由于目标计算机积极拒绝,无法连接”(Windows)。

SYNC-RCVD 状态

正常的TCP客户端中,这种情况几乎不可能出现,因为客户端没理由在尝试连接后又不接受服务端的返回报文。如果出现,可能是因为被防火墙错误拦截。若客户端在发送SYN握手包后立刻退出,由于此时TCP还处于SYN-SENT状态,并不会触发优雅关闭,内核会以RST报文响应服务端发送的SYN-ACK报文。

非正常的TCP客户端(如scapy、hping3)可能会有此类行为,它们通常是一些网络调试或渗透工具,这类工具并不使用TCP上层接口,而是直接使用 raw socket 完整控制网卡发出任何消息(可能受驱动或硬件限制)。由于此操作绕过了内核的TCP接口,所以内核没有为它们创建TCP协议栈。当该类工具发出 TCP SYN 报文后,通常只会检查是否收到了 SYN-ACK 报文,而不会返回ACK。由于它们未在内核中创建TCP连接,内核会自动对非预期的 SYN-ACK 报文返回RST。

这种方式可以通过SYN-ACK报文检测对应端口是否开放,但没有任何有效数据传输,且因为它发生在accept前,服务端进程无法记录日志,是一种常见的隐蔽端口扫描行为。

对服务器来说,这种问题影响很小,内核在收到RST报文后,便会将其从半连接队列中删除,没有可见的现象。

双工阶段的 RST

在TCP处于ESTABLISHED状态时,收到RST后,内核中相关的资源就被销毁了,并且会将错误设置为“连接被重置”。由于TCP是全双工协议,所以此时任何一方收到RST,行为都是一样的。

当收到有效的RST时,读或写都会产生连接被重置(connection reset by peer)的错误。然而由于之前提到的两个规则,同一条连接下,这个错误只会出现一次,再之后无论读或写,都不可能产生连接被重置的错误了。其原因如下。

  1. RST报文只会被处理一次,之后即使再收到报文,也会因为socket资源被销毁而不会再处理。
  2. 错误是读清的,一旦获得了该错误,在新错误出现前,就不会再有错误了。

所以,程序在感知到连接被重置后,再去读就会返回0代表EOF,不会出现错误。而写则会产生EPIPE错误,代表TCP写管道已经损坏,无法再写入。如果尝试用getsockopt函数获取错误,也是没有错误的。

当收到RST报文后,即使内核的读取缓冲区仍有数据,recv/read也无法读出。但如果正在读时收到RST,已有的数据还是能读出的。(Linux内核行为,不代表其他内核)类似地,即使内核的写入缓冲区仍有数据,这些数据也不会被发送,只会被丢弃。

进程退出

进程因任何原因退出(也包括kill -9后,系统会自动关闭进程的文件描述符。默认情况下,若没有使用setsocketopt配置SO_LINGERclose就会优雅关闭连接,走完挥手流程。

挥手阶段的RST

一端发送FIN包后,另一端就会进入CLOSE-WAIT状态。关闭可以由任何一端触发,其效果相同,故本文假设客户端触发关闭,首次发送FIN包。

CLOSE-WAIT

在TCP的设计中,FIN报文表示发送者不会再写入,TCP开始进入关闭流程。但是,没有任何一种报文可以准确表示不会再读取。服务端收到FIN报文只能确定客户端不会再发送,但不确定客户端是否也不会再接收。如果此时服务端继续发送数据,若客户端还能继续读取,则会返回ACK,若客户端进程完全关闭了连接,内核会通过发送RST报文的方式告知服务端不会再读取。所以当服务端处于CLOSE-WAIT状态时,收到RST报文也不会出现连接被重置的错误,而是会出现Broken Pipe的错误。

这种错误也是一种相对比较常见的情况,常见于下载过程中客户端意外退出时。服务端正传输大量数据,但客户端不再能接收,则服务端会发生Broken Pipe错误。

FIN-WIAT

FIN-WAIT-1 与 FIN-WAIT-2 状态并无不同,区别只在是否收到了 FIN 包的 ACK。

此时进程仍然可以读,由于没有收到对方的 FIN 报文,所以客户端完全可以假设仍然可读,若读取过程中收到了 RST 报文,也会报告连接被重置错误。

ICMP 报文

ICMP 报文也可能会导致 TCP 连接出现错误。与 TCP RST 报文不同,ICMP 有多种错误,包括主机不可达、网络不可达、端口不可达等等。

ICMP错误分为软错误和硬错误,端口不可达、协议不可达和需要分段但设置了DF位被视为硬错误。对于硬错误,标准(RFC 9293 3.9.9.2)要求应该中断连接,但Linux并没有这样做,因为过去很多网络设备滥用ICMP报文,导致这类消息并不可靠。对于软错误,则要求必须不能中断连接。

事实上,曾经的标准(RFC 1122 3.2.2.1 发布于1989年)的要求是这样的。

即使某个传输协议已经有自己的方式来通知发送方某个端口不可达(例如,TCP 发送 RST 段),它仍然必须(MUST)接受 ICMP 的“端口不可达(Port Unreachable)”消息,并用于相同目的。

可能也正是由于ICMP报文的滥用,标准之后也更新了。所以,当前针对 TCP 的 ICMP 报文,在操作系统中通通被视为软错误,只有通过 getsockopt 才能获取该错误,使用recv / send是无法获取的,若真的出现不可达的情况,只会卡住。

内部错误

除了收到特定的报文,内核在处理TCP连接时也可能会发生错误。

Keepalive 超时

当配置了 Keepalive 检测时,若达到超时限制,则会返回超时错误。此时内核会向对端发送 RST 报文,并关闭 TCP 连接。故超时错误发生后,连接是无法再使用的。

本地防火墙关闭

一些本机防火墙工具可能会绕过用户态程序,直接通知内核关闭TCP连接。此时对端会收到 RST 报文,但是本机程序无法感知到 TCP 连接状态的变化,再尝试使用连接就会看到 Software caused connection aborted。即连接被强制终止。

C语言类型转换详解

C语言的类型及其转换一直是一个基础但又容易出错的场景,本文以C11标准为基础,为读者提供C语言类型转换的重要知识和最佳实践。

C是目前活跃的编程语言中历史最久的,自然积攒了相当多的历史遗留问题,这篇文章中尽可能指出C中常见的不明确点,避免读者踩坑。

C标准历史

在正式学习之前,我需要简短地介绍C语言的历史,这并非我教条,而是了解这段历史有助于理解为什么如今的C语言是这样的。

在计算机发展的田园时代,“标准”是一个几乎不存在的概念。一个比C还要古老的语言,Lisp,就很好地体现了这种现象:直到如今,仍然存在着大量Lisp方言和定制解释器,这让这种语言的学习、开发和维护都受到了不小的限制。最早期的C也是一样的,不同厂家很可能会基于它们的需求而定制C编译器和语法。随着C使用越来越广泛,1980年代,美国国家标准协会制定了第一套C语言的标准,并与1989年发布,该标准被称为C89。

C89和现在大家所见的C语言仍然有不小的区别,所以本文不会使用C89标准。一个显著的区别是C89的变量必须在函数体一开始就声明完,无法做到最基本的使用时声明。而C99就现在大家见到的C一致了。

由于早期存在众多独立实现,且C追求极限性能,所以产生了许多未定义行为与实现定义行为。

未定义行为与实现定义行为

未定义行为

未定义行为(UB)就是标准没有定义的行为。但是标准未定义并非标准没有说明,相反,C标准会明确提及一些操作是未定义的,标准中出现了一百多处 behavior is undefined,用于指导编译器应如何实现。

网络上有人说:“UB时编译器可以生成任何代码,所以不能假定任何行为。” 但是使用C时,遇到UB是很常见的事,不会有哪个编译器生成的代码检测到数组越界访问就调用rm -rf /*。所以了解一些常见的UB特点会有助于程序崩溃时的分析。

下面是一些典型的UB,部分UB会很常见,一些行为是可以预测的。

  • 解引用空指针、野指针。
  • 数组越界访问。
  • 使用未初始化的变量。
  • 一个语句中同时修改和使用变量。例:i = i++ + ++i

实现定义行为

由于C语言这种先存在实现后制定标准的特性,所以标准制定时必须考虑已有的实现,导致出现了大量很容易见到的“实现定义行为”。与未定义行为不同,虽然实现定义行为在C标准中没有说明,但C标准要求编译器的文档必须严格规定该行为。与未定义行为类似,C标准会明确指出哪些行为是实现定义的。

下面是几种常见的实现定义行为。

  • char类型的大小和符号,int类型的大小。
  • 对象(整型、结构体等)在内存中的表示方法。
  • 位域字段是否有符号。
  • 枚举值的具体类型。

C语言的类型系统

C语言的类型系统并不复杂,大致可以分为以下五类。

  1. 数字类型,包括整数、浮点数、复数、布尔值和枚举。
  2. 指针和数组。
  3. 结构体。
  4. 联合体。
  5. 函数。
  6. void类型。

类型的转换

显式转换与隐式转换

显式转换专指使用转换运算符(也就是平时所谓的强制类型转换)进行的转换。例如(char *) malloc(100)(size_t)lenght

除了显式类型转换,所有的类型转换均为隐式类型转换。隐式类型转换在赋值、传参、算数运算时都可能会发生。

数字间的类型转换

C语言中最常见的转换就是数字间的转换,数字间转换最常见的就是不同整数间的转换。

整数间的类型转换

C语言定义了五种标准整型和对应的无符号整型,每种有符号整型都对应着一个整型转换级别(rank)。对于五种标准有符号整数,即使类型实际上表示相同(例如64位Linux下的longint),其转换级别也不同。

简单地说,有符号整型的rank如此排序:long long int > long int > int > short int > signed char。除此之外,位宽(也可以说精度)高的整数要比位宽低的整数rank高。

每个有符号整型都有一个与之对应的无符号整型,无符号整数的rank则与其对应的有符号整型相同,即long long int = unsigned long long int > long int

char类型的特殊性

在这之中,char类型非常特殊。char类型具体是有符号还是无符号,是实现定义的。这也就导致了charsigned charunsigned char类型并不一样。作为参考,intsigned int是完全相同的两个类型。

C标准定义char的rank低于signed char,所以还有signed char = unsigned char > char

扩展整数类型

C标准还规定了扩展整数类型,这些类型是由编译器或扩展库额外提供的整型,例如gcc中的int128_t,或stdint.h中提供的int32_tuint64_t等。这些扩展整型的rank要比同位宽的标准整型低。若int为32位,则int = unsigned int > int32_t = uint32_t

整型提升

关于char类型的使用,有两个巨大的坑。一是char类型是否有符号是由实现定义的,二是char类型存在“整型提升”问题。让我们来看下面的C代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <stdint.h>
int main() {
signed char sc = -1;
unsigned char uc = 1;
printf("uc(1) > sc(-1) = %d\n", uc > sc);
signed long sl = -1;
unsigned long ul = 1;
printf("ul(1) > sl(-1) = %d\n", ul > sl);
int8_t i8 = -1;
uint8_t u8 = 1;
printf("u8(1) > i8(-1) = %d\n", u8 > i8);
int32_t i32 = -1;
uint32_t u32 = 1;
printf("u32(1) > i32(-1) = %d\n", u32 > i32);
unsigned char a = 0x80;
unsigned char b = 0x80;
int c = a+b;
printf("c = %d\n", c);
unsigned int a2 = 0x80000000;
unsigned int b2 = 0x80000000;
long long c2 = a2 + b2;
printf("c2 = %lld\n", c2);
return 0;
}

上面的代码会输出什么呢?对于没有仔细了解过的人来说,结果会很让人吃惊。

对于char,C标准规定可以存储任何基本执行字符集的成员(通常就是ASCII)。而对于int,则规定足以包含头文件limits.hINT_MININT_MAX的最大值(听君一席话,如听一席话)。所以不排除两者位宽相等的可能,但由于rank的约束,绝不可能char的位宽大于int

事实上,整型通常至少2个字节(也就是$[-32768, 32767]$),而char则一定是一个字节。

1
2
3
4
5
6
uc(1) > sc(-1) = 1
ul(1) > sl(-1) = 0
u8(1) > i8(-1) = 1
u32(1) > i32(-1) = 0
c = 256
c2 = 0

出现这种现象的原因就是因为存在“整型提升”。当一个整型的rank比int低时,若int可以表示该类型的所有值,那么该类型就会转换到int,若int不能表示该类型的所有值,就转换到unsigned int

可能有读者会有疑问:要是连unsigned int也不能表示该类型的所有值呢?但这其实不会发生。因为一个整型的rank比另一个低的必要条件是该整型的位宽小于或等于另一个,所以unsigned int必然能表示rank低于它的整型的所有值。

对于两种char类型,因为它们的rank比int低,且在64位Linux下位宽比int低。所以在进行算数运算时,会先提升到int。此时1和-1比较,自然是1比较大。类似的,由于ab都被提升到int,所以128+128并不会发生溢出,可以被类型为intc完美接收。

相反,long slunsigned long ul就不会发生类型提升。此时有符号被转换为无符号,变成了unsigned long的最大值,所以ul(1) > sl(-1)为假。而int32_t i32uint32_t u32虽然rank比int低,但其位宽与int相同,所以提升时被转为了unsigned int。类似的,两个无符号整型无法再提升,所以0x80000000 + 0x80000000会直接发生溢出,即使c2的类型为long long也无法得到正确的计算结果。

整型提升很大程度上是为了照顾硬件的实现。主流CPU的寄存器多为32位或64位,并不存在8位寄存器。所以即使是8位的char类型执行算数操作,也只能在32位的寄存器内实现。如果没有提升机制,会导致每次计算后都需要额外的指令来清除寄存器中多余的部分,拖慢整体运行速度。这也是C语言为了性能考虑。当然,如果操作数和结果的类型严格一致,无论是否发生提升,在主流平台上,结果都应该是一致的。

例如下面的ARM64汇编,就展示了ARM64架构中只能使用32位的寄存器w0w1来进行char类型的相加操作。X86虽有%sil寄存器别名,但也并非所有指令都支持该别名。

1
2
3
4
5
6
extern char a;
extern char b;
int main() {
char c = a + b;
printf("c = %d\n", c);
}
1
2
3
4
ldrb    w1, [x0, #:lo12:a]
adrp x0, b
ldrb w0, [x0, #:lo12:b]
add w1, w1, w0

整数的二进制表示

C语言标准并未严格规定整数一定要使用补码(2’s Complement Code),也可以使用原码(True form)或反码(1’s Complement Code)。但当前主流平台及编译器(Windows, Linux, Mac on X86, ARM, MIPS…)均使用补码表示。

整数、浮点数、复数间的类型转换

看完了地狱般的整数间的类型转换,浮点数和复数间的转换就显得轻松得多。C标准定义了三种浮点数:floatdoublelong double,其长度分别为32、64、128位。

整数与浮点数转换

当有限的浮点数转整数时,该浮点数首先会舍弃小数部分进行取整,若整型可以表示取整后的值,则保留该值。若无法表示,则行为未定义。

也就是所谓的向0取整。例:(int)7.8 == 7(int)(-9.5) == -9

当整数转浮点数时,若浮点数可以精确表示该整数,则保留整数的值。若无法精确表示该整数,则由实现定义取最接近的较高或较低的可表示值。若整数超出了浮点数的表示范围,则行为未定义。

即使是UINT64_MAXfloat也不会超出其表示范围,所以通常使用不必担心。对于自定义类型,请参考对应的文档。

浮点数间的转换

与整数转浮点数类似。若新类型可以精确表示原类型,则保留原类型的值。若无法精确表示,则由实现定义取最接近的较高或较低的可表示值。若超出了新类型的表示范围,则行为未定义。

复数间转换

C语言提供了可选的复数类型_Complex支持,但在实际编程中大多数程序员不会使用,这里只简单一提。

复数的类型与浮点数相同,包括float _Complexdouble _Complexlong double _Complex,只是复数还有一个虚部。复数间转换实部与虚部都遵循浮点数间转换的规律。若复数转浮点数,则虚部被丢弃;若浮点数转复数,则虚部为0。

布尔类型

布尔类型是C99新增的一种类型,此前C中并没有独立表示真假的布尔类型,通常是程序员根据需要,采用intchar等整数类型代替。C语言会将任何0值认为是false值。

没有独立的布尔值带来了一些局限性,最大的问题就是逻辑算符与算术算符结果的不统一。例如1 & 2 == 0,但是1 && 2 == 1。为此C99新增了独立的布尔类型_Bool,在#include <stdbool.h>后可以直接使用bool作为类型名。布尔类型只有0和1两种可能的值,统一了逻辑算符和数字算符的结果,允许编译器进行更激进的优化。

同样作为抽象的整数类型,_Bool类型的rank比其他所有整数类型的rank都要低。即signed char = unsigned char > char > _Bool

其他类型与布尔类型间的转换

数字和指针类型到布尔类型的转换非常简单,任何非0值都为true,而0值则为false。布尔值转换到数字,false会转为0,而true则会转为1。

例如,空指针NULL00.00 + 0j均会被转为falseinfnan0 + 10j都会转为true

复数转实数类型仅保留实部,但是转布尔类型时,当且仅当实部和虚部都为0时,结果才是false

通用算数转换

通用算数转换是最常见的隐式类型转换的模式,用于匹配算数符号两侧操作数的类型,以及确定结果的类型。

注意:不仅当算符两侧操作数类型不一致时会发生通用算数转换,但由于整型提升的存在,即使两侧操作数类型一致,也会发生通用算数转换。

通用算数类型转换先看是否有浮点数。若算符两侧任意一侧有浮点数,则另一侧也转为对应的浮点数。若两侧都为浮点数,则精度低的会转为精度高的。

精度排名: long double > double > float

例如long double + float => long double + long doubledouble + int => double + double

若不存在浮点数,则先进行整型提升。这里非常重要,即使两侧类型一致,也要先进行整型提升

然后根据以下规则转换。

如果两个操作数都为有符号或都为无符号,那么rank低的会转向rank高的;如果符号不一致,则位宽低的向位宽高的转换(就不提rank这个坑货了);如果位宽一致,则有符号会转为无符号。

C标准在这里写了很多晦涩难懂的Otherwise,我就总结成了上面一句话。

通用算数转换涵盖了所有二元算数运算符(不包含逻辑与或非)及C中唯一的三元条件表达式。对于三元条件表达式,很显然条件部分不参与转换。需要注意的是,由于整型提升的存在,即使是一元运算符也会发生这种转换。但赋值操作不在通用算数转换的范围内。

另外,无参数原型函数及可变参数函数的变参部分也会发生整型提升。

指针与数组的类型转换

指针与整数的转换

指针和整数间可以互相转换。但结果是实现定义的。在三种实现(gcc, clang, msvc)中,整数转为指针是没有问题的,当然,指向地址的有效性需要程序员来保证。而指针转整数,则可能会遇到不在整型范围内的情况。当然,为避免指针转整数出现上述问题,C标准中的标准库部分提供了可选的(但应该都有)intptr_tuintptr_t来保证指针转到该整数类型再转回,一定与原指针相同。

数组与指针的转换

数组到指针的隐式转换一直是C中的一个常见问题。早年间各种“数组就是指针”、“数组是由指针实现的”等等毫无理解的错误发言层出不穷。不过如今正确观念相比以前普及了很多,逐渐正确的声音开始占领舆论场,这让人欣慰不少。

首先必须要明确的一点是,数组与指针是完全不同的两种类型,它们不仅在C中的抽象语义不同,在实现上也是不同的,绝不能把数组和指针混为一谈。

C中很多数组的迷惑行为都是隐式类型转换导致的。而数组很容易发生隐式类型转换,从包含n个类型为T的元素的数组转换为指向T的指针,该指针指向数组首元素,只有以下三种情况才不会发生上述转换。

  1. 使用sizeof运算符获取数组大小。
  2. 使用一元&运算符取数组引用。
  3. 数组是用于初始化字符串数组的字符串字面量。(没错,字符串字面量是一个数组)

所以,很多问题都可以通过隐式类型转换来解释。

  • 为什么函数不能返回数组局部变量,但是却可以返回包含数组成员的结构体的局部变量?
    因为数组局部变量会隐式转换为指向数组首元素的指针,返回局部变量的指针自然有问题。但包含数组的结构体则不会发生隐式转换,会将整个结构体拷贝给返回值。注:事实上C标准直接规定了函数不能返回数组。
  • 为什么函数参数不能是数组?
    因为数组作为参数也会隐式转换为指向数组首元素的指针,根本无法直接传递数组。
  • 为什么数组不能赋值?
    因为数组会转换为指针,让一个数组=指针肯定是无法编译的。
  • 为什么数组不能自增、自减?
    因为自增自减也相当于重新给数组赋值,所以原因同上。

吐槽一下,既然都是放到通用寄存器里,都能拿来寻址,那是不是整数也是指针,整数也是指针实现的?

指针间的类型转换

C语言中void *类型的指针可以隐式转为任何类型的指针,反之,任何类型的指针也都可以隐式转为void *类型的指针。

这是一种很常见的转换,例如下面的代码就是C中常用的一些情况。

1
2
3
4
5
#define BUFLEN 1024UL
// 函数签名: void *malloc(size_t sz);
char *buf = malloc(BUFLEN);
// 函数签名: size_t fread(void *buf, size_t size, size_t nmemb, FILE *stream)
fread(buf, sizeof(*buf), BUFLEN, fd);

通过将指针类型声明为void *,可以有效存储各种类型的指针。特殊地, ((void *)0)在C中被定义为空指针NULL。任何类型的空指针都是相等的。

在C++中,NULL不再被定义为((void *)0),而是直接被定义为0L。因为C++为了支持函数重载及基于构造函数的隐式类型转换,禁止了void *与其他指针类型间的隐式转换,为了兼容C,只能让数字隐式转换为指针。

除了void *类型的指针外,其他类型的指针间只能进行强制类型转换。C不保证转换后的指针是否可以对齐,若没有正确对齐,会产生未定义行为。

C中需要进行不同指针类型转换的情况并不多,一个常见的场景是Linux内核链表,会通过宏频繁在链表头和数据之间发生强制类型转换。

还有一种需求是以另一种类型重新解释内存。例如整型1000转为浮点数,结果是1000.0,但如果就想知道1000这个整数的这些比特原封不动地解释为浮点数对应的值是什么,就可以通过指针的强制类型转换。例如大名鼎鼎的wtf快速平方根倒数算法就利用了这个能力。把传入的浮点数当作整数看待,进行了整数运算后再重新当浮点数看待。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float Q_rsqrt( float number )
{
long i;
float x2, y;
const float threehalfs = 1.5F;

x2 = number * 0.5F;
y = number;
i = * ( long * ) &y; // evil floating point bit level hacking(邪恶的浮点数位运算黑科技)
i = 0x5f3759df - ( i >> 1 ); // what the fuck?(这是什么鬼?)
y = * ( float * ) &i;
y = y * ( threehalfs - ( x2 * y * y ) ); // 1st iteration (第一次迭代)
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed(第二次迭代,可以删除)
return y;
}

函数到函数指针的隐式类型转换

在C中,函数是一个很特殊的存在。与很多现代语言的“一等公民函数”不同,C语言的函数不能进行任何运算,只能进行调用。和数组类似,除非是作为sizeof操作符或&操作符的操作数,否则函数都会隐式转换为“指向该函数类型的指针”。

但是C标准还规定,函数不能作为sizeof的操作数。所以函数的唯二作用就是被调用和隐式转换为指针。

1
2
3
void * (*allocator1)(size_t sz) = malloc;
// 等价于下面的写法
void * (*allocator2)(size_t sz) = &malloc;

TLS层如何使用非对称加密保护数据安全

TLS层

TLS(Transport Layer Security)传输层安全,为网络流量提供了一种通用可行的加密方案。大量应用的网络连接加密方案均使用TLS,包括HTTPS、SMTPS、Open VPN、MySQL等常见应用,且开发者也可以轻松为自己的网络应用添加TLS支持。目前其最新版本为TLS v1.3。

TLS采用非对称加密技术来避免流量被中间人窃听或篡改,本文仅会在下面简单介绍其数学原理,不会过多讨论。

目前主流的非对称加密方案,都是基于计算机计算大质因数分解或求解离散对数的复杂性的,例如我们可以轻易算出487358143418387 * 362528313977273 = 176681126036561847184048318651,但反过来将该数分解为两个质数相乘却异常困难。

随着量子计算机的发展,这类基于大质因数分解和离散对数复杂性的非对称加密方式有可能被将来诞生的量子计算机破解,所以近年出现了基于容错学习的格密码的非对称加密方式,但由于其诞生较晚,安全性尚未被广泛验证,所以浏览器会混合使用传统方案与新方案,以提供最大的安全性保障。

本文仅会讲解基于RSA和DH的密钥交换方案,其分别代表了加密传输共享密钥与计算共享密钥,其他密钥交换方式大同小异,主要差异来源于其中的数学原理。

非对称加密

在了解RSA之前,先了解一下什么是非对称加密。顾名思义,非对称加密是与对称加密相对的一个概念。例如我将Hello中的每个字母都替换为其后面的第5个字母,这串消息就被加密为了Mjqqt,而解密方法则是同样用5这个数字向前替换即可,数字5则是这次加密里的密钥。这种加密方和解密方共用一个密钥的加密方式,就是对称加密。电子计算机出现前人类上千年的密码史中,从凯撒密码到Enigma,所使用的加密方案都是对称加密。现代计算机常用的对称加密方式有AES、chacha20等。

非对称加密则是一种反直觉的加密方式,是直到电子计算机面世后才出现相关的研究。在非对称加密中,密钥并非是双方共享的,而是分为了公钥和私钥。相比对称加密使用同一个密钥进行加解密,非对称加密性质则更为特殊:公钥加密的数据,公钥本身无法解密,只有私钥才能解密;而私钥加密的数据,私钥自身也无法解密,只有公钥才能解密。

在实际使用时,公钥会向所有人公开,而私钥则由密钥的创建者保留,此后我们可以将密钥创建者称为服务器。当有人希望给服务器发消息时,他可以先向服务器申请其公钥,并用该公钥将信息加密并发送。由于公钥加密的消息只有私钥能够解密,公钥自身也无法解密,所以即使中间人获得了公钥与被加密的信息,也无法破解。当然,如果中间人可以修改通信内容为自己的公钥,就需要用到本文后面提到的公钥基础设施了。

此时,我们就已经基于非对称加密实现了基本安全、基本可用的安全传输方式了。当然,事实上的TLS协议并不会这么简单,我们需要根据实际情况,一步步完善,看看TLS究竟是如何构建网络世界的铜墙铁壁的。

让我们来看看我们刚才实现的最简陋的安全传输模型。

  1. 服务端生成一对密钥。
  2. 客户端向服务端请求公钥。
  3. 客户端用公钥加密信息。
  4. 客户端将被加密的信息发给服务端。

现在,一个只能监听的中间人就看不到客户端发给服务端的消息了,但是服务端却无法安全地给客户端回复消息,因为服务端没从客户端那里得到什么可用于加密的信息。为了能让服务端也可以安全地给客户端回复消息,我们需要引入密钥交换机制。

密钥交换

为了让服务端能够安全地给客户端回复消息,我们可以让客户端也有一对非对称加密密钥,服务端再从客户端获取密钥,然后如法炮制。

  1. 服务端与客户端各生成一对密钥。
  2. 客户端向服务端请求公钥。
  3. 客户端用服务端公钥加密信息。
  4. 客户端将客户端的公钥与被加密的信息发给服务端。
  5. 服务端用客户端公钥加密信息,并发给客户端。

问题确实解决了,但是非对称加密密钥的性能并不好,这种方式很浪费性能。我们注意到,客户端第一次向服务端发送数据时,这些数据就已经是加密的了,所以,我们完全可以让客户端给服务端发送一个客户端生成的对称密钥,并且将来的会话中就使用该对称密钥,这样双方就不必总是使用消耗性能的非对称加密了。这就是RSA密钥交换的雏形。

RSA密钥交换

在RSA密钥交换下,客户端和服务端使用交换得到的对称密钥进行沟通,其流程如下。

  1. 服务端生成一对密钥。
  2. 客户端向服务端请求公钥。
  3. 客户端生成一个对称密钥K
  4. 客户端用公钥加密K,并发给服务端。
  5. 客户端与服务端使用对称加密进行交流。

DH密钥交换

公钥基础设施

CA机构

证书结构

证书链

原子计数与内存序

原子操作

原子操作在不同的语境下有不同的含义。在CPU中,原子操作是一种不会被打断的操作,CPU的任何一条指令都是原子操作。在编程语言中,原子操作通常用来操作内存中的值,而此时原子形容的是读写内存时以一个整体进行操作,不会因为其他核心或硬件对内存的修改就出现和预期不一致的行为,它可能是一条指令,也可能由多条指令组成。原子操作是多核CPU中多线程同步的必须能力。

例如一个基本的x++语句,在编译后可能就会被拆成多条指令。又或者虽然仅被编译为一条指令,但在多核多线程场景下存在数据竞争导致结果与预期不符。

1
2
3
4
5
6
; ARM64架构
ldr w8, [x0]
add w8, w8, #1
str w8, [x0]
; X86架构
incl (%rdi)

为了解决这种问题,就需要CPU提供原子操作的能力,x++必须要不被其他东西干扰地完成。X86架构提供了一系列的原子访存指令,包括CAS、原子加法等。而ARM架构通常依赖锁缓存行的方式来实现多指令的原子操作。

不过对于单纯的读写操作,如果一个值小于内存总线的宽度,那么正常的读写指令都是原子的。

但是仅靠原子操作,并不能完美实现锁的功能,这是因为还存在指令重排的情况。

指令重排

指令重排是一种提高运行速度的方法,它可以在不改变单线程执行结果的情况下,对执行指令的顺序进行调整,从而尽可能充分利用CPU中的资源。

编译器重排

编译器在优化代码时,通常会为了提高速度,对指令进行重排,例如下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct add_s {
int a;
int b;
int sum;
};
void add_nz(struct add_s *s) {
if (s->b == 0) {
return;
}
int a = s->a;
int b = s->b;
s->sum = a + b;
}

在优化过程中,编译器看到代码虽然访问了两次s->b,但是两次访问期间并没有修改,所以优化后的代码中两次读取很可能只会将第一次的值加载入寄存器,而第二次直接使用寄存器内部的值。

这种编译器重排,通常可以通过为变量增加volatile修饰符或在必要处增加内联的空汇编指令asm volatile("" ::: "memory")来避免。

CPU重排

除了编译器可能会优化代码导致代码重排外,CPU也会重排代码使其不按顺序执行。相比编译器的重排,CPU的重排是为了最大限度地利用CPU中的各个元件,减少因为某一个指令执行慢而导致整个CPU流水线停顿的情况。

例如,CPU中加法器和除法器是两个元件,那么c = a + bd = a / b就可以同时计算,而除法计算较慢,可能除法还在计算过程中,加法就已经算完了,这时CPU就又会让更后面arr[a] = c的寻址功能占用加法器。

通过这种乱序执行,CPU可以最大限度的利用好内部的每个元件,但是代价就是多核场景中,无法保证读写顺序总是和代码逻辑一致。

阻止CPU重排

当然,为了正确实现多核同步,也有些指令会阻止CPU重排代码,例如X86的mfence指令,ARM的dmbisb指令等。另外有些存取指令本身也会阻止CPU重排它们周围的存取指令。

volatile关键字

volatile关键字在C和Java中都存在,有着类似但不完全一致的功能。在C/C++中,volatile仅仅是指明某块内存是易变的,它可能会被外部DMA设备或其他线程更改。编译器在生成访问volatile修饰的变量的代码时,不会将多次访问变为一次,也不会将后面的访问重排到前面。但是,编译器并不会插入阻止CPU重排的指令。而在Java中,volatile变量也明确保证会阻止CPU的指令重排。

内存序

显然,执行计算的代码只需要保证依赖关系没有错误,就可以随意重排。但是访存代码的重排就可能导致多线程访问与预期不符的问题。例如下面的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
int flag = 0;
int data1 = 0;
int data2 = 0;
void *read_thread(void *ctx) {
while (flag == 0) {
}
int d1 = data1;
int d2 = data2;
printf("data1 = %d, data2 = %d\n", d1, d2);
return NULL;
}

void *write_thread(void *ctx) {
data1 = 100;
data2 = 200;
flag = 1;
return NULL;
}

上面的代码中,读线程理论上应该总是能打印出data1 = 100, data2 = 200,但如果是ARM架构的设备,则可能出现打印不符合预期的情况。这可能是因为读线程中data1和data2先读,而flag后读导致的;也有很小的可能性是写线程中data1和data2后写,flag先写导致的。

而如果是X86的设备,这里则总是能保证正确地打印出来,这也就引出了本文的另一个话题:内存序模型。

不同的CPU实现有着不同的内存序模型,人们按照其对内存读写的重排力度大致分为两类:强内存序模型和弱内存序模型。常见的CPU中,X86是强内存序模型,ARM是弱内存序模型。

弱内存序模型

CPU 指令中存在很多内存读写的操作,但涉及到重排时,我们可以只关注两条指令会如何发生重排,并基于此加以推广。而每条指令又有读和写两种类型,所以一共有四种重排的基础模型:读读(Load-Load)、读写(Load-Store)、写读(Store-Load)、写写(Store-Store)。弱内存序模型下,这四种情况均有可能发生重排。

1
2
ldr w1, [x0, 12]       ; #1 如果按顺序执行,它应该先执行
ldr w2, [x0] ; #2 它应该后执行

上面的ARM64汇编中,读读乱序会导致 #2可能实际发生在 #1 之前。类似的几种重排在下面的代码中展示。

1
2
3
4
ldr w1, [x0, 12]  ; #3
str w3, [x2, 8] ; #4
str w4, [x2, 16] ; #5
ldr w5, [x2, 32] ; #6

其中,#3和#4发生重排,则是读写重排,也就是#4实际上发生在#1之前。而#4和#5的重排则是写写重排,#5和#6的重排为写读重排。由于数据没有依赖,在ARM架构中,它们的完成顺序完全是任意的。

ARM提供的是弱内存序模型,这能更好地利用CPU的乱序执行与分支预测能力,简化CPU的架构设计,提供更多的性能提升空间。但相对的,程序员就需要更小心地使用内存同步原语。当然,在高级语言内,程序员并不需要担心这种问题,无论是Java的volatile,还是C、C++ 中的 mutexatomic_t,都为程序员提供了开箱即用的完整内存序模型,无需担心上层应用出现问题。

重排原因

两条访存指令可能由于多种原因而出现实际执行顺序和代码顺序不一致的情况。例如一条访存指令直接使用寄存器访问,而另一条则有加法和算数左移。又例如一条指令访问的位置处于L1缓存内,而另一条完全没有缓存。很多原因都可能导致重排发生。

强内存序模型

相对的,X86提供的是强内存序模型。在强内存序模型下,读读、读写、写写重排都被禁止了。但是,X86也并未实现顺序一致性,仍然保留了写读重排。

难以避免的写读重排

什么即使X86这种强内存序架构也会出现乱序呢?显然这是出于执行效率优化的原因,处理器愿意放弃一些内存序保证来提高速度。
指令被CPU前端解码后,会进入重排缓冲区 (ROB, ReOrder Buffer)。当指令的所有相关动作都正确完成后,指令会离开重排缓冲区,此时指令执行完成。

X86虽然是强内存序模型的CPU,但是为了提高运行速度,其内部也并不会真的强制让每个读写指令都按顺序执行。CPU会将读写指令放入对应的缓冲区 (load buffer / store buffer) 以同时保证内部的乱序执行和最终一致性。

写入指令处于重排中时,其写入的值会暂存在写缓冲区,而当其retire后,写缓冲区中的内容会尽快提交到缓存。但是显然,执行过程中很有可能发生分支预测失败。当分支预测失败时,该写入的值会随乱序缓冲区一起被丢弃,不会污染缓存。如果读指令必须等待写指令完毕才能执行,就相当于必须要等到这条写指令的分支正确执行完成,在分支预测正确率较高的情况下,这样会损失相当多的性能。
相对的,对于一个可缓存读,其并不会产生缓存刷新(包括CPU缓存和页面缓存)以外的副作用,无论分支预测是否失败,其后的对其没有依赖的指令都可以执行,对性能的影响相对较小。

基于以上原因,CPU没有会使用强一致性模型,至少都会允许后面的读发生在前面的写之前,即Store-Load reordering。

下面是ARM和X86实现中重排机制的表格。

Load-Load Load-Store Store-Load Store-Store
X86 No No Yes No
ARM Yes Yes Yes Yes

预测执行与重排

指令重排会和预测执行同时出现,我在公司遇到了相关的问题。

简单复述一下这个bug。CPU与硬件共享内存,通过环形请求/完成队列进行通信。出现任务时,软件会将任务提交至请求队列,并轮询完成队列。硬件读到请求项后进行处理,处理完成后将完成事件送入完成队列。软件轮询到完成队列有新事件后,则会读完成队列内的数据,并返回给调用者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void submit_request(struct sq_entry *sqe) {   
}

struct cq_event *get_complete_event() {
}
void process_cqe(struct cq_event *cqe) {
int d = cqe->data;
// ... 处理完成事件
}
void event_loop() {
while (g_running) {
submit_request(submission_queue_entry);
// ...... 其他处理
struct cq_event *event = get_complete_event();
if (event != NULL) {
process_cqe(event);
}
}
}

正常思维来讲,毕竟cqe已经获取到了,那么事件内部的数据结构一定是正确的才对,但是事实上就是会偶现内部数据结构不正确的情况,经过漫长的定位后,最终确定为读取cqe和读取事件的内容时出现了读读乱序,导致先读了事件内容的垃圾数据,后来才读到cqe,而此时判断cqe为真,这些垃圾数据也就留下来了。

通常,多线程只需要合理运用锁和原子操作,就不需要关心汇编层面的问题,但是这个问题并非两个线程间出现数据竞争,而是一个CPU核心与另一个CPU内的其他硬件出现数据竞争。由于另一个是硬件,也无法让它使用锁,最后是通过软件增加dmb ish指令解决。

有个同事不认为读垃圾数据再写会先于判断之前发生,于是我写了下面的POC代码来反驳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
#include <stdio.h>
#include <stdint.h>
#include <pthread.h>
#include <stdbool.h>
#include <stdlib.h>

#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)

struct data128 {
// 发生错误的结构体长度就是128bits长,这里复制了结构体的关键字段
// 但出于保密原因,隐去了不必要的名字,并将关键名称修改为counter和parity
uint32_t counter;
uint32_t unused1;
uint32_t unused2;
uint32_t rsvd1 : 16;
uint32_t parity : 1;
uint32_t rsvd2 : 15;
};

__attribute__((aligned(64))) volatile bool write = false;
typedef __int128_t i128;

void *save_thread(void *arg)
{
uint32_t cnt = 0;
uint32_t p = 0;
while (true) {
p = 1 - p;
cnt++;
struct data128 tmp = {.counter = cnt, .parity = p};
i128 *tmp2 = (i128 *)&tmp;
i128 *data = (i128 *)arg;
// 原子性地将内容存入data,并且保证顺序
__atomic_store_n(data, *tmp2, __ATOMIC_SEQ_CST);
// 增加 isb 指令进行强内存序保证,保证写write前,所有指令都已经完成
// 这里其实没什么必要,因为上面是由内存序保证的原子写操作,不会让后面的写比前面的先执行
// 又加上这些主要是为了演示使用
asm volatile("isb" ::: "memory");
// 同样原子性写write,并保证后面的指令都已经完成
__atomic_store_n(&write, false, __ATOMIC_SEQ_CST);
// 标记write所在缓存为失效,这里可能没什么意义,因为写线程的缓存用处不大
asm volatile("dc civac, %0" ::"r"(&write));
while (!__atomic_load_n(&write, __ATOMIC_ACQUIRE)) {
// 等待write为true,当write为true时进行下一次循环
asm volatile("" ::: "memory");
}
// 等待所有指令完成再进行下一个循环
asm volatile("isb" ::: "memory");
asm volatile("dc civac, %0" ::"r"(&write));
}
}

void *read_thread(void *arg)
{
while (true) {
if (unlikely(write)) {
// 如果还没观察到写线程将write写为true,就循环忙等,直到观察到write为true。
// 编译期标记为较为不可能,让推测执行尽可能执行下方的语句。
continue;
}
struct data128 *data = (struct data128 *)arg;
// 此时读线程已经观察到write为false了
// 如果没有 Load-Load reordering,那么下面读到的值一定都是更新后的,且奇偶性一致,
// 因为写线程使用内存序保证write可见时,counter和parity一定也已经可见。
// 但实际上会有极小概率cnt与p的奇偶性不一致
// 此时就是因为先发生了预测执行的读取cnt和p,且在预测期间缓存发生了更新,所以cnt和p不一致
// 而write则是在缓存更新后被读取,所以write为假,可以走到该流程
// 一种可能的路径
/*
下面文字顺序是代码顺序,而前面的标号则代表reorder之后的实际执行顺序
2. 读 write
3. 判断 write 为假
1. 读counter (读后发生了缓存刷新,counter为旧值)
4. 读parity
*/
int cnt = data->counter;
int p = data->parity;
if (cnt % 2 != p) {
// 这里实测很难触发,但是跑个十几分钟也能复现
printf("Error: counter = %d, parity = %d\n", cnt, p);
printf("load-load reordering\n");
printf("write = %u, w = %u\n", write, 0);
abort();
}
// 将write置为true,写线程读到write为true后会再写一次。
// 这相当于读线程提交了一个事件给写线程,写线程获得该事件后就尝试再写一次。
__atomic_store_n(&write, true, __ATOMIC_RELEASE);
// 将缓存标记为失效,尽可能延迟读取到 write 变量的时间
asm volatile("dc civac, %0" ::"r"(&write));
// 等待所有指令完成再进行下一个循环
asm volatile("isb" ::: "memory");
}
}

void start_threads()
{
pthread_t tid1;
pthread_t tid2;
void *data = calloc(128, 1);
// 缓存行对齐
data = (void *)(((uintptr_t)data + 63) & (~63ULL));
pthread_create(&tid1, NULL, read_thread, data);
pthread_create(&tid2, NULL, save_thread, data);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
return;
}

// gcc ldr_reorder_test.c -O2 -g -o ldr_reorder_test
int main()
{
printf("sizof(struct data128) = %d\n", sizeof(struct data128));
start_threads();
return 0;
}

指令缓存与数据缓存

X86代码的内存一致性模型不仅体现在保守的乱序执行上,对于自修改代码,它也会保证数据缓存与指令缓存的一致性。这意味着在X86 JIT 编译器的开发中,不需要特殊的同步指令来将写入的JIT代码同步至CPU的指令缓存。
例如在下面的C代码中,如果写入该JIT代码后立即执行,则有概率会发生Illegal Instruction异常。这是因为数据缓存还未同步到指令缓存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void *write_some_aarch64_jit_code() {
unsigned char code[] = {
0x00, 0x00, 0x80, 0xd2, // mov x0, #1
0xc0, 0x03, 0x5f, 0xd6 // ret
};
void *mem = mmap(NULL, sizeof(code), PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED) {
perror("mmap");
return NULL;
}
for (int i = 0; i < sizeof(code); i++) {
((unsigned char *)mem)[i] = code[i];
}
return mem;
}

这种情况需要手动同步指令缓存与数据缓存,在ARM64中可以使用如下汇编进行同步。

1
2
3
4
5
6
; 假设要同步的数据/代码的内存地址为 x0
DC CVAC, x0 ; 清除数据缓存
DSB SY ; 确保缓存清除已完成
IC IVAU, x0 ; 清除指令缓存
DSB SY ; 确保数据同步完成
ISB ; 确保指令流水线反映最新的指令

这种方式可以清除x0所指向地址的缓存行(通常为64字节),如果有更长的指令需要清除,则需要在循环中执行该指令。
当然,GCC为我们提供了内置的帮助函数。

1
void __builtin___clear_cache(char *begin, char *end)

使用该函数,可以自动完成上面的汇编指令的功能,并可以方便的指定长度。

如何配置iptables

iptables 是Linux中的网络工具,其工作在用户空间,可以用来操作内核空间的netfilter模块,以自由控制经过该主机的包。它替换了老版的ipchains,提供了更多的功能。而其后继者是nftable,它比iptables更灵活、更具伸缩性、性能也更好,提供了更多的功能。但是因为iptables还存在,功能在大多数情况下也够用,最重要的是教程多,各种AI给出的回答也更准确全面,所以现在还是推荐学iptables

顾名思义,iptables主要作用在网络层,但事实上它也可以获取其他层的数据。管理员可以通过命令将这些数据做为判断条件,对数据包执行不同的操作。

处理单元

iptables对每个数据包进行处理,即使是TCP也不会认为其是一个流,但可以在规则中获取流相关的信息。对于超过MTU而必须分片的UDP数据包,其看到的是分片后的数据包,每个分片都会单独处理。

表和链

什么是表和链

在iptables中,有两个核心概念:表与链。定义上,表包含了用于处理包的规则链,每个表都对应了不同的包处理功能。而包会遍历规则链中的规则。

在了解iptables前,可以先简单了解它的前身:ipchainsipchains并不存在表,只有三条预置链input, outputforward,所有的操作,包括过滤、NAT和TOS修改都在这些链上完成,并且转发流量也会经过inputoutput链。在设计上,这更符合直觉,但随着网络规模日益扩大,复杂度也越来越高,在同一个链上完成的操作就变得越来越多,规则也越来越容易让人迷惑。并且三条链也无法完成一些NAT任务。由于以上多种缺点,iptables引入了表,将不同方面的功能放入不同的表中,降低耦合,增强扩展性,并提高性能。

在用户实际理解中,链可以认为是数据的移动路径,在这个路径上移动时会遍历规则,当遇到第一个匹配的规则时会执行相应的动作。而表可以认为是对数据进行的操作。

通常的iptables教程中均会提到其 “四表五链” ,也就是filter, nat, mangle, raw四个表,和INPUT, OUTPUT, FORWARD, PREROUTING, POSTROUTING五个链。但事实上这只是当前Linux主机常见的模式,表和链都可以根据需要自行增删。修改表通常涉及到内核模块,所以比较少见。一种比较常见的增加表的情况是当设备启用了SELinux时,会增加一个security表,用于控制流的安全上下文。AppArmor对netfilter的支持没有SELinux完善。此外,还有类似xt_conntrack的模块可以为iptables增加新的功能。

与表不同,链可以在用户空间使用命令直接进行增删,通过增加链可以让数据流更为清晰,简化管理;也可以让减少数据在主链的匹配次数,提高运行速度。例如Docker就在iptables中增加了属于自己的DOCKER-USER链。

五条内置链

链是数据运行的链路,当前内核版本下,共有五条预置链,它们各自有不同的功能。在下面的内容中,如果没有特殊说明,我会假设本机作为一个路由器,管理一个内网网段192.168.1.0/24,拥有一个内网IP 192.168.1.1 和一个公网IP 13.0.7.33,并开启了一个HTTP服务器监听80端口。

INPUT

所有前往本机的数据将会经过该链,如果本机作为路由器转发流量,转发的流量不会经过该链。本地回环数据包也会经过该链。如果在PREROUTING阶段使用NAT方式让流量进入本机,那么流量也会经过该链。
例:43.1.3.55:44291 -> 13.0.7.33:80
192.168.1.103:53211 -> 192.168.1.1:80
127.0.0.1:5555 -> 127.0.0.1:5556

OUPUT

本机应用程序发出的数据包会经过该链,类似于INPUT链,本机转发的流量不会经过OUTPUT链。数据包经过OUTPUT链后会进行一次路由决策,用于决定该向哪里转发数据包。
例:13.0.7.33:80 -> 43.1.3.55:44291
192.168.1.1:80 -> 192.168.1.103:53211
127.0.0.1:5556 -> 127.0.0.1:5555

FORWARD

如果本机作为路由器转发流量,则本机需要转发的数据会经过FORWARD链。想要该链起作用需要启用流量转发功能。
例:192.168.1.103:11032 -[SNAT]-> 8.8.8.8:53

PREROUTING

任何外部流量,如果目标MAC地址为本机或广播,则会经过该链。通过该链后本机会进行路由决策,区分到达本机的包和转发包。
由本机发出的数据包如果在路由决策后直接回到本机,则不会经过该链,所以本地回环包以及目的地址为自身IP的包不会经过该链。
即使网卡工作在混杂模式下,MAC不相符的数据包也不会由内核协议栈处理,而是直接丢弃(或被流量监控工具捕获)。

POSTROUTING

所有本机出方向的包,包括转发包本地回环包,都会经过该链。该链为所有路由都已决定后,在离开本机前做最后一步处理的链。其最主要功能为源NAT,用于为本机和子网内设备同时提供NAT服务。
During the lifecycle of "iptables", in which step, will kernel take  advantage of "route table"? - Unix & Linux Stack Exchange

四个内置表

filter

filter表是最常用的表,用于数据包过滤。通常应用是配置Linux本机防火墙,避免外网随意访问对外暴露的端口。例如仅允许部分IP段访问SSH端口、仅允许反向代理出口或WAF出口访问HTTP端口等。
如果配置iptables不指定表名,则默认为filter表。
其可以配置三个动作:ACCEPT, REJECT, DROP。可以作用于三个链上:input, output, forward
其中ACCEPT为接受,也就是允许连接或转发。REJECT为积极拒绝,TCP场景下会发送TCP RST报文,让连接方感知到该端口无法连接,但由于返回了RST报文,连接方可以得知该IP有对应设备。DROP为丢弃数据包而不做任何额外处理,在连接方看来无法收到有效信息,可能会被认为是主机不可达。
filter表主要用于INPUTFORWARD链上,但是也可以用于OUTPUT链上。功能与INPUT类似。

举例:禁止192.168.1.0/24网段以外的IP访问22端口,需要注意的是前后顺序不能变,否则无法连接。

1
2
iptables -A INPUT -p tcp -s 192.168.1.0/24 --dport 22 -j ACCEPT
iptables -A INPUT -p tcp -m tcp --dport 22 -j DROP

nat

nat表用于NAT,可以完成网络地址转换功能,包括源NAT、目的NAT、地址映射等。在大多数内核下,使用该表可以实现1:1 SNAT, 1:1 DNAT, M:1 SNAT, M:1 DNAT以及两个网段之间的M:N NAT。但是仅使用iptables无法实现M:N或1:N的NAT,需要借助其后继者nftables才可实现。在实现SNAT时,会尽可能不改变源端口。而实现DNAT时,如果没有指定端口范围则不会改变源端口。NAT表依赖连接跟踪功能,如果一个连接没有连接跟踪,则无法应用NAT表中的规则。

源NAT

NAT中最常用的为SNAT,这是内网访问公网时的常见形式。SNAT仅作用于POSTROUTING链。下面举个例子,假设内网网段为192.168.1.0/24,公网IP地址为13.0.7.33/32

1
iptables -t nat -A POSTROUTING -s 192.168.0.0/24 -o eth0 -j SNAT --to-source 13.0.7.33

这条命令将来自192.168.1.0/24网段的流量的源地址均转换为13.0.7.23,并且如果可能的话不会改变端口。

地址伪装

一种在家庭中更常见的情况是IP地址由运营商通过拨号等方式动态分配,那么可以使用MASQUERADE方式进行NAT,这种NAT方式与SNAT类似,但是它会自动使用当前网卡的实际IP地址。

1
iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o ppp0 -j MASQUERADE

这里转换到的地址为网卡ppp0自身的地址,如果IP地址更改,其也会自动更改地址。

目的NAT

目的NAT(DNAT)通常用于负载均衡,但iptables原生已经不再支持负载均衡式的目的NAT,所以并不是很重要。目的NAT可以作用于两个链,为PREROUTINGOUTPUT链,对于转发包,目的NAT会在PREROUTING链中处理,对于本机外发的包,则会在OUTPUT链中处理。

重定向

可以将外部包重定向到本机的其他端口,可作用于PREROUTINGOUTPUT链。它可以将数据包的目的地址重定向为其入接口的主IP,可以用于端口重定向和透明代理。

网络映射

网络映射是一种严格的映射,下面的命令会将192.168.1.0/24网段内的主机一一映射到10.0.0.0/24网段的主机,例如192.168.1.193会映射到10.0.0.193。如果其他网段的设备想要访问192.168.1.193,只需要访问10.0.0.193即可。

1
iptables -t nat -A PREROUTING -d 192.168.1.0/24 -j NETMAP --to 10.0.0.0/24

如果两个网段不一致,可能会出现无法访问到目标网段的主机,或者有些目标网段的主机无法被访问到的情况。

mangle

mangle表可以用来修改数据包的一些元信息。其能够修改包括数据包本身包含的信息,如TTL、TOS、TCP MSS等。也能数据包本身不包含,存储在本机内存中的额外标记信息。由mangle表修改的标记信息不仅能在iptalbes中使用,也可以用于路由决策。使用mangle表可以做到很多复杂的路由操作,但该表无法直接修改数据包的内容,如果想要修改数据包的内容,需要搭配NFQUEUE使用。

iptables用户手册中提到了一个修改TCP MSS的例子,如果你发现SSH可以连接,但SCP无法连接,或者类似网站可以连接,但无法收到消息。类似上面的流量一大就出现问题的情况,很可能是在路由过程中有些路由设备的MTU比本机的MTU更小,且不允许IP分片,导致段内容较多的TCP无法正常连接,此时可以尝试使用mangle表调整TCP MSS来减小MSS,测试该问题是否为链路过程中MTU过小引起。

1
2
3
4
5
6
# 自动将MSS设置为MTU
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --clamp-mss-to-pmtu
# 将MSS设置为500,包长度则为 500+20+20 = 540
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --set-mss 500

raw

raw表最大的作用是用于设置NOTRACK标记位,作用域PREROUTINGOUTPUT,一旦一个包被设置了该位,则内核不会再对该流进行连接跟踪,此时nat表会彻底失去作用。

应用实例

允许任何地址访问SSH端口

1
iptables -A INPUT -p tcp --dport 22 -j ACCEPT

允许访问目的端口为22的所有TCP入站连接,公网服务器默认需要配置这条,否则可能会连不上。

禁止非WAF服务商访问443端口

我们以Cloudflare其中的一个IP段 103.21.244.0/22为例。

1
2
iptables -A INPUT -p tcp -s 103.21.244.0/22 --dport 443 -j ACCEPT
iptables -A INPUT -p tcp --dport 443 -j DROP

由于iptables的规则为从上到下匹配,WAF服务商的规则要在前面,它们的流量可以命中前面的规则,允许连接,其他连接则会被静默忽略。

阻断长度超过1000的数据包

过滤条件可以指定数据包长度范围。当指定--length等过滤条件时,过滤条件会应用于整个IP数据包,所以其长度包括IP头部和TCP/UDP头部,但不包括以太网帧头部,也就是不包含MAC地址和数据包类型的长度。

1
iptables -A INPUT -p tcp -m tcp --dport 8885 -m length --length 1001: -j DROP

这条规则会丢弃目的端口为8885,包长度大于1000(不包括1000)的进入本机的数据包,但TCP负载的最大长度为960,即包括20字节的IP头和20字节的TCP头,在我的环境实测为948字节,因为我的设备本地传输时TCP头部为32字节,加上20字节的IP头,长度刚好1000字节,如果传输949字节的TCP数据就会被丢弃。

上面这条规则作用于输入链上,类似的规则也可以作用于转发链上。

1
iptables -A FORWARD -s 8.8.8.8/32 -p tcp -m length --length 1001: -j DROP

当设备转发一个TCP数据包时,如果这个数据包从IP8.8.8.8来,且长度大于1000,则会丢弃掉,但不会影响8.8.8.8到本机的数据包。

同网段单臂路由

同网段是不需要路由的,但是有些情况下可能会需要将一台设备的流量截取修再传回,或根据条件丢弃数据包模拟网络波动,但设备本身和路由器都不支持截取,唯一的一台Linux设备也只有一个网口时,就可以使用这种方式。
简单看下预期的网络拓扑:
客户端(192.168.1.114) <-> 本机(192.168.1.10) <-> 路由器(192.168.1.1) <-> 公网服务器(8.8.8.8)

首先在客户端上修改路由表,将路由指向本机。然后在本机启用转发功能。此时出流量会经过本机,但是回流量并不会经过本机,这是因为本机没有修改客户端的源IP,而路由器检测到客户端和路由器处于同一网段时,会直接将数据发给客户端,而不是发给本机,发生了来回链路不一致。
出方向链路:
客户端(192.168.1.114) -> 本机(192.168.1.10) -> 路由器(192.168.1.1) -> 公网服务器(8.8.8.8)
回方向链路:
客户端(192.168.1.114) <- 路由器(192.168.1.1) <- 公网服务器(8.8.8.8)

这就导致本机只能检测到去方向的包。为了解决这个问题,需要在本机增加SNAT,让客户端的流量经过本机后,源IP改为本机的IP,这样路由器才会将回包发回给本机。

1
iptables -t nat -A POSTROUTING -s 192.168.1.114 -j MASQUERADE

回放流量时禁止目标主机响应

有时需要使用tcpreplay回放流量测试防火墙功能,但回放时,目标主机可能会发送很多rst报文,导致防火墙不能正确建立会话表,无法正常进行测试,此时就可以在目标主机中使用iptables,丢弃来自回放设备的报文。

1
iptables -A INPUT -s 77.77.77.77/32 -j DROP

这条命令会丢弃来自77.77.77.77的所有报文,不会发送rst报文回去。

TCP、UDP 转发至不同路由

这是综合使用iptables和路由的方式。
首先可以标记不同的数据包。这里的每个mark都只会标记数据包,而不会标记整条流,所以这里只是根据TCP和UDP这样的流量静态信息来进行分流,如果需要标记整条流,从而使得不同TCP连接也能够分流,那么需要connmark跟踪。

1
2
iptables -t mangle -A PREROUTING -p tcp -j MARK --set-mark 100
iptables -t mangle -A PREROUTING -p tcp -j MARK --set-mark 200

接下来在/etc/iproute2/rt_tables中配置自定义路由表。

1
2
100 tcp_table
101 udp_table

使用ip ruleip route命令分别为不同的流量设置路由。

1
2
3
4
5
6
7
# 为路由表使用不同的网关
ip route add default via 1.1.1.1 dev eth0 table tcp_table
ip route add default via 2.2.2.2 dev eth0 table udp_table

# 为不同的标记分别使用不同的路由表
ip rule add fwmark 100 table tcp_table
ip rule add fwmark 200 table udp_table

修改数据包内容

iptables原生并不支持修改数据包内容,但是可以通过内核模块、eBPF或NFQUEUE等方式修改数据包内容。内核模块和eBPF由于工作在Linux内核中,所以开发语言非常受限,往往只能通过C、C++、Rust这样的可编译为无运行时可执行二进制的语言编写(所以Go不行,但是Lua行,没想到吧),其他语言编写内核相关模块会非常复杂。另外内核态程序一旦出现错误,很可能引起内核panic,引发整个系统崩溃,所以通常不建议使用内核模块扩展netfilter功能。

不过内核还提供了NFQUEUE来帮助我们绕过内核,在用户态控制网络数据包。
NFQUEUE的核心逻辑是将内核中的数据包复制到用户态程序中,并且由用户态程序通知内核数据包的去向。在这个过程中,用户态程序可以对数据包进行任何修改,并将修改后的数据包传回内核。可以使用scapynetfilterqueue结合NFQUEUE模块,使用Python在用户态运行这项功能。这种方式灵活性非常高,在做小规模的PoC测试时非常实用。

1
2
sudo iptables -I INPUT  -p tcp --dport 8085 -j NFQUEUE --queue-num 0
sudo iptables -I OUTPUT -p tcp --sport 8085 -j NFQUEUE --queue-num 0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from netfilterqueue import NetfilterQueue
from scapy.all import *

def process_packet(packet):
# 将数据包从 nfqueue 提取出来
pkt = IP(packet.get_payload())

# 打印数据包内容(用于调试)
print(pkt.summary())

# 修改数据包内容 (例如,修改数据包负载)
if pkt.haslayer(TCP):
pkt[TCP].payload = Raw(load=b'Changed payload')

# 将修改后的数据包重新放入 nfqueue
packet.set_payload(bytes(pkt))
packet.accept()

def main():
nfqueue = NetfilterQueue()
nfqueue.bind(0, process_packet)

try:
print("Starting packet processing...")
nfqueue.run()
except KeyboardInterrupt:
print("Stopping packet processing...")
finally:
nfqueue.unbind()

if __name__ == "__main__":
main()

网络命名空间

网络命名空间是 Linux 内核的一项功能,提供网络栈的隔离,允许 每个网络命名空间拥有自己的独立网络配置、接口、IP 地址、路由表和防火墙规则。

当创建一个新的网络命名空间时,它将以完全隔离的网络栈开始,除了回环接口 (lo) 外没有网络接口,这意味着在新的网络命名空间中运行的进程默认无法与其他命名空间或主机系统中的进程通信。

也可以通过这种方式在在一个操作系统内实现无需创建容器或虚拟机,就可以虚拟多个网络设备。可以用于简化一些情况下的验证。也可以用于测试可能导致无法联网的iptables命令,当处于网络命名空间内部时,对其的修改不会影响到本机网络,并且由于相当于直接连接tty,所以不会出现命令敲下后无法连接的问题。

通过epoll理解协程

IO多路复用

C10K问题

在互联网的最早期,网络上设备很少,那时每个连接都需要一个进程来处理。一些老的处理方式(如CGI)也沿用了多进程的思路来处理连接。但随着互联网规模快速扩大,一个服务器要同时服务的人越来越多,这也就产生了著名的C10K问题,也就是一个服务器如何处理一万个客户端同时连接。

进程与线程的局限性

进程出现后又出现了线程,相比进程,线程切换的创建与代价更低。也出现了进程池、线程池这样的技术,用于避免频繁创建和销毁的开销,仅保留切换的开销。但这些处理方案都有一个特点:一个进程或线程同时只能处理一个连接,即使连接空闲,线程也不能处理其他连接,这期间线程只能等待。

那么一个线程只能处理一个连接吗?显然不是的,线程和网络并没有什么必然的关联,一个线程既可以同时处理多个网络连接,一个网络连接也可以被多个线程处理。

阻塞与非阻塞

阻塞的概念非常容易理解,在生活中,如果我们看到有什么本该畅通的东西被其他东西堵住了,就可说这里被阻塞了。类似地,如果线程不得不等待某个某些数据到来或某个条件为真后才能继续执行后面的代码,我们就可以说线程被阻塞了。

虽然很多时候,只要耗时较长就可以称之为阻塞了,但严格地说,只有那些可能会导致线程无限等待的操作才被称作阻塞操作,例如等待锁的释放、等待网络连接的数据到达、等待管道内产生新数据等。否则就无法明确区分阻塞与非阻塞了(1us以上算阻塞?还是1ms以上算?)。

在Linux系统上读取磁盘文件时,O_NONBLOCK标志位并不能起有效作用。因为操作系统不会认为磁盘文件的读写有可能导致无限等待。

网络连接的阻塞操作

在看IO多路复用的实现前,我们先看看线程是怎么处理一个网络连接的。线程使用read / writerecv / send系统调用来读/写一个连接,每个连接在操作系统内核中都有一个读写缓冲区。读时如果内核缓冲区中还没有数据,操作系统会让线程阻塞等待,直到缓冲区中有数据后,读操作才能返回数据;写时如果内核缓冲区已满,操作也会被阻塞,直到将全部的数据写入缓冲区。这期间网卡会不断工作,将收到数据送入内核的读缓冲区,并将内核写缓冲区的内容发送出去。

这种机制导致了一个线程难以同时处理两个可能导致阻塞的连接。例如同时存在连接A和连接B,当线程尝试从连接A处收数据时,会阻塞在read调用中。如果此时连接A由于网络波动或其他原因没有数据到达,则线程会一直在此处等待,即使连接B可能已经有很多数据到达需要处理,线程也无法切换。

select, poll 与 epoll

为了让一个线程同时处理多个连接,系统开发者们设计了IO多路复用机制。在Linux上有三类系统调用:select, pollepoll,在UNIX类系统中与epoll对应的则是kqueue

select 与 poll

了解了上面阻塞操作的原理,再去理解IO多路复用就变成了一个简单的思路转变:当一个连接无法读写时,不要让操作系统等待,而是让操作系统立刻返回并告知因为无数据/数据已满而无法读写,此时线程就可以暂时放弃它而去检查另一个连接是否可读写了。这就是IO多路复用的基石:非阻塞IO。

检查连接是否可读写需要进行一次系统调用,检查很多个连接则需要进行很多次系统调用,而系统调用相比普通函数调用速度稍慢,操作系统的开发者们为了加快这一操作,开发了可以一次性检查多个连接是否可读写的系统调用:selelctpoll

注意:很多八股文会提到 poll系统调用是传入的参数是链表,但这并不正确。poll传入的是pollfd数组,并有一个参数n指示了有n个该元素。事实上,系统调用很少涉及链表结构和回调函数,这两者在系统调用中难以高效、安全地实现。

poll的签名如下:int poll(struct pollfd *fds, nfds_t nfds, int timeout);

这两个系统调用思路是相同的。使用者将所有需要检查读写的连接全部放入给定的结构中后,发起调用。此时线程会等待,直到这其中有连接可以进行读写、发生异常或达到设定的超时时间。这相当于操作系统在内部帮你遍历检查你提供的连接中有没有连接可以读写。当返回后,用户程序通过遍历检查返回数组中的元素来确认哪个连接可以被读/写了。这种方式将多次系统调用简化成一次,节约了频繁发起系统调用的时间。

但这种方式仍然有可优化的空间。

内核通常不会直接通过指针访问用户程序的内存,而是需要先从用户空间内复制到内核空间。而select和poll每次都必须将所有需要监控的连接提交到内核,导致每次都需要在内核空间和用户空间中复制较多的数据,并且用户程序还需要遍历检查。为了优化这些问题,Linux从 2.5.44版本中提供了epoll系统调用系列。

epoll

从上文selectpoll的使用方式可以看出,它们在内核中是无状态的,这意味着每次对它们的调用都是独立的,每次调用都需要提供全部感兴趣的连接。但实践中感兴趣的连接通常不会急剧变化。在维护长连接时,短时间内只有小部分连接需要读写,所以可以从内核中维护状态,程序只需要在需要的时候添加和删除即可(通常是连接accept后添加,close前删除,不过close会自动删除)。

由于需要在内核中维护状态,epoll提供了三个系统调用epoll_cerate, epoll_ctl, epoll_waitepoll_create用于创建epoll实例,让内核开始维护状态,epoll_ctl可以添加、删除或修改感兴趣的连接和事件,epoll_wait则用于等待事件发生,当有一个或多个连接有感兴趣的事件时,epoll_wait会返回这些事件。由于只返回了实际发生了事件的文件描述符,所以epoll可以尽量减少内核态到用户态的拷贝。

这是当前(2024年)Linux下大多数异步程序使用的方式。

io_uring 与 IOCP

io_uring是解决Linux在磁盘IO场景下的弱势的,在io_uring出现之前,Linux的异步磁盘IO通常是使用其他线程来完成,如tokiolibuv均使用线程池来处理文件的异步操作。只有部分数据库软件才会用那个又难用又复杂的Linux aio(可以说说就是这些数据库软件的开发者为了数据库高效读写磁盘才开发的)。

除了处理异步磁盘IO,io_uring也可以用于高效处理异步网络IO,由于数据拷贝有单独的内核线程来完成,而非read / write系统调用这种由用户线程发起的系统调用来完成,当前(2024年)在单线程场景下,io_uring要比epoll快一些,但多线程场景差距不大。io_uring的相比epoll减少了系统调用的次数,并且可以使用轮询方式彻底避免系统调用带来的开销。

IOCP是Windows NT内核中用于处理异步IO的接口,其原理与io_uring类似,但出现要更早。

什么是协程

在了解协程之前,让我们先区分一下同步与异步。

同步与异步

同步代表只有上一个任务执行完成,才会去执行下一个任务,即使两个任务之间没有任何关系。

而异步则是不等待上一个任务执行完成就去执行下一个任务。需要注意的是,异步只强调不等待上一个任务完成就执行新任务,并不代表上一个任务一定能和新任务同时执行。例如在单核CPU上,使用两个线程并发执行两个任务也是异步,但事实上CPU同时只能执行一个任务。

为什么按顺序执行的反而被称为同步

相信很多人会好奇,同步通常指同时进行,为什么在计算机领域里反而成了每个任务都按顺序执行呢?首先这并非翻译问题,英语中的 synchronous 也指的是 happening or existing at the same time. 所以英语国家的人也会有和我们一样的困惑。

事实上,同步这个术语来自于数字电路领域。数字电路中,有一个核心组件叫时钟电路。为了保证数字电路的执行不混乱,绝大部分元件都会受时钟电路的控制,每当时钟电路动作一次(上升沿或下降沿),它们也就集体动作一次,这样也就保证了所有的动作像流水线一样有先后顺序。所以,所谓的同步是与时钟电路同步。

而异步则是那个无视时钟电路,直接改变电路状态的方法。它通常表现为一个按钮,用户只要按下去电路就立刻切换到指定的状态。

构建在数字电路上的计算机沿用了这个叫法,所以同步就是跟随时钟信号按顺序执行;而异步则是靠信号或者事件插入,不按顺序地执行。

事件驱动编程

有了epoll的帮助,用户态编程就可以更轻松的处理更多同时发生的连接了。

一个线程处理一个连接只需要按照逻辑进行readwrite就可以了,执行状态会自然保存在线程的执行栈中。但有多个连接时,就需要分别记录下每个连接的执行状态,为此出现了事件驱动模型。简单地说,事件驱动的控制流不再是顺序的,而是被拆分成了一个个函数,每当一个新事件到来,就会触发这个事件类型对应的函数。下面用简单的C代码来表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#define BUF_SIZE 4096
struct socket {
int fd;
char *read_buf;
ssize_t read_size;

char *write_buf;
ssize_t write_size;
}

void on_read(struct socket *s) {
// 收到读事件的时候一定能读出点什么东西,不考虑错误
s->read_size = read(s->fd, s->read_buf, BUF_SIZE);
process_http(s->read_buf, s->read_size);
strncpy("HTTP/1.1 200 OK\r\n", s->write_buf, BUF_SIZE);
on_write(s); // 立刻尝试写,写不下去在再考虑其他问题
}

void on_write(struct socket *s) {
// 最简化,忽略写不完的情况
// 读后立刻触发一次写,但可能因为缓冲区满写不进去,那就返回,等之后写event到来再写
ssize_t w = write(s->fd, s->write_buf, s->write_size);
if (w < 0 && (errno == EWOULDBLOCK || errno == EAGAIN)) {
return;
}
close(s->fd);
}

void on_hangup(struct socket *s) { /* ... */ }

void event_loop() {
while (1) {
int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (int i = 0; i < num_events; i++) {
if (events[i].events & EPOLLUP) {
on_read(events[i].data.ptr);
} else if (events[i].events & EPOLLOUT) {
on_write(events[i].data.ptr);
} /* ... */
}
}
}

这种编程模型通过将状态保存到额外结构中可以有效管理大量连接的状态,但显然也会使开发的控制流变得复杂,阻塞开发模式下,程序员只需要关注当前位置的代码即可。然而事件驱动编程导致程序的控制流会在多个函数间反复变化,这些函数间可能相距很远,并且需要区分函数内的状态和在额外的结构中保存的持久状态,无疑加重了程序员的编码负担。

简化编程步骤

为了让语句尽可能集中在一处而非分散在多处,开发者们在事件驱动编程的基础上又衍生了许多开发方式。

以JavaScript为例,首先是闭包回调,通过闭包将类转换为闭包函数,这种方式不仅将代码位置合并到一起,而且状态也可以直接由闭包捕获,减少了非常多的工作量,但这种方式仍然有较大的缺点。首先是回调地狱,多次非阻塞调用就需要多个回调,而每个回调都会嵌套,导致多个回调中缩进非常复杂,并且由于回调函数仅仅代表将来会执行,接下来函数还是会执行下面的代码,导致控制流仍然不清晰。

1
2
3
4
5
6
7
8
9
10
11
setTimeout(function() { //第一层
console.log('我是第一个执行');
setTimeout(function() { //第二程
console.log('我第二个执行');
setTimeout(function() { //第三层
console.log('我第三个执行');
}, 1000);
console.log('但我在第二个和第三个之间执行');
}, 2000);
console.log('而我在第一个和第二个之间执行')
}, 3000)

为此,JavaScript又推出了Promise,这稍微缓解了一些回调地狱引起的问题。但是依然存在异常控制流不清晰,多个闭包间难以共享变量等等情况。

1
2
3
4
5
6
7
8
9
new Promise((resolve)=>{
console.log('我第一个执行');
resolve(0);
console.log('我第二个执行')
})
.then(() => {
return new Promise(resolve => setTimeout(resolve, 1000));
})
.then(r=>console.log('我第三个执行'))

在经过多年的尝试与改进后,几乎所有主流编程语言都选择了同一个方向:协程。

协程解决了什么问题

通过上面三个例子,我们可以看到事件驱动编程模型的缺点的主要原因:控制流由事件决定,而非逻辑决定,程序员必须照顾随时可能到来的事件,进而导致逻辑难以组织。而传统的同步编程,逻辑就清晰多了。

所以,协程的根本目标就在于:让程序员像编写同步代码一样编写异步代码。我们可以想象一个函数yield,当读写遇到阻塞时,可以将执行权交出去,而当收到可以读写的事件时,又可以回到交出执行权的那个地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
ssize_t unblocking_read(int fd, void *buf, ssize_t size) {
while (1) {
ssize_t ret = read(fd, buf, size);
if (ret < 0 && (errno == EWOULDBLOCK || errno == EAGAIN)) {
yield(); // 神奇的函数,能够跳出执行流程,去执行其他等待的函数,当有事件来的时候再从这里恢复
} else {
return ret;
}
}
}
ssize_t unblockint_write(int fd, void *buf, ssize_t size); // 与写类似

void process_http(int fd) {
char buf[4096];
ssize_t size = unblocking_read(fd, buf, sizeof(buf));
proc_http_header(buf, size);
unblocking_write(fd, "HTTP/1.1 200 OK\r\n");
close(fd);
return 0;
}

这就是协程的核心思想:通过包装让非阻塞调用的行为与阻塞调用类似,让程序员可以重新专注在逻辑维护而非状态维护上。可以看到相比上面的事件驱动模型,这里的核心函数process_http简化了非常多。

如何实现协程

主流的协程实现方式有两种,不需要额外函数调用栈的无栈协程与需要额外调用栈的有栈协程。

有栈协程

通常有栈协程被称为用户线程、虚拟线程、绿色线程、纤程等。从名字上就可以看出,有栈协程使用的思路与操作系统的线程一致,它为每个协程开辟独立的栈空间,但相较于操作系统的调度,有栈协程的调度需要保存的寄存器更少、无需进入内核态,并且调度时机也由程序自身控制,所以切换代价更低。其中Golang的goroutine、Java 21的虚拟线程都属于有栈协程。由于保留了函数的全部调用栈数据,这使得有栈协程的异步代码可以和同步代码没有任何区别,编写时不会增加任何额外的心智负担。这其中最具代表性的就是Golang,Golang从语言和标准库层面封装了常见阻塞操作的非阻塞版本,使用Golang编写代码几乎和使用多线程处理连接几乎完全一致,这使得迁移非常容易。

另外,有栈协程可以完全在运行时实现,而无需编译器的支持,例如C++的libgo库就是通过替换阻塞系统调用和运行时操作来达成和Golang类似的效果的。

我的这个仓库使用C语言实现了一个简单的单线程有栈协程,可以处理网络连接、协程睡眠两种情况,这个程序通过setjmp和很少量汇编编写。所以这个库只能用于X86-64 Linux环境。如果有机会我可以单独开一篇文章写如何实现协程。

无栈协程

无栈协程与有栈协程相对,其不保留函数的调用栈,只保留必要的变量。由于这个限制,产生了无栈协程的最大特点:必须要使用类似async / await的关键字标识协程函数与协程的切换时机。需要注意的是,有栈协程也可以使用类似的关键字,只是并非必须使用。

相比有栈协程,无栈协程需要的内存和额外保留的数据更少,但进行协程切换的时机则被固定在了await时。无栈协程通常使用闭包的思路实现:分析每个可能跨await使用的变量,将它们存放在堆上;同时将每个await块拆分成独立的函数,进行await时相当于返回包含必要局部变量的闭包等待调度。例如JavaScript的协程就是对Promise的一种包装和优化。可以看到,想要使用无栈协程,就必须要编译器提供支持。

大部分使用async / await的语言均为无栈协程实现,例如Python、JavaScript、Rust、C#等。

把编程语言分为强弱类型没有意义

把编程语言分为强弱类型本来就是很蠢的分类方式,得亏初学的时候没怎么见过,不然也得被绕进去。
隐式类型转换是编程语言中的重要部分,没有它编程就会变得异常复杂,所以哪怕像Rust这样学究式的语言,也提供了解引用自动多态的隐式类型转换方法。

真正要关注的是一个语言什么时候会发生隐式类型转换,以及这种隐式类型转换会造成多大的影响。
例如C、C++中存在数字的隐式类型转换,就需要注意长度,避免出现截断、环绕等现象发生。
而众所周知的弱类型语言JavaScript,则提供了数不清的隐式类型转换,成为了前端的天坑之一,我今天还看到讲parseIntMath.floor区别的视频,如果JS肯在parseInt参数不是字符串时报错,哪里还有这些视频的生存空间。

而同样是动态类型脚本语言的Python就要好得多,除了数字运算会出现整数到小数丢失精度以外,虽然存在隐式类型转换(如果你认为双目运算符两边类型不一样就算的话),但其语义都非常清晰,并且具有良好的文档支持(不要去瞎自定义魔术方法的情况下)。例如内置的序列(tuple, list, str, bytes, bytearray)都支持乘一个数字,其结果为重复n次,再比如numpy的广播。我认为这些都是隐式类型转换的优秀实践,符合直觉时允许这么做,而出现模糊的直觉时则尽快报错。

而当前较新的静态类型语言,都禁止了绝大部分的隐式类型转换。例如Golang中,不同精度的整数、浮点数禁止直接运算、类型别名也不会发生隐式类型转换、不允许重载运算符、传参时也不会自动提取出嵌入的匿名结构体,而需要手动提取(如果不了解Go,你可以认为其行为类似于父类指针无法指向子类对象)。Go中仅会发生具体类型到接口的隐式类型转换,可以说是编程语言中最严格的一档了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
type Runnable interface {
Run()
}
type Animal struct{}
func (a *Animal) Run() {}
type Dog struct {
Animal // 组合而非继承的匿名结构体,但提供了一部分继承的能力
}
func ReceiveRunnable(r Runnable) {}
func ReceiveAnimal(a *Animal) {}
func main() {
var dog Dog
ReceiveRunnable(&dog) // 可以,发生从指针到接口的隐式转换
ReceiveAnimal(&dog) // 不可以,无法提取匿名结构体
}

隐式类型转换造成最大问题是出现了无法预测隐式控制流,进而造成难以理解或不可预期的行为,例如C++会自动执行构造函数、JS的('b' + 'a' + + 'a' + 'a').toLowerCase()==='banana'。所以C反而没什么问题:除了数字运算之外,C的隐式类型转换其实没有带来任何隐式控制流–指针的类型转换(无论是强转还是隐式)都没有任何动作,它们仅仅发生在编译期。

  • Copyrights © 2019-2025 Ytyan

请我喝杯咖啡吧~