免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

C# WebSocketServer 源码实战:从跑通到扛住并发

C# WebSocketServer 源码实战:从跑通到扛住并发 简介这份C# WebSocketServer服务器源代码压缩包面向具备一定.NET基础、希望深入理解实时双向通信原理的开发者尤其适合正在学习网络编程或需要搭建聊天类实时应用的技术人员。包内共18个文件以10个cs源码文件为核心辅以3个csproj项目文件、1个sln解决方案、1个config配置、1个aspx页面、1个js脚本及1个user文件整体约44KB结构紧凑便于快速加载与调试。资源围绕System.Net.WebSockets命名空间展开涵盖服务器端连接管理、聊天消息的发布订阅与广播、客户端模拟测试等模块并涉及WebSocket握手升级、并发连接处理、心跳保活及异常断开等关键知识点。已有577人学习下载读者可通过分析完整解决方案掌握从监听端口、响应升级请求到收发文本与二进制消息的完整链路积累线程安全与性能优化经验切实提升实时交互应用的开发能力。1. 拿到一份 C# WebSocketServer 源码先别急着 F5很多人第一次接触 C# WebSocketServer 服务器源代码.zip是在做上位机、设备网关或者内部消息中转的时候。需求很朴素设备或前端要长连接HTTP 轮询太费于是想找一个能直接跑的 C# 服务端。解压之后通常是一堆 .cs、.csproj、可能还有 .sln看着像能跑但真正落地时会发现三件事没人告诉你它监听哪个端口、握手怎么处理、并发上来之后线程和内存怎么管。这篇笔记就按「先看懂结构再跑通最小回环最后压出边界」的顺序把一份 C# WebSocketServer 源码从能编译推到能上线。适合手里已经有源码包、或者准备自己写一版 WebSocket 服务端的 C# 开发者尤其是做 c#上位机、设备通讯和内部服务中转的人。2. 拆开源码包C# WebSocketServer 的骨架长什么样2.1 先认清它到底用了哪套底层C# 做 WebSocket 服务端常见就三条路HttpListener自建、System.Net.WebSockets配合 ASP.NET Core、以及第三方库如 Fleck、SuperSocket。一份名为 WebSocketServer 的源码包大概率是前两种之一。判断方法很简单打开 .csproj 看引用了什么!-- 典型 ASP.NET Core 宿主说明它跑在 Kestrel 上 -- Project SdkMicrosoft.NET.Sdk.Web PropertyGroup TargetFrameworknet8.0/TargetFramework /PropertyGroup /Project如果看到Microsoft.NET.Sdk.Web说明它依赖 Kestrel 和 ASP.NET Core 的中间件管线app.UseWebSockets()是入口如果看到的是Microsoft.NET.Sdk加System.Net.HttpListener那就是自建监听握手要自己解析Sec-WebSocket-Key。这两条路的并发模型、部署方式、TLS 配置完全不同先分清再动手能省掉后面一半的排查时间。提示不要凭文件名判断。有的包叫 WebSocketServer实际是控制台程序套了 HttpListener有的叫 Server反而是 ASP.NET Core 项目。2.2 目录结构与关键类定位一份结构清晰的源码通常会有这几个角色连接管理器保存所有活跃 socket、消息处理器收发与编解码、协议解析帧解析、掩码处理、以及宿主入口Program 或 Main。打开后按这个顺序找关注点常见命名你要确认的事宿主入口Program.cs / Main监听地址、端口从哪读连接管理ConnectionManager / Session用什么容器存连接是否线程安全收发循环ReceiveLoop / HandleClient缓冲区多大异常怎么退出帧处理WebSocketFrame / Parser是否处理分片、掩码、关闭帧配置appsettings.json / Config端口、超时、最大消息体如果源码里连接管理用的是ListWebSocket且没有锁这就是第一个要改的点。WebSocket 的接收是异步的多个客户端同时上下线时裸 List 会出现「集合已修改」异常这是血泪经验里最常见的一类翻车。2.3 把项目跑起来的最小步骤先不管业务逻辑目标只有一个本机起服务用浏览器或客户端连上发一条消息能回显。步骤按顺序来# 1. 还原依赖确认 SDK 版本匹配 dotnet restore # 2. 编译先看有没有硬错误 dotnet build -c Debug # 3. 运行注意控制台打印的监听地址 dotnet run --project ./WebSocketServer.csproj跑起来后控制台一般会打印Listening on ws://0.0.0.0:5000/ws之类的地址。这时候别急着写客户端先用浏览器控制台验证握手是否正常// 在浏览器 Console 里执行验证服务端握手与回显 const ws new WebSocket(ws://127.0.0.1:5000/ws); ws.onopen () ws.send(ping); ws.onmessage (e) console.log(recv:, e.data); ws.onerror (e) console.error(err:, e);onopen触发说明握手通过onmessage收到回显说明收发循环是通的。如果onopen都没触发问题一定在握手或路由不在业务代码。这一步是整个调试的分水岭握手不通查协议和中间件握手通了但没回显才查业务。参数上重点看三个监听端口别和已占用端口冲突、路径/ws还是根路径要和客户端一致、以及是否强制 TLS。本地调试先用 ws上线再换 wss混着调最容易把自己绕进去。3. 让 C# WebSocketServer 真正能扛连接管理与收发循环3.1 连接容器为什么不能用 List服务端一旦有几十上百个连接连接管理就是核心。裸ListWebSocket的问题在于添加和移除不是原子操作接收循环在遍历发送时另一个线程可能正在移除直接抛InvalidOperationException。正确做法是用ConcurrentDictionarykey 用连接 IDvalue 存 socket 和元数据。// 线程安全的连接池key 为连接唯一 ID private static readonly ConcurrentDictionarystring, ClientSession _sessions new(); public static void Add(string id, WebSocket socket) { var session new ClientSession { Id id, Socket socket, ConnectedAt DateTime.UtcNow }; // 用 TryAdd 避免重复 ID 覆盖已有连接 if (!_sessions.TryAdd(id, session)) { // 重复说明客户端重连未清理主动关闭旧连接 socket.Abort(); } }ConcurrentDictionary保证增删的线程安全但注意它只保证容器操作安全不保证你业务逻辑的复合操作安全。比如「先判断存在再发送」这种两步操作中间仍可能被移除发送时要 catch 异常并顺手清理。参数上连接 ID 建议用Guid.NewGuid().ToString(N)别用自增 int重连场景下自增 ID 会复用排查日志时对不上人。3.2 接收循环的缓冲区与分片处理WebSocket 的接收不是一次拿到完整消息而是按帧来。缓冲区太小会频繁触发多次 Receive太大浪费内存。常见做法是 4KB 起步配合MemoryStream拼接分片。// 单连接接收循环处理分片与关闭帧 private async Task ReceiveLoop(WebSocket socket, CancellationToken token) { var buffer new byte[4096]; var message new MemoryStream(); while (socket.State WebSocketState.Open !token.IsCancellationRequested) { WebSocketReceiveResult result; try { result await socket.ReceiveAsync(new ArraySegmentbyte(buffer), token); } catch (WebSocketException ex) { // 客户端异常断开记录后退出循环 Console.WriteLine($recv error: {ex.Message}); break; } if (result.MessageType WebSocketMessageType.Close) { // 收到关闭帧回一个 Close 完成握手 await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, bye, token); break; } message.Write(buffer, 0, result.Count); // EndOfMessage 为 true 才说明一条完整消息收齐 if (result.EndOfMessage) { var payload message.ToArray(); message.SetLength(0); // 复用流避免频繁分配 await HandleMessage(socket, payload); } } }这里的关键参数是EndOfMessage它决定你是否把当前缓冲当成完整消息。很多新手直接对每次 Receive 的结果做业务处理遇到大消息被拆成多帧时就解析失败表现为「小消息正常、大消息乱码」。缓冲区 4096 是经验值如果你的业务消息普遍超过 64KB可以调到 16KB 减少循环次数但别超过 64KB否则单连接内存占用会失控。3.3 发送侧为什么要加锁或排队WebSocket 协议规定同一时刻只能有一个发送操作两个线程同时SendAsync会抛InvalidOperationException。如果你的服务端有广播需求比如设备状态推送给多个客户端必须给每个连接的发送加锁或者用发送队列串行化。// 每个连接一把发送锁保证同一时刻只有一个 SendAsync private readonly SemaphoreSlim _sendLock new(1, 1); public async Task SendAsync(byte[] data, CancellationToken token) { await _sendLock.WaitAsync(token); try { await _socket.SendAsync( new ArraySegmentbyte(data), WebSocketMessageType.Text, true, // endOfMessage单帧发完 token); } finally { _sendLock.Release(); } }SemaphoreSlim比lock好在它支持异步等待不会阻塞线程池线程。广播场景下遍历连接池逐个调用SendAsync慢连接会拖住整个广播所以更稳的做法是给每个连接配一个有界队列发送线程独立消费队列满了就丢弃或断开该连接。这是从「能跑」到「能扛」的分界线也是 c#线程 相关面试里常被追问的点。4. 避坑与排查C# WebSocketServer 上线前必须过的坎4.1 握手 400 或连接立即断开现象客户端onopen不触发浏览器 Network 里看到 101 之前返回 400。原因通常是路径不匹配或中间件顺序错了。ASP.NET Core 里UseWebSockets()必须放在路由和终结点映射之前否则请求进不了 WebSocket 管线。解决确认app.UseWebSockets()在app.MapControllers()之前且客户端 URL 的路径与服务端AcceptWebSocketAsync的路径完全一致包括结尾斜杠。4.2 大消息解析失败或乱码现象短消息正常超过几 KB 的消息收到后 JSON 解析报错。原因是没有处理分片把单帧当完整消息。解决按 3.2 的方式用MemoryStream累积直到EndOfMessage为 true 再交给业务层。同时检查客户端是否设置了maxPayload某些客户端库默认限制 1MB超了会直接断。4.3 并发上来后 CPU 飙高、连接被拒现象几十个连接时正常上百个后 CPU 打满新连接连不上。原因多半是接收循环里用了同步阻塞调用或者每次 Receive 都新建大数组导致 GC 压力。解决全程 async/await缓冲区复用避免在循环里做字符串拼接和 LINQ 查询。用dotnet-counters看 GC 和线程池队列长度队列持续增长就说明有阻塞点。4.4 客户端断线后服务端连接不释放现象客户端拔网线或进程被杀服务端连接池里还留着死连接广播时不断报错。原因是没有心跳机制TCP 半开连接不会立刻通知应用层。解决服务端定时发 Ping 帧超过 N 次没收到 Pong 就主动关闭并清理。心跳间隔常见 30 秒超时 2 到 3 次。清理时记得从连接池移除并释放信号量否则会泄漏。4.5 部署到服务器后外网连不上现象本机调试正常部署到服务器后客户端连不上。原因通常是防火墙没放行端口或者监听地址绑成了127.0.0.1。解决监听地址用0.0.0.0在服务器安全组和系统防火墙里放行对应端口。Windows 上用netstat -ano | findstr 端口确认监听状态看到0.0.0.0:端口才算对。这一步和 c#上位机 现场调试遇到的情况一模一样别在代码里找半天先查网络。5. 进阶给 C# WebSocketServer 加一层可验证的压测与心跳5.1 用脚本压出真实并发上限服务端能不能扛不能靠感觉。写一个简单的压测客户端开 N 个连接每个连接定时发消息观察服务端内存和错误率。// 压测客户端开 200 个连接每 100ms 发一条 var tasks Enumerable.Range(0, 200).Select(async i { using var client new ClientWebSocket(); await client.ConnectAsync(new Uri(ws://127.0.0.1:5000/ws), CancellationToken.None); var payload Encoding.UTF8.GetBytes(${{\id\:{i},\ts\:{DateTime.UtcNow.Ticks}}}); for (int n 0; n 100; n) { await client.SendAsync(payload, WebSocketMessageType.Text, true, CancellationToken.None); await Task.Delay(100); } }); await Task.WhenAll(tasks);跑之前先记录服务端基线内存跑的过程中用任务管理器或dotnet-counters观察。重点看三个指标内存是否持续增长泄漏、错误日志是否集中出现并发 bug、以及消息延迟是否随连接数上升而恶化。200 连接是个起点逐步加到 500、1000找到错误率开始上升的拐点那个数字才是你这版源码的真实上限。5.2 心跳与优雅关闭的落地写法心跳不是可选项是生产环境的必需品。服务端定时遍历连接池对每个连接发 Ping同时记录最后一次 Pong 时间。// 心跳巡检30 秒一轮超时 90 秒断开 private async Task HeartbeatLoop(CancellationToken token) { while (!token.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(30), token); var now DateTime.UtcNow; foreach (var kv in _sessions) { var s kv.Value; if ((now - s.LastPong).TotalSeconds 90) { // 超时关闭并清理 await s.CloseAsync(heartbeat timeout); _sessions.TryRemove(kv.Key, out _); continue; } await s.PingAsync(token); } } }LastPong在收到 Pong 帧时更新。注意 Ping 和 Pong 是 WebSocket 协议层的控制帧不占用业务消息通道客户端库一般会自动回 Pong但自己写的客户端要手动处理。优雅关闭时先停心跳再遍历连接发 Close 帧等几秒让客户端确认最后强制 Abort 剩余连接。这套流程走完服务重启才不会让客户端集体报错。5.3 我踩过的那个坑早期我图省事把连接池的清理逻辑放在接收循环的 finally 里觉得连接断了自然会走到。结果遇到客户端网络抖动、TCP 连接没断但应用层不再发数据的情况接收循环一直挂着连接永远不释放跑一天下来连接池里全是僵尸。后来加了心跳巡检才根治。所以我的习惯是任何长连接服务上线前先问自己一句「心跳和超时清理写了吗」没写就别部署。希望帮到你。本文还有配套的精品资源点击获取
返回列表