《Go程序设计语言》9 使用共享变量实现并发

竞态

学过数据库的并发控制可能会对这部分有些熟悉。
数据竞态发生在两个goroutine并发读写同一个变量且至少其中一个是写入时。
比如有两个goroutine,针对变量A进行操作。其中使用R(A)表示读,W(A)表示写。假设 g o 1 = { R 1 ( A ) , W 1 ( A ) } go_1=\{R_1(A),W_1(A)\} go1={R1(A),W1(A)}, g o 2 = { R 2 ( A ) , W 2 ( A ) } go_2=\{R_2(A),W_2(A)\} go2={R2(A),W2(A)}。我们希望的正确结果是串行的结果,即goroutine1先对A进行一番修改,goroutine2在此基础上再进行一番修改。但是若两个goroutine进行并行的时候,很可能出现下面的操作序列 R 1 ( A ) , R 2 ( A ) , W 1 ( A ) , W 2 ( A ) R_1(A),R_2(A),W_1(A),W_2(A) R1(A),R2(A),W1(A),W2(A) 。此时goroutine1的写操作直接被覆盖掉了。

有三种方法来避免竞态:

  • 在某些场景下,可以要求不对变量进行修改。即杜绝“写”操作

  • 尽量避免从多个goroutine访问同一个变量。将变量限制在单个goroutine内部,而其它goroutine需要访问该变量时,就使用通道来进行通信。而具有变量所有权的,通过通道来代理变量的访问和修改的goroutine,称为监控goroutine。比如下面这样做

    package VarA
    var readch = make(chan int)
    var writech = make(chan int)
    
    func Read() int {return <-readch}
    func Write(delta int) {writech <- delta}
    
    func teller() {
    	var A int //变量A被限制再监控goroutine中
    	for {
    		select {
    		case delta := <-writech:
    			A += delta
    		case readch <- A:
    		}
    	}
    }
    
    func init() {
    	go teller()
    }
    
  • 第三种方法是同一时间上,只有一个goroutine能访问变量。这种方法称为互斥机制,接下来主要围绕这个展开

互斥锁 sync.Mutex

给出上一节的例子,使用互斥锁的解决方法

import "sync"
var (
	mu sync.Mutex
	A int
)

func Read() int {
	mu.Lock()
	a := A
	mu.Unlock()
	return b
}

func Write(delta int) {
	mu.Lock()
	A += delta
	mu.Unlock()

一旦上锁,则所有试图申请同一个锁(mu.Lock())的进程被阻塞,直到其它进程释放锁(mu.Unlock()),申请锁成功的进程再次上锁
注意,不同于数据库里面直接对数据进行上锁,这里需要将对同一个数据的操作放在同一个锁的Lock()Unlock()之间,才能实现类似对数据上锁的效果
为了使得上锁和解锁总是成对出现,使用defer不失为一种好选择,每次使用Lock()后,紧跟一句defer Unlock()

读写互斥锁sync.RWMutex

多个读操作是可以并发的,读和写不能并发,写和写不能并发。修改上面的程序

import "sync"
var (
	mu sync.RWMutex
	A int
)

func Read() int {
	mu.RLock()
	a := A
	mu.RUnlock()
	return b
}

func Write(delta int) {
	mu.Lock()
	A += delta
	mu.Unlock()
  • 使用mu.RLock(),mu.RUnlock()申请读锁和释放读锁
  • 写锁的操作还是一样的

在这里放一个锁的相容矩阵:
其中S锁相当于这里的读锁,X锁相当于写锁,该矩阵展示了T1在申请某锁后,T2申请各个锁时是否面临阻塞
在这里插入图片描述

内存同步

在缺乏显式同步的情况下,编译器和CPU在能保证么一个goroutine都满足串行一致性的基础上可以自由的重排访问内存的顺序

比如

var x,y int
go func() {
	x = 1
	fmtPrint("y:",y," ")
}
go func() {
	y = 1
	fmtPrint("x:",x," ")
}

直觉上似乎不可能出现x:0 y:0这样的结果,但是事实上是有可能的。这是因为在每个goroutine内部,都是写一个变量,读取另一个变量,在单独的goroutine内,编译器极有可能认为这两个操作的顺序对于单一goroutine的执行结果没有影响从而不必保持该顺序,因此会产生不可预料的效果

延迟初始化 sync.Once

sync.Once 只有一个方法 Do,该方法接收一个函数作为参数,并确保该函数只会被执行一次。如果多个 goroutine 同时调用 Do,只有一个 goroutine 会执行函数。通过这个特性我们可以用它来进行延迟初始化

var icons map[string]image.Image
var loadIconsOnce sync.Once

func loadIconds() {
	初始化
}

func Icon(name string) iamge.Image {
	loadIconOnce.Do(loadIcons)
	return icons[name]
}

效果就是:没有初始化,那就初始化;已经初始化过了,直接跳过

sync.Once 的实现原理非常简单,它使用一个 done 变量来记录函数是否已经被执行过。在第一次调用 Do 方法时,done 变量会被设置为 true,并且函数会被执行。在后续调用 Do 方法时,由于 done 变量已经被设置为 true,因此函数不会被执行,直接返回。

type Once struct {
    m    sync.Mutex
    done uint32
}

func (o *Once) Do(f func()) {
    if atomic.LoadUint32(&o.done) == 1 {
        return
    }
    o.m.Lock()
    defer o.m.Unlock()
    if o.done == 0 {
        defer atomic.StoreUint32(&o.done, 1)
        f()
    }
}

竞态检测器

使用-race 添加到go buildgo rungo test命令里面,输出一个报告

goroutine 与 线程

goroutine 与 操作系统线程的差异属于量变

可增长的栈

每个OS线程有一个固定大小的栈内存,一个goroutine在生命周期开始时只有很小的栈,但是可以按需增大或缩小。以维持巨大数量的goroutine创建(超十万个),或是占用内存较大的goroutine

goroutine调度

  • 线程的调度:OS线程由OS内核来调度。每隔几毫秒,一个硬件时钟中断发到CPU, CPU调用一个叫调度器的内核函数。这个函数暂停当前正在运行的线程,把它的寄存器信息保存到内存,查看线程列表并决定接下来运行哪一个线程,再从内存恢复线程的注册表信息, 最后继续执行选中的线程。
  • Go运行时包含一个自己的调度器,这个调度器使用一个称为m:n调度的技术(因为它可以复用/调度m个goroutine 到n个OS线程)。
    • Go调度器不是由硬件时钟来定期触发的,而是由特定的Go语言结构来触发的。比如当一个goroutine 调用time. Sleep或被通道阻塞或对互斥量操作时,调度器就会将这个goroutine 设为休眠模式, 并运行其他 goroutine直到前一个可重新唤醒为止
    • 因为不需要切换到内核语境,所以调用一个goroutine 比调度一个线程成本低很多。

goroutine没有标识

在大部分多线程操作的编程语言里,当前线程有一个独特标识,是一个整数或者是一个指针。但是goroutine没有

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值