Lec 15 网络与OS
阅读材料
Eliminating Receive Livelock in an Interrupt-driven Kernel, 1996
本节的主题有:
- 数据包格式与协议
- 内核中软件栈
- 过载行为——这节课的论文
首先,为什么我们需要了解软件如何处理网络流量?
软件协议栈(尤其是 Linux)被广泛用于数据包处理,比如低端路由器、防火墙、VPN或者是网络服务(DNS、Web)中,其性能、设计,以及过载(overload)时的行为,都是热门研究问题。比如,高并发时会不会丢包?CPU 成为瓶颈怎么办?队列堆积会发生什么?
论文的场景就是,一个有 3 个接口的路由器,连接两个局域网和互联网。
数据包格式与协议
以太网数据包格式
以太网帧头有用于同步的字段,由硬件在非常低的级别上识别,表示数据包的开始,称为前导码(preamble),比如10101011;而接收主机需要知道包结束,所以在结束会有另一个特殊的位模式,表示数据包的结束,。这两个开始和结束标志永远不会被软件看到,但是剩下的以太网帧到 H2 的网卡是由软件完成的。主机实际上负责设置和解析以太网包头。
// an Ehternet packet header, from net lab's
#define ETHADDR_LEN 6
struct eth {
uint8 dhost[ETHADDR_LEN]; // 48 位
uint8 shost[ETHADDR_LEN]; // 48 位
uint16 type; // 16 位
} __attribute__((packed));
#define ETHTYPE_IP 0x0800 // Internet protocol
#define ETHTYPE_ARP 0x0806 // Address resolution protocol
/*
|xxx| payload | TYPE | SRC ETHADDR | DST ETHADDR | yyy|
Host 1 ——————————————————————————————————————> Host 2
/*
- 目标以太网地址——48位
- 源以太网地址——48位
- 以太类型——16位
- 有效负载
- (CRC-32)
- (结束符号,例如 8b/10b 或曼彻斯特编码)
每个以太网网卡都有一个独一无二的地址,由NIC生产制造商指定。
- <24位制造商ID> +<24位序列号组成>
最早的以太网是“广播式”的(所有机器共享一根线)。主机通过目标地址判断包是不是发给自己的。当今的以太网局域网使用交换机,每个设备一根线,交换机根据目标 MAC 地址决定把包发给哪个设备。
是否可以所有场景下都用MAC地址作为唯一地址?
Solution: 不能。原因如下:
- 以太网不是唯一的底层地址,还有Wifi、光纤等其他链路层协议
- “平坦地址”(flat address)让广域网路由很困难。MAC 地址没有结构,看不出“属于哪个网络”;相比IP地址,其有分层的含义(网络号+主机号),包含了路由提示,路由器只看“网络号”就能转发
一个重要模式:高层协议被“嵌套”在低层协议里面。
[eth | ip | udp | DNS]
结构:
Ethernet Header IP Header UDP Header DNS Data
IP 头部
以太网类型字段是 0x0800 表示 IP,最重要的是地址,32 位 IP 地址可以唯一标识互联网中的主机,协议号告诉接收方如何处理这个包
// an IP packet hreader(在以太网包头之后)
struct ip {
uint8 ip_vhl; // version << 4 | hreader length >> 2
uint8 ip_tos; // type of service
uint16 ip_len; // total length
uint16 ip_id; // identification
uint16 ip_off; // fragment offset field
uint8 ip_ttl; // time to live
uint8 ip_p; // 协议号
uint16 ip_sum; // checksum
uint32 ip_src, ip_dst; // IP地址
} __attribute__((packed));
#define IPPROTO_TCP 6 // TCP
#define IPPROTO_UDP 17 // UDP东西很多,但是最重要的是IP地址,32位 IP 地址足以路由到任何互联网计算机, 地址高位包含“网络号”,帮助路由器理解如何在互联网中转发。 协议号告诉目的地如何处理数据包即,数据包应交给哪个高层协议处理(通常是 UDP 或 TCP)
tcpdump -t -n -xx ip | more
- -t: 不显示时间戳。 更干净,专注看包内容
- -xx: 以“十六进制 + 链路层头部”显示; 如果只是
-x则不显示链路层头部 - -n: 不做地址解析,默认会把google.com->为142.250.xx.xx;加-n直接显式IP,避免DNS查询开销,输出更快更真实
ARP
用于将 IP 地址转换为以太网地址的请求/响应协议, APR“嵌套”在以太网数据包中,类型 0x0806。请求包含所需的 IP 地址, 请求包广播到交换机上的每台主机,所有主机都会接收到该包,但只有拥有该 IP 地址的主机会响应。响应包含对应的以太网地址。
[图示:eth | ip | udp | DNS]
// 一个APR包的例子(在以太网包头之后)
struct arp {
uint16 hrd; // format of hardware address. e.g 0x0001 for Ether
uint16 pro; // format of protocol address. e.g 0x0800 for IP
uint8 hln; // length of hardware address. e.g 6
uint8 pln; // length of protocol address. e.g 4
uint16 op; // operation. 1 for req, 2 for resp
char sha[ETHADDR_LEN]; // sender hardware address
uint32 sip; // sender IP address
char tha[ETHADDR_LEN]; // target hardware address
uint32 tip; // target IP address
} __attribute__((packed));
#define ARP_HRD_ETHER 1 //Ethernet
enum {
ARP_OP_REQUEST = 1, // request hw addr given protocol addr
ARP_IP_REPLY = 2, // replies a hw addr given protocol addr
};UDP 头部
当一个数据包已经到达正确的主机后,应该交给哪个应用程序?IP 负责送到这台机器,UDP 负责送到哪个进程/程序。
UDP 头部位于 IP 数据包内部, 包含源和目标端口号,UDP 头部后是有效负载,例如 DNS 请求或响应, 一些端口是“公认的”,例如端口 53 预留给 DNS 服务器;其他端口根据需要分配给连接的客户端端。
// a UDP packet header(在IP报头之后)
struct udp {
uint16 sport; // src port
uint16 dport; // dst port
uint16 ulen; // length, including udp header, not including IP hdr
uint16 sum; // checksum
} __attribute__((packed));
// dport
// 53 or 0x11 -DNS应用程序通过 socket API 系统调用告诉内核, 它希望接收发送到某个端口的所有数据包。下面系统调用告诉内核,“凡是发到 53 端口的包,都交给我”
bind(sockfd, port=53)tcpdump捕捉udp包
tcpdump -t -n udp内核中软件栈
一个典型操作系统内核里的网络代码长什么样?
设计会直接影响性能,以及系统在过载(overload)时的表现。
整体控制流程:
(接收路径)
NIC → DMA RX ring → 中断 → 内核输入队列 → 网络线程 → socket → 应用
(发送路径)
应用 → socket → 输出队列 → TX ring → NIC网卡硬件(通常不止一个), 内部有缓冲区。
在接收过程中,网卡的DMA引擎会将接收到的数据包放到RX Ring(循环数组),接着接收中断处理函数,把数据从网卡拷贝到软件输入队列;网络线程处理 IP 等协议,可能进行转发,可能放入 socket 队列,最后应用程序通过socket接受数据,比如DNS和Web Server。
在发送过程中, 应用要发送数据时构造 UDP/TCP/IP 头放入输出队列,发送中断处理函数释放已经发送完成的 DMA 槽位,把软件输出队列的数据拷贝到 TX ring,最后网卡发送数据
为什么要这么多“队列”?
Solution: 原因:
- 吸收临时输入突发流量,避免在软件繁忙时被迫丢包。
- 保持网卡忙碌,CPU 在处理别的事时,网卡还能继续发送数据。
- 让不同模块(网卡 / 内核线程 / 应用)可以独立运行,通过队列作为边界解耦。队列让它们互不阻塞。
但这不是唯一设计,
- 用户态协议栈(绕过内核)。
- 直接用户访问 NIC(如 Intel 的 DPDK)。
- 轮询(而不是中断),就像论文里讲的那样
过载行为
为什么要读这篇论文? 这是一篇非常经典且有影响力的论文, 用来分析内核网络栈设计中的权衡,类似“活锁”的问题在很多系统中都会出现,比如互联网流量削峰。
上下文。 软路由非常常见,通常是Linux,例如防火墙,Modem、Wifi接入点。最大的网络速度通常会大于路由器速度,因此存在过载的可能性。
案例分析
这个系统是一个路由器,

如图6-2 所示
NIC → RX 中断 → 输入队列 → IP 处理 → 输出队列 → TX 中断 → NIC。
只有 一个 CPU 核心,所有工作(中断处理、IP 协议处理、转发)都在这一个核上完成。
中断处理的优先级最高。
图6-1是该内核的转发性能图。

下面有几个问题:
- 为什么黑点会上升?黑点表示 系统能够成功转发的吞吐量(即每秒能处理多少包)。横轴 = 输入包速率; 纵轴 = 实际完成的转发吞吐量
Solution: 因为输入速率增加,更多数据包进入系统,吞吐量随之上升
- 峰值由什么决定? 峰值@5000=> 200us/pkt
Solution: CPU处理能力上限
- 为什么黑点会下降?
Solution: 因为系统进入“活锁”, 中断占满 CPU,没时间做真正的 IP 处理。
- 下降速度由什么决定?
Solution: 由“浪费的 CPU 时间”决定,每个包都被“部分处理”, 最后却被丢弃。
- 没被转发的包去哪了?
Solution: 被丢弃
- 假设路由器设计得尽可能好,图表会是什么样子 ?
Solution: 达到峰值之后,持平
问题关键:
- 网卡中断的优先级高于其他所有处理。
- 随着输入速率的增加,中断会最终占用100%的CPU资源,这样一来,就没有 CPU 时间用于剩余的 IP 处理了。
- IP输入队列增长到允许的最长长度
- 网卡中断读取每个数据包,输入/输出队列发现已经最大队列长度,然后丢弃数据包。
高层次总结:系统浪费时间去“部分处理”那些最终会被丢弃的数据包。这就是 livelock 本质:忙,但没产出

论文的解决方案
改动: 去掉IP输入队列
去除IP输入队列, 不再排队,接收中断只做一件事——唤醒网络线程,然后关闭该网卡的中断。网络线程执行所有处理行为:重新检查网卡是否有更多输入,仅在没有输入等待时重新将网卡接受中断处于 enable 状态
具体实现伪代码
def polling_net_thread():
while(1)
if NIC 有包
读一个包
完整处理
else
开中断
sleep核心思想,用 轮询 替代中断风暴
此图包含他们的系统(菱形所示)

可以看到,Polling也有问题,如果 polling 线程每次 poll 都不停地处理 NIC 输入队列里的所有包,而不设上限:
- 当输入速率超过 MLFRR(Maximum Loss-Free Receive Rate,最大无丢包接收速率) 时,NIC 收到的包总是堆积。
- 轮询线程忙于无限处理输入队列里的数据 → 永远没时间去执行后续的 IP 转发和输出操作。即, CPU 时间都耗在“搬运 + 丢弃”,真正成功发出去的包数量极少。
活锁(livelock)的定义是:系统没有停下来(CPU 在拼命工作),但 没有取得实际进展(包几乎都没成功转发)。
轮询 + 调度机制
在这里可以引入各种控制手段:
- Quota(配额):一次 poll 只处理有限数量的包,避免独占 CPU;
- Feedback(反馈):根据后端队列的情况决定是继续 poll 还是暂时 sleep
具体而言, 所有数据包必须经过一个 用户态程序 screend(类似防火墙/过滤器)。内核里的轮询网络线程 从 NIC 拉取数据包,然后把包交给 screend 处理。在轮询循环时, 施加调度控制——配额和反馈
反馈机制 (feedback)如下:
- 当 screend 前的队列快满时,网络线程 暂停轮询(sleep)。
- 当队列快空时,再次唤醒网络线程继续取包。
配额作用如下:
- 一次 poll 只处理有限数量的包,避免独占 CPU;
轮询循环是。 最终达到的效果,网络线程和用户态程序能动态平衡,系统表现良好。
轮询循环是一个可以施加“调度控制”的关键位置,一旦你用 polling,而不是被中断驱动,你就“掌控节奏”了。 可以每次只处理 N 个包(quota); 根据队列长度(feedback)决定是否继续。
论文的价值
Linux优化——NAPI
过去,当数据包到达时,网卡会中断 CPU;内核处理硬中断后,再触发软中断完成协议处理,每秒可能有成千上万个包到达,处理这么多硬中断会把系统拖垮。解决方案是 2003 年引入的 NAPI(New API),关闭接收中断,改用轮询,一次轮询可以替代几十次硬中断。
但 NAPI 仍然运行在软中断上下文中,它会在“随机上下文”中运行。随机的意思是,软中断可能在中断返回时,或者是系统调用返回用户态时候,它会“偷走”当前正在运行的进程的 CPU 时间。简而言之,你的应用在跑,可能突然被网络处理抢 CPU,会导致不可预测的延迟。软中断很难被系统管理员控制,新补丁的做法,为每个支持 NAPI 的网卡创建一个独立的内核线程,内核线程可以调整优先级,可以绑定到特定 CPU,比如网卡0绑定到CPU0,调度器能更清楚这些工作用了多少 CPU,可能会增加轻微性能开销,但在很多系统中,这比极致性能更重要
Linux's NAPI polling/interrupting scheme:
- https://www.usenix.org/legacy/publications/library/proceedings/als01/full_papers/jamal/jamal.pdf
- https://lwn.net/Articles/833840/
screend:
- https://citeseerx.ist.psu.edu/document?repid=rep1&type=pdf&doi=a822dc4058040a5a3866af897d9af9f618ba83ed
- http://bitsavers.informatik.uni-stuttgart.de/pdf/dec/tech_reports/NSL-TN-2.pdf
AMD LANCE NIC interface:
- https://www.ardent-tool.com/datasheets/AMD_Am7990.pdf
- https://en.wikipedia.org/wiki/AMD_LANCE_Am7990通用设计原则
- 如果开始新工作会影响已有工作的完成,那就不要开始新工作
- 如果必须丢弃数据,应尽早丢弃
- 设计系统,使得负载增加时效率提高,而不是下降
其他解决方案
- 把多个中断合并成一个。 不每个包都触发中断,减少CPU开销
- 多核和多网卡队列。每个核处理一部分流量,并行处理
- 把网卡 DMA 队列暴露给用户程序(例如DPDK)
举一反三
系统的其他领域也存在类似现象,这里举两个例子。
随着连接数增加,超时 + 重传会导致问题。解释:丢包 → 重传,重传 → 更多流量,更容易丢包
随着 CPU 核数增加,自旋锁问题加剧。解释:多核同时竞争锁,大量 CPU 时间浪费在“空转”
复杂的多阶段系统,如果想在过载下仍然正常运行,就必须精心设计资源调度。每一层都可能成为瓶颈,如果调度不好可整体崩溃
论文阅读:消除接受活锁
Eliminating Receive Livelock in an Interrupt-driven Kernel, 1996
概要
大多数操作系统使用接口中断来调度网络任务。在低负载下,基于中断的系统能够提供较低的开销和良好的延迟,但在到达率较高时,系统性能会显著下降,除非采取措施防止多种病态现象。其中之一是接收活锁的各种形式,系统在这种情况下会一直处理中断,从而排斥其他必要任务。在极端情况下,系统无法将任何数据包传递给用户应用程序或输出。
为避免活锁及相关问题,操作系统必须像调度进程执行一样仔细地调度网络中断处理。我们修改了一个基于中断的网络实现,进行这样的调度,这消除了接收活锁问题,同时不降低系统性能的其他方面。我们展示了一些测量结果,证明了我们方法的成功。
1. 介绍
现代系统可以在几到几十微秒内响应中断;如果使用轮询实现相同的延迟,系统需要每秒轮询数万次,这会产生过多的开销。对于通用系统,基于中断的设计效果最好。
许多多媒体和客户端-服务器应用程序还具有另一种不利的特性:与传统的网络应用程序(如Telnet、FTP、电子邮件)不同,它们没有流量控制机制。一些多媒体应用需要恒定速率、低延迟的服务;基于RPC的客户端-服务器应用通常使用数据报式传输,而不是可靠的、流控的协议。需要注意的是,像磁盘这样的I/O设备只有在操作系统发出请求时才会生成中断,因此它们本质上是流控的,而网络接口则会生成未经请求的接收中断。
事件速率的提升和非流控协议的使用可能使主机陷入拥塞崩溃:一旦事件速率饱和系统,如果没有负反馈循环来控制源头,系统无法优雅地削减负载。如果主机在这种情况下仍然以满负荷运行。
基于中断的系统在过载情况下表现不佳。在中断级别执行的任务,按定义具有绝对优先级,超过所有其他任务。如果事件速率高到让系统全部时间都用于响应中断,那么其他任务将无法执行,系统吞吐量将降至零。我们称这种情况为接收活锁(receive livelock):系统并没有死锁,但它无法在任何任务上取得进展。任何纯粹的基于中断的系统,使用固定中断优先级的设计,在输入过载的条件下都会遭遇接收活锁。
然而,我们不应轻易放弃基于中断设计的明显优势。相反,我们应将网络中断处理子系统的控制与操作系统的调度机制和策略相结合
2. 相关应用
- 基于主机的路由, 虽然网络间路由传统上是使用专用的(通常是非中断驱动的)路由器系统完成的,但路由往往是使用更常规的主机完成的。几乎所有的互联网“防火墙”产品都使用 UNIX
- 被动网络监控:网络管理员、开发人员和研究人员通常使用 UNIX 系统,并将其网络接口置于“混杂模式”下,监控局域网上的流量,用于调试或统计数据收集。
- 网络文件服务:NFS 等协议的服务器通常是由 UNIX 系统构建的。 这些应用(以及其他类似的,如 Web 服务器)都可能面临重载的、没有流量控制的负载风险。
本文其余部分集中讨论基于主机的路由问题
3. 调度网络任务的要求
理想情况下,通信子系统能够处理最坏情况下的输入负载而不会饱和(即不超出其处理能力),但现实中由于成本的限制,通常无法构建如此强大的系统。在在过载情况下,目标期望是实现可控和渐进的性能下降。
当终端系统处理大量网络流量时,其性能在很大程度上依赖于任务的调度方式。用于调度数据包的场景应该保证系统能够维持可接受的吞吐量throughput、合理的延迟latency和抖动jitter(延迟变化)、公平fair的资源分配,以及总体的系统稳定性stability,尤其是在系统过载时,不应产生过多的开销。
我们可以将吞吐量定义为系统将数据包交付到最终使用者的速率,我们期望设计良好的系统在达到“最大无损接收速率(Maximum Loss Free Receive Rate, MLFRR)”之前,能够跟上提供的负载,并且在更高负载下,吞吐量不应低于这个值。当然,有效的吞吐量不仅仅取决于成功接收数据包;系统还必须传输数据包。
许多应用(如分布式系统和交互式多媒体)往往比起高吞吐量,更依赖低延迟和低抖动的通信。即使在过载时,我们也希望避免长队列(这会增加延迟)和突发性调度(这会增加抖动)。当主机因收到大量网络数据包而过载时,它还必须继续处理其他任务,以保持系统对管理和控制请求的响应能力
4. 中断驱动调度及其后果
在本节中,我们首先描述中断驱动系统的特性,然后确定在网络输入过载下中断驱动系统产生的三类问题:
- 在过载下接收活锁(receive livelock):只要输入过载持续,实际吞吐量将降为零
- 包传递或转发的延迟增加:系统在处理中断或后续包时,可能会延迟某个包的传递
- 包传输饥饿Starvation:即使CPU能够跟上输入负载,严格的优先级分配可能会阻止任何包的传输
4.1 中断驱动系统描述
网络输入优先处理任务的方式, 当包到达时,网络接口通过中断CPU来信号化这一事件。设备中断通常具有固定的中断优先级水平(IPL),并且会抢占所有在较低IPL下运行的任务;但不会抢占与其相同IPL下的任务。中断触发进入相应的网络设备驱动程序,设备驱动程序然后将包放入队列,并生成一个软件中断以进行进一步处理。软件中断的优先级较低,因此协议处理可以被后续中断抢占。不同IPL级别的步骤之间的队列提供了针对瞬时过载时丢包的隔离保护。
操作系统的调度器并不参与这类活动,事实上它对此完全不知情。
调度中断是一个代价高昂的操作,为了避免这种开销,网络设备驱动程序试图批处理中断,这将中断处理的开销分摊到多个包上。即使批处理,中断驱动系统在输入包过载时将花费大部分时间在设备IPL级别的代码上,也就是说,该设计给予了处理输入包绝对的优先级。
现代网络适配器可以在不需要主机干预的情况下接收多个连续的包,或者通过大量的缓冲,或者通过高度自主的DMA引擎。这使系统得以与网络隔离。过去给接收数据包的初步处理步骤赋予绝对优先级的理由不再那么重要了。
4.2 接受活锁
随着输入负载的增加,系统可能表现出三种行为:
- 在理想系统中,交付的吞吐量总是匹配输入负载。
- 在可实现的系统中,交付的吞吐量可以跟上输入负载,直到达到最大无丢失接收速率(MLFRR),在此之后吞吐量相对恒定。在超过MLFRR的负载下,系统仍然可以前进,但会丢弃一些输入数据,通常是在不同优先级的处理步骤之间的队列中丢包。
- 在易于出现接收活锁的系统中,随着输入负载的增加,超过MLFRR的负载下,吞吐量会下降。接收活锁发生在吞吐量降至零的点。
系统发生活锁(livelock)时,尽管系统投入了很多精力去部分处理接收到的数据包,但这些数据包最终都会被丢弃,因此所有的处理工作都白费了。接收中断批处理使情况变得稍微复杂一点。通过在重负载下提高系统效率,批处理可以提高MLFRR,但仅靠批处理无法防止活锁。
4.3 过载下的接收延迟
虽然通常认为中断驱动设计可以减少延迟,但它实际上可能会增加包传递的延迟。如果一组包快速突发到达,系统将对整个突发组执行链路层处理,而不是优先处理第一个包,因为链路层处理的优先级更高。因此,突发组的第一个包在链路层处理完成前不会传递给用户。第一个包的传递延迟几乎增加到接收整个突发组的时间。如果这个突发组包含多个独立的NFS RPC请求,这意味着服务器的磁盘会空闲,无法执行有用的工作
4.4 过载下的传输饥饿
在大多数系统中,包传输过程包括从输出队列中选择包,将其交给接口,等待接口发送包,然后释放相关缓冲区。包传输通常的优先级低于包接收。这种策略表面上看是合理的,因为它最小化了在突发到达的包超过可用缓冲空间时的丢包概率。然而,合理运行高层协议和应用程序需要传输处理取得足够的进展。
当系统长时间过载时,传输优先级的固定较低可能会导致吞吐量减少,甚至包传输完全停止。包可能在等待传输,但传输接口空闲。我们称这种情况为传输饥饿。
5. 通过更好的调度避免接受活锁
在本节中,提出几种避免接收活锁的技术。这些技术包括控制中断到达率的机制、基于轮询的机制,以及避免抢占的技术。
5.1 限制中断到达率
我们可以通过限制系统中中断的到达率来避免或延迟接收活锁。系统会检查中断处理是否占用了过多资源,如果是,暂时禁用中断。
系统可以通过以下方式推测是否即将发生活锁,以及有相应的办法。
- 由于队列溢出而丢包。相应办法: 因为内部队列已满时,则表明应当禁用输入中断。还需要一个触发器来重新启用输入中断,以防止不必要的数据包丢失。可以在内部缓冲区空间变得可用时或计时器到期时重新启用中断
- 高层协议或用户代码处理太慢了导致丢包。相应办法:在具有精细时钟寄存器的处理器上,计算出用户代码执行时间。如果这个总和(或一个运行平均值)超过了总经过时间的指定比例,内核就会禁用输入中断。
- 在没有精细时钟的系统上,可以通过在每个时钟中断时采样CPU状态(时钟中断通常会抢占设备中断处理)来粗略模拟这种方法。如果系统发现自己正处于处理中断的状态中,它可以禁用中断几次时钟周期
5.2 使用轮询
限制中断率可以防止系统饱和,但是无法保证系统在处理数据包时的进展。也就是说,系统还必须公平地分配数据包处理资源,包括输入和输出处理之间的资源以及多个(网络)接口之间的资源。我们可以通过仔细轮询所有数据包事件源,使用轮询调度round-robin来提供公平性。
在纯轮询系统中,调度程序会调用设备驱动程序来“监听”接收到的数据包和传输完成事件。这将控制设备级别的处理量,并且可以公平地分配资源,从而避免活锁。然而,简单地以固定间隔轮询会增加数据包接收和传输的延迟。
我们采用了混合设计,其中系统仅在中断触发时轮询,而中断仅在轮询暂停期间发生。在负载较低时,数据包到达不可预测,我们使用中断以避免延迟。在负载较高时,我们知道数据包以或接近系统的饱和率到达,因此我们使用轮询来确保进展和公平性,只有在没有更多工作待处理时才重新启用中断
5.3 避免抢占
接收活锁发生是因为中断处理抢占了所有其他数据包处理。我们可以通过使higher-level packet processing不可抢占来解决这个问题。我们观察到这可以通过以下两种通用方法之一来实现:
- 在高IPL(中断优先级级别)下可几乎做所有事情,或者
- 在高IPL下几乎什么都不做。
按照第一种方法,我们修改4.2BSD设计,消除软件中断,使用轮询机制来检查事件,和在设备IPL下完成处理接收到的数据包。由于数据处理发生在设备IPL下,它不能被其他数据包到达所抢占,从而保证内核协议栈中不会发生活锁。我们仍然需要使用速率控制机制来确保用户级应用程序的进展。
第二种方法中,流程为
- 当设备发生中断时,中断处理程序被调用
- 任务是设置一个“需要服务”的标志,并只是系统某些设备需要服务
- 中断处理程序调度一个轮询线程(这个线程尚未运行的话)
- 轮询线程启动,它运行在系统中最低的中断优先级(零IPL),这意味着它的运行不会被其他中断打断。它的任务是
- 检查“需要服务”的标志
- 处理相应的设备
- 一旦处理完所有的任务,他就会结束运行
- 恢复中断
这两种方法都消除了在设备驱动程序和高层协议软件之间排队数据包的需要,尽管如果协议栈必须阻塞,接收到的数据包必须在稍后的时刻排队。(例如,这会发生在数据准备好交付给用户进程时,或者当接收到IP片段且其伴随片段尚未可用时。)
5.4. 技术总结
总之,我们通过以下方式避免活锁:
- 仅使用中断来启动轮询。
- 使用轮询以公平地分配资源给事件源。
- 当内部队列满或CPU使用限制表明其他重要任务待处理时,暂时禁用输入。
- 早期丢弃数据包,而不是晚期丢弃,以避免浪费工作。一旦决定接收一个数据包,我们尽力将其处理到完成。
- 通过重新启用中断在没有待处理工作时避免轮询开销并保持低延迟。
- 让接收接口缓冲突发数据包,以避免丢弃数据包。
- 消除IP输入队列及相关开销。
我们观察到,低效的代码往往会加剧接收活锁。积极的优化、"快速路径"设计和去除不必要的步骤都有助于延迟活锁的到来。
6. BSD路由器中的活锁问题
我们考虑了一个具体的例子, IP 数据包路由器。(1) 实现最高可能的最大吞吐量;(2) 即使在过载情况下也保持高吞吐量;(3) 为用户模式任务分配足够的 CPU 周期;(4) 最小化延迟;以及 (5) 避免影响其他应用程序的性能。
6.1 测量方法
源主机以各种速率生成 IP/UDP 数据包,并通过路由器将它们发送到一个虚假的目的地址(我们通过在路由器的 ARP 表中插入一个虚假条目来欺骗路由器)。我们通过计算在给定时间内成功转发的数据包数量来测量路由器的性能,从而得出平均转发速率。
数据包生成器发送了 10000 个 UDP 数据包,每个数据包携带 4 字节的数据。该系统不会生成精确节奏的数据包流;报告的包速率是平均数,短期速率会有些波动。我们通过使用 "netstat" 程序(在路由器上, netstat -i)在每次试验前后采样输出接口计数 ("Opkts") 来计算交付的数据包速率
6.2 未修改内核的测量

我们首先测量了未修改的操作系统的性能,如图 6-1 所示。每个标记表示一个试验。填充的圆圈表示基于内核的转发性能,空心方块表示使用 screend 程序的性能,该程序在一些防火墙中用于筛选不需要的数据包。
在这种情况下,screend 被配置为接受所有数据包。从这些测试中可以明显看出,使用 screend 时,路由器在速率超过 2000 个数据包/秒时表现不佳,在约 6000 个数据包/秒时完全发生活锁。即使没有 screend,路由器的峰值为 4700 个数据包/秒,可能会在接近以太网最大包速率(约 14,880 个数据包/秒)时发生活锁。
6.3 为什么在 4.2BSD 模型中会发生活锁

4.2BSD 遵循了第 4.1 节中描述的模型,如图 6-2 所示。设备驱动程序运行在中断优先级级别 (IPL)比 IP 层通过软件中断的 高。驱动程序和 IP 代码之间的队列名为 "ipintrq",每个输出接口都有自己的缓冲队列。所有队列都有长度限制,超出的数据包会被丢弃。该系统中的设备驱动程序实现了中断批处理,因此在高输入速率下实际处理的中断很少。
一旦输入速率超过设备驱动程序从接口中拉取新数据包并将其添加到 IP 输入队列的速率,IP 代码就不会运行。因此,它永远不会从其队列(ipintrq)中移除数据包,队列会填满,所有后续接收的数据包都被丢弃。系统的 CPU 资源被饱和,因为它在提高的 IPL 级别上投入了大量 CPU 时间处理每个数据包。这是愚蠢的
6.4 解决活锁问题
我们通过尽可能在内核线程中完成工作,而不是在中断处理程序中,并通过消除 IP 输入队列及其相关的队列操作和软件中断(或线程调度)来解决活锁问题。一旦决定从接收接口取出数据包,我们尽量避免在之后丢弃它,因为这代表了浪费的工作量。
我们改为让这个线程使用轮询技术来高效地进行轮询调度。轮询线程使用附加的启发式方法来帮助实现我们的性能目标。
在boot阶段,修改后的接口驱动程序会向轮询系统注册自己,提供处理接收和发送数据包的回调程序,启用中断。
当数据包到达时,接口驱动程序的中断处理程序被执行。
- 但是几乎不做任何工作,它只是调度轮询线程(如果它尚未被调度)
- 记录接口”需要服务“的状态
- 从中断返回(注意,并没与重置设备的中断使能标志,因此系统在轮询线程处理所有待处理的数据包之前不会被额外的中断打扰志)
当轮询线程被调度时,它会检查所有注册的设备,查看它们是否请求处理,并调用相应的回调程序来完成中断处理程序原本需要做的工作。
- 接收数据包的回调程序直接调用 IP 输入处理例程,而不是将接收的数据包放在队列中以便稍后处理。
- 如果系统落后,接口的输入缓冲区将暂时储存数据包,任何多余的数据包将在系统浪费资源之前由接口丢弃。
- 轮询线程给回调程序分配处理数据包的配额。一旦回调程序用完配额,它必须返回到轮询线程。
- 轮询线程在多个接口之间以及在每个接口上的输入和输出处理之间进行轮询,以防止单个输入流独占 CPU
一旦接口上的所有数据包都被处理完,轮询线程调用驱动程序的中断使能回调
- 确保在后续的数据包事件发生时触发中断
6.5 结果和分析

图6-3总结了未使用screend程序时,我们的更改带来的结果。图中展示了几种不同的内核配置,使用不同的标记符号进行表示。修改后的内核(用方形标记表示)稍微提高了MLFRR(最大丢包前接收率),并在较高的输入速率下避免了活锁。
修改后的内核可以配置为表现得像一个未修改的系统(用空心圆表示),但其性能似乎略逊于实际未修改的系统(用实心圆表示)。
6.6 调度启发式方法
图6-3显示,如果轮询线程不对回调程序设定配额(能处理的数据包数量),当输入速率超过MLFRR时,总吞吐量几乎下降到零(图中用菱形表示)。这种活锁的出现是因为,尽管数据包不再在IP输入队列中被丢弃,但它们仍在堆积,并最终在输出接口队列中被丢弃。由于无法保证输出接口的运行速度与输入接口相同,这种输出队列的拥堵是不可避免的。
为什么系统未能清空输出队列?
如果数据包到达得太快,输入处理回调程序无法完成其工作。这意味着轮询线程无法调用输出接口的处理回调程序,这阻止了释放发送缓冲描述符,以便进一步传输数据包。这类似于在4.4节中描述的传输饥饿问题。在没有配额的修改内核中,结果实际上更糟糕,因为在该系统中,由于输出队列空间不足,数据包被丢弃,而不是在IP输入队列中被丢弃。
6.6.1 来自满队列的反馈

当使用screend程序时,修改后的系统表现如何?图6-4比较了未修改的内核(实心圆)和多个修改后的内核。按前面描述修改的内核(用方形标记表示)的性能与未修改的内核几乎一样差。问题在于,由于screend运行在用户模式下,内核必须将数据包排队以交付给screend。当系统过载时,该队列会填满并导致数据包丢弃。screend无法运行来清空此队列,因为系统将其所有的周期都用于处理输入数据包。
为了解决这个问题,我们检测到筛选队列变满时,会抑制进一步的输入处理(以及输入中断),直到队列中有更多可用空间。结果如图6-4中的灰色方形标记所示:没有活锁,峰值吞吐量大幅提高。通过队列状态反馈,系统能够正确分配CPU资源以使数据包顺利通过整个系统,而不是在中间点被丢弃。
在这些实验中,轮询配额设置为10个数据包,筛选队列的限制为32个数据包,当队列达到75%满时,系统会抑制输入处理。当筛选队列减少到25%满时,重新启用输入处理。我们任意选择了这些高水位和低水位标记,适当的调优可能会有所帮助。我们还设置了一个超时(任意选择为一个时钟周期,约1毫秒), 在超时后重新启用输入处理,以防screend程序挂起,这样不会无限期丢弃其他消费者的数据包。
同样的队列状态反馈技术也可以应用于系统中的其他队列,如接口输出队列、用于网络监控的数据包过滤器队列等。对于这些队列,反馈策略会更加复杂,因为可能很难确定输入处理负载是否确实阻碍了这些队列的进展。然而,由于screend通常作为系统上的唯一应用程序运行,因此筛选队列满了是一个明确的信号,表明输入数据包过多。
6.6.2 配合数量的选择

为了在非screend配置中避免活锁,我们必须为每次回调处理的数据包数量设置配额,因此我们研究了随着配额变化系统吞吐量的变化情况。 图6-5显示了结果;较小的配额表现更好。随着配额增加,活锁问题变得更加严重。 然而,当使用screend时,队列状态反馈机制可以防止活锁,较小的配额会略微降低最大吞吐量(约5%)。我们认为,通过每次回调处理更多的数据包,系统可以更有效地摊销轮询的成本,但增加配额也可能增加每个数据包的最坏情况下的延迟。
一旦配额足够大,能够使筛选队列填满一波数据包时,反馈机制可能会掩盖任何潜在的改进空间。

图6-6显示了在使用screend时的测试结果。总之,无论是否使用screend的测试表明,在测试的硬件配置下,10到20个数据包的配额可以实现稳定且接近最优的行为。对于其他CPU和网络接口,适当的值可能会有所不同,因此这一参数应该是可调的。
总之,无论是否使用screend的测试表明,在测试的硬件配置下,10到20个数据包的配额可以实现稳定且接近最优的行为
7. 保证用户级进程的进展
第6.4节描述的轮询和队列状态反馈机制可以确保在输入超载时,数据包处理的所有必要阶段都能取得进展。然而,它们并没有考虑到其他活动的需求,因此用户级进程可能仍然会因CPU周期不足而被饿死。这会导致系统的用户界面无响应,并干扰后台维护任务(例如路由表维护)。
我们通过在修改后的路由器上运行一个计算密集型进程,并向该路由器发送最小尺寸的数据包洪流,验证了这一效应。路由器以全速转发这些数据包(即仿佛没有用户模式进程在消耗资源),但用户进程几乎没有任何进展。
由于问题的根源在于数据包输入处理子系统占用了过多的CPU,我们可以通过简单地测量处理接收数据包所消耗的CPU时间,并在超过某个阈值时禁用输入处理,从而缓解这一问题。
我们在实验使用的Alpha架构中,包含一个高分辨率且低开销的计数器寄存器。该寄存器会计数每条指令周期(在当前实现中),并且可以在一条指令内读取,而不会产生任何数据缓存未命中。其他现代的RISC架构也支持类似的计数器。我们测量了一个周期内处理数据包代码所使用的CPU周期总量(这个周期目前被任意选择为多个时钟周期,约为10毫秒,以匹配调度器的量子)。每个周期结束时,一个定时器功能会清除数据包处理代码使用的CPU周期总量。每次修改后的内核开始它的轮询循环时,会读取周期计数器,并在循环结束时再次读取它,以测量在该轮询循环期间处理输入和输出数据包所花费的周期数(配额机制确保这个间隔相对较短)。然后,这个数字会被添加到运行总计中,如果总数超过阈值,则立即禁用输入处理。
在当前周期结束时,定时器会重新启用输入处理。系统的空闲线程执行时也会重新启用输入中断,并清除当前计数总量。
通过将阈值调整为周期总数的某个比例,可以相当精确地控制处理数据包所占用的CPU时间。我们尚未为这种控制实现编程接口;在我们的测试中,我们只是简单地通过修改内核中的全局变量来代表分配给网络处理的百分比,而系统会自动将这个值转换为对应的周期数。

图7显示了对于不同的周期阈值设置和各种输入速率情况下,一个计算密集型用户进程可以获得的CPU时间。曲线显示了随着输入速率的增加,系统行为相对稳定,但用户进程获得的CPU时间并没有达到阈值设置所暗示的水平。部分差异来源于系统开销;即使在没有输入负载的情况下,用户进程也只能获得大约94%的CPU周期。此外,周期限制机制抑制了数据包的输入处理,但没有抑制输出处理。在较高的输入速率下,在输入被禁止之前,输出队列会填满并消耗更多的CPU周期。
8. 总结和结论
在接收过载情况下表现不佳的系统无法提供一致的性能和良好的交互行为。活锁(livelock)从来不是应对过载的最佳解决方案。在本文中,我们展示了如何理解系统过载行为,并通过仔细调度数据包处理的时间来改善系统表现。
通过对UNIX系统的测量,我们证明了传统的中断驱动系统在过载情况下表现不佳,导致接收活锁以及发送饥饿问题。因为在这些系统中,随着数据包进入系统的深度增加,其处理优先级逐渐降低,过载时会出现过多的数据包丢失和无用的工作。这种病态现象不仅可能由长期的接收过载引起,也可能由短期的突发负载引起。
我们描述了一系列调度改进措施,帮助解决过载表现不佳的问题。这些改进包括:
- 限制中断到达速率,以减轻过载
- 通过轮询机制提供公平性
- 完整地处理接收到的数据包
- 明确调控用于数据包处理的CPU使用
我们的实验表明,这些调度机制提供了良好的过载处理表现,并消除了接收活锁问题。这些改进对专用系统和通用系统都具有帮助。