简介:这份源码资源面向具备一定C#基础、希望深入理解网络通信机制的开发者,围绕TCP/IP协议栈在.NET平台上的落地实践展开。内容涵盖服务端与客户端两端的完整实现,涉及TcpListener侦听端口、TcpClient建立连接、NetworkStream收发数据等核心流程,并延伸至多线程并发处理、async/await异步编程以及SslStream加密传输等进阶主题,适合作为网络编程课程设计、毕业项目或自学练手的参考范例。资源包共273个文件,以77个cs源码文件为主体,辅以csproj工程文件、sln解决方案、resx资源文件、exe可执行程序及pdb调试符号等,压缩包约5.94MB,工程结构完整,可直接在Visual Studio中打开编译运行。目前已有69人学习下载。通过研读这套源码,读者能够掌握TCP服务端循环监听与多客户端并发处理的基本框架,理解客户端连接建立与数据交互的完整链路,并借鉴其中的错误处理与资源管理思路,为构建更复杂的网络应用打下基础。
1. 从一份 C# TCP/IP 双端源码说起:谁在找它,拿它干什么
如果你正在做上位机、工控采集、内网工具或者课程设计,大概率绕不开一个需求:自己写一套 TCP 服务端和客户端,而不是套现成的 MQTT、gRPC 或者 WebSocket。原因很现实——很多现场设备只认裸 TCP,报文格式是自定义的,粘包规则是私有的,你没法用高层协议去套。这时候一份能跑通的 C# TCP/IP 双端源码,价值不在于代码多高级,而在于它把监听、连接、收发、断线重连、粘包处理这些脏活都摆出来了,你能直接改。
这份资源就是一套用 C# 写的 TCP/IP 服务端与客户端源码,覆盖了从TcpListener/TcpClient建连,到NetworkStream读写,再到多客户端并发管理的完整链路。它适合三类人:一是刚学完 C# 语法、想找个真实网络项目练手的;二是做上位机、需要快速搭一个内网通信骨架的;三是手里有私有协议、想找个干净底座往上叠业务逻辑的。下面我按“这东西怎么落地”的顺序,把关键实现、参数和坑一条条拆开。
2. 服务端骨架:TcpListener 监听、AcceptTcpClient 与并发模型怎么选
2.1 为什么用 TcpListener 而不是 Socket 裸写
C# 里做 TCP 服务端有两条路:直接用Socket,或者用TcpListener/TcpClient这层封装。这份源码走的是后者。TcpListener本质上是对Socket的薄封装,帮你省掉了Bind、Listen、Accept的样板代码,但底层还是同一套东西。选它的理由很直接:代码可读性高,新手不容易在AddressFamily、SocketType、ProtocolType这三个参数上翻车。
常见做法是这样起一个监听:
// 监听本机所有网卡的 8888 端口 var listener = new TcpListener(IPAddress.Any, 8888); listener.Start(); Console.WriteLine("服务端已启动,等待连接..."); while (true) { // AcceptTcpClient 是阻塞的,每来一个连接就返回一个 TcpClient TcpClient client = listener.AcceptTcpClient(); // 交给线程池处理,避免阻塞主循环 ThreadPool.QueueUserWorkItem(HandleClient, client); }这里IPAddress.Any表示绑定所有 IPv4 网卡,如果你只想本机调试,换成IPAddress.Loopback更安全。端口 8888 是示例,实际项目里要避开 1024 以下的系统保留端口。AcceptTcpClient是阻塞调用,所以必须放在循环里,而且每个连接要立刻甩给线程池或独立线程,否则第二个客户端连进来时你还在处理第一个,这就是最典型的“单线程服务端”翻车现场。
2.2 并发模型:ThreadPool 还是 async/await
源码里用的是ThreadPool.QueueUserWorkItem,这是比较传统的写法,优点是直观,缺点是连接数一多,线程池被占满后新连接会排队。如果你要撑几百上千个长连接,建议改成async/await模式:
// 异步接受连接,不占用线程 while (true) { TcpClient client = await listener.AcceptTcpClientAsync(); _ = HandleClientAsync(client); // 丢弃任务,让它在后台跑 } async Task HandleClientAsync(TcpClient client) { var stream = client.GetStream(); byte[] buffer = new byte[4096]; while (true) { int n = await stream.ReadAsync(buffer, 0, buffer.Length); if (n == 0) break; // 对端关闭 // 处理 buffer[0..n] } client.Close(); }AcceptTcpClientAsync和ReadAsync都不会阻塞线程,连接数上来后资源占用比线程池方案低一个量级。参数上,buffer大小 4096 是经验值,太小会导致频繁系统调用,太大浪费内存,实际按你单条报文最大长度来定。注意ReadAsync返回 0 只代表对端正常关闭,不代表出错,别把它当异常处理。
2.3 客户端连接与超时控制
客户端这边源码用的是TcpClient.Connect,但这里有个血泪经验:Connect在跨网段、对端不在线时可能卡十几秒才抛异常。生产代码里要么用带超时的异步版本,要么自己包一层:
var client = new TcpClient(); var task = client.ConnectAsync("192.168.1.100", 8888); if (await Task.WhenAny(task, Task.Delay(3000)) == task) { await task; // 连接成功 } else { throw new TimeoutException("连接超时"); }Task.Delay(3000)就是 3 秒超时,这个值按你的网络环境调,内网 1 到 2 秒足够,跨机房可以放到 5 秒。连接成功后拿GetStream()读写,和上面服务端逻辑对称。
3. 收发与粘包:NetworkStream 读写、分包规则和缓冲区参数
3.1 TCP 是字节流,粘包不是 bug 是特性
新手最容易踩的坑就是把 TCP 当成“一条消息一次 Read”。TCP 只保证字节顺序,不保证边界。你发两次 100 字节,对端可能一次 Read 到 200 字节,也可能分三次读到。这不是代码写错了,是协议本身如此。所以任何自定义协议都必须自己定分包规则,常见三种:
| 分包方式 | 规则 | 适用场景 |
|---|---|---|
| 固定长度 | 每条报文长度固定 | 指令简单、格式统一 |
| 长度前缀 | 前 4 字节存长度,后面跟内容 | 最通用,推荐 |
| 分隔符 | 用\r\n或特定字节结尾 | 文本协议、日志类 |
这份源码用的是长度前缀思路,读的时候先读 4 字节头,解析出长度,再循环读到够为止。
3.2 长度前缀分包的完整读写实现
发送端先算长度再拼包:
void SendMessage(NetworkStream stream, byte[] payload) { // 前 4 字节写长度(大端序,和多数协议一致) byte[] lenBytes = BitConverter.GetBytes(payload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes); stream.Write(lenBytes, 0, 4); stream.Write(payload, 0, payload.Length); }接收端要处理“半包”,不能假设一次 Read 就够:
byte[] ReadMessage(NetworkStream stream) { byte[] lenBuf = ReadExact(stream, 4); // 先读满 4 字节头 if (BitConverter.IsLittleEndian) Array.Reverse(lenBuf); int len = BitConverter.ToInt32(lenBuf, 0); return ReadExact(stream, len); // 再读满正文 } byte[] ReadExact(NetworkStream stream, int count) { byte[] buf = new byte[count]; int offset = 0; while (offset < count) { int n = stream.Read(buf, offset, count - offset); if (n == 0) throw new IOException("连接已关闭"); offset += n; } return buf; }ReadExact是这套逻辑的核心,它保证要么读满,要么抛异常,绝不会返回半截数据。BitConverter.IsLittleEndian那个判断是因为 Windows 是小端,而很多网络协议约定大端,不统一就会出现“长度解析成天文数字”的玄学问题。参数上,长度字段用 4 字节int最大支持 2GB,实际项目里建议加个上限校验,比如超过 1MB 直接断开,防止恶意包撑爆内存。
3.3 缓冲区大小与读写频率的取舍
NetworkStream.Read的缓冲区不是越大越好。设成 64KB 时,小报文会浪费内存;设成 256 字节,大报文要循环几百次。我一般按业务里 90% 报文的长度来定,比如多数报文在 1KB 以内,就用 4096 的缓冲区,兼顾系统调用次数和内存。另外NetworkStream默认ReadTimeout是无限,如果对端假死,你的线程会一直挂着,建议设一个:
stream.ReadTimeout = 10000; // 10 秒无数据就抛异常 stream.WriteTimeout = 10000;超时后Read会抛IOException,在 catch 里做断线清理,别让僵尸连接堆着。
4. 避坑与排查:断线重连、端口占用、编码乱码这些坑
4.1 现象:服务端启动报“地址已在使用”
原因通常是上一个进程没退干净,端口还处于TIME_WAIT状态,或者你重复Start了同一个TcpListener。解决方式是给监听套接字加SO_REUSEADDR,但TcpListener没直接暴露这个选项,得从底层Socket拿:
listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);加在Start()之前。另外排查时用netstat -ano | findstr 8888看端口被谁占了,拿到 PID 去任务管理器结束。
4.2 现象:客户端断线后服务端线程不释放
原因是Read阻塞在那里,对端拔网线时不会立刻返回 0,要等 TCP 超时。解决办法是设ReadTimeout,或者用心跳包:客户端每隔 5 秒发一个空包,服务端超过 15 秒没收到就主动Close。心跳间隔和超时倍数一般取 1:3,太灵敏会误杀,太迟钝会堆积僵尸连接。
4.3 现象:中文收发变成乱码
这是编码问题,不是 TCP 问题。NetworkStream只搬字节,编码要你自己定。发送前Encoding.UTF8.GetBytes(str),接收后Encoding.UTF8.GetString(bytes),两端必须一致。常见翻车是服务端用Default(GBK),客户端用 UTF8,结果一半中文变问号。统一用 UTF8,并在协议文档里写死。
4.4 现象:多客户端时数据串了
原因是多个线程共用了同一个NetworkStream或缓冲区。每个TcpClient必须有自己的NetworkStream,缓冲区也要在方法内new,不能提成类字段。如果确实要共享状态,用lock或ConcurrentQueue,别裸奔。
4.5 现象:大报文发送时对端只收到一半
Write不保证一次写完,虽然NetworkStream内部会尽量写完,但极端情况下仍可能部分写。稳妥做法是循环写:
int offset = 0; while (offset < data.Length) { int n = stream.WriteAsync(data, offset, data.Length - offset).Result; offset += n; }或者直接用WriteAsync并await,逻辑一样。
5. 进阶技巧:把双端源码改成可测试、可扩展的通信底座
5.1 用接口隔离传输层,方便单元测试
直接new TcpClient()的代码没法单测,因为跑测试时没有真实对端。我一般会抽一个ITransport接口,把Send/Receive抽象出来,生产用 TCP 实现,测试用内存队列实现。这样分包逻辑、协议解析都能脱离网络跑测试,回归成本低很多。
public interface ITransport { Task SendAsync(byte[] data); Task<byte[]> ReceiveAsync(); }TcpTransport内部包NetworkStream,FakeTransport内部用BlockingCollection<byte[]>,两边跑同一套协议解析代码,测出来的结果才可信。
5.2 用 CancellationToken 优雅关闭
服务端退出时如果直接listener.Stop(),正在处理的连接会被硬切。更好的做法是传CancellationToken到每个处理任务,收到取消信号后先发一个“服务端即将关闭”的协议包,再等 1 秒关闭:
async Task HandleClientAsync(TcpClient client, CancellationToken token) { try { while (!token.IsCancellationRequested) { var msg = await ReadMessageAsync(client.GetStream(), token); // 处理消息 } } catch (OperationCanceledException) { /* 正常退出 */ } finally { client.Close(); } }ReadMessageAsync里把token传给ReadAsync,取消时立刻抛异常,不会卡在 IO 上。这个习惯我是在一次线上重启事故后养成的——当时直接杀进程,几十个客户端全部报连接重置,从那以后每次关闭都强制走一遍取消流程。
5.3 日志与抓包:排查协议问题的两把刀
协议对不上时,光看代码没用。我一般同时开两个东西:一是StreamWriter把收发字节按十六进制打到日志,二是 Wireshark 抓包对比。日志里重点看长度字段和实际字节数是否一致,抓包里看 TCP 分段和重传。常见做法是给日志加个开关,生产环境只记错误,调试时全开,避免日志把磁盘写满。
void LogHex(string tag, byte[] data, int len) { if (!Verbose) return; Console.WriteLine($"{tag}: {BitConverter.ToString(data, 0, len)}"); }BitConverter.ToString输出的是AA-BB-CC格式,和 Wireshark 的十六进制视图能对上,比对起来快。参数len一定要传实际读到的长度,别传整个缓冲区,否则日志里全是00,反而干扰判断。
这套源码的价值不在代码本身多复杂,而在于它把 TCP 双端通信里最容易出问题的几个点——并发、粘包、超时、关闭——都摆在了明面上。你拿到后先跑通本机回环,再把分包规则换成自己的协议,最后补上心跳和日志,基本就能当内网通信底座用了。希望帮到你。
本文还有配套的精品资源,点击获取