云计算百科
云计算领域专业知识百科平台

C# WinForms 从零实现一个 TCP 通信程序(服务端 + 客户端,支持多客户端与广播)

C# WinForms 从零实现一个 TCP 通信程序(服务端 + 客户端,支持多客户端与广播)

本文记录自己用 C# WinForms + .NET Framework 4.7.2 写的一个入门级 TCP 通信 Demo,包含一个服务端和一个客户端。服务端基于 TcpListener,客户端基于 TcpClient,服务端支持多个客户端同时连接,并能向所有在线客户端广播消息。适合刚接触 Socket / TCP 的同学照着抄一遍跑通。


一、效果展示

(服务端主界面)
在这里插入图片描述

(客户端主界面)
在这里插入图片描述

(通信联调效果)
在这里插入图片描述


二、开发环境

项目说明
IDE Visual Studio 2022
框架 .NET Framework 4.7.2
技术栈 C# + WinForms + System.Net.Sockets
解决方案结构 一个解决方案 work.9.11.TCP.sln,内含两个 WinForms 工程:TCP服务端T(服务端)、TCP客户端(客户端)
默认监听端口 60000(0~100 是系统预留端口,个人学习建议用 3000 / 8080 / 60000 这种)

💡 同一台机器上调试,服务端 IP 可以填 127.0.0.1;局域网内互连用 ipconfig 查到的本机 IPv4 地址即可(本项目里默认填的是 192.168.215.47,换机器记得改)。


三、整体思路

TCP 通信是典型的 C / S 模型:

  • 服务端先在某个 IP + 端口上"启动监听",然后一直等待客户端来连。
  • 客户端知道服务端的 IP 和端口后,主动发起连接。
  • 连接建立后,双方通过一个 NetworkStream(网络流)互相收发字节:发送方把字符串按 UTF-8 编码成 byte[] 写进流,接收方从流里读出 byte[] 再解码成字符串。
  • 因为收消息是"随时可能来"的,不能卡住 UI 线程,所以接收必须放在后台线程 / 异步 Task 里;拿到数据后再用 Invoke 切回 UI 线程刷新界面。
  • 本项目服务端用一个 Dictionary<EndPoint, TcpClient> 维护所有已连接的客户端,既能给每个客户端单独收消息,也能遍历字典做"广播"。
  • 整体流程可以概括成下面这张时序图:

    在这里插入图片描述


    四、服务端实现(TCP服务端T)

    4.1 界面布局

    服务端窗体从上到下分三块:

    • TCP连接 分组框:IP 地址输入框 textBox1、端口输入框 textBox2、启动/停止按钮 button1。
    • 接收数据 分组框:只读多行文本框 richTextBox1,用来滚动显示所有客户端发来的消息。
    • 广播通知 分组框:输入框 richTextBox2 + "发送"按钮 button2,把消息群发在线客户端。

    4.2 字段定义

    先在窗体类里准备好服务端对象、取消令牌、以及保存所有客户端的字典:

    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;

    namespace TCP服务端T
    {
    public partial class Form1 : Form
    {
    // tcp 监听:代表 tcp 服务器
    TcpListener _server;
    CancellationTokenSource _cts;

    // 字典保存所有已连接的客户端,key 是客户端的终结点(IP+端口),value 是对应的 TcpClient
    // 发广播的时候,遍历字典里的每一个客户端都能收到
    Dictionary<EndPoint, TcpClient> _clients = new Dictionary<EndPoint, TcpClient>();

    public Form1()
    {
    InitializeComponent();

    // 窗体一加载就根据界面上填好的 IP 和端口准备好服务器对象
    IPEndPoint iPEndPoint = new IPEndPoint(
    IPAddress.Parse(textBox1.Text),
    int.Parse(textBox2.Text));
    _server = new TcpListener(iPEndPoint);
    }
    }
    }

    4.3 启动 / 停止服务器

    点击按钮后在"启动服务器"和"停止服务器"之间切换:

    private void button1_Click(object sender, EventArgs e)
    {
    if (button1.Text == "启动服务器")
    {
    // Start(100):100 代表当前服务器的最大监听(排队)数量
    _server?.Start(100);
    button1.Text = "停止服务器";

    // 启动后不允许再修改 IP 和端口,防止运行中改配置
    textBox1.ReadOnly = true;
    textBox2.ReadOnly = true;

    // 进入"接受客户端连接"的循环
    AcceptRequest();
    }
    else
    {
    // 通知后台循环退出
    _cts.Cancel();
    _server?.Stop();
    button1.Text = "启动服务器";
    textBox1.ReadOnly = false;
    textBox2.ReadOnly = false;
    }
    }

    4.4 循环接收客户端连接

    这一段是服务端的核心:开一个后台 Task,不停检查"有没有新客户端连进来",有就接收并登记到字典里,同时为这个客户端单独开一条收消息的循环:

    // 负责监听连接
    private void AcceptRequest()
    {
    _cts = new CancellationTokenSource();
    Task.Run(async () =>
    {
    while (!_cts.IsCancellationRequested)
    {
    // Pending():如果这次循环中没有监听到新客户端连接,就是 false,直接跳过节省开销
    if (!_server.Pending()) continue;

    // 异步接收当前连接上来的客户端
    TcpClient client = await _server.AcceptTcpClientAsync();

    // 拿到这个客户端的终结点(IP+端口)作为唯一标识
    EndPoint _endPoint = client.Client.RemoteEndPoint;

    if (!_clients.ContainsKey(_endPoint))
    {
    // 字典里没有,说明是新客户端,登记进去
    _clients.Add(_endPoint, client);
    // 为这个客户端启动一条独立的"收消息"循环
    ClientOneMessage(client);
    }
    }
    }, _cts.Token);
    }

    4.5 接收某个客户端的消息并回显

    每个客户端对应一个后台循环,读到消息后:① 显示到界面;② 回一条"服务器收到了消息:xxx"给该客户端:

    private void ClientOneMessage(TcpClient _c)
    {
    Task.Run(async () =>
    {
    while (!_cts.IsCancellationRequested && _c.Connected)
    {
    NetworkStream _stream = _c.GetStream();

    // 这次循环没有数据就跳过,避免空读浪费资源
    if (!_stream.DataAvailable) continue;

    // 按当前可用字节数开缓冲,读完即止
    byte[] buffer = new byte[_c.Available];
    await _stream.ReadAsync(buffer, 0, buffer.Length);

    // UTF-8 解码成字符串
    string message = Encoding.UTF8.GetString(buffer);

    // 跨线程更新 UI 必须用 Invoke
    Invoke(new Action(() =>
    {
    richTextBox1.AppendText(
    $"{DateTime.Now.ToString("MM月dd日HH:mm")}: {message}\\r\\n");
    }));

    // 给发消息的这个客户端回一句确认
    byte[] data = Encoding.UTF8.GetBytes($"服务器收到了消息:{message}");
    _ = _stream.WriteAsync(data, 0, data.Length);
    }
    });
    }

    4.6 向所有客户端广播

    遍历字典里的每一个在线客户端,把同一条消息写进去:

    // 广播发送
    private void button2_Click(object sender, EventArgs e)
    {
    if (richTextBox2.Text == "") return;

    // 拿到所有客户端
    foreach (var item in _clients)
    {
    TcpClient _client = item.Value;
    if (!_client.Connected) continue;

    NetworkStream networkStream = _client.GetStream();
    byte[] buffer = Encoding.UTF8.GetBytes(richTextBox2.Text);
    networkStream.WriteAsync(buffer, 0, buffer.Length);
    }
    richTextBox2.Text = "";
    }


    五、客户端实现(TCP客户端)

    5.1 界面布局

    和服务端基本对称:

    • TCP连接 分组框:IP、端口、建立/断开连接按钮 button1。
    • 接收数据 分组框:richTextBox1,显示服务端发来的内容。
    • 发送数据 分组框:输入框 richTextBox2 + "发送"按钮 button2。

    5.2 字段与后台接收循环

    TcpClient 本身没有"收到消息"事件,通常的做法就是用 while + Task 后台轮询,再用 CancellationTokenSource 在断开连接时把循环停下来:

    using System;
    using System.Net.Sockets;
    using System.Text;
    using System.Threading;
    using System.Threading.Tasks;
    using System.Windows.Forms;

    namespace TCP客户端
    {
    public partial class Form1 : Form
    {
    // 初始化客户端对象
    TcpClient _client = null;

    // 令牌:用来为某段异步代码打上标记,从而实现取消信号的传递
    CancellationTokenSource _cts = null;

    public Form1()
    {
    InitializeComponent();
    // TcpClient 没有专门的接收事件,一般用定时器或者 while 轮询接收
    }

    private async void ReadLoad()
    {
    if (_client != null)
    {
    _cts = new CancellationTokenSource();
    await Task.Run(async () =>
    {
    // IsCancellationRequested 默认为 false;调用 _cts.Cancel() 后会变成 true
    while (!_cts.IsCancellationRequested && _client.Connected)
    {
    NetworkStream _stream = _client.GetStream();

    // 这次循环没有数据就跳过,节省开销
    if (!_stream.DataAvailable) continue;

    byte[] buffer = new byte[_client.Available];
    await _stream.ReadAsync(buffer, 0, buffer.Length);
    string message = Encoding.UTF8.GetString(buffer);

    // 切回 UI 线程显示
    Invoke(new Action(() =>
    {
    richTextBox1.AppendText(
    $"{DateTime.Now.ToString("MM月dd日HH:mm")}:{message}\\r\\n");
    }));
    }
    }, _cts.Token);
    }
    }
    }
    }

    5.3 建立连接 / 断开连接

    点击按钮切换两种状态:

    private void button1_Click(object sender, EventArgs e)
    {
    if (button1.Text == "建立连接")
    {
    button1.Text = "断开连接";

    try
    {
    _client = new TcpClient();
    // ConnectAsync 异步向服务器发起连接
    // 1) ip 地址 2) 端口号(0~100 是系统预留端口,个人部署建议 3000/8080/60000)
    _client.ConnectAsync(textBox1.Text, int.Parse(textBox2.Text));

    MessageBox.Show("连接成功");

    // 连接建立后启动后台接收循环
    ReadLoad();
    }
    catch (Exception ex)
    {
    MessageBox.Show($"服务器连接失败:{ex.Message}");
    }
    }
    else
    {
    button1.Text = "建立连接";

    // 修改令牌状态,通知后台接收循环退出
    _cts?.Cancel();

    _client.Close();
    _client = null;

    MessageBox.Show("断开成功");
    }
    }

    5.4 发送消息

    只有在连接状态下才允许发送:

    // 发送信息
    private void button2_Click(object sender, EventArgs e)
    {
    if (string.IsNullOrWhiteSpace(richTextBox2.Text)) return;

    if (_client.Connected)
    {
    // 1) 拿到当前通信的网络流
    NetworkStream _stream = _client.GetStream();

    // 2) 把字符串按 UTF-8 加工成字节数组
    byte[] _data = Encoding.UTF8.GetBytes(richTextBox2.Text);

    // 3) 异步写出去
    _stream.WriteAsync(_data, 0, _data.Length);

    richTextBox2.Text = "";
    }
    else
    {
    MessageBox.Show("请先构建连接");
    }
    }


    六、运行与测试

  • 先启动服务端工程 TCP服务端T,确认 IP / 端口无误后点 启动服务器。
  • 再启动一个或多个客户端工程 TCP客户端,填同样的 IP 和端口,点 建立连接。
  • 在客户端输入消息点 发送,服务端"接收数据"区应滚动显示该消息,并回一句 服务器收到了消息:xxx。
  • 在服务端"广播通知"里输入消息点 发送,所有在线客户端都应能在"接收数据"区看到这条消息。
  • (运行测试截图)
    在这里插入图片描述


    七、几个容易踩的坑

    • 跨线程访问 UI 控件会抛异常:后台线程里拿到消息后,必须用 Invoke(或 BeginInvoke)切回 UI 线程再去改 richTextBox,否则会报"线程间操作无效"。
    • 接收不能死等 Read:本项目用 _stream.DataAvailable 做非阻塞判断,没有数据就 continue,这样循环可以随时通过 _cts.Cancel() 干净退出;如果直接 Read 死等,取消令牌可能响应不到。
    • 端口不要被占用:同一台机器上端口只能绑一次。如果服务端没正常退出又重新启动,会报"通常每个套接字地址只允许使用一次",把进程关干净或者换个端口即可。
    • 编码要统一:两端都用 Encoding.UTF8,不要一边 UTF-8 一边 Default,否则中文会乱码。
    • 断开连接要释放资源:_cts.Cancel() + _client.Close() 要成对出现,不然后台 Task 会一直挂着。
    • ConnectAsync 没 await:示例里为了简单直接调用了 ConnectAsync,学习阶段够用;严谨写法建议 await 它并放在 try/catch 里,这样连接失败能第一时间拿到异常。

    八、总结

    这个 Demo 虽然小,但把 TCP 通信里几个最核心的点都覆盖到了:

    • 服务端:TcpListener + Bind/Start + AcceptTcpClientAsync 接受连接;
    • 客户端:TcpClient.ConnectAsync 发起连接;
    • 收发:NetworkStream + byte[] + Encoding.UTF8;
    • 多客户端:用 Dictionary 维护连接表,天然支持广播;
    • 异步与取消:Task.Run + CancellationTokenSource + Invoke。

    在此基础上可以继续扩展:给每条消息加协议头(长度 + 内容,解决粘包问题)、客户端掉线时从字典里移除、做成消息队列 / 群聊房间、或者把 UI 换成 WPF / 控制台版本,都是很好的练手方向。


    本文为学习笔记,代码在 Visual Studio 2022 + .NET Framework 4.7.2 下调试通过。如果对你有帮助,欢迎点赞收藏~ 有问题评论区一起交流。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » C# WinForms 从零实现一个 TCP 通信程序(服务端 + 客户端,支持多客户端与广播)
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!