image

image

image

image

image

网络设备驱动

准备从网络接收数据

image

如果目的地址不是该网卡,且该网卡没有开启混杂模式,该包会被网卡丢弃。

网卡使用 DMA 将数据直接写到内存,操作系统可以直接从里面读取。

网卡在启动时会申请一个接收 ring buffer,也就是 rx ring buffer。 驱动在内存中分配一片缓冲区(skb)用来接收数据包。 rx ring buffer 指向 skb,接收 skb 的地址是 DMA 使用的物理地址。

这块内存区域是有限的,如果数据包的速率非常快,单个 CPU 来不及取走这些包,新来的包就会被丢弃。 ifconfig 查看网卡的时候,可以里面有个 overruns。 这时候,Receive Side Scaling(RSS 接收端扩展)或者多队列(multiqueue)一类的技术可能就会排上用场。

RX 队列的数量和大小可以通过 ethtool 进行配置,调整这两个参数会对收包或者丢包产生可见影响。

一些网卡有能力将接收到的包写到多个不同的内存区域,每个区域都是独立的接收队列。 这样操作系统就可以利用多个 CPU 并行处理收到的包。 网卡通过对 packet header(源地址、目的地址、端口等)做哈希来决定将 packet 放到哪个 RX 队列。

驱动通知网卡有一个新的描述符。 网卡从 rx ring buffer 中取出描述符,从而获知 skb 的地址和大小。 网卡收到新的数据包,将新数据包通过 DMA 直接写到 skb 中。

驱动也负责解绑(unmap)这些内存,读取数据,将数据送到网络栈。

硬件中断 IRQ

当一个数据帧通过 DMA 写到 RAM 内存后,网卡会产生一个硬件中断 IRQ。 网卡通过硬件中断 IRQ(NET_RX_SOFTIRQ 是设置对应软中断的标志位) 通知 CPU,告诉它有数据来了。

CPU 将数据总线的权利暂时交给 DMA,DMA 通过驱动将数据写入 DMA 相应的内存中。 DMA 数据写好,然后才会唤醒硬中断。

CPU 根据中断向量表,调用已经注册的中断函数。 这个中断函数会调到驱动程序 NIC Driver 中相应的函数。

驱动先禁用网卡的中断,表示驱动程序已经知道内存中有数据了。 告诉网卡下次再收到数据包直接写内存就可以了,不要再通知 CPU 了。 这样可以提高效率,避免 CPU 不停的被中断。

有三种常见的硬中断类型:

  • MSI-X
  • MSI
  • legacy IRQ

MSI-X 中断是比较推荐的方式,尤其是对于支持多队列的网卡。 因为每个 RX 队列有独立的 MSI-X 中断,因此可以被不同的 CPU 处理。 通过 irqbalance 方式,或者修改 /proc/irq/IRQ_NUMBER/smp_affinity

Enable NAPI

如果有大量的数据包到达,就会产生大量的硬件中断。 CPU 忙于处理硬件中断的时候,可用于处理其他任务的时间就会减少。

NAPI(New API)是一种新的机制,一次中断会接收多个包,可以减少产生的硬件中断的数量,但不能完全消除硬中断。

NAPI 存在的意义是无需硬件中断通知就可以接收网络数据。

NAPI 接收数据包的方式和传统方式不同,它允许设备驱动注册一个 poll 方法,然后调用这个方法完成收包。

NAPI poll 注册后,直到网卡被启用之后,NAPI 才被启用。

NAPI 启用后并不是立即开始工作,而是等硬中断触发。

NAPI 的使用方式:

  1. 驱动在初始化的时候注册 NAPI poll 方法。
  2. 驱动打开 NAPI 功能,默认没有在收包,处于未工作状态。
  3. 数据包到达,网卡通过 DMA 写到内存。
  4. 网卡触发一个硬中断,中断处理函数开始执行。
  5. 软中断 SoftIRQ,唤醒 NAPI 子系统,这会触发在一个单独的线程里,调用驱动注册的 poll 方法收包。
  6. 驱动禁止网卡产生新的硬件中断。这样做是为了 NAPI 能够在收包的时候不会被新的中断打扰。
  7. 一旦没有包需要收了,NAPI 关闭,网卡的硬中断重新开启。
  8. 转步骤 2。

poll 方法是通过调用 netif_napi_add 注册到 NAPI 的,同时还可以指定权重 weight,大部分驱动都 hardcode 为 64。

软中断 SoftIRQ

硬中断处理函数(handler)执行时,会屏蔽部分或全部(新的)硬中断。 中断被屏蔽的时间越长,丢失事件的可能性也就越大。 所以,所有耗时的操作都应该从硬中断处理逻辑中剥离出来,硬中断因此能尽可能快地执行,然后再重新打开硬中断。

内核的软中断系统是一种在硬件中断处理驱动中上下文之外执行代码的机制。

启动软中断后,硬件中断处理函数就结束返回了。

由于硬中断处理程序执行的过程中不能被中断,所以如果它执行时间过长,会导致 CPU 没法响应其它硬件的中断。

内核引入软中断,这样可以将硬中断处理函数中耗时的部分移到软中断处理函数里面来慢慢处理。

ksoftirqd(已经进入内核)

image

每个 CPU 上都运行着一个 ksoftirqd 进程,专门负责软中断的处理,在系统启动就注册了。 当它收到软中断后,就会调用相应软中断所对应的处理函数。

ksoftirqd 会调用网络模块的 net_rx_actionksoftirqd 进程调用 NAPI 的 poll 函数从 ring buffer 收包。poll 函数是网卡驱动在初始化阶段注册的。

ksoftirqd 做一些 bookeeping 工作,然后调用 __do_softirq

__do_softirq
  • 判断哪个 softirq 被 pending。
  • 计算 softirq 时间,用于统计。
  • 更新 softirq 执行相关的统计数据。
  • 执行 pending softirq 的处理函数。

查看 CPU 利用率时,si 字段对应的就是 softirq,软中断的 CPU 使用量。

ksoftirqd/0,表示这个软中断线程跑在 CPU 0 上。

Linux 网络设备子系统

net_rx_action

一旦软中断代码判断出有 SoftIRQ 处于 pending 状态,就会开始处理。 执行 net_rx_action,网络数据处理就此开始。

net_rx_action 调用网卡驱动里的 poll 来处理数据包。 在 poll 中,驱动会一个接一个的读取网卡写到内存中的数据包。

net_rx_action 从包所在的内存开始处理,包是被设备通过 DMA 直接送到内存的。 内存中数据包的格式只有驱动知道。

函数遍历 CPU 队列的 NAPI 变量列表,依次出队并操作之。 处理逻辑考虑任务量 work 和执行时间两个因素:

  • 跟踪记录工作量预算 work budget,预算可以调整。
  • 记录消耗的时间。
while (!list_empty(&sd->poll_list)) {
    struct napi_struct *n;
    int work, weight;

    /* If softirq window is exhausted then punt.
     * Allow this to run for 2 jiffies since which will allow an average latency of 1.5/HZ.
     */
    if (unlikely(budget <= 0 || time_after_eq(jiffies, time_limit)))
      goto softnet_break;

内核防止处理数据包过程霸占整个 CPU。 其中 budget 是该 CPU 所有 NAPI 变量的总预算。 这也是多队列网卡应该精心调整 IRQ smp_affinity 的原因。

多网卡多队列可能会出现这样的情况: 多个 NAPI 变量注册到同一个 CPU 上。 每个 CPU 上的所有 NAPI 变量共享一份 budget。

如果没有足够的 CPU 来分散网卡硬中断,可以考虑增加 net_rx_action 允许每个 CPU 处理更多包。 增加 budget 会增加 CPU 使用量,但可以减少延迟。

执行 poll 操作时,会尝试循环检查网卡是否有接收完毕的报文,直到系统设置的 net.core.netdev_budget 上限(默认 300),或者已经就绪报文。

NAPI poll

如果内核 DCA(Direct Cache Access)功能打开了,CPU 缓存是热的,对 RX ring 的访问会命中 CPU cache。

执行 igb_clean_rx_irq

执行 clean_complete,判断是否仍然有 work 可以做。 如果有,就返回 budget。net_rx_action 会将这个 NAPI 变量移动到 poll 列表的末尾。 如果所有 work 都已经完成,驱动通过调用 napi_complete 关闭 NAPI,并通过调用 igb_ring_irq_enable 重新进入可中断状态。 下次中断到来的时候回重新打开 NAPI。

igb_clean_rx_irq

驱动程序将内存中的数据包转换成内核网络模块能识别的 skb 格式。

igb_clean_rx_irq 方法是一个循环,每次处理一个包,直到 budget 用完,或者没有数 据需要处理了。

  • 从 RX 队列取一个 buffer,保存到一个 skb 类型的变量中。
  • 分配额外的 buffer 用于接收数据,因为已经用过的 buffer 被 clean out 了。一次分配 IGB_RX_BUFFER_WRITE 16 个。
  • 从 RX 队列取一个 buffer,保存到一个 skb 类型的变量中。
  • 判断这个 buffer 是不是一个包的最后一个 buffer。如果是,继续处理;如果不是,继续从 buffer 列表中拿出下一个 buffer,加到 skb。当数据帧的大小比一个 buffer 大的时候,会出现这种情况。
  • 验证数据的 layout 和头信息是正确的。
  • 更新 skb->len,表示这个包已经处理的字节数。
  • 设置 skb 的 hash、checksum、timestamp、VLAN id、protocol 字段。
  • 调用 napi_gro_receive 函数。
  • 更新处理过的包的统计信息
  • 循环直至处理的包数量达到 budget。

GRO(Generic Receive Offloading)

Large Receive Offloading(LRO)是一个硬件优化。 GRO 是 LRO 的一种软件实现。

通过合并"足够类似"的包来减少传送给网络栈的包数,这有助于减少 CPU 的使用量。

GRO 使协议层只需处理一个 header,而将包含大量数据的整个大包送到用户程序。

如果用 tcpdump 抓包,有时会看到机器收到了看起来不现实的、非常大的包,这很可能是系统开启了 GRO。

如果开启了 GRO,napi_gro_receive 将负责处理网络数据,并将数据送到协议栈。 大部分相关的逻辑在函数 dev_gro_receive 里实现。

如果没开启 RPS,napi_gro_receive 会直接调用 __netif_receive_skb_core

一旦 dev_gro_receive 完成,napi_skb_finish 就会被调用,其如果一个 packet 被合并了,就释放不用的变量,或者调用 netif_receive_skb 将数据发送到网络协议栈。

netif_receive_skb

netif_receive_skb 进入协议栈。

netif_receive_skb 被调用的位置:

  • napi_skb_finish,当 packet 不需要被合并到已经存在的某个 GRO flow 的时候。
  • napi_gro_complete 协议层提示需要 flush 当前的 flow 的时候。

netif_receive_skb 首先会检查用户有没有设置一个接收时间戳选项 sysctl,这个选项决定在包在到达 backlog queue 之前还是之后打时间戳。

如果启用,那立即打时间戳,在 RPS 之前,也就是 CPU 和 backlog queue 绑定之前。 如果没有启用,那只有在它进入到 backlog queue 之后才会打时间戳。

如果 RPS 开启了,那这个选项可以将打时间戳的任务分散个其他 CPU,但会带来一些延迟。

处理完时间戳后,netif_receive_skb 会根据 RPS 是否启用来做不同的事情。

如果 RPS 没启用,会调用 __netif_receive_skb

如果 RPS 启用了,它会做一些计算,判断使用哪个 CPU 的 backlog queue,这个过程由 get_rps_cpu 函数完成。 get_rps_cpu 会考虑 RFS 和 aRFS 设置,以此选出一个合适的 CPU。 通过调用 enqueue_to_backlog 将数据放到它的 backlog queue。

enqueue_to_backlog

enqueue_to_backlog 中,会将数据包放入 CPU 的 softnet_data 结构体的 input_pkt_queue 中,然后返回。

如果 input_pkt_queue 满了的话,该数据包将会被丢弃。 input_pkt_queue 的大小可以通过 net.core.netdev_max_backlog 来配置。

enqueue_to_backlog 被调用的地方很少。 在基于 RPS 处理包的地方,以及 netif_rx,会调用到它。 大部分驱动都不应该使用 netif_rx,而应该是用 netif_receive_skb

如果没用到 RPS,驱动也没有使用 netif_rx,那增大 backlog 并不会带来益处,因为它根本没被用到。

驱动如果调用了 netif_receive_skb,而且没启用 RPS,那么增大 net.core.netdev_max_backlog 并不会带来任何性能提升,因为没有数据包会被送到 input_pkt_queue

__netif_receive_skb

__netif_receive_skb 做一些 bookkeeping 工作,然后调用 __netif_receive_skb_core 将数据发送给更上面的协议层。

__netif_receive_skb_core

__netif_receive_skb_core 完成将数据送到协议栈这一繁重工作。

是否是 AF_PACKET 类型的 socket(原始套接字)。 如果是,拷贝一份 skb 给 taps。 tcpdump 抓包就是抓的这里的包。

处理完 tap 之后,__netif_receive_skb_core 将数据发送到协议层。 从数据包中取出协议信息,然后遍历注册在这个协议上的回调函数列表。 调用协议栈相应的函数,将数据包交给协议栈处理。

交给协议栈处理就是调用协议栈的相关函数,函数里面的代码会处理数据并将数据放到 socket 的接收缓存里面。

到这步之后,表示软中断处理程序处理完了一个数据包,然后软中断处理程序去处理内存中的下一个数据包。

待内存中的所有数据包被处理完成后,即 poll 函数执行完成,启用网卡的硬中断,这样下次网卡再收到数据的时候就会通知 CPU。

协议层

image

image

__netif_receive_skb_core 会调用 deliver_skbdeliver_skb 会调用 ip_rcv

IP 层

ip_rcv

ip_rcv 是 IP 模块的入口函数,主要处理 IP 协议包头相关信息,一些数据合法性验证,统计计数器更新等。

将垃圾数据包(目的 MAC 地址不是当前网卡,但由于网卡设置了混杂模式而被接收进来)直接丢掉。

以 netfilter 的方式调用 ip_rcv_finish 方法。 调用注册在 NF_INET_PRE_ROUTING 上的函数。 任何 iptables 规则都能在包刚进入 IP 层协议的时候被应用,在其他处理之前。

NF_INET_PRE_ROUTINGnetfilter 放在协议栈中的钩子。 可以通过 iptables 来注入一些数据包处理函数,用来修改或者丢弃数据包,如果数据包没被丢弃,将继续往下走。

进入 routing,进行路由。

如果目的 IP 是本地 IP,那么将会调用 ip_local_deliver。 进而调用 ip_local_deliver_finish

如果是目的 IP 不是本地 IP,且没有开启 ip forward 功能,那么数据包将被丢弃。 如果开启了 ip forward 功能,那将进入 ip_forward 函数。

ip_forward 会先调用 netfilter 注册的 NF_INET_FORWARD 相关函数。 如果数据包没有被丢弃,那么将继续往后调用 dst_output_sk 函数。

dst_output_sk 函数会调用 IP 层的相应函数将该数据包发送出去。

调用 ip_rcv_finish

调用 ip_local_deliver ip_local_deliver 会先调用 NF_INET_LOCAL_IN 相关的钩子程序。 如果通过,数据包将会向下发送。

NF_HOOK_THRESH 会检查是否有 filter 被安装,并会适时地返回到 IP 协议层,避免过深的进入 netfilter 处理,以及在 netfilter 下面再做 hook 的 iptables 和 conntrack。

netfilter 或 iptables 规则都是在软中断上下文中执行的,数量很多或规则很复杂时会导致网络延迟。

TCP 层

tcp_v4_rcv

UDP 层

udp_rcv

udp_rcv 函数是 UDP 模块的入口函数。

应用层

socket

应用层一般有两种方式接收数据:

  • recvfrom 函数阻塞在那里等着数据来。这种情况下当 socket 收到通知后,recvfrom 就会被唤醒,然后读取接收队列的数据。
  • 通过 epoll 或者 select 监听相应的 socket。当收到通知后,再调用 recvfrom 函数去读取接收队列的数据。

socket 基于内核的回调机制。 应用通过 socket 通信,socket 保存了通信双方信息,相当于一个连接信息。

socket 是一个五元组:

  • 源 IP
  • 源端口
  • 目的 IP
  • 目的端口
  • 类型,TCP or UDP
  1. 创建 socket。 应用程序申请创建 socket,具体实现由协议栈来完成。 协议栈首先会分配用于存放一个 socket 所需要的内存空间,然后往其中写入控制信息。 socket 刚创建时,数据收发还没有开始,需要写入初始状态的控制信息。
  2. 将这个 socket 的 fd 告诉应用程序。
  3. 收到 fd 后,应用程序再向协议栈委托收发数据时,就要提供 fd。 服务端在接收数据时,每来一个新连接,都会拷贝当前处于等待连接状态的 socket,然后写入控制信息,而原先的 socket 则继续等待新的连接。

recvfrom

image

recvfrom 是一个 glibc 的库函数。 该函数在执行后会将用户进行陷入到内核态,进入到 Linux 实现的系统调用 sys_recvfrom

image

fd

一个正整数,起到一个文件索引的作用,保存了一个指向文件的指针。

每创建或打开文件都会返回一个 fd

主流操作系统将 TCP/UDP 连接也当做 fd 管理。 每新建一个连接就会返回一个 fd