Go并发编程

同步(sync)

sync.Mutex和sync.RWMutex

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

读写锁与互斥锁的区别

读写锁是读/写互斥锁的简称,在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语言中,读写锁是互斥锁的一种扩展。

总结

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

  1. 不要忘记锁定或忘记解锁,这会导致goroutine的阻塞甚至死锁

  2. 不要传递互斥锁,这会产生它的副本,从而产生奇异或者导致互斥失效

  3. 让每一个互斥锁都只包含一个临界区,或一组相关临界区

  4. 不要解锁未锁定的锁,会引发不可恢复的panic

sync.Cond

条件变量与互斥锁

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

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

条件变量有三个方法:

  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方法。使用条件变量实现单向通知,双向通知需要两个条件变量,这是条件变量的基本使用规则。

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可能会在共享资源的状态不满足其要求的情况下被唤醒。

Signal方法与Broadcast方法的区别

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

  • 不同点:

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

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

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

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

  • 如果确定只有一个goroutine在等待,或者只许唤醒任意一个goroutine就可以满足要求,那么使用Signal方法。

  • 否则,使用Broadcast方法,只要设置好各个goroutine所期望的共享资源状态即可。

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

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

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

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()
}

注意点

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

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

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

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

因此,如果某个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值的两种操作的并发执行

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方法中剩余的代码。

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

Do方法在功能方面的特点

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

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

小结

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包中的其他高级同步工具,都是这样实现的

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类型并非开箱即用,这个类型只有这一个公开字段。

例子

标准库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中的临时对象有能够被及时地清理(垃圾回收期,在每次开始执行前,对所有创建的临时对象池中的值进行全面地清除),以节省内存空间。在这个维度上,临时对象池可以帮助程序实现可伸缩性。

临时对象的销毁

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

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

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

  1. 池清理函数会遍历池汇总列表,对于其中的每一个临时对象池,都会先将池中所有的私有临时对象和共享临时对象列表置为nil

  2. 然后再把这个池中的所有本地池列表都销毁掉

  3. 最后,池清理函数会把池汇总列表重置为空的切片,这样池中存储的临时对象就全部被清除干净了

  4. 如果临时对象池以外地的代码再无对它们的引用,那么在稍后的垃圾回收过程中,这些临时对象就会被当做垃圾销毁掉它们占用的内存空间就会被回收已备他用

临时对象池的数据结构

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

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

  • P存在的一个重要原因是为了分散并发程序的执行压力

  • 让临时对象池中的本地池列表的长度与P的数量相等的主要原因就是分散压力

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

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

  1. private:存储私有临时对象

  2. shared:共享临时对象列表

  3. sync.Mutex类型的嵌入字段

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

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

临时对象池如何存取值

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。

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{},这意味着可以包罗万象,所以必须在程序中自行保证它的键类型和值类型的正确性。

sync.Map对键类型的要求

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

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

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

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

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

  1. 使用reflect.Typeof()函数得到一个键值对应的反射类型值(即:reflect.Type类型的值)

  2. 调用这个值的Comparable方法得到确切的判断结果

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

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

方案一

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语言编译器去做类型检查,并用类型断言表达式作为辅助。

方案二

封装的结构体类型的所有方法都可以与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包中的函数和数据类型,外加一些简单的判等操作。

    • 更灵活,但是反射操作降低程序的性能。

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

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

  • 一个原生map被存在了sync.Map的read字段中,该字段是sync/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中,这个时候,该键值对的“已删除”标记会被抹去

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

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

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

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

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

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

atomic

并发编程里经常用到的技术,除了Context、计时器、互斥锁、通道外还有一种技术--原子操作在一些同步算法中会被用到。

对于一个Go程序来说,Go语言运行时系统中的调度器会恰当地安排其中所以的goroutine运行。

不过,在同一时刻,只可能有少数的goroutine真正地处于运行状态,并且这个数量只会与M的数量一致,而不会随着G的增多而增长。

为了公平期间,调度器总是会频繁地换上或者换下这些goroutine。

  • 换上:让一个goroutine由非运行状态转为运行状态,并促使其中的代码在某一个CPU核心上执行

  • 换下:是一个goroutine中的代码中断执行,并让它由运行状态转为非运行状态

这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都可以,即使这条语句在临界区之内

互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。

在众多同步工具中,真正能够保证原子性的只有原子操作。

原子操作在进行的过程中不允许中断。在底层,这会由CPU提供芯片级别的支持(一个原子操作只会由一个独立的CPU指令代表和完成),所以绝对有效。

即使在拥有多CPU核心或者多个CPU的计算机系统中,原子操作的保证也是不可撼动的。

原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性

原子操作是无锁的,直接通过CPU指令直接实现,在执行速度上比其他的同步工具快很多,通常会高出好几个数量级。

事实上,其它同步技术的实现常常依赖于原子操作。

正是因为原子操作不能被中断,所以需要足够简单,并且要求快速

如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多么大的影响。因此操作系统层面只针对二进制整数的原子操作提供支持。

Go对原子操作的支持

Go语言的原子操作也是基于操作系统和CPU的,所以只能对少数数据类型的值提供原子操作函数。这些函数在sync/atomic包中,用于同步访问整数和指针。

Go语言提供的原子操作都是非入侵式的,原子操作可以确保gorotuine之间不存在数据竞争。

竞争条件是由于异步的访问共享资源,并试图同时读写该资源而导致的,使用互斥锁和通道的思路都是在线程获得到访问权后阻塞其他线程对共享内存的访问,而使用原子操作解决数据竞争问题则是利用了其不可被打断的特性。

支持的5种原子操作:

  1. 加法(add)

  2. 比较并交换(compare and swap,简称CAS)

  3. 加载(load)

  4. 存储(store)

  5. 交换(swap)

这些函数针对的数据类型并不多,但是对这些类型中的每一个都有一套函数给予支持。

原子操作支持的6种数据类型:

  1. int32

  2. int64

  3. uint32

  4. uint64

  5. uintptr

  6. unsafe包中的Pointer:对unsate.Pointer类型没有提供原子加法操作的函数

sync/atomic包中还提供了名为Value的类型,用于存储任意类型的值。

注意点1

传入原子操作函数的第一个参数值对应的都应该是那个被操作的值,如:

func AddInt32(addr *int32, delta int32) (new int32)
// 以原子方式将增量添加到 *addr 并返回新的值

上面函数的一个参数应该是那个需要被增大的整数。这个参数类型为什么是*int32?因为原子操作函数需要的是被操作值的指针,而不是这个值本身,被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。传入值本身没有任何意义

unsafe.Pointer类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。

只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址,就能通过底层的指令,准确地操作这个内存地址上的数据

注意点2

用于原子加法操作的函数可以做原子减法吗?

func AddInt32(addr *int32, delta int32) (new int32)
// 以原子方式将增量添加到 *addr 并返回新的值

上面函数的第二个参数代表增量,它的类型是int32,是有符号的,将增量值设置为负数就能实现原子减法操作。

但是下面两个函数就不能直接将增量值赋予为负数进行减法,因为第二个表示增量的参数是无符号的:

func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint32(addr *uint32, delta uint32) (new uint32)

假设要将上面的增量赋予-3,可以进行类型转换:uint32(int32(-3)),这样转换后Go编译器会报错“常量-3不在unit32类型可表示范围内”,即表达式的结果值溢出。

换个操作方式,先将int32(-3)赋予增量,在将增量的类型转换为unit32,这样可以绕过编译器的检查并得到正确的结果。将这个结果作为第二个参数值传递给原子加法操作来实现原子减法目的。

官方文旦展示了另一种操作方式,^uint32(-N-1),其中N表示增量的负整数,即:

  1. 先把增量的绝对值减去1

  2. 再把得到的无类型的整数常量转换我uint32类型的值

  3. 在这个值上做按位异或操作得到最终的参数值

注意点3

CAS与swap的不同及优势:

CAS是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。(所谓的交换就是把新的值赋予给变量,并返回旧的值)

在进行CAS操作的时候,函数会先判断被操作的变量的当前值是否与预期的旧值相等。

  • 如果相等,就把新值赋给该变量,并返回true以表明交换操作已进行

  • 如果不相等,就忽略交换操作,并返回false

CAS操作并不是单一的操作,而是一种操作组合。这与其他的原子操作都不同,因此它的用途更广泛。例如,将它与for循环联用实现一种建议的自旋锁(spinlock):

for {
    if atomic.CompareAndSwapInt32(&num2, 10, 0) {
        fmt.Println("The second number has gone to zero.")
        break
    }
    time.Sleep(time.Millisecond * 500)
}

// 在for循环中的CAS操作可以不停检查某个需要满足的条件,一旦条件满足就退出for循环
// 只要条件未满足,当前的流程就会被一直阻塞在这里

这与互斥锁类似,但适用场景不同:

  • 互斥锁 :总是假设共享资源的状态会被其他的goroutine频繁地改变

  • for循环+CAS操作:总是假设共享资源状态的改变不是很频繁,或者,它的状态总会变成期望的那样,这是一种乐观假设

注意点4

对一个变量的写操作都是原子操作(如,加减、存储、交换等),那么对它的读操作还需要是原子操作吗?

有必要。就像读写锁中写操作与读操作是互斥的。这是为了防止读操作读到未被修改完的值

所以要对共享资源进行保护,那就要完全的保护,不完全的保护基本上和不保护没有什么区别。

原子操作支持的数据类型很有限,所以在很多场景下,互斥锁更加适用

一旦确定某个场景下可以适用原子操作函数,比如只涉及并发地读写单一的整数类型值,或者多个互不相关的整数类型值,就不要考虑互斥锁,因为原子操作函数的执行速度要比互斥锁快很多。使用起来也更简单,不会涉及临界区的选择,死锁等问题。使用CAS操作要注意可能引起“阻塞”流程

sync/atomic.Value

为了扩大原子操作的适用范围,Go 1.4在sync/atomic包中添加了一个新的类型Value,此类型的值相当于一个容器,可以被用来原子地存储和加载任意的值

atomic.Value类型是开箱即用的,声明一个该类型的变量(简称原子变量)之后就直接使用,该类型只有两个指针方法:StoreLoad

注意点

atomic.Value类型的值(原子值)被正真使用(用原子变量存储了值,就相当于真正使用),就不应该再被复制了。

atomic.Value类型属于结构体类型,而结构体类型属于值类型。所以复制这个值会产生一个完全分离的新值,两者怎么改变都不会相互影响。

用原子值来存储值,有两个强制性的使用规则:

  1. 不能用原子值存储nil:即不能把nil作为参数值传入原子值的Store方法,否则会引发一个panic

    注意,如果有一个接口类型的变量,它的动态值的nil,但动态类型却不是nil,那么它的值就不等于nil,所以这样一个变量的值可以被存入原子值中。

  2. 向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值

    例如,对于一个第一次存储了string类型值的原子值,在调用Store发放存储其他类型时会引发一个panic,提示这次存储的值的类型与之前的不一致。

    在原子值内部依据被存储值的实际类型进行判断,所以即使实现了同一个接口的不同类型,它们的值也不能被先后存储在同一个原子值中

遗憾的是:

  1. 无法通过某个方法知道一个原子值是否已经被真正使用

  2. 无法通过常规图解得到一个原子值存储的实际类型

这使得误用原子值的可能性大大增加,尤其在多个地方使用同一个原子值。

建议

  1. 不要把内部使用的原子值暴露给外界,比如声明一个全局的原子变量并不是一个正确的做法,这个变量的访问权限至少应该是包级私有。

  2. 如果不得不让包外,或者模块外的代码使用原子值,可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下,不要把原子值传递到外界,不论是传递原子值本身还是它的指针值

  3. 如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法型,如果不合法,直接返回对应的错误理性,从而避免panic的发生。

  4. 如果可能的话,把原子值封装到一个数据类型中,比如结构体,这样既可以通过该类型的方法更加安全地存储值,有可以在该类型中包含可存储值的合法信息。

尽量不要在原子值中存储引用类型的值,因为容易造成安全漏洞。如下代码所示:

var box6 atomic.Value
v6 := []int{1, 2, 3}    // 切片,引用类型
box6.Store(v6)
v6[1] = 4 // 注意,此处的操作不是并发安全的!

// 上述操作修改了切片中的值,也就修改了box6中存储的值
// 这样绕过了原子值而进行了非并发安全的操作

// 修改为这样

store := func(v []int) {
    replica := make([]int, len(v)) // 为切片值创建一个副本,副本涉及的数据与原值毫不相关
    copy(replica, v)
    box6.Store(replica)    // 把副本存储到box6中
}
store(v6)
v6[2] = 5 // 此处的操作是安全的。

// 修改切片中的值,不会修改box6中的值

原子操作与互斥所的区别

  • 互斥锁是一种数据结构,用来让一个线程执行程序的关键部分,完成互斥的多个操作。

  • 原子操作是针对某个值的单个互斥操作,这意味着没有其他线程可以打断它。

原子操作的优势,更轻量:比如CAS可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。

原子操作的劣势:比如CAS操作的做法趋于乐观,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换,那么在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。

把互斥锁理解为悲观锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。

atomic包提供了底层的原子性内存原语,这对于同步算法的实现很有用。这些函数一定要非常小心地使用,使用不当反而会增加系统资源的开销。

对于应用层来说,最好使用channelsync包中提供的功能来完成同步操作。

Context

使用WaitGroup可以实现一对多的goroutine协作流程同步,如果一开始不能确定子任务的goroutine数量,那么使用WaitGroup值来协调它们和分发子任务的goroutine就存在一定的风险。

一个解决方案是:分批地启用执行子任务的goroutine。

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline:返回 Context 被取消的时间,也就是完成工作的截止日期

  • Done:返回一个 channel,这个 channel 会在当前工作完成或者上下文被取消之后关闭,多次调用 Done 方法会返回同一个 channel

  • Err:返回 Context 结束的原因,它只会在 Done 返回的 channel 被关闭时才会返回非空的值,如果 Context 被取消,会返回 Canceled 错误;如果 Context 超时,会返回 DeadlineExceeded 错误

  • Value:可用于从 Context 中获取传递的键值信息

使用Context包实现一对多goroutine协作流程

func coordinateWithContext() {
    total := 12
    var num int32
    fmt.Printf("The number: %d [with context.Context]\n", num)
    // 调用context.Background和context.WithCancel创建一个可撤销的context对象ctx和一个撤销函数cancelFunc
    ctx, cancelFunc := context.WithCancel(context.Background())
    for i := 1; i <= total; i++ {
        //  每次迭代创建一个新的goroutine
        go addNum(&num, i, func() {
            // 在子goroutine中原子性的Load num变量
            if atomic.LoadInt32(&num) == int32(total) {
                // 如果num与total相等,表示所有子goroutine执行完成
                // 调用context的撤销函数
                cancelFunc()
            }
        })
    }
    // 调用Done函数,并试图针对该函数返回的通道进行接收操作
    // 一旦cancelFunc被调用,针对该通道的接收操作就会马上结束
    <-ctx.Done()
    fmt.Println("End.")
}

context.Context类型在1.7版本引入后,许多标准库都进行了扩展支持,包括:os/execnet,database/sql,runtime/pprofruntime/trace

context类型是一种非常通用的同步工具,它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号。更具体的说,Context类型可以提供一类代表上下文的值,此类值是并发安全的,可以被传播到多个goroutine

  • Context类型是一个接口类型,context包中实现该接口的所有的私有类型,都是基于某个数据类型的指针类型,所以如此传播并不会影响该类型值的功能和安全。

  • Context类型的值是可以衍生,可以通过Context值产生出任意个子值,这些子值可以携带其父值的属性和数据,也可以响应通过其父值传达的信号。

Context值共同构成了一棵代表了上下文全貌的树形结构。这棵树的树根(上下文根节点)是一个已经在context包中预定义好的Context值,它是全局唯一的。通过调用context.Background函数可以获取它。此处的上下文根节点只是最基本的支点,不通过任何额外的功能,既不能被撤销也不能携带任何数据。

context包中包含四个用于衍生context值的函数:

  • WithCancel:产生一个可撤销的parent的子值

  • WithDeadline,WithTimeout:产生一个会定时撤销的parent的子值

  • WithValue:产生一个会携带额外数据的parent的子值

这些函数的第一个参数类型都是context.Context,名称都是parent,这个位置上的参数都是它们将产生的Context值的父值。

撤销信号

Context接口类型中有两个方法与撤销相关:

  1. Done方法返回一个元素类型为struct{}的接收通道,这个通道的用途不是传递元素值,而是让调用方去感知撤销当前Context值的那个信号。一旦当前Context值被撤销,接收通道会立即关闭,(对于一个未包含任何元素值的通道,它的关闭使任何针对它的接收操作立即结束)。

  2. Err方法,让Context值的使用方感知到撤销信号的同时得到撤销的具体原因,该方法的结果是error类型的,并且其值只可能等于:

    1. context.Canceled变量的值:表示手动撤销

    2. 或者context.DeadlineExceeded变量的值:表示给定的过期时间已到而导致撤销

context.WithCancel函数产生一个可撤销的Context值,还会获得一个用于出发撤销信号的函数,通过调用该函数,可以触发针对这个Context值的撤销信号,一旦触发,撤销信号会立即被传达给这个Context值,并由它的Done方法的结果值(一个接收通道)表达出来。

撤销函数只负责触发信号,对应的可撤销的Context值也只负责传达信号,它们不会管后边具体的撤销操作,代码在感知到撤销信号后,可以进行任意的操作,Context值对此并没有任何的约束。

更进一步,这里的撤销最原始的含义是:

  • 终止程序对某种请求(比如HTTP请求)的响应,

  • 取消对某个指令(如SQL指令)的处理,

这是创建Context包和Context类型时的初衷

撤销信号在上下文树中的传播

Context包中包含四个用于衍生Context值的函数,其中的WithCancelWithDeadlineWithTimeout都是被用来基于给定的Context值产生可撤销的子值。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// 返回一个可被撤销的context值和一个出触发撤销信号的函数

撤销函数被调用后,Context值会先关闭它内部的接收通道,即Done方法会返回的那个通道。然后,它会向它的所有子值(或者说子节点)传达撤销信号。这些子值会继续把撤销信号传播下去,最后这个context值会断开它与其父值之间的关联。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithDeadlineWithTimeout函数生成的Context值也是可撤销的,它们不但可以被手动撤销,还会依据在生成时被给定的过期时间,自动地进行定时撤销(定时撤销的功能借助内部的计时器来实现),撤销的同时释放内部的计时器。

注意,通过调用context.WithValue函数得到的Context值是不可撤销的。撤销信号在被传播时,如遇到它们则会直接跨过,并试图将信号直接传给它们的子值

通过Context携带数据,并获取数据

func WithValue(parent Context, key, val interface{}) Context

WithValue函数在产生新的包含数据的Context值的时候,需要三个参数,即:父值、键(与map对键的约束类似,类型必须可判等)、值。因为从中获取数据的时候,根据给定的键来查找对应的值。不过这里并没有用map来存储数据。

Context类型的Value方法就是被用来获取数据的,在调用含数据的Context值的Value方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。如果父值中仍未存储相等的键,那么继续向上直到查找到根节点。

除了包含数据的Context可以存储数据,其他的Context值都不能携带数据,Context的Value方法在向上查找的过程中会直接跳过这几种类型的Context值。

如果调用的Value方法所属的Context本身就不包含数据,那么实际调用的就会是其父值的Value方法。因为这几种Context值的实际类型是结构体,它们通过将父值嵌入到自身来表达父子关系。

Context接口并没有提供改变数据的方法,因此在通常情况下,只能通过上下文树中添加含数据的Context值来存储新的数据,或者通过撤销此种值的父值丢弃相应的数据。如果存储在这里的数据可以从外部改变,那么必须自行保证安全

总结

Context类型的实际值分为三种:

  1. 根Context值

  2. 可撤销的Context值

    1. 手动撤销,手动调用撤销函数

    2. 定时撤销,设置定时撤销的时间,且不可更改,可在过期时间到达之前手动进行撤销

  3. 含数据的Context值,可以携带数据,每个值可以存储一对键值对,调用Value方法,它会沿着树根的方向逐个值进行查找,如果发现相等立即返回,否则将在最后返回nil

所有的Context值共同构成一颗上下文树,这棵树的作用域是全局的,根Context值是全局唯一的,不提供任何额外的功能。

撤销操作是Context值能够协调多个goroutine的关键,撤销信号总是会沿着上下文树叶子节点的方向传播。

含数据的Context不能被撤销,能被撤销的Context无法携带数据,它们共同组成一个整体(上下文树)。

最后更新于

这有帮助吗?