絡棧核心:ring buffer與TUN/TAP隊列機制全解析)
如果讓我用一個結(jié)構(gòu)解釋 Linux 網(wǎng)絡棧的吞吐秘密我會選 ring buffer。你要調(diào)網(wǎng)卡、分析丟包、理解 TUN/TAP 這類虛擬網(wǎng)絡設備最后都會碰到這個循環(huán)數(shù)組。很多文章把 ring buffer 講得很玄實際它就是個固定大小的環(huán)形隊列硬件放、軟件取TUN/TAP 看起來不像硬件但它同樣需要面對“隊列滿了怎么辦”的問題。這篇文章先寫第一部分把這兩個概念放在同一個語境里講清楚。用大白話說ring buffer 就是一塊“轉(zhuǎn)圈用”的內(nèi)存區(qū)數(shù)組元素是描述符每個描述符指向一塊可被 DMA 使用的數(shù)據(jù)緩沖區(qū)。網(wǎng)卡和驅(qū)動通過共享索引狀態(tài)在不需要鎖的情況下完成生產(chǎn)和消費。后面講 TUN/TAP 時你會發(fā)現(xiàn)用戶態(tài)和內(nèi)核態(tài)交換網(wǎng)絡包其實也在反復使用同樣的“有人生產(chǎn)、有人消費、滿了就丟”的模型。這篇更適合正在看網(wǎng)卡驅(qū)動、調(diào)網(wǎng)絡性能、或者準備用 TUN/TAP 做用戶態(tài)協(xié)議棧的人。標題里的 1 代表這是一個系列的第一部分這一篇先把框架搭起來下一篇再往 NAPI、vhost 和包調(diào)度方向延伸。1. 先從 ring buffer 的物理網(wǎng)卡出身講起1.1 隊列為什么是非要不可的很多人第一次看網(wǎng)卡驅(qū)動最大的疑問是為什么不給每個包現(xiàn)場分配內(nèi)存直接處理完就結(jié)束這個思路在低速率下沒問題但在萬兆甚至更高帶寬下會立刻崩掉。因為網(wǎng)卡收包是完全異步的。你永遠不知道下一個包什么時候到而中斷上下文里不能做可能睡眠的事情。如果每次收包都在中斷里分配內(nèi)存分配器會面臨巨大的競爭再加上GFP_ATOMIC的代價高吞吐場景下直接就扛不住。更關(guān)鍵的是一個網(wǎng)絡包并不是你讀完一個字節(jié)就能處理完它還要被放進某個數(shù)據(jù)結(jié)構(gòu)里繼續(xù)向上傳遞。如果沒有一塊提前準備好的緩沖區(qū)驅(qū)動就得一邊收包一邊找內(nèi)存這會把最熱路徑變成一場災難。所以網(wǎng)卡驅(qū)動普遍采用“預先分配一批緩沖區(qū)循環(huán)復用”的思路。這個循環(huán)復用的結(jié)構(gòu)就是 ring buffer。它的本質(zhì)是一個固定大小的數(shù)組加兩個索引數(shù)組元素叫描述符descriptor每個描述符記錄一塊 DMA 緩沖區(qū)的地點和大小。1.2 RX 方向網(wǎng)卡是生產(chǎn)者驅(qū)動是消費者從收包方向看網(wǎng)卡是生產(chǎn)者驅(qū)動是消費者。驅(qū)動初始化時會分配一塊 DMA ring并把一批空緩沖區(qū)填進去然后告訴網(wǎng)卡“這些地址你可以用”。網(wǎng)卡每收到一個包就用 DMA 把數(shù)據(jù)寫到其中一個緩沖區(qū)里再更新描述符狀態(tài)和索引。一個典型的 RX 描述符里通常包含這幾類信息字段作用DMA 地址硬件要把收到的數(shù)據(jù)寫到哪塊內(nèi)存長度緩沖區(qū)容量實際收包后變成實際字節(jié)數(shù)狀態(tài)字是否完成、是否有錯誤、校驗和狀態(tài)輔助信息VLAN 標簽、RSS hash不同網(wǎng)卡差異較大驅(qū)動在處理中斷或輪詢時會從自己記錄的消費位置開始逐個看描述符。如果沒有完成說明后面都是空的直接停下來如果完成了就把這個描述符對應的緩沖區(qū)變成 skb 送進協(xié)議棧然后重新分配一個空緩沖區(qū)還給 ring。整個過程就是生產(chǎn)索引和消費索引互相追趕。1.3 一次收包的完整回合我把一次完整的收包過程拆成下面幾步你可以對著驅(qū)動代碼去驗證驅(qū)動初始化 RX ring準備好若干個 skb 及其 DMA 映射描述符狀態(tài)置為“硬件可用”。網(wǎng)卡收到數(shù)據(jù)幀通過 DMA 把數(shù)據(jù)寫入一個空閑緩沖區(qū)然后更新描述符狀態(tài)和硬件側(cè)的索引。網(wǎng)卡觸發(fā)中斷或者在 NAPI 機制下標記自己需要被輪詢。驅(qū)動從上次消費的位置開始掃描描述符把完成的包逐個處理提交給協(xié)議棧。驅(qū)動回收這些描述符對應的緩沖區(qū)并重新映射新的空緩沖區(qū)后更新軟件側(cè)索引。這 5 步里最容易出問題的就是第 5 步。如果驅(qū)動處理速度跟不上或者用戶態(tài)消費速度不夠快整個 ring 會被未處理完的描述符占滿。網(wǎng)卡手里沒有空描述符新來的包只能被丟棄。這就是硬件層面上最常見的一種丟包來源。2. ring buffer 調(diào)優(yōu)中最容易踩的坑2.1 固定大小循環(huán)使用不會自己增長ring buffer 不是動態(tài)隊列。它的容量在驅(qū)動加載時就定死了要么來自模塊參數(shù)要么來自網(wǎng)卡默認值。你可以通過 ethtool 修改但不能讓它“自動擴容”。這個設計是有意的固定大小意味著內(nèi)存可以預分配索引加一取模就可以完成循環(huán)避免動態(tài)擴容帶來的鎖和重分配開銷。但副作用也很明顯一旦瞬時流量超過消費能力ring 滿了就會丟包。這里“消費能力”不只是 CPU 頻率還包括中斷頻率、NAPI 調(diào)度、鎖競爭、驅(qū)動是否把 GRO/ LRO 做得好等因素。你經(jīng)常看到的rx_missed、rx_no_buffer、rx_fifo_errors這類計數(shù)器本質(zhì)上都和“緩沖不夠用”有關(guān)。2.2 多隊列比單隊列大更重要很多人一看到丟包第一反應是把 ring 調(diào)大。但單純調(diào)大一個隊列收益往往很有限。真正決定高吞吐的是“同時有多少個隊列在處理”?,F(xiàn)代網(wǎng)卡通過 RSSReceive Side Scaling把不同流哈希到不同隊列每個隊列可以在獨立 CPU 上處理這樣多核才能分擔壓力。所以排查時不要只看 ring size還要看隊列數(shù)和中斷親和性。ethtool -l eth0能看到網(wǎng)卡支持多少隊列cat /proc/interrupts能看到每個隊列的中斷都落在哪個 CPU 上。如果所有中斷都擠在同一個 CPU那你就是把 ring 調(diào)成 65536也還是會因為單核處理不過來而丟包。2.3 用 ethtool 看懂硬件參數(shù)實際排查物理網(wǎng)卡時我常用的命令是這幾條ethtool -g eth0 ethtool -l eth0 ethtool -G eth0 rx 4096 tx 4096 ethtool -S eth0 | grep -E rx_missed|rx_no_buffer|rx_fifo-g看當前 ring 參數(shù)-G調(diào)大小-S看驅(qū)動計數(shù)器。計數(shù)器名稱在不同網(wǎng)卡驅(qū)動里不一致但常見的幾個含義是相通的指標含義rx_missed / rx_dropped網(wǎng)卡內(nèi)部因為 buffer 不足導致的丟包rx_no_buffer驅(qū)動沒能及時補描述符rx_fifo_errorsFIFO 或 DMA 通道發(fā)生溢出這些數(shù)字出現(xiàn)增長說明生產(chǎn)端比消費端快。好消息是ring buffer 的消費端是可以優(yōu)化的啟用多隊列、綁定中斷、調(diào)大 NAPI budget、開啟 GRO都比盲目調(diào)大 ring 更有效。3. TUN/TAP 和 ring buffer 到底什么關(guān)系3.1 TUN/TAP 是什么TUN/TAP 是內(nèi)核里的一組虛擬網(wǎng)絡設備對應一個通用的驅(qū)動模塊。它的一側(cè)是真正的net_device另一側(cè)暴露成一個文件描述符。應用程序?qū)?dev/net/tun做 read/write就能拿到網(wǎng)絡包或者把包送進內(nèi)核協(xié)議棧。它和普通物理網(wǎng)卡最大的區(qū)別是沒有 DMA沒有硬件中斷也沒有真正的網(wǎng)絡上鏈路。但它在協(xié)議棧里的地位和一塊網(wǎng)卡很像你給tun0配 IP、寫路由內(nèi)核就會按照普通接口的邏輯把包送進去。TUN 和 TAP 的區(qū)別經(jīng)常被人忽略。TUN 工作在 L3你從 fd 里讀到的是 IP 包TAP 工作在 L2從 fd 里讀到的是完整以太網(wǎng)幀。選擇哪個取決于你要做什么。維度TUNTAP數(shù)據(jù)單元IP 包以太網(wǎng)幀是否有 MAC 地址沒有有是否處理 ARP不處理需要處理典型場景路由、用戶態(tài)協(xié)議棧二層橋接、虛擬機網(wǎng)絡我的建議是如果只需要 IP 層的自定義邏輯優(yōu)先用 TUN省掉以太網(wǎng)頭之后代碼簡單很多。如果需要轉(zhuǎn)發(fā)完整的二層報文才用 TAP。3.2 數(shù)據(jù)在內(nèi)核和用戶態(tài)之間怎么流動理解 TUN/TAP 最關(guān)鍵的一點是搞清楚方向。很多人一上來就暈。當你把一段 IP 包數(shù)據(jù)write(fd, buf, len)寫到 TUN 設備的 fd 時內(nèi)核會把它當作一次“包從虛擬網(wǎng)卡收到”然后交給協(xié)議棧處理。這個過程對應的是 tun0 的 RX 方向。反過來當內(nèi)核路由決策要把某個包從 tun0 發(fā)出去時驅(qū)動不會真的發(fā)送到線纜上而是把這個包排進 fd 對應的接收隊列。你的程序read(fd, buf, len)拿到的就是這個“內(nèi)核要發(fā)出的包”。所以你可以把 fd 讀寫看成“用戶態(tài)就是鏈路對端”。內(nèi)核往 tun0 上發(fā)就是讀給你你寫進 fd 就等于對端發(fā)了一個包給 tun0。很多文獻里說“TUN/TAP 把內(nèi)核協(xié)議棧暴露給用戶態(tài)”本質(zhì)就是這條路徑。3.3 它也有一個“有界隊列”這就是和 ring buffer 相通的地方TUN/TAP 沒有硬件 ring但它必須在用戶態(tài)和內(nèi)核態(tài)之間設置一個隊列來緩沖包。如果應用程序讀得太慢隊列里的包越積越多最終會溢出并丟棄新包。這和物理網(wǎng)卡 ring 滿時丟掉新包的行為在語義上是一致的?,F(xiàn)代內(nèi)核的 TUN 驅(qū)動在實現(xiàn)這個隊列時也大量使用了類似 ring 的緊湊隊列結(jié)構(gòu)。所以討論“TUN/TAP 和 ring buffer 的關(guān)系”時不要理解成“TUN/TAP 里面有個一模一樣的硬件 ring”而是“它同樣面臨生產(chǎn)者、消費者和有界緩沖這三個核心問題”。消費端慢就會丟包這是所有收包隊列的共同宿命。如果你的 TUN 程序只是 sleep 很久才讀一次那么ip -s link show tun0里的 TX 方向丟包數(shù)就會上漲。這里的 TX 方向還包括 qdisc 排隊。你可以在ip link set dev tun0 txqueuelen 1000里調(diào)整隊列長度但更重要的永遠是讓用戶態(tài)讀取得足夠快。4. 動手實驗從用戶態(tài)把 TUN 跑起來4.1 環(huán)境準備TUN/TAP 需要內(nèi)核模塊tun以及設備節(jié)點/dev/net/tun。多數(shù)發(fā)行版默認已經(jīng)準備好但你可以確認一下modprobe tun ls -l /dev/net/tun如果/dev/net/tun不存在需要手動創(chuàng)建設備節(jié)點并確保當前進程有 CAP_NET_ADMIN 權(quán)限。實驗里建議直接用 root 或者 sudo 運行程序。4.2 一個最小可用的 TUN 程序下面這段代碼只做了兩件事創(chuàng)建一個名為tun0的 TUN 設備然后循環(huán)讀取從內(nèi)核發(fā)給它的 IP 包并打印基本信息。#include stdio.h #include string.h #include fcntl.h #include unistd.h #include sys/ioctl.h #include linux/if.h #include linux/if_tun.h #include netinet/ip.h #include arpa/inet.h static int tun_alloc(char *dev, int flags) { struct ifreq ifr; int fd open(/dev/net/tun, O_RDWR); if (fd 0) { perror(open /dev/net/tun); return -1; } memset(ifr, 0, sizeof(ifr)); ifr.ifr_flags flags; if (dev[0]) { strncpy(ifr.ifr_name, dev, IFNAMSIZ - 1); } if (ioctl(fd, TUNSETIFF, ifr) 0) { perror(TUNSETIFF); close(fd); return -1; } strcpy(dev, ifr.ifr_name); return fd; } int main() { char dev[IFNAMSIZ] tun0; int fd tun_alloc(dev, IFF_TUN | IFF_NO_PI); if (fd 0) { return 1; } printf(allocated: %s\n, dev); char buf[65536]; while (1) { ssize_t n read(fd, buf, sizeof(buf)); if (n 0) { perror(read); break; } if (n 0) { continue; } struct iphdr *iph (struct iphdr *)buf; if (n (ssize_t)sizeof(struct iphdr) iph-version 4) { printf(packet len%zd proto%d src%s dst%s\n, n, iph-protocol, inet_ntoa(*(struct in_addr *)iph-saddr), inet_ntoa(*(struct in_addr *)iph-daddr)); } else { printf(packet len%zd\n, n); } } close(fd); return 0; }注意代碼里的IFF_NO_PI。加了它read 出來就直接是 IP 包。如果不加內(nèi)核會在包前面多塞一個 4 字節(jié)的 packet info 頭里面是標志位和協(xié)議類型。4.3 配置地址并驗證收包把程序編譯好以后用 root 運行它然后在另一個終端配置sudo ip addr add 10.0.0.1 peer 10.0.0.2 dev tun0 sudo ip link set dev tun0 up ping -c 1 -I tun0 10.0.0.2因為我們的程序只讀包、不回復ping 大概率會顯示超時。但在運行程序的終端里你應該能看到類似這樣的輸出allocated: tun0 packet len98 proto1 src10.0.0.1 dst10.0.0.2這就說明內(nèi)核已經(jīng)把發(fā)往 10.0.0.2 的 ICMP 請求經(jīng)過 tun0 交到了你的用戶態(tài)程序手里。這個實驗的目的不是讓 ping 通而是驗證 TUN fd 能拿到內(nèi)核發(fā)出的 L3 包。有個細節(jié)要記住這個程序退出后tun0接口通常就會消失因為 TUN 設備默認不持久化。想要接口在程序退出后繼續(xù)存在可以用ip tuntap add dev tun0 mode tun預先創(chuàng)建或者在 ioctl 里設置TUNSETPERSIST。4.4 從接口統(tǒng)計看到方向和丟包我經(jīng)??吹接腥税?TUN 的 RX/TX 方向搞反。你可以用這條命令看統(tǒng)計ip -s link show tun0這里的 RX 表示內(nèi)核從“網(wǎng)絡側(cè)”收到的包也就是用戶態(tài)通過 fd 寫入并成功交給協(xié)議棧的包。而 TX 表示內(nèi)核要發(fā)送到“網(wǎng)絡側(cè)”的包也就是用戶態(tài)從 fd 讀走的包。對這個虛擬設備來說讀 fd 對應 TX寫 fd 對應 RX和直覺是反的。如果你發(fā)現(xiàn) TX dropped 一直在漲那就說明用戶態(tài)讀線程不夠快包在 TUN 的軟件隊列里被丟了。這和物理網(wǎng)卡 ring 滿導致rx_missed上漲是同一個道理消費者跟不上生產(chǎn)者。5. 常見問題與排查方法速查5.1 調(diào)大了 ring為什么丟包沒有改善最常見的原因是消費端才是瓶頸。你把 RX ring 從 1024 調(diào)到 4096只是把“等待隊列”變長了一點但消費這些包的 CPU 還是同一個處理速度沒變。流量突發(fā)一上來隊列照樣被填滿。真正有效的動作是確認 RSS 多隊列已經(jīng)開啟把各隊列的中斷分散到不同 CPU開啟 GRO 合并小包檢查 NAPI budget 是否被限死。這些優(yōu)化做下去ring 里等待被消費的包才會明顯減少。5.2 ethtool 報 Operation not supported云端虛擬機的網(wǎng)卡往往不支持修改 ring 參數(shù)因為底層不是你能控制的硬件。ethtool -g報錯并不代表方法錯只是當前設備不提供這個接口。這種情況下?lián)Q用軟件視角繼續(xù)排查看/proc/net/dev的 dropped用cat /proc/interrupts看軟中斷分布用ip -s link看接口統(tǒng)計。很多丟包問題在軟件棧里也能找到答案。5.3 TUN 接口收不到包TUN 設備建好了但程序一直卡在 read 等不到包先檢查路由ip route get 10.0.0.2如果返回的路由不是走 tun0那內(nèi)核自然不會把包交給它。另外確認接口沒有 down以及配置的是 peer 地址還是普通地址。TUN 是點對點接口配置方式和普通以太網(wǎng)接口略有不同。5.4 排障速查表現(xiàn)象排查手段常見結(jié)論物理網(wǎng)卡 rx_missed 上漲ethtool -Sethtool -gring 太小或消費端太慢軟中斷集中在單核cat /proc/interrupts隊列中斷親和性不對TUN fd 讀得慢ip -s link show tun0用戶態(tài)消費是瓶頸ping TUN 超時但程序有打印檢查程序是否回復只讀程序不回復屬預期我自己的經(jīng)驗是不管物理網(wǎng)卡還是 TUN/TAP丟包的根因絕大多數(shù)不在“緩沖區(qū)不夠大”而在“消費端不夠快”。先把消費模型調(diào)順再去看要不要加 ring 容量這個順序反了就會越調(diào)越迷茫。這一篇把 ring buffer 的基礎(chǔ)模型和 TUN/TAP 的讀寫路徑理順了。下一篇我會沿著這個消費模型繼續(xù)拆NAPI 怎么輪詢、GRO 怎么合并、vhost 又是怎么把內(nèi)核隊列直接映射給用戶態(tài)的。到那時候你對“Linux 網(wǎng)絡里到底誰在排隊、誰在消費”就會有更完整的畫面。