news 2026/10/19 14:07:41

C# TCP通信实战:TcpClient/TcpListener用法与粘包心跳处理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C# TCP通信实战:TcpClient/TcpListener用法与粘包心跳处理

做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或者其他高性能库,业务层基本不用动——通信层和业务层的边界,一开始就别搅在一起。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 14:07:40

HFSS Help.pdf高效检索指南:从文档结构到仿真排错实战

简介&#xff1a;《HFSS Help.pdf》是ANSYS HFSS 2020 R1官方帮助文档的完整PDF版&#xff0c;面向从事高频电磁仿真、天线设计、信号完整性及微波电路研发的工程师和研究人员&#xff0c;可当作随查随用的工具手册。压缩包内共1个PDF文件&#xff0c;约105.29MB&#xff0c;内…

作者头像 李华
网站建设 2026/10/19 14:06:58

Kafka源码阅读实战:从rebalance卡死定位四大核心模块

1. 为什么“看源码”不是摆拍&#xff0c;而是 Kafka 使用者绕不开的生存技能“Kafka 源码学习”这六个字&#xff0c;在很多人的技术履历里像一枚镀金勋章——写在简历上显得厚重&#xff0c;面试时被问到能支吾两句&#xff0c;日常工作中却常年锁在收藏夹最底层。我见过太多…

作者头像 李华
网站建设 2026/10/19 14:06:06

open-code-review:基于Git Diff与LLM Agent的轻量级代码审查协议

1. 项目概述&#xff1a;这不是一个“代码审查工具”&#xff0c;而是一套可嵌入开发流程的轻量级开放协作协议“open-code-review”这个名字乍一听像某个开源项目&#xff0c;但实际它代表的是一种正在快速落地的新型协作范式——把传统意义上发生在 Pull Request 页面里的、由…

作者头像 李华
网站建设 2026/10/19 14:05:09

LangChain 核心组件与实战指南:从流水线编排到 RAG 落地避坑

第一次把 LangChain 装进自己的项目时&#xff0c;我其实挺不以为然的——不就是把大模型接口包一层吗&#xff1f;后来在某个人知识库问答项目里反复重构&#xff0c;我才发现真正的痛点不在于“调用模型”&#xff0c;而在于“如何组织一堆围绕模型的杂事”。LangChain 这个框…

作者头像 李华
网站建设 2026/10/19 14:03:04

SVR回归预测全流程:特征组合寻优与SHAP可解释分析

前一阵子我把一个基于回归预测的完整项目收敛成了一条相对成型的工作流&#xff1a;数据预处理阶段拼出198种特征工程与模型配置组合&#xff0c;中段接入SVR支持向量机回归并做参数寻优&#xff0c;后端用SHAP做可解释分析&#xff0c;最后在全新数据上验证预测效果。整套流程…

作者头像 李华
网站建设 2026/10/19 14:02:46

new关键字跨语言深度解析:对象创建、原型链与内存管理陷阱

new关键字大概是程序员最早背下来的语法之一&#xff0c;但也是最容易被随手带过的一个词。写代码的前两年&#xff0c;我觉得 new 就是在“变出一个对象”——new 一个日期、new 一个数组、new 一个组件&#xff0c;至于背后发生了什么&#xff0c;完全没想过。直到有一次排查…

作者头像 李华