ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Go 1.21 sync.OnceValue/OnceFunc 实战:三行搞定懒加载单例,告别手写双重检查

Go 1.21 sync.OnceValue/OnceFunc 实战:三行搞定懒加载单例,告别手写双重检查 Go 1.21 sync.OnceValue/OnceFunc 实战:三行搞定懒加载单例,告别手写双重检查「配置只加载一次」「数据库连接池全局初始化一次」「正则只编译一次」——这类「用到时才初始化、且只初始化一次、且并发安全」的需求,Go 里几乎天天遇到。老办法是sync.Once配一个包级变量,能用,但样板代码一堆,还容易踩坑。Go 1.21 新增的sync.OnceValue和sync.OnceFunc把它压成了三行。先看手写 sync.Once 的样板假设要懒加载一份配置(读文件、解析,开销不小,想用到时才做):var(cfgOnce sync.Once cfg*Config cfgErrerror)funcGetConfig()(*Config,error){cfgOnce.Do(func(){cfg,cfgErrloadConfig(app.yaml)// 只会执行一次})returncfg,cfgErr}这段没错,但暴露了几个烦人的点:三个包级变量(Once、结果、错误)散在外面,污染包命名空间,别的函数也能误改cfg。样板重复:每加一个懒加载单例,这套var Once Do就得抄一遍。容易漏并发保护:新人常直接写if cfg nil { cfg load() },在并发下是数据竞争 可能初始化多次的经典 bug。Go 1.21 的思路是:把「只跑一次」这件事封装成一个普通函数值,你调用它就自动享受「首次执行、后续复用、并发安全」,变量全藏进闭包里。sync.OnceValue:懒加载一个返回值sync.OnceValue(f)接收一个无参、返回一个值的函数f,返回一个新函数。这个新函数无论被调用多少次、被多少 goroutine 同时调用,f都只执行一次,之后每次调用都直接返回那一次的缓存结果:importsync// loadConfig 开销大,只想在第一次真正用到时执行一次varGetConfigsync.OnceValue(func()*Config{returnloadConfig(app.yaml)})funcmain(){c1:GetConfig()// 第一次:真正执行 loadConfigc2:GetConfig()// 第二次:直接返回缓存,不再执行// c1 c2,同一个实例}对比手写版,三个包级变量没了,Once没了,Do没了。GetConfig就是个普通函数,调用它就是「拿单例」。并发安全是内建的:一百个 goroutine 同时首次调用,loadConfig也只会跑一次,其余的会阻塞等第一次跑完再拿结果。编译正则、初始化连接这类同理:varemailResync.OnceValue(func()*regexp.Regexp{returnregexp.MustCompile(^[\w.\-][\w.\-]\.\w$)// 只编译一次})funcIsEmail(sstring)bool{returnemailRe().MatchString(s)// 每次调用 emailRe() 拿的是同一个已编译对象}sync.OnceValues:带 error 的初始化OnceValue只返回一个值,但初始化十有八九会失败(读文件、连数据库都可能出错)。Go 1.21 同时给了sync.OnceValues,返回两个值,专门伺候(T, error)这个 Go 里最常见的组合:vargetDBsync.OnceValues(func()(*sql.DB,error){db,err:sql.Open(postgres,os.Getenv(DSN))iferr!nil{returnnil,err}iferrdb.Ping();err!nil{returnnil,err}returndb,nil})funcQuery(ctx context.Context,qstring)(*sql.Rows,error){db,err:getDB()// 首次建连并 Ping;之后直接复用同一个 *sql.DB 和同一个 erriferr!nil{returnnil,fmt.Errorf(db 初始化失败: %w,err)}returndb.QueryContext(ctx,q)}这就等价于开头那个手写GetConfig,但把三个包级变量全收进了闭包,外部一个都碰不到。注意语义:错误也会被缓存。如果第一次初始化失败,后续每次调用都会拿到同一个失败的 err,不会重试——这对「配置错了就该一直报错」是对的,但对「网络抖动导致的临时失败」可能不是你想要的(见下文坑点)。sync.OnceFunc:只跑一次、不要返回值有时候你只想让某段副作用代码执行一次,不需要返回值——比如注册指标、初始化日志、打一次埋点。用sync.OnceFunc:varinitMetricssync.OnceFunc(func(){prometheus.MustRegister(requestCounter)// 重复注册会 panic,必须保证只跑一次log.Println(metrics registered)})funchandleRequest(){initMetrics()// 无论多少请求进来,注册只发生一次// ...}OnceFunc返回的函数无参无返回值,调多少次里面的逻辑都只执行第一次。它就是sync.Once.Do的等价物,但不用你手动声明和管理那个sync.Once变量。三个必须知道的坑坑一:错误被永久缓存,不会重试。OnceValues把首次的error也缓存了。如果初始化失败是暂时的(如启动时数据库还没起来),它会让你永远拿到那个失败,直到进程重启。如果你需要「失败可重试」,OnceValues就不合适,得退回手写逻辑或用带重试的初始化——别指望它帮你重试。坑二:初始化函数里 panic 会怎样?如果传入的f内部 panic 了,OnceValue/OnceFunc的语义是:这次算「已执行」,panic 向上抛出;之后再调用,会直接重新抛出同一个 panic 值,而不会再跑f。也就是说初始化崩了就一崩到底,后续调用不会「重试初始化」。这和sync.Once.Do的行为一致——Do里 panic 后Once也认为已执行过。所以初始化函数里该处理的错误用 error 返回,别用 panic 表达「可恢复的失败」。坑三:别在初始化函数里再调用它自己(死锁)。因为首次调用会持锁执行f,如果f内部又调了这个OnceValue返回的函数,就会自己等自己,死锁:vargetAsync.OnceValue(func()int{returngetA()1// 死锁!首次执行时又调自己,永远等不到锁释放})有相互初始化依赖时,把依赖抽成独立的 Once 函数,别让它们环状调用。什么时候还用手写 sync.OnceOnceValue系列覆盖了绝大多数「懒加载 缓存结果」的场景,但下面两种还是手写sync.Once更合适:需要失败重试:如前所述,新 API 会缓存错误。要重试就别用它。初始化逻辑要访问多个外部状态、或有复杂条件:闭包能装,但如果逻辑很长、还要读写别的字段,直接once.Do(func(){...})反而更清楚。日常「读一次配置/编译一次正则/建一次连接池」这类,直接上OnceValue/OnceValues/OnceFunc,别再抄那套三变量样板了。小结手写sync.Once 包级变量做懒加载单例,样板多、污染命名空间、易漏并发保护。Go 1.21 三兄弟:OnceValue(f)懒加载一个值、OnceValues(f)懒加载(T, error)、OnceFunc(f)只跑一次副作用,变量全藏进闭包,并发安全内建。用法就是var Get sync.OnceValue(func() T {...}),之后Get()拿单例,首次执行、后续复用。三个坑:错误会被永久缓存(不重试)、f里 panic 会一崩到底(后续重抛不重试)、别在f内递归调用自己(死锁)。需要失败重试或初始化逻辑复杂时,仍用手写sync.Once。一句话记忆点:懒加载单例别再抄三变量样板了,sync.OnceValue一行就是并发安全的「只算一次」。
返回列表