最近在看《UNIX網(wǎng)絡編程 卷1》和《FREEBSD操作系統(tǒng)設計與實現(xiàn)》這兩本書,我重點關注了TCP協(xié)議相關的內(nèi)容,結(jié)合自己后臺開發(fā)的經(jīng)驗,寫下這篇文章,一方面是為了幫助有需要的人,更重要的是方便自己整理思路,加深理解。
理論基礎
OSI網(wǎng)絡模型

OSI模型是一個七層模型,實際工程中,層次的劃分沒有這么細致。一般來說,物理層和數(shù)據(jù)層對應著硬件和設備驅(qū)動程序,例如網(wǎng)卡和網(wǎng)卡驅(qū)動。傳輸層和網(wǎng)絡層由操作系統(tǒng)內(nèi)核實現(xiàn),當用戶進程需要通過網(wǎng)絡傳輸數(shù)據(jù),通過系統(tǒng)調(diào)用的方式讓內(nèi)核將數(shù)據(jù)封裝為相應的協(xié)議格式,進而調(diào)用網(wǎng)卡驅(qū)動傳輸數(shù)據(jù)。頂上三層對應具體的網(wǎng)絡應用協(xié)議:FTP、HTTP等,這些應用層協(xié)議不需要知道具體的通信細節(jié)。
傳輸層
在實際工程中,我們常用的應用層服務(例如:HTTP服務、數(shù)據(jù)庫服務、緩存服務)通信的直接底層就是傳輸層,下圖是一些常用命令涉及的通信協(xié)議。

IPv4(Internet Protocol version 4)全稱是網(wǎng)際協(xié)議版本4,它使用32地址,平時常說的IP協(xié)議就是指IPv4,類似于192.168.99.100的地址可以看成4位256進制數(shù)據(jù),也就是32網(wǎng)絡地址。但隨著網(wǎng)絡設備爆炸式增長,32地址面臨這用完的風險,IPv6(Internet Protocol version 6)應運而生。IPv6使用128位地址,但IPv4地址耗盡的問題有了新的解決方案,目前普遍使用的還是IPv4,IPv6全面取代IPv4還有很長的距離。
UDP (User Datagram Protocol),全稱用戶數(shù)據(jù)報協(xié)議。UDP提供面向無連接的服務,客戶端和服務端不存在任何長期的關系。UDP不提供可靠的通信,它不保證數(shù)據(jù)報一定送達,也不保證數(shù)據(jù)包送達的先后順序,也不保證每份數(shù)據(jù)報只送達一次。雖然UDP可靠性差,但是消耗資源少,適用在網(wǎng)絡環(huán)境較好的局域網(wǎng)中,例如不需要精確統(tǒng)計的監(jiān)控服務(eg: Statsd)。由于使用了UDP,客戶端每次打點統(tǒng)計只需要一次發(fā)送UDP數(shù)據(jù)報的IO開銷,服務性能損失很小,而且在內(nèi)網(wǎng)環(huán)境數(shù)據(jù)包一般都能正常到達服務端,也能保證較高的可行度。
TCP(Transmission Control Protocl),全稱傳輸控制協(xié)議。和UDP相反,TCP提供了面向連接的服務,而且提供了可靠性保障。平常我們使用的應用層協(xié)議,例如HTTP,F(xiàn)TP等,幾乎都是建立在TCP協(xié)議之上,深入了解TCP的細節(jié)對于開發(fā)高質(zhì)量的后臺開發(fā)和客戶端開發(fā)都有很好的借鑒意義。下面開始重點介紹TCP協(xié)議的細節(jié)。
TCP協(xié)議
狀態(tài)轉(zhuǎn)換
為了提供可靠的通信服務,TCP通過三次分節(jié)建立連接,四次分節(jié)關閉連接,心跳檢查判斷連接是否正常,因此需要記錄連接的狀態(tài),TCP一共定義了11種不同的狀態(tài)。

通過netstat命令可以查看所有的tcp狀態(tài)。

三路握手

在三路握手之前,服務器必須準備好接收外來的連接。這通常通過調(diào)用bind和listen完成被動打開,此時服務進程有一個套接字處于LISTEN狀態(tài)。在客戶端發(fā)通過調(diào)用connect送一個SYN分節(jié)后,服務進程必須確認(ACK)此分節(jié),同時也發(fā)送一個SYN分節(jié),這兩步在同一分節(jié)中完成,通過上面的轉(zhuǎn)臺扭轉(zhuǎn)圖,可以知道服務進程中會生成一個處于SYN_RCVD狀態(tài)的套接字。當再次收到客戶端的ACK分節(jié)后,服務端的套接字狀態(tài)轉(zhuǎn)變?yōu)镋STABLISHED。
客戶端通過connect函數(shù)發(fā)起主動打開,在此之前客戶端套接字狀態(tài)為CLOSED。調(diào)用connect導致客戶TCP發(fā)送一個SYN分節(jié),此時套接字狀態(tài)有CLOSED變?yōu)镾YN_SENT,在收到服務器的SYN和ACK后,客戶端socket再發(fā)送ACK分節(jié),套接字狀態(tài)變?yōu)镋STABLISHED,此時connect返回。
備注:SYN分節(jié)中除了有序列號之外,還會有最大分節(jié)大小、窗口規(guī)模選項、時間戳等TCP參數(shù),具體可以參考協(xié)議詳細規(guī)定。
終止連接

上圖展示了客戶端執(zhí)行主動關閉的情形,實際上無論客戶端還是服務器,都可以執(zhí)行主動關閉。一般情況下客戶端執(zhí)行主動關閉較多,所以使用客戶端主動關閉為例講解。
客戶端調(diào)用close,執(zhí)行主動關閉時,發(fā)送FIN分節(jié),此時客戶端套接字狀態(tài)由ESTABLISED變?yōu)镕IN_WAIT_1。服務器收到這個FIN,會執(zhí)行被動關閉,并向客戶端發(fā)送ACK,F(xiàn)IN的接受也作為一個文件結(jié)束符傳遞給服務進程,如果此時服務進程調(diào)用套接字的方法,無論緩存區(qū)是否有數(shù)據(jù)都會返回EOF,服務端套接字狀態(tài)由ESTABLISED變?yōu)闉镃LOSE_WAIT。客戶端接收到ACK后,客戶端套接字狀態(tài)由FIN_WAIT_1變?yōu)镕IN_WAIT_2。
一段時間后,當服務進程調(diào)用close或者shutdown時,也會發(fā)生送FIN分節(jié),服務端套接字狀態(tài)由CLOSE_WAIT變?yōu)長AST_ACK??蛻舳嗽诮邮盏紽IN分節(jié)后,發(fā)送ACK分節(jié),客戶端套接字狀態(tài)由FIN_WAIT_2變?yōu)門IME_WAIT。服務器段接收到客戶端的ACK分節(jié),狀態(tài)變成CLOSED。

在某些情況下,第二和第三分節(jié)可能會合并發(fā)送。調(diào)用close可能會觸發(fā)主動關閉,當進程正?;蛘叻钦M顺鰰r,內(nèi)核會將該進程所使用的文件描述符對應的打開次數(shù)執(zhí)行減一操作,當某個文件打開次數(shù)為0時,也就是說所有的進程都沒有使用此文件時,也會觸發(fā)TCP的主動關閉操作。
TIME_WAIT狀態(tài)
在終止連接的過程中,主動關閉方套接字最終的狀態(tài)是TIME_WAIT,在經(jīng)過2MSL(maximun segment lifetime,每個IP數(shù)據(jù)報都包含一個跳限的字段,表明數(shù)據(jù)報能經(jīng)過的路由最大個數(shù),因此默認每個數(shù)據(jù)報在因特網(wǎng)中有一個最大存活時間)時間后狀態(tài)才變?yōu)镃LOSED,為什么這樣設計呢?
這樣的設計出于兩個考慮:
- 可靠地實現(xiàn)TCP全雙工連接的終止。上圖的四次分節(jié)關閉連接是在正常流程,實際情況中,任何一次分節(jié)都可能出現(xiàn)發(fā)送失敗的情況。主動關閉方最后的一個ACK分節(jié)可能會因為路由問題發(fā)送失敗,為了保證可靠性,需要重新發(fā)送保證另一方正確關閉套接字,因此此時的狀態(tài)不能為CLOSED。
- 允許老的重復分界在網(wǎng)絡中消失。加入10.10.89.9的3400端口和206.168.12.12的80端口建立了一個TCP連接,此連接中斷后,之前發(fā)送的TCP分節(jié)可能因為路由循環(huán)的問題還在因特網(wǎng)中游蕩,而此時這兩個機器相同的端口再建立起新的連接后,原來在網(wǎng)絡中游蕩的分解會對新的連接造成干擾。為了避免這種情況,設置一個2MSL的超時時間,保證之前還在網(wǎng)絡中游蕩的數(shù)據(jù)包完全消失。
套接字編程
下圖是C語言的套接字函數(shù),考慮Python的socket庫只是底層C庫的簡單封裝,接口參數(shù)大同小異,而且Python方便上手調(diào)試,語法上也更通俗易懂,所以本文使用Python的socket庫作為講解實例。

socket
socket是python套接字類,通過構造函數(shù)生成套接字對象,構造函數(shù)簽名如下

其中family參數(shù)指協(xié)議族;type參數(shù)指套接字類型;protocol值協(xié)議類型,或者設置為0,以選擇所給定family和type組合的系統(tǒng)默認值;fileno指文件描述符(我從來沒用過)。
| family | 說明 |
|---|---|
| AF_INET | IPv4協(xié)議 |
| AF_INET6 | IPv6協(xié)議 |
| AF_LOCAL | Unix域協(xié)議 |
| AF_ROUTE | 路由套接字 |
| AF_KEY | 密鑰套接字 |
| type | 說明 |
|---|---|
| SOCK_STREAM | 字節(jié)流套接字 |
| SOCK_DGRAM | 數(shù)據(jù)包套接字 |
| SOCK_SEQPACKET | 有序分組套接字 |
| SOCK_RAW | 原始套接字 |
| protocol | 說明 |
|---|---|
| IPPROTO_TCP | TCP傳輸協(xié)議 |
| IPPROTO_UDP | UDP傳輸協(xié)議 |
并非所有套接字family和type的組合都是有效的,下表給出了一些有效的組合和對應的協(xié)議,其中標是的項也是有效的,但是沒有找到便捷的縮略詞,而空白項是無效組合。

connect
connect用于客戶端和服務器建立連接,函數(shù)簽名如下:

客戶端在調(diào)用connect之前不必非得調(diào)用bind函數(shù),內(nèi)核會確定源IP地址,并選擇一個臨時端口作為源端口。如果使用TCP協(xié)議,connect將激發(fā)TCP的三路握手過程,TCP狀態(tài)由CLOSED變?yōu)镾YN_SENT,最終變?yōu)镋STABLISHED,在三路握手的過程中,可能會出現(xiàn)下面幾種情況導致connect報錯。connect失敗則套接字不可用,必須關閉,不能對這樣的套接字再次調(diào)connect函數(shù)。
- TCP客戶端沒有是收到SYN分節(jié)響應,一般發(fā)生在服務端backlog隊列已滿的情況下,服務器會對收到的SYN分節(jié)不做任何處理??蛻舳说却欢螘r間后會重新發(fā)送SYN分節(jié),直到等待時間超過上限,才會拋出
ETIMEDOUT錯誤(對應的python異常是TimeoutError)。 - 對客戶端SYN的響應是RST,表明服務端在指定的端口上沒有進程在等待與之連接,客戶端馬上會拋出
ECONNRFUSED錯誤。下圖是用python連接一個未使用的端口,拋出異常ConnectionRefusedError,該異常錯誤號碼111,errno中查找正是ECONNRFUSED對應的錯誤碼。

- 如果發(fā)出的SYN在中間的嗎某個路由器上引發(fā)了目的地不可達錯誤,客戶端會等待一段時間后重新發(fā)送,直到等待時間超過上限(和第一種情況類似),此時會拋出
ENETUNREACH或者EHOSTUNREACH錯誤。下圖為關閉本機網(wǎng)絡后,用python調(diào)用connect,由于網(wǎng)絡不可達,異常的錯誤碼為101,errno中查找正是ENETUNREACH錯誤碼。

bind
bind方法把一個本地協(xié)議地址賦予給一個套接字,方法簽名如下:

在不調(diào)用bind的情況下,內(nèi)核會確定IP地址,并分配臨時端口,這種情況很適合客戶端,因此客戶端在調(diào)用connect之前不調(diào)用bind方法。而服務端需要一個確定的ip和端口,因此需要調(diào)用bind指定地址和端口。一般情況下,服務器都有多個ip地址,除了環(huán)路地址127.0.0.1外,還有局域網(wǎng)和公網(wǎng)地址,如果bind綁定的是環(huán)路地址127.0.0.1,則只有本機通過環(huán)路地址才能訪問,如果需要通過任一ip地址都能訪問到,可以綁定通配地址0.0.0.0。當指定的端口為0時,內(nèi)核會分配一個臨時端口。
如果端口已經(jīng)在使用,會拋出EADDRINUSE(errno對應錯誤碼是98)異常,可以通過設置SO_REUSEADDR和SO_REUSEPORT這兩個套接字參數(shù)讓多個進程使用同一個TCP連接。

listen
當創(chuàng)建一個套接字時,默認為主動套接字,也就是說,是一個將調(diào)用connect發(fā)起連接的客戶套接字。listen方法把一個未連接的套接字轉(zhuǎn)換為一個被動套接字,指示內(nèi)核應接受指向該套接字的狀態(tài)請求。根據(jù)TCP狀態(tài)轉(zhuǎn)換圖,調(diào)用listen導致套接字從CLOSED狀態(tài)轉(zhuǎn)換到LISTEN狀態(tài)。此方法參數(shù)規(guī)定了內(nèi)核應該為相應套接字排隊的最大連接個數(shù),在bind之后,并在accept之前調(diào)用。

為了理解backlog參數(shù),我們必須認識到內(nèi)核為其中任何一個給定的監(jiān)聽套接字維護兩個隊列:
- 未完成連接隊列,每個這樣的SYN分節(jié)對應其中一項:已由某個客戶發(fā)出并到達服務器,而服務器正在等待完成相應的TCP三路握手過程,這些套接字處于SYN_RCVD狀態(tài)。
- 已完成連接隊列,每個已完成TCP三路握手過程的客戶對應其中一項,這些套接字處于ESTABLISHED狀態(tài)。

RTT指的是未連接隊列中的任何一項在隊列中的存活時間。linux下的backlog指的是已完成連接隊列的容量,如果服務器長時間未調(diào)用accept從此隊列中取走數(shù)據(jù),當新的客戶端通過三路握手重新建立連接時,服務器不會處理收到的SYN分節(jié),而客戶端會一直等待并不斷重試直到超時。在服務器負載很大的情況下,就會造成客戶端連接時間長,所以需要合理設置backlog大小。
accept
accept用于從已完成連接隊列頭返回下一個已完成連接,如果已完成連接隊列為空,那么進程會被投入睡眠(套接字為阻塞方式)。

accept會自動生成一個全新的文件描述符,代表與所返回客戶的TCP連接。需要注意的是,此處有兩個套接字對象,一個是監(jiān)聽套接字,一個返回的已連接套接字。區(qū)分這兩個套接字很重要,一個服務器通常僅僅創(chuàng)建一個監(jiān)聽套接字,它在該服務器的生命周期內(nèi)一直存在,內(nèi)核為每個由服務器進程接受的客戶連接創(chuàng)建一個已連接套接字(也就是說TCP三路握手已經(jīng)完成),當服務器完成對某個給定客戶的服務時,相應的已連接套接字會被關閉。
close
close方法用來關閉套接字,方法簽名如下:

需要注意的是,close方法并不一定會觸發(fā)TCP的四分組連接終止序列,當一個已連接套接字被多個進程打開時,關閉套接字只會導致此進程相應描述符的計數(shù)值減1,只有所有進程都將該套接字關閉后,套接字的引用計數(shù)值小于1以后,系統(tǒng)內(nèi)核才會開始終止連接操作,這一點在多進程開發(fā)過程中需要格外注意。如果確實想在某個TCP連接上發(fā)送FIN觸發(fā)主動關閉,可以調(diào)用shutdown方法。
send
send方法用于TCP發(fā)送數(shù)據(jù),方法簽名如下:

每一個TCP套接字都有一個發(fā)送緩沖區(qū),默認大小通過socket.SO_SNDBUF查看,當某個進程調(diào)用send時,內(nèi)核從該應用進程的緩沖區(qū)復制所有數(shù)據(jù)到所寫套接字的發(fā)送緩沖區(qū),如果該套接字的發(fā)送緩沖區(qū)容不下該應用進程的所有數(shù)據(jù)(或是應用進程的緩沖區(qū)大小大于套接字的發(fā)送緩沖區(qū),或是套接字的發(fā)送緩沖區(qū)已有其他數(shù)據(jù)),該應用進程將被投入睡眠(套接字阻塞的情況),內(nèi)核將不從系統(tǒng)調(diào)用返回,直到應用進程緩沖區(qū)的所有數(shù)據(jù)都復制到套接字發(fā)送緩存區(qū)。當對端確認收到數(shù)據(jù)后,會發(fā)送ACK分節(jié),隨著對端ACK的不斷到達,本端TCP才能從套接字發(fā)送緩存區(qū)中丟棄已確認的數(shù)據(jù)。

在類似于HTTP的應用層協(xié)議中,客戶端在發(fā)送完請求數(shù)據(jù)之后,可以調(diào)用s.shutdown(socket.SHUT_WR)告訴服務端所有的數(shù)據(jù)已經(jīng)發(fā)送完成,服務端通過recv會讀取到空字符串,之后就可以處理請求數(shù)據(jù)了。
recv
recv方法用于TCP接收數(shù)據(jù),方法簽名如下:

每一個TCP套接字也都有一個接受緩存區(qū),默認大小通過socket.SO_RCVBUF查看。當某個進程調(diào)用recv而且緩存區(qū)沒有數(shù)據(jù)時,該進程會被投入睡眠(套接字阻塞的情況),內(nèi)核將不從系統(tǒng)調(diào)用返回。
在《Unix網(wǎng)絡編程》中,所有C語言調(diào)用accept,read, write函數(shù)都會檢查errno是否等于EINTR,這是因為進程在執(zhí)行這些系統(tǒng)調(diào)用的時候可能會被信號打斷,導致系統(tǒng)調(diào)用返回。而我自己用python2.7嘗試的時候發(fā)現(xiàn)并沒有此問題,猜測是python針對系統(tǒng)調(diào)用被信號打斷的情況,自動重新執(zhí)行系統(tǒng)調(diào)用,stackoverflow上也證實了這一點: http://stackoverflow.com/questions/16094618/python-socket-recv-and-signals。
IO多路復用
在做服務器開發(fā)的時候,經(jīng)常會碰到處理多個套接字的情形,此時可以通過多進程或這多線程的模型解決此問題。用一個主進程或者主線程負責監(jiān)聽套接字,其它每個進程或線程負責一個已連接套接字,這樣還可以利用操作系統(tǒng)的線程切換實現(xiàn)多并發(fā),提高機器利用率。但是機器資源有限,不可能無限制的生成新線程或進程,IO多路復用應運而生。當內(nèi)核一旦發(fā)現(xiàn)進程指定的一個或者多個IO條件就緒,它就通知進程。
IO模型
Unix下有5中IO模型:
- 阻塞式IO
- 非阻塞式IO
- IO復用
- 信號驅(qū)動IO
- 異步IO
已讀取數(shù)據(jù)為例,講解這物種IO模型的區(qū)別。每次讀取數(shù)據(jù)包括以下兩個階段,而這五種模型的不同之處也體現(xiàn)在這兩個階段不同的處理。
- 等待數(shù)據(jù)準備好
- 從內(nèi)核想進程復制數(shù)據(jù)
阻塞式IO
socket套接字默認就是阻塞式IO。以recvfrom為例,用戶進程通過系統(tǒng)調(diào)用獲取TCP數(shù)據(jù),如果套接字緩存區(qū)沒有數(shù)據(jù),系統(tǒng)調(diào)用不會返回,造成用戶進程一直阻塞。直到緩存區(qū)有可用數(shù)據(jù),內(nèi)核將緩存區(qū)數(shù)據(jù)拷貝至用戶進程空間,系統(tǒng)調(diào)用才會返回。

非阻塞式IO
python可以通過調(diào)用s.setblocking(False)或者s.settimeout(0.0)將一個套接字設置為非阻塞式IO。以recvfrom為例,當沒有可用的數(shù)據(jù)時,用戶進程不會阻塞,而是馬上拋出EWOULDBLOCK錯誤(或者EAGAIN,對應的errno錯誤碼都是11),只有當數(shù)據(jù)復制到內(nèi)核空間后,才會正確返回數(shù)據(jù)。

IO多路復用
在有多個IO操作時,先阻塞于select調(diào)用,等待數(shù)據(jù)報套接字變?yōu)榭勺x,然后再通過recvfrom把緩存區(qū)數(shù)據(jù)復制到用戶進程空間。和阻塞是IO相比,當處理的套接字個數(shù)較少的時候,多路復其實沒有性能上的優(yōu)勢,它的優(yōu)勢在于可以方便操作很多套接字。

信號驅(qū)動式IO
通過信號處理的方式讀取數(shù)據(jù)。

異步IO
當數(shù)據(jù)包被復制到用戶進程后,用戶通過callback的方式獲取數(shù)據(jù)。

模型對比

可以發(fā)現(xiàn),前四種IO模型——阻塞式IO、非阻塞式IO、IO復用、信號驅(qū)動IO都是同步IO模型,因為真正的IO操作(recvfrom)將阻塞進程,只有異步IO模型才不會導致用戶進程阻塞。
python使用
較早的時候使用的多路復用是select函數(shù),但是由于時間復雜度較高,很快就被其他的函數(shù)替代:linux下的epoll,unix下的kqueue,windows下的iocp。為了屏蔽不同系統(tǒng)下的不同實現(xiàn),跨平臺的第三方庫出現(xiàn):libuv、libev、libevent等,這些庫根據(jù)平臺的不同,調(diào)用不同的底層代碼。
如果想直接使用底層的epoll或者select,它們封裝在python的select庫中;libuv、libev都有相應的python封裝,庫名叫做pyuv、pyev,通過pip安裝后即可使用。
python示例
一般情況下,為了提升服務的承載量,都會采用進程+IO多路復用或者線程+IO多路復用的開發(fā)模式。IO多路復用是為了一個并發(fā)單位管理多個套接字,而多進程或者多線程是為了充分利用多核。由于GIL的存在,python多線程模型并不能充分多核,因此我們常見的wsgi server,例如:gunicorn、uwsgi、tornado等都是使用的多進程+IO多路服用開發(fā)模式。
tornado使用epoll管理多個套接字,gunicorn和uwsgi都可以使用gevent,gevent是一個python網(wǎng)絡庫,用greenlet做協(xié)程切換,每個協(xié)程管理一個套接字,主協(xié)程通過libevent輪詢查找可用的套接字。因為gevent可以通過monkey patch將socket設置為非阻塞模式,因此當服務器有數(shù)據(jù)庫、緩存或者其他網(wǎng)絡請求的時候,相比tornado,uwsgi和gunicorn可以充分利用這部分的阻塞時間。和gunicorn相比,uwsgi是c語言實現(xiàn),直觀感覺這三個server的性能應該是:uwsgi > gunicorn > tornado,和網(wǎng)上的benchmark大致匹配。
django的作者在github上實現(xiàn)了一個wsgi server,項目地址: https://github.com/jonashaag/bjoern,使用C語言實現(xiàn),代碼量很少,性能據(jù)說比uwsgi還好,十分適合網(wǎng)絡開發(fā)進階學習。參考這份代碼,我用python實現(xiàn)了一個thrift server,項目地址:https://github.com/LiuRoy/dracula,和thriftpy的TThreadedServer做了一個簡單的性能對比。
| |50|100|150|200|250|300|350|400|450|
|---|---|---|---|---|---|---|---|---|---|---|
| libev| 92| 181| 269.9| 355.2| 362.6| 367.1| 373.8| 378.5| 315(3%)|
| thread| 88.9| 180.5| 266.1| 354.8| 428.9| 460.2| 486.5(2%)| 477.9(7%)| 486.5(22%)|

橫坐標是連接個數(shù),縱坐標是qps,括號內(nèi)的數(shù)字表示錯誤率。在連接數(shù)較少的情況下,使用libev管理socket和多線程性能相差不大,在連接數(shù)超過200后,libev模型的請求耗時會增加,導致qps增加的并不多,但是線程模型在連接數(shù)很多的情況下,會導致部分請求一直得不到處理,在連接個數(shù)350的時候就會出現(xiàn)部分請求超時,而libev模型在450的時候才會出現(xiàn)。