做C#的后端同学,基本都会有一阵子绕不开TCP通信。我最早做设备对接项目时,傻乎乎地直接用Socket类,Bind、Listen、Accept、BeginReceive一路写下来,光处理半包粘包就折腾了一周。后来换成TcpClient和TcpListener这套高层封装,代码量直接砍掉一大半,而且稳定性反而更好。这篇文章就专门聊聊,怎么用.NET自带的TcpClient和TcpListener搭一套靠谱的TCP通信服务,从服务端监听、客户端连接,到多客户端并发、断线检测这些避不开的坑,最后给你一个能直接抄的通信骨架。适合刚开始接触C#网络编程的同学,也适合那些用Socket用得心累、想回到标准库找答案的人。
1. 为什么是TcpClient/TcpListener:先搞清楚这套封装帮你省了什么
做TCP通信,第一反应不该是去翻Socket的API文档。我见过太多人一上来就new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp),然后对着Bind、Listen、Accept、BeginReceive发呆。确实,裸Socket能做一切,但你要付出的代价是手动管理套接字生命周期、手动处理字节缓冲区、手动处理Accept循环,还要在各种异常返回码里猜"到底是不是断线了"。
TcpClient和TcpListener就是.NET替你把这一层琐碎封装好的高层入口。它们内部还是操作Socket,但对开发者暴露的是"客户端"和"服务端"两个更直观的概念:服务端注册一个TcpListener监听端口,Accept到连接后得到一个代表具体连接的TcpClient;客户端直接new一个TcpClient去Connect。剩下的收发数据,统一走GetStream()返回的NetworkStream——你在流上读、写字节,就像操作文件流一样,至少不用再记Socket.Send的offset和flags参数。
1.1 一张表看懂裸Socket和封装的对应关系
| 你真正想做的事 | 裸Socket的写法 | TcpClient/TcpListener的写法 |
|---|---|---|
| 建立监听 | socket.Bind(endpoint); socket.Listen(backlog); | new TcpListener(ip, port).Start(); |
| 等待连接 | Socket accepted = socket.Accept(); | TcpClient client = listener.AcceptTcpClient(); |
| 远端连接 | socket.Connect(host, port); | client.Connect(host, port); |
| 收数据 | int n = socket.Receive(buffer, 0, len, SocketFlags.None); | int n = stream.Read(buffer, 0, len); |
| 发数据 | socket.Send(buffer, 0, len, SocketFlags.None); | stream.Write(buffer, 0, len); |
| 释放 | socket.Shutdown(...); socket.Close(); | client.Close(); |
看到没有,几乎所有操作都变成了一行。尤其"拿到客户端连接"这件事,裸Socket的Accept返回的还是Socket,你要自己记这个Socket对应哪个远端地址;而AcceptTcpClient直接给你一个TcpClient,内部已经封装好了远端IP、端口、收发缓冲区这些细节,你只管往GetStream()上写。
1.2 用管道来理解"流式传输"
TCP和UDP最大的区别,就是TCP没有"消息边界"。我习惯用一个比喻:UDP像寄快递,每次发一个包裹,接收方每次按包裹收,虽然可能丢件,但包裹的边界清清楚楚;TCP像往一根水管里倒水,你倒多少、倒几杯,对面接到的只是一条连续的水流,中间哪儿是杯子的分界,完全看不出来。
这个特性决定了我们必须在应用层自己定义"消息格式",也就是后文要讲的粘包半包问题。理解这一点,你就明白TcpClient提供的NetworkStream.Read为什么返回值经常不等于你想要的长度——它读的是水流,不是包裹。
1.3 什么时候不该用这套封装
我也不是无脑推荐TcpClient。如果你的场景是游戏网关、IM长连接网关这种单机要扛几万连接、十多万QPS的服务,TcpClient封装的粒度对底层控制不够,你应该考虑SocketAsyncEventArgs或者基于它的网络库,把接收缓冲、对象池、Socket选项全部握在自己手里。但如果你做的是设备数据采集、内部系统对接、中控服务、上下行指令这种"并发几百连接、单条消息几KB"的业务,TcpClient/TcpListener的封装绝对是性价比之王。标准库、无三方依赖、跨平台(.NET Core之后Linux和Windows一样跑),出了问题社区资料也最好找。
2. 服务端骨架:TcpListener从监听端口到拿到客户端连接的完整链路
服务端写起来其实比客户端简单,因为逻辑单一:先监听,再循环接受连接,每个连接交给处理器。但这里面有几个细节,第一次写的人几乎必踩。
2.1 监听地址的选择:IPAddress.Any不是随便写的
new TcpListener(IPAddress.Any, 9000)表示监听本机所有网卡上的9000端口。这意味着客户端可以用127.0.0.1连、用内网IP连、如果机器暴露到公网且防火墙放行,也能用公网IP连。
而new TcpListener(IPAddress.Parse("127.0.0.1"), 9000)只监听回环地址,只能本机自己访问,适合做本地工具服务或者调试。最常见的错误是:连不上服务端就怀疑代码,结果发现监听的是Loopback口,局域网里其他机器根本够不到。
还有一种需求是只允许指定网卡的IP进入,比如服务器有10.0.8.5这个内网地址,你就用IPAddress.Parse("10.0.8.5")指定监听这张网卡。这里我建议的做法是:监听地址放到配置项里,测试环境用127.0.0.1,生产环境用IPAddress.Any或特定内网IP,避免每次改代码。
2.2 Start之后,Accept循环是怎么转起来的
TcpListener.Start()只是把端口占住,真正拿到连接要靠Accept。最简单的写法:
var listener = new TcpListener(IPAddress.Any, 9000); listener.Start(); while (true) { TcpClient client = listener.AcceptTcpClient(); Console.WriteLine($"收到连接: {client.Client.RemoteEndPoint}"); _ = HandleClientAsync(client); }AcceptTcpClient()是同步阻塞的,Accept不到连接就卡在那一行。AcceptTcpClientAsync()则返回一个可等待的任务,配合await用。这两个在业务层效果差不多,但await那版不占线程池线程,我基本都用异步版。
需要注意的是backlog参数:listener.Start(backlog)可以指定内核中等待Accept的队列长度。如果连接来得太快,而你处理Accept的速度跟不上,超过backlog的连接会被内核直接拒绝。默认值在不同平台不完全一样,高并发生产环境我会显式设成128或256,宁可让连接排队等Accept,也不要让内核丢弃。
2.3 "拿到连接之后呢"——并发处理的入口
Accept循环拿到TcpClient之后,千万别在Accept循环里直接读数据。否则第一个客户端连上,你忙着读它的数据,第二个客户端就卡在backlog队列里了。正确姿势是把每个客户端交给独立的任务去处理:
while (true) { TcpClient client = await listener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleClientAsync(client)); } async Task HandleClientAsync(TcpClient client) { try { await using var stream = client.GetStream(); // 读数据、处理业务、回写…… } catch (Exception ex) { Console.WriteLine(ex.Message); } finally { client.Close(); } }有个容易被忽视的坑:Task.Run这种"发射后不管"的写法,如果HandleClientAsync内部没有完整包住try/catch,任何一个未捕获异常都会变成被系统"观察到的异常"。"丢"这个Task之前,一定要保证方法内部把异常吃干净。更讲究一点,可以用_ = Task.Run(...).ContinueWith(t => Console.WriteLine(t.Exception), TaskContinuationOptions.OnlyOnFaulted);做兜底日志。
3. 客户端侧:TcpClient连接建立、NetworkStream收发与优雅断开
服务端是被动等连接,客户端是主动出击。TcpClient在客户端侧的封装更明显:你不需要关心Bind、不需要关心Accept,就三件事:连上、收发、断掉。但要真的写得稳,至少有四个细节值得细抠。
3.1 Connect的两种写法和超时控制
TcpClient创建连接通常两种写法:
// 写法一:两步走,方便设置属性 var client = new TcpClient(); client.NoDelay = true; client.Connect("192.168.1.20", 9000); // 写法二:构造函数里直接连,代码最短 var client2 = new TcpClient("192.168.1.20", 9000);我推荐写法一。一来可以在Connect之前设置NoDelay、ReceiveTimeout这些影响连接的属性,二来可以插一个"连接超时"保护。TcpClient.Connect在没有指定超时的情况下,遇到网络黑洞(比如对端IP不通、防火墙丢弃SYN包)可能卡很久,这在线程里就是灾难。
从.NET 6开始,ConnectAsync支持CancellationToken,超时特别好写:
var client = new TcpClient(); using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); try { await client.ConnectAsync("192.168.1.20", 9000, cts.Token); } catch (OperationCanceledException) { Console.WriteLine("连接超时,放弃"); return; }如果是老框架,就用Task.WhenAny(client.ConnectAsync(...), Task.Delay(3000))这种套路,效果类似。连接超时一定要设,这是我做过几百次对接后最深的一条体会——网络故障里"连不上"和"连得慢"是两回事,没有超时保护的客户端会在故障时把整个调用方线程都拖死。
3.2 GetStream之后:NetworkStream的读写习惯
拿到连接后,真正的数据通道是client.GetStream()返回的NetworkStream。对多数场景,你甚至不该直接操作TcpClient的收发了——直接stream.Read()/stream.Write()就好。
await using var stream = client.GetStream(); // 发送:把字符串转成字节,写到流里 byte[] sendBytes = Encoding.UTF8.GetBytes("你好,服务端"); await stream.WriteAsync(sendBytes); await stream.FlushAsync();这里强调一点:NetworkStream默认不是缓冲流,WriteAsync之后数据基本会立刻发到网络。调用FlushAsync是为了语义完整,实际对NetworkStream几乎没影响。真正要注意的是WriteAsync之后不要立刻Close——TCP是双向的,你这边写完了,对端可能还有数据要回给你,立刻关连接会丢掉对端正在发送的数据。
接收侧,NetworkStream只在有数据时才返回,没有数据时ReadAsync会一直等。这个"等"既是好处也是坑——你不可能预先知道对端啥时候发消息,所以常用的模式是"一收一读"的循环:
byte[] buffer = new byte[1024]; while (true) { int read = await stream.ReadAsync(buffer, 0, buffer.Length); if (read == 0) { // 对端正常关闭连接(FIN包),读返回0 break; } Console.WriteLine($"收到 {read} 字节"); }Read返回0,只有一种情况:对端调用Close或者Shutdown正常关闭了连接。这个返回值一定要判断,不然你会以为一直没消息,结果在死循环里空转。
3.3 优雅断开:Shutdown、Close、Dispose到底怎么用
断开连接比多数人想得更讲究。直接client.Close()当然能断,但如果你正在读流,Close会直接抛异常,而且对端感知到的是RST而不是FIN——这会导致对端读到一个异常,而不是正常的"连接关闭"。
我习惯的优雅关闭顺序是:
// 1. 告诉对端:我不再发送数据了 client.Client.Shutdown(SocketShutdown.Send); // 2. 继续读,直到读到对端的回应或读到0(对端也关闭了) while (await stream.ReadAsync(buffer, 0, buffer.Length) > 0) { } // 3. 全关 client.Close();Shutdown(SocketShutdown.Send)会发出FIN包,语义是"我发完了,但还愿意收"。这给对端一个体面的机会把剩余数据发完。业务里不强制要求这么讲究,但对端如果读到异常会很困惑,生产环境对接第三方系统时,优雅关闭能少很多"为什么对方报连接异常"的沟通成本。
4. 多客户端并发:三种处理模型,以及我的务实选择
TCP服务端几乎必然面对多客户端。Accept循环每收到一个连接,就要思考"这个连接对应的工作放哪里跑"。这节的三种模型,我按复杂度从低到高讲。
4.1 一连接一线程(或一任务):最简单但也最有限
最直觉的做法是每来一个客户端就new Thread(HandleClient)或Task.Run(() => HandleClient(client))。好处是代码心智负担极低,每个连接的收发逻辑和单客户端通信一模一样,不需要想"谁来调度收发"。
代价是资源开销。虽然Task比线程轻,但每个连接都要占一个可用的Task调度,IO密集还好,如果是高频收发,上下文切换和内存占用会随连接数线性上升。我的经验是,这个模型在百级连接内完全够用,哪怕每个连接都是高频心跳,也没什么压力。但上了千级,你就该考虑下面的模型。
4.2 async/await + 会话对象:不增加线程数,也不失控制力
这是我最常用的方案,也是我要重点推荐的。核心思想是:主Accept循环只管接收连接,连接进来后注册一个"会话对象",会话内部用async/await做流式读写,完全不占独立线程。IO等待时线程会回到线程池,真正做到了几千个连接也不会开几千个线程。
public class ClientSession { private readonly int _id; private readonly TcpClient _client; private readonly NetworkStream _stream; public ClientSession(int id, TcpClient client) { _id = id; _client = client; _stream = client.GetStream(); } public async Task ProcessAsync() { /* 读写循环 */ } }然后在Accept循环里:
var session = new ClientSession(id, client); _sessions.TryAdd(id, session); _ = Task.Run(session.ProcessAsync); // 或直接 _ = session.ProcessAsync();会话对象的好处不只是并发:你可以把它放进ConcurrentDictionary<int, ClientSession>,实现"向所有在线客户端广播""按ID定向推送""统计在线状态"这类业务非常顺手。我做过的一个设备状态服务,就是用这个模型管着四五百个设备长连接,内存占用稳定在几十MB级别。
4.3 SocketAsyncEventArgs:高性能路线的真实成本
再往上就是SocketAsyncEventArgs(SAEA)了。它通过SocketAsyncEventArgs对象池+完成端口,把收发操作变成真正的异步IO,几乎不占用托管线程,处理上万个连接都没问题。但代价是代码复杂度陡增——你要自己管理缓冲数组池、自己拼包、自己复用SAEA对象,遗漏任何一个池化细节都会埋下内存泄漏或数据错乱的雷。
我的判断是:如果你的业务连接数真的到了几千或上万,并且每条连接的数据流量都不小,SAEA或基于它的开源网络库才值得引入。在绝大多数企业内部的采集、控制、消息转发场景里,async/await的模型已经跑到瓶颈之前,首要问题通常不是通信框架,而是业务逻辑或协议设计。
4.4 三个模型的横向对比
| 模型 | 适用连接量 | 代码量 | 控制力 | 典型场景 |
|---|---|---|---|---|
| Task每连接 | ≤ 200 | 低 | 低 | 内部调试、工具服务、简单设备对接 |
| async/await会话 | 数百 ~ 数千 | 中 | 中 | 绝大多数业务服务端 |
| SocketAsyncEventArgs | 数千 ~ 数万 | 高 | 高 | 网关、长连接推送、游戏服 |
如果让我给一个基本原则:先拿async/await会话模型起步,架构上把会话层独立出来,真到了瓶颈再局部替换IO层。通信框架最忌讳一上来就上最复杂的,因为排错成本会跟着复杂度指数上升。
5. 最容易翻车的四个点:粘包半包、假死连接、缓冲区与并发释放
这节是全文最重要的部分。前面讲的是怎么把TCP跑起来,这节讲的是怎么把TCP跑稳。这四个问题我几乎在每一个TCP项目里都见过,属于"教材不写但线上必现"的典型坑。
5.1 粘包半包:为什么TCP是"流"不是"消息"
假设你调用了两次WriteAsync,第一次发了hello,第二次发了world。对端读到的可能是helloworld一次出现(粘包),也可能是hel和loworld分两次到(半包)。这是TCP流的本性,不是bug。
解法只有一个:在应用层定义"消息边界"。常见协议设计有三种:
- 固定长度:每条消息固定N字节,不够就补位。适合报文长度恒定的命令协议。
- 分隔符:用
\r\n或\0结尾。适合文本命令,但内容本身不能包含分隔符。 - 长度前缀:先读4字节(或2字节)存消息体长度,再读这么长的消息体。最通用,二进制和文本都适合。
我所有项目都用长度前缀。原因很简单:它能承载任意长度的二进制数据,还能顺带做长度校验(比如声明长度超过1MB就断开),既不浪费字节,又不容易被内容里的特殊字符干扰。
下面这段是配套的"读够N字节"函数。注意ReadAsync一次返回的字节数不一定等于你申请的字节数,必须循环读:
static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int offset, int count) { int total = 0; while (total < count) { int read = await stream.ReadAsync(buffer, offset + total, count - total); if (read == 0) { throw new EndOfStreamException("连接在对端正常关闭(读到EOF)"); } total += read; } }有了ReadExactAsync,收一条消息的标准流程就变成:
// 1. 读4字节长度头 byte[] header = new byte[4]; await ReadExactAsync(_stream, header, 0, 4); int bodyLength = BitConverter.ToInt32(header, 0); // 2. 校验长度,防恶意帧 if (bodyLength <= 0 || bodyLength > 1_048_576) { throw new InvalidDataException($"非法长度: {bodyLength}"); } // 3. 读bodyLength字节的完整消息 byte[] body = new byte[bodyLength]; await ReadExactAsync(_stream, body, 0, bodyLength);这里有个安全细节:读取长度头之后,一定要做长度上限校验。如果不对,一个异常客户端发一个0xFFFFFFFF的长度,你的程序就会试图分配4GB的数组,直接内存溢出。长度校验是TCP服务端的第一道防线,加一个1MB限制也就是两行代码的事。
5.2 假死连接:TCP没有主动通知,只能靠探测
这是我踩得最深的坑。客户端程序崩溃、拔网线、断电,服务端是感知不到的——TCP的FIN包根本发不出来,服务端Socket还傻乎乎地以为连接活着。表现出来就是"客户端列表里挂着几十个早已死亡的设备"。
轻量级的探测手段是Socket的Poll + Available组合:
private static bool IsPeerGone(TcpClient client) { if (client.Client == null) { return true; } // 对端关闭连接(FIN/RST)后,Socket会变成可读,但没有数据可读 if (client.Client.Poll(1000, SelectMode.SelectRead) && client.Client.Available == 0) { return true; } return false; }这个判断只对"对端主动关闭或异常收到RST"的情况有效。对"网络完全断了但机器还活着"的半开连接,Poll是测不出来的,必须靠应用层心跳。
心跳协议是所有生产级TCP服务的基础设施:客户端每隔若干秒发一条心跳消息,服务端如果超过N秒没收到某个连接的任何数据,就判定它不健康,主动关闭并清理会话。心跳包的实现不复杂,就是在会话的读取循环之外,加一个Task.Delay循环检查"最近收到数据的时间戳",超过阈值就触发断开:
DateTime lastActive = DateTime.UtcNow; _ = MonitorAsync(); while (true) { // 读消息…… lastActive = DateTime.UtcNow; } async Task MonitorAsync() { while (true) { await Task.Delay(TimeSpan.FromSeconds(5)); if ((DateTime.UtcNow - lastActive) > TimeSpan.FromSeconds(30)) { Console.WriteLine("心跳超时,断开连接"); _client.Close(); break; } } }30秒这个阈值是按需求定的。心跳间隔5秒、超时30秒是比较常见的配置,既能容忍网络抖动,又不会让死连接赖着不走。
5.3 缓冲区大小与"读多读少"的坑
很多新手写接收逻辑是这样的:
byte[] buffer = new byte[1024]; int n = await stream.ReadAsync(buffer, 0, buffer.Length); // 直接把 buffer 转字符串处理这个写法最大的问题是:n是"这次读到的字节数",它不等于"一条完整消息的字节数"。你申请1024字节,TCP可能一次只给了16字节,也可能塞给你2000字节(剩下的在下一轮才读完)。在没有消息边界的情况下,直接处理buffer几乎是必然出乱码。
我的建议是:读缓冲区只是"临时落脚的桶",真正处理消息要按协议来。用长度前缀协议时,我习惯先读固定4字节头,再按头部声明长度分配body数组。这样每条消息的内存刚好够,不会多读也不会少读。
高频收发场景,每次new byte[bodyLength]会有不小的分配压力。优化手段是ArrayPool<byte>.Shared.Rent(bodyLength),用完后Return。这个优化等到你发现GC压力的时候再上就行,不要一开始就引入复杂度。
此外TcpClient.ReceiveBufferSize和SendBufferSize设置的是内核socket缓冲区大小,不是说设了4KB就能一次收4KB。默认值一般是8KB/64KB,绝大多数业务不需要动它,除非你的消息体普遍在几十KB以上,调大ReceiveBufferSize对吞吐可能有点帮助。记住一句话:应用层消息大小和socket缓冲区大小,是两个维度的事。
5.4 同一流上的并发读写与释放顺序
NetworkStream允许一个线程/任务读、另一个线程/任务同时写(全双工),这是没问题的。但同一方向上不要并发:两个任务同时调用WriteAsync,最后写的数据可能会交错;两个任务同时ReadAsync,读出来的数据顺序会乱。
如果你有多个业务线程要向同一个客户端写数据,正确做法是加锁或引入发送队列:
private readonly SemaphoreSlim _sendLock = new(1, 1); public async Task SendAsync(byte[] frame) { await _sendLock.WaitAsync(); try { await _stream.WriteAsync(frame); } finally { _sendLock.Release(); } }释放顺序也容易踩雷。如果读循环还在ReadAsync上阻塞,另一个线程直接client.Close(),阻塞的读任务会抛出ObjectDisposedException或IOException。这不是bug,但如果你没有在异常处理里区分"主动关闭"和"异常断线",日志就会被一堆无意义的异常刷屏。
我通常的释放顺序是:先取消读写用的CancellationTokenSource,再Close。读写循环把OperationCanceledException当作正常退出处理,这样整个关闭过程是"预期内"的,日志干净,对端也能收到FIN。
6. 一套可直接套用的通信骨架:长度前缀协议的服务端与客户端
讲了这么多,最后给一个能直接跑起来的最小骨架。我用了最简单的"长度前缀 + UTF-8文本"协议,你把它替换成自己的业务消息类即可。这个骨架我实测过,可以当作你项目的起点。
6.1 协议约定
- 帧 = 4字节长度头(Little-Endian) + 消息体(UTF-8编码)
- 长度头表示消息体的字节数
- 消息体最大限制 1MB
之所以选Little-Endian,是因为现代x86/ARM平台默认就是小端,直接BitConverter最省事。如果你要跟嵌入式设备或其他语言对接,先确认对方的字节序,这是常见协议坑之一。
6.2 服务端完整代码
using System.Collections.Concurrent; using System.Net; using System.Net.Sockets; using System.Text; var server = new TcpChatServer(9000); await server.StartAsync(); public class TcpChatServer { private readonly TcpListener _listener; private readonly ConcurrentDictionary<int, ClientSession> _sessions = new(); private int _nextId; public TcpChatServer(int port) { _listener = new TcpListener(IPAddress.Any, port); } public async Task StartAsync() { _listener.Start(256); Console.WriteLine($"监听 {_listener.LocalEndpoint}"); while (true) { TcpClient client = await _listener.AcceptTcpClientAsync(); int id = Interlocked.Increment(ref _nextId); var session = new ClientSession(id, client, _sessions); _sessions[id] = session; Console.WriteLine($"客户端 #{id} 接入,在线 {_sessions.Count}"); _ = Task.Run(session.ProcessAsync); } } } public class ClientSession { private readonly int _id; private readonly TcpClient _client; private readonly NetworkStream _stream; private readonly ConcurrentDictionary<int, ClientSession> _sessions; public ClientSession(int id, TcpClient client, ConcurrentDictionary<int, ClientSession> sessions) { _id = id; _client = client; _client.NoDelay = true; _stream = client.GetStream(); _sessions = sessions; } public async Task ProcessAsync() { try { while (true) { byte[] header = new byte[4]; await ReadExactAsync(_stream, header, 0, 4); int bodyLength = BitConverter.ToInt32(header, 0); if (bodyLength <= 0 || bodyLength > 1_048_576) { throw new InvalidDataException($"非法消息长度 {bodyLength}"); } byte[] body = new byte[bodyLength]; await ReadExactAsync(_stream, body, 0, bodyLength); string message = Encoding.UTF8.GetString(body); Console.WriteLine($"#{_id} 说: {message}"); string reply = $"服务端收到: {message}"; await SendAsync(reply); } } catch (Exception ex) { Console.WriteLine($"#{_id} 连接结束: {ex.Message}"); } finally { _sessions.TryRemove(_id, out _); _client.Close(); Console.WriteLine($"#{_id} 下线,在线 {_sessions.Count}"); } } public async Task SendAsync(string message) { byte[] body = Encoding.UTF8.GetBytes(message); await SendRawAsync(body); } private async Task SendRawAsync(byte[] body) { byte[] frame = new byte[4 + body.Length]; WriteInt32LittleEndian(frame, 0, body.Length); Buffer.BlockCopy(body, 0, frame, 4, body.Length); await _stream.WriteAsync(frame, 0, frame.Length); await _stream.FlushAsync(); } private static void WriteInt32LittleEndian(byte[] buffer, int offset, int value) { buffer[offset] = (byte)value; buffer[offset + 1] = (byte)(value >> 8); buffer[offset + 2] = (byte)(value >> 16); buffer[offset + 3] = (byte)(value >> 24); } private static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int offset, int count) { int total = 0; while (total < count) { int read = await stream.ReadAsync(buffer, offset + total, count - total); if (read == 0) { throw new EndOfStreamException("对端已关闭连接"); } total += read; } } }这个服务端把会话放进了ConcurrentDictionary,后续你要做广播、定向推送、在线列表,直接遍历_sessions就行。NoDelay = true很重要——默认开启Nagle算法,小消息会被攒在一起再发,实时性会变差,做指令下发时体验尤其明显。
6.3 客户端完整代码
using System.Net; using System.Net.Sockets; using System.Text; var client = new TcpClient { NoDelay = true }; try { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3)); await client.ConnectAsync(IPAddress.Parse("127.0.0.1"), 9000, cts.Token); } catch (OperationCanceledException) { Console.WriteLine("连接超时,退出"); return; } await using var stream = client.GetStream(); // 发送一条消息 await SendStringAsync(stream, "你好,C# 的 TCP 服务端"); Console.WriteLine("已发送"); // 接收回复 try { byte[] header = new byte[4]; await ReadExactAsync(stream, header, 0, 4); int bodyLength = BitConverter.ToInt32(header, 0); byte[] body = new byte[bodyLength]; await ReadExactAsync(stream, body, 0, bodyLength); Console.WriteLine($"收到回复: {Encoding.UTF8.GetString(body)}"); } catch (Exception ex) { Console.WriteLine($"接收失败: {ex.Message}"); } client.Close(); static async Task SendStringAsync(NetworkStream stream, string message) { byte[] body = Encoding.UTF8.GetBytes(message); byte[] frame = new byte[4 + body.Length]; WriteInt32LittleEndian(frame, 0, body.Length); Buffer.BlockCopy(body, 0, frame, 4, body.Length); await stream.WriteAsync(frame, 0, frame.Length); await stream.FlushAsync(); } static void WriteInt32LittleEndian(byte[] buffer, int offset, int value) { buffer[offset] = (byte)value; buffer[offset + 1] = (byte)(value >> 8); buffer[offset + 2] = (byte)(value >> 16); buffer[offset + 3] = (byte)(value >> 24); } static async Task ReadExactAsync(NetworkStream stream, byte[] buffer, int offset, int count) { int total = 0; while (total < count) { int read = await stream.ReadAsync(buffer, offset + total, count - total); if (read == 0) { throw new EndOfStreamException("连接已被对端关闭"); } total += read; } }这段客户端在127.0.0.1上收发一条消息就退出,属于最小验证。你真要做长连接客户端,把"接收回复"部分改成while (true)循环,再加心跳逻辑即可。
6.4 调试TCP服务端的几个实用心得
最后分享几个只有踩过坑才记得住的经验。
第一,先跑通本机回环。服务端监听IPAddress.Any,客户端连127.0.0.1,先把通信用例验证完,再放到局域网、再放到跨网段。三步走能帮你把"代码问题"和"网络环境问题"分开。
第二,善用抓包工具。本机抓包看9000端口的握手和FIN/RST,能瞬间判断"连接到底断没断、谁先断的"。抓包结果里如果看到大量RST包,多半是某一边直接Close导致异常终止。这一点对排查假死连接问题几乎是一击必中。
第三,日志里记关键帧。我所有的TCP服务都会在收发两端各打一行日志:时间、方向、消息长度、消息摘要(截断前64字节)。出现问题时,直接看两端日志的时间线就能判断是哪一端丢了包或者延迟了。别等线上问题才想着加日志——TCP调试里日志就是最好的"记忆",等出问题时再补加就来不及了。
第四,用netstat -ano | findstr 9000看端口监听状态和已建立连接数。如果连接数持续上涨但业务在线数没涨,大概率就是假死连接没被清理干净,回来看心跳逻辑。
这套骨架我从第一次做C# TCP项目用到现在,换过几个业务场景,核心"会话 + 长度前缀协议 + 心跳"的框架一直没变。你从它起步,把协议换成自己的业务格式,把心跳和掉线重连补上,就是一个能上生产的TCP通信模块。真要说有什么远方,就是这套骨架足够薄,后续真要换SAEA或者其他高性能库,业务层基本不用动——通信层和业务层的边界,一开始就别搅在一起。