12、Go并发编程

12、Go 并发编程


goroutine 启动与调度

什么是 goroutine?

goroutine 是 Go 语言中的轻量级用户态线程,由 Go 运行时管理。启动一个 goroutine 只需使用 go 关键字,语法简单,开销极小(初始栈仅约 2KB),可以在一个程序中轻松创建成千上万个 goroutine。

package main

import (
    "fmt"
    "time"
)

func printNum(id int) {
    for i := 0; i < 5; i++ {
        fmt.Printf("goroutine %d: %d\n", id, i)
        time.Sleep(time.Millisecond * 100)
    }
}

func main() {
    // 启动 10 个 goroutine
    for i := 0; i < 10; i++ {
        go printNum(i)
    }
    // 主 goroutine 等待一会儿,否则可能看不到输出
    time.Sleep(time.Second)
    fmt.Println("done")
}

注意:主 goroutine 退出会导致程序结束,所有 goroutine 会被强制终止,所以这里用 time.Sleep 等待(实际开发中应使用 sync.WaitGroup 或 channel 同步)。

GOMAXPROCS

runtime.GOMAXPROCS(n) 设置可以同时执行用户级 Go 代码的操作系统线程数(逻辑处理器 P 的数量)。默认值等于机器的 CPU 核心数。

  • 影响并发度:增大 n 可以提高并行度,但不能超过物理核心数。
  • 可以在程序运行时动态调整(但一般不频繁变动)。
import "runtime"
func main() {
    fmt.Println("Default GOMAXPROCS:", runtime.GOMAXPROCS(0)) // 获取当前值
    runtime.GOMAXPROCS(2) // 设置为 2
}

GOMAXPROCS 不限制创建 goroutine 的数量,只限制真正并行执行的 goroutine 数量。当某个 goroutine 进入系统调用或阻塞时,P 可以切换执行其他 goroutine。

Gosched

runtime.Gosched() 让出当前 goroutine 的执行权,将其放回队列,并让调度器重新调度。当前 goroutine 未来仍会被再次调度执行。

常用于:

  • 避免某个 goroutine 长时间霸占 P。
  • 调试或协作式调度场景。
go func() {
    for i := 0; i < 10; i++ {
        fmt.Println("goroutine1:", i)
        runtime.Gosched() // 让出 CPU,给其他 goroutine 执行机会
    }
}()

注意:Gosched 不会挂起 goroutine,只是暂停并立即重新参与调度,相当于一次自愿的上下文切换。

Goexit

runtime.Goexit() 立即终止调用它的 goroutine,但会保证该 goroutine 中所有已注册的 defer 语句被执行(这不同于直接调用 os.Exit)。

  • 终止后 goroutine 不会恢复,也不会触发 panic。
  • 如果在主 goroutine 调用 Goexit,会等待其他 goroutine 结束后才让程序崩溃(因为主 goroutine 退出但其他未结束)——通常不会主动在主 goroutine 中使用。
func worker() {
    defer fmt.Println("cleanup done")
    fmt.Println("working...")
    runtime.Goexit()
    fmt.Println("never printed")
}

输出:

working...
cleanup done

重要区分:return 只是退出函数(若在 goroutine 顶层则退出 goroutine);os.Exit 立即退出进程,不执行任何 defer;Goexit 退出 goroutine 但执行 defer。

goroutine vs OS 线程

特性 goroutine OS 线程
管理者 Go 运行时 操作系统内核
栈大小 初始约 2KB,可动态伸缩 通常固定 1MB 或更多
上下文切换 用户态切换,开销极小 内核态切换,开销较大
数量 可以轻松创建数十万个 通常几百到几千个
调度 Go 调度器 (G-P-M 模型) 操作系统调度器

goroutine 不是线程,而是运行在线程之上的协作式任务。Go 运行时会将多个 goroutine 映射到少量的 OS 线程上(默认最大线程数为 GOMAXPROCS),通过抢占式调度实现并发。

goroutine 调度示意图(GMP 模型)

GMP 的三个角色

  • G(Goroutine):轻量级用户态线程,包含执行栈、程序计数器、状态等信息。
  • M(Machine):操作系统线程(OS Thread),真正执行计算的资源,由内核管理。
  • P(Processor):逻辑处理器,是调度的上下文,维护一个本地 goroutine 队列。P 的数量由 runtime.GOMAXPROCS(n) 决定,默认等于 CPU 核心数。

关系:每个 P 必须绑定一个 M 才能执行 G。P 就像“执照”,M 必须获取 P 才能执行 Go 代码。

调度示意图(ASCII 艺术)

11

调度流程详解

  1. 创建 Goroutine
    使用 go func() 创建新的 G,它会被优先放入当前 P 的本地运行队列(若本地队列已满,则放入全局队列)。
  2. M 绑定 P 执行
    每个 P 需要分配一个 M 来运行其队列中的 G。M 会不断从 P 的本地队列中弹出 G 并执行,这就是典型的“循环调度”(不需要时钟中断,是协作式 + 抢占式结合)。
  3. 系统调用阻塞
    当 G 执行同步系统调用(如文件 I/O)时,M 会陷入内核阻塞。此时,运行时会解绑该 M 的 P,并将 P 交给另一个空闲的 M(或新建一个 M),让 P 继续执行其他 G。这样就避免了因某个线程阻塞而浪费 CPU。
  4. 网络 I/O 等异步阻塞
    对于网络 I/O(使用 netpoller),G 会被挂起,M 不会被阻塞。当 I/O 就绪后,G 会被重新放回 P 的队列。
  5. 工作窃取(Work Stealing)
    当一个 P 的本地队列为空时,它会尝试:
    • 从全局运行队列中获取 G。
    • 从其他 P 的本地队列中“偷”一半的 G(平衡负载)。
      这保证了所有 CPU 核心都能被充分利用。
  6. 抢占调度
    如果一个 G 执行时间过长(超过 10ms),运行时会向其发送抢占信号(基于信号的抢占,Go 1.14+),G 会在合适的时机(如函数调用)让出执行权,防止单一 G 独占 P。

GOMAXPROCS 的影响

GOMAXPROCS 设定的是 P 的数量,也就是同时执行 Go 代码的操作系统线程数。

  • 即使有成千上万个 goroutine,也只有 GOMAXPROCS 个可以真正并行。
  • 增大该值可以利用多核并行,但要避免无谓的上下文切换。
  • 在容器环境或 I/O 密集型应用中,适当调整 GOMAXPROCS 可以优化性能。
组件 作用 特点
G 函数执行任务 轻量栈(初始 2KB),数量不受限
M 操作系统线程 重量级,通常等于或大于 P 数量
P 逻辑处理器,持有 G 队列 数量由 GOMAXPROCS 决定,是并发度的关键
本地队列 P 私有的 G 队列 无锁访问,高效
全局队列 共享的 G 队列 访问需要加锁,作为溢出和窃取源

channel

channel 是 Go 中 goroutine 之间通信的管道,遵循 CSP(Communicating Sequential Processes)模型。核心思想:不要通过共享内存来通信,而要通过通信来共享内存。

声明与创建

// 声明未初始化的 channel(nil)
var ch chan int

// 创建无缓冲 channel
ch = make(chan int)

// 创建有缓冲 channel,容量为 3
ch = make(chan int, 3)

无缓冲 channel(同步通信)

无缓冲 channel 的发送和接收必须同时准备好,否则会阻塞:

  • 发送操作 ch <- value 会阻塞直到有另一个 goroutine 执行接收。
  • 接收操作 <-ch 会阻塞直到有另一个 goroutine 发送数据。

示例:goroutine 通过 channel 发送数据给 main

func main() {
    ch := make(chan string)

    go func() {
        time.Sleep(time.Second)
        ch <- "hello from goroutine" // 发送后阻塞,直到 main 接收
    }()

    msg := <-ch // 阻塞等待接收
    fmt.Println(msg)
}

有缓冲 channel(异步通信)

有缓冲 channel 有一个容量缓冲区:

  • 发送方只在缓冲区满时阻塞。
  • 接收方只在缓冲区空时阻塞。
func main() {
    ch := make(chan int, 2)
    ch <- 1 // 不阻塞,缓冲区有空位
    ch <- 2 // 不阻塞
    // ch <- 3 // 会阻塞,因为缓冲区已满(容量为2)

    fmt.Println(<-ch) // 1
    fmt.Println(<-ch) // 2
}

阻塞行为对比:

  • 无缓冲:每一次发送都需要对应的接收,否则阻塞,相当于同步传递。
  • 有缓冲:在容量未耗尽时发送可不阻塞,接收同理,相当于异步队列。

关闭 channel

使用 close(ch) 关闭 channel,接收方可以检测到 channel 是否关闭:

v, ok := <-ch
if !ok {
    fmt.Println("channel closed")
}

遍历 channel 常用 for range,直到 channel 关闭并读完所有数据。

单向通道(只读/只写)

默认创建的 channel 是双向的(可读可写),但在函数参数或返回值中,可以将双向通道转换为单向通道,用以约束操作方向,增强代码安全性和可读性。

类型 写法 含义
双向通道 chan T 可发送和接收
只读通道 <-chan T 只能从中接收数据
只写通道 chan<- T 只能向其中发送数据
  • 示例:限制通道方向
// 生产者返回一个只读通道,外部只能读
func producer() <-chan int {
    ch := make(chan int)
    go func() {
        for i := 0; i < 5; i++ {
            ch <- i
        }
        close(ch)
    }()
    return ch // 双向通道自动转换为只读
}

// 消费者接收只读通道
func consumer(ch <-chan int) {
    for v := range ch {
        fmt.Println(v)
    }
}

func main() {
    ch := producer()
    consumer(ch)
}
  • producer 内部创建双向通道,返回时隐式转为 <-chan int,防止调用方误发数据。consumer 的参数声明为只读,明确语义。
  • 双向通道可以隐式转换为单向通道,但反过来不行(无法从单向转回双向)。
  • 试图从只写通道读取或向只读通道发送会导致编译错误。

select 多路复用

需要同时等待多个 channel 操作时,select 语句可以监听多个 case,哪个先就绪就执行哪个。如果多个同时就绪,随机选择一个。

基本使用

select {
case msg1 := <-ch1:
    fmt.Println("from ch1:", msg1)
case ch2 <- 42:
    fmt.Println("sent to ch2")
default:
    fmt.Println("no channel ready")
}

default 分支可避免阻塞(非阻塞操作)。

超时控制

使用 time.After 返回一个在指定时间后发送一个值的 channel,配合 select 实现超时:

func main() {
    ch := make(chan string)

    go func() {
        time.Sleep(2 * time.Second)
        ch <- "result"
    }()

    select {
    case res := <-ch:
        fmt.Println(res)
    case <-time.After(1 * time.Second):
        fmt.Println("timeout!")
    }
}

由于 sleep 2 秒,而超时设置的 1 秒,最终输出 timeout!。

sync.WaitGroup、sync.Mutex

sync.WaitGroup:等待一组 goroutine 完成

WaitGroup 用于等待一组并发任务结束,类似倒计时计数器。

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1) // 计数加 1
        go func(id int) {
            defer wg.Done() // 完成时计数减 1
            fmt.Printf("worker %d\n", id)
        }(i)
    }

    wg.Wait() // 阻塞直到计数为 0
    fmt.Println("all done")
}

sync.Mutex:互斥锁保护共享数据

多个 goroutine 同时读写共享变量会引发数据竞争(data race)。可以使用 sync.Mutex 保护临界区。

并发计数器示例:

type Counter struct {
    mu    sync.Mutex
    value int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

func main() {
    var wg sync.WaitGroup
    counter := Counter{}

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Inc()
        }()
    }

    wg.Wait()
    fmt.Println(counter.Value()) // 1000
}

运行 race 检测:

go run -race main.go

如果没有竞争,不会输出警告。若忘记加锁,检测器会报告数据竞争。

sync.Once、sync.RWMutex

sync.Once:确保函数只执行一次

sync.Once 实现单例模式、延迟初始化等场景,即使多个 goroutine 同时调用 Do,内部函数也只会执行一次。

单例模式示例:

type Singleton struct {
    data string
}

var instance *Singleton
var once sync.Once

func GetInstance() *Singleton {
    once.Do(func() {
        instance = &Singleton{data: "initialized"}
        fmt.Println("Singleton created")
    })
    return instance
}

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            s := GetInstance()
            fmt.Printf("%p\n", s)
        }()
    }
    wg.Wait()
}

无论多少个 goroutine 调用 GetInstance,"Singleton created" 只会打印一次,所有 goroutine 拿到的实例相同。

Once 使用场景:数据库连接池初始化、加载配置文件、注册驱动等。

sync.RWMutex:读写锁

适用于读多写少的场景,允许多个 goroutine 同时读,写时独占。

type SafeMap struct {
    mu   sync.RWMutex
    data map[string]string
}

func (m *SafeMap) Get(key string) string {
    m.mu.RLock()         // 读锁,允许多个读并发
    defer m.mu.RUnlock()
    return m.data[key]
}

func (m *SafeMap) Set(key, value string) {
    m.mu.Lock()          // 写锁,独占
    defer m.mu.Unlock()
    m.data[key] = value
}

数据竞争

多个 goroutine 并发访问同一块内存,且至少有一个是写操作,没有同步保护,就会发生数据竞争。结果不可预测。

锁保护

  • 故意制造数据竞争
func main() {
    counter := 0
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // 没有锁,竞争!
        }()
    }

    wg.Wait()
    fmt.Println(counter) // 结果小于 1000,每次可能不同
}
  • 使用 go run -race 检测

运行:

go run -race race.go

会输出类似:

==================
WARNING: DATA RACE
Read at 0x00c00001a0b8 by goroutine 8:
  main.main.func1()
      /path/race.go:15 +0x4a

Previous write at 0x00c00001a0b8 by goroutine 7:
  main.main.func1()
      /path/race.go:15 +0x5e
==================

报告指明了哪两个 goroutine 在访问同一个内存地址,以及是哪一行代码。

  • 用锁修复:将计数器修改用 sync.Mutex 保护,再次运行 go run -race,不再有警告。

阅读 race 报告技巧:关注 WARNING: DATA RACE 后面的两个访问操作,看它们是否确实存在冲突(读-写或写-写),以及各自对应的 goroutine 和源码位置。

Mutex 作为匿名字段必须使用指针接收者

如果在一个结构体中嵌入 sync.Mutex 作为匿名字段,并且在定义方法时使用了值接收者,那么每次调用该方法时都会复制整个结构体,包括内部的 Mutex。这会导致每个副本拥有独立的锁状态,锁机制完全失效,无法保护共享资源。

  • 错误示例(值接收者——锁失效)
type SafeCounter struct {
    sync.Mutex
    count int
}

// 值接收者:每次调用都会复制 SafeCounter,锁被复制,各副本独立
func (c SafeCounter) Inc() {
    c.Lock()
    c.count++   // 操作的是副本的字段
    c.Unlock()  // 解锁的也是副本的锁
    // 外面的原始 SafeCounter 的锁根本没有被持有!
}

并发调用 Inc 时,多个 goroutine 各自获得一个副本,它们之间没有互斥,对 count 的读写产生数据竞争。

  • 正确做法:指针接收者
func (c *SafeCounter) Inc() {
    c.Lock()
    c.count++
    c.Unlock()
}

使用指针接收者,方法操作的是原始结构体,只有一个锁实例,互斥有效。

原则:只要结构体包含 sync.Mutex 或类似不可复制的同步原语,其方法必须使用指针接收者。可以通过 go vet 的 copylocks 检测器发现这类问题。

原子操作(sync/atomic)

原子操作提供 无锁 的并发安全保证,适用于简单的整数操作(增加、交换、比较并交换等),性能远高于 Mutex,因为不需要上下文切换和内核参与。

  • 使用 atomic 实现无锁计数器
import "sync/atomic"

func main() {
    var counter int64 = 0
    var wg sync.WaitGroup

    for i := 0; i < 1000; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            atomic.AddInt64(&counter, 1)
        }()
    }

    wg.Wait()
    fmt.Println(atomic.LoadInt64(&counter)) // 1000
}

其他常用函数:

  • atomic.AddInt64(&val, delta) 加一个值
  • atomic.LoadInt64(&val) 原子读取
  • atomic.StoreInt64(&val, new) 原子写入
  • atomic.CompareAndSwapInt64(&val, old, new) CAS 操作

Mutex vs atomic 性能对比

粗略基准测试(1000 个 goroutine 各增加 10000 次):

  • Mutex 耗时约 50ms(涉及内核调度)
  • atomic 耗时约 5ms(纯用户态指令)

选用建议:

  • 对单一整数的简单操作,优先使用 atomic。
  • 保护一段代码块(多条语句)或复杂结构,使用 Mutex。

生产者-消费者模式

生产者-消费者是经典的并发模型:一个或多个 goroutine 负责产生数据(生产者),一个或多个 goroutine 负责处理数据(消费者),它们之间通过 channel 传递数据,实现解耦和流量控制。

  • 实现:单生产者,多消费者
func producer(ch chan<- int, count int) {
    for i := 0; i < count; i++ {
        ch <- i
        fmt.Printf("Produced: %d\n", i)
    }
    close(ch) // 生产完毕,关闭通道
}

func consumer(id int, ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()
    for v := range ch {
        fmt.Printf("Consumer %d consumed: %d\n", id, v)
        time.Sleep(time.Millisecond * 10) // 模拟处理
    }
}

func main() {
    const numConsumers = 3
    ch := make(chan int, 5) // 带缓冲,减少阻塞
    var wg sync.WaitGroup

    // 启动消费者
    for i := 0; i < numConsumers; i++ {
        wg.Add(1)
        go consumer(i, ch, &wg)
    }

    // 启动生产者
    go producer(ch, 20)

    wg.Wait() // 等待所有消费者完成
    fmt.Println("All done")
}
  • 生产者生产完毕后调用 close(ch)。
  • 消费者使用 for range,通道关闭后自动退出循环。
  • 使用 sync.WaitGroup 等待所有消费者退出后主函数返回。

Worker 池

固定数量 Worker 池

创建一个固定数量的 worker goroutine,它们从一个共享的任务 channel 中获取任务并执行。可以控制并发度,避免无限制创建 goroutine 耗尽资源。

  • 实现
type Task struct {
    ID int
    // 可携带其他数据
}

func worker(id int, tasks <-chan Task, wg *sync.WaitGroup) {
    defer wg.Done()
    for task := range tasks {
        fmt.Printf("Worker %d processing task %d\n", id, task.ID)
        time.Sleep(time.Millisecond * 100) // 模拟耗时任务
    }
}

func main() {
    const numWorkers = 4
    tasks := make(chan Task, 10)
    var wg sync.WaitGroup

    // 启动 worker
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go worker(i, tasks, &wg)
    }

    // 分发任务
    for i := 0; i < 20; i++ {
        tasks <- Task{ID: i}
    }
    close(tasks) // 不再有新任务,关闭通道

    wg.Wait() // 等待所有 worker 退出
    fmt.Println("All tasks completed")
}
  • 使用 有缓冲 channel 作为任务队列,避免 worker 频繁阻塞。
  • 任务分发完毕后 关闭 channel,worker 使用 range 自动退出。
  • sync.WaitGroup 确保主 goroutine 等待所有 worker 完成。

动态添加任务与优雅关闭

  • 支持在运行时动态添加任务,无需等到所有任务都加入后才关闭 channel。
  • 支持优雅关闭:通知 worker 停止接收新任务,并等待当前任务执行完毕。
  • 可复用 Worker 池
type WorkerPool struct {
    tasks    chan func()     // 任务函数
    wg       sync.WaitGroup
    quit     chan struct{}   // 通知 worker 退出
    quitOnce sync.Once       // 保证只关闭一次 quit
}

func NewWorkerPool(numWorkers int) *WorkerPool {
    p := &WorkerPool{
        tasks: make(chan func()),
        quit:  make(chan struct{}),
    }
    p.wg.Add(numWorkers)
    for i := 0; i < numWorkers; i++ {
        go p.worker()
    }
    return p
}

func (p *WorkerPool) worker() {
    defer p.wg.Done()
    for {
        select {
        case task, ok := <-p.tasks:
            if !ok {
                return // 任务通道关闭
            }
            task()
        case <-p.quit:
            return // 收到退出信号
        }
    }
}

func (p *WorkerPool) Submit(task func()) {
    p.tasks <- task
}

// 优雅关闭:不再接受新任务,等待已有任务完成
func (p *WorkerPool) Shutdown() {
    close(p.tasks) // 关闭任务通道,worker 会处理完剩余任务
    p.wg.Wait()
}

// 立即停止:发送 quit 信号,worker 立刻退出
func (p *WorkerPool) Stop() {
    p.quitOnce.Do(func() {
        close(p.quit)
    })
    p.wg.Wait()
}

func main() {
    pool := NewWorkerPool(3)

    // 动态提交任务
    for i := 0; i < 10; i++ {
        id := i
        pool.Submit(func() {
            fmt.Printf("Processing %d\n", id)
            time.Sleep(time.Second)
        })
    }

    // 优雅关闭:等待所有已提交的任务执行完毕
    pool.Shutdown()
    fmt.Println("All tasks finished")
}
  • quit channel:用于立即终止 worker,适用于需要紧急停止的场景。
  • 关闭 tasks channel:使 worker 在处理完剩余任务后自动退出。
  • 复用性:通过函数闭包传递任务,无需定义具体 Task 结构体,适用于各种任务。

并发实战:简易并发爬虫

爬取多个 URL,使用并发提高效率,但限制最大并发数(例如同时只有 5 个请求)。

  • 实现方案:使用带缓冲 channel 作为信号量
func crawl(url string) {
    resp, err := http.Get(url)
    if err != nil {
        fmt.Printf("Error fetching %s: %v\n", url, err)
        return
    }
    defer resp.Body.Close()
    fmt.Printf("%s - %d\n", url, resp.StatusCode)
}

func main() {
    urls := []string{
        "https://www.google.com",
        "https://www.github.com",
        "https://www.stackoverflow.com",
        // ... 更多
    }

    const maxConcurrency = 5
    sem := make(chan struct{}, maxConcurrency) // 信号量
    var wg sync.WaitGroup

    for _, url := range urls {
        wg.Add(1)
        go func(u string) {
            defer wg.Done()
            sem <- struct{}{}        // 获取许可证
            defer func() { <-sem }() // 释放许可证
            crawl(u)
        }(url)
    }
    wg.Wait()
    fmt.Println("Crawl finished")
}

为每个爬取操作加入 context 超时:

func crawlWithTimeout(url string, timeout time.Duration) {
    ctx, cancel := context.WithTimeout(context.Background(), timeout)
    defer cancel()
    req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
    resp, err := http.DefaultClient.Do(req)
    // ...
}
posted @ 2026-09-24 16:28  爱喝奶茶的CC怪  阅读(20)  评论(0)    收藏  举报