半年前我以源碼的方式描述了網絡包的接收過程。之后不斷有粉絲提醒我還沒聊發送過程呢。好,安排!
在開始今天的文章之前,我先來請大家思考幾個小問題。
問1:我們在查看內核發送數據消耗的 CPU 時,是應該看 sy 還是 si ?
問2:為什么你服務器上的 /proc/softirqs 里 NET_RX 要比 NET_TX 大的多的多?
問3:發送網絡數據的時候都涉及到哪些內存拷貝操作?
這些問題雖然在線上經常看到,但我們似乎很少去深究。如果真的能透徹地把這些問題理解到位,我們對性能的掌控能力將會變得更強。
帶著這三個問題,我們開始今天對 Linux 內核網絡發送過程的深度剖析。還是按照我們之前的傳統,先從一段簡單的代碼作為切入。如下代碼是一個典型服務器程序的典型的縮微代碼:
int main(){
fd = socket(AF_INET, SOCK_STREAM, 0);
bind(fd, ...);
listen(fd, ...);
cfd = accept(fd, ...);
// 接收用戶請求
read(cfd, ...);
// 用戶請求處理
dosometing();
// 給用戶返回結果
send(cfd, buf, sizeof(buf), 0);
}
今天我們來討論上述代碼中,調用 send 之后內核是怎么樣把數據包發送出去的。本文基于Linux 3.10,網卡驅動采用Intel的igb網卡舉例。
預警:本文共有一萬多字,25 張圖,長文慎入!
一、Linux 網絡發送過程總覽
我覺得看 Linux 源碼最重要的是得有整體上的把握,而不是一開始就陷入各種細節。
我這里先給大家準備了一個總的流程圖,簡單闡述下 send 發送了的數據是如何一步一步被發送到網卡的。
在這幅圖中,我們看到用戶數據被拷貝到內核態,然后經過協議棧處理后進入到了 RingBuffer 中。隨后網卡驅動真正將數據發送了出去。當發送完成的時候,是通過硬中斷來通知 CPU,然后清理 RingBuffer。
因為文章后面要進入源碼,所以我們再從源碼的角度給出一個流程圖。
雖然數據這時已經發送完畢,但是其實還有一件重要的事情沒有做,那就是釋放緩存隊列等內存。
那內核是如何知道什么時候才能釋放內存的呢,當然是等網絡發送完畢之后。網卡在發送完畢的時候,會給 CPU 發送一個硬中斷來通知 CPU。更完整的流程看圖:
注意,我們今天的主題雖然是發送數據,但是硬中斷最終觸發的軟中斷卻是 NET_RX_SOFTIRQ,而并不是 NET_TX_SOFTIRQ !!!(T 是 transmit 的縮寫,R 表示 receive)
意不意外,驚不驚喜???
所以這就是開篇問題 1 的一部分的原因(注意,這只是一部分原因)。
問1:在服務器上查看 /proc/softirqs,為什么 NET_RX 要比 NET_TX 大的多的多?
傳輸完成最終會觸發 NET_RX,而不是 NET_TX。所以自然你觀測 /proc/softirqs 也就能看到 NET_RX 更多了。
好,現在你已經對內核是怎么發送網絡包的有一個全局上的把握了。不要得意,我們需要了解的細節才是更有價值的地方,讓我們繼續!!
二、網卡啟動準備
現在的服務器上的網卡一般都是支持多隊列的。每一個隊列上都是由一個 RingBuffer 表示的,開啟了多隊列以后的的網卡就會對應有多個 RingBuffer。
網卡在啟動時最重要的任務之一就是分配和初始化 RingBuffer,理解了 RingBuffer 將會非常有助于后面我們掌握發送。因為今天的主題是發送,所以就以傳輸隊列為例,我們來看下網卡啟動時分配 RingBuffer 的實際過程。
在網卡啟動的時候,會調用到 __igb_open 函數,RingBuffer 就是在這里分配的。
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic int __igb_open(struct net_device *netdev, bool resuming)
{
struct igb_adapter *adapter = netdev_priv(netdev);
//分配傳輸描述符數組
err = igb_setup_all_tx_resources(adapter);
//分配接收描述符數組
err = igb_setup_all_rx_resources(adapter);
//開啟全部隊列
netif_tx_start_all_queues(netdev);
}
在上面 __igb_open 函數調用 igb_setup_all_tx_resources 分配所有的傳輸 RingBuffer, 調用 igb_setup_all_rx_resources 創建所有的接收 RingBuffer。
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic int igb_setup_all_tx_resources(struct igb_adapter *adapter)
{
//有幾個隊列就構造幾個 RingBuffer
for (i = 0; i 《 adapter-》num_tx_queues; i++) {
igb_setup_tx_resources(adapter-》tx_ring[i]);
}
}
真正的 RingBuffer 構造過程是在 igb_setup_tx_resources 中完成的。
//file: drivers/net/ethernet/intel/igb/igb_main.cint igb_setup_tx_resources(struct igb_ring *tx_ring)
{
//1.申請 igb_tx_buffer 數組內存
size = sizeof(struct igb_tx_buffer) * tx_ring-》count;
tx_ring-》tx_buffer_info = vzalloc(size);
//2.申請 e1000_adv_tx_desc DMA 數組內存
tx_ring-》size = tx_ring-》count * sizeof(union e1000_adv_tx_desc);
tx_ring-》size = ALIGN(tx_ring-》size, 4096);
tx_ring-》desc = dma_alloc_coherent(dev, tx_ring-》size,
&tx_ring-》dma, GFP_KERNEL);
//3.初始化隊列成員
tx_ring-》next_to_use = 0;
tx_ring-》next_to_clean = 0;
}
從上述源碼可以看到,實際上一個 RingBuffer 的內部不僅僅是一個環形隊列數組,而是有兩個。
1)igb_tx_buffer 數組:這個數組是內核使用的,通過 vzalloc 申請的。
2)e1000_adv_tx_desc 數組:這個數組是網卡硬件使用的,硬件是可以通過 DMA 直接訪問這塊內存,通過 dma_alloc_coherent 分配。
這個時候它們之間還沒有啥聯系。將來在發送的時候,這兩個環形數組中相同位置的指針將都將指向同一個 skb。這樣,內核和硬件就能共同訪問同樣的數據了,內核往 skb 里寫數據,網卡硬件負責發送。
最后調用 netif_tx_start_all_queues 開啟隊列。另外,對于硬中斷的處理函數 igb_msix_ring 其實也是在 __igb_open 中注冊的。
三、accept 創建新 socket
在發送數據之前,我們往往還需要一個已經建立好連接的 socket。
我們就以開篇服務器縮微源代碼中提到的 accept 為例,當 accept 之后,進程會創建一個新的 socket 出來,然后把它放到當前進程的打開文件列表中,專門用于和對應的客戶端通信。
假設服務器進程通過 accept 和客戶端建立了兩條連接,我們來簡單看一下這兩條連接和進程的關聯關系。
其中代表一條連接的 socket 內核對象更為具體一點的結構圖如下。
為了避免喧賓奪主,accept 詳細的源碼過程這里就不介紹了,感興趣請參考 《圖解 | 深入揭秘 epoll 是如何實現 IO 多路復用的!》。一文中的第一部分。
今天我們還是把重點放到數據發送過程上。
四、發送數據真正開始
4.1 send 系統調用實現
send 系統調用的源碼位于文件 net/socket.c 中。在這個系統調用里,內部其實真正使用的是 sendto 系統調用。整個調用鏈條雖然不短,但其實主要只干了兩件簡單的事情,
第一是在內核中把真正的 socket 找出來,在這個對象里記錄著各種協議棧的函數地址。
第二是構造一個 struct msghdr 對象,把用戶傳入的數據,比如 buffer地址、數據長度啥的,統統都裝進去。
剩下的事情就交給下一層,協議棧里的函數 inet_sendmsg 了,其中 inet_sendmsg 函數的地址是通過 socket 內核對象里的 ops 成員找到的。大致流程如圖。
有了上面的了解,我們再看起源碼就要容易許多了。源碼如下:
//file: net/socket.c
SYSCALL_DEFINE4(send, int, fd, void __user *, buff, size_t, len,
unsigned int, flags)
{
return sys_sendto(fd, buff, len, flags, NULL, 0);
}
SYSCALL_DEFINE6(......)
{
//1.根據 fd 查找到 socket
sock = sockfd_lookup_light(fd, &err, &fput_needed);
//2.構造 msghdr
struct msghdr msg;
struct iovec iov;
iov.iov_base = buff;
iov.iov_len = len;
msg.msg_iovlen = 1;
msg.msg_iov = &iov;
msg.msg_flags = flags;
......
//3.發送數據
sock_sendmsg(sock, &msg, len);
}
從源碼可以看到,我們在用戶態使用的 send 函數和 sendto 函數其實都是 sendto 系統調用實現的。send 只是為了方便,封裝出來的一個更易于調用的方式而已。
在 sendto 系統調用里,首先根據用戶傳進來的 socket 句柄號來查找真正的 socket 內核對象。接著把用戶請求的 buff、len、flag 等參數都統統打包到一個 struct msghdr 對象中。
接著調用了 sock_sendmsg =》 __sock_sendmsg ==》 __sock_sendmsg_nosec。在__sock_sendmsg_nosec 中,調用將會由系統調用進入到協議棧,我們來看它的源碼。
//file: net/socket.cstatic inline int __sock_sendmsg_nosec(...)
{
......
return sock-》ops-》sendmsg(iocb, sock, msg, size);
}
通過第三節里的 socket 內核對象結構圖,我們可以看到,這里調用的是 sock-》ops-》sendmsg 實際執行的是 inet_sendmsg。這個函數是 AF_INET 協議族提供的通用發送函數。
4.2 傳輸層處理
1)傳輸層拷貝
在進入到協議棧 inet_sendmsg 以后,內核接著會找到 socket 上的具體協議發送函數。對于 TCP 協議來說,那就是 tcp_sendmsg(同樣也是通過 socket 內核對象找到的)。
在這個函數中,內核會申請一個內核態的 skb 內存,將用戶待發送的數據拷貝進去。注意這個時候不一定會真正開始發送,如果沒有達到發送條件的話很可能這次調用直接就返回了。大概過程如圖:
我們來看 inet_sendmsg 函數的源碼。
//file: net/ipv4/af_inet.cint inet_sendmsg(......)
{
......
return sk-》sk_prot-》sendmsg(iocb, sk, msg, size);
}
在這個函數中會調用到具體協議的發送函數。同樣參考第三節里的 socket 內核對象結構圖,我們看到對于 TCP 協議下的 socket 來說,來說 sk-》sk_prot-》sendmsg 指向的是 tcp_sendmsg(對于 UPD 來說是 udp_sendmsg)。
tcp_sendmsg 這個函數比較長,我們分多次來看它。先看這一段
//file: net/ipv4/tcp.cint tcp_sendmsg(...)
{
while(...){
while(...){
//獲取發送隊列
skb = tcp_write_queue_tail(sk);
//申請skb 并拷貝
......
}
}
}
//file: include/net/tcp.hstatic inline struct sk_buff *tcp_write_queue_tail(const struct sock *sk)
{
return skb_peek_tail(&sk-》sk_write_queue);
}
理解對 socket 調用 tcp_write_queue_tail 是理解發送的前提。如上所示,這個函數是在獲取 socket 發送隊列中的最后一個 skb。skb 是 struct sk_buff 對象的簡稱,用戶的發送隊列就是該對象組成的一個鏈表。
我們再接著看 tcp_sendmsg 的其它部分。
//file: net/ipv4/tcp.cint tcp_sendmsg(struct kiocb *iocb, struct sock *sk, struct msghdr *msg,
size_t size)
{
//獲取用戶傳遞過來的數據和標志
iov = msg-》msg_iov; //用戶數據地址
iovlen = msg-》msg_iovlen; //數據塊數為1
flags = msg-》msg_flags; //各種標志
//遍歷用戶層的數據塊
while (--iovlen 》= 0) {
//待發送數據塊的地址
unsigned char __user *from = iov-》iov_base;
while (seglen 》 0) {
//需要申請新的 skb
if (copy 《= 0) {
//申請 skb,并添加到發送隊列的尾部
skb = sk_stream_alloc_skb(sk,
select_size(sk, sg),
sk-》sk_allocation);
//把 skb 掛到socket的發送隊列上
skb_entail(sk, skb);
}
// skb 中有足夠的空間
if (skb_availroom(skb) 》 0) {
//拷貝用戶空間的數據到內核空間,同時計算校驗和
//from是用戶空間的數據地址
skb_add_data_nocache(sk, skb, from, copy);
}
......
這個函數比較長,不過其實邏輯并不復雜。其中 msg-》msg_iov 存儲的是用戶態內存的要發送的數據的 buffer。接下來在內核態申請內核內存,比如 skb,并把用戶內存里的數據拷貝到內核態內存中。這就會涉及到一次或者幾次內存拷貝的開銷。
至于內核什么時候真正把 skb 發送出去。在 tcp_sendmsg 中會進行一些判斷。
//file: net/ipv4/tcp.cint tcp_sendmsg(...)
{
while(...){
while(...){
//申請內核內存并進行拷貝
//發送判斷
if (forced_push(tp)) {
tcp_mark_push(tp, skb);
__tcp_push_pending_frames(sk, mss_now, TCP_NAGLE_PUSH);
} else if (skb == tcp_send_head(sk))
tcp_push_one(sk, mss_now);
}
continue;
}
}
}
只有滿足 forced_push(tp) 或者 skb == tcp_send_head(sk) 成立的時候,內核才會真正啟動發送數據包。其中 forced_push(tp) 判斷的是未發送的數據數據是否已經超過最大窗口的一半了。
條件都不滿足的話,這次的用戶要發送的數據只是拷貝到內核就算完事了!
2)傳輸層發送
假設現在內核發送條件已經滿足了,我們再來跟蹤一下實際的發送過程。對于上小節函數中,當滿足真正發送條件的時候,無論調用的是 __tcp_push_pending_frames 還是 tcp_push_one 最終都實際會執行到 tcp_write_xmit。
所以我們直接從 tcp_write_xmit 看起,這個函數處理了傳輸層的擁塞控制、滑動窗口相關的工作。滿足窗口要求的時候,設置一下 TCP 頭然后將 skb 傳到更低的網絡層進行處理。
我們來看下 tcp_write_xmit 的源碼。
//file: net/ipv4/tcp_output.cstatic bool tcp_write_xmit(struct sock *sk, unsigned int mss_now, int nonagle,
int push_one, gfp_t gfp)
{
//循環獲取待發送 skb
while ((skb = tcp_send_head(sk)))
{
//滑動窗口相關
cwnd_quota = tcp_cwnd_test(tp, skb);
tcp_snd_wnd_test(tp, skb, mss_now);
tcp_mss_split_point(...);
tso_fragment(sk, skb, ...);
......
//真正開啟發送
tcp_transmit_skb(sk, skb, 1, gfp);
}
}
可以看到我們之前在網絡協議里學的滑動窗口、擁塞控制就是在這個函數中完成的,這部分就不過多展開了,感興趣同學自己找這段源碼來讀。我們今天只看發送主過程,那就走到了 tcp_transmit_skb。
//file: net/ipv4/tcp_output.cstatic int tcp_transmit_skb(struct sock *sk, struct sk_buff *skb, int clone_it,
gfp_t gfp_mask)
{
//1.克隆新 skb 出來
if (likely(clone_it)) {
skb = skb_clone(skb, gfp_mask);
......
}
//2.封裝 TCP 頭
th = tcp_hdr(skb);
th-》source = inet-》inet_sport;
th-》dest = inet-》inet_dport;
th-》window = ...;
th-》urg = ...;
......
//3.調用網絡層發送接口
err = icsk-》icsk_af_ops-》queue_xmit(skb, &inet-》cork.fl);
}
第一件事是先克隆一個新的 skb,這里重點說下為什么要復制一個 skb 出來呢?
是因為 skb 后續在調用網絡層,最后到達網卡發送完成的時候,這個 skb 會被釋放掉。而我們知道 TCP 協議是支持丟失重傳的,在收到對方的 ACK 之前,這個 skb 不能被刪除。所以內核的做法就是每次調用網卡發送的時候,實際上傳遞出去的是 skb 的一個拷貝。等收到 ACK 再真正刪除。
第二件事是修改 skb 中的 TCP header,根據實際情況把 TCP 頭設置好。這里要介紹一個小技巧,skb 內部其實包含了網絡協議中所有的 header。在設置 TCP 頭的時候,只是把指針指向 skb 的合適位置。后面再設置 IP 頭的時候,在把指針挪一挪就行,避免頻繁的內存申請和拷貝,效率很高。
tcp_transmit_skb 是發送數據位于傳輸層的最后一步,接下來就可以進入到網絡層進行下一層的操作了。調用了網絡層提供的發送接口icsk-》icsk_af_ops-》queue_xmit()。
在下面的這個源碼中,我們的知道了 queue_xmit 其實指向的是 ip_queue_xmit 函數。
//file: net/ipv4/tcp_ipv4.cconst struct inet_connection_sock_af_ops ipv4_specific = {
.queue_xmit = ip_queue_xmit,
.send_check = tcp_v4_send_check,
...
}
自此,傳輸層的工作也就都完成了。數據離開了傳輸層,接下來將會進入到內核在網絡層的實現里。
4.3 網絡層發送處理
Linux 內核網絡層的發送的實現位于 net/ipv4/ip_output.c 這個文件。傳輸層調用到的 ip_queue_xmit 也在這里。(從文件名上也能看出來進入到 IP 層了,源文件名已經從 tcp_xxx 變成了 ip_xxx。)
在網絡層里主要處理路由項查找、IP 頭設置、netfilter 過濾、skb 切分(大于 MTU 的話)等幾項工作,處理完這些工作后會交給更下層的鄰居子系統來處理。
我們來看網絡層入口函數 ip_queue_xmit 的源碼:
//file: net/ipv4/ip_output.cint ip_queue_xmit(struct sk_buff *skb, struct flowi *fl)
{
//檢查 socket 中是否有緩存的路由表
rt = (struct rtable *)__sk_dst_check(sk, 0);
if (rt == NULL) {
//沒有緩存則展開查找
//則查找路由項, 并緩存到 socket 中
rt = ip_route_output_ports(...);
sk_setup_caps(sk, &rt-》dst);
}
//為 skb 設置路由表
skb_dst_set_noref(skb, &rt-》dst);
//設置 IP header
iph = ip_hdr(skb);
iph-》protocol = sk-》sk_protocol;
iph-》ttl = ip_select_ttl(inet, &rt-》dst);
iph-》frag_off = ...;
//發送
ip_local_out(skb);
}
ip_queue_xmit 已經到了網絡層,在這個函數里我們看到了網絡層相關的功能路由項查找,如果找到了則設置到 skb 上(沒有路由的話就直接報錯返回了)。
在 Linux 上通過 route 命令可以看到你本機的路由配置。
在路由表中,可以查到某個目的網絡應該通過哪個 Iface(網卡),哪個 Gateway(網卡)發送出去。查找出來以后緩存到 socket 上,下次再發送數據就不用查了。
接著把路由表地址也放到 skb 里去。
//file: include/linux/skbuff.hstruct sk_buff {
//保存了一些路由相關信息
unsigned long _skb_refdst;
}
接下來就是定位到 skb 里的 IP 頭的位置上,然后開始按照協議規范設置 IP header。
再通過 ip_local_out 進入到下一步的處理。
//file: net/ipv4/ip_output.c int ip_local_out(struct sk_buff *skb)
{
//執行 netfilter 過濾
err = __ip_local_out(skb);
//開始發送數據
if (likely(err == 1))
err = dst_output(skb);
......
在 ip_local_out =》 __ip_local_out =》 nf_hook 會執行 netfilter 過濾。如果你使用 iptables 配置了一些規則,那么這里將檢測是否命中規則。如果你設置了非常復雜的 netfilter 規則,在這個函數這里將會導致你的進程 CPU 開銷會極大增加。
還是不多展開說,繼續只聊和發送有關的過程 dst_output。
//file: include/net/dst.hstatic inline int dst_output(struct sk_buff *skb)
{
return skb_dst(skb)-》output(skb);
}
此函數找到到這個 skb 的路由表(dst 條目) ,然后調用路由表的 output 方法。這又是一個函數指針,指向的是 ip_output 方法。
//file: net/ipv4/ip_output.cint ip_output(struct sk_buff *skb)
{
//統計
...。.
//再次交給 netfilter,完畢后回調 ip_finish_output
return NF_HOOK_COND(NFPROTO_IPV4, NF_INET_POST_ROUTING, skb, NULL, dev,
ip_finish_output,
!(IPCB(skb)-》flags & IPSKB_REROUTED));
}
在 ip_output 中進行一些簡單的,統計工作,再次執行 netfilter 過濾。過濾通過之后回調 ip_finish_output。
//file: net/ipv4/ip_output.cstatic int ip_finish_output(struct sk_buff *skb)
{
//大于 mtu 的話就要進行分片了
if (skb-》len 》 ip_skb_dst_mtu(skb) && !skb_is_gso(skb))
return ip_fragment(skb, ip_finish_output2);
else
return ip_finish_output2(skb);
}
在 ip_finish_output 中我們看到,如果數據大于 MTU 的話,是會執行分片的。
實際 MTU 大小確定依賴 MTU 發現,以太網幀為 1500 字節。之前 QQ 團隊在早期的時候,會盡量控制自己數據包尺寸小于 MTU,通過這種方式來優化網絡性能。因為分片會帶來兩個問題:1、需要進行額外的切分處理,有額外性能開銷。2、只要一個分片丟失,整個包都得重傳。所以避免分片既杜絕了分片開銷,也大大降低了重傳率。
在 ip_finish_output2 中,終于發送過程會進入到下一層,鄰居子系統中。
//file: net/ipv4/ip_output.cstatic inline int ip_finish_output2(struct sk_buff *skb)
{
//根據下一跳 IP 地址查找鄰居項,找不到就創建一個
nexthop = (__force u32) rt_nexthop(rt, ip_hdr(skb)-》daddr);
neigh = __ipv4_neigh_lookup_noref(dev, nexthop);
if (unlikely(!neigh))
neigh = __neigh_create(&arp_tbl, &nexthop, dev, false);
//繼續向下層傳遞
int res = dst_neigh_output(dst, neigh, skb);
}
4.4 鄰居子系統
鄰居子系統是位于網絡層和數據鏈路層中間的一個系統,其作用是對網絡層提供一個封裝,讓網絡層不必關心下層的地址信息,讓下層來決定發送到哪個 MAC 地址。
而且這個鄰居子系統并不位于協議棧 net/ipv4/ 目錄內,而是位于 net/core/neighbour.c。因為無論是對于 IPv4 還是 IPv6 ,都需要使用該模塊。
在鄰居子系統里主要是查找或者創建鄰居項,在創造鄰居項的時候,有可能會發出實際的 arp 請求。然后封裝一下 MAC 頭,將發送過程再傳遞到更下層的網絡設備子系統。大致流程如圖。
理解了大致流程,我們再回頭看源碼。在上面小節 ip_finish_output2 源碼中調用了 __ipv4_neigh_lookup_noref。它是在 arp 緩存中進行查找,其第二個參數傳入的是路由下一跳 IP 信息。
//file: include/net/arp.hextern struct neigh_table arp_tbl;static inline struct neighbour *__ipv4_neigh_lookup_noref(
struct net_device *dev, u32 key)
{
struct neigh_hash_table *nht = rcu_dereference_bh(arp_tbl.nht);
//計算 hash 值,加速查找
hash_val = arp_hashfn(......);
for (n = rcu_dereference_bh(nht-》hash_buckets[hash_val]);
n != NULL;
n = rcu_dereference_bh(n-》next)) {
if (n-》dev == dev && *(u32 *)n-》primary_key == key)
return n;
}
}
如果查找不到,則調用 __neigh_create 創建一個鄰居。
//file: net/core/neighbour.cstruct neighbour *__neigh_create(......)
{
//申請鄰居表項
struct neighbour *n1, *rc, *n = neigh_alloc(tbl, dev);
//構造賦值
memcpy(n-》primary_key, pkey, key_len);
n-》dev = dev;
n-》parms-》neigh_setup(n);
//最后添加到鄰居 hashtable 中
rcu_assign_pointer(nht-》hash_buckets[hash_val], n);
......
有了鄰居項以后,此時仍然還不具備發送 IP 報文的能力,因為目的 MAC 地址還未獲取。調用 dst_neigh_output 繼續傳遞 skb。
//file: include/net/dst.hstatic inline int dst_neigh_output(struct dst_entry *dst,
struct neighbour *n, struct sk_buff *skb)
{
......
return n-》output(n, skb);
}
調用 output,實際指向的是 neigh_resolve_output。在這個函數內部有可能會發出 arp 網絡請求。
//file: net/core/neighbour.cint neigh_resolve_output(){
//注意:這里可能會觸發 arp 請求
if (!neigh_event_send(neigh, skb)) {
//neigh-》ha 是 MAC 地址
dev_hard_header(skb, dev, ntohs(skb-》protocol),
neigh-》ha, NULL, skb-》len);
//發送
dev_queue_xmit(skb);
}
}
當獲取到硬件 MAC 地址以后,就可以封裝 skb 的 MAC 頭了。最后調用 dev_queue_xmit 將 skb 傳遞給 Linux 網絡設備子系統。
4.5 網絡設備子系統
鄰居子系統通過 dev_queue_xmit 進入到網絡設備子系統中來。
//file: net/core/dev.c int dev_queue_xmit(struct sk_buff *skb)
{
//選擇發送隊列
txq = netdev_pick_tx(dev, skb);
//獲取與此隊列關聯的排隊規則
q = rcu_dereference_bh(txq-》qdisc);
//如果有隊列,則調用__dev_xmit_skb 繼續處理數據
if (q-》enqueue) {
rc = __dev_xmit_skb(skb, q, dev, txq);
goto out;
}
//沒有隊列的是回環設備和隧道設備
......
}
開篇第二節網卡啟動準備里我們說過,網卡是有多個發送隊列的(尤其是現在的網卡)。上面對 netdev_pick_tx 函數的調用就是選擇一個隊列進行發送。
netdev_pick_tx 發送隊列的選擇受 XPS 等配置的影響,而且還有緩存,也是一套小復雜的邏輯。這里我們只關注兩個邏輯,首先會獲取用戶的 XPS 配置,否則就自動計算了。代碼見 netdev_pick_tx =》 __netdev_pick_tx。
//file: net/core/flow_dissector.c
u16 __netdev_pick_tx(struct net_device *dev, struct sk_buff *skb)
{
//獲取 XPS 配置
int new_index = get_xps_queue(dev, skb);
//自動計算隊列
if (new_index 《 0)
new_index = skb_tx_hash(dev, skb);}
然后獲取與此隊列關聯的 qdisc。在 linux 上通過 tc 命令可以看到 qdisc 類型,例如對于我的某臺多隊列網卡機器上是 mq disc。
#tc qdisc
qdisc mq 0: dev eth0 root
大部分的設備都有隊列(回環設備和隧道設備除外),所以現在我們進入到 __dev_xmit_skb。
//file: net/core/dev.cstatic inline int __dev_xmit_skb(struct sk_buff *skb, struct Qdisc *q,
struct net_device *dev,
struct netdev_queue *txq)
{
//1.如果可以繞開排隊系統
if ((q-》flags & TCQ_F_CAN_BYPASS) && !qdisc_qlen(q) &&
qdisc_run_begin(q)) {
......
}
//2.正常排隊
else {
//入隊
q-》enqueue(skb, q)
//開始發送
__qdisc_run(q);
}
}
上述代碼中分兩種情況,1 是可以 bypass(繞過)排隊系統的,另外一種是正常排隊。我們只看第二種情況。
先調用 q-》enqueue 把 skb 添加到隊列里。然后調用 __qdisc_run 開始發送。
//file: net/sched/sch_generic.cvoid __qdisc_run(struct Qdisc *q)
{
int quota = weight_p;
//循環從隊列取出一個 skb 并發送
while (qdisc_restart(q)) {
// 如果發生下面情況之一,則延后處理:
// 1. quota 用盡
// 2. 其他進程需要 CPU
if (--quota 《= 0 || need_resched()) {
//將觸發一次 NET_TX_SOFTIRQ 類型 softirq
__netif_schedule(q);
break;
}
}
}
在上述代碼中,我們看到 while 循環不斷地從隊列中取出 skb 并進行發送。注意,這個時候其實都占用的是用戶進程的系統態時間(sy)。只有當 quota 用盡或者其它進程需要 CPU 的時候才觸發軟中斷進行發送。
所以這就是為什么一般服務器上查看 /proc/softirqs,一般 NET_RX 都要比 NET_TX 大的多的第二個原因。對于讀來說,都是要經過 NET_RX 軟中斷,而對于發送來說,只有系統態配額用盡才讓軟中斷上。
我們來把精力在放到 qdisc_restart 上,繼續看發送過程。
static inline int qdisc_restart(struct Qdisc *q)
{
//從 qdisc 中取出要發送的 skb
skb = dequeue_skb(q);
...
return sch_direct_xmit(skb, q, dev, txq, root_lock);
}
qdisc_restart 從隊列中取出一個 skb,并調用 sch_direct_xmit 繼續發送。
//file: net/sched/sch_generic.cint sch_direct_xmit(struct sk_buff *skb, struct Qdisc *q,
struct net_device *dev, struct netdev_queue *txq,
spinlock_t *root_lock)
{
//調用驅動程序來發送數據
ret = dev_hard_start_xmit(skb, dev, txq);
}
4.6 軟中斷調度
在 4.5 咱們看到了如果系統態 CPU 發送網絡包不夠用的時候,會調用 __netif_schedule 觸發一個軟中斷。該函數會進入到 __netif_reschedule,由它來實際發出 NET_TX_SOFTIRQ 類型軟中斷。
軟中斷是由內核線程來運行的,該線程會進入到 net_tx_action 函數,在該函數中能獲取到發送隊列,并也最終調用到驅動程序里的入口函數 dev_hard_start_xmit。
//file: net/core/dev.cstatic inline void __netif_reschedule(struct Qdisc *q)
{
sd = &__get_cpu_var(softnet_data);
q-》next_sched = NULL;
*sd-》output_queue_tailp = q;
sd-》output_queue_tailp = &q-》next_sched;
......
raise_softirq_irqoff(NET_TX_SOFTIRQ);
}
在該函數里在軟中斷能訪問到的 softnet_data 里設置了要發送的數據隊列,添加到了 output_queue 里了。緊接著觸發了 NET_TX_SOFTIRQ 類型的軟中斷。(T 代表 transmit 傳輸)
軟中斷的入口代碼我這里也不詳細扒了,感興趣的同學參考《圖解Linux網絡包接收過程》一文中的 3.2 小節 - ksoftirqd內核線程處理軟中斷。
我們直接從 NET_TX_SOFTIRQ softirq 注冊的回調函數 net_tx_action講起。用戶態進程觸發完軟中斷之后,會有一個軟中斷內核線程會執行到 net_tx_action。
牢記,這以后發送數據消耗的 CPU 就都顯示在 si 這里了,不會消耗用戶進程的系統時間了。
//file: net/core/dev.cstatic void net_tx_action(struct softirq_action *h)
{
//通過 softnet_data 獲取發送隊列
struct softnet_data *sd = &__get_cpu_var(softnet_data);
// 如果 output queue 上有 qdisc
if (sd-》output_queue) {
// 將 head 指向第一個 qdisc
head = sd-》output_queue;
//遍歷 qdsics 列表
while (head) {
struct Qdisc *q = head;
head = head-》next_sched;
//發送數據
qdisc_run(q);
}
}
}
軟中斷這里會獲取 softnet_data。前面我們看到進程內核態在調用 __netif_reschedule 的時候把發送隊列寫到 softnet_data 的 output_queue 里了。軟中斷循環遍歷 sd-》output_queue 發送數據幀。
來看 qdisc_run,它和進程用戶態一樣,也會調用到 __qdisc_run。
//file: include/net/pkt_sched.hstatic inline void qdisc_run(struct Qdisc *q)
{
if (qdisc_run_begin(q))
__qdisc_run(q);
}
然后一樣就是進入 qdisc_restart =》 sch_direct_xmit,直到驅動程序函數 dev_hard_start_xmit。
4.7 igb 網卡驅動發送
我們前面看到,無論是對于用戶進程的內核態,還是對于軟中斷上下文,都會調用到網絡設備子系統中的 dev_hard_start_xmit 函數。在這個函數中,會調用到驅動里的發送函數 igb_xmit_frame。
在驅動函數里,將 skb 會掛到 RingBuffer上,驅動調用完畢后,數據包將真正從網卡發送出去。
我們來看看實際的源碼:
//file: net/core/dev.cint dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev,
struct netdev_queue *txq)
{
//獲取設備的回調函數集合 ops
const struct net_device_ops *ops = dev-》netdev_ops;
//獲取設備支持的功能列表
features = netif_skb_features(skb);
//調用驅動的 ops 里面的發送回調函數 ndo_start_xmit 將數據包傳給網卡設備
skb_len = skb-》len;
rc = ops-》ndo_start_xmit(skb, dev);
}
其中 ndo_start_xmit 是網卡驅動要實現的一個函數,是在 net_device_ops 中定義的。
//file: include/linux/netdevice.hstruct net_device_ops {
netdev_tx_t (*ndo_start_xmit) (struct sk_buff *skb,
struct net_device *dev);
}
在 igb 網卡驅動源碼中,我們找到了。
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic const struct net_device_ops igb_netdev_ops = {
.ndo_open = igb_open,
.ndo_stop = igb_close,
.ndo_start_xmit = igb_xmit_frame,
...
};
也就是說,對于網絡設備層定義的 ndo_start_xmit, igb 的實現函數是 igb_xmit_frame。這個函數是在網卡驅動初始化的時候被賦值的。具體初始化過程參見《圖解Linux網絡包接收過程》一文中的 2.4 節,網卡驅動初始化。
所以在上面網絡設備層調用 ops-》ndo_start_xmit 的時候,會實際上進入 igb_xmit_frame 這個函數中。我們進入這個函數來看看驅動程序是如何工作的。
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic netdev_tx_t igb_xmit_frame(struct sk_buff *skb,
struct net_device *netdev)
{
......
return igb_xmit_frame_ring(skb, igb_tx_queue_mapping(adapter, skb));
}
netdev_tx_t igb_xmit_frame_ring(struct sk_buff *skb,
struct igb_ring *tx_ring)
{
//獲取TX Queue 中下一個可用緩沖區信息
first = &tx_ring-》tx_buffer_info[tx_ring-》next_to_use];
first-》skb = skb;
first-》bytecount = skb-》len;
first-》gso_segs = 1;
//igb_tx_map 函數準備給設備發送的數據。
igb_tx_map(tx_ring, first, hdr_len);
}
在這里從網卡的發送隊列的 RingBuffer 中取下來一個元素,并將 skb 掛到元素上。
igb_tx_map 函數處理將 skb 數據映射到網卡可訪問的內存 DMA 區域。
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic void igb_tx_map(struct igb_ring *tx_ring,
struct igb_tx_buffer *first,
const u8 hdr_len)
{
//獲取下一個可用描述符指針
tx_desc = IGB_TX_DESC(tx_ring, i);
//為 skb-》data 構造內存映射,以允許設備通過 DMA 從 RAM 中讀取數據
dma = dma_map_single(tx_ring-》dev, skb-》data, size, DMA_TO_DEVICE);
//遍歷該數據包的所有分片,為 skb 的每個分片生成有效映射
for (frag = &skb_shinfo(skb)-》frags[0];; frag++) {
tx_desc-》read.buffer_addr = cpu_to_le64(dma);
tx_desc-》read.cmd_type_len = ...;
tx_desc-》read.olinfo_status = 0;
}
//設置最后一個descriptor
cmd_type |= size | IGB_TXD_DCMD;
tx_desc-》read.cmd_type_len = cpu_to_le32(cmd_type);
/* Force memory writes to complete before letting h/w know there
* are new descriptors to fetch
*/
wmb();
}
當所有需要的描述符都已建好,且 skb 的所有數據都映射到 DMA 地址后,驅動就會進入到它的最后一步,觸發真實的發送。
4.8 發送完成硬中斷
當數據發送完成以后,其實工作并沒有結束。因為內存還沒有清理。當發送完成的時候,網卡設備會觸發一個硬中斷來釋放內存。
在《圖解Linux網絡包接收過程》 一文中的 3.1 和 3.2 小節,我們詳細講述過硬中斷和軟中斷的處理過程。
在發送完成硬中斷里,會執行 RingBuffer 內存的清理工作,如圖。
再回頭看一下硬中斷觸發軟中斷的源碼。
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic inline void ____napi_schedule(...){
list_add_tail(&napi-》poll_list, &sd-》poll_list);
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
這里有個很有意思的細節,無論硬中斷是因為是有數據要接收,還是說發送完成通知,從硬中斷觸發的軟中斷都是 NET_RX_SOFTIRQ。這個我們在第一節說過了,這是軟中斷統計中 RX 要高于 TX 的一個原因。
好我們接著進入軟中斷的回調函數 igb_poll。在這個函數里,我們注意到有一行 igb_clean_tx_irq,參見源碼:
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic int igb_poll(struct napi_struct *napi, int budget)
{
//performs the transmit completion operations
if (q_vector-》tx.ring)
clean_complete = igb_clean_tx_irq(q_vector);
...
}
我們來看看當傳輸完成的時候,igb_clean_tx_irq 都干啥了。
//file: drivers/net/ethernet/intel/igb/igb_main.cstatic bool igb_clean_tx_irq(struct igb_q_vector *q_vector)
{
//free the skb
dev_kfree_skb_any(tx_buffer-》skb);
//clear tx_buffer data
tx_buffer-》skb = NULL;
dma_unmap_len_set(tx_buffer, len, 0);
// clear last DMA location and unmap remaining buffers */
while (tx_desc != eop_desc) {
}
}
無非就是清理了 skb,解除了 DMA 映射等等。到了這一步,傳輸才算是基本完成了。
為啥我說是基本完成,而不是全部完成了呢?因為傳輸層需要保證可靠性,所以 skb 其實還沒有刪除。它得等收到對方的 ACK 之后才會真正刪除,那個時候才算是徹底的發送完畢。
最后
用一張圖總結一下整個發送過程
了解了整個發送過程以后,我們回頭再來回顧開篇提到的幾個問題。
1.我們在監控內核發送數據消耗的 CPU 時,是應該看 sy 還是 si ?
在網絡包的發送過程中,用戶進程(在內核態)完成了絕大部分的工作,甚至連調用驅動的事情都干了。只有當內核態進程被切走前才會發起軟中斷。發送過程中,絕大部分(90%)以上的開銷都是在用戶進程內核態消耗掉的。
只有一少部分情況下才會觸發軟中斷(NET_TX 類型),由軟中斷 ksoftirqd 內核進程來發送。
所以,在監控網絡 IO 對服務器造成的 CPU 開銷的時候,不能僅僅只看 si,而是應該把 si、sy 都考慮進來。
2. 在服務器上查看 /proc/softirqs,為什么 NET_RX 要比 NET_TX 大的多的多?
之前我認為 NET_RX 是讀取,NET_TX 是傳輸。對于一個既收取用戶請求,又給用戶返回的 Server 來說。這兩塊的數字應該差不多才對,至少不會有數量級的差異。但事實上,飛哥手頭的一臺服務器是這樣的:
經過今天的源碼分析,發現這個問題的原因有兩個。
第一個原因是當數據發送完成以后,通過硬中斷的方式來通知驅動發送完畢。但是硬中斷無論是有數據接收,還是對于發送完畢,觸發的軟中斷都是 NET_RX_SOFTIRQ,而并不是 NET_TX_SOFTIRQ。
第二個原因是對于讀來說,都是要經過 NET_RX 軟中斷的,都走 ksoftirqd 內核進程。而對于發送來說,絕大部分工作都是在用戶進程內核態處理了,只有系統態配額用盡才會發出 NET_TX,讓軟中斷上。
綜上兩個原因,那么在機器上查看 NET_RX 比 NET_TX 大的多就不難理解了。
3.發送網絡數據的時候都涉及到哪些內存拷貝操作?
這里的內存拷貝,我們只特指待發送數據的內存拷貝。
第一次拷貝操作是內核申請完 skb 之后,這時候會將用戶傳遞進來的 buffer 里的數據內容都拷貝到 skb 中。如果要發送的數據量比較大的話,這個拷貝操作開銷還是不小的。
第二次拷貝操作是從傳輸層進入網絡層的時候,每一個 skb 都會被克隆一個新的副本出來。網絡層以及下面的驅動、軟中斷等組件在發送完成的時候會將這個副本刪除。傳輸層保存著原始的 skb,在當網絡對方沒有 ack 的時候,還可以重新發送,以實現 TCP 中要求的可靠傳輸。
第三次拷貝不是必須的,只有當 IP 層發現 skb 大于 MTU 時才需要進行。會再申請額外的 skb,并將原來的 skb 拷貝為多個小的 skb。
這里插入個題外話,大家在網絡性能優化中經常聽到的零拷貝,我覺得這有點點夸張的成分。TCP 為了保證可靠性,第二次的拷貝根本就沒法省。如果包再大于 MTU 的話,分片時的拷貝同樣也避免不了。
看到這里,相信內核發送數據包對于你來說,已經不再是一個完全不懂的黑盒了。本文哪怕你只看懂十分之一,你也已經掌握了這個黑盒的打開方式。這在你將來優化網絡性能時你就會知道從哪兒下手了。
原文標題:25 張圖,一萬字,拆解 Linux 網絡包發送過程
文章出處:【微信公眾號:Linux愛好者】歡迎添加關注!文章轉載請注明出處。
責任編輯:haq
-
Linux
+關注
關注
87文章
11306瀏覽量
209572 -
網絡
+關注
關注
14文章
7569瀏覽量
88810
原文標題:25 張圖,一萬字,拆解 Linux 網絡包發送過程
文章出處:【微信號:LinuxHub,微信公眾號:Linux愛好者】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論