理解 TCP/IP 網路棧 & 編寫網路應用

蛋疼的axb發表於2015-05-05

1.譯註

之前在網上看到了這篇描述tcp網路棧原理的文章,感覺不錯,決定抽空把這篇文章翻譯一下。一來重新溫習一下TCP相關知識,二來練練英文。 很久沒翻譯文章了難免有誤,建議有能力的同學還是看一下原文。

2.概述

我們難以想象沒有了TCP/IP之後的網路服務。所有我們開發並在NHN使用的網路服務都基於TCP/IP這個堅實的基礎。理解資料如何通過網路傳輸可以幫助你通過調優、排查或引進新技術之類的手段提升效能。

本文將基於Linux OS和硬體層的資料流和控制流來描述網路棧的整體執行方式。

3.TCP/IP的關鍵特性

我如何設計一個能在快速傳輸資料的同時保證資料順序並且不丟失資料的網路協議?TCP/IP在設計時就基於這個考慮。以下是在瞭解整體網路棧前需要知道的TCP/IP的主要特性:

TCP和IP
從技術上講,TCP和IP處於不同的層,應該分別解釋它們。但在這裡我們把他們看做一個整體。

  1. 面向連線首先,傳輸資料前需要在兩個終端之間建立連線(本地和遠端)。在這裡,“TCP連線識別符號(TCP connection identifier)”是兩個終端地址的組合,類似本地ip地址,本地埠號,遠端ip地址,遠端埠號的形式。
  2. 雙向位元組流通過位元組流實現雙向資料通訊。
  3. 順序投遞接收者在接收資料時與傳送者傳送的資料順序相同。因此,資料需要是有序的,為了表示這個順序,TCP/IP使用了32位的int資料型別。
  4. 通過ACK實現可靠性當傳送者向接收者傳送資料,但沒有收到來自接收方的ACK(acknowledgement,應答)時,傳送者的TCP層將重發資料。因此,傳送者的TCP層會把接收者還沒有應答的資料暫存起來。
  5. 流量控制傳送者的傳送速度與接收者的接收能力相關。接收者會把它能接收的最大位元組數(未使用的緩衝區大小,又叫接收視窗,receive window)告知傳送者。傳送者傳送的最大位元組數與接收者的接收視窗大小一致。
  6. 阻塞控制阻塞視窗是不同於接收視窗的另一個概念,它通過限制網路中的資料流的體積來防止網路阻塞。類似於接收視窗,傳送者通過通過一些演算法(例如TCP Vegas,Westwood,BIC,CUBIC)來計算髮送對應的接收者的阻塞視窗能容納的最多的資料。和流量控制不同,阻塞控制只在傳送方實現。(譯註:傳送者類似於通過ack時間之類的演算法判斷當前網路是否阻塞,從而調節傳送速度)

4.資料傳輸

網路棧有很多層。圖一表示了這些層的型別:

圖1:傳送資料時網路棧中各層對資料的操作

這些層可以被大致歸類到三個區域中:

  1. 使用者區
  2. 核心區
  3. 裝置區

使用者區和核心區的任務由CPU執行。使用者區和核心區被叫做“主機(host)”以區別於裝置區。在這裡,裝置是傳送和接收資料包的網路介面卡(Network Interface Card,NIC)。它有一個更常用的術語:網路卡。

我們來了解一下使用者區。首先,應用程式建立要傳送的資料(圖1中的“User Data”)並且呼叫write()系統呼叫來傳送資料(在這裡假設socket已經被建立了,對應圖1中的“fd”)。當系統呼叫被呼叫之後上下文切換到核心區。

像Linux或者Unix這類POSIX系列的作業系統通過檔案描述符(file descriptor)把socket暴露給應用程式。在這類系統中,socket是檔案的一種。檔案系統執行簡單的檢查並呼叫socket結構中指向的socket函式。

核心中的socket包含兩個緩衝區。

  1. 一個用於緩衝要傳送的資料
  2. 一個用於緩衝要接收的資料

write()系統呼叫被呼叫時,使用者區的資料被拷貝到核心記憶體中,並插入到socket的傳送緩衝區末尾。這樣來保證傳送的資料有序。在圖1中,淺灰色框表示在socket緩衝區中的資料。之後,TCP被呼叫了。

socket會關聯一個叫做TCP控制塊(TCP Control Block)的結構,TCB包含了處理TCP連線所需的資料。包括連線狀態(LISTENESTABLISHEDTIME_WAIT),接收視窗,阻塞視窗,順序號,重發計時器,等等。

如果當前的TCP狀態允許資料傳輸,一個新的TCP分段(TCP segment,或者叫資料包,packet)將被建立。如果由於流量控制或者其它原因不能傳輸資料,系統呼叫會在這裡結束,之後會返回到使用者態。(換句話說,控制權會交回到應用程式程式碼)

TCP分段有兩部分

  1. TCP頭
  2. 攜帶的資料

圖2:TCP幀的結構

TCP資料包的payload部分會包含在socket傳送緩衝區裡的沒有應答的資料。攜帶資料的最大長度是接收視窗、阻塞視窗和最大分段長度(maximum segment size,MSS)中的最大值。

之後會計算TCP校驗值。在計算時,頭資訊(ip地址、分段長度和埠號)會包含在內。根據TCP狀態可傳送一個或多個資料包。

事實上,當前的網路棧使用了校驗解除安裝(checksum offload),TCP校驗和會由NIC計算,而不是核心。但是,為了解釋方便我們還是假設核心計算校驗和。

被建立的TCP分段繼續走到下面的IP層。IP層向TCP分段中增加了IP頭並且執行了IP路由(IP routing)。IP路由是尋找到達目的IP的下一跳IP地址的過程。

在IP層計算並增加了IP頭校驗和之後,它把資料傳送到鏈路層。鏈路層通過地址解析協議(Address Resolution Protocol,ARP)搜尋下一跳IP地址對應的MAC地址。之後它會向資料包中增加鏈路頭,在增加鏈路頭之後主機要傳送的資料包就是完整的了。

在執行IP路由時,會選擇一個傳輸介面(NIC)。介面被用於把資料包傳送至下一跳IP。於是,用於傳送的NIC驅動程式被呼叫了。

在這個時候,如果正在執行資料包捕獲程式(例如tcpdump或wireshark)的話,核心將把資料包拷貝到這些程式的記憶體緩衝區中。用相同的方式,接收的資料包直接在驅動被捕獲。通常來說,traffic shaper(沒懂)函式被實現以在這個層上執行。

驅動程式(與核心)通過請求NIC製造商定義的通訊協議傳輸資料。

在接收到資料包傳輸請求之後,NIC把資料包從系統記憶體中拷貝到它自己的記憶體中,之後把資料包傳送到網路上。在此時,由於要遵守乙太網標準(Ethernet standard),NIC會向資料包中增加幀間隙(Inter-Frame Gap,IFG),同步碼(preamble)和crc校驗和。幀間隙和同步碼用於區分資料包的開始(網路術語叫做幀,framing),crc用於保護資料(與TCP或者IP校驗和的目的相同)。NIC會基於乙太網的物理速度和流量控制決定資料包開始傳輸的時間。It is like getting the floor and speaking in a conference room.(沒看懂)

當NIC傳送了資料包,NIC會在主機的CPU上產生中斷。所有的中斷會有自己的中斷號,作業系統會用這個中斷號查詢合適的程式去處理中斷。驅動程式在啟動時會註冊一個處理中斷的函式。作業系統呼叫中斷處理程式,之後中斷處理程式會把已傳送的資料包返回給作業系統。

到此為止我們討論了當應用程式執行了write之後,資料流經核心和裝置的過程。但是,除了應用程式直接呼叫write之外,核心也可以直接呼叫TCP傳輸資料包。比如當接收到ACK並且得知接收方的接收視窗增大之後,核心會建立包含socket緩衝區剩餘資料的TCP片段並且把資料傳送給接收者。

5.資料接收

現在我們看一下資料是如何被接收的。資料接收是網路棧如何處理流入資料包的過程。圖3展現了網路棧如何處理接收的資料包。

圖3:接收資料時網路棧中各層對資料的操作

首先,NIC把資料包寫入它自身的記憶體。通過CRC校驗檢查資料包是否有效,之後把資料包傳送到主機的記憶體緩衝區。這裡說的緩衝區是驅動程式提前向核心申請好的一塊記憶體區域,用於存放接收的資料包。在緩衝區被系統分配之後,驅動會把這部分記憶體的地址和大小告知NIC。如果主機沒有為驅動程式分配緩衝區,那麼當NIC接收到資料包時有可能會直接丟棄它。

在把資料包傳送到主機緩衝區之後,NIC會向主機發出中斷。

之後,驅動程式會判斷它是否能處理新的資料包。到目前為止使用的是由製造商定義的網路卡驅動的通訊協議。

當驅動應該向上層傳送資料包時,資料包必須被放進一個作業系統能夠理解和使用的資料結構。例如Linux的sk_buff,或者BSD系列核心的mbuf,或者windows的NET_BUFFER_LIST。驅動會把資料包包裝成指定的資料結構,併傳送到上一層。

鏈路層會檢查資料包是否有效並且解析出上層的協議(網路協議)。此時它會判斷鏈路頭中的乙太網型別。(IPv4的乙太網型別是0x0800)。它會把鏈路頭刪掉並且把資料包傳送到IP層。

IP層同樣會檢查資料包是否有效。或者說,會檢查IP頭校驗和。它會執行IP路由判斷,判斷是由本機處理資料包還是把資料包傳送到其它系統。如果資料包必須由本地系統處理,IP層會通過IP header中引用的原型值(proto value)解析上層協議(傳輸協議)。TCP原型值為6。系統會刪除IP頭,並且把資料包傳送到TCP層。

就像之前的幾層,TCP層檢查資料包是否有效,同時會檢查TCP校驗和。就像之前提到的,如果當前的網路棧使用了校驗解除安裝,那麼TCP校驗和會由NIC計算,而不是核心。

之後它會查詢資料包對應的TCP控制塊(TCB),這時會使用資料包中的<源ip,源埠,目的IP,目的埠>做標識。在查詢到對應的連線之後,會執行協議中定義的操作去處理資料包。如果接收到的是新資料,資料會被增加到socket的接收緩衝區。根據tcp連線的狀態,此時也可以傳送新的TCP包(比如傳送ACK包)。此時,TCP/IP接收資料包的處理完成。

socket接收緩衝區的大小就是TCP接收視窗。TCP吞吐量會隨著接收視窗變大而增加。過去socket緩衝區大小是應用或作業系統的配置的定值。最新的網路棧使用了一個函式去自動決定接收緩衝區的大小。

當應用程式呼叫read系統呼叫時,程式會切換到核心區,並且會把socket接收緩衝區中的資料拷貝到使用者區。拷貝後的資料會從socket緩衝區中移除。之後TCP會被呼叫,TCP會增加接收視窗的大小(因為緩衝區有了新空間)。並且會根據協議狀態傳送資料包。如果沒有資料包傳送,系統呼叫結束。

6.網路棧開發方向

到此為止描述的網路棧中的函式都是最基礎的函式。在1990年代早期的網路棧函式只比上面描述的多一些。但是最近的隨著網路棧的實現層次變高,網路棧增加了很多函式和複雜度。

最新的網路棧可以按以下需求分類:

6.1.操作報文處理過程

它包括類似Netfilter(firewall,NAT)的功能和流量控制。通過在處理流程中增加使用者可以控制的程式碼,可以由使用者控制實現不同的功能。

6.2.協議效能

用於改進特定網路環境下的吞吐量、延遲和可靠性。典型例子是增加的流量控制演算法和額外的類似SACK的TCP功能。由於已經超出了範圍,在這裡就不深入討論協議改進了。

6.3.高效的報文處理

高效的報文處理指的是通過降低處理報文的CPU週期、記憶體用量和處理報文需要訪問記憶體的次數這些手段,來提升每秒可以處理的報文數量。有很多降低系統延遲的嘗試,比如協議棧並行處理(stack parallel processing)、報頭預處理(header prediction)、零拷貝(zero-copy)、單次拷貝(single-copy)、校驗解除安裝(checksum offload)、TSO(TCP Segment Offload)、LRO(Large Receive Offload)、RSS(Receive Side Scaling)等。

7.網路棧中的流程控制

現在我們看一下Linux網路棧內部流程的細節。網路棧基於事件驅動的方式執行,在事件產生時做出相應的反應。因此,沒有一個獨立的執行緒去執行網路棧。圖1和圖3展示了最簡單的控制流程圖。圖4說明了更加準確的控制流程。

圖4:網路棧中的流程控制

在圖4中的Flow(1),應用程式通過系統呼叫去執行(使用)TCP。例如,呼叫read系統呼叫或者wirte系統呼叫並執行TCP。但是,這一步裡沒有資料包傳輸。

Flow(2)跟Flow(1)類似,在執行TCP後需要傳輸報文。它會建立資料包並且把資料包傳送給驅動程式。驅動程式上層有一個佇列。資料包首先被放入佇列,之後佇列的資料結構決定何時把資料包傳送給驅動程式。這個是Linux的佇列規則(queue discipline,qdisc)。Linux的傳輸管理函式用來管理佇列規則。預設的佇列規則是簡單的先入先出佇列。通過使用其它的佇列管理規則,可以執行多種操作,例如人造資料丟包、資料包延遲、通訊比率限制,等等。在Flow(1)和Flow(2)中,驅動和應用程式處於相同的執行緒。

Flow(3)展示了TCP使用的定時器超時的情況。比如,當TIME_WAIT定時器超時後,TCP被呼叫並刪除連線。

類似Flow(3),Flow(4)是TCP的定時器超時並且需要傳送TCP執行結果資料包的情況。比如,當重傳計時器超時後,會傳送“沒有收到ACK”資料包。

Flow(3)和Flow(4)展示了定時器軟中斷的處理過程。

當NIC驅動收到中斷時,它將釋放已經傳輸的資料包。在大多數情況下,驅動的執行過程到這裡就結束了。Flow(5)是資料包積累在傳輸佇列裡的情況。驅動請求軟中斷,之後軟中斷的處理程式把傳輸佇列裡堆積的資料包傳送給驅動程式。

當NIC驅動程式接收到中斷並且發現有新接收的資料包時,它會請求軟中斷。軟中斷會呼叫驅動程式處理接收到的資料包並且把他們傳送到上一層。在Linux中,上面描述的處理接收到的資料包的過程叫做New API(NAPI)。它和輪詢類似,因為驅動並不直接把資料包傳送到上一層,而是上層直接來取資料。實際程式碼中叫做NAPI poll 或 poll。

Flow(6)展示了TCP執行完成,Flow(7)展示了需要更多資料包傳輸的情況。Flow(5、6、7)的NIC中斷都是通過軟中斷執行的。

8.如何處理中斷和接收資料包

中斷處理過程是複雜的,但是你需要了解資料包接收和處理流程中的和效能相關的問題。圖5展示了一次中斷的處理流程。

圖5:處理中斷、軟中斷和接收資料

假設CPU0正在執行應用程式。在此時,NIC接收到了一個資料包並且在CPU0上產生了中斷。CPU0執行了核心中斷處理程式(irq)。這個處理程式關聯了一箇中斷號並且核心會呼叫驅動裡對應的中斷處理程式。驅動在釋放已經傳輸的資料包之後呼叫napi_schedule()函式去處理接收到的資料包,這個函式會請求軟中斷。

在執行了驅動的中斷處理程式後,控制權被轉移到核心中斷處理程式。核心中的處理程式會執行軟中斷的處理程式。

在中斷上下文被執行之後,軟中斷的上下文會被執行。中斷上下文和軟中斷上下文會通過相同的執行緒執行,但是它們會使用不同的棧。並且中斷上下文會遮蔽硬體中斷;而軟中斷上下文不會遮蔽硬體中斷。

處理接收到的資料包的軟中斷處理程式是net_rx_action()函式。這個函式會呼叫驅動程式的poll()函式。而poll()函式會呼叫netif_receive_skb()函式,並把接收到的資料包一個接一個的傳送到上層。在軟中斷處理結束後,應用程式會從停止的位置重新開始執行。(After processing the softirq, the application restarts execution from the stopped point in order to request a system call.沒太明白這一句的system call是啥意思)

因此,接收中斷請求的CPU會負責處理接收資料包從始至終的整個過程。在Linux、BSD和Windows中,處理過程基本是類似的。

當你檢查伺服器CPU利用率時,有時你可以檢查伺服器的很多CPU中是否只有一個CPU在艱難執行軟中斷。這個現象產生的原因就是上文所解釋的資料包接收的處理過程。為了解決這個問題開發出了多佇列NIC(multi-queue NIC)、RSS和RPS。

下面是譯者翻譯的《理解TCP/IP網路棧&編寫網路應用》的下篇,會通過講解TCP的程式碼實現幫助大家理解傳送、接收資料的流程,也描述了一些網路卡、驅動等網路棧底層的原理。

8.資料結構

以下是一些關鍵資料結構。我們瞭解一下這些資料結構再開始檢視程式碼。

8.1.sk_buff_structure

首先,sk_buff結構或skb結構代表一個資料包。圖6展現了sk_buff中的一些結構。隨著功能變得更強大,它們也變得更復雜了。但是還是有一些任何人都能想到的基本功能。

圖6:資料包結構

8.1.1.包含資料和後設資料

這個結構直接包含或者通過指標引用了資料包。在圖6中,一些資料包(從Ethernet到Buffer部分)使用了指標,一些額外的資料(frags)引用了實際的記憶體頁。

一些必要的資訊比如頭和內容長度被儲存在後設資料區。例如,在圖6中,mac_headernetwork_headertransport_header都有相應的指標,指向鏈路頭、IP頭和TCP頭的起始地址。這種方式讓TCP協議處理過程變得簡單。

8.1.2.如何增加或刪除頭

資料包在網路棧的各層中上升或下降時會增加或刪除資料頭。為了更有效率的處理而使用了指標。例如,要刪除鏈路頭只需要修改head pointer的值。

8.1.3.如何合併或切分資料包

為了更有效率的執行把資料包增到或從socket緩衝區中刪除這類操作而使用了連結串列,或者叫資料包鏈。next和prev指標用於這個場景。

8.1.4.快速分配和釋放

無論何時建立資料包都會分配一個資料結構,此時會用到快速分配器。比如,如果資料通過10Gb的乙太網傳輸,每秒會有超過一百萬個物件被建立和銷燬。

9.TCP控制塊(TCP Control Block)

其次,有一個表示TCP連線的資料結構,之前它被抽象的叫做TCP控制塊。Linux使用了tcp_sock這個資料結構。在圖7中,你可以看到檔案、socket和tcp_socket的關係。

圖7:TCP Connection結構

當系統呼叫發生後,它會找到應用程式在進行系統呼叫時使用的檔案描述符對應的檔案。對Unix系的作業系統來說,檔案本身和通用檔案系統儲存的裝置都被抽象成了檔案。因此,檔案結構包含了必要的資訊。對於socket來說,使用獨立的socket結構儲存socket相關的資訊,檔案結構通過指標來引用socket。socket又引用了tcp_socktcp_sock可以分為sock,inet_sock等等,用來支援除了TCP之外的協議,可以認為這是一種多型。

所有TCP協議用到的狀態資訊都被存在tcp_sock裡。例如順序號、接收視窗、阻塞控制和重傳送定時器都儲存在tcp_sock中。

socket的傳送緩衝區和接收緩衝區由sk_buff連結串列組成並被包含在tcp_sock中。為防止頻繁查詢路由,也會在tcp_sock中引用IP路由結果dst_entry。通過dst_entry可以簡單的查詢到目標的MAC地址之類的ARP的結果。dst_entry是路由表的一部分,而路由表是個很複雜的結構,在這篇文件裡就不再討論了。用來傳送資料的網路卡(NIC)可以通過dst_entry找到。網路卡通過net_device描述。

因此,僅通過查詢檔案和指標就可以很簡單的查詢到處理TCP連線的所有的資料結構(從檔案到驅動)。這個資料結構的大小就是每個TCP連線佔用記憶體的大小。這個結構佔用的記憶體只有幾kb大小(排除了資料包中的資料)。但隨著一些功能被加入,記憶體佔用也在逐漸增加。

最後,我們來看一下TCP連線查詢表(TCP connection lookup table)。這是一個用來查詢接收到的資料包對應tcp連線的雜湊表。系統會用資料包的<來源ip,目標ip,來源埠,目標埠>和Jenkins雜湊演算法去計算雜湊值。選擇這個雜湊函式的原因是為了防止對雜湊表的攻擊。

10.追蹤程式碼:如何傳輸資料

我們將會通過追蹤實際的Linux核心原始碼去檢查協議棧中執行的關鍵任務。在這裡,我們將會觀察經常使用的兩條路徑。

首先是應用程式呼叫了write系統呼叫時的執行路徑。

當應用呼叫了write系統呼叫時,核心將在檔案層執行write()函式。首先,核心會取出檔案描述符對應的檔案結構體,之後會呼叫aio_write,這是一個函式指標。在檔案結構體中,你可以看到file_perations結構體指標。這個結構被通稱為函式表(function table),其中包含了一些函式的指標,比如aio_read或者aio_write。對於socket來說,實際的表是socket_file_ops,aio_write對應的函式是sock_aio_write。在這裡函式表的作用類似於java中的interface,核心使用這種機制進行程式碼抽象或重構。

sock_aio_write()函式會從檔案結構體中取出socket結構體並呼叫sendmsg,這也是一個函式指標。socket結構體中包含了proto_ops函式表。IPv4的TCP實現中,proto_ops的具體實現是inet_stream_ops,sendmsg的實現是tcp_sendmsg

tcp_sendmsg(譯註:原文寫的是tcp_sengmsg,應該是筆誤)會從socket中取得tcp_sock(也就是TCP控制塊,TCB),並把應用程式請求傳送的資料拷貝到socket傳送緩衝中(譯註:就是根據傳送資料建立sk_buff連結串列)。當把資料拷貝到sk_buff中時,每個sk_buff會包含多少位元組資料?在程式碼建立資料包時,每個sk_buff中會包含MSS位元組(通過tcp_send_mss函式獲取),在這裡MSS表示每個TCP資料包能攜帶資料的最大值。通過使用TSO(TCP Segment Offload)和GSO(Generic Segmentation Offload)技術,一個sk_buff可以儲存大於MSS的資料。在這篇文章裡就不詳細解釋了。

sk_stream_alloc_skb函式會建立新的sk_buff,之後通過skb_entail把新建立的sk_buff放到send_socket_buffer的末尾。skb_add_data函式會把應用層的資料拷貝到sk_buff的buffer中。通過重複這個過程(建立sk_buff然後把它加入到socket傳送緩衝區)完成所有資料的拷貝。因此,大小是MSS的多個sk_buff會在socket傳送緩衝區中形成一個連結串列。最終呼叫tcp_push把待傳送的資料做成資料包,並且傳送出去。

tcp_push函式會在TCP允許的範圍內順序傳送儘可能多的sk_buff資料。首先會呼叫tcp_send_head取得傳送緩衝區中第一個sk_buff,然後呼叫tcp_cwnd_testtcp_send_wnd_test檢查堵塞視窗和接收視窗,判斷接收方是否可以接收新資料。之後呼叫tcp_transmit_skb函式來建立資料包。

tcp_transmit_skb會建立指定sk_buff的拷貝(通過pskb_copy),但它不會拷貝應用層傳送的資料,而是拷貝一些後設資料。之後會呼叫skb_push來確保和記錄頭部欄位的值。send_check計算TCP校驗和(如果使用校驗和解除安裝checksum offload技術則不會做這一步計算)。最終呼叫queue_xmit把資料傳送到IP層。IPv4中queue_xmit的實現函式是ip_queue_xmit

ip_queue_xmit函式執行IP層的一些必要的任務。__sk_dst_check檢查快取的路由是否有效。如果沒有被快取的路由項,或者路由無效,它將會執行IP路由選擇(IP routing)。之後呼叫skb_push來計算和記錄IP頭欄位的值。之後,隨著函式執行,ip_send_check計算IP頭校驗和並且呼叫netfilter功能(譯註:這是核心的一個模組)。如果使用ip_finish_output函式會建立IP資料分片,但在使用TCP協議時不會建立分片,因此核心會直接呼叫ip_finish_output2來增加鏈路頭,並完成資料包的建立。

最終的資料包會通過dev_queue_xmit函式完成傳輸。首先,資料包通過排隊規則(譯註:qdisc,上篇文章簡單介紹過)傳遞。如果使用了預設的排隊規則並且佇列是空的,那麼會跳過佇列而直接呼叫sch_direct_xmit把資料包傳送到驅動。dev_hard_start_xmit會呼叫實際的驅動程式。在呼叫驅動之前,裝置的傳送(譯註:TX,transmit)被鎖定,防止多個執行緒同時使用裝置。由於核心鎖定了裝置的傳送,驅動傳送資料相關的程式碼就不需要額外的鎖了。這裡下次要講(譯註:這裡是說原作者的下篇文章)的並行開發有很大關係。

ndo_start_xmit函式會呼叫驅動的程式碼。在這之前,你會看到ptype_alldev_queue_xmit_nitptype_all是個包含了一些模組的列表(比如資料包捕獲)。如果捕獲程式正在執行,資料包會被ptype_all拷貝到其它程式中。因此,tcpdump中顯示的都是傳送給驅動的資料包。當使用了校驗和解除安裝(checksum offload)或TSO(TCP Segment Offload)這些技術時,網路卡(NIC)會運算元據包,所以tcpdump得到的資料包和實際傳送到網路的資料包有可能不一致。在結束了資料包傳輸以後,驅動中斷處理程式會返回傳送了的sk_buff

11.追蹤程式碼:如何接收資料

一般來說,接收資料的執行路徑是接收一個資料包並把資料加入到socket的接收緩衝區。在執行了驅動中斷處理程式之後,首先執行的是napi poll處理程式。

就像之前說的,net_rx_action函式是用於接收資料的軟中斷處理函式。首先,請求napi poll的驅動會檢索poll_list,並且呼叫驅動的poll處理程式(poll handler)。驅動會把接收到的資料包包裝成sk_buff,之後呼叫netif_receive_skb

如果有模組在請求資料包,那麼netif_receive_skb會把資料包傳送給那個模組。類似於之前討論過的傳送的過程,在這裡驅動接收到的資料包會傳送給註冊到ptype_all列表的那些模組,資料包在這裡被捕獲。

之後,根據資料包的型別,不同資料包會被傳輸到相應的上層。鏈路頭中包含了2位元組的乙太網型別(ethertype)欄位,這個欄位的值標識了資料包的型別。驅動會把這個值記錄到sk_buff中(skb->protocol)。每一種協議有自己的packet_type結構體,並且會把指向結構體的指標放入ptype_base雜湊表中。IPv4使用的是ip_packate_type,型別欄位中的值是IPv4型別(ETH_P_IP)。於是,對於IPv4型別的資料包會呼叫ip_recv函式。

ip_rcv函式執行IP層必要的操作。它會解析資料包中的長度和IP頭校驗和。在經過netfilter程式碼時會執行ip_local_deliver函式。如果需要的話還會裝配IP分片。之後會通過netfilter的程式碼呼叫ip_local_deliver_finish函式,這個函式通過呼叫__skb_pull移除IP頭,並且通過IP頭的protocol值查詢上層協議標記。類似於ptype_base,每個傳輸層協議會在inet_protos中註冊自己的net_protocol結構體。IPv4 TCP使用tcp_protocol,於是會呼叫tcp_v4_rcv函式繼續處理。

當資料包到達TCP層時,資料包的處理流程取決於TCP狀態和包型別。在這裡,我們將看到TCP連線處於ESTABLISHED狀態時處理到達的資料包的過程。在沒有出現出現丟包或者亂序等異常的情況下,伺服器會頻繁的執行這條路徑。

首先,tcp_v4_rcv函式驗證包的有效性,如果tcp頭的大小比資料的偏移大時(th->doff < sizeof(struct tcphdr) /4)則說明包頭有錯誤。(如果沒有錯誤)之後會呼叫__inet_lookup_skb在存放TCP連線的雜湊表裡查詢資料包所屬的連線。從查詢到的sock結構體可以找到所有需要的資料結構(比如tcp_sock),也可以取得對應的socket。

實際的協議在tcp_v4_do_rcv函式中處理。如果TCP正處於ESTABLISHED狀態則會呼叫tcp_rcv_established(譯註:原文寫的是tcp_rcv_esablished,應該是筆誤)。由於ESTABLISHED是最常用的狀態,所以它被單獨處理和優化了。tcp_rcv_established首先會執行頭部預測(header prediction)的程式碼。頭部預測會快速檢測和處理常見情況。在這裡常見的情況是沒有在傳送資料,實際收到的資料包正是應該收到的下一個資料包,例如TCP接收到了它期望收到的那個順序號。在這種情況下在把資料加入到socket緩衝區、傳送ack之後完成處理。

往前翻的話你會看到比較truesizesk_forward_allow的語句。這個用來檢查socket接收緩衝區中是否還有空閒空間來存放剛收到的資料。如果有的話,頭部預測為“命中”(預測成功)。之後呼叫__skb_pull來刪除TCP頭。再之後呼叫__skb_queue_tail來把資料包增加到socket接收緩衝區。最終會視情況呼叫__tcp_ack_snd_check傳送ACK。此時處理完成。

如果沒有足夠的空間,那麼會走一條比較慢的路徑。tcp_data_queue函式會新分配socket接收緩衝區的空間並把資料增加到緩衝區中。在這種情況下,接受緩衝區的大小在需要時會自動增加。和上一段說的快速路徑不同,此時會在可行的情況下會呼叫tcp_data_snd_check傳送一個新的資料回包。最終,如果需要的話會呼叫tcp_ack_snd_check來傳送一個ACK包。

這兩條路徑下執行的程式碼量不大,因為這裡討論的都是通常情況。或者說,其它特殊情況的處理會慢的多,比如接收亂序這類情況。

12.驅動和網路卡如何通訊

驅動(driver)和網路卡(nic)之間的通訊處於協議棧的底層,大多數人並不關心。但是,為了解決效能問題,網路卡會處理越來越多的任務。理解基礎的處理方式會幫助你理解額外這些優化技術。

網路卡和驅動之間使用非同步通訊。首先,驅動請求資料傳輸時CPU不會等待結果而是會繼續處理其它任務,之後網路卡傳送資料包並通知CPU,驅動程式返回通過事件接收的資料包(這些資料包可以看作是非同步傳送的返回值)。

和資料包傳輸類似,資料包的接收也是非同步的。首先驅動請求接受接收資料包然後CPU去執行其它任務,之後網路卡接收資料包並通知CPU,然後驅動處理接收到的資料包(返回資料)。

因此需要有一個空間來儲存請求和響應(request and response)。大多數情況網路卡會使用環形佇列資料結構(ring structure)。環形佇列類似於普通的佇列,其中有固定數量的元素,每個元素會儲存一個請求或一個相應資料。環形佇列中元素是順序的,“環形”的意思是佇列雖然是定長的,但是其中的元素會按順序重用。

圖8展示了資料包傳輸的過程,你會看到如何使用環形佇列。

圖8:驅動和網路卡之間的通訊:如何傳輸資料包

驅動接收到上層發來的資料包並建立網路卡能夠識別的描述符。傳送描述符(send descriptor)預設會包含資料包的大小和記憶體地址。這裡網路卡需要的是記憶體中的實體地址,驅動需要把資料包的虛擬地址轉換成實體地址。之後,驅動會把傳送描述符新增到傳送環形佇列(TX ring)(1),傳送環形佇列中包含的實際是傳送描述符。

之後,驅動會把請求通知給網路卡(2)。驅動直接把資料寫入指定的網路卡記憶體地址中(譯註:這裡應該是網路卡的暫存器地址,寫入的是通知而不是資料包)。這種CPU直接向裝置傳送資料的傳輸方式叫做程式化I/O(Programmed I/O,PIO)。

網路卡被通知後從主機的傳送佇列中取得傳送描述符(3)。這種裝置直接訪問記憶體而不需要呼叫CPU的記憶體訪問方式叫做直接記憶體訪問(Direct Memory Access,DMA)。

在取得傳送描述符後,網路卡會得到資料包的地址和大小並且從主機記憶體中取得實際的資料包(4)。如果有校驗和解除安裝(checksum offload)的話,網路卡會在拿到資料後計算資料包的校驗和,因此開銷不大。

網路卡傳送資料包(5)之後把傳送資料包的數量寫入主機記憶體(6)。之後它會觸發一次中斷,驅動程式會讀取傳送資料包的數量並根據數量返回已傳送的資料包。

在圖9中,你會看到接收資料包的流程。

圖9:驅動和網路卡之間的通訊:如何接收資料包

首先,驅動會在主機記憶體中分配一塊緩衝區用來接收資料包,之後建立接收描述符(receive descriptor)。接收描述符預設會包含緩衝區的大小和地址。類似於傳送描述符,接收描述符中儲存的也是DMA使用的實體地址。之後,驅動會把接收描述符加入到接收環形佇列(RX ring)(1),接收環形佇列儲存的都是接收資料的請求。

通過PIO,驅動程式通知網路卡有一個新的描述符(2),網路卡會把新的描述符從接收佇列中取出來,然後把緩衝區的大小和地址儲存到網路卡記憶體中(3)。

在接收到資料包時,網路卡會把資料包傳送到主機記憶體緩衝區中(5)。如果存在校驗和解除安裝函式,那麼網路卡會在此時計算校驗和。接收資料包的實際大小、校驗和結果和其他資訊會儲存在獨立的環形佇列中(接收返回環形佇列,receive return ring)(6)。接收返回佇列儲存了接收請求的處理結果,或者叫響應(response)。之後網路卡會發出一箇中斷(7)。驅動程式從接收返回佇列中獲取接收到的資料包的資訊,如果必要的話,還會分配新記憶體緩衝區並重復(1)和(2)。

調優網路棧時,大部分人會說環形佇列和中斷的設定需要被調整。當傳送環形佇列很大時,很多次的傳送請求可以一次完成;當接收環形佇列很大,可以一次性接收多個資料包。更大的環形佇列對於大流量資料包接收/傳送是很有用的。由於CPU在處理中斷時有大量開銷,大量大多數情況下,網路卡使用一個計時器來減少中斷。為了避免對宿主機過多的中斷,傳送和接收資料包的時候中斷會被收集起來並且定期呼叫(interrupt coalescing,中斷聚合)。

13.網路棧中的緩衝區和流量控制

在網路棧中流量控制在幾個階段被執行。圖10展示了傳輸資料時的一些緩衝區。首先,應用會建立資料並把資料加入到socket傳送緩衝區。如果緩衝區中沒有剩餘空間的話,系統呼叫會失敗或阻塞應用程式。因此,應用程式到核心的傳送速率由socket緩衝區大小來限制。

圖10:資料傳送相關的緩衝

TCP通過傳輸佇列(qdisc)建立並把資料包傳送給驅動程式。這是一個典型的先入先出佇列,佇列最大長度是txqueuelen,可以通過ifconfig命令來檢視實際大小。通常來說,大約有幾千個資料包。

驅動和網路卡之間是傳輸環形佇列(TX ring),它被認為是傳輸請求佇列(transmission request queue)。如果佇列中沒有剩餘空間的話就不會再繼續建立傳輸請求,並且資料包會積累在傳輸佇列中,如果資料包積累的太多,那麼新的資料包會被丟棄。

網路卡會把要傳送的資料包儲存在內部緩衝區中。這個佇列中的資料包速度受網路卡物理速度的影響(例如,1Gb/s的網路卡不能承擔10Gb/s的效能)。根據乙太網流量控制,當網路卡的接收緩衝區沒有空間時,資料包傳輸會被停止。

當核心速度大於網路卡時,資料包會堆積在網路卡的緩衝區中。如果緩衝區中沒有空間時會停止處理傳輸環形佇列(TX ring)。越來越多的請求堆積在傳輸環形佇列中,最終佇列中空間被耗盡。驅動程式不能再繼續建立傳輸請求資料包會堆積在傳輸佇列(transmit queue)中。壓力通過各種緩衝從底向上逐級反饋。

圖11展示了接收資料包經過的緩衝區。資料包先被儲存在網路卡的接收緩衝區中。從流量控制的視角來看,驅動和網路卡之間的接收環形緩衝區(RX ring)可以被看作是資料包的緩衝區。驅動程式從環形緩衝區取得資料包並把它們傳送到上層。伺服器系統的網路卡驅動預設會使用NAPI,所以在驅動和上層之間沒有緩衝區。因此,可以認為上層直接從接收環形緩衝區中取得資料,資料包的資料部分被儲存在socket的接收緩衝區中。應用程式從socket接收緩衝區取得資料。

圖11:與接收資料包相關的緩衝

不支援NAPI的驅動程式會把資料包儲存在積壓佇列(backlog queue)中。之後,NAPI處理程式取得資料包,因此積壓佇列可以被認為是在驅動程式和上層之間的緩衝區。

如果核心處理資料包的速度低於網路卡的速度,接收迴圈緩衝區佇列(RX ring)會被寫滿,網路卡的緩衝區空間(NIC internal buffer)也會被寫滿,當使用了以太流量控制(Ethernet flow control)時,接收方網路卡會向傳送方網路卡傳送請求來停止傳輸或丟棄資料包。

因為TCP支援端對端流量控制,所以不會出現由於socket接收佇列空間不足而丟包的情況。但是,當使用UDP協議時,因為UDP協議不支援流量控制,如果應用程式處理速度不夠的時候會出現socket接收緩衝區空間不足而丟包的情況。

在圖10和圖11中展示的傳輸環形佇列(TX ring)和接收環形佇列(RX ring)的大小可以用ethtool檢視。在大多數看重吞吐量的負載情況下,增加環形佇列的大小和socket緩衝區大小會有一些幫助。增加大小會減少高速收發資料包時由於緩衝區空間不足而造成的異常。

14.結論

最初,我計劃只解釋那些會對你編寫網路程式有幫助的東西,執行效能測試和解決問題。即使是我最初的計劃,在這篇文件中包含的內容也會很多。我希望這篇文章會幫助你開發網路程式並監控它們的效能。TCP/IP協議本身就十分複雜並有很多異常情況。但是,你不需要理解OS中TCP/IP協議相關的每一行程式碼來理解效能和分析現象。只需要理解它的上下文對你就會十分有幫助。

隨著系統效能和作業系統網路棧實現的持續提升,最近的伺服器能承受10-20Gb/s的TCP吞吐量而不出現任何問題。近期也出現了與效能相關的很多的新技術,例如TSO, LRO, RSS, GSO, GRO, UFO, XPS, IOAT, DDIO, TOE等等(譯註:這些我就不翻譯了……),讓我們有些困惑。

在下篇文章中,我會從效能的觀點解釋關於網路棧,並且討論這些技術的問題和收益。

By Hyeongyeop Kim, Senior Engineer at Performance Engineering Lab, NHN Corporation.

15.譯者最後說幾句

本來看著文章內容不多,但是實際翻譯下來才發現有這麼多字……翻譯過程中發現有很多地方自己之前的理解也有一些錯誤,如果各位發現哪裡翻譯的有問題的話麻煩告知一下,感激不盡~~

對於TCP協議來說,雖然已經看過很多相關的文章,但是真正要完整的描述一次tcp資料包傳送和接收的過程實際上還是很難做到的。對於TCP或者核心之類的文章很多,但是我觀察了一下,大部分人的問題並不是看的文章不夠多,而是看的不夠仔細。

與其每天樂此不疲的mark一堆技術文章然後隨便掃幾眼,還不如安心把一篇文章完整的讀透,甚至再退一步,把文章從頭到尾通讀一遍,那也是極好的。這也是這次翻譯這篇文章的初衷,與各位共勉。

相關文章