Skip to content

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监控,基于信号的抢占
  1. 调度策略:

    • 本地队列优先:P的本地runq
    • 全局队列轮询:定期检查全局runq
    • 工作窃取:从其他P偷取G
    • 网络轮询:netpoller处理网络事件
  2. 性能优化:

    • 避免线程频繁创建销毁
    • 减少系统调用开销
    • 实现良好的负载均衡
    • 最小化调度延迟

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重排序

操作

  1. Load/Store: 原子读写操作
  2. Add: 原子加法运算
  3. Swap: 原子值交换
  4. 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压力
  • 提供临时对象的复用机制
  • 自动清理机制防止内存泄漏
  1. 特性:
  • 线程安全的对象池
  • GC时自动清空池中对象
  • 每个P维护本地池减少竞争
  • 支持自定义对象创建函数
  1. 内部结构:
  • 每个P有独立的poolLocal
  • 包含private(单对象)和shared(多对象队列)
  • 全局victim机制延迟回收
  1. 生命周期:
  • 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:携带值的上下文
    */
}

正在精进