17-sync

0.1. sync.Mutex和sync.RWMutex

0.1.1. 竞态条件、临界区和同步工具

Go语言宣扬“用通信的方式共享数据”,但是,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,大部分现代编程语言都使用后一种方式作为并发编程的解决方案。

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

共享数据的一致性代表着:多个线程对共享数据的操作总是可以达到它们各自预期的效果

同步的用途有两个:

  1. 避免多个线程,在同一时刻操作同一个数据块
  2. 协调多个线程,避免它们在同一时刻执行同一个代码块

这些数据块和代码块的背后都隐含着一种或多种资源(如存储资源,计算资源、I/O资源、网络资源等),把它们看成是共享资源,同步其实就是在控制多个线程对共享资源的访问

如果某个共享资源的访问,在同一时刻只能有一个线程进行,那么多个并发线程对该共享资源的访问是完全串行的。只要一个代码块需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section),由于要访问到资源而必须进入的那个区域。

如果针对同一个共享资源,这样的代码块有多个,那么它们成为相关临界区。

  1. 它们可以是一个内含了共享数据的结构体及其方法,
  2. 也可以是操作同一块共享数据的多个函数。

临界区总是需要受到保护,否则就会产生竞态条件,施加保护的重要手段之一,就是使用实现了某种同步机制的工具,称为同步工具。

image

在Go语言中,可供选择的同步工具不少,最重要最常用的同步工具是互斥量(mutual exclusion,简称mutex)。sync包中的Mutex就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。

一个互斥锁可以被用来包含一个临界区或者一组相关临界区,通过它来保证在同一时刻只有一个goroutine处于该临界区内。每当有goroutine想进入临界区时,都需要先对它进行锁定,离开时要及时进行解锁。

  • 锁定操作可以通过调用互斥锁的Lock方法实现
  • 解锁操作可以通过调用互斥锁的Unlock方法实现
mu.Lock()
_, err := writer.Write([]byte(data))
if err != nil {
    log.Printf("error:%s[%d]",err,id)
}
mu.UnLock()

0.1.2. 使用互斥锁注意事项

  • 不要重复锁定互斥锁:

对于一个已经锁定的互斥锁进行锁定,会立即阻塞当前的goroutine,这个goroutine执行的流程会一直停滞在调用该互斥锁的Lock方法的那行代码上。直到互斥锁的Unlock方法被调用,并且这里的锁定操作成功之后,临界区的代码才会执行。

  • 不要忘记解锁互斥锁,必要时使用defer语句

可以避免出现重复锁定。因为忘记解锁会使得其他goroutine无法进入到互斥锁保护的临界区中,轻则功能失效,重则死锁崩溃。程序的流程可以分叉也可以被中断,所以一个流程在锁定某个互斥锁之后,紧跟着defer语句进行解锁是比较稳妥的。

  • 不要对尚未锁定或者已解锁的互斥锁解锁

解锁未锁定的互斥锁会立即引起panic。与死锁的panic一样,无法被恢复。因此对于每一个锁定操作有且只有一个对应的解锁操作

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

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

Go语言运行时系统不允许这种情况出现,当发现所以用户级goroutine都处于等待会抛出如下panic:

fatal error: all goroutines are asleep - deadlock!

Go语言运行时系统自行抛出的panic都属于致命错误,无法被恢复,调用recover函数对它们起不到任何作用,程序死锁,必然崩溃

当每个互斥锁都只保护一个临界区或者一组相关临界区可以有效避免死锁。

0.1.3. 传递互斥锁

Go语言中的互斥锁是开箱即用的,声明一个sync.Mutex类型(该类型是一个结构体类型,属于值类型)的变量就可以直接使用了。

对于值类型的操作,把它传给一个函数,将它从函数中返回,把它赋给其他变量,让它进入某个通道都会导致它的副本的产生。原值与副本、副本与副本之间都是完全独立的,它们都是不同的互斥锁

如果把一个互斥锁作为参数传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何的影响。

0.1.4. 读写锁与互斥锁的区别

读写锁是读/写互斥锁的简称,在Go语言中,读写锁由sync.RWMutex类型的值代表,也是开箱即用。

读写锁把读操作和写操作区别对待,可以对这两种操作施加不同的保护。相比于互斥锁,读写锁实现更加细粒度的访问控制。一个读写锁中包含两个锁:

  1. 写锁:sync.RWMutex类型中的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁
  2. 读锁:sync.RWMutex类型中的RLock方法和RUnlock方法分别用于对于读锁进行锁定和解锁

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

  1. 写锁已被锁定的情况下,再试图锁定写锁,会阻塞当前goroutine
  2. 写锁已被锁定的情况下,再试图锁定读锁,会阻塞当前goroutine
  3. 读锁已被锁定的情况下,再试图锁定写锁,会阻塞当前的goroutine
  4. 读锁已被锁定的情况下,再试图锁定读锁,不会阻塞当前goroutine

也就是说,对于某个受到读写锁保护的共享资源:

  1. 多个写操作不能同时进行
  2. 读操作和写操作不能同时进行
  3. 多个读操作可以同时进行

通常不能同时进行的操作称为互斥操作

  • 对于写锁进行解锁操作,会唤醒所有因试图锁定读锁而被阻塞的goroutine,通常会使它们都成功完成对读锁的锁定。
  • 对于读锁进行解锁操作,只会在没有其他读锁锁定的前提下,唤醒因试图锁定写锁而被阻塞的goroutine,并且最终只会有一个被唤醒的goroutine(等待时间最长的那个)成功完成对写锁的锁定,其他goroutine继续等待。

读写锁中对写操作之间的互斥是通过内含的一个互斥锁实现的,在Go语言中,读写锁是互斥锁的一种扩展。

0.1.5. 总结

互斥锁常常被用来保证多个goroutine并发访问同一个共享资源时的完全串行。

  1. 不要忘记锁定或忘记解锁,这会导致goroutine的阻塞甚至死锁
  2. 不要传递互斥锁,这会产生它的副本,从而产生奇异或者导致互斥失效
  3. 让每一个互斥锁都只包含一个临界区,或一组相关临界区
  4. 不要解锁未锁定的锁,会引发不可恢复的panic

0.2. sync.Cond

0.2.1. 条件变量与互斥锁

条件变量是另一个同步工具,它是基于互斥锁的,它不是用来保护临界区和共享资源的,而是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被用来通知互斥锁阻塞的线程。

条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的

条件变量有三个方法:

  1. 等待通知(Wait):在互斥锁保护下进行
  2. 单发通知(signal):在互斥锁解锁后进行
  3. 广播通知(broadcast):在互斥锁解锁后进行
var mailbox uint8
var lock sync.RWMutex   // 读写锁
sendCond := sync.NewCond(&lock)     // 条件变量
recvCond := sync.NewCond(lock.RLocker())        // 条件变量

// func NewCond(l Locker) *Cond  返回带有锁的条件变量的指针值

// goroutine 1
lock.Lock() // 锁定写锁
for mailbox == 1 {  // 如果有情报就等待
 sendCond.Wait()    // 解锁写锁,加入通知队列,阻塞当前代码行
}
mailbox = 1     // 如果没有情报就放入情报。1表示放入情报
lock.Unlock()   // 解锁写锁
recvCond.Signal()   // 发起通知情报已经放好

// goroutine 2
lock.RLock()    // 锁定读锁
for mailbox == 0 {  // 如果没有情报就等待
 recvCond.Wait()    // 解锁读锁,加入通知队列,阻塞当前代码行
}
mailbox = 0     // 如果有情报就取出情报。0表示取出情报
lock.RUnlock()  // 解锁读锁
sendCond.Signal()   // 发起通知情报已经取走

只要条件不满足,就会调用wait方法,需要发起通知就调用signal方法。使用条件变量实现单向通知,双向通知需要两个条件变量,这是条件变量的基本使用规则。

0.2.2. Wait方法

条件变量的Wait方法主要做了四件事情:

  • 把调用它的goroutine(即当前goroutine)加入到当前条件变量的通知队列中
  • 解锁当前的条件变量基于的那个互斥锁(条件变量的Wait方法在阻塞当前goroutine前,会解锁它基于的互斥锁,所以在调用Wait之前,必须先锁定互斥锁,否则调用Wait方法会引发不可恢复的painc。)

如果Wait方法不先解锁互斥锁,那么只会有两种后果,不是当前程序因panic而崩溃,就是相关的goroutine全面阻塞。

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

需要使用for循环包裹Wait方法来多次检查共享资源的状态,因为当一个goroutine收到通知被唤醒,但却发现共享组员的状态依然不符合要求,那么应该再次调用条件变量Wait方法,并继续等待下一次通知的到来。例如下面的情况:

  1. 多个goroutine等待共享资源的同一种状态。
  2. 共享资源有多种状态,单一的结果不可能满足所以goroutine的条件。
  3. 某些多CPU核心的计算机系统中,没有收到条件变量的通知,调用其Wait方法的goroutine也可能会被唤醒,这是计算机硬件层面决定的,即使是操作系统本身提供的条件变量也会如此。

综上,在包裹条件变量的Wait方法时,总是应该使用for语句,因为等待通知而被阻塞的goroutine可能会在共享资源的状态不满足其要求的情况下被唤醒。

0.2.3. Signal方法与Broadcast方法的区别

  • 共同点:都是被用来发送通知的
  • 不同点:

    1. 前者的通知只会唤醒一个因此而等待的goroutine

    条件变量的Wait方法总是会把当前goroutine添加到通知队列的队尾,而signal方法总会从通知队列的队首开始,查找可被唤醒的goroutine。因此会唤醒最早等待的那一个。

    1. 后者的通知会唤醒所有为此等待的goroutine

这两个方法的行为决定了它们的使用场景。

  • 如果确定只有一个goroutine在等待,或者只许唤醒任意一个goroutine就可以满足要求,那么使用Signal方法。
  • 否则,使用Broadcast方法,只要设置好各个goroutine所期望的共享资源状态即可。

与Wait方法不同,Signal方法和Broadcast方法并不需要在互斥锁的保护下进行。最好在解锁条件变量基于的互斥锁之后在调用它们。这更有利于程序的运行效率

条件变量的通知具有即时性,即在发送通知的时候没有goroutine为此等待,那么该通知就会被直接丢弃。在这之后才开始等待的goroutine只可能被后面的通知唤醒。

通过对互斥锁的合理使用,可以是一个goroutine在执行临界区中的代码时,不被其他的goroutine打扰。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)

0.3. sync.WaitGroup 和 sync.Once

使用通道进行多goroutine协作:声明一个通道,使它的容量与手动启动的goroutine的数量相同,之后再利用这个通道,让主goroutine等待其他goroutine的运行结束。

func coordinateWithChan() {
 sign := make(chan struct{}, 2)
 num := int32(0)
 fmt.Printf("The number: %d [with chan struct{}]\n", num)
 max := int32(10)
 go addNum(&num, 1, max, func() {
  sign <- struct{}{}
 })
 go addNum(&num, 2, max, func() {
  sign <- struct{}{}
 })
 <-sign
 <-sign
}

以上操作,略显丑陋。

使用sync包的WaitGroup类型,它比通道更加适合这种一对多的goroutine协作流程。

sync.WaitGroup开箱即用,并发安全,同样的它一旦真正被使用,就不能再被复制。

WaitGroup有三个指针方法:

  • Add:可以想象在该类型中有一个计数器,默认值为0,可以调用该值类型的Add方法来增加或者减少这个计数器的值。一般用来记录等待的goroutine的数量。
  • Done:对计数器中的值进行减操作,可以在需要等待的goroutine中,通过defer语句调用它。
  • Wait:阻塞当前的goroutine,直到其所属值中的计数器归零。如果在该方法调用的时候,计数器的值已经是0,那么就不做任何事情。

将上面代码修改为WaitGroup版本:

func coordinateWithWaitGroup() {
 var wg sync.WaitGroup
 wg.Add(2)
 num := int32(0)
 fmt.Printf("The number: %d [with sync.WaitGroup]\n", num)
 max := int32(10)
 go addNum(&num, 3, max, wg.Done)
 go addNum(&num, 4, max, wg.Done)
 wg.Wait()
}

0.3.1. 注意点

sync.WaitGroup类型值中计数器的值不能小于0。如果小于0,会引发一个panic。不适当的调用Done方法和Add方法都会引起这个问题,因为在Add方法中可以传入一个负数。

如果同时调用Add方法和Wait方法,假设在两个goroutine中,分别调用这两个方法,那么就可能会让这个Add方法抛出panic。这种情况不容易复现,虽然WaitGroup值本身并不需要初始化,但是尽早增加其计数器的值,是非常有必要的。

WaitGroup值是可以被复用的,但是需要保证其计数周期的完整性。在WaitGroup值的生命周期中,它可以经历任意多个计数周期。但是只有在它走完当前的计数周期之后,才能够开始下一个计数周期

计数周期:WaitGroup值中的计数器值由0变为某个正整数,然后经过一系列的变化,最终由某个正整数又变回0。

images

因此,如果某个WaitGroup值的Wait方法在某个计数周期中被调用,会立即阻塞当前的goroutine,直到这个计数周期完成,在这种情况下,该值的下一个计数周期必须等到Wait方法执行结束之后,才能够开始

如果在一个此类值的Wait方法被执行期间,跨越了两个计数周期,会引发一个panic。举个例子:

  1. 在当前goroutine因调用WaitGroup值的Wait方法,而被阻塞的时候
  2. 另一个goroutine调用该值的Done方法,并使计数器的值变为0
  3. 这会唤醒当前的goroutine,并使它试图继续执行Wait方法中其余的代码
  4. 这时又有一个goroutine调用了它的Add方法,并让计数器的值又从0变成了某个正整数
  5. 此时,这里的Wait方法就会立即抛出一个panic

WaitGroup使用禁忌:不要把增加计数器值的操作和调用Wait方法的代码,放在不同的goroutine中执行。杜绝同一个WaitGroup值的两种操作的并发执行

0.3.2. sync.Once类型值的Do方法如何保证只执行参数函数一次

sync.WaitGroup类型一样,sync.Once类型也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个sync.Mutex类型的字段,所以复制该类型的值也会导致功能的失效。

Once类型的Do方法只接受一个参数,这个参数的类型必须是func(),即:无参数声明和结果声明的函数。该方法的功能并不是对每一种参数函数都只执行一次,而是只执行首次被调用时传入的那个函数,并且之后不会再执行任何参数函数。如果有多个只需执行一次的函数,为它们每个都分配一个sync.Once类型的值。

Once类型中还有一个叫done的unit32类型的字段,它的作用是记录其所属值的Do方法被调用的次数,该值只能是0或者1。一旦Do方法的首次调用完成,它的值就会从0变成1。

done字段虽然只有0或者1,但是使用了四字节的uint32类型:

  1. 对这个字段的操作必须是原子的,Do方法在一开始就会通过调用atomic.LoadUint32函数来获取该字段的值,并且一旦发现该值为1,就直接返回。初步保证了Do方法,只会执行首次被调用时传入的函数

如果两个goroutine都调用了同一个新的Once的Do方法,几乎同时执行条件判断代码,那么会因为判断结果为false而继续执行Do方法中剩余的代码。

  1. 所以在条件判断之后,Do方法会立即锁定其所属值中的那个sync.Mutex类型的字段m,然后,它会在临界区中再次检查done字段的值,并且仅在条件满足时,才会去调用参数函数,以及用原子操作把done的值变为1。

0.3.2.1. Do方法在功能方面的特点

  1. Do方法只会在参数函数执行结束之后把done字段的值变为1,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关goroutine的阻塞。

  2. Do方法在参数函数执行结束后,对done字段的赋值用的是原子操作,并且,这一操作是被挂在defer语句中的,因此,不论参数函数的执行会以怎样的方式结束,done字段的值都会变为1。即使这个参数函数没有执行成功(引发了一个panic),我们也无法使用同一个once值重新执行它。如果需要为参数函数的执行设定重试机制,要考虑Once值的适时替换问题。

0.3.3. 小结

sync包中的WaitGroup类型和Once类型都是非常易用的同步工具。它们都是开箱即用和并发安全的。

利用WaitGroup值,可以方便地实现一对多的goroutine协作流程,即:一个分发子任务的goroutine和多个执行子任务的goroutine,共同来完成一个较大的任务。

使用WaitGroup值的时候,一定要注意,千万不能让其中的计数器的值小于0,否则就会引发panic。我们最好用“先统一Add,再并发Done,最后Wait”这种标准方式,来使用WaitGroup值。尤其不要在调用Wait方法,同时,并发地通过调用Add方法去增加其计数器的值,因为这也有可能引发panic。

Once值的使用比WaitGroup值更简单,只有一个Do方法,同一个Once值的Do方法永远只会执行第一次被调用时传入的参数函数,不论这个函数的执行会以怎么样的方式结束。

只要传入某个Do方法的参数函数没有结束执行,任何只会调用该方法的goroutine就会被阻塞。只有在这个参数函数执行结束后,那些goroutine才会逐一被唤醒。

Once类型使用互斥锁和原子操作实现了功能,而WaitGroup类型中只用到了原子操作。它们都是更高级的同步工具,基于基本的通用工具,实现了某一种特定的功能。sync包中的其他高级同步工具,都是这样实现的

0.4. sync.Pool

sync.Pool类型(结构体类型,它的值被真正使用之后,就不应再被复制了)被称为临时对象存储池,它的值可以被用来存储临时的对象。

临时对象的意思是:

  1. 不需要持久使用的某一类值,这类值对于程序来说可有可无,如果有的话会明显更好
  2. 它的创建和销毁可以在任何时候发生,并且完全不会影响到程序的功能
  3. 它们无需被区分,其中的任何一个值都可以替代另一个

如果某类值完全满足上述条件,就可以把它们存储到临时对象池中。

可以把临时对象池当作针对某种数据的缓存来用,这是临时对象池最主要的用途。

sync.Pool类型只有两个方法:

func (p *Pool) Put(x interface{})
func (p *Pool) Get() interface{}
  • Put:用于在当前的池中存放临时对象,它接收一个interface类型的参数
  • Get:用于从当前的池中获取临时对象,它返回一个interface类型的值

    Get可能会从当前池中删除掉任何一个值,然后把这个值作为结果返回。如果此时池中没有任何值,那么这个方法就会使用当前池的New字段创建一个值,并直接将其返回。

sync.Pool类型的New字段代表着创建临时对象的函数,它的类型是没有参数但有唯一结果的函数类型:

type Pool struct {
    // 这个函数是Get方法最后的临时对象获取手段
    // 该函数的结果值并不会存入当前的临时对象中
    // 而是直接返回给Get方法的调用方
    New func() interface{}  
    // contains filtered or unexported fields
}

这里New字段的实际值需要我们在初始化临时对象池的时候给定。否则,在调用它的Get方法的时候可能会得到nil。sync.Pool类型并非开箱即用,这个类型只有这一个公开字段。

0.4.1. 例子

标准库fmt中使用sync.Pool类型,这个包会创建一个用于缓存某类临时对象的sync.Pool类型值,并将这个值赋给ppFree变量,这个临时对象可以用于识别、格式化和暂存需要打印的内容:

var ppFree = sync.Pool{
 New: func() interface{} { return new(pp) },
}

// ppFree的New字段在被调用的时候,总是会返回一个全新的pp类型值的指针(即临时对象)
// 这保证了ppFree的Get方法总能返回一个可以包含需要打印内容的值

// pp类型是fmt包中的私有类型它的每一个值在这里都是独立平等可重用的

这些临时对象既互不干扰,又不会受到外部状态的影响。它们几乎只针对某个需要打印内容的缓冲区。由于fmt包中的代码在真正使用这些临时对象之前,总是会对其进行重置,所以它们并不在意取到哪个临时对象。这就是临时对象的平等性的具体体现

这些代码在使用完临时对象后,都会先抹掉其中已缓冲的内容,然后再把它存放到ppFree中。这样就为重用这类临时对象做好了准备。

fmt.Printlnfmt.Printf等打印函数都是这样使用ppFree,以及其中的临时对象。因此程序同时执行很多打印函数调用的时候,ppFree可以及时地把它缓存的临时对象提供给它们,以加快执行的速度。当程序在一段时间内不再执行打印函数,ppFree中的临时对象有能够被及时地清理(垃圾回收期,在每次开始执行前,对所有创建的临时对象池中的值进行全面地清除),以节省内存空间。在这个维度上,临时对象池可以帮助程序实现可伸缩性。

0.4.2. 临时对象的销毁

sync包在被初始化的时候,会向Go语言运行时系统注册一个函数(池清理函数),这个函数的功能就是清除所有已创建的临时对象池中的值,这样Go语言运行时系统在执行垃圾回收之前会先执行池清理函数。

sync包中有一个包级私有的全局变量(池汇总列表),这个变量代表了当前的程序中使用的所有临时对象池的汇总,它是元素类型为*sync.Pool的切片。

在一个临时对象池的Put方法和Get方法第一次被调用的时候,这个池就会被添加到池汇总列表中。因此,池清理函数总是能访问到所有正在被真正使用的临时对象池。即:

  1. 池清理函数会遍历池汇总列表,对于其中的每一个临时对象池,都会先将池中所有的私有临时对象和共享临时对象列表置为nil
  2. 然后再把这个池中的所有本地池列表都销毁掉
  3. 最后,池清理函数会把池汇总列表重置为空的切片,这样池中存储的临时对象就全部被清除干净了
  4. 如果临时对象池一外地的代码再无对它们的引用,那么在稍后的垃圾回收过程中,这些临时对象就会被当做垃圾销毁掉它们占用的内存空间就会被回收已备他用

0.4.3. 临时对象池的数据结构

在临时对象池中有一个多层的数据结构,因为它的存在使得临时对象池能够非常高效地存储大量的值。这个数据结构的顶层,称为本地池列表(它是一个数组),这个列表的长度总是与Go语言调度器中P的数量相同。

Go语言调度器中的P是processor的缩写,指的是一种可以承载若干个G(goroutine),且能够使这些G适时地与M(machine,即系统级线程)进行对接,并得到真正运行的中介。因为P 的存在,G和M才能够进行高校、灵活的配对,从而实现强大的并发编程模型。

  • P存在的一个重要原因是为了分散并发程序的执行压力
  • 让临时对象池中的本地池列表的长度与P的数量相等的主要原因就是分散压力

这里的压力包括两方面:存储和性能。

本地池列表中的每个本地池包含三个字段:

  1. private:存储私有临时对象
  2. shared:共享临时对象列表
  3. sync.Mutex类型的嵌入字段

images

每个本地池都对应着一个P,以为你一个goroutine要真正运行起来必须先与某个P产生关联。在程序调用临时对象池的Put或Get方法的时候,总会先试图从该临时对象池的本地池列表中,获取与之对应的本地池,依据的就是与当前goroutine关联的那个P的ID。

也就是说,一个临时对象池的Put方法或Get方法或获取到哪一个本地池,完全取决于调用它的代码所在的goroutine关联的那个P

0.4.4. 临时对象池如何存取值

Put:

  1. 总会先试图把新的临时对象,存储到对应的本地池的private字段中,以便在后面获取临时对象的时候,可以快速地拿到一个可用的值。
  2. 只有当这个private字段已经存在有某个值时,该方法才会去访问本地池的shared字段。

Get:

  1. 总会先试图从对应的本地池的private字段处获取一个临时对象
  2. 当private字段的值为nil时,它才会去访问本地池的shared字段

一个本地池的shared字段原则上可以被任何goroutine中的代码访问,不论这个goroutine管理的是哪一个P,因此shard也称为共享临时对象列表。因为shared字段是共享的,所以必须收到互斥锁的保护。

一个本地池的private字段只能被与之对应的那个P所关联的goroutine中的代码访问到,所以它是P级私有的。

本地池本身拥有互斥锁功能,即它嵌入的那个sync.Mutex类型的字段。Put方法会在互斥锁的保护下,把新的临时对象追加到共享临时对象列表的末尾。Get方法会在互斥锁的保护下,试图把共享临时对象列表中的最后一个元素值取出并作为结果。

这里的共享临时对象列表也可能是空的,这可能是由于这个本地池中的所有临时对象都已经被取走了,也可能是当前的临时对象池刚被清理过。

  1. 无论什么原因,Get方法都会去访问当前临时对象中的所有本地池,它会之歌搜索它的共享临时对象列表。发现某个共享临时对象列表中包含元素值,它就会把该列表的最后一个元素取出来并作为结果返回。
  2. 即使这样也可能无法拿到一个可用的临时对象,Get会调用创建临时对象的那个函数。这个函数由临时对象池的New字段代表,并且需要在初始化临时对象池的时候给定。如果这个字段为nil,那么Get方法此时也只能返回nil。

images

0.5. sync.Map

Go语言自带的字典类型map不是并发安全的,也就是说,在同一时间段内,让不同goroutine中的代码,对同一个字典进行读写操作是不安全的。字典值本身可能会因为这些操作而产生混乱,相关程序也可能会因此发生不可预知的问题。

使用sync.Mutex或者sync.RWMutex,在加上原生的map就可以轻松实现并发安全的字典。

Go 1.9中发布了并发安全的字典类型sync.Map。这个字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全,同时,它的存、取、删等操作都可以基本保证常数时间内执行完毕。它的算法复杂的与map类型一样都是0(1)

与单纯使用原生map和互斥锁相比,sync.Map可以显著地减少锁的争用。sync.Map本身虽然也用到了锁,但是,它其实在尽可能地避免使用锁。因为使用锁就意味着把一些并发的操作强制串行化,这会降低程序的性能,尤其是在计算机拥有多个CPU核心的情况下。因此能用原子操作(原子操作的局限性,只能对一些基本的数据类型提供支持)的情况下就不要用锁。

无论在何种场景下使用sync.Map,都需要注意它与原生map明显不同,它只是标准库中的一员,而不是语言层面的东西。编译器并不会对它的键和值进行特殊的类型检查。

sync.Map所有的方法涉及的键和值的类型都是interface{},这意味着可以包罗万象,所以必须在程序中自行保证它的键类型和值类型的正确性。

0.5.1. sync.Map对键类型的要求

键的实际类型不能是函数类型、字段类型和切片类型

Go语言原生字典的键类型也不能是函数类型、字典类型和切片类型。

sync.Map内部的存储介质就是原生字典,又因为原生字典的键类型也是interface{},所以绝对不能带这任何实际类型为函数类型、字典类型或切片类型的键值去操作sync.Map因为这些键值的实际类型只有在程序运行期间才能确定,所以Go语言编译器是无法在编译期间对它们进行检查的,不正确的键值实际类型会引发panic

所以在每次操作sync.Map的时候,显式地检查键值的实际类型。更好的操作是针对同一个sync.Map的存、取、删操作都集中起来,然后统一编写检查代码。或者把sync.Map封装在一个结构体中也是一个不错的选择。

必须保证键的类型是可比较的(可判等的),实在拿不准,可以:

  1. 使用reflect.Typeof()函数得到一个键值对应的反射类型值(即:reflect.Type类型的值)
  2. 调用这个值的Comparable方法得到确切的判断结果

0.5.2. 保证sync.Map中键值的类型正确性

使用类型断言表达式或者反射操作来保证它们的类型正确性。

0.5.2.1. 方案一

sync.Map只能存储某个特定类型的键。一旦确定好了键的类型,就可以在存、取、删操作的时候,使用类型断言表达式去对键的类型做检查。

一般情况下,这样的检查并不繁琐,如果把sync.Map封装在一个结构体类型里就更方便了,这样完全可以使用Go语言编译器帮助进行类型检查,如下所示:

// 键类型为int,值类型为string
// 在这个结构体中,只有sync.Map类型的字段m
type IntStrMap struct {
 m sync.Map
}

func (iMap *IntStrMap) Delete(key int) {
 iMap.m.Delete(key)
}

func (iMap *IntStrMap) Load(key int) (value string, ok bool) {
 v, ok := iMap.m.Load(key)
 if v != nil {
  value = v.(string)
 }
 return
}

func (iMap *IntStrMap) LoadOrStore(key int, value string) (actual string, loaded bool) {
 a, loaded := iMap.m.LoadOrStore(key, value)
 actual = a.(string)
 return
}

func (iMap *IntStrMap) Range(f func(key int, value string) bool) {
 f1 := func(key, value interface{}) bool {
  return f(key.(int), value.(string))
 }
 iMap.m.Range(f1)
}

func (iMap *IntStrMap) Store(key int, value string) {
 iMap.m.Store(key, value)
}

这样在这些方法操作键值的时候,就不再需要进行类型检查,也不用担心类型会不正确。

因此,在确定键和值的具体类型的情况下,可以利用Go语言编译器去做类型检查,并用类型断言表达式作为辅助。

0.5.2.2. 方案二

封装的结构体类型的所有方法都可以与sync.Map类型的方法完全一致(包括方法名称和方法签名)。不过在这些方法中需要添加一些类型检查的代码。这样sync.Map的键和值类型必须在初始化的时候就完全确定,并且必须先保证键的类型是可比较的。

所以封装的结构体如下:

// 可自定义键和值类型的sync.Map
type ConcurrentMap struct {
 m         sync.Map

//  键和值都是反射类型,该类型可以代表Go语言的任何数据类型
// 这个类型值容易获得,通过调用reflect.Typeof函数并把样本值传入即可
 keyType   reflect.Type
 valueType reflect.Type
}

func (cMap *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
    // 将一个接口类型值传入reflect.Typeof函数,就可以得到这个值的实际类型对应的反射类型值
 if reflect.TypeOf(key) != cMap.keyType {
  return
 }
 return cMap.m.Load(key)
}

func (cMap *ConcurrentMap) Store(key, value interface{}) {
    // 当key和value的实际类型不符合要求时,store方法会立即引发panic
    // 因为store方法没有结果声明,所以在参数值有问题的时候,无法通过比较平和的方式告知调用方
 if reflect.TypeOf(key) != cMap.keyType {
  panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
 }
 if reflect.TypeOf(value) != cMap.valueType {
  panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value)))
 }
 cMap.m.Store(key, value)
}

// 也可以为store方法添加一个error类型的结果,在发现参数值类型不正确的时候,
// 让它直接返回响应的error类型值而不是引发panic实际中可根据应用场景进行改进和优化
  • 方案一:
    • 适合可以完全确定键和值类型的情况,可以使用Go语言编译器做类型检查,并用类型断言表达式做辅助。
    • 明显的缺陷就是无法灵活地改变字典的键和值的类型,需求多样化则编码工作量增加。
  • 方案二:
    • 无需在程序运行之前明确键和值的类型,只要在初始化sync.Map的时候,动态地给它们就可以,主要使用reflect包中的函数和数据类型,外加一些简单的判等操作。
    • 更灵活,但是反射操作降低程序的性能。

0.5.3. sync.Map如何尽量避免使用锁

sync.Map类型在内部使用大量的原子操作来存取键和值,并使用两个原生的map作为存储介质:

  • 一个原生map被存在了sync.Map的read字段中,该字段是sycn/atomic.Value类型的。这个原生字典可以被看做一个快照,它总会在条件满足时,去重新保存所属的sync.Map值中包含的所有键值对。

    read字段虽然不会增减其中的键,但却允许变更其中的键所对应的值,所以它不是传统意义上的快照,它的只读特性只是对其中键的集合而言的。

    read字段的类型可知,sync.Map在替换read的时候根本用不着锁,并且read字段在存储键值对的时候,还在值之上封装了一层:

    1. 先把值转换为unsafe.Pointer`类型的值
    2. 再把后者封装后存储在其中的原生map中

    这样,在变更某个键所对应的值的时候,就可以使用原子操作了。

  • 另一个原生map存在sync.Map的dirty字段中,它存储键值对的方式与read字段一只,它的键类型是interface{},并且同样把值先做转换和封装,然后再进行存储。

read和dirty字段如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此

这两个字典在存储键和值的时候,只会存入它们的某个指针,而不是基本值。

读取:

  1. sync.Map在查找指定的键锁对应的值的时候,总会先去read中寻找,并不需要锁定互斥锁
  2. 只有在确定read中没有,但dirty中可能还有这个键的时候,它才会在锁的包含下去访问dirty

存储:

  1. sync.Map在存储键值对的时候,只要read中已存有这个键
  2. 并且该键值对未被标记为“已删除”,就会把新值存到里面直接返回,这种情况下也不需要用到锁
  3. 否则,它才会在锁的保护下把键值对存储到dirty中,这个时候,该键值对的“已删除”标记会被抹去

images

只有当一个键值对应该被删除,但却仍然存在与read中的时候,才会被用标记为“已删除”的方式进行逻辑删除,而不会直接被物理删除。这种情况会在重建dirty后的一段时间内出现,过不了多久,就会被真正删除。在查找和遍历键值对的时候,已经被逻辑删除的键值对永远会被无视

对于删除键值对,sync.Map会先去检查read中是否有对应的键,如果没有,dirty中可能有,那么它会在锁保护下,试图从dirty中删除该键值对。最后,sync.Map会把该键值对中指向值的那个指针置为nil,这是另一种逻辑删除方式。

需要注意,read和dirty之间是会相互转换的,在dirty中查找键值对次数足够多的时候,sync.Map会把dirty直接作为read,保存在它的read字段中,然后把代表dirty的dirty字段置为nil。

在这之后,一旦再有新的键值对存入,它就会依据read去重建dirty,这个时候会把read中已经逻辑删除的键值对过滤掉,这些操作都在锁的保护下进行。

images

综上,sync.Map的read和dirty中的键值对集合并不是实时同步的,它们在某些时间段内可能会不同,由于read中的键值对的集合不能被改变,所以其中的键值对有时候可能是不全的,相反,dirty中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。

因此在读操作很多,写操作很少的情况下,sync.Map的性能会更好,在几个写操作当中,新增键值对的操作对sync.Map的性能影响最大,其次是删除操作,最后是修改操作。如果被操作的键值对已经存在于sync.Map的read中,并且没有被逻辑删除,那么修改它并不会使用到锁,对其性能的影响会很小。

上次修改: 25 November 2019