不能直接用——sync.once只保证函数最多执行一次,但无法返回error或token,也不阻塞后续调用者等待结果;token刷新需支持并发等待、结果共享与错误传播,应结合errgroup.withcontext与channel实现。

sync.Once 能不能直接用于 Token 刷新?
不能直接用 —— sync.Once 只保证“函数最多执行一次”,但它不处理“执行中阻塞其他调用者等待结果”这个关键需求。Token 刷新是典型的“有返回值的耗时操作”,而 sync.Once.Do 的参数函数签名是 func(),无法返回 error 或新 token,更没法让后续协程等它完成后再拿结果。
常见错误现象:
多个 goroutine 同时调用 refreshToken(),其中第一个触发刷新,但其余协程在 Do 返回后立刻读到旧 token 或空值,导致并发请求仍携带过期凭证失败。
-
sync.Once适合无返回、纯初始化(如启动监听、加载配置),不适合带结果的异步协调 - Token 刷新必须支持:① 首次调用发起请求;② 其他并发调用者挂起等待;③ 请求成功后统一返回新 token;④ 请求失败也要透出错误,避免静默失败
- 正确方向是组合
sync.Once+sync.RWMutex+ channel 或 errgroup,但更推荐用errgroup封装等待逻辑
用 errgroup.WithContext 实现“只刷一次,全员共享”
这是目前最简洁、健壮的方案:用 errgroup 启动一个刷新任务,同时让所有等待者通过 channel 接收结果,天然支持超时、取消和错误传播。
实操建议:
- 定义一个
refreshCh(chan result),所有请求者先尝试从它读;若 channel 未关闭,说明还没刷完,就去触发刷新 - 刷新逻辑放在
errgroup.Go中,由第一个协程启动;g.Wait()会阻塞直到完成或出错 - 刷新完成后,关闭 channel 并写入结果(含 error);其他协程从 channel 读取即得最终结果
- 务必配合
context.WithTimeout,防止刷新服务卡死导致所有请求永久挂起
示例核心逻辑:
var (
refreshMu sync.RWMutex
refreshCh = make(chan result, 1)
)
type result struct {
token string
err error
}
func GetToken() (string, error) {
refreshMu.RLock()
if ch := refreshCh; ch != nil {
refreshMu.RUnlock()
r := refreshMu.Lock()
if refreshCh == nil {
refreshCh = make(chan result, 1)
g, ctx := errgroup.WithContext(context.WithTimeout(context.Background(), 5*time.Second))
g.Go(func() error {
tok, err := doHTTPRefresh() // 真实刷新逻辑
refreshCh <p>}</p><div class="aritcle_card flexRow artxards">
<div class="artcardd flexRow">
<a class="aritcle_card_img" rel="nofollow" href="/xiazai/skill8661" title="Golang Uber Dig"><img
src="https://img.php.cn/upload/skill/000/000/081/179215391842073.jpg" alt="Golang Uber Dig" onerror="this.onerror='';this.src='/static/lhimages/moren/morentu.png'" ></a>
<div class="aritcle_card_info flexColumn">
<a rel="nofollow" href="/xiazai/skill8661" title="Golang Uber Dig" class="overflowclass">Golang Uber Dig</a>
<p class="overflowclass">使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等</p>
</div>
<a rel="nofollow" href="/xiazai/skill8661" title="Golang Uber Dig" class="aritcle_card_btn flexRow flexcenter"><b></b><span>下载</span>
</a>
</div>
</div>
为什么不用 double-check + Mutex?
有人会写“先读缓存 → 为空则加锁 → 再检查 → 刷新 → 解锁”,即 double-checked locking。这在 Go 里不仅没必要,还容易翻车。
问题在于:
- Go 编译器和 CPU 不保证写操作的可见顺序,即使加了
mutex,没有atomic或sync/atomic语义,其他 goroutine 可能读到部分写入的脏数据 - 手动实现易漏掉对
refreshCh或结果变量的内存屏障保护,导致竞态检测工具(go run -race)报错 - 一旦刷新失败,下次调用还得重新走一遍锁流程,而
errgroup方案可自然复用 channel 关闭状态,失败也只刷一次
更关键的是:标准库已提供 errgroup 这种经过充分验证的模式,重复造轮子既增加维护成本,又引入隐蔽 bug 风险。
刷新失败后怎么重试?
sync.Once 一旦内部函数 panic 或返回 error,它就认为“已执行完毕”,后续调用不再触发 —— 这对 Token 刷新是灾难性的:第一次网络超时失败,之后所有请求永远拿不到新 token。
所以必须绕过 Once 的“不可重试”限制:
- 不要把整个刷新逻辑塞进
once.Do;只用它来控制“是否已启动刷新协程”,而不是“是否已完成” - 把刷新结果(含 error)存在共享变量中,并用
sync.RWMutex保护读写 - 重试逻辑应由上层控制:比如在
GetToken()返回 error 后,调用方决定是否 sleep 后重试,或降级使用旧 token(如果未过期) - 若需自动重试,应在
doHTTPRefresh内部实现(如重试 2 次 + 指数退避),而不是依赖Once
真正难的不是“只刷一次”,而是“刷失败后如何让下一次调用感知并决策”。这需要明确区分“刷新动作的协调”和“业务逻辑的容错”,混在一起只会让代码越来越脆。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










