C# WinForm 中 Socket 通信:服务端/客户端代码实现讲解
本文承接《Socket通信原理详解》,专注于在 WinForm 项目中如何把原生
SocketAPI 落地成一个可以运行的"服务端 + 客户端"程序,包含完整代码和逐段讲解。
一、WinForm 场景下需要额外处理的两个问题
在动手写代码之前,先明确 WinForm 桌面程序相比控制台程序,多出来的两个"坑":
1.1 阻塞方法不能直接放在 UI 线程上跑
Accept()、Receive()、Connect() 都是同步阻塞方法,如果直接写在按钮点击事件里,界面会"假死"。所以必须用 Task.Run(...) 把它们丢到后台线程执行。
1.2 后台线程不能直接操作界面控件
WinForm 的控件(TextBox、ListBox、RichTextBox 等)只能被创建它们的那个线程(即 UI 线程)直接访问。网络收发发生在后台线程,收到数据后要更新界面,必须用 Invoke 把这段更新界面的代码"丢回"UI线程去执行:
csharp
if (控件.InvokeRequired)
{
控件.Invoke(new Action(() => 更新界面的代码()));
return;
}
这两点会贯穿下面服务端和客户端的全部代码。
二、完整代码实现:服务端
下面是一个可以运行的最简 Socket 服务端示例,支持多个客户端同时连接、群发消息。
2.1 服务端窗体完整代码
csharp
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
public partial class ServerForm : Form
{
Socket serverSocket; // 负责监听的Socket
List<Socket> clientSockets = new List<Socket>(); // 保存所有已连接的客户端Socket
readonly object _lock = new object(); // 保护clientSockets的多线程访问
CancellationTokenSource cts;
public ServerForm()
{
InitializeComponent();
}
// 启动服务端
private void btnStart_Click(object sender, EventArgs e)
{
// 1. 创建
serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
// 2. 绑定
IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, int.Parse(txtPort.Text));
serverSocket.Bind(endPoint);
// 3. 监听
serverSocket.Listen(100);
cts = new CancellationTokenSource();
// 4. 在后台线程里循环Accept,避免阻塞UI
Task.Run(() => AcceptLoop(cts.Token));
AppendLog("服务端已启动,等待客户端连接...");
}
// 循环接受新连接
private void AcceptLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
Socket client = serverSocket.Accept(); // 阻塞,直到有新客户端连上
lock (_lock) { clientSockets.Add(client); }
AppendLog($"客户端已连接:{client.RemoteEndPoint}");
// 为这个新客户端单独开一个线程,专门处理它的收发
Task.Run(() => ReceiveLoop(client, token));
}
catch
{
break; // serverSocket被关闭时Accept会抛异常,此时退出循环
}
}
}
// 每个客户端独立的接收循环
private void ReceiveLoop(Socket client, CancellationToken token)
{
byte[] buffer = new byte[1024];
try
{
while (!token.IsCancellationRequested)
{
int len = client.Receive(buffer); // 阻塞,直到收到数据
if (len == 0) break; // 对方正常关闭连接
string message = Encoding.UTF8.GetString(buffer, 0, len);
AppendLog($"收到消息:{message}");
}
}
catch
{
// 客户端异常断开
}
finally
{
lock (_lock) { clientSockets.Remove(client); }
client.Close();
AppendLog("客户端已断开");
}
}
// 群发消息给所有客户端
private void btnSend_Click(object sender, EventArgs e)
{
byte[] data = Encoding.UTF8.GetBytes(txtSend.Text);
lock (_lock)
{
foreach (var client in clientSockets)
{
if (client.Connected)
client.Send(data);
}
}
txtSend.Text = "";
}
// 安全地往界面追加日志(自动判断是否需要切回UI线程)
private void AppendLog(string text)
{
if (richTextBoxLog.InvokeRequired)
{
richTextBoxLog.Invoke(new Action(() => AppendLog(text)));
return;
}
richTextBoxLog.AppendText($"{DateTime.Now:HH:mm:ss} {text}{Environment.NewLine}");
}
// 关闭窗体时清理资源
private void ServerForm_FormClosing(object sender, FormClosingEventArgs e)
{
cts?.Cancel();
serverSocket?.Close();
lock (_lock)
{
foreach (var c in clientSockets) c.Close();
}
}
}
2.2 逐段代码讲解
字段设计
csharp
Socket serverSocket;
List<Socket> clientSockets = new List<Socket>();
readonly object _lock = new object();
CancellationTokenSource cts;
serverSocket:全程只负责"监听端口、接受新连接",不参与和具体某个客户端的数据收发。clientSockets:一个列表,保存当前所有在线客户端的Socket,群发消息时需要遍历它。_lock:因为这个列表会被"接受新连接的后台线程"和"每个客户端各自的接收线程"同时修改,多线程环境下必须加锁保护,否则可能出现集合被同时修改导致的异常或数据错乱。cts(CancellationTokenSource):用来统一控制所有后台循环的停止,调用cts.Cancel()后,所有循环里的token.IsCancellationRequested会变成true,从而自然退出。
启动服务端:创建 → 绑定 → 监听 → 开始接受连接
csharp
private void btnStart_Click(object sender, EventArgs e)
{
serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint endPoint = new IPEndPoint(IPAddress.Any, int.Parse(txtPort.Text));
serverSocket.Bind(endPoint);
serverSocket.Listen(100);
cts = new CancellationTokenSource();
Task.Run(() => AcceptLoop(cts.Token));
AppendLog("服务端已启动,等待客户端连接...");
}
这个按钮点击事件严格按照"四步走"的顺序执行:new Socket 创建对象、Bind 绑定端口、Listen 进入监听状态、最后用 Task.Run 把真正"接客人"的循环丢到后台线程去跑。注意最后一步是关键 ------如果不用 Task.Run 包裹,紧接着调用的 Accept() 会直接卡死点击事件所在的UI线程。
接受连接的循环
csharp
private void AcceptLoop(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
Socket client = serverSocket.Accept();
lock (_lock) { clientSockets.Add(client); }
AppendLog($"客户端已连接:{client.RemoteEndPoint}");
Task.Run(() => ReceiveLoop(client, token));
}
catch
{
break;
}
}
}
这个循环只做一件事 :不停地调用 Accept() 等新客户端连进来。一旦接到一个,立刻做两件事------加入 clientSockets 列表(加锁保护),然后马上 再开一个新的后台任务 ReceiveLoop 去专门处理这个客户端后续的收发,自己完全不参与,转身继续回到 while 循环等下一个新客户端。
try/catch 包住 Accept() 是因为:当外部调用 serverSocket.Close()(比如窗体关闭时)后,正卡在 Accept() 里的调用会抛出异常,用 catch { break; } 捕获这个异常并退出循环,是一种优雅停止后台任务的方式。
每个客户端独立的接收循环
csharp
private void ReceiveLoop(Socket client, CancellationToken token)
{
byte[] buffer = new byte[1024];
try
{
while (!token.IsCancellationRequested)
{
int len = client.Receive(buffer);
if (len == 0) break;
string message = Encoding.UTF8.GetString(buffer, 0, len);
AppendLog($"收到消息:{message}");
}
}
catch { }
finally
{
lock (_lock) { clientSockets.Remove(client); }
client.Close();
AppendLog("客户端已断开");
}
}
这是每一个 已连接客户端专属的一段循环(有几个客户端在线,后台就同时跑着几份这样的循环)。核心逻辑是不断调用 Receive 等待这个客户端发数据过来:
len == 0:表示这个客户端正常关闭 了连接(完成了TCP的挥手过程),此时用break主动跳出循环。catch { }:捕获客户端异常断开 的情况(比如网络中断、进程崩溃),此时Receive会抛出异常,直接捕获忽略,交给finally统一处理善后。finally块:不管是正常退出还是异常退出,都会执行 ------把这个客户端从在线列表移除、关闭它的Socket释放资源、打印一条断开日志。这种"无论如何都要执行清理"的场景,正是finally关键字存在的意义。
群发消息
csharp
private void btnSend_Click(object sender, EventArgs e)
{
byte[] data = Encoding.UTF8.GetBytes(txtSend.Text);
lock (_lock)
{
foreach (var client in clientSockets)
{
if (client.Connected)
client.Send(data);
}
}
txtSend.Text = "";
}
遍历当前所有在线的客户端 Socket,逐个调用 Send 把同一份数据发出去,即为"群发"。遍历前用 lock 锁住,是因为遍历过程中如果同时 有另一个线程正在往这个列表里"添加新客户端"或"移除已断开客户端",会导致集合被并发修改而抛出异常(InvalidOperationException: 集合已修改)。
安全更新界面的通用写法
csharp
private void AppendLog(string text)
{
if (richTextBoxLog.InvokeRequired)
{
richTextBoxLog.Invoke(new Action(() => AppendLog(text)));
return;
}
richTextBoxLog.AppendText($"{DateTime.Now:HH:mm:ss} {text}{Environment.NewLine}");
}
InvokeRequired 属性会自动判断"当前调用这个方法的线程,是不是创建这个控件的那个线程"。如果不是(说明是后台线程在调用),就用 Invoke 把自己重新调用一遍,这一次会在正确的UI线程上执行,从而绕过 if 判断直接走到 AppendText。这样写的好处是:这个方法不管被谁调用(UI线程的按钮事件,还是后台的接收线程),都能安全地更新界面,不需要在每个调用点重复写判断逻辑。
窗体关闭时的资源清理
csharp
private void ServerForm_FormClosing(object sender, FormClosingEventArgs e)
{
cts?.Cancel();
serverSocket?.Close();
lock (_lock)
{
foreach (var c in clientSockets) c.Close();
}
}
程序退出前,主动通知所有后台循环停止(cts.Cancel()),关闭监听 Socket 和所有客户端连接。这一步很重要------如果不主动清理,某些 Socket 资源可能没有被正常释放,下次重新启动服务端绑定同一个端口时可能会遇到"地址已被占用"的问题。
三、完整代码实现:客户端
3.1 客户端窗体完整代码
csharp
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
public partial class ClientForm : Form
{
Socket clientSocket;
CancellationTokenSource cts;
public ClientForm()
{
InitializeComponent();
}
// 连接服务端
private void btnConnect_Click(object sender, EventArgs e)
{
try
{
clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse(txtIp.Text), int.Parse(txtPort.Text));
clientSocket.Connect(serverEndPoint); // 同步阻塞,直到连上或超时失败
AppendLog("连接服务端成功");
cts = new CancellationTokenSource();
Task.Run(() => ReceiveLoop(cts.Token)); // 启动后台接收循环
}
catch (Exception ex)
{
MessageBox.Show($"连接失败:{ex.Message}");
}
}
// 持续接收服务端发来的消息
private void ReceiveLoop(CancellationToken token)
{
byte[] buffer = new byte[1024];
try
{
while (!token.IsCancellationRequested)
{
int len = clientSocket.Receive(buffer);
if (len == 0) break; // 服务端关闭了连接
string message = Encoding.UTF8.GetString(buffer, 0, len);
AppendLog($"收到:{message}");
}
}
catch
{
// 连接异常断开
}
finally
{
AppendLog("与服务端的连接已断开");
}
}
// 发送消息
private void btnSend_Click(object sender, EventArgs e)
{
if (clientSocket == null || !clientSocket.Connected)
{
MessageBox.Show("尚未连接服务端");
return;
}
byte[] data = Encoding.UTF8.GetBytes(txtSend.Text);
clientSocket.Send(data);
txtSend.Text = "";
}
private void AppendLog(string text)
{
if (richTextBoxLog.InvokeRequired)
{
richTextBoxLog.Invoke(new Action(() => AppendLog(text)));
return;
}
richTextBoxLog.AppendText($"{DateTime.Now:HH:mm:ss} {text}{Environment.NewLine}");
}
private void ClientForm_FormClosing(object sender, FormClosingEventArgs e)
{
cts?.Cancel();
clientSocket?.Close();
}
}
3.2 逐段代码讲解
连接服务端
csharp
private void btnConnect_Click(object sender, EventArgs e)
{
try
{
clientSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
IPEndPoint serverEndPoint = new IPEndPoint(IPAddress.Parse(txtIp.Text), int.Parse(txtPort.Text));
clientSocket.Connect(serverEndPoint);
AppendLog("连接服务端成功");
cts = new CancellationTokenSource();
Task.Run(() => ReceiveLoop(cts.Token));
}
catch (Exception ex)
{
MessageBox.Show($"连接失败:{ex.Message}");
}
}
clientSocket.Connect(...) 同样是同步阻塞方法,这里选择直接在按钮点击事件里调用(没有用 Task.Run 包裹),意味着点击"连接"按钮后界面会短暂卡住 ,直到连接成功或抛出异常。这是一个可以接受的取舍------连接这个动作本身通常很快(局域网内几乎瞬间完成),而且如果失败需要立刻用 try/catch 弹出提示,用同步方式写反而逻辑更直接、不容易出现之前讨论过的"跨线程操作UI"问题。如果需要追求界面完全不卡顿,可以换成 ConnectAsync 或包一层 Task.Run。
连接成功后,立刻用 Task.Run 启动一个持续运行的后台接收循环------这一步是必须异步/后台执行的,因为它需要"一直守着",不能是一次性调用。
接收循环
csharp
private void ReceiveLoop(CancellationToken token)
{
byte[] buffer = new byte[1024];
try
{
while (!token.IsCancellationRequested)
{
int len = clientSocket.Receive(buffer);
if (len == 0) break;
string message = Encoding.UTF8.GetString(buffer, 0, len);
AppendLog($"收到:{message}");
}
}
catch { }
finally
{
AppendLog("与服务端的连接已断开");
}
}
跟服务端的 ReceiveLoop 结构完全一致:循环调用 Receive 等待数据,len==0 表示服务端正常关闭了连接,异常表示连接被意外中断,finally 里统一记一条"已断开"的日志。客户端和服务端在"如何持续接收数据"这件事上,写法是对称的 ,因为一旦连接建立,双方的 Socket 在能力上是平等的,都可以随时 Send/Receive。
发送消息
csharp
private void btnSend_Click(object sender, EventArgs e)
{
if (clientSocket == null || !clientSocket.Connected)
{
MessageBox.Show("尚未连接服务端");
return;
}
byte[] data = Encoding.UTF8.GetBytes(txtSend.Text);
clientSocket.Send(data);
txtSend.Text = "";
}
发送前先做一次防御性检查:clientSocket 是否已经创建、是否处于已连接状态。避免用户还没点击"连接"或者连接已经断开的情况下点击"发送",直接调用 Send 会抛出异常。
其余部分 (AppendLog、FormClosing)跟服务端的写法思路完全一样:AppendLog 用 InvokeRequired 保证任何线程调用都安全;FormClosing 里主动取消后台任务、关闭 Socket,做好资源清理。
四、服务端与客户端代码结构的对称性
把两边的代码放在一起看,会发现一个规律------服务端处理"每一个客户端"的逻辑,和客户端处理"这一个服务端"的逻辑,本质上是同一套模板:
| 环节 | 服务端(对每个客户端) | 客户端(对唯一的服务端) |
|---|---|---|
| 建立通道 | Accept() 得到一个 Socket |
Connect() 得到一个 Socket |
| 持续接收 | 独立的后台循环 while + Receive |
独立的后台循环 while + Receive |
| 判断断开 | Receive 返回 0 |
Receive 返回 0 |
| 发送数据 | 遍历所有连接逐个 Send(群发) |
直接对唯一的 clientSocket 调 Send |
| 清理资源 | finally 里移除+关闭 |
finally 里记录断开日志 |
区别仅仅在于:服务端要同时维护"多个"这样的通道(所以需要一个集合 + 锁),客户端只需要维护"一个"通道(不需要集合和锁)。理解了这一层对称关系,以后无论是扩展成"客户端也能同时连接多个服务端",还是"服务端需要给每个客户端做更复杂的状态管理",都是在这套基础模板上做增量修改。
五、可以在此基础上继续完善的方向
- 私聊/分组转发 :服务端给每个
Socket关联一个"用户名"(通常做法是客户端连接后先发一条"登录"消息上报身份),维护用户名 -> Socket的映射,转发时按用户名查找目标Socket,而不是无差别群发。 - 消息协议化 :目前收发的都是纯文本字符串,如果需要传递"消息类型""发送者""目标接收者"等结构化信息,需要设计一个消息类,用 JSON 序列化/反序列化后再通过
Send/Receive传输。 - 粘包处理 :目前每次
Receive到的数据被当作一条完整消息直接处理,实际生产场景中需要加入分隔符或长度前缀机制,正确切分连续到达的字节流(详见《Socket通信原理详解》中的相关章节)。 - 异步/高性能优化 :当前"每个客户端一个线程"的模型在连接数较大时会消耗较多系统资源,追求高并发可以考虑升级为基于
SocketAsyncEventArgs的异步模型。