简介:这是一份面向C#网络编程学习者与后端开发者的高性能TCP通讯源码,基于完成端口(IOCP)实现,重点解决高并发场景下数据收发效率与CPU占用之间的平衡问题。包内共59个文件,以cs源码、exe可执行程序、config配置、resx资源及csproj工程文件为主,另有dll、pdb、sln等辅助文件,压缩包约122KB,结构上分为IocpServer服务端与IocpClient客户端两个独立工程,并附有源码必读说明。源码封装了可直接复用的网络通讯类,默认监听9900端口,在双核2G内存环境下实测可支撑5000以上客户端连接,收发处理速度快且CPU使用率低,适合用于项目集成或高并发性能测试。开发环境为Visual Studio 2010与.NET 4.0,调试时需将IP改为本机地址,若提示积极拒绝可尝试关闭防火墙。目前已有1303人学习下载,可作为理解IOCP模型与搭建TCP长连接服务的实践参考。
1. 从「完全端口」说起:一套 C# TCP 服务器和客户端源码到底解决了什么
很多人第一次看到「完全端口」这个词会愣一下,其实它想表达的是端口全开、连接全收——服务器监听一个端口,能同时扛住成千上万个客户端连接,而不是连几十个就开始掉线、卡死、内存飙升。这套 C# 源码要解决的核心问题就一个:用托管语言写出接近原生的高并发 TCP 通信能力,同时把服务端和客户端两侧都给你,拿来就能改、能压、能上线。
如果你正在做上位机、工控采集、GB28181 类信令转发、或者自研一套设备管理后台,大概率绕不开 TCP 长连接。市面上的选择无非几种:自己用Socket裸写、上DotNetty、或者干脆换语言。这套源码的价值在于,它把 IOCP 完成端口模型用 C# 封装到了「看得懂、改得动」的程度,既不是黑匣子,也不用你从零啃协议栈。适合谁?适合已经会 C# 基础语法、想跨过「能连上」到「能扛住」这道坎的工程师。下面我按选型、实现、参数、排错、进阶的顺序,把这条路走一遍。
2. 完全端口模型在 C# 里怎么落地:IOCP、SocketAsyncEventArgs 与线程模型
2.1 为什么托管代码也能做到高并发
Windows 上的高性能网络绕不开 IOCP(I/O Completion Port,I/O 完成端口)。它的本质是把「等待数据到达」这件事从应用线程手里拿走,交给内核。传统阻塞式Socket.Receive会让一个线程死等一个连接,1 万连接就要 1 万线程,光线程切换就把 CPU 吃光了。IOCP 的做法是:你投递一个异步读请求,内核收完数据后把完成事件丢进队列,由固定数量的工作线程去取,线程数和连接数解耦。
C# 里访问 IOCP 有两条路。一条是Socket.BeginReceive/EndReceive的 APM 模式,底层就是 IOCP,但每次操作都分配IAsyncResult,GC 压力大。另一条是SocketAsyncEventArgs,它是 .NET 专门为高吞吐场景设计的,对象可以复用,避免频繁分配。这套源码走的是第二条路,这也是「完全端口」能扛住压力的关键。
提示:
SocketAsyncEventArgs的复用是性能命门。每次 new 一个,等于把 IOCP 的优势又还回去了。
2.2 服务端最小可跑骨架
先看监听和接受连接的部分。下面这段是服务端启动的核心,省略了业务回调,只保留网络骨架:
// 服务端:创建监听 Socket 并投递 Accept var listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listener.Bind(new IPEndPoint(IPAddress.Any, 9000)); listener.Listen(512); // backlog,等待队列长度 var acceptArgs = new SocketAsyncEventArgs(); acceptArgs.Completed += OnAcceptCompleted; // 投递第一个 Accept,之后在回调里循环投递 if (!listener.AcceptAsync(acceptArgs)) { OnAcceptCompleted(null, acceptArgs); // 同步完成要手动调 }逻辑说明:Listen(512)的 backlog 不是「最大连接数」,而是三次握手完成后、还没被Accept取走的队列长度。设太小,高并发建连时会丢连接。AcceptAsync返回false表示操作同步完成,此时不会触发Completed事件,必须手动调用回调,这是新手最容易漏的一步,漏了就是「只能连一个」。
参数说明:AddressFamily.InterNetwork是 IPv4,要双栈就换InterNetworkV6并开DualMode;SocketType.Stream对应 TCP;端口 9000 按实际改,注意别撞上系统保留段。
2.3 接收循环与缓冲区管理
接受连接后,每个连接要挂一个接收循环。核心是「收完再投递」,形成闭环:
void StartReceive(SocketAsyncEventArgs args) { args.SetBuffer(args.Offset, args.Count); // 复用已有缓冲 bool pending = args.AcceptSocket.ReceiveAsync(args); if (!pending) ProcessReceive(args); // 同步完成立即处理 } void OnReceiveCompleted(object sender, SocketAsyncEventArgs args) { if (args.BytesTransferred > 0 && args.SocketError == SocketError.Success) { // 处理 args.Buffer 中 [Offset, BytesTransferred) 的数据 ProcessReceive(args); StartReceive(args); // 关键:处理完继续投递 } else { CloseClient(args); // 对端关闭或出错 } }逻辑说明:BytesTransferred == 0且无错误,通常是对端正常关闭(FIN),要主动释放资源。ProcessReceive里做粘包拆包,不能假设一次Receive就是一条完整消息。StartReceive放在处理之后,保证同一连接的读写不并发冲突。
参数说明:缓冲区大小常见 4KB 到 64KB。太小导致系统调用频繁,太大浪费内存。1 万连接 × 8KB ≈ 80MB,心里要有数。缓冲区建议用池化(ArrayPool<byte>或自建池),别每个连接 new 一个。
2.4 客户端侧怎么写才对称
客户端比服务端简单,但要注意连接超时和重连。Socket.ConnectAsync本身没有超时参数,得自己用Task.WhenAny或定时器兜底:
var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); var connectTask = client.ConnectAsync(ip, port); // 3 秒超时兜底 if (await Task.WhenAny(connectTask, Task.Delay(3000)) != connectTask) { client.Close(); throw new TimeoutException("连接超时"); }逻辑说明:ConnectAsync返回的 Task 在超时后并不会自动取消底层连接,所以超时分支里必须Close(),否则会泄漏半开连接。重连策略建议指数退避,别死循环猛连,会把服务端握手队列打满。
参数说明:超时时间按网络环境定,内网 1~3 秒,跨机房 5~10 秒。重连间隔从 1 秒起,翻倍到 30 秒封顶。
3. 粘包拆包、心跳与断线重连:协议层必须自己补的三件事
3.1 粘包拆包的本质与定长/变长方案
TCP 是字节流,没有消息边界。你发两次 100 字节,对端可能一次收到 200 字节,也可能分三次收到。这不是 bug,是协议特性。解决办法就两类:定长包头 + 长度字段,或者特殊分隔符。
推荐「长度前缀」方案:每条消息前 4 字节写消息体长度,接收方先读 4 字节,再按长度读满。下面是一个可复用的拆包器思路:
// 假设 buffer 是累积缓冲,pos 是已解析位置 while (pos + 4 <= validLen) { int bodyLen = BitConverter.ToInt32(buffer, pos); if (pos + 4 + bodyLen > validLen) break; // 半包,等下次 // 完整消息:[pos+4, pos+4+bodyLen) DispatchMessage(buffer, pos + 4, bodyLen); pos += 4 + bodyLen; } // 把剩余未解析数据前移,pos 归零逻辑说明:validLen是当前缓冲区有效数据长度,不是缓冲区容量。半包时break出去等下次Receive,不能丢数据。解析完要把剩余字节Buffer.BlockCopy到缓冲区头部,避免无限增长。
参数说明:长度字段用int(4 字节)还是ushort(2 字节)看单条消息上限。2 字节最大 65535,够大多数信令场景;要传文件就上 4 字节。字节序要和客户端约定一致,BitConverter默认小端。
3.2 心跳保活:为什么 TCP KeepAlive 不够用
系统级TCP KeepAlive默认 2 小时才探测一次,对实时性要求高的场景等于没有。应用层心跳才是常规做法:客户端每 N 秒发一个空包或 ping,服务端超时未收到就判定掉线。
// 服务端:每个连接记录最后活跃时间 class Connection { public DateTime LastActive; public Socket Socket; } // 定时器每 5 秒扫一遍 foreach (var c in connections) { if ((DateTime.Now - c.LastActive).TotalSeconds > 30) { CloseClient(c); // 30 秒没心跳,踢掉 } }逻辑说明:心跳间隔和超时阈值要成比例,常见「5 秒发、30 秒判死」。扫描定时器别用Timer每连接一个,用一个全局定时器遍历,否则 1 万连接就是 1 万个定时器。
参数说明:移动网络下心跳间隔别太短,太短费电且容易误判;内网可以 3~5 秒。超时阈值至少是心跳间隔的 3 倍,容忍偶发丢包。
3.3 断线重连的状态机
重连不是「断了就 while(true) 连」,要有状态机:未连接 → 连接中 → 已连接 → 断开 → 退避等待 → 连接中。关键是退避等待期间要能被主动取消,否则程序退出时线程卡住。
async Task ReconnectLoop(CancellationToken token) { int delay = 1000; while (!token.IsCancellationRequested) { try { await ConnectAsync(token); delay = 1000; // 连上后重置退避 await ReceiveLoop(token); } catch { /* 记录日志 */ } await Task.Delay(delay, token); delay = Math.Min(delay * 2, 30000); // 指数退避封顶 30 秒 } }逻辑说明:ReceiveLoop正常返回或抛异常都意味着连接断了,统一走重连。退避在连上后重置,避免「连上一次又断」时退避时间已经涨到 30 秒。
参数说明:初始延迟 1 秒,倍数 2,封顶 30 秒是通用配置。对实时性敏感的场景可以封顶 5 秒,但要做好服务端限流,防止雪崩。
4. 避坑与排查:这套源码上线前必须过的五道坎
4.1 现象:连接数一过千就报「无法分配内存」
原因:每个连接都 new 了独立的接收缓冲和SocketAsyncEventArgs,内存碎片化加上 GC 压力,32 位进程尤其容易撞 2GB 上限。
解决:缓冲区池化,SocketAsyncEventArgs也池化。用ConcurrentBag<SocketAsyncEventArgs>做对象池,取用归还。同时把项目编译目标改成 x64,别用 AnyCPU 跑在 32 位宿主上。
4.2 现象:CPU 单核跑满,其他核闲着
原因:IOCP 工作线程数没配好,或者业务处理里有全局锁,所有完成事件挤在一个线程上。
解决:ThreadPool.SetMinThreads把最小工作线程调到 CPU 核数以上,避免线程注入延迟。检查业务代码里的lock,把全局锁拆成按连接或按分片的锁。SocketAsyncEventArgs的Completed回调本身在线程池上跑,不要在回调里做耗时同步操作。
4.3 现象:客户端偶发连不上,服务端日志却没有任何记录
原因:Listen的 backlog 太小,三次握手完成的连接堆在队列里没被及时Accept,超时后被系统丢弃。
解决:backlog 调到 512 甚至 1024。同时检查Accept回调里是否及时投递了下一个AcceptAsync,漏投递会导致队列只减不增。
4.4 现象:压测一段时间后内存持续上涨不回落
原因:连接关闭时没有释放SocketAsyncEventArgs和缓冲区,或者事件订阅没取消,导致对象被引用无法回收。
解决:CloseClient里统一做三件事——Shutdown+Close+Dispose,把SocketAsyncEventArgs归还对象池,取消所有事件订阅。用dotnet-counters观察 GC 各代回收情况,Gen2 只涨不降基本就是泄漏。
4.5 现象:跨机房部署时吞吐骤降,延迟抖动大
原因:TCP 窗口和 Nagle 算法在小包场景下互相拖累,加上跨机房 RTT 高,窗口填不满。
解决:对延迟敏感的小包场景设Socket.NoDelay = true关掉 Nagle。适当调大SO_RCVBUF和SO_SNDBUF,但别盲目调,先测 RTT 和带宽积。跨机房建议做连接复用,别频繁建连。
5. 进阶技巧:用压测数据反推参数,而不是拍脑袋
参数调优最忌讳「别人说 8KB 好我就用 8KB」。正确做法是先搭一个可复现的压测环境,用数据说话。我一般会写一个简单的压测客户端,模拟 N 个连接、每条连接每秒发 M 条固定长度消息,跑 10 分钟看服务端的吞吐、延迟分布和内存曲线。
// 压测客户端核心:N 个连接并发发消息 var tasks = Enumerable.Range(0, connectionCount).Select(async i => { using var sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); await sock.ConnectAsync(serverIp, port); var payload = new byte[messageSize]; var sw = Stopwatch.StartNew(); while (sw.Elapsed < TimeSpan.FromMinutes(10)) { await sock.SendAsync(payload); Interlocked.Increment(ref sentCount); } }); await Task.WhenAll(tasks);逻辑说明:Interlocked.Increment保证计数线程安全。SendAsync在缓冲区满时会等待,这正好能压出服务端的接收瓶颈。跑完统计sentCount和耗时,算出实际吞吐。
参数说明:connectionCount从 100 起步,逐步加到 1000、5000、10000,观察拐点。messageSize分别测 64B、512B、4KB,小包看 QPS,大包看带宽。拐点出现的地方就是当前配置的极限,再往上加只会让延迟飙升。
几个我踩过的经验值,供你起步参考,但一定要自己压一遍:
| 参数 | 保守值 | 激进值 | 观察指标 |
|---|---|---|---|
| 接收缓冲区 | 4KB | 16KB | 内存占用、系统调用次数 |
| backlog | 512 | 2048 | 建连失败率 |
| 心跳间隔 | 5s | 3s | 误判率、流量 |
| 重连封顶 | 30s | 5s | 服务端握手压力 |
| 工作线程下限 | CPU 核数 | 核数×2 | CPU 利用率均衡度 |
最后说个习惯:每次改完参数,把压测结果和配置一起记到版本库里,标注日期和机器规格。网络性能这东西玄学成分不少,同一套参数换台机器结果可能完全不同,没有记录就等于没有后悔药。我吃过这个亏,调了三天以为找到了最优解,结果换环境全崩,回头连当时改了什么都想不起来。希望帮到你。
本文还有配套的精品资源,点击获取