单核下,Goroutine 并发调度的疑问(上)

问题: 单核不就是只有一个系统线程执行多个groutine么,groutine需要排队呀.排队不就是串行化执行么

你的疑问非常好,我明白你在纠结的地方:既然单核只有一个系统线程(在 GOMAXPROCS=1 的情况下),Goroutine 看起来像是“排队”执行,那为什么还会出现并发问题?让我再深入解释一下,尽量清晰地解答你的困惑。

单核和 Goroutine 的调度

是的,在单核 CPU 上,且设置了 GOMAXPROCS=1 后,Go 运行时只会使用一个操作系统线程(称为 M,Machine)来执行所有的 Goroutine。这个线程就像一个工人,它需要处理多个 Goroutine(任务)。但这里的关键是:Goroutine 的执行并不是严格按照“一个完成后再开始下一个”的顺序,而是可以随时被切换的

想象一下这个工人(线程)在处理任务(Goroutine)时,不是把一个任务从头做到尾再换下一个,而是可能会在某个任务做到一半时停下来,去处理另一个任务,然后再回来继续之前的任务。这种切换是由 Go 运行时的调度器(Scheduler)控制的,而不是简单地“排队等候、顺序完成”。

为什么不是严格的串行化?

如果 Goroutine 是严格串行化执行的,那么确实不会有并发问题,因为每个 Goroutine 会完整地执行完它的代码(比如 i++),然后再轮到下一个。但 Go 的设计不是这样的,它追求的是轻量级并发

  • 调度器的抢占:在 Go 1.14 之后,调度器引入了抢占机制。即使一个 Goroutine 正在执行,如果它运行时间较长(比如循环或计算),调度器会强制暂停它,让其他 Goroutine 有机会运行。
  • 协作式切换:在早于 1.14 的版本中,Goroutine 会在某些特定点(如 I/O 操作、调用 runtime.Gosched())主动让出控制权。
  • 任务交错执行:即使没有抢占,调度器也会在 Goroutine 的执行过程中寻找切换点,导致多个 Goroutine 的代码交错运行。

这种交错执行意味着,即使只有一个线程,Goroutine 的代码片段仍然可能以非预期的顺序执行。这就是为什么即使在单核环境下,i++ 仍然会有问题。

用一个比喻说明

假设你是一个厨师(单核线程),需要为三个顾客(三个 Goroutine)各做一个汉堡(执行 i++)。每个汉堡的制作分为三步:拿面包、放肉饼、盖上盖子。如果你是严格串行化的,你会:

  1. 为顾客 A 做完整个汉堡(拿面包 → 放肉饼 → 盖盖子)。
  2. 再为顾客 B 做完整个汉堡。
  3. 最后为顾客 C 做。

但 Go 的调度器更像是让你在做汉堡时随意切换:

  • 你为顾客 A 拿了面包(读 i)。
  • 突然切换到顾客 B,拿了面包(读 i)。
  • 然后为顾客 A 放肉饼、盖盖子(完成 i++)。
  • 再为顾客 B 放肉饼、盖盖子(完成 i++)。

结果是,两个顾客的汉堡制作步骤交错了,如果 i++ 的值依赖于前一个操作的结果,就会出错。

回到 i++

i++ 包含“读-改-写”三个步骤。如果 Goroutine 在执行到一半时被切换:

  • Goroutine A 读取 i = 0,还没来得及写回就被暂停。
  • Goroutine B 读取 i = 0,完成 i++,写回 i = 1
  • Goroutine A 恢复,基于它之前读取的 0 计算 i = 1,写回 i = 1

即使只有一个线程,这种交错执行仍然会导致数据不一致。这不是因为有多个线程并行,而是因为 Goroutine 的并发调度打破了串行化的假设。

“排队”不等于“完整执行”

你提到的“排队”,可能是指 Goroutine 在等待执行,但这里的“排队”只是说它们在调度器的队列中等待被分配时间片,而不是等待前一个 Goroutine 完全结束。调度器会动态决定何时切换 Goroutine,而不是等一个 Goroutine 完整执行完再换下一个。

单核下的实验

再看一个简单的例子:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    runtime.GOMAXPROCS(1) // 强制单线程
    var i int
    var wg sync.WaitGroup

    wg.Add(2)
    go func() {
        defer wg.Done()
        i++ // 读-改-写
        fmt.Println("Goroutine 1:", i)
    }()
    go func() {
        defer wg.Done()
        i++ // 读-改-写
        fmt.Println("Goroutine 2:", i)
    }()

    wg.Wait()
    fmt.Println("Final i:", i)
}

运行多次,你可能会看到:

Goroutine 1: 1
Goroutine 2: 1
Final i: 1

或者:

Goroutine 2: 1
Goroutine 1: 2
Final i: 2

这说明即使在单核单线程下,i++ 的执行顺序是不确定的,因为 Goroutine 的切换导致了竞态条件。

如何实现严格串行化?

如果你真的想要严格的串行化,可以不用 go 关键字,或者用一个主 Goroutine 按顺序调用:

package main

import "fmt"

func main() {
    var i int
    i++
    fmt.Println("Step 1:", i)
    i++
    fmt.Println("Step 2:", i)
    fmt.Println("Final i:", i)
}

这样输出一定是 1, 2, 2,因为没有并发。但这就失去了 Go 的并发优势。

总结

  • 单核环境下,GOMAXPROCS=1 确实只有一个系统线程,但 Goroutine 是并发调度的,不是严格排队等前一个完成。
  • “排队”是指 Goroutine 在等待时间片,而不是等待前一个 Goroutine 完整执行。
  • i++ 的非原子性加上 Goroutine 的交错执行,导致即使单核也会出现数据不一致。
  • 要避免问题,仍然需要用 sync.Mutexsync/atomic
posted @ 2025-03-25 08:45  仁义礼智信的  阅读(7)  评论(0)    收藏  举报