使用 sync.Once 解决 Go 并发场景下的重复下线广播问题

使用 sync.Once 解决 Go 并发场景下的重复下线广播问题

业务背景

我们正在开发一个简单的基于 TCP 的聊天室服务端。在这个系统中,每个连接上来的客户端会被抽象为一个 User 对象。为了实现并发处理,服务端会为每一个 User 分配专门的 goroutine(协程)用来阻塞读取客户端发送的消息,并将消息广播给全局维护的其他在线用户。

核心业务流程:

  • 上线 :将 User 加入服务端的全局 OnlineMap 中,并广播"✅已上线!"给所有人。
  • 消息处理 :后台 ManagerMessage 协程循环调用 user.Conn.Read() 接收消息,如果是指令则处理,否则广播。
  • 下线 :当连接意外断开或用户主动输入 exit 指令时,将 UserOnlineMap 中移除,断开底层连接并广播"❌已下线!"。

并发 Bug 显现

在测试 exit 命令退出功能时,我们发现了一个奇怪的现象。其他在线用户会收到两遍完全相同的下线提醒:

复制代码
 [127.0.0.1:62215]127.0.0.1:62215:❌已下线!
 [127.0.0.1:62215]127.0.0.1:62215:❌已下线!

原因排查与相关代码

导致这个 Bug 的根本原因是用户注销与清理的逻辑(Logout)被并发触发了两次。看下我们的核心消息处理代码:

复制代码
 func (s *Server) ManagerMessage(user *User) {
     buf := make([]byte, 4096)
     for {
         n, err := user.Conn.Read(buf)
         if n == 0 || err != nil {
             // 【触发点 2】: 兜底清理。由于读到 EOF 或断开报错,触发注销
             user.Logout()
             return
         }
 ​
         rawMsg := string(buf[:n])
         if rawMsg == "exit" {
             // 【触发点 1】: 主动退出。当接收到了退出指令时,触发注销
             user.Logout()
             // (协程后续会因 Socket 断开而在上面 Read() 处终止)
         } else {
              // 处理正常消息...
         }
     }
 }

原先的注销逻辑极其简单:

复制代码
 func (u *User) Logout() {
     u.Offline() // 从在线列表中移除,并广播"❌已下线!"
     u.Close()   // 释放 Socket 套接字等资源
 }

Bug 复现路径:

  1. 用户输入 exit
  2. 协程命中 rawMsg == "exit",开始执行 【触发点 1】 的主动 Logout(),广播第一次"已下线",并调用了底层的 Close() 关闭连接。
  3. 随后协程进入下一次 for 循环,调用 user.Conn.Read() 等待下一条消息。
  4. 因为底层 Socket 刚才就被关闭了,Read() 瞬间返回挂起并返回错误(err != nil)。
  5. 协程命中上方错误兜底逻辑,执行了 【触发点 2】 的被动 Logout(),广播了第二次"已下线"!

解决方案:引入 sync.Once

在服务端并发编程中,针对一个对象的资源释放和状态变更(特别是向外的下线广播),必须保证操作的幂等性------即无论清理方法被调用多少次,核心下线逻辑只能执行一次。

Go 标准库提供的 sync.Once 机制完美契合这一场景。我们在 User 结构体中增加一个专门用于控制注销逻辑的自带锁:

复制代码
 import (
     "net"
     "sync"
 )
 ​
 type User struct {
     Name       string
     Conn       net.Conn
     // 其他业务字段...
 ​
     // 加入 sync.Once 锁
     logoutOnce sync.Once
 }
 ​
 // 改造后的 Logout
 func (u *User) Logout() {
     // Do 内部传入的闭包函数,在对象生命周期内绝对只会被执行一次
     u.logoutOnce.Do(func() {
         u.Offline()
         u.Close()
     })
 }

总结

引入 sync.Once.Do 之后,无论是先解析到了 exit 从而主动触发退出,还是因网络异常直接引发 Read() 报错进行兜底,只有"最先到达"的那次 Logout() 会真实调用下线广播。后续引发的重复清理调用都会被 sync.Once 机制拦截并忽略。

这种做法十分优雅地解耦了 "不确定的多个业务触发条件""唯一的资源清理终态" ,它不仅修复了重复消息的体验 Bug,还彻底消除了对底层通道重复 close() 可能引发 Panic 的严重隐患。

相关推荐
Yang961125 分钟前
鼎讯101C测向天线200MHz至500MHz比幅测向解读
网络
2601_9669496531 分钟前
只拿到股票代码还不够:量化程序到底还需要哪些标的信息?——从数据建模开始理解股票元数据
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
m0_7345717642 分钟前
深入理解5g <十八>传输块tbsize的计算
开发语言·5g
yi碗汤园1 小时前
OSS对象存储服务
网络·阿里云
Doris__HE2 小时前
【元脑服务器NF5476G7-NF5476M7技术规格分享】
运维·服务器·网络·数据库·性能优化
RAN11426760082 小时前
1.3第一个程序的介绍
开发语言·qt
blue_ice .3 小时前
Verilog DDS 数字信号发生器:DAC 输出模拟波形与 ADC 回采观测
开发语言·单片机·嵌入式硬件·fpga开发
远翔调光芯片^138287988723 小时前
ECP5702能芯科技PD取电芯片在市场上的优势有哪些?
开发语言·人工智能·单片机·嵌入式硬件·智能家居
泡海椒4 小时前
规则引擎对比:JQuick-Java vs Drools 轻量场景选型对比
java·开发语言
boxiansheng1634 小时前
408计算机网络
网络·计算机网络·智能路由器