Go 原生并发原语和最佳实践
The following article is from 幽鬼 Author 程序员ug
Go 编程语言是用并发作为一等公民创建的。它是一种语言,通过抽象出语言中并发原语1背后的并行性细节,您可以轻松编写高度并行的程序。
大多数语言都将并行化作为标准库的一部分,或者期望开发者生态系统提供并行化库。通过在 Go 语言中包含并发原语,你可以编写并行性的程序,而无需了解编写并行代码的来龙去脉。
1、并发(Concurrent)设计
Go 的设计者非常重视并发设计,将其作为一种方法论,这种方法论的基础是交流关键信息,而不是阻塞和共享这些信息。
对并发设计的强调允许应用程序代码正确地按顺序或并行执行,而无需设计和实现并行化,这是常态。并发设计的想法并不新鲜,事实上一个很好的例子是从瀑布式开发到敏捷开发,这实际上是向并发工程实践(早期迭代、可重复过程)的转变。
并发设计是关于编写“正确”程序与编写“并行”程序。
在 Go 中构建并发程序时要问的问题:
我是否堵塞在临界区? 是否有更正确(如以 Go 为中心)的方式来编写此代码? 我可以通过通信来提高代码的功能性和可读性吗?
如果其中任何一个是 Yes,那么你应该重新考虑你的设计,考虑用 Go 的最佳实践。
Communicating Sequential Processes (CSP)
Go 语言的一部分的基础来自 Hoare 的一篇论文,该论文讨论了语言需要将并发视为语言的一部分而不是事后的想法。该论文提出了一种线程安全队列,它允许应用程序中不同进程之间的数据通信。
如果你通读这篇论文,你会发现Go 中的 channel
原语与论文中对原语的描述非常相似,实际上这来自 Rob Pike 之前基于 CSP 构建语言的工作。
在 Pike 的一次演讲中,他将真正的问题确定为“需要一种方法来编写并发软件,以指导我们的设计和实现。”他接着说并发编程不是让程序并行化以更快地运行,而是“利用流程和通信的力量来设计优雅、响应迅速、可靠的系统。”
通过通信实现并发
我们从 Go 的创建者那里听到的最常见的短语之一是:
Don’t communicate by sharing memory, share memory by communicating. - Rob Pike
即:不要通过共享内存来通信,而是通过通信来共享内存。
这种观点反映了 Go 是基于 CSP 的事实,并且该语言具有用于在线程之间通信的本机原语(goroutine)。
下面的代码是使用通信而不是使用 mutex 来管理对共享资源的访问的示例:
// Adapted from https://github.com/devnw/ttl
// a TTL cache implementation for Go.
func readwriteloop(
incoming <-chan interface{},
) <-chan interface{} {
// Create a channel to send data to.
outgoing = make(chan interface{})
go func(
incoming <-chan interface{},
outgoing chan<- interface{},
) {
defer close(outgoing)
// `value` is the shared
// resource or critical section.
var value interface{}
for {
select {
// incoming is the channel where data is
// sent to set the shared resource.
case v, ok := <-incoming:
if !ok {
return // Exit the go routine.
}
// Write the data to the shared resource.
value = v.v
// outgoing is the channel that
// the shared resource on request
case outgoing <- value:
}
}
}(incoming, outgoing)
return outgoing
}
让我们看一下代码,看看它做了什么。
请注意,这没有使用 sync
包或任何阻塞函数。此代码仅使用 Go 并发原语 go
,select
和chan
共享资源的所有权由 goroutine 管理。(第 17 行) 即使该方法包含一个 goroutine,对共享资源的访问也不会并行发生。(第 30 和 34 行) 该 select
语句用于检查读取或写入请求。(第 24 和 34 行)从 incoming 会更新该值。(第 24 行) 从 goroutine 外部读取的通道使用共享资源的当前值执行对 outgoing 写入。(第 34 行)
由于单个 goroutine 本身没有并行性,因此可以通过返回的只读通道安全地访问共享资源 。事实上,select
这里使用该语句提供了许多好处。选择原语部分对此进行了更详细的介绍。
堵塞 vs 通信
堵塞:
暂停临界区读/写的进程 需要了解阻塞的必要性 需要了解如何避免竞争和死锁 内存元素由多个进程/线程直接共享
通信:
根据要求共享临界区 当有事情要做时,进程开始工作 内存元素是通过通信共享,而不是直接共享的
2、Go 原生并发原语
Goroutine
什么是 Goroutine?
goroutine 是轻量级的线程。一旦 goroutine 从父 goroutine 中分离出来,它就会被移交给 Go 运行时执行。然而,与&in不同的是,bash这些进程被安排在 Go 运行时中执行,而不必并行执行。
注意:这里“调度”的区别很重要,因为 Go 运行时多路复用 goroutine 的执行,以提高操作系统调度之上的性能。这意味着无法对 goroutine 何时执行做出任何假设。
Goroutine 的泄露
由 go
原语创建的 goroutine 很轻量,但重要的是要知道它们不是免费的。清理 goroutine 对于确保在 Go 运行时中正确收集资源非常重要。
应该花时间在设计上考虑清理。确保长时间运行的 goroutine 在发生故障时正确退出。同样重要的是不要创建无数的 goroutine。
生成一个 goroutine 很简单,因此很容易无节制的生成新的 goroutine,生成的每个 goroutine 的最小开销约为 2kb。如果你的代码创建了太多 goroutine,并且每个 goroutine 都有很大的开销,那么栈空间可能不够。这在生产环境中调试起来非常困难,因为很难判断堆栈在哪里溢出以及堆栈在哪里泄漏。
当发生堆栈溢出时,运行时将 panic,程序将退出,每个 goroutine 都会将堆栈信息打印到标准错误。这会在日志中产生大量噪音并且不是很有用。不仅堆栈信息没有用,而且还会输出大量数据(每个 goroutine 的日志,包括它的标识符和状态)。这也很难调试,因为通常操作系统上的日志缓冲区可能太小而无法容纳所有堆栈信息。
注意:公平地说,我只在应用程序使用超过 400,000 个大型 goroutine 的生产环境中看到这种情况。这可能很少见,对于大多数应用程序来说都不是问题。
TL;DR:设计 goroutines 时要考虑到最终结果,以便它们在完成时正确停止。
Goroutine 中的 panic
一般来说,Go 应用程序中的 panic 是违反最佳实践的,应该避免。代替 panic,你应该返回并处理函数中的错误。但是,如果有必要使用 panic
,必须知道,在 goroutine 如果没有 defer 的 recover,panic 会导致整个应用程序崩溃。
最佳实践:不要 Panic!
这在生产环境中非常难以调试,因为它需要stderr
重定向到文件,因为你的应用程序很可能作为守护进程运行。如果你有一个日志聚合器并且它被设置为监控标准错误或文件日志,这会更容易。
如果不确定,可以在 goroutine 增加 defer/recover
defer func() {
if r := recover(); r != nil {
// Handle Panic HERE
}
}()
Channels
Go 中的 Channel 是什么?
源自 Hoare (1977) 的 Communicating Sequential Processes 论文通道是 Go 中的一种通信机制,它支持以线程安全的方式传输数据。它可用于安全有效地在并行 goroutine 之间进行通信,而无需 mutex。
通道将构建并行代码的困难抽象到 Go 运行时,并提供了一种在 goroutine 之间进行通信的简单方法。从本质上讲,通道的最简单形式是数据队列。
用 Rob Pike 的话来说:“Channels orchestrate; mutexes serialize.”
通道在 Go 中是如何工作的?
默认情况下,通道是阻塞的。这意味着,如果你尝试从通道读取,它将阻止该 goroutine 的处理,直到有要读取的内容(即数据发送到通道)。同样,如果你尝试写入通道并且没有数据消费者(即从通道读取),它将阻止该 goroutine 的处理,直到有消费者。
在 Go 中,围绕通道有一些非常重要的行为。Go 运行时被设计为非常高效,因此如果有一个 goroutine 在读取或写入通道上被阻塞,则运行时将在等待执行某些操作时使 goroutine 休眠。一旦通道有生产者或消费者,它将唤醒阻塞的 goroutine 并继续处理。
理解这一点非常重要,因为它允许你通过使用通道显式地利用系统的 CPU 争用。
注意: 一个 nil 通道总是堵塞
关闭 Channel
channel 完成后,最好关闭它。这通过 close
实现。
有时可能无法关闭通道,因为它会在应用程序的其他地方引起 panic(即往关闭的通道写入)。在这种情况下,当通道超出作用范围时,它将被垃圾收集。
// Create the channel
ch := make(chan int)
// Do something with the channel
// Close the channel
close(ch)
如果通道被限制在同一个作用域(即函数),你可以使用 defer
关键字来保证函数返回时通道是关闭的。
// Create the channel
ch := make(chan int)
defer close(ch) // Close the channel when func returns
// Do something with the channel
当一个通道关闭时,不允许再写入。注意关闭通道的方式非常重要,因为如果你尝试写入已关闭的通道,运行时将 panic。因此,过早关闭通道可能会产生意想不到的副作用。
通道关闭后,它将不再阻塞读取。这意味着所有阻塞在通道上的 goroutine 都将被唤醒并继续处理。读取时返回的零值将是通道类型的值,读取返回的第二个值将是 false
。
// Create the channel
ch := make(chan int)
// Do something with the channel
// Close the channel
close(ch)
// Read from closed channel
data, ok := <-ch
if !ok {
// Channel is closed
}
如果在上面的示例中关闭了通道,则 ok
参数将是 false
。
注意: 只读 channel 不能关闭
Channels 类型
Go 中有几种不同类型的通道。它们每个都有各自的优点和缺点。
无缓冲 Channels
// Unbuffered channels are the simplest type of channel.
ch := make(chan int)
要创建无缓冲通道,请调用 make 函数,并提供通道类型。不要在第二个参数中提供大小值,如上例所示。
无缓冲通道默认是阻塞的,并且会阻塞 goroutine 直到有东西要读取或写入。
缓冲 Channels
// Buffered channels are the other primary type of channel.
ch := make(chan int, 10)
要创建缓冲通道,调用 make 函数,提供通道类型和缓冲区大小。上面的示例将创建一个缓冲区大小为 10 的通道。如果你尝试写入已满的通道,它将阻塞 goroutine,直到缓冲区中有空间。如果你试图从一个空的通道中读取,它将阻塞 goroutine,直到有东西要读取。
但是,如果你想写入通道并且缓冲区有可用空间,则它不会阻塞 goroutine。
注意: 通常,仅在真的需要时才使用缓冲通道。最佳实践是使用无缓冲通道。
只读和只写 Channels
通道的一个有趣用例是拥有一个仅用于读取或写入的通道。当你有一个需要从通道读取但你不希望该 goroutine 写入时,这很有用,反之亦然。这对于下面描述的所有者模式特别有用。
这是创建只读或只写通道的语法。
// Define the variable with var
var writeOnly chan<- int
var readOnly <-chan int
mychan := make(chan int)
// Assign the channel to the variable
readOnly = mychan
writeOnly = mychan
箭头指示通道的方向。chan
前面的箭头表示数据流进入通道,而 chan
后面的箭头表示数据流流出通道。
只读通道的一个示例是time.Tick
函数:
// Tick is a convenience wrapper for NewTicker providing access to the ticking
// channel only
func Tick(d Duration) <-chan Time
此方法返回一个只读通道,time
包以指定的时间间隔在内部写入该通道。这种模式确保了时钟滴答的实现逻辑与time
包隔离,因为用户不需要往通道写。
当你需要写入通道但你知道 goroutine 不需要从中读取时,只写通道非常有用。下面描述的所有者模式就是一个很好的例子 。
Channels 的设计注意事项
设计注意事项包括:
哪个作用域拥有 channel? 非所有者有什么能力? 完全所有权 只读 只写 channel 将如何清理? 哪个 goroutine 负责清理 channel?
所有者模式
Owner Pattern 是 Go 中的一种常见设计模式,用于确保通道的所有权由创建或拥有 goroutine 正确管理。这允许 goroutine 管理通道的整个生命周期并确保正确关闭通道。
以下是 Go 中所有者模式的示例:
func NewTime(ctx context.Context) <-chan time.Time {
tchan := make(chan time.Time)
go func(tchan chan<- time.Time) {
defer close(tchan)
for {
select {
case <-ctx.Done():
return
case tchan <- time.Now():
}
}
}(tchan)
return tchan
}
好处:
NewTime 控制通道实例化和清理(第 2 行和第 5 行) 通过定义只读/只写边界避免乱用 限制行为不一致的可能性
关于此示例的重要说明。上下文 ctx
传递给函数 NewTime
并用于指示 goroutine 停止。tchan
通道是普通的无缓冲通道,但以只读方式返回。
当传递给内部 goroutine 时,tchan
通道作为只写通道传递。因为内部 goroutine 提供了一个只写通道,所以它有责任在完成时关闭通道。
使用 select
语句,time.Now()
调用仅在从通道读取时执行。这确保time.Now()
调用的执行与从通道读取同步。这种类型的模式有助于抢先减少 CPU 周期。
循环 Channels
从通道读取的一种方法是使用for
循环。这在某些情况下可能很有用。
var tchan <-chan time.Time
for t := range tchan {
fmt.Println(t)
}
我不推荐这种方法有几个原因。首先,不能保证通道会关闭(打破循环)。其次,循环不遵守上下文,这意味着如果取消上下文,循环将永远不会退出。第二点特别重要,因为没有优雅的方式来退出 goroutine。
我建议不要在通道上循环,而是使用以下模式,在该模式中使用带有select
语句的无限循环。这种模式确保检查上下文,如果它被取消,循环退出,同时还允许循环仍然从通道中读取。
var tchan <-chan time.Time
for {
select {
case <-ctx.Done(): // Graceful exit
return
case t, ok := <-tchan: // Read from the time ticker
if !ok { // Channel closed, exit
return
}
fmt.Println(t)
}
}
下文会详细讨论这个。
转发 Channels
在适当的情况下,将通道从一个转发到另一个也是一种有用的模式。这是使用<- <-
运算符完成的。
这是将一个通道转发到另一个通道的示例:
func forward(ctx context.Context, from <-chan int) <-chan int {
to := make(chan int)
go func() {
for {
select {
case <-ctx.Done():
return
case to <- <-from: // Forward from into the to channel
}
}
}()
return to
}
注意: 使用此模式,你无法检测
from
通道何时关闭。这意味着from
通道将不断地向to
通道发送数据,并且内部 goroutine 将永远不会退出,从而导致大量零值数据和 goroutine 泄漏。
根据你的使用场景,这可能是可取的,但是,重要的是要注意,当你需要检测关闭的通道时,这种模式不是一个好方式。
Select 语句
select
语句允许在 Go 应用程序中管理多个通道,并可用于触发操作、管理数据或以其他方式创建逻辑并发流。
select {
case data, ok := <- incoming: // Data Read
if !ok {
return
}
// ...
case outgoing <- data: // Data Write
// ...
default: // Non-blocking default action
// ...
}
注意:
select
本质上是随机的。这意味着如果有多个通道准备好同时读取或写入,该select
语句将随机选择一个 case 语句来执行。
测试 Select 语句
select 语句的随机性会使测试 select 语句有点棘手,尤其是在测试以确保上下文取消正确退出例程时。
这是一个如何使用统计测试来测试 select 语句的示例,其中测试执行的次数确保测试失败的统计可能性很低。
此测试通过在并行例程中运行相同的已取消上下文 100 次来工作,其中两个上下文中只有一个已被取消。在这种情况下,总会有一个通道的消费者,因此每次循环运行时,都有 50% 的可能性会执行 context case。
通过运行 100 次,有 50% 的机会选择触发上下文情况,测试将无法检测到所有 100 个测试的上下文取消的可能性非常非常低。
带上下文的 Work 取消
在构建 Go 应用程序的早期,用户构建具有done
通道的应用程序,他们将在其中创建一个看起来像这样的通道:done := make(chan struct{})
,这是一种非常简单的方法,可以向 goroutine 发出它应该退出的信号,因为你所要做的就是关闭通道并将其用作退出信号。
// Example of a simple done channel
func main() {
done := make(chan struct{})
go doWork(done)
go func() {
// Exit anything using the done channel
defer close(done)
// Do some more work
}()
<-done
}
func doWork(done <-chan struct{}) {
for {
select {
case <-done:
return
default:
// ...
}
}
}
这种模式变得如此普遍,以至于 Go 团队创建了context 包 作为替代。该包提供了一个接口context.Context
,可用于向 goroutine 发出信号,告知它在Done
方法返回的只读通道返回时退出 。
import "context"
func doWork(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
// ...
}
}
}
除此之外,他们还提供了一些用于创建分层上下文、超时上下文和可以取消的上下文的方法。
context.WithCancel 返回 context.Context
以及context.CancelFunc
,可用于取消上下文的函数字面值。context.WithTimeout 与 WithCancel
返回一致,但具有超时,将在指定time.Duration
时间过去后取消上下文。context.WithDeadline 与 WithCancel 返回一致,但有一个截止日期,将在指定的时间过去后取消上下文。
最佳实践:接受上下文的函数的第一个参数应该 始终 是 context,并且应该命名为 ctx
。
原文链接:https://benjiv.com/go-native-concurrency-primitives/。
往期推荐
欢迎关注「幽鬼」,像她一样做团队的核心。