01
写在前面
过去 Web 开发的工作比较少涉及到并发的问题,每个用户请求在独立的 线程 里面进行,偶尔涉及到异步任务但是线程间数据同步模型非常简单,因此并未深入探究过并发这一块。最近在写游戏相关的服务端代码时发现数据的并发同步场景非常多,因此花了一点时间来探索和总结。这是一个系列文章,本文为第四篇。
本文简单介绍 Golang 中配置可用 CPU 核的方法及其可能导致的误解。
02
Golang 在单核上的“并发”问题
gotour上的乌龙案例
在 中介绍了 Golang 并发编程中 map 类型的“脆弱”性。具体地,Golang 的运行时( runtime )会强校验并发读写的状态,如果发现有协程(goroutine)读 map 同时有其他协程读或者写同一个 map,程序就会直接异常退出。
然而蹊跷的是,在 Golang 官方教程中,并发部分有一个示例(见这里 )却并没有因为多个协程并发写同一个 map 变量而异常退出。示例的主要内容是通过一个 Mutex 锁来限定 SafeCounter 结构体中的 v 变量(map类型)的并发读写,其源码如下:
如果去掉 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 官方 这个示例所运行的服务器很大概率默认添加了单个物理核的限制(可能考虑到节省资源),从而导致上面提到的乌龙示例。不过这里需要特别说明一下,按照进程调度的基本原理,假设每个协程可以在任意过程被中断,理论上单个物理核上也可能会引发 map 的并发错误从而导致进程异常退出(因为 map 的读与写过程都很复杂,二者都不是原子性的),从这个角度配置单个核并不能保证 Go 线程安全(此项有待进一步确认)。
上面所提到的乌龙示例一般不会碰到,因为大部分的 开发环境 都是多核心的;不过如果开发环境是单核配置的 虚拟机 就会遇到了(我周围就有朋友用单核的虚拟机作为开发环境学习 Golang)。
runtime.GOMAXPROCS(1) 方法
翻译官方对 GOMAXPROCS 的描述:GOMAXPROCS 可设置能够同时运行代码逻辑的最大 CPU 数量。
在了解了单个 CPU 核心 对 map 类型变量的影响后,可能有的同学会考虑通过 runtime.GOMAXPROCS(1) 限制 Golang 应用可使用的 CPU 核心从而增加代码的健壮性——其实这种考虑是比较危险的。
首先, map 的读写过程都不是原子性的(原子性的概念参考 中的阐述),这就导致读写过程可能被在任意过程中断,从而引发 map 的并发读写校验生效导致程序异常退出(这一条有待进一步确认 goroutine 的调度机制)。其次,在低成本创建 goroutine 的编程模型中,单核心的配置可能造成逻辑死锁,比如下面的代码就会僵死:
03
小结
Golang 运行时默认会启用所有的 CPU 核心,可以通过 runtime.GOMAXPROCS() 方法配置可用的最大 CPU核心 数量。当只有一个 CPU 核的时候(比如虚拟机只配置了一个物理核,或者通过 runtime.GOMAXPROCS(1) 配置只使用一个物理核),会对 map 类型变量的并发稳定性产生一些影响(不加锁的情况下也不会出现并发读写问题),但是开发者不应该依赖这个特性来试图增加代码的健壮性,否则会造成无法预料的结果。
参考
- Linux系统调度原理浅析 – 敬维 简单介绍了 进程、线程、多线程模型、时间片以及调度等概念
- Linux系统调度原理浅析(二) – 敬维 简单介绍了 进程、线程、调度以及Goroutine的调度
- golang多核设置 介绍了 golang 的多核配置方法(runtime.GOMAXPROCS(1))及示例
著作权归作者所有。
原文:
本文作者:敬维,原创授权发布