2872 字
14 分钟
Go 并发编程
Go 并发编程
一、并发基础概念
1.1 并发和并行
- 并行:多个任务在同一时刻真正同时执行,需要多核CPU支持
- 并发:多个任务在同一时间段内交替执行,单核CPU也能实现并发
1.2 上下文切换
1.2.1 定义
- CPU从执行一个线程/进程切换到执行另一个线程/进程的过程
1.2.2 切换过程
- 保存当前任务的执行状态(寄存器、程序计数器、栈指针等)
- 加载下一个任务的执行状态
1.2.3 触发原因
- 时间片用完、I/O等待、优先级调度、主动让出等
1.2.4 如何减少上下文切换
- 无锁编程:使用CAS等原子操作替代锁
- 减少线程数量:使用线程池,避免创建过多线程
- 使用协程:Go的goroutine是用户态轻量级线程,切换成本远低于系统线程
- 避免不必要的锁竞争:减小锁粒度,使用读写锁等
1.3 并发核心概念
1.3.1 并发安全
- 在多个线程访问共享资源时,不会出现数据竞争和不一致的问题
1.3.2 共享资源
- 可被多个线程同时访问的资源:全局变量、堆上分配的内存、文件、数据库连接、网络连接
1.3.3 互斥
- 同一时刻只允许一个线程访问共享资源,其他线程需要等待
1.3.4 同步
- 协调多个线程的执行顺序和时机的机制
二、原子操作
2.1 原子操作基础
2.1.1 定义
- 原子操作是不可分割的操作,在执行过程中不会被中断,要么全部执行完成,要么完全不执行
- 适用于对单个变量的简单操作
2.1.2 Go语言中的原子操作
- Go通过sync/atomic包提供原子操作
- 底层通过CPU的LOCK指令实现,可以锁定内存总线
- 封装了不同架构下的原子操作实现,提供统一接口
2.1.3 原子操作和锁的区别
- 实现层面:原子操作由CPU指令支持,锁在软件层面实现
- 保护范围:原子操作保护单个变量,锁保护代码段
- 性能:原子操作性能更高,无需上下文切换
2.2 原子操作类型
2.2.1 整数类型的原子操作
- atomic提供的全部方法都能保证操作的原子性
- Add方法可以传负数做减法
- atomic提供的这些方法第一个参数类型需要传递指针类型
2.2.2 指针的原子操作
- atomic.LoadPointer():加载指针类型,并转换成对应类型
- atomic.StorePointer():存储一个地址
- atomic.CompareAndSwapPointer():比较并交换指针
- atomic.SwapPointer():交换指针
- atomic.Value:可存储任意类型值,但只能整体替换,不支持部分修改,不能解决map/slice等容器的并发安全问题
三、锁机制
3.1 锁的分类
3.1.1 悲观锁和乐观锁
- 悲观锁:认为并发冲突总是会发生,因此在数据被访问时就会加锁
- 乐观锁:假设冲突不会发生,对共享资源修改时不会先加锁
3.1.2 乐观锁实现机制
- CAS:通过atomic.CompareAndSwap()实现
- 版本号机制:
- 给需要更新的数据加一个版本号,数据更新时版本加1
- 获取数据的时候同时也将version值拿出来
- 更新时校验读取时的版本和当前实际版本是否一致
3.2 自旋锁
3.2.1 实现原理
- 在一个循环中不断尝试获取锁,直到成功获取锁为止
3.2.2 自旋锁的优缺点
- 优点:避免了线程切换的开销,持锁时间短时性能很好
- 缺点:持锁时间长或者竞争锁频繁会导致CPU资源的浪费,反而降低性能
3.3 互斥锁
3.3.1 互斥锁的两种模式
- 正常模式:非公平锁,唤醒的goroutine不直接拥有锁,和新goroutine竞争,新goroutine更容易抢占
- 饥饿模式:公平锁,直接由unlock把锁交给等待队列中排在第一位的goroutine,新goroutine直接进入队列的尾部
3.3.2 切换到饥饿模式的条件
- 切换到饥饿模式:当goroutine等待锁超过1ms
- 切换回正常模式:等待队列为空或等待时间小于1ms
3.4 如何选择合适的锁
- 不确定持有锁的时间:互斥锁或者读写锁
- 如果确定锁的持有时间很短:可以考虑自旋锁
- 读操作比较多的情况下:优先考虑读写锁
3.5 死锁
3.5.1 定义
- 死锁是多个goroutine相互等待对方释放资源的状态
- runtime检测到的死锁会触发fatal panic,无法被recover捕获
3.5.2 避免死锁
- 加锁解锁成对出现,使用defer确保解锁
- 按相同顺序获取多个锁
- 使用超时机制
- 使用TryLock尝试获取锁(Go 1.18+)
- 选择合适的并发原语(如channel)
3.6 锁的饥饿问题
3.6.1 问题描述
- 在极端场景下,有些goroutine始终抢不到锁
3.6.2 从goroutine的调度方式分析饥饿问题的处理
- 通过队列的方式调度goroutine
- P每调度61次后,下次调度时会先尝试从全局队列获取G
- 基于信号的抢占式调度防止长时间占用
四、同步原语
4.1 条件变量
- sync.Cond是Go中的条件变量实现
- 用于等待/通知场景,让goroutine在某个条件满足时被唤醒
- 必须配合互斥锁使用
4.2 信号量
4.2.1 Go中的信号量
- Go标准库没有直接提供信号量
- 可通过带缓冲channel实现信号量功能
- golang.org/x/sync/semaphore扩展包提供了加权信号量
4.2.2 信号量的常用操作
- P操作(Wait/Acquire):获取信号量,计数器减1,若为0则阻塞
- V操作(Signal/Release):释放信号量,计数器加1,唤醒等待者
五、Channel
5.1 Channel基础
5.1.1 使用channel实现互斥锁
- 定义一个缓冲为1的channel
- 空结构体写入,加锁
- 往外读取,解锁
- 性能比sync.Mutex差(channel涉及更复杂的调度和内存分配)
5.2 Channel使用场景
5.2.1 如何优雅的关闭通道
- 单发送者多接收者:由发送者来关闭通道,确保只执行一次
- 多发送者单接收者:接收者通过额外channel通知发送者停止发送
- 多发送者多接收者:在一个新的协程通过另一个channel来通知发送者关闭channel
5.2.2 如何使用通道对HTTP请求进行限速
- 创建带缓冲的channel作为令牌桶
- 初始化时填充令牌
- 定时器定期补充令牌
- 请求处理前获取令牌
- 使用worker pool控制并发
六、并发数据结构
6.1 Map并发问题
6.1.1 解决方案
- 使用sync.Mutex或sync.RWMutex实现
- 使用sync.Map
- 使用channel串行化操作
6.2 Slice并发安全
6.2.1 问题
- 切片不是并发安全的
6.2.2 如何实现并发安全
- 加锁方案:使用sync.Mutex或sync.RWMutex
- 分片加锁:将slice分成多个分片,每个分片独立加锁,降低锁竞争
- channel方案:通过channel串行化所有操作
- Copy-On-Write:读时共享,写时复制
七、Goroutine管理
7.1 Go限制运行时操作系统线程的数量
- debug.SetMaxThreads():限制程序可创建的最大OS线程数(M)
- debug.SetMaxStack():设置单个goroutine栈的最大内存
- debug.SetMemoryLimit():设置Go程序的总内存限制
- runtime.GOMAXPROCS():设置可同时执行的最大P数量,默认等于CPU核心数
7.2 主协程如何等待其他协程
- sync.WaitGroup:最常用,适合等待多个goroutine
- channel + select:适合少量goroutine或需要传递结果
- context:适合需要取消操作的场景
7.3 主协程永不退出
- select{}:推荐,不占用CPU
- 通过channel实现监听操作系统的信号
- 向一个没有缓冲的channel中写入或读取数据
- 向一个nil的channel中写入或读取数据
- 无限循环:不推荐,占用CPU
7.4 协程泄漏
7.4.1 定义
- goroutine无法正常退出,持续占用内存和资源
7.4.2 协程泄漏常见原因及解决措施
- 当前协程永久阻塞
- channel缓冲区满的情况下发送和接收的速度不匹配
- 从一个nil的通道读取数据,或者向一个nil的通道发送数据
- 使用一个不含任何分支的select → 使用select+default避免阻塞
- 不再使用的协程没有退出机制 → 传入context,之后使用cancel任务取消
- 协程执行时间过长 → 设置超时时间,使用协程池
- 协程占用内存过多 → 分而治之
八、限流机制
8.1 限流概述
- 限制请求速率,保护服务,避免服务过载
8.2 限流算法
8.2.1 固定窗口
- 固定时间窗口内限制请求数,超出部分被拒绝
8.2.2 滑动窗口
- 允许的请求数量在滑动时间窗口内是动态变化的
8.2.3 漏桶
- 请求以固定速率从漏桶中流出,超出部分被丢弃
8.2.4 令牌桶
- 程序以恒定速率向桶中放令牌,直到桶放满
- 如果桶中有剩余的token,请求放行
- 如果没有剩余的token,等待
九、并发编程常见问题
9.1 闭包中goroutine的坑
9.1.1 常见问题
- 延迟绑定:闭包引用的是变量地址,不是值
- 竞态条件:多个goroutine修改同一变量
- 外部变量修改:外部变量可能在goroutine执行前被其他代码修改
9.1.2 解决办法
- 使用临时变量
- 往闭包传参
- 将变量作为参数传递给闭包
9.2 并发编程的误区
9.2.1 数据竞争问题
- 多个goroutine访问共享变量 → 使用go run -race检测
- HTTP handler中的并发访问 → 使用singleflight来避免缓存穿透
9.2.2 goroutine生命周期管理
- 主goroutine提前退出
- 子goroutine没有退出机制 → 使用WaitGroup或channel等待
9.2.3 错误使用channel
- channel的读写没有同时准备好,导致死锁,程序崩溃
- 向已关闭的channel发送数据,重复关闭channel → 使用ok变量判断
9.2.4 sync包中的类型不应该被复制
- Mutex、WaitGroup等不应该被复制(会导致错误行为)
- Go语言层面不会阻止复制,但go vet会检测并警告
- 解决方案:使用指针类型
9.2.5 错误使用sync.WaitGroup
- 在goroutine中调用Add()方法 → Add()必须在goroutine启动前调用
- Done()必须确保被调用 → 使用defer确保调用
9.2.6 加解锁没有配对
- 需要成对出现
十、高级并发模式
10.1 SingleFlight
10.1.1 使用场景
- 微博热搜榜每秒百万并发请求,榜单每分钟更新一次,使用缓存优化。但缓存失效瞬间,大量请求同时查询数据库获取最新榜单,造成数据库压力激增
10.1.2 解决方案
- 使用singleflight合并相同请求,只让第一个请求查询数据库,其他请求等待并共享结果,避免缓存击穿
10.1.3 实现原理
- 用map记录正在进行的请求,相同key的请求共享结果
- 第一个请求执行,其他请求阻塞等待
- 通过channel广播结果给所有等待者
- 互斥锁保护map的并发安全