尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

singleflight

singleflight singleflight包是 Go 官方扩展库golang.org/x/sync中的一个工具主要用于抑制重复的函数调用。其经典场景是防止缓存击穿当多个并发请求同时需要同一个资源例如同一个 key 的数据库查询时只让第一个请求真正执行其余请求则等待并共享第一个请求的结果。下面结合你给出的源码从结构、核心流程、panic/goexit 处理等几个方面进行详细讲解。1. 核心数据结构call(在途或已完成的调用)gotype call struct { wg sync.WaitGroup val any err error dups int chans []chan- Result }wg用于等待原始调用完成。重复调用者会Wait在该 WaitGroup 上原始调用完成时Done。val/err存放fn返回的结果和错误。dups记录有多少个重复调用共享了这次结果包括自己不算只有后来者才增加dups。chans仅用于DoChan方法存放所有等待结果的 channel。Group(调用组)gotype Group struct { mu sync.Mutex m map[string]*call }通过key来区分不同任务。相同 key 的调用会合并。使用互斥锁保护 mapmap 惰性初始化。Resultgotype Result struct { Val any Err error Shared bool }Shared表示该结果是否被多个调用者共享即有重复调用发生。2. 具体方法详解Do方法gofunc (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool)流程加锁检查 map 是否初始化。如果该 key 已有call说明有请求正在执行c.dups记录重复次数。解锁然后c.wg.Wait()等待原始调用完成。等待结束后检查错误类型若是*panicError说明原始函数发生了 panic直接panic(e)将 panic 传播给当前等待者保持与原始调用一致的行为。若是errGoexit说明原始函数调用了runtime.Goexit则调用runtime.Goexit()退出当前 goroutine。否则正常返回c.val, c.err, trueshared true。如果没有call新建callwg.Add(1)放入 map解锁。调用g.doCall(c, key, fn)实际执行用户函数。返回结果并根据c.dups 0决定shared值。DoChan方法gofunc (g *Group) DoChan(key string, fn func() (any, error)) -chan Result与Do类似但不阻塞等待结果而是返回一个容量为 1 的 channel。流程创建ch : make(chan Result, 1)。加锁后若发现已有call则将ch追加到c.chans切片中dups然后直接返回ch。若无call新建call把ch作为chans的初始成员启动一个新 goroutine 执行g.doCall然后返回ch。调用者可从 channel 中收结果但当发生 panic 或Goexit时channel 不会收到任何值也不会被关闭。这是故意的设计文档明确说明“返回的 channel 不会被关闭”。3.doCall—— 调用执行核心doCall负责实际调用fn并处理返回、panic 和runtime.Goexit三种情况。难点在于区分普通 panic 和runtime.Goexitruntime.Goexit会终止当前 goroutine但会先执行所有 defer。在 defer 中调用recover会返回nil因此无法直接用recover的返回值来区分。标准库的做法是利用“双重 defer”配合标志位。双重 defer 结构func (g *Group) doCall(c *call, key string, fn func() (any, error)) { normalReturn : false recovered : false // 外层 defer最终清理和结果分发 defer func() { // 如果既不是正常返回也没有 recover 到 panic那就是 Goexit if !normalReturn !recovered { c.err errGoexit } g.mu.Lock() c.wg.Done() if g.m[key] c { delete(g.m, key) // 清理 map } g.mu.Unlock() // 根据 err 类型决定行为 if e, ok : c.err.(*panicError); ok { // 原始函数发生了 panic if len(c.chans) 0 { go panic(e) // 在新 goroutine 中 panic确保无法被恢复 select {} // 阻塞当前 goroutine使其出现在 crash dump 中 } else { panic(e) // 直接 panic } } else if c.err errGoexit { // 已是 Goexit 流程无需再调用 } else { // 正常情况向所有 DoChan 的等待者发送结果 for _, ch : range c.chans { ch - Result{c.val, c.err, c.dups 0} } } }() // 内层匿名函数调用 fn 并 recover func() { defer func() { if !normalReturn { // 发生了 panicrecover 捕获 if r : recover(); r ! nil { c.err newPanicError(r) } } }() c.val, c.err fn() normalReturn true }() // 如果内层函数没有正常返回normalReturn 仍为 false // 且没有 recover即 recover 返回 nil意味着 runtime.Goexit // 则 recovered 保持 false外层 defer 能据此判定为 Goexit。 if !normalReturn { recovered true } }执行逻辑解析内层匿名函数func()执行fn()若fn正常返回 →normalReturn true。若fn中发生 panic → 内层 defer 的recover()捕获到值c.err newPanicError(r)normalReturn仍为 false。此时recover返回非 nil所以外层的!recovered仍成立等一下内层 defer 是在if !normalReturn里调用的recover()一旦 recover 到值c.err被设置。然后内层函数结束此时控制流回到外层if !normalReturn { recovered true }。所以若发生 panicrecovered被置为 true。若fn调用了runtime.Goexit→ 该 goroutine 停止执行fn后面的代码normalReturn保持 false然后执行所有 defer。内层 defer 中的recover()返回 nil因 Goexit 不算 panic因此c.err保持 nil。接着recovered也保持 false因为if !normalReturn { recovered true }这句还没执行 goroutine 就退出了不对runtime.Goexit会执行所有 defer 然后退出所以在外层 defer 执行前控制流不会到达if !normalReturn { recovered true }。因此外层 defer 中看到!normalReturn !recovered成立将c.err设为errGoexit。外层 defer 保证无论何时都会跑它在最后根据c.err的类型进行处理panicError重新把 panic 抛出去但为了避免被调用者的recover捕获后无法继续代码做了分支处理。有chans时即有人用DoChan等待启动新 goroutine 去 panic自身则永远阻塞在select {}这样当前 goroutine 会留在所有 goroutine 转储中便于排查。errGoexit啥也不做外层 defer 结束后goroutine 将正式退出Goexit 流程继续。正常向所有DoChanchannel 发送Result。4. 辅助类型和错误处理panicErrortype panicError struct { value any stack []byte }将 panic 的值和发生时的调用栈包装在一起。实现了Error接口输出格式为panic value\n\n堆栈。Unwrap方法允许通过errors.Is/As提取原始错误如果value是error接口。newPanicError调用debug.Stack()获取堆栈。裁剪掉第一行形如goroutine 1 [running]:因为这行信息在后续传播时可能已过时。返回*panicError。errGoexit一个哨兵错误表示用户函数中调用了runtime.Goexit。5.Forget方法func (g *Group) Forget(key string)简单地从 map 中删除指定 key。这样后续相同 key 的Do调用就会发起新的实际请求而不是等待可能还在执行的旧请求。常用在异步场景下想让某个 key 在未来重新触发加载。6. 使用上的注意事项结果共享的正确性shared只表示是否有多个 goroutine 获取到了结果不代表结果本身可以不加锁并发读写。返回值val若为指针或引用类型调用方需要自行同步。DoChan 的 panic 泄漏如代码注释所述当fn发生 panic 且使用DoChan时等待结果的 channel 将永远收不到数据且不关闭这会导致 goroutine 泄漏。因此使用DoChan时最好配合select和超时或者确保fn不会 panic。Goexit 的传播通过将errGoexit传递给等待者再由等待者调用runtime.Goexit()来维持原始语义这是一个很精妙的设计。双重 defer 技巧这段代码是 Go 中区分 panic 和runtime.Goexit的经典范例值得细读。官方的singleflight库虽然核心的去重机制很优秀但也存在一些设计上的局限和边界情况问题而社区涌现的许多替代库正是为了解决这些痛点而生的各自有不同的侧重点。官方库(singleflight)的设计局限与问题缺乏原生超时/取消机制官方库的方法不支持传入context.Context这是一个关键限制。如果飞行中的请求阻塞如DB卡死所有等待者将无限期挂起这比缓存击穿本身风险更高。通常需要使用方自己封装一层来解决。interface{}导致类型不安全官方库使用interface{}传递和返回结果这意味着放弃了编译时类型检查必须在运行时进行类型断言容易引入 panic。同时这也会带来额外的内存分配和 GC 压力。无法处理常态化的批量请求如果你有常态化的批量查询需求例如一批 ID 获取用户信息等待每个 ID 的独立请求合并效率很低。而官方库不支持在回调中传递多个 Key一次性批量获取它们的结果。与Forget方法相关的并发 Bug当异步的Forget和doCall的清理逻辑并发执行时Forget删除 key 后doCall执行完毕会再次删除这可能会误删其他协程刚刚设置的新 key。官方修复方案是引入一个forgotten布尔值标记此修复已合入 master 分支。关于DoChan方法中go panic(e)的冗余性源码中处理 panic 的方式曾引发社区对代码冗余的讨论。维护者的意图是保留当前 goroutine 的现场以便调试但其副作用是让 panic 永远无法被上层恢复代价是程序直接崩溃。社区替代库的核心特点这些库大多都支持泛型并且绝大多数也支持context.Context。在此基础上它们各自的突出特点和对比如下替代库最突出的差异化特点性能/设计亮点github.com/samber/go-singleflightx支持批量请求 (Batching)支持在回调中传入多个 Key 批量获取结果提供分片组 (Sharded Groups)减少锁竞争支持Nullable Result。github.com/oy3o/singleflight极致零内存分配 (Zero Allocation)通过sync.Pool等手段在缓存未命中场景下也能实现0 内存分配对 GC 极度友好。基准测试显示延迟约为 140ns优于官方的 190ns。github.com/brunomvsouza/singleflight追求100% API兼容风格保守力求与原版100%兼容。版本号跟随上游可作为泛型版的直接替代品。github.com/marwan-at-work/singleflight原生 Context 支持设计上引入了context.Context当 Context 被取消如HTTP请求断开时等待者可以提前退出避免无效等待。通用的避坑指南与最佳实践配置超时与熔断必须为飞行中的函数调用设置超时或引入熔断机制防止底层服务故障导致所有请求堆积。精确设计 Key确保 Key 能唯一代表需要去重的业务操作。避免在 Key 中包含无关的动态参数如时间戳否则去重会失效。保持 Group 为单例singleflight.Group实例必须在整个服务生命周期内全局唯一通常定义为包级变量才能保证去重效果。警惕高频 Key 的性能开销在高并发下如果 Key 数量巨大且大部分都不可重复sync.Mutex的锁竞争反而会带来微小的性能损耗约5-10μs虽然不大但在极端性能敏感场景值得注意。正确使用指针类型当fn返回的是指针如*User时多个 goroutine 会共享该指针。务必确保后续对该指针的读取操作是并发安全的避免数据竞争。下面是golang官方库代码package singleflight // import golang.org/x/sync/singleflight import ( bytes errors fmt runtime runtime/debug sync ) // errGoexit indicates the runtime.Goexit was called in // the user given function. var errGoexit errors.New(runtime.Goexit was called) // A panicError is an arbitrary value recovered from a panic // with the stack trace during the execution of given function. type panicError struct { value any stack []byte } // Error implements error interface. func (p *panicError) Error() string { return fmt.Sprintf(%v\n\n%s, p.value, p.stack) } func (p *panicError) Unwrap() error { err, ok : p.value.(error) if !ok { return nil } return err } func newPanicError(v any) error { stack : debug.Stack() // The first line of the stack trace is of the form goroutine N [status]: // but by the time the panic reaches Do the goroutine may no longer exist // and its status will have changed. Trim out the misleading line. if line : bytes.IndexByte(stack[:], \n); line 0 { stack stack[line1:] } return panicError{value: v, stack: stack} } // call is an in-flight or completed singleflight.Do call type call struct { wg sync.WaitGroup // These fields are written once before the WaitGroup is done // and are only read after the WaitGroup is done. val any err error // These fields are read and written with the singleflight // mutex held before the WaitGroup is done, and are read but // not written after the WaitGroup is done. dups int chans []chan- Result } // Group represents a class of work and forms a namespace in // which units of work can be executed with duplicate suppression. type Group struct { mu sync.Mutex // protects m m map[string]*call // lazily initialized } // Result holds the results of Do, so they can be passed // on a channel. type Result struct { Val any Err error Shared bool } // Do executes and returns the results of the given function, making // sure that only one execution is in-flight for a given key at a // time. If a duplicate comes in, the duplicate caller waits for the // original to complete and receives the same results. // The return value shared indicates whether v was given to multiple callers. func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool) { g.mu.Lock() if g.m nil { g.m make(map[string]*call) } if c, ok : g.m[key]; ok { c.dups g.mu.Unlock() c.wg.Wait() if e, ok : c.err.(*panicError); ok { panic(e) } else if c.err errGoexit { runtime.Goexit() } return c.val, c.err, true } c : new(call) c.wg.Add(1) g.m[key] c g.mu.Unlock() g.doCall(c, key, fn) return c.val, c.err, c.dups 0 } // DoChan is like Do but returns a channel that will receive the // results when they are ready. // // The returned channel will not be closed. func (g *Group) DoChan(key string, fn func() (any, error)) -chan Result { ch : make(chan Result, 1) g.mu.Lock() if g.m nil { g.m make(map[string]*call) } if c, ok : g.m[key]; ok { c.dups c.chans append(c.chans, ch) g.mu.Unlock() return ch } c : call{chans: []chan- Result{ch}} c.wg.Add(1) g.m[key] c g.mu.Unlock() go g.doCall(c, key, fn) return ch } // doCall handles the single call for a key. func (g *Group) doCall(c *call, key string, fn func() (any, error)) { normalReturn : false recovered : false // use double-defer to distinguish panic from runtime.Goexit, // more details see https://golang.org/cl/134395 defer func() { // the given function invoked runtime.Goexit if !normalReturn !recovered { c.err errGoexit } g.mu.Lock() defer g.mu.Unlock() c.wg.Done() if g.m[key] c { delete(g.m, key) } if e, ok : c.err.(*panicError); ok { // In order to prevent the waiting channels from being blocked forever, // needs to ensure that this panic cannot be recovered. if len(c.chans) 0 { go panic(e) select {} // Keep this goroutine around so that it will appear in the crash dump. } else { panic(e) } } else if c.err errGoexit { // Already in the process of goexit, no need to call again } else { // Normal return for _, ch : range c.chans { ch - Result{c.val, c.err, c.dups 0} } } }() func() { defer func() { if !normalReturn { // Ideally, we would wait to take a stack trace until weve determined // whether this is a panic or a runtime.Goexit. // // Unfortunately, the only way we can distinguish the two is to see // whether the recover stopped the goroutine from terminating, and by // the time we know that, the part of the stack trace relevant to the // panic has been discarded. if r : recover(); r ! nil { c.err newPanicError(r) } } }() c.val, c.err fn() normalReturn true }() if !normalReturn { recovered true } } // Forget tells the singleflight to forget about a key. Future calls // to Do for this key will call the function rather than waiting for // an earlier call to complete. func (g *Group) Forget(key string) { g.mu.Lock() delete(g.m, key) g.mu.Unlock() }
返回列表