Golang 并发编程
Goroutine
- 轻量级用户线程
- goroutine 无法直接返回值,需要通过 channel 通信
- 频繁创建和销毁Goroutine会带来性能开销,Goroutine池是一种有效的优化手段。
状态监控
- 可以通过
runtime.NumGoroutine()获取当前正在运行 goroutine 数量 runtime.NumCPU()获取 CPU 核心数,可以启动两倍的 CPU 数量的协程runtime.GOMAXPROCS(0)设置或者获取最大goroutine数量- CPU 密集型基本等于 CPU 核心数,I/O 密集型会略大于核心数
- 可以通过池化降低 GC
状态
- _Gidle: 刚创建,还未初始化
- _Grunnable: 可运行,在运行队列中
- _Grunning: 正在运行
- _Gsyscall: 系统调用阻塞
- _Gwaiting: 等待状态(channel、锁等)
- _Gdead: 已退出
- _Gcopystack = 8 // 栈正在被复制,不在运行队列中
- _Gpreempted = 9 // 因为抢占而停止,等待恢复 状态转换路径: _Gidle -> _Grunnable -> _Grunning -> _Gwaiting/_Gsyscall -> _Grunnable -> ... -> _Gdead
泄露
- 协程一直读取一个永远没有数据的 chan,会一直阻塞
- 可以通过超时的 context 避免
- 无限循环:循环变量可能一直不发生变化导致协程一直占用,需要避免
- 未正确关闭资源:比如开启了ticker,但是忘记关闭,导致死循环,需要及时调用 defer 进行关闭。
- context未正确传播,协程中没有使用正确的 context,导致外层的 context 关闭之后,协程依旧运行中
- 需要使用
context.WithCancel/context.WithTimeout/context.WithDeadline,并保证Context链式传播,一般将Context放在函数签名的第一个
- 需要使用
- 可以在每次协程开启前后通过
runtime.NumGoroutine()
GMP模型
- Go调度器,实现了M:N调度模型,将大量Goroutine调度到少数系统线程上 G (Goroutine): 用户态线程
- 包含栈、程序计数器、状态信息,初始栈大小2KB,可动态增长到1GB
- 由Go运行时管理,不是系统线程
M (Machine): 系统线程
- 执行G的载体
- 数量通常等于CPU核心数
P (Processor): 逻辑处理器,提供执行上下文
- 维护可运行的 G 双端队列,最多 256 个 G
- 当本地队列满时,将一半的 G 移到全局队列
- 本地队列空时,会从全局队列或其他 P 偷取 G,更加负载均衡
- 工作窃取:本地队列为空时,随机选择一个 P,从它的队列尾部窃取一半工作量
- 避免负载不均衡,提高整体吞吐量
- 数量由GOMAXPROCS决定,默认等于 M
- M必须绑定P才能执行G
- 当 G 因系统当用阻塞时,会阻塞 M,这个时候 P 和 M 会解绑(hand off),并寻找新的空闲的 M,没有空闲的 M 就新建一个 M
- 当 G因 channel 或者 network I/O 阻塞时,不会阻塞 M,M会寻找其他 runnable 的 G;当阻塞的 G恢复后会重新进入 runnable 进入 P队列等待执行 此外还有一个全局队列存放 G(无限制) 如果没有 P,那么所有的 G 竞争 M,会使用一个全局的大锁,粒度太高了
一个 P 绑定多个 G,通常一个 P 绑定一个 M 如果没有 P,所有的 G 竞争 M,那么加锁频繁,上下文切换开销大,且 P 通过管理执行上下文更好的利用 CPU 缓存(一个 G 一般不会跳转到其他的 CPU 上运行) 且无法做到工作窃取(未知 )
调度
- 主动调度:runtime.Gosched()、channel阻塞等
- 被动调度:系统调用、时间片到期等
- 抢占调度:sysmon监控,基于信号的抢占
调度策略:
- 本地队列优先:P的本地runq
- 全局队列轮询:定期检查全局runq
- 工作窃取:从其他P偷取G
- 网络轮询:netpoller处理网络事件
性能优化:
- 避免线程频繁创建销毁
- 减少系统调用开销
- 实现良好的负载均衡
- 最小化调度延迟
锁
Mutex 互斥锁
结构如下
go
type Mutex struct {
state int32 // 状态字段
sema uint32 // 信号量,用于阻塞/唤醒 goroutine
}- state
- 第 0 位:是否已锁定,1 表示已锁定
- 第 1 位:是否有唤醒的 goroutine
- 第 2 位:是否处于饥饿模式
- 第 3-32 位:等待者数量,最大 2^29 - 1 个等待者
- 饥饿模式:
- 如果一个 goroutine 等待锁的时间超过 1ms且当前锁没有处于饥饿模式或者当前队列只剩下一个 G,当前锁进入饥饿模式
- 锁下次直接传递给等待队列的第一个 goroutine
- 新到达的 goroutine 不会尝试抢锁,直接进入等待队列的尾部;
- 当等待队列中的 goroutine 都获取并释放锁后或者当前的 goroutine 的等待时间少于 1ms,锁会退出饥饿模式,回到正常的 “唤醒 - 抢锁” 逻辑。
- 新来的 goroutine 和被唤醒的 goroutine 竞争锁
- 新来的 goroutine 有优势(它们已经在 CPU 上运行)
- 被唤醒的 goroutine 如果没有抢到锁会再次被阻塞(时间长了锁就会进入饥饿模式)
- 正常模式吞吐量更高,新 goroutine 进来不需要进行上下文切换
- 饥饿模式:保证公平性,避免长时间等待
- Mutex 自旋:
- goroutine 不会立即进入休眠,而是进行短暂的忙等待
- 满足以下所有条件:
- 锁已被占用,且不是饥饿模式
- 积累的自旋次数小于 4
- CPU 核数大于 1
- 有空闲的 P
- 当前 goroutine 所在的 P 的本地队列为空
- 可以避免上下文切换,加快响应
RWMutex读写锁
- 多个读者可以同时读取
- 写者必须独占访问
- 读者和写者不能同时访问
- 写者之间互斥
- 如果没有锁定,直接解锁会触发 panic,可以一个协程锁,另一个协程解锁
- 写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并都可以成功锁定读锁。
- 读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而被阻塞的 goroutine,其中等待时间最长的一个 goroutine 会被唤醒。
场景
- 场景1:配置管理
- 场景2:缓存系统
- 场景3:路由表
- 场景4:统计计数器
Once
查看结构
go
// sync.Once 的简化实现
type Once struct {
done uint32 // 调用过这个值就不是 0 了,双重检查就不会通过
m sync.Mutex
}
func (o *Once) Do(f func()) {
// 快速路径:如果已经执行过,直接返回
if atomic.LoadUint32(&o.done) == 0 {
// 慢速路径:需要同步
o.doSlow(f)
}
}
func (o *Once) doSlow(f func()) {
o.m.Lock()
defer o.m.Unlock()
// 双重检查
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}使用方式
go
func GetInstance() *Singleton {
once.Do(func() {
fmt.Println("创建单例实例")
instance = &Singleton{data: "singleton"}
})
return instance
}确保函数只执行一次,是实现单例模式和初始化逻辑的重要工具,参数是一个无参数无返回值的函数 如果调用时 panic 了,依旧只会调用一次
- 懒汉式:使用时创建,线程不安全,有锁竞争,需要使用 sync.Once(一般不用自己通过互斥锁实现)
- 饿汉式:程序启动就创建,线程安全,无锁竞争,可能浪费资源(不用到)
WaitGroup 等待组
go
// WaitGroup的底层结构(简化版)
type WaitGroup struct {
noCopy noCopy
// state1包含计数器和等待者数量
state1 [3]uint32
}state1[0]:counter,跟踪还有多少个goroutine需要完成state1[1]:waiter,跟踪有多少个goroutine在等待state1[2]:sema,信号量,用于阻塞和唤醒等待的goroutine- 当计数器减为 0 时,通过信号量唤醒 wait
使用方法
go
// WaitGroup基本用法演示
func correctWaitGroupPattern() {
var wg sync.WaitGroup
tasks := []string{"task1", "task2", "task3", "task4"}
for _, task := range tasks {
wg.Add(1) // 在启动goroutine之前增加计数
go func(taskName string) {
defer wg.Done() // 使用defer确保Done一定会被调用
// 模拟任务处理
time.Sleep(time.Duration(len(taskName)) * 100 * time.Millisecond)
}(task) // 避免闭包变量捕获问题
}
wg.Wait()
}注意事项:
- 在启动 goroutine 之前调用Add(),Add在goroutine内部调用,导致在Add()之前就开始等待,出现死锁
- 使用 defer 确保 Done() 被调用
- 如果Add 比 Done 多等待永远不会结束,Add 比 Done 少,出现 Panic
- 不要复制 WaitGroup,通过指针传递,否则是新建了一个 WaitGroup(很少用)
优化方案:
- 一次性通过 Add 添加所有计数,避免 for 循环一次一次的添加
- 配合 context 使用,异常提前 cancel,并且支持超时等,避免出现泄露
go
select {
case <-ctx.Done():
fmt.Printf("任务 %d 被取消: %v\n", id, ctx.Err())
return
default: // 出错 cancel
fmt.Printf("任务 %d 正常完成\n", id)
}sysmon 有什么作用
sysmon 也叫监控线程,变动的周期性检查,好处
- 释放闲置超过5 分钟的 span 物理内存;
- 如果超过2 分钟没有垃圾回收,强制执行;
- 将长时间未处理的 netpoll 添加到全局队列;
- 向长时间运行的 G 任务发出抢占调度(超过10ms的 g,会进行 retake);
- 收回因 syscall 长时间阻塞的 P;
atomic
轻量级并发同步,通过CPU硬件指令保证操作的原子性
操作要么完全执行,要么完全不执行。 防止编译器和CPU重排序
操作
- Load/Store: 原子读写操作
- Add: 原子加法运算
- Swap: 原子值交换
- CompareAndSwap: 条件性原子更新
atomic.CompareAndSwapInt32(&value, old_value, new_value)如果值为 old_value,则更新为 new_value- 执行成功返回 true,否则返回 false
- 很容易实现自旋,如果返回 false,就进行重试
- 但是会有ABA问题: 值可能被修改后又改回,使用版本号(一直递增),但是无法自旋(版本不会回退)
- 低竞争选择 cas,否则选择 mutex
条件变量Cond
条件变量(Cond)是Go语言提供的一种同步原语,用于在某个条件满足时唤醒等待的goroutine。 sync.Cond的基本结构:
go
type Cond struct {
noCopy noCopy
L Locker // 关联的锁(通常是*Mutex或*RWMutex)
notify notifyList
checker copyChecker
}
// 创建方式
var mu sync.Mutex
cond := sync.NewCond(&mu)主要操作:
- Wait(): 释放锁并等待
- 1、释放关联的锁
- 2、将当前goroutine加入等待队列并阻塞
- 3、被唤醒后重新获取锁 c.L.Lock()
- 4、调用时需要加锁,保护 condition
- Signal(): 唤醒一个等待的goroutine
- Broadcast(): 唤醒所有等待的goroutine
- 这两个都可以不加锁直接使用 场景
- 生产者消费者:当消费者发现没有数据时释放锁并等待,生产者生产后,唤醒
- 一般使用 for 验证资源,如果用 if,当多个消费者被唤醒,可能过度消费(虚假唤醒)
- 游戏:所有玩家在准备后释放锁并等待,当所有玩家都准备好,管理员点击开始,唤醒所有等待的goroutine
对象池
sync.Pool是Go标准库提供的对象池实现,用于重用对象以减少GC压力。
- 减少对象分配和GC压力
- 提供临时对象的复用机制
- 自动清理机制防止内存泄漏
- 特性:
- 线程安全的对象池
- GC时自动清空池中对象
- 每个P维护本地池减少竞争
- 支持自定义对象创建函数
- 内部结构:
- 每个P有独立的poolLocal
- 包含private(单对象)和shared(多对象队列)
- 全局victim机制延迟回收
- 生命周期:
- Get时优先从本地获取
- Put时放回本地池
- GC时清空但有victim缓存
注意,每次使用后清除对象的数据 基本使用
go
// 创建一个缓冲区池
bufferPool := sync.Pool{
New: func() interface{} {
fmt.Println("创建新的缓冲区")
return make([]byte, 1024) // 1KB缓冲区
},
}
// 获取对象,如果第一次获取 - 会调用New函数,第二次开始进行重用
buffer1 := bufferPool.Get().([]byte)
// 放回池中
bufferPool.Put(buffer1)Context上下文管理详解
Context是Go语言中用于管理请求生命周期、传递取消信号和截止时间的核心机制。本章深入探讨Context的设计原理、使用模式和最佳实践。
go
package main
import (
"context"
"fmt"
"sync"
"time"
)
func demonstrateContextBasics() {
fmt.Println("=== Context基本概念 ===")
/*
Context主要用途:
1. 取消传播:将取消信号传播到整个调用链
2. 超时控制:设置操作的超时时间
3. 截止时间:设置绝对的截止时间
4. 值传递:在调用链中传递请求级别的数据
Context类型:
- Background:根上下文,永不取消
- TODO:占位符上下文,用于不确定的情况
- WithCancel:可手动取消的上下文
- WithTimeout:带超时的上下文
- WithDeadline:带截止时间的上下文
- WithValue:携带值的上下文
*/
}