深入理解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
网络分层模型应该是这样的。

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

2.2.2 Linux启动
➥ 创建ksoftirqd内核线程
Linux的软中断都是在专门的内核线程(ksoftirqd)中进行的,该线程数量不是1个,而是N个,其中N等于你的机器的核数。
➥ 网络子系统初始化
在网络子系统的初始化过程中,会为每个CPU初始化softnet_data
,也会为Rx_SOFTIRQ
和TX_SOFTIRQ
注册处理函数。
➥ 协议栈注册
内核实现了网络层的IP协议,也实现了传输层的TCP协议和UDP协议。这些协议对应的实现函数分别是ip_rcv0
、tcp_v4_rcv0
和udp_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_resources
和igb_setup_all_rx_resources
。在调用igb_setup_all_x_resources
这一步操作中,分配了RingBuffer
,并建立内存和Rx
队列的映射关系。(Rx
和Tx
队列的数量和大小可以通过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_rcv
、udp_rcv
等协议处理函数中进行处理。
在_netif_receive_skb_core
中,有原来经常使用的tcpdump命令的抓包点。tcpdump
是通过虚拟协议的方式工作的,它会将抓包函数以协议的形式挂到ptype_all
上。设备层遍历所有的“协议”,这样就能抓到数据包来供我们查看了。tcpdump
会执行到
packet_create
。
_netif_receive_skb_core
函数取出protocol
,它会从数据包中取出协议信息,然后遍历注册在这个协议上的回调函数列表。
➥ IP
层处理
IP层接收网络包的主入口ip_rcv
。NF_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 7月 21 11:07 rx-0
drwxr-xr-x. 3 root root 0 7月 21 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
主要是在IP
、ARP
等层实现的。可以通过搜索对NF_HOOK
函数的引用来深入了解netfilter的实现。如果配置过于复杂的规则,则会消耗过多的CPU,加大网络延迟。
➥ tcpdump能否抓到被iptable封禁的包?
tcpdump
工作在设备层,将包送到IP层以前就能处理。而netfilter
工作在IP
、ARP
等层。netfilter
是在tcpdump
后面工作的,所以iptable
封禁规则影响不到tcpdump的抓包。
不过发包过程恰恰相反,发包的时候,netfilter
在协议层就被过滤掉了,所以tcpdump
什么也看不到
➥ 网络接收过程中的CPU开销如何查看?
➥ DPDK是什么神器?
那么有没有办法让用户进程能绕开内核协议栈,自己直接从网卡接收数据呢?如果这样可行,那繁杂的内核协议栈处理、内核态到用户态内存拷贝开销、唤醒用户进程开销等就可以省掉了。确实有,DPDK就是其中的一种。
第3章 内核是如何与用户进程协作的
3.1 相关实际问题
网络包被从网卡送到协议栈,接下来内核还有一项重要的工作,就是在协议栈接收处理完输入包以后,要能通知到用户进程,让用户进程能够收到并处理这些数据。进程和内核配合有很多种方案,本章只深入分析两种典型的。
第一种是同步阻塞的方案(在Java中习惯叫BIO
),一般都是在客户端使用。它的优点是使用起来非常方便,非常符合人的思维方式,但缺点就是性能较差。
第二种是多路IO复用的方案,这种方案在服务端用得比较多。Linux上多路复用方案有select
、poll
、epoll
,它们三个中epoll的性能表现是最优秀的
➥ 阻塞到底是怎么一回事?
➥ 同步阻塞IO都需要哪些开销?
➥ 多路复用epoll为什么就能提高网络性能?
➥ epoll也是阻塞的?
➥ 为什么Redis的网络性能很突出?
3.2 socket的直接创建

图中,右边的
struct
应该是sock
当软中断上收到数据包时会通过调用sk_data_ready
函数指针(实际被设置成了sock_def_readable()
)来唤醒在sock上等待的进程。
至此,一个TCP
对象,确切地说是AF_INET
协议族下的SOCK_STREAM
对象就算创建完成了。这里花费了一次socket
系统调用的开销。
3.3 内核和用户进程协作之阻塞方式
在同步阻塞IO模型中,先是用户进程发起创建socket
的指令,然后切换到内核态完成了内核对象的初始化。接下来,Linux在数据包的接收上,是硬中断和ksoftirqd
线程在进行处理。当ksoftirqd
线程处理完以后,再通知相关的用户进程。
3.3.1 等待接收消息
接下来看recv
函数依赖的底层实现。首先通过strace
命令跟踪,可以看到clib库函数recv
会执行recvform
系统调用。
进入系统调用后,用户进程就进入了内核态,执行一系列的内核协议层函数,然后到socket
对象的接收队列中查看是否有数据,没有的话就把自己添加到socket
对应的等待队列里。最后让出CPU,操作系统会选择下一个就绪状态的进程来执行。
后面当内核收完数据产生就绪事件的时候,就可以查找socket
等待队列上的等待项,进而可以找到回调函数和在等待该socket就绪事件的进程了。
最后调用sk_wait_event
让出CPU,进程将进入睡眠状态,这会导致一次进程上下文的开销,而这个开销是昂贵的,大约需要消耗几个微秒的CPU时间。
3.3.2 软中断模块
TCP协议的接收函数tcp_v4_rcv
接收流程:
软中断(也就是Linux里的ksoftirqd
线程)里收到数据包以后,发现是TCP包就会执行tcp_v4_rcV
函数。接着往下,如果是ESTABLISH
状态下的数据包,则最终会把数据拆出来放到对应socket
的接收队列中,然后调用sk_data_ready
来唤醒用户进程。
💡即使有多个进程都阻塞在同一个socket上,也只唤醒一个进程。其作用是为了避免“惊群”,而不是把所有的进程都唤醒。 在socket上等待而被阻塞的进程就被推入可运行队列里了,这又将产生一次进程上下文切换的开销。
3.4 内核和用户进程协作之epoll
在很多连接中的某条上有IO事件发生时直接快速把它找出来。其实这个事情Linux操作系统已经替我们都
做好了,它就是我们所熟知的IO多路复用机制。这里的复用指的就是对进程的复用。
在Linux上多路复用方案有select
、poll
、epoll
。它们三个中的epoll的性能表现是最优秀的,能支持的并发量也最大。
3.4.1 epoll内核对象的创建
在用户进程调用epoll_create
时,内核会创建一个struct eventpoll
的内核对象,并把它关联到当前进程的已打开文件列表中,进程与epoll
关系如下:
epoll对象结构:
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
对象的红黑树。
红黑树节点13
和17
是2个socket
关系节点,通过ffd
指向 struct file
,最终指向 struct socket
。 socket
通过等待队列 引用等待事件。
3.4.3 epoll_wait之等待接收
epoll_wait
做的事情不复杂,当它被调用时它观察eventpoll->rdllist
链表里有没有数据。有数据就返回,没有数据就创建一个等待队列项,将其添加到eventpoll
的等待队列上,然后把自己阻塞掉完事
💡 当没有IO事件的时候,
epoll
也会阻塞掉当前进程。这个是合理的,因为没有事情可做了占着CPU也没什么意义。拿epoll
来说,epoll
本身是阻塞的,但一般会把socket
设置成非阻塞。
3.4.4 数据来了
在前面epol_ctl
执行的时候,内核为每一个socket
都添加了一个等待队列项。在epoll_wait
运行完的时候,又在eventpoll
对象上添加了等待队列元素。
➥ 将数据接收到任务队列
在tcp_v4_rcv
中首先根据收到的网络包的header
里的source
和dest
信息在本机上查询对应的socket
。找到以后,是ESTABLISH
状态下的包,这样就又进入tcp_rcv_established
函数中进行处理了。将数据接收到队列中,唤醒socket
上阻塞掉的进程。
➥ 查找就绪回调函数
调用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 网络包发送过程总览
用户数据被拷贝到内核态,然后经过协议栈处理后进入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
成员找到。
4.4.2 传输层处理
➥ 传输层拷贝
在进入协议栈inet_sendmsg
以后,内核接着会找到socket
上的具体协议发送函数。对于TCP协议来说,那就是tcp_sendmsg(同样也是通过socket内核对象找到的)。
在这个函数中,内核会申请一个内核态的skb
内存,将用户待发送的数据拷贝进去。注意,这个时候不一定会真正开始发送,如果没有达到发送条件,很可能这次调用直接就返回了。
理解对socket
调用tcp_write_queue_tail
是理解发送的前提这个函数是在获取socket
发送队列中的最后一个skb
。skb
是struct 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
传到更低的网络层进行处理。
第一件事是先克隆一个新的skb
,为什么要复制一个skb
出来?
这是因为skb
后续在调用网络层,最后到达网卡发送完成的时候,这个skb
会被释放掉。而我们知道TCP协议是支持丢失重传的,在收到对方的ACK
之前,这个skb
不能被删除。所以内核的做法就是每次调用网卡发送的时候,实际上传递出去的是skb
的一个拷贝,等收到ACK
再真正删除。
第二件事是修改skb
中的TCP头,根据实际情况把TCP头设置好。skb
内部其实包含了网络协议中所有的头(header)。在设置TCP头的时候,只是把指针指向skb
的合适位置。后面设置IP
头的时候,再把指针挪一挪就行,避免频繁的内存申请和拷贝,效率很高。
4.4.3 网络层发送处理
Linux内核网络层的发送的实现位于net/ipv4/ip_output.c
这个文件。传输层调用到的ip_queue_xmit
也在这里。
在网络层主要处理路由项查找、IP头设置、netfilter
过滤、skb
切分(大于MTU
的话)
在调用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
,都需要使用该模块。
在邻居子系统里主要查找或者创建邻居项,在创建邻居项的时候,有可能会发出实际的arp
请求。然后封装MAC
头,将发送过程再传递到更下层的网络设备子系统。
4.4.5 网络设备子系统
邻居子系统通过dev_queue_xmit
进入网络设备子系统。
在代码__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
。
💡这以后发送数据消耗的CPU
就都显示在si
这里,不会消耗用户进程的系统时间。
4.4.7 igb网卡驱动发送
无论对于用户进程的内核态,还是对于软中断上下文,都会调用网络设备子系统中的dev_hard_start_xmit
函数。在这个函数中,会调用到驱动里的发送函数igb_xmit_frame
。在驱动函数里,会将skb
挂到RingBuffer
上,驱动调用完毕,数据包将真正从网卡发送出去。
从网卡的发送队列的RingBuffer
中取下来一个元素,并将skb
挂到元素上,igb_x_map
函数将skb
数据映射到网卡可访问的内存DMA
区域。 当所有需要的描述符都已建好,且skb
的所有数据都映射到DMA
地址后,驱动就会进入到它的最后一步,触发真实的发送。
4.5 RingBuffer内存回收
当数据发送完以后,其实工作并没有结束。因为内存还没有清理。当发送完成的时候,网卡设备会触发一个硬中断来释放内存。在发送硬中断的过程里,会执行RingBuffer
内存的清理工作。
无论硬中断是因为有数据要接收,还是发送完成通知,从硬中断触发的软中断都是NET_RX_SOFTIRQ
。它是软中断统计
中RX
要高于TX
的一个原因。
总结
➥ 在查看内核发送数据消耗的CPU时,应该看sy还是si?
在网络包的发送过程中,用户进程(在内核态)完成了绝大部分的工作,甚至连调用驱动的工作都干了。只当内核态进程被切走前才会发起软中断。发送过程中,绝大部分(90%)以上的开销都是在用户进程内核态消耗掉的。只有一少部分情况才会触发软中断(NET_TX
类型),由软中断ksoftirqd
内核线程来发送。所以,在监控网络IO对服务器造成的CPU开销的时候,不能仅看si
,而是应该把si
、sy
都考虑进来。
➥ 在服务器上查看/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
硬盘上的数据需要经过两次拷贝才能到用户进程的内存。第一次是从硬盘DMA
到PageCache
。第二次是从PageCache
拷贝到用户内存。send
系统调用在前面讲过了。那么read+send
系统调用发送一个文件出去数据需要经过的拷贝过程如图所示。
💡数据经过了
3
次必须的深拷贝,加1
次浅拷贝,再加1
次非必要的深拷贝。共5
次拷贝。
sendfile
就是内核提供的一个可用来减少发送文件时拷贝开销的一个技术方案。在sendfile
系统调用里,数据不需要拷贝到用户空间,在内核态就能完成发送处理,这就显著减少了需要拷贝的次数。
💡零拷贝只是减少了与用户态内存的
2
次拷贝。
➥ 为什么Kafka的网络性能很突出?
重要原因之一就是采用了sendfile
系统调用来发送网络数据包,减少了内核态和用户态之间的频繁数据拷贝。