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

写在前面

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

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

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 类型变量的并发稳定性产生一些影响(不加锁的情况下也不会出现并发读写问题),但是开发者不应该依赖这个特性来试图增加代码的健壮性,否则会造成无法预料的结果。

参考