Go核心36讲笔记-三

sync.Mutex与sync.RWMutex

  • 并发变成关系最紧密的代码包。我们首先要看的就是sync包
  • go与其他大多数语言不同在于,它比较宣扬用通讯的方式共享数据,而不是用共享数据的的方式来传递信息。
  • 竞态条件(race condition)一旦数据被多个线程共享,那么就很可能会产生争用和冲突的情况,往往会破坏共享数据的一致性
  • 共享数据的一致性代表某种约定,即:多个线程对共享数据的操作,总是可以达到它们各自预期的效果。
  • 如果这个一致性得不到保证,那么将会影响到一些线程中代码和流程的正确执行,甚至会造成某种不可预知的错误。这种错误一般都很难发现和定位,排查起来的成本也是非常高的,所以一定要尽量避免。
    举个例子,同时有多个线程连续向同一个缓冲区写入数据块,如果没有一个机制去协调这些线程的写入操作的话,那么被写入的数据块就很可能会出现错乱。比如,在线程A还没有写完一个数据块的时候,线程B就开始写入另外一个数据块了。
    显然,这两个数据块中的数据会被混在一起,并且已经很难分清了。因此,在这种情况下,我们就需要采取一些措施来协调它们对缓冲区的修改。这通常就会涉及同步。

概括来讲,同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。
由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、1/0资源、网络资源等等),所以我们可以把它们看做是共享资源,或者说共享资源的代表。我们所说的同步其实就是在控制多个线程对共享资源的访问。

一个线程在想要访问某一个共享资源的时候,需要先申请对该资源的访问权限,并且只有在申请成功之后,访问才能真正开始。

而当线程对共享资源的访问结束时,它还必须归还对该资源的访问权限,若要再次访问仍需申请。

你可以把这里所说的访问权限想象成一块令牌,线程一日拿到了令牌,就可以进入指定的区域,从
而访问到资源,而一旦线程要离开这个区域了,就需要把令牌还回去,绝不能把令牌带走。

如果针对某个共享资源的访问令牌只有一块,那么在同一时刻,就最多只能有一个线程进入到那个区域,并访问到该资源。
这时,我们可以说,多个并发运行的线程对这个共享资源的访问是完全串行的。只要一个代码片段
需要实现对共享资源的串行化访问,就可以被视为一个临界区(criticalsection),也就是我刚刚
说的,由于要访问到资源而必须进入的那个区域。
比如,在我前面举的那个例子中,实现了数据块写入操作的代码就共同组成了一个临界区。如果针如果这个一致性得不到保证,那么将会影响到一些线程中代码和流程的正确执行,甚至会造成某种不可预知的错误。这种错误一般都很难发现和定位,排查起来的成本也是非常高的,所以一定要尽量避免。

  • 举个例子,同时有多个线程连续向同一个缓冲区写入数据块,如果没有一个机制去协调这些线程的写入操作的话,那么被写入的数据块就很可能会出现错乱。比如,在线程A还没有写完一个数据块的时候,线程B就开始写入另外一个数据块了。
    显然,这两个数据块中的数据会被混在一起,并且已经很难分清了。因此,在这种情况下,我们就需要采取一些措施来协调它们对缓冲区的修改。这通常就会涉及同步。

概括来讲,同步的用途有两个,一个是避免多个线程在同一时刻操作同一个数据块,另一个是协调多个线程,以避免它们在同一时刻执行同一个代码块。
由于这样的数据块和代码块的背后都隐含着一种或多种资源(比如存储资源、计算资源、1/0资源、网络资源等等),所以我们可以把它们看做是共享资源,或者说共享资源的代表。我们所说的同步其实就是在控制多个线程对共享资源的访问。

一个线程在想要访问某一个共享资源的时候,需要先申请对该资源的访问权限,并且只有在申请成功之后,访问才能真正开始。

而当线程对共享资源的访问结束时,它还必须归还对该资源的访问权限,若要再次访问仍需申请。

你可以把这里所说的访问权限想象成一块令牌,线程一旦拿到了令牌,就可以进入指定的区域,从
而访问到资源,而一旦线程要离开这个区域了,就需要把令牌还回去,绝不能把令牌带走。

如果针对某个共享资源的访问令牌只有一块,那么在同一时刻,就最多只能有一个线程进入到那个区域,并访问到该资源。
这时,我们可以说,多个并发运行的线程对这个共享资源的访问是完全串行的。只要一个代码片段
需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section),也就是我刚刚
说的,由于要访问到资源而必须进入的那个区域。
比如,在我前面举的那个例子中,实现了数据块写入操作的代码就共同组成了一个临界区。如果针对同一个共享资源,这样的代码片段有多个,那么它们就可以被称为相关临界区。

它们可以是一个内含了共享数据的结构体及其方法,也可以是操作同一块共享数据的多个函数。林杰区总是需要受到保护的,否则就会产生竞态条件,施加保护的手段之一,就是使用实现了某种同步机制的工具,也称为同步工具。

  • go中同步工具有很多,最常用的就是互斥量(mutual exclusion 简称mutex)sync包中的Mutex就是与对其应的类型,该类型的值可以被称为互斥量或者互斥锁。
  • 一个互斥锁可以被用来保护一个临界区或者一组相关临界区,保证同一时间只有一个goroutine处于该临界区之内;通过goroutine进入临界区,都需要先对它进行锁定,并且每个goroutine离开临界区时,都要及时对它进行解锁。
  • 具体操作 mu.Lock() mu.UnLock()
  • 注意 不要把一个互斥锁同时用在多个地方,这不但让你的程序变慢,还会大大增加死锁的可能
  • 所谓地锁,指的是当前程序中的主goroutine,以及我们启用的那些goroutine都已经被阻塞,这些goroutine被统称为用户及goroutine,这就相当于整个程序都已经停滞不前了。go语言发现所有用户级goroutine都处于等待状态,就会自行抛出一个带有如下信息的panic:
  • fatal error: all goroutines are asleep -deadlock
  • 这种致命错误,无法恢复,一旦死锁,程序必然崩溃
  • sync.Mutex的类型属于结构体类型,属于值类型,把它传递给函数,或者从函数中返回,赋值给变量,让它进入某个通道都会导致它的副本产生。原值和副本属于不同的互斥锁

读写锁

读写锁是读/写互斥锁的简称,读写锁由sync.RWMutex类型的值代表。
读写锁实际上包含了2个锁,读锁和写锁。sync.RWMutex类型中的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁;而它的RLock方法和RUnlock方法则分别用于读锁进行锁定和解锁。

读写锁规则如下:
1 在写锁已被锁定的情况下再试图锁定写锁,会阻塞当前的goroutine
2 在写锁已被锁定的情况下试图锁定读锁,也会阻塞当前的goroutine
3 在读锁已被锁定的情况下试图锁定写锁,同样会阻塞当前的goroutine
4 在读锁已被锁定的情况下再试图锁定读锁,并不会阻塞当前的goroutine

也就是说 某个收到读写锁保护的共享资源,多个写操作不能同时进行,写操作和读操作也不能同时进行,但多个读操作却可以同时进行。
tips: 读写锁对写操作之间的互斥,其实通过它内含的一个互斥锁实现的。

sync.Cond

条件变量(conditional variable): 基于互斥锁;
必须有互斥锁的支撑才能发挥作用。
条件变量并不是用来保护临界区和共享资源的,它是用于协调想要访问共享资源的那些线程的。
当共享资源的状态发生变化时,它可以用来通知互斥锁阻塞的线程。

  • 条件变量怎么样与互斥锁配合使用?
    • 条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。
      条件
    • 条件变量提供的方法有三个:等待通知(wait)、单发通知(signal)和广播通知(broadcast)
    • 我们在利用条件变量等待通知的时候,需要在它基于的那个互斥锁保护下进行。
    • 而在进行单发通知或者广播通知的时候,却是恰恰相反,也就是说需要在对应的互斥锁解锁之后再做这两种操作。
  • 聚栗:
    var mailbox unit8
    var lock sync.RWMutex
    sendCond := sync.NewCond(&lock)
    recvCond := sync.NewCond(lock.RLocker())
  • 变量mailbox代表信箱,是unit8类型的。若它的值为0则表示信箱中没有情报,而当它的值为1时则说明信箱中有情报。
    lock是一个类型 为sync.RWMutex的变量,是一个读写锁,它可以被视为信箱上的那把锁。
  • 两个条件变量:sendCond和recvCond,他们都是*sync.Cond类型的,同时也-都是由sync.NewCond函数来初始化的。
    sync.Cond类型,不是开箱即用,这里利用sync.NewCond函数创建它的指针值,需要一个sync.Locker类型的参数值。
    因为条件变量是基于互斥锁的,它必须有互斥锁的支撑才能起作用,因此这里的参数值是不可或缺的,它会参与到条件变量的方法实现当中。
    sync.Locker其实是一个接口,在它的声明中只包含了两个方法定义Lock()和Unlock()。sync.Mutex类型和sync.RWMutex类型都拥有Lock方法和Unlock方法,只不过它们都是指针方法。因此这两个类型的指针类型才是sync.Locker接口的实现类型。
  • lock变量的Lock方法和Unlock方法分别用于对其中写锁的锁定和解锁,它们与sendCond变量的含义是对应的。sendCond对共享资源的写操作。recvCond是读操作
  • 最后一行代码,因为需要的是lock变量中的读锁,还是sync.Locker类型的,可是lock变量中用于对读锁进行锁定和解锁的方法确是RLock和RUnlock,它们与sync.Locker接口中定义的方法不匹配。好在sync.RWMutex类型的RLocker方法可以实现,传入调用表达式lock.RLocker()的结果值,就可以使该函数返回符合要求的条件变量了。这个结果值拥有的Lock和Unlock方法,内部分别调用lock变量的RLock方法和RUnlock方法。
  • 使用方法
    lock.Lock()
    for mailbox == 1 {
    sendCond.Wait()
    }
    mailbox = 1
    lock.Unlock()
    recvCond.Signal()

获取方法

lock.RLock()
for mailbox == 0 {
    recvCond.Wait()
}

mailbox = 0
lock.RUnlock()
sendCond.Signal()

利用条件变量可以实现单向的通知。

问题1 条件变量 Wait 做了什么?

  • 把调用它的goroutine() 加入到当前条件变量的通知队列中。
  • 解锁当前的条件变量基于的那个互斥锁
  • 让当前的goroutine 处于等待状态,等到通知到来时再决定是否唤醒它。此时,这个goroutine就会阻塞在调用这个Wait方法的那行代码上。
  • 如果通知到来并且决定唤醒这个goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁。自此以后,当前的goroutine就会继续执行后面的代码了

问题2 条件变量Signal方法和Broadcast方法有哪些异同

  • 相同点 都是用来发送通知的
  • 不同点:
    • Signal的通知只会唤醒一个因此而等待的goroutine,而Broadcast的通知却会唤醒所有为此等待的goroutine
    • 条件变量的Wait方法总会把当前的goroutine添加到队列的队尾,而它的Signal方法总会从通知队列的队首开始查找可被唤醒的goroutine,所以Signal方法的通知而被唤醒的goroutine 一般都是最早等待的那一个。
  • 适用场景:确定只有一个goroutine在等待通知,或者只需要唤醒任意一个goroutine就能满足要求,那么适用条件变量的Signal方法就好了。否则适用Broadcast方法总没错,只要你设置好各个goroutine所期望的共享资源状态就可以。
  • 条件变量Signal和Broadcast方法并不需要在互斥锁的保护下执行,最好在解锁条件变量基于的那个互斥锁之后,再去调用它的这个方法,有利于程序的运行效率。条件变量的同时具有即时性,也就是说,如果发送通知的时候没有goroutine为此等待,那么该通知就会被直接丢弃。在这之后才开始等待的goroutine只可能被后面的通知唤醒。

总结

1 条件变量基于互斥锁的一种同步工具
2 sync.NewCond函数来初始化sync.Cond类型的条件变量
3 sync.NewCond函数需要一个sync.Locker类型的参数值
4 sync.Mutex类型的值以及sync.RWMutex类型的值都可以满足这个要求。另外,后者的RLocker方法可以返回这个值中的读锁,同样可以作为sync.NewCond 函数的参数值,如此就可以生成与读写锁中的读锁对应的条件变量了
5 条件变量的Signal方法只会唤醒一个因等待通知而被阻塞的goroutine,而它的Broadcast方法却可以唤醒所有为此而等待的goroutine
6 条件变量通知具有即时性,当通知被发送的时候,如果没有任何goroutine需要被唤醒,那么该通知就会立即失效。

发表评论

您的电子邮箱地址不会被公开。 必填项已用*标注