Golang中WebSocket偶尔无法建立正常连接的问题探讨
Golang中WebSocket偶尔无法建立正常连接的问题探讨 背景 我们正在开发一个与WebSocket相关的软件服务。我们使用的是Go 1.23.1版本。在使用WebSocket进行通信的过程中,我们遇到了几个障碍。
有时,WebSocket连接无法正常建立。这种现象在某些Windows 10电脑上持续出现,而大多数Windows 10电脑则没有这个问题。
现象 为了更方便地复现此问题,我编写了以下最简化的建立WebSocket连接的代码。在下面的代码中,我使用Gin框架来监听HTTP路由请求。在接收到客户端的HTTP请求后,我使用github.com/gorilla/websocket库(不仅仅是这个库,我还尝试了网上其他可用的WebSocket相关库。不幸的是,它们都无法解决我上面提到的问题)来建立WebSocket连接。
import (
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"log"
"net/http"
)
var (
upgrader websocket.Upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
)
type LogWriter struct{}
func (w *LogWriter) Write(data []byte) (int, error) {
log.Printf("%s", data)
return len(data), nil
}
func wsHandle(c *gin.Context) {
_, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("upgrade error:%s", err)
return
}
}
func main() {
gin.DefaultWriter = &LogWriter{}
gin.DefaultErrorWriter = &LogWriter{}
server := gin.Default()
server.GET("/ws", wsHandle)
err := server.Run("127.0.0.1:10020")
if err != nil {
panic(err)
}
}
我在问题发生的环境中运行了上述简单代码。服务开始正确监听10020端口后,我在客户端使用以下代码来与服务建立连接。
ws = new WebSocket("ws://127.0.0.1:10020/ws")
然后我注意到ws.readyState一直保持在0,从未变为1,这表明双方一直处于建立连接的状态。
我使用Wireshark工具追踪了整个建立连接的过程。通信的整个过程如下图所示。

正常情况下,接下来应该有一条Switching Protocols的通信记录。然而,在异常环境中它缺失了,这表明在建立通信的过程中发生了阻塞。
问题定位 我开始在代码中设置断点来定位问题。我找到了发生阻塞的具体位置(在文件https://cs.opensource.google/go/go/+/master:src/net/http/server.go;l=689)。出现阻塞现象的代码片段如下所示。
1. func (cr *connReader) backgroundRead() {
2. n, err := cr.conn.rwc.Read(cr.byteBuf[:])
3. cr.lock()
4. if n == 1 {
5. cr.hasByte = true
6. // We were past the end of the previous request's body already
7. // (since we wouldn't be in a background read otherwise), so
8. // this is a pipelined HTTP request. Prior to Go 1.11 we used to
9. // send on the CloseNotify channel and cancel the context here,
10. // but the behavior was documented as only "may", and we only
11. // did that because that's how CloseNotify accidentally behaved
12. // in very early Go releases prior to context support. Once we
13. // added context support, people used a Handler's
14. // Request.Context() and passed it along. Having that context
15. // cancel on pipelined HTTP requests caused problems.
16. // Fortunately, almost nothing uses HTTP/1.x pipelining.
17. // Unfortunately, apt-get does, or sometimes does.
18. // New Go 1.11 behavior: don't fire CloseNotify or cancel
19. // contexts on pipelined requests. Shouldn't affect people, but
20. // fixes cases like Issue 23921. This does mean that a client
21. // closing their TCP connection after sending a pipelined
22. // request won't cancel the context, but we'll catch that on any
23. // write failure (in checkConnErrorWriter.Write).
24. // If the server never writes, yes, there are still contrived
25. // server & client behaviors where this fails to ever cancel the
26. // context, but that's kinda why HTTP/1.x pipelining died
27. // anyway.
28. }
29. if ne, ok := err.(net.Error); ok && cr.aborted && ne.Timeout() {
30. // Ignore this error. It's the expected error from
31. // another goroutine calling abortPendingRead.
32. } else if err != nil {
33. cr.handleReadError(err)
34. }
35. cr.aborted = false
36. cr.inRead = false
37. cr.unlock()
38. cr.cond.Broadcast()
39. }
40. func (cr *connReader) abortPendingRead() {
41. cr.lock()
42. defer cr.unlock()
43. if !cr.inRead {
44. return
45. }
46. cr.aborted = true
47. cr.conn.rwc.SetReadDeadline(aLongTimeAgo)
48. for cr.inRead {
49. cr.cond.Wait()
50. }
51. cr.conn.rwc.SetReadDeadline(time.Time{})
52. }
阻塞位置分别发生在第2行和第49行。可以看到,在第47行设置了rwc的读取超时限制。然而,第2行的rwc仍然阻塞在Read方法上。然后我追踪了正常环境下的代码执行过程,发现在第47行执行完成后,第2行的Read方法从阻塞状态释放到了非阻塞状态。通过对比,可以确定这就是导致WebSocket连接无法正常建立的原因。这似乎暗示在net/http库或net/http库更底层的库中存在一些潜在问题……
解决方案 我原本打算进一步研究net库的底层代码,以找出底层是否存在一些不可忽视的bug。然而,我在上述代码的第2行前面添加了一段等待代码(如下所示),这立即解决了问题。现在即使在异常的电脑上,WebSocket连接也能正常建立了。这真是太神奇了!
1. func (cr *connReader) backgroundRead() {
2. time.Sleep(time.Microsecond)
3. n, err := cr.conn.rwc.Read(cr.byteBuf[:])
4. cr.lock()
我的期望 虽然这个临时解决方案可以解决问题,但我仍然没有弄清楚其根本原因。我也想知道是否有更好的方法来解决这个问题。同时,有必要澄清这是否意味着net库中存在一些需要修复的潜在bug。如果确实存在bug,应该如何修复?毕竟,仅仅在一行代码前添加一个等待语句就能解决问题,这真的很难让人信任这段代码。
我希望有人能指出这个问题的根本原因是什么,并提出一个更正常、可靠、安全的方法来解决无法建立正常WebSocket连接的问题。
更多关于Golang中WebSocket偶尔无法建立正常连接的问题探讨的实战教程也可以访问 https://www.itying.com/category-94-b0.html
嗯?我不太明白你在说什么。我在开发过程中没有遇到过这样的问题。 net/http 属于 http 的范畴,如果你想验证这一点,你应该只使用 http.server 来进行。这可能看起来与 websocket 没有直接关系。
更多关于Golang中WebSocket偶尔无法建立正常连接的问题探讨的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
anodiebird:
func wsHandle(c *gin.Context) {
_, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("upgrade error:%s", err)
return
}
// 下一步?你应该在这里处理连接
}
以下是我写的一些代码,仅作为示例。我对 gin 不太熟悉…
mux:= http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
log.Infof("new connection:%s\n", r.RemoteAddr)
webConn, err := ws.Accept(w, r, &ws.AcceptOptions{
OriginPatterns: []string{"*"},
})
if err != nil {
log.Talk.Errorf("accept failed:%s\n", err)
return
}
go handleWsConn(webConn)
)
这个仓库或许对你有帮助 https://github.com/coder/websocket
这个问题确实很典型,涉及到Go标准库中HTTP/1.1连接处理的竞态条件。根本原因是abortPendingRead()和backgroundRead()之间的时序问题。
问题根源分析
在HTTP/1.1中,当服务器处理完一个请求后,会尝试读取下一个请求的第一个字节来判断是否还有后续请求(pipelining)。backgroundRead()就是执行这个后台读取的。
关键问题在于:
abortPendingRead()在第47行设置了一个过去的超时时间(aLongTimeAgo)- 然后等待
backgroundRead()退出(第48-50行) - 但
backgroundRead()可能在第2行的Read()调用中被阻塞
在某些Windows系统上,TCP栈的实现差异可能导致SetReadDeadline对已经阻塞的Read()调用生效延迟,从而产生死锁。
标准解决方案
不要修改标准库代码,而是在应用层解决。以下是几种可靠的方法:
方案1:使用连接超时控制
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
HandshakeTimeout: 5 * time.Second, // 添加握手超时
}
func wsHandle(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("upgrade error:%s", err)
return
}
defer conn.Close()
// 设置读写超时
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetWriteDeadline(time.Now().Add(60 * time.Second))
// 处理WebSocket消息
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
// 处理消息
}
}
方案2:使用HTTP/2或禁用Keep-Alive
func main() {
server := gin.Default()
server.GET("/ws", wsHandle)
s := &http.Server{
Addr: "127.0.0.1:10020",
Handler: server,
// 禁用Keep-Alive,避免pipelining问题
IdleTimeout: 0,
}
err := s.ListenAndServe()
if err != nil {
panic(err)
}
}
方案3:使用独立的WebSocket服务器
func main() {
http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
mt, message, err := conn.ReadMessage()
if err != nil {
break
}
err = conn.WriteMessage(mt, message)
if err != nil {
break
}
}
})
server := &http.Server{
Addr: "127.0.0.1:10020",
ReadHeaderTimeout: 3 * time.Second,
}
err := server.ListenAndServe()
if err != nil {
panic(err)
}
}
针对Windows的特定修复
对于Windows环境,可以添加TCP连接参数优化:
func main() {
server := gin.Default()
server.GET("/ws", wsHandle)
ln, err := net.Listen("tcp", "127.0.0.1:10020")
if err != nil {
panic(err)
}
// 设置TCP KeepAlive
tcpListener := ln.(*net.TCPListener)
tcpListener.SetDeadline(time.Now().Add(30 * time.Second))
err = http.Serve(tcpListener, server)
if err != nil {
panic(err)
}
}
客户端重试机制
在客户端添加重试逻辑:
function connectWebSocket(retries = 3, delay = 1000) {
const ws = new WebSocket("ws://127.0.0.1:10020/ws");
ws.onopen = function() {
console.log("WebSocket连接成功");
};
ws.onerror = function(error) {
console.error("WebSocket错误:", error);
if (retries > 0) {
setTimeout(() => {
connectWebSocket(retries - 1, delay * 2);
}, delay);
}
};
return ws;
}
const ws = connectWebSocket();
监控和诊断
添加连接状态监控:
var (
connectionCount int32
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
HandshakeTimeout: 5 * time.Second,
}
)
func wsHandle(c *gin.Context) {
atomic.AddInt32(&connectionCount, 1)
defer atomic.AddInt32(&connectionCount, -1)
start := time.Now()
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("连接失败: %v, 耗时: %v", err, time.Since(start))
return
}
log.Printf("连接成功, 当前连接数: %d", atomic.LoadInt32(&connectionCount))
// ... 处理连接
}
这个问题在Go的issue tracker中有相关讨论(如#31234、#35982),主要影响Windows系统上的HTTP/1.1 pipelining场景。对于WebSocket服务,推荐使用方案1和方案3的组合,即设置合理的超时参数并使用独立的服务器配置。

