0. intro
《理解了实现再谈网络性能》,作者张彦飞,github
1. 内核是如何接收网络包的
在Linux内核实现中,链路层协议靠网卡驱动来实现,网络层、传输层由内核协议栈来完成。
内核收包的路径示意图:
注意:当RingBuffer满的时候,新来的数据包将给丢弃。ifconfig查看⽹卡的时候,可以 ⾥⾯有个overruns,表示因为环形队列满被丢弃的包。如果发现有丢包,可能需要通过ethtool命令来加⼤环形队列的⻓度
⾸先在开始收包之前,Linux 要做许多的准备⼯作:
- 创建ksoftirqd线程,为它设置好它⾃⼰的线程函数(后⾯使用它来处理软中断)
- 协议栈注册,linux要实现许多协议,⽐如arp,icmp,ip,udp,tcp,每⼀个协议都会将⾃⼰的处理函数注册⼀下,⽅便包来了迅速找到对应的处理函数
- ⽹卡驱动初始化,每个驱动都有⼀个初始化函数,内核会让驱动也初始化⼀下。在这个初始化过程中,把⾃⼰的DMA准备好,把NAPI的poll函数地址告诉内核
- 启动⽹卡,分配RX,TX队列,注册中断对应的处理函数
以上是内核准备收包之前的重要⼯作,等这些步骤都 ready 之后,打开硬中断,等待数据包的到来:
- ⽹卡将数据帧 DMA 到内存的 RingBuffer 中,然后向 CPU 发起中断通知
- CPU 响应中断请求,调⽤⽹卡启动时注册的中断处理函数。中断处理函数完成后,发起软中断请求
- 内核线程 ksoftirqd 线程发现有软中断请求到来,先关闭硬中断。
- ksoftirqd 线程开始调⽤驱动的 poll 函数收包
- poll 函数将收到的包送到协议栈注册的 ip_rcv 函数中
- ip_rcv 函数再将包送到 udp_rcv 函数中(对于 tcp 包就送到 tcp_rcv )
2. 内核是如何与进程进行协作的
- sock_owned_by_user 判断的是⽤户是不是正在这个 socket 上进⾏系统调⽤( socket 被占⽤),如果没有,那就可以直接放到 socket 的接收队列中。如果有,那就通过 sk_add_backlog 把数据包添加到 backlog 队列。
- 当⽤户释放的 socket 的时候,内核会检查 backlog 队列,如果有数据再移动到接收队列中。
- sk_rcvqueues_full 接收队列如果满了的话,将直接把包丢弃。- 接收队列⼤⼩受内核参数 net.core.rmem_max 和 net.core.rmem_default 影响
UDP 下用户进程如何和内核协同
内核在通知⽹络包的运⾏环境分两部分:
- 第⼀部分是我们⾃⼰代码所在的进程,我们调⽤的 socket() 函数会进⼊内核态创建必要内核对象。 recv() 函数在进⼊内核态以后负责查看接收队列,以及在没有数据可处 理的时候把当前进程阻塞掉,让出 CPU。
- 第⼆部分是硬中断、软中断上下⽂(系统进程 ksoftirqd)。在这些组件中,将包处理完后会放到 socket 的接收队列中。然后再根据 socket 内核对象找到其等待队列中正在因为等待⽽被阻塞掉的进程,然后把它唤醒。
每次⼀个进程专⻔为了等⼀个 socket 上的数据就得被从 CPU 上拿下来。然后再换上另⼀个进程。等到数据 ready 了,睡眠的进程⼜会被唤醒。总共两次进程上下⽂切换开销,根据之前的测试来看,每⼀次切换⼤约是 3-5 us(微秒)左右。 如果是⽹络 IO 密集型的应⽤的话,CPU 就不停地做进程切换这种⽆⽤功。在服务端⻆⾊上,这种模式完全没办法使⽤。因为这种简单模型⾥的 socket 和进程是⼀对⼀ 的。
2.3 IO 多路复⽤ EPOLL 内部实现