问题: 单核不就是只有一个系统线程执行多个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++
)。每个汉堡的制作分为三步:拿面包、放肉饼、盖上盖子。如果你是严格串行化的,你会:
- 为顾客 A 做完整个汉堡(拿面包 → 放肉饼 → 盖盖子)。
- 再为顾客 B 做完整个汉堡。
- 最后为顾客 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 mainimport ("fmt""runtime""sync"
)func main() {runtime.GOMAXPROCS(1) // 强制单线程var i intvar wg sync.WaitGroupwg.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 mainimport "fmt"func main() {var i inti++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.Mutex
或sync/atomic
。