Go语言的100个错误使用场景(61-68)|并发实践
前言
大家好,这里是白泽。《Go语言的100个错误以及如何避免》是最近朋友推荐我阅读的书籍,我初步浏览之后,大为惊喜。就像这书中第一章的标题说到的:“Go: Simple to learn but hard to master”,整本书通过分析100个错误使用 Go 语言的场景,带你深入理解 Go 语言。
我的愿景是以这套文章,在保持权威性的基础上,脱离对原文的依赖,对这100个场景进行篇幅合适的中文讲解。所涉内容较多,总计约 8w 字,这是该系列的第八篇文章,对应书中第61-68个错误场景。
🌟 当然,如果您是一位 Go 学习的新手,您可以在我开源的学习仓库中,找到针对《Go 程序设计语言》英文书籍的配套笔记,其他所有文章也会整理收集在其中。
📺 B站:白泽talk,公众号【白泽talk】,聊天交流群:622383022,原书电子版可以加群获取。
前文链接:
9. 并发实践
🌟 章节概述
- 防止发生 goroutine 和 channel 中的常见错误
- 理解标准数据结构在并发场景的使用
- 使用标准库和一些扩展
- 避免数据竞争和死锁
9.1 context 的不恰当传播(#61)
context 作为承载上下文的实例,经常在各个函数之间传播,由于 context.Context
本身是一个接口,它声明了四个方法:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
当一个 context 因为过期或者被手动 cancel,都会导致上下文关闭。此时可以从 Done()
获得的 channel 中获得关闭信号,以及从 Err()
方法获得原因。
这也导致了,在传递 context 实例的时候,因为一些原因导致传递给子步骤的 context 已经关闭,但是子步骤中需要使用到,从而造成混淆。
🌟 假设有一个场景,针对收到的一个 HTTP 请求,服务端会处理一些任务,得到结果A,同时将处理结果A通过 Kafka 异步发送一个事件,同时主协程返回任务处理结果A给客户端。
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() {
err := publish(r.Context(), response)
// Do something with err
}()
writeResponse(response)
}
考虑以下三个场景:
- 客户端请求关闭
- 如果是 HTTP/2 的请求,当请求被取消
- 当 response 已经被返回给客户端
前两个场景,如果在执行完 doSomeTask()
的到 response 并调用 publish 后,请求被取消,则 publish 函数是可以允许接收一个被关闭的 context 实例的,只要在函数内判断当 context 被取消时,不发送消息即可。(当然不做任何处理,允许发送也是没有问题的)
但如果是已经将 writeResponse(response)
触发,响应给客户端,则 *http.Request
关联的 context 会被取消,此时如果在 publish()
函数中,做了 context 实例是否被取消的判断,则会出现混淆。因为此时是执行成功的链路,只是 go func()
执行逻辑因为异步的原因慢了,kafka 消息还是需要发送的。
🌟 解决方案:
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 {
return d.ctx.Value(key)
}
------------------------
// 使用方式
err := publish(detach{ctx: r.Context()}, response)
自定义 context 实例,将 Done() 和 Err() 方法失效,当不希望 context 的关闭对子步骤造成影响,可以通过这种方式,保留从原 context.Context 的实例中,获取上下文参数 value 的能力。
9.2 开启一个协程但不知道何时关闭(#62)
goroutine 泄漏:
协程启动将占用一个约 2KB 大小的栈内存空间,并随着使用增长或者收缩占用的空间,一个协程可以持有一个引用类型的变量,且分配在堆上。goroutine 也可以持有 HTTP 链接、数据库连接池等各种资源,如果协程发生了泄漏,则这些协程内原本应该被优雅释放的资源也将发生泄漏。
🌟 错误示例一:
ch := foo()
go func() {
for v := range ch {
//..
}
}()
在上述示例中,新创建的协程只有当主协程创建的 channel 被关闭的时候才会结束,但是如果外部没有主动关闭,则这个子协程会发生泄漏,永远无法关闭。
🌟 错误示例二:
假设应用执行之前需要通过一个函数去监听外部的配置信息。
func main() {
newWatcher()
// Run the application
}
type watcher struct{ /* Some resource */}
func newWatcher() {
w := watcher{}
go w.watch()
}
上述代码的问题在于,newWatcher 函数内启动的子协程会由于主协程的结束而被迫终止,导致 watcher 结构体所持有的资源,没有被优雅关闭。
🌟 错误示例三:
在错误示例二的基础上,容易犯的一个错误是,认为可以通过传递一个 context 来感知主协程关闭,从而控制子协程资源的释放。
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
newWatcher(ctx)
// Run the application
}
type watcher struct{ /* Some resource */}
func newWatcher(ctx context.Context) {
w := watcher{}
go w.watch(ctx)
}
错误原因:此时主协程如果关闭了传递给 watcher 结构体的 context,但是依旧有可能主函数直接执行完成,关闭了,子协程即使收到了 context 关闭的信号,依旧不一定有时间完成资源的释放。
⏰ 正确示例:
func main() {
w := newWatcher()
defer w.close()
// Run the application
}
func newWatcher() watcher {
w := watcher{}
go w.watch()
return w
}
type watcher struct{ /* Some resource */}
func (w watcher) close() {
// Close the resources
}
前几个示例出现资源释放问题的原因在于,在父协程关闭的时候,并没有阻塞等待子协程资源的释放,因此正确示例中,主协程在 return 之前,主动关闭 watcher 结构体持有的资源,实现优雅退出。
🌟 最佳实践:
将 goroutine 当作一种资源,在创建的开始就需要考虑何时关闭,并且如果 goroutine 持有了其他的资源,则需要一并考虑这些资源的释放。
如果要关闭主协程,务必将所有的释放工作,提前完成。
9.3 在循环中没有谨慎使用协程(#63)
错误示例:
s := []int{1, 2, 3}
for _, i := range s {
go func() {
fmt.Println(i)
}()
}
// 输出结果可能是:233,333
循环结构内部的 goroutine,这种闭包的写法,持有的 i 是同一个变量,因此虽然 i 是按照顺序1,2,3赋值的,但是并不能决定协程是在 i 等于几的时候触发打印操作。
比如出现233的执行顺序图示如下:
解决方案一:
for _, i := range s {
val := i
go fun() {
fmt.Println(val)
}()
}
通过引入 val 变量,可以确保 val 也是按顺序1,2,3进行赋值的,因为是局部变量,因此可以确保最终打印结果的有序。
解决方案二:
for _, i := range s {
go func(val int) {
fmt.Print(val)
}(i)
}
此时 goroutine 内部并没有直接引用外部的变量,此时 val 是输入的一部分,因此是一份新的拷贝,并不会引用同一个变量 i,所以依旧可以输出123。
9.4 使用 select 和 channel 期待某个确定的行为(#64)
假设需要同时监听两个 channel,一个 channel 获取消息,一个 channel 获取关闭信号:
for {
select {
// 此时 messageCh 是一个具有缓冲的 channel
case v := <-messageCh:
fmt.Println(v)
case <-disconnectCh:
fmt.Println("disconnection, return")
return
}
}
---------------------------------------------
for i := 0; i < 10; i++ {
messageCh <- i
}
disconnectCh <- struct{}{}
// 执行之后,输出结果可能为
0
1
2
3
4
5
disconnection, return
Go语言中:虽然 select 的两个 case,第一个获取 message 的 channel 排在前面,但是当多个条件同时成立的时候,执行是随机的,为了避免饥饿的情况。
为了能够顺利打印出所有的十个数,有两种方案:
- 将有缓冲的 channel 替换成无缓冲的 channel,这样使得消息的发送和接收成为了一个阻塞的串行流程,在完成所有数字的打印操作之前,主协程并不会执行
disconnectCh <- struct{}{}
这句代码。 - 使用单一的一个 channel 获取消息以及结束信号,用一个结构体作为 channel 的消息内容。
假设一定有多个消息的接收端,则通常来说,无法预测消息执行的顺序,一个可选的解决方案:
for {
select {
case v := <-messageCh:
fmt.Println(v)
case <-disconnectCh:
for {
select {
case v := <-messageCh:
fmt.Println(v)
default:
fmt.Println("disconnection, return")
return
}
}
}
}
当触发关闭链接的时候,在一个新的循环中消费 messageCh 中剩余所有的 message,select 语句的 defalut case 当且仅当没有其他 case 匹配的时候会执行。
当然如果某一个时刻,还有协程即将向 messageCh 发送消息,但是 messageCh 此刻为空,则会执行 select/default case,导致未发送的 message 的丢失。
9.5 不使用用于通知的 channel(#65)
假设需要一个 channel,为另一个协程传递关闭链接的信号,此时可以通过如下实现:
disconnectCh := make(chan bool)
这种方式可以通过传递一个 true 字面量用于通知子协程关闭链接,但是 false 字面量是没有意义的,此时需要的只是一个信号,所以可以使用空的结构体实现:
disconnectCh := make(chan struct{})
空的结构体本身不占用额外的存储空间,但是可以达到传递信号的效果,是 Go 语言当中地道的用法。
使用 struct{} 作为占位,经常出现在其他场景中,比如创建一个集合:
set := make(map[K]struct{})
9.6 不使用 nil channel(#66)
nil channel 的特性:
var ch chan int
<-ch // 会阻塞
ch<-1 // 会阻塞
假设有这样一个场景,需要从两个 channel 中接收数据,并且合并两个 channel 的数据到另一个 channel,且另一个 channel 的 buffer 长度为1。
错误示例一:
func merge(ch1, ch3 <-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
}
这种情况下,必须等 ch1 所有数据全部读取完毕,才会读取 ch2 的,并不是一个并发模型。
错误示例二:
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)
}()
}
使用 for/select 可以实现随机从两个 channel 中获取 v,但是问题在于,上述这种 for 循环将永远无法结束,即使外部可以控制将 ch1 和 ch2 都关闭了,但是面对两个关闭的 channel,select 的两个 case 的读取操作是不会阻塞的,依旧会读取出 0 值,并传递给 ch,导致 close(ch) 永远无法触发。
错误示例三:
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
}
通过状态机的形式,控制当两个 ch 都关闭的时候,触发第三个 channel 的关闭。但是上述实现有一个问题,就是即使 ch1 或者 ch2 有一者关闭了,因为 select 的两个 case 依旧不是阻塞的,所以会出现浪费 CPU 进行空转的情况,比如 ch1 已经关闭了,但是 select 依旧是随机触发了 case1,导致在触发另一个 case2 之前,会出现重复进入 select 循环的情况。(因为必须两个状态都是 true 才会使得状态机触发 close(ch) 的逻辑)。
推荐方案:
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 ch
}
利用 nil channel 的阻塞特性(存入和取出元素都会阻塞),使得当任一 channel 关闭之后,直接设置为 nil,这样会导致这个关联的 select 的 case 将永远阻塞,不会触发,会强制依赖另一个 case 的读取情况,如果另一个 channel 也关闭了,设置为 nil,则 for 循环条件不满足,结束循环,可以触发 close(ch)。
9.7 对 channel 的大小感到疑惑(#67)
如果从简单控制协程之间的同步,可以选择无缓冲的 channel,因为使用带有缓冲的 channel 并不能完全控制多个协程的执行顺序。
哪些情况下使用带有缓冲的 channel 更好:
- worker 工作池模式,如果有多个协程充当 worker,消费任务,那么可以创建一个容量等价于 worker 个数的 channel 用于传递结果,或者发送任务。
- 限制资源的访问,可以通过带有缓冲的 channel 限制可以访问某个资源的协程的数量(请求数量),达到一种限流的效果。
但是本质来说,设置带有缓冲的 channel 的大小与当前业务息息相关,使用更大的 channel 意味着允许更多的协程进行合作,但是也会消耗更多的内存,同时协程的执行也会消耗 CPU 的资源,因此,需要权衡 Memory 和 CPU 的使用后决定 buffer 的 size。
9.8 忽视 string 格式化的副作用(#68)
在协程并发的场景中,string 格式化存在副作用,下面讲解两个场景。
- etcd 数据竞争
etcd 是一个基于 Go 语言实现的分布式的 key-value 存储,提供了接口用于集群间的数据变更监听和交互,例如:
type Watcher interface {
// Watch 监听通过一个 key 获得的 channel,然后从 channel 中获取需要监听的事件
Watch(ctx context.Context, key string, opts ...OpOption) WatchChan
Close() error
}
服务端需要提供一个结构体,实现 Watcher 接口,并为客户端提供服务:
type watcher struct {
// streams 持有所有所有活跃的 gRPC streams
streams map[string]*watchGrpcStream
}
func (w *watcher) Watch(ctx context.Context, key string, opts ...OpOption) WatchChan {
ctxKey := fmt.Sprintf("%v", ctx)
// ...
wgs := w.stream[ctxKey]
// ...
}
上述 API 基于 gRPC 的 streaming 操作,本质是用于客户端和服务端的通信。
其中 ctxKey 是 map 的 key,通过 context 的格式化得到,当使用通过 context.WithValue
创建的 context 进行格式化的时候,Go 会读取这个 context 中所有的 value 值,在这种情况下,开发者会发现 context 包含了可变的值,例如一个指向结构体的指针,因此在多个协程间传递的 context 的值可能会被某个协程修改,从而导致数据竞争问题,最终影响格式化的准确性。
这种情况下,推荐的解决方式是选择不使用 fmt.Sprintf
去格式化 map 的 key,以免发生 context 格式化 value 的问题,或者额外实现一个 context 类型,格式化可以确定的上下文的 value。
- 死锁
假设有一个 customer 结构体,提供了修改 age 的方法和格式化输出方法,且由于会被并发读写,因此使用读写锁保护:
type Customer struct {
mutex sync.RWMutex
id string
age int
}
func (c *Customer) UpdateAge(age int) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if age < 0 {
return fmt.Errorf("age should be positive for customer %v", c)
}
c.age = age
return nil
}
func (c *Customer) String() string {
c.mutex.RLock()
defer c.mutex.RUnlock()
return fmt.Sprintf("id %s, age %d", c.id, c.age)
}
死锁的场景:假设为顾客修改 age,设置了一个小于0的age,则会触发 fmt.Errorf 格式化输出错误,由于格式化 %v 的时候,会调用 Customer 的 String() 方法,由于写锁已经被占用,String() 无法获取读锁,导致死锁。
解决方案:
- 单元测试很重要,充分的单元测试可以检测出问题
- 改变锁的使用时机:先判断 age 非法,在修改 age 之前,再上锁。
func (c *Customer) UpdateAge(age int) error {
if age < 0 {
return fmt.Errorf("age should be positive for customer %v", c)
}
c.mutex.Lock()
defer c.mutex.Unlock()
c.age = age
return nil
}
当然,第一种写法,也并不一定会导致,打印错误信息的时候触发死锁,只要确保不在持有写锁的时候,去试图获取读锁即可:
func (c *Customer) UpdateAge(age int) error {
c.mutex.Lock()
defer c.mutex.Unlock()
if age < 0 {
return fmt.Errorf("age should be positive for customer %d", c.id)
}
c.age = age
return nil
}
上述情况下,在打印错误的时候,只需要使用 c.id,并不会触发 Customer 的 String() 方法,从而避免了死锁。
小节
你已完成全书学习68%,再接再厉。