Go 并发

Goroutine
Memory model
Package sync
chan
Package context
References

Processes and Threads

操作系统会为该应用程序创建一个进程。作为一个应用程序,它像一个为所有资源而运行的容器。这些资源包括内存地址空间、文件句柄、设备和线程。
线程是操作系统调度的一种执行路径,用于在处理器执行我们在函数中编写的代码。一个进程从一个线程开始,即主线程,当该线程终止时,进程终止。这是因为主线程是应用程序的原点。然后,主线程可以依次启动更多的线程,而这些线程可以启动更多的线程。
无论线程属于哪个进程,操作系统都会安排线程在可用处理器上运行。每个操作系统都有自己的算法来做出这些决定。

Goroutines and Parallelism

Go 语言层面支持的 go 关键字,可以快速的让一个函数创建为 goroutine,我们可以认为 main 函数就是作为 goroutine 执行的。操作系统调度线程在可用处理器上运行,Go运行时调度 goroutines 在绑定到单个操作系统线程的逻辑处理器中运行§。即使使用这个单一的逻辑处理器和操作系统线程,也可以调度数十万 goroutine 以惊人的效率和性能并发运行。
Concurrency is not Parallelism.
并发不是并行。并行是指两个或多个线程同时在不同的处理器执行代码。如果将运行时配置为使用多个逻辑处理器,则调度程序将在这些逻辑处理器之间分配 goroutine,这将导致 goroutine 在不同的操作系统线程上运行。但是,要获得真正的并行性,您需要在具有多个物理处理器的计算机上运行程序。否则,goroutines 将针对单个物理处理器并发运行,即使 Go 运行时使用多个逻辑处理器。

Keep yourself busy or do the work yourself

如果你的 goroutine 在从另一个 goroutine 获得结果之前无法取得进展,那么通常情况下,你自己去做这项工作比委托它( go func() )更简单。
这通常消除了将结果从 goroutine 返回到其启动器所需的大量状态跟踪和 chan 操作。

将目录读取到一个 slice 中,然后返回整个切片,或者如果出现错误,则返回错误。这是同步调用的,ListDirectory 的调用方会阻塞,直到读取所有目录条目。根据目录的大小,这可能需要很长时间,并且可能会分配大量内存来构建目录条目名称的 slice。
ListDirectory 返回一个 chan string,将通过该 chan 传递目录。当通道关闭时,这表示不再有目录。由于在 ListDirectory 返回后发生通道的填充,ListDirectory 可能内部启动 goroutine 来填充通道

ListDirectory chan 版本还有两个问题:
通过使用一个关闭的通道作为不再需要处理的项目的信号,ListDirectory 无法告诉调用者通过通道返回的项目集不完整,因为中途遇到了错误。调用方无法区分空目录与完全从目录读取的错误之间的区别。这两种方法都会导致从 ListDirectory 返回的通道会立即关闭。
调用者必须继续从通道读取,直到它关闭,因为这是调用者知道开始填充通道的 goroutine 已经停止的唯一方法。这对 ListDirectory 的使用是一个严重的限制,调用者必须花时间从通道读取数据,即使它可能已经收到了它想要的答案。对于大中型目录,它可能在内存使用方面更为高校,但这种方法并不比原始的基于 slice 的方法快。

Never start a goroutine without knowning when it will stop

在这个例子中,goroutine 泄漏可以在 code review 快速识别出来。不幸的是,生产代码中的 goroutine 泄漏通常更难找到。我无法说明 goroutine 泄漏可能发生的所有可能方式,您可能会遇到:

这个简单的应用程序在两个不同的端口上提供 http 流量,端口8080用于应用程序流量,端口8001用于访问 /debug/pprof 端点

使用 sync.WaitGroup 来追踪每一个创建的 goroutine

Memory Model

https://golang.org/ref/mem
如何保证在一个 goroutine 中看到在另一个 goroutine 修改的变量的值,如果程序中修改数据时有其他 goroutine 同时读取,那么必须将读取串行化。为了串行化访问,请使用 channel 或其他同步原语,例如 sync 和 sync/atomic 来保护数据。
Happen-Before
在一个 goroutine 中,读和写一定是按照程序中的顺序执行的。即编译器和处理器只有在不会改变这个 goroutine 的行为时才可能修改读和写的执行顺序。由于重排,不同的goroutine 可能会看到不同的执行顺序。例如,一个goroutine 执行 a = 1;b = 2;,另一个 goroutine 可能看到 b 在 a 之前更新。

Memory Reordering
用户写下的代码,先要编译成汇编代码,也就是各种指令,包括读写内存的指令。CPU 的设计者们,为了榨干 CPU 的性能,无所不用其极,各种手段都用上了,你可能听过不少,像流水线、分支预测等等。其中,为了提高读写内存的效率,会对读写指令进行重新排列,这就是所谓的 内存重排,英文为 MemoryReordering。
这一部分说的是 CPU 重排,其实还有编译器重排。比如:

单个 goroutine 中没有并发,所以上面两个定义是相同的:
读操作 r 看到最近一次的写操作 w 写入 v 的值。
当多个 goroutine 访问共享变量 v 时,它们必须使用同步事件来建立先行发生这一条件来保证读操作能看到需要的写操作。
对变量 v 的零值初始化在内存模型中表现的与写操作相同。
对大于 single machine word 的变量的读写操作表现的像以不确定顺序对多个 single machine word的变量的操作。

https://www.jianshu.com/p/5e44168f47a3

Detecting Race Conditions With Go
data race 是两个或多个 goroutine 访问同一个资源(如变量或数据结构),并尝试对该资源进行读写而不考虑其他 goroutine。这种类型的代码可以创建您见过的最疯狂和最随机的 bug。通常需要大量的日志记录和运气才能找到这些类型的bug。
早在6月份的Go 1.1中,Go 工具引入了一个 race detector。竞争检测器是在构建过程中内置到程序中的代码。然后,一旦你的程序运行,它就能够检测并报告它发现的任何竞争条件。它非常酷,并且在识别罪魁祸首的代码方面做了令人难以置信的工作。
3/8.go
go build -race
go test -race

Go memory model 提到过: 表示写入单个 machine word 将是原子的,但 interface 内部是是两个 machine word 的值。另一个goroutine 可能在更改接口值时观察到它的内容。
在这个例子中,Ben 和 Jerry 内存结构布局是相同的,因此它们在某种意义上是兼容的。想象一下,如果他们有不同的内存布局会发生什么混乱?
如果是一个普通的指针、map、slice 可以安全的更新吗?
没有安全的 data race(safe data race)。您的程序要么没有 data race,要么其操作未定义。
原子性
可见行

sync.atomic

我们想到使用 Go 同步语义解决:
Mutex
RWMutex
Atomic
3/config.go

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

Benchmark 是出结果真相的真理,即便我们知道可能 Mutex vs Atomic 的情况里,Mutex 相对更重。因为涉及到更多的 goroutine 之间的上下文切换 pack blocking goroutine,以及唤醒 goroutine

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

Mutex

这个案例基于两个 goroutine:
goroutine 1 持有锁很长时间
goroutine 2 每100ms 持有一次锁
都是100ms 的周期,但是由于 goroutine 1 不断的请求锁,可预期它会更频繁的持续到锁。我们基于 Go 1.8 循环了10次,下面是锁的请求占用分布:

首先,goroutine1 将获得锁并休眠100ms。当goroutine2 试图获取锁时,它将被添加到锁的队列中- FIFO 顺序,goroutine 将进入等待状态。
然后,当 goroutine1 完成它的工作时,它将释放锁。此版本将通知队列唤醒 goroutine2。goroutine2 将被标记为可运行的,并且正在等待 Go 调度程序在线程上运行

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

我们看看几种 Mutex 锁的实现:
Barging. 这种模式是为了提高吞吐量,当锁被释放时,它会唤醒第一个等待者,然后把锁给第一个等待者或者给第一个请求锁的人。
Handsoff. 当锁释放时候,锁会一直持有直到第一个等待者准备好获取锁。它降低了吞吐量,因为锁被持有,即使另一个 goroutine 准备获取它。
一个互斥锁的 handsoff 会完美地平衡两个goroutine 之间的锁分配,但是会降低性能,因为它会迫使第一个 goroutine 等待锁。
Spinning. 自旋在等待队列为空或者应用程序重度使用锁时效果不错。Parking 和 Unparking goroutines 有不低的性能成本开销,相比自旋来说要慢得多。

Go 1.8 使用了 Barging 和 Spining 的结合实现。当试图获取已经被持有的锁时,如果本地队列为空并且 P 的数量大于1,goroutine 将自旋几次(用一个 P 旋转会阻塞程序)。自旋后,goroutine park。在程序高频使用锁的情况下,它充当了一个快速路径。
Go 1.9 通过添加一个新的饥饿模式来解决先前解释的问题,该模式将会在释放时候触发 handsoff。所有等待锁超过一毫秒的 goroutine(也称为有界等待)将被诊断为饥饿。当被标记为饥饿状态时,unlock 方法会 handsoff 把锁直接扔给第一个等待者。
在饥饿模式下,自旋也被停用,因为传入的goroutines 将没有机会获取为下一个等待者保留的锁。

我们把一个复杂的任务,尤其是依赖多个微服务 rpc 需要聚合数据的任务,分解为依赖和并行,依赖的意思为: 需要上游 a 的数据才能访问下游 b 的数据进行组合。但是并行的意思为: 分解为多个小任务并行执行,最终等全部执行完毕。
https://pkg.go.dev/golang.org/x/sync/errgroup
核心原理: 利用 sync.Waitgroup 管理并行执行的 goroutine。 3/10.go

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。

Go Channel

channels 是一种类型安全的消息队列,充当两个 goroutine 之间的管道,将通过它同步的进行任意资源的交换。chan 控制 goroutines 交互的能力从而创建了 Go 同步机制。当创建的 chan 没有容量时,称为无缓冲通道。反过来,使用容量创建的 chan 称为缓冲通道。
要了解通过 chan 交互的 goroutine 的同步行为是什么,我们需要知道通道的类型和状态。根据我们使用的是无缓冲通道还是缓冲通道,场景会有所不同,所以让我们单独讨论每个场景。

ch := make(chan struct{})
无缓冲 chan 没有容量,因此进行任何交换前需要两个 goroutine 同时准备好。当 goroutine 试图将一个资源发送到一个无缓冲的通道并且没有goroutine 等待接收该资源时,该通道将锁住发送 goroutine 并使其等待。当 goroutine 尝试从无缓冲通道接收,并且没有 goroutine 等待发送资源时,该通道将锁住接收 goroutine 并使其等待。
无缓冲信道的本质是保证同步。

第一个 goroutine 在发送消息 foo 之后被阻塞,因为还没有接收者准备好。规范中对这种行为进行了很好的解释:https://golang.org/ref/spec#Channel_types
“If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready.”
https://golang.org/doc/effective_go.html#channels
“If the channel is unbuffered, the sender blocks until the receiver has received the value”
Receive 先于 Send 发生。
好处: 100% 保证能收到。
代价: 延迟时间未知。

buffer channels
buffered channel 具有容量,因此其行为可能有点不同。当 goroutine 试图将资源发送到缓冲通道,而该通道已满时,该通道将锁住 goroutine并使其等待缓冲区可用。如果通道中有空间,发送可以立即进行,goroutine 可以继续。当goroutine 试图从缓冲通道接收数据,而缓冲通道为空时,该通道将锁住 goroutine 并使其等待资源被发送。

Latencies due to under-sized buffer

我们在 chan 创建过程中定义的缓冲区大小可能会极大地影响性能。我将使用密集使用 chan 的扇出模式来查看不同缓冲区大小的影响。在我们的基准测试中,一个 producer 将在通道中注入百万个整数元素,而5个 worker 将读取并将它们追加到一个名为 total 的结果变量中

Timing out
Moving on
Pipeline
Fan-out, Fan-in
Cancellation
Close 先于 Receive 发生(类似 Buffered)。
不需要传递数据,或者传递 nil。
非常适合去掉和超时控制。
Contex
https://blog.golang.org/concurrency-timeouts
https://blog.golang.org/pipelines
https://talks.golang.org/2013/advconc.slide#1
https://github.com/go-kratos/kratos/tree/master/pkg/sync

Design Philosophy
If any given Send on a channel CAN cause the sending goroutine to block:
Not allowed to use a Buffered channel larger than 1.
Buffers larger than 1 must have reason/measurements.
Must know what happens when the sending goroutine blocks.
If any given Send on a channel WON’T cause the sending goroutine to block:
You have the exact number of buffers for each send.
Fan Out pattern
You have the buffer measured for max capacity.
Drop pattern
Less is more with buffers.
Don’t think about performance when thinking about buffers.
Buffers can help to reduce blocking latency between signaling.
Reducing blocking latency towards zero does not necessarily mean better throughput.
If a buffer of one is giving you good enough throughput then keep it.
Question buffers that are larger than one and measure for size.
Find the smallest buffer possible that provides good enough throughput.

Request-scoped context
在 Go 服务中,每个传入的请求都在其自己的goroutine 中处理。请求处理程序通常启动额外的 goroutine 来访问其他后端,如数据库和 RPC服务。处理请求的 goroutine 通常需要访问特定于请求(request-specific context)的值,例如最终用户的身份、授权令牌和请求的截止日期(deadline)。当一个请求被取消或超时时,处理该请求的所有 goroutine 都应该快速退出(fail fast),这样系统就可以回收它们正在使用的任何资源。
Go 1.7 引入一个 context 包,它使得跨 API 边界的请求范围元数据、取消信号和截止日期很容易传递给处理请求所涉及的所有 goroutine(显示传递)。
其他语言: Thread Local Storage(TLS),XXXContext

如何将 context 集成到 API 中?
在将 context 集成到 API 中时,要记住的最重要的一点是,它的作用域是请求级别 的。例如,沿单个数据库查询存在是有意义的,但沿数据库对象存在则没有意义。
目前有两种方法可以将 context 对象集成到 API 中:
The first parameter of a function call
首参数传递 context 对象,比如,参考 net 包 Dialer.DialContext。此函数执行正常的 Dial 操作,但可以通过 context 对象取消函数调用。

Optional config on a request structure
在第一个 request 对象中携带一个可选的 context 对象。例如 net/http 库的 Request.WithContext,通过携带给定的 context 对象,返回一个新的 Request 对象。

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx:

Incoming requests to a server should create a Context.
使用 context 的一个很好的心智模型是它应该在程序中流动,应该贯穿你的代码。这通常意味着您不希望将其存储在结构体之中。它从一个函数传递到另一个函数,并根据需要进行扩展。理想情况下,每个请求都会创建一个 context 对象,并在请求结束时过期。
不存储上下文的一个例外是,当您需要将它放入一个结构中时,该结构纯粹用作通过通道传递的消息。如下例所示。

context.WithValue 内部基于 valueCtx 实现:

为了实现不断的 WithValue,构建新的 context,内部在查找 key 时候,使用递归方式不断从当前,从父节点寻找匹配的 key,直到 root context(Backgrond 和 TODO Value 函数会返回 nil)。

context.WithValue 方法允许上下文携带请求范围的数据。这些数据必须是安全的,以便多个 goroutine 同时使用。这里的数据,更多是面向请求的元数据,不应该作为函数的可选参数来使用(比如 context 里面挂了一个sql.Tx 对象,传递到 Dao 层使用),因为元数据相对函数参数更加是隐含的,面向请求的。而参数是更加显示的。
同一个 context 对象可以传递给在不同 goroutine 中运行的函数;上下文对于多个 goroutine 同时使用是安全的。对于值类型最容易犯错的地方,在于 context value 应该是 immutable 的,每次重新赋值应该是新的 context,即: context.WithValue(ctx, oldvalue)
https://pkg.go.dev/google.golang.org/grpc/metadata
Context.Value should inform, not control

当一个 context 被取消时,从它派生的所有 context 也将被取消。WithCancel(ctx) 参数 ctx 认为是 parent ctx,在内部会进行一个传播关系链的关联。Done() 返回 一个 chan,当我们取消某个parent context, 实际上上会递归层层 cancel 掉自己的 child context 的 done chan 从而让整个调用链中所有监听 cancel 的 goroutine退出。

如果要实现一个超时控制,通过上面的context 的parent/child 机制,其实我们只需要启动一个定时器,然后在超时的时候,直接将当前的 context 给 cancel 掉,就可以实现监听在当前和下层的额context.Done() 的 goroutine 的退出。

Final Notes
Incoming requests to a server should create a Context.
Outgoing calls to servers should accept a Context.
Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.
The chain of function calls between them must propagate the Context.
Replace a Context using WithCancel, WithDeadline, WithTimeout, or WithValue.
When a Context is canceled, all Contexts derived from it are also canceled.
The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.
Do not pass a nil Context, even if a function permits it. Pass a TODO context if you are unsure about which Context to use.
Use context values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

你可能感兴趣的:(Goland研究,golang,开发语言,后端)