C# TCP/IP服务端与客户端源码实战:从跑通到避坑

发布时间:2026/10/10 17:23:11
C# TCP/IP服务端与客户端源码实战:从跑通到避坑
简介这份源码资源面向具备一定 C# 基础、希望深入理解网络通信机制的学习者与开发者围绕 TCP/IP 协议给出服务端与客户端的完整实现范例。内容基于 .NET 的 System.Net、System.Net.Sockets 命名空间演示 TcpListener 侦听端口、TcpClient 建立连接、NetworkStream 收发数据等核心流程并涉及多客户端并发、多线程与 async/await 异步处理、SslStream 加密传输及资源管理等进阶主题可作为网络编程入门到实战的参考。压缩包共 273 个文件约 5.94MB以 77 个 cs 源码文件为主辅以 csproj、sln 工程文件、resx 资源、exe 与 dll 编译产物、txt 说明及 ico、png、jpg 等图标图片工程结构完整可直接打开运行调试。目前已有 69 人学习下载适合对照源码理解连接建立、数据收发与异常处理思路并在此基础上扩展自己的网络应用。1. 从一份 c#写的tcpip服务端与客户端源码说起为什么我建议你亲手跑一遍很多人第一次接触网络编程是从一份 c#写的tcpip服务端与客户端源码开始的。下载下来双击运行服务端窗口一片空白客户端连上去发一句 hello服务端打印出来然后就没有然后了。这份源码能跑但你未必真的看懂了它为什么能跑。我见过太多人拿着这类源码改端口、改 IP改完连不上就开始怀疑人生最后把问题归结为玄学。这份源码真正值钱的地方不是那几十行Socket调用而是它把 TCP/IP 通信里最容易被忽略的三件事摆到了台面上连接是怎么建立的、数据是怎么切分的、断开是怎么被感知的。你只有亲手把它跑起来再故意制造几次断线、粘包、半包才能理解为什么生产环境的服务端不能只写一个Accept循环就完事。这篇文章面向的是想用 C# 做上位机、做设备网关、做内部工具通信的开发者尤其是那些需要把 TCP 服务端和客户端同时握在手里的人。下面我会从选型、最小可运行代码、参数调优、踩坑排查一路讲到进阶用法尽量让你照着就能复现。2. 拆开这份源码TcpListener 与 TcpClient 到底替你做了什么2.1 为什么常见做法是 TcpListener 而不是裸 Socket在 C# 里写 TCP 服务端最底层的入口是System.Net.Sockets.Socket。你可以直接new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)然后自己Bind、Listen、Accept。但绝大多数 c#写的tcpip服务端与客户端源码会选择TcpListener和TcpClient这一层封装原因很实际TcpListener把地址族、套接字类型、协议类型这三个参数替你固定成了 TCP over IPv4AcceptTcpClient()直接返回一个已经包装好的TcpClient你拿到手就能读NetworkStream。裸Socket不是不能用而是它把太多细节暴露给你。比如Accept()返回的是一个全新的Socket你需要自己管理它的生命周期而TcpListener.AcceptTcpClient()返回的TcpClient在Dispose时会顺带关闭底层套接字。对于一份教学或内部工具级别的源码用TcpListener是性价比最高的选择。常见做法是服务端一个TcpListener常驻每个连接开一个独立任务去处理这样主线程只负责接受连接不会被某个客户端的慢请求拖死。这里有一个选型上的分水岭如果你的连接数在几百以内TcpListenerTask.Run完全够用如果连接数上千就要考虑SocketAsyncEventArgs或者直接上System.IO.Pipelines。这份源码大概率属于前者所以你在读的时候要清楚它的适用边界不要拿它去扛高并发。2.2 最小可运行的服务端从 Bind 到 Accept 的完整链路下面这段代码是我从这类源码里提炼出来的最小骨架去掉了业务逻辑只保留通信主干。你可以直接新建一个 .NET 控制台项目把Program.cs替换成它。using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; class TcpServer { static async Task Main() { // 监听所有网卡的 9000 端口IPAddress.Any 等价于 0.0.0.0 var listener new TcpListener(IPAddress.Any, 9000); listener.Start(); Console.WriteLine(服务端已启动监听 9000 端口); while (true) { // AcceptTcpClientAsync 不会阻塞主线程适合放在 while 里 TcpClient client await listener.AcceptTcpClientAsync(); // 每个客户端交给独立任务避免相互阻塞 _ Task.Run(() HandleClient(client)); } } static async Task HandleClient(TcpClient client) { // 拿到网络流读和写都走它 NetworkStream stream client.GetStream(); byte[] buffer new byte[1024]; try { while (true) { int n await stream.ReadAsync(buffer, 0, buffer.Length); if (n 0) break; // 返回 0 表示对端正常关闭 string msg Encoding.UTF8.GetString(buffer, 0, n); Console.WriteLine($收到: {msg}); // 原样回写方便客户端验证链路 byte[] echo Encoding.UTF8.GetBytes($echo: {msg}); await stream.WriteAsync(echo, 0, echo.Length); } } catch (Exception ex) { Console.WriteLine($连接异常: {ex.Message}); } finally { client.Close(); // 确保套接字释放避免句柄泄漏 } } }逻辑上分三层listener.Start()之后操作系统内核开始在这个端口上排队等待连接AcceptTcpClientAsync()从队列里取出一个已完成三次握手的连接HandleClient里的ReadAsync才是真正读数据的地方。参数上IPAddress.Any表示监听本机所有 IPv4 地址如果你只想本机调试可以换成IPAddress.Loopback。缓冲区1024是单次读取上限不代表一条消息的长度这一点后面讲粘包时会重点说。ReadAsync返回 0 是 TCP 里判断对端关闭的标准信号不要用异常去判断断开那样既不可靠也不优雅。2.3 客户端侧连接、发送、接收的三段式写法客户端代码通常比服务端短但坑一点不少。下面是对应的最小客户端。using System; using System.Net.Sockets; using System.Text; using System.Threading.Tasks; class TcpClientDemo { static async Task Main() { // 连接本机 9000 端口服务端没起会直接抛异常 var client new TcpClient(); await client.ConnectAsync(127.0.0.1, 9000); Console.WriteLine(已连接服务端); NetworkStream stream client.GetStream(); // 发送一条消息 string text hello tcp; byte[] data Encoding.UTF8.GetBytes(text); await stream.WriteAsync(data, 0, data.Length); // 接收回显 byte[] buffer new byte[1024]; int n await stream.ReadAsync(buffer, 0, buffer.Length); Console.WriteLine($收到: {Encoding.UTF8.GetString(buffer, 0, n)}); client.Close(); } }ConnectAsync内部完成三次握手成功返回就代表连接已建立。WriteAsync只保证数据写进了内核发送缓冲区不保证对端已经收到。ReadAsync这里只读了一次是因为回显很短实际业务里必须循环读。客户端最容易翻车的地方是ConnectAsync没有超时控制服务端 IP 不可达时可能卡很久生产代码里应该配合CancellationToken设置超时。2.4 把两份代码跑起来验证链路是否真的通了先启动服务端看到服务端已启动后再启动客户端。客户端打印已连接服务端和收到: echo: hello tcp服务端打印收到: hello tcp这条链路就算通了。如果你想验证多客户端可以同时开三个客户端窗口服务端会为每个连接打印独立的日志。验证项操作预期现象基本连通先起服务端再起客户端双方都打印收发日志端口占用服务端启动两次第二次抛SocketException提示地址已使用对端关闭客户端直接关窗口服务端ReadAsync返回 0退出循环服务端未起先起客户端客户端抛连接被拒绝异常这张表里的四项建议你每一项都手动做一遍。尤其是对端关闭这一项很多人写的服务端在客户端异常退出后会一直挂着不释放资源就是因为没处理ReadAsync返回 0 的情况。3. 参数怎么设缓冲区、超时与并发模型的三组关键选择3.1 缓冲区大小不是越大越好byte[] buffer new byte[1024]是这类源码里最常见的写法但它只是一个单次读取的容器。TCP 是字节流协议没有消息边界一次ReadAsync可能读到半条消息也可能读到两条半。缓冲区设成 1024 还是 8192影响的是系统调用次数不是消息完整性。我一般会这样选如果是短指令类通信比如设备控制指令单条不超过 512 字节缓冲区用 1024 足够如果是文件传输或大 JSON缓冲区用 8192 或 16384减少ReadAsync调用次数。但无论缓冲区多大你都必须自己定义消息边界。常见做法有两种定长包头加长度字段或者用换行符分隔。下面是一个带长度前缀的读取示例。// 先读 4 字节长度头再按长度读正文 static async Taskbyte[] ReadMessageAsync(NetworkStream stream) { byte[] lenBuf new byte[4]; int read 0; while (read 4) { int n await stream.ReadAsync(lenBuf, read, 4 - read); if (n 0) throw new IOException(连接已关闭); read n; } int len BitConverter.ToInt32(lenBuf, 0); byte[] body new byte[len]; read 0; while (read len) { int n await stream.ReadAsync(body, read, len - read); if (n 0) throw new IOException(连接已关闭); read n; } return body; }这段代码的关键在于两个while循环。ReadAsync不保证一次读满你要求的字节数所以必须循环读到够为止。BitConverter.ToInt32默认是小端序如果对端是大端序设备这里要手动翻转字节序这是上位机对接硬件时的高频翻车点。3.2 超时设置别让一个死连接拖垮整个服务端TcpClient默认没有读写超时ReadAsync会一直等下去。如果客户端网线被拔服务端可能几分钟甚至更久才感知到。解决办法有两个一是设置ReceiveTimeout二是用CancellationToken配合Task.WhenAny做超时。// 方式一设置接收超时单位毫秒 client.ReceiveTimeout 5000; // 方式二用 CancellationTokenSource 控制单次读取 using var cts new CancellationTokenSource(TimeSpan.FromSeconds(5)); try { int n await stream.ReadAsync(buffer, 0, buffer.Length, cts.Token); } catch (OperationCanceledException) { Console.WriteLine(读取超时准备断开); client.Close(); }方式一简单但它是同步套接字层面的超时在某些异步场景下行为不一致。方式二更可控推荐在异步代码里统一用。注意CancellationTokenSource用完要释放否则会有定时器资源泄漏。超时时间设多少取决于业务设备心跳类通信一般 3 到 5 秒人工操作类可以放宽到 30 秒。3.3 并发模型一连接一任务能撑多少前面服务端用的是Task.Run处理每个连接。这个模型在连接数几百时表现良好因为每个任务大部分时间在await上等待不占线程。但连接数上千后任务调度和内存开销会上升。这时候可以考虑用Channel做连接队列或者直接上SocketAsyncEventArgs做事件驱动。并发模型适用连接数优点代价一连接一 Task几十到几百代码直观易调试连接多时任务开销大Channel 固定消费者几百到几千可控并发度需要自己管理队列SocketAsyncEventArgs几千以上内存复用性能高代码复杂易出错我的建议是先用一连接一 Task 把业务跑通等压测发现瓶颈再换。不要一上来就抄高并发模型那样出了问题你连排查的抓手都没有。这份 c#写的tcpip服务端与客户端源码如果用的是前者说明它的定位就是中小规模通信你把它用在合适的地方就好。4. 避坑与排查TCP 通信里最容易翻车的五件事4.1 现象客户端发了两条消息服务端一次全读出来了原因TCP 是字节流没有消息边界。两次WriteAsync的数据可能被合并成一次ReadAsync返回这就是粘包。解决在应用层定义边界用长度前缀或分隔符。上面的ReadMessageAsync就是长度前缀方案。不要试图用Thread.Sleep去错开发送那是掩耳盗铃。4.2 现象服务端明明发了数据客户端却收不到原因WriteAsync只写入内核缓冲区如果客户端没有及时读缓冲区满了之后WriteAsync会等待。更隐蔽的情况是服务端用了StreamWriter但没Flush。解决检查是否调用了Flush或者直接用NetworkStream.WriteAsync。另外确认客户端确实在循环读而不是读一次就退出。4.3 现象客户端断开后服务端 CPU 占用飙升原因客户端异常断开时如果服务端没有正确处理ReadAsync返回 0 或异常可能陷入死循环反复读一个已关闭的流。解决在ReadAsync返回 0 或抛异常时立即break并关闭连接。同时检查while(true)里是否有异常被吞掉的情况。4.4 现象本机测试正常局域网另一台机器连不上原因服务端绑定了IPAddress.Loopback只监听 127.0.0.1。解决改成IPAddress.Any。另外检查 Windows 防火墙是否放行了对应端口这一步经常被忽略。命令行可以用netstat -ano | findstr 9000确认监听地址是不是 0.0.0.0。4.5 现象端口被占用服务端启动失败原因上一次运行的服务端进程没有完全退出端口还处于TIME_WAIT或进程仍存活。解决先用netstat -ano | findstr 9000找到 PID再用任务管理器或taskkill /PID xxx /F结束进程。如果频繁遇到可以给TcpListener设置ReuseAddress但生产环境要谨慎避免掩盖真正的端口冲突。5. 进阶用法把这份源码改造成能长期运行的服务5.1 加心跳与断线重连让连接自己活过来最小源码跑通后下一步是让它能长期运行。核心是心跳客户端每隔几秒发一个固定字节服务端收到就刷新最后活跃时间服务端起一个定时器扫描超过阈值没心跳的连接并主动关闭。客户端侧则要检测连接断开后自动重连重连间隔用退避策略比如 1 秒、2 秒、4 秒、8 秒避免服务端刚重启就被大量重连打满。// 服务端心跳扫描每 10 秒检查一次超过 30 秒无数据则断开 var timer new System.Timers.Timer(10000); timer.Elapsed (s, e) { var now DateTime.UtcNow; foreach (var kv in _clients) { if ((now - kv.Value.LastActive).TotalSeconds 30) { kv.Value.Client.Close(); _clients.TryRemove(kv.Key, out _); } } }; timer.AutoReset true; timer.Start();这里用ConcurrentDictionary保存连接键可以用客户端远程端点。LastActive在每次成功读取后更新。注意关闭连接后要从字典移除否则字典会越来越大这也是内存泄漏的常见来源。5.2 用日志和抓包验证你的判断排查 TCP 问题时日志要记录四件事连接建立、数据收发长度、异常类型、连接关闭。不要只打印收到消息要打印字节数。如果日志看不出问题就用 Wireshark 抓包过滤条件写tcp.port 9000看三次握手是否完成、数据包是否到达、是否有 RST。我自己的习惯是任何一次连不上的排查先看netstat确认监听再看防火墙最后才抓包。顺序反了会浪费很多时间。5.3 一个我踩过的坑别在 UI 线程里同步等待早年我把这份源码搬到 WinForms 上位机里在按钮事件里直接调stream.Read结果界面直接卡死。后来改成async void事件处理器配合await才恢复正常。血泪经验是TCP 读写一律用异步UI 线程只负责更新控件数据通过Invoke或IProgress回传。如果你现在还在用Thread加Invoke的老写法建议尽早换成async/await代码量和出错概率都会下降。这份 c#写的tcpip服务端与客户端源码本身不复杂复杂的是它背后那套你需要亲手验证的通信规则。我一般会把它当成一个起点先跑通再故意制造粘包和断线最后加上心跳和日志直到它能在我自己的场景里稳定跑上一周。希望帮到你。本文还有配套的精品资源点击获取