首次发布 -阳光融汇资本完成新基金首关
06-18
大家好,大家好,我是小楼。最近又写了一个bug。
在线服务陷入僵局。幸运的是,这是一项新服务,没有造成太大影响。
问题出在Go的读写锁上。如果您正在编写Java,则不必将其划掉。
您还应该阅读这篇文章。本文的重点是Java和Go的读写锁的比较。
读完之后你甚至会有一种模糊的感觉。感想:Go的读写锁有bug吗?简单抽象一下故障重放的背景:服务端服务(Go语言实现)提供http接口,客户端服务调用该接口。
整体架构非常简单,不用画架构图也能看懂。这两个服务已经上线并运行了一段时间,没有出现任何问题。
突然有一天,客户端向服务器调用的所有接口都超时了。遇到这种问题,我第一时间查看日志和监控。
客户端全是超时日志,服务器端日志没有任何异常。连请求的监听也没有上报,就好像客户端请求没有到达服务器端一样。
于是我就去服务器手动请求接口,结果却是卡住了。现在客户被排除在外了。
服务器端肯定有问题。这种卡住的问题其实很容易排查。
直接用pprof看协程卡在哪里基本上就可以得出结论了(类似于Java的jstack的工具)。不过这个服务并没有开启pprof,所以只能改代码开启pprof。
重新发布并等待下次问题再次出现。幸运的是,我很幸运。
2天后问题就出来了。使用pprof查看程序卡在哪里:L1.png结果卡在一个地方,判断集群或者服务是否流量小。
该接口将接受集群名称或服务。名称的参数,然后判断集群或者服务是否是低流量集群,然后做一系列的事情。
做什么并不重要。小流量集群在配置中心配置。
我提取了这段代码(图为判断集群分支,下面的代码在更简单的服务分支中解释,底层是一样的)。为了避免漏洞,我简单解释一下程序逻辑:首先小流量的配置定义了一个读写锁(sync.RWMutex),在内存中维护了哪些业务需要灰度的规则(scopesMap) L2 .png 当配置发生变化时,调用reset刷新scopesMap,使用写锁,后续逻辑省略L3.png判断是否为灰度服务。
首先添加读锁,查看规则是否存在:L4.png,然后加锁判断服务是否符合规则:L5。 png这样把关键点圈出来,一看就知道问题所在。
读锁加了两次。第二次是不必要的,也是一个错误。
确实,删除第二个添加读锁的代码就能解决问题。如果事情到这里就结束了,那么这篇文章就没有必要写了。
我们来分析一下为什么会出现死锁。为什么会出现僵局?当我看到这个结果时,我的第一反应是Go的锁的重入问题。
熟悉Java的同学都熟悉锁的重入性。万一有些读者不明白锁的可重入性,我用一句话概括一下:可重入锁是一种可以重复进入的锁,也叫递归锁。
Java中有一个ReentrantLock。比如重复加锁就没有问题:L6.png。
但Go中的锁是不可重入的:L7.png。我也曾踩过这个坑。
这是Go的一个实现问题。只要你愿意,你也可以在Java中实现不可重入锁,但是Java中使用的大部分都是可重入锁,因为使用起来更加方便。
至于Go为什么不实现可重入锁,可以参考建宇刀《Go 为什么不支持可重入锁?》的这篇文章。原因可以归结为Go的设计者认为可重入锁是一个糟糕的设计,所以他们没有采用它。
不过我觉得这篇文章的评论更精彩:L8.png 说到这里,你可能会说,上面的问题显然是读写锁(sync.RWMutex)。读写锁有什么特点?阅读和阅读并不是相互排斥的。
阅读与写作、写作与写作是相互排斥的。由于读锁不是互斥的,即可以加两个读锁,那么读锁必须是可重入的。
我们写个demo来测试一下:L9.png确实是我们想的那样。我们看一下加读锁的逻辑: L10.png 看我框的代码。
如果有写锁等待,则读锁需要等待写锁! L11.png 这是什么逻辑?如果一个协程已经获得了读锁,而另一个协程尝试添加写锁,此时它应该无法添加。没有问题。
如果读锁协程再次尝试获取读锁,则需要等待写锁,这就是死锁!为了验证,我构建了一个demo:L12.png。该代码按照①、②、③的顺序执行。
②节的写锁需要等待①读锁释放,③节的读锁需要等待②节的写锁。释放最终是一个死锁逻辑。
仔细想想,这里最有争议的就是必须等待写锁之后才能再次进入读锁的逻辑。Java中也是这样吗?尝试编写一个演示:L13.pngJava 什么也没做,这是为什么?如果您有疑问,请查看源代码!不过Java的源码太长,不是本文的重点,所以我只讲几个重要的结论: Java的ReentrantReadWriteLock支持锁降级,但不能升级。
即已经获取写锁的线程可以继续获取读锁,但不能获取读锁。加锁线程无法再获取写锁; ReentrantReadWriteLock 实现了公平锁和非公平锁。
公平锁的情况下,在获取读锁或写锁之前,需要检查同步队列中的线程是否排在我之前;不公平锁情况:写锁可以直接抢占锁,但读锁获取有一个让步条件。如果当前同步队列head.next正在等待写锁,并且是不可重入的,那么它必须让步并等待。
在Java的实现下,如果一个线程持有读锁,那么写锁自然需要等待,但是持有读锁的线程也可以重新进入读锁。我们发现Java和Go的读写锁实现不一致。
这种不一致就是我们写下BUG的原因。这合理吗?抛开实现不谈,我们来思考一下。
这合理吗?协程(或线程)已获取读锁。当其他协程(线程)获取写锁时,必须等待读锁的释放。
既然这个协程(或线程)已经拥有读锁,为什么还要再次获取它呢?读锁时,是否需要检查是否有其他写锁在等待?你可以想象病人正在排队看医生。前面的病人向医生询问,进去后关上了门。
无论他在里面待多久(理论上),这都是他的权利。后面的病人要等他出来才能开门。
但Go的实现是,每次问完问题后,前一个病人都要看看门外,看看是否有人在等待。如果有人在等,那么他就得等门外的人问完之后再问,但是门外的人不问。
我们等着他问,大家就陷入了僵局,没有人愿意去看医生。仔细想一想,你认为这是Go的一个bug吗? Go为什么要这样实现?我尝试在github上搜索并发现这个问题:should nothang if thread has has a write-lock? #7 看有人怎么回答:L14.png 这家伙说,这不符合Go锁的原理。
Go 的锁不知道协程或线程信息。它只知道代码调用的顺序,即读写锁不能升级或降级。
Java中的锁记录了持有者(线程id),但是Go的锁不知道持有者是谁,所以获取读锁后,再次获取读锁。这里的逻辑是它无法区分持有者和其他人。
协程,所以统一处理。这实际上反映在Go源码的注释中。
后来才注意到:L15.png翻译为:如果一个协程持有读锁,另一个协程可能会调用Lock来添加写锁,那么就不再有协程可以获取读锁,直到前一个读锁被释放。这是为了禁用读锁递归。
它还确保锁最终可用;阻塞写锁调用将排除新的读锁。不过这个警告太不起眼了,大概有这样的效果: L16.png 这个场景很像产品和程序员: 产品经理:我要实现这个功能,我不在乎怎么实现 Go :这破坏了我的设计原则,不接受这个功能的产品经理:大家退一步,找个更便宜的方法来解决。
于是,程序员写了一篇关于读写锁的说明:L17.png 最后一个死锁坑,真的好容易踩。尤其是Java程序员写Go,所以我们在写Go代码的时候,还是要写得更像Go。
Go的设计者相当“偏执”,认为“糟糕”的设计不会被实现。例如,锁的实现不应该依赖于线程和协程信息;可重入(递归)锁是一个糟糕的设计。
所以这个看似有bug的设计也有一定的道理。当然,每个人都有自己的想法。
你觉得Go的读写锁这样实现合理吗?如果您读后觉得有所收获,请点赞并继续阅读。
版权声明:本文内容由互联网用户自发贡献,本站不拥有所有权,不承担相关法律责任。如果发现本站有涉嫌抄袭的内容,欢迎发送邮件 举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。
标签:
相关文章
06-18
06-18
06-18
06-06
06-17
最新文章
【玩转GPU】ControlNet初学者生存指南
【实战】获取小程序中用户的城市信息(附源码)
包雪雪简单介绍Vue.js:开学
Go进阶:使用Gin框架简单实现服务端渲染
线程池介绍及实际案例分享
JMeter 注释 18 - JMeter 常用配置组件介绍
基于Sentry的大数据权限解决方案
【云+社区年度征文集】GPE监控介绍及使用