当前位置: 首页 > 科技观察

这不是另一个Go错误吗?

时间:2023-03-22 13:16:31 科技观察

大家好,大家好,我是小楼。最近,我双写了一个BUG。在线服务陷入僵局。好在是新服务,没有太大影响。问题是Go的读写锁。如果您正在编写Java,则不必将其划掉。你应该阅读这篇文章。本文的重点是Java和Go的读写锁的对比。甚至看完之后,你还会有一种隐隐的感觉:Go的读写锁是不是有bug?故障回放的后台简单抽象:一个服务端服务(Go语言实现)提供一个http接口,另一个客户端服务调用这个接口。整体架构非常简单,不用画架构图也能看懂。这两个服务已经运行了一段时间没有出现任何问题。突然有一天,客户端调用服务端的所有接口都超时了。遇到此类问题,第一时间查看日志和监控。client端全是超时日志,server端日志没有异常。连请求的监听都不上报,就好像client端的请求没有到达server端一样。于是去服务器手动请求接口,卡主不动。这样就排除了客户端,肯定是服务端有问题。这种卡死的问题其实很容易排查。直接用pprof看协程卡在哪里(类似Java的jstack的工具)基本可以得出结论,但是这个服务没有开启pprof,所以只能改代码开启pprofRepost等待出问题下次再出现。还好2天后问题出来了。用pprof看程序卡在什么地方:原来是卡在一个地方判断是集群还是服务是小流量。接口会接受一个集群名或者服务名的参数,然后判断这个集群或者服务是不是小流量集群,然后做一系列的事情,不管你做什么。小流量集群在配置中心配置。我提取了这段代码(图中是判断集群分支,下面的代码在一个更简单的服务分支中解释,底层同理)。为了避免漏洞,这里我简单说明一下程序的逻辑:首先,小流量的配置定义了一个读写锁(sync.RWMutex),哪些服务需要保存在内存中。调用reset刷新scopesMap,使用写锁,省略后续逻辑:判断是否为灰度服务,先加读锁看规则是否存在:加锁判断服务是否命中规则:关键点这样圈出来,可能一眼就看出哪里出了问题,加了两次读锁,第二次就没必要了,错了。确实,删除第二个加读锁的代码是没有问题的。如果事情到这里就结束了,那么这篇文章就没有必要写了。下面分析一下为什么会出现死锁。为什么会出现死锁看到这个结果,我的第一反应是Go的锁重入。熟悉Java的同学对锁重入并不陌生。以防有的读者对锁的可重入不理解,我就一句话总结:可重入锁就是可以反复进入的锁,也叫递归锁。Java中有一个ReentrantLock,比如这个,重复加锁没有问题:但是Go中的锁是不可重入的:这个坑我踩过,是Go的实现问题。只要你愿意,你也可以在Java中实现不可重入锁,但Java大多使用可重入锁,因为使用起来更方便。至于为什么Go没有实现可重入锁,原因是Go的设计者认为可重入锁是一个糟糕的设计,所以没有采用。但是我觉得这篇文章的评论更精彩:说到这里,你可能会说上面的问题明明就是读写锁(sync.RWMutex)。读写锁有什么特点?阅读和阅读并不相互排斥。读和写、写和写是互斥的。由于读锁不是互斥的,即两个读锁可以相加,那么读锁一定是可重入的。写个demo测试一下:果然和我们想的一样,我们来看看加读锁的逻辑:看我框架的代码,如果有写锁等待,读锁需要等待写锁!这是什么逻辑?如果一个协程已经获取了读锁,另一个协程试图加写锁,此时应该加不上,没有问题。如果读锁协程再去获取读锁,需要等待写锁,这就是死锁!为了验证,我构建了一个demo:这段代码按照①、②、③的顺序执行。②段写锁需要等待①段读锁释放,③段读锁需要等待②段写锁释放。最后,就是一个死锁逻辑。仔细想想,这里最有争议的就是获得了读锁之后还要等待写锁再次进入读锁的逻辑。在Java中是这样吗?写个demo试一下:Java没毛病,为什么呢?犹豫不决,还是看源码吧!但是Java的源码太长,也不是本文的重点,所以我只说几个重要的结论:Java的ReentrantReadWriteLock支持锁降级,但不能升级,即已经获得write的线程lock可以继续获取读锁,但是读锁的线程不能再获取写锁;ReentrantReadWriteLock实现了两种锁,公平锁和非公平锁。在公平锁的情况下,在获取读锁和写锁之前,需要检查同步队列中的线程是否排在我前面;theunfairlylock这种情况下:写锁可以直接抢占锁,但是对于读锁的获取有一个让步条件。如果当前同步队列head.next正在等待写锁且不可重入,则必须yield等待。在Java的实现下,如果一个线程持有读锁,写锁自然需要等待,但是持有读锁的线程也可以再次重新进入读锁。我们发现Java和Go的读写锁实现不一致,这种不一致就是我们写BUG的原因。这合理吗?抛开执行不谈,我们想想,这样做合理吗?协程(或线程)已经获取了读锁。当其他协程(线程)获得写锁时,必须等待读锁释放。既然这个协程(或线程)已经拥有读锁,为什么还要再获取呢?读锁时,是否需要检查是否有其他写锁在等待?可想而知,患者排队看病。第一个病人问了医生,进来后关上了门。无论(理论上)他有权在里面问多久,他不出来,后面的病人就不能开门。但是围棋的实现是,之前的病人每问完一个问题就得查看门外是否有人等着。等着他问,大家就这么僵持着,根本就没有人想着去看病。想了想,是不是觉得这是Go的BUG?为什么Go如此实现?我尝试在github上搜索,发现了这个问题:https://github.com/golang/go/issues/30657从标题可以看出他遇到了和我一样的问题:Read-lockingshouldn'thang如果线程已经有一个写锁?#30657看看有人怎么回答的:老大说这不符合Go锁的原理。Go锁不知道协程或线程信息是的,只知道代码调用的顺序,即读写锁无法升级或降级。Java中的锁记录了持有者(threadid),而Go中的锁并不知道持有者是谁,所以在获取到读锁后再次获取读锁。这里的逻辑无法区分是持有者还是他人。协程,所以统一处理。这个其实在Go源码的注释中有体现,后来才注意到:翻译过来就是:如果一个协程持有读锁,另一个协程可能会调用Lock加写锁,那么就不再有协程A了进程可以获取一个读锁,直到上一个读锁被释放,这是为了防止读锁递归。还确保锁最终可用,阻塞写锁调用将排除新的读锁。但是这个警告太不显眼了,大概是因为这个效果:这个场景很像产品和程序员:产品经理:我要实现这个功能,我不关心怎么实现。Go:这违反了我的设计原则,不接受这个特性。产品经理:大家退一步说,你可以用更便宜的方案来解决!于是,程序员写了一篇关于读写锁的评论:最后一个死锁坑真的很容易踩,尤其是Java程序员写Go,所以写Go代码的时候,还是要多写Go。.Go的设计者比较“偏执”,认为“不好”的设计坚决不去实施。比如锁的实现不应该依赖于线程和协程的信息;可重入(递归)锁是一个糟糕的设计。所以这种看似有BUG的设计也有一定的道理。当然,每个人都有自己的想法。你觉得这样实现Go的读写锁合理吗?