用net.Conn而非http.Server因聊天室需长连接双向通信,HTTP无状态短连接无法维持在线状态;TCP连接需手动管理生命周期,广播时须用sync.Map并发安全地深拷贝并逐个写入,失败则清理连接防泄漏。

为什么用 net.Conn 而不是 http.Server
聊天室本质是长连接、双向实时通信,HTTP 是无状态短连接,每次请求都要重建 TCP 连接,无法维持用户在线状态或即时广播。TCP 服务端用 net.Listen("tcp", ":8080") 接收连接后,每个 net.Conn 对应一个客户端,可独立读写,适合持续收发消息。
常见错误是试图用 http.HandleFunc 处理“发送消息”请求,结果发现客户端断开后连接丢失、广播失效、无法感知下线——这不是 HTTP 的设计场景。
- HTTP 适合页面加载、API 查询等一次性交互
- TCP 连接需手动管理生命周期:读取时遇
io.EOF表示客户端关闭,要从在线列表中移除 - 所有广播逻辑必须在 goroutine 中异步写入各
conn.Write(),否则一个卡住的连接会阻塞整个广播
如何安全地广播消息给所有在线用户
核心难点是并发读写在线连接列表(map[net.Conn]bool 或 []net.Conn)以及避免写操作 panic。不能直接遍历原始切片并调用 conn.Write(),因为某次写失败(如客户端已断开但未及时检测)会导致后续连接写入被跳过。
推荐做法:维护一个 sync.Map 存储 conn 和元数据(如用户名),广播前先深拷贝活跃连接列表,再逐个写入,并在写失败时清理该连接。
立即学习“go语言免费学习笔记(深入)”;
- 写入前检查
conn != nil和conn.RemoteAddr()是否可访问(部分已关闭连接仍返回地址) - 对每个
conn.Write()加select+time.After(5 * time.Second)防止永久阻塞 - 写失败后立即调用
conn.Close()并从sync.Map中Delete(),否则内存泄漏
for conn := range clients { // clients 是 *sync.Map
if rw, ok := conn.(net.Conn); ok {
select {
case <-done:
return
default:
_, err := rw.Write(msg)
if err != nil {
rw.Close()
clients.Delete(rw)
}
}
}
}怎么处理粘包和消息边界
TCP 是字节流协议,conn.Read() 不保证一次读到完整消息。用户输入 “hello” + 回车,可能分两次到达:第一次 “hel”,第二次 “lo\n”;也可能合并:“hello\nworld\n”。不处理就会导致解析错乱。
最简方案是约定换行符 \n 分隔消息,用 bufio.Scanner 替代裸 Read():
-
scanner := bufio.NewScanner(conn)自动按行切割,scanner.Scan()返回 true 即有一条完整消息 - 注意设置最大行长:
scanner.Buffer(make([]byte, 4096), 65536),防超长输入耗尽内存 - 不要混用
scanner和conn.Read(),底层bufio.Reader缓存会冲突
若需二进制协议或自定义长度头,就得自己解析:先读 4 字节长度字段,再读对应字节数——但聊天室文本场景,换行分隔足够且不易出错。
为什么 defer conn.Close() 放在 goroutine 入口容易出问题
典型写法:go func() { defer conn.Close(); handleConn(conn) }() 看似优雅,实则危险。如果 handleConn 中发生 panic,defer 会执行,但此时其他 goroutine 可能还在往该 conn 写数据,导致 write on closed network connection 错误。
更稳妥的做法是只在明确退出读/写逻辑时关闭,比如:
- 读循环结束(
scanner.Scan() == false)后关闭连接 - 写广播时检测到
conn.Write()返回io.ErrClosedPipe或net.ErrClosed后主动清理 - 用
sync.Once包裹conn.Close(),确保只关一次
真正难的是状态同步:一个连接可能同时被读协程、广播协程、超时协程访问,关闭时机必须由唯一权威方决定(通常是读协程检测到 EOF 或 error 后触发全局清理)。










