所讀版本:機械工業出版社
這一篇在做什麼
上一篇先用同步方式把 Echo 跑通,但同步 Socket 有一個非常明顯的問題:很多操作都是阻塞的。這一篇往前走三步:
- 先看異步客戶端
- 再看異步服務端
- 最後整理
Poll與Select兩種狀態檢測 / 多路複用思路
為什麼要離開同步模型
同步方式很好理解,但 Connect、Accept、Receive、Send 這些方法一旦阻塞,程式主流程就會停在原地。
對 Unity 專案來說,這通常意味著:
- 畫面更新可能被卡住
- 輸入回應不即時
- 同時處理多個客戶端會變得很笨重
所以接下來的目標,就是把這些等待 I/O 的動作改成非阻塞的模式。
異步客戶端
異步連接
建立連接時可以改用:
IAsyncResult BeginConnect(string host, int port, AsyncCallback
requestCallback, object state)
void EndConnect(IAsyncResult asyncResult)
其中:
AsyncCallback requestCallback是連接完成後要呼叫的回調函數object state會原樣傳回給回調函數
實際做法是:
- 呼叫
BeginConnect - 在回調函數中呼叫
EndConnect - 連接完成後再進一步做接收或發送
public void Connect()
{
socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
socket.BeginConnect("127.0.0.1", 8888, ConnectCallback, socket);
}
public void ConnectCallback(IAsyncResult result)
{
try
{
Socket socket = (Socket)result.AsyncState;
socket.EndConnect(result);
}
catch (SocketException ex)
{
Debug.Log("Socket Connect Failed: " + ex.ToString());
}
}
異步接收
接收數據可以改用:
IAsyncResult BeginReceive(byte[] buffer, int offset, int size,
SocketFlags socketFlags, AsyncCallback callback, object state)
int EndReceive(IAsyncResult asyncResult)
幾個核心參數:
buffer:接收緩衝區offset:從哪個位置開始寫入size:最多接收多少字節
連接成功後就可以開始第一次異步接收,之後每次回調完成,再重新掛下一次接收。
public void ConnectCallback(IAsyncResult result)
{
try
{
//...
//確認連接後開始接收數據
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
}
catch(SocketException ex)
{
// ...
}
}
public void ReceiveCallback(IAsyncResult result)
{
try
{
Socket socket = (Socket)result.AsyncState;
int count = socket.EndReceive(result);
recvStr = System.Text.Encoding.UTF8.GetString(readBuff, 0, count);
Debug.Log("Receive Success");
//結束接受一次後,再開始準備接收下一串的數據
socket.BeginReceive(readBuff, 0, 1024, 0, ReceiveCallback, socket);
}
catch (SocketException ex)
{
Debug.Log("Socket Receive Failed: " + ex.ToString());
}
}
這種寫法的重點是:每次接收完成後,都要自己把下一次接收重新掛上去。
異步發送
先理解同步 Send 為什麼也可能卡
Send 雖然看起來只是把資料送出去,但對 TCP 來說,它背後牽涉的是可靠傳輸。
也就是說:
- 發送方把數據寫進發送緩衝區
- 作業系統負責真正發送
- 如果對方還沒確認收到,系統可能重傳
每個 Socket 在 OS 內部都有自己的發送緩衝區。當緩衝區滿了之後,同步 Send 就會阻塞。

因此,Send 返回只代表「資料成功寫入本地發送緩衝區」,不是代表遠端一定已經處理完畢。
用 BeginSend 改成異步
異步發送的接口如下:
IAsyncResult BeginSend(byte[] buffer, int offset, int size,
SocketFlags socketFlags, AsyncCallback callback, object state)
int EndSend(IAsyncResult asyncResult)
public void Send()
{
//Send Req 異步
string sendStr = reqField.text;
byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(sendStr);
socket.BeginSend(sendBytes, 0, sendBytes.Length, 0, SendCallback, socket);
}
public void SendCallback(IAsyncResult result)
{
try
{
Socket socket = (Socket)result.AsyncState;
int count = socket.EndSend(result);
Debug.Log("Socket Send Success: " + count);
}
catch(SocketException ex)
{
Debug.Log("Socket Send Failed: " + ex.ToString());
}
}
異步服務端
客戶端改成異步之後,服務端也要跟上。最基本的是把 Accept 與 Receive 改成非阻塞形式。
異步接受連接
接口如下:
IAsyncResult BeginAccept(AsyncCallback callback, object state)
Socket EndAccept(IAsyncResult asyncResult)
static void Main(string[] args)
{
//Create Socket
listenSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
//Bind
IPAddress ipAdr = IPAddress.Parse("192.168.1.5");
IPEndPoint ipEp = new IPEndPoint(ipAdr, 8888); //綁定IP和端口
listenSocket.Bind(ipEp);
//Listen
listenSocket.Listen(0);
Console.WriteLine("Server Activated");
//Async Accept
listenSocket.BeginAccept(AcceptCallback, listenSocket);
Console.ReadLine();
}
public static void AcceptCallback(IAsyncResult result)
{
try
{
Console.WriteLine("Server Accepted");
Socket listenfd = (Socket)result.AsyncState;
//為連接分配一個ClientState
Socket clientfd = listenfd.EndAccept(result); //返回連接到服務端的客戶端Socket
ClientState state = new ClientState();
state.socket = clientfd;
clients.Add(clientfd, state);
//異步接收客戶端數據
clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
//重新開始接收連接
listenfd.BeginAccept(AcceptCallback, listenfd);
}
catch(SocketException ex)
{
Console.WriteLine("Socket Accept Failed: " + ex.ToString());
}
}
服務端這裡最容易漏的一點,是在 EndAccept 之後要再次呼叫 BeginAccept,否則只會接第一個客戶端。
異步接收客戶端數據
接收端和客戶端一樣,都是 BeginReceive / EndReceive 的組合。
public static void AcceptCallback(IAsyncResult result)
{
try
{
//...
//異步接收客戶端數據
clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
//...
}
//...
}
public static void ReceiveCallback(IAsyncResult result)
{
try
{
ClientState state = (ClientState)result.AsyncState;
Socket clientfd = state.socket;
int count = clientfd.EndReceive(result); //數據接收完畢,返回值<=0代表Socket連接斷開
if(count == 0) //客戶端關閉
{
clientfd.Close();
clients.Remove(clientfd);
Console.WriteLine("Socket Closed");
return;
}
//處理數據
string recvStr = System.Text.Encoding.UTF8.GetString(state.readBuff, 0, count);
byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(recvStr);
clientfd.Send(sendBytes);
//重新開始接收數據
clientfd.BeginReceive(state.readBuff, 0, 1024, 0, ReceiveCallback, state);
}
catch(SocketException ex)
{
Console.WriteLine("Socket Receive Failed: " + ex.ToString());
}
}
這段邏輯裡有兩個常見判斷:
count == 0:對端已關閉連接- 正常收到資料後,要再次掛上新的
BeginReceive
狀態檢測:Poll
除了完全依靠異步回調,還有一條路線是主動查詢 Socket 狀態。Poll 就是最基本的做法之一。
public bool Pool(int microSeconds, SelectMode mode)
參數含義:
microSeconds:等待時間,單位是微秒mode:檢查的狀態類型
SelectMode 常見三種:
SelectRead:可讀SelectWrite:可寫SelectError:出錯
它的本質就是:先檢查 Socket 是否進入某種可操作狀態,再決定要不要去調 Receive 或 Send。
客戶端 Poll
private void Update()
{
if (socket == null) { return; }
//有可讀數據
if(socket.Poll(0, SelectMode.SelectRead))
{
byte[] readBuff = new byte[1024];
int count = socket.Receive(readBuff);
string recvStr = System.Text.Encoding.UTF8.GetString(readBuff, 0, count);
responseText.text = recvStr;
}
}
服務端 Poll
static void Main(string[] args)
{
//Create Socket ...
//Bind ...
//Listen ...
while (true)
{
//檢查有沒有客戶端連接
if(listenSocket.Poll(0, SelectMode.SelectRead))
{
ReadListenfd(listenSocket);
}
foreach (ClientState state in clients.Values)
{
Socket clientfd = state.socket;
//檢查客戶端Socket有沒有可讀的信息
if(clientfd.Poll(0, SelectMode.SelectRead))
{
if (!ReadClientfd(clientfd))
{
break;
}
}
}
System.Threading.Thread.Sleep(1);
}
}
public static void ReadListenfd(Socket listenSocket)
{
Console.WriteLine("Accept");
Socket clientfd = listenSocket.Accept();
ClientState cs = new ClientState();
cs.socket = clientfd;
clients.Add(clientfd, cs);
}
public static bool ReadClientfd(Socket clientSocket)
{
ClientState cs = clients[clientSocket];
//接收數據
int count = 0;
try
{
count = clientSocket.Receive(cs.readBuff);
}
catch(SocketException ex)
{
clientSocket.Close();
clients.Remove(clientSocket);
Console.WriteLine("Receive Socket Exception: " + ex.ToString());
return false;
}
//客戶端關閉
if(count == 0)
{
clientSocket.Close();
clients.Remove(clientSocket);
Console.WriteLine("Socket Closed");
return false;
}
//廣播信息至所有客戶端
string recvStr = System.Text.Encoding.UTF8.GetString(cs.readBuff, 0, count);
Console.WriteLine("Receive: " + recvStr);
string sendStr = clientSocket.RemoteEndPoint.ToString() + ":" + recvStr;
byte[] sendBytes = System.Text.Encoding.UTF8.GetBytes(sendStr);
foreach (ClientState state in clients.Values)
{
state.socket.Send(sendBytes);
}
return true;
}
Poll 的限制
Poll 雖然能避免直接在阻塞方法上傻等,但它仍然需要程式不停輪詢。也就是說,就算沒有數據進來,循環仍然會一直跑,CPU 仍然會被持續消耗。
多路複用:Select
為了解決輪詢浪費,另一個更常見的做法是多路複用。這裡的 Select,就是一次檢查多個 Socket 是否有可讀 / 可寫 / 出錯狀態。
public static void Select(IList checkRead, IList checkWrite, IList checkError, int microSeconds)
做法是:
- 先把想監視的 Socket 放進列表
- 呼叫
Socket.Select Select會根據狀態修改列表內容- 只處理那些真的已經就緒的 Socket
Select 服務端
//Select模式
List<Socket> checkRead = new List<Socket>();
while (true)
{
checkRead.Clear();
checkRead.Add(listenSocket);
foreach (ClientState clientState in clients.Values)
{
checkRead.Add(clientState.socket);
}
Socket.Select(checkRead, null, null, 1000);
foreach (Socket socket in checkRead)
{
if(socket == listenSocket)
{
ReadListenfd(socket);
}
else
{
ReadClientfd(socket);
}
}
}
這裡的關鍵是把監聽 Socket 和所有客戶端 Socket 一起放進 checkRead 裡。當 Select 返回後:
- 如果是
listenSocket可讀,代表有新客戶端連入 - 如果是某個 client socket 可讀,代表它有數據可收
Select 客戶端
private void Update()
{
if (socket == null) { return; }
//Select
checkRead.Clear();
checkRead.Add(socket);
Socket.Select(checkRead, null, null, 0);
foreach (Socket s in checkRead)
{
byte[] readBuff = new byte[1024];
int count = socket.Receive(readBuff);
string recvStr =
System.Text.Encoding.UTF8.GetString(readBuff, 0, count);
responseText.text = recvStr;
}
}
這一篇先記住的重點
這一篇真正重要的不是 API 名稱本身,而是三種處理思路:
- 異步回調:把等待 I/O 的工作交給系統,完成後再回調
Poll:主動輪詢單個 Socket 狀態Select:一次檢查多個 Socket,避免無意義地反覆空轉
如果要簡化成一句話,就是:
同步模型容易寫,但很容易被阻塞;一旦要做真正可用的網絡程序,就得開始處理異步與多個連接的協調問題。