使用共享变量实现并发
竞态
学过数据库的并发控制可能会对这部分有些熟悉。
数据竞态发生在两个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 build
、go run
、go 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没有