并发
并发是指同时进行多个任务的程序。
go语言有两种并发编程的风格:第一种是goroutine和管道(channel),他们支持通信顺序进程(communicating sequential processes)或被简称为CSP,CSP是一个并发的模式,在不同的执行体(goroutine)之间传递值。第二种是共享内存多线程的传统模型。go语言提倡通信共享内存而不是通过共享内存而实现通信。
Goroutines
在go语言中,每一个并发的执行单元叫作一个goroutine
。类似于其他语言的线程,但goroutine
是由go的运行时(runtime)调度和管理的。程序运行的main函数就是一个goroutine
,我们叫它main goroutine
。
创建goroutine
go语言中只需要在调用的函数前面加上go
关键字,就可以为一个函数创建一个goroutine
。
func f() {
fmt.Println("goroutine!!!!")
}
func main() {
go f()
fmt.Println("main goroutine")
}
// main goroutine
// goroutine!!!!
运行结果goroutine!!!!
打印在main goroutine
之后,原因是goroutine
的执行需要耗时;有时goroutine!!!!
会打印不出,原因是当main()
函数执行之后,就会结束所有在main goroutine
中执行的goroutine
。
启动多个goroutine
我们还可以启动多个goroutine
。为了保证每个goroutine
都可以执行结束,这里引用了sync.WaitGroup
来实现goroutine
同步。
var wg sync.WaitGroup
func f(i int) {
defer wg.Done() //消费掉一个goroutine
fmt.Printf("goroutine %d\n", i)
}
func main() {
for i := 0; i < 5; i++ {
wg.Add(1) //每启动一个goroutine就计数+1
go f(i)
}
wg.Wait() //等待所有的goroutine运行结束
}
/*
goroutine 4
goroutine 1
goroutine 3
goroutine 2
goroutine 0
*/
多次运行上面的代码,每次运行结果都不同,这是由于并发导致的,goroutine
的调度是随机的。
goroutine 与线程
栈
OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),goroutine
的栈不是固定的,一个goroutine
的栈在其生命周期开始时只有很小的栈(典型情况下2KB),它可以按需增大和缩小,其中栈的大小限制可以达到1GB。
goroutine调度
GPM
是go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。
G
就是goroutine,里面除了存放本goroutine信息外,还有与所在P的绑定等信息。P
管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界)。M(machine)
是go运行时(runtime)对操作系统内核线程的虚拟,M与内核线程一般是一一映射的关系,一个goroutine最终是要放到M上执行的。
P与M一般也是一一对应的。它们关系是:P管理着一组G挂在到M上运行。P的个数是通过runtime.GOMAXPROCS
设定(最大256),go1.5版本之后默认为物理线程数。groutine的调度是在用户态下完成的,不涉及到内核态与用户态之间的频繁切换。
go语言中可以通过runtime.GOMAXPROCS()
函数设置当前程序并发时占用的CPU逻辑核心数。
go语言中的操作系统线程和goroutine的关系:
- 一个操作系统线程对应用户态多个goroutine
- go程序可以同时使用多个操作系统线程
- goroutine和OS线程是多对多的关系,即m:n(m个goroutine到n个OS线程)。
Channels
如果说goroutine
是go语言程序的并发体,那么channel
则是它们之间的通信机制。一个channel
是一个通信机制,它可以让一个goroutine
通过它给另一个goroutine
发送值信息。一个可以发送int
类型数据的channel一般写为chan int
。
声明一个channel
var 变量 chan 元素类型
var ch chan int
使用make
函数来创建,格式如下
make(chan 元素类型,[缓冲大小])
ch := make(chan int) //ch 的格式是 `chan int`
和map类似,channel也对应一个make创建的底层数据结构的引用。channel和其他引用类型一样,channel的零值也是nil。
channel之间可以使用==
进行比较,如果引用同一个对象,那么比较的结果为真。
一个channel有发送和接受两个操作,都是通信行为。 一个发送语句将一个值从一个goroutine
通过channel
发送到另一个执行接收操作的goroutine
。发送和接收两个操作都是用<-
运算符。
发送和接收的语法如下:
ch <- x // 发送x到channel
x := <- ch //接收一个从channel中取出的值,赋值给x
<- ch //接收一个从channel中取出的值,忽略结果
channel还支持close操作,用于关闭channel,随后对基于该channel的任何发送操作都将导致panic异常。对一个已经被close过的channel进行接收操作依然可以接收到之前已经成功发送到数据;如果channel中没有数据了,将产生一个零值。
close(ch)
无缓冲的通道
无缓冲通道上的发送将会阻塞,直到另一个goroutine
在对应的通道上执行接收操作,这时值传送完成。两个goroutine都可以继续执行。如果接收操作先发生,那么接收者goroutine
也将阻塞,直到有另一个goroutine
在相同的channel
上执行发送操作。
当通过一个无缓冲通道发送数据时,接收者收到数据发生在再次唤醒发送者goroutine之前。
如果没有接收者,只有一个发送者,那么程序在运行时会报错
ch := make(chan int)
ch <- 10
fmt.Println("发送成功")
fatal error: all goroutines are asleep - deadlock!
这段代码会在ch <- 10
这一行形成死锁。解决的方法就是启动一个goroutine去接受值。
func recv(c chan int) {
x := <-c
fmt.Printf("接收到值:%d\n", x)
}
func main(){
ch := make(chan int)
go recv(ch)
ch <- 10
fmt.Println("发送成功")
}
无缓冲通道进行通信将导致发送和接收的goroutine
同步化,因此,无缓冲通道也被称为同步通道
。
有缓冲的通道
我们可以在使用make函数初始化channel时,指定其通道的容量,容量代表通道中存放元素的数量
func main(){
ch := make(chan int ,1) //创建一个容量为1的有缓冲区的通道
}
只要通道的容量大于零,那么就是有缓冲的通道。
select 多路复用
在有些时候我们需要同时从多个通道中接收数据。在循环迭代中,不能保证多个通道都能顺利接收数据,比如第一个channel中没有事件发送过来那么程序会被阻塞,这样也就无法接收第二个channel的事件了。为了能够多路复用,go语言提供了select
语句
select{
case <- ch1:
//...
case x:=<- ch2:
//...
case ch3 <- y:
//...
default:
//...
}
每一个case代表一个通信操作(在某个channel上进行发送和接收)
select会等待case中有能够执行的case时去执行。当条件满足时,select才会去通信并执行case之后的语句;这时候其他通信是不执行的。空的select{}会永远的等待下去。
创建一个buff为1的channel,所以会出现交替执行case的情况,因此打印出来的值为0 2 4 6 8
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x)
case ch <- i:
}
}
如果多个case同时就绪,那么select会随机地选择一个执行,这样来保证每一个channel都有平等的被select的机会。
将上面例子中的buff更改为2,这个时候出现的结果就是不固定的随机的。
ch := make(chan int, 2)
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x)
case ch <- i:
}
}
select中的default来设置当其他的操作都不能够马上被处理时程序需要执行的逻辑。
数据竞争
数据竞争会在两个以上的goroutine并发访问相同的变量且至少其中一个为写操作时发生。比较常见的例子是银行转账或者计数。
var x int64
func add() {
for i := 0; i < 1000; i++ {
x++
}
wg.Done()
}
func main(){
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
当有2个goroutine同时访问同一个变量x,就会造成数据竞争,结果达不到预期。
解决数据竞争的方式
- 不要去写变量:如果我们在创建goroutine之前的初始化阶段,就初始化了map中的所有条目并且再也不去修改它们,那么任意数量的goroutine并发访问Icon都是安全的,因为每一个goroutine都只是去读取而已。
- 避免从多个goroutine访问变量:将访问的变量限定在一个单独的goroutine中。
- 允许很多goroutine去访问变量,但同一个时刻最多只有一个goroutine在访问,这种方式称为“互斥”。
sync.Mutex互斥锁
go语言使用sync
包的Mutex
类型来实现互斥锁。针对上面的代码我们加入互斥锁
var (
x int64
mu sync.Mutex
)
func add() {
for i := 0; i < 1000; i++ {
mu.Lock()
x++
mu.Unlock()
}
wg.Done()
}
func main(){
wg.Add(2)
go add()
go add()
wg.Wait()
fmt.Println(x)
}
加锁后发现可以得到预期的结果。每次一个goroutine访问x变量时,它都会调用mutex的Lock方法来获取一个互斥锁,如果其他goroutine已经获取这个锁了,这个操作会被阻塞直到其他的goroutine调用了Unlock使该锁变回可用状态。
多个goroutine
同时等待一个锁时,唤醒的策略是随机的。
sync.RWMutex读写锁
互斥锁是完全互斥的,但好多业务场景是读多写少,我们并发的读取资源不设计到资源修改的时候是没有必要加锁的,针对这种情况读写锁是更好的选择。go语言提供这样的锁是sync.RWMutex
。
我们模拟一个读写操作,测试使用互斥锁所用的时间
var mu sync.Mutex
func write() {
//加写锁
mu.Lock()
defer mu.Unlock()
x++
time.Sleep(10 * time.Millisecond)
wg.Done()
}
func read() {
//加读锁
mu.Lock()
defer mu.Unlock()
time.Sleep(time.Millisecond)
wg.Done()
}
func main(){
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go write()
}
for i := 0; i < 10000; i++ {
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
我们会发现时间基本在10+秒,因为读的时候也会加锁。
然后将上述改为读写锁,我们在测试下时间
var rwlock sync.RWMutex
func write() {
//加写锁
rwlock.Lock()
defer rwlock.Unlock()
x++
time.Sleep(10 * time.Millisecond)
wg.Done()
}
func read() {
//加读锁
rwlock.RLock()
defer rwlock.RUnlock()
time.Sleep(time.Millisecond)
wg.Done()
}
func main(){
start := time.Now()
for i := 0; i < 10; i++ {
wg.Add(1)
go write()
}
for i := 0; i < 10000; i++ {
wg.Add(1)
go read()
}
wg.Wait()
end := time.Now()
fmt.Println(end.Sub(start))
}
发现使用读写锁后,时间仅需要120ms。如果当读写次数相当时,读写锁的效果就会下降甚至不如互斥锁。
sync.Once惰性初始化
如果初始化成本比较大的话,那么将初始化延迟到需要的时候再去做就是一个比较好的选择。有时我们需要某些操作在高并发操作中只执行一次,例如加载配置文件。
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"),
}
}
// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
if icons == nil {
loadIcons() // one-time initialization
}
return icons[name]
}
但是当有多个goroutine
并发调用icons时不是并发安全的,现代的编译器和CPU可能会在保证每个goroutine
都满足串行一致的基础上自由的重排访问内存的顺序。loadIcons函数可能会被重排为以下结果
func loadIcons() {
icons = make(map[string]image.Image)
icons["spades.png"] = loadIcon("spades.png")
icons["hearts.png"] = loadIcon("hearts.png")
icons["diamonds.png"] = loadIcon("diamonds.png")
icons["clubs.png"] = loadIcon("clubs.png")
}
因此,一个goroutine在检查icons是非空时,也并不能确定这个变量初始化完成了,可能只初始化了一个空的map。这个时候我们可以使用互斥锁来完成,但使用互斥锁就无法对变量进行并发访问。
var mu sync.Mutex // guards icons
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 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{
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,然后释放锁。如果没有查到map,那么就获得一个写锁,因为内存同步机制的存在,我们无法观察到某个goroutine内变量初始化的情况,所以此时需要对icons进行判空,以防止其他goroutine初始化过了。经过上述代码就可以实现资源初始化了,但太复杂容易出错,好在go语言sync.Once
为我们提供了这种支持。
其签名如下
func (o *Once) Do(f func()){}
如果要执行的函数f()
有参数的话 ,需要闭包来实现。
var loadIconsOnce sync.Once
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{
loadIconsOnce.Do(loadIcons)
return icons[name]
}
一次性的初始化需要一个互斥量mutex和一个boolean变量来记录初始化是不是已经完成了。
go语言会先判断boolean变量是否为1,只有不为1才锁定mutex。在第一次调用时,boolean变量的值为false,Do会调用loadIcons 并将boolean更改为true。随后调用什么都不会做,但是mutex同步会保证icons变量对所有的goroutine可见。使用这种方式,我们能够避免在变量被构建完成之前和其他的goroutine共享该变量。