《Unity 3D遊戲客戶端基礎框架》多執行緒非同步 Socket 框架構建
引言:
之前寫過一個 demo 案例大致講解了 Socket 通訊的過程,並和自建的伺服器完成連線和簡單的資料通訊,詳細的內容可以檢視 Unity3D —— Socket通訊(C#)。但是在實際專案應用的過程中,這個 demo 的實現方式顯得異常簡陋,而且對應多個業務同時發起 Socket 通訊請求的處理能力也是有限,總不能每個請求都建立一個執行緒去監聽返回結果,所以有必要進一步優化一番,例如加入執行緒池管理已經用一個佇列來管理同時發起的請求,讓 Socket 請求和接收非同步執行,基本的思路就是引入 多執行緒
和 非同步
這兩個概念。
設計思路:
正如上面提及的,我們優化的思路主要兩點:多執行緒
1.多執行緒:
考慮到併發處理能力,假如是服務端,我們需要對每個連線上來的 Socket 客戶端建立管理執行緒,通常會用執行緒池來管理。而在客戶端,我們可以選擇建立三個核心的執行緒,根據功能分為:
傳送資料執行緒 SenderThread:負責將資料傳送給服務端;
接收資料執行緒 ReceiverThread:負責接收服務端的資料;
其他業務執行緒 MainThread:負責收發資料之外的操作,例如:壓縮資料、加密解密和資料打包解包等。
由於在 .NET 中已經提供了 System.Threading.Thread
這個執行緒的基礎類,所以我們可以通過使用這個類來建立我們自己的執行緒管理類,新增一些附加屬性。
大致步驟:
- 先建立一個
Socket
物件,然後通過對應的 API 去連線伺服器 IP 埠,可以有兩種方式:阻塞式
的Connect
介面;非阻塞式
的BeginConnect
介面; - 連線完成後建立
傳送執行緒
和接收執行緒
,並將套接字 Socket 物件傳入它們的操作方法中; - 傳送執行緒的傳送方法是被呼叫才觸發的,接收執行緒則需要一個輪休等待網路資料的方法。
2.非同步 Socket:
在這一點上,主要做法就是使用佇列的概念,即不管要傳送訊息還是收到訊息,都先把訊息放到佇列(傳送佇列、接收佇列)中,然後依次從佇列中中取出訊息來執行。
佇列:在 .NET 中,提供了一個 Queue
object
的物件或資料,所以自然也能存放我們的訊息。
- 傳送佇列:
當我們要傳送一條網路協議的時候,不是直接將訊息丟給丟給 Socket 通道進行傳輸,而是先通過Queue.Enqueue
介面壓入佇列,然後在一個迴圈執行的邏輯(輪詢)中不斷地從佇列中通過Queue.Dequeue
介面取出佇列中的訊息然後再傳遞給 Socket 進行真實的網路傳輸。 - 接收佇列:
接收網路資料也是相似的過程,從緩衝區取到位元組流資料,解析成功後,先存在接收佇列中,然後再迴圈遍歷操作中從佇列裡取出訊息進行處理。
當然,為了解決佇列在多執行緒中的併發問題,還需要了解一下 lock
方法,這是解決問題的方法之一,但不是唯一辦法,這裡我的原則是在不嚴重影響效率的情況下怎麼簡單怎麼來,使用大致如下:
//執行緒安全的訪問佇列的方式
lock (mQueue)
{
...
}
3.記憶體流快取池:
在做訊息資料的讀寫和格式轉換操作時,都會用到 MemoryStream
,這是一個流結構,其後備儲存是記憶體,也就是每次建立一個這樣的物件都會佔用一定的記憶體空間,假如每一次操作都建立一個 MemoryStream
物件,用完又不做主動回收,顯然效率較低。
記憶體流池化思想:我們可以在池中使用一個
List<MemoryStream>
列表來儲存已建立的流物件,再使用Dictionary<MemoryStream,bool>
字典來儲存每一個物件是否可用的狀態,每次需要建立流物件的步驟:- 通過判斷列表中 MemoryStream 個數是否大於0,假如大於0則取出第一個,並刪除列表中第一個 item (防止重複引用),在字典中記錄此物件為不可用狀態;
- 假如列表中已經沒有可用的 MemoryStream,則建立新的 MemoryStream 物件並在字典中記錄狀態,省去了刪除列表 item 的步驟;
- 使用完畢後,將 MemoryStream 物件的屬性重置,並放回管理列表,字典中該物件的狀態置為可用狀態。
多個記憶體池:為了避免多執行緒的併發問題,所以一般一個記憶體池只允許一個執行緒來訪問,根據上述我們建立三個核心執行緒的思想,所以這裡我們也要對應地建立三個池,分別為:傳送執行緒使用 MemoryStream 池、接收執行緒使用 MemoryStream 池 和 業務執行緒執行緒使用 MemoryStream 池。
所以最終每個池需要提供至少兩個介面:
New
用於從池中獲取一個可用的 MemoryStream 物件;Delete
用於將一個使用完後的 MemoryStream 物件回收到池中。
4.收發包執行緒管理類:
這個類主要完成連線的建立和斷開的管理,以及在連線成功後建立讀寫包的執行緒,並將網路讀寫操作的物件傳遞給收發包的執行緒以便後續網路資料包的接收和傳送操作:
建立連線:
在 .Net 中提供了兩種建立 Socket 連線的方式(用於客戶端):- 使用
System.Net.Sockets.TcpClient
建立一個TcpClient
物件,然後使用TcpClient.BeginConnect
介面去連線指定 IP 埠,使用這種方式建立連線後; - 使用
System.Net.Sockets.Socket
建立一個Socket
物件,然後通過Socket.Connect
去連線指定 IP 埠。
- 使用
資料收發:
TcpClient
通過TcpClient.GetStream()
介面得到一個NetworkStream
物件,然後分別使用NetworkStream.Write
和NetworkStream.Read
完成網路資料的傳送和接收;Socket
連線完成後,直接使用最開始建立的 Socket 套接字物件呼叫Socket.Send
和Scoket.Receive
來完成網路資料的傳送和接收。
斷開連線:
TcpClient
需要執行兩步操作:一是關閉NetworkStream
,二是關閉TcpClient
,都是呼叫對應的關閉方法:TcpClient.GetStream().Close()
/TcpClient.Close()
;Socket
直接關閉即可Socket.Close()
。
5.呼叫非同步的 API:
出於靈活性的考慮,我還是選擇使用 Socket
來收發資料而不使用封裝好的 TcpClient
中獲取的 NetworkStream
物件來實現,主要考慮兩點:
- 其一,我打算使用2個位元組的
ushort
型別資料來表示資料包體的大小,將每次的資料包控制在 65535 byte 之內; - 其二,傳輸資料型別沒有限制。
6.NetworkStream.Write 和 Socket.Send 對比:
這兩個都是 TCP 通訊中傳送資料的介面,但是,NetworkStream
在使用 Write(byte[] buffer, int offset, int size)
傳輸資料時,會在資料的頭部加上一個 int
型別的資料,即傳入的 size
引數,用於表示 buffer
的大小,但假如定製網路協議的時候規定只允許在頭部使用2個位元組表示資料的長度,即一個 short
型別,那此時 NetworkStream
就無法滿足需求了,只能使用 Socket
來完成。
參考 官方介紹,C# 中的
ushort
取值範圍是0 ~ 65535
,即2個位元組表示的包頭,一次最大傳輸資料大小是 65535 byte 大概看為是 64kb。
7.非同步收發介面:
- 傳送:
使用Socket.BeginSend
和Socket.EnSend
這對 API 來實現非同步的資料接收操作; - 接收:
使用Socket.BeginReceive
和``Socket.EnReceive
這對 API 來實現非同步的資料接收操作。
案例:
網上也有使用類似的思路對 C# 的 Socket 進行封裝的,這裡簡單列舉幾個: