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工具追踪了整个建立连接的过程。通信的整个过程如下图所示。

通过Wireshark捕获WebSocket建立通信的过程。

正常情况下,接下来应该有一条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

3 回复

嗯?我不太明白你在说什么。我在开发过程中没有遇到过这样的问题。 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()就是执行这个后台读取的。

关键问题在于:

  1. abortPendingRead()在第47行设置了一个过去的超时时间(aLongTimeAgo
  2. 然后等待backgroundRead()退出(第48-50行)
  3. 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的组合,即设置合理的超时参数并使用独立的服务器配置。

回到顶部