0%

CloudWeGo-Netpoll ,以下简称为 Netpoll,是由字节跳动开发的一款专注于 RPC 场景的高性能 NIO 网络库。大大多基于多路复用的网络库,基本框架都比较类似。但不同的网络库会针对不同的应用场景对基本框架进行修改,从而使其在固定场景下发挥出更好的性能。本文着重分析 Netpoll 与其他网络库不同的设计,以及这些设计是如何满足 RPC 场景的。

Netpoll 场景

RPC 场景,在我的理解中,主要可以由以下几个特点来概括:

  • 较重的处理逻辑,事务处理中可能会有较长时间的阻塞;
  • 通常采用短连接或者长连接池的形式;
  • 具有超时机制,可能会产生较多的失效连接。

在这种场景下,使用 go 标准网络库开发服务端比较方便,但无法达到较高的性能,这主要来源于 goroutine 的调度开销上。在微服务场景下,服务器之间的交互非常频繁,服务器 A 到服务器 B 之间可能会需要多条 rpc 逻辑连接,如果为这些逻辑连接全部开辟出一条物理连接,会对服务端和客户端都造成比较大的压力。通常,rpc 框架会选择使用多路复用的方式,避免开辟过多的物理连接。但又因为 rpc 依赖链路这种情景,串行处理 rpc 是性能非常低下的。当使用标准网络库时,虽然 rpc 的解析在阻塞 IO 下也可以实现多路复用,但是 rpc 的处理逻辑必须要开辟一个新的 goroutine 来防止队首阻塞。这是因为 rpc 可能是具有依赖链路的。通过分析,我们可以发现,使用 go 标准网络库进行开发,虽然可以避免开辟过多 goroutine 用于解析,但是仍然需要为每一条业务逻辑开辟出一个 goroutine。当 goroutine 过多时,调度器的压力会比较大,造成较大的延迟。

而 go 语言的一些其他网络库如 gnet,底层会使用 ring_buffer 作为缓冲区(新版本中也可以作为 linked_list 作为缓冲区)来获取更高的性能。由于 ring_buffer 中内存地址是会被复用的,并且生命周期难以被管理,如果应用层的业务逻辑没有阻塞的情况下,可以直接在读事件中的 callback 中完成事件处理(如 redis 的 500us 左右的纯内存操作,HAProxy 的转发操作),那么这种网络框架性能是非常高的,核心点就是不需要分配内存。但是在 rpc 场景下,业务的处理逻辑非常重,仍然需要分配内存进行拷贝,防止 ring_buffer 被覆盖,这种框架的优势就没有那么明显了。(当然,可以在业务层自行实现内存池来解决这一问题,不过这相当于把问题抛给了用户)

综上所述,Netpoll 所要做的,核心有两点:

  • 实现非阻塞读写,避免开辟过多 goroutine;
  • 实现生命周期可调控的内存复用,避免分配内存。

高性能网络库“三板斧”

各种实现的 Reactor 模式,其高性能主要来自于避免了各种耗时操作,如内存分配、线程开辟、互斥量竞争,几乎所有的高性能网络库都做了以下几点优化:

  • Multi-Reactors/Master-Workers 模型,避免 epoll 惊群效应;
  • EventLoop 模型的高效运行机制;
  • 高效的内存管理机制,尽量避免内存分配;
  • 高性能线程/goroutine池,避免用户手动开辟处理异步逻辑。

这几点在 Netpoll 中都有涉及,其中内存管理是借助 sync.Pool 和 link_buffer 实现的;高性能协程池则是使用了自家的 gopool

Reactor 模式实现

Netpoll 中使用的是主从 Reactor 模式,能够有效地避免 epoll 惊群效应。主从 Reactor 模式实现上的一个细节问题是如何进行 fd 的传递,即系统调用 accept 获得的 fd 如何注册到 Worker Reactor 的 epoll 上。在 muduo 和 gnet 的实现中,都是以队列的形式进行传递;而在 Netpoll 实现中,则是直接在 Master Reactor 线程使用 epoll 系统调用将新到达连接的 fd 注册到 Worker Reactor 上。

Netpoll 中处理新连接到达的代码比较分散,这里简述一下调用链:server.OnRead -> connection.init -> connection.onPrepare -> connection.register -> FDOperator.Control -> poll.Control

上述调用链是发生在 Master Reactor 线程中的,这是利用了 epoll 线程安全的特性,当使用 epoll 相关系统调用时会使用自旋锁来保证红黑树结构的线程安全。poll.Control函数会根据输入参数使用不同的系统调用来维护 epoll 的注册表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Control implements Poll.
func (p *defaultPoll) Control(operator *FDOperator, event PollEvent) error {
var op int
var evt epollevent
*(**FDOperator)(unsafe.Pointer(&evt.data)) = operator
switch event {
case PollReadable: // server accept a new connection and wait read
operator.inuse()
op, evt.events = syscall.EPOLL_CTL_ADD, syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollWritable: // client create a new connection and wait connect finished
operator.inuse()
op, evt.events = syscall.EPOLL_CTL_ADD, EPOLLET|syscall.EPOLLOUT|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollModReadable: // client wait read/write
op, evt.events = syscall.EPOLL_CTL_MOD, syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollDetach: // deregister
op, evt.events = syscall.EPOLL_CTL_DEL, syscall.EPOLLIN|syscall.EPOLLOUT|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollR2RW: // connection wait read/write
op, evt.events = syscall.EPOLL_CTL_MOD, syscall.EPOLLIN|syscall.EPOLLOUT|syscall.EPOLLRDHUP|syscall.EPOLLERR
case PollRW2R: // connection wait read
op, evt.events = syscall.EPOLL_CTL_MOD, syscall.EPOLLIN|syscall.EPOLLRDHUP|syscall.EPOLLERR
}
return EpollCtl(p.fd, op, operator.FD, &evt)
}

当新连接到达时,FDOperator.event == PollReadable 或者 FDOperator.event == PollModReadable,因此该函数会将新到达的 fd 注册到对应的 poll 结构体上。

Nocopy Buffer

Nocopy Buffer 是 Netpoll 设计的核心内容,连接多路复用、ZeroCopy 优化都是基于 Nocopy Buffer 结构的。

linkBuffer 数据结构

Nocopy Buffer 本质上是一个基于链表的无锁读写结构,链表的节点是linkBufferNode数据结构。

1
2
3
4
5
6
7
8
9
type linkBufferNode struct {
buf []byte // buffer
off int // read-offset
malloc int // write-offset
refer int32 // reference count
readonly bool // read-only node
origin *linkBufferNode // the root node of the extends
next *linkBufferNode // the next node of the linked buffer
}

linkBufferNode本质上是一个可引用、具有读写标识位的缓冲区,由于单独对读标识位和写标识位操作是可以并发的,所以其是一个单读写可并发的无锁结构。

linkBuffer则是linkBufferNode组成的链表,其数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
type LinkBuffer struct {
length int64 // 可读长度
mallocSize int // 可写长度

head *linkBufferNode // 链表头部
read *linkBufferNode // 读取位置
flush *linkBufferNode // 写入提交位置
write *linkBufferNode // 链表尾部

caches [][]byte // 从内存池中获取的内存,用于跨 node 读取
}

linkBuffer中使用四个标识位来描述当前的读写状态。head 指向链表的头部,head 与 read 节点之间是可以被释放的节点,read 至 flush 节点是当前可读的区域,flush 至 write 节点是当前可以写入的区域。

/images/link_buffer.png

flush 节点指向的 node 是渐变的,代表其中一部分区域是可读的,一部分区域是不可读的。因为同一个节点可能会被同时写入和读取。虽然图中未指出,但是 head 与 read 节点也可能重合,可以被安全释放的范围是 [head,read) 。

Nocopy 体现在哪

linkBuffer 具有 nocopy 特性,但并非所有的接口都是 nocopy 的。

linkBuffer 的所有读接口的处理逻辑都是类似的,这些读取操作是否为 nocopy 取决于读取的位置。以 linkBuffer.Next函数为例,分析什么情况读取是不需要拷贝的,代码只保留了用于分析的部分:

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
func (b *LinkBuffer) Next(n int) (p []byte, err error) {

// 检查长度并移动更新 length
...

// 读取长度小于一个节点
if b.isSingleNode(n) {
return b.read.Next(n), nil
}

// 大于 1k 的内存块进行内存池管理,避免分配大内存
if block1k < n && n <= mallocMax {
p = malloc(n, n)
b.caches = append(b.caches, p)
} else {
p = make([]byte, n)
}

var pIdx,l int
// 循环读取不同节点,直到满足需求
for ack := n; ack > 0; ack = ack - l {
l = b.read.Len()
if l >= ack {
pIdx += copy(p[pIdx:], b.read.Next(ack))
break
} else if l > 0 {
pIdx += copy(p[pIdx:], b.read.Next(l))
}
b.read = b.read.next
}

_ = pIdx
return p, nil
}

linkBuffer.Next函数中的读取一共具有两种情况。第一种情况是读取的长度小于 read node 的剩余可读取长度,在这种情况下并没有使用 copy 操作,而是复制了地址,这种情况下是 nocopy 的;另外一种情况是读取的长度大于 read node 的剩余可读取长度,这时候需要将分散在各个节点的数据拷贝到一起,如果要求的数据过长,甚至可能会发生多次拷贝。为了避免出现多次拷贝的情况,应该设置linkBufferNode.buf的长度大于用户需要读取数据的平均长度。

另外值得注意的是,函数中还会根据用户要求的长度来决定不同的内存分配策略。当用户需要的长度过大时,会考虑从内存池中获取一块内存,并将这块内存保存在 caches 数组中,再选择合适的时机将内存归还内存池。

linkBuffer 的写接口只有一部分是 nocopy 的,即linkBuffer.Malloc函数。

1
2
3
4
5
6
7
8
9
10
11
func (b *LinkBuffer) Malloc(n int) (buf []byte, err error) {
if n <= 0 {
return
}
b.mallocSize += n

// 寻找写入位置,若无位置,则创建新节点
b.growth(n)
// 更新标志位
return b.write.Malloc(n), nil
}

函数的逻辑也非常简单,就是在链表中找到一个连续的内存区域,然后将该内存区域返回给用户。如果所有的内存区域都不足以写入,那么将分配一个新的节点,该节点大小正好为用户要求的区域。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (b *LinkBuffer) growth(n int) {
if n <= 0 {
return
}
// 跳过只读节点,并且要求剩余可写入空间充足
for b.write.readonly || cap(b.write.buf)-b.write.malloc < n {
// 寻找至最后一个节点后,分配新节点
if b.write.next == nil {
// 新节点的大小正好为用户需求大小
b.write.next = newLinkBufferNode(n)
b.write = b.write.next
return
}
b.write = b.write.next
}
}

linkBuffer.growth函数很好地封装了分配新节点和寻找内存区域的功能,这个函数还是比较好的。注意到,函数中有一段逻辑是跳过了所有的只读节点,那么什么情况下会出现只读节点呢?linkBuffer 中只读节点只来源于用户的接口函数linkBuffer.WriteDirect。该函数可能会导致用户方的乱序写入,因此可能会出现下图中的情况。

/images/link_buffer_read_only_area

用户在获取内存后,首先写入了 node2 中的部分,而 node1 中虽然仍有空白内存,但该内存已经被用户 hold。此时,如果用户又调用了一次写入操作,必须将数据写入到“未写入部分”,因为“待写入部分”后续会被其他写操作覆盖。linkBuffer 实现中的做法是直接将 node1 设置未 readonly 状态,这里的 readonly 并不是完全禁止写入,而是禁止再从该节点上获取位置进行写入,已经获取位置的区域仍然是可以写入的。

为什么是无锁的

linkBuffer 可以用作读缓冲或写缓冲,由于每一个 connection 都会被分配到一个 poller 上,在读缓冲区时,poller 底层只会对 poller 进行写入(接收数据),在写缓冲时,只会对 poller 进行读取(发送数据)。所以无论哪种情况下,Netpoller 都保证框架对缓冲区的读写是单线程的,由于读与写操作使用了不同的标志位,因此只要用户可以保证也使用单线程进行读写,就能够保证 linkBuffer 的无锁并发访问。

连接多路复用

连接多路复用这个概念是存在于客户端的,服务端中不需要连接多路复用。Netpoll 在客户端实现连接多路复用的基础是非阻塞 IO,而 linkBuffer 则是实现高性能的多路复用手段。同时,由于连接多路复用是协议依赖的,NetPoll 只是提供了多路复用的支持,并在官方 blog 中给出了可行的方案。

/images/link_buffer_read_only_area

连接多路复用方案包含以下几个要素:

  • Virtual Connection:建立在真实连接之上的虚拟连接,具有一个 uuid 用于区分;
  • Shared Map:根据 uuid-virtual conn 的方式来存储虚拟连接;
  • Dispatcher:用于读取并解析数据包,根据数据包中的 id 选择对应的 Virtual Connection;
  • Rpc Protocol:一个支持多路复用的通信协议。

方案中,一个真实连接能够承载多个虚拟连接。这些虚拟连接都通过一个分发器来间接与读写缓冲区交互,虽然多个虚拟连接可能运行在不同的 goroutine 中,但是读写操作最终只能够由 Dispatcher 来处理,因此 linkBuffer 之上依然是一读或一写,能够保持无锁并发的特性。Dispatcher 在读取数据后,可以不拷贝数据,而是直接返回对应的切片位置,不同虚拟客户端之间操作不同的切片位置,仍然能够保证无锁并发。

ZeroCopy

Netpoll 目前并不提供 zero copy 的支持。如果要使用 zero copy 的系统调用,就必须要保证需要发送的数据在被内核拷贝掉网卡没有被释放掉。这对于 Netpoll 的框架来说会比较麻烦,因为 linkBuffer 每一次在进行写入时,会根据写入的字节长度来对内存区域进行释放。被释放的内存会进入可 GC 状态或进入内存池,这两种状态下都不能够保证内存的存活周期。如果想要在解决这个问题,可能需要大幅度修改代码框架。

NetPoll 的官方博客中有这样一段介绍,我不太能够理解:

于是,字节跳动框架组和字节跳动内核组合作,由内核组提供了同步的接口:当调用 sendmsg 的时候,内核会监听并拦截内核原先给业务的回调,并且在回调完成后才会让 sendmsg 返回。 这使得我们无需更改原有模型,可以很方便地接入 ZeroCopy send。同时,字节跳动内核组还实现了基于 unix domain socket 的 ZeroCopy,可以使得业务进程与 Mesh sidecar 之间的通信也达到零拷贝。

这样是让 sendmsg阻塞直到将内核将内存写入,如果在写入速度比较频繁的情况下,这样会不会导致写入操作被阻塞过长时间。

之前学习了很多与计算机网络相关的内容,虽然建立起了比较系统的概念,但是这些概念仅仅停留在模型上;而操作系统中究竟是如何对各种协议进行处理的仍然不是特别清楚。本文主要梳理 linux 内核网络模型的概念,并在此基础上探究一些常用的网络工具是如何实现的,实现在 linux 协议栈的哪个位置。

linux 内核网络模型

从功能上,linux 内核可以划分为五个部分,分别是:

  • 进程管理:主要负责 CPU 的访问,进行 CPU 调度;
  • 内存管理:主要负责控制内存的访问;
  • 文件系统:主要负责组织文件系统,实现文件操作;
  • 设备管理:主要负责控制外部设备;
  • 网络:主要负责管理网络设备,实现网络协议栈。

linux 内核的网络部分主要具备两个功能:管理网络设备、实现网络协议栈。管理网络设备对应五层协议中的物理层和数据链路层;实现网络协议栈对应五层协议中的网络层和运输层。在 linux 的网络实现体系中,linux 为了抽象与实现相分离,将内核中的网络部分划分为五个层次:

  • 系统调用接口:用户空间的访问接口,提供网络功能的统一封装。
  • 协议无关接口:使用 socket 来实现对网络通信的抽象化,实现 TCP 和 UDP 的统一封装。
  • 网络协议栈:linux 中各种网络协议的具体实现,处理各种网络协议的逻辑。
  • 设备无关接口:为网络协议栈提供操作物理设备的统一封装。
  • 设备驱动程序:负责管理物理网络设备的驱动程序。

/images/linux-network.png

其中,网络协议栈部分包括数据链路层、网络层、传输层。除南北通信外,linux 内核还提供了东西向通信。在协议栈的数据链路层,linux 提供了 bridge hook,用户可以将同一主机上的不同网卡相互桥接,实现在数据链路层的消息转发。在协议栈的网络层,linux 提供了 route 接口,用于实现 ip 包在不同 host ip 之间的传递。

/images/linux-stack.png

虽然一台物理主机上可能会有多个网络设备,但是这些网络设备会共用同一个协议。如果想要使主机上的部分进程不与其他进程共享协议栈,那么就必须借助虚拟化技术来实现网络隔离,如虚拟机技术,虚拟容器技术。这些虚拟技术通常是使用虚拟网卡来实现的。

重要数据结构

linux 网络部分中最为重要的数据结构有两个抽象net_devicesocket和一个缓冲sk_buff

net_device

net_device是所有网络设备的抽象描述,为上层协议栈提供统一的接口,主要两个方面的内容:

  • 网络设备硬件属性:端口地址、驱动函数、中断函数等;
  • 网络设备配置信息:ip 地址、子网掩码等。

这一抽象数据结构实现了协议栈和网络设备的隔离,从而使得虚拟网络设备的实现成为可能。

socket

socket 是对所有传输层协议的抽象,统一了网络 IO 与文件 IO 操作,它主要实现了应用层与系统协议栈的接口统一、网络与文件访问方式的统一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct socket {
socket_state state;

kmemcheck_bitfield_begin(type);
short type;
kmemcheck_bitfield_end(type);

unsigned long flags;

struct socket_wq __rcu *wq;

struct file *file;
struct sock *sk;
const struct proto_ops *ops;
};

创建 struct socket 的函数 socket(3)不仅仅可以创建常规的 TCP、UDP 套接字,还可以通过控制协议族来创建 ICMP、甚至 ARP 套接字。经过 socket 的统一封装,这些位于不同层次的协议使用相同的接口被用户调用,向用户隐藏了底层系统的复杂度。struct socket的读写同样是使用了统一的接口来封装收发数据,不同的协议会使用不同的函数写入,也就是每一种 struct socket 最终调用的读写函数是不一样的,如 ICMP 协议在写入时使用的是icmp_send,因此不会经过传输层,而是直接从网络层向下进行网络通信流程。

1
2
3
4
5
// 创建一个 IMCP 套接字,SOCK_RAW 表明为原始网络协议
int icmp_fd = ::socket(AF_INET,SOCK_RAW,IPPROTO_ICMP);

// 创建一个 ARP 套接字,SOCK_PACKET 表明为链路层协议
int arp_df = :: socket(AF_PACKET,SOCK_PACKET,htons(ETH_P_ARP));

sk_buff

sk_buff结构体作用域整个数据包的处理,它承载着网络包的内容。sk_buff结构体的定义比较长,源码不贴出。主要包括以下几个方面的内容:

  • 用于实现 sk_buff的双向链表的结构;
  • 数据的各种指针:head、tail、data、end、user_data;
  • 长度标识:len、head room、tail room;
  • 协议报头:各网络协议层的报头;
  • sock*dev*:指向对应的 sock 结构体,对应的网络设备;
  • 控制缓存 char cb[40]:避免每层协议都分配内存,用于每一层临时存储信息;

linux 中,无论是写入还是读取网络包,都需要借助 sk_buff。在初始化阶段,数据并不会写入缓冲区的首部,而是会预留出一段空间,用于报文头的写入。在解析/生成报文的过程中,sk_buff中的 data 会不断移动,指向下一层级报文的开始位置。为了避免每一个层级额外分配内存,每一个sk_buff内部都预留了 40 字节大小的控制缓存,存储临时信息。这种设计能够在报文的解析和生成过程中避免频繁分配内存,大大降低了内存拷贝的需要。

更加详细的内容可以参考博客文章

网络包的读取流程

本章会从读取网络包的视角来分析 linux 网络部分的各个层级的主要工作,以及操作系统预留的一些 hook 可以用于实现的技术。

网络设备硬中断

数据帧被网卡接收前,会先检查数据的完整性,对错误数据包直接丢弃。

网卡内部具有一段内存区域,该内存区域是以 FIFO 形式来访问的,是网卡和驱动程序的共享内存。但是网卡内部是采用直接寻址的方式,并没有使用虚拟内存映射的方法来访问。当网卡中有数据帧到达时,网卡设备会将数据帧内容拷贝到该内存区域,然后向 CPU 发起一个硬中断,通知 CPU 有数据到达。

CPU 收到硬中断后,会在 skb 缓冲区分配一个 skb 数据结构,然后从网卡中将数据拷贝到 skb 中(因为网卡中的内存区域很小,很容易被占满),更新网卡状态;从数据帧中提出协议等信息,写入 skb 结构体中,然后向操作系统发出一个软中断。如果网卡的 FIFO 缓冲区或者 OS 中为网卡开辟的缓冲区被占满,那么数据包就会被直接丢弃,这是丢包的原因之一。

软中断处理入口

相较于网络设备硬中断,软中断处理是一个很长的流程,它会负责处理 OS 内存中已经到达的网络数据,并最终将其送入到对应的协议栈位置。

软中断处理会被调度到硬中断发生的 CPU 上,处理逻辑的入口函数是 net_rx_action。该函数的主要逻辑是遍历系统中的网卡设备,执行被网卡驱动注册的 poll 函数。为了限制单次软中断的运行时间,net_rx_action每次运行时都会被设置一个配额和一个截止时间,当完成配额数量或者超时后,即使没有完成所有的事件,函数也会退出,并等待被下一次中断唤醒。

另一个比较重要的函数是 netif_receive_skb,该函数是 skb 结构体的分发器,负责根据 skb 结构体的信息将其分发到对应的协议栈上,进行不同的处理。值得一提的是,虽然网络层协议目前很少,但为了考虑拓展性,函数中并没有使用分支的方式判断,而是使用了哈希表来进行查询的。netif_receive_skb同样也是抓包程序、数据链路层桥接等功能的入口。在提交至上层协议栈前,会首先根据抓包程序设置的过滤器,将符合条件的数据包复制。如 wireshark 中的 capture filter,就直接对应这一过程,如果不对其进行设置,可能会导致系统的性能下降。

1
2
3
4
5
6
7
8
//pcap逻辑,这里会将数据送入抓包点。
list_for_each_entry_rcu(ptype, &ptype_all, list) {
if (!ptype->dev || ptype->dev == skb->dev) {
if (pt_prev)
ret = deliver_skb(skb, pt_prev, orig_dev);
pt_prev = ptype;
}
}

数据链路层桥则是虚拟网卡 TAP 的基础,可以在数据链路层将数据包转发给其他虚拟网卡设备,该技术是基于 hook 函数 netif_receive_skb 的。该函数会调用 rx_handler 来实现不同的功能,每一块宿主网卡都只能注册一个 rx_handler,但是网卡和网卡可以使用链路的方式来注册,从而实现一个宿主网卡上挂载多个虚拟网卡。

linux tc 是在此阶段被处理的,其中__netif_receive_skb_core处理 tc ingress,dev_xmit处理 tc egress。

网络层处理

网络层处理主要会对各种协议进行解析和处理,这一阶段的输入是经过解析后的网络层包。不同协议的包入口函数名为 prococolname_rcv,结束函数为 prococolname_rcv_finish。这一层主要进行路由、NAT、防火墙等操作。

ip 协议

ip 网络层的入口为ip_rcv,在这一函数中会对包进行检查,如果该 ip 包经过了分割,会首先将多个分片组装。在完成这些预处理工作后,ip 包会经过路由和 netfilter 的处理流程。可见,netfilter 这种内核防火墙并不会对数据包产生复制,利用其来做网络转发性能会远高于 nginx 等应用层代理。具体的处理流程如下图所示:

img

不难看到,ip 协议的处理流程和 iptables 的四链处理流程是一致的。

在 linux 的netfilter实现中,使用了ip_conntrack结构体来记录每一个连接的状态,该状态是一个协议无关的状态,主要记录连接src 和 dst 的 ip 以及端口号、连接创建时间、发送字节等信息。ip 层协议栈维护了一个 ip_conntrack 的哈希表,用于记录当前活跃的所有被追踪连接。

其他协议

除了 IP 协议,网络层处理还有 ARP 协议、ICMP 协议等,如果这些协议到达网络层后没有更高层次的协议,那么处理就会结束。以 ICMP 协议为例,数据包在到达网络层后会直接在内核态中被处理,不会再进行传递。linux 内核中处理 ICMP 应答的函数是 icmp_rcv()源码有 100 行左右,但核心功能比较简单,其主要步骤用文字表述后如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int icmp_rcv(struct sk_buff *skb)
{
// 检验 ICMP 包有效性

// 获取 ICMP 报文类型

// 处理 ICMP 报文消息

// 根据 ICMP 报文类型,选择处理函数,应答处理均在内核态运行
success = icmp_pointers[icmph->type].handler(skb);

// 结束本次网络包读取
if (success) {
consume_skb(skb);
return 0;
}
}

可以看到,函数中会根据报文类型来选择合适的处理函数,处理函数会根据当前系统协议栈的状态来处理 ICMP 请求。即ICMP 的应答报文会在内核态生成,因此 ICMP 报文的读取在网络层就会结束。

传输层处理

接收数据包时,传输层处理的主要流程为:解析报文包——( 报文包进入接收队列 )——处理应答。当数据包为用户进程所需时,需要将其加入接收队列,如果接收队列已满,同样也会导致数据包被丢弃

TCP 部分

为了控制和管理每个连接,linux 内核中有使用了 TCP_SKB_CB 数据结构来描述 TCP control block,简称 TCB,用于存储单个 tcp 连接的状态。所有的 TCB 以表的形式组织起来,方便查询和修改。TCB 结构只会在 TCP 发送/写入数据、建立/关闭连接时被修改。TCB 主要记录以下部分的内容,源码较长,这里不贴出:

  • 连接四元组:TCP 双方 ip、port;
  • TCP 状态:SEQ,ACK,连接状态,SACK/FACK 重传等信息;
  • 进程号:开启 tcp 连接的进程。

TCP 的所有收发操作都会依据 TCB 的状态来进行调节,而孤儿 socket、RST 包等一些功能也都是基于 TCB 来完成的。如果 ip 层解析后的 tcp 包能够匹配具体的 process,那么就会处理该消息并将内容封装为 tcp_data ,放入 tcp_data 队列中,然后通过 tcp_read 接口来注册 poll、epoll 事件,通知被阻塞的进程。具体的处理流程如下图:

697113-20160228211142163-1933891846

TCP 中接受消息的处理路径有两条: fast path和 slow path。slow path 是当前 tcp 连接出现异常状况的处理策略,相比 fast path,它需要额外处理紧急指针、乱序数据。tcp 协议栈会使用预测标志的方法来决定使用哪种策略,当满足以下条件时,会使用 fast path:

  • 无乱序数据和紧急数据
  • 接收窗口不为 0
  • 接收缓冲区未耗尽

connect系统调用结束、收到第三次握手中 ACK时,tcp 会立刻使用 fast path;当 tcp 从异常中恢复、更新窗口后、可接收缓冲区大小变化时,都会尝试开启 fast path。

如果不存在相应的 TCB 或 TCB 中对应的进程已经退出,那么协议栈将会进行异常处理,给对端发送 RST 包。当一个进程退出时,会隐式地关闭所有 socket,但是此时连接还没有被正常关闭,因此会出现 TCB 存在,但是对应进程已经不存活的状态。

UDP 部分

由于 UDP 是一个无状态协议,相比于 TCP 协议的处理流程,UDP 协议的处理更加简单。linux 协议栈不需要为 UDP 维护状态机模型,只需要解析包后通知用户进程即可。

网络包的写入流程

上一章已经讲述了 linux 网络部分的各个模块内容,本章主要讲述的是写入流程中比较特殊的部分。具体的 write flow 则不再阐述。

传输层

TCP 包发送时必须考虑到丢包的情况,因此需要设立重传机制。linux 内核中为每一个 tcp 连接设置了一个以双向链表形式组织的重传队列,在收到对方的 ACK 前,tcp 包都会被保存在队列中,等待触发重传机制时进行调用。

TCP 重传可以分别超时重传和快速重传(SACK、DACK)两种,其中快速重传是在 TCP 包的接收中处理的,而超时重传则是由内核内部的定时器触发的。每一次 TCP 协议栈进行发包时,会设置一个超时重传定时器,触发时间为 2*RTT;若在定时器超时前收到 ACK,会撤销定时器,否则会在定时器触发时重传 TCP 报文。超时重传中有许多处理细节,可以参考博客

除超时重传定时器外,linux 内核的 TCP 协议栈为每一个 TCP 连接维护了一个定时器队列timer_list,存储在sock struct 中。定时器一共具有九种,分别是:超时重传定时器、SYNACK 定时器、Delayed ACK 定时器、Keepalive 定时器、零窗口探测定时器、FIN_WAIT2 定时器、TIME_WAIT 定时器、ER 定时器、尾部丢失探测器;这些定时器是随 sock 结构体一进行初始化的。这些定时器的触发和执行是由内核函数 update_process_times负责的,TCP 协议栈并不会单独开辟内核线程来处理这些定时器,而是采用系统软中断的方式来处理定时器。

其中 FIN_WAIT2 定时器是用于处理孤儿 socket 的相关动作的。当进程使用系统调用后close,进程不会继续持有 socket ,但是此时 TCP 协议栈尚未能够完成 TCP 的四次握手,这时 socket 会转换为 孤儿 socket,由协议栈负责完成握手;该定时器则是用于记录孤儿 socket 的存活时间的,若该定时器被触发,协议栈将会释放 socket 相关信息。

网络层

网络层中的写入流程有一个点比较特殊,那就是 IP 包在进行封装时需要使用 ARP 协议来获取 MAC 地址。由于这一步骤需要使用目标 IP 作为输入,破坏了下层协议不依赖上层协议数据的原则,所以才会产生 ARP 协议层次的争议。在 OSI 模型中,将 ARP 定义为数据链路层协议;而在 TCP/IP 模型中则将其定义为网络层协议。

数据链路层

所有的网络包在数据链路层都会通过dev_queue_xmit进入等待队列,操作系统会定期调用xmit_one函数来发送一个或多个数据包至网络设备,每一个网络设备都具有一个等待队列。该等待队列也是一个 hook,可以由用户进行调控。linux 中系统命令 tc(traffic control) 就是利用该 hook 来实现的。默认情况下,该等待队列是一个 FIFO 队列,用户也可以通过配置来实现更加复杂的功能。

/images/dev_quue.png

linux 内核在进行入队和出队操作时,使用了两个函数指针,实现了队列的抽象化。在 linux tc 实现中,允许一个抽象队列以树状组织,形成一个队列树。队列树的根节点和非叶子结点是抽象队列基类,不具备任何队列功能,但是具有两个过滤器,用于入队和出队的选择与过滤功能。每一个叶子结点都会维护一个队列实例,可以是 FIFO 队列、随机队列、优先级队列等中的一种。当链路层包需要入队时,会从根节点以入队过滤器指定的策略对链路层包进行分流,最终经过策略选择入队到某一个叶子结点的队列中。当需要出队时,则会根据过滤选择器中设置的优先级选择一个或多个链路层出队。

默认情况下, 抽象队列中根节点过滤器不设置任何规则,等待队列只设置了一个 FIFO 队列,即不实现任何功能。tc 命令中的队列规程,类别,过滤器其实就分别对应了等待队列实例,等待队列实例的类别,过滤器规则。

虚拟网络设备

linux 中许多种类的虚拟网络设备,常见的有 IFB、TUN/TAP、VETH 等,他们都是通过统一的 netif_receive_skb hook 函数来实现的。所有的虚拟网络设备都工作在数据链路层,可以选择与主机共享协议栈,或者选择使用虚拟技术实现新的协议栈。

Loopback Interface

Loopback Interface 是一个特殊的虚拟网卡,一般作为 lo 网卡挂载在 ip 127.0.0.1上。Loopback Interface 同样工作在数据链路层,发送到虚拟网卡的数据需要先经过传输层和网络层。网卡的 ip 输出函数会直接串联到 ip 输入函数中,中间没有任何缓存。

IFB

IFB(中介功能块设备)是IMQ(中介队列设备)的继任者,也是最简单的虚拟网卡,它以 bridge 的形式与其他网卡连接在一起,它不改变数据包的流向。IFB 网卡的设计初衷是为了拓展 linux tc 的功能。

使用方法可以参考博客

TUN/TAP

TAP/TUN是Linux内核实现的一对虚拟网络设备,TAP工作在二层,TUN工作在三层,Linux内核通过TAP/TUN设备向绑定该设备的用户空间程序发送数据,反之,用户空间程序也可以像操作硬件网络设备那样,通过TAP/TUN设备发送数据。

VETH

VETH 网卡是成对出现的,一对 VETH 网卡可以使用两套不同的协议栈,通常用于网络协议栈的隔离。VETH 网卡可以与物理网卡或其他虚拟网卡进行桥接,通过路由规则就可以实现网路的隔离。

NAT

NAT 可以分为 DNAT 和 SNAT 两种,由于进行 NAT 操作的主机必须记录转换前与转换后的对应关系,必须要建表来对 NAT 状态进行记录和追踪。但由于 linux 中已经具有链路追踪的功能,为了减少内存占用,linux 中的 nat 实现是依赖于 ip_conntrack 的。在前面的介绍中,提到过位于传输层协议栈,具有一个 ip_conntrack 哈希表,该哈希表就是 linux 中 nat 的实现基础。

考虑如下的一次 SNAT,源地址为 ip1:port1,目的地址为 ip2:port2,需要转换的地址为 ip3:port3。首先在正向连接中,会创建 origin tuple 和 reply tuple,插入到连接对应的 ip_contrack 结构中,随后修改 reply tuple 中的信息;将修改完成后的两个 tuple 计算哈希值,插入到连接追踪表中。

linux_nat

这样,在反向连接中,就可以通过查询连接追踪表来判断是否需要进行 de-SNAT 操作。如本次连接中,可以通过 hash_value2 来查询到对应的 tuple 信息,发现该 tuple 在所属的 ip_conntrack 为 reply tuple,然后根据 origin tuple 中的信息就可以知道如何进行 de-NAT 操作。

LVS

lvs(linux virtual server)是工作在网络层的一个虚拟服务器,工作在内核态。lvs 是利用 netfilter 的 hook 来实现的,它能够截获指定条件的 ip 包,并将其根据一定的策略转发到其他 linux 主机上,实现服务器的负载均衡。lvs 的工作位置比较特殊,它位于网络层与传输层之间。

/images/lvs_pos.png

在经过网络层的处理之后,数据包会先被 lvs 截获,如果满足 lvs 的匹配条件,那么将会由 lvs 进行处理;若果不满足 lvs 的匹配条件,数据包才会被递交给传输层。由于 lvs 的特殊位置,在下层协议栈以及其他主机的视角中,它是作为一个传输层/应用层设备工作的,所以它被称作是一个“服务器”。但是,由于 lvs 并不真实处理应用层的请求,只是将数据包转发给其他的真实服务器,所以称其是“虚拟的”。lvs 更像是一个虚拟的应用层网关。

lvs 具有一个虚拟的端口号和虚拟的网络地址,主机外可以像访问应用层服务一样访问 lvs。lvs 使用的网络地址称为 VIP(virtual ip)。由于 lvs 的位置比真实应用层更低,如果 lvs 与真实应用层使用相同的 host:ip,lvs 会截获全部的网络包。

ipvs 是 lvs 的一种实现,它利用哈希表来实现网络地址的匹配与转发,相比使用网络层的链路处理模式更加高效。ipvs 一共具有三种工作模式:

  • NAT 模式:通过 DNAT 实现转发。性能最差,但最灵活,允许 lvs 与 real server 端口号不同;
  • DR 模式:通过 MAC 地址实现转发。性能最好,只支持无 ARP 网络,要求相同的 virtual ip。
  • IPIP 模式:通过网络隧道实现转发。性能中等,灵活度中等,要求设备支持网络隧道技术,且不保证客户端一定能够收到回包。

NAT 模式的模型比较简单,不过多介绍。NAT 模式的性能较差主要源自于正向和反向网络包都需要经过 lvs 所在主机的协议栈,但也是因为经过协议栈的原因,可以使用软件来进行各种处理,有最好的灵活性。另外,NAT 模式也是最成熟,风险最小,最常用的模式,k8s 中 ipvs 代理的搭建就是使用的 NAT 模式。

DR 模式是利用数据链路层的原理来实现转发的。DR 模式要求每一个 RS 都配置一个与 LVS 相同的 VIP,并隐藏该 VIP(外部无法访问),这是为了防止被 ARP 寻址。DR 模式中的 LVS 会将收到的 ip 包中的 MAC 地址根据一定策略修改为 RS 的 MAC 地址。当数据包到达 RS 时,由于 VIP 能够匹配到 RS 中的 VIP,RS 协议栈将会接收该数据包然后处理。DR 需要回包时,由于来包中的 MAC 地址是属于数据包真实发送者的,回包并不会经过 LVS。在具体配置过程中,DR 模式非常复杂并且对网络的拓扑要求会比较高。

IPIP 模式则是在 LVS 与 RS 之间使用网络隧道进行传输,原始数据包会被包裹一层数据包,相当于原始数据包并没有经过 RS 的协议栈(原始数据包是作为包裹数据包的内容传递的)。所以 RS 的应用层看到的 SRC IP 是真实发送者的,但是网络隧道的存在,在发送回包时其实发送者与 RS 之间并没有建立起数据链路层的通道。如果网络情况比较特殊,即发送者能够与 lvs 通信,但不能与 rs 通信,就会导致返回链路无法成功建立,IPIP 模式失效。

可以参考该博客,其方法已经实验证实有效。