Linux 网络

引言

本文整理了 Linux 内核中网络的相关知识,其他 Linux 相关文章均收录于 <Linux系列文章>

网络模块

最后,我们介绍一下 Linux 的网络实现,Linux 以一种结构良好到令人惊讶的模型,整合了各种协议和底层硬件。

互联的计算机

计算机之间的通信是一个复杂的主题,引出了许多问题,诸如:

  • 如何建立物理连接?使用何种线缆?通信介质有哪些限制和特殊要求?
  • 如何处理传输错误?
  • 如何识别网络中的每一台计算机?
  • 如果两台计算机通过其他计算机连接,那么二者之间的数据交换如何进行?如何查找最佳的路由?
  • 如何打包数据,使之不依赖于特定计算机的特性?
  • 如果一台计算机提供了几个网络服务,如何识别这些服务?

这类问题还有很多。最“合理”的系统应该将问题分类,创建各种层来解决明确定义的问题,层间借助固定的机制进行通信。这种方法大大简化了实现、维护,以及调试。

ISO/OSI 和 TCP/IP 模型

众所周知的 ISO(国际标准化组织)设计了一个参考模型,定义了组成网络的各个层,该模型由七层组成,被称为 OSI(开放系统互联)模型,如下图所示。但对某些问题而言,划分为七层太过详细了,实际大家通常使用的是另一个简化版,它将 OSI 的一些层合并,该模型由 4 层组成,结构更加简单,它被称为 TCP/IP 模型。IP 表示网络协议,TCP 表示传输控制协议。如今因特网上的大部分通讯都是基于 TCP/IP 模型的。
tcp-ip-osi
上图中的每一层都只能与相邻的上下两层通讯,它甚至无法感知到其他层的存在。各层的职责如下:

  1. 主机到网络层负责将信息从一台计算机传输到远程计算机。它处理传输介质的物理性质,并将数据流划分为定长的帧(frame),以便在发生传输错误时重传数据块。如果几台计算机共享同一传输线路,网络接口下必须有一个唯一的号,称之为 MAC 地址(MAC address),通常烧进硬件中。各厂商之间的协议保证该 ID 是全球唯一的。MAC 地址的格式形如 08:00:46:2B:FE:E8。从内核看来,该层是由网卡的设备驱动程序实现的。
  2. OSI 模型的网络层实际上就是 TCP/IP 模型中的 IP 层,它们负责在网络中的计算机之间交换数据的任务,这些计算机不一定是直接相连的,如下图所示,A 和 B 之间不是直接相连的,因此网络层的任务是找到一条路线,使得计算机可以彼此通信,例如 A-E-B 或者 A-E-C-B。
    internet-layer-example
    因为在网络传输线路上各个节点所能接受的数据长度可能是各不一样的,所以网络层还要肩负将数据划分为合适长度的数据包的工作。在上层发送数据时,网络层将数据分为长度更小的数据包,然后在接收端再重新组合起来,这样高层协议就能透明地传输任意长度的数据,而不用考虑下层的特性。
    网络层借助 IP 协议实现,IP 协议有两个版本 IPv4 和 IPv6,我们这里主要介绍 IPv4。网络层会为每一个节点分配一个唯一的地址,我们称之为 IP 地址,以便计算机可以彼此通信,它的格式如 62.26.212.19 或 192.168.1.8,这些地址有的是由权威机构分配的,有的是为私有网络预留的使用时可以自由分配。通过这些为私有网络预留的 IP,我们可以在地址层次上将网络灵活地划分为子网。
  3. 传输层的任务是在两个建立了链路的计算机上,控制应用程序之间的数据传输。在计算机之间建立通信链路还不够,还必须在客户和服务器应用之间建立连接。在因特网中 TCP 或者 UDP (用户数据包协议)就处于该层中。每个对网络层数据感兴趣的应用都会使用一个唯一的端口号,来唯一的标识目标系统上的服务器应用程序。通常,80 端口用于 Web 服务器。浏览器客户端必须向服务器地址发送请求,已获得所需的数据。自然,客户端也必须有一个唯一的端口号,使得 Web 服务器可以回传数据。一个完整的传输层地址形如 192.168.1.8:80,它以冒号分割 IP 地址与端口号。传输层的另一个任务是可以(但不是必须的,比如 UDP 协议)提供一个可靠的连接,使得通过该连接发送的数据按照给定的顺序到达,不会丢失数据。
  4. 应用层表示从应用程序的视角来看的网络连接。两个应用程序建立连接之后,应用层负责传输实际的业务数据。

Linux 的网络分层模型

Linux 内核网络子系统的实现和 TCP/IP 模型非常相似。相关的 C 语言代码划分为不同层次,各层次都有明确定义的任务,各个层次只能通过明确定义的接口与上下紧邻的层次通信。这种做法的好处在于,可以组合使用各种设备、传输机制和协议。例如,通常的以太网卡不仅可用于建立因特网(IP)连接,还可以在其上传输其他类型的协议,如 Appletalk 或 IPX,而无须对网卡的设备驱动程序做任何类型的修改。下图说明了内核对这个分层模型的实现。
internet-system-interface
分层模型不仅反映在网络系统的代码实现上,也反映在传输的数据内容上,通常各个层的数据都由首部和数据两部分构成,如下图。
protocol-data
首部部分包含了与数据部分有关的元数据(目标地址、长度、传输协议类型等),数据部分包含有用数据(或净荷)。

传输的基本单位是(以太网)帧,网卡以帧为单位发送数据。帧首部部分的主数据项是目标系统的硬件地址,这是数据传输的目的地,通过电缆传输数据时也需要该数据项。

高层协议的数据在封装到以太网帧时,将协议产生的首部和数据二元组封装到帧的数据部分。在因特网网络上,这是互联网络层数据。因为通过以太网不仅可以传输 IP 数据包,还可以传输其他协议的数据包,如 Appletalk 或 IPX 数据包,接收系统必须能够区分不同的协议类型,以便将数据转发到正确的处理程序进一步处理。分析数据并查明使用的传输协议是非常耗时的。因此,以太网帧的首部(和所有其他现代网络协议的首部部分)包含了一个标识符,唯一地标识了帧数据部分中的协议类型。这些标识符(用于以太网传输)由一个国际组织(IEEE)分配。协议栈中的所有协议都有这种划分。为此,传输的每个帧开始都是一系列协议首部,而后才是应用层的数据,如下图所示,图中清楚地说明了为了容纳控制信息所牺牲的部分带宽。
data-structure

网络命名空间

起初,在一个的 Linux 系统中整个系统会共享一个单独的 NIC (网络接口控制器,又称网卡)和路由表的集合。当我们通过路由策略修改路由表项时,它会适用到系统的每一个角落。但是随着命名空间的出现,打破了上述的情况。在拥有多个网络命名空间时,各个命名空间单独维护属于自己的 NIC 和路由规则,它们之间的操作是相互独立的,换句话说新创建出来的网络命名空间就像一个”没有插网卡的虚拟主机”一样,它只有一个本地回环网卡,除此之外,它不能访问其他任何主机甚至宿主机。为了让其能够访问宿主机我们需要为其创建一个虚拟网卡,然后为其配置路由表,将所有流量通过虚拟网卡转发到宿主机上去。这样这个新的网络命名空间才有机会访问到其他网络节点。

下面我将以一个例子的形式介绍这个过程,实际上我们在 Kubernetes 的文章中已经演示过同样的例子。

首先,我们创建一个新的网络命名空间,然后确认一下是否创建成功。

1
2
3
4
ip netns add foo

ip netns list
foo

创建好之后,我们不妨看一看这个命名空间中现存的网卡,可以发现它默认只存在本地回环网卡。

1
2
3
ip netns exec foo ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

这时候,在这个新的命名空间中是无法访问到我们的宿主机的,不过我们可以通过创建虚拟 Ethernet(veth)接口的方式,将这个新命名空间和宿主机连接起来。虚拟 Ethernet 接口是一种有趣的结构,它们总是成对出现。连接起来就像一个隧道一样,从一端 veth 接口进去,然后从另一端的 veth 接口出来。接下来我将演示如何通过 veth 接口将新建的命名空间与系统的默认命名(宿主机网络)空间连通。

首先,创建一个 veth 对,我们现在系统的默认命名空间中创建它们,然后我们列出默认命名空间中的所有网卡,可以看到其中包好刚创建好的 veth1 和 veth0。

1
2
3
4
5
6
7
8
9
10
11
12
13
ip link add veth0 type veth peer name veth1

ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether x:x:x:x:x:x brd ff:ff:ff:ff:ff:ff
3: p4p1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 00:e0:4c:68:64:0d brd ff:ff:ff:ff:ff:ff
10: veth1@veth0: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 6e:63:30:af:63:58 brd ff:ff:ff:ff:ff:ff
11: veth0@veth1: <BROADCAST,MULTICAST,M-DOWN> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether b6:e3:25:65:3b:3b brd ff:ff:ff:ff:ff:ff

接下来,我们将一个 veth 接口移到新建的命名空间中,并在新命名空间中重新确认其网卡列表。可以看到 veth1 已经移动进来了,并且它会从默认命名空间中消失。总结一下,一个网卡只会出现在一个命名空间中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ip link set veth1 netns foo

ip netns exec foo ip link list
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
10: veth1@if11: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether 6e:63:30:af:63:58 brd ff:ff:ff:ff:ff:ff link-netnsid 0

ip link list
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether x:x:x:x:x:x brd ff:ff:ff:ff:ff:ff
3: p4p1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 00:e0:4c:68:64:0d brd ff:ff:ff:ff:ff:ff
11: veth0@if10: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN mode DEFAULT group default qlen 1000
link/ether b6:e3:25:65:3b:3b brd ff:ff:ff:ff:ff:ff link-netnsid 0

你会发现,无论是 veth 0 还是 veth1 都是处于关闭状态(DOWN),这里我们分别为它们指定一个 IP 地址,然后激活网卡。网卡激活后,我们就能查看到它们的 IP 地址了。

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
ip addr add 10.1.1.1/24 dev veth0
ip netns exec foo ip addr add 10.1.1.2/24 dev veth1
ip netns exec foo ip link set veth1 up
ip link set veth0 up

ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
inet 127.0.0.1/8 scope host lo
valid_lft forever preferred_lft forever
2: em1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether x:x:x:x:x:x brd ff:ff:ff:ff:ff:ff
inet x.x.x.x/x brd x.x.x.x scope global noprefixroute dynamic em1
valid_lft 61841sec preferred_lft 61841sec
3: p4p1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
link/ether 00:e0:4c:68:64:0d brd ff:ff:ff:ff:ff:ff
inet 192.168.202.2/24 brd 192.168.202.255 scope global noprefixroute p4p1
valid_lft forever preferred_lft forever
11: veth0@if10: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether b6:e3:25:65:3b:3b brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 10.1.1.1/24 scope global veth0
valid_lft forever preferred_lft forever

ip netns exec foo ip addr show
1: lo: <LOOPBACK> mtu 65536 qdisc noop state DOWN group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
10: veth1@if11: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 6e:63:30:af:63:58 brd ff:ff:ff:ff:ff:ff link-netnsid 0
inet 10.1.1.2/24 scope global veth1
valid_lft forever preferred_lft forever
inet6 fe80::6c63:30ff:feaf:6358/64 scope link
valid_lft forever preferred_lft forever
You have new mail in /var/spool/mail/root

到此为止,我们的新命名空间中已经可以 ping 通默认命名空间的 veth0。

1
2
3
4
5
6
ip netns exec foo ping 10.1.1.1
PING 10.1.1.1 \(10.1.1.1\) 56\(84\) bytes of data.
64 bytes from 10.1.1.1: icmp_seq=1 ttl=64 time=0.047 ms

ip netns exec foo ip route show
10.1.1.0/24 dev veth1 proto kernel scope link src 10.1.1.2

套接字实现

再开始介绍网络的各个层之间,我们先来说一说套接字的概念,因为它会贯穿于各个层,尤其是套接字的缓冲区。

我们知道外设在 Linux 中不过是普通的文件,我们可以通过正常的读写操作访问外设,但是网卡设备并不能这样使用,因为网络是分层的,各个层负责不同的目标,我们不能指望设备驱动完成所有层的工作,同时这些工作是完全公用的所以也不该交给用户代码处理,所以在用户代码与网络设备之间必须由内核构建一层抽象,而套接字接口就扮演了这样的一个角色。

用户代码中可以使用套接字建立网络连接,并可以像操作 inode 一样的读写操作来访问网络,从这个角度看套接字就像一个文件描述符,而在套接字的内部实现中完成网络中各个层的工作。

套接字不仅可以用于各种传输协议的 IP 连接,亦可以用于内核支持的所有其他地址和协议类型,例如本地 UNIX 套接字,IPX,Appletalk 等。因此,在创建套接字时,必须指定所需的地址和协议类型。

套接字是使用 socket 函数库创建的,我这里不打算介绍 socket 的使用了,我认为大家应该已经对它再清楚不过了。值得一提的是,建立好连接后,我们可以通过 netstat -na 查看所有的 TCP/IP 连接的状态。

1
2
3
4
5
6
netstat -na
Active Internet connections \(servers and established\)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:9862 0.0.0.0:* LISTEN 31612/java
tcp 0 0 10.34.163.173:39847 0.0.0.0:* LISTEN 9823/java
tcp 0 0 0.0.0.0:61001 0.0.0.0:* LISTEN 8155/java

套接字缓冲区

理解了套接字的概念之后,我们着重来介绍一下套接字缓冲区。我们知道当内核接收到网络数据包时,底层协议的数据将传递到更高层,发送数据时,各个协议产生的数据依次向更底层传递,直到最终发送。这些传递过程对网络系统的性能至关重要,因此内核使用了一个特殊的结构,被称为套接字缓冲区。它用来在网络的各个层次之间交换数据而不用来回复制,对性能的提升非常可观。套接字是网络系统的基石之一,因此在网络的各个层上都需要处理该结构。

那么套接字缓冲器到底是怎么达到不用复制数据就在多个层次间共享数据的呢?回想一下之前介绍的网络包嵌套结构(下层协议的载荷就是上层协议的完整数据),那么我们是不是通过指针就能快速的划分出属于不同层的协议包的起始地址和结束地址,然后我们将协议包的首尾地址交给对应的协议处理函数就行,而不用拷贝到一块新的内存中。套接字缓冲区的基本思想就是这样的。
socket-buffer-data
上图中展示的就是缓冲区中维护的一块数据,head 和 end 指向了数据在内存中的起始和结束位置。你会发现这个区域实际上可能大于需要的实际长度,这是因为在准备发送数据包时,最初可能不知道最终硬件层面的数据帧的长度。data 和 tail 指向了完整协议栈数据区域的起始和结束地址。mac_header 指向了 MAC 协议首部的地址,而 network_header 和 transport_header 指向了网络层和传输层协议首部地址。通过这些指针,内核可以将套接字缓冲区的内存数据用于不同的协议。
expand-socket-buffer
在一个新数据包产生时,TCP 层首先在用户空间中分配内存来容纳该数据包数据(首部和净荷)。分配的空间大于数据实际需要的长度,因此较低的协议层可以进一步增加首部。

分配一个套接字缓冲区,使得 head 和 end 分别指向上述内存区的起始和结束地址,而 TCP 数据位于 data 和 tail 之间。

在套接字缓冲区传递到互联网络层时,必须增加一个新层。只需要向已经分配但尚未占用的那部分内存空间写入数据即可,除了 data 之外所有的指针都不变,data 现在指向 IP 首部的起始处。下面的各层会重复同样的操作,直到数据包组件完成,即将通过网络发送。

对接收的数据包进行分析的过程是类似的。数据包数据复制到内核分配的一个内存区中,并在整个分析期间一直处于该内存中。与该数据包相关联的套接字缓冲区在各层之间顺序传递,各层依次将其中的各个指针设置为正确值。

总结来说,每个数据包都会有一个和它对应的套接字缓冲区,而这个缓冲区又从属于某一个套接字对象,即便是同一个套接字对象的不同数据包,它们的套接字缓冲区也可能是不连续的,它们之间会以链表的形式管理。
socket-buffer-link

网络访问层

现在,我们将注意力转向网络实现的最底层,即网络访问层,该层主要负责在计算机之间传输信息,与网卡的设备驱动程序直接协作。

在内核中,每个网络设备都会被表示为一个 net_device 结构体,其中主要记录了设备共享内存的信息,设备的 I/O 地址,设备的中断编号,设备的名字和索引值,硬件类型,硬件地址(MAC),最大传输单元,所从属的命名空间等。系统中的所有设备会以链表的形式存储在内核中,而且每个设备都会有一个唯一的名字,我们可以通过 ls -l /sys/class/net 查看所有设备。

1
2
3
4
5
ls -l /sys/class/net
total 0
lrwxrwxrwx 1 root root 0 Nov 28 13:37 eth0 -> ../../devices/pci0000:00/0000:00:19.0/net/eth0
lrwxrwxrwx 1 root root 0 Nov 28 13:37 lo -> ../../devices/virtual/net/lo
lrwxrwxrwx 1 root root 0 Nov 27 14:49 veth0 -> ../../devices/virtual/net/veth0

上例中,lo 表示环回接口,而 veth0 是我们在前一节中创建的虚拟网卡,而其他接口则是 PCI 网卡。网络设备的名字通常能够表示出网络设备的类型,末尾的数字用于区分同一类型的多个网卡,下表列出了常见的设备类别。
interface-name-pattern
网络设备分两个方向工作,即发送和接收(这两个方向通常称为下向流和上向流)。我们接下来将分别介绍这两个部分的实现方案。

接收数据

先说说接收数据,因为数据帧到达内核的时间是不可预测的。所以设备驱动程序都使用中断来通知内核有数据到达。网络驱动程序会对特定的设备中断设置了处理程序,因此每当该中断被引发时(即数据到达),内核都调用该处理程序,将数据从网卡传输到物理内存,或通知内核在一定时间后进行处理,几乎所有的网卡都支持 DMA 模式,能够自行将数据传输到物理内存。但这些拷贝到内存的数据仍然需要被进一步处理,不过不会在硬中断上下文中进行,因为这个过程太费时间。

当前,内核中有两套处理接收数据的框架,其中一个是传统方法,它可以处理那些低速的设备,而对于那些高速网络设备,使用传统方法会出问题,所以后来网络子系统中才出现了新的处理框架。这里我们先来说说处理低速设备的传统方法。

下图给出了传统方法的处理过程,因为数据帧是在中断上下文中接收到的,所以中断处理程序只能执行些基本的任务,避免系统(或当前 CPU)的其他任务延迟太长时间。
old-api-handle-receive-frame
在中断上下文中,数据由 3 个短函数处理,执行了下列任务。

  1. net_interrupt 是由设备驱动程序设置的中断处理程序。它将确定该中断是否真的是由接收到的数据帧引发的(也存在其他的可能性,例如,报告错误或确认某些适配器执行的传输任务)。如果确实是有数据帧到来,则控制权将转移到 net_rx 函数。
  2. net_rx 函数也是特定于网卡的,首先创建一个新的套接字缓冲区,数据帧的内容接下来从网卡传输到缓冲区(也就是进入了物理内存),然后使用内核源代码中针对各种传输类型的库函数来分析数据帧首部数据。这项分析将确定数据所使用的网络层协议,例如 IP 协议。
  3. 与上述两个方法不同,netif_rx 函数不是特定于网络驱动程序的,调用它标志着控制权由特定于网卡的代码转移到了网络层的通用接口部分。该函数的作用是,将接收到的数据帧放置到一个特定的 CPU 等待队列上,并退出上下文,使 CPU 可以执行其他任务。注意这个 CPU 等待队列表示的是等待被进一步处理的任务队列,并不是数据队列,数据已经在第二步中保存在套接字对应的缓冲区中了。而且这里之所以每个 CPU 都有一个队列,是为了让下一步的处理过程支持并行,而且不必使用到锁机制来保护等待队列免受并发访问,因为每个 CPU 只处理自身的队列,不会打扰其他 CPU 的工作。netif_rx 在结束工作之前会触发软中断,之后的处理过程都由软中断进行。

net_rx_action 就是对应的软中断处理程序,它会循环地将自身 CPU 队列上的所有待决数据帧都拿出来,根据数据帧的头部,分析出数据帧的上层协议类型,然后将其传递给网络层(上一层)的接收函数。

如果设备的传输率不是那么高时,前面讨论的这个方法可以很好地将数据帧从网络设备传输到内核的更高层。每次一个以太网帧到达时,都使用一个 IRQ 通知内核。对低速设备来说,在下一个数据帧到达之前,IRQ 的处理通常已经结束。由于下一个数据帧也通过 IRQ 通知,如果前个数据帧的 IRQ 尚未处理完成,则会导致问题。现代以太网卡的速度高达 10000 Mbits,如果使用旧式方法来驱动此类设备,将造成所谓的“中断风暴”。为解决该问题,新方法使用了 IRQ 和轮询的组合。

当高速设备开始接收数据时,处理方案如下:

  1. 第一个数据帧将导致网络适配器发出 IRQ。为防止之后的数据帧到来导致的更多的 IRQ,驱动程序会关闭该适配器的Rx IRQ。并将该适配器放置到一个轮询表上。然后触发软中断 NET_RX_SOFTIRQ。软中断中会轮询所有的设备。
  2. 只要轮询表上的适配器还有数据帧需要处理,内核就以轮询(每个设备处理一部分数据)的方式一直处理该设备的数据帧。
  3. 当轮询表上的适配器没有数据帧可供处理时,将其从轮询表中移除,并重新启用它的 Rx中断。

通过这个新方案,如果在新的数据帧到达时,旧的数据帧仍然处于处理过程中,工作不会因额外的中断而减速。而且在没有数据帧还需要处理时,将停止轮询,设备将恢复到之前的 IRQ 驱动的运行方式,所以不会有什么性能损失。

这个新方案的另一个优点是可以高效地丢弃数据帧。如果内核确信因为有很多其他工作需要处理,而导致无法处理任何新的数据帧时,那么网络适配器可以直接丢弃该数据帧,无须复制到内核。

那么什么样的设备才能使用新方法呢?

  1. 设备必须能够保留多个接收的数据帧,例如保存到 DMA 环形缓冲区中(Rx缓冲区),注意它不是老方法提到的套接字缓冲区,而是设备分配的 DMA 内存缓冲区,DMA 缓冲区处于更下层。
  2. 该设备必须能够禁用并重启用于数据帧接收的 IRQ。

如果系统中有多个设备,会怎么样呢?这是通过循环轮询各个设备来解决的。下图概述了这种情况。
interface-frame-handle-loop
就像前文所述,如果一个数据帧到达一个空的 Rx 缓冲区,则将相应的设备置于轮询表中。换句话说,轮询表可以包含多个设备。内核以循环方式处理链表上的所有设备: 内核依次轮询各个设备,每个设备只会处理一小批数据帧,之后跳到下一个设备进行处理,此外,某个设备都带有个相对权重,表示其在轮询表中其他设备相比,该设备的相对重要性。较快的设备权重较大,较慢的设备权重较小。权重相当于指定了在一个轮询的循环中应该处理多少数据帧,通过它可以保证内核会为高速设备处理更多的数据帧。

值得一提的是,权重也被内核称为 “预算”,表示一次轮询中内核允许设备驱动处理的数据帧数目。当预算用完时,会将该设备移动到轮询表的末尾。而当设备中没有足够的数据帧时,说明 Rx 缓冲区为空,那么内核会将该设备从轮询表中移除,并重启 Rx IRQ。

下图中的过程说明了新方案的软中断中都做了什么工作。
net-rx-action
本质上,内核通过依次调用各个设备特定的 poll 方法,处理轮询表上当前的所有设备。设备的权重用作该设备本身的预算,即轮询的一步中可能处理的数据帧数目。
为了确保在这个软中断的处理程序中,不会花费过多时间,对一次软中断处理过程设定了以下跳出条件(软中断重复执行 10 次后,守护进程也会帮着处理软中断)。

  1. 处理程序已经花费了超出一个 jiffie 的时间。
  2. 所处理数据帧的总数,已经超过了 netdev_budget 指定的全局预算总值。通常,总值设置为300,但可以通过 /proc/sys/net/core/netdev_budget 修改。

这个预算不能与各个网络设备本身的预算混淆! 在每个轮询步之后,都从全局预算中减去处理的数据帧数目,如果该预算值下降到 0,则退出软中断处理程序。而在轮询了一个设备之后,内核会检查所处理的数据帧数目,与该设备的预算是否相等。如果相等,则将其移动到轮询表末尾,在链表中所有其他设备都处理过之后,继续轮询该设备。显然,这实现了网络设备之间的循环调度。

发送数据

在网络层中,特定协议的函数会通知网络访问层处理套接字缓冲区中定义的数据,然后网络访问层将包装并发送这些数据。在这个过程中,必须注意哪些问题呢?除了特定协议需要完成的首部和校验和,以及由高层协议生成的数据之外,数据的路由是最重要的。即使计算机只有一个网卡,内核仍然需要区分发送到外部目标的数据和针对环回接口的数据,不过数据的路由在网络层已经决定了,所以在网络访问层这里实际上已经从上层(网络层)得到了接收方的 MAC 地址。在网络访问层这里,只需要根据下层协议的不同,填充不用的数据头即可。然后,将数据帧放置到设备的发送等待队列上。在数据帧放置到等待队列上一定的时间之后,设备驱动程序将其发出。

网络层

在网络访问层主要的工作还是面向于网络设备驱动程序,而到了网络层,就已经与硬件几乎完全分离。这里之所以说几乎,是因为网络层不仅负责收发数据,还负责在彼此不直接连接的系统之间转发和路由数据包。查找最佳路由并选择适当的网络设备发送数据包,也涉及了对下层 MAC 地址的处理。如果网络层不考虑硬件特性,是无法将较大的数据包拆分成较小单元的。因为每种物理传输技术都会有一个对应的数据包最大值(MTU),IP 协议必须根据它将较大的数据包拆分成较小的单元,由接收方重新组合,这样才能为上层协议隐藏底层的硬件特性。

在网络层主要有两个协议 IPv4 和 IPv6,IPv6 的出现主要是为了解决 IPv4 地址库界问题。我们接下来将主要介绍 IPv4 的实现,因为 IPv6 和它的工作模式基本是相同的,我们会在最后简单地介绍一下 IPv6。

IPv4

IP 协议的数据包如下图所示。
ipv4
其中各个段的意义如下:

  • version:指定了所用 IP 协议的版本。该字段的有效值为 4 或 6。
  • IHL:定义了首部的长度,由于选项数量可变,这个值不一定是相同的。
  • Codepoint:用于更复杂的协议选项,我们不介绍它。
  • Length:指定了数据包的总长度,即首部加数据的长度。
  • fragmentID:分片 ID 相同说明这些数据包组成了一个较大的 IP 数据包。接收方根据分片 ID 将一个 IP 数据包的多个分片整合到一起。
  • 标志:分片标志 DF(don’t fragment)表示数据包不可拆分为更小单元,MF 表示当前包是一个更大数据包的分片,会面还会有其他分片(除了最后一个分片外,所有分片都会标记 MF)
  • fragment offset:分片偏移量描述了当前分片在所属地 IP 数据包的哪个位置。
  • TTL:”Time to Live”,指定了从发送者到接收者的传输路径上中间站点的最大数日(或跳数)。推荐值为 60。
  • Protocol:标识了上层协议(传输层)是什么。例如,TCP 和 UDP 协议。
  • Checksum: 包含了一个校验和,根据首部和数据的内容计算。如果接收方计算的值和它不同,那么可能发生了传输错误,应该丢弃该数据包。
  • src 和 dest:指定了源和目标的 32 位 IP 地址。
  • options:用于扩展选项,在这里不讨论。
  • data:保存了数据(净荷)。

IP 首部中所有的数值都以网络字节序存储(大端序)。

接下来我们看一下 IPv4 的主要工作路线,我们后续的介绍将主要根据该路线展开。
ipv4-process
从上图中你可以看到,发送和接收过程并不是完全分离的,如果一个流入的数据包只是通过当前计算机进行转发,那么内核会在网络层收到该数据包后直接发送给下一个计算机,而不会上交到上层(传输层)中。

接收数据包

在数据包被接收时,它会被存储在套接字缓冲区中,然后处理流执行到 ip_rcv 之后,必须检查接收到的信息确保它是正确的。主要包括计算并比较检验和,是否达到 IP 首部的最小长度,协议的版本是否和当前处理程序对应。
ipv4-process
在进行了检查之后,内核并不会立即继续对数据包进行处理,而是会调用一个 netfilter 挂钩(PRE_ROUTING),它使得用户空间可以对数据包进行操作。netfilter 挂钩实际上在内核的好几个固定的地方都有(如上图所示),这使得数据包能够在一些关键的地方被用户动态的修改操作策略。我们会在后面详细的介绍 netfilter,这里大家只需要知道它是网络层处理过程中的很多个切面,用户可以在这些切面上操作数据包(用户态进行),操作完之后再回到内核态继续往下处理该数据包。过了 PRE_ROUTING 的挂钩之后,内核会通过路由规则检查(我们后面会介绍路由的检查方式),在这里内核会区分出该数据包是要自己接收的包还是一个路过的包。如果是要自己接收那么内核会调用 ip_local_deliver 将其递交到更上层(传输层),这里会涉及到判断 IP 载荷的数据协议,然后设置套接字缓冲区中的传输层首部指针。而如果内核发现该包的目标地址不是当前计算机,则会根据路由规则转发给下一个计算机或者丢弃。

交付到本地传输层

由于 IP 数据包是分片的,所以内核在接收到 IP 数据包时,首先要对分片的数据包进行合并。内核在一个独立的缓存中管理了一个完整 IP 数据包的所有分片,该缓存叫做分片缓存,在缓存中,属于同一个完整数据包的各个分片保存在一个独立的等待队列中,直到该数据包的所有分片都到达。为了执行效率,内核通过一个散列表来维护内核中所有的分片缓存,散列函数基于分片 ID(注意不是分片偏移),源地址,目的地址,协议标识。为防止丢帧后分片缓存一直积压,内核会通过计时器的方式删除过期的分片缓存。

当一个数据包的所有分片都存入分页缓存后,内核会将所有分片重新组合起来,然后将释放套接字缓冲区中的对应数据,最后将合并后的完整数据包递交给下一处理函数。而如果分片没有到齐时,内核会直接返回,等所有分片到齐再进一步处理。
ip-fragment
在将完整数据包交付给上层之前,内核还会调用 LOCAL_IN netfilter 挂钩。挂钩函数处理结束后,内核根据分析出的上层协议类型,递交给对应的函数处理。
ipv4-process

数据包转发

IP 数据包可能如上所述交给本计算机处理,也可能直接离开网络层,转发到另一台计算机,而不涉及本计算机的传输层协议处理过程。对于转发的数据包,又分为如下两种情况:

  1. 目标计算机在某个本地网络中,当前计算机与该网络有链接。
  2. 目标计算机在地理上属于远程计算机,不连接到本地网络,只能通过网关访问。

关于转发和路由的介绍我们会在后面展开,这里我们关注与一些转发过程中的完整过程:

  1. 首先内核会判断 IP 数据包的 TTL,它保存了该数据包还允许转发的次数,如果该值小于等于 1,内核会直接丢弃该包,不会转发
  2. TTL 减一
  3. 执行 IP_FORWARD netfilter 挂钩函数
  4. 将数据包交给发送函数,那里会进行适当的路由转发
发送数据包

发送数据包的第一步就是确认该数据包的路由,而同一套接字的所有数据包目的地址都是相同的,所以对于一个套接字来说,只需要在最初确定一次路由之后,都可以重复复用该路由方案。

在任何 IP 协议的实现中,路由都是重要的部分,不仅发送给另一台计算机的数据包需要路由,即便是发送到本地计算机的包也需要路由。说到底,路由就是查找数据包应该从计算机的哪个网络设备发出,发送到哪个 MAC 地址的问题。这里,我们把数据包按照发送的目标计算机类型,分为三种情况:

  1. 其目标是本地主机:地址是环回地址或者是本地主机在本地网络中的地址
  2. 其目标是当前主机直接连接的计算机:当前主机所处的本地网络 IP 段与目标地址匹配
  3. 其目标是远程计算机:既不是本地网络中的地址,也不是回环地址,只能经由中间人转发

我们前面讨论了第一类情况,这些数据包将传递到更高层的协议中,进行进一步的处理(所有数据包都会传递到路由子系统)。如果数据包的目标主机与本地主机直接相连,路由的工作就集中确定通过哪个网卡发送数据,并且发送到哪个目标 MAC。内核中会为每个网络设备维护该网卡分配的 IP 地址,以及该网卡所处的本地网络的子网掩码,我们通过 ifconfig 就能够查看它们。一般来说网卡对应的子网范围是什么,那么该子网的数据包路由就应该通过该网卡发送,我们可以通过 ip route 查看路由表。

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
ifconfig
em1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet x.x.x.x netmask 255.255.255.0 broadcast 10.34.163.255
ether x:x:x:x:x:x txqueuelen 1000 \(Ethernet\)
RX packets 529667012 bytes 470791138766 \(438.4 GiB\)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 294042853 bytes 123382940734 \(114.9 GiB\)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0
device interrupt 20 memory 0xf7100000-f7120000

lo: flags=73<UP,LOOPBACK,RUNNING> mtu 65536
inet 127.0.0.1 netmask 255.0.0.0
loop txqueuelen 1000 \(Local Loopback\)
RX packets 202529078 bytes 126519666188 \(117.8 GiB\)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 202529078 bytes 126519666188 \(117.8 GiB\)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

veth0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
inet 10.1.1.1 netmask 255.255.255.0 broadcast 0.0.0.0
ether b6:e3:25:65:3b:3b txqueuelen 1000 \(Ethernet\)
RX packets 36 bytes 2728 \(2.6 KiB\)
RX errors 0 dropped 0 overruns 0 frame 0
TX packets 34 bytes 4290 \(4.1 KiB\)
TX errors 0 dropped 0 overruns 0 carrier 0 collisions 0

ip route
default via x.x.x.x dev em1 proto dhcp metric 101
10.1.1.0/24 dev veth0 proto kernel scope link src 10.1.1.1

如果目标地址是 127.0.0.1,它就会命中本地主机的环回网卡 lo,数据包会递交给上一层。而如果目标地址命中了我们前面创建的 veth0 的子网范围 10.1.1.1/24,则说明目标主机是和我们直接相连的,这时候内核会查询 ARP(地址解析协议)表。该表中记录了和本地主机处于同一本地网络的其他主机的 IP 和 MAC 地址的对应关系,它本质上是一个缓存。我们可以通过 cat /proc/net/arp 查看。

1
2
3
cat /proc/net/arp
IP address HW type Flags HW address Mask Device
10.1.1.2 0x1 0x2 6e:63:30:af:63:58 * veth0

我们可以看到 ARP 表中存在我们之前创建的 veth1 设备的 IP 以及它的 MAC 地址,而且会标识应该从哪个网卡设备发出数据帧。既然 ARP 表是一个缓存,那么最初的数据是怎么加入进 ARP 缓存的呢?当目标 IP 不存在 ARP 表中时,内核会向该网卡的广播地址发送一条 ARP 广播,询问谁的 IP 地址是 10.1.1.2? 对应的 MAC 地址是什么?,这时候 veth1 网卡发现自己的 IP 是这个 会回复该询问,而该网络中的其他主机将忽略该询问请求。当 10.1.1.2 的回应返回本地主机时,本地主机就会将其加入到 ARP 缓存中,然后我们才能通过 /proc/net/arp 查看到它。明确了目标主机的 MAC 地址之后,内核就可以很自然地将目的 MAC 设置为该值,并通过对应的网卡发送。
arp-request
接下来我们再说说第三种情况————网络包的目标是远程计算机。当内核发现网络包的目标地址不属于任何一个本地网络时,就会根据路由信息查找网卡,数据包会通过该网关转发。如下图所示,本地主机的默认网卡是 x.x.x.x 对应的网卡是 em1。这时候内核发现目的主机不是本地主机直连的主机,所以希望通过网关转发,网关一般和本地主机处于同一个本地网络中,所以之后会像前面描述的,它会将目的 MAC 地址设置为网关的 MAC 地址(ARP 查询),通过对应的网卡发送。

1
2
3
ip route
default via x.x.x.x dev em1 proto dhcp metric 101
10.1.1.0/24 dev veth0 proto kernel scope link src 10.1.1.1

实际上在内核中为了加速路由的查找过程会维护各种缓存,以路由为例,内核会维护一个 fib(Forwarding Information Base)表,其中保存了路由选择信息,我们可以通过 cat /proc/net/fib_trie 查看。

确认完路由方案后,我们就已经明确了应该将数据帧的发送任务交给哪个网卡设备处理,以及目标的 MAC 地址是什么。接下来内核会先执行 POST_ROUTING netfilter 挂钩函数。然后根据目标设备的 MTU 确认当前数据包是否需要分片。如果有必要,内核会将 IP 分组划分成更小的单元,如下图所示。
ip-fragment
简单地说,内核会循环地从数据包中抽出一个数据分片,其长度与对应的 MTU 兼容,创建一个新的套接字缓冲区来保存抽取的数据分片,分片的顺序基于分片的偏移量构建,MF 标志位也需要设置,最后一个分片的标志位设置为 0,然后计算并填上校验和之后交给下一层进行发送。

netfilter

netfilter 是一个 Linux 内核框架,通过它可以根据动态定义的条件来过滤和操作分组。这显著地增加了网络设置的灵活性,从简单的防火墙,到对网络通信数据的详细分析,到复杂的、依赖于状态的分组过滤器都能通过 netfilter 实现。

简而言之,netfilter 框架向内核添加了下列能力。

  • 根据状态及其他条件,对不同数据流方向(进入、外出、转发)进行分组过滤(packet filtering)。
  • NAT(network address translation,网络地址转换),根据某些规则来转换源地址和目标地址。例如,NAT可用于实现因特网连接的共享,有几台不直接连接到因特网的计算机可以共享一个因特网访问入口(通常称为伪装或透明代理)。
  • 分组处理(packet manghing)和操作(manipulation),根据特定的规则拆分和修改分组。

我们可以通过在运行时向内核载入模块来增强 netfilter 的功能。一个定义好的规则集,告知内核在何时使用各个模块的代码。内核和 netfilter 之间的接口保持在很小(小到不能再小)的规模上,尽可能使两个领域彼此隔离,避免二者的相互干扰,同时这样有利于提高网络代码的稳定性。

前几节中经常提到,netfilter 挂钩位于内核中各个位置,以支持 netfilter 代码的执行。这些不仅用于 IPv4,也用于 IPv6 和 DECNET 协议。这里只讨论了 IPv4,但其概念同样适用于其他两种协议。

netfilter 实现划分为如下两个部分。

  • 内核代码中的挂钩,位于网络实现的核心,用于调用 netfiter 代码。
  • netfilter 模块,其代码挂钩内部调用,但其独立于其余的网络代码。内核中包含一些标准模块,它们提供了常用的处理函数,同时也可以在扩展模块中定义用户期望的各种函数。

iptables 是用户配置 netfilter 的一个主流工具,通过它可以配置防火墙、数据包过滤器等。这些只是建立在 netfilter 框架上的模块,它提供了一个功能全面、定义良好的库函数集合,以便数据包的处理。这里不会详细描述如何从用户空间激活和管理这些规则,大家可以参考网络管理方面的大量文献。

IPv6

就在 11 月 25 日 UTC + 1 15:35 IPv4 的所有地址都已分配出去,这也就意味着之后 IPv6 快要接替 IPv4 的角色了,现在 Linux 内核对 IPv6 标准的支持已经达到了产品级别。IPv6 在许多方面类似于 IPv4,这里我们只会简单介绍一下。

IPv6 采用了全新的数据包格式,其中使用了 128 位的 IP 地址。该结构比 IPv4 的简单得多,其首部只包含 8 个字段,需要特别注意的是其中没有分片相关的字段,这并不是因为 IPv6 中不需要分片,而是将这部分信息存储在一个拓展首部中了。next header 字段即指向该拓展首部。由于拓展首部的使用,IPv6 将很容易引入因特性。
ipv6-data
由于地址长度由 32 位增长到 128 位,IP 地址的标识方式发生了改变,如果继续沿用 10 进制表示法,那么需要超长的字符串。因而 IPv6 优先使用 16 进制表示法,FEDC:BA98:7654:3210:FEDC:BA98:7654:3210,同时混用 IPv6 和 IPv4 的情况也是允许的,0:0:0:0:0:FFFF:129.144.52.38。

在内核代码的处理流程上,上层协议不会感知到下层使用的是 IPv6 还是 IPv4,而下层协议上也不会有什么太大的变化,网络层的变化最明显,但是相较于 IPv4 来说结构变更并不多,尽管函数名不同了,但是代码的执行路径大致上是相同的。
ipv6-process

传输层

传输层有两大主要的协议,分别是 UDP(User Datagram Protocol) 和 TCP(Transmission Control Protocol),前者用于发送数据报,而后者可建立安全的面向于连接的服务。UDP 是一个简单地易于实现的协议,但 TCP 就复杂得多。

UDP

前面我们解释过,网络层根据 IP 数据包中的协议字段区分出不同的上层协议,当上层协议是 UDP 时,进一步地处理 UDP 数据报。在这一步中,UDP 数据报的处理函数拿到的数据也是一个套接字缓冲区,正常的处理过程如下:

  1. 确认数据报的正确性
  2. 查找与该数据报匹配的套接字对象,根据目标端口查找
  3. 如果存在匹配的套接字对象,将数据放在一个特定于套接字的等待队列上,并通知进程有新的数据到达,如果有进程在这个等待队列上睡眠等待,那么内核会唤醒所有在这上睡眠的进程
  4. 如果没有匹配的套接字对象,向数据报的发送方告知目标不可达

UDP 报文的数据结构如下图所示。源端口和目的端口分别指定了数据报的源系统和目的系统端口号,值域在 0 ~ 65535,因为二者都是 16 位的。长度是首部和数据的总长,接字节统计。
udp-data

TCP

TCP 的实现相较于 UDP 来说复杂得多,TCP 支持数据流的安全传输,而且是面向连接的通信模型,所以在内核中需要很多管理开销,我们这里只会简单地介绍 TCP 的实现概观,不会详细介绍 Linux 实现的每一个细节,因为如果每个细节都介绍的话,都够出一本书了。下面我们将着重介绍连接的建立,连接的断开,数据流的按需传输这三个部分。

在开始介绍之前,我们不妨先看一看 TCP 协议的一些标准。首先,一个 TCP 连接可能存在多种状态,比如 listen,established 等。对于一条 TCP 来说,它总会在这些状态之间不断的切换,就如下图所示。
tcp-status
乍一看会感觉很乱,不过没关系,我们这里只需要知道 TCP 是建立在一个有限状态机的模型上工作的,内核根据当前状态以及收到了数据包内容或者时间条件,在各种状态中进行转换。后面,我们会按照连接的各个处理部分分别介绍状态的转换过程,那时候大家就对状态的转换更加清晰。

TCP 首部

tcp-data
TCP 数据包的首部包含了状态数据和其他连接信息,其结构如上图所示。

  • source 和 dest 指定了所用的端口号。类似于 UDP,二者都是 2 字节。
  • seq 是个序列号。它指定了 TCP 数据包在数据流中的位置,在数据丢失需要重新传输时很重要。
  • ack_seq 也包含了一个序列号,在确认收到 TCP 数据包时使用。
  • doff 表示载荷部分偏移量(data offset)相当于指定了 TCP 首部结构的长度,由于一些选项是可变的,所以 TCP 首部的长度并不是定长的。
  • reserved 不可用(总是0)。
  • urg(紧急)、ack(确认)、psh(推)、rst(重置)、syn(同步) 和 fin 都是控制标志,描述了这个数据包在控制层面所扮演的角色。
  • window 告诉连接的发送方,接收方的缓冲区距离填满还差多少字节。这用于在快速的发送方和低速接收方通信时防止数据的积压。
  • checksum 是数据包的校验和。
  • options 是可变长度列表,包含了额外的连接选项。options 字段可能需要补齐,因为数据必须是 32 位对齐的,这样处理的更快。
  • 实际数据(或载荷)在首部之后。
接收数据包方式

TCP 所有的操作(连接的建立和关闭,数据传输)都是通过数据包的属性和标记来进行的,再开始介绍 TCP 的状态转移之间,我们先介绍一下刚收到数据包时的一些共通的部分。也就是从网络层根据协议类型递交到 TCP 处理函数,到 TCP 处理函数根据状态处理数据包之间的过程。

系统中的每个 TCP 套接字都归入下列 3 个散列表中的一个。

  1. 完全连接的套接字
  2. 等待连接(监听端口)的套接字
  3. 处理建立过程中的套接字

在对数据包进行各种检查并将首部中的信息复制到套接字缓冲区的控制块之后,内核会根据客户端以及服务端的 IP 地址,网络端口号等信息查找与该数据包对应的套接字实例。与 UDP 不同的是,找到对应的套接字实例后,并不意味着工作的结束,工作才刚刚开始。内核会根据当前该连接的状态,进行前面的状态转移。

三次握手

在可以使用 TCP 链路之前,必须在客户端和主机之间显式建立连接。如上所述,在主动(active)和被动(passive)连接的建立方式是有区别的。内核(即连接所涉及的两台机器的内核)在连接建立之前,客户端进程的套接字状态为 CLOSED,而服务器套接字的状态是 LISTEN。

建立 TCP 连接的过程需要交换 3 个 TCP 数据包的交互,因而称为三次握手(three-way handshake)。如下图所示,就是连接建立过程中会发生的操作。
tcp-handshake

  • 客户端通过向服务器发送 SYN(SYN 标志置为 1)包来发出连接请求。客户端的套接字状态由 CLOSED 变为 SYN-SENT。
  • 服务器在一个监听套接字上接收到连接请求,并返回 SYN + ACK 包(两个标志同时置为 1),服务器套接字的状态由 LISTEN 变为 SYN-RECEVED.
  • 客户端套接字接收到 SYN + ACK 组合包(同一个包内同时设置 SYN 和 ACK 标志位即可,不用发送两个独立的包)后,切换到 ESTABLISHED 状态,表示连接已经建立。然后发送一个 ACK 数据包服务器。
  • 服务器接收到 ACK 数据包后,也切换到 ESTABLISHED 状态。这就完成了两端的连接建立工作,可以开始数据交换。

建立连接使用的数据包不包含载荷部分,只需要 TCP 首部即可。
原则上,可以仅使用一个或两个数据包就能建立连接。但这里为什么使用 3 个数据包呢?我们考虑这样一种,客户端向服务端发送一个数据包,双方都建立连接,连接状态直接是 ESTABLISHED,看着好像没问题。但是,如果客户端发出数据包之后数据还没到达,客户端就关闭了,服务端创建套接字对象后,实际上已经不会有客户端再向这个套接字发送数据了,也就是说该套接字是不该存在的,而服务端却不知道。所以在服务端接收到一个连接请求后,需要重新向客户端机器确认一下“你是不是刚才想建立连接来着”,然后客户端回答“是有这么一回事”,然后客户端和服务端分别将套接字状态设为 ESTABLISHED。而且 3 次握手可以让双方都知道对方的 SEQ 初始化值,后续的数据是从这个 SEQ 开始的,因为数据包可能乱序到达,所以要靠它来确立顺序报文的基准点。

在连接建立后,TCP 链路的特点就显现出来了。每个数据包发送时都指定个序列号,而接收方的 TCP
协议实例在接收到数据包之后,都必须确认。我们以连接建立的过程给大家解释一下双方是如何通知对方包已经收到的:

1
2
3
1 192.168.0.143 192.168.1.10 TCP 1025 > 80 [SYN] Seq=2895263889 Ack=0
2 192.168.1.10 192.168.0.143 TCP 80 > 1025 [SYN,ACK] Seq=2882478813 Ack=2895263890
3 192.168.0.143 192.168.1.10 TCP 1025 > 80 [ACK] Seq=2895263890 Ack=2882478814

客户端会为发送的第一个数据包生成随机的序列号 2895263889,保存在在 TCP 首部的 SEQ 字段中。服务器在该数据包到达时,返回一个 SYN + ACK 的组合包(我们这里关注的是 SYN 和 ACK 字段的数字,而不是表明这个数据包是 SYN 包或者 ACK 包时使用的标志位),序列号服务端生成的一个随机序列号,而 ACK 字段的值是刚收到的客户端数据包 SEQ 的值 + 1。也就是说,在 TCP 协议中 ACK 的值传达出的一个信息是,对方 SEQ < ACK - 1 的所有数据包都已经收到了。

从客户端的视角来看的话,用户空间程序调用 open 库函数,发出 socketcall 系统调用到内核,然后内核产生 TCP 首部并将相关内容设置到套接字缓冲区中,之后将套接字的状态从 CLOSED 置为 SYN_SENT。接下来将一个 SYN 数据包发送到网络层,网络层再像前面所说的那样寻址发送到服务器端。此外,在内核中还会创建一个定时器,确保如果一定的时间内没有收到服务端的确认数据包时,自动重发 SYN 数据包。当然,如果重试的次数过多的话,连接建立会失败。

现在,客户端必须等待服务端对 SYN 数据包的确认以及确认连接请求的一个 SYN 数据包。当收到该数据包后,客户端会将连接状态置为 ESTABLISHED,并返回一个 ACK 数据包,完成连接的建立。

另外,有几个事情需要注意一下:

  1. 建立连接时 SYN 超时。如果 Server 接到了 Client 发的 SYN 后回了 SYN+ACK,但是这时候 Client 掉线了,Server 端没有收到 Client 返回的 ACK。对 Server 来说这个连接处于一个中间状态,即没成功,也没失败。所以在这种情况下,Server 端会重发 SYN+ACK。在 Linux 下,默认重试次数为 5 次,重试的间隔时间从 1s 开始每次都翻倍,重试时间间隔为1s,2s,4s,8s,16s,32s。所以,总共需要 1s + 2s + 4s+ 8s+ 16s + 32s = 63s,内核才会把断开这个连接。
  2. 由于上述的情况,就有人恶意的制造 SYN 报文攻击 Server,每发送完 SYN 报文就下线,但是服务器要 63 秒后才会释放对应的资源,这样攻击者就可以把服务器的 SYN 连接队列耗尽,让正常的连接请求不能处理。所以,Linux 下给了一个 tcp_syncookies 来对应这个问题,如果 SYN 队列满了,内核会根据原地址端口目标地址端口和时间戳生成一个特别的 SEQ 过去(也叫 Cookie),如果是攻击者则不会相应,如果是正常连接则会把这个 Cookie 发回来,然后服务端根据这个 Cookie 建立连接。但请注意别在大负载连接的情况下使用这个参数,因为 SYN Cookie 只是一个妥协版的 TCP,并不严谨。除此之外,还有别的参数可以使用,比如 tcp_synack_retries 可以减少重试次数,tcp_max_syn_backlog 可以增大 SYN 队列长度。tcp_abort_on_overflow 则表示处理不过来就直接拒绝。
  3. 为什么要用随机数初始化 SEQ。如果我们使用一个定值作为初始 SEQ,那么假设我们 Client 发送了一批数据包过去 5 - 10,然后 Client 网断了,于是 Client 重连 TCP,并发送初始 SEQ 的报文(假设 SEQ = 1),这时候服务端可能收到了 5-10 的报文,它所期待下一个报文 SEQ 是 11,这时候收到的初始 SEQ 的报文就会被丢弃。所以,初始序列号会和一个时钟绑定在一起,这个时钟 4 ms 对初始 SEQ 加一,这样下来一个 SEQ 的周期就是大约 4.55 小时。这时候除非上述问题发生时,碰巧初始 SEQ 循环到 0,否则就不会出现这个问题,这个几率已经很小了。
数据包传输

在按照上述方式建立一个连接之后,数据即可在计算机之间传输。但是因为 TCP 的特性,我们还有很多事需要解决,TCP 的这些特性如下:

  • 按次序传输字节流。
  • 通过自动化机制重传丢失的数据包。
  • 每个方向上的数据流都独立控制(客户端 -> 服务端,服务端 -> 客户端),并且数据的发送速度与对应主机的接收能力匹配。

尽管最初这些需求可能看起来并不复杂,但满足这些要求所需要的过程和技巧是相对较多的。因为大多数连接是基于 TCP 的,实现的速度和效率是很关键的,所以 Linux 内核借助了很多技巧和优化。在讲述如何通过已建立的连接来实现数据传输之前,必须讨论些底层的原理。我们先详细的解释一下 SEQ 和 ACK 的值是如何确立的。

就像连接建立时使用到的数据包序号一样,正常数据的传输也是通过 SYN 和 ACK 序号来进行的。但与上文提到的内容相比,正常数据传输使用到的序列号包含了更深层次的信息。序列号根据什么分配的呢?在建立连接时,生成一个随机数作为 SYN 的初始值。接下来使用一种系统化的方法来支持对所有接收到的数据包的严格确认。

在最初发送的序列号基础上,会为 TCP 传输的每个字节都分配一个唯一的序列号,例如,假定 TCP 系统的初始随机数是 100。因而,发送的前 16 个字节的序列号就是 100、101…115。

TCP 使用一种累积式确认(cumulative acknowlegment)方案。这意味着一次确认将覆盖一个连续的字节范围。每发送一个 ACK 数字都说明数据流的上一个 ACK 数和当前 ACK 数之间的所有字节均已收到(发送第一个 ACK 时的含义是从 SEQ 起点到该 ACK 值之间的所有数据均已收到)。因为最后一个收到的字节的索引值比 ACK 数小 1,因而 ACK 数也表示了下一个要发送的字节的索引号。例如,ACK = 166 说明字节索引在 166 之前的所有字节均已收到,预期下个分组中的字节索引从 166 开始。

该机制同样也用于跟踪丢失的数据包。但请注意,TCP 没有提供显式的重传请求机制。换句话说,接收方不能请求发送方重发丢失的数据包。而处理这个问题的主动权在发送方这里,如果在一定的超时时间内发送方没有收到确认(ACK),则重新发送丢失的部分。

接下来我们将完整的介绍数据包的传输过程,这里我们假设连接双方的套接字状态已经处于 ESTABLISHED 状态。先说说数据接收方要处理的工作。

  1. 当通过散列表找到套接字实例并确认套接字状态为 ESTABLISHED 之后
  2. 根据该数据包的情况作出如下处理:
    • 数据包包含 ACK 标志:说明对方收到了自己发送的数据,这里涉及的操作是不仅包括了分析对方接收能力,还包括从自己的重发队列中删除一部分内容(因为如果一定时间没有收到 ACK 则需要重传),我们后面再详细介绍。
    • 数据包包含 PSH 标志:
      • 如果数据包的 SEQ 不等于上次自己发送的 ACK 的值:
        • 如果 SEQ < my_last_ack:说明是冗余包,则直接丢弃
        • 如果 SEQ > my_last_ack:说明是乱序包,将其放置在一个队列上,直到形成一个连续的数据段时才会处理
      • 如果数据包的 SEQ 等于上次自己发送的 ACK 的值,则说明这份数据是按序到达的,那么该数据将立即处理,同时也会确认等待队列中是否和该包构成了一个连续的数据段,如果是的话一同处理
  3. 将接收到的数据递交到套接字层,如果有在套接字上等待数据的进程,则唤醒该进程

而对于发送方来说,它的处理过程是这样的。

  1. 首先确认套接字的状态是 ESTABLISHED,如果不是这样则等待到连接建立完成再处理
  2. 将数据从用户空间进程的地址空间复制到内核空间,用于建立一个 TCP 数据包
  3. 在发送 TCP 数据包时,并不仅仅是构建一个首部转入网络层,它还必须进行如下处理
    • 确认接收方等待队列上必须有足够的空间可用于存放该数据,根据近期收到的对方数据包首部的 window 字段可以得出对方的接收能力,如果对方接收能力不足,则暂缓发送过程进入队列等待
    • 必须实现防止连接拥塞的 ECN 机制:通常来说,TCP/IP 网络通过丢弃数据包来表明信道阻塞。在 ECN 成功协商的情况下,ECN 感知路由器可以在 IP 头中设置一个标记来代替丢弃数据包,以表明阻塞即将发生。数据包的接收端回应发送端的表示,降低其传输速率,就如同在往常中检测到包丢失那样。
    • 检测某一方出现网络断开的情况,以免通信出现停顿。首先想到的是 TCP 中的 KeepAlive 机制。KeepAlive 并不是 TCP 协议的一部分,但是大多数操作系统都实现了这个机制(所以需要在操作系统层面设置 KeepAlive 的相关参数)。KeepAlive 机制开启后,在一定时间内(一般时间为 7200s,参数 tcp_keepalive_time)在链路上没有数据传送的情况下,TCP 层将发送相应的 KeepAlive 探针以确定连接可用性,探测失败后重试 10(参数 tcp_keepalive_probes)次,每次间隔时间 75s(参数 tcp_keepalive_intvl),所有探测失败后,才认为当前连接已经不可用。
    • TCP 慢启动(slow-start)机制要求在通信开始时,逐渐增大数据包长度。当主机开始发送数据时,如果立即将大量数据字节注入到网络,那么就有可能引起网络拥塞,因为现在并不清楚网络的负荷情况。因此,较好的方法是先探测一下,即由小到大逐渐增大发送数据的尺度,也就是说,由小到大逐渐增大拥塞窗口数值(后面介绍拥塞窗口)。
    • 发送但未得到确认的数据,必须在一定的超时时间间隔之后反复重传,直至接收方最终确认,这个机制是通过滑动窗口方案实现的。
  4. 如果确认需要发送某一数据包,则将数据转发到网络层

这里我们注重介绍一下滑动窗口机制,因为它是 TCP 数据发送过程中最核心的部分。

滑动窗口

就像前面所说的,如果 TCP 连接的双方不知道对方的接收能力,就可能会发送过多的数据包而引起网络拥塞,导致丢包,传输的效率就会变差。

所以,TCP 引入了一些技术和设计来做网络流控,活动窗口是其中一个技术。前面我们说过,TCP 首部里有一个字段叫 Window,这个字段是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,这样就不会导致接收端处理不过来。下面我们先看一下 TCP 缓冲区的一些数据结构。
socket-buffer-example
接收端程序的 LastByteRead 指向了用户进程在缓冲区中读到的位置,NextByteExpected 指向了期待的下一个数据的字节编号,而 LastByteRcved 指向了收到的最后一个包的最后一个字节的序号(乱序情况)。而发送端的 LastByteAcked 指向了收到的最大一个 ACK 所标识的位置(表示对方成功接收)。LastByteAcked -> LastByteSent 的数据表示发送出去但是没收到 ACK 的数据。LastByteWritten 表示上层应用进程正在写的位置。

综上,接收端会在给发送端返回的 ACK 报文中汇报自己的 Window = MaxReceivBuffer - LastByteRcvd - 1。而发送方会根据这个窗口控制发送数据的大小,以确保对方可以处理。

下图是滑动窗口的示意图:
tcp-slide-window
上图中,有四个部分,它们分别是:

  1. 已收到 ACK 确认的数据
  2. 发出但是没收到 ACK 的数据
  3. 在窗口中还没发送的数据(接收方容得下这部分数据)
  4. 还没准备要发送的数据(接收方容不下这部分数据)

在收到 ACK = 36 时滑动的效果如下所示:
slide-window-after-received.png
然后我们看一下连接双方数据包交换的效果:
tcp-slide-window-exchange-packet
上图展示了一个处理能力很差 Server 在接收 Client 数据的情况,最终窗口的大小缩小到 0,这时候 Client 就不会再发送数据了,那么 Client 什么时候才能再感知到对端能够开始处理数据了呢?为了解决这个问题,Client 会发送一些不包含数据的 ZWP(Zero Window Probe)包,Server 在收到这类包时会通过 ACK 包来告知 Client 当前 Window 的大小。Client 发送 ZWP 包的间隔时间是 30s,如果 连续 3 次发送返回的 ACK 都是 0 的话,有些 TCP 实现中会直接断开连接。

另外,你需要知道网络上有个 MTU,对于以太网来说,MTU 是 1500 字节,除去 TCP+IP 头的 40 个字节,真正的数据传输可以有 1460,这就是所谓的 MSS(Max Segment Size)注意,TCP 的 RFC 定义这个 MSS 的默认值是 536,这是因为 RFC 791 里说了任何一个 IP 设备都得最少接收 576 尺寸的大小(实际上 576 是拨号的网络的 MTU,而 576 减去 IP 头的 20 个字节就是 536)。如果你的网络包可以塞满 MTU,那么你可以用满整个带宽,如果不能,那么你就会浪费带宽。

除此之外,如果 Server 处理不过来这么多数据,那么就会导致 window 越来越小,最后只剩下几个字节,但是 TCP + IP 的头部就有 40 个字节,为了这几个字节用了 40 个字节的头部开销就很浪费。为了解决这个问题,Client 和 Server 端都有解决方案:

  • 对于 Server 端,如果 window 太小,就直接返回 window = 0,等 Window 超过一定大小时(MSS),才返回真正的 Window。
  • 对于 Client 端,如果 window 大于 MSS 时才开始发送数据包。

由于上述方案是默认打开的,所以对于一些只发小包的程序,比如 ssh 这样的交互式程序,你需要显示的关闭这个策略(通过套接字的 TCP_NODELAY 参数)。

前面的介绍中我们都是假设数据包正确到达,如果对端迟迟不发送 ACK 过来,说明数据包可能丢了,这时候可能会重发数据包,但是重发数据包又会造成网络负担的更一步加重,会导致更大的延迟和丢包,于是就会陷入恶性循环,这也被称为“网络风暴”。为了解决这个问题,TCP 使用了一套拥塞控制机制。它分为 4 个算法:慢启动,拥塞避免,拥塞发生,快速恢复。注意:这里要解决的问题已经不是连接双方的接收队列的情况了,而是数据包在中间传输的过程。

拥塞控制

首先,我们看一下慢启动,它的思想是,刚加入网络时,慢慢的提速,算法如下:

  1. 连接建好时拥塞窗口(cwnd,Congestion Window)为 1,不过现在网速已经很快了,所以 Linux 3.0 后 cwnd 的初始值是 10,表明一次发送可以从滑动窗口中发送一个 MSS 大小的数据。
  2. 每当收到一个 ACK,cwnd++,呈线性增长
  3. 每当过了一个 RTT(Round Trip Time,也就是一个数据包从发出去到回来的时间),cwnd *= 2,呈指数增长
  4. 还有一个 ssthresh(slow start threshold),是一个上限,当 cwnd >= ssthresh 时,进入拥塞避免算法

所以,当网速很快时,ACK 返回也会很快,RTT 也很短,那么启动过程实际上会很快。
slow-start-cwnd
那么 ssthresh 是多少呢? 它的默认值是 65535 字节,当 cwnd 达到这个大小后,cwnd 的算法会发生如下变化:

  1. 每当收到一个 ACK,cwnd = cwnd + 1 / cwnd,也就意味着当同一时刻发送的所有数据包都 ACK 后才会 cwnd + 1
  2. 每当过了一个 RTT,cwnd += 1

这样就可以避免增长过快导致网络拥塞,慢慢的增加调整到网络的最佳值。很明显,是一个线性上升的算法。

接下来我们谈谈丢包的判定,内核会在两种情况下认为一个数据包丢了:

  1. 超过超时时间 RTO(Retransmission TimeOut,它主要根据 RTT 计算,一般比 RTT 大),内核认为这种情况比较严重,所以处理对策也很强烈
    • sshthresh = cwnd / 2
    • cwnd 重置为 1
    • 进入慢启动过程
  2. 连续收到 3 个同样的 ACK 值,说明对应的数据包丢了,这时候不用等到 RTO,这时候可能采用两种算法中的一个
    • TCP Tahoe 的实现和RTO超时一样。
    • TCP Reno 的实现是:
      • cwnd = cwnd /2
      • sshthresh = cwnd
      • 进入快速恢复算法——Fast Recovery

因为连续收到三个相同的 ACK 说明从 ACK 指定的序号之后有三个数据包被收到了,但是我们不知道是哪三个,因为可能在 ACK 之后的包可能有 7 8个或者更多。所以,Fast Recovery 的方案是:

  1. cwnd = sshthresh + 3 * MSS
  2. 重传从 Duplicated ACK 指定的包
  3. 如果在收到重复的 ACK,则 cwnd += 1
  4. 如果收到新的 ACK,那么 cwnd = sshthresh,然后就进入了拥塞避免算法

如果你仔细思考一下上面的这个算法,你就会知道,上面这个算法也有问题,那就是它依赖于 3 个重复的 Acks。注意,3 个重复的 Acks 并不代表只丢了一个数据包,很有可能是丢了好多包。但这个算法只会重传一个,而剩下的那些包只能等到 RTO 超时,于是,超时一个窗口就减半一下,多个超时会造成 TCP 的传输速度呈级数下降,而且也不会触发 Fast Recovery 算法了。

于是,1995年,TCP New Reno 算法提出来,主要就是在没有对端告知丢失的包是哪个的前提下改进Fast Recovery算法。

当 sender 这边收到了 3 个 Duplicated Acks,进入 Fast Retransimit 模式,开发重传重复 Acks 指示的那个包。如果只有这一个包丢了,那么,重传这个包后回来的 Ack 会把整个已经被 sender 传输出去的数据 ack 回来。如果没有的话,说明有多个包丢了。我们叫这个 ACK 为 Partial ACK。

一旦 Sender 这边发现了 Partial ACK 出现,那么,sender 就可以推理出来有多个包被丢了,于是乎继续重传 sliding window 里未被 ack 的第一个包。直到再也收不到了 Partial Ack,才真正结束 Fast Recovery 这个过程

我们可以看到,这个“Fast Recovery的变更”是一个非常激进的玩法,他同时延长了 Fast Retransmit 和 Fast Recovery 的过程。

下图中简单地展示了上面各种算法的样子:
cwnd-congestion

连接终止

类似于连接建立,TCP 连接的关闭也是可以通过一系列数据包交换完成的,但是并不总是以这种方式进行,比如程序崩溃等。总结来说,TCP 连接断开的方式有如下两种:

  1. 在参与传输的某一方(偶尔也会两个系统同时发出请求的情况)显式请求关闭连接时,连接会以优雅关闭(graceful close)的方式终止。
  2. 高层协议有可能导致连接终止或异常中止(例如,可能因为程序崩溃)。

总的来说,第一种情况通常更为常见,这里只讨论这种情况并忽略第二种情况,因为第二种情况只要对端提供连接检查机制,比如前面所说的 KeepAlive 机制,就能有效的清除失效的套接字。

为了优雅地关闭连接,TCP 连接的参与方必须交换 4 个数据包。各个步骤的顺序描述如下。
tcp-close

  1. Client 调用标准库函数 close,发出一个 TCP 数据包,首部中的 FIN 标志置位。发起者的连接切换到 FIN_WAIT_1 状态,这表明 Client 已经不想在这个 TCP 连接上发送数据了。
  2. Server 收到 FIN 数据包并返回一个 ACK 数据包(ACK = SEQ + 1)。其套接字状态从 ESTABLISHED 改变为 CLOSE_WAIT,同时内核还会以“文件结束”的方式通知使用套接字的进程,并等待进程决定断开连接,一般来说进程都会在这种情况下立即断开。
  3. Client 在收到 ACK 数据包之后,Client 的套接字状态从 FIN_WAIT_1变为 FIN_WAIT_2,这里不直接关闭是因为可能 Server 还有没发完的数据,要等服务端发完数据并且确定要关闭时 Client 才能真正断开。
  4. 得到内核的通知后,Server 上与对应套接字相关的应用程序也决定执行 close,这时候 Server 向 Client 发送 FIN 数据包。Server 的套接字状态变为 LAST_ACK。这里为啥还要发 FIN 给 Client 呢?就像前面所说的,Server 可能还有数据要发给 Client,如果确实如此的话,通过一个 FIN 报文可以告知 Client,我也没数据要发送了,你可以关闭连接了。
  5. Client 收到来自 Server 的 FIN 数据包之后,会用一个 ACK 数据包确认 Server 发送的 FIN,这里如果不发送 ACK 给 Server 的话,Server 可能会以为自己发送的 FIN Client 没有收到,这样就会重发 FIN 包。然后 Client 首先进入 TIME_WAIT 状态,接下来在一定时间后自动切换到 CLOSED 状态,这个时间的长度是 2 * MSL,两倍的报文最大存活时间,RFC793 定义了 MSL 为 2 分钟,Linux 设置成了 30s。Client 最后为什么要等待 2MSL 的时间才将套接的状态置为 CLOSED 呢?第一,我们必须要假想网络是不可靠的,你无法保证你最后发送的 ACK 报文会一定被对方收到,因此对方处于 LAST_ACK 状态下的 SOCKET 可能会因为超时未收到 ACK 报文,而重发 FIN 报文,所以这个 TIME_WAIT 状态的作用就是用来重发可能丢失的 ACK 报文。第二,是为了防止两个不同连接的报文混淆,因为当 Client 套接字断开后,网络端口就会被释放,那么其他进程就能使用这个端口,如果其他进程也用这个端口给同一个 Server 同一端口发送数据的话,新套接字的 SYN 报文可能比失效套接字的最后一个 ACK 报文先传输到 Server,这时候 Server 就会搞不清楚状况。所以这里用 2*MSL 作为缓冲时间。
  6. Server 收到 ACK 数据包之后,其套接字也切换到 CLOSED 状态。

应用层

套接字将 UNIX 隐喻“万物皆文件”应用到了网络连接上。在建立连接后,用户空间进程使用普通文件操作来访问套接字。这在内核是怎么实现的呢?由于 VFS 层的开放结构,我们只需很少的工作就能做到这一点。回想一下前面介绍的虚拟文件系统 VFS inode,每个套接字都会分配一个该类型的 inode,inode 中又包含了所有操作该文件的函数指针。内核只需要为这些文件操作函数指向套接字的下层操作函数就能轻松的以文件接口的形式访问套接字接口,这很类似于 VFS 向下兼容各种文件系统的思想。

但是,并不是所有的套接字接口都能够被 VFS 接口所兼容,对于那些无法兼容的接口,实现在 C 标准库中,使用了 socketcall 系统调用。socketcall 充当一个多路分解器,将各种任务分配中不同的函数执行,例如创建一个套接字、绑定或发送数据,它们都是通过这唯一的一个系统调用实现的。大家可能会好奇,这么多函数用一个系统调用,怎么做到的呢?它们处理的任务各不相同,参数列表可能差别很大。系统调用的第一个参数是一个数值常数,表明所要用的套接字接口是什么。例如,SYS_COCKET、SYS_BIND、SYS_ACCEPT 等。标准库的套接字接口名基本和这些常数是一一对应的,但在内部都重定向为使用 socketcall 系统调用和对应的常数。这个系统调用最大接收 6 个参数,首先它会将参数都从用户空间拷贝到内核空间,然后根据所要使用的套接字接口的不同,使用不同数量的参数。

当用户进程想要创建套接字时,会通过系统调用创建对应的套接字实例,内核会为该套接字创建一个伪文件,还要分配一个文件描述符,将其作为系统调用的结果返回。

当要接收数据时,用户进程需要在系统调用中指定套接字对应的文件描述符,内核会根据文件描述符找到对应的文件,然后根据文件再找到与之对应的 inode,最后通过 inode 找到对应的套接字对象。然后调用套接字对象中的 recvmsg 方法,其中会根据套接字的类型,执行到 TCP 或者 UDP 的对应方法,对于 UDP 来说接下来的处理过程如下。

  1. 如果接收队列上至少有一个数据包,则移除并返回给用户进程。
  2. 如果接收队列是空的,显然没有数据可以返回给用户进程,进程可以使用的 wait_for_packet 是自己睡眠,直到数据到达。
  3. 当新数据到达时,会唤醒所有在此套接字上睡眠的进程。
  4. 最后别忘了,当数据从内核中返回给用户进程时,是要将数据从内核空间拷贝到用户空间的。

TCP 的处理过程基本也遵循上述模式,但是其中涉及到很多协议的细节,所以相对复杂一点,这里我们就不更深一步的介绍了。

当用户进程想要发送数据时,即可以使用与网络相关的库函数 send 等,也可以使用的文件接口中的 write 函数等。这些函数在内核中最终会执行到同一个处理函数(条条大路通罗马)。内核会将用户数据从用户地址空间中拷贝到内核空间,然后调用特定协议的发送函数,产生所需协议格式的数据包,最后转发给下层协议。

参考内容

[1]《Linux内核设计与实现》
[2]《Linux系统编程》
[3]《深入理解Linux内核》
[4]《深入Linux内核架构》
[5] Linux 内核进程管理之进程ID
[6] 服务器三大体系SMP、NUMA、MPP介绍
[7] Linux中的物理内存管理 [一]
[8] Linux内核中的page migration和compaction机制简介
[9] 物理地址、虚拟地址(线性地址)、逻辑地址以及MMU的知识
[10] 逻辑地址
[11] linux内核学习笔记-struct vm_area_struct
[12] Linux中匿名页的反向映射
[13] 系统调用过程详解
[14] 再谈Linux内核中的RCU机制
[15] Unix domain socket 和 TCP/IP socket 的区别
[16] Linux通用块设备层
[17] ext2文件系统结构分析
[18] linux ACL权限规划:getfacl,setfacl使用
[18] 查找——图文翔解RadixTree(基数树)
[19] 页缓存page cache和地址空间address_space
[20] rocketmq使用的系统参数(dirty_background_ration dirty_ratio)
[21] Linux内存调节之zone watermark
[22] Linux的内存回收和交换
[23] Linux中的内存回收[一]
[24] linux内存源码分析 - 内存回收(整体流程)
[25] Linux 软中断机制分析
[26] 对 jiffies 溢出、回绕及 time_after 宏的理解
[27] learn-linux-network-namespace
[28] 显式拥塞通知
[29] 聊聊 TCP 长连接和心跳那些事
[30] 关于 TCP/IP,必知必会的十个问题
[31] TCP协议三次握手连接四次握手断开和DOS攻击
[32] TCP 的那些事儿(上)
[33] TCP 的那些事儿(下)

贝克街的流浪猫 wechat
您的打赏将鼓励我继续分享!
  • 本文作者: 贝克街的流浪猫
  • 本文链接: https://www.beikejiedeliulangmao.top/linux/network/
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 创作声明: 本文基于上述所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。