并发编程
初识并发
接下来我们会用两个简单的函数来说明什么是并发,所有的例子都是基于下面这段代码的。
package main import ( "fmt" "time" ) func eat(){ fmt.Println("start eat") time.Sleep(time.Second) fmt.Println("end eat") } func drink(){ fmt.Println("start drink") time.Sleep(time.Second) fmt.Println("end drink") }
接下来这段代码是以串行的方式执行的,此时,eat函数要执行约1s,drink函数要执行约1s,这样大概需要2s左右的时间才能执行完。
func main(){ eat() drink() fmt.Println("main") }
此时,我们希望在eat的同时还能执行drink,看起来就像有两个人同时做两件事,这样的效率是更高的,而方式也非常简单,只需要在调用的函数(普通函数和匿名函数)前面加上一个go关键字。举个例子如下:
func main(){ go eat() go drink() fmt.Println("main") }
上面的例子在执行的过程中,你可能没有办法看到eat、drink完整的执行结果。
因为当main()函数返回的时候,所有在main()函数中启动的go的函数会一同结束。
所以我们要想办法让main函数等一等hello函数,最简单粗暴的方式就是time.Sleep了。
func main(){ go eat() go drink() fmt.Println("main") time.Sleep(time.Second) fmt.Println("end") }
结果:
main
start eat
start drink
end eat
end drink
end
我们会发现,之前必须要执行完eat之后才能执行drink函数,而现在eat和drink两个函数和main函数都在同时执行了。这就是并发的效果。
刚刚我们在程序中实现并发的方法是用到了一个go关键字,那么这个go是什么,又完成了哪些事情呢?
并发的概念
并行 : 并行是指两者同时执行,比如赛跑,两个人都在不停的往前跑;(资源够用,比如三个线程,四核的CPU )
并发 : 并发是指资源有限的情况下,两者交替轮流使用资源,比如一段路(单核CPU资源)同时只能过一个人,A走一段后,让给B,B用完继续给A ,交替使用,目的是提高效率。
区别:
并行是从微观上,也就是在一个精确的时间片刻,有不同的程序在执行,这就要求必须有多个处理器。
并发是从宏观上,在一个时间段上可以看出是同时执行的,比如一个服务器同时处理多个session。
goroutine
概念简介
goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。
在Go语言编程中你不需要去自己写进程、线程、协程,你的技能包里只有一个技能–goroutine,当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个goroutine去执行这个函数就可以了,就是这么简单粗暴。
// Go语言中使用goroutine非常简单,只需要在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。 // 一个goroutine必定对应一个函数,可以创建多个goroutine去执行相同的函数。 // 在程序启动时,Go程序就会为main()函数创建一个默认的goroutine。 // 当main()函数返回的时候该goroutine就结束了,所有在main()函数中启动的goroutine会一同结束。
sync.WaitGroup
在代码中,我们的eat和drink两个方法的执行时间是不确定的,我们为了让这两个函数任务执行圆满,在main函数中预测一个时间来进行sleep并不是个明智之举。
在go语言中可以使用sync.WaitGroup来实现并发任务的同步。sync.WaitGroup常用的方法如下表所示:
| 方法名 | 功能 |
| (wg *WaitGroup) Add(delta int) | 计数器+delta |
| (wg *WaitGroup) Done() | 计数器-1 |
| (wg *WaitGroup) Wait() | 阻塞知道计数器为0 |
sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了N个并发任务时,就将计数器的值增加N。每个任务完成时通过用Done()方法将计数器减一。通过调用Wait()来等待并发任务执行完,当计数器值为0时,表示所有并发任务已经完成。
需要注意的是,sync.WaitGroup是一个结构体,在某些场景需要把它当变量传递的时候要传递指针。
我们利用sync.WaitGroup将上面的代码优化一下:
package main import ( "fmt" "sync" "time" )
var wg sync.WaitGroup
func eat(){ defer wg.Done() fmt.Println("start eat") time.Sleep(time.Second) fmt.Println("end eat") } func drink(){ wg.Done() fmt.Println("start drink") time.Sleep(time.Second) fmt.Println("end drink") } func main(){ wg.Add(1) go eat() wg.Add(1) go drink() wg.Wait() }
package main import ( "fmt" "sync" "time" ) var wg sync.WaitGroup func eat(){ defer wg.Done() fmt.Println("start eat") time.Sleep(time.Second) fmt.Println("end eat") } func drink(){ defer wg.Done() fmt.Println("start drink") time.Sleep(time.Second) fmt.Println("end drink") } func main(){ for i:=0;i<5;i++{ wg.Add(1) go eat() go drink() } wg.Wait() }
一次性可以创建多少个goroutine?
OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。
操作系统的进程与线程
进程
进程是一个实体。每一个进程都有它自己的地址空间,一般情况下,包括文本区域(text region)、数据区域(data region)和堆栈(stack region)。文本区域存储处理器执行的代码;数据区域存储变量和进程执行期间使用的动态分配的内存;堆栈区域存储着活动过程调用的指令和本地变量。
进程的三状态图

在了解其他概念之前,我们首先要了解进程的几个状态。在程序运行的过程中,由于被操作系统的调度算法控制,程序会进入几个状态:就绪,运行和阻塞。
(1)就绪(Ready)状态
当进程已分配到除CPU以外的所有必要的资源,只要获得处理机便可立即执行,这时的进程状态称为就绪状态。
(2)执行/运行(Running)状态当进程已获得处理机,其程序正在处理机上执行,此时的进程状态称为执行状态。
(3)阻塞(Blocked)状态正在执行的进程,由于等待某个事件发生而无法执行时,便放弃处理机而处于阻塞状态。引起进程阻塞的事件可有多种,例如,等待I/O完成、申请缓冲区不能满足、等待信件(信号)等。
// 就绪 package main // 运行 import ( "fmt" "time" ) func main(){ c := 1 *2 time.Sleep(time.Second) // 阻塞 // 就绪 d := c * 10 // 运行 fmt.Print(d) }
线程
gorountine与操作系统
goroutine调度
GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。
G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。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调度方面的性能。详解
GOMAXPROCS
Go运行时的调度器使用GOMAXPROCS参数来确定需要使用多少个OS线程来同时执行Go代码。默认值是机器上的CPU核心数。例如在一个8核心的机器上,调度器会把Go代码同时调度到8个OS线程上(GOMAXPROCS是m:n调度中的n)。
Go语言中可以通过runtime.GOMAXPROCS()函数设置当前程序并发时占用的CPU逻辑核心数。
Go1.5版本之前,默认使用的是单核心执行。Go1.5版本之后,默认使用全部的CPU逻辑核心数。
我们可以通过将任务分配到不同的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) }
两个任务只有一个逻辑核心,此时是做完一个任务再做另一个任务。 将逻辑核心数设为2,此时两个任务并行执行,代码如下。
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) }
Go语言中的操作系统线程和goroutine的关系:
- 一个操作系统线程对应用户态多个goroutine。
- go程序可以同时使用多个操作系统线程。
- goroutine和OS线程是多对多的关系,即m:n。
// 就绪 package main // 运行 import ( "fmt" "runtime" "time" ) func fib(n int) int { if n>2{ return fib(n-1)+fib(n-2) }else if n==1 || n == 2{ return 1 } return 0 } func print_fib(n int){ fmt.Println(fib(n)) } func main(){ runtime.GOMAXPROCS(3) // 分别改成1、2、3查看结果 go print_fib(45) go print_fib(45) go print_fib(45) time.Sleep(time.Second*10) }
channel
channel介绍
单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义。
虽然可以使用共享内存进行数据交换,但是共享内存在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法势必造成性能问题。
Go语言的并发模型是CSP(Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。
如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。
Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。
channel的操作
声明:channel是一种类型,一种引用类型。声明通道类型的格式如下:
var 变量 chan 元素类型
var ch1 chan int // 声明一个传递整型的通道 var ch2 chan bool // 声明一个传递布尔型的通道 var ch3 chan []int // 声明一个传递int切片的通道
初始化:声名之后的通道要使用make函数初始化之后才能使用,示例:
make(chan 元素类型, [缓冲大小]) //channel的缓冲大小是可选的。
ch4 := make(chan int) ch5 := make(chan bool) ch6 := make(chan []int)
通道有发送(send)、接收(receive)和关闭(close)三种操作。发送和接收都使用<-符号。
ch := make(chan int) // 创建一个通道 ch <- 10 // 发送 :把10发送到ch中。 x := <- ch // 接收 :从ch中接收值并赋值给变量x。 <-ch // 接收 :从ch中接收值,忽略结果。 close(ch) // 关闭通道
关于关闭通道需要注意的事情是,只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被垃圾回收机制回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要做的,但关闭通道不是必须的。
关闭后的通道有以下特点:
对一个关闭的通道再发送值就会导致panic。
对一个关闭的通道进行接收会一直获取值直到通道为空。
对一个关闭的并且没有值的通道执行接收操作会得到对应类型的零值。
关闭一个已经关闭的通道会导致panic。
缓冲通道
无缓冲通道
func main(){ ch := make(chan int) // 创建一个通道 ch <- 10 // 把10发送到ch中,<-将一个值发送到通道中。 x := <- ch // 从ch中接收值并赋值给变量x,接收值。 <-ch // 从ch中接收值,忽略结果,接收值。 fmt.Println(x) close(ch) // 关闭通道 }
执行上面的代码会报以下错误,
fatal error: all goroutines are asleep - deadlock! goroutine 1 [chan send]: main.main() .../通道.go:11 +0x59
为什么会出现deadlock错误呢?
因为我们使用ch := make(chan int)创建的是无缓冲的通道,无缓冲通道只有在有人接收值的时候才能发送值。就像你住的小区没有快递柜和代收点,快递员给你打电话必须要把这个物品送到你的手中,简单来说就是无缓冲的通道必须有接收才能发送。
上面的代码会阻塞在ch <- 10这一行代码形成死锁,那如何解决这个问题呢?
一种方法是启用一个goroutine去接收值,例如:
package main import "fmt" func recv(ch chan int){ x := <- ch // 从ch中接收值并赋值给变量x,接收值。 fmt.Println(x) } func main(){ ch := make(chan int) // 创建一个通道 go recv(ch) ch <- 10 // 把10发送到ch中,<-将一个值发送到通道中。 }
无缓冲通道上的发送操作会阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。相反,如果接收操作先执行,接收方的goroutine将阻塞,直到另一个goroutine在该通道上发送一个值。
使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道也被称为同步通道。
有缓冲通道
解决上面问题的方法还有一种就是使用有缓冲区的通道。我们可以在使用make函数初始化通道的时候为其指定通道的容量,例如:
package main func main(){ ch := make(chan int,1) // 创建一个容量为1的通道 ch <- 10 // 把10发送到ch中,<-将一个值发送到通道中。 }
只要通道的容量大于零,那么该通道就是有缓冲的通道,通道的容量表示通道中能存放元素的数量。就像你小区的快递柜只有那么个多格子,格子满了就装不下了,就阻塞了,等到别人取走一个快递员就能往里面放一个。
我们可以使用内置的len函数获取通道内元素的数量,使用cap函数获取通道的容量,虽然我们很少会这么做。
循环接收值
当向通道中发送完数据时,我们可以通过close函数来关闭通道。
当通道被关闭时,再往该通道发送值会引发panic,从该通道里接收的值一直都是类型零值。那如何判断一个通道是否被关闭了呢?
我们来看下面这两个例子:
package main import "fmt" func sender(ch1 chan int){ for i:=0;i<10;i++{ ch1<-i } close(ch1) } func main(){ ch1 := make(chan int,1) // 创建一个容量为1的通道 go sender(ch1) for{ i,ok :=<- ch1 fmt.Println(i,ok) if !ok{ break } } }
上面这种方式,我们通过for循环取值,并判断ok的bool值,当bool值为False的时候,代表通道中已经没有值了,此时通过break结束循环。
package main import "fmt" func sender(ch1 chan int){ for i:=0;i<10;i++{ ch1<-i } close(ch1) } func main(){ ch1 := make(chan int,1) // 创建一个容量为1的通道 go sender(ch1) for i := range ch1{ fmt.Println(i) } }
从上面的例子中我们看到有两种方式在接收值的时候判断该通道是否被关闭,我们通常使用的是for range的方式,当通道被关闭的时候就会退出for range。
package main import "fmt" func sender(ch1 chan int){ for i:=0;i<10;i++{ ch1<-i } close(ch1) } func middleWare(ch1 chan int,ch2 chan int){ for{ i,ok :=<- ch1 if !ok{ break } ch2 <- i*i } close(ch2) } func main(){ ch1 := make(chan int,1) // 创建一个容量为1的通道 ch2 := make(chan int,1) // 创建一个容量为1的通道 go sender(ch1) go middleWare(ch1,ch2) for i := range ch2{ fmt.Println(i) } }
单向通道
有的时候我们会将通道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用通道都会对其进行限制,比如限制通道在函数中只能发送或只能接收。
Go语言中提供了单向通道来处理这种情况。例如,我们把上面的例子改造如下:
package main import ( "fmt" "time" ) func sender(ch1 chan<- int){ // 声明单通道ch1,只能向ch1中发送数据 for i:=0;i<10;i++{ ch1<-i } close(ch1) } func recver(ch1 <-chan int){ // 声明单通道ch1,只能从ch1中获取数据 for i:= range ch1{ fmt.Println(i) } } func main(){ ch1 := make(chan int,1) // 创建一个容量为1的通道 go sender(ch1) go recver(ch1) time.Sleep(time.Second) }
chan<- int是一个只能发送的通道,可以发送但是不能接收;<-chan int是一个只能接收的通道,可以接收但是不能发送。
在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,但反过来是不可以的。
channel(通道)总结

关闭已经关闭的channel也会引发panic。
worker pool(goroutine池)
在工作中我们通常会使用可以指定启动的goroutine数量–worker pool模式,控制goroutine的数量,防止goroutine泄漏和暴涨。
一个简易的work pool示例代码如下:
func worker(id int, jobs <-chan int, results chan<- int) { for j := range jobs { fmt.Printf("worker:%d start job:%d\n", id, j) time.Sleep(time.Second) fmt.Printf("worker:%d end job:%d\n", id, j) results <- j * 2 } } func main() { jobs := make(chan int, 100) results := make(chan int, 100) // 开启3个goroutine for w := 1; w <= 3; w++ { go worker(w, jobs, results) } // 5个任务 for j := 1; j <= 5; j++ { jobs <- j } close(jobs) // 输出结果 for a := 1; a <= 5; a++ { <-results } }
select多路复用
在某些场景下我们需要同时从多个通道接收数据。通道在接收数据时,如果没有数据可以接收将会发生阻塞。你也许会写出如下代码使用遍历的方式来实现:
for{ // 尝试从ch1接收值 data, ok := <-ch1 // 尝试从ch2接收值 data, ok := <-ch2 … }
这种方式虽然可以实现从多个通道接收值的需求,但是运行性能会差很多。为了应对这种场景,Go内置了select关键字,可以同时响应多个通道的操作。
select的使用类似于switch语句,它有一系列case分支和一个默认的分支。每个case会对应一个通道的通信(接收或发送)过程。select会一直等待,直到某个case的通信操作完成时,就会执行case分支对应的语句。具体格式如下:
select{ case <-ch1: ... case data := <-ch2: ... case ch3<-data: ... default: 默认操作 }
举个小例子来演示下select的使用:
func main() { ch := make(chan int, 1) for i := 0; i < 10; i++ { select { case x := <-ch: fmt.Println(x) case ch <- i: } } }
使用select语句能提高代码的可读性。
- 可处理一个或多个channel的发送/接收操作。
- 如果多个
case同时满足,select会随机选择一个。 - 对于没有
case的select{}会一直等待,可用于阻塞main函数。
并发安全和锁
有时候在Go代码中可能会存在多个goroutine同时操作一个资源(临界区),这种情况会发生竞态问题(数据竞态)。类比现实生活中的例子有十字路口被各个方向的的汽车竞争;还有火车上的卫生间被车厢里的人竞争。
var x int64 var wg sync.WaitGroup func add() { for i := 0; i < 5000; i++ { x = x + 1 } wg.Done() } func main() { wg.Add(2) go add() go add() wg.Wait() fmt.Println(x) }
上面的代码中我们开启了两个goroutine去累加变量x的值,这两个goroutine在访问和修改x变量的时候就会存在数据竞争,导致最后的结果与期待的不符。
互斥锁
互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。 使用互斥锁来修复上面代码的问题:
var x int64 var wg sync.WaitGroup var lock sync.Mutex func add() { for i := 0; i < 5000; i++ { lock.Lock() // 加锁 x = x + 1 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无论是获取读锁还是写锁都会等待。
读锁与互斥锁
package main import ( "fmt" "sync" "time" ) var x int64 var wg sync.WaitGroup var lock sync.Mutex var rwlock sync.RWMutex func read() { lock.Lock() fmt.Println(x) time.Sleep(time.Second) // 假设每次读数据需要1秒 lock.Unlock() wg.Done() } func read_rlock() { rwlock.RLock() // 加读锁 fmt.Println(x) time.Sleep(time.Second) // 假设每次读数据需要1秒 rwlock.RUnlock() // 解读锁 wg.Done() } func main() { start1 := time.Now() for i:=0;i<10;i++{ wg.Add(1) go read() } wg.Wait() end1 := time.Now() fmt.Println(end1.Sub(start1)) //10.031709215s start := time.Now() for i:=0;i<10;i++{ wg.Add(1) go read_rlock() } wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) //1.032026705s }
写锁与互斥锁
package main import ( "fmt" "sync" "time" ) var x int64 var wg sync.WaitGroup var lock sync.Mutex var rwlock sync.RWMutex func write() { lock.Lock() // 加互斥锁 x+=1 time.Sleep(time.Second) // 假设每次写数据需要1秒 lock.Unlock() // 解互斥锁 wg.Done() } func write_wLock() { rwlock.Lock() // 加写锁 x+=1 time.Sleep(time.Second) // 假设每次写数据需要1秒 rwlock.Unlock() // 解写锁 wg.Done() } func main() { start1 := time.Now() for i:=0;i<10;i++{ wg.Add(1) go write() } wg.Wait() end1 := time.Now() fmt.Println(end1.Sub(start1)) //10.034463041s start := time.Now() for i:=0;i<10;i++{ wg.Add(1) go write_wLock() } wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) //10.020692144s }
package main import ( "fmt" "sync" "time" ) var x int64 var wg sync.WaitGroup var lock sync.Mutex var rwlock sync.RWMutex //func read() { // lock.Lock() // fmt.Println(x) // time.Sleep(time.Second) // 假设每次读数据需要1秒 // lock.Unlock() // wg.Done() //} // func read_rlock() { rwlock.RLock() // 加读锁 fmt.Println(x) time.Sleep(time.Second) // 假设每次读数据需要1秒 rwlock.RUnlock() // 解读锁 wg.Done() } func write() { lock.Lock() // 加互斥锁 x+=1 time.Sleep(time.Second) // 假设每次写数据需要1秒 lock.Unlock() // 解互斥锁 wg.Done() } func write_wLock() { rwlock.Lock() // 加写锁 x+=1 time.Sleep(time.Second) // 假设每次写数据需要1秒 rwlock.Unlock() // 解写锁 wg.Done() } func main() { start1 := time.Now() for i:=0;i<10;i++{ wg.Add(1) go read_rlock() } wg.Wait() end1 := time.Now() fmt.Println(end1.Sub(start1)) //1.005582327s start := time.Now() for i:=0;i<10;i++{ wg.Add(1) go write_wLock() } wg.Wait() end := time.Now() fmt.Println(end.Sub(start)) //10.029123829s }
需要注意的是读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。
sync.Once
在编程的很多场景下我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。
Go语言中的sync包中提供了一个针对只执行一次场景的解决方案–sync.Once。
sync.Once只有一个Do方法,其签名如下:
func (o *Once) Do(f func()) {}
备注:如果要执行的函数f需要传递参数就需要搭配闭包来使用。
加载配置文件示例
延迟一个开销很大的初始化操作到真正用到它的时候再执行是一个很好的实践。因为预先初始化一个变量(比如在init函数中完成初始化)会增加程序的启动耗时,而且有可能实际执行过程中这个变量没有用上,那么这个初始化操作就不是必须要做的。我们来看一个例子:
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操作,但是这样做又会引发性能问题。
使用sync.Once改造的示例代码如下:
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错误。
像这种场景下就需要为map加锁来保证并发的安全性了,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包的函数/类型实现同步更好。
练习题
- 使用
goroutine和channel实现一个计算int64随机数各位数和的程序。- 开启一个
goroutine循环生成int64类型的随机数,发送到jobChan - 开启24个
goroutine从jobChan中取出随机数计算各位数的和,将结果发送到resultChan - 主
goroutine从resultChan取出结果并打印到终端输出
- 开启一个
- 为了保证业务代码的执行性能将之前写的日志库改写为异步记录日志方式。

浙公网安备 33010602011771号