Go语言进阶(四)PackageSync

Share Memory By Communicating(使用通信共享内存)

传统的线程模型(通常在编写 Java、C++ 和Python 程序时使用)程序员在线程之间通信需要使用共享内存。通常,共享数据结构由锁保护,线程将争用这些锁来访问数据。在某些情况下,通过使用线程安全的数据结构(如 Python 的Queue),这会变得更容易

Go 的并发原语 goroutineschannels 为构造并发软件提供了一种优雅而独特的方法。Go 没有显式地使用锁来协调对共享数据的访问,而是鼓励使用 changoroutine 之间传递对数据的引用。这种方法确保在给定的时间只有一个 goroutine 可以访问数据。

Do not communicate by sharing memory; instead, share memory by communicating.

一个爬虫案例:案例来源(https://go.dev/blog/codelab-share)

使用互斥锁写法,比较冗长。

chan写法:

Detecting Race Conditions With Go

data race 是两个或多个 goroutine 访问同一个资源(如变量或数据结构),并尝试对该资源进行读写而不考虑其他 goroutine。这种类型的代码可以创建您见过的最疯狂和最随机的 bug。通常需要大量的日志记录和运气才能找到这些类型的bug

Go 1.1中,Go 工具引入了一个 race detector。竞争检测器是在构建过程中内置到程序中的代码。然后,一旦你的程序运行,它就能够检测并报告它发现的任何竞争条件。它非常酷,并且在识别罪魁祸首的代码方面做了令人难以置信的工作。

  • go build -race,不建议生产环境使用,性能有影响。
  • go test -race

举个栗子:

开启2个协程,对公共Counter进行操作。

提示存在一个data race,提示很详细,data_race.go:23存在一个读操作,data_race.go:25有一个写操作。

编译后多次执行,我们可以看到,有概率可以碰到脏读的情况。

有人可能会觉得下面这段代码,不是一个原子操作所以才会有问题。

如果改成Counter++或者Counter = Counter + 1就没有问题了。但是我们通过查看底层汇编:

image-20211209145312212

实际上有三行汇编代码在执行以增加计数器。这三行汇编代码看起来很像原始的 Go 代码。在这三行汇编代码之后可能有一个上下文切换。尽管程序现在正在运行,但从技术上讲,这个 bug 仍然存在。我们的 Go 代码看起来像是在安全地访问资源,而实际上底层的程序集代码根本就不安全。

我们应该使用 Go 同步语义: MutexAtomic

又是一个栗子:

上面这段代码benjerry相互交叉调用,我们来看下输出:

为什么会出现这种情况呢?

我们第一感觉是 single machine word 应该是原子赋值,为啥 -race 会乱报。我们执行这个代码看看会发生什么。

底层interface由两个指针组成(TypeData)。

image-20211209150734998

对于语句 var maker IceCreamMaker=ben,编译器将生成执行以下操作的代码。

image-20211209150647338

loop1() 执行 maker=jerry 语句时,必须更新接口值的两个字段。

image-20211209151016733

sync.atomic

举个栗子:cfg 作为包级全局对象,在这个例子中被多个 goroutine 同时访问,因此这里存在 data race,会看到不连续的内存输出。

输出不正确

使用atomic解决问题

输出正确

当然这里也可以使用互斥锁来处理这个问题。但是性能没有atomic.Value好。写极少,读很多的场景使用atomic.Value性能更好。

image-20211209152632828

Copy-On-Write 思路在微服务降级或者 local cache 场景中经常使用。写时复制指的是,写操作时候复制全量老数据到一个新的对象中,携带上本次新写的数据,之后利用原子替换(atomic.Value),更新调用者的变量。来完成无锁访问共享数据。

Mutex

继续举个栗子:基于Go 1.8

这个栗子基于两个 goroutine:

  • goroutine 1 持有锁很长时间
  • goroutine 2100ms 持有一次锁

都是100ms 的周期,但是由于 goroutine 1 不断的请求锁,可预期它会更频繁的持续到锁。我们基于 Go 1.8 循环了10次,下面是锁的请求占用分布:

image-20211209152959277

这是什么原因呢?

首先,goroutine1 将获得锁并休眠100ms。当goroutine2 试图获取锁时,它将被添加到锁的队列中 FIFO 顺序,goroutine 将进入等待状态。

然后,当 goroutine1 完成它的工作时,它将释放锁。此版本将通知队列唤醒 goroutine2goroutine2 将被标记为可运行的,并且正在等待 Go 调度程序在线程上运行。

image-20211209153845288

image-20211209153830428

然而,当 goroutine2 等待运行时,goroutine1将再次请求锁。
goroutine2 尝试去获取锁,结果悲剧的发现锁又被人持有了,它自己继续进入到等待模式。

image-20211209153932434

image-20211209153941666

几种锁的实现

1、 Barging. 这种模式是为了提高吞吐量,当锁被释放时,它会唤醒第一个等待者,然后把锁给第一个等待者或者给第一个请求锁的人。

image-20211209154249805

2、Handsoff. 当锁释放时候,锁会一直持有直到第一个等待者准备好获取锁。它降低了吞吐量,因为锁被持有,即使另一个 goroutine 准备获取它。(一个互斥锁的 handsoff 会完美地平衡两个goroutine 之间的锁分配,但是会降低性能,因为它会迫使第一个 goroutine 等待锁。)

image-20211209154313845

3、Spinning. 自旋在等待队列为空或者应用程序重度使用锁时效果不错。parkingunparking, goroutines 有不低的性能成本开销,相比自旋来说要慢得多。

image-20211209154325023

Go 1.8 使用了 BargingSpining 的结合实现。当试图获取已经被持有的锁时,如果本地队列为空并且 P 的数量大于1,goroutine 将自旋几次(用一个 P 旋转会阻塞程序)。自旋后,goroutine park。在程序高频使用锁的情况下,它充当了一个快速路径。

Go 1.9 通过添加一个新的饥饿模式来解决先前解释的问题,该模式将会在释放时候触发 handsoff。所有等待锁超过一毫秒的 goroutine(也称为有界等待)将被诊断为饥饿。当被标记为饥饿状态时,unlock 方法会 handsoff 把锁直接扔给第一个等待者。

在饥饿模式下,自旋也被停用,因为传入的goroutines 将没有机会获取为下一个等待者保留的锁。

errgroup

我们把一个复杂的任务,尤其是依赖多个微服务 rpc 需要聚合数据的任务,分解为依赖和并行,依赖的意思为: 需要上游 a 的数据才能访问下游 b 的数据进行组合。但是并行的意思为: 分解为多个小任务并行执行,最终等全部执行完毕。

官方自包: https://pkg.go.dev/golang.org/x/sync/errgroup

核心原理: 利用 sync.Waitgroup 管理并行执行的 goroutine

sync.Pool

sync.Pool 的场景是用来保存和复用临时对象,以减少内存分配,降低 GC 压力(Request-Driven 特别合适)。

Get 返回 Pool 中的任意一个对象。如果 Pool 为空,则调用 New 返回一个新创建的对象。

放进 Pool 中的对象,会在说不准什么时候被回收掉。所以如果事先 Put 进去 100 个对象,下次 Get 的时候发现 Pool 是空也是有可能的。

不过这个特性的一个好处就在于不用担心 Pool 会一直增长,因为 Go 已经帮你在 Pool 中做了回收机制。

这个清理过程是在每次垃圾回收之前做的。之前每次GC 时都会清空 pool,而在1.13版本中引入了 victim cache,会将 pool 内数据拷贝一份,避免 GC 将其清空,即使没有引用的内容也可以保留最多两轮 GC

不适合放连接池因为随时会被回收,最好放一些无状态的东西,可以随时被回收。