>
目錄
- 一、相關(guān)實際問題
- 二、網(wǎng)絡(luò)包發(fā)送過程總覽
- 三、網(wǎng)卡啟動準備
- 四、數(shù)據(jù)從用戶進程到網(wǎng)卡的詳細過程
- 1)系統(tǒng)調(diào)用實現(xiàn)
- 2)傳輸層處理
- 3)網(wǎng)絡(luò)層發(fā)送處理
- 4)鄰居子系統(tǒng)
- 5)網(wǎng)絡(luò)設(shè)備子系統(tǒng)
- 6)軟中斷調(diào)度
- 7)igb網(wǎng)卡驅(qū)動發(fā)送
- 五、RingBuffer內(nèi)存回收
- 六、問題解答
一、相關(guān)實際問題
- 查看內(nèi)核發(fā)送數(shù)據(jù)消耗的CPU時應(yīng)該看sy還是si
- 在服務(wù)器上查看/proc/softirqs,為什么NET_RX要比NET_TX大得多
- 發(fā)送網(wǎng)絡(luò)數(shù)據(jù)的時候都涉及那些內(nèi)存拷貝操作
- 零拷貝到底是怎么回事
- 為什么Kafka的網(wǎng)絡(luò)性能很突出
二、網(wǎng)絡(luò)包發(fā)送過程總覽
- 調(diào)用系統(tǒng)調(diào)用send發(fā)送
- 內(nèi)存拷貝
- 協(xié)議處理
- 進入驅(qū)動RingBuffer
- 實際發(fā)送
- 中斷通知發(fā)送完成
- 清理RingBuffer
三、網(wǎng)卡啟動準備
現(xiàn)在的服務(wù)器上的網(wǎng)卡一般都是支持多隊列的。每一個隊列都是由一個RingBuffer表示的,開啟了多隊列以后的網(wǎng)卡就會有多個RingBuffer。
網(wǎng)卡啟動時最重要的任務(wù)就是分配和初始化RingBuffer,在網(wǎng)卡啟動的時候會調(diào)用到__igb_open函數(shù),RingBuffer就是在這里分配的。
static int __igb_open(struct net_device *netdev, bool resuming) { // 分配傳輸描述符數(shù)組 err = igb_setup_all_tx_resources(adpater); // 分配接收描述符數(shù)組 err = igb_setup_all_rx_resources(adpater); // 注冊中斷處理函數(shù) err = igb_request_irq(adapter); if(err) goto err_req_irq; // 啟用NAPI for(i = 0; i < adapter->num_q_vectors; i++) napi_enable(&(adapter->q_vector[i]->napi)); ...... } static int igb_setup_all_tx_resources(struct igb_adapter *adapter) { // 有幾個隊列就構(gòu)造幾個RingBuffer for(int i = 0; i < adapter->num_tx_queues; i++) { igb_setup_tx_resources(adapter->tx_ring[i]); } }
igb_setup_tx_resources內(nèi)部也是申請了兩個數(shù)組,igb_tx_buffer數(shù)組和e1000_adv_tx_desc數(shù)組,一個供內(nèi)核使用,一個供網(wǎng)卡硬件使用。
在這個時候它們之間還沒什么關(guān)系,將來在發(fā)送數(shù)據(jù)的時候這兩個數(shù)組的指針都指向同一個skb,這樣內(nèi)核和硬件就能共同訪問同樣的數(shù)據(jù)了。
內(nèi)核往skb寫數(shù)據(jù),網(wǎng)卡硬件負責發(fā)送。
硬中斷的處理函數(shù)igb_msix_ring也是在__igb_open函數(shù)中注冊的。
四、數(shù)據(jù)從用戶進程到網(wǎng)卡的詳細過程
1)系統(tǒng)調(diào)用實現(xiàn)
send系統(tǒng)調(diào)用內(nèi)部真正使用的是sendto系統(tǒng)調(diào)用,主要做了兩件事:
- 在內(nèi)核中把真正的socket找出來
- 構(gòu)造struct msghdr對象, 把用戶傳入的數(shù)據(jù),比如buffer地址(用戶待發(fā)送數(shù)據(jù)的指針)、數(shù)據(jù)長度、發(fā)送標志都裝進去
SYS_CALL_DEFINE6(sendto, ......) { sock = sockfd_lookup_light(fd, &err, &fput_needed); struct msghdr msg; struct iovec iov; iov.iov_base = buff; iov.iov_len = len; msg.msg_iovlen = &iov; msg.msg_iov = &iov; msg.msg_flags = flags; ...... sock_sendmsg(sock, &msg, len); }
sock_sendmsg經(jīng)過一系列調(diào)用,最終來到__sock_sendmsg_nosec中調(diào)用sock->ops->sendmsg
對于AF_INET協(xié)議族的socket,sendmsg的實現(xiàn)統(tǒng)一為inet_sendmsg
2)傳輸層處理
1. 傳輸層拷貝
在進入?yún)f(xié)議棧inet_sendmsg以后,內(nèi)核接著會找到sock中具體的協(xié)議處理函數(shù),對于TCP協(xié)議而言,sk_prot操作函數(shù)集實例為tcp_prot,其中.sendmsg的實現(xiàn)為tcp_sendmsg(對于UDP而言中的為udp_sendmsg)。
int inet_sendmsg(......) { ...... return sk->sk_prot->sendmsg(iocb, sk, msg, size); } int tcp_sendmsg(......) { ...... // 獲取用戶傳遞過來的數(shù)據(jù)和標志 iov = msg->msg_iov; // 用戶數(shù)據(jù)地址 iovlen = msg->msg_iovlen; // 數(shù)據(jù)塊數(shù)為1 flags = msg->msg_flags; // 各種標志 copied = 0; // 已拷貝到發(fā)送隊列的字節(jié)數(shù) // 遍歷用戶層的數(shù)據(jù)塊 while(--iovlen >= 0) { // 待發(fā)送數(shù)據(jù)塊的長度 size_t seglen = iov->len; // 待發(fā)送數(shù)據(jù)塊的地址 unsigned char __user *from = iov->iov_base; // 指向下一個數(shù)據(jù)塊 iovlen++; ...... while(seglen > 0) { int copy = 0; int max = size_goal; // 單個skb最大的數(shù)據(jù)長度 skb = tcp_write_queue_tail(sk); // 獲取發(fā)送隊列最后一個skb // 用于返回發(fā)送隊列第一個數(shù)據(jù)包,如果不是NULL說明還有未發(fā)送的數(shù)據(jù) if(tcp_send_head(sk)) { ... copy = max - skb->len; // 該skb還可以存放的字節(jié)數(shù) } // 需要申請新的skb if(copy <= 0) { // 發(fā)送隊列的總大小大于等于發(fā)送緩存的上限,或尚發(fā)送緩存中未發(fā)送的數(shù)據(jù)量超過了用戶的設(shè)置值,進入等待 if(!sk_stream_memory_free(sk)) { goto wait_for_sndbuf; } // 申請一個skb skb = sk_stream_alloc_skb(sk, select_size(sk, sg), sk->sk_allocation); ... // 把skb添加到sock的發(fā)送隊列尾部 skb_entail(sk, skb); } if(copy > seglen) copy = seglen; // skb的線性數(shù)據(jù)區(qū)中有足夠的空間 if(skb_availroom(skb)) > 0) { copy = min_t(int, copy, skb_availroom(skb)); // 將用戶空間的數(shù)據(jù)拷貝到內(nèi)核空間,同時計算校驗和 err = skb_add_data_nocache(sk, skb, from, copy); if(err) goto do_fault; } // 線性數(shù)據(jù)區(qū)用完,使用分頁區(qū) else{ ... }
這個函數(shù)的實現(xiàn)邏輯比較復雜,代碼總只顯示了skb拷貝的相關(guān)部分,總體邏輯如下:
-
如果使用了TCP Fast Open,則會在發(fā)送SYN包的同時帶上數(shù)據(jù)
-
如果連接尚未建好,不處于ESTABLISHED或者CLOSE_WAIT狀態(tài)則進程進入睡眠,等待三次握手的完成
-
獲取當前的MSS(最大報文長度)和size_goal(一個理想的TCP數(shù)據(jù)包大小,受MTU、MSS、TCP窗口大小影響)
- 如果網(wǎng)卡支持GSO(利用網(wǎng)卡分片),size_goal會是MSS的整數(shù)倍
-
遍歷用戶層的數(shù)據(jù)塊數(shù)組
-
獲取發(fā)送隊列的最后一個skb,如果是尚未發(fā)送的,且長度未到達size_goal,那么向這個skb繼續(xù)追加數(shù)據(jù)
-
否則申請一個新的skb來裝載數(shù)據(jù)
- 如果發(fā)送隊列的總大小大于等于發(fā)送緩存的上限,或者發(fā)送緩存中尚未發(fā)送的數(shù)據(jù)量超過了用戶的設(shè)置值:設(shè)置發(fā)送時發(fā)送緩存不夠的標志,進入等待
- 申請一個skb,其線性區(qū)的大小為通過select_size()得到的線性數(shù)據(jù)區(qū)中TCP負荷的大小和最大的協(xié)議頭長度,申請失敗則等待可用內(nèi)存
- 前兩步成功則更新skb的TCP控制塊字段,把skb加入發(fā)送隊列隊尾,增加發(fā)送隊列的大小,減少預分配緩存的大小
-
將數(shù)據(jù)拷貝至skb中
-
如果skb的線性數(shù)據(jù)區(qū)還有剩余,就復制到線性數(shù)據(jù)區(qū)同時計算校驗和
-
如果已經(jīng)用完則使用分頁區(qū)
- 檢查分頁區(qū)是否有可用空間,沒有則申請新的page,申請失敗則說明內(nèi)存不足,之后會設(shè)置TCP內(nèi)存壓力標志,減小發(fā)送緩沖區(qū)的上限,睡眠等待內(nèi)存
- 判斷能否往最后一個分頁追加數(shù)據(jù),不能追加時,檢查分頁數(shù)是否已經(jīng)達到了上限或網(wǎng)卡是否不支持分散聚合,如果是的話就將skb設(shè)置為PSH標志,然后回到4.2中重新申請一個skb來繼續(xù)填裝數(shù)據(jù)
- 從系統(tǒng)層面判斷此次分頁發(fā)送緩存的申請是否合法
- 拷貝用戶空間的數(shù)據(jù)到skb的分頁中,同時計算校驗和。更新skb的長度字段,更新sock的發(fā)送隊列大小和預分配緩存
- 如果把數(shù)據(jù)追加到最后一個分頁了,更新最后一個分頁的數(shù)據(jù)大小。否則初始化新的分頁
-
-
拷貝成功后更新:發(fā)送隊列的最后一個序號、skb的結(jié)束序號、已經(jīng)拷貝到發(fā)送隊列的數(shù)據(jù)量
-
發(fā)送數(shù)據(jù)
- 如果所有數(shù)據(jù)都拷貝好了就退出循環(huán)進行發(fā)送
- 如果skb還可以繼續(xù)裝填數(shù)據(jù)或者發(fā)送的是帶外數(shù)據(jù)那么就繼續(xù)拷貝數(shù)據(jù)先不發(fā)送
- 如果為發(fā)送的數(shù)據(jù)已經(jīng)超過最大窗口的一半則設(shè)置PUSH標志后盡可能地將發(fā)送隊列中的skb發(fā)送出去
- 如果當前skb就是發(fā)送隊列中唯一一個skb,則將這一個skb發(fā)送出去
- 如果上述過程中出現(xiàn)緩存不足,且已經(jīng)有數(shù)據(jù)拷貝到發(fā)送隊列了也直接發(fā)送
-
這里的發(fā)送數(shù)據(jù)只是指調(diào)用tcp_push或者tcp_push_one(情況4)或者__tcp_push_pending_frames(情況3)嘗試發(fā)送,并不一定真的發(fā)送到網(wǎng)絡(luò)(tcp_sendmsg主要任務(wù)只是將應(yīng)用程序的數(shù)據(jù)封裝成網(wǎng)絡(luò)數(shù)據(jù)包放到發(fā)送隊列)。
數(shù)據(jù)何時實際被發(fā)送到網(wǎng)絡(luò),取決于許多因素,包括但不限于:
- TCP的擁塞控制算法:TCP使用了復雜的擁塞控制算法來防止網(wǎng)絡(luò)過載。如果TCP判斷網(wǎng)絡(luò)可能出現(xiàn)擁塞,它可能會延遲發(fā)送數(shù)據(jù)。
- 發(fā)送窗口的大?。篢CP使用發(fā)送窗口和接收窗口來控制數(shù)據(jù)的發(fā)送和接收。如果發(fā)送窗口已滿(即已發(fā)送但未被確認的數(shù)據(jù)量達到了發(fā)送窗口的大?。?,那么TCP必須等待接收到確認信息后才能發(fā)送更多的數(shù)據(jù)。
- 網(wǎng)絡(luò)設(shè)備(如網(wǎng)卡)的狀態(tài):如果網(wǎng)絡(luò)設(shè)備繁忙或出現(xiàn)錯誤,數(shù)據(jù)可能會被暫時掛起而無法立即發(fā)送。
struct sk_buff(常簡稱為skb)在Linux網(wǎng)絡(luò)棧中表示一個網(wǎng)絡(luò)包。它有兩個主要的數(shù)據(jù)區(qū)用來存儲數(shù)據(jù),分別是線性數(shù)據(jù)區(qū)(linear data area)和分頁區(qū)(paged data area)。
- 線性數(shù)據(jù)區(qū)(linear data area): 這個區(qū)域連續(xù)存儲數(shù)據(jù),并且能夠容納一個完整的網(wǎng)絡(luò)包的所有協(xié)議頭,比如MAC頭、IP頭和TCP/UDP頭等。除了協(xié)議頭部,線性數(shù)據(jù)區(qū)還可以包含一部分或全部的數(shù)據(jù)負載。每個skb都有一個線性數(shù)據(jù)區(qū)。
- 分頁區(qū)(paged data area): 一些情況下,為了優(yōu)化內(nèi)存使用和提高性能,skb的數(shù)據(jù)負載部分可以存儲在一個或多個內(nèi)存頁中,而非線性數(shù)據(jù)區(qū)。分頁區(qū)的數(shù)據(jù)通常只包含數(shù)據(jù)負載部分,不包含協(xié)議頭部。如果一個skb的數(shù)據(jù)全部放入了線性數(shù)據(jù)區(qū),那么這個skb就沒有分頁區(qū)。
這種設(shè)計的好處是,對于大的數(shù)據(jù)包,可以將其數(shù)據(jù)負載部分存儲在分頁區(qū),避免對大塊連續(xù)內(nèi)存的分配,從而提高內(nèi)存使用效率,減少內(nèi)存碎片。另外,這種設(shè)計也可以更好地支持零拷貝技術(shù)。例如,當網(wǎng)絡(luò)棧接收到一個大數(shù)據(jù)包時,可以直接將數(shù)據(jù)包的數(shù)據(jù)負載部分留在原始的接收緩沖區(qū)(即分頁區(qū)),而無需將其拷貝到線性數(shù)據(jù)區(qū),從而節(jié)省了內(nèi)存拷貝的開銷。
2. 傳輸層發(fā)送
上面的發(fā)送數(shù)據(jù)步驟,不論是調(diào)用__tcp_push_pending_frames還是tcp_push_one,最終都會執(zhí)行到tcp_write_xmit(在網(wǎng)絡(luò)協(xié)議中學到滑動窗口、擁塞控制就是在這個函數(shù)中完成的),函數(shù)的主要邏輯如下:
- 如果要發(fā)送多個數(shù)據(jù)段則先發(fā)送一個路徑mtu探測
- 檢測擁塞窗口的大小,如果窗口已滿(通過窗口大小-正在網(wǎng)絡(luò)上傳輸?shù)陌鼣?shù)目判斷)則不發(fā)送
- 檢測當前報文是否完全在發(fā)送窗口內(nèi),如果不是則不發(fā)送
- 判斷是否需要延時發(fā)送(取決于擁塞窗口和發(fā)送窗口)
- 根據(jù)需要對數(shù)據(jù)包進行分段(取決于擁塞窗口和發(fā)送窗口)
- tcp_transmit_skb發(fā)送數(shù)據(jù)包
- 如果push_one則結(jié)束循環(huán),否則繼續(xù)遍歷隊列發(fā)送
- 結(jié)束循環(huán)后如果本次有數(shù)據(jù)發(fā)送,則對TCP擁塞窗口進行檢查確認
這里我們只關(guān)注發(fā)送的主過程,其他部分不過多展開,即來到tcp_transmit_skb函數(shù)
static 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.調(diào)用網(wǎng)絡(luò)層發(fā)送接口 err = icsk->icsk_af_ops->xmit(skb, &inet->cort.fl); }
第一件事就是先克隆一個新的skb,因為skb后續(xù)在調(diào)用網(wǎng)絡(luò)層,最后到達網(wǎng)卡發(fā)送完成的時候,這個skb會被釋放掉。而TCP協(xié)議是支持丟失重傳的,在收到對方的ACK之前,這個skb不能被刪除掉。所以內(nèi)核的做法就是每次調(diào)用網(wǎng)卡發(fā)送的時候,實際上傳遞出去的是skb的一個拷貝。等收到ACK再真正刪除。
第二件事是修改skb的TCP頭,根據(jù)實際情況把TCP頭設(shè)置好。實際上skb內(nèi)部包含了網(wǎng)絡(luò)協(xié)議中所有的頭,在設(shè)置TCP頭的時候,只是把指針指向skb合適的位置。后面設(shè)置IP頭的時候,再把指針挪動一下即可,避免了頻繁的內(nèi)存申請和拷貝,提高效率。
tcp_transmit_skb是發(fā)送數(shù)據(jù)位于傳輸層的最后一步,調(diào)用了網(wǎng)絡(luò)層提供的發(fā)送接口icsk->icsk_Af_ops->queue_xmit()之后就可以進入網(wǎng)絡(luò)層進行下一層的操作了。
3)網(wǎng)絡(luò)層發(fā)送處理
在tcp_ipv4中,queue_xmit指向的是ip_queue_xmit,具體實現(xiàn)如下:
int 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設(shè)置路由表 skb_dst_set_noref(skb, &rt->dst); // 設(shè)置IP頭 iph = ip_hdr(skb); ip->protocol = sk->sk_protocol; iph->ttl = ip_select_ttl(inet, &rt->dst); ip->frag_off = ...; ip_copy_addr(iph, f14); ...... // 發(fā)送 ip_local_out(skb); }
這個函數(shù)主要做的就是找到該把這個包發(fā)往哪,并構(gòu)造好IP包頭。它會去查詢socket中是否有緩存的路由表,如果有則直接構(gòu)造包頭,如果沒有就去查詢并緩存到sokect,然后為skb設(shè)置路由表,最后封裝ip頭,發(fā)往ip_local_out函數(shù)。
ip_local_out中主要會經(jīng)過__ip_local_out => nf_hook 的過程進行netfilter的過濾。如果使用iptables配置了一些規(guī)則,那么這里將檢測到是否命中規(guī)則,然后進行相應(yīng)的操作,如網(wǎng)絡(luò)地址轉(zhuǎn)換、數(shù)據(jù)包內(nèi)容修改、數(shù)據(jù)包過濾等。如果設(shè)置了非常復雜的netfilter規(guī)則,則在這個函數(shù)會導致進程CPU的開銷大增。經(jīng)過netfilter處理之后,(忽略其他部分)調(diào)用dst_output(skb)函數(shù)。
dst_output會去調(diào)用skb_dst(skb)->output(skb),即找到skb的路由表(dst條目),然后調(diào)用路由表的output方法。這里是個函數(shù)指針,指向的是ip_output方法。
在ip_output方法中首先會進行一些簡單的統(tǒng)計工作,隨后再次執(zhí)行netfilter過濾。過濾通過之后回調(diào)ip_finish_output。
在ip_finish_output中,會校驗數(shù)據(jù)包的長度,如果大于MTU,就會執(zhí)行分片。MTU的大小是通過MTU發(fā)現(xiàn)機制確定,在以太網(wǎng)中為1500字節(jié)。分片會帶來兩個問題:
- 需要進行額外的處理,會有性能開銷
- 只要一個分片丟失,整個包都要重傳
如果不需要分片則調(diào)用ip_finish_output2函數(shù),根據(jù)下一跳的IP地址查找鄰居項,找不到就創(chuàng)建一個,然后發(fā)給下一層——鄰居子系統(tǒng)。
總體過程如下:
-
ip_queue_xmit
- 查找并設(shè)置路由項
- 設(shè)置IP頭
-
ip_local_out:netfilter過濾
-
ip_output
- 統(tǒng)計工作
- 再次netfilter過濾
-
ip_finish_output
- 大于MTU的話進行分片
- 調(diào)用ip_finish_output2
4)鄰居子系統(tǒng)
鄰居子系統(tǒng)是位于網(wǎng)絡(luò)層和數(shù)據(jù)鏈路層中間的一個系統(tǒng),其作用是為網(wǎng)絡(luò)層提供一個下層的封裝,讓網(wǎng)絡(luò)層不用關(guān)心下層的地址信息,讓下層來決定發(fā)送到哪個MAC地址。
鄰居子系統(tǒng)不位于協(xié)議棧net/ipv4/目錄內(nèi),而是位于net/core/neighbour.c,因為無論對于ipv4還是ipv6都需要使用該模塊
在鄰居子系統(tǒng)中主要查找或者創(chuàng)建鄰居項,在創(chuàng)建鄰居項時有可能會發(fā)出實際的arp請求。然后封裝MAC頭,將發(fā)生過程再傳遞給更下層的網(wǎng)絡(luò)設(shè)備子系統(tǒng)。
ip_finish_output2的實現(xiàn)邏輯大致流程如下:
-
rt_nexthop:獲取路由下一跳的IP信息
-
__ipv4_neigh_lookup_noref:根據(jù)下一條IP信息在arp緩存中查找鄰居項
-
__neigh_create:創(chuàng)建一個鄰居項,并加入鄰居哈希表
-
dst_neight_output => neighbour->output(實際指向neigh_resolve_output):
- 封裝MAC頭(可能會先觸發(fā)arp請求)
- 調(diào)用dev_queue_xmit發(fā)送到下層
5)網(wǎng)絡(luò)設(shè)備子系統(tǒng)
鄰居子系統(tǒng)通過dev_queue_xmit進入網(wǎng)絡(luò)設(shè)備子系統(tǒng),dev_queue_xmit的工作邏輯如下
- 選擇發(fā)送隊列
- 獲取排隊規(guī)則
- 存在隊列則調(diào)用__dev_xmit_skb繼續(xù)處理
在前面講過,網(wǎng)卡是有多個發(fā)送隊列的,所以首先需要選擇一個隊列進行發(fā)送。隊列的選擇首先是通過獲取用戶的XPS配置(為隊列綁核),如果沒有配置則調(diào)用skb_tx_hash去計算出選擇的隊列。接著會根據(jù)與此隊列關(guān)聯(lián)的qdisc得到該隊列的排隊規(guī)則。
最后會根據(jù)是否存在隊列(如果是發(fā)給回環(huán)設(shè)備或者隧道設(shè)備則沒有隊列)來決定后續(xù)數(shù)據(jù)包流向。對于存在隊列的設(shè)備會進入__dev_xmit_skb函數(shù)。
在Linux網(wǎng)絡(luò)子系統(tǒng)中,qdisc(Queueing Discipline,隊列規(guī)則)是一個用于管理網(wǎng)絡(luò)包排隊和發(fā)送的核心組件。它決定了網(wǎng)絡(luò)包在發(fā)送隊列中的排列順序,以及何時從隊列中取出包進行發(fā)送。qdisc還可以應(yīng)用于網(wǎng)絡(luò)流量控制,包括流量整形(traffic shaping)、流量調(diào)度(traffic scheduling)、流量多工(traffic multiplexing)等。
Linux提供了許多預定義的qdisc類型,包括:
- pfifo_fast:這是默認的qdisc類型,提供了基本的先入先出(FIFO)隊列行為。
- mq:多隊列時的默認類型,本身并不進行任何數(shù)據(jù)包的排隊或調(diào)度,而是為網(wǎng)絡(luò)設(shè)備的每個發(fā)送隊列創(chuàng)建和管理一個子 qdisc。
- tbf (Token Bucket Filter):提供了基本的流量整形功能,可以限制網(wǎng)絡(luò)流量的速率。
- htb (Hierarchical Token Bucket):一個更復雜的流量整形qdisc,可以支持多級隊列和不同的流量類別。
- sfq (Stochastic Fairness Queueing):提供了公平隊列調(diào)度,可以防止某一流量占用過多的帶寬。
每個網(wǎng)絡(luò)設(shè)備(如eth0、eth1等)都有一個關(guān)聯(lián)的qdisc,用于管理這個設(shè)備的發(fā)送隊列。用戶可以通過tc(traffic control)工具來配置和管理qdisc。
對于支持多隊列的網(wǎng)卡,Linux內(nèi)核為發(fā)送和接收隊列分別分配一個qdisc。每個qdisc獨立管理其對應(yīng)的隊列,包括決定隊列中的數(shù)據(jù)包發(fā)送順序,應(yīng)用流量控制策略等。這樣,可以實現(xiàn)每個隊列的獨立調(diào)度和流量控制,提高整體網(wǎng)絡(luò)性能。
我們可以說,對于支持多隊列的網(wǎng)卡,內(nèi)核中的每個發(fā)送隊列都對應(yīng)一個硬件的發(fā)送隊列(也就是 Ring Buffer)。選擇哪個內(nèi)核發(fā)送隊列發(fā)送數(shù)據(jù)包,也就決定了數(shù)據(jù)包將被放入哪個 Ring Buffer。數(shù)據(jù)包從 qdisc 的發(fā)送隊列出隊后,會被放入 Ring Buffer,然后由硬件發(fā)送到網(wǎng)絡(luò)線路上。所以,Ring Buffer 在發(fā)送路徑上位于發(fā)送隊列之后。
將struct sock的發(fā)送隊列和網(wǎng)卡的Ring Buffer之間設(shè)置一個由qdisc(隊列規(guī)則)管理的發(fā)送隊列,可以提供更靈活的網(wǎng)絡(luò)流量控制和調(diào)度策略,以適應(yīng)不同的網(wǎng)絡(luò)環(huán)境和需求。
下面是一些具體的原因:
- 流量整形和控制:qdisc可以實現(xiàn)各種復雜的排隊規(guī)則,用于控制數(shù)據(jù)包的發(fā)送順序和時間。這可以用于實現(xiàn)流量整形(比如限制數(shù)據(jù)的發(fā)送速率以避免網(wǎng)絡(luò)擁塞)和流量調(diào)度(比如按照優(yōu)先級或服務(wù)質(zhì)量(QoS)要求來調(diào)度不同的數(shù)據(jù)包)。
- 對抗網(wǎng)絡(luò)擁塞:qdisc可以通過管理發(fā)送隊列,使得在網(wǎng)絡(luò)擁塞時可以控制數(shù)據(jù)的發(fā)送,而不是簡單地將所有數(shù)據(jù)立即發(fā)送出去,這可以避免網(wǎng)絡(luò)擁塞的加劇。
- 公平性:在多個網(wǎng)絡(luò)連接共享同一個網(wǎng)絡(luò)設(shè)備的情況下,qdisc可以確保每個連接得到公平的網(wǎng)絡(luò)帶寬,而不會因為某個連接的數(shù)據(jù)過多而餓死其他的連接。
- 性能優(yōu)化:qdisc可以根據(jù)網(wǎng)絡(luò)設(shè)備的特性(例如,對于支持多隊列(Multi-Queue)的網(wǎng)卡)和當前的網(wǎng)絡(luò)條件來優(yōu)化數(shù)據(jù)包的發(fā)送,以提高網(wǎng)絡(luò)的吞吐量和性能。
__dev_xmit_skb分為三種情況:
-
qdisc停用:釋放數(shù)據(jù)并返回代碼設(shè)置為NET_XMIT_DROP
-
qdisc允許繞過排隊系統(tǒng)&&沒有其他包要發(fā)送&&qdisc沒有運行:繞過排隊系統(tǒng),調(diào)用sch_direct_xmit發(fā)送數(shù)據(jù)
-
其他情況:正常排隊
- 調(diào)用q->enqueue入隊
- 調(diào)用__qdisc_run開始發(fā)送
void __qdisc_run(struct Qdisc *q) { int quota = weight_p; // 循環(huán)從隊列取出一個skb并發(fā)送 while(qdisc_restart(q)) { // 如果quota耗盡或其他進程需要CPU則延后處理 if(--quota <= 0 || need_resched) { // 將觸發(fā)一次NET_TX_SOFTIRQ類型的softirq __netif_shcedule(q); break; } } }
從上述代碼中可以看到,while循環(huán)不斷地從隊列中取出skb并進行發(fā)送,這個時候其實占用的都是用戶進程系統(tǒng)態(tài)時間sy,只有當quota用盡或者其他進程需要CPU的時候才觸發(fā)軟中斷進行發(fā)送。
這就是為什么服務(wù)器上查看/proc/softirqs,一般NET_RX要比NET_TX大得多的原因。對于接收來說,都要經(jīng)過NET_RX軟中斷,而對于發(fā)送來說,只有系統(tǒng)配額用盡才讓軟中斷上。
這里我們聚焦于qdisc_restart函數(shù)上,這個函數(shù)用于從qdisc隊列中取包并發(fā)給網(wǎng)絡(luò)驅(qū)動
static inline int qdisc_restart(struct Qdisc *q) { struct sk_buff *skb = dequeue_skb(q); if (!skb) return 0; ...... return sch_direct_xmit(skb, q, dev, txq, root_lock); }
首先調(diào)用 dequeue_skb() 從 qdisc 中取出要發(fā)送的 skb。如果隊列為空,返回 0, 這將導致上層的 qdisc_restart() 返回 false,繼而退出 while 循環(huán)。
如果拿到了skb則調(diào)用sch_direct_xmit繼續(xù)發(fā)送,該函數(shù)會調(diào)用dev_hard_start_xmit,進入驅(qū)動程序發(fā)包,如果無法發(fā)送則重新入隊。
即整個__qdisc_run的整體邏輯為:while 循環(huán)調(diào)用 qdisc_restart(),后者取出一個 skb,然后嘗試通過 sch_direct_xmit() 來發(fā)送;sch_direct_xmit 調(diào)用 dev_hard_start_xmit 來向驅(qū)動程序進行實際發(fā)送。任何無法發(fā)送的 skb 都重新入隊,將在 NET_TX softirq 中進行發(fā)送。
6)軟中斷調(diào)度
上一部分中如果發(fā)送網(wǎng)絡(luò)包的時候CPU耗盡了,會調(diào)用進入__netif_schedule,該函數(shù)會進入__netif_reschedule,將發(fā)送隊列設(shè)置到softnet_data上,并最終發(fā)出一個NET_TX_SOFTIRQ類型的軟中斷。軟中斷是由內(nèi)核進程運行的,該進程會進入net_tx_action函數(shù),在該函數(shù)中能獲得發(fā)送隊列,并最終也調(diào)用到驅(qū)動程序的入口函數(shù)dev_hard_start_xmit。
從觸發(fā)軟中斷開始以后發(fā)送數(shù)據(jù)消耗的CPU就都顯示在si中,而不會消耗用戶進程的系統(tǒng)時間
static void net_tx_action(struct softirq_action *h) { struct softnet_data *sd = &__get_cpu_var(softnet_data); // 如果softnet_data設(shè)置了發(fā)送隊列 if(sd->output_queue) { // 將head指向第一個qdisc head = sd->output_queue; // 遍歷所有發(fā)送隊列 while(head) { struct Qdisc *q = head; head = head->next_sched; // 處理數(shù)據(jù) qdisc_run(q); } } } static inline void qdisc_run(struct Qdisc *q) { if(qdisc_run_begin(q)) __qdisc_run(q); }
可以看到軟中斷的處理中,最后和前面一樣都是調(diào)用了__qdisc_run。也就是說不管是在qdisc_restart中直接處理,還是軟中斷來處理,最終實際都會來到dev_hard_start_xmit(__qdisc_run => qdisc_restart => dev_hard_start_xmit)。
7)igb網(wǎng)卡驅(qū)動發(fā)送
通過前面的介紹可知,無論對于用戶進程的內(nèi)核態(tài),還是對于軟中斷上下文,都會調(diào)用網(wǎng)絡(luò)設(shè)備子系統(tǒng)的dev_hard_start_xmit函數(shù),在這個函數(shù)中,會調(diào)用驅(qū)動里的發(fā)送函數(shù)igb_xmit_frame。在驅(qū)動函數(shù)里,會將skb掛到RingBuffer上,驅(qū)動調(diào)用完畢,數(shù)據(jù)包真正從網(wǎng)卡發(fā)送出去。
int dev_hard_start_xmit(struct sk_buff *skb, struct net_device *dev, struct netdev_queue *txq) { // 獲取設(shè)備的回調(diào)函數(shù)ops const struct net_device_ops * ops = dev->netdev_ops; // 獲取設(shè)備支持的功能列表 features = netif_skb_features(skb); // 調(diào)用驅(qū)動的ops里的發(fā)送回調(diào)函數(shù)ndo_start_xmit將數(shù)據(jù)包傳給網(wǎng)卡設(shè)備 skb_len = skb->len; rc = ops->ndo_start_xmit(skb, dev); }
這里ndo_start_xmit是網(wǎng)卡驅(qū)動要實現(xiàn)的函數(shù),igb網(wǎng)卡驅(qū)動中的實現(xiàn)是igb_xmit_frame(在網(wǎng)卡驅(qū)動程序初始化的時候賦值的)。igb_xmit_frame主要會去調(diào)用igb_xmit_frame_ring函數(shù)
netdev_tx_t igb_xmit_frame_ring(struct sk_buff *skb, struct igb_ring *tx_ring) { // 獲取TX queue中下一個可用緩沖區(qū)的信息 first = &tx_ring->tx_buffer_info[tx_ring->next_to_use]; first->skb = skb; first->bytecount = skb->len; first->gso_segs = 1; // 準備給設(shè)備發(fā)送的數(shù)據(jù) igb_tx_map(tx_ring, first, hdr_len); } static 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構(gòu)造內(nèi)存映射,以允許設(shè)備通過DMA從RAM中讀取數(shù)據(jù) dma = dma_map_single(tx_ring->dev, skb->data, size, DMA_TO_DEVICE); // 遍歷該數(shù)據(jù)包的所有分片,為skb的每個分片生成有效映射 for(frag = &skb_shinfo(skb)->frags[0]; ; flag++){ tx_desc->read.buffer_addr = cpu_to_le64(dma); tx_desc->read.cmd_type_len = ...; tx_desc->read.olinfo_status = 0; } // 設(shè)置最后一個descriptor cmd_type |= size | IGB_TXD_DCMD; tx_desc->read.cmd_type_len = cpu_to_le32(cmd_type); }
在這里從網(wǎng)卡的發(fā)送隊列的RingBuffer上取下來一個元素,并將skb掛到元素上。然后使用igb_tx_map函數(shù)將skb數(shù)據(jù)映射到網(wǎng)卡可訪問的內(nèi)存DMA區(qū)域。
這里可以理解為&tx_ring->tx_buffer_info[tx_ring->next_to_use]拿到了RingBuffer發(fā)送隊列中指針數(shù)組(前文提到的igb_tx_buffer,網(wǎng)卡啟動的時候創(chuàng)建的供內(nèi)核使用的數(shù)組)的下一個可用的元素,然后為其填充skb、byte_count等數(shù)據(jù)。
填充完成之后,獲取描述符數(shù)組(前文提到的e1000_adv_tx_desc,網(wǎng)卡啟動的時候創(chuàng)建的供網(wǎng)卡使用的數(shù)組)的下一個可用元素。
調(diào)用dma_map_single函數(shù)創(chuàng)建內(nèi)存和設(shè)備之間的DMA映射,tx_ring->dev是設(shè)備的硬件描述符,即網(wǎng)卡,skb->data是要映射的地址,size是映射的數(shù)據(jù)的大小,即數(shù)據(jù)包的大小,DMA_TO_DEVICE是指映射的方向,這里是數(shù)據(jù)將從內(nèi)存?zhèn)鬏數(shù)皆O(shè)備,返回的調(diào)用結(jié)果是一個DMA地址,存儲在dma變量中,設(shè)備可以直接通過這個地址訪問到skb的數(shù)據(jù)。
最后就是為前面拿到的描述符填充信息,將dma賦值給buffer_addr,網(wǎng)卡使用的時候就是從這里拿到數(shù)據(jù)包的地址。
當所有需要的描述符都建好,且skb的所有數(shù)據(jù)都映射到DMA地址后,驅(qū)動就會進入到它的最后一步,觸發(fā)真實的發(fā)送。
到目前為止我們可以這么理解:
應(yīng)用程序?qū)?shù)據(jù)發(fā)送到 socket,這些數(shù)據(jù)會被放入與 sock 中的發(fā)送隊列。然后,網(wǎng)絡(luò)協(xié)議棧(例如 TCP 或 UDP)將這些數(shù)據(jù)從 socket 的發(fā)送隊列中取出,往下層封裝,然后將這些數(shù)據(jù)包放入由 qdisc 管理的設(shè)備發(fā)送隊列中。最后,這些數(shù)據(jù)包將從設(shè)備發(fā)送隊列出隊,放置到RingBuffer的指針數(shù)組中,通過dma將數(shù)據(jù)包的地址映射到可供網(wǎng)卡訪問的內(nèi)存DMA區(qū)域,由硬件讀取后發(fā)送到網(wǎng)絡(luò)上。
五、RingBuffer內(nèi)存回收
當數(shù)據(jù)發(fā)送完以后,其實工作并沒有結(jié)束,因為內(nèi)存還沒有清理。當發(fā)送完成的時候,網(wǎng)卡設(shè)備會觸發(fā)一個硬中斷(硬中斷會去觸發(fā)軟中斷)來釋放內(nèi)存。
這里需要注意的就是,雖然是數(shù)據(jù)發(fā)送完成通知,但是硬中斷觸發(fā)的軟中斷是NET_RX_SOFTIRQ,這也就是為什么軟中斷統(tǒng)計中RX要高于TX的另一個原因。
硬中斷中會向softnet_data添加poll_list,軟中斷中輪詢后調(diào)用其poll回調(diào)函數(shù),具體實現(xiàn)是igb_poll,其會在q_vector->tx.ring存在時去調(diào)用igb_clean_tx_irq。
static bool igb_clean_tx_irq(struct igb_q_vector *q_vector) { // 釋放skb dev_kfree_skb_any(tx_buffer->skb); // 清除tx_buffer數(shù)據(jù) tx_buffer->skb = NULL; // 將tx_buffer指定的DMA緩沖區(qū)的長度設(shè)置為0 dma_unmap_len_set(tx_buffer, len 0); // 清除最后的DMA位置,解除映射 while(tx_desc != eop_desc) { } }
其實邏輯無非就是清理了skb(其中data保存的數(shù)據(jù)包沒有釋放),解決了DMA映射等,到了這一步傳輸才算基本完成。
當然因為傳輸層需要保證可靠性,所以數(shù)據(jù)包還沒有刪除,此時還有前面的拷貝過的skb指向它,它得等到收到對方的ACK之后才會真正刪除。
六、問題解答
-
查看內(nèi)核發(fā)送數(shù)據(jù)消耗的CPU時應(yīng)該看sy還是si
- 在網(wǎng)絡(luò)包發(fā)送過程中,用戶進程(在內(nèi)核態(tài))完成了絕大部分的工作,甚至連調(diào)用驅(qū)動的工作都干了。只有當內(nèi)核態(tài)進程被切走前才會發(fā)起軟中斷。發(fā)送過程中百分之九十以上的開銷都是在用戶進程內(nèi)核態(tài)消耗掉的,只有一少部分情況才會觸發(fā)軟中斷,有軟中斷ksoftirqd內(nèi)核線程來發(fā)送。
- 所以在監(jiān)控網(wǎng)絡(luò)IO對服務(wù)器造成的CPU開銷的時候,不能近看si,而是應(yīng)該把si、sy(內(nèi)核占用CPU時間比例)都考慮進來。
-
在服務(wù)器上查看/proc/softirqs,為什么NET_RX要比NET_TX大得多
- 對于讀來說,都是要經(jīng)過NET_RX軟中斷的,都走ksoftirqd內(nèi)核線程。而對于發(fā)送來說,絕大部份工作都是在用戶進程內(nèi)核態(tài)處理了,只有系統(tǒng)態(tài)配額用盡才會發(fā)出NET_TX,讓軟中斷處理。
- 當數(shù)據(jù)發(fā)送完以后,通過硬中斷的方式來通知驅(qū)動發(fā)送完畢。但是硬中斷無論是有數(shù)據(jù)接收還是發(fā)送完畢,觸發(fā)的軟中斷都是NET_RX_SOFTIRQ而不是NET_TX_SOFTIRQ。
-
發(fā)送網(wǎng)絡(luò)數(shù)據(jù)的時候都涉及那些內(nèi)存拷貝操作
- 這里只指內(nèi)存拷貝
- 內(nèi)核申請完skb之后,將用戶傳遞進來的buffer里的數(shù)據(jù)拷貝到skb。如果數(shù)據(jù)量大,這個拷貝操作還是開銷不小的。
- 從傳輸層進入網(wǎng)絡(luò)層時。每個skb都會被克隆出一個新的副本,目的是保存原始的skb,當網(wǎng)絡(luò)對方?jīng)]有發(fā)揮ACK的時候還可以重新發(fā)送,易實現(xiàn)TCP中要求的可靠傳輸。不過這次只是淺拷貝,只拷貝skb描述符本身,所指向的數(shù)據(jù)還是復用的。
- 第三次拷貝不是必須的,只有當IP層發(fā)現(xiàn)skb大于MTU時才需要進行,此時會再申請額外的skb,并將原來的skb拷貝成多個小的skb。
-
零拷貝到底是怎么回事
- 如果想把本機的一個文件通過網(wǎng)絡(luò)發(fā)送出去,需要先調(diào)用read將文件讀到內(nèi)存,之后再調(diào)用send將文件發(fā)送出去
- 假設(shè)數(shù)據(jù)之前沒有讀去過,那么read系統(tǒng)調(diào)用需要兩次拷貝才能到用戶進程的內(nèi)存。第一次是從硬盤DMA到Page Cache。第二次是從Page Cache拷貝到內(nèi)存。send系統(tǒng)調(diào)用也同理,先CPU拷貝到socket發(fā)送隊列,之后網(wǎng)卡進行DMA拷貝。
- 如果要發(fā)送的數(shù)據(jù)量較大,那么就需要花費不少的時間在數(shù)據(jù)拷貝上。而sendfile就是內(nèi)核提供的一個可用來減少發(fā)送文件時拷貝開銷的一個技術(shù)方案。在sendfile系統(tǒng)調(diào)用里,數(shù)據(jù)不需要拷貝到用戶空間,在內(nèi)核態(tài)就能完成發(fā)送處理,減少了拷貝的次數(shù)。
-
為什么Kafka的網(wǎng)絡(luò)性能很突出
- Kafka高性能的原因有很多,其中重要的原因之一就是采用了sendfile系統(tǒng)調(diào)用來發(fā)送網(wǎng)絡(luò)數(shù)據(jù)包,減少了內(nèi)核態(tài)和用戶態(tài)之間的頻繁數(shù)據(jù)拷貝。
以上就是深入理解Linux網(wǎng)絡(luò)之內(nèi)核是如何發(fā)送網(wǎng)絡(luò)包的的詳細內(nèi)容,更多關(guān)于Linux 內(nèi)核發(fā)送網(wǎng)絡(luò)包的資料請關(guān)注其它相關(guān)文章!
>