Go核心36讲五-sync.WaitGroup和sync.Once

sync.WaitGroup和sync.Once

  • sync.WaitGroup比通道更加适合实现这种一对多的goroutine写作流程

  • 开箱即用,并发安全

  • 三个指针方法:Add、Done、Wait

    • Add方法增加或减少计数器的值(需要等待的goroutine数量)
    • Done方法,用于对其所属值中计数器的值进行减一操作,我们可以在需要等待的goroutine中,通过defer语句调用它
    • Wait方法功能是阻塞当前goroutine,直到其所属的值中的计数器归零,如果在该方法被调用的时候,那个计数器的值就是0,那么它将不会做任何事情。
      func main() {
          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
    因为这样会引发panic

  • 计数周期:计数器的值始于零又归于0(由0-1-0)

  • 不要把增加其计数器的值操作和调用其Wait方法的代码,放在不同的goroutine中执行。

    知识扩展

  • sync.Once类型的Do方法怎么保证只执行参数函数一次的?

  • sync.Once属于结构体类型,开箱即用,并发安全

  • 其中的Do方法只接受一个无参数声明和结果声明的函数(func()),且执行首次被调用时传入的那个函数,并且之后不再执行任何参数函数;

  • Once类型中用done(uint32类型的字段),记录Do方法被调用次数,该值只能为0,1,一旦Do的方法首次调用完成,它的值就会从0变为1

  • 这样只是第一步,第二步Do方法会立即锁定其所属值中的那个sync.Mutex类型的字段m,然后在临界区中再次检查done字段的值。第一检查叫快路径,第二次检查叫慢路径

  • Do方法的2个特点

  • 1 如果参数函数的执行需要很长时间或者根本就不会结束,那么可能会导致相关的goroutine同时阻塞在锁定该Once值的互斥锁m的那行代码上

  • 2 Do方法在参数函数执行结束后,对done字段的赋值用的是原子操作,并且,这一操作是被挂在defer语句中的。因此不论参数函数的执行会以怎么样的方式结束,done字段的值都会变为1;也就是说即便引发了一个panic,也无法用同一个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包中的其他高级同步工具,其实也都是这样的。

context.Context类型

  • 在使用sync.WaitGroup的Add函数时,并不知道有多少个goroutine,所以需要分批启用执行子任务的goroutine

    func main() {
        total := 12
        stride := 3
        var num int32
        var wg sync.WaitGroup
        for i := 1; i <= toal; i= i+ stride {
            wg.Add(stride)
            for j:=0; j < stride; j++ {
                go addNum(&num, i+j, wg.Done)
            }
            wg.Wait()
        }
    
        fmt.Println("End.")
    }
  • context.Context类型是Go1.7发布时才被加入到标准库的。

  • 它是一种非常通用的同步工具,它的值不但可以被任意的扩散,而且还可以被用来传递额外的信息和信号

  • Context类型可以提供一类代表上下文的值,此类值是并发安全的,也就是说它可以被传播给多个goroutine

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

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

  • 所有的Context值共同构成了一颗代表了上下文全貌的树形结构,根节点是一个已经在context包中预定义好的Context值,全局唯一,通过调用context.Background 函数,可以获得到它

  • 这里注意一下,这个上下文根节点仅仅是一个最基本的支点,它不提供任何额外的功能。也就是说,它既不可以被撤销(cancel),也不能携带任何数据。
    除此之外,context包中还包含了四个用于繁衍Context值的函数,即:WithCancel、 WithDeadline withTimeout和withvalue。
    这些函数的第一个参数的类型都是contextContext,而名称都为parent。顾名思义,这个位置上的参数对应的都是它们将会产生的Context值的父值。
    WithCancel函数用于产生一个可撤销的parent的子值。在coordinateWithContext函数中,我通过调用该函数,获得了一个衍生自上下文根节点的Context值,和一个用于触发撤销信号的函数。
    而withDeadline函数和withTimeout函数则都可以被用来产生一个会定时撤销的parent的子值。至于withValue函数,我们可以通过调用它,产生一个会携带额外数据的parent的子值。

  • 可撤销的在Context包中代表什么,撤销一个Context又意味什么?

  • Done方法返回一个元素类型为struct{}的接收通道,用来让调用方法去感知撤销当前Context值的那个信号

  • 一旦Context撤销,接收通道就立即关闭。

  • 撤销被用来表达撤销状态的信号,如果当动词讲,指得就时对撤销信号的传达,而可撤销的指的则是具有传达这种撤销信号的能力。

当我们通过调用contextWithCancel函数产生一个可撤销的Context值时,还会获得一个用于触发撤销信号的函数。

通过调用这个函数,我们就可以触发针对这个Context值的撤销信号。一日触发,撤销信号就会立
即被传达给这个Context值,并由它的Done方法的结果值(一个接收通道)表达出来。
撤销函数只负责触发信号,而对应的可撤销的Context值也只负责传达信号,它们都不会去管后边具体的“撤销”操作。实际上,我们的代码可以在感知到撤销信号之后,进行任意的操作,Context值对此并没有任何的约束。

  • 问题2:撤销信号是如何在上下文树中传播的?
    我在前面讲了,context包中包含了四个用于繁衍Context值的函数。其中的WithCancel WithDeadline和withtimeout都是被用来基于给定的Context值产生可撤销的子值的。
    context包的WithCancel函数在被调用后会产生两个结果值。第一个结果值就是那个可撒销的 Context值,而第二个结果值则是用于触发撤销信号的函数。
    在撤销函数被调用之后,对应的Context值会先关闭它内部的接收通道,也就是它的Done方法会返回的那个通道。
    然后,它会向它的所有子值(或者说子节点)传达撒销信号。这些子值会如法炮制。把撒销信号继续传播下去。最后,这个Context值会断开它与其父值之间的关联。
    我们通过调用context包的WithDeadline函数或者withTimeout函数生成的Context值也是可撒销的。它们不但可以被手动撤销,还会依据在生成时被给定的过期时间,自动地进行定时撤销。这里定时撤销的功能是借助它们内部的计时器来实现的。
    当过期时间到达时,这两种Context值的行为与Context值被手动撤销时的行为是几乎一致的,只不过前者会在最后停止并释放掉其内部的计时器。
    最后要注意,通过调用contextWithValue函数得到的Context值是不可撒销的。撤销信号在被传播时,若遇到它们则会直接跨过,并试图将信号直接传给它们的子值。

  • 问题3:怎样通过context值携带数据?怎样从中获取数据?
    既然谈到了context包的WithValue函数,我们就来说说Context值携带数据的方式。
    withValue函数在产生新的context值(以下简称含数据的Context值)的时候需要三个参数,即:父值、键和值。与“字典对于键的约束”类似,这里键的类型必须是可判等的。
    原因很简单,当我们从中获取数据的时候,它需要根据给定的键来查找对应的值。不过,这种 Context值并不是用字典来存储键和值的,后两者只是被简单地存储在前者的相应字段中而已
    Context类型的Value方法就是被用来获取数据的。在我们调用含数据的Context值的Value方法时。它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。
    如果其父值中仍然未存储相等的键,那么该方法就会沿着上下文根节点的方向一路查找下去。
    注意,除了含数据的Context值以外,其他几种Context值都是无法携带数据的。因此 Context值的Value方法在沿路查找的时候,会直接跨过那几种值。
    如果我们调用的Value方法的所属值本身就是不含数据的,那么实际调用的就将会是其父辈或祖辈的Value方法。这是由于这几种Context值的实际类型,都属于结构体类型,并且它们都是通过“将其父值嵌入到自身”,来表达父子关系的。
    最后,提醒一下,Context接口并没有提供改变数据的方法。因此,在通常情况下,我们只能通过在上下文树中添加含数据的Context值来存储新的数据,或者通过撤销此种值的父值丢弃掉相应的数据。如果你存储在这里的数据可以从外部改变,那么必须自行保证安全。

  • 小结: Context类型是一个可以实现多goroutine协作流程的同步工具,还可以通过此类型的值传达撤销信号或传递数据。

  • Context类型的实际值大体有三种:根Context,可撤销的Context和含数据的Context

  • 可撤销的Context又分为:只可手动撤销的Context值,可以定时撤销的Context的值

  • 我们可以通过生成它们时得到的撤销函数来对其进行手动的撤销。对于后者,定时撤销的时间必须在生成时就完全确定,并且不能更改。不过,我们可以在过期时间达到之前,对其进行手动的撤销。
    一旦撤销函数被调用,撤销信号就会立即被传达给对应的Context值,并由该值的Done方法返回的接收通道表达出来。
    “撤销”这个操作是Context值能够协调多个goroutine的关键所在。撤销信号总是会沿着上下文树叶子节点的方向传播开来。
    含数据的Context值可以携带数据。每个值都可以存储一对键和值。在我们调用它的Value方法的
    时候,它会沿着上下文树的根节点的方向逐个值的进行查找。如果发现相等的键,它就会立即返回
    对应的值,否则将在最后返回nil。
    含数据的Context值不能被撤销,而可撤销的Context值又无法携带数据。但是,由于它们共同组成了一个有机的整体(即上下文树),所以在功能上要比syncWaitGroup强大得多。

sync.Pool

  • 此类型可以称之为临时对象池,它的值用来存储临时的对象,属于结构体类型。
  • 临时对象:不需要持久使用的某一类值。可以当做针对某种数据的缓存来用。
  • Put方法:存放临时对象,接收interface{}类型参数
  • Get方法:获取临时对象,返回interface{}类型的值
  • sync包被初始化的时候,会向Go运行时系统注册一个函数,用来清除所有的已经创建的临时对象池中的值。
  • 池汇总列表:sync包中有一个包级私有的全局变量,代表了当前程序使用的所有临时对象池的汇总,元素类型为*sync.Pool的切片
  • 清理函数会遍历池汇总列表,对其中的每一个临时对象池,它都会先将池中所有的私有临时对象和共享临时对象列表都设置为nil,然后再把这池中的所有本地池列表都销毁掉
  • 最后池清理函数会把池汇总列表重置为空的切片,这样池中的存储的临时对象就全部被清除干净了。稍后被垃圾回收
  • 临时对象池有一个New字段,我们在初始化这个池的时候最好给定它。临时对象池还拥有两个方
    法,即:Put和Get,它们分别被用干向池中存放临时对象,和从池中获取临时对象。
  • 小结:

    • 临时对象池中存储的每一个值都应该是独立的、平等的和可重用的。我们应该既不用关心从池中拿
      到的是哪一个值,也不用在意这个值是否已经被使用过。
      要完全做到这两点,可能会需要我们额外地写一些代码。不过,这个代码量应该是微乎其微的,就
      像fmt包对临时对象池的用法那样。所以,在选用临时对象池的时候,我们必须要把它将要存储的
      值的特性考虑在内。

    • 在临时对象池的内部,有一个多层的数据结构支撑着对临时对象的存储。它的顶层是本地池列表
      其中包含了与某个P对应的那些本地池,并且其长度与P的数量总是相同的。

    • 在每个本地池中,都包含一个私有的临时对象和一个共享的临时对象列表。前者只能被其对应的
      所关联的那个goroutine中的代码访问到,而后者却没有这个约束。从另一个角度讲,前者用于临时对象的快速存取,而后者则用于临时对象的池内共享。

    • 正因为有了这样的数据结构,临时对象池才能够有效地分散存储压力和性能压力。同时,又因为临
      时对象池的Get方法对这个数据结构的妙用,才使得其中的临时对象能够被高效地利用,比如,该
      方法有时候会从其他的本地池的共享临时对象列表中,“偷取”一个临时对象。

    • 这样的内部结构和存取方式,让临时对象池成为了一个特点鲜明的同步工具。它存储的临时对象都应该是拥有较长生命周期的值,并且,这些值不应该被某个qoroutine中的代码长期的持有和使
      用。

    • 因此,临时对象池非常适合用作针对某种数据的缓存。从某种角度讲,临时对象池可以帮助程序实
      现可伸缩性,这也正是它的最大价值。

sync.Map 并发安全字典

  • map并不是并发安全的,即同一时间,不同的goroutine代码,对同一个map读写是不安全的。
  • 1.9中加入了并发安全字典类型sync.Map
  • 它的算法复杂度与map类型一样都是o(1)
  • 并发安全字典和原生字典map一样对键的类型有要求,键的实际类型不能是函数类型、字典类型、切片类型
  • 可以用类型断言表达式或者反射操作来保证他们的类型正确性
    • 并发安全字典只能存储某个特定类型的键
    • 封装的结构体类型的所有方法,都可以与sync.Map类型的方法完全一致
  • sync.map的键和值类型都是interface{}
    问题2:并发安全字典如何做到尽量避免使用锁?
    sync.Map类型在内部使用了大量的原子操作来存取键和值,并使用了两个原生的map作为存储介质。
    其中一个原生map被存在了syncMap的read字段中,该字段是sync/atomicValue类型的。这个原生字典可以被看作一个快照,它总会在条件满足时,去重新保存所属的syncMap值中包含的所有键值对。
    为了描述方便,我们在后面简称它为只读字典。不过,只读字典虽然不会增减其中的键,但却允许变更其中的键所对应的值。所以,它并不是传统意义上的快照,它的只读特性只是对于其中键的集合而言的。

由read字段的类型可知,svncMap在替换只读字典的时候根本用不着锁。另外,这个只读字典在
存储键值对的时候,还在值之上封装了一层。

它先把值转换为了unsafePointer类型的值,然后再把后者封装,并储存在其中的原生字典中。
如此一来,在变更某个键所对应的值的时候,就也可以使用原子操作了。
sync.Map中的另一个原生字典由它的dirty字段代表。它存储键值对的方式与read字段中的原生字典一致,它的键类型也是interface{,并且同样是把值先做转换和封装后再进行储存的。我们暂且把它称为脏字典。
注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,
对于两个值来说也是如此,正如前文所述。这两个字典在存储键和值的时候都只会存入它们的某个
指针,而不是基本值。

sync.Map在查找指定的键所对应的值的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。
只有当确定“只读字典中没有,但脏字典中可能会有这个键”的时候,它才会在锁的保护下去访问脏字典。
相对应的,syncMap在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”就会把新值存到里面并直接返回,这种情况下也不需要用到锁。
否则,它才会在锁的保护下把键值对存储到脏字典中。这个时候,该键值对的“已删除”标记会被抹去。
顺便说一句,只有当一个键值对应该被删除,但却仍然存在于只读字典中的时候,才会被用标记
为“已删除”的方式进行逻辑删除,而不会直接被物理删除。
这种情况会在重建脏字典以后的一段时间内出现。不过,过不了多久,它们就会被真正删除掉。在查找和遍历键值对的时候,已被逻辑删除的键值对永远会被无视。
对于删除键值对,syncMap会先去检查只读字典中是否有对应的键。如果没有,脏字典中可能有,那么它就会在锁的保护下,试图从脏字典中删掉该键值对。

最后,svncMap会把该键值对中指向值的那个指针置为nil,这是另一种逻辑删除的方式

除此之外,还有一个细节需要注意,只读字典和脏字典之间是会互相转换的。在脏字典中查找键值
对次数足够多的时候,syncMap会把脏字典直接作为只读字典,保存在它的read字段中,然后把代表脏字典的dirty字段的值置为nil。

在这之后,一日再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会把只读
字典中已被逻辑删除的键值对过滤掉。理所当然,这些转换操作肯定都需要在锁的保护下进行。

综上所述,syncMap的只读字典和脏字典中的键值对集合并不是实时同步的,它们在某些时间段
内可能会有不同。
由于只读字典中键的集合不能被改变,所以其中的键值对有时候可能是不全的。相反,脏字典中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。
因此,可以看出,在读操作有很多但写操作却很少的情况下,并发安全字典的性能往往会更好。在几个写操作当中,新增键值对的操作对并发安全字典的性能影响是最大的,其次是删除操作,最后才是修改操作。
如果被操作的键值对已经存在于syncMap的只读字典中,并且没有被逻辑删除,那么修改它并不会使用到锁,对其性能的影响就会很小。

发表评论

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