最近在看《UNIX網絡編程 卷1》和《FREEBSD操做系統設計與實現》這兩本書,我重點關注了TCP協議相關的內容,結合本身後臺開發的經驗,寫下這篇文章,一方面是爲了幫助有須要的人,更重要的是方便本身整理思路,加深理解。python
OSI模型是一個七層模型,實際工程中,層次的劃分沒有這麼細緻。通常來講,物理層和數據層對應着硬件和設備驅動程序,例如網卡和網卡驅動。傳輸層和網絡層由操做系統內核實現,當用戶進程須要經過網絡傳輸數據,經過系統調用的方式讓內核將數據封裝爲相應的協議格式,進而調用網卡驅動傳輸數據。頂上三層對應具體的網絡應用協議:FTP、HTTP等,這些應用層協議不須要知道具體的通訊細節。linux
在實際工程中,咱們經常使用的應用層服務(例如:HTTP服務、數據庫服務、緩存服務)通訊的直接底層就是傳輸層,下圖是一些經常使用命令涉及的通訊協議。git
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還有很長的距離。github
UDP (User Datagram Protocol),全稱用戶數據報協議。UDP提供面向無鏈接的服務,客戶端和服務端不存在任何長期的關係。UDP不提供可靠的通訊,它不保證數據報必定送達,也不保證數據包送達的前後順序,也不保證每份數據報只送達一次。雖然UDP可靠性差,可是消耗資源少,適用在網絡環境較好的局域網中,例如不須要精確統計的監控服務(eg: Statsd)。因爲使用了UDP,客戶端每次打點統計只須要一次發送UDP數據報的IO開銷,服務性能損失很小,並且在內網環境數據包通常都能正常到達服務端,也能保證較高的可行度。數據庫
TCP(Transmission Control Protocl),全稱傳輸控制協議。和UDP相反,TCP提供了面向鏈接的服務,並且提供了可靠性保障。日常咱們使用的應用層協議,例如HTTP,FTP等,幾乎都是創建在TCP協議之上,深刻了解TCP的細節對於開發高質量的後臺開發和客戶端開發都有很好的借鑑意義。下面開始重點介紹TCP協議的細節。django
爲了提供可靠的通訊服務,TCP經過三次分節創建鏈接,四次分節關閉鏈接,心跳檢查判斷鏈接是否正常,所以須要記錄鏈接的狀態,TCP一共定義了11種不一樣的狀態。編程
經過netstat
命令能夠查看全部的tcp狀態。windows
在三路握手以前,服務器必須準備好接收外來的鏈接。這一般經過調用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,在通過2MSL(maximun segment lifetime,每一個IP數據報都包含一個跳限的字段,代表數據報能通過的路由最大個數,所以默認每一個數據報在因特網中有一個最大存活時間)時間後狀態才變爲CLOSED,爲何這樣設計呢?
這樣的設計出於兩個考慮:
下圖是C語言的套接字函數,考慮Python的socket庫只是底層C庫的簡單封裝,接口參數大同小異,並且Python方便上手調試,語法上也更通俗易懂,因此本文使用Python的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
以前沒必要非得調用bind
函數,內核會肯定源IP地址,並選擇一個臨時端口做爲源端口。若是使用TCP協議,connect
將激發TCP的三路握手過程,TCP狀態由CLOSED變爲SYN_SENT,最終變爲ESTABLISHED,在三路握手的過程當中,可能會出現下面幾種狀況致使connect
報錯。connect
失敗則套接字不可用,必須關閉,不能對這樣的套接字再次調connect
函數。
ETIMEDOUT
錯誤(對應的python異常是TimeoutError
)。ECONNRFUSED
錯誤。下圖是用python鏈接一個未使用的端口,拋出異常ConnectionRefusedError
,該異常錯誤號碼111,errno中查找正是ECONNRFUSED
對應的錯誤碼。ENETUNREACH
或者EHOSTUNREACH
錯誤。下圖爲關閉本機網絡後,用python調用connect
,因爲網絡不可達,異常的錯誤碼爲101,errno中查找正是ENETUNREACH
錯誤碼。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鏈接。
當建立一個套接字時,默認爲主動套接字,也就是說,是一個將調用connect發起鏈接的客戶套接字。listen方法把一個未鏈接的套接字轉換爲一個被動套接字,指示內核應接受指向該套接字的狀態請求。根據TCP狀態轉換圖,調用listen
致使套接字從CLOSED狀態轉換到LISTEN狀態。此方法參數規定了內核應該爲相應套接字排隊的最大鏈接個數,在bind
以後,並在accept
以前調用。
爲了理解backlog參數,咱們必須認識到內核爲其中任何一個給定的監聽套接字維護兩個隊列:
RTT指的是未鏈接隊列中的任何一項在隊列中的存活時間。linux下的backlog指的是已完成鏈接隊列的容量,若是服務器長時間未調用accept
今後隊列中取走數據,當新的客戶端經過三路握手從新創建鏈接時,服務器不會處理收到的SYN分節,而客戶端會一直等待並不斷重試直到超時。在服務器負載很大的狀況下,就會形成客戶端鏈接時間長,因此須要合理設置backlog大小。
accept
用於從已完成鏈接隊列頭返回下一個已完成鏈接,若是已完成鏈接隊列爲空,那麼進程會被投入睡眠(套接字爲阻塞方式)。
accept
會自動生成一個全新的文件描述符,表明與所返回客戶的TCP鏈接。須要注意的是,此處有兩個套接字對象,一個是監聽套接字,一個返回的已鏈接套接字。區分這兩個套接字很重要,一個服務器一般僅僅建立一個監聽套接字,它在該服務器的生命週期內一直存在,內核爲每一個由服務器進程接受的客戶鏈接建立一個已鏈接套接字(也就是說TCP三路握手已經完成),當服務器完成對某個給定客戶的服務時,相應的已鏈接套接字會被關閉。
close
方法用來關閉套接字,方法簽名以下:
須要注意的是,close
方法並不必定會觸發TCP的四分組鏈接終止序列,當一個已鏈接套接字被多個進程打開時,關閉套接字只會致使此進程相應描述符的計數值減1,只有全部進程都將該套接字關閉後,套接字的引用計數值小於1之後,系統內核纔會開始終止鏈接操做,這一點在多進程開發過程當中須要格外注意。若是確實想在某個TCP鏈接上發送FIN觸發主動關閉,能夠調用shutdown
方法。
send
方法用於TCP發送數據,方法簽名以下:
每個TCP套接字都有一個發送緩衝區,默認大小經過socket.SO_SNDBUF
查看,當某個進程調用send
時,內核從該應用進程的緩衝區複製全部數據到所寫套接字的發送緩衝區,若是該套接字的發送緩衝區容不下該應用進程的全部數據(或是應用進程的緩衝區大小大於套接字的發送緩衝區,或是套接字的發送緩衝區已有其餘數據),該應用進程將被投入睡眠(套接字阻塞的狀況),內核將不從系統調用返回,直到應用進程緩衝區的全部數據都複製到套接字發送緩存區。當對端確認收到數據後,會發送ACK分節,隨着對端ACK的不斷到達,本端TCP才能從套接字發送緩存區中丟棄已確認的數據。
在相似於HTTP的應用層協議中,客戶端在發送完請求數據以後,能夠調用s.shutdown(socket.SHUT_WR)
告訴服務端全部的數據已經發送完成,服務端經過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條件就緒,它就通知進程。
Unix下有5中IO模型:
已讀取數據爲例,講解這物種IO模型的區別。每次讀取數據包括如下兩個階段,而這五種模型的不一樣之處也體如今這兩個階段不一樣的處理。
socket套接字默認就是阻塞式IO。以recvfrom
爲例,用戶進程經過系統調用獲取TCP數據,若是套接字緩存區沒有數據,系統調用不會返回,形成用戶進程一直阻塞。直到緩存區有可用數據,內核將緩存區數據拷貝至用戶進程空間,系統調用纔會返回。
python能夠經過調用s.setblocking(False)
或者s.settimeout(0.0)
將一個套接字設置爲非阻塞式IO。以recvfrom
爲例,當沒有可用的數據時,用戶進程不會阻塞,而是立刻拋出EWOULDBLOCK錯誤(或者EAGAIN,對應的errno錯誤碼都是11),只有當數據複製到內核空間後,纔會正確返回數據。
在有多個IO操做時,先阻塞於select調用,等待數據報套接字變爲可讀,而後再經過recvfrom
把緩存區數據複製到用戶進程空間。和阻塞是IO相比,當處理的套接字個數較少的時候,多路復其實沒有性能上的優點,它的優點在於能夠方便操做不少套接字。
經過信號處理的方式讀取數據。
當數據包被複制到用戶進程後,用戶經過callback的方式獲取數據。
能夠發現,前四種IO模型——阻塞式IO、非阻塞式IO、IO複用、信號驅動IO都是同步IO模型,由於真正的IO操做(recvfrom
)將阻塞進程,只有異步IO模型纔不會致使用戶進程阻塞。
較早的時候使用的多路複用是select函數,可是因爲時間複雜度較高,很快就被其餘的函數替代:linux下的epoll,unix下的kqueue,windows下的iocp。爲了屏蔽不一樣系統下的不一樣實現,跨平臺的第三方庫出現:libuv、libev、libevent等,這些庫根據平臺的不一樣,調用不一樣的底層代碼。
若是想直接使用底層的epoll或者select,它們封裝在python的select庫中;libuv、libev都有相應的python封裝,庫名叫作pyuv、pyev,經過pip安裝後便可使用。
通常狀況下,爲了提高服務的承載量,都會採用進程+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的時候纔會出現。