简介:面向.NET开发者的高性能TCP通信资源包,以HPSocket.Net库为核心,提供了C#版Socket收发TCP协议的可运行样例,适合需要实现稳定网络通信、并发连接管理的桌面或服务端开发者。压缩包共662个文件,合计918KB,主体为329个cs源码文件与43个csproj工程文件,另含64个config配置、48个resx资源及证书相关文件,覆盖从连接配置到安全验证的环节。已有457人学习下载。通过源码可重点掌握HPSocket.Net的异步收发、多线程连接管理、消息模板定义等特性,也能对照原生Socket调用方式理解封装思路,遇到粘包、断线重连、多客户端并发等问题时可直接参照样例改造复用。整体目录按客户端/服务端分离,配合示例脚本可快速搭建测试环境,是一份兼顾基础与工程化的网络编程参考资料。
1. HPSocket.Net 是什么:一个让 .NET 开发者绕开 IOCP 苦海的 TCP 通讯库
做上位机或者网关服务的人,大概率都有过这种经历:用 C# 自带的 TcpListener 写了个服务端,本地连几十个客户端没问题,一到现场接两百台设备,CPU 飙高、连接频繁掉线、收到的数据还莫名其妙粘在一起。这时候你才会意识到,socket 网络编程不是 new 一个 Socket 然后 BeginReceive 那么简单,tcp 三次握手之外还有并发模型、缓冲区管理、拆包组包这一大堆事。
HPSocket 就是为解决这个问题来的。它是一套基于 IOCP(完成端口)的高性能 socket 库,原生代码用 C++ 实现,同时提供了 .NET 封装包 HPSocket.Net,让 C# 开发者能在 WinForm、WPF、.NET Maui 或者普通控制台服务里直接调用,而不必自己写完成端口逻辑。标题里的 HPSocket.Net-develop 就是 GitHub 上这个 .NET 封装源码仓库的常见命名方式,clone 下来后你会看到 Server、Client、Agent 三类核心组件。
这篇笔记会把 HPSocket.Net 从「能用」讲到「敢用在生产环境」。目标是让新手照着一路敲下来能跑通一个 TCP 服务端和客户端,让熟手看到 PUSH/PULL/PACK 三种模式的选型边界,以及那些容易让人翻车的拆包、断连、线程阻塞问题。
2. 为什么需要 HPSocket:socket 编程的难点和 IOCP 的价值
2.1 TCP 不是消息协议:三次握手之后才是麻烦的开始
很多做业务系统的人第一次写 TCP 通讯,都会下意识地把 TCP 当成「一条消息发一次」的通道。实际上 tcp 是一种面向字节流的协议,三次握手建立连接只是开始,真正麻烦的是数据到达之后没有边界。
你在服务端 OnReceive 里拿到的数据,可能是客户端一次 Send 的全部内容,也可能是半包,还可能是两个 Send 拼在一起的一大坨。更隐蔽的是,如果链路质量不好,TCP 协议栈会自动重传、重组,你应用层拿到的字节顺序是保证的,但长度和边界完全不可控。
C# 自带的 TcpClient 和 NetworkStream 不是不能写,而是要自己管理每个连接的接收缓冲区、半包残留、连接状态的线程安全。到几百个连接的时候,一个连接一个线程的做法基本就废了,线程上下文切换能把 CPU 吃满,锁竞争能把业务逻辑拖死。这就是 HPSocket 这类库存在的根本原因——它不是帮你省掉写 Socket 的代码,而是把连接调度和 IO 完成模型替你扛住。
2.2 IOCP 和传统阻塞模型的本质区别
IOCP 是 Windows 上最高效的异步 IO 模型。传统做法是一个连接一个线程,线程阻塞在 Receive 上等数据,连接多了线程就爆炸。IOCP 的思路是:线程池只负责处理「已经完成」的 IO 操作,没有数据来的时候线程是空闲的,可以服务其他连接。
HPSocket 的核心优势就在这里。它底层创建的工作线程数量不是按连接数走的,而是按 CPU 核心数走的。也就是说,你开 5000 个连接,底层可能只用了 8 个线程做 IO 调度。这对 .NET 应用是实打实的收益——你的业务线程可以专注做协议解析和逻辑处理,不用每个连接都去抢一个线程。
HPSocket.Net 做的事情,就是把这套 C++ 层的完成端口机制封装成 C# 能直接调用的事件接口。你在 C# 里写的是 OnReceive、OnConnect、OnDisconnect,底层跑的还是 IOCP。这也是为什么它比你在 .NET 里自己用 SocketAsyncEventArgs 写要省事得多——SocketAsyncEventArgs 也能用 IOCP,但要自己处理连接池、SAEA 对象复用、异常分支,工作量不在一个量级。
2.3 PUSH、PULL、PACK:三种工作模式怎么选
HPSocket 的 .NET 封装里,服务端和客户端都分三种模式,这是选型时第一个要做的决定。
PUSH 模式最简单,数据到达后 HPSocket 直接通过事件回调把完整的缓冲区丢给你。这个「完整」不代表是一条完整的业务消息,而是说这一包 TCP 数据完整地交给了你。你要自己做粘包拆包。优点是代码最少,缺点是缓冲区由库管理,数据来了必须尽快处理,不能拖太久。
PULL 模式正好反过来。库通知你有数据到了,但不直接给你数据,而是让你调用 Fetch 方法按需读取。这种模式适合你自己想精确控制缓冲区的场景,比如做协议网关,要把原始字节流转发出去之前做一层整流。
PACK 模式是 HPSocket 的特色,按「包头 + 包体」的格式自动拆包。包头里包含包体长度,库会在内部帮你把粘包和半包处理好,回调里给的每个 buffer 就是一条完整消息。如果你的协议是自定的,又能接受用 HPSocket 的包头格式,PACK 模式能省掉非常多的脏活。
提示:三种模式可以混用,但建议一个连接的生命周期内不要切换。服务端用 PACK,客户端也用 PACK,两边头格式要对齐。否则你会看到数据全部乱掉,而且很难排查。
3. 动手搭一个 TCP 服务端:HPSocket.Net 的最小可用工程
3.1 创建服务端并启动监听:先跑通再说
先不做花哨的设计,用控制台项目把服务端跑起来。首先从源码仓库把 HPSocket.Net 编译出来或者直接引用 NuGet 包,注意 x86/x64 要和你程序集目标平台一致,这是最常见的翻车点。
using HPSocket.Net; using HPSocket; // 创建一个 TCP 服务端实例,使用 PUSH 模式 TcpServer server = new TcpServer(); server.Address = "0.0.0.0"; server.Port = 9500; server.MaxConnectionCount = 5000; // 注册基础事件 server.OnConnect += (connId, clientIp, clientPort) => { Console.WriteLine($"[连接] {clientIp}:{clientPort} connId={connId}"); return HandleResult.Ok; }; server.OnReceive += (connId, bytes, length) => { // PUSH 模式:bytes 是本次收到的完整 TCP 数据块 // 注意:不代表是一条完整业务消息,后面还要拆包 Console.WriteLine($"[数据] connId={connId} length={length}"); return HandleResult.Ok; }; server.OnDisconnect += (connId, socketError) => { Console.WriteLine($"[断开] connId={connId} error={socketError}"); return HandleResult.Ok; }; // 启动监听 bool started = server.Start(); if (started) { Console.WriteLine("服务端已启动,监听 0.0.0.0:9500"); } else { Console.WriteLine($"启动失败,错误码:{server.ErrorCode} {server.ErrorMessage}"); }这段代码里值得注意几个参数。MaxConnectionCount不是随便设的,它影响着底层连接池的预分配策略,设太大内存占用会明显上升,设太小高峰期连接会被拒。一般按实际业务峰值的 1.5 倍设比较合理。Address填 0.0.0.0 表示监听所有网卡,如果只想对内网服务可以填具体 IP。
OnConnect里返回HandleResult.Ok,这个返回值很重要。如果返回Ignore,HPSocket 会拒绝这条连接。很多人在这个回调里做鉴权,把非法客户端拒掉,这是合理的做法,但要保证回调执行够快,不要在回调里查数据库,否则阻塞了 IOCP 工作线程,整个服务端的吞吐都会下降。
3.2 维护连接表:给每个连接一个业务身份
连上来的客户端要能区分是谁,不能只拿 connId 当一切。ConnId 是 HPSocket 分配的长整型句柄,但你不能在这个 ID 里编码业务信息。常见做法是维护一个 ConcurrentDictionary 或者在 OnConnect 时把客户端 IP 和端口记录下来。
using System.Collections.Concurrent; ConcurrentDictionary<IntPtr, ClientSession> sessions = new ConcurrentDictionary<IntPtr, ClientSession>(); server.OnConnect += (connId, clientIp, clientPort) => { var session = new ClientSession { ConnId = connId, Ip = clientIp, Port = clientPort, ConnectedAt = DateTime.UtcNow }; sessions[connId] = session; return HandleResult.Ok; }; server.OnDisconnect += (connId, socketError) => { sessions.TryRemove(connId, out _); Console.WriteLine($"[断开] connId={connId} 剩余连接数={sessions.Count}"); return HandleResult.Ok; };注意这里用的键类型是IntPtr。HPSocket.Net 的 connId 类型在不同版本里可能是 IntPtr 也可能是 long,以你引用的封装版本为准。如果在编译时报类型不匹配,看一下源码里连接句柄的 typedef 定义。
OnDisconnect里做清理要幂等。因为异常断开时系统可能连续触发多次断开回调,TryRemove 第二次会返回 false,这没问题。但绝不要在 OnDisconnect 里再去操作已经释放的会话对象,否则会出现空引用异常。
3.3 真实收发数据:回显服务与字节数核对
跑通收发最简单的方法就是做回显。客户端发什么,服务端原样发回去。这一步能验证链路双向通不通,也能让你直观看到数据到达的频率和长度分布。
server.OnReceive += (connId, bytes, length) => { // 把收到的数据原样发回给客户端 bool ok = server.Send(connId, bytes, length); if (!ok) { Console.WriteLine($"[发送失败] connId={connId} error={server.ErrorCode}"); } return HandleResult.Ok; };Send方法的成功与否不代表数据已经到对端了,只代表数据成功交给了 HPSocket 的发送队列。TCP 的可靠性由协议栈保证,但业务上如果发完就要确认,得在应用层做应答。
这段代码还暴露了一个问题:如果客户端发得很快,OnReceive 触发频率会很高,你在里面做业务逻辑就会拖慢整个接收循环。所以后面的章节我把事件处理和业务处理拆开。
注意:在事件回调里调用同一个 server 实例的 Send 是线程安全的,HPSocket 内部做了锁。但如果你在业务线程里调用 Send,得注意 connId 是否已经失效。对已断开连接调用 Send 不会崩,但返回值是 false,错误码是某个连接不存在或已关闭的码,要用 ErrorCode 去判断。
4. 客户端接入与拆包:从能通到不丢不重
4.1 用 HPSocket.Net 写客户端:和服务端对称
服务端跑通之后,客户端反而成了最容易踩坑的地方。很多人用 C# 原生 TcpClient 去连 HPSocket 服务端,连是能连上,但后面聊到 PACK 模式就尴尬了——两边对不齐。建议直接用 HPSocket.Net 的 TcpClient 封装,保证行为一致。
TcpClient client = new TcpClient(); client.Address = "127.0.0.1"; client.Port = 9500; client.OnConnect += (connId) => { Console.WriteLine("[客户端] 连接成功"); return HandleResult.Ok; }; client.OnReceive += (connId, bytes, length) => { Console.WriteLine($"[客户端] 收到 {length} 字节"); // 做拆包处理,后面讲 return HandleResult.Ok; }; bool connected = client.Connect(); if (connected) { string msg = "hello server"; byte[] data = Encoding.UTF8.GetBytes(msg); client.Send(data, data.Length); }这里多想一步:客户端的Connect是同步阻塞的,默认会有连接超时时间。生产环境建议不要在主线程里调用 Connect,而是放到后台线程,否则服务端没启动时,界面就会卡住。HPSocket 底层连接是异步完成的,Connect 返回值只表示连接请求已发出,并不代表 tcp 三次握手已完成。要确认真正连上了,等 OnConnect 回调。
4.2 粘包半包:为什么收到的字节总是对不上
前面反复提到 TCP 是字节流,这时候就能看到实际案例了。客户端连续发送两条消息:
byte[] msg1 = Encoding.UTF8.GetBytes("hello"); byte[] msg2 = Encoding.UTF8.GetBytes("world"); client.Send(msg1, msg1.Length); client.Send(msg2, msg2.Length);服务端 OnReceive 可能一次收到helloworld十个字节。这就是粘包。如果客户端发送超大数据,服务端可能分两次收到,一次 7 字节一次 3 字节,这是半包。网上常有人问「为什么 socket 接收到奇数字节,后面会补一个随机数」,其实根本不是随机数——那是下一包数据的开头,被你的接收逻辑误当成了填充。
解决粘包半包的标准做法是自定义帧格式。最常用的是「长度头 + 负载」。固定用 4 字节作为长度头,存负载的字节数,服务端先收满 4 字节,解析长度,再收够 length 个字节才算一条完整消息。
// 服务端维护每个连接独立的拆包缓冲区 class PacketDecoder { private MemoryStream buffer = new MemoryStream(); public List<byte[]> Decode(byte[] data, int length) { var messages = new List<byte[]>(); buffer.Write(data, 0, length); buffer.Position = 0; while (buffer.Length - buffer.Position >= 4) { // 1. 读长度头 byte[] header = new byte[4]; buffer.Read(header, 0, 4); int bodyLen = BitConverter.ToInt32(header, 0); // 2. 检查完整负载是否已到齐 if (buffer.Length - buffer.Position < bodyLen) { // 半包:把读取位置退回到包头,等待下一次数据 buffer.Position -= 4; break; } // 3. 读取完整负载 byte[] body = new byte[bodyLen]; buffer.Read(body, 0, bodyLen); messages.Add(body); } // 4. 把剩余未处理的数据压缩到缓冲区前半段 var rest = new byte[buffer.Length - buffer.Position]; Array.Copy(buffer.GetBuffer(), (int)buffer.Position, rest, 0, rest.Length); buffer.SetLength(0); buffer.Write(rest, 0, rest.Length); return messages; } }这个拆包逻辑的核心是那个Position -= 4回退操作。当长度头读到但负载还没到齐时,要把位置退到包头位置,等下一包 TCP 数据来了再重新解析。如果忘了回退,下一包数据会被当成负载的继续,整个流就错位了。
BitConverter.ToInt32(header, 0)需要注意字节序。HPSocket 底层走的是网络字节序,但 C# 的 BitConverter 用的是本机字节序(小端)。如果对端是 C++ 或者嵌入式设备,可能用大端序,两边解析长度会差着量级。常见的错误是解析出几百兆的长度,然后程序疯狂申请内存直到爆掉。解决方式是收到长度头后做一次字节序转换,或者约定好统一用大端。
4.3 长连接 vs 短连接:心跳与断线检测
你写好了拆包逻辑,能正确处理粘包半包了,但连接稳定性还差一步——心跳。
TCP 连接有一个特点:如果链路物理断开很久,比如网线被拔了或者对端断电,本机可能很长时间才能感知到。因为 TCP 协议栈只在发送数据时才会发现对端不可达,如果两边一直没数据,连接就僵在那里。Windows 上这个时间可能长达几十分钟。
HPSocket 服务端可以设置心跳检测参数。比较实用的方式是应用层心跳,客户端每 30 秒发送一个 PING 帧,服务端收到后回 PONG。如果服务端连续 3 个心跳周期没收到客户端任何数据,就主动调用Disconnect把连接断开。
server.OnReceive += (connId, bytes, length) => { // 假设心跳是固定 4 字节的 PING if (length == 4 && bytes[0] == (byte)'P' && bytes[1] == (byte)'I') { server.Send(connId, pongBytes, pongBytes.Length); return HandleResult.Ok; } // 正常的业务数据走拆包流程 // ... return HandleResult.Ok; };这不是为了省那点内存,而是为了让资源及时释放。连接表里每条连接都占着 socket 句柄、缓冲区、会话对象,不清理的话,几万个死连接能把内存吃穿。真正的断线不一定触发 OnDisconnect,把心跳做进协议是对自己负责。
提示:HPSocket 也提供底层 KeepAlive 设置,但它只负责让 TCP 协议栈发送探测包,探测不到不代表立刻通知你,应用层光是靠它不太够。要做到及时感知,还是应用层心跳最可靠。
5. HPSocket.Net 的避坑清单:现象、原因与解决办法
5.1 现象:连接不稳定,客户端时而连得上时而连不上
这个现象在局域网里可能不明显,一旦跨网段或者经过防火墙就很典型。客户端 TCP 三次握手只完成了两次,抓包看到 SYN 发出去了,但没收到 SYN+ACK,或者收到了但客户端回了 RST。
原因多半不是 HPSocket 的问题,而是网络环境里防火墙丢包、端口未放行,或者半连接队列满了。服务端 Listen 的 backlog 参数设置太小会直接丢新连接,HPSocket 的MaxConnectionCount设得太小也会触发拒绝策略。
解决:先启动服务端后立即用netstat -an | findstr 9500看监听状态是 LISTENING 还是 SYN_RECEIVED。如果 SYN_RECEIVED 大量积压,说明握手没完成,问题在网络设备或半连接队列。如果服务端没起来,检查 ErrorCode,端口被占用在 Windows 上会给出明确错误码,别只看「启动失败」。
5.2 现象:服务端收到大量垃圾数据,长度几万甚至几十万
这多半是把字节序搞错了。用 PACK 模式或者自定义长度头时,客户端发送长度头用的还是小端,服务端按大端解析,4 个字节的 0x05 00 00 00 —— 本来应该解析出 5,结果被解析成 83886080。然后内存申请失败或疯狂拼接。
解决:在长度头解析位置统一用BinaryPrimitives.ReadInt32BigEndian(header)这种明确指定字节序的 API,或者所有端都约定好小端并在代码里写注释。不要依赖BitConverter.IsLittleEndian在运行时判断然后手动反转——这种代码早晚有人改错。
5.3 现象:回调里做耗时操作,整个服务端吞吐骤降
HPSocket 的 OnReceive 是在底层 IO 工作线程里同步调用的。你在回调里写日志、访问网络、处理大数组,都会阻塞这一个线程。表面上你只阻塞了一个回调,实际上这个线程本来要服务几百个连接的 IO 调度,阻塞一下就全卡住了。
解决:事件回调里只做两件事——把 bytes 拷贝出来入队,或者干脆直接调用buffer.Transfer把数据转移到业务队列。业务逻辑放在独立线程池里跑。下面的代码是我常用的做法:
BlockingCollection<(IntPtr connId, byte[] data)> recvQueue = new BlockingCollection<(IntPtr, byte[])>(); server.OnReceive += (connId, bytes, length) => { byte[] copy = new byte[length]; Array.Copy(bytes, 0, copy, 0, length); recvQueue.Add((connId, copy)); return HandleResult.Ok; }; // 业务线程 Task.Run(() => { foreach (var item in recvQueue.GetConsumingEnumerable()) { // 在这里做拆包和业务处理 } });注意一定要拷贝而不是直接引用原始 buffer。HPSocket 的 PUSH 模式里,回调结束后那个缓冲区会被库回收,你再拿去异步处理就到野指针了。虽然 .NET 里是托管内存不会野指针,但内容可能已被覆盖。
5.4 现象:OnDisconnect 没触发,连接表越来越满
前面说过 TCP 断线僵死的问题。如果客户端是异常掉电或者网线被拔,服务端可能一直认为连接存在。你的连接表里堆满了假连接,新连接又挤不进来。
解决:应用层心跳兜底。服务端记录每个连接最后收到数据的时间,定时器每分钟扫一遍,超过 90 秒没数据的主动调用Disconnect(connId)。注意Disconnect会触发 OnDisconnect 回调,清理逻辑要放在回调里做,不要直接在定时器里移除连接表。
5.5 现象:x64 进程调用 HPSocket 报 BadImageFormatException
HPSocket 的原生库只有对应架构的版本。你的项目如果 AnyCPU 模式跑在 x64 系统上,加载 32 位原生 DLL 就会直接抛异常。.NET 的 AnyCPU 在 x64 系统上是 64 位进程,但如果你引用的封装包默认带了 x86 的 native 库,就必踩这个坑。
解决:项目属性里直接指定 x64 或 x86,不要用 AnyCPU。发布的时候确认runtimes/win-x64/native下的文件被拷贝到了输出目录。这种错误不是代码逻辑问题,但特别隐蔽,第一次遇到容易怀疑是 HPSocket 库本身坏了。
6. 进阶玩法:事件驱动之外,值得做的一次压测和验证
前面的事件回调写法对中小型项目已经够用了。如果你要把 HPSocket.Net 推到生产环境,我建议再做两件事。
第一是写一个简单的压测工具,不依赖第三方工具,就用 HPSocket 客户端连服务端,模拟几百个连接同时收发数据。在客户端里统计每秒事务数,服务端统计每秒接收字节数,两边对一下数字,能看出 HPSocket 实际的吞吐上限。压测时注意客户端别和服务端跑在同一台机器上,否则测出来的是本机回环的性能,没有参考价值。
第二是验证拆包逻辑的正确性。用一个随机数据生成器,发送长度随机、内容随机的消息,服务端解析后再原样返回,客户端比对内容是否一致。连续跑几万条消息,如果一条都不差,你的拆包逻辑才算真的稳。很多人在这个环节发现问题——收发的数据总量对了,但个别消息内容错位了,这就是半包回退逻辑有 bug。
我之前在一个工控项目里用 HPSocket.Net 接了三千多台设备,每台设备 5 秒上报一次数据,跑了两个多月没重启过。中间遇到的最大坑反而是我在 OnReceive 里写了个日志库调用,日志阻塞了 IO 线程,导致设备上报超时,看起来像是网络断了。排查了整整两天,最后把日志挪到业务队列里就好了。这些经验说穿了不玄学,但每一个都是用血泪换的。如果你也在用这个库做类似的事,希望这些细节能帮你少踩一次坑。
在这条链路里,我想起一句话——你不需要重新发明 TCP,但你需要尊重 TCP。HPSocket.Net 帮你把底层扛住了,你就要在业务层把边界想清楚。希望帮到你。
本文还有配套的精品资源,点击获取