简介:这是一份基于C#语言实现的TCP/IP异步通信示例工程,面向需要掌握网络编程基础与异步Socket开发技巧的初、中级开发者,也适合作为课程设计或毕业设计的参考。压缩包共收录29个文件,其中C#源文件多达14个,构成服务器端与客户端的核心逻辑;其余包含界面资源文件、解决方案工程配置、用户设置以及说明文档,整体打包后仅有34KB,结构紧凑。目前已有153人学习。工程同时提供了异步服务器与异步客户端两套项目,演示了TcpListener端口监听、TcpClient发起连接、通过NetworkStream异步读写数据等关键环节,并进一步处理了字符串编码转换、Socket异常捕获和缓冲区复用等实际问题,能够帮助读者理清异步TCP通信的完整流程,并为后续开发实时聊天、远程控制等网络应用提供可复用的代码基础。
1. 异步TCP:为什么你的上位机一收发数据界面就卡死
如果你写过C#上位机或者Socket通信,大概率遇到过这个场景:用TcpClient的同步方法收发数据,点击"连接"按钮后界面直接无响应,拖动窗口跟拖一块石头似的。问题根源在于同步API阻塞了调用线程,而UI线程恰好就是那个被堵死的倒霉蛋。异步TCP用回调或者async/await把网络操作挂起,线程先去干别的事,数据来了再回来处理。这份TCP.rar资源是C#实现的异步TCP通信项目,里面是完整的AsyncTcpServer和AsyncTcpClient两个工程,适合正在做上位机、物联网网关、设备通信的开发者参考,也适合刚接触Socket编程想搞明白异步模式怎么落地的初学者。它能解决的核心问题是:在C#里怎么写一个不卡界面、不丢数据、能扛多客户端连接的异步TCP通信层。
2. 先搞懂异步模型:回调模式和Task模式怎么选
2.1 异步Socket的本质是"你等它,还是它喊你"
同步模式里,Receive方法执行到RTO超时之前,调用线程一直挂在系统调用上。异步模式的核心思想是:把耗时的网络操作交给操作系统,完成之后通过回调通知你。C#里传统写法是BeginReceive/EndReceive这种基于IAsyncResult的模式,加AsyncCallback委托;新写法是直接用async/await配合Task。两种模式在System.Net.Sockets.Socket底层都有完整支持。
我先说结论:新项目直接用async/await,别碰BeginXXX那套。原因有两点。第一,async/await的代码读起来是线性的,出错排查容易,而回调模式控制流是反的——先调用BeginReceive,然后在EndReceive里再调下一个BeginReceive,这个递归回调链一旦中间出个异常没接住,整个接收循环就死了。第二,两个模式混用会踩坑,比如在async方法里调BeginReceive,回调里又用await,上下文切换和SynchronizationContext的处理会很混乱。
2.2 把这套异步模型落到TCP通信层的基本骨架
一个可复用的异步TCP通信层,核心拆成三块:连接管理、数据收发、状态上报。连接管理负责Accept新连接、记录在线客户端、处理断开回调;数据收发负责接收字节流、按协议拆包、发送响应;状态上报负责把连接状态、收发日志抛给上层UI。
下面这段是AsyncTcpServer的核心接收循环,用async/await实现:
private async void AcceptLoop() { while (!_cancellationToken.IsCancellationRequested) { try { Socket clientSocket = await _listener.AcceptAsync(); _ = Task.Run(() => HandleClientAsync(clientSocket)); } catch (ObjectDisposedException) { break; // listener已关闭 } catch (Exception ex) { OnError?.Invoke(this, new ExceptionEventArgs("Accept错误", ex)); } } } private async Task HandleClientAsync(Socket clientSocket) { var buffer = new byte[4096]; var remoteEndPoint = clientSocket.RemoteEndPoint?.ToString(); OnClientConnected?.Invoke(this, new ClientEventArgs(remoteEndPoint ?? "")); while (!_cancellationToken.IsCancellationRequested) { try { int received = await clientSocket.ReceiveAsync( new ArraySegment<byte>(buffer), SocketFlags.None); if (received == 0) { break; // 对端正常关闭 } byte[] data = new byte[received]; Array.Copy(buffer, data, received); OnDataReceived?.Invoke(this, new DataEventArgs(remoteEndPoint ?? "", data)); } catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionReset) { break; // 对端强制关闭 } catch (Exception ex) { OnError?.Invoke(this, new ExceptionEventArgs("Receive异常", ex)); break; } } OnClientDisconnected?.Invoke(this, new ClientEventArgs(remoteEndPoint ?? "")); clientSocket.SafeClose(); }代码逻辑拆解:AcceptAsync挂起等待新连接,每 accept 一个客户端就丢到一个独立Task里去处理,这就是"多客户端不互相阻塞"的关键——每个连接有自己的接收循环。HandleClientAsync里ReceiveAsync挂起等待数据到达,不会占着CPU。received == 0表示对端正常关闭(发送了FIN),要退出循环;ConnectionReset表示对端发来RST,比如客户端程序异常退出没走正常关闭流程。异常分支必须兜底,否则其中一个客户端崩了,整个服务进程也跟着崩。
参数说明:缓冲区大小用4096字节是通用保守值,适合报文几百字节到几K的工控场景。如果业务报文特别大(比如>4KB的单包),这个值要往上调,不然一次接收只能截到前4K,协议层需要再做重组。SafeClose是一个扩展方法,里面做了关闭和释放的双重保险,直接用Close()可能漏掉Dispose导致句柄泄漏。
再补一个发送侧的写法。接收有粘包拆包问题,发送也有一个老坑:多个线程同时调SendAsync,字节可能交错。你从业务层开三个线程往同一个客户端写数据,底层如果没做序列化,对端收到的包顺序会乱。我一般在TcpSession类里放一个SemaphoreSlim(或一颗锁)保证同一时刻只有一个发送在飞:
private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); public async Task SendAsync(byte[] data) { await _sendLock.WaitAsync(); try { await _socket.SendAsync(new ArraySegment<byte>(data), SocketFlags.None); } finally { _sendLock.Release(); } }SemaphoreSlim(1, 1)意思是同一时间只允许一个任务进入临界区,第二个SendAsync会排队等第一个完成后才发,这样不同线程的发送操作不会互相穿插、不会出现半个包混着别的包内容发出去的情况。
2.3 服务端要管理好连接表,别丢了客户端句柄
AsyncTcpServer里的多个客户端连接是并行的,每个HandleClientAsync都在各自的Task上跑。你绝不能用一个局部变量去存客户端Socket,因为下一个连接进来了局部变量就变了。正确做法是维护一个ConcurrentDictionary<string, TcpSession>,key用RemoteEndPoint.ToString(),value是对应的包装会话。
private readonly ConcurrentDictionary<string, TcpSession> _sessions = new ConcurrentDictionary<string, TcpSession>();连接进来就TryAdd,断开就TryRemove。枚举遍历时用ToArray()拷贝快照,别直接foreach字典,因为字典可能在遍历过程中被别的线程TryRemove,典型的"集合已修改"的运行时异常就出在这里。
3. 客户端侧的异步收发:从连接、发送到断线重连
3.1 用TcpClient包装类压住异步细节
服务端用Socket直接用到底,因为TcpListener.AcceptAsync()返回的本来就是Socket,没必要再包一层TcpClient。但客户端侧不一样,连接服务器要配IP、端口、超时、重连策略,这些逻辑包到一个AsyncTcpClient类里更干净。下面这个类是资源中客户端工程的常用骨架:
public class AsyncTcpClient : IDisposable { private TcpClient _tcpClient; private NetworkStream _stream; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public async Task ConnectAsync(string ip, int port, int timeoutMs = 3000) { _tcpClient = new TcpClient(); using (var cts = new CancellationTokenSource(timeoutMs)) { try { await _tcpClient.ConnectAsync(ip, port, cts.Token); } catch (OperationCanceledException) { throw new TimeoutException($"连接{ip}:{port}超时({timeoutMs}ms)"); } } _stream = _tcpClient.GetStream(); _ = ReceiveLoopAsync(_cts.Token); } private async Task ReceiveLoopAsync(CancellationToken token) { var buffer = new byte[4096]; while (!token.IsCancellationRequested) { int received = await _stream.ReadAsync(buffer, 0, buffer.Length, token); if (received == 0) break; OnDataReceived?.Invoke(this, new DataEventArgs(buffer.Take(received).ToArray())); } } }关键参数说明:ConnectAsync的第三个参数timeoutMs设默认3秒,上位机连设备时如果不加超时,ConnectAsync可能因为系统TCP栈的重传机制挂十几秒。用CancellationTokenSource(timeoutMs)做连接超时,到点就抛OperationCanceledException,再转成业务层的TimeoutException,这样UI层能分辨"连不上"和"超时"两种失败。ReceiveLoopAsync用_stream.ReadAsync而不是Socket.ReceiveAsync,因为有了NetworkStream之后,流的API更贴近读写文件的编程习惯,内部还是异步Socket。
注意区分ReadAsync(buffer, 0, buffer.Length, token)和Socket级ReceiveAsync(ArraySegment<byte>, SocketFlags):前者是NetworkStream的API,后者是Socket的API,两者都阻塞当前线程直到有数据或有异常,但底层回调分发机制不同。混用时别指望两种调用能共享同一个缓冲区。
3.2 Connect之后立刻启动ReceiveLoop,别等发送才建接收
新人最常见的翻车:写完ConnectAsync,迫不及待开始SendAsync,发现收到服务器回包时没有回调。原因不是服务器没回,而是你没有启动接收循环——数据全滞留在操作系统缓冲区里,没有代码把它读出来。正确的顺序是:连接成功 → 立即启动ReceiveLoopAsync→ 再进业务逻辑。上述代码已经把_ = ReceiveLoopAsync(_cts.Token);放进了ConnectAsync里,这是刻意安排的。
3.3 断线重连要有退避策略,不能无脑死循环
工控现场,设备端可能断电重启、网线可能被误拔,客户端必须能自动恢复。但重连不能写个while(true)高频重试,不然服务器一恢复瞬间被冲垮。常见的做法是带指数退避:
private async Task ReconnectLoopAsync() { int retryDelayMs = 1000; const int maxDelayMs = 30000; while (!_cts.IsCancellationRequested) { try { await ConnectAsync(_ip, _port, 3000); retryDelayMs = 1000; // 连上了就重置退避 OnReconnected?.Invoke(this, EventArgs.Empty); break; } catch (Exception) { OnReconnectFailed?.Invoke(this, new ReconnectEventArgs(retryDelayMs)); await Task.Delay(retryDelayMs, _cts.Token); retryDelayMs = Math.Min(retryDelayMs * 2, maxDelayMs); } } }退避策略好处:第一次失败等1秒,再失败等2秒、4秒、8秒……最多30秒一次。这样服务器恢复期间,你的客户端在最坏情况下每30秒探一次,不会对服务器产生压力。Task.Delay传了_cts.Token,程序关掉时重连循环能立刻停下来,不用等当前延时结束。
4. 避坑指南:异步TCP最常踩的六个坑
4.1 粘包半包:收到数据要按协议拆,不能按字节数当一条消息
现象:客户端发了三条消息,服务端ReceiveAsync一次返回的数据里可能包含两条半,下次返回半条,业务层解析直接乱套。
原因:TCP是字节流协议,不保留消息边界,你调Send三次跟调Send一次合并发送,在网络层面上没有区别;对端接收到的字节流是内核帮你拼好的,不保证每次Receive正好对应一次Send。
解决:必须自己定应用层协议,常见做法是"4字节长度头 + 消息体",也叫LengthField。接收时先把4字节头读完整,再按长度读消息体。我一般用MemoryStream累积缓冲:
private readonly MemoryStream _bufferStream = new MemoryStream(); private byte[] TryParsePacket() { _bufferStream.Position = 0; if (_bufferStream.Length < 4) return null; // 头还没齐 byte[] header = new byte[4]; _bufferStream.Read(header, 0, 4); int bodyLen = BitConverter.ToInt32(header, 0); if (_bufferStream.Length < 4 + bodyLen) return null; // 体还没齐 _bufferStream.Position = 4; byte[] body = new byte[bodyLen]; _bufferStream.Read(body, 0, bodyLen); // 移除已消费的数据 byte[] remaining = _bufferStream.ToArray().Skip(4 + bodyLen).ToArray(); _bufferStream.SetLength(0); _bufferStream.Write(remaining, 0, remaining.Length); return body; }TryParsePacket在ReceiveLoop里每次拿到新数据后调用。头没齐返回null等下一次,体没齐也返回null,但要注意MemoryStream的Position在跨多次接收时不能重置——所以每次调用开头强制Position = 0。
4.2 SocketException远程主机强迫关闭
现象:客户端连着服务器,突然抛SocketException: 远程主机强迫关闭了一个现有的连接,且异常里SocketErrorCode == ConnectionReset。
原因:对端进程崩溃、拔网线、或者服务端没走Shutdown直接Close,内核会发RST而不是FIN,本端下一次ReceiveAsync立刻抛异常。RST不像FIN那样优雅,没有"收到0字节"的过渡,直接炸。
解决:把ConnectionReset和ConnectionAborted这两个错误码单独捞出来,按"对端断开"处理,该清理清理,该重连重连,别把它当普通异常上报到UI弹一堆红色弹窗:
catch (SocketException ex) when (ex.SocketErrorCode == SocketError.ConnectionReset || ex.SocketErrorCode == SocketError.ConnectionAborted) { // 对端异常断开,进入清理流程 }4.3 异步回调里抛的异常被吞掉
现象:程序跑着跑着突然没反应了,日志里什么都没有,但网络连接确实断了。
原因:在async void事件回调或者Task里抛的异常,如果没有被try-catch捕获,进程可能直接崩溃(async void),也可能异常被框架吞掉(Task没被await,GC兜底时抛UnobservedTaskException)。该机制的微妙之处在于:你根本不知道异常发生的确切时间点,排查难度跟破案一样。
解决:每条async void事件处理器里必须有全局try-catch;每个_ = Task.Run(...)的委托开头也要包try-catch。事件回调除了一个入口点之外,内部不抛结构化异常,全部转成OnError事件抛出去,由UI层统一记录。这必须写进Code Review清单,血泪经验。
4.4 UI线程假死:await之后回不到UI线程
现象:点击按钮连接服务器,连接过程中界面还能动,连接成功后拖动窗口开始卡。
原因:await默认捕获当前SynchronizationContext并回到其中执行。但如果你在后台Task里直接调了UI的控件属性,或者在事件回调里用了.ConfigureAwait(false)之后又碰到UI依赖,就会出现线程错乱。反过来,有些UI之间互相等待造成死锁。
解决:约定三条。第一,所有await后面如果需要刷新UI,一律await完直接更新(WPF/WinForms会回到UI上下文);如果是在无UI上下文的后台线程,ConfigureAwait(false)可以放宽。第二,事件回调抛数据给UI层时,用Control.BeginInvoke或Dispatcher.BeginInvoke,不要在事件回调线程里直接碰控件。第三,UI的按钮点击处理器里不能有.Result或.Wait(),这两个同步阻塞会和async void的上下文形成死锁。
4.5 关闭连接时的ObjectDisposedException
现象:点"停止服务"之后再测试连接,服务端抛ObjectDisposedException: 无法访问已释放的对象。
原因:关闭流程没有先停接收循环再关Socket,或者关闭操作触发了AcceptAsync/ReceiveAsync的回调,回调里又试图访问已释放的Socket。
解决:关闭要按序走:先CancellationTokenSource.Cancel()停接收循环,再Socket.Shutdown(Both),再Close()。AcceptLoop的ObjectDisposedException分支用break退出,不能在这个异常里做重试逻辑,因为此时资源已经处于销毁状态,重试只会生成更多异常。
4.6 缓冲区不够大导致的数据截断
现象:服务器传来一个6000字节的报文,客户端只收到4096字节,后面丢了。
原因:ReceiveLoop里的buffer定死为4096,一次ReadAsync只能读4KB,没读完的字节留在内核缓冲区。第二次ReadAsync能接着读,但如果协议是按"一条消息必须完整一次取回"设计,第二次读到的开头就是同一消息的后半截,业务层当"新消息"处理就错了。
解决:缓冲区大小不能拍脑袋定。先看协议最大报文长度:如果上限是16KB,buffer至少16KB+余量(我习惯1.5倍)。另外接收逻辑必须和粘包拆包配合——缓冲区大小决定了每次内核能取到的最大连续字节数,拆包逻辑是幂等的,不管一大块分几次到,最终都能拼出完整消息。记住拆包是缓冲区的补充,不是替代品,Buffer太小,拆包逻辑也没用。
5. 性能调优与稳定性加固:从"能跑"到"扛得住"
5.1 服务端并发能力:避免每连接一线程的过时做法
旧代码里常见的做法是Accept之后new Thread处理客户端,这在几十个连接时还行,几百上千个连接时会拖垮线程池:线程栈默认1MB,1000条连接光是栈就得占1GB虚拟内存。异步模型的优势在于不占线程——网络操作期间线程就被释放了。
AsyncTcpServer的AcceptLoop里_ = Task.Run(...)仍然会吃线程池线程,但那是"连接活跃期间"而不是"连接存活期间",数据到达时才短暂占有线程,空闲连接几乎零开销。如果要彻底优化,可以改成全程无Task.Run,只用Socket的异步API在单线程事件循环里驱动,但代码复杂度直线上升。我的建议是:如果是上位机场景几十个连接,现有模型完全够用,别过早优化;如果是服务端C10K场景,直接换SocketAsyncEventArgs,那套API用池化的SocketAsyncEventArgs对象避免每次分配,做高并发服务器内存分配压力最小。
5.2 缓冲区复用:避免每次Receive都new数组
上面代码里new byte[4096]在ReceiveLoop里只创建一次,这是对的。要注意的是拆包时Array.Copy再分配一次存储,这里也是必要的,因为要把"缓冲区内的有效数据"和"缓冲区本身"解耦。高并发场景下,byte[] data = new byte[received]的分配频率等于消息频率,可以用ArrayPool<byte>来压GC压力:
byte[] rented = ArrayPool<byte>.Shared.Rent(received); Array.Copy(buffer, 0, rented, 0, received); try { OnDataReceived?.Invoke(this, new DataEventArgs(rented, received)); } finally { ArrayPool<byte>.Shared.Return(rented); }注意:Rent拿到的数组长度可能大于received,所以事件参数必须传有效长度,接收逻辑里只能访问前received个字节。Return之后数组内容会被后续租用覆盖,事件回调里如果异步处理数据,得等处理完再Return,否则会产生数据串包(A连接的数据出现在B连接里)。
5.3 心跳机制:应用层保活是最后一道防线
TCP的KeepAlive默认需要2小时无流量才探测,工控场景根本等不起,设备掉线半个多小时才发现,复盘时发现服务器日志显示设备早已拔线。所以应用层必须做心跳。
心跳常见的坑:心跳包和业务包混在一起,业务层收到心跳也要走完整拆包逻辑,不然心跳包长度不对会导致正常报文解析错位。我用的是"心跳独立标记"策略:0xAA作为心跳标志字节,不套长度头,收到它只刷新"最后活跃时间",不进业务管道。
服务器侧的心跳检查逻辑:每30秒扫描一次全部客户端,超过90秒没收到任何数据就判定超时断开。这个扫描动作要放在Timer回调里,不能占着接收线程做——接收线程只负责收和转,永不做耗时操作。
5.4 日志的结构化:能定位"谁""什么时候""干了什么"
异步程序的问题排查比同步难一个量级,因为你不知道执行流在哪个Task上,日志里没有上下文根本无从下手。我给AsyncTcpClient设计日志时,强制每条日志包含:
- 连接唯一标识:
client-{ip}:{port}-{连接序号} - 线程与Task ID:
Environment.CurrentManagedThreadId - 操作名:
Connect/Send/Receive/Close - 方向标记:
-> 发送/<- 接收
日志样例:
[t=1][tid=6][client-192.168.1.10:502-3] -> 发送 12字节: 01 03 00 00 00 01 84 0A [t=2][tid=12][client-192.168.1.10:502-3] <- 接收 7字节: 01 03 02 01 02 79 1A只要日志规范了,"连接被服务器关闭"这类问题,一看日志就知道最后一次收发间隔多久、是哪个方向断的。这套格式在调试Modbus TCP、自定义协议网关时非常好用。
6. 压测验证方法:这台服务器到底能扛多少并发
资源里提供的AsyncTcpServer工程编译出来之后,别急着接业务,先做一轮纯压测验证异步模型是否生效。压测工具我推荐用Python脚本而不是又写一套C#客户端——Python的async+asyncio可以快速造出几百个并发连接,给C#服务端施加真实压力。
import asyncio import time async def client_session(client_id: int, host: str, port: int, count: int): try: reader, writer = await asyncio.open_connection(host, port) for i in range(count): msg = f"client-{client_id}-msg-{i}".encode('utf-8') writer.write(msg) await writer.drain() resp = await reader.read(4096) if resp != msg: print(f"[FAIL] {client_id} 第{i}条响应不匹配") writer.close() await writer.wait_closed() except Exception as e: print(f"[ERROR] client-{client_id}: {e}") async def main(): host = "127.0.0.1" port = 9000 clients = 200 msgs_per_client = 100 tasks = [client_session(i, host, port, msgs_per_client) for i in range(clients)] start = time.time() await asyncio.gather(*tasks) elapsed = time.time() - start total_msgs = clients * msgs_per_client print(f"完成: {clients}连接 x {msgs_per_client}条 = {total_msgs}条, 耗时{elapsed:.2f}s, 吞吐{total_msgs / elapsed:.0f}条/s") if __name__ == "__main__": asyncio.run(main())这段Python压测脚本说明:asyncio.open_connection建立的不只是连接,而是返回reader/writer流式对象,drain()确保数据写完再继续发下一条,如果C#服务端没做发送锁,这里就可能暴露出交错发送的问题。200个客户端同时发起100条消息,总共2万条请求,服务端如果每条都正确回显且不丢数据,说明异步模型是健康的。如果跑一半连接被重置,回到第4.2节查错误码;如果响应乱序,检查服务端的发送锁和拆包逻辑。
再验证一个隐藏问题:用netstat -ano | findstr 9000看服务端的ESTABLISHED连接数,应该稳定在200左右。如果远高于200或者大量TIME_WAIT,说明服务端在处理关闭连接时有泄漏或没有及时清理。TIME_WAIT是主动关闭方的现象,被动关的进程一般看不到TIME_WAIT堆积,如果出现了异常数量的TIME_WAIT,多半是你的服务端主动关闭了连接,但关闭逻辑写在了不该写的地方(比如OnDataReceived里误判断了空连接)。
最后说一个我自己的习惯:每轮压测结束,强制跑一遍"疯狂开关连接"测试——连续开关500次连接,看服务端会不会在文件描述符、Socket句柄上疯狂上涨。异步模型最常见的问题不是并发不够,是连接关了但句柄没释放,跑一个晚上内存和句柄数就上去了。从那以后,每次改完通信代码我都强制走一遍压测加开关测试,先确认这层不会泄漏,再谈业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取