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