問題概述
最近在處理一些TCP客戶端的專案,服務端是C語言開發的socket. 實際專案開始的時候使用預設的阻塞模式並未發現異常。程式碼如下
1 public class SocketService 2 { 3 public delegate void TcpEventHandler1(byte[] receivebody, int length); 4 public event TcpEventHandler1 OnGetCS; 5 Socket client = null; 6 IPEndPoint endPoint = null; 7 public SocketService(string ip, int port) 8 { 9 client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 10 //client.Blocking = false;預設是阻塞模式 11 endPoint = new IPEndPoint(IPAddress.Parse(ip), port); 12 IsRcv = true; 13 } 14 15 Thread rthr = null;//非同步執行緒用於接收資料 16 17 /// <summary> 18 /// 表示是否繼續接收資料 19 /// </summary> 20 public bool IsRcv { get; set; } 21 /// <summary> 22 /// 開啟連線 23 /// </summary> 24 /// <returns></returns> 25 public bool Open() 26 { 27 if (client != null && endPoint != null) 28 { 29 try 30 { 31 client.Connect(endPoint); 32 Console.WriteLine("連線成功"); 33 34 //啟動非同步監聽 35 rthr = new Thread(ReceiveMsg); 36 rthr.IsBackground = true; 37 rthr.SetApartmentState(ApartmentState.STA); 38 rthr.Start(); 39 return true; 40 } 41 catch 42 { 43 AbortThread(); 44 Console.WriteLine("連線失敗!"); 45 } 46 } 47 return false; 48 } 49 50 /// <summary> 51 /// 關閉接收資料執行緒 52 /// </summary> 53 private void AbortThread() 54 { 55 if (rthr != null) 56 { 57 rthr.Abort(); 58 } 59 } 60 61 /// <summary> 62 /// 關閉連線 63 /// </summary> 64 public void Close() 65 { 66 if (client.Connected) 67 { 68 client.Close(); 69 } 70 } 71 72 /// <summary> 73 /// 接收資料 74 /// </summary> 75 private void ReceiveMsg() 76 { 77 byte[] arrMsg = new byte[1024 * 1024]; 78 try 79 { 80 while (IsRcv) 81 { 82 int length = client.Receive(arrMsg);//阻塞模式,此次執行緒會停止繼續執行,直到socket核心有資料 83 byte type; 84 if (length > 0) 85 OnGetCS(arrMsg, length); //出發資料接收事件 86 } 87 } 88 catch (Exception ex) 89 { 90 rthr.Abort(); 91 client.Close(); 92 client = null; 93 Console.WriteLine("伺服器斷開連線"); 94 } 95 } 96 }
當客戶執行久後就發現 從伺服器端發過來的資料到處理完成整個環節消耗的時間比較多(比同行慢)。
使用TCP 監聽助手,和客戶端程式在OnGetCS處列印出時間比較分析,發現TCP助手顯示收到的時間會比客戶端程式顯示的快500-800MS左右。
.也就是說伺服器已經吧資料傳送到客戶端TCP緩衝區了,只是客戶端 int length = client.Receive(arrMsg); 並麼有及時獲得相應。
查了很多資料都沒有查到有類似的問題。最後我用C#模擬做了一個TCP服務端與自己的TCP客戶端之間通訊,則完全沒有延遲。
因此只能考慮語言特性的差別了。C#畢竟封裝了很多資訊。這個時候再檢視TCP監聽助手對比伺服器是C的和C#的發現 C伺服器在指令標記位沒有PSH標記位,而C#的則有這個標記位,如下圖(此處C#作為伺服器的有興趣的可以自己去試)
查詢網路上的一段解釋如下
PSH 的作用
TCP 模組什麼時候將資料傳送出去(從傳送緩衝區中取資料),以及 read 函式什麼時候將資料從接收緩衝區讀取都是未知的。
如果使用 PSH 標誌,上面這件事就確認下來了:
- 傳送端
對於傳送方來說,由 TCP 模組自行決定,何時將接收緩衝區中的資料打包成 TCP 報文,並加上 PSH 標誌(在圖 1 中,為了演示,我們假設人為的干涉了 PSH 標誌位)。一般來說,每一次 write,都會將這一次的資料打包成一個或多個 TCP 報文段(如果資料量大於 MSS 的話,就會被打包成多個 TCP 段),並將最後一個 TCP 報文段標記為 PSH。
當然上面說的只是一般的情況,如果傳送緩衝區滿了,TCP 同樣會將傳送緩衝區中的所有資料打包傳送。
- 接收端
如果接收方接收到了某個 TCP 報文段包含了 PSH 標誌,則立即將緩衝區中的所有資料推送給應用程式(read 函式返回)。
當然有時候接收緩衝區滿了,也會推送。
通過這個解釋瞬間總算是明白了,早期C開發的很多TCP通訊,都是不帶PSH標記位的,後來的產品很多都遵守這個模式了,然後我們的C#預設就是使用PSH標記位。 因此就導致了資料接收延遲500-800MS(根據PSH的解釋這個延遲具體多久是未知的)。
解決方案
最簡單的是伺服器端增加這個標記位傳送過來。一番討論後,人家寫這個伺服器的人都已經離職了,沒人會處理。那麼客戶是上帝,只能我們這邊來處理了。這裡就要用到非阻止模式的socket了。
首先我在網上查到很多人說非同步就是非阻止模式。這個完全是錯誤的。非同步同步與阻止模式是沒有關係的兩個概念。 當阻塞模式下有一個執行緒不斷在等待緩衝區把資料交給它處理,非同步的話就是觸發回撥方法,同步的話就繼續執行同步的業務程式碼。
而非阻塞模式的邏輯是,客戶端的連線,讀取資料執行緒都不會被阻塞,也就是會立即返回。比如連線的邏輯是客戶端發起connect連線,因為TCP連線有幾次握手的情況,需要一定的時間,然而非阻塞要求立即返回,這個時候系統會拋一個異常(Win32Excetion)。
我們則只需要在異常裡處理這個TCP連線需要一定時間的問題。可以迴圈讀取TCP連線狀態來確認是否連線成功。client.Poll 方法來查詢當前連線狀態。同理讀取的時候也是在該異常裡迴圈讀取。
1 public class SocketService 2 { 3 public delegate void TcpEventHandler1(byte[] receivebody, int length); 4 public event TcpEventHandler1 OnGetCS; 5 Socket client = null; 6 IPEndPoint endPoint = null; 7 public SocketService(string ip, int port) 8 { 9 client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); 10 client.Blocking = false;//非阻塞模式,定時迴圈讀取緩衝區的資料把它拼接到緩衝區資料佇列 arrMsg 11 endPoint = new IPEndPoint(IPAddress.Parse(ip), port); 12 } 13 14 Thread rthr = null; 15 /// <summary> 16 /// 表示是否繼續接收資料 17 /// </summary> 18 public bool IsRcv { get; set; } 19 /// <summary> 20 /// 非阻塞模式 21 /// </summary> 22 /// <param name="timeout"></param> 23 /// <returns></returns> 24 public bool Open(int timeout = 1000) 25 { 26 bool connected = false; 27 if (client != null && endPoint != null) 28 { 29 try 30 { 31 client.Connect(endPoint);//此處不會阻塞,如果是正在連線伺服器的話,則會跑出win32Excetion異常(這裡如果是netcore在linux上的話,怎麼也會丟擲異常,具體異常自行查閱) 32 Console.WriteLine("連線成功"); 33 //啟動非同步監聽 34 connected = true; 35 } 36 catch (Win32Exception ex) 37 { 38 if (ex.ErrorCode == 10035) // WSAEWOULDBLOCK is expected, means connect is in progress 39 { 40 var dt = DateTime.Now; 41 while (true)//迴圈讀取當前連線的狀態,如果timeout時間內還沒連線成功,則反饋連線失敗。 42 { 43 if (dt.AddMilliseconds(timeout) < DateTime.Now) 44 { 45 break; 46 } 47 connected = client.Poll(1000000, SelectMode.SelectWrite);//不會阻塞 48 if (connected) 49 { 50 connected = true; 51 break; 52 } 53 } 54 } 55 } 56 catch (Exception ex) 57 { 58 AbortThread(); 59 Console.WriteLine("連線失敗"); 60 } 61 } 62 if (connected) 63 { 64 StartReceive();//連線成功則啟動資料讀取執行緒 65 } 66 return connected; 67 } 68 69 private void StartReceive() 70 { 71 rthr = new Thread(ReceiveMsgNonBlock); 72 rthr.IsBackground = true; 73 rthr.SetApartmentState(ApartmentState.STA); //設定通訊執行緒通訊執行緒同步設定,才能在開啟接受檔案時 開啟 檔案選擇框 74 rthr.Start(); 75 } 76 77 private void AbortThread() 78 { 79 if (rthr != null) 80 { 81 rthr.Abort(); 82 } 83 } 84 85 public void Close() 86 { 87 if (client.Connected) 88 { 89 client.Close(); 90 } 91 } 92 93 /// <summary> 94 /// app端緩衝池 95 /// </summary> 96 byte[] arrMsg = new byte[1024 * 1024]; 97 /// <summary> 98 /// 當前緩衝池的長度 99 /// </summary> 100 int currentlength = 0; 101 102 /// <summary> 103 /// 讀取TCP緩衝資料 104 /// </summary> 105 private void ReceiveMsgNonBlock() 106 { 107 while (true) 108 { 109 try 110 { 111 byte[] tempBytes = new byte[1024 * 1024]; 112 113 int length = client.Receive(tempBytes);//此處不會阻塞,如果有資料則繼續,如果沒有資料則丟擲Win32Exception異常(linux 下netcore自行查詢異常型別 ) 114 115 DealMsg(tempBytes, length); 116 } 117 catch (Win32Exception ex) 118 { 119 120 if (ex.ErrorCode == 10035) // WSAEWOULDBLOCK is expected, means connect is in progress 121 { 122 Thread.Sleep(50); 123 } 124 125 } 126 catch (Exception ex) 127 { 128 rthr.Abort(); 129 client.Close(); 130 client = null; 131 Console.WriteLine("伺服器斷開連線"); 132 break; 133 } 134 } 135 } 136 137 /// <summary> 138 /// 把當前讀取到的資料新增到app,並且根據自己的TCP約定的規則分析包頭包尾長度校驗等等資訊,來確認在arrMsg中獲取自己想要的資料包最後交給OnGetCS事件 139 /// </summary> 140 /// <param name="bytes"></param> 141 /// <param name="length"></param> 142 public void DealMsg(byte[] bytes, int length) 143 { 144 //先把資料拷貝到 全域性陣列arrMsg 145 if (bytes.Length + this.currentlength > 1024 * 1024) 146 { 147 byte[] arrMsg = new byte[1024 * 1024]; 148 } 149 150 Array.Copy(bytes, 0, arrMsg, this.currentlength, length); 151 this.currentlength += length; 152 153 154 ///根據自己的包頭包尾的規則來擷取TCP資料包,因為實際執行當中要考慮到服務端傳送特別大的資料包,以及伺服器太忙的時候分段傳送資料包的情況。因此不能盲目的以為讀取的緩衝區的資料就是一個完成的資料包。 155 ///最終生成tmpMsg。 156 var tmpMsg = new byte[1000]; 157 OnGetCS(tmpMsg, tmpMsg.Length); 158 } 159 }
經過測試,通過迴圈主動去讀取緩衝帶完美的解決了客戶端緩慢的問題,實際執行的時候讀取緩衝區的時間間隔可以根據需求自行更改,本例中用了50ms。