實作非同步模式的 TCP 用戶端

2 comments
TcpClient 類別是以 Socket 類別為基礎所建立的抽象層 TCP 服務,它提供簡單的方法以連接、傳送和接收網路間的資料,用來簡化 TCP 用戶端應用程式的開發。TcpClient 提供同步與非同步兩種通訊模式,本文將會著重探討如何使用非同步程式撰寫模型來處理網路服務要求。

同步模式
在一般的情況下我們都使用同步的封鎖模式來建立 Socket 應用程式,因為這種方式最簡單也最直接。以下範例將會以同步作業的方式發送 HTTP 請求:
using (TcpClient client = new TcpClient("www.msn.com", 80))
{
using (NetworkStream stream = client.GetStream())
{
byte[] send = Encoding.UTF8.GetBytes("GET HTTP/1.0 \r\n\r\n");
stream.Write(send, 0, send.Length);
byte[] bytes = new byte[client.ReceiveBufferSize];
int bytesRead = stream.Read(bytes, 0, (int)client.ReceiveBufferSize);
String data = Encoding.UTF8.GetString(bytes);
char[] unused = { (char)data[bytesRead] };
Console.WriteLine(data.TrimEnd(unused));
}
}

在上例中,我們使用 TcpClient 的建構式進行同步的連接嘗試,並透過 Stream 通訊端同步收發網路資料。在這段時間內,執行同步要求的執行緒會因為等候網路作業完成而無法再執行其他工作。如果該執行緒是 UI 執行緒,那麼應用程式就被封鎖並停止回應使用者的輸入。雖然使用同步封鎖的 Socket 從觀念上來說比較容易使用,然而如果你的應用程式需要保持快速回應、提升延展性及可靠性,那麼就不應該使用同步封鎖的作業方式。

非同步模式
非同步用戶端通訊端使用標準 .NET Framework 非同步程式撰寫模型,讓你的應用程式不會因為等候同步作業完成的執行緒而被封鎖。因為非同步作業(Asynchronous Operation)會在不同的執行緒上(在背景中)執行,所以應用程式可以在呼叫非同步方法(BeginOperationName)的執行緒上繼續執行指令。例如,在 Windows 應用程式中,非同步執行作業將會委派給背景執行緒,而使用者介面執行緒(在前景中)在作業執行時仍可以保持回應狀態。


接下來,我們將以 Windows Form 應用程式來逐步建構非同步傳輸的 HTTP 用戶端程式。首先,必須先建立用來在非同步呼叫間傳送狀態資訊的狀態物件。然後,呼叫 TcpClient 物件的 BeginConnect 方法開始遠端主機連接的非同步要求。
TcpClient client = new TcpClient();
StateObject state = new StateObject(client);
state.Data.AppendFormat("GET {0} HTTP/1.0\r\n", url);
state.Data.AppendFormat("Host:{0}\r\n\r\n", hostName);

client.BeginConnect(hostName, 0,
new AsyncCallback(EndConnectCallback), state);

當完成非同步連線要求時,系統會回呼實作 AsyncCallback 委派的 EndConnectCallback 方法。在這個方法中,我們必須呼叫 EndConnect 方法來結束擱置的非同步連接要求。下列程式碼實作了 EndConnectCallback 方法:
private void EndConnectCallback(IAsyncResult ar)
{
StateObject state = (StateObject)ar.AsyncState;
TcpClient client = state.Client;

try
{
client.EndConnect(ar);

if (client.Connected)
{
NetworkStream stream = client.GetStream();
if (stream.CanWrite)
{
byte[] send = Encoding.UTF8.GetBytes(state.Data.ToString());
stream.BeginWrite(send, 0, send.Length,
new AsyncCallback(EndWriteCallback), state);
}
}
else
{
DisplayStatus(string.Format("Ready (last error: {0})", "Connect Failed!"));
}
}
catch (Exception ex)
{
client.Close();
DisplayStatus(string.Format("Ready (last error: {0})", ex.Message));
}
}

在用戶端通訊端讀寫資料時,我們需要定義一個能儲存非同步作業相關資訊的狀態物件。
internal class StateObject
{
public readonly TcpClient Client;
public readonly byte[] Buffer;
public readonly StringBuilder Data;
public readonly int BufferSize = 1024;

public StateObject(TcpClient client)
{
this.Client = client;
this.Data = new StringBuilder();
this.Buffer = new byte[this.BufferSize];
}
}

當成功連接到遠端主機後,接著會透過 Stream 通訊端的 BeginWrite 方法以非同步方式將 HTTP 請求字串寫入網路資料流,並於作業完成後回呼實作 AsyncCallback 委派的 EndWriteCallback 方法,這個方法會在網路資料流做好接收準備時接收遠端主機回應的資料。
private void EndWriteCallback(IAsyncResult ar)
{
StateObject state = (StateObject)ar.AsyncState;
TcpClient client = state.Client;

try
{
NetworkStream stream = client.GetStream();
stream.EndWrite(ar);

state.Data.Length = 0;
if (stream.CanRead)
{
stream.BeginRead(state.Buffer, 0, state.BufferSize,
new AsyncCallback(EndReadCallback), state);
}
}
catch (Exception ex)
{
client.Close();
DisplayStatus(string.Format("Ready (last error: {0})", ex.Message));
}
}

接著會呼叫 Stream 通訊端的 BeginRead 方法,以非同步方式從用戶端通訊端讀取資料,並於作業完成後回呼實作 AsyncCallback 委派的 EndReadCallback 方法,將遠端主機回應的資料讀入資料緩衝區並建立訊息字串。然後再次呼叫 BeginRead 方法,直到用戶端接收資料完畢。
private void EndReadCallback(IAsyncResult ar)
{
StateObject state = (StateObject)ar.AsyncState;
TcpClient client = state.Client;
NetworkStream stream = client.GetStream();

int bytesRead = stream.EndRead(ar);

if (bytesRead > 0)
{
state.Data.Append(Encoding.UTF8.GetString(state.Buffer, 0, bytesRead));
stream.BeginRead(state.Buffer, 0, state.BufferSize,
new AsyncCallback(EndReadCallback), state);
}
else
{
client.Close();
DisplayResults(state.Data.ToString());
}
}

最後,呼叫 DisplayResults 方法將非同步讀取作業所建立訊息字串顯示在 TextBox 控制項。
public void DisplayResults(string text)
{
if (InvokeRequired)
{
BeginInvoke(new Action<string>(DisplayResults), text);
return;
}

int hdrLen = 0;
if (-1 != (hdrLen = text.IndexOf("\r\n\r\n")))
{
uxHeader.Text = text.Substring(0, hdrLen);
char[] unused = { '\r', '\n', '\r', '\n' };
uxContent.Text = text.Substring(hdrLen).TrimStart(unused);
}
else
{
uxHeader.Text = string.Empty;
uxContent.Text = text;
}
}

因為 Windows Form 控制項的存取並沒有保證執行緒安全(Thread-safe),也就是說在當一個以上的執行緒同時存取共用資源時,就會發生競爭情形而導致狀態不一致或是死結的情況。所以,在多執行緒的表單中必須以安全執行緒的方式來存取控制項。因此,在 DisplayResults 方法中使用表單的 InvokeRequired 屬性來判斷呼叫端是否來自於建立控制項之執行緒以外的執行緒。如果是跨執行緒呼叫(Cross-Thread),則必須使用 Invoke 或是 BeginInvoke 方法來封送處理(Marshaling)至控制項的執行緒。當你在 Visual Studio 偵錯工具中執行應用程式,若有不安全的執行緒嘗試存取控制項時,偵錯工具會引發 InvalidOperationException 例外狀況:「跨執行緒作業無效: 存取控制項 control name 時所使用的執行緒與建立控制項的執行緒不同」。這也意味只有在偵錯期間才有可能發現這個例外狀況,如果應用程式完成建置與佈署,那麼即使有發生不安全的存取情況也不會引發錯誤,建議你在發現問題時加以修正。



Download sample code

參考資料:
Using an Asynchronous Client Socket
Asynchronous Client Socket Example
Asynchronous operations, pinning
以非同步的方式呼叫同步方法
使用非同步用戶端通訊端

繼續閱讀...