前言
之前一直很少接觸多線程這塊。這次項目中剛好用到了網絡編程TCP這塊,做一個服務端,需要使用到多線程,所以記錄下過程。希望可以幫到自己的同時能給別人帶來一點點收獲~
關於TCP的介紹就不多講,神馬經典的三次握手、四次握手,可以參考下面幾篇博客學習了解:
效果預覽
客戶端是一個門禁設備,主要是向服務端發送實時數據(200ms)。服務端解析出進出人數並打印顯示。

實現步驟
因為主要是在服務器上監聽各設備的連接請求以及回應並打印出入人數,所以界面我設計成這樣:

可以在窗體事件中綁定本地IP,代碼如下:
//獲取本地的IP地址 string AddressIP = string.Empty; foreach (IPAddress _IPAddress in Dns.GetHostEntry(Dns.GetHostName()).AddressList) { if (_IPAddress.AddressFamily.ToString() == "InterNetwork") { AddressIP = _IPAddress.ToString(); } } //給IP控件賦值 txtIp.Text = AddressIP;
首先我們需要定義幾個全局變量
Thread threadWatch = null; // 負責監聽客戶端連接請求的 線程; Socket socketWatch = null;
Dictionary<string, Socket> dict = new Dictionary<string, Socket>();//存放套接字
Dictionary<string, Thread> dictThread = new Dictionary<string, Thread>();//存放線程
然后可以開始我們的點擊事件啟動服務啦

首先我們創建負責監聽的套接字,用到了 System.Net.Socket 下的尋址方案AddressFamily ,然后后面跟套接字類型,最后是支持的協議。
在Bind綁定后,我們創建了負責監聽的線程。代碼如下:
// 創建負責監聽的套接字,注意其中的參數; socketWatch = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); // 獲得文本框中的IP對象; IPAddress address = IPAddress.Parse(txtIp.Text.Trim()); // 創建包含ip和端口號的網絡節點對象; IPEndPoint endPoint = new IPEndPoint(address, int.Parse(txtPort.Text.Trim())); try { // 將負責監聽的套接字綁定到唯一的ip和端口上; socketWatch.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); socketWatch.Bind(endPoint); } catch (SocketException se) { MessageBox.Show("異常:" + se.Message); return; } // 設置監聽隊列的長度; socketWatch.Listen(10000); // 創建負責監聽的線程; threadWatch = new Thread(WatchConnecting); threadWatch.IsBackground = true; threadWatch.Start(); ShowMsg("服務器啟動監聽成功!");
其中 WatchConnecting方法是負責監聽新客戶端請求的

相信圖片中注釋已經很詳細了,主要是監聽到有客戶端的連接請求后,開辟一個新線程用來接收客戶端發來的數據,有一點比較重要就是在Start方法中傳遞了當前socket對象
/// <summary> /// 監聽客戶端請求的方法; /// </summary> void WatchConnecting() { ShowMsg("新客戶端連接成功!"); while (true) // 持續不斷的監聽客戶端的連接請求; { // 開始監聽客戶端連接請求,Accept方法會阻斷當前的線程; Socket sokConnection = socketWatch.Accept(); // 一旦監聽到一個客戶端的請求,就返回一個與該客戶端通信的 套接字; var ssss = sokConnection.RemoteEndPoint.ToString().Split(':'); //查找ListBox集合中是否包含此IP開頭的項,找到為0,找不到為-1 if (lbOnline.FindString(ssss[0]) >= 0) { lbOnline.Items.Remove(sokConnection.RemoteEndPoint.ToString()); } else { lbOnline.Items.Add(sokConnection.RemoteEndPoint.ToString()); } // 將與客戶端連接的 套接字 對象添加到集合中; dict.Add(sokConnection.RemoteEndPoint.ToString(), sokConnection); Thread thr = new Thread(RecMsg); thr.IsBackground = true; thr.Start(sokConnection); dictThread.Add(sokConnection.RemoteEndPoint.ToString(), thr); // 將新建的線程 添加 到線程的集合中去。 } }
其中接收數據 RecMsg方法如下:

解釋如圖,一目了然,代碼如下
void RecMsg(object sokConnectionparn) { Socket sokClient = sokConnectionparn as Socket; while (true) { // 定義一個緩存區; byte[] arrMsgRec = new byte[1024]; // 將接受到的數據存入到輸入 arrMsgRec中; int length = -1; try { length = sokClient.Receive(arrMsgRec); // 接收數據,並返回數據的長度; if (length > 0) { //主業務 } else { // 從 通信套接字 集合中刪除被中斷連接的通信套接字; dict.Remove(sokClient.RemoteEndPoint.ToString()); // 從通信線程集合中刪除被中斷連接的通信線程對象; dictThread.Remove(sokClient.RemoteEndPoint.ToString()); // 從列表中移除被中斷的連接IP lbOnline.Items.Remove(sokClient.RemoteEndPoint.ToString()); ShowMsg("" + sokClient.RemoteEndPoint.ToString() + "斷開連接\r\n"); //log.log("遇見異常"+se.Message); break; } } catch (SocketException se) { // 從 通信套接字 集合中刪除被中斷連接的通信套接字; dict.Remove(sokClient.RemoteEndPoint.ToString()); // 從通信線程集合中刪除被中斷連接的通信線程對象; dictThread.Remove(sokClient.RemoteEndPoint.ToString()); // 從列表中移除被中斷的連接IP lbOnline.Items.Remove(sokClient.RemoteEndPoint.ToString()); ShowMsg("" + sokClient.RemoteEndPoint.ToString() + "斷開,異常消息:" + se.Message + "\r\n"); //log.log("遇見異常"+se.Message); break; } catch (Exception e) { // 從 通信套接字 集合中刪除被中斷連接的通信套接字; dict.Remove(sokClient.RemoteEndPoint.ToString()); // 從通信線程集合中刪除被中斷連接的通信線程對象; dictThread.Remove(sokClient.RemoteEndPoint.ToString()); // 從列表中移除被中斷的連接IP lbOnline.Items.Remove(sokClient.RemoteEndPoint.ToString()); ShowMsg("異常消息:" + e.Message + "\r\n"); // log.log("遇見異常" + e.Message); break; } } }
其中那個ShowMsg方法主要是在窗體中打印當前接收情況和一些異常情況,方法如下:
void ShowMsg(string str) { if (!BPS_Help.ChangeByte(txtMsg.Text, 2000)) { txtMsg.Text = ""; txtMsg.AppendText(str + "\r\n"); } else { txtMsg.AppendText(str + "\r\n"); } }
其中用到了一個方法判斷ChangeByte ,如果文本長度超過2000個字節,就清空再重新賦值。具體實現如下:
/// <summary> /// 判斷文本框混合輸入長度 /// </summary> /// <param name="str">要判斷的字符串</param> /// <param name="i">長度</param> /// <returns></returns> public static bool ChangeByte(string str, int i) { byte[] b = Encoding.Default.GetBytes(str); int m = b.Length; if (m < i) { return true; } else { return false; } }
心得體會:其實整個流程並不復雜,但我遇到一個問題是,客戶端每200毫秒發一次連接過來后,服務端會報一個遠程主機已經強制關閉連接,開始我以為是我這邊服務器線程間的問題或者是阻塞神馬的,后來和客戶端聯調才發現問題,原來是服務器回應客戶端心跳包的長度有問題,服務端定義的是1024字節,但是客戶端只接受32字節的心跳包回應才會正確解析~所以,對接協議要溝通清楚,溝通清楚,溝通清楚,重要的事情說說三遍
還有幾個點值得注意
1,有時候會遇到窗體間的控件訪問異常,需要這樣處理

Control.CheckForIllegalCrossThreadCalls = false;
2 多線程調試比較麻煩,可以采用打印日志的方式,例如:

具體實現可以參考我的另一篇博客:點我跳轉
3 ,接收解析客戶端數據的時候,要注意大小端的問題,比如下面這個第9位和第8位如果解出來和實際不相符,可以把兩邊顛倒一下。
public int Get_ch2In(byte[] data) { var ch2In = (data[9] << 8) | data[8]; return ch2In; }
4 在接收到客戶端數據的時候,有些地方要注意轉換成十六進制再看結果是否正確
public int Get_ch3In(byte[] data) { int ch3In = 0; for (int i = 12; i < 14; i++) { ch3In = int.Parse(ch3In + BPS_Help.HexOf(data[i])); } return ch3In; }
上面這個方法在對data[i]進行了十六進制的轉換,轉換方法如下:
/// <summary> /// 轉換成十六進制數 /// </summary> /// <param name="AscNum"></param> /// <returns></returns> public static string HexOf(int AscNum) { string TStr; if (AscNum > 255) { AscNum = AscNum % 256; } TStr = AscNum.ToString("X"); if (TStr.Length == 1) { TStr = "0" + TStr; } return TStr; }
5 還有個可以了解的是將數組轉換成結構,參考代碼如下:
/// <summary> /// Byte數組轉結構體 /// </summary> /// <param name="bytes">byte數組</param> /// <param name="type">結構體類型</param> /// <returns>轉換后的結構體</returns> public static object BytesToStuct(byte[] bytes, Type type) { //得到結構體的大小 int size = Marshal.SizeOf(type); //byte數組長度小於結構體的大小 if (size > bytes.Length) { return null; } IntPtr structPtr = Marshal.AllocHGlobal(size); Marshal.Copy(bytes, 0, structPtr, size); object obj = Marshal.PtrToStructure(structPtr, type); //釋放內存空間 Marshal.FreeHGlobal(structPtr); return obj; }
調用方法如下,注意,此處的package的結構應該和協議中客戶端發送的數據結構一致才能轉換

如協議中是這樣的定義的話:

那在代碼中就可以這樣定義一個package結構體
/// <summary> /// 數據包結構體 /// </summary> [StructLayoutAttribute(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 1)] public struct Package { /// <summary> /// 確定為命令包的標識 /// </summary> public int commandFlag; /// <summary> /// 命令 /// </summary> public int command; /// <summary> ///數據長度(數據段不包括包頭) /// </summary> public int dataLength; /// <summary> /// 通道編號 /// </summary> public short channelNo; /// <summary> /// 塊編號 /// </summary> public short blockNo; /// <summary> /// 開始標記 /// </summary> public int startFlag; /// <summary> /// 結束標記0x0D0A為結束符 /// </summary> public int finishFlag; /// <summary> /// 校驗碼 /// </summary> public int checksum; /// <summary> /// 保留 char數組,SizeConst表示數組個數,在轉成 /// byte數組前必須先初始化數組,再使用,初始化 /// 的數組長度必須和SizeConst一致,例:test=new char[4]; /// </summary> [MarshalAs(UnmanagedType.ByValArray, SizeConst = 4)] public char[] reserve; }
Demo下載
TCP多線程服務器及客戶端Demo
點我跳去下載 密碼:3hzs
git一下:我要去Git
收發實體對象
2017.3.11 補充
如果服務器和客戶端公用一個實體類,那還好說,如果服務器和客戶端分別使用結構相同但不是同一個項目下的實體類,該如何用正確的姿勢收發呢?
首先簡單看看效果如下:

具體實現:
因為前面提到不在同一項目下,如果直接序列化和反序列化,就會反序列化失敗,因為不能對不是同一命名空間下的類進行此類操作,那么解決辦法可以新建一個類庫Studnet,然后重新生成dll,在服務器和客戶端分別引用此dll,就可以對此dll進行序列化和反序列化操作了。
項目結構如下圖(這里是作為演示,將客戶端和服務器放在同一解決方案下,實際上這種情況解決的就是客戶端和服務器是兩個單獨的解決方案)

客戶端發送核心代碼:
void showClient() { address = IPAddress.Parse("127.0.0.1"); endpoint = new IPEndPoint(address, 5000); socketClient = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); try { socketClient.Connect(endpoint); Console.WriteLine("連接服務端成功\r\n准備發送實體Student"); Student.Studnet_entity ms = new Student.Studnet_entity() { ID = 1, Name = "張三", Phone = "13237157517", sex = 1, Now_Time = DateTime.Now }; using (MemoryStream memory = new MemoryStream()) { BinaryFormatter formatter = new BinaryFormatter(); formatter.Serialize(memory, ms); Console.WriteLine("發送長度:" + memory.ToArray().Length); socketClient.Send(memory.ToArray()); Console.WriteLine("我發送了 學生實體對象\r\n"); } } catch (Exception) { throw; } }
服務端接收並解析實體對象核心代碼
/// <summary> /// 服務端負責監聽客戶端發來的數據方法 /// </summary> void RecMsg(object socketClientPara) { byte[] arrMsgRec = new byte[1024];//手動准備空間 Socket socketClient = socketClientPara as Socket; List<byte> listbyte = new List<byte>(); while (true) { //將接受到的數據存入arrMsgRec數組,並返回真正接收到的數據長度 int length = socketClient.Receive(arrMsgRec); if (length > arrMsgRec.Length) { listbyte.AddRange(arrMsgRec); } else { for (int i = 0; i < length; i++) listbyte.Add(arrMsgRec[i]); break; } } //創建內存流 using (MemoryStream m = new MemoryStream(listbyte.ToArray())) { //創建以二進制格式對對象進行序列化和反序列化 BinaryFormatter bf = new BinaryFormatter(); Console.WriteLine("m.length" + m.ToArray().Length); //反序列化 object dataObj = bf.Deserialize(m); //得到解析后的實體對象 Student.Studnet_entity dt = dataObj as Studnet_entity; Console.WriteLine("接收客戶端長度:" + listbyte.Count + " 反序列化結果是:ID:" + dt.ID + " 姓名:" + dt.Name + " 當前時間:" + dt.Now_Time); } }
收發實體對象Demo
點我前去下載Demo 密碼:x2ke
