TCP socket原理與編程實踐

1. TCP中的阻塞/非阻塞

1.1 內核讀緩衝區:

存儲內核從外部接收到的數據,供用戶程序讀取

*recv(SOCKET fd, char buf, int len, int flag)**用於socket的讀取,即從內核讀緩衝區
讀取數據到用戶傳入的buf.

阻塞socket的recv函數執行流程如下:

  1. 等待內核寫緩衝區的數據被清空(被TCP協議傳送完畢)
  2. 如果協議發送寫緩衝區的數據時出現網絡錯誤,返回-1
  3. 如果寫緩衝區沒有數據了,檢查內核讀緩衝區
  4. 如果內核讀緩衝區沒有數據(TCP協議正在接收數據),則等待,直到有數據(TCP協議接收
    完數據)
  5. 如果等待TCP協議接收時網絡斷了,返回0
  6. 當內核讀緩衝區有數據後(TCP協議接收完數據),將內核讀緩衝區數據拷貝到buf中,返回
    拷貝的字節數(<=len)
  7. 如果內核讀緩衝區的數據沒有拷貝完,則需要下一次拷貝
  8. 如果拷貝時出錯,返回-1

返回值:

  • >0: 從內核讀緩衝區讀取的字節數

  • 0: 等待TCP協議接收數據時網絡斷了,對端關閉連接

  • -1: 分情況,具體的錯誤查看errno
    阻塞: 網絡錯誤(TCP發送寫緩衝區數據出錯)
    超時(等待讀緩衝區出現數據超時)
    拷貝錯誤(拷貝讀緩衝區數據出錯)

    非阻塞: 網絡錯誤(TCP發送寫緩衝區數據出錯)
    讀緩衝區無數據
    拷貝錯誤(拷貝讀緩衝區數據出錯)

非阻塞and阻塞:
相同點: 都需要經歷寫緩衝區被清空的等待過程(如果調用recv時寫緩衝區有數據)
不同點:

  1. 阻塞socket: 如果內核讀緩衝區沒有數據可讀,則阻塞,recv不返回,超時後返回-1,
    置errno=EWOULDBLOCK
  2. 非阻塞socket: 如果內核讀緩衝區沒有數據可讀,則立即返回-1並置errno=EWOULDBLOCK

編程注意事項:

  1. 返回0表明對端關閉連接,本端也要關閉連接
  2. 阻塞socket從讀緩衝區拷貝數據時,如果一次拷貝不完,怎麼辦?
    什麼叫TCP協議接收完一次數據?如何定義一次接收?
    上一次的還沒拷貝完,讀緩衝區又出現了下一次傳過來的數據,需要區分兩次數據嗎?how?

1.2 內核寫緩衝區:

存儲用戶程序向內核寫入的數據,供內核向外部發送
send(SOCKET fd, const char buf, int len, int flag)*

阻塞socket的send函數執行流程如下:

  1. 比較待發送數據的長度len和內核寫緩衝區的長度m
  2. 若len > m,返回-1
  3. 若len <= m, 檢查TCP協議是否正在傳送寫緩衝區的數據
  4. 若正在傳送,等待
  5. 若等待時出現網絡錯誤,返回-1
  6. 若傳送完畢(寫緩衝區可能有數據也可能沒有),比較len和剩餘空間c
  7. 若len > c,等待TCP將寫緩衝區的數據發送乾淨
  8. 若等待時出現網絡錯誤,返回-1
  9. 若len <=c, 拷貝buf中數據到剩餘空間
  10. 若拷貝成功,返回拷貝的字節數(==len)
  11. 拷貝出錯,返回-1

內核緩衝區的狀態不因send/recv的調用發生改變,而是send/recv調用時內核緩衝區處於
不同的狀態下這兩個函數的額返回值會不同,使用epoll時,epoll根據內核緩衝區的狀態
來發出通知,在某個狀態下如果調用recv/send會返回(並不需要真的調用),則發出對應的
通知

返回值:

  • >0: 拷貝到內核寫緩衝區的字節數(==len)
  • -1: 分情況
    阻塞:
    a) 網絡錯誤(等待TCP協議傳送數據或等待TCP協議清空寫緩衝區)
    b) 超時(等待TCP協議清空寫緩衝區)
    c) 拷貝錯誤
    非阻塞:
    a) 網絡錯誤(等待TCP協議傳送數據)
    b) len>c,寫緩衝區剩餘空間不夠
    c) 拷貝錯誤
    當使用send()發送空數據時,如char* buf[1024] = {0},返回0

非阻塞and阻塞socket:
相同點: 都需要比較len和m; 都需要等待TCP協議傳送數據(如果調用send時TCP協議正在傳送數據)
不同點:

  1. 阻塞socket: 若len>c, 剩餘空間不足,需要阻塞等待TCP協議清空寫緩衝區,等待超時,返回-1,置errno=EWOULDBLOCK
  2. 非阻塞socket:若len>c, 剩餘空間不足,直接返回-1,置errno=EWOULDBLOCK,雖然TCP協議扔需要清空寫緩衝區

socket的可讀/可寫指的是啥?

可讀指的是讀緩衝區處於該狀態時調用recv()能返回,可寫指的是寫緩衝區處於該狀態時
調用send()能返回;

可讀可分爲3類:

  1. 讀緩衝區有數據可讀(高於低水位標記)
  2. 對讀緩衝區調用recv()時返回-1,errno被置位,出現網絡錯誤(拷貝錯誤是讀的過程出現的)
  3. 讀緩衝區通過TCP協議收到對端的SYN或FIN消息,請求建立連接或關閉對端的寫(本端的讀)
    注意這裏的SYN是針對listen套接字,FIN是針對已連接套接字

可寫可分爲3類:

  1. 寫緩衝區剩餘空間足夠

  2. 對寫緩衝區調用send()時返回-1,errno被置位,出現網絡錯誤(拷貝錯誤是寫的過程出現的)

  3. 寫緩衝區收到本端將要給對端通過TCP協議發送的FIN信息,請求關閉本端的寫;

    注意這個條件的特殊性:
    寫意味着主動操作,爲了避免本端多次發送FIN,設計了一種機制,當第一次發送FIN後
    (調用了close()),意味着我想關閉本端的寫,但只能發送一次,如果你編程錯誤,
    調用了2次,對不起,下次你發送FIN仍然可以寫進寫緩衝區,但是本端進程會理解收到
    sigpipe信號關閉進程,太暴力了點了吧?

對socket可讀/可寫條件的理解:
a) 緩衝區真的有數據可讀/可寫調用recv()/send()當然要返回,天經地義
b) 對染緩衝區沒有數據可讀,但是在等待的過程出現了錯誤,錯誤必須處理,因此得返回
c) socket不僅要處理數據的讀寫,還要處理連接的建立和釋放,對端請求建立連接or斷開
連接or本端發送斷開連接的信號後那本端調用recv/send查看讀/寫緩衝區也要能返回

socket的可讀/可寫與epoll的EPOLLIN/EPOLLOUT是一個意思嗎?

不是一個意思,socket的可讀/可寫包含的範圍更廣,socket的可讀/可寫實際上對應epoll的
各種通知條件(EPOLLIN,EPOLLOUT,EPOLLERR等)的組合,後者的通知條件實際上是對socket
可讀/可寫的細化,方便編程處理,你說socket可讀,我如何才能區分是真的有數據可讀還是
返回了錯誤,我的程序邏輯該怎麼設計?

可讀(默認阻塞模式):

  1. 緩衝區真的有數據: EPOLLIN
  2. 網絡錯誤: EPOLLERR
  3. 對端SYN: EPOLLIN
    對端FIN: EPOLLRDHUP

非阻塞模式下EPOOIN通知的條件還包括緩衝區沒數據的情形,因爲此時recv()也返回,
只是返回-1且置errno爲EWOULDBLOCK

所以收到EPOLLIN時需要自己判斷區分緩衝區有數據(or TCP還在接收)還是收到了SYN,區分的方式:
SYN: 針對監聽套接字
真的有數據: 針對已連接套接字
所以可根據socket的狀態來判斷,是LISTENING還是CONNECTED

可寫:

  1. 緩衝區真的由剩餘空間: EPOLLOUT
  2. 網絡錯誤: EPOLLERR
  3. 本端FIN: 不用epoll操心,膽敢再發一次FIN,直接殺掉你的進程

非阻塞模式下EPOLLOUT通知的條件還包括緩衝區剩餘空間足夠的情形,因爲此時send()也
發揮,只是返回-1且置errno爲EWOULDBLOCK

1.3 爲什麼要搞出一個非阻塞來?難道阻塞不香嗎?

阻塞確實香,因爲socket讀寫本質上是與內核讀/寫緩衝區打交道,緩衝區裏沒數據那就
等着唄;
然而在單線程程序中這麼幹等着程序邏輯就不能處理其他的事情了,多浪費cpu資源,
不如直接返回,告訴調用者現在數據還沒準備好,您待會兒再來

1.4 連接建立階段的阻塞/非阻塞

阻塞模式下調用connect()時發生了什麼?
1) 本端通過TCP協議發起SYN給對端
2) 等待對端通過TCP協議回覆ACK
3) 如果對端不可達or超時,返回-1,置errno
4) 如果收到ack,返回0,連接成功

爲什麼想要在非阻塞模式下調用connect()?

阻塞模式需要等待一個RTT的時間,受網絡抖動影響大,我設計client時不想幹等着,
還想着能在單線程中處理其他的業務邏輯,因此我要求使用非阻塞socket.

注意client端的socket只有一個,既負責建立連接又負責收發數據(訪問其持有的讀/寫
緩衝區),server端的socket有2個,listen_fd和conn_fd,前者負責建立連接,後者負責
收發數據,因爲server需要對接好多client,將監聽任務拎出來有助於提高併發
listen_fd只負責處理socket可讀(其實只有收到SYN);
conn_fd負責處理socket可讀(排除掉收到syn的其他可讀條件)和可寫

因此client端想調用connect()時使用非阻塞socket,必然同時影響client端收發數據
的代碼邏輯要按照非阻塞socket的方式編寫!!!

非阻塞模式下調用connect()發生了什麼?
1) 本端通過TCP協議發送SYN給對端
2) 如果對端回覆ACK,返回0,連接成功
3) 如果對端還沒回復ACK(剛發送就想收到回覆,哪有那麼快),返回-1,TCP協議置位errno,
若errno爲EINPROGRESS,則表示你再等等,若爲其他值,網絡出錯

應當如何使用非阻塞socket的connect()?
一般情況下調用connect()返回的都是-1(不可能剛發送就收到回覆),
1) 判斷下errno是否是EINPROGRESS,若是,認爲正常,把socket加入epoll監聽中去,
否則,網絡錯誤,重新連接
2) EPOLLOUT時,先判斷下是否還有錯誤(通過getsockopt),有錯誤重連,無錯誤連接成功

調用listen()發生了什麼?
listen只是把一個可以主動連接的套接字變成被動連接的套接字,不涉及與對端socket
的通信,返回值爲0表示成功,爲-1表示失敗(比如綁定了一個已經在監聽的端口或系統
保留端口)

如何由監聽套接字產生已連接套接字?
1) 監聽套接字檢測socket可讀條件(收到SYN)
2) 收到連接請求,發送ack,將其放入一個隊列(半連接隊列),因爲可能由多個client請求連接,
所以要排隊
3) 等待client的ack,收到哪個client的ack,就將半連接隊列中的那個client標記對象放入已連接隊列
3) 等待已連接隊列非空,accept()從中取出交給進程,調用一次,取出一個
4) 若等待超時,返回-1
所以accept阻塞的原因是已連接隊列爲空

爲什麼想把監聽套接字也變爲非阻塞的?
因爲想在等待收到client的ack過程中做點其他的

阻塞模式下調用accept()發生了什麼?
檢查已連接隊列,若爲空,阻塞,若不爲空,返回已連接套接字

非阻塞模式下調用accept()發生了什麼?
檢查已連接隊列,若爲空,返回-1,若不爲空,返回已連接套接字

應當如何使用非阻塞socket的accept()?
無論阻塞還是非阻塞,都需要使用while循環,while(accept()!=-1),因爲accept一次只能
取出一個已連接套接字;
阻塞socket不再循環是因爲等待已連接隊列非空超時;
非阻塞socket不再循環是因爲已連接隊列爲空;
阻塞listen socket不需要epoll來監聽socket可讀,因爲直到已連接隊列非空時才返回
非阻塞listen socket需要epoll來監聽socket可讀, 因爲已連接隊列爲空也返回,總得
回來再次檢查希望能取到已連接套接字,因此需要epoll來幫忙監視listen socket可讀,
提供再次檢查的契機

2. epoll爲代表的多路複用

2.1 爲什麼要提出多路複用的機制

因爲我們想享受非阻塞socket帶來的好處,即可以在簡單的單線程程序中同時處理socket
讀寫和其他任務,不至於因阻塞等待在socket讀寫上而乾瞪眼;

但是想法很美好,如果socket數據準備好了,誰來通知我呢?總不能隔一段時間輪詢一次吧?

多路複用機制本質上是幫我們代理了對一個socket的內核緩衝區的監控,數據準備好了
就通知用戶.但是隻爲一個socket服務也太浪費了,因此同時代理多個socket,因此稱爲
多路複用

2.2 epoll實現了哪些功能

  1. 內核緩衝區準備好被用戶訪問(此時用戶訪問不用再等待,調用send()/recv()可直接
    返回,但內核緩衝區不一定可讀或可寫)時通知用戶,表現爲epoll_wait返回

    內核讀緩衝區: 寫緩衝區已清空但讀緩衝區暫無數據時;
    讀緩衝區出現數據時;
    內核寫緩衝區: TCP協議沒有正在發送數據且寫緩衝區剩餘空間不夠時;
    寫緩衝區剩餘空間夠或其中的數據已被髮送乾淨時;

  2. 通知用戶時提醒現在內核緩衝區的狀態,表現爲EPOLLIN, EPOLLOUT, EPOLLERR, EPOLLRDHUP等

實質上是將對非阻塞socket調用send()/recv()時可能出現的返回值和errno做了分類

EPOLLIN:
a) 讀緩衝區有數據,recv返回讀取的字節數
b) 讀緩衝區無數據,recv返回-1,置errno=EWOULDBLOCK
c) 讀緩衝區無數據,recv返回0(對端發送FIN)
d) 讀緩衝區收到SYN

收到EPOLLIN後,
i) 若當前socket爲監聽套接字,調用accept()
ii) 否則,調用ioctlsocket查看接收的數據量是否爲0,若爲0,close socket
iii) 否則,調用recv()讀數據

EPOLLOUT:
a) 寫緩衝區剩餘空間足夠或已被髮送乾淨,send返回寫入的字節數
b) 寫緩衝區剩餘空間不足,send返回-1,置errno=EWOULDBLOCK
c) 已發送syn

收到EPOLLOUT後,
i) 若當前socket還未變爲connected(調用connect,已發送Syn還不確定是否收到ack),
調用getsockopt看是否有錯誤,是,close socket,否則正常,更改狀態爲connected
ii) 若當前socket已連接,調用send()發數據

EPOLLERR: 排除掉EWOULDBLOCK和拷貝錯誤的剩下的唯一一種錯誤:網絡錯誤

收到EPOLLERR後,解除註冊,close socket

EPOLLERR只能知道本端的內核緩衝區報告出錯了,且是網絡錯誤,
不知道出錯的原因是否是對端異常斷開(不發通知,如FIN就斷掉)

如果對端異常斷開導致了網絡錯誤,本端是沒有辦法通過EPOLLERR感知的,
只能通過本端再執行一步 寫/讀(讀之前會清空寫緩衝區)報錯,然後才
發現對端異常關閉連接,

這需要server主動去向client發數據來檢測client是否存活,不必要,
一個替代策略是檢測超時,client超時未發出請求server就主動斷開連接

EPOLLRDHUP: 對面正常關閉連接(close),此時recv()返回0,其實是將EPOLLIN的©
單獨拎出來,本端需要取消註冊socket,close socket

收到EPOLLRDHUP後,解除註冊,close socket

如果不支持EPOLLRDHUP, 則收到EPOLLIN通知後,
i) 檢測調用recv是否返回0
ii) 若返回0,從epoll中刪除socket的註冊,並close socket

3) 爲什麼epoll搞出了LT和ET兩種模式?

爲了省事.

epoll本質上就是監控socket的內核緩衝區狀態的監控器;

LT模式下t1時刻讀緩衝區有數據可讀,發出EPOLLIN通知,用戶調用recv()讀了,沒讀完,
t2時刻讀緩衝區剩下的數據還在,再次發出EPOLLIN通知,用戶繼續讀

你說數據量大,只能多次讀,但是兩次讀需要epoll來兩次觸發,在兩次觸發之間還處理
了其他socket的讀寫,多耽誤時間,爲啥不一次讀完?

好好好,那我調用while循環一直讀直到recv返回-1(說明數據讀完了或讀取出錯)總
可以了吧

那爲什麼還需要epoll的LT模式多次觸發?你都給我保證了每次EPOLLIN通知你都讀完,
那就砍掉多次觸發,改成ET模式下的一次觸發吧,當非阻塞socket下調用recv()從等待
到可以返回的時刻發出EPOLLIN通知,此後即使是你的鍋,因爲疏忽沒有把數據讀完,
讀緩衝區還有數據也不再觸發

對寫緩衝區,ET模式下如果一次send()後寫緩衝區的剩餘空間還夠再來幾次寫,而你
只寫一次,那下次再想寫時也不會有EPOLLOUT通知,因爲ET模式下只在調用send()
從等待到可以返回的時刻發出EPOLLOUT通知,因此一次寫必須得把寫緩衝區的剩餘
空間給寫得不夠用了才行,得調用while循環寫

LT/ET模式對讀寫邏輯有什麼要求?
讀: 關心的是我能否把緩衝區的數據全部讀完
寫: 關心的時我能否把應用層要發的一條數據一次性寫入緩衝區

 a) LT模式-讀: 可以只recv一下,等待EPOLLIN再次通知時再讀取,缺點是浪費時間    
               也可以在while循環中讀直到recv返回-1,這樣保證只通知一次就     
               把讀緩衝區的數據全部讀完                                     
 b) ET模式-讀: 只能在while循環中讀直到recv返回-1,這樣保證只通知一次就把     
               讀緩衝區的數據全部讀完                                       
 c) LT模式-寫: 只send一下,等EPOLLOUT再次通知時寫入                          
               也可以在while循環中寫直到send返回-1,但不保證能把想發的數據   
               一次發完,只能保證把寫緩衝區寫完,儘可能的發,下次通知時再發    
 d) ET模式-寫: 只能在while循環中寫直到send返回-1,但不保證把想發的數據一次   
               發完,只能保證把寫緩衝區寫完,儘可能的發,下次通知時用戶再發

3. TCP socket如何保證應用層消息的完整性

自己寫程序打包,解包,定義一個消息的結束標識符,考慮protobuf?

TCP是基於流的協議,不記錄用戶發送的間隔,也無法感知哪些字節是屬於用戶定義的同一條
消息,只是將消息看成字節流,對流中的每個字節編號,保證接收端與發送端的字節流順序
一致

4. 阻塞/非阻塞, epoll, LT/ET, 單線程/多線程 到底該怎麼搭配使用?

  1. 阻塞-多線程:
    以讀緩衝區爲例,緩衝區無數據時,等待,直到有數據開始讀,其他操作都不能進行
    如果想避免對其他操作的影響,可將讀單獨放在一個線程

  2. 非阻塞-epoll-單線程-LT
    非阻塞當一次沒能讀成功時(內核讀緩衝區還沒數據),需要有一個代理來檢測內核
    讀緩衝區的狀態,下次可返回時通知用戶,因此使用epoll,這樣epoll通知用戶,用戶
    執行讀邏輯,如果讀成功,就處理讀取的數據,如果讀取不成功,代碼也可往下執行
    處理其他事情,不會因爲讀取數據阻塞而不能繼續往下執行代碼;
    epoll監測讀緩衝區的狀態觸發方式可選水平觸發

    主要缺點:
    沒有數據可讀(返回-1置errno=EWOULDBLOCK)也會觸發EPOLLIN,頻繁觸發佔用cpu資源,
    與輪訓差不多,浪費cpu資源

  3. 非阻塞-epoll-單線程-ET
    epoll監測讀緩衝區的狀態觸發方式可選邊沿觸發

5. client和server通過socket通信應該怎樣設計?

5.1 client的讀寫模型

client在請求-響應模式中處於主動地位,主動發送請求,且發送時間由自己決定;      
發送請求的獲取可以是終端的stdin或android/ios app提供的接口等;               
  1. client需要處理3個文件描述符: stdin, stdout, socket;
  2. 包含stdin - socket send, socket recv - stdout兩個流程,要保證這兩個流程不能
    相互干擾,即從標準輸入讀入來向server發消息時不能阻塞從server收消息,從server收
    消息也不能阻塞從標準輸入寫消息;
  3. stdin一定是阻塞的,等待用戶輸入消息,即標準輸入緩衝區爲空時,必須等待,等其出現
    數據後,才能讀取並通過socket send
  4. socket可以是阻塞的,也可以是非阻塞的,非阻塞模式下必須有額外的監控機制,如
    select/poll/epoll
  5. 分離stdin和socket recv有2種方式:
    a) 使用多路複用 select/poll/epoll, 設置socket非阻塞, 同時監控stdin和socket
    可讀,可寫,注意socket的可寫包含connect連接成功的判斷
    b) 使用多路複用 select/poll/epoll, 設置socket阻塞,同時監控stdin, socket可讀,
    無需監控socket可寫,因爲socket寫緩衝區空間不夠時會阻塞,相比於非阻塞socket
    響應會慢一些,因爲雖然多路複用可以同時監控stdin和socket可讀,但執行時畢竟
    是在一個線程中順序處理,如先處理stdin就緒,再處理socket可讀就緒,但處理stdin
    就緒時如果socket寫緩衝區空間不夠,需要等待,等待這段時間是沒有辦法繼續處理
    socket可讀事件的,所以不建議此種方式
    c) 使用2個進程分別處理stdin-socket send和socket recv-stdout,實現真正的分離,
    這樣socket可以且只能設置成阻塞模式
    由於兩個流程沒有共享資源,所以無需使用雙線程,
    編程簡單,效果好,推薦使用
    使用多路複用分離時在main函數中雖然與server端的類似,都是啓動dispatcher後死循環
    運行,好像沒有給send留下位置,但send是包含在stdin的監聽中了,程序會自動監測stdin
    的輸入,一旦有輸入,就進入程序處理邏輯

5.2 server的讀寫模型

server在請求-響應模式中處於被動地位,被動接收請求,處理後發出響應,即使不處理
只轉發,充當了類似client的角色,但仍是接收請求,轉發請求,收到響應,轉發響應,
不改變其被動角色的本質,因此對於混合了client和server兩種角色的server,應當
使用server的被動讀寫模型,而非client的主動讀寫模型

  1. server只需要處理一種但很多個socket文件描述符,即併發
  2. reactor模式使用多路複用,基於事件驅動,統一所有socket的讀寫在一個線程中完成,
    編程簡單,避免了多線程同時處理讀寫和計算帶來的編程難度
  3. 單線程模式下需要順序處理每個就緒的socket,雖然使用非阻塞避免了讀寫阻塞對其他
    socket的影響,但計算時間仍不可避免
  4. 爲加快計算速度,分離計算和讀寫,建立線程池統一處理所有socket的請求,請求排隊,
    處理後得到的響應排隊,所有讀寫任務由主線程完成
  5. 不同socket其讀寫需求不同,對讀寫進一步拆分,主線程僅僅accept創建連接套接字,
    根據讀寫需求將連接套接字歸類,形成若干個subreactor處理讀寫,原有的計算線程池
    不變
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章