Go---Go并发编程(详细)


Goroutine(协程)

在java/c++中我们要实现并发编程的时候,我们通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智。

为此Go语言提供了 goroutine 这样的机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

使用goroutine

`单个 goroutine`
func main() {
    
    
   //hello()
   go hello()
   // 有时 hello() 并不执行,因为程序会为main函数创建一个默认的goroutine,
   // 当main里的语句执行完goroutine也就结束了,没有 go hello() 执行的时间
   fmt.Println("main hello")
   // 为了确保go hello() 的goroutine能够执行可以延缓程序结束时间
   time.Sleep(time.Second)
}

func hello() {
    
    
   fmt.Println("hello")
}

单个 goroutine 可以通过时间延后来使这个 goroutine 被完全执行,但是当 goroutine 多到上百上千或更多时在使用 time.Sleep() 显然就没办法确定给多少时间来让 goroutine 被完全执行了,给多了影响程序效率,给少了有的 goroutine 又不会执行影响程序结果,这时候我们就要用到另一个东西那就是 sync.WaitGroup。

WaitGroup对象内部有个计时器, 最初从0 开始, 他有3个方法 Add() , Done(), Wait()用来控制计数器的数量。 Add(n) 把计数器设置成n, Done() 每次把计数器-1, wait() 会阻塞代码的运行, 直到计数器的值减为0。将 goroutine 所剩数量与WaitGroup结合可以解决上述问题

var wg sync.WaitGroup
func main() {
    
    
   for i := 0; i < 10; i++ {
    
    
      // 每添加一个 goroutine wg + 1
      wg.Add(1)
      go sayNum(i)
   }
   // 等待 wg = 0 在执行后面的代码
   wg.Wait()
   fmt.Println("end")
}

func sayNum(i int) {
    
    
   // 当一个 goroutine 结束就 - 1
   defer wg.Done()
   fmt.Println(i)
}

当我们运行这个代码就会发现每次的输出都不同,这是因为这10个 goroutine 的执行是并发的,而调度却是随机的

goroutine与线程

goroutine的栈是可增长的

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。

goroutine调度

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

  • 1.G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
  • 2.P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
  • 3.M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;

P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。

P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。

单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PKmOgZxa-1637983441094)(image-20211124133622706.png)]

Goroutine池使用实例

作用:Goroutine池可以有效控制goroutine数量,防止goroutine数量暴涨

需求:

  • 计算一个数字的各个位数之和,例如数字123,结果为1+2+3=6
  • 随机生成数字进行计算,一直生成,一直计算
package main

import (
    "fmt"
    "math/rand"
)

type Job struct {
    
    
    // id
    Id int
    // 需要计算的随机数
    RandNum int
}

type Result struct {
    
    
    // 这里必须传对象实例
    job *Job
    // 求和
    sum int
}

func main() {
    
    
    // 需要2个管道
    // 1.job管道
    jobChan := make(chan *Job, 128)
    // 2.结果管道
    resultChan := make(chan *Result, 128)
    // 3.创建工作池
    createPool(64, jobChan, resultChan)
    // 4.开个打印的协程
    go func(resultChan chan *Result) {
    
    
        // 遍历结果管道打印
        for result := range resultChan {
    
    
            fmt.Printf("job id:%v randnum:%v result:%d\n", result.job.Id,
                result.job.RandNum, result.sum)
        }
    }(resultChan)
    var id int
    // 循环创建job,输入到管道
    for {
    
    
        id++
        // 生成随机数
        r_num := rand.Int()
        job := &Job{
    
    
            Id:      id,
            RandNum: r_num,
        }
        jobChan <- job
    }
}

// 创建工作池
// 参数1:开几个协程
func createPool(num int, jobChan chan *Job, resultChan chan *Result) {
    
    
    // 根据开协程个数,去跑运行
    for i := 0; i < num; i++ {
    
    
        go func(jobChan chan *Job, resultChan chan *Result) {
    
    
            // 执行运算
            // 遍历job管道所有数据,进行相加
            for job := range jobChan {
    
    
                // 随机数接过来
                r_num := job.RandNum
                // 随机数每一位相加
                // 定义返回值
                var sum int
                for r_num != 0 {
    
    
                    tmp := r_num % 10
                    sum += tmp
                    r_num /= 10
                }
                // 想要的结果是Result
                r := &Result{
    
    
                    job: job,
                    sum: sum,
                }
                //运算结果扔到管道
                resultChan <- r
            }
        }(jobChan, resultChan)
    }
}

部分结果(控制台打印):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x1cOXqLo-1637983441095)(image-20211124211848787.png)]

runtime包

runtime.Gosched()

退出当前的 goroutine ,为其他 goroutine 腾出执行空间,最后再执行被退出的 goroutine

中文文档给了我们一个特别有意思的比喻:

(大概意思就是本来计划的好好的周末出去烧烤,但是你妈让你去相亲,两种情况第一就是你相亲速度非常快,见面就黄不耽误你继续烧烤,第二种情况就是你相亲速度特别慢,见面就是你侬我侬的,耽误了烧烤,但是还馋就是耽误了烧烤你还得去烧烤)

package main

import (
	"fmt"
	"runtime"
)

func main() {
    
    
	// 让所有协程在一个核上执行
	runtime.GOMAXPROCS(1)
	go func(s string) {
    
    
		for i := 0; i < 2; i++ {
    
    
			fmt.Println(s,i)
		}
	}("协程运行中:")
	// 主协程
	for i := 0; i < 2; i++ {
    
    
		fmt.Println("hello")
        // 停一下,再次分配任务
		runtime.Gosched()
		fmt.Println("world",i)
	}
}

runtime.Goexit()

退出当前的 goroutine ,以后也不会执行

(一边烧烤一边相亲,突然发现相亲对象太丑影响烧烤,果断让她滚蛋,然后也就没有然后了)

package main

import (
    "fmt"
    "runtime"
)

func main() {
    
    
    go func() {
    
    
        defer fmt.Println("A.defer")
        func() {
    
    
            defer fmt.Println("B.defer")
            // 结束协程
            runtime.Goexit()
            defer fmt.Println("C.defer")
            fmt.Println("B")
        }()
        fmt.Println("A")
    }()
    for {
    
    
    }
}

runtime.GOMAXPROCS

Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。

Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。

运行下面两个代码实例有助于理解核的数量对协程执行的影响

func a() {
    
    
    for i := 1; i < 10; i++ {
    
    
        fmt.Println("A:", i)
    }
}

func b() {
    
    
    for i := 1; i < 10; i++ {
    
    
        fmt.Println("B:", i)
    }
}

func main() {
    
    
	// 设定为单核执行
    runtime.GOMAXPROCS(1)
    go a()
    go b()
    time.Sleep(time.Second)
}  
func a() {
    
    
    for i := 1; i < 10; i++ {
    
    
        fmt.Println("A:", i)
    }
}

func b() {
    
    
    for i := 1; i < 10; i++ {
    
    
        fmt.Println("B:", i)
    }
}

func main() {
    
    
    // 设定为双核执行
    runtime.GOMAXPROCS(2)
    go a()
    go b()
    time.Sleep(time.Second)
}  

操作系统线程和goroutine的关系

  • 1.一个操作系统线程对应用户态多个goroutine。
  • 2.go程序可以同时使用多个操作系统线程。
  • 3.goroutine和OS线程是多对多的关系,即m:n。

channel

我们设置函数的意义就是为了在特定的输入下获取到特定的输出,如果只是让函数一味的并发而不进行值的传递,那么这个并发就是没有意义的。

如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

channel是个特殊的类型,通道类似传送带或是队列,遵循先进先出(First In First Out)的原则。

通道的声明和初始化

`每个通道都是特定类型的,在声明时需要指明通道里传输的元素类型`
var 变量 chan 元素类型
`例`
var ch chan int
println(ch)			// 0x0 	 空值为 nil

声明的通道后需要使用make函数初始化之后才能使用。初始化后的通道空值为一个十六进制的地址

`在不进行初始化的情况下使用通道会报 deadlock`
`其中缓存大小是可选项`
make(chan 元素类型, [缓冲大小])
`例`
var ch1,ch2 chan int
ch1 = make(chan int)	// 无缓存的通道ch1
ch2 = make(chan int,20)	// 缓存大小为20的通道ch2

也可以直接定义通道

ch3 := make(chan int)

channel操作

  • 发送
ch <- 10 // 把10发送到ch中   
  • 接收
x := <- ch // 从ch中接收值并赋值给变量x
<-ch       // 从ch中接收值,忽略结果   
  • 关闭
close(ch)   

关闭后的通道有以下特点:

只有当发送信息的 goroutine 将所有信息发送完成才能关闭通道,通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。

关闭的通道就想一头封闭住的管子,管出不管进。

​ 1.对一个关闭的通道再发送值就会导致panic。
​ 2.对一个关闭的通道进行接收会一直获取值直到通道为空。
​ 3.对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
​ 4.关闭一个已经关闭的通道会导致panic。

通道的缓存大小

无缓存

无缓冲的通道又称为阻塞的通道,发送给通道的值必须被接收,不然会 panic

func main() {
    
    
   ch1 := make(chan int)
   ch1 <- 10           // deadlock!
}

正常使用应该为

func main() {
    
    
	ch := make(chan int)
	go func(ch1 chan int) {
    
    
		res := <- ch1
		fmt.Println(res)
	}(ch)
	ch <- 10
	close(ch)
	time.Sleep(time.Second)		// 延缓结束时间使 goroutine 能够执行完
}

如果接收和发送在连个 goroutine 上,无法判断哪一个 goroutine 会先执行,这是如果先执行的是接收方,那么接收方会先阻塞一段时间等待有 goroutine 往该通道发送值。这个过程完成了两个 goroutine 的同步,因此无缓存通道也被成为同步通道。

有缓冲
func main() {
    
    
   ch1 := make(chan int,10)
   ch1 <- 10           // 不会报 deadlock!
   for i := 0; i < 10; i++ {
    
    
      ch1 <- i
      fmt.Println(i+1)         // 超出缓存范围会报 deadlock!
   }
}

只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。

单向通道

在很多时候函数只会向通道单方向存值或取值,Go语言中提供了单向通道来处理这种情况。

func counter(out chan<- int) {
    
    
    for i := 0; i < 100; i++ {
    
    
        out <- i
    }
    close(out)
}

func squarer(out chan<- int, in <-chan int) {
    
    
    for i := range in {
    
    
        out <- i * i
    }
    close(out)
}
func printer(in <-chan int) {
    
    
    for i := range in {
    
    
        fmt.Println(i)
    }
}

func main() {
    
    
    ch1 := make(chan int)
    ch2 := make(chan int)
    go counter(ch1)
    go squarer(ch2, ch1)
    printer(ch2)
}  

1.chan <- int是一个只能发送的通道,可以发送但是不能接收;

2.<- chan int是一个只能接收的通道,可以接收但是不能发送。

单向通道只是对函数来说的,在定义通道是不存在单向的通道,因为没有意义。

定时器

Timer

时间到了只执行一次

简单使用:

package main

import (
   "fmt"
   "time"
)

func main() {
    
    
   // timer 简单使用
   timer := time.NewTimer(2 * time.Second)
   t1 := time.Now()
   fmt.Println(t1)
   t2 := <- timer.C   // 当打印完 t1 会停留2秒钟
   fmt.Println(t2)
   //t3 := <- timer.C // 再次接收会 deadlock!
   //fmt.Println(t3)
}

延时功能:

package main

import (
   "fmt"
   "time"
)

func main() {
    
    
   //实现延时功能
   // 1.time.Sleep()
   time.Sleep(2 * time.Second)
   fmt.Println(time.Now())
   // 2.<- time.NewTimer().C
   <- time.NewTimer(2 * time.Second).C
   fmt.Println(time.Now())
   // 3.<- time.After()
   <- time.After(2 * time.Second)
   fmt.Println(time.Now())
}

关闭定时器:

package main

import (
   "fmt"
   "time"
)

func main() {
    
    
   timer := time.NewTimer(2 * time.Second)
   go func() {
    
    
      fmt.Println("定时器准备工作")
      // 如果将定时器停止则,协程也在这一步终止,不会继续执行该协程后续代码,但不会影响别的协程进行
      <- timer.C                   
      fmt.Println("定时器开始工作")
   }()
   go func() {
    
    
      fmt.Println("另一个协程正在运行")
   }()
   stop := timer.Stop()
   if stop {
    
    
      fmt.Println("停止计时器成功")
   }
   time.Sleep(5 * time.Second)
}

重置定时器时间:

package main

import (
   "fmt"
   "time"
)

func main() {
    
    
   timer := time.NewTimer(3 * time.Second)
   // 将定时器时间改为 1 秒
   timer.Reset(1 * time.Second)
   fmt.Println(time.Now())
   // 一秒后执行
   fmt.Println(<-timer.C)
   timer.Reset(2 * time.Second)
   // 重置后的 timer 可以再次使用,不会出现 deadlock!
   fmt.Println(<-timer.C)
}

Ticker

时间到了,多次执行

使用:

package main

import (
   "fmt"
   "time"
)

func main() {
    
    
   ticker := time.NewTicker(time.Second)
   go func() {
    
    
      i := 0
      for i != 5 {
    
    
         i ++
         fmt.Println(<- ticker.C)
      }
      fmt.Println("协程结束")
   }()
   time.Sleep(6 * time.Second)
}

select

有些需求会让我们从多个通道接收数据,若是没有数据可以被接收就会产生阻塞的情况,影响程序效率。

我们可以使用:

for{
    
    
    // 尝试从ch1接收值
    data, ok := <-ch1
    // 尝试从ch2接收值
    data, ok := <-ch2
    …
}

解决阻塞问题,但是运行性能会差很多。

这时候我们就可以使用 select 关键字

使用格式

 select {
    
    
    case <-chan1:
       // 如果chan1成功读到数据,则进行该case处理语句
    case chan2 <- 1:
       // 如果成功向chan2写入数据,则进行该case处理语句
    default:
       // 如果上面都没有成功,则进入default处理流程
    }

监听通道,直到有一个 channel 开始执行

package main

import (
   "fmt"
)

func main() {
    
    
   // 定义两个没有缓存的通道
   ch1 := make(chan string)
   ch2 := make(chan string)
   // 执行两个子协程来向通道发送数据
   go func(chan string) {
    
    
      ch1 <- "ch1"
   }(ch1)
   go func(chan string) {
    
    
      ch2 <- "ch2"
   }(ch2)
   // 使用select对管道进行监控,只会执行第一个被发送数据的通道
   select {
    
    
   case s1 := <- ch1:
      fmt.Println("s1 =",s1)
   case s2 := <- ch2:
      fmt.Println("s2 =",s2)
   }
}

判断管道是否存满

package main

import (
   "fmt"
   "time"
)

func main() {
    
    
   // 创建通道
   ch := make(chan string,10)
   // 子协程发送数据
   go func(ch1 chan string) {
    
    
      for {
    
    
         select {
    
    
         case ch1 <- "hello":
            fmt.Println("发送数据")
         default:
            fmt.Println("管道满了")
         }
         time.Sleep(time.Microsecond * 500000)
      }
   }(ch)
   // 接收数据
   for s := range  ch {
    
    
      fmt.Println("res:",s)
      time.Sleep(time.Second)
   }
}

并发安全和锁

竞态问题

有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。类比现实生活中的例子有十字路口被各个方向的的汽车竞争;还有火车上的卫生间被车厢里的人竞争。

例如以下的代码

package main

import (
   "fmt"
   "sync"
)

var x int64
var wg sync.WaitGroup

func add() {
    
    
   defer wg.Done()
   for i := 0; i < 5000; i++ {
    
    
      x++
   }
}
// 原本预期为10000,但是输出却是不确定的
func main() {
    
    
   wg.Add(2)
   go add()
   go add()
   wg.Wait()
   fmt.Println(x)
}

上面的代码中我们开启了两个goroutine去累加变量x的值,这两个goroutine在访问和修改x变量的时候就会存在数据竞争,导致最后的结果与期待的不符。

互斥锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。

使用方法

package main

import (
   "fmt"
   "sync"
)

var x int64
var wg sync.WaitGroup
var lock sync.Mutex

func add() {
    
    
   for i := 0; i < 5000; i++ {
    
    
      lock.Lock() // 加锁
      x ++
      lock.Unlock() // 解锁
   }
   wg.Done()
}
// 结果与预期一致
func main() {
    
    
   wg.Add(2)
   go add()
   go add()
   wg.Wait()
   fmt.Println(x)
}

使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

读写互斥锁

互斥锁是完全互斥的,但是有很多实际的场景下是读多写少的,当我们并发的去读取一个资源不涉及资源修改的时候是没有必要加锁的,这种场景下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync包中的RWMutex类型。

读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就会等待;当一个goroutine获取写锁之后,其他的goroutine无论是获取读锁还是写锁都会等待。

使用示例:

var (
    x      int64
    wg     sync.WaitGroup
    lock   sync.Mutex
    rwlock sync.RWMutex
)

func write() {
    
    
    // lock.Lock()   // 加互斥锁
    rwlock.Lock() // 加写锁
    x = x + 1
    time.Sleep(10 * time.Millisecond) // 假设读操作耗时10毫秒
    rwlock.Unlock()                   // 解写锁
    // lock.Unlock()                     // 解互斥锁
    wg.Done()
}

func read() {
    
    
    // lock.Lock()                  // 加互斥锁
    rwlock.RLock()               // 加读锁
    time.Sleep(time.Millisecond) // 假设读操作耗时1毫秒
    rwlock.RUnlock()             // 解读锁
    // lock.Unlock()                // 解互斥锁
    wg.Done()
}

func main() {
    
    
    start := time.Now()
    for i := 0; i < 10; i++ {
    
    
        wg.Add(1)
        go write()
    }

    for i := 0; i < 1000; i++ {
    
    
        wg.Add(1)
        go read()
    }

    wg.Wait()
    end := time.Now()
    fmt.Println(end.Sub(start))
}

需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。

Sync

为了使协程都能完成工作,前面我们是使用time.Sleep()来生硬的控制执行时间,但这在实际应用中一般是不可取的,为此Go语言提供了 sync.WaitGroup 来实现并发任务的同步。

主要方法

方法名 功能
(wg * WaitGroup) Add(delta int) 计数器+delta
(wg *WaitGroup) Done() 计数器-1
(wg *WaitGroup) Wait() 阻塞直到计数器变为0

sync.WaitGroup 内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N 个并发任务时,就将计数器值增加N。每个任务完成时通过调用Done()方法将计数器减1。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。

简单使用:

var wg sync.WaitGroup

func hello() {
    
    
    defer wg.Done()
    fmt.Println("Hello Goroutine!")
}
func main() {
    
    
    wg.Add(1)
    go hello() // 启动另外一个goroutine去执行hello函数
    fmt.Println("main goroutine done!")
    wg.Wait()
}

需要注意sync.WaitGroup是一个结构体,传递的时候要传递指针。

sync.Once

Go语言为只执行一次的场景(配置文件、)提供了解决方案 – sync.Once

sync.Once 只提供了Do这一个方法

func (o *Once) Do(f func()) {
    
    
   // Note: Here is an incorrect implementation of Do:
   //
   // if atomic.CompareAndSwapUint32(&o.done, 0, 1) {
    
    
   //    f()
   // }
   //
   // Do guarantees that when it returns, f has finished.
   // This implementation would not implement that guarantee:
   // given two simultaneous calls, the winner of the cas would
   // call f, and the second would return immediately, without
   // waiting for the first's call to f to complete.
   // This is why the slow path falls back to a mutex, and why
   // the atomic.StoreUint32 must be delayed until after f returns.

   if atomic.LoadUint32(&o.done) == 0 {
    
    
      // Outlined slow-path to allow inlining of the fast-path.
      o.doSlow(f)
   }
}

加载配置文件示例:

var icons map[string]image.Image

func loadIcons() {
    
    
    icons = map[string]image.Image{
    
    
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 被多个goroutine调用时不是并发安全的
func Icon(name string) image.Image {
    
    
    if icons == nil {
    
    
        loadIcons()
    }
    return icons[name]
}

多个goroutine并发调用Icon函数时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine都满足串行一致的基础上自由地重排访问内存的顺序。loadIcons函数可能会被重排为以下结果:

func loadIcons() {
    
    
    icons = make(map[string]image.Image)
    icons["left"] = loadIcon("left.png")
    icons["up"] = loadIcon("up.png")
    icons["right"] = loadIcon("right.png")
    icons["down"] = loadIcon("down.png")
} 

在这种情况下就会出现即使判断了icons不是nil也不意味着变量初始化完成了。考虑到这种情况,我们能想到的办法就是添加互斥锁,保证初始化icons的时候不会被其他的goroutine操作,但是这样做又会引发性能问题。

var icons map[string]image.Image

var loadIconsOnce sync.Once

func loadIcons() {
    
    
    icons = map[string]image.Image{
    
    
        "left":  loadIcon("left.png"),
        "up":    loadIcon("up.png"),
        "right": loadIcon("right.png"),
        "down":  loadIcon("down.png"),
    }
}

// Icon 是并发安全的
func Icon(name string) image.Image {
    
    
    loadIconsOnce.Do(loadIcons)
    return icons[name]
}

sync.Once 其实内部包含一个互斥锁和一个布尔值,互斥锁保证布尔值和数据的安全,而布尔值用来记录初始化是否完成。这样设计就能保证初始化操作的时候是并发安全的并且初始化操作也不会被执行多次。

sync.Map

Go语言中内置的map不是并发安全的。

var m = make(map[string]int)

func get(key string) int {
    
    
    return m[key]
}

func set(key string, value int) {
    
    
    m[key] = value
}

func main() {
    
    
    wg := sync.WaitGroup{
    
    }
    for i := 0; i < 20; i++ {
    
    
        wg.Add(1)
        go func(n int) {
    
    
            key := strconv.Itoa(n)
            set(key, n)
            fmt.Printf("k=:%v,v:=%v\n", key, get(key))
            wg.Done()
        }(i)
    }
    wg.Wait()
}

上面的代码开启少量几个goroutine的时候可能没什么问题,当并发多了之后执行上面的代码就会报fatal error: concurrent map writes错误。

Go语言的sync包中提供了一个开箱即用的并发安全版map – sync.Map。开箱即用表示不用像内置的map一样使用make函数初始化就能直接使用。同时sync.Map内置了诸如Store、Load、LoadOrStore、Delete、Range等操作方法。

var m = sync.Map{
    
    }

func main() {
    
    
    wg := sync.WaitGroup{
    
    }
    for i := 0; i < 20; i++ {
    
    
        wg.Add(1)
        go func(n int) {
    
    
            key := strconv.Itoa(n)
            m.Store(key, n)
            value, _ := m.Load(key)
            fmt.Printf("k=:%v,v:=%v\n", key, value)
            wg.Done()
        }(i)
    }
    wg.Wait()
} 

原子操作

代码中的加锁操作因为涉及内核态的上下文切换会比较耗时、代价比较高。针对基本数据类型我们还可以使用原子操作来保证并发安全,因为原子操作是Go语言提供的方法它在用户态就可以完成,因此性能比加锁操作更好。Go语言中原子操作由内置的标准库sync/atomic提供。

atomic包

方法 解释
func LoadInt32(addr *int32) (val int32) func LoadInt64(addr *int64) (val int64)
func LoadUint32(addr*uint32) (val uint32)
func LoadUint64(addr*uint64) (val uint64)
func LoadUintptr(addr*uintptr) (val uintptr)
func LoadPointer(addr*unsafe.Pointer) (val unsafe.Pointer)
读取操作
func StoreInt32(addr *int32, val int32) func StoreInt64(addr *int64, val int64) func StoreUint32(addr *uint32, val uint32) func StoreUint64(addr *uint64, val uint64) func StoreUintptr(addr *uintptr, val uintptr) func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer) 写入操作
func AddInt32(addr *int32, delta int32) (new int32) func AddInt64(addr *int64, delta int64) (new int64) func AddUint32(addr *uint32, delta uint32) (new uint32) func AddUint64(addr *uint64, delta uint64) (new uint64) func AddUintptr(addr *uintptr, delta uintptr) (new uintptr) 修改操作
func SwapInt32(addr *int32, new int32) (old int32) func SwapInt64(addr *int64, new int64) (old int64) func SwapUint32(addr *uint32, new uint32) (old uint32) func SwapUint64(addr *uint64, new uint64) (old uint64) func SwapUintptr(addr *uintptr, new uintptr) (old uintptr) func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer) 交换操作
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool) func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool) func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool) func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool) func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool) 比较并交换操作

使用示例

var x int64
var l sync.Mutex
var wg sync.WaitGroup

// 普通版加函数
func add() {
    
    
    // x = x + 1
    x++ // 等价于上面的操作
    wg.Done()
}

// 互斥锁版加函数
func mutexAdd() {
    
    
    l.Lock()
    x++
    l.Unlock()
    wg.Done()
}

// 原子操作版加函数
func atomicAdd() {
    
    
    atomic.AddInt64(&x, 1)
    wg.Done()
}

func main() {
    
    
    start := time.Now()
    for i := 0; i < 10000; i++ {
    
    
        wg.Add(1)
        // go add()       // 普通版add函数 不是并发安全的
        // go mutexAdd()  // 加锁版add函数 是并发安全的,但是加锁性能开销大
        go atomicAdd() // 原子操作版add函数 是并发安全,性能优于加锁版
    }
    wg.Wait()
    end := time.Now()
    fmt.Println(x)
    fmt.Println(end.Sub(start))
}

atomic包提供了底层的原子级内存操作,对于同步算法的实现很有用。这些函数必须谨慎地使用确保正确性。除了某些特殊的底层应用,使用通道或者sync包的函数/类型实现同步更好。

参考文档: go语言中文文档
Go 语言之旅

猜你喜欢

转载自blog.csdn.net/weixin_52025712/article/details/121574503
今日推荐