所讀版本:機械工業出版社

這一篇在做什麼

上一篇先用同步方式把 Echo 跑通,但同步 Socket 有一個非常明顯的問題:很多操作都是阻塞的。這一篇往前走三步:

  • 先看異步客戶端
  • 再看異步服務端
  • 最後整理 PollSelect 兩種狀態檢測 / 多路複用思路

為什麼要離開同步模型

同步方式很好理解,但 ConnectAcceptReceiveSend 這些方法一旦阻塞,程式主流程就會停在原地。

對 Unity 專案來說,這通常意味著:

  • 畫面更新可能被卡住
  • 輸入回應不即時
  • 同時處理多個客戶端會變得很笨重

所以接下來的目標,就是把這些等待 I/O 的動作改成非阻塞的模式。

異步客戶端

異步連接

建立連接時可以改用:

IAsyncResult BeginConnect(string host, int port, AsyncCallback
                          requestCallback, object state)
void EndConnect(IAsyncResult asyncResult)

其中:

  • AsyncCallback requestCallback 是連接完成後要呼叫的回調函數
  • object state 會原樣傳回給回調函數

實際做法是:

  1. 呼叫 BeginConnect
  2. 在回調函數中呼叫 EndConnect
  3. 連接完成後再進一步做接收或發送
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());
    }
}

異步服務端

客戶端改成異步之後,服務端也要跟上。最基本的是把 AcceptReceive 改成非阻塞形式。

異步接受連接

接口如下:

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 是否進入某種可操作狀態,再決定要不要去調 ReceiveSend

客戶端 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)

做法是:

  1. 先把想監視的 Socket 放進列表
  2. 呼叫 Socket.Select
  3. Select 會根據狀態修改列表內容
  4. 只處理那些真的已經就緒的 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,避免無意義地反覆空轉

如果要簡化成一句話,就是:

同步模型容易寫,但很容易被阻塞;一旦要做真正可用的網絡程序,就得開始處理異步與多個連接的協調問題。