前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >这不会又是一个Go的BUG吧?

这不会又是一个Go的BUG吧?

原创
作者头像
龟仙老人
发布于 2022-06-22 02:02:02
发布于 2022-06-22 02:02:02
7212
举报
文章被收录于专栏:捉虫大师捉虫大师

hello,大家好呀,我是小楼。

最近我又双叒叕写了个BUG,一个线上服务死锁了,不过幸亏是个新服务,没有什么大影响。

出问题的是Go的读写锁,如果你是写Java的,不必划走,更要看看本文,本文的重点在于Java和Go的读写锁对比,甚至看完后你会有一个隐隐的感觉:Go的读写锁是不是有BUG?

故障回放

背景简单抽象一下:一个server服务(Go语言实现),提供了一个http接口,另有一个client服务来调用这个接口,整体架构非常简单,甚至都不用画架构图你也能够理解。

这两个服务上线运行了一段时间都没什么问题,突然有一天client调用这个server的接口全都超时了。

碰到这种问题,第一时间去查看日志和监控,client端全是超时日志,server端日志没有异常,甚至连请求的监控都没有上报,仿佛client端的请求没有到达server端一样。

于是去server服务器上手动请求了一下接口,结果卡主不动,这下排除了client,一定是server端出了问题。

这种卡死的问题其实很好查,直接用pprof看协程卡在哪里基本就能得出结论(和Java的jstack类似的工具),但这个服务没有开启pprof,只能改了代码打开pprof重新发布,等待下次问题复现。

好在运气不错,2天后问题就出来了,用pprof看下程序卡在了哪里:

L1.png
L1.png

原来卡在了一个判断集群或服务是否是小流量的地方,该接口会接受一个集群名或服务名的参数,然后判断该集群或服务是否是小流量集群,进而做一系列事,至于做了啥不重要。小流量集群是配置在配置中心中。

我把这段代码摘出来(图中是走的判断集群分支,下面代码以更简单的服务分支讲解,底层一致)。为了避免空洞,这里我先简单讲解一下程序的逻辑:

  • 首先小流量的配置定义了一个读写锁(sync.RWMutex),以及在内存中保持了哪些服务需要灰度的规则(scopesMap)
L2.png
L2.png
  • 配置变更时调用reset刷新这个scopesMap,用写锁,后续逻辑省略
L3.png
L3.png
  • 判断是否为灰度服务,先加读锁看看规则是否存在:
L4.png
L4.png
  • 再加锁判断服务是否命中规则:
L5.png
L5.png

这样圈出重点,你可能一眼就看出问题了,读锁加了两次,第二次没有必要,属于手误了。确实,删除第二个加读锁的代码就没问题了。如果事情到这就结束了,那这篇文章也没有必要写了,下面我们分析下为什么会死锁。

为什么会死锁

看到这个结果,我第一反应是Go的锁的重入性问题。

熟悉Java的同学对锁的重入并不陌生,以防有读者不明白锁的重入性,我用一句话来概括:

可重入锁就是可以重复进入的锁,也叫递归锁

Java中有一个ReentrantLock,比如这样,重复加锁是没有问题的:

L6.png
L6.png

但Go里面的锁是不可重入的:

L7.png
L7.png

这个坑我也踩过,这是Go的实现问题。只要你愿意,用Java也能实现不可重入锁,但Java中大多数使用的还是可重入锁,因为用起来比较方便。

至于Go为什么不实现一个可重入的锁,可以参考煎鱼大佬的这篇文章《Go 为什么不支持可重入锁?》 ,其原因总结起来就是Go的设计者觉得重入锁是个不好的设计,所以没有采纳。不过我觉得这篇文章的评论更精彩:

L8.png
L8.png

说到这,你可能会说,上面出问题的明明是读写锁(sync.RWMutex),读写锁的特点是什么?

  • 读与读之间不互斥
  • 读与写、写与写之间互斥

既然读锁之间是不互斥,也就是可加两次读锁,那么读锁必然是可重入的。我们写个demo测试下:

L9.png
L9.png

果然如我们所想,顺便看一下加读锁的逻辑:

L10.png
L10.png

看我框出的代码,如果有写锁在等待,读锁需要等写锁!

L11.png
L11.png

这是什么逻辑?

如果一个协程已经拿到了读锁,另一个协程尝试加写锁,这时应该加不了,没什么问题。如果这个读锁的协程再去拿读锁,需要等写锁,这就死锁了啊!

为了验证,我构造了一个demo:

L12.png
L12.png

这段代码按①、②、③顺序执行,第②段写锁需要等第①个读锁释放,第③段读锁需要等第②段写锁释放,最终就是一个死锁的逻辑。

仔细想,这里面最有争议的要属已经拿到读锁再次进入读锁需要等写锁这个逻辑。

Java中是这样的吗?写个demo试试:

L13.png
L13.png

Java一点事都没有,这是为啥?遇事不决,看源码!但Java的源码太长,又不是本文重点,所以就只说几点重要的结论:

  1. Java的ReentrantReadWriteLock支持锁降级,但不能升级,即获取了写锁的线程,可以继续获取读锁,但获取读锁的线程无法再获取写锁;
  2. ReentrantReadWriteLock实现了公平和非公平两种锁,公平锁的情况下,获取读锁、写锁前需要看同步队列中是否先线程在我之前排队;非公平锁的情况下:写锁可以直接抢占锁,但是读锁获取有一个让步条件,如果当前同步队列head.next是一个写锁在等待,并且自己不是重入的,就要让步等待。

在Java的实现下,如果一个线程持有了读锁,写锁自然是需要等待的,但是持有读锁的线程也可以再次重入该读锁。

我们发现Java和Go的读写锁实现不一致,这个不一致也就是导致我们写出BUG的原因。

这合理吗

抛开实现,我们思考一下这样合理吗?

  • 一个协程(或线程)已经获取到了读锁,别的协程(线程)获取写锁时必然需要等待读锁的释放
  • 既然这个协程(或线程)已经拥有了这个读锁,那么为什么再次获取读锁时需要管别的写锁是否等待呢?

可以想象病人排队看医生,前面一个病人向医生问诊,进去后把门关上,在里面无论问多长时间(理论上)是他的权利,后面的病人在他没出来前是不能打开门的。

但Go的实现却是,前一个病人每问完一句话得看一眼门外是否有人在等,如果有人在等,那他就要等门外的人问完才能问,但门外的人又在等他问,所以大家死锁了,谁都别想看完病。

是不是细思下来,感觉这是不是Go的一个BUG?

Go为什么这么实现

我尝试去github上搜索了一下,发现了这个issue:

https://github.com/golang/go/issues/30657

从标题就能看出他遇到了和我一样的问题:

Read-locking shouldn't hang if thread has already a write-lock? #30657

看看里面有人是怎么回答的:

L14.png
L14.png

这位大佬说,这不符合Go锁的原理,Go的锁是不知道协程或者线程信息的,只知道代码调用先后顺序,即读写锁无法升级或降级。

Java中的锁记录了持有者(线程id),但Go的锁是不知道持有者是谁,所以获取了读锁之后再次获取读锁,这里的逻辑是区分不了是持有者还是其他的协程,所以就统一处理。

这点其实在Go源码的注释中体现了,我也是后来才注意到:

L15.png
L15.png

翻译一下是:

如果一个协程持有读锁,另一个协程可能会调用Lock加写锁,那么再也没有一个协程可以获得读锁,直到前一个读锁释放,这是为了禁止读锁递归。也确保了锁最终可用,一个阻塞的写锁调用会将新的读锁排除在外。

不过这个警示实在是太不起眼了,大概就是这个效果:

L16.png
L16.png

这一幕像极了产品和程序员:

  • 产品经理:我要实现这个功能,怎么实现我不管
  • Go:这破坏了我的设计原则,不接受这个功能
  • 产品经理:大家都退一步,你换个代价小的方法解决吧

于是,程序员在读写锁上写下了一段注释:

L17.png
L17.png

最后

这个死锁的坑确实很容易踩,尤其是Java程序员来写Go,所以我们写Go代码时还是得写得更Go一点才行。

Go的设计者比较「偏执」,认为「不好」的设计坚决不去实现,就如锁的实现不应该依赖线程、协程信息;可重入(递归)锁是一种不好的设计。所以这种看似有BUG的设计,也存在一定的道理

当然每个人都有自己的想法,你觉得Go的读写锁这样实现合理吗?

如果你看完觉得有点收获,给个在看吧,你的支持是我持续创作的动力~

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
2 条评论
热度
最新
666
666
回复回复点赞举报
不错
不错
回复回复点赞举报
推荐阅读
编辑精选文章
换一批
并发编程的奥秘:探索锁机制的多样性与应用
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
小皮侠
2024/10/18
1250
并发编程的奥秘:探索锁机制的多样性与应用
史上最全 Java 中各种锁的介绍
在计算机科学中,锁(lock)或互斥(mutex)是一种同步机制,用于在有许多执行线程的环境中强制对资源的访问限制。锁旨在强制实施互斥排他、并发控制策略。 锁通常需要硬件支持才能有效实施。这种支持通常采取一个或多个原子指令的形式,如"test-and-set", "fetch-and-add" or "compare-and-swap"”。这些指令允许单个进程测试锁是否空闲,如果空闲,则通过单个原子操作获取锁。
java金融
2020/08/04
3780
并发编程-19AQS同步组件之重入锁ReentrantLock、 读写锁ReentrantReadWriteLock、Condition
重入锁ReentrantLock,顾名思义,就是支持重进入的锁,它表示该锁能够支持一个线程对 资源的重复加锁,而不会造成自己阻塞自己。
小小工匠
2021/08/17
2850
【JavaEE初阶】多线程重点知识以及常考的面试题-多线程进阶(一)
悲观锁: 总是以最坏的情况考虑, 每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样一来如果其他线程想拿到这个数据就会阻塞等待直到拿到锁为止. 乐观锁: 假设数据⼀般情况下不会产生并发冲突,所以在数据进行提交更新的时候,才会正式对数据是否产生并发冲突进行检测,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。
用户11369350
2025/04/15
1170
【JavaEE初阶】多线程重点知识以及常考的面试题-多线程进阶(一)
聊聊 Java 的几把 JVM 级锁
在计算机行业有一个定律叫"摩尔定律",在此定律下,计算机的性能突飞猛进,而且价格也随之越来越便宜, CPU 从单核到了多核,缓存性能也得到了很大提升,尤其是多核 CPU 技术的到来,计算机同一时刻可以处理多个任务。在硬件层面的发展带来的效率极大提升中,软件层面的多线程编程已经成为必然趋势,然而多线程编程就会引入数据安全性问题,有矛必有盾,于是发明了“锁”来解决线程安全问题。在这篇文章中,总结了 Java 中几把经典的 JVM 级别的锁。
用户1516716
2020/02/20
1.2K0
聊聊 Java 的几把 JVM 级锁
【JavaEE初阶】多线程进阶(五)常见锁策略 CAS synchronized优化原理
互斥锁:一个线程加锁了,另一个线程尝试加锁时,就会阻塞等待。(例如synchronized,提供了加锁和解锁的操作。) 读写锁:提供了三种操作
xxxflower
2023/05/10
1750
【JavaEE初阶】多线程进阶(五)常见锁策略 CAS synchronized优化原理
【多线程】乐观/悲观锁、重量级/轻量级锁、挂起等待/自旋锁、公平/非公锁、可重入/不可重入锁、读写锁
synchronized:只是市面上五花八门的锁种,其中一种典型的实现,Java 内置的,推荐使用的锁
椰椰椰耶
2024/09/20
860
【多线程】乐观/悲观锁、重量级/轻量级锁、挂起等待/自旋锁、公平/非公锁、可重入/不可重入锁、读写锁
Java中15种锁的介绍
在读很多并发文章中,会提及各种各样锁如公平锁,乐观锁等等,这篇文章介绍各种锁的分类。介绍的内容如下:
Java团长
2019/04/25
3910
图解Java中那18 把锁
举个生活中的例子,假设厕所只有一个坑位了,悲观锁上厕所会第一时间把门反锁上,这样其他人上厕所只能在门外等候,这种状态就是「阻塞」了。
肉眼品世界
2021/09/27
2500
图解Java中那18 把锁
【多线程】多线程进阶 & JUC
synchronized锁在初始情况下是乐观锁,预估接下来出现锁冲突的概率不大,同时会统计锁冲突的次数,达到一定程度之后就会转化为悲观锁
2的n次方
2024/10/15
1210
【多线程】多线程进阶 & JUC
死磕Java并发:J.U.C之读写锁:ReentrantReadWriteLock
重入锁ReentrantLock是排他锁,排他锁在同一时刻仅有一个线程可以进行访问,但是在大多数场景下,大部分时间都是提供读服务,而写服务占有的时间较少。然而读服务不存在数据竞争问题,如果一个线程在读时禁止其他线程读势必会导致性能降低。所以就提供了读写锁。
程序猿DD
2018/07/31
2610
死磕Java并发:J.U.C之读写锁:ReentrantReadWriteLock
深度解析Redisson框架的分布式锁运行原理与高级知识点
分布式系统中的锁管理一直是一个复杂而关键的问题。在这个领域,Redisson框架凭借其出色的性能和功能成为了开发者的首选之一。本篇博客将深入探讨Redisson框架的分布式锁运行原理以及涉及的高级知识点。通过详细的解释和示例代码,您将更好地理解如何在分布式环境中使用Redisson框架来实现分布式锁。
疯狂的KK
2023/09/05
2K0
深度解析Redisson框架的分布式锁运行原理与高级知识点
5000字 | 24张图带你彻底理解21种并发锁
乐观锁是一种乐观思想,假定当前环境是读多写少,遇到并发写的概率比较低,读数据时认为别的线程不会正在进行修改(所以没有上锁)。写数据时,判断当前 与期望值是否相同,如果相同则进行更新(更新期间加锁,保证是原子性的)。
悟空聊架构
2020/09/03
8310
java 读写锁_Java中的读写锁「建议收藏」
a)Java中的锁——Lock和synchronized中介绍的ReentrantLock和synchronized基本上都是排它锁,意味着这些锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,在写线程访问的时候其他的读线程和写线程都会被阻塞。读写锁维护一对锁(读锁和写锁),通过锁的分离,使得并发性提高。
全栈程序员站长
2022/09/22
2.9K0
java 读写锁_Java中的读写锁「建议收藏」
万字长文带你了解Java中锁的分类
Java中的锁是一种多线程编程中的同步机制,用于控制线程对共享资源的访问,防止并发访问时的数据竞争和死锁问题。通过使用锁机制,可以实现数据的同步访问,确保多个线程安全地访问共享资源,从而提高程序的并发性能。
索码理
2023/08/21
5670
万字长文带你了解Java中锁的分类
Java 种15种锁的介绍:公平锁,可重入锁,独享锁,互斥锁等等
在读很多并发文章中,会提及各种各样锁如公平锁,乐观锁等等,这篇文章介绍各种锁的分类。介绍的内容如下:
美的让人心动
2019/06/15
2.5K0
Go RWMutex:高并发读多写少场景下的性能优化利器
在这篇文章 Go Mutex:保护并发访问共享资源的利器 中,主要介绍了 Go 语言中互斥锁 Mutex 的概念、对应的字段与方法、基本使用和易错场景,最后基于 Mutex 实现一个简单的协程安全的缓存。而本文,我们来看看另一个更高效的 Go 并发原语,RWMutex。
陈明勇
2023/04/24
8930
Go RWMutex:高并发读多写少场景下的性能优化利器
不可不说的Java“锁”事
Java提供了种类丰富的锁,每种锁因其特性的不同,在适当的场景下能够展现出非常高的效率。本文旨在对锁相关源码(本文中的源码来自JDK 8)、使用场景进行举例,为读者介绍主流锁的知识点,以及不同的锁的适用场景。
美团技术团队
2018/11/16
7400
【小家java】JUC并发编程之Synchronized和Lock、ReadWriteLock、ReentantLock的使用以及原理剖析
我们很多人在学习多线程开发的时候,一遇到并发问题就是synchronized,相对于当时的我们来说synchronized是这么的神奇而又强大,那个时候我们赋予它一个名字“同步”,也成为了我们解决多线程情况的百试不爽的良药。 但是我们知道synchronized是一把重量级的锁,对效率是不友好的,所以在JDK1.5版本之后,推出了轻量级的锁Lock。但是呢,随着Javs SE 1.6对synchronized进行的各种优化后,synchronized并不会显得那么重了。 因此本文就从这个角度,来分析分析synchronized的原理和使用,也会介绍Lock的使用的。
YourBatman
2019/09/03
4500
【小家java】JUC并发编程之Synchronized和Lock、ReadWriteLock、ReentantLock的使用以及原理剖析
推荐阅读
相关推荐
并发编程的奥秘:探索锁机制的多样性与应用
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档