前言
单线程能保证程序执行顺序和简单性,但无法提高吞吐量,也无法充分利用 CPU/IO 资源,并发的目的不是让执行单元同步执行,而是在保证执行单元共享数据安全的前提下,让多个执行单元并发执行,从而充分利用硬件资源。
Go协程(Goroutine)轻量高效,当多个执行单元(Goroutines)并发执行时,会产生如下并发控制问题
- 主Goroutine怎么等待所有Goroutines执行完再退出?
- Goroutines之间怎么安全共享数据?即如何避免竞态问题
- 多个Goroutines开启后如何1次终止?
Golang标准库中的3大工具(WaitGroup、Channel、Context)分别解决了以上并发控制问题。
| 工具 | 作用 | 底层 |
|---|---|---|
| channel | goroutine 通信 | 队列 + 锁 +读/写等待队列 |
| context | 取消信号传播 | close(channel) |
| WaitGroup | 等待所有Goroutine结束 | atomic + semaphore |
WaitGroup
main函数等待,开启的子Goroutines正常执行完毕。
package main import ( "fmt" "golang.org/x/sys/windows" "sync" ) var naturalNumberCh = make(chan int, 100) var wg sync.WaitGroup func write() { threadID := windows.GetCurrentThreadId() defer func() { wg.Done() //记得channel写入完成关闭,否则for range循环读一直不结束! close(naturalNumberCh) fmt.Printf("----write-%d结束\n", threadID) }() for i := 0; i <= 100; i++ { naturalNumberCh <- i } } func read() { defer wg.Done() threadID := windows.GetCurrentThreadId() //记得写完了关闭Channel,否则for range循环不结束! for n := range naturalNumberCh { fmt.Printf("----read-goroutine-%d读取到值%d\n", threadID, n) } } func main() { defer fmt.Println("mian函数结束") readerCount := 3 writeCount := 1 //开1个写go程 for i := 0; i < writeCount; i++ { go write() } //开3个读go程执行结束后主动通知main函数,发请求结束的请求! for i := 0; i < readerCount; i++ { go read() } gocount := readerCount + writeCount wg.Add(gocount) wg.Wait() }
Channel
Golang的Channel是CSP模型的核心实现,本质是
- 环形队列(Buffer)
- 互斥锁(Mutex)
- 读写等待队列(Recvq/Sendq)
有以下两大使用场景
- 无缓冲区Channel:主要用于在2个Goroutine之间建立执行同步点(可替代传统互斥锁功能)
- 有缓冲Channel:主要用于在多个Goroutine之间安全异步传输数据(可满足并发执行单元共享内存的需求)
以上2种类型的Channel可以完全替代传统的共享内存+锁并发模型,实现更加安全、更加简洁的并发控制。
package main import ( "context" "fmt" "sync" ) func main() { ch1 := make(chan int, 5) ch2 := make(chan int, 5) sCh := make(chan int, 10) wg := &sync.WaitGroup{} ctx, cancel := context.WithCancel(context.Background()) wg.Add(4) //生产者1 go func() { defer wg.Done() defer close(ch1) for i := 1; i <= 5; i++ { ch1 <- i } }() //生产者2 go func() { defer wg.Done() defer close(ch2) for i := 6; i <= 10; i++ { ch2 <- i } }() //生产者:组装ch1和ch2 go func(ctx context.Context) { ch1Closed := false // 标记ch1是否已关闭 ch2Closed := false // 标记ch2是否已关闭 defer wg.Done() defer close(sCh) for { if ch1Closed && ch2Closed { return } select { case i1, ok := <-ch1: if ok { sCh <- i1 } else { ch1 = nil ch1Closed = true } case i2, ok := <-ch2: if ok { sCh <- i2 } else { ch2 = nil ch2Closed = true } case <-ctx.Done(): return } } }(ctx) //Fan-In消费者 go func() { defer wg.Done() for i := 0; i < 10; i++ { fmt.Println(<-sCh) } fmt.Println("所有数据消费完成") cancel() }() wg.Wait() }
Context
在并发编程场景,通常会开启多个Goroutines执行任务,当多个Goroutines并发开启后,如何发送1次退出信号,就能一次性优雅退出这些Goroutines呢?一石多鸟。
单播
在Golang中Channel的发送操作具有单播语义:1个生产者向1个Channel中发送1个值,只会被1个消费者Goroutine接收并消费。(发送1次 → 只会消费1 次)
即使有多个Goroutine同时监听同1个Channel,这个值最终也只会被其中1个Goroutine抢到并消费,因此Channel更接近于消息队列,1条消息被消费后就不复存在了。
广播
Close(channel) 的行为与发送值完全不同,当Channel被关闭后,所有正在监听该Channel的消费者Goroutines都会立即被唤醒。
关闭Channel实现Context
Context的Cancel取消机制正是利用了Close(channel) 的广播特性,实现了1个Goroutine向多个Goroutines广播取消信号。
Context是什么?
Context直译为上下文,主要作用是利用1个Goroutine向多个Goroutines传递上下文信息。上下文信息包含
- K-V值
- 取消信息
- 超时时间
- 截止时间
Context是链式向下传播的,每1次WithValue/WithCancel/WitTimeout 都在链上增加一个节点,当子Context 再派生下一代Context时,逐渐形成层级化的树形结构,父节点的取消或超时会被下游感知。
context.Background() 【根节点】
│
┌──────────────┴──────────────┐
│ │
WithCancel(child1) WithTimeout(child2) 【子节点】
│ │
│ ┌───────┴───────┐
│ │ │
WithValue(grand1) WithCancel(grand2) WithValue(grand3) 【孙子节点】
│ │ │
【曾孙节点】 【曾孙节点】 【曾孙节点】
当父节点触发取消(Cancel)或超时(Timeout)时,这个信号会沿着树形结构递归传递到所有下游子节点,确保所有关联的Goroutines都能感知并优雅退出。
Goroutine泄漏
想象1个场景,老板叫了多个工人(Gorutines)来公司干活,活干完了,招来的工人(Gorutine)赖在老板办公室不走,还占老板的卧室跟老板一起睡觉,已经完成使命的Gorutine占着内存不退出,就是Gorutine泄露。
Goroutine泄露:启动了Goroutine,但Goroutine永远阻塞、永远不退出,一直占系统资源。
Channel可以保证Goroutine的数据传输通信安全且同步。
Contxet代表的是1次调用链的控制信号,同1条调用链共享1个Context,不要跨调用链复用,Context可以随时终止当前调用链上关联的Goroutine,防止Goroutine泄露。
Contxt有2大核心作用
- 第一,掌控终止权:对同一调用链上的所有Goroutine统一管理 退出时机,要么主动发指令叫停,要么设置超时自动终止,杜绝协程赖着不走;
- 第二,传递公共值:在同一调用链的Goroutine之间,安全传递请求级元数据(比如用户 ID、请求 ID、traceID),不用层层传参数,简洁又统一。
Context方法
Context是1个接口,该接口定义了四个需要实现的方法。具体签名如下:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Background()和TODO() (根节点)
Go内置两个函数:Background()和TODO(),这两个函数分别返回一个实现了Context接口的background和todo。我们代码中最开始都是以这两个内置的上下文对象作为最顶层的partent context,衍生出更多的子上下文对象。
Background()主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。
TODO(),它目前还不知道具体的使用场景,如果我们不知道该使用什么Context的时候,可以使用这个。(必须要传递1个context类型的参数)
todo本质上也是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。
WithCancel(取消/退出)
WithDeadline(绝对超时时间)
当Context的截止日过期时, ctx.Done()返回后context deadline exceeded。
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func connectMyql(ctx context.Context) {
defer wg.Done()
for {
time.Sleep(time.Second * 1)
fmt.Println("我连我连...我连莲莲....")
select {
case <-ctx.Done():
fmt.Println(ctx.Err())
return
default:
}
}
}
func main() {
//设置context 10秒钟之后过期
d := time.Now().Add(time.Second * 10)
ctx, cancel := context.WithDeadline(context.Background(), d)
/*
尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践
如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
*/
defer cancel()
wg.Add(1)
go connectMyql(ctx)
wg.Wait()
}
WithTimeout(相对超时时间)
package main
import (
"context"
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
func connectMyql(ctx context.Context) {
defer wg.Done()
for {
time.Sleep(time.Second * 1)
fmt.Println("我连我连...我连莲莲....")
select {
case <-ctx.Done():
fmt.Println(ctx.Err())
return
default:
}
}
}
func main() {
//设置context 从当前时间开始10秒钟之后过期(决对时间)
// d := time.Now().Add(time.Second * 10)
// ctx, cancel := context.WithDeadline(context.Background(), d)
//设置相对时间 5秒钟后过期(相对时间)
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
/*
尽管ctx会过期,但在任何情况下调用它的cancel函数都是很好的实践
如果不这样做,可能会使上下文及其父类存活的时间超过必要的时间。
*/
defer cancel()
wg.Add(1)
go connectMyql(ctx)
wg.Wait()
}
WithValue(传递值)
如何在子Goroutine开启时就与生俱来一些元数据!
WithValue可以保证1个Gorutin繁衍了N代子Goroutines之后,它的后代Goroutines都能with(携带)1个固定值,这样就可以自上而下追溯这个调用链了!
package main
import (
"context"
"fmt"
"sync"
"time"
)
// context.WithValue
//TraceCode 自定义类型
type TraceCode string
var wg sync.WaitGroup
func worker(ctx context.Context) {
defer wg.Done()
key := TraceCode("TRACE_CODE")
// 在子goroutine中获取trace code,(string)是类型断言!
traceCode, ok := ctx.Value(key).(string)
if !ok {
fmt.Println("invalid trace code")
}
for {
fmt.Printf("worker, trace code:%s\n", traceCode)
// 假设正常连接数据库耗时1秒
time.Sleep(time.Second * 1)
// 10秒后自动调用
select {
case <-ctx.Done():
fmt.Println("worker done!")
return
default:
}
}
}
func main() {
// 设置1个10秒的超时
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
//在系统的入口中设置trace code传递给后续启动的goroutine实现微服务日志数据聚合
ctx = context.WithValue(ctx, TraceCode("TRACE_CODE"), "666")
wg.Add(1)
go worker(ctx)
//主线程等待10秒后
time.Sleep(time.Second * 10)
//通知子goroutine结束
cancel()
wg.Wait()
fmt.Println("over")
}
Context应用场景
微服务链路追踪:作为1个微服务架构,微服务之间session不共享的服务端,如何追踪客户端1次request都调用了哪些微服务组件?并且聚合日志。
Context注意事项
| 错误操作 | 导致后果 | 正确操作 |
|---|---|---|
| 不 defer cancel | Goroutine泄露 | 所有取消型Context必defer cancel () |
| 存结构体 | 跨请求覆盖、随机取消 | Context只传不存,仅作函数参数 |
| 父取消子必挂 | 内层任务被意外终止 | 独立任务用 background 重建 Context |
| value 用字符串 key | 数据被篡改、隐蔽Bug | 自定义唯一类型作为 key |
| value 传业务参数 | 代码混乱、无参数校验 | 业务参数显式传,Context 只传元数据 |
| 跨请求复用 Context | 批量请求异常、生产事故 | 一条调用链使用1个新的Context,严格区分调用链 |
参考
浙公网安备 33010602011771号