Go 语言原生支持 Goroutine 轻量级并发,相比 Java 线程占用资源极低、调度更高效。但多 Goroutine 同时操作共享资源时,必然出现竞态条件、数据竞争、结果错乱、死锁、协程阻塞等问题。Go 提供了一整套完整的并发同步与锁解决方案:互斥锁、读写锁、原子操作、同步等待组、条件变量、单次执行、Channel 通信、信号量等。本文覆盖 Go 并发同步所有核心知识点,包含竞态条件深度解析,每节统一采用:理论知识点 + 示例代码 + 注意事项 结构,适配 CSDN 排版,可直接复制发布。
一、并发基础:竞态条件、数据竞争与同步必要性
理论知识点
Goroutine 并发特性:轻量级协程,由 Go 运行时调度,成千上万个可同时运行,协程调度顺序随机不可控。
共享资源:多个 Goroutine 同时读写同一个全局变量、结构体字段、缓存资源,即为共享资源。
竞态条件(Race Condition)多 Goroutine 并发执行时,程序最终运行结果依赖于协程调度时序,执行顺序不同结果就不同,出现不可复现、随机错乱的隐性 Bug,这类问题统称为竞态条件。核心区分:
- 数据竞争一定属于竞态条件
- 竞态条件不一定是数据竞争:仅执行时序依赖、无共享变量读写,也会产生竞态条件。
数据竞争:是竞态条件最常见的一种表现形式;无同步保护时,多个协程同时读写共享变量,读取 - 修改 - 写入三步被打断,最终结果随机错误。
并发同步核心目标:消除竞态条件、保证原子操作、串行访问共享资源、控制协程执行顺序、避免数据竞争。
检测数据竞争:go run -race main.go可自动检测代码中数据竞争位置,间接排查大部分竞态隐患。
示例代码
package main import ( "fmt" "time" ) var count int func add() { for i := 0; i < 1000; i++ { count++ } } func main() { for i := 0; i < 10; i++ { go add() } time.Sleep(1 * time.Second) // 预期10000,实际每次结果都不一样,典型竞态条件+数据竞争 fmt.Println("count =", count) }注意事项
只有同时读 + 写才会产生数据竞争,只读无竞争,但仍可能存在时序型竞态条件。禁止用time.Sleep做并发同步,无法固定协程时序,不能消除竞态条件。上线项目必须杜绝数据竞争与竞态条件,会引发偶现 bug 极难排查。
二、sync.Mutex 互斥锁(独占锁)
理论知识点
原理:独占排他锁,同一时刻只能有一个 Goroutine 持有锁,其他协程阻塞等待,强制串行访问共享资源,从根源消除竞态条件和数据竞争。底层实现:基于 CAS 原子指令 + 信号量,无竞争时用户态直接加锁,有竞争协程休眠入等待队列。特性:不可重入、公平抢占、零值可用无需初始化。适用场景:读写混合、强一致性要求、普通共享变量保护,解决共享资源引发的竞态条件。
示例代码
package main import ( "fmt" "sync" ) var ( count int mu sync.Mutex wg sync.WaitGroup ) func add() { defer wg.Done() mu.Lock() defer mu.Unlock() for i := 0; i < 1000; i++ { count++ } } func main() { wg.Add(10) for i := 0; i < 10; i++ { go add() } wg.Wait() fmt.Println("count =", count) }注意事项
Lock () 和 Unlock () 必须成对出现,推荐 defer Unlock ()。不可重入:同一协程重复加锁直接死锁。锁粒度尽量最小化,锁内不要放 IO、网络请求等耗时逻辑。禁止复制 Mutex,锁是有状态的,必须传指针,否则无法保护共享资源、消除竞态。
三、sync.RWMutex 读写锁(读多写少优化锁)
理论知识点
原理:读写分离锁,遵循三大规则:读与读:共享并发,多个协程可同时加读锁读与写:互斥,写阻塞读、读阻塞写写与写:互斥,同一时刻只能一个写写锁优先:有写协程等待时,新读请求会阻塞,避免写饥饿。作用:在读多写少场景下,兼顾并发性能与数据一致性,高效消除竞态条件。适用场景:读多写少,配置中心、本地缓存、字典查询。
示例代码
package main import ( "fmt" "sync" "time" ) var ( data int rwMu sync.RWMutex wg sync.WaitGroup ) // 读 func read(idx int) { defer wg.Done() rwMu.RLock() defer rwMu.RUnlock() fmt.Printf("协程%d 读取数据:%d\n", idx, data) time.Sleep(100 * time.Millisecond) } // 写 func write() { defer wg.Done() rwMu.Lock() defer rwMu.Unlock() data++ fmt.Println("写入数据:", data) } func main() { wg.Add(3) for i := 0; i < 3; i++ { go write() } wg.Add(5) for i := 0; i < 5; i++ { go read(i) } wg.Wait() }注意事项
读锁用 RLock/RUnlock,写锁用 Lock/Unlock,不可混用。同样不支持重入,重复加锁会死锁。写频繁场景没必要用读写锁,性能和 Mutex 一致,无法带来额外收益。
四、sync/atomic 原子操作(无锁并发)
理论知识点
原理:基于 CPU 硬件原子指令,无锁、无阻塞、用户态直接完成,性能远超锁;将读 - 改 - 写封装为不可分割操作,彻底解决简单数值运算的竞态条件。支持操作:增减、赋值、加载、比较交换 CAS。支持类型:int32、int64、uint32、uint64、uintptr、指针。适用场景:简单计数器、状态标记、自增 ID。
示例代码
package main import ( "fmt" "sync" "sync/atomic" ) var count int64 var wg sync.WaitGroup func add() { defer wg.Done() atomic.AddInt64(&count, 1) } func main() { wg.Add(1000) for i := 0; i < 1000; i++ { go add() } wg.Wait() fmt.Println("原子计数:", atomic.LoadInt64(&count)) }注意事项
仅能操作基础数值类型,复杂业务逻辑无法替代锁解决竞态。必须使用 atomic 包方法读写变量,禁止直接普通读写,否则重新引入竞态条件。32 位系统下操作 int64 要保证内存 8 字节对齐,否则 panic。
五、sync.WaitGroup 等待组(协程批量等待)
理论知识点
原理:计数器机制Add (n):增加计数Done ():计数减 1Wait ():阻塞直到计数为 0底层基于原子操作 + 信号量实现,轻量高效。作用:主线程等待所有子 Goroutine 执行完毕,替代 Sleep,解决协程执行时序不一致引发的时序型竞态条件。
示例代码
package main import ( "fmt" "sync" ) func task(id int, wg *sync.WaitGroup) { defer wg.Done() fmt.Printf("任务 %d 执行完成\n", id) } func main() { var wg sync.WaitGroup wg.Add(5) for i := 1; i <= 5; i++ { go task(i, &wg) } wg.Wait() fmt.Println("所有任务执行完毕") }注意事项
Add 必须在启动协程之前调用。必须传指针,值传递会拷贝副本,计数不共享,引发时序竞态。Done 次数必须和 Add 匹配,否则永久阻塞。WaitGroup 不支持复用,一轮结束建议重新声明。
六、sync.Once 单次执行
理论知识点
原理:保证全局只执行一次函数,底层用互斥锁 + 标志位双重检查。作用:解决多协程同时初始化引发的初始化竞态条件。适用场景:全局初始化、单例模式、配置加载、连接池初始化。无论多少协程同时调用,有且仅有一次执行。
示例代码
package main import ( "fmt" "sync" ) var once sync.Once func initFunc() { fmt.Println("全局初始化,只执行一次") } func main() { var wg sync.WaitGroup wg.Add(10) for i := 0; i < 10; i++ { go func() { defer wg.Done() once.Do(initFunc) }() } wg.Wait() }注意事项
Once.Do 传入函数无参无返回,传参用闭包。若执行函数 panic,Once 依然标记为已执行,不会重试。一个 Once 实例只能控制一个逻辑单次执行。
七、sync.Cond 条件变量(等待 / 唤醒机制)
理论知识点
原理:搭配 Mutex 使用,实现协程等待条件、满足后唤醒。作用:解决条件不满足时提前执行的条件型竞态条件。核心方法:Wait ():释放锁并阻塞等待,被唤醒后重新抢锁Signal ():唤醒一个等待协程Broadcast ():唤醒所有等待协程解决问题:替代 for 循环轮询,降低 CPU 空转。适用:生产者消费者、任务队列、资源就绪等待。
示例代码
package main import ( "fmt" "sync" "time" ) func main() { var mu sync.Mutex cond := sync.NewCond(&mu) ready := false // 消费者 go func() { mu.Lock() for !ready { cond.Wait() } fmt.Println("条件满足,开始执行") mu.Unlock() }() time.Sleep(1 * time.Second) mu.Lock() ready = true cond.Signal() mu.Unlock() time.Sleep(500 * time.Millisecond) }注意事项
Wait 必须在加锁后调用,否则 panic。必须用 for 循环判断条件,防止虚假唤醒,避免隐性竞态。Signal 唤醒单个,Broadcast 唤醒全部,按需选择。
八、Channel 通道(Go 推荐并发通信方式)
理论知识点
设计哲学:不要共享内存来通信,要通过通信共享内存。原理:Channel 是队列结构,自带并发安全,底层维护发送 / 接收等待队列。分类:无缓冲 Channel:收发同步阻塞有缓冲 Channel:异步缓冲,满了才阻塞作用:协程通信、同步控制、限流、任务分发、替代锁做同步;从设计层面规避共享资源,彻底杜绝竞态条件与数据竞争。
示例代码
package main import "fmt" func worker(ch chan<- int) { ch <- 100 } func main() { ch := make(chan int) go worker(ch) val := <-ch fmt.Println("接收值:", val) close(ch) }注意事项
关闭通道后不能再发送,否则 panic。可从已关闭通道接收零值,不会阻塞。尽量用 Channel 做协程通信,少用共享变量 + 锁,从源头减少竞态条件。
九、sync.Semaphore 信号量(限流控制)
理论知识点
原理:控制同时并发的协程数量,实现限流、资源池控制。本质:基于计数信号量,P 操作申请资源、V 操作释放资源。作用:限制并发协程数,避免高并发争抢资源引发的竞态条件。适用:接口限流、爬虫并发限制、连接池最大连接数。
示例代码
package main import ( "fmt" "sync" "time" ) func main() { // 最大并发3 sem := make(chan struct{}, 3) var wg sync.WaitGroup for i := 1; i <= 10; i++ { wg.Add(1) sem <- struct{}{} go func(idx int) { defer wg.Done() defer func() { <-sem }() fmt.Printf("协程%d 执行业务\n", idx) time.Sleep(500 * time.Millisecond) }(i) } wg.Wait() }注意事项
有缓冲 Channel 是 Go 最常用的简易信号量实现。必须保证每个申请都有释放,否则阻塞泄漏、引发并发异常。
十、并发锁机制选型总结
简单计数器 / 状态标记 → 优先 atomic 原子操作普通共享变量、读写混合 → sync.Mutex 互斥锁读多写少、缓存配置 → sync.RWMutex 读写锁等待一组协程完成 → sync.WaitGroup全局一次初始化 / 单例 → sync.Once条件等待、生产者消费者 → sync.Cond协程通信、解耦同步 → Channel并发限流、控制最大协程数 → 有缓冲 Channel 信号量
十一、并发编程通用避坑准则
锁粒度尽量小,锁内不做耗时操作。绝不复制 Mutex、RWMutex 等同步原语。优先 Channel 通信,其次原子操作,最后才用锁。禁止滥用全局共享变量,尽量通过 Channel 传递数据。开发时开启 go run -race 检测数据竞争,排查潜在竞态条件。警惕接口 nil、锁重入、循环等待引发死锁。
十二、Go 并发同步高频面试题(必背)
什么是竞态条件?和数据竞争的区别?答:竞态条件是多协程因调度时序不确定,导致程序结果依赖执行顺序、随机出错;数据竞争是多个协程无同步保护同时读写共享变量。关系:数据竞争是竞态条件的子集,竞态条件不一定是数据竞争。
什么是数据竞争?怎么检测和解决?答:多个 goroutine 同时读写同一个共享变量,且无同步保护,造成结果错乱。检测:go run -race main.go 解决:加互斥锁、读写锁、原子操作、通过 Channel 通信避免共享变量。
Mutex 和 RWMutex 区别?适用场景?答:Mutex:读写都互斥,同一时刻只能一个协程持有锁;适合读写均衡、强一致性场景。RWMutex:读读共享、读写互斥、写写互斥;适合读多写少场景,并发读性能更高。
互斥锁能不能重入?为什么?答:Go 的 sync.Mutex 不支持重入。同一 goroutine 重复加锁会直接阻塞,造成死锁,需要自己控制加锁逻辑。
atomic 原子操作和锁哪个性能好?答:atomic 基于 CPU 硬件指令,无锁无阻塞,性能远高于 Mutex;但只支持简单数值运算,复杂逻辑只能用锁。
WaitGroup 为什么必须传指针?答:WaitGroup 是结构体,值传递会发生拷贝,每个协程拿到的是副本,计数器不共享,导致逻辑失效、永久阻塞。
sync.Once 底层原理?应用场景?答:底层互斥锁 + 标记位,双重检查保证函数全局只执行一次;常用于单例模式、配置初始化、连接池初始化。
sync.Cond 作用?为什么要用 for 循环判断条件?答:Cond 用于协程条件等待与唤醒,替代轮询空转。必须用 for 循环:防止操作系统虚假唤醒,被意外唤醒后重新校验条件。
Go 为什么推荐 Channel 而不是共享变量加锁?答:Go 设计理念:通过通信共享内存,而不是共享内存通信。Channel 天然并发安全、解耦性强、逻辑更清晰,减少手动加锁带来的死锁和竞态条件复杂度。
无缓冲通道和有缓冲通道区别?答:无缓冲:发送和接收必须同时准备好,同步阻塞。有缓冲:缓冲区未满可异步发送,满了才阻塞;可做限流、队列缓冲。
Go 死锁产生的常见原因有哪些?答:互斥锁重入多个协程互相持有对方需要的锁Channel 收发互相等待无匹配WaitGroup Add 和 Done 数量不匹配Cond Wait 未加锁直接调用
十三、知识思维导图(文字版)
plaintext
Go并发同步与锁机制 ├── 并发基础 │ ├── Goroutine轻协程 │ ├── 共享资源 │ ├── 竞态条件 │ └── 数据竞争及检测 ├── 锁机制 │ ├── sync.Mutex 互斥锁 │ └── sync.RWMutex 读写锁 ├── 无锁同步 │ └── atomic 原子操作 ├── 同步工具 │ ├── sync.WaitGroup 等待组 │ ├── sync.Once 单次执行 │ └── sync.Cond 条件变量 ├── 通信同步 │ └── Channel 通道(无缓冲/有缓冲) ├── 高级应用 │ └── Channel实现信号量限流 ├── 选型原则 ├── 避坑规范 └── 高频面试题版权声明:本文为原创 Go 进阶技术文章,CSDN 首发,禁止未经授权转载、抄袭与搬运,侵权必究!