形緩沖區(qū)——內(nèi)核和用戶態(tài)之間的快遞中轉(zhuǎn)站)
抓包原理探秘 14環(huán)形緩沖區(qū)——內(nèi)核和用戶態(tài)之間的快遞中轉(zhuǎn)站大家好我是毛衣哥。這一期聊一個你可能沒注意到但極其重要的組件——環(huán)形緩沖區(qū)。沒有它tcpdump 自己就能把你的 CPU 吃光。名字雖然土但設(shè)計是真的騷。前兩篇我們聊了 BPF 怎么在內(nèi)核里做過濾——但過濾完之后呢數(shù)據(jù)怎么從內(nèi)核空間送到用戶態(tài)的 tcpdump 進程里如果每個數(shù)據(jù)包都觸發(fā)一次從內(nèi)核到用戶態(tài)的傳輸——那 CPU 就全浪費在切換上了。環(huán)形緩沖區(qū)就是這個問題的解。它是 BPF 架構(gòu)中一個容易被忽略但極其精妙的設(shè)計。沒有緩沖區(qū)會怎樣假設(shè)沒有環(huán)形緩沖區(qū)tcpdump 的讀取流程是這樣的數(shù)據(jù)包通過 BPF 過濾需要保留 → 內(nèi)核立刻喚醒 tcpdump 進程 → tcpdump 調(diào)用 read() 讀取這個包 → 切換回內(nèi)核態(tài)把數(shù)據(jù)復(fù)制到用戶態(tài) → 切換回用戶態(tài)tcpdump 處理數(shù)據(jù)、打印輸出 → tcpdump 再次調(diào)用 read() 等待下一個包一次完整的切換過程需要做保存當前進程的寄存器切換頁表虛擬內(nèi)存映射執(zhí)行系統(tǒng)調(diào)用入口代碼從內(nèi)核態(tài)返回用戶態(tài)恢復(fù)進程的寄存器這個過程大約需要1-3 微秒。如果你每秒處理10 萬個數(shù)據(jù)包10 萬 PPS在千兆網(wǎng)絡(luò)上非常常見僅僅是切換開銷就達到了100-300 毫秒/秒——占了一顆 CPU 核心的 10-30%。如果沒有緩沖區(qū)tcpdump 自身就會成為系統(tǒng)的性能瓶頸。環(huán)形緩沖區(qū)的設(shè)計環(huán)形緩沖區(qū)解決的核心問題就是批量傳輸減少切換次數(shù)。它在內(nèi)核維護了一個環(huán)狀的緩沖隊列預(yù)處理內(nèi)存4 MB 的連續(xù)物理內(nèi)存固定分配 結(jié)構(gòu) ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │ P1 │ P2 │ P3 │ P4 │ P5 │ │ │ │ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ ↑ ↑ 寫指針 讀指針 內(nèi)核往這寫 用戶態(tài)從這里讀工作原理內(nèi)核把通過 BPF 過濾后的數(shù)據(jù)包復(fù)制到寫指針位置寫指針向前移動指向下一個空閑位置用戶態(tài)程序從讀指針位置開始讀通過 read() 系統(tǒng)調(diào)用讀完后讀指針向前移動如果寫指針寫滿了整個環(huán)——它會回到開頭開始寫因此叫環(huán)形如果寫指針追上了讀指針——緩沖區(qū)滿了新包無處可放// 環(huán)形緩沖區(qū)的讀寫偽代碼 struct RingBuffer { buf: byte[], // 固定大小的緩沖區(qū) size: int, // 緩沖區(qū)總大小 write_ptr: int, // 內(nèi)核寫指針位置 read_ptr: int, // 用戶態(tài)讀指針位置 } // 內(nèi)核側(cè)BPF 過濾后寫入緩沖區(qū) function kernel_write_packet(rb, packet): data_len len(packet) // 檢查有沒有空間寫指針 數(shù)據(jù)長度 會超過讀指針嗎 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: // 緩沖區(qū)滿了丟包計數(shù) stats.dropped_by_kernel // 不阻塞直接丟棄 // 用戶態(tài)側(cè)tcpdump 讀取緩沖區(qū) function user_read_packets(rb): while rb.read_ptr rb.write_ptr: // 從讀指針位置讀一個包 packet_size peek_u32(rb.buf, rb.read_ptr % rb.size) // 用戶態(tài)收到這個包 user_buffer copy_from_ring(rb.buf, rb.read_ptr % rb.size, packet_size) process_packet(user_buffer) rb.read_ptr packet_size // 讀指針前進 // 如果讀指針超過緩沖區(qū)末尾寫指針可能也繞回來了 // 但 write_ptr 永遠是單調(diào)遞增的——通過取模訪問數(shù)組緩沖區(qū)滿了怎么辦內(nèi)核必須做選擇——沒有完美的答案丟棄最新的包保留老的丟掉新的。公平——先到的包不會被丟棄。丟棄最老的包保留新的丟掉老的。用戶可能更關(guān)心最近的流量。阻塞生產(chǎn)者讓內(nèi)核停下來等待用戶態(tài)讀取。絕對不能做如果阻塞了整個網(wǎng)絡(luò)協(xié)議棧都會?!驗閮?nèi)核的軟中斷上下文不能阻塞等待。tcpdump 的選擇是丟棄最新到達的包。即寫指針越過讀指針時新包被丟棄——tcpdump 統(tǒng)計里的dropped by kernel就是被丟棄的數(shù)據(jù)包的數(shù)量。tcpdump 的 -B 參數(shù)到底在調(diào)什么當你運行tcpdump -B 4096時注意單位是 KB你實際上是在設(shè)置內(nèi)核 BPF 環(huán)形緩沖區(qū)的大小。tcpdump -B 1024 → 緩沖區(qū) 1 MB tcpdump -B 4096 → 緩沖區(qū) 4 MB默認 tcpdump -B 65536 → 緩沖區(qū) 64 MB大流量場景增大緩沖區(qū)的作用緩沖區(qū)越大能緩沖的數(shù)據(jù)包越多寫指針追上讀指針的概率越低丟包的概率越低增大緩沖區(qū)的代價緩沖區(qū)是內(nèi)核空間的內(nèi)存非連續(xù)但物理上需要連續(xù)性高內(nèi)核非連續(xù)內(nèi)存分配有限——不能無限大實際限制通常在 32-256 MB什么情況下你需要調(diào)大緩沖區(qū)看 tcpdump 的輸出tcpdump: 68723 packets captured tcpdump: 241799 packets received by filter tcpdump: 93616 packets dropped by kernel如果dropped by kernel的數(shù)量很大超過 captured 的 10%說明環(huán)形緩沖區(qū)滿了。你需要調(diào)大緩沖區(qū)-B 16384或者收縮過濾條件少抓一些包或者把數(shù)據(jù)寫到文件而不是終端寫終端的 I/O 開銷很大mmap 零拷貝更高級的優(yōu)化環(huán)形緩沖區(qū)雖然好但還有一個問題數(shù)據(jù)從內(nèi)核復(fù)制到用戶態(tài)還是需要一次 copy。內(nèi)核BPF 環(huán)形緩沖區(qū)→ 復(fù)制到用戶態(tài)tcpdump 的 read buffer→ 處理 ↑ 這里有一次內(nèi)存復(fù)制現(xiàn)代的 BPF 實現(xiàn)包括 eBPF支持mmap 模式內(nèi)存映射。用戶態(tài)程序通過 mmap 把內(nèi)核的環(huán)形緩沖區(qū)映射到自己的地址空間。mmap 之后內(nèi)核BPF 環(huán)形緩沖區(qū) → tcpdump 直接從映射內(nèi)存中讀取數(shù)據(jù) ↑ 不需要復(fù)制兩個地址空間共享同一塊物理內(nèi)存這樣就消除了一次內(nèi)存復(fù)制的開銷。在高速率抓包場景下mmap 模式可以將 CPU 利用率降低 30-50%。一個具體的數(shù)字你打開tcpdump -i eth0 port 80在千兆網(wǎng)絡(luò)每秒約 10 萬個包上運行。以下是有緩沖和無緩沖兩種模式的數(shù)據(jù)無緩沖每個包一個 read 系統(tǒng)調(diào)用次數(shù)100,000 次/秒 CPU 占用僅切換10-30% CPU 占用含處理40-60% 有緩沖mmap4MB 緩沖區(qū) 系統(tǒng)調(diào)用次數(shù)10-20 次/秒每積累幾千個包才叫一次 read CPU 占用僅切換 1% CPU 占用含處理10-20%這就是環(huán)形緩沖區(qū)的威力——把每包一次系統(tǒng)調(diào)用變成了每幾千個包一次系統(tǒng)調(diào)用。切換開銷從 CPU 占比 30% 降到了 1% 以下。下期預(yù)告libpcap——全世界抓包工具共同的老父親。tcpdump、Wireshark、nmap、Snort——這些工具看似毫無關(guān)系但它們底層都在調(diào)用同一個庫。我們從 C 函數(shù)的維度看這個庫的設(shè)計。DumpAny 怎么做環(huán)形緩沖區(qū)解決的是生產(chǎn)者內(nèi)核和消費者用戶態(tài)程序速度不匹配的問題。DumpAny 的代理引擎面臨同類的挑戰(zhàn)解密線程生產(chǎn)者每秒產(chǎn)出幾千條明文請求UI 渲染線程消費者每秒只能刷 60 幀。DumpAny 的解法跟環(huán)形緩沖區(qū)如出一轍——中間加一個無鎖環(huán)形隊列生產(chǎn)者往隊尾寫消費者從隊頭批量取。滿了就丟最舊的不阻塞生產(chǎn)者。如果你在高流量抓包時發(fā)現(xiàn)界面偶爾漏了幾條請求——那就是這個隊列溢出了跟 tcpdump 的dropped by kernel一個道理只不過發(fā)生在用戶態(tài)。寫指針 → ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ P1│ P2│ P3│ P4│ P5│ │ │ │ └───┴───┴───┴───┴───┴───┴───┴───┘ ↑ ↑ 讀指針 寫指針 用戶態(tài)讀 內(nèi)核寫 緩沖區(qū)滿了的情況 ┌───┬───┬───┬───┬───┬───┬───┬───┐ │ P9│ P2│ P3│ P4│ P5│ P6│ P7│ P8│ └───┴───┴───┴───┴───┴───┴───┴───┘ ↑ 寫指針追上了讀指針丟包 dropped by kernel 聊幾句tcpdump 的-B調(diào)的是什么單位—— 內(nèi)核 BPF 環(huán)形緩沖區(qū)大小KB-B 40964MB 是默認。緩沖區(qū)滿時內(nèi)核絕對不能怎么做—— 阻塞生產(chǎn)者軟中斷上下文不能阻塞一阻塞整個協(xié)議棧停擺。一次用戶態(tài)/內(nèi)核態(tài)切換要多久—— 約 1-3 微秒。mmap 零拷貝能降多少 CPU—— 高吞吐抓包場景降 30-50%。ring buffer 丟包這茬DumpAny 幫你把緩沖區(qū)調(diào)好了基本不會因為 buffer 太小丟包。官網(wǎng) dumpany.cn開源免費。