likes
comments
collection
share

go内存模型(二)

作者站长头像
站长
· 阅读数 12

这是我参与2022首次更文挑战的第11天,活动详情查看:2022首次更文挑战

原文链接:golang.org/ref/mem

Channel communication(管道通信)

管道通信是goroutines之间同步的主要方法。每一个发送到一个特别的管道会被匹配给一个相应的不同的goroutine从管道中接收。

一个管道的发送 happens before管道完成接收。

下面这个程序:保证会运行"hello, world"。

var c = make(chan int, 10)
var a string

func f() {
	a = "hello,world"
	c <- 0
}

func main() {
	go f()
	<-c
	print(a)
}

对a的写 happens before 于c的发送,c的发送又happens before于c完成接收,c的接收也happens before于print函数。

一个channel的关闭happens before于接收返回0值,因为channel被关闭了。

在前面的例子中,把c<-0替换成 close©产出的程序拥有相同的保证行为。

从一个非缓冲channel的接收 happens before于channel完成时的发送。

这一个程序(跟上面一样,但是发送和接收交换,并且使用了一个非缓冲的channel):

var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}

func main() {
	go f()
	c <- 0
	print(a)
}

也保证会打印"hello, world"。对a的写 happens before于对c的接收,c的接收happens before于相应的c的完成时的发送,c的完成时的发送happens before于print。

如果channel是缓冲的(e.g. c = make(chan int, 1)),这样的话程序将不会保证打印"hello, world"(可能会打印空的字符串或者其他东西。)

一个容量为c的channel的第k个接收,happens before于第k+c个channel完成时的发送。

这个规则概括了缓冲channel的前置规则。它允许一个计数信号量通过一个缓冲channel来建模:channel中的项目数量相当于活跃使用的数量,channel的容量相当于同时使用的最大数量,发送一个项目获得信号量,然后接受一个项目释放这个信号量。这是一个用于限制并发的惯用语。

这个程序为work list中的每一个元素开启一个goroutine,但是这些goroutines使用limit channel来协调,确保最多同时只有三个运行中的work函数。

var limit = make(chan int, 3)

func main() {
	for _, w := range work {
		go func(w func()) {
			limit <- 1
			w()
			<-limit
		}(w)
	}
	select{}
}

Locks

sync包实现了两个锁数据类型,sync.Mutex和sync.RWMutex.

对任何sync.Mutex或者sync.RWMutex的变量l来说,n < m,调用n的l.Unlock() happens before m 的l.Lock()返回。

var l sync.Mutex
var a string

func f() {
	a = "hello, world"
	l.Unlock()
}

func main() {
	l.Lock()
	go f()
	l.Lock()
	print(a)
}

这个程序保证打印"hello, world"。第一个调用l.Unlock() happens before 于第二个l.Lock()(main中)的返回,这个返回happens before 于print函数。

对任何调用l.RLock() 在一个sync.RWMutex变量l,有一个n,使得l.RLock在调用n到l.Unlock之后发生(返回),而匹配的l.RUnlock在调用n+1到l.Lock之前发生。

Once sync包提供了一个安全机制来初始化,在有多个goroutines时通过使用Once类型。多线程可以为一个指定的f函数执行once.Do(f),但是只要有一个执行f()函数,其他的调用会被阻塞直到f() 已经返回。

从once.Do(f)的一个简单f() 的调用 happens(返回) before 任何调用once.Do(f)的返回。

var a string
var once sync.Once

func setup() {
	a = "hello, world"
}

func doprint() {
	once.Do(setup)
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

在这个程序中,调用twoprint方法只会执行一个setup。setup 函数将会甚至在print的调用之前完成。结果是"hello, world"将会被打印两次。

Incorrect synchronization 注意,一个读r可能会观察到被一个跟r并发发生的写w 写过的值。即便这发生了,也不意味着读 happening after r 会观察到happen before w的写。

var a, b int

func f() {
	a = 1
	b = 2
}

func g() {
	print(b)
	print(a)
}

func main() {
	go f()
	g()
}

这个程序将会打印2和0

这个事实让一些通用的惯用语失效了。

双重校验锁是一种为了避免同步开销的尝试。例如,这个twoprint可能被不正确地写成:

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func doprint() {
	if !done {
		once.Do(setup)
	}
	print(a)
}

func twoprint() {
	go doprint()
	go doprint()
}

但是没有办法保证的是,在doprint,观察到对done的写意味着观察到对a的写。这个版本会打印一个空的字符串而不是"hello, world".

另一个不正确的惯用是忙等一个值,就像这里面:

var a string
var done bool

func setup() {
	a = "hello, world"
	done = true
}

func main() {
	go setup()
	for !done {
	}
	print(a)
}

就像上面,没有保证的是,在main,观察到对done的写意味着观察到对a的写,因此这个程序也可以打印一个空字符串。更惨的是, 没法保证对done的写会曾被main观察到,因为没有同步时间在两个线程之间。main中的循环无法保证完成。

在这个主题上还有更精妙的变形,例如这个程序。

type T struct {
	msg string
}

var g *T

func setup() {
	t := new(T)
	t.msg = "hello, world"
	g = t
}

func main() {
	go setup()
	for g == nil {
	}
	print(g.msg)
}

即使main观察到g!=nil并且推出勋魂,也没法保证它将会观察到g.msg的初始化。

在这些所有的例子中,解决方案是相同的:使用明确的同步机制。