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 模型:
整体流程可以概括成下面这张时序图:

四、服务端实现(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("请先构建连接");
}
}
六、运行与测试
(运行测试截图)

七、几个容易踩的坑
- 跨线程访问 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 下调试通过。如果对你有帮助,欢迎点赞收藏~ 有问题评论区一起交流。
网硕互联帮助中心



评论前必须登录!
注册