天天看点

Go36-26-互斥锁与读写锁从同步讲起互斥锁读写锁

相比于Go语言宣扬的“用通讯的方式共享数据”,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流。本篇就是讨论一些与多线程、共享资源以及同步有关的知识。

sync包,就是一个与并发编程关系紧密的代码包。这里“sync”的中文意思就是“同步”。

这里会讲一些重要的并发编程概念:竞态条件、临界区、互斥量、死锁。死锁会在互斥锁里引出。

一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况。这种情况也被称为竞态条件(race condition),这往往会破幻共享数据的一致性。

概括来讲,同步的用途有两个:

避免多个线程在同一时刻操作同一个数据块

协调多个线程,避免它们在同一时刻执行同一个代码块

由于这样的数据块和代码块的背后都隐含着一种或多种资源,可以把他们看作是共享资源。

同步就是在控制多个线程对共享资源的访问。针对某个资源的访问,同一时刻只能有一个线程访问到该资源。那么可以说,多个并发进行的线程对这个共享资源的访问是完全串行的。只要一个代码片段需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section)。也就是说,要访问到资源就必须进入到这个区域。如果针对一个共享资源,这样的代码片段有多个,那么它们就可以被称为相关临界区。

应对竞态条件的问题,就需要施加一些保护的手段。方法之一就是使用实现了某种同步机制的工具,也称为同步工具。在Go语言中,可供我们选择的同步工具并不少。其中,最重要且最常用的同步工具当属互斥量(mutual exclusion,简称 mutex)。sync包中的Mutex就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。

虽然Go语言是以“用通讯的方式共享数据”为亮点,但是依然提供了一些易用的同步工具。而互斥锁就是最常用到的一个。

一个互斥锁可以被用来保护一个临界区或者一组相关临界区。保证同一时刻只有一个goroutine处于改临界区之内。每当有goroutine想进入临界区是,需要对它进行锁定,并且在离开临界区时进行解锁。

使用互斥锁时,锁定操作可以通过调用互斥锁的Lock方法实现,而解锁是调用Unlock方法。示例如下:

这个示例提供了一个命令行参数-lock,可以选择加锁或者不加锁来运行这个程序。这样可以方便的比较在代码中加锁的作用。

使用互斥锁时的注意事项:

不要重复加锁

不要忘记解锁,最好是使用defer语句

不要对尚未加锁或者已经解锁的互斥锁解锁

不要在多个函数之间直接传递互斥锁

对一个已经被锁定的互斥锁进行锁定,是会立即阻塞当前goroutine的。会一直等到该互斥锁在别的goroutine里被解锁,并且这里的锁定操作完成为止。如果那边解锁后又被别的goroutine锁定了,那就继续等,一直到抢到锁完成锁定操作。

虽然没有任何的强制规定,你是可以用同一个互斥锁保护多个无关的临界区的。但是这样做,一定会使你的程序变的复杂,就是说不要这么做,需要的话,就多搞几把锁。如果真的把一个互斥锁同时用在了多个地方,必然会有更多的goroutine征用这把锁。这不但会使得程序变慢,还会打打增加死锁(deadlock)的可能性。

死锁

所谓死锁,就是当前程序中的主goroutine,以及启用的那个goroutine都已经被阻塞。这些goroutine可以被统称为用户级的goroutine。就是说整个程序都停滞不前了。

Go语言运行时,系统是不允许死锁的情况出现的。只要发现所有的用户级goroutine都处于等待状态,就会自行抛出panic。随便写个函数,连续上2次锁就死锁了:

抛出的信息如下,主要就看第一行<code>fatal error: all goroutines are asleep - deadlock!</code>:

这种在Go运行时系统自行抛出的panic都属于致命错误,是无法被恢复的。调用recover函数也不起作用。就是说,一旦死锁,程序必然崩溃。

要避免这种情况,最有效的做法就是,让每一个互斥锁只保护一个临界区或一组相关的临界区。

用defer语句解锁

还要注意,对同一个goroutine而言,既不要重复锁定一个互斥锁,也不要忘记进行解锁。这里不要忘记解锁的一个很重要的原因就是为了避免重复锁定。在很多时候,一个函数执行的流程并不是单一的,流程中间可能会有分叉、也可能会被中断。最保险的做法就是使用defer语句来进行解锁,并且这样的defer语句应该紧跟在锁定操作的后面。

上面的那个示例,没有按这里说的来做,因为整个写操作是在for循环里的。解锁操作后还有其他语句要执行,这里是for循环里的其他迭代要处理。而defer语句是只有程序退出后才会执行的。不过这都不是借口,要按这里最保险的做法来做,只需要把for循环里的语句再写一个函数或匿名函数就可以用defer了:

解锁未锁定的互斥锁也会立即引发panic。并且与死锁一样,也是无法被恢复的。从这一定看,也是需要保证对于没一个锁定操作,都必须且只能由一个对应的解锁操作。就是要让他们成对出现,这也算是互斥锁一个很重要的使用原则。而利用defer语句进行解锁就可以很容易的做到这一点。

互斥锁是结构体、值类型

Go语言中的互斥锁时开箱即用的,就是一旦声明了一个sync.Mutex类型的变量,就可以直接使用它。不过要注意,该类型是一个结构体,属于值类型:

对于值类型,把它传递给一个函数、将他从函数中返回、把它赋值给其他变量、让它进入某个通道都会导致它的副本的产生。这里,原值和副本以及多个副本之间都是完全独立的,是不同的互斥锁。举例说明,如果你把一个互斥锁作为参数值传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何影响。

这就是为什么“不要在多个函数之间直接传递互斥锁”。避免歧义,即使你希望的是在这个函数中使用另外一个互斥锁也不要这样做。

学习了上面的注意事项和建议,就来看看如何更好的使用互斥锁。下面是一个使用互斥锁的示例:

这个示例中,分别有读和写的两个处理函数。而处理函数里做的事情就是:加锁、defer解锁,完成读或写操作然后返回。这里就做到了加锁和解锁操作成对出现,并且把锁和要保护的共享资源放在一起了。

示例中还有一个互斥锁在handlerConfig结构体中,要保护的共享资源也是handlerConfig结构体中的counter字段。并且写了一个方法count实现对counter字段的锁定和修改。

读写锁是读/写互斥锁的简称。在Go语言中,读写锁有sync.RWMutex类型的值代表。与sync.Mutex一样,这个类型也是开箱即用的。开箱即用,应该就是指不用赋值,定义了之后直接就能用了。就是让它的零值也具有意义。

读写锁就是把共享资源的“读操作”和“写操作”区别对待了。为两种操作施加了不同程度的保护。相比于互斥锁,读写锁可以实现更加细腻的访问控制。

一个读写锁中实际包含了两个锁,读锁和写锁:

写锁,它的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁

读锁,它的RLock方法和RUnlock方法分别用于对读锁进行锁定和解锁

对于同一个读写锁,有如下的规则:

在写锁已被锁定的情况下,再视图锁定写锁,会阻塞当前goroutine

在写锁已被锁定的情况下,试图锁定读锁,也会阻塞当前goroutine

在读锁已被锁定的情况下,试图锁定写锁,同样会阻塞当前goroutine

在读写已被锁定的情况下,再视图锁定读锁,并不会阻塞当前的goroutine

总结一下,就是可以有多个读操作,读锁锁定的情况下,别的goroutine也可以读。其他的情况下要操作,只能等之前锁定的操作完成释放锁,并且抢到锁了。再换个角度说,就是多个读操作可以同时进行,多个写操作不能同时进行,读和写操作也不能同时进行。

读写锁对写操作之间的互斥,其实是通过它内含的一个互斥锁实现的。因此,读写锁是互斥锁的一种扩展。所以无论是互斥锁还是读写锁,都不要试图去解锁未锁定的锁,因为这样会引发不可恢复的panic。

之前互斥锁的示例中,使用互斥锁保护了对缓冲区的读写操作,而这里又讲了读写锁,不要被这里读和写的说法锁迷惑。对缓冲区的读操作是会把读到的内容从缓冲区里去除的,所以是有类似写的操作在里面的,使用互斥锁时正确的做法,并且不能使用这里的读写锁。

而这个示例中的读操作,就仅仅只是去获取到值而已了,在读操作的时候加个读锁正合适: