原文:《Manning.100.Go.Mistakes.and.How.to.Avoid.Them》61-67
从本文中将会学到
-
防止 goroutine 的常见错误和channel -
了解标准数据结构与并发代码使用的影响 -
使用标准库和一些扩展 -
避免数据竞争和死锁
传递不当的上下文
上下文在golang中使用频繁,并且也建议进行Context的传递。然而,上下文传递会产生一些“微妙”的坑,导致子函数无法正确的运行。 举例:创建一个HTTP服务器来完成任务并返回响应。但是在返回响应之前,需要发送向Kafka发送Topic。对于这种场景,为了不想增加http客户端的响应时间,通常会将该处理逻辑放入到新的goroutine中。 假设有一个可以使用的发布函数,它接受上下文,因此如果上下文发生变化,发布消息的操作可以被中断。例子如下:
func handler(w http.ResponseWriter, r *http.Request){
response, err := doSomeTask(r.Context(), r) // 完成一些计算任务
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
go func() { // 创建协程发布kafka消息
err := publish(r.Context(), response)
// Do something with err
}()
// 完成响应
writeResponse(response)
}
首先我们调用 doSomeTask 函数来获取响应变量。之后,它执行goroutine 调用publish函数,并格式化 HTTP 响应。另外,在调用publish后,传播附加到 HTTP 请求的上下文。猜一下这段代码有什么问题吗?必须知道http中的context会在以下几种情况下取消:
-
当客户端连接关闭; -
在HTTP2中,请求被取消时; -
完成响应时;
在前两种情况下,可能会正确处理函数。例如,如果我们得到一个来自 doSomeTask 的响应,但客户端已关闭连接,这样在上下文已取消的情况下调用publish,也不会发布消息。但如果是下面这种情况会怎么样? 当响应被写入客户端时,与该响应关联的上下文请求将被取消。因此,我们面临着竞争条件:
-
如果响应是在kafka发布之后,能够成功的完成响应以及发送kafka数据。 -
然而,如果响应在kafka发送消息之前或者正在进行中,消息将无法正确发送。
如何解决?一种方法是不传递parent context。 取而代之的事,使用空上下文来调用publish;
err := publish(context.Background(), response)
不管响应已经响应了多久,都能正确的调用publish。但是如果上下文包含有用的值怎么办?例如,如果上下文包含用于分布式跟踪的关联 ID,我们可以关联 HTTP请求和 Kafka 发布。理想情况下,我们希望有一个新的上下文与潜在的父context取消无关,但仍然传达其中的值。 标准包并没有立即解决这个问题。因此,一个可能的解决方案是实现我们自己的 Go 上下文,类似于上下文 提供,但它不携带取消信号。 context.Context 是一个包含四个方法的接口:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
上下文的截止时间由 Deadline 方法管理,取消信号由 Done 和 Err 方法管理。当截止日期已过或上下文已被取消时,Done 应返回一个关闭的通道,而 Err 应返回错误。最后,通过 Value 方法携带这些值。 让我们创建一个自定义上下文,将取消信号与父context分离:
type detach struct {
ctx context.Context
}
func (d detach) Deadline() (time.Time, bool) {
return time.Time{}, false
}
func (d detach) Done() <-chan struct{} {
return nil
}
func (d detach) Err() error {
return nil
}
func (d detach) Value(key any) any { //委托从上级context中获取值
return d.ctx.Value(key)
}
除了调用父上下文来检索值的 Value 方法之外,其他方法返回默认值,因此上下文永远不会被视为已过期或取消。由于我们的自定义上下文,我们现在可以调用发布并分离取消信号:
err := publish(detach{ctx: r.Context()}, response)
现在传递给发布的上下文永远不会过期或被取消,但它会携带父上下文的值。 总之,传播上下文应该谨慎进行。我们举例说明了本节提供了一个基于与 HTTP 请求关联的上下文处理异步操作的示例。因为一旦我们返回,上下文就会被取消响应时,异步操作也可能意外停止。忍耐一下,请注意传播给定上下文的影响,并且如有必要,始终可以为特定操作创建自定义上下文。
不知道何时关闭已启动的goroutine
Goroutines 启动起来既简单又便宜——如此简单和便宜以至于我们可能不一定制定何时停止新 goroutine 的计划,这可能会导致泄漏。不知道何时停止 goroutine 是一个设计问题,也是 Go 中常见的并发错误。 让我们了解一下原因以及如何预防。 首先,让我们量化一下 goroutine 泄漏的含义。在内存方面,goroutine从最小堆栈大小 2 KB 开始,它可以根据需要增长和缩小(最大堆栈大小在 64 位上为 1 GB,在 32 位上为 250 MB)。在内存方面,goroutine 还可以保存分配给堆的变量引用。与此同时,一个 goroutine可以保存 HTTP 或数据库连接、打开的文件和网络等资源最终应该正常关闭的套接字。如果 Goroutine 泄漏,这些各种资源也会被泄露。 让我们看一个 goroutine 停止点不清楚的例子。这里,父 goroutine 调用一个返回通道的函数,然后创建一个新的 goroutine 将继续从该通道接收消息:
ch := foo()
go func() {
for v := range ch {
// ...
}
}()
当 ch 关闭时,创建的 goroutine 将退出。但我们确切知道什么时候通道将被关闭?这可能不明显,因为 ch 是由 foo 创建的。如果通道从未关闭,那就是泄漏。所以,应该时刻保持警惕 goroutine 的退出点并确保最终能够关闭。 让我们讨论一个具体的例子。我们会设计一个需要监控应用的一些外部配置(例如,使用数据库连接)。这是一个初始化操作:
func main() {
newWatcher()
// Run the application
}
type watcher struct { /* Some resources */ }
func newWatcher() {
w := watcher{}
go w.watch() // 创建goroutine,监控外部配置项
}
我们调用 newWatcher,它创建一个watcher结构并启动一个负责的 goroutine监控配置。这段代码的问题在于,当 main goroutine 退出(可能是因为操作系统信号或因为它的工作负载有限),应用程序已停止。因此,watcher创建的资源不会被优雅地关闭。我们怎样才能防止这种情况发生呢? 一种选择可能是向 newWatcher 传递一个上下文,该上下文将在main函数返回时被取消:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
newWatcher(ctx)
// Run the application
}
func newWatcher(ctx context.Context) {
w := watcher{}
go w.watch(ctx)
}
我们将创建的上下文传播到 watch 方法。当上下文被取消时,watcher结构应该关闭其资源。然而,我们能保证 watch会有时间这样做吗?绝对没有——这是一个设计缺陷。 问题是我们已经使用了信号来传达,让 goroutine 必须停止运行。在资源关闭之前我们不会阻塞父 goroutine。 让我们确保我们这样做:
func main() {
w := newWatcher()
defer w.close()
}
func newWatcher() watcher {
w := watcher{}
go w.watch()
return w
}
func (w watcher) close() {
// Close the resources
}
watcher 有一个新方法:close。而不是向watcher发出信号,表明该关闭了它的资源,我们现在调用这个 close 方法,使用 defer 来保证资源在应用程序退出之前关闭。 总之,我们要记住,goroutine 是一种资源,就像任何其他必须使用的资源一样。最终将关闭以释放内存或其他资源。启动一个 goroutine 而不需要知道何时停止是一个设计问题。每当一个 goroutine 启动时,我们应该对于何时停止有一个明确的计划。最后但并非最不重要的一点是,如果一个 goroutine 创建了资源及其生命周期与应用程序的生命周期绑定,这可能更安全在退出应用程序之前等待此 goroutine 完成。这样,我们就可以确保可以释放资源。
goroutine和循环变量
处理 goroutine 和循环变量可能是 Go 开发人员在编写并发应用程序时最常见的错误之一。我们来看一个具体例子;然后我们将定义此类错误的条件以及如何预防它。 在下面的示例中,我们初始化一个切片。然后,创建一个闭包的goroutine函数来访问这个元素:
s := []int{1, 2, 3}
for _, i := range s {
go func() {
fmt.Print(i)
}()
}
可能期望这段代码以不特定的顺序打印 123 (因为没有保证创建的第一个 goroutine 将首先完成)。然而,这个输出代码不是确定性的。例如,有时会打印 233,有时会打印 333。什么原因?在此示例中,我们从闭包创建新的 goroutine。作为提醒,闭包是一个函数它引用其主体外部的变量:这里是 i 变量。我们必须知道,当执行闭包 goroutine 时,它不会捕获创建 goroutine 时的值。相反,所有的 goroutine 都引用完全相同的变量。当 goroutine 运行时,它会打印执行 fmt.Print 时 i 的值。因此,自 goroutine 启动以来, i 可能已被修改。 下图显示了当代码打印 233 时可能的执行情况。随着时间的推移,i 的值变化:1、2,然后是 3。在每次迭代中,我们都会启动一个新的 goroutine。因为无法保证每个 goroutine 何时启动和完成,结果也各不相同。在这个例子中,第一个 goroutine 在 i 等于 2 时打印 i。然后当值已经等于 3 时,其他 goroutine 会打印 i。因此, 示例打印 233。此代码的行为不是确定性的:如果我们希望每个闭包在创建 goroutine 时都访问 i 的值,有什么解决方案? 如果我们想继续使用闭包,第一个选项,创建一个新变量:
for _, i := range s {
val := i
go func() {
fmt.Print(val)
}()
}
为什么这段代码有效?在每次迭代中,我们创建一个新的局部 val 变量。这变量在创建 goroutine 之前捕获 i 的当前值。因此,当每个闭包 goroutine 都会执行 print 语句,它会按照预期执行价值。此代码打印 123(同样,没有特定的顺序)。 第二个选项不再依赖于闭包,而是使用实际的功能:
for _, i := range s {
go func(val int) {
fmt.Print(val)
}(i)
}
我们仍然在新的 goroutine 中执行匿名函数(我们不运行 go f(i),例如),但这一次它不是闭包。该函数没有引用 val作为来自其体外的变量; val 现在是函数输入的一部分。通过这样做,我们在每次迭代中修复 i 并使我们的应用程序按预期工作。 我们必须谨慎对待 goroutine 和循环变量。如果 goroutine 是一个访问从其主体外部声明的迭代变量的闭包,那就是一个问题。我们可以通过创建局部变量来修复它(例如,正如我们所见在执行 goroutine 之前使用 val := i )或使该函数再是闭包。这两种选择都有效,而且没有哪一种是我们应该优先选择的。一些开发人员可能会发现闭包方法更方便,而其他开发人员可能会发现函数方法更具表现力。
正确的使用select和channel
Go 开发人员在使用通道时常犯的一个错误是:关于 select 在多个通道中的行为方式的错误假设。错误的假设可能会导致难以识别和重现的微妙错误。 假设我们想要实现一个 goroutine,需要从两个channel接受数据:
-
messageCh 用于要处理的新消息。 -
disconnectCh 接收传达断开连接的通知。在这种情况下,我们想从父函数返回。
在这两个通道中,我们要优先考虑 messageCh。例如,如果断开连接发生这种情况时,我们要确保在返回之前已收到所有消息。 我们可能决定像这样处理优先级:
for {
select {
case v := <-messageCh:
fmt.Println(v)
case <-disconnectCh:
fmt.Println("disconnection, return")
return
}
}
我们使用 select 从多个渠道接收。因为我们想要优先考虑messageCh,我们可能会假设我们应该首先编写 messageCh 案例,然后 接下来是disconnectCh 情况。但这段代码真的有效吗?让我们尝试一下,写一个发送 10 条消息然后发送断开连接的虚拟生产者 goroutine:
for i := 0; i < 10; i++ {
messageCh <- i
}
disconnectCh <- struct{}{}
// 结果可能如下
/*
0
1
2
3
4
disconnection, return
*/
我们没有消耗这 10 条消息,而是只收到了其中 5 条。什么原因?在于多通道的select语句的规范:
★
如果一项或多项channel可以继续进行,则通过统一的伪随机选择,选择一项可以继续进行的通道,
”
这种行为一开始可能看起来很奇怪,但有一个很好的理由:为了防止可能的饥饿。假设选择的第一个可能的通信基于原顺序。在这种情况下,我们可能会陷入这样的情况,例如,我们只由于发送者速度快,因此从一个通道接收。为了防止这种情况发生,设计师决定采用随机选择的方式。 回到我们的例子,尽管 case v := <-messageCh 是第一个源顺序,如果messageCh和disconnectCh中都有消息,则没有保证选择哪种情况。因此,该示例的行为不是确定性的。我们可能会收到 0 条消息,或者 5 条,或者 10 条。 我们怎样才能克服这种情况呢?如果我们愿意的话,有不同的可能性在断开连接的情况下返回之前接收所有消息。
-
使 messageCh 成为无缓冲通道而不是缓冲通道。因为发送者 goroutine 会阻塞,直到接收者 goroutine 准备好为止,这种方法保证在从disconnectCh 断开连接之前收到来自messageCh 的所有消息。 -
使用单个通道而不是两个通道。例如,我们可以定义一个传达新消息或断开连接的结构。Channel保证发送的消息的顺序与消息的顺序相同收到,所以我们可以确保断开连接是最后收到的。
如果我们遇到有多个生产者协程的情况,可能无法保证哪个先写。因此,我们是否有一个无缓冲的messageCh 通道或单个通道,它会导致生产者 goroutine 之间的竞争条件。在这种情况下,我们可以实施以下解决方案:
-
从messageCh 或disconnectCh 接收。 -
如果收到断开连接: -
读取messageCh 中的所有现有消息(如果有)。 -
然后返回。
for {
select {
case v := <-messageCh:
fmt.Println(v)
case <-disconnectCh:
for {
select {
case v := <-messageCh:
fmt.Println(v)
default:
fmt.Println("disconnection, return")
return
}
}
}
}
该解决方案使用内部 for/select,有两种情况:一种在 messageCh 上,另一种在 messageCh 上默认情况。仅当没有其他选项时才选择在 select 语句中使用默认值案例匹配。在这种情况下,这意味着我们只有在收到所有信息后才会返回messageCh 中的剩余消息。 让我们看一个示例来了解此代码的工作原理。我们将考虑以下情况messageCh 中有两条消息,disconnectCh 中有一条断开连接,如下所示在这种情况下,正如我们所说, select 随机选择一种情况或另一种情况。让我们假设 select 选择第二种情况;
因此,我们收到断开连接并进入内部选择(图9.4)。在这里,作为只要消息保留在 messageCh 中,select 就会始终优先考虑第一种默认情况:
一旦我们收到了来自messageCh的所有消息,select就不会阻塞并且选择默认情况(图 9.6)。因此,我们返回并停止 goroutine。
这是一种确保我们从通道接收所有剩余消息的方法多个通道上的接收器。当然,如果在goroutine之后发送messageCh已返回(例如,如果我们有多个生产者 goroutine),我们将错过这个信息。当对多个通道使用 select 时,我们必须记住,如果多个通道选项是可能的,源顺序中的第一种情况不会自动获胜。相反,Go 是随机选择的,因此不能保证会选择哪个选项。为了克服这种行为,在单个生产者 goroutine 的情况下,我们可以使用无缓冲通道或单个通道。在多个生产者的情况下在 goroutine 中,我们可以使用内部选择和默认值来处理优先级。
不使用通知类型Channel
通道是一种通过信号在 goroutine 之间进行通信的机制。信号可以带有或不带有数据。但对于 Go 程序员来说,情况并不总是如此直接,如何处理后一种情况。 让我们看一个具体的例子。我们将创建一个通道,当发生某种断开连接时,该通道会通知我们。一个想法是将其作为 chan bool 处理:
disconnectCh := make(chan bool)
现在,假设我们与一个为我们提供这样一个通道的 API 进行交互。因为它是布尔值的通道,我们可以接收 true 或 false 消息。大概是清楚true传达的内容。但false是什么意思呢?这是否意味着我们还没有断开连接?那么在这种情况下,我们多久会收到这样的信号呢?可以意味着我们已经重新连接了? 我们是否应该期望收到false信息?也许我们应该只期望收到True的消息。在这个例子中,如果我们没有要求特殊的值来进行通信,可以使用没有数据的类型,empty结构体: chan struct{}。 在 Go 中,空结构体是没有任何字段的结构体。无论架构如何,它占用零字节的存储空间,我们可以使用 unsafe.Sizeof 进行验证:
var s struct{}
fmt.Println(unsafe.Sizeof(s))
0
★
为什么不使用空接口(var i interface{})?因为一个空接口不是免费的;在32位架构上它占用8个字节,64 位架构上它占用16个字节。
”
空结构是表达含义缺失的事实上的标准。例如,如果我们需要一个哈希集结构(唯一元素的集合),我们应该使用空结构作为值:map[K]struct{}。 应用于通道,如果我们想创建一个通道来发送通知而无需数据,在 Go 中执行此操作的适当方法是 chan struct{}。最知名的之一Go 上下文附带了空结构通道的使用。 通道可以有数据,也可以没有数据。如果我们想设计一个惯用的 API关于 Go 标准,让我们记住没有数据的通道应该是用 chan struct{} 类型表示。通过这种方式,它可以让接收者清楚地知道他们不应期望消息内容具有任何意义,而应期望它们具有以下事实:收到一条消息。在 Go 中,此类通道称为通知通道。
不使用nil通道
使用 Go 和通道时的一个常见错误是忘记 nil 通道有时会有帮助。那么什么是 nil 通道,我们为什么要关心他们?这是本节的范围。 让我们从创建一个 nil 通道并等待接收消息的 goroutine 开始。这段代码应该做什么?
var ch chan int
<-ch
ch 是 chan int 类型。通道的零值为nil,ch 为nil。 Goroutine不会惊慌;但是,它将永远阻塞。 如果我们向 nil 通道发送消息,原理是相同的。这个协程永远阻塞:
var ch chan int
ch <- 0
那么 Go 允许从 nil 接收消息或向 nil 发送消息的目的是什么?我们通过一个具体的例子来讨论这个问题。 我们将实现一个 func merge(ch1, ch2 <-chan int) <-chan int 函数来将两个通道合并为一个通道。通过合并它们,我们的意思是ch1 或 ch2 中收到的每条消息都将发送到返回的通道。我们如何在 Go 中做到这一点?让我们首先编写一个简单的实现来启动goroutine 并从两个通道接收(生成的通道将是一个缓冲的具有一个元素的通道):
func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
go func() {
for v := range ch1 {
ch <- v
}
for v := range ch2 {
ch <- v
}
close(ch)
}()
return ch
}
在另一个 goroutine 中,我们从两个通道接收消息,并且每条消息最终都会正在发表于 ch. 第一个版本的主要问题是我们从 ch1 接收,然后我们接收从ch2开始。这意味着在 ch1 关闭之前我们不会从 ch2 接收数据。这不适合我们的用例,因为 ch1 可能永远打开,所以我们希望同时从两个通道接收。 让我们使用 select 编写一个带有并发接收器的改进版本:
func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
go func() {
for {
select {
case v := <-ch1:
ch <- v
case v := <-ch2:
ch <- v
}
}
close(ch)
}()
return ch
}
select 语句让 goroutine 同时等待多个操作。因为我们将其包装在 for 循环中,所以我们应该重复接收来自一个循环的消息或者其他渠道,对吗?但这段代码真的有效吗? 一个问题是 close(ch) 语句无法访问。循环遍历一个当通道关闭时,使用范围运算符的通道会中断。但是,那当 ch1 或 ch2 关闭时,我们实现 for/select 的方式不会捕获。 更糟糕的是,如果在某个时刻 ch1 或 ch2 关闭,则合并后的接收者将执行以下操作:
received: 0
received: 0
received: 0
received: 0
received: 0
因此接收者将重复接收等于零的整数。为什么?接收来自关闭通道是一种非阻塞操作:
ch1 := make(chan int)
close(ch1)
fmt.Print(<-ch1, <-ch1)
虽然我们可能期望这段代码会出现恐慌或阻塞,但它会运行并打印0 0。我们在这里捕获的是关闭事件,而不是实际的消息。检查是否我们收到消息或关闭信号,我们必须这样做:
ch1 := make(chan int)
close(ch1)
v, open := <-ch1
fmt.Print(v, open)
// 0 false
同时,我们还将 0 赋给 v,因为它是整数的零值。 让我们回到第二个解决方案。我们说过,如果 ch1 是,则效果不太好关闭;例如,因为选择的情况是 case v := <-ch1,所以我们将继续输入此情况并向合并通道发布零整数。 让我们退后一步,看看解决这个问题的最佳方法是什么。我们必须从两个渠道接收。那么,要么
-
ch1 首先关闭,因此我们必须从 ch2 接收数据,直到它关闭为止。 -
ch2 首先关闭,因此我们必须从 ch1 接收数据,直到它关闭为止。

func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
ch1Closed := false
ch2Closed := false
go func() {
for {
select {
case v, open := <-ch1:
if !open {
ch1Closed = true
break
}
ch <- v
case v, open := <-ch2:
if !open {
ch2Closed = true
break
}
ch <- v
}
if ch1Closed && ch2Closed {
close(ch)
return
}
}
}()
return ch
}
我们定义两个布尔值 ch1Closed 和 ch2Closed。一旦我们收到一条来自通道,我们检查它是否是关闭信号。如果是这样,我们通过标记来处理它通道已关闭(例如,ch1Closed = true)。两个通道关闭后,我们关闭合并的通道并停止 goroutine。 除了开始变得复杂之外,这段代码还有什么问题呢?有一个主要问题:当两个通道之一关闭时,for 循环将充当忙等待循环,这意味着即使其他通道中没有收到新消息,它也会继续循环。我们必须牢记对方的行为在我们的示例中选择语句。假设 ch1 已关闭(因此我们不会收到任何新的消息);当我们再次到达 select 时,它将等待以下三个条件之一发生:
-
ch1 关闭。 -
ch2 有一条新消息。 -
ch2 关闭
第一个条件,ch1 关闭,始终有效。因此,只要我们不在 ch2 中收到消息并且该通道未关闭,我们将继续循环第一个案例。这将导致 CPU 周期的浪费,必须避免。因此,我们的解决方案不可行。 我们可以尝试增强状态机部分并实现 sub-for/select在每个案例中循环。但这会让我们的代码变得更加复杂和困难去理解。 现在是回归零通道的最佳时机。正如我们提到的,从nil 通道将永远阻塞。在我们的解决方案中使用这个想法怎么样?代替在通道关闭后设置一个布尔值,我们将把这个通道赋值为 nil。让我们写最终版本:
func merge(ch1, ch2 <-chan int) <-chan int {
ch := make(chan int, 1)
go func() {
for ch1 != nil || ch2 != nil {
select {
case v, open := <-ch1:
if !open {
ch1 = nil
break
}
ch <- v
case v, open := <-ch2:
if !open {
ch2 = nil
break
}
ch <- v
}
}
close(ch)
}()
return
}
首先,只要至少有一个通道仍然打开,我们就循环。那么,例如,如果 ch1 是关闭后,我们将 ch1 赋值为 nil。因此,在下一次循环迭代期间,选择语句将仅等待两个条件:
-
ch2 有一条新消息。 -
ch2 关闭。
ch1 不再是等式的一部分,因为它是一个零通道。同时,我们保留ch2 的逻辑相同,并在关闭后将其赋值为 nil。最后,当两个通道都close,我们关闭合并的通道并返回。显示了这个模型执行:这是我们一直在等待的实施。我们涵盖所有不同的情况,并且它不需要会浪费 CPU 周期的繁忙循环。 总而言之,我们已经看到等待或发送到 nil 通道是一种阻塞行动,而且这种行为并非无用。正如我们在整个示例中所看到的合并两个通道,我们可以使用 nil 通道来实现优雅的状态,将从 select 语句中删除一个案例的机器。让我们把这个想法保留在头脑中: nil 通道在某些情况下很有用,并且在处理并发代码时应该成为 Go 开发操作工具集的一部分。
channel大小的困惑
当我们使用 make 内置函数创建通道时,通道可以是无缓冲或缓冲。与这个主题相关,有两个错误经常发生:不知道何时使用其中之一;并且,如果我们使用缓冲通道,使用什么尺寸。让我们来看看这些要点。 首先,让我们记住核心概念。无缓冲通道是没有任何容量的通道。它可以通过省略大小或提供 0 大小来创建:
ch1 := make(chan int)
ch2 := make(chan int, 0)
使用无缓冲通道(有时称为同步通道),发送方将阻塞直到接收者从通道接收到数据。 相反,缓冲通道具有容量,并且必须使用大小来创建它大于或等于1:
ch3 := make(chan int, 1)
使用缓冲通道,发送者可以在通道未满时发送消息。 一旦通道已满,它将阻塞,直到接收者 Goroutine 收到消息。例子:
ch3 := make(chan int, 1)
ch3 <-1 // 未阻塞
ch3 <-2 // !!!!阻塞
第一个发送不会阻塞,而第二个发送则会阻塞,因为此时通道已满阶段。 让我们退一步讨论两者渠道类型之间的根本区别。通道是一种并发抽象,用于实现通信Goroutines 之间。但是同步呢?并发、同步意味着我们可以保证多个 goroutine 在某个时刻处于已知状态。例如,互斥体提供同步,因为它确保只有一个 goroutine 可以同时处于临界区。关于渠道:
-
无缓冲通道可实现同步。我们保证两个 goroutine 将处于已知状态:一个接收,另一个发送信息。 -
缓冲通道不提供任何强同步。事实上,生产者 goroutine 可以发送一条消息,然后在以下情况下继续执行:通道未满。唯一的保证是 Goroutine 在消息发送之前不会收到消息。但这只是因为因果关系的保证(你不在准备咖啡之前先喝掉咖啡)。
记住这一基本区别非常重要。两种通道类型均启用通信,但只有一种提供同步。如果我们需要同步,我们必须使用无缓冲通道。无缓冲通道也可能更容易推理:缓冲通道可能会导致难以理解的死锁,这些死锁会立即发生对于无缓冲的通道来说很明显。 在其他情况下,无缓冲通道更可取:例如,在通知通道的情况,其中通知是通过通道关闭 (close(ch)) 处理的。这里,使用缓冲通道不会带来任何好处。 但是如果我们需要一个缓冲通道怎么办?我们应该提供什么尺寸?默认我们应该为缓冲通道使用的值是最小值:1。因此,我们可以接近从这个角度来看问题:有什么充分的理由不使用值 1 吗?这是我们应该使用其他尺寸的可能情况列表:
-
使用类似池化模式时,意味着旋转固定数量的需要将数据发送到共享通道的 goroutine。在这种情况下,我们可以绑定通道大小与创建的 goroutine 数量的关系。 -
当使用通道解决速率限制问题时。例如,如果我们需要通过限制请求数量来强制资源利用率,我们应该设置根据限制增加通道大小。
如果我们不属于这些情况,则应谨慎使用不同的通道大小。使用魔数来设置通道大小的代码库是很常见的:
ch := make(chan int, 40)
为什么是40?理由是什么?为什么不是 50 甚至 1000?设置这样的值应该是有充分理由的。也许这是根据基准测试或性能测试决定的。在许多情况下,评论其基本原理这样的值可能是个好主意。 让我们记住,确定准确的队列大小并不是一个容易的问题。首先,是CPU和内存之间的平衡。值越小,越多 我们可能会面临CPU争用。但值越大,需要的内存就越多被分配。 另一点需要考虑的是 2011 年关于 LMAX 的白皮书中提到的一点Disruptor(Martin Thompson 等人;https://lmax-exchange.github.io/disruptor/files/Disruptor-1.0.pdf)
由于速度差异,队列在消费者和生产者之间通常总是接近满或接近空。他们很少在平衡的中间地带运作
生产率和消费率均匀匹配。
因此,很难找到稳定准确的通道尺寸,这意味着准确的通道尺寸不会导致太多争用或浪费内存分配的值。 这就是为什么,除了所描述的情况外,通常最好从默认值开始通道大小为 1。当不确定时,我们仍然可以使用基准来测量它,例如: 与编程中的几乎任何主题一样,都可以找到例外。因此,本节的目标不是详尽无遗,而是就我们的尺寸给出指示 应该在创建通道时使用。同步是无缓冲的无缓冲通道,而不是缓冲通道。此外,如果我们需要一个缓冲通道,我们应记住使用 1 作为通道大小的默认值。我们应该只决定通过准确的流程谨慎使用另一个值,以及理由。也许应该发表评论(注释)。最后但并非最不重要的一点是,让我们记住,选择缓冲通道也可能会导致不明显的死锁,这些死锁更容易被发现无缓冲通道。
原文始发于微信公众号(小唐云原生):【100 Mistakes】golang并发的坑-1
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/247547.html