计算机网络学习笔记(7)—— Linux 网络包收发详解

Posted by 皮皮潘 on 09-07,2022

最近看完了飞哥的《深入理解Linux网络: 修炼底层内功,掌握高性能原理》一书,感觉收获颇丰,特此将学习过程中笔记记录下来作为总结与分享,有兴趣的同学可以去读一下原作,想必会对于 Linux 网络有一个更加深刻的理解以及一次酣畅淋漓的源码阅读体验

收包流程

内核收包

Linux 内核收包如下图所示:
内核收包流程.jpg

具体流程已经在图中描述地很清晰了,需要注意的是以下几点:

  1. ksoftirqd 是在 OS 初始化阶段创建的内核线程,每个 CPU 会对应一个 ksoftirqd,它主要用来处理各种各样的软中断
  2. 在打开网卡的时候会为网卡分配多个队列,而每个队列会对应两个 RingBuffer(RXRing 和 TXRing),以及一个独有的硬件中断号(也即每个队列对应一个硬件中断号),而每个RingBuffer内部又有两个环形队列数组,一个是内核使用的igb_rx_buffer 数组,一个是网卡硬件 DMA 使用的e1000_adv_rx_desc 数组,两者之间存在内存映射,它们相同位置的指针都指向同一个skb,这样内核和硬件都能访问不同的数组但是共同访问同样的数据
  3. 硬中断和软中断通过 processor_id 实现了映射绑定,因此处理了硬中断的 cpu,它绑定的 ksoftird 同时也会处理该硬中断对应的软中断,而硬中断由哪个 cpu 处理则取决于配置的硬中断的cpu 亲和性了,因此我们可以通过调整每一个队列自身的中断号的 cpu 亲和性来调整软中断的 cpu 亲和性,从而使得网络收包的负载在每个 cpu 上面均衡

进程收包

在完成了内核收包之后,就需要进程从接收队列去收包然后做真正的处理了,两者之间其实没有先后关系,彼此是异步的,简单地讲,内核通过 ksoftirqd 将收到的网络包放入到 Read Buffer 接受队列之后就会唤醒等待的进程,而当进程调用 receive 去主动收包时,接收队列中可能不存在网络包,此时对应的进程就会挂起等待网络包的到达

展开来讲的话,用户进程通过 socket 进行网络包的的读取,一个 socke t实际由一个 struct socket 组合一个 struct sock 构成,在传统的 BIO 中当用户进程调用 socket 上的 receive 方法后会执行对应的系统调用进入内核态,然后查看 sock 上的接收队列中是否有数据,如果有数据就将数据从内核空间的接收队列上拷贝到用户空间中用户定义的Buffer中,如果没有数据则封装一个等待队列节点注册回调函数并入队然后挂起当前进程,并等待 ksoftirqd 在接收到网络包后调用回调函数将其唤醒(调用注册的回调函数这一步很重要,因为后面epoll也是通过相同方式但是不同的注册函数实现的,这一设计保证了 ksoftirqd 处理逻辑的不变性,符合开闭原则)。

具体的唤醒流程如下:当有数据包到达时会由网卡 DMA 到RingBuffer 中,然后触发硬中断,硬中断进一步触发软中断,软中断进程(ksoftirqd)被唤醒后根据数据包中携带的四元组找到对应的 socket,然后将数据包 skb 从RingBuffer放入到sock对应的接收队列中(此处没有发生内存拷贝仅仅只是放置了指针)然后通过调用sk_data_ready函数指针来调用在等待队列中入队节点的回调函数(之前注册的auto_remove_wake_function)进而唤醒前述的在 socket 上等待的进程并继续执行前述的有数据包时的流程(p.s. 这里通过内核态的方式来使得ksoftirqd和处在内核态的用户进程可以直接共享内核空间的数据而不会由于CR3的切换导致虚拟内存映射错误的情况发生,另外操作系统通过一个在系统启动使初始化的与 cpu 绑定的一块 soft_data 结构体来在用户进程和处理软中断的进程 ksoftirqd 之间实现数据的传递)

socket 查找过程与 RST

每个socket通过四元组进行标识并在内核中通过哈希表加链表的方式存储socket集合,内核会先通过四元组在 established 的 socket 中寻找对应的socket,如果找不到就会在 listened 的 socket 中寻找,如果还是找不到不到对应的socket的时候(Socket 没有监听对应端口或者连接没有建立或者连接信息丢失)接收端会给发送端返回一个 RST 的 tcp 包或者一个 ICMP 包从而在接收端触发 Connection Reset By Peer 错误。

在已经接受到了 RST 的连接上继续发送 Packet 则会直接由发送端的内核触发 Broken Pipe 错误(SIGPIPE 信号),如果继续接收 Packet 则会再次由发送端的内核触发 Connection Reset By Peer 错误

BIO 与 Epoll

前面讲的进程收包是通过阻塞的方式进行,众所周知,采用 BIO 的方式在并发量大的情况下会产生大量的进程从而导致很高的 Context Switch 的开销,为了解决这一问题, Linux 给出了基于 Epoll 的解决方案,Epoll 的总体逻辑与 BIO 的是一致的,只是对于回调的处理不相同,这里再次膜拜一下内核的精巧

在 BIO 中,socket 的等待队列中入队的回调函数(当数据到来时被softirqd调用)是 auto_remove_wake_function,其底层调用了 default_wake_function 与 BIO 的特性相结合用来唤醒阻塞在 socket 上面的进程,而在epoll中,由于进程不再会因为读数据而阻塞,同时所有的 socket 都由 epoll 进行管理,因此对应 socket 的等待队列中入队的回调函数也改为了 ep_poll_callback,这使得当数据就绪时会将对应的socket 放入就绪描述符队列 rdllist 进而可以被无阻塞地读取(因为epoll只会返回就绪队列中的socket,而就绪队列中的socket都是已经有数据可以被读取的从而不会阻塞进程)

在具体的使用中,当调用 epoll_wait 后,它会观察就绪描述符队列 rdllist 中是否有就绪的数据,如果有数据就返回对应socket描述符,如果没有就创建一个等待队列节点注册回调函数 default_wake_function,然后将其入队到 epoll 本身的等待队列中,最后阻塞当前进程并等待 softirqd 回调注册在当前 epoll 的 socket 等待队列上的 ep_poll_callback ,这个 回调函数会在 ksoftirqd 将 socket 入队到 rdlllist 之后(socket 的回调函数)被调用进而将在 epoll_wait 上等待的进程唤醒,这里需要注意的是,epoll 与 BIO 不同的点在于 BIO 每个 socket 都各自对应一个或多个进程,因此其上的回调函数也是唤醒每个socket各自的进程的,而在 epoll 中由于所有的socket 都交由 epoll 管理,因此对应的回调函数唤醒的是block 在 epoll 上的进程而非 socket 上的进程

其中软中断回调时的回调函数调用关系整理如下:
sock_def_readable: sock 对象初始化时设置
=> ep_poll_callback: 调用 epoll ctl 时添加到 socket 上
=> default_wake_function:调用 epoll wait 时设置到epoll上

另外从这里也能看出,当没有数据的时候,epoll也会发生阻塞,但是阻塞频率相较于BIO是大大降低了的,因此浪费在进程切换上的开销也大大降低了,而且只要活儿比较多 epoll 根本不会让进程阻塞,除此之外虽然 epoll 本身是阻塞的,但是一般会把socket设置成非阻塞,从而保证每次socket读取都不会阻塞,而是快速返回接收队列上的数据

epoll 整体生命周期如下图所示:
epoll.jpg

总结一下,epoll相关的函数里内核运行环境分两部分:

  1. 用户进程内核态。调用 epoll_wailt 等函数时会将进程陷入内核态来执行。这部分代码负责查看就绪描述符队列 rdllist,以及负责把当前进程阻塞掉让出 CPU
  2. 硬、软中断上下文。在这些组件中,将包从网卡接收过来进行处理,然后放到 sooket 的接收队列。对于epoll来说,需要再找到 socket 关联的 epitem,并把它添加到 epoll 对象的就绪描述符队列中。这个时候再检查一下 epoll 上是否有被阻塞的进程,如果有唤醒它

另外在epoll中红黑树的作用仅仅是为了在控制函数(epoll_ctl)中快速查找,插入以及删除epitem,它对于epoll在高并发时的高性能并没有起到决定性作用

P.S. 同步异步与阻塞非阻塞的解释:BIO 和 Epoll 分别代表了同步阻塞与异步非阻塞,其实同步异步是一对,阻塞与非阻塞是一对,其中异步与同步主要从被调用者的角度出发,是否根据操作流顺序被动返回完成的输出,还是异步操作流等执行完了再主动告知完成的输出,阻塞与非阻塞主要从调用者角度出发,是否会因为调用某个函数进而由于等待某个事件而主动让出CPU挂起的操作

发包流程

发包整体流程如下图所示:
发包流程.jpg
发包的流程在图中已经描述的很清楚了,这里主要注意以下几点:

  1. 不同于内核收包流程,每一个网络包都会触发一次 NET_RX 软中断,对于发送网络包来说它会在内核中连续发送多个网络包直到内核态配额用尽,才会发起软中断,交由 ksoftirqd 将 Buffer 中剩下的网络包发送出去,因此一般NET_RX都要比NET_TX大得多
  2. 在发送数据的时候除了从用户态到内核态需要一次内存拷贝(生成skb并将地址从用户空间转换为内核空间)之外在传输层进入网络层之前也还要进行一次拷贝,因为网络层及其下网络子系统只管把数据包发出去,然后就任务结束并把 skb 的内存释放了,但是传输层还要保证可靠性,因此可能还要重传 skb,所以这里传给网络层的是内存拷贝后的副本,真正的 skb 是在对应的网络包被确定接受了之后被释放的,另外哪怕是网络层以及其下的网络子系统中的内存释放也是在网卡发送完成的时候触发一个硬中断,然后触发 NET_RX 软中断实现的,这也是为什么NET_RX都要比NET_TX大得多的第二个原因

建立连接

连接流程

客户端在执行connect函数的时候,具体步骤如下:

  1. 把本地socket状态设置成TCP_SYN_SENT
  2. 选用一个可用的端口
  3. 构建一个skb带有syn标识位
  4. 发出SYN握手请求
  5. 启动重传定时器

这里需要注意的是一般 server 端在监听某个端口前都会通过 bind 绑定某个端口,那如果在 connect 之前使用 bind 会发生什么呢?答案很简单,将会使得connect系统调用时的端口选择方式无效,转而使用bind时确定的端口。因为调用bind时如果传入了端口号,会尝试首先使用该端口号,如果传入了0,也会自动选择一个。但是该端口会被被重复使用进而导致无法并发地创建连接,所以对于客户端角色的socket,不建议使用bind

另外这里可能会对于 Java 玩家造成困惑的是,server 和client 所用的 socket 其实都是同一个类型的(底层systemcall 没有 Java 所谓的 ServerSocket 和 Socket 的区分,那个都是 Java 封装的),只不过它会根据 socket 所处的不同状态进入不同的处理逻辑,除了 ESTABLISHED 和 TIME_WAIT,其他状态的处理都会进 tcp_rcv_state_process方法,其中如果是 TCP_LISTEN,TCP_SYN_RECV 则进入服务端接收连接的流程(第一次和第三次握手同时也对应了Listen Socket 和 Child Socket),如果是 TCP_SYN_SENT 则进入客户端第二次握手处理的流程,因此这里也可以看到建立连接对于 Server 端而言其实是 receive 收包的一种特殊情况

结合三次握手,我们可以对于连接建立流程有一个更加透彻的理解,之后面试的时候也可以从源码层面聊聊三次握手做了些什么了,详细过程如下:
三次握手.jpg

这里需要解释的是 Server 端的 ServerSocket 的状态一直是 LISTEN 状态的,因此第一个 SYN 请求会交由 ServerSocket 处理,并创建对应的 Child Socket 将其状态设置为 SYN_RECV 并放入半连接队列,因此对于接下来的 ACK 请求则交由 Child Socket 处理了,它会将自身放入全连接队列,并把状态设置为 ESTABLISHED

另外从图中我们可以看到,因为客户端在发出ACK之后就会设置状态为ESTABLiSHED而从服务端的视角来看,它是从收到SYN开始计时,到收到ACK为连接建立完成的,因此一条TCP的建立时间为1个RTT多一点(算上内核发送,接受,处理以及上下文转换的几微妙)而不是1.5个RTT,另外第一次握手失败只有客户端知道因此交由客户端定时器重试,而第三次握手失败只有服务端知道(客户端发出ACK后以为连接已经建立了)因此交由服务端定时器重试

连接开销与最大连接可能性

最后聊一下一个 socket 连接的开销,通过飞哥的一系列计算,他给出了如下结论(由于具体涉及到内核内存管理的 SLAB 算法,以及 Socket 的核心内核对象等,因此在本篇博文中不再详述,之后会在操作系统的内存管理对应的博文中展开描述):

一个established的连接的内核对象的内存大概为3K,其中核心内核对象如下:

  1. struct socket_alloc, 大小约为0.62KB,sltab缓存名是sock inode cache
  2. stuct tcp_sock,大小约为1.94KB,slab缓存名是tcp
  3. struct dentry,大小约为0.19 KB,slab缓存名是dentry
  4. struct file,大小约为0.25KB,slab缓存名是flip

一个非established(timewait)的连接的内核对象由于释放了大部分,因此其内存大概为0.3K

上述的内存占用仅仅是内核对象的,不包括发送和接收缓冲区所占用的内存,数据收发对内存的消耗相对复杂,涉及tcp_rmem, tcp_wmem 等内核参数限制,也涉及滑动窗口,流量控制等协议层面的控制。在具体实践中,只要发送出去的数据能接收到对面的 ACK ,且没有数据要继续发送的话,发送缓冲区用完就立即释放了,而对于接收方而言要等到应用层接收数据将其拷贝到用户进程内存以后之后,接收缓冲区才会及时回收

在linux中万物皆文件,而每个socket对应一个打开的文件,因此能打开的socket数目与系统配置的最大文件数相关,一个支持百万连接的基本配置如下:

config.jpg

对于服务端而言,由于在四元组中客户端的 ip 与 port 都不尽相同,因此可以很轻松地达到百万连接,但是对于客户端而言,在四元组中服务端的 ip 与 port 都是固定的,一次比较难达到单机发起很多的连接,比较t rick 的方法是:

  1. 调高 port 的范围 (最多到 65535)
  2. 使用虚拟 ip,每个新的虚拟 ip 都能增加 port 数量的连接

参考

  1. 《深入理解Linux网络: 修炼底层内功,掌握高性能原理》