WinForm中Socket通信服务端客户端代码实现讲解

C# WinForm 中 Socket 通信:服务端/客户端代码实现讲解

本文承接《Socket通信原理详解》,专注于在 WinForm 项目中如何把原生 Socket API 落地成一个可以运行的"服务端 + 客户端"程序,包含完整代码和逐段讲解。

一、WinForm 场景下需要额外处理的两个问题

在动手写代码之前,先明确 WinForm 桌面程序相比控制台程序,多出来的两个"坑":

1.1 阻塞方法不能直接放在 UI 线程上跑

Accept()Receive()Connect() 都是同步阻塞方法,如果直接写在按钮点击事件里,界面会"假死"。所以必须用 Task.Run(...) 把它们丢到后台线程执行。

1.2 后台线程不能直接操作界面控件

WinForm 的控件(TextBoxListBoxRichTextBox 等)只能被创建它们的那个线程(即 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:因为这个列表会被"接受新连接的后台线程"和"每个客户端各自的接收线程"同时修改,多线程环境下必须加锁保护,否则可能出现集合被同时修改导致的异常或数据错乱。
  • ctsCancellationTokenSource):用来统一控制所有后台循环的停止,调用 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 会抛出异常。

其余部分AppendLogFormClosing)跟服务端的写法思路完全一样:AppendLogInvokeRequired 保证任何线程调用都安全;FormClosing 里主动取消后台任务、关闭 Socket,做好资源清理。


四、服务端与客户端代码结构的对称性

把两边的代码放在一起看,会发现一个规律------服务端处理"每一个客户端"的逻辑,和客户端处理"这一个服务端"的逻辑,本质上是同一套模板

环节 服务端(对每个客户端) 客户端(对唯一的服务端)
建立通道 Accept() 得到一个 Socket Connect() 得到一个 Socket
持续接收 独立的后台循环 while + Receive 独立的后台循环 while + Receive
判断断开 Receive 返回 0 Receive 返回 0
发送数据 遍历所有连接逐个 Send(群发) 直接对唯一的 clientSocketSend
清理资源 finally 里移除+关闭 finally 里记录断开日志

区别仅仅在于:服务端要同时维护"多个"这样的通道(所以需要一个集合 + 锁),客户端只需要维护"一个"通道(不需要集合和锁)。理解了这一层对称关系,以后无论是扩展成"客户端也能同时连接多个服务端",还是"服务端需要给每个客户端做更复杂的状态管理",都是在这套基础模板上做增量修改。


五、可以在此基础上继续完善的方向

  1. 私聊/分组转发 :服务端给每个 Socket 关联一个"用户名"(通常做法是客户端连接后先发一条"登录"消息上报身份),维护 用户名 -> Socket 的映射,转发时按用户名查找目标 Socket,而不是无差别群发。
  2. 消息协议化 :目前收发的都是纯文本字符串,如果需要传递"消息类型""发送者""目标接收者"等结构化信息,需要设计一个消息类,用 JSON 序列化/反序列化后再通过 Send/Receive 传输。
  3. 粘包处理 :目前每次 Receive 到的数据被当作一条完整消息直接处理,实际生产场景中需要加入分隔符或长度前缀机制,正确切分连续到达的字节流(详见《Socket通信原理详解》中的相关章节)。
  4. 异步/高性能优化 :当前"每个客户端一个线程"的模型在连接数较大时会消耗较多系统资源,追求高并发可以考虑升级为基于 SocketAsyncEventArgs 的异步模型。
相关推荐
captain3764 小时前
网络原理(4)-TCP ▲▲▲
java·服务器·网络·网络协议·tcp/ip
上海云盾-小余14 小时前
BGP 高防底层原理:TCP 异常流量识别与清洗机制
运维·网络·tcp/ip
captain37614 小时前
▲网络原理(2)-TCP
java·服务器·网络·tcp/ip·java-ee
唐青枫19 小时前
别只会看日志:C#.NET SOS 调试扩展从 Dump 到内存泄漏排查
c#·.net
captain3761 天前
网络原理(3)-TCP核心机制连接管理▲▲▲
java·服务器·网络·ide·网络协议·tcp/ip·java-ee
易岳群1 天前
搞清 Socket、TCP、MQTT 的通信关系:从底层协议到物联网实战
网络协议·tcp/ip·socket·通信协议关系
宵时待雨1 天前
linux笔记归纳19:网络层协议IP
linux·网络·笔记·网络协议·tcp/ip
河北之花1 天前
计算机三级:BGQ
网络·tcp/ip·计算机网络·智能路由器
yswenli1 天前
基于C#开发网站视频嗅探下载工具——BrowserVideoGrabber
c#·videodownload·webvideodownload·videograbber