Golang最为让人熟知的并发模型当属CSP并发模型,也就是由goroutine和channel构成的GMP并发模型,具体内容不在赘述了,可以翻回之前的文章查看。在这里,要讲讲Golang的其他并发方式。
Golang不仅可以使用CSP并发模式,还可以使用传统的共享数据的并发模式。
临界区(critical section)
这是传统语言比较常用的的方式,即加锁。加锁使其线程同步,每次只允许一个goroutine进入某个代码块,此代码块区域称之为”*临界区(critical section)*”。
Golang为*临界区(critical section)*提供的是互斥锁的包和条件变量的包。
互斥锁
就是通常使用的锁,用来让线程串行用的。Golang提供了互斥锁sync.Mutex
和读写互斥锁 sync.RWMutex
,用法极其简单:
1 | var s sync.Mutex |
Lock和Unlock
sync.Mutex
和sync.RWMutex
的区别
没啥大的区别,只不过sync.RWMutex
更加细腻,可以将“读操作”和“写操作”区别对待。sync.RWMutex
中的Lock和unLock针对写操作
1 | var s sync.RWMutex |
sync.RWMutex
中的RLock和RUnLock针对读操作
1 | var s sync.RWMutex |
读写锁有以下规则:
- 写锁被锁定,(再试图进行)读锁和写锁都阻塞
- 读锁被锁定,(再试图进行)写锁阻塞,(再试图进行)读锁不阻塞
即:多个写操作不能同时进行,写操作和读操作也不能同时进行,多个读操作可以同时进行
注意事项:
- 不要重复锁定互斥锁;因为代码写起来麻烦,容易出错,万一死锁(deadlock)了就废了。Go语言运行时系统自己抛出的panic都属于致命错误,都是无法恢复的,调用
recover
函数对它们起不到任何作用。一旦产生死锁,程序必然崩溃。 - 锁定和解锁一定要成对出现,如果怕忘记解锁,最好是使用
defer
语句来解锁;但是,一定不要对未锁定的或者已经锁定的互斥锁解锁,因为会触发panic
,而且此panic
和死锁一样,属于致命错误,程序肯定崩溃 sync.Mutex
是个结构体,尽量不要其当做参数,在多个函数直接传播。因为没啥意义,Golang的参数都是副本,多个副本之间都是相互独立的。
条件变量Cond
互斥锁是用来锁住资源,“创造”临界区的。而条件变量Cond可以认为是用来自行调度线程(在此即为groutine)的,当某个状态时,阻塞等待,当状态改变时,唤醒。
Cond的使用,离不开互斥锁,即离不开sync.Mutex
和sync.RWMutex
。
Cond初始化都需要有个互斥锁。(ps:哪怕初始化不需要,就应用场景而言,也得需要个互斥锁)
Cond
提供Wait、Signal、Broadcast 三种方法。
Wait表示线程(groutine)阻塞等待;
Signal表示唤醒等待的groutine;
Broadcast表示唤醒等待的所有groutine;
初始化:
1 | cond := sync.NewCond(&sync.Mutex{}) |
在其中一个groutine中:
1 | cond.L.Lock() |
以上算是模板
在另外一个groutine中:
1 | cond.L.Lock() |
原子操作(atomicity)
原子操作是硬件芯片级别的支持,所以可以保证绝对的线程安全。而且执行效率比其他方式要高出好几个数量级。
Go语言的原子操作当然也是基于CPU和操作系统的,Go语言提供的原子操作的包是sync/atomic
,此包提供了加(Add)、CAS(交换并比较 compare and swap)、成对出现的存储(store)和加载(load)以及交换(swap)。
此包提供的大多数函数针对的数据类型也非常的单一:只有整型!使用方式十分的简单,看着函数直接调用就好。
1 | var a int32 |
在此特别强调一下CAS,CAS对应的函数前缀是“CompareAndSwap”,含义和用法正如英文翻译:比较并交换。在进行CAS操作的时候,函数会先判断被操作变量的当前值是否与我们预期的旧值相等,如果相等,它就把新值赋给该变量,并返回true,反之,就忽略此操作,并返回false。
可能是Golang提供的原子操作的数据类型实在是有限,Go又补充了一个结构体atomic.Value
,此结构体相当于一个小容器,可以提供原子操作的存储store
和提取load
1 | var atomicVal atomic.Value |
其他
为了能更好的调度goroutine,Go提供了sync.WaitGroup
、sync.Once
还有context
sync.WaitGroup
sync.WaitGroup
的作用就是在多goroutine并发程序中,让主goroutine等待所有goroutine执行结束。(直接查看代码注释)sync.WaitGroup
提供了三个函数Add
、Done
和Wait
三者用法如下:
- Add 写在主goroutine中,参数为将要运行的goroutine的数量
- Done 写在各个非主goroutine中,表示运行结束
- Wait 写在主goroutine中,block主goroutine,等待所有其他goroutine运行结束
1 | var wait sync.WaitGroup |
注意
sync.WaitGroup
中有一个计数器,记录的是需要等待的goroutine的数量,默认值是0,可以通过Add方法来增加或者减少值,但是切记,千万不能让计数器的值小于零,会触发panic!
sync.WaitGroup
调用Wait方法的时候,sync.WaitGroup
中计数器的值一定要为0。因此Add中的值一定要等于非主goroutine的数量!
且不要把Add和Wait方法放到不同的goroutine中执行!
sync.Once
真真正正的只执行一次。
sync.Once
只要一个方法:Do
,里面就一个参数:func
。多说无益,复制下面代码,猜猜执行结果就知道了。
1 | package main |
执行结果
1 | Only once |
没错,只有一行。真只执行了一次。
context
context可以用来实现一对多的goroutine协作。这个包的应用场景主要是在API中。字面意思也很直接,上下文。当一个请求来时,会产生一个goroutine,但是这个goroutine往往要衍生出许多额外的goroutine去处理操作,例如链接database、请求rpc请求。。等等,这些衍生的goroutine和主goroutine有很多公用数据的,例如同一个请求生命周期、用户认证信息、token等,当这个请求超时或者被取消的时候,这里所有的goroutine都应该结束。context就可以帮助我们达到这个效果。
很显然,主goroutine和衍生的所有子goroutine之间形成了一颗树结构。我们的context可以从根节点遍布整棵树,当然,是线程安全的。
线程之间的基本是这样的:
1 | func DoSomething(ctx context.Context, arg Arg) error { |
有两个根context:background和todo;这两个根都是contenxt空的,没有值的。两者也没啥太本质的区别,Background是最常用的,作为Context这个树结构的最顶层的Context,它不能被取消。当不知道用啥context的时候就可以用TODO。
根生成子节点有以下方法:
1 | //生成可撤销的Context (手动撤销) |
可撤销的Context
以下是每个方法的调用方式(全都来自godoc,可粘贴复用):
可撤销的func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
1 | gen := func(ctx context.Context) <-chan int { |
要想结束所有线程,就调用ctx, cancel := context.WithCancel(context.Background())
函数返回的cancel函数即可,当撤销函数被调用之后,对应的Context值会先关闭它内部的接收通道,也就是它的Done方法返回的通道。
WithDeadline
和WithTimeout
用法基本类似,而且WithTimeout函数内部调用了WithDeadline函数。两者唯一区别是WithTimeout表示从现在开始xxx超时,而WithDeadline的时间可以是之前的时间:意思是说WithTimeout表示从现在开始, xxx时间后超时。而WithDeadline表示xx时间点,结束!这个时间点可以是昨天,时间点不收任何限制。
以下是godoc给出的列子:
WithDeadline
1 | package main |
输出:
1 | context deadline exceeded |
WithTimeout
1 | package main |
输出:
1 | context deadline exceeded |
不可撤销的context,传递值
WithValue
可以用来在传递值的,值的存取是以KV的形式来进行的。直接上例子
1 | type favContextKey string |
输出:
1 | found value: Go1 |
请关注我
更多精彩内容,请搜索我的微信公众号 码农RyuGou
或者扫码