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