个人主页island1314⛺️ 欢迎关注点赞 留言 收藏 生活总是不会一帆风顺前进的道路也不会永远一马平川如何面对挫折影响人生走向 – 《人民日报》 目录前言1. 什么是并发安全2. 锁与同步原语2.1 WaitGroup2.2 sync.Once2.3 Map2.4 Mutex2.5 RWMutex3. 无锁原子操作4. Channel vs 锁 vs 原子操作前言Go 的并发哲学是“通过通信共享内存而非通过共享内存通信”但工程实践中共享状态依然无处不在。掌握sync包与sync/atomic包是写出零数据竞争、高性能、可维护并发代码的必修课。 核心定位锁保护复杂状态原子操作保护简单值Channel 负责通信与协调。三者互补非替代关系。1. 什么是并发安全术语定义检测手段数据竞争 (Data Race)两个以上 goroutine 并发访问同一变量至少一个是写操作且无同步机制go run -race/go test -race竞态条件 (Race Condition)程序行为依赖于不可控的执行时序逻辑错误不一定触发数据竞争压力测试、代码审查Happens-BeforeGo 内存模型定义的顺序保证若操作 A happens-before B则 A 的内存写入对 B 可见锁释放→获取、channel 收发、sync.Once等天然保证铁律任何共享可变状态必须通过锁、原子操作或 Channel明确同步。靠“运气”或“经验”必然翻车。比如多个goroutine同时操作一个资源临界区varxint64varwg sync.WaitGroupfuncadd(){fori:0;i5000;i{xx1}wg.Done()}funcmain(){wg.Add(2)goadd()goadd()wg.Wait()fmt.Println(x)}上面的代码中我们开启了两个goroutine去累加变量x的值这两个goroutine在访问和修改x变量的时候就会存在数据竞争导致最后的结果与期待的不符。2. 锁与同步原语sync包 核心 API类型核心方法适用场景关键特性sync.MutexLock(),Unlock()保护临界区、复杂数据结构❌ 不可重入、支持公平/饥饿模式切换、defer Unlock()最佳实践sync.RWMutexRLock(),RUnlock(),Lock(),Unlock()读多写少场景缓存、配置写优先防饿死、读写互斥、降级/升级需手动实现sync.WaitGroupAdd(),Done(),Wait()等待一批 goroutine 完成⚠️ 必须传指针、计数器不能为负、不可复用sync.OnceDo(f func())单例初始化、懒加载线程安全、仅执行一次、支持 panic 重试sync.MapLoad(),Store(),LoadOrStore(),Range()读多写少 / 键集合不相交非map替代品、写操作有额外开销、适合缓存/元数据sync.PoolGet(),Put()临时对象复用如bytes.Buffer减少 GC 压力、不保证对象存活、适合高频短生命周期对象sync.CondWait(),Signal(),Broadcast()复杂条件等待现已少用底层基于 Mutex、Channel 通常更优雅标准用法示例// 1. Mutex 保护计数器typeSafeCounterstruct{mu sync.Mutex countint}func(c*SafeCounter)Inc(){c.mu.Lock()deferc.mu.Unlock()// ✅ 紧跟 Lock防遗漏c.count}// 2. RWMutex 读多写少typeConfigstruct{mu sync.RWMutex datamap[string]string}func(c*Config)Get(keystring)string{c.mu.RLock()deferc.mu.RUnlock()returnc.data[key]}func(c*Config)Set(key,valstring){c.mu.Lock()deferc.mu.Unlock()c.data[key]val}// 3. Once 线程安全初始化varonce sync.Oncevardb*sql.DBfuncgetDB()*sql.DB{once.Do(func(){db,_sql.Open(postgres,...)})returndb}底层原理简述Runtime 视角组件底层实现调度器集成sync.MutexFutexLinux/ CRITICAL_SECTIONWin 自适应自旋 → 挂起runtime_SemacquireMutex→ G 进入_Gwaitingsync.RWMutex读写计数器 写等待队列写优先调度避免读饿死写sync/atomicCPU 原子指令LOCK XADD/CMPXCHG 内存屏障绕过调度器直接操作硬件无 G 状态切换sync.Pool每 P 本地缓存 全局共享链表GC 前自动清理降低堆压力 Go 1.8 引入Mutex 饥饿模式当等待队列超过 1ms新锁请求直接排队防止长尾延迟。现代 Go 锁性能已接近硬件极限。2.1 WaitGroup在代码中生硬的使用time.Sleep肯定是不合适的Go语言中可以使用sync.WaitGroup来实现并发任务的同步。sync.WaitGroup有以下几个方法方法名功能(wg * WaitGroup) Add(delta int)计数器delta(wg *WaitGroup) Done()计数器-1(wg *WaitGroup) Wait()阻塞直到计数器变为0sync.WaitGroup内部维护着一个计数器计数器的值可以增加和减少。例如当我们启动了N 个并发任务时就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完当计数器值为0时表示所有并发任务已经完成。我们利用sync.WaitGroup将上面的代码优化一下varwg sync.WaitGroupfunchello(){deferwg.Done()fmt.Println(Hello Goroutine!)}funcmain(){wg.Add(1)gohello()// 启动另外一个goroutine去执行hello函数fmt.Println(main goroutine done!)wg.Wait()}需要注意sync.WaitGroup是一个结构体传递的时候要传递指针。2.2 sync.Once说在前面的话这是一个进阶知识点。在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次例如只加载一次配置文件、只关闭一次通道等。Go语言中的sync包中提供了一个针对只执行一次场景的解决方案–sync.Once。sync.Once只有一个Do方法其签名如下func(o*Once)Do(ffunc()){}注意如果要执行的函数f需要传递参数就需要搭配闭包来使用。加载配置文件示例延迟一个开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量比如在init函数中完成初始化会增加程序的启动耗时而且有可能实际执行过程中这个变量没有用上那么这个初始化操作就不是必须要做的。我们来看一个例子variconsmap[string]image.ImagefuncloadIcons(){iconsmap[string]image.Image{left:loadIcon(left.png),up:loadIcon(up.png),right:loadIcon(right.png),down:loadIcon(down.png),}}// Icon 被多个goroutine调用时不是并发安全的funcIcon(namestring)image.Image{ificonsnil{loadIcons()}returnicons[name]}多个goroutine并发调用Icon函数时不是并发安全的现代的编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons函数可能会被重排为以下结果funcloadIcons(){iconsmake(map[string]image.Image)icons[left]loadIcon(left.png)icons[up]loadIcon(up.png)icons[right]loadIcon(right.png)icons[down]loadIcon(down.png)}在这种情况下就会出现即使判断了icons不是nil也不意味着变量初始化完成了。考虑到这种情况我们能想到的办法就是添加互斥锁保证初始化icons的时候不会被其他的goroutine操作但是这样做又会引发性能问题。使用sync.Once改造的示例代码如下variconsmap[string]image.ImagevarloadIconsOnce sync.OncefuncloadIcons(){iconsmap[string]image.Image{left:loadIcon(left.png),up:loadIcon(up.png),right:loadIcon(right.png),down:loadIcon(down.png),}}// Icon 是并发安全的funcIcon(namestring)image.Image{loadIconsOnce.Do(loadIcons)returnicons[name]}sync.Once其实内部包含一个互斥锁和一个布尔值互斥锁保证布尔值和数据的安全而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。2.3 MapGo语言中内置的map不是并发安全的。请看下面的示例varmmake(map[string]int)funcget(keystring)int{returnm[key]}funcset(keystring,valueint){m[key]value}funcmain(){wg:sync.WaitGroup{}fori:0;i20;i{wg.Add(1)gofunc(nint){key:strconv.Itoa(n)set(key,n)fmt.Printf(k:%v,v:%v\n,key,get(key))wg.Done()}(i)}wg.Wait()}上面的代码开启少量几个goroutine的时候可能没什么问题当并发多了之后执行上面的代码就会报fatal error: concurrent map writes错误。像这种场景下就需要为map加锁来保证并发的安全性了Go语言的sync包中提供了一个开箱即用的并发安全版map–sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。varmsync.Map{}funcmain(){wg:sync.WaitGroup{}fori:0;i20;i{wg.Add(1)gofunc(nint){key:strconv.Itoa(n)m.Store(key,n)value,_:m.Load(key)fmt.Printf(k:%v,v:%v\n,key,value)wg.Done()}(i)}wg.Wait()}2.4 Mutex互斥锁是一种常用的控制共享资源访问的方法它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。 使用互斥锁来修复上面代码的问题varxint64varwg sync.WaitGroupvarlock sync.Mutexfuncadd(){fori:0;i5000;i{lock.Lock()// 加锁xx1lock.Unlock()// 解锁}wg.Done()}funcmain(){wg.Add(2)goadd()goadd()wg.Wait()fmt.Println(x)}使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区其他的goroutine则在等待锁当互斥锁释放后等待的goroutine才可以获取锁进入临界区多个goroutine同时等待一个锁时唤醒的策略是随机的。2.5 RWMutex互斥锁是完全互斥的但是有很多实际的场景下是读多写少的当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。读写锁分为两种读锁和写锁。当一个goroutine获取读锁之后其他的goroutine如果是获取读锁会继续获得锁如果是获取写锁就会等待当一个goroutine获取写锁之后其他的goroutine无论是获取读锁还是写锁都会等待。读写锁示例var(xint64wg sync.WaitGroup lock sync.Mutex rwlock sync.RWMutex)funcwrite(){// lock.Lock() // 加互斥锁rwlock.Lock()// 加写锁xx1time.Sleep(10*time.Millisecond)// 假设读操作耗时10毫秒rwlock.Unlock()// 解写锁// lock.Unlock() // 解互斥锁wg.Done()}funcread(){// lock.Lock() // 加互斥锁rwlock.RLock()// 加读锁time.Sleep(time.Millisecond)// 假设读操作耗时1毫秒rwlock.RUnlock()// 解读锁// lock.Unlock() // 解互斥锁wg.Done()}funcmain(){start:time.Now()fori:0;i10;i{wg.Add(1)gowrite()}fori:0;i1000;i{wg.Add(1)goread()}wg.Wait()end:time.Now()fmt.Println(end.Sub(start))}需要注意的是读写锁非常适合读多写少的场景如果读和写的操作差别不大读写锁的优势就发挥不出来。3. 无锁原子操作背景代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全因为原子操作是Go语言提供的方法它在用户态就可以完成因此性能比加锁操作更好。Go语言中原子操作由内置的标准库sync/atomic提供。原子操作基于 CPU 指令如 x86 的LOCK CMPXCHG无需上下文切换、无锁竞争性能极高但仅适用于简单值的状态更新。 核心 APIGo 1.19 推荐使用类型化 API操作旧版函数新版类型推荐说明加法atomic.AddInt64(v, delta)var v atomic.Int64; v.Add(delta)计数器、统计指标读取atomic.LoadInt32(v)v.Load()无锁读最新值写入atomic.StoreInt64(v, val)v.Store(val)无锁写交换atomic.SwapPointer(p, new)p.Swap(new)返回旧值比较交换 (CAS)atomic.CompareAndSwapInt32(v, old, new)v.CompareAndSwap(old, new)锁自由算法基石我们填写一个示例来比较下互斥锁和原子操作的性能。varxint64varl sync.Mutexvarwg sync.WaitGroup// 普通版加函数funcadd(){// x x 1x// 等价于上面的操作wg.Done()}// 互斥锁版加函数funcmutexAdd(){l.Lock()xl.Unlock()wg.Done()}// 原子操作版加函数funcatomicAdd(){atomic.AddInt64(x,1)wg.Done()}funcmain(){start:time.Now()fori:0;i10000;i{wg.Add(1)// go add() // 普通版add函数 不是并发安全的// go mutexAdd() // 加锁版add函数 是并发安全的但是加锁性能开销大goatomicAdd()// 原子操作版add函数 是并发安全性能优于加锁版}wg.Wait()end:time.Now()fmt.Println(x)fmt.Println(end.Sub(start))}⚠️注意原子操作不保证复合操作的原子性。例如if v.Load() 0 { v.Store(1) }仍有竞争必须用CompareAndSwap。4. Channel vs 锁 vs 原子操作维度Channelsync.Mutex/RWMutexsync/atomic设计哲学CSP 通信模型临界区保护无锁状态更新适用数据消息流、任务、所有权转移复杂结构体、map/slice、多字段一致性整数、布尔、指针、简单标志位性能中等调度拷贝开销低~中锁竞争/上下文切换⭐ 极高CPU 指令级安全性最高类型安全、防死锁高依赖开发者规范中仅保简单操作复合逻辑易错推荐场景工作队列、事件流、超时控制缓存、配置、连接池、状态机计数器、开关、锁自由数据结构经验法则优先 Channel 传递数据/控制权必须共享状态时复杂结构 →Mutex读多写少 →RWMutex仅更新单个基础类型 →atomic永远用-race验证atomic包提供了底层的原子级内存操作对于同步算法的实现很有用。这些函数必须谨慎地保证正确使用。除了某些特殊的底层应用使用通道或者sync包的函数/类型实现同步更好。 致命陷阱与最佳实践陷阱现象正确姿势复制 Mutexmu : struct{ sync.Mutex }{}值拷贝导致锁失效✅ 永远传指针或作为结构体字段锁范围过大持有锁时调用网络/磁盘/Channel → 死锁或性能暴跌✅ 锁内只做内存操作尽早UnlockRWMutex 写饿死持续读请求阻塞写请求Go 1.8 已优化但仍需注意✅ 写频繁场景直接用Mutexsync.Map 滥用频繁增删键 → 性能反不如mapMutex✅ 仅用于“写入一次读取多次”或“键不相交”原子操作替代锁atomic.Add后读 map → 数据竞争仍在✅ 原子操作只管自身变量不保护关联数据忘记-race本地测试通过线上偶发崩溃✅ CI/CD 必加go test -race ./... 一句话总结并发安全不是“不共享”而是“明确同步”。用 Channel 通信用 Mutex 保护复杂状态用 atomic 优化简单计数器。始终-race验证锁内只做内存操作原子操作不跨变量。【★,°:.☆(▽)/$:.°★】那么本篇到此就结束啦如果有不懂 和 发现问题的小伙伴可以在评论区说出来哦同时我还会继续更新关于【GoLang】的内容请持续关注我