首页 > 技术文章 > 一天搞懂Go语言(6)——使用共享变量实现并发

yrxing 2020-12-22 21:12 原文

竞态

  竞态是指多个goroutine按某些交错顺序执行时无法给出正确的结果。竞态对于程序是致命的,因为它们可能潜伏在程序中,出现的频率也很低,有可能仅在高负载环境或者使用特定平台、架构时才出现。数据竞态发生于两个goroutine并发读写同一个变量并且至少其中一个是写入时。当发生数据竞态的变量类型是大于一个机器字长的类型(接口、字符串和slice)时,事情就变得复杂了。

var x []int
go func(){ x = make([]int,10)}()
go func(){ x = make([]int,1000)}()
x[999]=1 //注意:未定义行为,可能造成内存异常

   上述并发调用可能造成slice指针来自于第一个make调用而长度来自于第二个make调用,造成内存读取异常。如何在程序中避免数据竞态呢?

  第一种方法是不要修改变量。那些从不修改的数据结构以及不可变数据结构本质上是并发安全的,也不需要做任何同步。

  第二种是避免从多个goroutine访问同一个变量。由于其他goroutine无法直接访问相关变量,因此它们就必须使用通道来向受限goroutine发送查询请求或者更新变量,“不要通过共享内存来通信,而应该通过通信来共享内存”。使用通道请求一个受限变量的所有访问的goroutine称为该变量的监控goroutine。

  第三种避免数据竞态的办法是允许多个goroutine访问同一个变量,但在同一时间只有一个goroutine可以访问,这种方法称为互斥机制

互斥锁:sync.Mutex

   一个计数上限为1的信号量称为二进制信号量。

var(
	sema = make(chan struct{},1)
	balance int
) 

func Deposit(amount int){
	sema<- struct{}{} //获取令牌
	balance += amount
	<-sema //释放令牌
}

func Balance() int  {
	sema<- struct{}{} //获取令牌
	b := balance
	<-sema
	return b
}

  sync包有一个单独的Mutex类型来支持这种模式,它的Lock方法用于获取令牌,Unlock用于释放令牌。

var(
	mu = sync.Mutex{} //保护balance
	balance int
)

func Deposit(amount int){
	mu.Lock() //获取令牌
	balance += amount  //临界区
	mu.Unlock() //释放令牌
}

func Balance() int  {
	mu.Lock() //获取令牌
	b := balance
	mu.Unlock()
	return b
}

   锁的概念这里不在介绍,只是演示一下在go语言里面怎么使用。这种函数、互斥锁、变量的组合方式称为监控模式。在更复杂的场景中,很难确定在所有分支中Lock和UnLock都成对执行了。Go语言的defer语句就可以解决这个问题:通过延迟执行Unlock就可以把临界区域隐式扩展到当前函数结尾,避免了必须在一个或者多个远离Lock的位置插入一条Unlock语句

func Balance() int {
    mu.Lock()
    defer mu.Unlock()
    return balance //不再需要局部变量b了
}

  当然defer执行成本大,但在处理并发程序时,优先考虑清晰度,拒绝过早优化。在可以使用的地方,尽量使用defer来让临界区域扩展到函数结尾处。

读写互斥锁:sync.RWMutex

  读操作是完全可以并发运行的,但写操作需要获得完全独享的访问权限,这种锁称为多读单写锁,go语言通过sync.RWMutex提供。

var mu sync.RWMutex
var balance int

func Balance() int{
    mu.RLock() //读锁
    defer mu.RUnlock()
    return balance
}

   Deposite函数无须更改,通过调用mu.Lock和mu.Unlock获取和释放一个写锁。

  仅在绝大部分goroutine都在获取读锁并且锁竞争比较激烈时,RWMutex才有优势

内存同步

  Balance方法需要互斥锁的原因有两个:防止Balance插入到其他操作中间也是很重要的;同步不仅涉及多个goroutine的执行顺序问题,还会影响到内存。考虑下面程序:

var x,y int
go func(){
	x = 1
	fmt.Print("y:",y,"")
}()

go func(){
	y=1
	fmt.Print("x:",x,"")
}()

   y:0 x:1/x:0 y:1都有可能出现,但是x:0 y:0/y:0 x:0的出现就在我们意料之外了,在某些特定编译器、CPU下这的确可能发生。

  尽管很容易把并发理解为多个goroutine中语句的某种交错执行方式,但正如上面例子所显示,这并不是一个现代编译器和CPU的工作方式。因为赋值和Print对应不同的变量,所以编译器就可能会认为两个语句的执行顺序不会影响结果,然后就交换了这两个语句的执行顺序。CPU也有类似问题,如果两个goroutine在不同的CPU上执行,每个CPU有自己的缓存,那么一个goroutine的写入操作在同步到内存之前对另一个goroutine的Print语句是不可见的。

  总结:在可能的情况下,把变量限制到单个goroutine中,对于其他变量,使用互斥锁。因为在缺乏显式同步的情况下,编译器和CPU在能保证每个goroutine都满足串行一致性的基础上可以自由地重排访问内存的顺序。

延迟初始化:sync.Once

  预先初始化一个变量会增加程序的启动延时,并且如果实际执行时有可能根本用不上这个变量,那么初始化也不是必须的。

  sync包提供了针对一次性初始化问题的特化解决方案:sync.Once。从概念上讲,Once包含一个布尔变量和一个互斥变量,布尔变量记录初始化是否已经完成,互斥量则负责保护这个布尔变量和客户端的数据结构。

var loadIconsOnce sync.Once

loadIconsOnce.Do(func_name) //Once唯一方法Do以初始化函数作为它的参数

 竞态检测器

  Go语言运行时和工具链装备了一个精致并易于使用的动态分析工具:竞态检测器(race detector)。

  简单的把-race命令行参数加到go build、go run、go test命令里边即可使用该功能。竞态检测器会研究时间流,找到那些有问题的案例。这个工具会输出一份报告,包括变量的标识以及读写goroutine当时的调用栈,通常情况下这些信息足以定位问题了。

goroutine与线程

可增长的栈

  每个OS线程都有一个固定大小的栈内存(通常是2MB),栈内存区域用于保存在其他函数调用期间那些正在执行或临时暂停的函数中的局部变量。这个固定的栈大小既太大又太小

  作为对比,一个goroutine在生命周期开始时只有一个很小的栈,典型情况下时2KB,这个栈不是固定大小的,它可以按需增大或缩小。goroutine的栈大小限制可以达到1GB。

goroutine调度

  OS线程由OS内核来调度,每隔几毫秒,一个硬件时钟中断发送到CPU,CPU调用一个叫调度器的内核函数。以为OS线程由内核来调度,所以控制权限从一个线程到另外一个线程需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构,这个操作其实是很慢的。

  Go运行时包含一个自己的调度器,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。Go调度器只需关心单个Go程序的goroutine问题,它不是由硬件时钟来定期触发的,而是由特定的Go语言结构来触发的。因为它不需要切换到内核语境,所以调度一个goroutine比调度一个线程成本低很多。

GOMAXPROCS

  Go调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码,默认是机器上CPU数量,一个有8个CPU的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n中的n)。正在休眠或者正被通道阻塞的goroutine不需要占用线程;阻塞在IO和其他系统调用中或调用非Go语言写的函数的goroutine需要一个独立的OS线程,但这个线程不计算在GOMAXPROCS内。

goroutine没有标识

  在大部分支持多线程的操作系统和编程语言里,当前线程都有一个独特的标识,它通常可以取一个整数或者指针。goroutine没有可供程序员访问的标识,go语言鼓励一种更简单的编程风格,其中,能影响一个函数行为的参数应当是显式指定的。

 

推荐阅读