浅谈go语言中的读写锁和互斥锁
Hello,各位小伙伴大家好,我是小栈君,近期气温有所下降,希望各位小伙伴记得防寒保暖,不要感冒了哦。
本期分享主题是关于go语言中的锁的应用场景,以及为各位小伙伴介绍实战应用中最为广泛的读写锁和互斥锁。
互联网生态的日益繁荣,人们的生活便利得到了极大的提高,通过网上操作我们基本上可以实现很多需求。
网站疯狂访问的背后应对的是一波接一波的挑战。所以在应对系统的稳定和并发的时候,程序中的“锁”就孕育而生。
互斥锁
在编程中,引入了对象互斥锁的概念,来保证共享数据操作的完整性。每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。
也就是将共享资源变成独占资源。互斥锁的应用场景通常是写大于读操作的,它不同于读写锁的读者随意访问,而写者只有一个。
它代表的资源就是一个,不管是读者还是写者,只要谁拥有了它,那么其他人就只有等待解锁后,隐约在脑海中浮现出“宝刀屠龙,谁与针锋”的话语。
其实我们可以很形象的理解一下互斥锁,资源就好比是一个厕所,很多人都想上厕所,但是坑位只有一个,那么谁获取了互斥锁,那么谁就有权利进去,其他人只有在门口排队等待。也就是我们通常所说的阻塞。
图片1.png在go语言的sync包中也是有对于互斥锁的解释,互斥锁的结构体很简单,并且他的接口就只有一个加锁和一个解锁操作。
当value为空时就是一个解锁的互斥锁,也就是其他人都可以来使用。并且当互斥锁第一次使用的时候就不能再被复制。
图片2.png并且在代码中也有很详细的说明,有兴趣的小伙伴可以参考代码源码进行了解。大致的意思就是说互斥锁有两种模式,正常和饥饿模式。
在正常的模式下采用的是FIFO模式即先进先出,但是会被等待者唤醒。没有拥有互斥锁的等待者会同新来的协程进行竞争,获取锁的使用权。
但是新来的协程拥有一个优势就是他们是运行在CPU上的,而之前有可能会有很多进程或协程需要被唤醒,所以他们有可能在毫秒之间就被人插队了。
也就是新来的不需要被唤醒直接获取到。如果一个在1ms的时间内没有获取到互斥量,那么它将进入到饥饿模式。
也就是说在互斥锁的饥饿模式下他会进行有序的交接。也就是会将互斥锁的所有权进行移交到排在前面的等待者。
而新来的等待者想要获取互斥锁就只有乖乖排队。他也不会试图去抢占互斥锁。如果说在饥饿模式下他是最后一个互斥锁的拥有者的话,或是等待少于1ms获取锁,那么他就会重新转变为正常模式。
其实在正常模式下协程会有更好的性能,但是饥饿模式是为了预防更多不确定的情况。
互斥锁实战:
我们先进行一个简单的模拟,定义个Map进行模拟数据库对象,制定一个Map的切片来做为数据对象的id和name,并且进行数据的初始化,当我们开启10个并发请求进行修改某一个值的时候。
图片3.png我们可以看到最终的结果是修改成功了。但是各位小伙伴我们是真的就没有任何问题了么?
在go语言中我们其实可以检查是否有问题可以使用 go build -race 来进行数据竞争检测。
[小知识:以后小伙伴在不确定的情况下都可以进行使用命令进行验证,当我们不确定打包工具有哪些命令的时候,我们可以用go help build 来进行查看]
4.jpg双击后我们可以看到出现了以下问题,看来当初我们直接使用goland进行运行看到的表现往往有一丝丝的不妥当,还是太年轻了啊!
5.jpg我们也可以看到在点击文件后得到的结论是确实对于共享资源的抢占是会导致问题的,而且在go语言中我们也是很友好的进行了提前的测试,避免了在线上问题排查。
对于线程相关的问题在生产上其实排查相对而言是有点困难的。在文件中也可以明确的显示出问题出现在17行,也就是我们在对于Map切片进行操作的时候。
6.jpg所以我们针对多并发访问的时候需要对共享资源进行加锁处理,目前模拟的应用场景是写大于读的时候。我们使用互斥锁进行相应的操作。
得到的结论和之前的一样,但是我们同样需要对这段程序进行go build -race 操作。
7.jpg最终得到了正确无误的操作。以上就是我们关于互斥锁的初步使用。按照使用规范来讲,不管是互斥锁和读写锁,我们都应该尽可能的对于小范围的进行使用,在关键处进行使用,避免程序拥有大量的阻塞。
读写锁
读写锁实际是一种特殊的自旋锁,它把对共享资源的访问者划分成读者和写者,读者只对共享资源进行读访问,写者则需要对共享资源进行写操作。
这种锁相对于自旋锁而言,能提高并发性,因为在多处理器系统中,它允许同时有多个读者来访问共享资源,最大可能的读者数为实际的逻辑CPU数。
写者是排他性的,一个读写锁同时只能有一个写者或多个读者(与CPU数相关),但不能同时既有读者又有写者。在读写锁保持期间也是抢占失效的。
如果读写锁当前没有读者,也没有写者,那么写者可以立刻获得读写锁,否则它必须自旋在那里,直到没有任何写者或读者。
如果读写锁没有写者,那么读者可以立即获得该读写锁,否则读者必须自旋在那里,直到写者释放该读写锁。
所以针对于系统中需要读多写少的情况下,我们就需要使用到读写锁进行应对程序的并发,保护程序的安全、稳定、高效的运行。
在go语言中内置包中已经实现了关于读写锁操作,我们在系统中需要使用可以很方便的进行操作。
8.jpg在代码注释中很明确的可以看出,在go语言中的sync包中的RWMutex就是一个读写互斥锁,该锁可以有任意数量的读者或是只有一个写者持有,形象的说就好比一场电影可以有无数的人来看,但是导演就只有一个。
当值为空的时候,他是处于解锁状态,并且在读写锁第一次使用的时候是不允许被拷贝的。如果一个协程(goroutine)持有读的权限,另一个协程进行锁操作,那么没有任何一个协程能够再持有读锁,除非被释放。
当然开发者也提醒了禁止进行递归读锁定,是为了保证锁能够一直可用。大概意思就是说这部电影不能让你一个人独自看,毕竟独乐乐不如众乐乐嘛。
在代码中我们常用的两个操作就是Lock 和Unlock 即加解锁操作,通过代码中我们可以知道go语言最多读者可以高达10亿个,已经能够完美的满足到我们的业务需求了。
9.jpg读写锁实战:
接下来我们将模拟一下关于网页中的读写锁操作,读写锁的应用场景多数是类似于朋友圈或微博的并发场景下的读大于写的场景,所以我们用读写两个循环协程进行模拟数据库的请求操作,并是使用count进行原子计数,最终得到的结果如下:
10.jpg当然如果我们依旧关闭锁操作会得到相同的结果么?
11.jpg使用goland执行后的结果和之前加锁的状态是是没有什么区别,但是真的没有什么区别么?
让我们使用一下go build -race 命令,最后得到的结果如下:
12.jpg表面风轻云淡的情况下内部已经翻江倒海了,所以小栈君在这里也是奉劝各位三思而后行。
读写锁与互斥锁性能大比拼
读写锁和互斥锁的各自实战情况已经初略的给各位分享了一遍,总体而言用法是比较简单的,并且有兴趣的小伙伴可以看看go语言的内置包,后续我也会陆续为大家分享关于go语言实现二叉树,链表结构等文章,让大家更加深入的感受到go语言的魅力。
当然我也在筹划其他语言的分享,所以各位小伙伴,如果你喜欢我的文章,麻烦分享并关注小栈君哦。
回归正题,我们在使用各自的使用场景下并没有感受到读写锁鱼互斥锁性能上有多大的区别。所以小栈君接下来就一个场景分别使用两个锁来进行模拟请求计数,得出结论。
我们首先使用读写锁进行模拟如图所示:
13.jpg我们先定义读写锁、数据库数据、还有相关的原子计数器。这里有一个小知识点,我们在计数的时候用了atomic,这样可以保证计数的准确和安全。然后开启了一个写协程和一百个读协程。
为了加大请求力度我们有在每个协程里开启了无线循环读取,用1毫秒模拟查询时间,用10毫秒模拟写时间。最终得出的结论是计数器计算到了16万接近17万次。
14.jpg然后相同的代码我们仅仅只修改了一下锁的机制,将读写锁改成了互斥锁,各位可以看到效果就是代码执行了1695次,相差是非常大的。
15.jpg 16.jpg以上就是关于go语言中的互斥锁和读写锁的分享。事实证明,只有锁用的对,我们就可以早些下班啦。
好了,今天的分享就到这啦,如果你喜欢我的分享,麻烦你点击一个好看或赞,我是小栈君,不定期分享IT干货,包括但不限于区块链、大数据、Python、go、等系列专题。原创不易,更新较慢,多多包涵。希望与你共同成长。我们下期再见啦,拜了个拜~