浅谈 Golang 中数据的并发同步问题(三)

栏目: Go · 发布时间: 4年前

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

写在前面

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

本文简单介绍 Golang 中 map 类型的安全使用。

Golang 中 map 的使用

在业务逻辑中保存 key-value 是一个非常普遍的需求,因此 Map 的使用场景非常多。

不允许并发读写的 map

在 Golang 源码实现中对 map 的要求比较高(见《 Go maps in action 》): Maps are not safe for concurrent use: it's not defined what happens when you read and write to them simultaneously (当并发使用时 Maps 是不安全的,当并发地读写 map 的时候无法预知会发生啥 )。

如果不加保护地在不同的线程中读写 map 类型的数据,代码会直接崩溃并异常退出。比如下面的代码:

package main

func main() {
	m := make(map[int]int)
	go func() {
		for {
			_ = m[1]
		}
	}()
	go func() {
		for {
			m[2] = 1
		}
	}()
	select {}
}

运行上面的代码可以得到下面类似的结果:

go run map/main.go 
# fatal error: concurrent map read and map write
# ....(省略异常堆栈)

从输出结果来看,Golang 运行时明确禁止 map 的并发读写,且在检测到这种情况后直接异常退出。这不同于其他数据类型,比如 intstring 等,对比下面的代码(说明:下面的代码存在隐形的并发问题,具体参考《 浅谈 Golang 中数据的并发同步问题(二) 》):

// 运行下面的代码并不会异常退出,不同于上面 map 类型的 m 的使用
// go run main.go 
package main

func main() {
	var m int
	go func() {
		for {
			_ = m
		}
	}()
	go func() {
		for {
			m = 1
		}
	}()
	select {}
}

再次 需要说明 ,虽然上面的代码在不同的线程中访问 int 类型的数据并未直接异常退出,但是这种不加任何安全措施的并发读写是存在安全风险的,具体参考《 浅谈 Golang 中数据的并发同步问题(二) 》。

安全使用 map——显而易见地加锁

既然 Golang 在运行时不允许对 map 的并发读写,当需要在多个线程中读写 map 时,显而易见的方式是 加锁 (如《 浅谈 Golang 中数据的并发同步问题(一) 》所描述的)。

下面的代码把 map 类型的 m 封装在一个匿名的 struct 中,同时整个匿名的 struct 继承了 sync.RWMutex 结构,因此拥有了 加读写锁 的功能,从而安全地实现了多个线程对 map 的 “并发读写”:

package main

import (
	"sync"
)

func main() {
	var counter = struct {
		sync.RWMutex
		m map[string]int
	}{m: make(map[string]int)}

	go func() {
		for {
			counter.RLock()
			_ = counter.m["some_key"]
			counter.RUnlock()
		}
	}()
	go func() {
		for {
			counter.Lock()
			counter.m["some_key"]++
			counter.Unlock()
		}
	}()
	select {}
}

为什么 map 并发读写时会在运行时异常退出

最后提一下这个问题: 为什么 int、string、slice 等变量在多个线程读写时运行正常,而 map 在多个线程并发读写时会运行时异常退出? 其实这个涉及到 map 的具体实现(我知道这是一句废话 +_+)。

简单来讲,可以从 Go 源码中 map 运行时相关的部分 窥见一些依据: map 的增改删查可以分别对应到 func mapassign()func mapaccess1()func mapdelete() 这几个函数,每个函数都有非常长的执行逻辑;如果多个线程并发读写同一个 map ,大概率会出现 ① mapassign 函数(增加某个 key 的值)执行到一半的时候 mapaccess1 读取到一个相应的零值,② mapaccess1 函数(读取某个 key 的值)执行到一半的时候 mapdelete 已经删除了对应的 key ,等等。

同时考虑到增删数据时底层数据的改变(比如扩容重分配,这一块还没深入研究,可以自行查看源码=。=),因此保持 map 的单纯变得很重要;为避免出现难以 debug 的异常, 运行时环境显式地并发异常退出也就可以理解了。

小结

Golang 的运行时会 在 map 的增改删查过程中检测是否有并发读写的情况,当发现并发读写时直接异常退出 。相对于其他数据类型(比如 int、string、slice 等),map 的并发使用是比较严苛的(安全&性能的折中);可以认为 map 的这种严苛很大程度上降低了诡异 bug 的产生,增加代码的鲁棒性。

最后,当提到 map 的并发使用时,很多时候会提到 sync.Map 的使用,不过由于它大量使用了 interface{} 类型,使用起来并不是那么方便;目前为止, 我更喜欢加读写锁的方式 来使用 map 而不是使用线程安全的 sync.Map :laughing:

参考


以上所述就是小编给大家介绍的《浅谈 Golang 中数据的并发同步问题(三)》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

The Web Designer's Idea Book, Vol. 2

The Web Designer's Idea Book, Vol. 2

Patrick McNeil / How / 2010-9-19 / USD 30.00

Web Design Inspiration at a Glance Volume 2 of The Web Designer's Idea Book includes more than 650 new websites arranged thematically, so you can easily find inspiration for your work. Auth......一起来看看 《The Web Designer's Idea Book, Vol. 2》 这本书的介绍吧!

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

SHA 加密
SHA 加密

SHA 加密工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试