news 2026/10/4 2:59:19

Go 并发:sync 包锁机制通俗拆解,新手再也不搞混互斥锁、读写锁

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Go 并发:sync 包锁机制通俗拆解,新手再也不搞混互斥锁、读写锁

1. 前言

Goroutine 是 Go 语言最亮眼的特性,轻量协程让并发代码写起来很方便。但很多新手写多协程代码时,会遇到一个诡异问题:明明逻辑看着没问题,多次运行结果却不一样。

这就是竞态条件,而sync包里的锁,就是用来解决这个问题的。

网上不少讲解 sync.Mutex 和 RWMutex 的文章,一上来就切入底层状态机、源码实现,刚学 Go 的同学很容易看得头大。

这篇文章,我们不从底层源码开始,而是从问题出发,用极简的代码例子搞懂两件事:

  1. 互斥锁 Mutex 到底做了什么,怎么正确使用,会踩哪些坑
  2. 读写锁 RWMutex 和普通互斥锁的区别,什么时候才该选用读写锁

读完之后,你能分清两种锁,并且知道在业务代码里怎么选,避开新手高频 bug。

前置基础:知道什么是 goroutine,简单了解并发、竞态条件。

2. 先搞懂:为什么需要锁?竞态条件是什么

先看一个计数器示例:多个 goroutine 同时修改同一个变量,结果和预期不一致。

package main import ( "fmt" "time" ) func main() { cnt := 0 for i := 0; i < 1000; i++ { go func() { cnt++ }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }

多次运行,你会发现输出不是固定 1000,每次数值不一样。

原因:cnt++不是原子操作,会拆成「读取值、+1、写回」三步,多个协程同时执行,就会互相覆盖,出现数据丢失。这就是竞态条件。想要修复,就要加锁。

一句话总结:多个协程同时读写同一份共享数据,就会产生竞态,锁就是用来保护共享资源的工具。

3. sync.Mutex 互斥锁:最简单的排他锁

核心特性:同一时间,只能有一个 goroutine 拿到锁,不管读还是写,拿到锁才能操作共享变量,操作完解锁,其他协程才有机会获取锁。

两个核心方法:

  • mu.Lock():尝试获取锁,如果锁已经被别人拿着,当前 goroutine 阻塞等待
  • mu.Unlock():释放锁,其他等待的协程可以竞争获取

最佳实践:用defer mu.Unlock(),保证即使临界区代码 panic,锁也一定会释放,避免死锁。

下面用 Mutex 修复上面的计数器,完整可运行:

package main import ( "fmt" "sync" "time" ) func main() { var mu sync.Mutex cnt := 0 for i := 0; i < 1000; i++ { go func() { mu.Lock() cnt++ mu.Unlock() }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }

多次运行,输出稳定等于1000,竞态问题消失。

关键要点:

  • Unlock 必须执行,推荐defer mu.Unlock()。
  • 禁止锁复制!把 Mutex 当成值拷贝,会复制一把全新未上锁的锁,防护直接失效。
  • 常见坑:忘记解锁(死锁)、重复解锁(panic)。

结构体里带 Mutex,传参一律用指针。

❌ 错误示例:

type Counter struct { mu sync.Mutex cnt int } // 传值,锁被拷贝,防护失效 func (c Counter) Add() { c.mu.Lock() defer c.mu.Unlock() c.cnt++ }
  1. 不要在Lock()之前写defer mu.Unlock()
// ❌ 错误!还没拿到锁,就注册了解锁,运行直接panic defer mu.Unlock() mu.Lock()

defer 的执行逻辑:

defer的作用:注册一个函数,等到当前函数退出的时候才执行,但注册动作是立刻发生的。

代码执行顺序:

  1. 执行defer mu.Unlock()→立刻注册:函数结束时,调用mu.Unlock()
  2. 然后执行mu.Lock(),尝试获取锁(有可能阻塞,拿不到锁)

重复 Unlock 会直接 panic;同一个 goroutine 重复 Lock 直接死锁。

4. sync.RWMutex 读写锁:读多写少场景优化

核心规则

sync.RWMutex读写锁,把操作分成读锁 RLock ()/RUnlock ()和写锁 Lock ()/Unlock ()规则记住这 4 条就够:

  1. ✅读读并行:多个 goroutine 可以同时拿到读锁,一起读数据,提升并发读性能
  2. ❌读写互斥:有协程持有读锁,写锁需要等待;有写锁,读锁要等待
  3. ❌写写互斥:同一时间只能一个协程拿写锁
  4. 写锁优先级较高:申请写锁到来后,后续新来的读锁会被阻塞,防止写饥饿

适用场景:读多写少,比如本地缓存、配置信息,大量查询,修改操作很少。

下面用 RWMutex 实现一个简单的配置缓存,对比 Mutex 看读多场景下的性能优势:

package main import ( "fmt" "sync" ) type Config struct { mu sync.RWMutex data map[string]string } func (c *Config) Get(key string) string { c.mu.RLock() defer c.mu.RUnlock() return c.data[key] } func (c *Config) Set(key, value string) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = value } func main() { cfg := &Config{data: make(map[string]string)} cfg.Set("theme", "dark") fmt.Println("theme =", cfg.Get("theme")) }

误区:不是所有场景都用 RWMutex!写频繁场景,RWMutex 开销反而大于普通 Mutex。

RWMutex 常见误区

  1. ❌ 不是万能加速!写频繁场景不要上 RWMutex。读写锁内部逻辑更复杂,写多的时候开销比普通 Mutex 更大,性能反而变差。
  2. ❌ 读锁只是保护读,读的时候如果要修改数据,必须上写锁。
  3. 配套成对使用:RLock()必须搭配RUnlock(),不能混用Lock/Unlock。

5. Mutex vs RWMutex 快速对比表

锁规则适用场景
sync.Mutex读写互斥,同一时间仅一个协程访问读写频率差不多,或者写操作频繁
sync.RWMutex读读并行,读写互斥、写写互斥读多写少

6. 高频踩坑合集(重点,博客收藏点)

建议:锁保护范围尽量缩小,只包裹读写共享变量的那一小段临界区代码。

  1. defer Unlock 顺序错误:必须先 Lock,再注册 defer 解锁,顺序颠倒直接 panic
  2. 锁拷贝:结构体包含锁,方法接收者不用指针,锁拷贝导致并发保护失效
  3. 死锁场景:同一个 goroutine 重复 Lock;协程 A 持有锁等待 B,协程 B 持有锁等待 A,循环等待
  4. RWMutex 滥用:写操作并不少,强行换成读写锁,增加额外开销,收益很小
  5. 锁粒度太大:一把大锁包裹大量无关业务逻辑,并发直接退化成串行,失去 goroutine 并发优势

7. 什么时候尽量不要用锁?

Go 设计理念:不要通过共享内存通信;通过通信共享内存。

简单说:优先用 channel 做协程之间消息传递,而不是直接共享变量加锁。

举例子:简单计数器可以用 channel 来实现,完全规避竞态。

package main import ( "fmt" ) func main() { ch := make(chan int, 1) done := make(chan struct{}) go func() { cnt := 0 for i := 0; i < 1000; i++ { cnt++ } ch <- cnt close(done) }() <-done fmt.Println("cnt =", <-ch) }

思路:channel 作为唯一通道,所有协程只能通过 channel 来读写计数,天然串行化访问共享数据,消除竞态。

当然 channel 也有适用边界,复杂状态管理场景,锁依然是更合适的选择。

锁是兜底方案,不是并发首选方案。

8. 小结

  • sync.Mutex:排他互斥锁,简单可靠,是并发保护的通用首选。读写差不多、写频繁优先选它。

  • sync.RWMutex:读写锁,仅读多写少场景才有性能收益,不要盲目使用。

  • 锁的核心目的:保护共享变量,消除竞态条件。

  • 编码准则:锁粒度越小越好,用完尽快释放;永远不要拷贝锁。

  • Go 并发哲学:优先 channel,锁作为兜底方案。

9. 附录:完整源码汇总(可直接复制运行)

下面是本文所有示例代码的汇总,方便读者直接复制运行、修改测试。

9.1 无锁计数器(错误示例)

package main import ( "fmt" "time" ) func main() { cnt := 0 for i := 0; i < 1000; i++ { go func() { cnt++ }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }

9.2 Mutex 修复计数器

package main import ( "fmt" "sync" "time" ) func main() { var mu sync.Mutex cnt := 0 for i := 0; i < 1000; i++ { go func() { mu.Lock() cnt++ mu.Unlock() }() } time.Sleep(100 * time.Millisecond) fmt.Println("cnt =", cnt) }

9.3 RWMutex 配置缓存

package main import ( "fmt" "sync" ) type Config struct { mu sync.RWMutex data map[string]string } func (c *Config) Get(key string) string { c.mu.RLock() defer c.mu.RUnlock() return c.data[key] } func (c *Config) Set(key, value string) { c.mu.Lock() defer c.mu.Unlock() c.data[key] = value } func main() { cfg := &Config{data: make(map[string]string)} cfg.Set("theme", "dark") fmt.Println("theme =", cfg.Get("theme")) }

9.4 Channel 实现计数器(无锁方案)

package main import ( "fmt" ) func main() { ch := make(chan int, 1) done := make(chan struct{}) go func() { cnt := 0 for i := 0; i < 1000; i++ { cnt++ } ch <- cnt close(done) }() <-done fmt.Println("cnt =", <-ch) }

你写 Go 并发时踩过锁的坑吗?死锁、锁复制、defer 顺序错误这类问题都可以在评论聊聊。收藏本文,可在评论区留下疑惑,将为你更新 sync 包疑惑的通俗讲解。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 2:55:08

蘑菇采摘机器人视觉落地实战:从图像采集到采摘点精确定位

简介&#xff1a;本资源是一篇发表于1995年《农业工程学报》的学术论文&#xff0c;聚焦计算机视觉技术在蘑菇采摘机器人中的工程化应用&#xff0c;面向农业自动化、智能装备、图像处理方向的高校师生、科研人员及农业机器人开发者。文章系统阐述了基于图像分析的蘑菇识别与定…

作者头像 李华
网站建设 2026/10/4 2:54:41

WinDbg中_DEVICE_NODE资源列表三字段怎么选?

干过内核调试的人&#xff0c;应该对 WinDbg 里dt nt!_DEVICE_NODE的输出不陌生。一长串结构体中&#xff0c;ResourceList、ResourceListTranslated、BootResources这三兄弟经常并列出现&#xff0c;而且经常长得完全不一样。很多朋友问我&#xff1a;“这三个到底啥区别&…

作者头像 李华
网站建设 2026/10/4 2:54:08

HTML5音频视频开发实战:从编码兼容到自动播放与控制条

做前端这些年&#xff0c;我见过不少朋友一上来就在HTML里写<video src"xxx.mp4" controls>&#xff0c;浏览器转两圈没反应&#xff0c;然后满屏找设置按钮。坦白说&#xff0c;HTML 音频/视频这块&#xff0c;记住标签叫audio和video只是入门&#xff0c;真正…

作者头像 李华
网站建设 2026/10/4 2:48:22

Windows下Claude Code落地:社区教程整理与避坑

来源说明 本文内容主要整理自一篇掘金社区教程&#xff08;作者个人经验&#xff09;&#xff0c;并非官方文档。文中涉及的安装方式、默认路径、接口地址、配置键名、状态词含义等&#xff0c;均属于该社区教程的说法&#xff0c;未经官方文档核验。实际落地时请以 Anthropic …

作者头像 李华
网站建设 2026/10/4 2:48:20

Coding Agent为何抛弃纯Chat?拆解Claude Code与Hermes的工程闭环逻辑

如果你跟着网页版ChatGPT或Claude写过一次像样的项目&#xff0c;大概率经历过这个循环&#xff1a;在对话框里描述需求&#xff0c;AI给出一段代码&#xff0c;你复制进编辑器&#xff0c;运行&#xff0c;报错&#xff0c;再粘贴回对话框&#xff0c;它又改一处&#xff0c;你…

作者头像 李华
网站建设 2026/10/4 2:47:51

MRAM实战:PIC32MZ驱动MR25H40CDF实现工业数据可靠存储

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华