内容简介:过去 Web 开发的工作比较少涉及到并发的问题,每个用户请求在独立的线程里面进行,偶尔涉及到异步任务但是线程间数据同步模型非常简单,因此并未深入探究过并发这一块。最近在写游戏相关的服务端代码时发现数据的并发同步场景非常多,因此花了一点时间来探索和总结。这是一个系列文章,本文为第四篇。本文简单介绍 Golang 中配置可用 CPU 核的方法及其可能导致的误解。在上一篇博客中介绍了 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
类型变量的并发稳定性产生一些影响(不加锁的情况下也不会出现并发读写问题),但是 开发者不应该依赖这个特性来试图增加代码的健壮性
,否则会造成无法预料的结果。
参考
- Linux系统调度原理浅析 - 敬维 简单介绍了 进程、线程、多线程模型、时间片以及调度等概念
- Linux系统调度原理浅析(二) - 敬维 简单介绍了 进程、线程、调度以及Goroutine的调度
- 浅谈 Golang 中数据的并发同步问题(二) - 敬维 介绍了原子性
-
golang多核设置
介绍了 golang 的多核配置方法(
runtime.GOMAXPROCS(1)
)及示例
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持 码农网
猜你喜欢:- [Java并发-17-并发设计模式] Immutability模式:如何利用不变性解决并发问题?
- 线上账务系统余额并发更新问题记录
- 并发那些事:可见性问题的万恶之源
- 浅谈 Golang 中数据的并发同步问题(三)
- 浅谈 Golang 中数据的并发同步问题(二)
- 浅谈 Golang 中数据的并发同步问题(一)
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。