Golang 在单核上的“并发”及其引发的误导现象

写在前面

过去 Web 开发的工作比较少涉及到并发的问题,每个用户请求在独立的线程里面进行,偶尔涉及到异步任务但是线程间数据同步模型非常简单,因此并未深入探究过并发这一块。最近在写游戏相关的服务端代码时发现数据的并发同步场景非常多,因此花了一点时间来探索和总结。这是一个系列文章,本文为第四篇。

本文简单介绍 Golang 中配置可用 CPU 核的方法及其可能导致的误解。

(截止到 2020年05月 此文章中所描述的案例已经不适合了。go 在推出 1.14 版本后,运行时调度改为异步抢占,因此文章中的“逻辑死锁”已经不存在了。此文章仅可作为 go 版本演化的一个案例看。)

Golang 在单核上的“并发”问题

gotour上的乌龙案例

上一篇博客中介绍了 Golang 并发编程中 map 类型的“脆弱”性。具体地,Golang 的运行时(runtime)会强校验并发读写的状态,如果发现有协程(goroutine)读 map 同时有其他协程读或者写同一个 map,程序就会直接异常退出。

然而蹊跷的是,在 Golang 官方教程中,并发部分有一个示例(见这里,需自备梯子)却并没有因为多个协程并发写同一个 map 变量而异常退出。示例的主要内容是通过一个 Mutex 锁来限定 SafeCounter 结构体中的 v 变量(map类型)的并发读写,其源码如下:

package main

import (
	"fmt"
	"sync"
	"time"
)

// SafeCounter is safe to use concurrently.
type SafeCounter struct {
	v   map[string]int
	mux sync.Mutex
}

// Inc increments the counter for the given key.
func (c *SafeCounter) Inc(key string) {
	c.mux.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	c.v[key]++
	c.mux.Unlock()
}

// Value returns the current value of the counter for the given key.
func (c *SafeCounter) Value(key string) int {
	c.mux.Lock()
	// Lock so only one goroutine at a time can access the map c.v.
	defer c.mux.Unlock()
	return c.v[key]
}

func main() {
	c := SafeCounter{v: make(map[string]int)}
	for i := 0; i < 1000; i++ {
		go c.Inc("somekey")
	}

	time.Sleep(time.Second)
	fmt.Println(c.Value("somekey"))
}

如果去掉 Inc 函数中 mux 加锁与解锁的过程(如下面的代码所示),理论上示例代码会报出 concurrent map writes 错误,但是如果登录官方对应的 tour 页面,修改 Inc 方法后运行却并未报出 并发写 map 的错误(此结论截止到 2019/05/15,已经提了 issue,官方可能会做修复)

// 修改后的 Inc 函数,此处去掉了锁相关的过程
func (c *SafeCounter) Inc(key string) {
	c.v[key]++
}

单个物理核心上的“并发”

如果 CPU 只有单个物理核,Golang 运行时(runtime)如何才能实现逻辑上的 “并发” 呢? 其实我们可以类比操作系统的多进程模型(参考《 Linux系统调度原理浅析 》和《 Linux系统调度原理浅析(二) 》),引入 时间片 的概念,把一个物理核的使用权按时间片划分并分配给所有的协程(goroutine),每个协程消耗自己的时间片 轮流交替 在同一个物理核上运行,从而实现逻辑上的 “并发”。

其实这里面就涉及到一个问题,如果 Golang 代码运行时只被分配了一个物理核(比如宿主机只有一个物理核,或者通过 runtime.GOMAXPROCS(1)显式配置 Golang 进程只能使用一个核),那么是否就意味着 Golang 运行时(runtime)对 map 的读写都变成了顺序的从而避免了并发错误呢?

目前来看,一个物理核的运行时配置确实会让 map 表现的不那么 “脆弱”。Golang 官方 https://tour.golang.org/concurrency/9 这个示例所运行的服务器很大概率默认添加了单个物理核的限制(可能考虑到节省资源),从而导致上面提到的乌龙示例。不过这里需要特别说明一下,按照进程调度的基本原理,假设每个协程可以在任意过程被中断,理论上单个物理核上也可能会引发 map 的并发错误从而导致进程异常退出(因为 map 的读与写过程都很复杂,二者都不是原子性的),从这个角度配置单个核并不能保证 Go 线程安全(此项有待进一步确认)。

上面所提到的乌龙示例一般不会碰到,因为大部分的开发环境都是多核心的;不过如果开发环境是单核配置的虚拟机就会遇到了(我周围就有朋友用单核的虚拟机作为开发环境学习 Golang)。

runtime.GOMAXPROCS(1) 方法

翻译官方对 GOMAXPROCS 的描述:GOMAXPROCS 可设置能够同时运行代码逻辑的最大 CPU 数量。

在了解了单个 CPU 核心 对 map 类型变量的影响后,可能有的同学会考虑通过 runtime.GOMAXPROCS(1) 限制 Golang 应用可使用的 CPU 核心从而增加代码的健壮性——其实这种考虑是比较危险的。

首先, map 的读写过程都不是原子性的(原子性的概念参考《 浅谈 Golang 中数据的并发同步问题(二) 》中的阐述),这就导致读写过程可能被在任意过程中断,从而引发 map 的并发读写校验生效导致程序异常退出(这一条有待进一步确认 goroutine 的调度机制)。其次,在低成本创建 goroutine 的编程模型中,单核心的配置可能造成逻辑死锁,比如下面的代码就会僵死:

package main

import (
	"fmt"
	"runtime"
)

var (
	flag = false
	str  string
)

func foo() {
	flag = true
	str = "setup complete!"
}

func main() {
	runtime.GOMAXPROCS(1)
	go foo()
	for {
		if flag {
			break
		}
	}
	fmt.Println(str)
}

小结

Golang 运行时默认会启用所有的 CPU 核心,可以通过 runtime.GOMAXPROCS() 方法配置可用的最大 CPU核心数量。当只有一个 CPU 核的时候(比如虚拟机只配置了一个物理核,或者通过 runtime.GOMAXPROCS(1) 配置只使用一个物理核),会对 map 类型变量的并发稳定性产生一些影响(不加锁的情况下也不会出现并发读写问题),但是开发者不应该依赖这个特性来试图增加代码的健壮性,否则会造成无法预料的结果。

参考