ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

14:环形缓冲区——内核和用户态之间的快递中转站

14:环形缓冲区——内核和用户态之间的快递中转站 抓包原理探秘 14环形缓冲区——内核和用户态之间的快递中转站大家好我是毛衣哥。这一期聊一个你可能没注意到但极其重要的组件——环形缓冲区。没有它tcpdump 自己就能把你的 CPU 吃光。名字虽然土但设计是真的骚。前两篇我们聊了 BPF 怎么在内核里做过滤——但过滤完之后呢数据怎么从内核空间送到用户态的 tcpdump 进程里如果每个数据包都触发一次从内核到用户态的传输——那 CPU 就全浪费在切换上了。环形缓冲区就是这个问题的解。它是 BPF 架构中一个容易被忽略但极其精妙的设计。没有缓冲区会怎样假设没有环形缓冲区tcpdump 的读取流程是这样的数据包通过 BPF 过滤需要保留 → 内核立刻唤醒 tcpdump 进程 → tcpdump 调用 read() 读取这个包 → 切换回内核态把数据复制到用户态 → 切换回用户态tcpdump 处理数据、打印输出 → tcpdump 再次调用 read() 等待下一个包一次完整的切换过程需要做保存当前进程的寄存器切换页表虚拟内存映射执行系统调用入口代码从内核态返回用户态恢复进程的寄存器这个过程大约需要1-3 微秒。如果你每秒处理10 万个数据包10 万 PPS在千兆网络上非常常见仅仅是切换开销就达到了100-300 毫秒/秒——占了一颗 CPU 核心的 10-30%。如果没有缓冲区tcpdump 自身就会成为系统的性能瓶颈。环形缓冲区的设计环形缓冲区解决的核心问题就是批量传输减少切换次数。它在内核维护了一个环状的缓冲队列预处理内存4 MB 的连续物理内存固定分配 结构 ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │ P1 │ P2 │ P3 │ P4 │ P5 │ │ │ │ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ ↑ ↑ 写指针 读指针 内核往这写 用户态从这里读工作原理内核把通过 BPF 过滤后的数据包复制到写指针位置写指针向前移动指向下一个空闲位置用户态程序从读指针位置开始读通过 read() 系统调用读完后读指针向前移动如果写指针写满了整个环——它会回到开头开始写因此叫环形如果写指针追上了读指针——缓冲区满了新包无处可放// 环形缓冲区的读写伪代码 struct RingBuffer { buf: byte[], // 固定大小的缓冲区 size: int, // 缓冲区总大小 write_ptr: int, // 内核写指针位置 read_ptr: int, // 用户态读指针位置 } // 内核侧BPF 过滤后写入缓冲区 function kernel_write_packet(rb, packet): data_len len(packet) // 检查有没有空间写指针 数据长度 会超过读指针吗 if rb.write_ptr data_len rb.read_ptr rb.size: // 有空闲空间 copy_to_ring(rb.buf, rb.write_ptr % rb.size, packet) rb.write_ptr data_len else: // 缓冲区满了丢包计数 stats.dropped_by_kernel // 不阻塞直接丢弃 // 用户态侧tcpdump 读取缓冲区 function user_read_packets(rb): while rb.read_ptr rb.write_ptr: // 从读指针位置读一个包 packet_size peek_u32(rb.buf, rb.read_ptr % rb.size) // 用户态收到这个包 user_buffer copy_from_ring(rb.buf, rb.read_ptr % rb.size, packet_size) process_packet(user_buffer) rb.read_ptr packet_size // 读指针前进 // 如果读指针超过缓冲区末尾写指针可能也绕回来了 // 但 write_ptr 永远是单调递增的——通过取模访问数组缓冲区满了怎么办内核必须做选择——没有完美的答案丢弃最新的包保留老的丢掉新的。公平——先到的包不会被丢弃。丢弃最老的包保留新的丢掉老的。用户可能更关心最近的流量。阻塞生产者让内核停下来等待用户态读取。绝对不能做如果阻塞了整个网络协议栈都会停——因为内核的软中断上下文不能阻塞等待。tcpdump 的选择是丢弃最新到达的包。即写指针越过读指针时新包被丢弃——tcpdump 统计里的dropped by kernel就是被丢弃的数据包的数量。tcpdump 的 -B 参数到底在调什么当你运行tcpdump -B 4096时注意单位是 KB你实际上是在设置内核 BPF 环形缓冲区的大小。tcpdump -B 1024 → 缓冲区 1 MB tcpdump -B 4096 → 缓冲区 4 MB默认 tcpdump -B 65536 → 缓冲区 64 MB大流量场景增大缓冲区的作用缓冲区越大能缓冲的数据包越多写指针追上读指针的概率越低丢包的概率越低增大缓冲区的代价缓冲区是内核空间的内存非连续但物理上需要连续性高内核非连续内存分配有限——不能无限大实际限制通常在 32-256 MB什么情况下你需要调大缓冲区看 tcpdump 的输出tcpdump: 68723 packets captured tcpdump: 241799 packets received by filter tcpdump: 93616 packets dropped by kernel如果dropped by kernel的数量很大超过 captured 的 10%说明环形缓冲区满了。你需要调大缓冲区-B 16384或者收缩过滤条件少抓一些包或者把数据写到文件而不是终端写终端的 I/O 开销很大mmap 零拷贝更高级的优化环形缓冲区虽然好但还有一个问题数据从内核复制到用户态还是需要一次 copy。内核BPF 环形缓冲区→ 复制到用户态tcpdump 的 read buffer→ 处理 ↑ 这里有一次内存复制现代的 BPF 实现包括 eBPF支持mmap 模式内存映射。用户态程序通过 mmap 把内核的环形缓冲区映射到自己的地址空间。mmap 之后内核BPF 环形缓冲区 → tcpdump 直接从映射内存中读取数据 ↑ 不需要复制两个地址空间共享同一块物理内存这样就消除了一次内存复制的开销。在高速率抓包场景下mmap 模式可以将 CPU 利用率降低 30-50%。一个具体的数字你打开tcpdump -i eth0 port 80在千兆网络每秒约 10 万个包上运行。以下是有缓冲和无缓冲两种模式的数据无缓冲每个包一个 read 系统调用次数100,000 次/秒 CPU 占用仅切换10-30% CPU 占用含处理40-60% 有缓冲mmap4MB 缓冲区 系统调用次数10-20 次/秒每积累几千个包才叫一次 read CPU 占用仅切换 1% CPU 占用含处理10-20%这就是环形缓冲区的威力——把每包一次系统调用变成了每几千个包一次系统调用。切换开销从 CPU 占比 30% 降到了 1% 以下。下期预告libpcap——全世界抓包工具共同的老父亲。tcpdump、Wireshark、nmap、Snort——这些工具看似毫无关系但它们底层都在调用同一个库。我们从 C 函数的维度看这个库的设计。DumpAny 怎么做环形缓冲区解决的是生产者内核和消费者用户态程序速度不匹配的问题。DumpAny 的代理引擎面临同类的挑战解密线程生产者每秒产出几千条明文请求UI 渲染线程消费者每秒只能刷 60 帧。DumpAny 的解法跟环形缓冲区如出一辙——中间加一个无锁环形队列生产者往队尾写消费者从队头批量取。满了就丢最旧的不阻塞生产者。如果你在高流量抓包时发现界面偶尔漏了几条请求——那就是这个队列溢出了跟 tcpdump 的dropped by kernel一个道理只不过发生在用户态。写指针 → ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ P1│ P2│ P3│ P4│ P5│ │ │ │ └───┴───┴───┴───┴───┴───┴───┴───┘ ↑ ↑ 读指针 写指针 用户态读 内核写 缓冲区满了的情况 ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ P9│ P2│ P3│ P4│ P5│ P6│ P7│ P8│ └───┴───┴───┴───┴───┴───┴───┴───┘ ↑ 写指针追上了读指针丢包 dropped by kernel 聊几句tcpdump 的-B调的是什么单位—— 内核 BPF 环形缓冲区大小KB-B 40964MB 是默认。缓冲区满时内核绝对不能怎么做—— 阻塞生产者软中断上下文不能阻塞一阻塞整个协议栈停摆。一次用户态/内核态切换要多久—— 约 1-3 微秒。mmap 零拷贝能降多少 CPU—— 高吞吐抓包场景降 30-50%。ring buffer 丢包这茬DumpAny 帮你把缓冲区调好了基本不会因为 buffer 太小丢包。官网 dumpany.cn开源免费。
返回列表