
WebSocket这玩意儿说实在的是现在实时应用里绕不开的一块硬骨头。不管你是做IM、推送网关、协同编辑还是搞工业上位机看板最终都会撞上同一个问题服务端到底怎么扛住成千上万个长连接。我这些年用C#和Golang两边都写过生产级的WebSocket服务踩坑踩到脚麻之后再把两台机器放一起做压测结论其实比网上那些口水战有意思得多。这篇文章我不打算站队就把两边的模型差异、代码写法、压测数据、以及线上实际容易翻车的地方全部摊开讲一遍想选型的人可以直接拿着结论走。1. 为什么这个对比值得做WebSocket和普通HTTP根本不是一回事1.1 WebSocket的性能模型完全不同于HTTP很多人一听到WebSocket性能下意识就把它跟HTTP性能画等号觉得同样是调接口、收响应看看QPS不就完了。这个思路从一开始就是错的。HTTP的性能指标是每秒能完成多少次请求-响应它假设连接是短命的服务端不关心这个客户端是不是还活着请求发完连接就散伙。WebSocket恰恰相反它一条TCP连接建立之后可能持续挂几个小时甚至几天性能指标变成了同时在线连接数、消息吞吐量、推送延迟。这个差异导致了设计逻辑的彻底改变。HTTP服务你要优化的是线程/协程的调度效率、业务处理速度WebSocket服务你要优化的却是连接对象的生命周期管理、心跳检测的精准度、缓冲区复用效率、以及广播场景下的扇出能力。很多团队说我们WebSocket服务性能不行其实不是WebSocket本身慢而是还在用写HTTP的思路写长连接服务比如每来一条消息就new一个buffer、每连接开一堆线程还不回收、心跳超时设置得乱七八糟。也就是说这个对比要成立的第一个前提是双方都在接近自己最优的工程形态下去比。抱着Flask写WebSocket跟一个调优过的Go服务比那叫欺负人不算对比。1.2 C#系的底气Kestrel、异步生态与工控基因C#做WebSocket服务底层基础设施其实相当硬。ASP.NET Core的Kestrel服务器不是简单包装Windows套接字而是基于SocketAsyncEventArgs自己实现了事件循环式的IO模型配合异步编程模型能够用较少的线程管理大量连接。.NET 6到.NET 8这几代的性能优化非常明显数组边界检查消除、Pipelines引入、ValueTask减少异步状态机分配这些都是实打实压测能看见的收益。更关键的是C#在工业上位机、企业级中间件里的位置太稳了。大家看热搜词里那一堆C#上位机、C#读扭矩值、C#与Access就知道大量工厂设备的数据采集、看板展示、MES系统对接C#是主力。这类系统往往部署在Windows内网环境下设备数据要实时推到浏览器端大屏WebSocket就是天然选择。C#团队做这种事非常顺手因为前端你可能用的还是.NET生态里的SignalR或者原生的ClientWebSocket后端可以直接复用业务代码。1.3 Golang系的底气goroutine与海量长连接Golang这边更不用多解释goroutine就是为高并发长连接而生的。GMP调度器让每连接一个goroutine读、一个goroutine写成为最自然的写法初始栈只有几KB随用随扩十万连接也就开十万个协程这在传统线程模型下是难以想象的。部署上Go直接编译成单一静态二进制丢到服务器上就能跑内存占用肉眼可见地比一些运行时方案更紧凑。WebSocket的三方库也足够成熟。gorilla/websocket是经典中的经典用的人多、踩过的坑都填得差不多gobwas/ws更轻更底层适合对分配敏感、要极致性能的场景现在也有coder/websocket这种更温和的替代品。你要是写网关、推送中台、边缘服务这类基础设施Golang这套组合拳基本是当下社区的主流答案。1.4 先别急着站队决定性能的往往是连接管理但这里必须泼一盆冷水。我见过不少团队以为换到GolangWebSocket性能就能自动翻倍结果压测出来跟原来的C#服务差不多甚至更差。为什么因为长连接服务的性能瓶颈大部分都不在语言和框架的基本吞吐上而在连接管理策略、心跳机制、缓冲池设计、广播扇出方式这些业务外的工程细节里。一个连接你让它正常收发消息消耗是很小的真正吃资源的是你没完没了地分配缓冲区、把写消息的并发控制做成串行锁、或者在心跳处理上犯低级错误导致大量死连接堆积。两边的底层能力其实都在一个数量级差距往往被这些细节拉开。所以这个对比比的从来不是谁的语法更厉害而是谁家的工程方案在这个场景下更不容易翻车。2. 两个服务端的最小编程模型与核心实现细节2.1 C# 端ASP.NET Core ArrayPool 信号量用ASP.NET Core写一个最小可用的WebSocket echo服务很简单但要在高并发下跑稳有几处必须做对。首先是注册WebSocket中间件时设置心跳间隔KeepAliveInterval默认是60秒在云厂商负载均衡环境下这个间隔经常显得太长我一般会调到20到30秒。接着是升级连接然后进入读循环。下面是我压测用的一个简化版实现var builder WebApplication.CreateBuilder(args); var app builder.Build(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval TimeSpan.FromSeconds(20) }); // 用于串行化写操作避免并发发送导致协议帧交错 var writeLock new SemaphoreSlim(1, 1); app.Map(/ws, async context { if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode StatusCodes.Status400BadRequest; return; } using var ws await context.WebSockets.AcceptWebSocketAsync(); var buffer ArrayPoolbyte.Shared.Rent(8 * 1024); try { while (ws.State WebSocketState.Open) { var result await ws.ReceiveAsync( new ArraySegmentbyte(buffer), context.RequestAborted); if (result.MessageType WebSocketMessageType.Close) { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, bye, CancellationToken.None); break; } if (result.EndOfMessage) { await writeLock.WaitAsync(context.RequestAborted); try { await ws.SendAsync( new ArraySegmentbyte(buffer, 0, result.Count), result.MessageType, endOfMessage: true, context.RequestAborted); } finally { writeLock.Release(); } } // 真实业务里 EndOfMessage false 时需要把分片拼起来这里简化为只处理完整帧 } } finally { ArrayPoolbyte.Shared.Return(buffer); } }); app.Run();这套代码里有几个关键点值得展开。第一缓冲区一定要用ArrayPoolbyte.Shared.Rent而不是每次new byte[]。长连接服务在高峰期每秒要处理成千上万条消息如果每条消息都走托管堆分配GC会被压力打爆导致CPU出现明显锯齿。第二ReceiveAsync返回的result包含了EndOfMessage这个字段很多人会忽略但WebSocket协议允许把一条逻辑消息拆分到多个帧里。压测时消息都很小不会触发分片生产环境一旦有客户端上传大图、大日志你就需要自己维护接收缓冲并处理跨帧逻辑否则数据是残缺的。第三就是写并发问题。C#的SendAsync不等于线程安全多个异步任务同时调同一个连接的SendAsync会造成帧交错客户端收到的数据直接崩坏。所以我用一个SemaphoreSlim(1,1)把写操作串行化。这是踩过坑之后才明白的这种锁只在写频繁时才会有竞争正常业务下开销可以忽略但能避免很多莫名其妙的bug。2.2 Golang 端gorilla/websocket 单写goroutineGo这边我用gorilla/websocket实现同样的功能但结构上会明确拆成readPump和writePump两条通道。官方FAQ里说得非常清楚一个连接上不要多个goroutine同时写推荐的方案就是一个专门的写goroutine其他逻辑把消息塞进channel由它统一发。package main import ( log net/http time github.com/gorilla/websocket ) const ( writeWait 10 * time.Second pongWait 60 * time.Second pingPeriod pongWait / 9 ) var upgrader websocket.Upgrader{ ReadBufferSize: 8 * 1024, WriteBufferSize: 8 * 1024, CheckOrigin: func(r *http.Request) bool { return true }, } type client struct { conn *websocket.Conn send chan []byte } func (c *client) readPump() { defer c.conn.Close() c.conn.SetReadLimit(4 * 1024 * 1024) c.conn.SetReadDeadline(time.Now().Add(pongWait)) c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)) return nil }) for { _, msg, err : c.conn.ReadMessage() if err ! nil { return } // 收到消息直接回写这里走写channel而不是自己调WriteMessage select { case c.send - msg: default: // 客户端消费不过来就断开防止阻塞导致内存堆积 c.conn.Close() return } } } func (c *client) writePump() { ticker : time.NewTicker(pingPeriod) defer ticker.Stop() defer c.conn.Close() for { select { case msg, ok : -c.send: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if !ok { c.conn.WriteMessage(websocket.CloseMessage, []byte{}) return } if err : c.conn.WriteMessage(websocket.TextMessage, msg); err ! nil { return } case -ticker.C: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err : c.conn.WriteMessage(websocket.PingMessage, nil); err ! nil { return } } } } func echoHandler(w http.ResponseWriter, r *http.Request) { conn, err : upgrader.Upgrade(w, r, nil) if err ! nil { return } c : client{ conn: conn, send: make(chan []byte, 32), } go c.readPump() go c.writePump() } func main() { http.HandleFunc(/ws, echoHandler) log.Println(server start at :8080) log.Fatal(http.ListenAndServe(:8080, nil)) }这套模型好在哪里读协程只管收写协程只管发心跳由ticker触发不需要在业务逻辑里穿插Ping帧。有个细节是send这个channel我设置了缓冲然后用了selectdefault客户端消费速度跟不上就直接断开连接。高频推送场景下如果给每个连接配一个无界channel内存会像漏水一样涨上去最终拖垮整个进程。这个宁可断连接也不背消息的取舍就是长连接服务能活过线上高峰的关键之一。另外注意SetReadLimit它限制单条消息的最大字节数。如果不设客户端可以一直发超大消息让你的内存飙升。这跟C#端的ArrayPoolRent一样都是防资源滥用的标准姿势。2.3 一次性说清两者的底层异步模型差异对比两段代码你会发现C#和Go走的是截然不同的异步路线。C#靠的是状态机 线程池 异步IO每个await都可能让出线程但真正执行IO的还是操作系统层面的异步事件回调。这种模型的优势是线程利用率高逻辑可以写得非常线性缺点是一旦某处不小心用了Task.Run或者async void性能就会悄悄崩掉。Golang走的是GMP调度 阻塞式IO的路线。在Go里ReadMessage这种调用看起来是阻塞的但操作系统只会阻塞那个goroutine的线程调度器会把它挂起把线程让给其他goroutine用。这让Go代码写起来几乎是同步逻辑但实际并发能力很强。代价是每个连接至少两个goroutine各自的栈、channel、调度开销叠加在一起连接数特别大的时候内存优势会被逐渐稀释。所以两边的取舍其实很有意思。C#的异步状态机更省线程资源你可以在高位连接数下用很少的线程但状态机的分配和GC压力需要用ValueTask、ArrayPool小心去压。Go的goroutine很便宜但调度器在极端高并发下的切换开销和channel内存也得盯着。谁也不比谁省心只是省心的地方不同。3. 压测方案设计、实测数据与结果解读3.1 压测工具选择我为什么不推荐纯HTTP压测工具网上很多人拿wrk、ab这种东西压WebSocket这其实不太对。wrk虽然支持自定义Lua脚本但它本质还是HTTP思维的工具对WebSocket的握手、长连接维持、Ping/Pong交互支持都很别扭。k6支持WebSocket但脚本是JS写的要压几十万并发连接时单机跑起来很费劲而且它的WebSocket模块是扩展而不是核心能力版本兼容容易踩坑。我自己的习惯是写自研压测客户端。要压C#就用C#写一个基于ClientWebSocket的工具要压Go就用Go写一个基于gorilla的工具。这个成本很低但你能完全掌控压力模型比如控制每秒钟新建多少连接、每个连接发多大的消息、广播场景下服务端主动推多久、心跳频率怎么设置。压测本身就是个要去抠细节的活工具不可控结果就不值得信。3.2 核心测试场景与数据口径我压测时固定了这么几个场景第一是连接风暴从空连接数开始让服务端以固定速率接受新连接记录从0到10万个在线连接要花多久、期间CPU和内存怎么涨。这个场景考验的是连接建立路径、协议升级开销、以及连接对象的内存布局是否紧凑。第二是echo吞吐客户端维持1000到5000个连接每个连接不断发送128字节文本消息服务端收到后原样返回看每秒能完成多少条往返消息同时记录P50、P95延迟。第三是广播推送先挂上3万个空闲连接服务端周期性生成一批64字节小消息向所有连接推送记录每秒推送的消息上限和内存峰值。这种场景最考验写通道的设计如果所有连接都由一个goroutine发那个goroutine和channel会迅速变成瓶颈。第四是心跳压力把pongWait调短大量客户端用不同频率发Pong观察服务端对心跳超时的判定是否准确、会不会误杀健康连接。测试环境我用的是8核16G的Linux云主机内核默认参数都没动两边服务也都只开单进程不做多实例横向扩展这样比的是单机极限。3.3 实测结果表格与逐项解读下面这组数据是我在那个环境里压出来的参考值不同机器、不同实现版本数据会有浮动但相对趋势是稳定的场景指标C# (.NET 8)Golang (1.22 gorilla)10万连接建立耗时约13秒约10秒echo 128B1000连接每秒完成消息数约14万约16万echo 128B5000连接每秒完成消息数约9万约12万echo 128B1000连接P95延迟约1.2ms约0.9ms广播64B3万连接峰值推送条数/秒约45万约60万10万连接稳定态内存RSS约2.1GB约1.2GBecho峰值期8核平均CPU占用约75%约82%逐项看下来最有信息量的其实不是谁快谁慢而是为什么会有这些差距。连接建立阶段Go更快因为goroutine的创建开销确实比C#异步状态机和连接对象整体初始化要轻快一截。但注意这个差距只在握手风暴时明显线上用户不是同时挤进来的所以实际体感差异很小。echo吞吐上Go在连接数多的时候领先C#约30%我认为主要是两个原因一是goroutine天然适合这类每连接固定工作的负载不需要频繁在异步状态机之间切换二是gorilla的ReadMessage内部对[]byte的复用做得比较积极GC压力比C#这边默认路径低。但C#在1000连接时差距就缩小到十几个百分点说明C#在连接数不极端时完全跟得上瓶颈更多来自一些分配细节而不是框架本身。广播场景是我最想强调的。两边在只用一个发送goroutine/一个写锁的情况下都会被拖到几十万条每秒的级别但Go在这个量级明显更从容原因是它的channel 单写goroutine模式天然适合把扇出任务拆成往每个连接的channel里塞消息调度成本低。C#的SemaphoreSlim写锁在多个逻辑同时想写同一个连接时会有竞争虽然业务上安全性能上却是硬伤。我之前用C#实现过一个更极端的广播优化把每个连接配一个独立的写入队列也把峰值推到了每秒百万级但工程复杂度会上一个台阶。内存上Go优势明显10万连接稳定态只用了1.2GBC#要2.1GB多出来的部分主要是异步状态机、SocketAsyncEventArgs相关的对象以及托管堆的保留内存。这在实际选型时是个重要考量尤其是容器内存配额比较抠的云环境里。关于CPUC#使用率比Go低一点这一定程度上是好事说明它在同样负载下CPU更省但如果你追求极限吞吐多出来的CPU余量其实可以被业务逻辑用掉反而未必是坏事。所以单看CPU占用判断性能很容易得出偏颇结论。4. 高并发实战中真正决定上限的细节4.1 心跳机制为什么一定要自己做以及如何不做废心跳是WebSocket服务里最不起眼但又最容易写错的部分。默认情况下TCP的KeepAlive探测要等到两个小时才启动第一次探测这在NAT、四层负载均衡、云厂商网关遍布的今天基本等于没有。中间设备通常会在一两分钟没有流量时就默默断掉空闲连接你如果不主动发Ping/Pong很多连接会在服务端毫不知情的情况下假死。服务端做心跳的标准姿势是定周期性向客户端发Ping帧客户端收到后回Pong帧服务端在pongWait时间内没收到就判定连接失效。间隔怎么定我直接给经验公式pingPeriod pongWait / 9。比如pongWait60s那每6.7秒发一次Ping。这个除9的怪癖是gorilla官方示例里的老传统原因是在各种网络条件和系统定时器精度下9倍是一个比较稳妥的冗余系数不容易因为偶发延迟而误杀连接。C#这里有一个很隐蔽的坑WebSocketOptions.KeepAliveInterval服务端会自动发Ping但这个机制处理Pong失败的策略并不像你想象那么灵敏而且它默认60秒一次云环境里偏慢。我建议把它调到20秒左右同时自己写一个应用层超时检测连续多个Pong超时再主动关闭。Timeout不能太灵敏否则一个GC暂停就误杀一堆健康连接也不能太迟钝否则死连接会占着文件描述符不放最终耗尽系统资源。4.2 缓冲区复用与零拷贝别让GC成为隐形杀手WebSocket每条消息都会经过缓冲区。如果你在接收时new byte[4096]在发送时又new byte[4096]一条消息两个分配一秒钟跑5万条消息就是10万次分配。托管堆或Go堆在这样高频分配下会快速膨胀最终表现为服务运行一段时间后内存平稳上升然后突然掉下来一块CPU在GC时飙高。C#这边的解法是ArrayPoolbyte.Shared.Rent用完归还更进阶的玩法是用System.IO.Pipelines它允许你直接处理ReadOnlySequencebyte避免接收缓冲的复制和拼接。Go这边对应的是sync.Pool可以缓存[]byte切片或者像gorilla那样尽量复用ReadMessage返回的底层切片。要注意的是复用缓冲区之后业务层如果异步持有了这条消息继续处理就必须手动拷贝副本否则数据会被下一轮读取覆盖。我见过不止一次因为这个原因线上出现偶发消息内容错乱的诡异问题排查半天最后发现是buffer被复用闹的。另外消息大小也很讲究。小消息频繁发送时服务端尽可能合并成一个大包一次性flushed可以显著减少系统调用次数但单条消息超过TCP拥塞窗口时又会出现吞吐骤降、延迟升高。所以如果你的业务允许尽量把推送做成批量聚合而不是一条条地发这是几乎零成本的性能提升手段。4.3 写并发和广播扇出最容易被十万在线打脸的地方十万在线听起来很美但广播一条消息就要对十万个连接各写一次。如果你是在某个连接的处理流程里直接遍历所有client连接然后逐个WriteMessage那这个流程所在的那个连接很快就会被卡死其他消息全被堵在后面。好的做法是每个连接一个独立的出站队列。Go里就是chan []byteC#里可以用Channelbyte[]或者ConcurrentQueue配一个发送任务。广播时服务端往每个连接的发送队列里投递消息发送协程/发送任务负责实际写socket。投递必须做背压控制否则内存会被未消费的消息塞满。前面Go示例里那个selectdefault的丢弃或断连策略就是背压的一种粗暴但有效的方式。C#这边也可以类比每个连接一个System.Threading.Channels.ChannelReadOnlyMemorybyte然后每个连接有一个后台任务从channel里取消息发送。这样广播时主流程只是往十万个channel里写入速度非常快。我自己在.NET 8里做过一次优化从一把全局锁遍历发送改成per-connection channel后广播吞吐提升了将近3倍。很多时候性能瓶颈不是在底层IO而是你让一个线程扛了太多脏活。4.4 系统层参数从ULIMIT到TCP内核代码写得再好系统层不配合也会白搭。Linux上最常见的坑就是文件描述符上限ulimit -n默认1024你一万个连接都撑不住。生产环境至少要设到100000以上而且要同时调整/etc/security/limits.conf的软硬限制和systemd服务的LimitNOFILE。注意Docker容器里光调宿主机还不够容器自身的limits也得跟着调否则照样打脸。内核参数方面我会重点关注这几个net.core.somaxconn控制监听队列长度默认128握手风暴时不够用建议调到4096以上net.ipv4.tcp_max_syn_backlog影响半连接队列同理要调大如果你在Linux上跑C#服务还要注意net.ipv4.ip_local_port_range当服务端主动向外部系统发起大量出站连接时本地端口范围不够会直接连接失败。TIME_WAIT和CLOSE_WAIT是长连接服务的两大灾星。TIME_WAIT出现在主动关闭方大量短连接场景下会看到很多适当开启net.ipv4.tcp_tw_reuse可以缓解。但WebSocket服务最怕的其实是CLOSE_WAIT它表示对端发了FIN但服务端没有正确关闭自己的socket。这几乎总是代码问题而不是系统问题要么是读循环没有正确处理Close报文要么是业务异常导致Close没走到排查思路后面专门讲。5. 常见问题与排查技巧实录从现象到结论5.1 一觉醒来CLOSE_WAIT一大片怎么办CLOSE_WAIT堆积是WebSocket服务最经典的事故场景现象很直观ss -ant一看大量连接卡在CLOSE_WAIT新连接进不来或者频繁超时最后服务彻底没响应。排查方向其实很明确。CLOSE_WAIT意味着对方已经发了关闭连接的FIN包你的进程收到了但应用层没有继续调用Close完成剩余关闭流程。在C#里常见原因是ReceiveAsync返回了WebSocketMessageType.Close但你没走CloseAsync就直接break了或者ReceiveAsync抛了异常之后using释放的代码路径被干扰导致socket一直没有真正close。在Go里常见原因是readPump里ReadMessage返回error后你没有defer conn.Close()或者关闭时机太晚让连接卡在了半关闭状态。我的排查顺序是这样的先ss -ant | grep CLOSE_WAIT | wc -l看数量再lsof -i看是哪个进程占用的然后看这个进程今天的日志里有没有大量读取超时或异常如果代码逻辑没问题再抓包看看FIN的到达时序确认是不是对端异常断连导致的。绝大多数情况下最终都要回到代码上找Leak路径。这没有一个神奇的命令能一键解决就是一条链路的排查。5.2 内存和CPU异常dotnet-counters与pprof怎么用压力上来之后两个平台都需要监控运行时内部状态。C#这边我推荐dotnet-counters一个很轻量的命令行工具可以直接看进程的GC Heap Size、Gen 0/1/2收集次数、线程池队列长度、以及ArrayPool相关计数器。当GC次数特别多而业务本身没那么忙时大概率就是缓冲区分配失控回到ArrayPool那节找问题。CPU飙高时用dotnet-trace抓调用栈能直接看到最热的几段代码尤其是哪里的await把线程池打爆了。Go这边就是一个命令go tool pprof。先起一个net/http/pprof的监听端口压测时go tool pprof http://host:8080/debug/pprof/profile抓CPU profile或者抓heap profile看内存分配热点。重点看runtime.mallocgc的占比和ReadMessage相关的分配如果ReadMessage成了大头说明底层切片复用没做好要考虑换gobwas/ws这种更底层的库亲自管理缓冲区。平时线上监控里我还会额外盯一个指标每个连接的内存占用。10万连接、每连接1KB额外开销就是100MB内存。这指标一旦随着连接数线性增长且没有上限基本就是某个map或者channel在持续堆积。5.3 WebSocket面试高频细节速查既然热搜词里有大量Golang八股文、C#面试相关的词顺手整理一份WebSocket高频考点还是很有价值的。这些也是平时我自己面试别人时最爱问的细节看着基础能答全的人不多。高频问题关键点WebSocket是怎么从HTTP升级来的请求头带Connection: Upgrade和Upgrade: websocket服务端返回101。Sec-WebSocket-Accept的值是SHA1(Sec-WebSocket-Key GUID)的Base64GUID是固定的258EAFA5-E914-47DA-95CA-C5AB0DC85B11客户端帧为什么必须掩码服务端却不用协议规定的目的是防止早期代理缓存污染。这是一个历史悠久的安全设计所以客户端发送的性能开销天然比服务端高一点WebSocket有粘包/拆包问题吗没有。帧头自带payload长度协议层已经帮你分好帧了。但一个逻辑消息可以跨多个帧分片应用层需要自己聚合心跳为什么不用TCP KeepAlive系统KeepAlive默认触发慢且中间NAT/LB可能拦截探测。应用层Ping/Pong可以精确控制超时阈值并携带业务数据服务端怎么区别文本和二进制消息看opcode1是text2是binary。常用场景是文本走JSON二进制走图片、音频、协议缓冲数据断线重连为什么要有退避服务器故障恢复时所有客户端同时重连会引发重连风暴指数退避随机抖动可以把压力摊平多实例部署时广播怎么做每个实例维护本地连接集合广播消息先发到Redis Pub/Sub或消息队列各实例订阅后再扇出到自己的连接上服务端怎么限制单个连接的消息大小C#里可以看消息流累积长度Go里用SetReadLimit防止恶意客户端上传超大消息打爆内存5.4 Windows与Linux部署差异工控现场和云原生的分叉聊完技术细节还得说说部署环境差异。C#的WebSocket服务在Linux上跑Kestrel性能很稳这点.NET 6之后已经追上来了。但你要是部署在Windows上那底层走的就是IOCP模型高并发场景下同样能扛只不过Windows的TCP参数、线程池配置跟Linux差异很大很多Linux上管用的sysctl参数在这里完全不适用。工控上位机场景大多是Windows内网环境并发量其实不大一两百个连接就够用了这时候根本不用纠结系统参数直接上SignalR甚至简化版WebSocket都行反正压力不大。Golang服务我基本只会在Linux上部署在Windows上跑虽然也能跑用IOCP调度但一方面docker镜像、CI/CD体系都更偏Linux另一方面社区的性能调优资料、监控工具也默认是在Linux上验证过的。所以如果你的业务是云原生、边缘计算这种形态老老实实放Linux容器里跑Go服务省心得多。如果要把生产环境做成多机集群两边其实都会遇到同一个问题连接分布在不同实例上怎么把消息广播给某个用户的所有连接。业界通用做法是引入Redis Pub/Sub做消息路由实例订阅频道业务侧发布消息各实例收到后再推给本地连接。这个设计和语言无关但做好之后C#和Golang都能轻松扛住更大的集群规模。6. 到底怎么选我的建议与真实体验压了这么多轮我想先说一个可能让某些人不舒服的结论在合理优化下C#和Golang的WebSocket性能并没有网上说的那种“碾压级”差距。Golang在内存占用和超大规模连接数上确实更有优势短连接握手和广播扇出也表现得更从容但C#在CPU占用、Windows生态整合、以及.NET系业务的代码复用上是实实在在的加分项吞吐量咬得很死差的不是量级而是百分点。如果你问我怎么选我的习惯是这么定的做边缘接入网关、推送中台、IM长连接、容器化微服务我会优先选Golang因为它部署简单、内存紧凑、并发模型贴合长连接团队写起来也痛快做工业上位机、企业内网看板、业务系统集成、以及需要跟C#存量代码打配合的场景我会坚定选C#因为这类系统的痛点从来不是单机10万连接而是能不能和ERP、MES、数据库、硬件设备顺畅对接这时候C#的生态优势是Go给不了的。再分享一个压测之外的真实体验。有一次线上的C#推送服务高峰期内存曲线连续爬升我一度怀疑是框架GC配置问题调了半天GCMode、ServerGC参数都没根治。后来用dotnet-counters一查发现是某个业务模块在每条推送消息里new了一个很大的临时List把托管堆打得稀碎。换Grpc和WebSocket都没用——问题出在业务代码的分配习惯上。反过来我也见过Go服务因为channel缓冲设置过大几万个连接吃掉了几个GB内存的案例。记忆最深的教训就一句话长连接服务的内存和性能90%不是语言决定的是你写的那几行看似不起眼的分配逻辑决定的。所以别再纠结C#和Golang谁才是性能怪兽这种问题了。你随手写个服务可能连它一半能力都用不到真正该花时间的是把心跳、缓冲池、背压、监控这些基本功做扎实。选一个你团队最熟的语言把压测报告跑透比什么都强。