这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记
Goroutines 和 Channels
并发程序指同时进行多个任务的程序
go 中的并发程序可以使用两种手段来实现,本章的 goroutines 和 channel 支持 顺序通信进程(CSP)
CSP 是一种现代的并发编程模型,在这种编程模型中值会在不同的运行实例(goroutines)中传递
Goroutines
在 go 中,每一个并发的执行单元叫做一个 goroutine
当一个程序启动时,其主函数即在一个单独的 goroutines 中运行,称为 main goroutine
新的 goroutine 会用 go 语句创建,go 语句是一个普通的函数或方法调用前加上关键字 go
go 语句会使其语句中的函数在一个新创建的 goroutine 中运行
f()
go f()
Demo:
计算斐波那契数列的第 45 个元素值:
改进之处是用 go 语句创建了一个打印动画
func main() {
go spinner(100 * time.Millisecond)
const n = 45
finN := fib(n)
fmt.Printf("\rFibonacci(%d) = %d\n", n, finN)
}
func spinner(delay time.Duration) {
for {
for _, r := range `-|/` {
fmt.Printf("\r%c", r)
time.Sleep(delay)
}
}
}
func fib(x int) int {
if x < 2 {
return x
}
return fib(x-1) + fib(x-2)
}
动画显示了几秒之后,fib(45) 成功返回。并退出程序,因为主函数返回时,所有的 goroutine 都会被直接打断
spinner 和 fib 是同时执行的,因为 spinner 是一个死循环,如果串行执行,fib 就不会执行了
案例 并发的Clock服务
实现一个每隔一秒将当前时间写到客户端的服务器:
package main
import (
"io"
"log"
"net"
"time"
)
func main() {
listener, err := net.Listen("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
handleConn(conn)
}
}
func handleConn(c net.Conn) {
defer c.Close()
for {
_, err := io.WriteString(c, time.Now().Format("15:04:05\n"))
if err != nil {
return
}
time.Sleep(1 * time.Second)
}
}
如果有多个客户端同时对该服务器发起 tcp 连接,那么只有等第一个服务器退出连接,第二个服务器才能得到相应
我们只需要在 handleConn 函数的前面加上 go 关键字,就可以让每一次 handleConn 的调用进入一个独立的 goroutine
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
go handleConn(conn)
}
这样多个客户端就可以同时接收到时间了
案例 并发的Echo服务
创建一个 echo 服务器
服务器:处理来自 localhost:8000 的 tcp 请求,从 conn 中读取内容 并打印大写,不变,小写三种
func main() {
listener, err := net.Listen("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
for {
conn, err := listener.Accept()
if err != nil {
log.Print(err)
continue
}
go handleConn(conn)
}
}
func echo(c net.Conn, shout string, delay time.Duration) {
fmt.Fprintln(c, "\t", strings.ToUpper(shout))
time.Sleep(delay)
fmt.Fprintln(c, "\t", shout)
time.Sleep(delay)
fmt.Fprintln(c, "\t", strings.ToLower(shout))
}
func handleConn(c net.Conn) {
input := bufio.NewScanner(c)
for input.Scan() {
go echo(c, input.Text(), 1*time.Second)
}
}
netcat 连接:
用于创建连接,并在命令行输入内容给服务器
func main() {
conn, err := net.Dial("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
defer conn.Close()
go mustCopy(os.Stdout, conn)
mustCopy(conn, os.Stdin)
}
func mustCopy(dst io.Writer, src io.Reader) {
if _, err := io.Copy(dst, src); err != nil {
log.Fatal(err)
}
}
Channels
channels 是 goroutine 之间的通信机制,它可以让一个 goroutine 给另一个 goroutine 发送消息
每个 channel 都有一个特殊的类型,也就是 channel 可以发送的数据类型,比如一个可以发送 int 类型数据的 channel 一般写为 chan int
使用内置的 make 函数可以创建一个 channel
ch := make(chan int)
和 map 类似,channel 也对应一个 make 创建的底层数据结构的引用
一个 channel 有发送和接收两个主要操作,都是通信行为,这个行为是相对 goroutine 而言的,即从 goroutine 发送和 goroutine 接收
一个发送语句将一个值从一个 goroutine 通过 channel 发送到另一个执行接收操作的 goroutine
发送和接收两个操作都使用 <- 运算符,在发送语句中, <- 用于分割 channel 和要发送的值,在接收语句中, <- 写在 channel 对象之前
ch <- x // a send statement
x = <- ch // a receive expression
<-ch // a receive statement, result is discarded
channel 也支持 close 操作,关闭之后的 channel 不能对其发送数据,但是可以接收 channel 关闭之前收到的数据
close(ch)
以最简单方式调用 make 函数创建的是一个无缓存的 channel,如果指定第二个整形参数,就可以确定 channel 的容量
ch = make(chan int) // 无缓存
ch = make(chan int, 0) // 无缓存
ch = make(chan int, 3) // 有缓存
不带缓存的 Channels
一个基于无缓存 Channels 的发送操作将导致发送者的 goroutine 阻塞,直到另一个 goroutine 在相同的 Channels 上执行接收操作
当发送的值通过 Channels 成功传输之后,两个 goroutine 可以继续执行后面的语句。反之,如果接收操作先发生,那么接收者 goroutine 也将阻塞,直到有另一个 goroutine 在相同的 Channels 上执行发送操作
基于无缓存 Channels 的发送和接收操作将导致两个 goroutine 做一次同步操作,当通过一个无缓存 Channels 发送数据时,接收者收到数据发生在唤醒发送者 goroutine 之前
Demo:
让 主goroutine 等待 后台goroutine 工作完成之后再退出
func main(){
conn, err := net.Dial("tcp", "localhost:8000")
if err != nil{
log.Fatal(err)
}
done := make(chan struct{})
go func(){
io.Copy(os.Stdout, conn)
log.Println("done")
// 往通道发送数据
done <- struct{}{}
}()
mustCopy(conn, os.Stdin)
conn.Close()
// 如果后台的 goroutine 没有完成 copy并打印 done, 主goroutine 就会阻塞在这里
<-done
}
串联的 Channels(Pipeline)
channels 可以将多个 goroutine 连接在一起,一个 Channel 的输出作为下一个 Channel 的输入,这种串联的 Channels 就是 管道
Demo:
第一个 goroutine从1开始,加进 通道naturals
第二个 goroutine 将数据从通道naturals中取出,然后做平方 加进通道 squares
最后在 主goroutine 中直接打印 squares通道中的 结果
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
// Counter
go func() {
for x := 1; ; x++ {
naturals <- x
}
}()
go func() {
for {
x := <-naturals
squares <- x * x
}
}()
for {
fmt.Println(<-squares)
}
}
如果我们只希望发送有限的数列,就需要用到 close 函数
close(naturals)
当一个被关闭的 channel 中已发送的数据都被成功接收后,后续的接收操作将不再阻塞,它们会立即返回一个零值。关闭上面例子中的 naturals 变量对应的 channel 并不能终止循环,square 依然会收到一个永无休止的零值序列
如果通道的接收变成两个结果,第二个结果是一个 布尔值 ok ,true 表示成功从 channels 接收到值,false表示 channels 已经被关闭并且里面没有值可以接收。
故代码可以修改成:
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
// Counter
go func() {
for x := 1; x < 20; x++ {
naturals <- x
}
close(naturals)
}()
go func() {
for {
x, ok := <-naturals
if !ok {
break
}
squares <- x * x
}
close(squares)
}()
for {
fmt.Println(<-squares)
}
}
但是这样写显得很笨拙,故 go 语言支持 range循环 读取 channels
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
// Counter
go func() {
for x := 1; x < 20; x++ {
naturals <- x
}
close(naturals)
}()
go func() {
for x := range naturals {
squares <- x * x
}
close(squares)
}()
for x := range squares {
fmt.Println(x)
}
}
单方向的Channel
前面的例子中使用了三个 goroutine,然后使用两个 channel 来连接它们,它们都是 main 函数的局部变量
将三个 goroutine 拆分为以下三个函数是自然的想法:
func counter(out chan int)
func squarer(out, in chan int)
func printer(in chan int)
其中计算平方的 squarer 函数在两个 串联Channels 的中间,因此拥有两个channel类型的参数,一个用于输入一个用于输出
当一个 channel 作为一个函数参数时,它一般总是被专门用于只发送或者只接收
故 go 中提供了单方向的 channel 类型,分别用于只发送或者只接收的 channel
类型 chan<- int 表示一个只发送 int 的 channel
类型 <-chan int 表示一个只接收 int 的 channel
package main
import "fmt"
func main() {
naturals := make(chan int)
squares := make(chan int)
// naturals 作为接收通道
go counter(naturals)
// naturals作为发送通道,squares作为接收通道
go square(naturals, squares)
// squares 作为发送通道
printer(squares)
}
func counter(out chan<- int) {
for i := 1; i < 20; i++ {
out <- i
}
close(out)
}
func square(in <-chan int, out chan<- int) {
for i := range in {
out <- i * i
}
close(out)
}
func printer(in <-chan int) {
for x := range in {
fmt.Println(x)
}
}
main 函数中调用 counter(naturals) 将导致 chan int 类型隐式转换为 chan <- int 类型
但是不能反向转换
带缓存的Channels
带缓存的 Channel 内部持有一个元素队列,队列的最大容量是 make 函数的第二个参数
// 最大容量为 3
ch = make(chan string, 3)
向缓存 Channel 的发送操作就是向内部缓存队列的尾部插入元素,接收操作则是从队列的头部删除元素
如果内部缓存队列是满的,那么发送操作将阻塞直到因另一个 goroutine 执行接收操作而是放了新的队列空间
我们可以在无阻塞的情况下向新创建的 channel 连续发送3个值
ch <- "A"
ch <- "B"
ch <- "C"
如果再发送值的话就会阻塞
在某些特殊的情况下,程序需要直到 channel 内部缓存的容量,可以用内置的 cap 函数获取
fmt.Println(cap(ch)) // 3
对于 len 函数,如果传入的是 channel,那么将返回 channel 内部缓存队列中有效元素的个数
fmt.Println(len(ch))
Demo:
向三个镜像站点发出请求,但是只接收最快的响应
func mirroredQuery() string {
responses := make(chan string, 3)
go func() {responses <- request("asia.gopl.io")}()
go func() {responses <- request("europe.gopl.io")}()
go func() {responses <- request("americas.gopl.io")}()
return <-responses
}
如果我们使用了 无缓存的 channel ,那么慢的两个 goroutine 就会因为没有人接收而被永远卡住,这种情况称为 goroutine 泄露
并发的循环
本节中,我们会探索一些用来在并行时循环迭代的常见并发模型
下面的程序会循环迭代一些图片文件名,并为每一张图片生成一个缩略图:
func makeThumbnails(filenames []string){
for_, f := range filenames{
if _, err := thumbnail.ImageFile(f); err != nil{
log.Println(err)
}
}
}
因为我们处理文件的顺序无关紧要,因为每一个图片的拉伸操作和其他图片的处理操作都是独立的,所以可以轻易实现并行处理
func makeThumbnails2(filenames []string){
for_, f := range filenames{
go thumbnail.ImageFile(f)
}
}
但是这个版本会有问题,因为 makeThumbnails 在它还没有完成工作之前就返回了,它的工作紧紧只是启动所有的 goroutine,没有等这些 goroutine 执行完毕,它就返回了
虽然没有什么直接的办法等待 goroutine 完成,但是我们可以改变 goroutine 里的代码让其能够将完成情况报告给外部的 goroutine 知晓,即向一个共享的 channel 中发送事件
func makeThumbnails3(filenames []string){
ch := make(chan struct{})
for _, f := range filenames{
go func(f string){
thumbnail.ImageFile(f)
ch <- struct{}{}
}(f)
}
for range filenames{
<-ch
}
}
如果我们想要从每一个 worker goroutine 往 主goroutine 中返回错误值,就把 Channel 声明称 error 类型即可
func makeThumbnails4(filenames []string) error {
errors := make(chan error)
for _, f := range filenames{
go func(f string){
_, err := thumbnail.ImageFile(f)
errors <- err
}(f)
}
for range filenames{
if(err := <- errors; err != nil){
return err
}
}
return nil
}
这个程序有一个bug,当它遇到第一个 非nil 的error 时会直接将 error 返回到调用方,使得没有一个 goroutine 去排空 errors channel
这样剩下的 work goroutine 在向这个 channel 发送值时,都会永远阻塞。(相当于是消费者不存在了的情况)
可以选择一个具有合适大小的 buffered channel ,这样这些 worker goroutine 向 channel 中发送错误时就不会阻塞
func makeThumbnails5(filenames []string)(thumbfiles []string, err error) {
type item struct{
thumbfile string
err error
}
ch := make(chan item, len(filenames))
for _, f := range filenames{
go func(f string){
var it item
it.thumbfile, it.err := thumbnail.ImageFile(f)
ch <- it
}(f)
}
for range filename{
it := <-ch
if(it.err != nil){
return nil, err
}
thumbfiles = append(thumbfiles, it.thumbfile)
}
return thumbfiles, nil
}
最后一个版本,反悔了文件的大小总计数,而且传入文件名是一个 channel
为了知道最后一个 goroutine 什么时候结束,我们需要一个递增的计数器,在每一个 goroutine 启动时加一,在 goroutine 退出时减一
func makeThumbnails6(filename <- chan string) int64{
sizes := make(chan int64)
var wg sync.WaitGroup
for f := range filenames{
wg.Add(1)
go func(f string){
defer wg.Done()
thumb, err := thumbnail.ImageFile(f)
if err != nil{
log.Println(err)
return
}
info, _ := os.Stat(thumb)
sizes <- info.Size()
}(f)
}
go func(){
wg.Wait()
close(sizes)
}()
var total int64
for size := range sizes{
total += size
}
return total
}
基于共享变量的并发
上一章介绍了 goroutine 和 channel 这样直接而且自然的方式来实现并发
然而这样做我们实际上回避了在写并发代码时必须处理的一些重要而且细微的问题
竞争条件
如果一个函数在线性程序中可以正确工作,在并发的情况下,这个函数依然可以正确地工作的话,那么我们就说这个函数是并发安全的
竞争条件是指程序在多个 goroutine 交叉执行时,没有给出正确的结果
我们来看一个简单的银行账户程序:
package bank
var balance int
func Deposit(amount int) {balance = balance + amount}
func Balance() int {return balance}
如果我们并发地而不是顺序地调用这些函数的话,Balance 就再也没办法保证结果正确了:
// P1
go func(){
bank.Deposit(200) // A1
fmt.Println("=", bank.Banlance()) // A2
}()
// P2
go bank.Deposit(100) // A3
P1 存了200,然后检查余额,同时P2存了100
因为 P1 和 P2 是并发执行的,如果 P1 在往回写数据的时应该是 200,但是 P2 先往结果中写入了 100 ,故最终结果会是 200,而不是300
这个程序包含了一个特定的竞争条件,叫做数据竞争, 只要有两个 goroutine 并发访问同一变量,且至少其中一个是写操作的时候就会发生数据竞争
再看一个更加夸张的错误:
var x []int
go func() {x = make([]int, 10)}()
go func() {x = make([]int, 1000000)}()
x[999999] = 1
上述代码中 x 的值是未定义的,可能是 nil,可能是一个长度为 10 的slice,可能是长度为 1000000 的slice,甚至有可能指针由第一个make而来,长度从第二个make而来,x就变成了混合体,一个自称长度有 1000000 却只有 10 的slice
所以并发并不是简单的语句交叉执行
有三种方式可以避免数据竞争:
- 不去写变量
虽然不写变量是最简单的办法,但是在特定场合下必须要写
- 避免多个 goroutine 访问变量
go中有一句口头禅:不要使用共享数据来通信,使用通信来共享数据
一个提供对一个指定的变量通过 channel 来请求的 goroutine 叫做这个变量的 monitor goroutine
Demo:
package bank
var deposits = make(chan int)
var balances = make(chan int)
func Deposit(amount int) { deposits <- amount }
func Balance() int { return <-balances }
func teller() {
var balance int
for {
select {
case amount := <-deposits:
balance += amount
case balances <- balance:
}
}
}
func init() {
go teller()
}
即使当一个变量无法在其整个生命周期内被绑定到一个独立的 goroutine ,绑定依然是并发问题的一个解决方案。
在一条流水线上的 goroutine 之间共享变量是很普遍的行为,在这两者间会通过 channel 来传输地址信息
如果流水线的每一个阶段都能够避免在变量传送到下一阶段再去访问它,那么对这个变量的所有访问就是线性的
Demo:
在下面的例子中,Cakes会被严格地访问,先是 baker goroutine,然后是 icer goroutine
type Cake struct {state string}
func baker(cooked chan <- *Cake){
for {
cake := new(Cake)
cake.state = "cooked"
cooked <- cake
}
}
func icer(iced chan<- *Cake, cooked <-chan *Cake){
for cake := range cooked{
cake.state = "iced"
iced <- cake
}
}
- 允许很多 gorouine 访问变量,但是同一时刻只有一个 goroutine 在访问
这种方法称为 "互斥"
sync.Mutex 互斥锁
我们可以用一个容量只有 1 的 channel 来保证最多只有一个 goroutine 在同一时刻访问一个变量
Demo:
var (
sema = make(chan struct{}, 1)
balance int
)
func Deposit(amount int){
// 如果通道满了 阻塞到通道空为止
sema <- struct{}{}
balance = balance + amount
<- sema
}
func Balance() int{
// 同理
sema <- struct{}{}
b := balance
<-sema
return b
}
这种互斥很实用,在 sync 包中的 Mutex 类型直接支持
var (
mu sync.Mutex
balance int
)
func Deposit(amount int){
mu.Lock()
balance = balance + amount
mu.Unlock()
}
func Balance() int{
mu.Lock()
b := balance
mu.Unlock()
return b
}
Mutex 就相当于一个互斥锁,在 Lock 和 Unlock 之间的代码中的内容被称为 临界区
goroutine 在结束后必须释放锁,无论执行是否成功,故最好使用 defer 来关闭锁
func Balance() int{
mu.Lock()
// 在return 之后执行
defer mu.Unlock()
return balance
}
如果给 bank 案例加上取款操作:
func Withdraw(amount int) bool {
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false
}
return true
}
如果很多取款操作一起执行时,余额会瞬间减少到 0 以下,那么这些取款操作全都会失败
因为取款操作不是一个原子操作,它包含三个步骤,每一步都需要去获取并释放互斥锁,而不会锁上整个取款流程
理想情况下,取款操作应该在整个操作中只获得一次互斥锁,但是下面的操作是错误的
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false
}
return true
}
因为 Deposit 会调用 mu.Lock 去第二次获取互斥锁,而 go 没有重入锁,即无法对一个上锁的锁再上锁
一个通用的解决方案是将一个函数分离为多个函数,一个函数不用获取锁(假定这个函数一定运行在 mutex 中),一个函数需要获取锁
var (
mu sync.Mutex{}
balance int
)
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
// 注意这里是 小写的 d 和 b
deposit(-amount)
if balance() < 0 {
deposit(amount)
return false
}
return true
}
// 上锁版
func Deposit(amount int){
mu.Lock()
balance = balance + amount
mu.Unlock()
}
// 不上锁版
func deposit(amount int){
balance += amount
}
func Balance() int{
mu.Lock()
defer mu.Unlock()
return balance
}
func balance() int{
return balance
}
sync.RWMutex 读写锁
由于 Balance 函数只需要读取变量的状态,所以我们同时让多个 Balance 调用并发运行事实上是安全的,只要在运行的时候没有存款或取款操作就行
故我们需要一种特殊的锁,允许多个只读操作并行执行,但是写操作完全互斥,这种锁叫 “多读单写”锁,在 go 中是 sync.RWMutex
var mu sync.RWMutex
var balance
func Balance() int{
mu.RLock()
defer mu.RUnlock()
return balance
}
在改进之后,Balance 函数现在调用 RLock 和 RUnlock 方法来获取和释放一个读取锁,Deposit 没有变化,调用 Lock 和 Ulock 方法获取和释放一个写锁
RLock 只能在临界区共享变量没有任何写入操作时使用
sync.WaitGroup
go 中除了可以用通道和互斥锁进行两个并发程序间的同步外,还可以使用等待组进行多个任务的同步,等待组可以保证在并发环境下完成指定数量的任务
在 sync.WaitGroup 类型中,维护着一个计数,默认值为0
等待组有如下几个方法:
func (wg *WaitGroup) Add(delta int) 等待组的计数器加上 delta,可以用于初始化
func (wg *WaitGroup) Done() 等待组的计数器减 1
func (wg *WaitGroup) Wait() 等待组计数器不等于 0 时阻塞直到为 0
Demo:
package main
import (
"fmt"
"sync"
)
// 创建等待组
var wg sync.WaitGroup
func ManyGoWait() {
for i := 0; i < 5; i++ {
// 每次循环加一
wg.Add(1)
go func(j int) {
// goroutine 结束时 减一
defer wg.Done()
hello(j)
}(i)
}
// 阻塞
wg.Wait()
}
func hello(x int) {
fmt.Println("hello goroutine ", x)
}
func main() {
ManyGoWait()
}
内存同步
同步不仅仅是一些 goroutine 执行顺序的问题,同样也会涉及到内存的问题
在现代计算机中可能会有一堆处理器,每一个都会有其本地缓存,一般对于内存的写入会在每一个处理器中缓冲,并在必要时一起 flush 到主存
这种情况下,这些数据可能会以当初 goroutine 写入顺序不同的顺序被提交到主存。
考虑一下下面的代码的可能输出:
var x, y int
go func(){
x = 1 // A1
fmt.Print("y:", y, " ") // A2
}()
go func(){
y = 1 // B1
fmt.Print("x:", x, " ") // B2
}()
因为两个 goroutine 是并发执行的,那么可能会打印出下面的结果:
y:0 x:1 // A1 A2 B1 B2
x:0 y:1
x:1 y:1 // A1 B1 A2 B2
y:1 x:1
但是可能会出现意想不到的结果:
x:0 y:0
y:0 x:0
根据所使用的的编译器,CPU,或者其他很多影响因子,这两种情况也是有可能发生的
因为赋值和打印指向不同的变量,编译器可能会断定两条语句的顺序不会影响执行结果,并且会交换两个语句的执行顺序
如果两个 goroutine 在不同的 CPU 上执行,每一个核心都有自己的缓存,这样一个 goroutine 的写入对于其他 goroutine 的 Print,在主存同步之前就是不可见的了
所以所有的并发问题都可以用一致的、简单的既定的模式来规避,所以可能的话,将变量限定在 goroutine 内部,如果是多个 goroutine 都需要访问的变量,使用互斥条件来访问
sync.Once 初始化
如果初始化成本比较大的话,那么将初始化延迟到需要的时候再去做就是一个比较好的选择。
比如下面这个 map 变量:
var icons map[string]image.Image
func loadIcons(){
icons = map[string]image.Image{
"spades.png": loadIcon("spades.png"),
"hearts.png": loadIcon("hearts.png"),
"diamonds.png": loadIcon("diamonds.png"),
"clubs.png": loadIcon("clubs.png"),
}
}
func Icon(name string) image.Image{
if icons == nil{
loadIcons()
}
return icons[name]
}
如果一个变量只被一个单独的 goroutine 所访问的话,我们可以使用上面的这种模板,但这种模板在 Icon 被 并发 调用时并不安全
Icon 函数首先测试 icons 是否为空,然后 load 这些 icons,当第一个 goroutine 在忙着 load 数据的时候,第二个 goroutine 也进来了,发现 icons == nil,同样也会调用 loadIcons 函数
但是 编译器和CPU 可以随意更改访问内存的指令顺序,只要保证每一个 goroutine 自己的执行顺序一致
有可能是下面的代码这样:
func loadIcons(){
icons = make(map[string]image.Image)
icons["spades.png"] = loadIcon("spades.png")
//....
}
因此,一个 goroutine 在检查 icons 是非空时,也并不能就假设这个变量的初始化流程已经走完了,有可能初始化了一半,此时已经是非空了
简单而又正确地保证所有 goroutine 能够观察到 loadcons 效果的方式就是用一个 mutex 来同步检查
var mu sync.Mutex
var icons map[string]image.Image
func Icon(name string) image.Image{
mu.Lock()
defer mu.Unlock()
if icons == nil{
loadIcons()
}
return icons[name]
}
改进的方法就是引入允许多读的锁:
var mu sync.RWMutex
var icons map[string]image.Image
func Icon(name string) image.Image{
mu.RLock()
if icons != nil{
icon := icons[name]
mu.RUnlock()
return icon
}
mu.RUnlock()
mu.Lock()
if icons == nil{
loadIcons()
}
icon := icons[name]
mu.Unlock()
return icon
}
上面的代码有两个临界区,goroutine 首先获取一个读锁,查询 map ,然后释放锁,如果条目被找到了,那么直接返回
如果没有找到,那 goroutine 会获取一个写锁,检查 icons 是否是 nil ,以防其他 goroutine 对其进行初始化
上面的模板使我们的程序能够更好的并发,但是有一点太复杂且容易出错
所以 sync 包为我们提供了一个专门的方案来解决这种一次性初始化问题:sync.Once
概念上来讲,一次性的初始化需要一个互斥量 mutex 和一个 bool变量 来记录初始化是否已经完成
var loadIconsOnce sync.Once
var icons map[string]image.Image
func Icon(name string) image.Image{
loadIconsOnce.Do(loadIcons)
return icons[name]
}
每一次 Do 的调用都会锁定 mutex,并检查 bool变量
在第一次调用时,bool变量的值是 false ,Do 会调用 loadIcons 并将 bool变量 设置为 true
随后的调用什么都不会做,但是 mutex 同步会保证 loadIcons 对内存产生的效果能够对所有 goroutine 可见