Goroutine 与调度器

Goroutine 是绿色线程(Green Thread),运行在 Go 运行时的调度器之上。调度器实现了 M:N 模型,即 M 个系统线程调度 N 个 Goroutine。Goroutine 可以在 syscall 进入阻塞状态时自动出让 CPU,类似于 Java 在进入锁以前自动引入自旋,这实际上是一种抢占式调度(Preemptive Scheduling)。也可以通过 runtime.Gosched() 主动出让 CPU。

调度器还可能无缘由地主动抢占 Goroutine 的时间片(比如已经运行了 10ms)。因为是绿色线程,所以可以很便宜地创造百万 Goroutine。在 Go 1.5 以后,可以通过 GOMAXPROCS 来使用更多的逻辑 CPU(而不是系统进程)来利用多核。主线程不是主线程,主线程也是一个 main goroutine。

Go 关键字基本就等同于 Java 中提交一个 Runnable 到 CompletableFuture 的 CommonPool。在没有 Channel 的帮助时,goroutine 几乎可以等同于一个绿色的守护线程。

Go 1.14+ 基于信号的抢占式调度

在 Go 1.14 之前,Go 的抢占式调度主要基于协作式调度,通过函数调用插入检查点来实现。Go 1.14 引入了基于信号的抢占式调度(Signal-based Preemption),通过向运行时间过长的 Goroutine 发送 SIGURG 信号来强制其暂停。这种机制使得长时间运行的函数(如无限循环)也能被及时抢占,提高了调度器的响应性和公平性。

GMP 调度模型

Go 的调度器采用 GMP 模型:

  • G(Goroutine):Go 协程,代表需要执行的代码和栈空间
  • M(Machine):系统线程,负责执行 G,与操作系统内核线程 1:1 对应
  • P(Processor):逻辑处理器,维护一个本地运行队列,存储可运行的 G

Go 实现了 M:N 的调度(而不是 1:M 的调度),G 通过 P(只看到 P),可以在不同的 M 之间自由切换,这是其他 Green Thread 做不到的(因为其他 Green Thread 本质上还是单进程/系统线程)。G 可以阻塞,M 永远不阻塞。

graph TD
    subgraph "GMP 调度模型"
        M1[M1<br/>系统线程] -->|持有| P1[P1<br/>逻辑处理器]
        M2[M2<br/>系统线程] -->|持有| P2[P2<br/>逻辑处理器]
        M3[M3<br/>系统线程] -->|等待| P3[P3<br/>逻辑处理器]
        
        P1 -->|本地队列| G1((G1))
        P1 -->|本地队列| G2((G2))
        P1 -->|本地队列| G3((G3))
        
        P2 -->|本地队列| G4((G4))
        P2 -->|本地队列| G5((G5))
        
        P3 -->|本地队列| G6((G6))
        
        subgraph "全局队列"
            G7((G7))
            G8((G8))
        end
        
        P1 -.->|工作窃取| P2
        P2 -.->|工作窃取| P1
        P1 -.->|获取| G7
        P2 -.->|获取| G8
    end
    
    style G1 fill:#90EE90
    style G2 fill:#90EE90
    style G3 fill:#90EE90
    style G4 fill:#90EE90
    style G5 fill:#90EE90
    style G6 fill:#90EE90
    style G7 fill:#87CEEB
    style G8 fill:#87CEEB

Goroutine 调度流程

Goroutine 的生命周期包括创建、放入队列、调度执行、阻塞或完成等阶段:

graph LR
    A[创建 Goroutine] --> B[放入 P 本地队列<br/>或全局队列]
    B --> C{队列是否为空}
    C -->|否| D[M 从 P 获取 G]
    C -->|是| E[M 从全局队列获取<br/>或工作窃取]
    E --> D
    D --> F[执行 G]
    F --> G{执行状态}
    G -->|阻塞| H[将 G 放入等待队列<br/>M 绑定其他 G]
    G -->|完成| I[清理 G 资源]
    G -->|时间片用完| J[将 G 放回队列<br/>重新调度]
    H --> K[阻塞结束<br/>重新入队]
    K --> B
    J --> B

调度器采用了工作窃取(Work Stealing)算法,当某个 P 的本地队列为空时,可以从其他 P 的本地队列窃取一半的 Goroutine,也可以从全局队列获取。调度器的细节可以查看《也谈goroutine调度器》

Channel 与 CSP 模型

Channel 是 Go 并发的核心原语,实现了 CSP(Communicating Sequential Processes,通信顺序进程)模型。CSP 的核心理念是 “通过通信来共享内存”(Share memory by communicating),而不是传统的 “通过共享内存来通信”。

Channel 的无 buffer 版本和有 buffer 版本类似于 Java 的 SynchronousQueue 和 BlockingQueue。Channel 是化异步为同步的利器。select 语句中的 default 和 timeout 可以让阻塞操作变为非阻塞的操作。Channel 的阻塞也会引起 Goroutine 的调度。

Channel 通信过程

sequenceDiagram
    participant G1 as Goroutine 1<br/>(Sender)
    participant Ch as Channel
    participant G2 as Goroutine 2<br/>(Receiver)
    
    Note over G1,G2: 无缓冲 Channel
    G1->>Ch: ch <- value<br/>(阻塞等待接收者)
    Ch->>G2: value<br/>(唤醒接收者)
    G1-->>G1: 继续执行
    
    Note over G1,G2: 有缓冲 Channel<br/>(缓冲区未满)
    G1->>Ch: ch <- value<br/>(直接写入缓冲区)
    G1-->>G1: 继续执行
    G2->>Ch: <-ch<br/>(从缓冲区读取)
    G2-->>G2: 获取值
    
    Note over G1,G2: 有缓冲 Channel<br/>(缓冲区已满)
    G1->>Ch: ch <- value<br/>(阻塞等待空间)
    Ch->>G2: 唤醒接收者
    G2->>Ch: <-ch<br/>(释放缓冲区空间)
    G1->>Ch: value<br/>(写入成功)
    G1-->>G1: 继续执行

Channel 的特性

  • 单向 Channel:单向 channel 有点像泛型里的单边操作的 wildcard,可以封印掉读/写能力,而且需要通过声明强转。
  • Channel 关闭:channel 可以被关闭。关闭有什么用呢?不关闭它也会被垃圾回收,关闭只是 sender 给 receiver 发送的一个状态变化。
  • Select 语句:channel 是通过描述若干操作的模式匹配来实现 select 的。

Context 包与 Goroutine 生命周期

Go 的 context 包提供了一种跨 API 边界传递取消信号、截止时间和其他请求范围值的标准方法。它主要用于协调多个 Goroutine 的生命周期,实现优雅的取消和超时控制。

Context 的类型

  • context.Background():根 context,通常用于主函数、初始化和测试
  • context.TODO():不确定使用什么 context 时使用
  • context.WithCancel():创建可取消的 context
  • context.WithTimeout():创建带超时的 context
  • context.WithDeadline():创建带截止时间的 context
  • context.WithValue():创建携带键值对的 context

Context 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("Worker %d 被取消\n", id)
return
default:
fmt.Printf("Worker %d 正在工作\n", id)
time.Sleep(500 * time.Millisecond)
}
}
}

func main() {
ctx, cancel := context.WithCancel(context.Background())

for i := 1; i <= 3; i++ {
go worker(ctx, i)
}

time.Sleep(2 * time.Second)
cancel() // 取消所有 worker
time.Sleep(1 * time.Second)
}

Sync 包的并发工具

Go 的 sync 包提供了多种并发控制工具,虽然 Go 提倡使用 channel 进行通信,但在某些场景下,sync 包的工具更加高效和简洁。

WaitGroup

WaitGroup 用于等待一组 Goroutine 完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var wg sync.WaitGroup

func worker(id int) {
defer wg.Done()
fmt.Printf("Worker %d 开始\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d 完成\n", id)
}

func main() {
for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i)
}
wg.Wait()
fmt.Println("所有 worker 完成")
}

Once

Once 确保某个函数只执行一次:

1
2
3
4
5
6
7
8
9
var once sync.Once
var instance *Singleton

func getInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}

Pool

Pool 是临时对象的缓存池,用于减少内存分配:

1
2
3
4
5
6
7
8
9
10
11
12
13
var bufPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}

func processData(data []byte) {
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
buf.Reset()
buf.Write(data)
// 使用 buf 处理数据
}

Mutex

Go 也有 mutex,虽然不提倡使用(提倡用 channel),但在某些场景下仍然有用:

1
2
3
4
5
6
7
8
var mu sync.Mutex
var counter int

func increment() {
mu.Lock()
defer mu.Unlock()
counter++
}

与其他并发方案的对比

异步回调

异步回调会把程序拆得七零八落。人脑还是线性思维的。化异步为同步是最重要的。Goroutine 就是化异步为同步的。

GreenThread/Coroutine/Fiber 方案

GreenThread/Coroutine/Fiber 方案遇到阻塞的时候,可以自己 yield 出 CPU,但还是需要外部的线程把 context resume 回来。Go 则由 scheduler 自动识别代劳了。需要外部线程来不断切换 context,其实是一种单线程的并发,尽量减少阻塞时间而能够多利用 CPU 罢了。

M:1 或 M:N 调度

因为有了遇到 IO 阻塞的时候自动 yield 的机制,所以其他机制中 M:1 或者 M:N 的时候,单线程可能会用 IO 系统调用阻塞整个进程的情况,不再发生。

参考资源

更多的例子见 Go by Example《并发之痛 Thread,Goroutine,Actor》《Go 调度器: M, P 和 G》《golang大杀器GMP模型》