嘿,你是不是也遇到过这种情况——面试官问"聊过网络编程吗",然后脑子一片空白。或者在做项目时,被一大堆 Socket、Thread、async/await 搞得晕头转向。
咱们今天就来聊一个真实的例子:怎样从 0 到 1 构建一个可用的 TCP 聊天应用。这不是那种教科书里的 Hello World,而是包含连接管理、消息广播、断线处理的完整系统。
我在几个内网项目中都用过这套方案,摸爬滚打积累的经验,现在分享给你。
首先,要理解为什么自己写 TCP 通信会这么容易出问题。
很多人第一次写 Socket 通信时,往往这样干:
csharp// ❌ 这样写,迟早翻车
TcpClient client = new TcpClient();
client.Connect("127.0.0.1", 5000);
NetworkStream stream = client.GetStream();
byte[] buffer = new byte[1024];
int read = stream.Read(buffer, 0, buffer.Length);
// 问题:主线程卡住了!UI 冻结!
为什么?因为 Read() 是阻塞式的——一旦没数据来,线程就傻等着,导致整个程序响应不了。
你需要的是这个思路:把接收消息放到后台线程,UI 线程该干啥干啥。
想象一下,100个客户端连上来,服务器怎么记住它们?
这就是为什么例子里用了 ConcurrentDictionary —— 它天生支持多线程安全操作,不用自己手写 lock,性能也更好。
这个最隐蔽。代码看起来没问题,但时间一长,服务器的文件描述符或网络连接数就用尽了。解决办法就是实现 IDisposable 模式,确保 Socket、Stream 都被正确释放。
先看看咱们的项目结构。为啥这样组织?
AppTcpChat/ ├── Models/ │ └── ChatMessage.cs ← 数据模型层 ├── Core/ │ ├── TcpServer.cs ← 服务端核心逻辑 │ └── TcpChatClient.cs ← 客户端核心逻辑 └── UI/ ├── FrmMain.cs ← 主窗口 ├── FrmServer.cs ← 服务端 UI └── FrmClient.cs ← 客户端 UI
为什么要这样分? 很简单——你的网络通信逻辑应该独立于 UI。这样即使哪天改用 WPF 或 ASP.NET Core,Core 层的代码一行不改。这就是关注点分离的威力。



csharpprivate async Task AcceptLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
var client = await _listener!.AcceptTcpClientAsync(token);
_ = Task.Run(() => HandleClientAsync(client, token), token);
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { ServerLog?.Invoke($"接受连接异常: {ex.Message}"); }
}
}
这里的妙招在于:每个客户端连接都扔到一个独立的 Task 去处理。主线程继续监听下一个连接。这样就不会因为某个客户端慢而影响其他人。
关键字 _ 是什么意思?—— 表示"我知道会有一个返回值,但我不关心"。这样做是为了避免 C# 编译器的警告。
csharpprivate async Task HandleClientAsync(TcpClient client, CancellationToken token)
{
var endpoint = client.Client.RemoteEndPoint?.ToString() ?? "unknown";
var clientKey = endpoint;
_clients[clientKey] = client; // 记录这个客户端
ClientConnected?.Invoke(clientKey);
var stream = client.GetStream();
var buffer = new byte[4096];
try
{
while (!token.IsCancellationRequested)
{
var read = await stream.ReadAsync(buffer, token);
if (read == 0) break; // 客户端关闭连接
var text = Encoding.UTF8.GetString(buffer, 0, read).Trim();
// 广播给所有其他客户端
var line = $"[{DateTime.Now:HH:mm:ss}] {clientKey}: {text}\n";
var data = Encoding.UTF8.GetBytes(line);
foreach (var kv in _clients)
{
if (kv.Key != clientKey)
SendRaw(kv.Value, data);
}
}
}
finally
{
_clients.TryRemove(clientKey, out _);
client.Close();
}
}
看似简单的几行,其实暗藏玄机:
read == 0 判断:这是客户端已断开的信号。TCP 规约就这样,没数据而且 read 返回 0,那就是再见了Encoding.UTF8.GetString:国际化啊,兼容中文、日文等,Encoding.Default 在某些系统上会翻车SendRaw 里的 try-catch:可能某个客户端的连接已死,发送会异常。这里咱们选择静默处理(catch 了就不再抛出)csharppublic void Dispose()
{
if (_disposed) return; // 防止重复释放
Stop();
_cts.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
为什么要 GC.SuppressFinalize?—— 告诉垃圾回收器"我已经手动清理了,不用再调用析构函数"。这样能小幅提升性能。
服务端负责接纳,客户端要做的就是:连上、接收、断开。
csharppublic async Task ConnectAsync()
{
_cts = new CancellationTokenSource();
_client = new TcpClient();
await _client.ConnectAsync(_host, _port);
_stream = _client.GetStream();
IsConnected = true;
Connected?.Invoke();
_ = Task.Run(() => ReceiveLoopAsync(_cts.Token));
}
public void Disconnect()
{
_cts.Cancel(); // 第一重保险:取消接收循环
_stream?.Close(); // 第二重保险:关闭数据流
_client?.Close();
IsConnected = false;
Disconnected?.Invoke();
}
为什么要两步走?
_cts.Cancel() 会让接收循环检测到 IsCancellationRequested == true,然后自己退出ReadAsync 里等着,这会抛出 OperationCanceledException,finally 块会执行清理所以,既有优雅的信号(CancellationToken),也有硬关闭的手段(Close)。双保险。
csharpprivate async Task ReceiveLoopAsync(CancellationToken token)
{
var buffer = new byte[4096];
try
{
while (!token.IsCancellationRequested)
{
var read = await _stream!.ReadAsync(buffer, token);
if (read == 0) break;
var text = Encoding.UTF8.GetString(buffer, 0, read).Trim();
var msg = new ChatMessage
{
Sender = "远端",
Content = text,
Time = DateTime.Now,
Type = MessageType.Normal
};
MessageReceived?.Invoke(msg); // 事件通知 UI
}
}
catch (OperationCanceledException) { } // 正常的取消,不报错
catch (Exception ex) { ClientLog?.Invoke($"接收异常: {ex.Message}"); }
finally
{
IsConnected = false;
Disconnected?.Invoke();
}
}
你可能注意到了 MessageReceived?.Invoke(msg) 这一行。这就是事件驱动的精髓:
客户端只管接收和触发事件,具体怎么显示交给 UI 层。两者耦合度最低。
WinForms 有个"金律":只有 UI 线程能修改控件。如果你在后台线程里直接改 Label.Text,会崩溃。
csharpprivate void SafeInvoke(Action action)
{
if (InvokeRequired)
Invoke(action); // UI 线程执行
else
action(); // 已经在 UI 线程了,直接执行
}
用法:
csharpprivate void Client_MessageReceived(ChatMessage msg) =>
SafeInvoke(() => AppendChatLog(msg));
这样,无论后台线程什么时候调用 Client_MessageReceived,消息都会在 UI 线程里显示。
csharpprivate void AppendChatLog(ChatMessage msg)
{
var color = msg.Type switch
{
MessageType.System => Color.FromArgb(100, 130, 180), // 蓝灰色
MessageType.Private => Color.FromArgb(160, 80, 180), // 紫色
_ => Color.FromArgb(30, 50, 90) // 深蓝
};
rtbChatLog.SelectionStart = rtbChatLog.TextLength;
rtbChatLog.SelectionLength = 0;
rtbChatLog.SelectionColor = color;
rtbChatLog.AppendText(msg.ToString() + Environment.NewLine);
rtbChatLog.ScrollToCaret();
}
这里的套路是:
细节决定体验。用户看到不同颜色的消息,瞬间觉得你的应用"高级"了。
csharpvar buffer = new byte[4096]; // 为什么是 4096?
4096 字节是常见的网络分包大小(TCP MSS)。如果设太小(比如 256),大消息会被截断;设太大(比如 1MB),内存浪费。4096 是经验值,可以用,别乱改。
csharp_clients.TryGetValue(targetName, out var target);
SendRaw(target, Encoding.UTF8.GetBytes(msg.ToString() + "\n"));
看起来没问题,但如果 targetName 不存在呢?target 就是 null,传给 SendRaw。虽然 SendRaw 里有 client?.GetStream() 的空值检查,但更好的做法是:
csharpif (_clients.TryGetValue(targetName, out var target))
{
SendRaw(target, Encoding.UTF8.GetBytes(msg.ToString() + "\n"));
}
else
{
ServerLog?.Invoke($"目标客户端不存在: {targetName}");
}
在 NAT 穿透、代理网络中,TCP 连接可能"假死"——双方都认为连接还活着,实际已经中断。解决办法是加心跳:每隔一段时间,服务器发个 ping 消息,客户端回 pong。代码示例我这里就不展开了,核心思想就是定时任务 + Task.Delay。
csharp// ❌ 混用了不同编码
var data1 = Encoding.UTF8.GetBytes(msg);
var data2 = Encoding.ASCII.GetBytes(msg); // 中文会乱码!
始终用 UTF-8。这是业界标准,没理由换。
如果同时有 50 个客户端,你的广播是这样吗?
csharpforeach (var kv in _clients)
{
SendRaw(kv.Value, data); // 50 次网络 I/O
}
在超高负载场景下,可以考虑消息队列 + 批量发送。但对于中小型应用(几十个连接),这优化收益不大。不过度优化是最好的优化。
csharp// 新版本可以这样
public ValueTask SendMessageAsync(string content)
{
// ...
}
ValueTask 避免了堆分配,在高频调用时能减少 GC 压力。但需要 .NET 5+。
csharpprivate async Task CleanupDeadConnectionsAsync()
{
while (true)
{
await Task.Delay(TimeSpan.FromSeconds(30));
var deadClients = new List<string>();
foreach (var kv in _clients)
{
if (!kv.Value.Connected)
deadClients.Add(kv.Key);
}
foreach (var key in deadClients)
{
_clients.TryRemove(key, out _);
}
}
}
每 30 秒检查一次,把死连接踢出去。这样 _clients 字典不会无限增长。
我在一个电力系统项目中用过这套方案(改造后的版本)。当时需要在局域网里让 50 台 PLC 设备与上位机通信。
改动点:
\x00 心跳包,对端回复这些改动都不复杂,就是在基础的 TCP 基础上加上一层协议。
| 核心概念 | 关键要点 |
|---|---|
| 异步编程 | 网络 I/O 必须异步,别卡主线程 |
| 线程安全 | ConcurrentDictionary、lock、Interlocked 三件套 |
| 资源管理 | IDisposable + using,养成习惯 |
| 事件驱动 | 通信层发事件,UI 层监听事件,解耦万岁 |
| 错误处理 | try-catch-finally,每一层都要防守 |
写到这儿,我想问你个问题:如果现在要支持 1000 个客户端同时在线,该怎么优化?
简单想法可能是"买更好的服务器",但真实答案可能是:
这就超出了这篇文章的范畴了。但希望通过这个例子,你理解了从小型单机应用到大型系统的演进思路。
编程不止是敲代码,更要理解代码背后的原理。希望这篇文章对你有帮助。
有问题欢迎在评论里讨论!
关键词:C# TCP 编程、异步网络、WinForms、消息通信、线程安全
适用场景:工业控制、LAN 聊天应用、局域网工具开发
难度等级:中等(需要熟悉 async/await、事件、IDisposable)
相关信息
通过网盘分享的文件:AppTcpChat.zip 链接: https://pan.baidu.com/s/1KLXBgws44v_asvETH4enNg?pwd=x2u5 提取码: x2u5 --来自百度网盘超级会员v9的分享
本文作者:技术老小子
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!