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的并发安全
Go 并发编程
https://fuwari.vercel.app/posts/go_concurrency/
作者
Jarrett
发布于
2025-08-16
许可协议
CC BY-NC-SA 4.0