简介:这是一份面向C#网络编程学习者的Socket通信示例工程,围绕“客户端之间直接通信”展开,由服务器端与客户端两套程序组成。方案采用面向连接的Socket,自定义应答模式完成双向数据收发;服务器可响应多个客户端连接请求,既能向指定客户端发送消息,也支持群发,并具备异常响应能力,可处理对端非正常退出等情形。压缩包共44个文件,以C#源码、可执行程序、TXT说明和资源文件为主,整体仅104KB,便于下载后直接运行与研读。目前已有5207人学习下载。通过完整工程,读者可掌握多客户端连接管理、点对点直连通信、单发与群发逻辑以及Socket异常捕获等关键实现,适合用作课程设计、实验报告或Socket入门进阶的参考资料。
1. C# Socket客户端直连:信号服务器只做媒人,数据不再过中转
两台机器要互发数据,直觉做法是架一台服务器当中转,所有消息都过它一遍。这份 C# Socket 项目反着来:服务器只在开始时做一次“牵线”,交换双方的 IP 和端口,之后两个客户端各自创建新的 Socket 直接互联,业务数据完全绕开服务器。对延迟敏感的场景,这差别立刻能感受到——哪怕在内网,多一跳中转都会让视频画面或远程操作变卡。这套方案适合局域网内的点对点聊天、文件互传、远程控制这类应用,也能让刚接触 Socket 的开发者看清“直连”和“C/S 转发”在连接模型上的本质区别:中转模式下服务器是数据瓶颈,直连模式下服务器只是一个轻量级的握手入口。
2. 架构与协议设计:一次信令握手换一条独立数据通道
2.1 整体拓扑:一条控制链路加一条数据链路的双 Socket 模型
这套系统里每个客户端至少要维护两条 TCP 连接。第一条是连到信令服务器的控制连接,用来注册自己的名字、请求目标端点、接收对方的 IP 和端口;第二条才是真正干活的直连数据连接,建立在两个客户端之间。两条连接职责完全不同,代码上必须分开管理,混在一起会非常痛苦。
客户端A 信令服务器 客户端B | 1.注册(名字+端口) | | |------------------------->| | | | 2.注册(名字+端口) | | |<-----------------------------| | 3.请求与B直连 | | |------------------------->| | | | 4.返回B的端点,同时告知B | |<-------------------------|------------------------------>| | 5.创建数据Socket,直连B | | |--------------------------------------------------------->| | 6.握手成功,开始互发数据 | | |<-------------------------------------------------------->|为什么一定要信令服务器这一跳?因为两个客户端在建立直连之前,都不知道对方的 IP 和监听端口——这就是 Socket 编程里最经典的“先有鸡还是先有蛋”问题。服务器在这里充当了 Rendezvous(会合点)的角色,它只负责交换元数据,之后的字节流一概不碰。常见做法是客户端启动时随机选一个本地端口用于数据监听,然后把这个端口通过控制连接报告给服务器,服务器登记下来,等待别的客户端来查。
2.2 自定义协议:四个字节长度头加协议号的封包格式
TCP 是字节流协议,没有消息边界。如果不定义自己的应用层协议,接收方根本不知道一条消息在哪里结束、下一条从哪里开始。这套项目里我用的是最普遍也最好维护的格式:每个数据包由“4 字节包体长度 + 1 字节协议号 + 负载数据”组成。
协议号定义如下:
| 协议号 | 含义 | 负载内容 |
|---|---|---|
| 0x01 | 注册自身信息 | 昵称 + 数据监听端口 |
| 0x02 | 请求与目标直连 | 目标昵称 |
| 0x03 | 直连握手应答 | 目标 IP + 端口(字符串) |
| 0x04 | 心跳保活 | 空 |
| 0x05 | 业务数据 | 真正的聊天内容或文件块 |
这个设计的取舍在于:长度头用 4 字节而不是 2 字节,是给未来传文件留余地。2 字节最大只能表示 64KB,传一张图片就要拆几十个包;4 字节能到 1GB,虽然实际单包通常限制在 1MB 以内,但至少不用改协议。协议号用 1 字节,短小够用,等需要消息确认、文件分块、流控指令时再往后顺延编号就行。
2.3 握手的完整时序:从注册到数据 Socket 真正连通
一次成功的直连要经历五个步骤,任何一步失败都要回到控制连接上重试。第一步是两端分别向服务器注册,服务器把“昵称 → IP:端口”这对映射存进字典;第二步是 A 发送 0x02 请求直连 B;第三步是服务器查表,把 B 的端点返回给 A,同时主动通知 B“有个客户端要和你建立直连”;第四步两边各自new Socket并向对方Connect;第五步连接建立后,先发一条握手消息确认身份,再把控制连接上的这条 Socket 保持最小带宽占用,只做心跳保活。
这里有个细节值得注意:两个客户端几乎同时向对方发起 Connect,属于典型的 TCP 同时打开(Simultaneous Open)。在 Windows 下这种并发连接一般能握手成功,但为了稳妥,实际项目里通常让主动方先 Connect,被动方在 Accept 循环里等着,也就是一主一从的常规模式。这套项目用的就是后者,编程模型更简单,也不用担心同时打开在部分嵌入式系统上支持不好的问题。
3. 核心代码拆解:从 Accept 到直连数据收发的完整链路
3.1 服务端:Accept 循环与客户端会话登记
信令服务器复杂度不高,核心就两件事:不停 Accept 新连接,然后启动一个接收线程处理每个客户端发来的协议包。我用ConcurrentDictionary维护在线客户端,键是昵称,值是 Socket,天然避免多线程下的字典并发写冲突。
// SignalServer.cs —— 信令服务器,只负责登记和端点交换 public class SignalServer { private TcpListener _listener; private readonly ConcurrentDictionary<string, Socket> _clients = new(); public void Start(int port) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); _listener.BeginAcceptSocket(OnAccept, null); // 先挂起第一个异步接收 Console.WriteLine($"信令服务器已启动,端口 {port}"); } private void OnAccept(IAsyncResult ar) { Socket client = _listener.EndAcceptSocket(ar); _listener.BeginAcceptSocket(OnAccept, null); // 立刻挂下一个,不能阻塞新连接 ThreadPool.QueueUserWorkItem(ReceiveLoop, client); // 每个连接单独线程处理 } private void ReceiveLoop(object state) { Socket socket = (Socket)state; ConnectionBuffer buffer = new ConnectionBuffer(); byte[] chunk = new byte[8192]; try { while (true) { int read = socket.Receive(chunk); if (read == 0) break; // 对端正常关闭,退出循环 foreach (byte[] body in buffer.Feed(chunk.AsSpan(0, read).ToArray())) { int protocol = body[0]; if (protocol == 0x01) { string text = Encoding.UTF8.GetString(body, 1, body.Length - 1); string[] parts = text.Split('|'); _clients[parts[0]] = socket; // 昵称 -> 该客户端的控制连接 Console.WriteLine($"{parts[0]} 注册成功"); } else if (protocol == 0x02) { string target = Encoding.UTF8.GetString(body, 1, body.Length - 1); HandleDirectRequest(socket, target); } } } } catch (SocketException) { } finally { foreach (var kvp in _clients) if (kvp.Value == socket) _clients.TryRemove(kvp.Key, out _); socket.Close(); } } }逻辑说明:BeginAcceptSocket在回调里再次调用自己,构成一个永不阻塞的异步 Accept 链——这是 Windows 平台上做长连接服务器的标准姿势。每个连接来了之后扔进线程池跑ReceiveLoop,避免一个慢客户端拖住全局。Receive返回 0 表示对端优雅关闭,这是判断断线最可靠的信号之一。ConnectionBuffer.Feed负责把可能半截或粘连的字节流拆成一包一包的完整消息,它的实现放在 3.3 小节讲。
参数说明:port是信令服务器监听端口,建议 5000 以上的高位端口,避免和常见服务冲突。接收缓冲区chunk开 8192 字节对信令消息绰绰有余,但真正传文件时不能在控制连接上搞,得走直连的数据 Socket——这是后面避坑章要展开的点。注册负载用昵称|数据监听端口的文本格式,简单直观,切分用Split('|')就够了,没必要为信令数据上 JSON。
3.2 客户端直连握手:请求、端点互换与新连接建立
客户端这边的握手逻辑是核心。它要发 0x02 请求,等服务端回 0x03 拿到目标端点,然后创建一条全新的数据 Socket 去连接对方。
// DirectClient.cs —— 客户端发起直连的核心方法 public Socket ConnectToTarget(Socket controlSocket, string targetName) { // 1. 向服务器查询目标端点 byte[] req = BuildPacket(0x02, Encoding.UTF8.GetBytes(targetName)); controlSocket.Send(req); // 2. 读取握手应答,这里复用一个带超时的读包方法 byte[] respBody = ReadPacketWithTimeout(controlSocket, 3000); if (respBody == null || respBody[0] != 0x03) throw new TimeoutException("信令服务器握手超时"); string endpointStr = Encoding.UTF8.GetString(respBody, 1, respBody.Length - 1); if (!IPEndPoint.TryParse(endpointStr, out IPEndPoint remoteEp)) throw new FormatException($"无法解析端点: {endpointStr}"); // 3. 创建全新的数据 Socket,而不是挪用控制连接 Socket dataSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); dataSocket.ReceiveTimeout = 3000; dataSocket.NoDelay = true; // 小消息不用等 Nagle 攒包,延迟优先 dataSocket.Connect(remoteEp); // 4. 直连建立后发送一条身份确认,告诉对端“我是谁” byte[] hello = BuildPacket(0x05, Encoding.UTF8.GetBytes("direct-handshake")); dataSocket.Send(hello); Console.WriteLine($"与 {remoteEp} 的直连已建立"); return dataSocket; }逻辑说明:注意这里出现了两条 Socket——controlSocket保持和信令服务器的连接,dataSocket才是直连对端的通道。很多新手会把控制连接直接拿过来给目标发数据,那就变成服务器转发了,和直连的目标背道而驰。NoDelay = true关掉 Nagle 算法,对于几十字节的聊天消息来说,关掉后延迟能降低一个台阶;如果需要传大文件,可以关掉,因为大包不受 Nagle 影响。
参数说明:ReadPacketWithTimeout(controlSocket, 3000)里的 3000 是毫秒超时,覆盖“服务器查无此人”或“网络分区”的情况。IPEndPoint.TryParse解析192.168.1.5:51000这种组合,注意端口号一定要带,否则连上不去的概率极高。直连握手用的BuildPacket就是前面协议里讲过的“长度头 + 协议号 + 负载”组装函数,这里不再展开。
3.3 接收循环与粘包解析:累积缓冲区的正确切包姿势
这是整个项目里最值得抄的一段代码。TCP 是流,一次Receive可能读到半包、一整个包、甚至好几个包拼在一起。如果直接用返回值拼字符串,你会在调试窗口看到一堆乱码和错位的消息。正确做法是维护一个累积缓冲区,每次读到的字节追加进去,然后循环尝试从中切出完整包。
// ConnectionBuffer.cs —— 解决粘包/半包问题的核心工具 public class ConnectionBuffer { private readonly List<byte> _cache = new List<byte>(); // 每来一批新数据就追加进缓存,然后尝试切包 // 返回切出来的完整包体列表,切不出来就返回空 public List<byte[]> Feed(byte[] chunk) { List<byte[]> packets = new List<byte[]>(); _cache.AddRange(chunk); while (_cache.Count >= 4) // 至少要有长度头才能判断 { int len = BitConverter.ToInt32(_cache.GetRange(0, 4).ToArray(), 0); if (len <= 0 || len > 1_048_576) // 1MB 上限,防止恶意超大包拖死内存 { _cache.Clear(); throw new InvalidDataException($"非法包长度: {len}"); } if (_cache.Count < 4 + len) break; // 半包,等下一段数据到来 byte[] body = _cache.GetRange(4, len).ToArray(); _cache.RemoveRange(0, 4 + len); // 消费掉这一包 packets.Add(body); } return packets; } }逻辑说明:while循环是关键,它保证一次Feed能把缓存里所有完整包都切出来。_cache.Count < 4 + len时直接break,说明长度头有了但负载还没到齐,这是典型的半包场景,等下一次Receive再补。误用点在于很多人用List<byte>每次都RemoveAt(0)来消费数据,复杂度退化成 O(n²),数据量大时肉眼可见地卡顿;这里用RemoveRange整段移除,配合GetRange拷贝,性能足够,代码也直观。
参数说明:1_048_576是单包最大字节数,聊天场景远用不到,但保底起见给了 1MB。如果你要改造成传文件,建议把上限抬到 16MB 或干脆拆块传输,不然缓冲区的List<byte>会频繁扩容。这个类设计成无状态、每次Feed返回包集合,天然线程友好——每个 Socket 连接维护一个自己的ConnectionBuffer实例,互不干扰。
4. 避坑实录:Windows 下直连 Socket 的四个真实翻车现场
4.1 粘包与半包:一次 Receive 回来一串连在一起的消息
现象:客户端发了三条消息,接收方只收到一次Receive返回,解出来的字符串是“你好吗我很好谢谢”这种拼接体;或者反过来,一条 200KB 的消息被拆成几十次Receive,每次只到几十字节,直接Encoding.UTF8.GetString会解出乱码。
原因:TCP 不感知消息边界,底层的 ACK 和重传机制决定了应用层拿到的是字节流,不是消息流。什么时候拆、拆成几段,完全看网卡驱动心情,没有任何保证。
解决:任何基于 TCP 的自定义协议都必须带长度头。用ConnectionBuffer累积数据,够一个完整包就切出来,不够就留在缓存里等下一批。我从不在Receive回调里直接处理消息,永远先Feed进缓冲区,再遍历返回值。这算是我做过最值的一笔投资,之后所有 Socket 项目都直接复用这个类。
4.2 SocketException 远程主机强制关闭:对端崩了,这边还在傻等
现象:A 和 B 直连正聊着,B 的进程崩溃了,A 这边的Receive并没有立刻返回,而是先抛出SocketException,错误码是ConnectionReset(10054),有时候甚至什么都不抛,一直阻塞到操作系统超时。
原因:B 的进程被强杀时,操作系统会发 RST 包而不是 FIN 包。A 收到 RST 后,正在Receive的调用会直接以异常形式退出。如果 B 是正常调用Close(),A 的Receive才会返回 0。这两种情况如果代码没区分,就表现为日志里时而异常、时而正常退出。
解决:Receive的返回值要当作断线信号处理——返回 0 就跳出循环。捕获SocketException时判断SocketError.ConnectionReset,统一当作对端下线处理,清理本地会话并通知 UI 层。直连通路上我给两端加了心跳(0x04 协议)兜底,30 秒发一次,90 秒没等到就主动判定死链,不依赖平时容易被吞掉的异常。
4.3 服务器拿到的 IP 连不上:内网回报的端点成了“假地址”
现象:信令服务器在公网服务器上跑,两个客户端都在各自公司的局域网里。服务器把 B 的“公网 IP + 端口”回给 A,A 拿着这个地址去Connect,结果超时。
原因:这是 NAT 环境的经典问题。服务器看到的 IP 是路由器公网出口的地址,两个客户端实际上都在内网,公网地址之间根本路由不到对方内网的那台机器。项目在纯局域网场景没问题,一旦跨公网就翻车。
解决:局域网内开发时让客户端上报IPAddress.Loopback转成局域网 IP 去连。跨公网要做 NAT 穿透,常见姿势是两个客户端都向同一个公网服务器发送 UDP 包,服务器把双方观察到的公网端点互换,两边立刻向对方的公网端点发 UDP——这就是 UDP 打洞。TCP 打洞难度大得多,如果穿透失败,保底方案是回退成服务器中转模式。我把回退逻辑做成了配置项,DirectMode=false时所有数据走服务器转发,功能不缺损,但性能打折。
4.4 多线程同时 Send:两条消息互相穿插,字节流乱了
现象:UI 线程里用户发了“你好”,后台线程同时在发心跳包,对端收到的是“你心跳好”这种字节错乱的包,长度头对不上,直接触发 4.1 里的非法长度异常。
原因:Socket.Send在多线程下看似每次调用都是原子的,实际上 TCP 层会把多个线程发上来的字节混在一起写进发送缓冲区,应用层的数据顺序和边界完全丢失。
解决:给数据 Socket 加一把发送锁,或者用一个独立的发送队列,所有线程都往队列里塞包,单线程逐个Send。我习惯用后者,因为发送队列天然支持批量发送和拥塞控制——需要限速时在队列出口加个定时器就行。代码实现上就是一个ConcurrentQueue<byte[]>加一个后台消费线程,对端看到的永远是整包整包的消息,不再穿插。
5. 二次开发:从点对点聊天升级到房间互连与文件传输
想把这套直连模型做成真正的产品,社区最常加的两个能力是房间多人互连和文件传输。
房间模式改造要从信令服务器下手。把ConcurrentDictionary<string, Socket>的键从“昵称”改成“房间号”,0x02 请求不再查单个用户,而是返回同一房间内全部成员的端点列表。新成员加入房间时,服务器主动把已有成员列表推给它,反过来也推给老成员“有人进来了”,新客户端和房间内每个老客户端各建一条直连。注意这个模型是 O(n²) 的连接量,房间超过 20 人时直连数逼近 200 条,资源开销巨大,更好的做法是房间内只让主持人做转发,或者超过阈值自动切换服务器中转,不要无脑全互联。
文件传输建议别复用聊天 Socket。大文件的分块传输会把消息时延拖高,体验极差。我在项目里是单独开一条传输 Socket,每次传输的文件按 256KB 分块,块格式是:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 块序号 | 4 | 0 起始 |
| 总块数 | 4 | 用于接收端预分配空间 |
| CRC32 | 4 | 校验整块数据 |
| 数据体 | 变长 | 真正文件内容 |
发送方每发一块就等接收方的 ACK,超时 2 秒重发同一块;收到重复块直接丢弃但也要回 ACK。这个协议简陋但可靠,配合直连通道能跑出比服务器中转高得多的吞吐。加断线重连时,心跳线程每 30 秒发 0x04,连续 3 次无响应就触发重连,重连间隔按 500ms、1s、2s、4s 做指数退避,最多重试 5 次。
从那以后我每次动协议头或者加新包类型,都强制写一个十几行的最小控制台对拍程序,一边发一边收,先验证粘包解析,再开多线程测 Send 并发,最后才接 UI。直连通信这种底层模块,黑匣子式地丢进去跑迟早要还债。希望帮到你。
本文还有配套的精品资源,点击获取