当前位置: 首页 > news >正文

深入理解Linux网络-读书笔记(一)

第2章 内核是如何接收网络包的

2.1 相关实际问题

➥ RingBuffer到底是什么,RingBuffer为什么会丢包?

➥ 网络相关的硬中断、软中断都是什么?

➥ Linux里的ksoftirqd内核线程是干什么的?

➥ 为什么网卡开启多队列能提升网络性能?

➥ tcpdump是如何工作的?

➥ iptable/netfilter是在哪一层实现的?

➥ tcpdump能否抓到被iptable封禁的包?

➥ 网络接收过程中的CPU开销如何查看?

➥ DPDK是什么神器?

2.2 数据是如何从网卡到协议栈的

2.2.1 Linux网络收包总览

​ 在TCP/IP网络分层模型里,整个协议栈被分成了物理层、链路层、网络层、传输层和应用层。应用层对应的是我们常见的Nginx、FTP等各种应用,也包括我们写的各种服务端程序。Linux内核以及网卡驱动主要实现链路层、网络层和传输层这三层上的功能,内核为更上面的应用层提供socket接口来支持用户进程访问。以Linux的视角看到的TCP/IP网络分层模型应该是这样的。

image-20250720073021237

Linux的源码中,网络设备驱动对应的逻辑位于driver/net/ethernet,其中lntel系列网卡的驱动在driver/netethernet/intel目录下,协议栈模块代码位于kernelnet目录下。

内核和网络设备驱动是通过中断的方式来处理的。当设备上有数据到达时,会给CPU的相关引脚触发一个电压变化,以通知CPU来处理数据。对于网络模块来说,由于处理过程比较复杂和耗时,如果在中断函数中完成所有的处理,将会导致中断处理函数(优先级过高)过度占用CPU,使得CPU无法响应其他设备,例如鼠标和键盘的消息。因此Linux中断处理函数是分上半部下半部的。上半部只进行最简单的工作,快速处理然后释放CPU,接着CPU就可以允许其他中断进来。将剩下的绝大部分的工作都放到下半部,可以慢慢、从容处理。2.4以后的Linux内核版本采用的下半部实现方式是软中断,由ksoftirqd内核线程全权处理。硬中断是通过给CPU物理引脚施加电压变化实现的,而软中断是通过给内存中的一个变量赋予二进制值以标记有软中断发生。

image-20250720073350644

2.2.2 Linux启动

➥ 创建ksoftirqd内核线程

Linux的软中断都是在专门的内核线程(ksoftirqd)中进行的,该线程数量不是1个,而是N个,其中N等于你的机器的核数

➥ 网络子系统初始化

在网络子系统的初始化过程中,会为每个CPU初始化softnet_data,也会为Rx_SOFTIRQTX_SOFTIRQ注册处理函数。

➥ 协议栈注册

内核实现了网络层的IP协议,也实现了传输层的TCP协议和UDP协议。这些协议对应的实现函数分别是ip_rcv0tcp_v4_rcv0udp_rcv0。和平时写代码的方式不一样的是,内核是通过注册的方式来实现的。

➥ 网卡驱动初始化

每一个驱动程序(不仅仅包括网卡驱动程序)会使用module_init向内核注册一个初始化函数,当驱动程序被加载时,内核会调用这个函数。比如igb网卡驱动程序的代码位于drivers/net/ethernet/inteligb/igb_main.c中。

➥ 启动网卡

当初始化都完成以后,就可以启动网卡了。当启用一个网卡时(例如,通过if config eth0 up),net_device_ops变量中定义的ndo_open方法会被调用。这是一个函数指针,对于igb网卡来说,该指针指向的是igb_open方法。

启动网卡过程:

启动网卡过程

_igb_open函数调用了igb_setup_all_tx_resourcesigb_setup_all_rx_resources。在调用igb_setup_all_x_resources这一步操作中,分配了RingBuffer,并建立内存和Rx队列的映射关系。(RxTx队列的数量和大小可以通过ethtool进行配置。)

根据Rx值创建对应的数量的RingBuffer

实际上一个RingBuffer的内部不是仅有一个环形队列数组,而是有两个。

  • igb_rx_buffer数组:这个数组是内核使用的,通过vzalloc申请的。
  • e1000_adv_rx_desc数组:这个数组是网卡硬件使用的,通过dma_alloc_coherent分配。

接收队列内部:

接收队列内部

对于多队列的网卡,为每一个队列都注册了中断,其对应的中断处理函数是igb_msix_ring(该函数也在drivers/net/ethernet/intel/igb/igb_main.c下)。还可以看到,在msix方式下,每个RX队列有独立的MSI-X中断,从
网卡硬件中断的层面就可以设置让收到的包被不同的CPU处理。(可以通过irqbalance,或者修改 /proc/irq/IRQNUMBER/smpaffinity,从而修改和CPU的绑定行为。)

将来在发送的时候,这两个环形数组中相同位置的指针都将指向同一个skb,这样,内核和硬件就能共同访问同样的数
据了,内核往skb写数据,网卡硬件负责发送。

2.2.3 迎接数据的到来

➥ 硬中断处理

首先,当数据帧从网线到达网卡上的时候,第一站是网卡的接收队列。网卡在分配给自己的RingBuffer中寻找可用的内存位置,找到后DMA引擎会把数据DMA到网卡之前关联的内存里,到这个时候CPU都是无感的。当DMA操作完成以后,网卡会向CPU发起一个硬中断,通知CPU有数据到达。

硬中断处理过程:

硬中断处理过程

💡 当RingBuffer满的时候,新来的数据包将被丢弃。使用ifconfig命令查看网卡的时候,可以看到里面有个overruns,表示因为环形队列满被丢弃的包数。如果发现有丢包,可能需要通过ethtool命令来加大环形队列的长度。

➥ ksoftirqd内核线程处理软中断

网络包的接收处理过程主要都在ksoftirqd内核线程中完成,软中断都是在这里处理的,流程如下:

软中断处理过程

硬中断中的设置软中断标记,和ksoftirqd中的判断是否有软中断到达,都是基于smp_processor_id()的。这意味着只要硬中断在哪个CPU上被响应,那么软中断也是在这个CPU上处理的。所以说,如果你发现Linux软中断的CPU消耗都集中在一个核上,正确的做法应该是调整硬中断的CPU亲和性,将硬中断打散到不同的CPU核上去。

➥ 网络协议栈处理

netif_receive_skb函数会根据包的协议进行处理,假如是UDP包,将包依次送到ip_rcvudp_rcv等协议处理函数中进行处理。

_netif_receive_skb_core中,有原来经常使用的tcpdump命令的抓包点。tcpdump是通过虚拟协议的方式工作的,它会将抓包函数协议的形式挂到ptype_all上。设备层遍历所有的“协议”,这样就能抓到数据包来供我们查看了。tcpdump会执行到
packet_create

_netif_receive_skb_core函数取出protocol,它会从数据包中取出协议信息,然后遍历注册在这个协议上的回调函数列表。

IP层处理

IP层接收网络包的主入口ip_rcvNF_HOOK是一个钩子函数,它就是我们日常工作中经常用到的iptables netfilter
过滤。如果有很多或者很复杂的netfilter规则,会在这里消耗过多的CPU资源,加大网络延迟。

总结

➥ RingBuffer到底是什么,RingBuffer为什么会丢包?

RingBuffer是内存中的一块特殊区域,平时所说的环形队列其实是笼统的说法。事实上这个数据结构包括igb_rx_buffer环形队列数组、e1000_adv_x_desc环形队列数组及众多的skb

网卡在收到数据的时候以DMA的方式将包写到RingBuffer中。软中断收包的时候来这里把skb取走,并申请新的skb重新挂上去。有些网上的技术文章讲到RingBuffer内存是预先分配好的,有的文章则认为RingBuffer里使用的内存是随着网络包的收发而动态分配的。这两个说法之所以看起来有点混乱,是因为没有说清楚是指针数组还是skb。指针数组是预先分配好的,而skb会随着收包过程而动态申请

这个RingBuffer是有大小和长度限制的,长度可以通过ethtool工具查看。

ethtool -g eth0Ring parameters for eth0:
Pre-set maximums:       # 网卡硬件支持的 最大环形缓冲区大小(由驱动和硬件决定)。
RX:             256
RX Mini:        0
RX Jumbo:       0
TX:             256
Current hardware settings:  # 当前实际生效的缓冲区大小(可能小于最大值)
RX:             256
RX Mini:        0
RX Jumbo:       0
TX:             256ethtool -S eth0ethtool -G eth1 rx 4096 tx 4096

➥ 网络相关的硬中断、软中断都是什么?

➥ Linux里的ksoftirqd内核线程是干什么的?

软中断是在ksoftirqd内核线程中执行的。软中断的信息可以从/proc/softirqs读取。

cat /proc/softirqsCPU0       CPU1       HI:          0          8TIMER: 2180253675 1961595737NET_TX:      10777      11214NET_RX:  750389903  495902556BLOCK:   29736638   43074561
BLOCK_IOPOLL:          0          0TASKLET:      78938      80958SCHED:  740560152  619020629HRTIMER:          0          0RCU: 1755885593 1624702309

➥ 为什么网卡开启多队列能提升网络性能?

ethtool -l eth0
# 查看真正生效的队列数。
ls /sys/class/net/eth0/queues
drwxr-xr-x. 2 root root 0 721 11:07 rx-0
drwxr-xr-x. 3 root root 0 721 11:07 tx-0# 如果想加大队列数,ethtool工具可以搞定。
ethtool -L etho combined 32#通过/proc/interrupts可以看到该队列对应的硬件中断号
cat /proc/interruptsCPU0       CPU1       0:        194          0   IO-APIC-edge      timer1:         10          0   IO-APIC-edge      i80428:          0          0   IO-APIC-edge      rtc09:          0          0   IO-APIC-fasteoi   acpi12:         15          0   IO-APIC-edge      i804216:          0          0   IO-APIC-fasteoi   i801_smbus20:         36          0   IO-APIC-fasteoi   uhci_hcd:usb321:          0          0   IO-APIC-fasteoi   ehci_hcd:usb122:    2435979          0   IO-APIC-fasteoi   ehci_hcd:usb2, virtio5
.... 59:          0          0   PCI-MSI-edge      virtio0-config  #虚拟网卡060:  746889171  492867245   PCI-MSI-edge      virtio0-input.061:       4844       7639   PCI-MSI-edge      virtio0-output.062:          0          0   PCI-MSI-edge      virtio2-config63:         31          0   PCI-MSI-edge      virtio2-virtqueues64:          0          0   PCI-MSI-edge      virtio3-config65:   22161752   27894799   PCI-MSI-edge      virtio3-req.066:          0          0   PCI-MSI-edge      virtio1-config67:          0          0   PCI-MSI-edge      virtio1-control68:          0          0   PCI-MSI-edge      virtio1-event69:        256          0   PCI-MSI-edge      virtio1-request70:    2958274    9948509   PCI-MSI-edge      0000:00:1f.271:          0          0   PCI-MSI-edge      virtio4-config72:   13188865   15891894   PCI-MSI-edge      virtio4-req.0
NMI:          0          0   Non-maskable interrupts
......
PIN:          0          0   Posted-interrupt notification event
NPI:          0          0   Nested posted-interrupt event
PIW:          0          0   Posted-interrupt wakeup event# 通过该中断号对应的smp_affinity可以查看到亲和的CPU核是哪一个。
cat /proc/irq/53/smp_affinity
8
# 这个亲和性是通过二进制中的比特位来标记的。例如8是二进制的1000,第4位为1,代表的就是第4个CPU核心 CPU3。

➥ tcpdump是如何工作的?

➥ iptable/netfilter是在哪一层实现的?

netfilter主要是在IPARP等层实现的。可以通过搜索对NF_HOOK函数的引用来深入了解netfilter的实现。如果配置过于复杂的规则,则会消耗过多的CPU,加大网络延迟。

➥ tcpdump能否抓到被iptable封禁的包?

tcpdump工作在设备层,将包送到IP层以前就能处理。而netfilter工作在IPARP等层。netfilter是在tcpdump后面工作的,所以iptable封禁规则影响不到tcpdump的抓包。

image-20250720102316043

不过发包过程恰恰相反,发包的时候,netfilter在协议层就被过滤掉了,所以tcpdump什么也看不到

➥ 网络接收过程中的CPU开销如何查看?

➥ DPDK是什么神器?

那么有没有办法让用户进程能绕开内核协议栈,自己直接从网卡接收数据呢?如果这样可行,那繁杂的内核协议栈处理、内核态到用户态内存拷贝开销、唤醒用户进程开销等就可以省掉了。确实有,DPDK就是其中的一种。

第3章 内核是如何与用户进程协作的

3.1 相关实际问题

网络包被从网卡送到协议栈,接下来内核还有一项重要的工作,就是在协议栈接收处理完输入包以后,要能通知到用户进程,让用户进程能够收到并处理这些数据。进程和内核配合有很多种方案,本章只深入分析两种典型的。

第一种是同步阻塞的方案(在Java中习惯叫BIO),一般都是在客户端使用。它的优点是使用起来非常方便,非常符合人的思维方式,但缺点就是性能较差。

第二种是多路IO复用的方案,这种方案在服务端用得比较多。Linux上多路复用方案有selectpollepoll,它们三个中epoll的性能表现是最优秀的

➥ 阻塞到底是怎么一回事?

➥ 同步阻塞IO都需要哪些开销?

➥ 多路复用epoll为什么就能提高网络性能?

➥ epoll也是阻塞的?

➥ 为什么Redis的网络性能很突出?

3.2 socket的直接创建

socket内核结构

图中,右边的struct应该是sock

当软中断上收到数据包时会通过调用sk_data_ready函数指针(实际被设置成了sock_def_readable())来唤醒在sock上等待的进程。

至此,一个TCP对象,确切地说是AF_INET协议族下的SOCK_STREAM对象就算创建完成了。这里花费了一次socket系统调用的开销。

3.3 内核和用户进程协作之阻塞方式

在同步阻塞IO模型中,先是用户进程发起创建socket的指令,然后切换到内核态完成了内核对象的初始化。接下来,Linux在数据包的接收上,是硬中断ksoftirqd线程在进行处理。当ksoftirqd线程处理完以后,再通知相关的用户进程

image-20250720111009061

3.3.1 等待接收消息

接下来看recv函数依赖的底层实现。首先通过strace命令跟踪,可以看到clib库函数recv会执行recvform系统调用。

进入系统调用后,用户进程就进入了内核态,执行一系列的内核协议层函数,然后到socket对象的接收队列中查看是否有数据,没有的话就把自己添加到socket对应的等待队列里。最后让出CPU,操作系统会选择下一个就绪状态的进程来执行。

image-20250720111321263

后面当内核收完数据产生就绪事件的时候,就可以查找socket等待队列上的等待项,进而可以找到回调函数和在等待该socket就绪事件的进程了。

最后调用sk_wait_event让出CPU,进程将进入睡眠状态,这会导致一次进程上下文的开销,而这个开销是昂贵的,大约需要消耗几个微秒的CPU时间。

3.3.2 软中断模块

TCP协议的接收函数tcp_v4_rcv接收流程:

image-20250720111758842

软中断(也就是Linux里的ksoftirqd线程)里收到数据包以后,发现是TCP包就会执行tcp_v4_rcV函数。接着往下,如果是ESTABLISH状态下的数据包,则最终会把数据拆出来放到对应socket的接收队列中,然后调用sk_data_ready来唤醒用户进程。

💡即使有多个进程都阻塞在同一个socket上,也只唤醒一个进程。其作用是为了避免“惊群”,而不是把所有的进程都唤醒。 在socket上等待而被阻塞的进程就被推入可运行队列里了,这又将产生一次进程上下文切换的开销。

3.4 内核和用户进程协作之epoll

很多连接中的某条上有IO事件发生时直接快速把它找出来。其实这个事情Linux操作系统已经替我们都
做好了,它就是我们所熟知的IO多路复用机制。这里的复用指的就是对进程的复用

在Linux上多路复用方案有selectpollepoll。它们三个中的epoll的性能表现是最优秀的,能支持的并发量也最大。

3.4.1 epoll内核对象的创建

在用户进程调用epoll_create时,内核会创建一个struct eventpoll的内核对象,并把它关联当前进程已打开文件列表中,进程与epoll关系如下:

image-20250720113231113

epoll对象结构:

image-20250720113330154

eventpoll这个结构体中的几个成员的含义如下:

wq等待队列链表。软中断数据就绪的时候会通过wq来找到阻塞epoll对象上的用户进程

rbr:一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpol内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有socket连接

rdllist:就绪的描述符的链表。当有连接就绪的时候,内核会把就绪的连接放到rdllist链表里。这样应用进程只需要判断链表就能找出就绪连接,而不用去遍历整棵树。

3.4.2 为epoll添加socket

理解这一步是理解整个epoll的关键。

假设现在和客户端的多个连接socket都创建好了,也创建好了epoll内核对象。在使用epoll_ctl注册每一个socket的时候,内核会做如下三件事情:

1️⃣分配一个红黑树节点对象epitem

2️⃣将等待事件添加到socket等待队列中,其回调函数是ep_poll_callback

3️⃣将epitem插入epoll对象的红黑树。

image-20250720114150816

红黑树节点1317 是2个socket关系节点,通过ffd指向 struct file,最终指向 struct socketsocket 通过等待队列 引用等待事件。

3.4.3 epoll_wait之等待接收

epoll_wait做的事情不复杂,当它被调用时它观察eventpoll->rdllist链表里有没有数据。有数据就返回,没有数据就创建一个等待队列项,将其添加到eventpoll的等待队列上,然后把自己阻塞掉完事

image-20250720114724340

💡 当没有IO事件的时候,epoll也会阻塞掉当前进程。这个是合理的,因为没有事情可做了占着CPU也没什么意义。拿epoll来说,epoll本身是阻塞的,但一般会把socket设置成非阻塞。

3.4.4 数据来了

在前面epol_ctl执行的时候,内核为每一个socket都添加了一个等待队列项。在epoll_wait运行完的时候,又在eventpoll对象上添加了等待队列元素。

➥ 将数据接收到任务队列

tcp_v4_rcv中首先根据收到的网络包的header里的sourcedest信息在本机上查询对应的socket。找到以后,是ESTABLISH状态下的包,这样就又进入tcp_rcv_established函数中进行处理了。将数据接收到队列中,唤醒socket上阻塞掉的进程。

image-20250720123038022

➥ 查找就绪回调函数

调用tcp_queue_rcv完成接收之后,接着再调用sk_data_ready来唤醒在socket等待的用户进程。这又是一个函数指针。在accept函数创建socket流程里提到的sock_init_data函数,其中已经把sk_data_ready设置成sock_def_readable函数了。它是默认的数据就绪处理函数

socket上数据就绪时,内核将以sock_def_readable这个函数为入口,找到epoll_ctl添加socket时在其上设置的回调函数ep_poll_callback

➥ 执行socket就绪回调函数

找到了socket等待队列项里注册的函数ep_poll_callback,接着软中断就会调用它。 在ep_poll_callback中根据等待任务队列项上额外的base指针可以找到epitem,进而也可以找到eventpoll对象。它做的第一件事就是把自己的epitem添加到epoll的就绪队列中。接着它又会查看eventpoll对象上的等待队列里是否有等待项(epoll_wait执行的时候会设置)。如果没有等待项,软中断的事情就做完了。如果有等待项,那就找到等待项里设置的回调函数。

➥ 执行epoll就绪通知

default_wake_function中找到等待队列项里的进程描述符,然后唤醒它.

💡在实践中,只要活儿足够多,epoll_wait根本不会让进程阻塞。用户进程会一直干活儿,一直干活儿,直到epoll_wait里实在没活儿可干的时候才主动让出CPU。这就是epoll高效的核心原因所在!

总结

➥ 阻塞到底是怎么一回事?

网络开发模型中,经常会遇到阻塞和非阻塞的概念。阻塞其实说的是进程因为等待某个事件而主动让出CPU挂起的操作。在网络IO中,当进程等待socket上的数据时,如果数据还没有到来,那就把当前进程状态从TASK_RUNNKNG修改为TASK_INTERRUPTIPLE,然后主动让出CPU。由调度器来调度下一个就绪状态的进程来执行。

所以,以后你在分析某个技术方案是不是阻塞的时候,关键要看进程有没有放弃CPU。如果放弃了,那就是阻塞。如果没放弃,那就是非阻塞。事实上,recvfrom也可以设置成非阻塞。在这种情况下,如果socket上没有数据到达,调用直接返回空,而不是挂起等待。

➥ 同步阻塞IO都需要哪些开销?

  • 进程通过recv系统调用接收一个socket上的数据时,如果数据没有达到,进程就被从CPU上拿下来,然后再换上另一个进程。这导致一次进程上下文切换的开销。
  • 当连接上的数据就绪的时候,睡眠的进程又会被唤醒,又是一次进程切换的开销。
  • 一个进程同时只能等待一条连接,如果有很多并发,则需要很多进程。每个进程都将占用大约几MB的内存。

➥ 多路复用epoll为什么就能提高网络性能?

其实epoll高性能最根本的原因是极大程度地减少了无用的进程上下文切换,让进程更专注地处理网络请求。

在内核的硬、软中断上下文中,包从网卡接收过来进行处理,然后放到socket的接收队列。再找到socket关联的epitem,并把它添加到epoll对象的就绪链表中。在用户进程中,通过调用epoll_wait来查看就绪链表中是否有事件到达,如果有,直
接取走进行处理。处理完毕再次调用epoll_wait。在高并发的实践中,只要活儿足够多,epoll_wait根本不会让进程阻塞。用户进程会一直干活儿,一直干活儿,直到epoll_wait里实在没活儿可干的时候才主动让出CPU。至于红黑树,仅仅是提高了epoll查找、添加、删除socket时的效率而已,不算epoll在高并发场景高性能的根本原因。

➥ epoll也是阻塞的?

很多人以为只要一提到阻塞,就是性能差,其实这就冤枉了阻塞。阻塞说的是进程因为等待某个事件而主动让出CPU挂起的操作。

例如,一个epoll对象下添加了一万个客户端连接的socket。假设所有这些socket上都还没有数据达到,这个时候进程调用epoll_wait发现没有任何事情可干。这种情况下用户进程就会被阻塞掉,而这种情况是完全正常的,没有工作需要处理,那还占着CPU是没有道理的。

阻塞不会导致低性能,过多过频繁的阻塞才会epoll的阻塞和它的高性能并不冲突。

➥ 为什么Redis的网络性能很突出?

第4章 内核是如何发送网络包的

4.1 相关实际问题

➥ 在查看内核发送数据消耗的CPU时,应该看sy还是si?

➥ 在服务器上查看/proc/softirqs,为什么NET_RX要比NET_TX大得多的多?

➥ 发送网络数据的时候都涉及哪些内存拷贝操作?

➥ 零拷贝到底是怎么回事?

➥ 为什么Kafka的网络性能很突出?

4.2 网络包发送过程总览

image-20250720125041449

用户数据被拷贝到内核态,然后经过协议栈处理后进入RingBuffer。随后网卡驱动真正将数据发送了出去。当发送完成的时候,是通过硬中断来通知CPU,然后清理RingBuffer

4.3 网卡启动准备

现在的服务器上的网卡一般都是支持多队列的。每一个队列都是由一个RingBuffer表示的,开启了多队列以后的网卡就会对应有多个RingBuffer

网卡在启动时最重要的任务之一就是分配和初始化RingBuffer

4.4 数据从用户进程到网卡的详细过程

4.4.1 send系统调用实现

send系统调用内部其实真正使用的是sendto系统调用。整个调用链条虽然不短,但其实主要只干了两件简单的事情:

  • 第一是在内核中把真正的socket找出来,在这个对象里记录着各种协议栈的函数地址。
  • 第二是构造一个 struct msghdr对象,把用户传入的数据,比如buffer地址、数据长度什么的,都装进去。

剩下的事情就交给下一层,协议栈里的函数inet_sendmsg了,其中inet_sendmsg函数的地址是通过sockot内核对象里的ops成员找到。

image-20250720142914014

4.4.2 传输层处理

➥ 传输层拷贝

在进入协议栈inet_sendmsg以后,内核接着会找到socket上的具体协议发送函数。对于TCP协议来说,那就是tcp_sendmsg(同样也是通过socket内核对象找到的)。

在这个函数中,内核会申请一个内核态skb内存,将用户待发送的数据拷贝进去。注意,这个时候不一定会真正开始发送,如果没有达到发送条件,很可能这次调用直接就返回了

image-20250720143210010

理解对socket调用tcp_write_queue_tail是理解发送的前提这个函数是在获取socket发送队列中的最后一个skbskbstruct sk_buff对象的简称,用户的发送队列就是该对象组成的一个链表。

用户态内存要发送的数据的buffer。接下来在内核态申请内核内存,比如skb,并把用户内存里的数据拷贝到内核态内存中,这就会涉及一次或者几次内存拷贝的开销。

只有满足 forced_push(tp)或者 skb == tcp_send_head(sk)成立的时候,内核才会真正启动发送数据包。其中forced_push(tp)判断的是未发送的数据是否已经超过最大窗口的一半了。

条件都不满足的话,这次用户要发送的数据只是拷贝到内核就算完事了!

➥ 传输层发送

当满足真正发送条件的时候,无论调用的是_tcp_push_pending_frames还是tcp_push_one,最终都会实际执行到tcp_write_xmit。这个函数处理了传输层的拥塞控制、滑动窗口相关的工作。满足窗口要求的时候,设置TCP头然后将skb传到更低的网络层进行处理。

image-20250720144220236

第一件事是先克隆一个新的skb,为什么要复制一个skb出来?

这是因为skb后续在调用网络层,最后到达网卡发送完成的时候,这个skb被释放掉。而我们知道TCP协议是支持丢失重传的,在收到对方的ACK之前,这个skb不能被删除。所以内核的做法就是每次调用网卡发送的时候,实际上传递出去的是skb的一个拷贝,等收到ACK再真正删除

第二件事是修改skb中的TCP头,根据实际情况把TCP头设置好。skb内部其实包含了网络协议中所有的头(header)。在设置TCP头的时候,只是把指针指向skb的合适位置。后面设置IP头的时候,再把指针挪一挪就行,避免频繁的内存申请和拷贝,效率很高。

image-20250720144540185

4.4.3 网络层发送处理

​ Linux内核网络层的发送的实现位于net/ipv4/ip_output.c这个文件。传输层调用到的ip_queue_xmit 也在这里。

在网络层主要处理路由项查找、IP头设置、netfilter过滤、skb切分(大于MTU的话)

image-20250720144733300

在调用ip_local_out =>_ip_local_out =>nf _hook的过程中会执行netfilter过滤。如果使用iptables配置了一些规则,那么这里将检测是否命中规则。如果设置了非常复杂的netfilter规则,在这里这个函数将会导致进程CPU开销大增。

ip_finish_output中可以看到,如果数据大于MTU,是会执行分片的。

实际MTU大小通过MTU发现机制确定,在以太网中为1500字节。

因为分片会带来两个问题:

  • 1.需要进行额外的切分处理,有额外性能开销;
  • 2.只要一个分片丢失,整个包都要重传。

所以避免分片既杜绝了分片开销,也大大降低了重传率

4.4.4 邻居子系统

​ 邻居子系统是位于网络层数据链路层中间的一个系统,其作用是为网络层提供一个下层的封装,让网络层不必关心下层的地址信息,让下层来决定发送到哪个MAC地址。

而且这个邻居子系统并不位于协议栈net/ipv4/目录内,而是位于net/core/neighbour.C。因为无论是对于IPv4还是IPv6,都需要使用该模块。

image-20250720145326119

在邻居子系统里主要查找或者创建邻居项,在创建邻居项的时候,有可能会发出实际的arp请求。然后封装MAC头,将发送过程再传递到更下层的网络设备子系统。

image-20250720145421983

4.4.5 网络设备子系统

邻居子系统通过dev_queue_xmit进入网络设备子系统。

image-20250720145534022

在代码__qdisc_run中可以看到,while循环不断地从队列中取出skb并进行发送。注意,这个时候其实都占用的是用户进程系统态时间(sy)。只有当quota用尽或者其他进程需要CPU的时候才触发软中断进行发送。所以这就是为什么在服务器上查看/proc/softirqs,一般NET_RX都要比NET_TX大得多的第二个原因。对于接收来说,都要经过NET_RX软中断,而对于发送来说,只有系统态配额用尽才让软中断上

4.4.6 软中断调度

如果发送网络包的时候系统态CPU用尽了,会调用__netif_schedule触发一个软中断。该函数会进入__netif_reschedule,由它来实际发出NET_TX_SOFTIRQ类型软中断。软中断是由内核进程来运行的,该进程会进入net_tx_action函数,在该函数中能获取发送队列,并也最终调用到驱动程序里的入口函数dev_hard_start_xmit

image-20250720150303244

💡这以后发送数据消耗的CPU就都显示在si这里,不会消耗用户进程的系统时间。

4.4.7 igb网卡驱动发送

无论对于用户进程的内核态,还是对于软中断上下文,都会调用网络设备子系统中的dev_hard_start_xmit函数。在这个函数中,会调用到驱动里的发送函数igb_xmit_frame。在驱动函数里,会将skb挂到RingBuffer上,驱动调用完毕,数据包将真正从网卡发送出去

image-20250720150619212

从网卡的发送队列的RingBuffer中取下来一个元素,并将skb挂到元素上,igb_x_map函数将skb数据映射到网卡可访问的内存DMA区域。 当所有需要的描述符都已建好,且skb所有数据都映射到DMA地址后,驱动就会进入到它的最后一步,触发真实的发送

4.5 RingBuffer内存回收

当数据发送完以后,其实工作并没有结束。因为内存还没有清理。当发送完成的时候,网卡设备会触发一个硬中断来释放内存。在发送硬中断的过程里,会执行RingBuffer内存的清理工作。

image-20250720151039780

无论硬中断是因为有数据要接收,还是发送完成通知,从硬中断触发的软中断都是NET_RX_SOFTIRQ。它是软中断统计
RX要高于TX的一个原因。

总结

➥ 在查看内核发送数据消耗的CPU时,应该看sy还是si?

在网络包的发送过程中,用户进程(在内核态)完成了绝大部分的工作,甚至连调用驱动的工作都干了。只当内核态进程被切走前才会发起软中断。发送过程中,绝大部分(90%)以上的开销都是在用户进程内核态消耗掉的。只有一少部分情况才会触发软中断(NET_TX类型),由软中断ksoftirqd内核线程来发送。所以,在监控网络IO对服务器造成的CPU开销的时候,不能仅看si,而是应该把sisy都考虑进来。

image-20250720151535821

➥ 在服务器上查看/proc/softirqs,为什么NET_RX要比NET_TX大得多的多?

💡错误认识:NET_RX是接收,NET_TX是传输。对于一个既收取用户请求,又给用户返回的服务器来说,这两块的数字应该差不多才对,至少不会有数量级的差异。

第一个原因是当数据发送完以后,通过硬中断的方式来通知驱动发送完毕。但是硬中断无论是有数据接收,还是发送完毕,触发的软中断都是NET_RX_SOFTIRQ,并不是NET_TX_SOFTIRQ

第二个原因是对于来说,都是要经过NET_RX软中断的,都走ksoftirqd内核线程。而对于发送来说,绝大部分工作都是在用户进程内核态处理了,只有系统态配额用尽才会发出NET_TX,让软中断上。

➥ 发送网络数据的时候都涉及哪些内存拷贝操作?

这里的内存拷贝,只特指待发送数据的内存拷贝。

第一次拷贝操作是在内核申请完skb之后,这时候会将用户传递进来的buffer里的数据内容都拷贝到skb

第二次拷贝操作是从传输层进入网络层的时候,每一个skb都会被克隆出来一个新的副本。目的是保存原始的skb,当网络对方没有发回ACK的时候,还可以重新发送,以实现TCP中要求的可靠传输。不过这次只是浅拷贝,只拷贝skb描述符本身,所指向的数据还是复用的。

第三次拷贝不是必需的,只有当IP层发现skb大于MTU时才需要进行。此时会再申请额外的skb,并将原来的skb拷贝为多个小的skb

💡大家在谈论网络性能优化中经常听到“零拷贝”,有一点点夸张的成分。TCP为了保证可靠性,第二次的拷贝根本就没法省。如果包大于MTU分片时的拷贝同样避免不了

➥ 零拷贝到底是怎么回事?

如果想把本机的一个文件通过网络发送出去,我们的做法之一就是先用read系统调用把文件读取到内存,然后再调用send把文件发送出去。

假设数据之前从来没有读取过,那么read硬盘上的数据需要经过两次拷贝才能到用户进程的内存。第一次是从硬盘DMAPageCache。第二次是从PageCache拷贝到用户内存send系统调用在前面讲过了。那么read+send系统调用发送一个文件出去数据需要经过的拷贝过程如图所示。

image-20250720152620108

💡数据经过了3次必须的深拷贝,加1次浅拷贝,再加1次非必要的深拷贝。共5次拷贝。

sendfile就是内核提供的一个可用来减少发送文件时拷贝开销的一个技术方案。在sendfile系统调用里,数据不需要拷贝到用户空间,在内核态就能完成发送处理,这就显著减少了需要拷贝的次数。

image-20250720152925571

💡零拷贝只是减少了与用户态内存2次拷贝。

➥ 为什么Kafka的网络性能很突出?

重要原因之一就是采用了sendfile系统调用来发送网络数据包,减少了内核态和用户态之间的频繁数据拷贝。

http://www.dtcms.com/a/300902.html

相关文章:

  • 新手开发 App,容易陷入哪些误区?
  • 北京-4年功能测试2年空窗-报培训班学测开-第六十一天-模拟面试第一次
  • 数据结构基础内容(第二篇:线性结构)
  • 智谱AI GLM大模型 GLM-4-Plus的快速使用 ChatOpenAI类来调用GLM-4模型
  • 2025第15届上海生物发酵展将于8月7号启幕
  • HBuilder X打包发布微信小程序
  • PDF转图片实用指南:如何批量高效转换?
  • cuda编程笔记(10)--memory access 优化
  • 《P4568 [JLOI2011] 飞行路线》
  • Flutter开发实战之性能优化与调试
  • 自动标注软件X-AnyLabeling的使用教程
  • OpenLayers 综合案例-地图绘制
  • 深入理解Linux网络--读书笔记(二)
  • HDFS基础命令
  • 简易 BMI 身体质量指数计算器
  • 墨者:SQL注入漏洞测试(布尔盲注)
  • FastAPI入门:查询参数模型、多个请求体参数
  • (LeetCode 面试经典 150 题)71. 简化路径 (字符串)
  • 小白投资理财 - 从换手率和成交量分析股票趋势
  • Vue vuex模块化编码
  • 网络资源模板--基于Android Studio 实现的新闻App
  • 自由学习记录(74)
  • 基于混沌系统的图像加密学习日志——论文学习3
  • unity3dTextMeshPro 设置中文字体,解决中文显示为框或中文字后面带背景颜色的问题
  • Unity SMAA
  • 三、搭建springCloudAlibaba2021.1版本分布式微服务-springcloud loadbalancer负载均衡
  • 习题综合练习
  • 自然语言处理NLP (1)
  • 【笔记】系统
  • 上位机知识篇---AJAX