☰
Go并发编程:sync包原语详解与工程实践避坑指南
2026/10/7 12:10:04 网站建设 项目流程

作为一个写了多年代码的Go选手,我对sync包的感受很复杂:它既是并发编程的基石,又是翻车率最高的地方。网上的教程大多只讲用法,很少讲清楚“为什么这么设计”和“实际项目中会踩哪些坑”。这篇笔记是基础系列的第十五篇,专门把sync包整体过一遍,从底层原理到真实工程场景,一次性补齐那些年我们对并发原语的理解缺口。

1. 为什么并发问题的根子都在sync包上

1.1 一次线上数据竞争:从现象到根因

先讲个真实事故。去年我负责的一个订单服务,在高峰期偶尔会出现订单状态错乱——明明用户已经支付了,数据库里却是“待支付”。刚开始怀疑是缓存问题,后来通过日志发现,原因竟然是服务里有一段并发处理逻辑:多个goroutine同时读写同一个订单结构体,而这个结构体里的状态字段没有任何保护。

type Order struct { Status string Amount float64 } // 并发场景下,多个goroutine同时读写Order.Status func handleOrder(order *Order, isPaid bool) { if isPaid { order.Status = "paid" // 写 } fmt.Println(order.Status) // 读 }

这段代码的问题,本质上是数据竞争(data race)。多个goroutine同时访问同一个内存地址,其中至少一个在写,且没有同步机制,那么结果就完全不可控——可能读到旧值,可能读到新值,甚至可能读到中间状态。在Go里,这种问题用单测很难发现,因为只有特定调度时序下才会触发。

当时的修复方案很简单,给结构体加一把sync.Mutex锁:

type Order struct { mu sync.Mutex Status string Amount float64 } func (o *Order) MarkPaid() { o.mu.Lock() defer o.mu.Unlock() o.Status = "paid" }

这不是什么高深操作,但如果不懂sync包背后的原理,很容易出现“加了锁还是不对”的情况——比如锁的作用域没覆盖住所有读写路径,或者用了锁却复制了锁对象。后面我会详细讲这些坑。

1.2 happens-before:理解sync的第一性原理

要真正理解sync包,绕不开Go内存模型里的happens-before原则。

简单说,如果一个操作A在逻辑上先于操作B(A happens-before B),那么B一定能看到A所做的写操作。sync包提供的每个原语,核心目的就是建立happens-before关系:

  • Mutex.Lock()之后的代码,能够看到Unlock()之前的所有写操作;
  • WaitGroup.Wait()返回后,能看到之前Done()之前的所有写操作;
  • Once.Do(f)执行完之后,能看到f过程中的所有写操作;
  • Cond.Wait()返回后,能看到对应Signal()或Broadcast()之前的所有写操作;
  • Channel的发送与接收之间,也有严格的内存序保证。

换句话说,sync包的各个类型,就是帮你把内存屏障(memory barrier)以API的形式封装好。不懂happens-before,就去猜锁的“玄学”;懂了之后,你会发现所有的并发问题都是可推导的。

1.3 channel与sync的边界

这其实是Go社区争论很多年的话题。Rob Pike虽然提出了“不要通过共享内存来通信,而要通过通信来共享内存”,但这并不意味着channel能替代所有sync原语。

我个人的选型原则很简单:

  • 需要传递数据本身,比如一个job队列、一段结果,用channel;
  • 需要保护某个共享状态,比如计数器、缓存Map、复杂对象,用sync.Mutex/RWMutex;
  • 需要多个任务全部完成后才继续,用WaitGroup;
  • 需要某个初始化逻辑只执行一次,用Once;
  • 需要复用对象以减少GC压力,用Pool。

channel的底层其实还是用锁实现的,它在某些场景(如广播、超时控制)确实更优雅,但性能上通常不如直接上锁。真正的高手是按场景选工具,而不是为了“更Go”强行用channel。

2. Mutex与RWMutex:锁的代价与正确用法

2.1 从自旋锁到饥饿模式:Mutex的内部演进

sync.Mutex在Go 1.18之后经历了比较大的重构,现在从源码层面看,它有三种模式:

  • 正常模式(非公平):新来的锁请求会尝试CAS获取锁,抢不到就进入自旋等待(runtime_ProcyPin短暂忙等),然后进入等待队列;
  • 饥饿模式(公平):当一个等待者等待时间超过1ms时,Mutex会切换为饥饿模式,新来的请求不再参与竞争,直接排队。这样能防止“后来者”反复抢走锁,导致早期等待者被“饿死”;
  • 退出饥饿模式:当等待者获取到锁后,如果它的等待时间不到1ms,或者它是队列中最后一个等待者,就切回正常模式。

实际工程中,锁竞争激烈时,饥饿模式能显著降低尾延迟。日常开发我们不需要操心底层,但有一个结论要知道:Mutex的排序是不保证公平的,不要依赖它做顺序控制。

写一段压力测试来观察锁的性能特征:

func BenchmarkMutex(b *testing.B) { var mu sync.Mutex counter := 0 b.RunParallel(func(pb *testing.PB) { for pb.Next() { mu.Lock() counter++ mu.Unlock() } }) }

实测下来,单线程加锁无竞争时开销大约20ns;8线程竞争时,单次加锁可能会到100ns以上。锁不是在“做”什么事,而是让并发操作“排队”,所以锁的粒度设计直接影响吞吐量。

2.2 Mutex的保护范围:锁什么、怎么锁

很多人加锁的姿势不对,最常见的就是锁的范围太小:

// 错误示例:锁没有覆盖整个读-改-写过程 func (s *Store) Incr() int { s.mu.Lock() s.count++ s.mu.Unlock() return s.count // 这里读的是加锁之后的值吗?未必,有可能被其他goroutine改掉了 }

这个return s.count在读的时候没有锁保护,虽然它大概率能读到正确值,但严格来说是数据竞争。正确做法要么把读也放进锁内,要么用atomic包:

func (s *Store) Incr() int { s.mu.Lock() defer s.mu.Unlock() s.count++ return s.count }

另一个常见误区是锁的粒度太大,把不需要同步的IO操作也放进锁里,导致吞吐量断崖式下降。比如持有锁期间做网络请求、写文件、甚至Sleep,会让所有竞争者排队,锁的争用急剧上升。正确做法是:只锁内存临界区,IO操作挪到锁外。

2.3 Copy锁与死锁:两个高频坑

复制锁这个问题,我在代码评审里见过太多次。sync.Mutex结构体内部有一个状态字段,如果在锁被持有的状态下复制了Mutex对象,那么两个“副本”共享同一个内部状态,逻辑上等于同一个锁,但代码上已经完全割裂。

Go 1.20之前,复制Mutex不会报错,只会让你疯狂猜测哪里出了问题。好在go vet自带了copylocks检查器,能在编译期抓住这种问题:

go vet ./...

我自己还踩过一回通过函数传参复制锁的坑,那个项目里一个结构体里嵌了Mutex,结构体却经常按值传递,结果就是明明上了锁,实际保护的却不是同一个锁对象,数据照样竞争。排查了两天才定位到根因,后来规范就是:任何包含锁字段的结构体,一律用指针传递,永远禁止按值拷贝。

死锁就更经典了。两个goroutine互相持有对方需要的锁,谁也不释放,程序整个卡死。Go检测不出死锁(除非触发fatal error: all goroutines are asleep - deadlock!),因为死锁通常发生在运行时而不是编译期。我的经验是:锁的获取顺序要全局统一。比如多个订单锁时,总是先锁ID小的,再锁ID大的,能有效规避循环等待。

2.4 RWMutex适合什么场景:读多写少与写锁饥饿

sync.RWMutex是Mutex的升级版:允许多个读者同时持有读锁,写者必须独占。这非常适合读多写少的场景,比如配置表、路由表、缓存目录。

读读不互斥,读写互斥,写写互斥——这是RWMutex的基本语义。但这带来一个潜在麻烦:如果读锁被长时间持有很多,写锁会一直拿不到,造成所谓的“写者饥饿”。RWMutex在实现上保证了等待的写者优先于新来的读者,也就是说一旦有写者在等,新的读请求就会排队,避免写者饿死。

var rw sync.RWMutex func ReadConfig() map[string]string { rw.RLock() defer rw.RUnlock() return config }

性能上,RLock/RUnlock比Lock/Unlock略重一点。如果临界区只有几行内存操作,而且写操作占比已经很高,那么RWMutex并不比普通Mutex快多少。只有当读操作远多于写操作(比如几十比一)时,RWMutex的收益才明显。

3. WaitGroup与Once:并发生命周期管理

3.1 WaitGroup的时序陷阱:Add必须在Wait之前

WaitGroup是Go里最常用的并发编排原语,但它的使用有一个反直觉的坑:Add操作必须在Wait之前完成计数,至少要保证在逻辑上有happens-before关系。

看一个经典错误:

func main() { var wg sync.WaitGroup for i := 0; i < 5; i++ { go func() { wg.Add(1) // 错误:Add在groutine内部执行 defer wg.Done() doWork() }() } wg.Wait() fmt.Println("done") }

这段代码的问题是:主goroutine可能在这5个子goroutine都还没来得及执行wg.Add(1)时,就已经执行到wg.Wait()了。此时计数器是0,Wait()直接返回,后续Done()又导致计数器归零,程序提前退出。特别是当子goroutine创建耗时较长、调度延迟较大时,这个问题会稳定复现。

正确姿势很无脑但很可靠:

var wg sync.WaitGroup for i := 0; i < 5; i++ { wg.Add(1) // 在启动goroutine之前Add go func() { defer wg.Done() doWork() }() } wg.Wait()

如果确实需要动态增加任务,必须在Wait开始之前把Add全部执行完,或者用带缓冲的channel来动态通知子goroutine“有活干了”。不要把WaitGroup当线程池用——它的设计目标是固定批次任务的等待。

3.2 动态Add的正确姿势与main goroutine等待模式

有些场景是任务运行过程中会产生子任务,这时WaitGroup的计数会动态变化。标准做法是:

func processJobs(jobs []Job) { var wg sync.WaitGroup wg.Add(len(jobs)) for _, job := range jobs { go func(j Job) { defer wg.Done() // 过程里开启子任务,感觉要Add?不,应该把子任务也提前规划好 j.Run() }(job) } wg.Wait() }

有一条硬规则:Add()的调用必须在对应的goroutine启动之前完成,这样才能保证Wait()不会提前返回。如果你实在要在coroutine内部Add,可以在外面先加一个大计数,再内部add是无法保证安全性的。

实际项目中我更喜欢把WaitGroup封装到一个结构体里,用方法管理Add/Done,避免调用方误用:

type TaskGroup struct { wg sync.WaitGroup } func (g *TaskGroup) Go(f func()) { g.wg.Add(1) go func() { defer g.wg.Done() f() }() } func (g *TaskGroup) Wait() { g.wg.Wait() }

3.3 Once的用法与Go 1.20后的变化

sync.Once保证某个函数只执行一次,这在单例初始化、配置加载、资源池建立时非常有用:

var ( once sync.Once cfg *Config ) func GetConfig() *Config { once.Do(func() { cfg = LoadConfig() }) return cfg }

Once.Do有几个值得注意的细节:

  1. 如果f中panic了,Once会视为已完成,后续的Do调用不会重新执行f。这其实是个大坑,f内部不能随便panic,否则整个初始化就“半永久”失败了。
  2. Once不能被复制,和Mutex一样,复制之后的结果是两份独立的“单次”保证,极容易引发逻辑错误。
  3. Go 1.20之后,Once提供了一种更保守的做法:可以用atomic.Bool或atomic.Pointer替代Once做懒加载,因为Once.Do本身也有少量锁竞争。

我的经验是:Once适合进程生命周期内只需要一次初始化的场景;如果初始化可能失败、需要重试,就不要用Once,自己用Mutex + 状态位控制:

var ( mu sync.Mutex cfg *Config done bool ) func GetConfig() *Config { mu.Lock() defer mu.Unlock() if !done { var err error cfg, err = LoadConfig() if err == nil { done = true } } return cfg }

4. Cond与Pool:条件同步与对象复用

4.1 Cond:为什么每次都要用for循环包住Wait

sync.Cond在标准库里的存在感不高,但处理“等待某个条件成立”的业务场景时,它是非常顺手的工具。它的核心API是Wait、Signal和Broadcast。

Wait的行为是:自动释放底层锁,挂起当前goroutine,等待被唤醒;被唤醒后,会重新获取锁并返回。这里有一个几乎所有文档都会强调、但初学者经常忘记的关键点:Wait返回后,条件不一定成立。

原因主要有两个:

  1. 你可能被假唤醒(spurious wakeup),这是在操作系统层面就可能发生的;
  2. 即使不是假唤醒,Signal只唤醒一个goroutine,如果多个goroutine在等同一个条件,另一个goroutine可能抢先把条件瓜分掉了。

所以正确姿势是用for循环而不是if:

c.L.Lock() for !condition() { c.Wait() } // 此时条件一定成立 c.L.Unlock()

这个for循环实际上是“重新检查条件”的保险丝,是Cond使用中最重要的一条铁律。永远不要用if包住Wait,这是死代码的温床。

4.2 Signal与Broadcast的取舍

Signal唤醒一个等待者,Broadcast唤醒所有等待者。选择原则:

  • 当每次状态变化“只能”被一个消费者处理时(如任务队列pop),用Signal;
  • 当状态变化需要“所有”消费者都感知时(如全局配置刷新、退出信号),用Broadcast。

举个实际例子,我有一个内部消息处理引擎,多个worker从任务队列取job,当队列为空时所有worker都在c.Wait();生产者在放入job后调用c.Signal(),保证只有一个worker被唤醒去取任务。这样能避免惊群效应,也就是所有worker都被唤醒,但只有一个人抢到锁,其他又回去睡觉,白白浪费调度。

如果生产者一次放了多个job,直接Broadcast反而更好,因为多个worker可以并行消费。

4.3 Pool:顺手捡到的性能优化神器

sync.Pool的目标是复用临时对象,降低GC压力和分配开销。它特别适合大量创建、用完即弃、没有长期持有需求的对象,比如JSON编码的bytes.Buffer、数据库连接(但连接池通常有更完整的实现)、序列化上下文。

var bufferPool = sync.Pool{ New: func() interface{} { return new(bytes.Buffer) }, } func Marshal(v interface{}) ([]byte, error) { buf := bufferPool.Get().(*bytes.Buffer) defer bufferPool.Put(buf) buf.Reset() if err := json.NewEncoder(buf).Encode(v); err != nil { return nil, err } return buf.Bytes(), nil }

Pool.Get会在池里没有对象时调用New新建一个;Put归还对象。但有个反直觉的点:Pool里的对象随时可能被GC清理掉。也就是说,你不能假设Put进去的对象一定能在后续Get中拿回来。换句话说,Pool的设计是“尽力而为”,不是高效的cache,不适合用来保存有状态的长生命周期对象。

如果Put进去的对象内部还有引用的数据(比如一段大slice),不Reset就放回去,下次Get到之后会产生脏数据。所以Put之前一定要把对象恢复到零状态,这是Pool使用里最容易被忽略的细节。

4.4 Pool的坑:Go 1.23之后的实验特性

最近有个新东西:Go 1.23的GOEXPERIMENT=pooldecommit,它能让Pool在空闲时把内存归还给OS,略微牺牲一点响应速度来降低常驻内存。如果你的服务有大量临时对象,且对内存水位敏感,可以关注一下这个实验特性。

但对于一般业务代码,我建议不要过度优化。先用Pool解决明确的、可测量的分配开销问题。grep一下线上profile里的alloc_objects数据,再决定要不要上Pool,别为提速而抽象的架构。

5. sync.Map:什么时候值得用

5.1 为什么原生map不能并发写

Go的原生map在并发写时直接会触发运行时panic——fatal error: concurrent map writes。原因很简单,map的底层哈希表扩容、rehash过程是non-atomic的,并发写会破坏内部结构。所以并发场景下,要么给map加锁,要么用sync.Map。

我先说结论:如果只是普通的并发读写,map加Mutex通常比sync.Map更简单高效。sync.Map不是万能药。

5.2 sync.Map的空间换时间思路:read与dirty两级缓存

sync.Map之所以在特定场景下比map+Mutex快,在于它通过双Map结构(read和dirty)减少了锁竞争:

  • read:只读的dirty副本,读取时无锁;
  • dirty:可写的map,写入时需要加锁;
  • 读取时先查read,miss了才查dirty,并触发一次missLocked将dirty提升为read;
  • 删除时使用expunged标记,避免立即修改read。

所以sync.Map适合**读多写少、key相对稳定(不频繁删除)**的场景。比如服务里的全局配置字典、id到对象的映射。这时候读几乎无锁,性能接近原生map。

不合适的场景也明确:频繁写入、key生命周期很短(经常新建/删除)、写多读少的计数器类Map。这些场景直接用map+RWMutex更稳更简单。

5.3 什么时候该用sync.Map

工程上的判定标准,我建议问自己三个问题:

  1. 这个Map的读请求量级是不是远大于写请求?
  2. key集合是不是基本稳定,不会频繁删除插入?
  3. 是否运行在多个goroutine同时访问、且写操作也需要锁保护的场景?

三个答案全是“是”,再用sync.Map。否则老老实实加Mutex,代码更清晰,review的人也不用来回问。

另有一个知识点:sync.Map的LoadOrStore是原子操作,这在“初始化单例缓存”场景非常有用,equivalence用Mutex自己写要小心双重检查锁是否成立。

6. sync生态的隐藏成员:errgroup与官方扩展

6.1 errgroup的基本用法

严格来说errgroup不在sync包里,它在golang.org/x/sync/errgroup,是官方扩展库。但几乎每个用Go做并发编排的项目都会用,我把它放进sync笔记里一起讲。

errgroup解决的核心问题是:一组goroutine并发执行,任何一个goroutine返回错误,整个组停止,并等待所有goroutine结束,把第一个错误返回来。

g, ctx := errgroup.WithContext(ctx) for _, file := range files { file := file g.Go(func() error { return processFile(ctx, file) }) } if err := g.Wait(); err != nil { log.Fatal(err) }

它与WaitGroup的最大区别:Wait()返回error;用WithContext时,第一个返回错误的goroutine会自动触发context取消,从而让其他goroutine收到取消信号,优雅退出。

6.2 WithContext与取消机制

WithContext是errgroup最实用的一面。它把context和错误传播绑定在一起,适合做“只要有一步失败就整体失败”的批处理任务,比如并发下载多个文件、批量处理订单、同时调用多个上游RPC。

具体实现上,g.Go启动的goroutine如果返回非nil错误时,group会通过context.WithCancel取消整个ctx。这个机制在我们公司内部的服务里是标配:批量调用多个下游服务,任何一个返回5xx就立刻取消其他还在跑的任务,整体响应时间大大降低。

6.3 一个常见的坑:goroutine内的局部变量

errgroup使用时要特别注意闭包捕获问题。Go 1.22之前的循环变量是共享的,如果你在for _, file := range files里直接启动goroutine,file会被所有goroutine捕获并最终变成最后一个值。我见过太多因此出现的线上bug。

更安全的写法:

g.Go(func() error { return processFile(ctx, file) // 在for外面先复制 file := file })

Go 1.22起循环变量每次迭代都有了独立的新变量,问题得到缓解,但如果项目还在老版本,务必保持file := file的习惯。另外,errgroup里Go方法不会阻塞,但Wait会阻塞,这个顺序不要搞反。

7. 说到排查:race检测器与go vet

7.1 让我抓狂的一次死锁调试经历

去年维护的一个任务调度模块,突发高频死锁。当时的现象是服务CPU占用不高,但大量请求超时,goroutine还在持续堆积。抓了pprof一看,几十个goroutine全部阻塞在sync.Mutex.Lock()上。

进一步查代码,发现问题是这样的:A方法持有锁M,然后调用了B方法;B方法内部又对同一个M加锁——也就是重复加同一把非重入锁。sync.Mutex不是可重入锁,同一个goroutine重复Lock()会把自己锁死。这个在Java里很常见(synchronized是可重入的),但在Go里直接翻车。

当时修复的思路是:要么B方法不要求加锁,把B设计成“调用方负责持锁”的私有方法;要么拆成两把锁。从架构上我选择了后者,因为B是需要独立被调用的。这个教训后来我写进了团队的代码规范里:凡是方法名前面加了下划线的,都认为是“已持锁”的私有方法,禁止再Lock。

7.2 go test -race:并发问题的照妖镜

go test -race是排查数据竞争的最强工具。它通过运行时对每个内存访问插入检测代码,能准确报告数据竞争的goroutine栈。经验是,所有涉及并发的项目必须在CI里跑-race的单测。

go test -race ./...

-race对性能影响很大(通常慢5-10倍),但只跑测试完全没问题。我几乎每次都开着race检测器做联调,它能在你还没意识到有问题的时候就报出竞争点,省下很多线上修复成本。

race报告里会给出两个goroutine的栈帧,其中一个写、一个读(或者两个写)。只要照着栈去锁住对应的代码路径就行。但注意:race报告只能证明测试覆盖到的路径有竞争,不能证明没有竞争。所以关键代码路径要尽可能设计覆盖并发场景的测试用例。

7.3 go vet的copylocks检查

go vet默认开启copylocks检查。它通过静态分析找出“复制包含Mutex的结构体”的情况。虽然它不是银弹(动态复制的案例还是查不出来),但已经把最常见的按值传参问题堵死了。

构建时养成习惯:

go build -vet=off ./... # 或者 go test -vet=all ./...

不过我的建议是:不要跳过vet,把一个带copylocks问题的代码build出来没有任何意义,提前炸在CI里比线上事故强一百倍。

8. 这些trick你越早知道越好

8.1 零值可用:为什么var mutex sync.Mutex就能用

Go语言有个很妙的特性:sync.Mutex、RWMutex、WaitGroup、Once、Pool这些类型的零值都是可以直接使用的状态。也就是说:

var mu sync.Mutex // 直接能用 var wg sync.WaitGroup // 直接能用

这是通过内部的小技巧noCopy和合理的初始状态做到的。刚入门的开发者可能会疑惑:不需要调用New()或者初始化函数吗?确实不需要,这是Go的“零值可用”哲学,与结构体的普通定义不同,它通过go vet来提醒你不要复制类型实例。

所以定义包含锁的结构体时,直接这样写就行:

type Cache struct { mu sync.Mutex items map[string]Item }

因为Mutex的零值就是解锁状态,一旦第一次加锁就不准再复制了。不要额外写初始化函数去new(sync.Mutex),纯属多此一举。

8.2 不可复制的锁:为什么panic都没报

复制锁导致的问题,多数时候不会直接panic,而是静默地破坏并发安全。比如这个例子:

type SafeCounter struct { mu sync.Mutex v int } // 错误:按值传了一份SafeCounter,锁也被复制了 func (c SafeCounter) Incr() { c.mu.Lock() c.v++ c.mu.Unlock() }

Incr一调用,给的是c的副本,方法里加锁的是副本的锁,方法外真正的c.mu根本没有锁。如果多个goroutine同时调用Incr,数据照样竞争。

解决:方法的接收者改成指针func (c *SafeCounter) Incr(),同时保证该类型的使用方也都传指针。所有手动定义Mutex结构体的场景,接收者一律指针。

更隐蔽的一个是go test可能会报mutex is held的vet错误,但线上并没有。这就是vet的价值:它在编译时把大部分复制锁的问题拦住了。

8.3 Locker接口与锁的抽象

sync.Locker是包暴露的接口:

type Locker interface { Lock() Unlock() }

一般不要面向它来写业务代码,但如果你要自研一些并发控制组件(比如接口限流器、租约锁),可以考虑让它们实现Locker,这样便能无缝嵌入其他需要Locker的第三方库。比如sync.Cond的构造函数就要一个Locker接口,你既可以传Mutex指针,也可以传RWMutex的读锁。

注意,RWMutex的RLock不是Locker接口的一部分——因为RLock不满足Lock/Unlock的方法签名。如果想让读写锁实现Locker,那就用它的Lock/Unlock。高层的抽象在复杂项目里能减少很多样板代码,但别过度,锁的抽象容易把简单逻辑搞复杂。

8.4 channel与Mutex选型表

我把自己这些年做网关、聚合服务总结的并发选型心得列了一个速查表:

场景推荐方案原因
一对多传递数据channel语义清晰,支持超时、取消
多goroutine共享状态Mutex直接保护变量,简单可靠
读多写少配置/CacheRWMutex或sync.MapRWMutex更通用,sync.Map适合大Map
等待一批任务完成WaitGroup轻量、原生支持
单例初始化Once保证严格一次
条件变量等待Cond避免自旋浪费CPU
临时对象复用Pool降低GC与分配开销
并发批处理+错误传播errgroup自动取消,收敛错误

这里想多说一句,选择并发原语就像选螺丝刀,没有哪个是“更高级”的。关键看你的临界区到底在保护什么:是数据,是合作关系,还是计算资源。数据用锁,协调用channel,批量生命周期用WaitGroup,初始化用Once——这些东西组合起来,基本能覆盖Go并发编程99%的真实需求。

写到这里,我顺手去翻了一下之前的笔记,第一篇到第十四篇讲的都是基础语法、slice/map、goroutine和channel这些。sync包放在第十五篇,其实是把前面零散的并发知识收拢在一起——从“怎么开goroutine”到“怎么让goroutine之间安全协作”,中间缺的正是这些同步原语。如果你正处在刚学完channel、却不知道什么时候该上锁的阶段,这篇笔记应该能帮你把模糊的边界划清楚。之后有机会,我打算再写一篇关于atomic包和内存序的笔记,把无锁编程这块也补上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询