简介:这份源码资源面向具备一定 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 说明,工程结构完整,可直接在 Visual Studio 中打开调试。目前已有 69 人学习,适合对照源码梳理通信流程、理解连接管理与资源释放思路。
1. 从一份 C# TCP/IP 源码说起:服务端与客户端到底该怎么写
很多人第一次拿到「C# 写的 TCP/IP 服务端与客户端源码」这类东西,是在做上位机、设备网关或者内部工具的时候。需求很朴素:一台机器监听端口,另一台机器连上来,双方收发字节流,最好还能同时带几十上百个连接。真动手才发现,网上抄来的示例要么只能一对一,要么粘包粘到怀疑人生,要么客户端一断线服务端就抛异常。这篇笔记就围绕这份源码该有的骨架,把服务端和客户端从监听、连接、收发到断线重连整条链路拆开讲清楚,顺带把 C# 里TcpListener、TcpClient、NetworkStream这几个类的边界和参数说明白。适合正在写 C# 上位机、做服务端接口测试、或者需要自己搭一套轻量通信层的同学,新手能照着跑通,熟手能对照检查自己的实现有没有埋雷。
2. 服务端骨架:TcpListener 监听、AcceptTcpClient 与并发模型怎么选
2.1 为什么服务端不能只写一个 Accept 循环
最朴素的写法是listener.AcceptTcpClient()拿到一个连接,处理完再 Accept 下一个。这种写法在只有一个客户端时没问题,一旦第二个客户端连上来,它只能排队等着,前一个不断开就永远轮不到它。TCP/IP 服务端的本质是「一个监听端口 + N 个独立会话」,每个会话有自己的收发节奏,所以必须把 Accept 和会话处理拆开。
C# 里常见的三种并发模型:
| 模型 | 写法 | 适用场景 | 代价 |
|---|---|---|---|
| 同步阻塞 + 每连接一线程 | new Thread(HandleClient) | 连接数几十以内,逻辑简单 | 线程多,上下文切换开销大 |
| 异步回调 | BeginAcceptTcpClient | 老项目兼容 | 回调嵌套,难维护 |
| async/await | AcceptTcpClientAsync | 现代项目首选 | 需要理解异步语义 |
我一般直接上async/await,代码线性、异常好捕获,连接数几百也没压力。下面是最小可运行的服务端骨架。
using System; using System.Net; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; class TcpServer { private readonly TcpListener _listener; private readonly CancellationTokenSource _cts = new CancellationTokenSource(); public TcpServer(int port) { // IPAddress.Any 表示监听本机所有网卡,端口由外部传入 _listener = new TcpListener(IPAddress.Any, port); } public async Task StartAsync() { _listener.Start(); Console.WriteLine($"服务端已启动,监听端口 {((IPEndPoint)_listener.LocalEndpoint).Port}"); while (!_cts.IsCancellationRequested) { // AcceptTcpClientAsync 不会阻塞线程,连接到来时才继续 TcpClient client = await _listener.AcceptTcpClientAsync(); // 每个连接丢到独立任务里,不阻塞 Accept 循环 _ = HandleClientAsync(client, _cts.Token); } } private async Task HandleClientAsync(TcpClient client, CancellationToken token) { string remote = client.Client.RemoteEndPoint?.ToString() ?? "unknown"; Console.WriteLine($"客户端接入: {remote}"); try { using (client) using (NetworkStream stream = client.GetStream()) { byte[] buffer = new byte[4096]; while (!token.IsCancellationRequested) { // ReadAsync 返回 0 表示对端正常关闭 int read = await stream.ReadAsync(buffer, 0, buffer.Length, token); if (read == 0) break; string text = Encoding.UTF8.GetString(buffer, 0, read); Console.WriteLine($"[{remote}] 收到: {text}"); // 原样回显,实际项目里换成业务处理 byte[] echo = Encoding.UTF8.GetBytes($"echo:{text}"); await stream.WriteAsync(echo, 0, echo.Length, token); } } } catch (Exception ex) { // 客户端强制断开时这里会抛 IOException,属正常现象 Console.WriteLine($"连接 {remote} 异常结束: {ex.Message}"); } finally { Console.WriteLine($"客户端断开: {remote}"); } } public void Stop() { _cts.Cancel(); _listener.Stop(); } }逻辑说明:StartAsync里只做一件事——不断接受新连接,把每个连接交给HandleClientAsync。HandleClientAsync用using保证TcpClient和NetworkStream一定释放,ReadAsync返回 0 是判断对端关闭的标准做法,不要用client.Connected去判断,那个属性经常骗人。
参数说明:buffer大小 4096 是经验值,太小会频繁触发读,太大浪费内存;IPAddress.Any换成IPAddress.Loopback就只允许本机连;AcceptTcpClientAsync没有超时参数,要控制接入速率得自己在外面加信号量。
2.2 端口、 backlog 与 KeepAlive 三个必调参数
TcpListener构造之后、Start之前,有几个参数值得单独说。
第一是端口。0 到 1023 是系统保留端口,普通程序别碰;1024 到 49151 是注册端口,自己用选 8000 以上比较稳。端口被占用时Start()会抛SocketException,错误码AddressAlreadyInUse,这时候要么换端口,要么查是谁占着。
第二是 backlog。TcpListener.Start(int backlog)里的 backlog 是「已完成三次握手但还没被 Accept 的队列长度」,默认值在不同平台不一样。高并发接入场景下,如果 Accept 循环处理慢,队列满了新连接会被系统拒绝。我一般显式写Start(512),配合异步 Accept 基本不会丢连接。
第三是 KeepAlive。TCP 默认两小时才发一次保活探测,对长连接业务来说太久了,客户端拔网线服务端可能两小时才知道。可以在连接建立后设置:
client.Client.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); // 前两个参数是 Windows 特有的,单位毫秒 client.Client.IOControl( IOControlCode.KeepAliveValues, BitConverter.GetBytes(1).Concat(BitConverter.GetBytes(10000)) .Concat(BitConverter.GetBytes(5000)).ToArray(), null);这段设置的意思是:10 秒没有数据往来就开始探测,探测间隔 5 秒。注意IOControl这套在 Linux 上不生效,跨平台项目得用应用层心跳代替,这也是后面避坑章节要展开的点。
3. 客户端实现:TcpClient 连接、收发与断线重连的完整写法
3.1 连接超时为什么必须自己控制
TcpClient.Connect和ConnectAsync默认的超时跟操作系统有关,Windows 上没连上的地址可能要等二十秒才返回。做上位机时这个体验是灾难性的——界面卡死二十秒。所以连接必须自己加超时。
using System; using System.Net.Sockets; using System.Text; using System.Threading; using System.Threading.Tasks; class TcpClientWrapper { private TcpClient _client; private NetworkStream _stream; private readonly string _host; private readonly int _port; public TcpClientWrapper(string host, int port) { _host = host; _port = port; } public async Task<bool> ConnectAsync(int timeoutMs = 3000) { _client = new TcpClient(); using (var cts = new CancellationTokenSource(timeoutMs)) { try { // ConnectAsync 支持 CancellationToken,超时会抛 OperationCanceledException await _client.ConnectAsync(_host, _port).WaitAsync(cts.Token); _stream = _client.GetStream(); Console.WriteLine("连接成功"); return true; } catch (OperationCanceledException) { Console.WriteLine($"连接 {_host}:{_port} 超时({timeoutMs}ms)"); _client?.Close(); return false; } catch (SocketException ex) { Console.WriteLine($"连接失败: {ex.SocketErrorCode}"); _client?.Close(); return false; } } } public async Task SendAsync(string message) { if (_stream == null) throw new InvalidOperationException("尚未连接"); byte[] data = Encoding.UTF8.GetBytes(message); await _stream.WriteAsync(data, 0, data.Length); } public async Task<string> ReceiveAsync() { byte[] buffer = new byte[4096]; int read = await _stream.ReadAsync(buffer, 0, buffer.Length); if (read == 0) return null; // 服务端关闭 return Encoding.UTF8.GetString(buffer, 0, read); } public void Close() { _stream?.Close(); _client?.Close(); } }逻辑说明:WaitAsync(cts.Token)是 .NET 6 之后给任意 Task 加超时的通用写法,比Task.WhenAny干净。ConnectAsync抛SocketException时看SocketErrorCode,ConnectionRefused说明服务端没监听,HostUnreachable说明网络不通,这两个排查方向完全不同。
参数说明:timeoutMs默认 3000 是内网场景的经验值,跨公网可以放到 5000 到 8000;buffer同样 4096,和服务端保持一致便于对照。
3.2 断线重连:别用 while(true) 硬怼
客户端断线重连最常见的翻车写法是while(true) { try { Connect(); break; } catch { Thread.Sleep(1000); } }。这种写法在服务端长时间不可用时会把 CPU 和日志刷爆,而且没有退避,服务端刚恢复就被一堆客户端同时冲击。
我一般用指数退避加最大间隔:
public async Task ReconnectLoopAsync(CancellationToken token) { int delay = 1000; // 初始 1 秒 const int maxDelay = 30000; // 最大 30 秒 while (!token.IsCancellationRequested) { if (await ConnectAsync()) { delay = 1000; // 连上后重置退避 return; } Console.WriteLine($"{delay}ms 后重试"); await Task.Delay(delay, token); // 每次失败翻倍,封顶 maxDelay delay = Math.Min(delay * 2, maxDelay); } }逻辑说明:连上就重置delay,保证下次断线还是从 1 秒开始;失败就翻倍,避免服务端没起来时疯狂重试。token用来在程序退出时中断重连循环,不然关窗口时线程还挂着。
参数说明:初始 1 秒适合内网,公网可以放到 2 到 3 秒;maxDelay30 秒是平衡「恢复速度」和「服务端压力」的结果,再大用户感知就明显了。
4. 粘包与拆包:TCP 字节流为什么不能当消息用
4.1 粘包的本质是 TCP 没有消息边界
很多人第一次写 TCP 通信会踩这个坑:客户端连续Send("AAA")、Send("BBB"),服务端一次Read收到"AAABBB",或者反过来,一条消息被拆成两次收到。这不是 bug,是 TCP 的设计——它只保证字节顺序,不保证「一次发对应一次收」。
解决思路只有一条:在应用层自己定边界。常见三种方案:
| 方案 | 格式 | 优点 | 缺点 |
|---|---|---|---|
| 固定长度 | 每条消息 N 字节 | 解析简单 | 消息长度受限,浪费带宽 |
| 分隔符 | 消息 +\n | 可读性好 | 消息里不能出现分隔符 |
| 长度前缀 | 4 字节长度 + 消息体 | 通用、高效 | 需要处理半包 |
长度前缀是最通用的,下面给一个完整的收发封装。
// 发送:先写 4 字节大端长度,再写消息体 public static async Task SendMessageAsync(NetworkStream stream, byte[] payload) { byte[] lengthPrefix = BitConverter.GetBytes(payload.Length); if (BitConverter.IsLittleEndian) Array.Reverse(lengthPrefix); // 统一用大端,跨平台一致 await stream.WriteAsync(lengthPrefix, 0, 4); await stream.WriteAsync(payload, 0, payload.Length); } // 接收:先读满 4 字节长度,再按长度读满消息体 public static async Task<byte[]> ReadMessageAsync(NetworkStream stream) { byte[] lengthBuffer = await ReadExactAsync(stream, 4); if (lengthBuffer == null) return null; // 对端关闭 if (BitConverter.IsLittleEndian) Array.Reverse(lengthBuffer); int length = BitConverter.ToInt32(lengthBuffer, 0); if (length <= 0 || length > 10 * 1024 * 1024) throw new InvalidOperationException($"非法消息长度: {length}"); return await ReadExactAsync(stream, length); } // 辅助方法:保证读满 count 字节,处理半包 private static async Task<byte[]> ReadExactAsync(NetworkStream stream, int count) { byte[] buffer = new byte[count]; int offset = 0; while (offset < count) { int read = await stream.ReadAsync(buffer, offset, count - offset); if (read == 0) return null; // 对端关闭 offset += read; } return buffer; }逻辑说明:ReadExactAsync是核心,ReadAsync一次可能只返回部分数据,必须循环读到offset == count才算一条完整消息。长度前缀统一用大端,避免大小端机器互通时出错。
参数说明:长度上限设 10MB 是防御性编程,防止对端发个int.MaxValue让你直接 OOM;实际项目按业务最大消息调整,一般 1MB 以内够用。
4.2 心跳包:KeepAlive 不够用时的应用层方案
前面提过 TCP KeepAlive 默认两小时,跨平台还不好设。长连接业务里我一般加应用层心跳:客户端每 30 秒发一个固定格式的心跳包,服务端收到就更新「最后活跃时间」,超过 90 秒没收到就主动断开。
// 服务端维护每个连接的最后活跃时间 private readonly ConcurrentDictionary<string, DateTime> _lastActive = new(); // 收到任何数据都刷新 _lastActive[remote] = DateTime.UtcNow; // 后台定时扫描,清理超时连接 private async Task CleanupLoopAsync(CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(30000, token); var now = DateTime.UtcNow; foreach (var kv in _lastActive) { if ((now - kv.Value).TotalSeconds > 90) { Console.WriteLine($"心跳超时,断开 {kv.Key}"); // 这里需要能根据 remote 找到对应 TcpClient 并关闭 } } } }逻辑说明:心跳间隔 30 秒、超时 90 秒是「容忍两次丢包」的经验值。心跳包本身可以是一个特殊前缀的短消息,业务层收到直接忽略。
参数说明:心跳间隔别小于 10 秒,否则连接数一多网络全是心跳;超时至少是心跳间隔的 2 到 3 倍,给网络抖动留余量。
5. 避坑与排查:C# TCP/IP 源码里最容易翻车的 5 个点
5.1 现象:客户端断开后服务端抛 IOException
原因:客户端强制关闭(比如直接杀进程)时,服务端正在ReadAsync,会抛IOException: 远程主机强迫关闭了一个现有的连接。这是 TCP 的正常行为,不是代码 bug。
解决:在HandleClientAsync里把IOException和ObjectDisposedException单独 catch 掉,只记日志不往上抛。判断对端关闭的正确方式是ReadAsync返回 0,而不是client.Connected。
5.2 现象:服务端跑一段时间后端口被占用,重启失败
原因:TcpListener.Stop()之后,端口会进入TIME_WAIT状态,默认持续几十秒到几分钟,期间再Start同一个端口会抛AddressAlreadyInUse。
解决:在Start之前设置ReuseAddress:
_listener.Server.SetSocketOption( SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); _listener.Start(512);注意这个选项在 Windows 和 Linux 上语义略有差异,生产环境更稳妥的做法是让程序优雅退出,别频繁重启。
5.3 现象:中文消息收到乱码
原因:发送端用Encoding.UTF8,接收端用Encoding.Default,或者反过来。Encoding.Default在中文 Windows 上是 GBK,跨平台必翻车。
解决:两端统一用Encoding.UTF8,并且长度前缀算的是「UTF-8 编码后的字节数」,不是字符串的Length。"你好".Length是 2,但 UTF-8 编码后是 6 字节,这个差异是乱码和截断的常见来源。
5.4 现象:连接数一多,服务端内存暴涨
原因:每个连接分配了 4096 字节 buffer,加上TcpClient和NetworkStream对象,一万个连接就是几十 MB 起步。如果 buffer 开到 64KB,直接几百 MB。
解决:buffer 按业务最大消息设,别图省事开大;连接数上千时考虑用SocketAsyncEventArgs做零分配收发,或者上System.IO.Pipelines。普通上位机几十个连接,现在的写法完全够用,别过度设计。
5.5 现象:客户端连上了但收不到数据
原因:服务端WriteAsync之后没有Flush,或者客户端ReadAsync的 buffer 太小,一条消息被拆成多次读但客户端只读了一次。
解决:NetworkStream默认不缓冲,WriteAsync直接发出,不需要Flush;问题多半在客户端没按长度前缀循环读。用第 4 章的ReadExactAsync替换裸ReadAsync,这个坑基本就消失了。
6. 进阶技巧:用 Wireshark 和日志把通信问题钉死
写完能跑只是第一步,真正省时间的是出问题时能快速定位。我一般做两件事:抓包和结构化日志。
抓包用 Wireshark,过滤条件直接写tcp.port == 8888,能看到三次握手、每次收发的字节数、FIN 和 RST。粘包问题在 Wireshark 里一目了然——你能看到两个应用层消息被合在一个 TCP 段里,或者一条消息跨了两个段。这一步能省掉大量「到底是发送端还是接收端的问题」的扯皮。
日志方面,别用Console.WriteLine打天下。给每条收发记录加上连接标识、时间戳、字节数:
void LogTraffic(string connId, string direction, byte[] data) { // 只打前 64 字节,避免大消息刷爆日志 int preview = Math.Min(data.Length, 64); string hex = BitConverter.ToString(data, 0, preview).Replace("-", " "); Console.WriteLine($"{DateTime.Now:HH:mm:ss.fff} [{connId}] {direction} {data.Length}B | {hex}"); }connId用remote endpoint或者自增编号都行,关键是同一条连接的所有日志能串起来。十六进制预览比直接打字符串有用,因为二进制协议里很多字段不是可打印字符。
还有一个我踩过的坑:调试时把TcpClient.NoDelay设成false,小消息会被 Nagle 算法攒着一起发,看起来像「延迟很高」。对实时性有要求的场景,连接建立后立刻设client.NoDelay = true,代价是每个小包都单独发,网络利用率略降,但延迟稳定。
最后说个习惯:每次改完收发逻辑,先写一个「回环测试」——服务端和客户端跑在同一台机器上,用127.0.0.1连,发一万条随机长度的消息,校验每条都完整收到。这个测试能在五分钟内暴露粘包、半包、编码、长度前缀的所有问题,比在真实网络里瞎试高效得多。我现在的项目里,这套回环测试是提交代码前的固定动作,希望帮到你。
本文还有配套的精品资源,点击获取