最近在看《UNIX網絡編程 卷1》和《FREEBSD操作系統設計與實現》這兩本書,我重點關注了TCP協議相關的內容,結合自己后台開發的經驗,寫下這篇文章,一方面是為了幫助有需要的人,更重要的是方便自己整理思路,加深理解。
理論基礎
OSI網絡模型
OSI模型是一個七層模型,實際工程中,層次的划分沒有這么細致。一般來說,物理層和數據層對應着硬件和設備驅動程序,例如網卡和網卡驅動。傳輸層和網絡層由操作系統內核實現,當用戶進程需要通過網絡傳輸數據,通過系統調用的方式讓內核將數據封裝為相應的協議格式,進而調用網卡驅動傳輸數據。頂上三層對應具體的網絡應用協議:FTP、HTTP等,這些應用層協議不需要知道具體的通信細節。
傳輸層
在實際工程中,我們常用的應用層服務(例如:HTTP服務、數據庫服務、緩存服務)通信的直接底層就是傳輸層,下圖是一些常用命令涉及的通信協議。
IPv4(Internet Protocol version 4)全稱是網際協議版本4,它使用32地址,平時常說的IP協議就是指IPv4,類似於192.168.99.100
的地址可以看成4位256進制數據,也就是32網絡地址。但隨着網絡設備爆炸式增長,32地址面臨這用完的風險,IPv6(Internet Protocol version 6)應運而生。IPv6使用128位地址,但IPv4地址耗盡的問題有了新的解決方案,目前普遍使用的還是IPv4,IPv6全面取代IPv4還有很長的距離。
UDP (User Datagram Protocol),全稱用戶數據報協議。UDP提供面向無連接的服務,客戶端和服務端不存在任何長期的關系。UDP不提供可靠的通信,它不保證數據報一定送達,也不保證數據包送達的先后順序,也不保證每份數據報只送達一次。雖然UDP可靠性差,但是消耗資源少,適用在網絡環境較好的局域網中,例如不需要精確統計的監控服務(eg: Statsd)。由於使用了UDP,客戶端每次打點統計只需要一次發送UDP數據報的IO開銷,服務性能損失很小,而且在內網環境數據包一般都能正常到達服務端,也能保證較高的可行度。
TCP(Transmission Control Protocl),全稱傳輸控制協議。和UDP相反,TCP提供了面向連接的服務,而且提供了可靠性保障。平常我們使用的應用層協議,例如HTTP,FTP等,幾乎都是建立在TCP協議之上,深入了解TCP的細節對於開發高質量的后台開發和客戶端開發都有很好的借鑒意義。下面開始重點介紹TCP協議的細節。
TCP協議
狀態轉換
為了提供可靠的通信服務,TCP通過三次分節建立連接,四次分節關閉連接,心跳檢查判斷連接是否正常,因此需要記錄連接的狀態,TCP一共定義了11種不同的狀態。
通過netstat
命令可以查看所有的tcp狀態。
三路握手
在三路握手之前,服務器必須准備好接收外來的連接。這通常通過調用bind
和listen
完成被動打開,此時服務進程有一個套接字處於LISTEN狀態。在客戶端發通過調用connect
送一個SYN分節后,服務進程必須確認(ACK)此分節,同時也發送一個SYN分節,這兩步在同一分節中完成,通過上面的轉台扭轉圖,可以知道服務進程中會生成一個處於SYN_RCVD狀態的套接字。當再次收到客戶端的ACK分節后,服務端的套接字狀態轉變為ESTABLISHED。
客戶端通過connect函數發起主動打開,在此之前客戶端套接字狀態為CLOSED。調用connect導致客戶TCP發送一個SYN分節,此時套接字狀態有CLOSED變為SYN_SENT,在收到服務器的SYN和ACK后,客戶端socket再發送ACK分節,套接字狀態變為ESTABLISHED,此時connect返回。
備注:SYN分節中除了有序列號之外,還會有最大分節大小、窗口規模選項、時間戳等TCP參數,具體可以參考協議詳細規定。
終止連接
上圖展示了客戶端執行主動關閉的情形,實際上無論客戶端還是服務器,都可以執行主動關閉。一般情況下客戶端執行主動關閉較多,所以使用客戶端主動關閉為例講解。
客戶端調用close
,執行主動關閉時,發送FIN分節,此時客戶端套接字狀態由ESTABLISED變為FIN_WAIT_1。服務器收到這個FIN,會執行被動關閉,並向客戶端發送ACK,FIN的接受也作為一個文件結束符傳遞給服務進程,如果此時服務進程調用套接字的方法,無論緩存區是否有數據都會返回EOF,服務端套接字狀態由ESTABLISED變為為CLOSE_WAIT。客戶端接收到ACK后,客戶端套接字狀態由FIN_WAIT_1變為FIN_WAIT_2。
一段時間后,當服務進程調用close
或者shutdown
時,也會發生送FIN分節,服務端套接字狀態由CLOSE_WAIT變為LAST_ACK。客戶端在接收到FIN分節后,發送ACK分節,客戶端套接字狀態由FIN_WAIT_2變為TIME_WAIT。服務器段接收到客戶端的ACK分節,狀態變成CLOSED。
在某些情況下,第二和第三分節可能會合並發送。調用close
可能會觸發主動關閉,當進程正常或者非正常退出時,內核會將該進程所使用的文件描述符對應的打開次數執行減一操作,當某個文件打開次數為0時,也就是說所有的進程都沒有使用此文件時,也會觸發TCP的主動關閉操作。
TIME_WAIT狀態
在終止連接的過程中,主動關閉方套接字最終的狀態是TIME_WAIT,在經過2MSL(maximun segment lifetime,每個IP數據報都包含一個跳限的字段,表明數據報能經過的路由最大個數,因此默認每個數據報在因特網中有一個最大存活時間)時間后狀態才變為CLOSED,為什么這樣設計呢?
這樣的設計出於兩個考慮:
- 可靠地實現TCP全雙工連接的終止。上圖的四次分節關閉連接是在正常流程,實際情況中,任何一次分節都可能出現發送失敗的情況。主動關閉方最后的一個ACK分節可能會因為路由問題發送失敗,為了保證可靠性,需要重新發送保證另一方正確關閉套接字,因此此時的狀態不能為CLOSED。
- 允許老的重復分界在網絡中消失。加入10.10.89.9的3400端口和206.168.12.12的80端口建立了一個TCP連接,此連接中斷后,之前發送的TCP分節可能因為路由循環的問題還在因特網中游盪,而此時這兩個機器相同的端口再建立起新的連接后,原來在網絡中游盪的分解會對新的連接造成干擾。為了避免這種情況,設置一個2MSL的超時時間,保證之前還在網絡中游盪的數據包完全消失。
套接字編程
下圖是C語言的套接字函數,考慮Python的socket庫只是底層C庫的簡單封裝,接口參數大同小異,而且Python方便上手調試,語法上也更通俗易懂,所以本文使用Python的socket庫作為講解實例。
socket
socket
是python套接字類,通過構造函數生成套接字對象,構造函數簽名如下
其中family參數指協議族;type參數指套接字類型;protocol值協議類型,或者設置為0,以選擇所給定family和type組合的系統默認值;fileno指文件描述符(我從來沒用過)。
family | 說明 |
---|---|
AF_INET | IPv4協議 |
AF_INET6 | IPv6協議 |
AF_LOCAL | Unix域協議 |
AF_ROUTE | 路由套接字 |
AF_KEY | 密鑰套接字 |
type | 說明 |
---|---|
SOCK_STREAM | 字節流套接字 |
SOCK_DGRAM | 數據包套接字 |
SOCK_SEQPACKET | 有序分組套接字 |
SOCK_RAW | 原始套接字 |
protocol | 說明 |
---|---|
IPPROTO_TCP | TCP傳輸協議 |
IPPROTO_UDP | UDP傳輸協議 |
並非所有套接字family和type的組合都是有效的,下表給出了一些有效的組合和對應的協議,其中標是
的項也是有效的,但是沒有找到便捷的縮略詞,而空白項是無效組合。
connect
connect
用於客戶端和服務器建立連接,函數簽名如下:
客戶端在調用connect
之前不必非得調用bind
函數,內核會確定源IP地址,並選擇一個臨時端口作為源端口。如果使用TCP協議,connect
將激發TCP的三路握手過程,TCP狀態由CLOSED變為SYN_SENT,最終變為ESTABLISHED,在三路握手的過程中,可能會出現下面幾種情況導致connect
報錯。connect
失敗則套接字不可用,必須關閉,不能對這樣的套接字再次調connect
函數。
- TCP客戶端沒有是收到SYN分節響應,一般發生在服務端backlog隊列已滿的情況下,服務器會對收到的SYN分節不做任何處理。客戶端等待一段時間后會重新發送SYN分節,直到等待時間超過上限,才會拋出
ETIMEDOUT
錯誤(對應的python異常是TimeoutError
)。 - 對客戶端SYN的響應是RST,表明服務端在指定的端口上沒有進程在等待與之連接,客戶端馬上會拋出
ECONNRFUSED
錯誤。下圖是用python連接一個未使用的端口,拋出異常ConnectionRefusedError
,該異常錯誤號碼111,errno中查找正是ECONNRFUSED
對應的錯誤碼。
- 如果發出的SYN在中間的嗎某個路由器上引發了目的地不可達錯誤,客戶端會等待一段時間后重新發送,直到等待時間超過上限(和第一種情況類似),此時會拋出
ENETUNREACH
或者EHOSTUNREACH
錯誤。下圖為關閉本機網絡后,用python調用connect
,由於網絡不可達,異常的錯誤碼為101,errno中查找正是ENETUNREACH
錯誤碼。
bind
bind
方法把一個本地協議地址賦予給一個套接字,方法簽名如下:
在不調用bind
的情況下,內核會確定IP地址,並分配臨時端口,這種情況很適合客戶端,因此客戶端在調用connect
之前不調用bind
方法。而服務端需要一個確定的ip和端口,因此需要調用bind
指定地址和端口。一般情況下,服務器都有多個ip地址,除了環路地址127.0.0.1
外,還有局域網和公網地址,如果bind
綁定的是環路地址127.0.0.1
,則只有本機通過環路地址才能訪問,如果需要通過任一ip地址都能訪問到,可以綁定通配地址0.0.0.0
。當指定的端口為0時,內核會分配一個臨時端口。
如果端口已經在使用,會拋出EADDRINUSE(errno對應錯誤碼是98)異常,可以通過設置SO_REUSEADDR和SO_REUSEPORT這兩個套接字參數讓多個進程使用同一個TCP連接。
listen
當創建一個套接字時,默認為主動套接字,也就是說,是一個將調用connect發起連接的客戶套接字。listen方法把一個未連接的套接字轉換為一個被動套接字,指示內核應接受指向該套接字的狀態請求。根據TCP狀態轉換圖,調用listen
導致套接字從CLOSED狀態轉換到LISTEN狀態。此方法參數規定了內核應該為相應套接字排隊的最大連接個數,在bind
之后,並在accept
之前調用。
為了理解backlog參數,我們必須認識到內核為其中任何一個給定的監聽套接字維護兩個隊列:
- 未完成連接隊列,每個這樣的SYN分節對應其中一項:已由某個客戶發出並到達服務器,而服務器正在等待完成相應的TCP三路握手過程,這些套接字處於SYN_RCVD狀態。
- 已完成連接隊列,每個已完成TCP三路握手過程的客戶對應其中一項,這些套接字處於ESTABLISHED狀態。
RTT指的是未連接隊列中的任何一項在隊列中的存活時間。linux下的backlog指的是已完成連接隊列的容量,如果服務器長時間未調用accept
從此隊列中取走數據,當新的客戶端通過三路握手重新建立連接時,服務器不會處理收到的SYN分節,而客戶端會一直等待並不斷重試直到超時。在服務器負載很大的情況下,就會造成客戶端連接時間長,所以需要合理設置backlog大小。
accept
accept
用於從已完成連接隊列頭返回下一個已完成連接,如果已完成連接隊列為空,那么進程會被投入睡眠(套接字為阻塞方式)。
accept
會自動生成一個全新的文件描述符,代表與所返回客戶的TCP連接。需要注意的是,此處有兩個套接字對象,一個是監聽套接字,一個返回的已連接套接字。區分這兩個套接字很重要,一個服務器通常僅僅創建一個監聽套接字,它在該服務器的生命周期內一直存在,內核為每個由服務器進程接受的客戶連接創建一個已連接套接字(也就是說TCP三路握手已經完成),當服務器完成對某個給定客戶的服務時,相應的已連接套接字會被關閉。
close
close
方法用來關閉套接字,方法簽名如下:
需要注意的是,close
方法並不一定會觸發TCP的四分組連接終止序列,當一個已連接套接字被多個進程打開時,關閉套接字只會導致此進程相應描述符的計數值減1,只有所有進程都將該套接字關閉后,套接字的引用計數值小於1以后,系統內核才會開始終止連接操作,這一點在多進程開發過程中需要格外注意。如果確實想在某個TCP連接上發送FIN觸發主動關閉,可以調用shutdown
方法。
send
send
方法用於TCP發送數據,方法簽名如下:
每一個TCP套接字都有一個發送緩沖區,默認大小通過socket.SO_SNDBUF
查看,當某個進程調用send
時,內核從該應用進程的緩沖區復制所有數據到所寫套接字的發送緩沖區,如果該套接字的發送緩沖區容不下該應用進程的所有數據(或是應用進程的緩沖區大小大於套接字的發送緩沖區,或是套接字的發送緩沖區已有其他數據),該應用進程將被投入睡眠(套接字阻塞的情況),內核將不從系統調用返回,直到應用進程緩沖區的所有數據都復制到套接字發送緩存區。當對端確認收到數據后,會發送ACK分節,隨着對端ACK的不斷到達,本端TCP才能從套接字發送緩存區中丟棄已確認的數據。
在類似於HTTP的應用層協議中,客戶端在發送完請求數據之后,可以調用s.shutdown(socket.SHUT_WR)
告訴服務端所有的數據已經發送完成,服務端通過recv
會讀取到空字符串,之后就可以處理請求數據了。
recv
recv
方法用於TCP接收數據,方法簽名如下:
每一個TCP套接字也都有一個接受緩存區,默認大小通過socket.SO_RCVBUF
查看。當某個進程調用recv
而且緩存區沒有數據時,該進程會被投入睡眠(套接字阻塞的情況),內核將不從系統調用返回。
在《Unix網絡編程》中,所有C語言調用accept
,read
, write
函數都會檢查errno是否等於EINTR
,這是因為進程在執行這些系統調用的時候可能會被信號打斷,導致系統調用返回。而我自己用python2.7嘗試的時候發現並沒有此問題,猜測是python針對系統調用被信號打斷的情況,自動重新執行系統調用,stackoverflow上也證實了這一點: http://stackoverflow.com/questions/16094618/python-socket-recv-and-signals。
IO多路復用
在做服務器開發的時候,經常會碰到處理多個套接字的情形,此時可以通過多進程或這多線程的模型解決此問題。用一個主進程或者主線程負責監聽套接字,其它每個進程或線程負責一個已連接套接字,這樣還可以利用操作系統的線程切換實現多並發,提高機器利用率。但是機器資源有限,不可能無限制的生成新線程或進程,IO多路復用應運而生。當內核一旦發現進程指定的一個或者多個IO條件就緒,它就通知進程。
IO模型
Unix下有5中IO模型:
- 阻塞式IO
- 非阻塞式IO
- IO復用
- 信號驅動IO
- 異步IO
已讀取數據為例,講解這物種IO模型的區別。每次讀取數據包括以下兩個階段,而這五種模型的不同之處也體現在這兩個階段不同的處理。
- 等待數據准備好
- 從內核想進程復制數據
阻塞式IO
socket套接字默認就是阻塞式IO。以recvfrom
為例,用戶進程通過系統調用獲取TCP數據,如果套接字緩存區沒有數據,系統調用不會返回,造成用戶進程一直阻塞。直到緩存區有可用數據,內核將緩存區數據拷貝至用戶進程空間,系統調用才會返回。
非阻塞式IO
python可以通過調用s.setblocking(False)
或者s.settimeout(0.0)
將一個套接字設置為非阻塞式IO。以recvfrom
為例,當沒有可用的數據時,用戶進程不會阻塞,而是馬上拋出EWOULDBLOCK錯誤(或者EAGAIN,對應的errno錯誤碼都是11),只有當數據復制到內核空間后,才會正確返回數據。
IO多路復用
在有多個IO操作時,先阻塞於select調用,等待數據報套接字變為可讀,然后再通過recvfrom
把緩存區數據復制到用戶進程空間。和阻塞是IO相比,當處理的套接字個數較少的時候,多路復其實沒有性能上的優勢,它的優勢在於可以方便操作很多套接字。
信號驅動式IO
通過信號處理的方式讀取數據。
異步IO
當數據包被復制到用戶進程后,用戶通過callback的方式獲取數據。
模型對比
可以發現,前四種IO模型——阻塞式IO、非阻塞式IO、IO復用、信號驅動IO都是同步IO模型,因為真正的IO操作(recvfrom
)將阻塞進程,只有異步IO模型才不會導致用戶進程阻塞。
python使用
較早的時候使用的多路復用是select函數,但是由於時間復雜度較高,很快就被其他的函數替代:linux下的epoll,unix下的kqueue,windows下的iocp。為了屏蔽不同系統下的不同實現,跨平台的第三方庫出現:libuv、libev、libevent等,這些庫根據平台的不同,調用不同的底層代碼。
如果想直接使用底層的epoll或者select,它們封裝在python的select庫中;libuv、libev都有相應的python封裝,庫名叫做pyuv、pyev,通過pip安裝后即可使用。
python示例
一般情況下,為了提升服務的承載量,都會采用進程+IO多路復用或者線程+IO多路復用的開發模式。IO多路復用是為了一個並發單位管理多個套接字,而多進程或者多線程是為了充分利用多核。由於GIL的存在,python多線程模型並不能充分多核,因此我們常見的wsgi server,例如:gunicorn、uwsgi、tornado等都是使用的多進程+IO多路服用開發模式。
tornado使用epoll管理多個套接字,gunicorn和uwsgi都可以使用gevent,gevent是一個python網絡庫,用greenlet做協程切換,每個協程管理一個套接字,主協程通過libevent輪詢查找可用的套接字。因為gevent可以通過monkey patch將socket設置為非阻塞模式,因此當服務器有數據庫、緩存或者其他網絡請求的時候,相比tornado,uwsgi和gunicorn可以充分利用這部分的阻塞時間。和gunicorn相比,uwsgi是c語言實現,直觀感覺這三個server的性能應該是:uwsgi > gunicorn > tornado,和網上的benchmark大致匹配。
django的作者在github上實現了一個wsgi server,項目地址: https://github.com/jonashaag/bjoern,使用C語言實現,代碼量很少,性能據說比uwsgi還好,十分適合網絡開發進階學習。參考這份代碼,我用python實現了一個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%) |
橫坐標是連接個數,縱坐標是qps,括號內的數字表示錯誤率。在連接數較少的情況下,使用libev管理socket和多線程性能相差不大,在連接數超過200后,libev模型的請求耗時會增加,導致qps增加的並不多,但是線程模型在連接數很多的情況下,會導致部分請求一直得不到處理,在連接個數350的時候就會出現部分請求超時,而libev模型在450的時候才會出現。