Go语言除了可以使用通道进行多个goroutine间数据交换的方式之外,还提供了传统的同步工具。它们都在Go的标准代码包 sync 和 sync/atomic 中,包括原子操作、互斥锁、条件变量以及等待组。
原子操作是指执行过程不能被中断的操作。在针对某个值的原子操作执行过程中,CPU绝不会再去执行其他针对该值的操作,无论这些其他操作是否为原子操作。
Go语言提供的原子操作都是非侵入式的。它们由标准库代码包 sync/atomic 中的众多API函数实现,我们可以通过调用这些函数对几种简单类型的值执行原子操作。这些类型包括6种:int32、int64、uint32、uint64、uintptr 和 unsafe.Pointer。这些函数提供的原子操作共有5种:增或减、比较并交换、载入、存储和交换。它们分别提供了不同的功能,且适用的场景也有所区别。
sync/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) |
比较并交换操作 |
示例1:把一个int32类型的变量 i32 的值增大3,可以这样做:
newi32 := atomic.AddInt32(&i32, 3)
示例2:要原子地将 int64 类型的变量 i64 的值减少3,可以这样做:
var i64 int64 = 8
atomic.AddInt64(&i64, -3)
互斥锁是传统并发程序对共享资源进行访问控制的主要手段。在Go语言中,它是由标准库代码包 sync 中的 mutex 结构体类型表示的,即 sync.Mutex 类型。互斥锁能保证同时只有一个 goroutine 可以访问共享资源。
sync.Mutex 类型的零值表示未被锁定的互斥量。也就是说,它是一个开箱即用的工具,我们只需要对它进行简单声明后即可使用。
var mutex sync.Mutex //声明一个互斥锁
在使用其他编程语言(比如C、Java)的锁工具时,我们可能会犯一个低级错误:忘记解开已被锁住的锁,从而导致诸如流程执行异常、线程执行停滞、甚至程序死锁等一些列问题。然而,在Go语言中,这个低级错误的发生率极低,其主要原因是存在 defer 语句。关于此的惯用做法是在锁定互斥锁之后,紧接着就用defer 语句保证该互斥锁的及时解锁。请看下面的代码片段:
var metex sync.Mutex
func write() {
mutex.Lock()
defer mutex.Unlock()
//省略若干代码
...
}
例子:互斥锁的使用,对全局变量的控制访问。
package main
import (
"fmt"
"sync"
"time"
)
//全局变量
var(
//共享变量
count int
//与共享变量对应的互斥锁
countGuard sync.Mutex
)
func SetCount(c int) {
countGuard.Lock() //加锁
count = c
countGuard.Unlock() //解锁
}
func GetCount() int {
countGuard.Lock()
//在函数退出时解锁
defer countGuard.Unlock()
return count
}
func main(){
var i int
fmt.Scanln(&i)
//启动一个goroutine
go SetCount(i)
//让main函数的goroutine休眠1秒
time.Sleep(time.Second)
fmt.Printf("count=%d\n", GetCount())
}
运行结果:
24
count=24
在读比写多的场景下,可以优先使用读写互斥锁,这比使用普通读写更高效。sync 包中的 REMutex 提供了对读写互斥锁的封装。读写锁与普通互斥锁最大的不同,就是可以分别针对读操作和写操作进行锁定和解锁操作。读写锁遵循的访问控制规则与互斥锁有所不同。读写锁控制下的多个写操作之间都是互斥的,并且写操作与读操作之间也是互斥的。但是,多个读操作之间却不存在互斥关系。在这样的互斥策略下,读写锁可以在大大降低因锁而造成的性能损耗的情况下,完成对共享资源的访问控制。Go中的读写锁由结构体类型 sync.RWMutex 表示。与互斥锁一样,sync.RWMutex 类型的零值就已经是可用的读写锁实例了。此类型的方法集合中包含两对方法,即:
// 方法集合1
func (*RWMutex) Lock()
func (*RWMuex) Unlock()
// 方法集合2
func (*RWMutex) RLock()
func (*RWMutex) RUlock()
前一对方法的名称与互斥锁的那两个方法完全一致,它们分别代表了对写操作的锁定和解锁,以下简称它们为“写锁定”和“写解锁”。而后一对方法则分别表示了对读操作的锁定和解锁,以下简称它们为读锁定和读解锁。
写解锁会试图唤醒所有因欲进行读操作而被阻塞的 goroutine,而读解锁只会在已无任何读锁定的情况下,试图唤醒一个因欲进行写锁定而被阻塞的goroutine。若对一个未被写锁定的读写锁进行写解锁,就会引发一个不可恢复的运行时恐慌(panic),而对一个未被读锁定的读写锁进行读解锁同样也会引发运行时恐慌(panic)。
无论锁定针对的是写操作还是读操作,都应该尽快解锁。当一个goroutine进行读锁定后,其他的goroutine如果是获取读锁会继续获得锁,如果是获取写锁就要等待;当一个goroutine进行写锁定后,其他的goroutine无论是获取读锁还是写锁都要等待。也就是说,对于同一个读写锁来说,施加于其上的读锁定可以有多个,因此只有对互斥锁进行等量的读解锁,才能让某一个写锁定获得进行的机会,否则就会使欲进行写锁定的goroutine一直处于阻塞状态;而对于同一个读写锁来说,施加于其上的写锁定只能有一个,在未进行写解锁之前,其他goroutine的读锁定或是写锁定都要等待。
示例:读写锁使用例子。
package main
import (
"fmt"
"sync"
"time"
)
func main(){
var rwm sync.RWMutex
for i:=0; i<3; i++ {
go func(i int){
fmt.Printf("Try to lock for reading... [%d]\n", i)
rwm.RLock() //读加锁
fmt.Printf("Locked for reading. [%d]\n", i)
time.Sleep(time.Second) //假设读操作耗时1秒
fmt.Printf("Try to unlock for reading... [%d]\n", i)
rwm.RUnlock()
fmt.Printf("Unlocked for reading. [%d]\n", i)
}(i)
}
time.Sleep(time.Millisecond * 100) //main函数休眠100毫秒
fmt.Printf("Try to lock for writing...\n")
rwm.Lock() //写锁定
fmt.Printf("Locked for writing.\n")
time.Sleep(time.Second * 5) //假设写操作耗时5秒
rwm.Unlock() //写解锁
fmt.Printf("Unlocked for writing.\n")
}
运行结果:
Try to lock for reading... [2]
Locked for reading. [2]
Try to lock for reading... [0]
Locked for reading. [0]
Try to lock for reading... [1]
Locked for reading. [1]
Try to lock for writing...
Try to unlock for reading... [1]
Unlocked for reading. [1]
Try to unlock for reading... [2]
Unlocked for reading. [2]
Try to unlock for reading... [0]
Unlocked for reading. [0]
Locked for writing.
Unlocked for writing.
《代码说明》从运行结果可以看出,对于读写锁rwm,可以同时有多个读锁定,而main函数的goroutine尝试进行写锁定操作时会被阻塞,只有当所有的读锁定都解锁后,写锁定操作才能进行。
Go标准库的sync包中的sync.Cond 类型代表了条件变量。与互斥锁、读写锁不同,简单的声明无法创建一个可用的条件变量,还需要用到sync.NewCond()函数进行初始化。该函数的声明如下:
// src/sync/cond.go
func NewCond(l Locker) *Cond
//Locker 是一个接口类型,定义在: src/sync/mutex.go
// A Locker represents an object that can be locked and unlocked.
type Locker interface {
Lock()
Unlock()
}
条件变量总要和互斥量组合使用。 sync.NewCond()函数的唯一参数是 sync.Locker类型的,而具体的参数值既可以是一个互斥锁,也可以是一个读写锁。 sync.NewCond()函数在被调用后,会返回一个 *sync.Cond 类型的返回值,我们可以调用该值拥有的几个方法来操纵这个条件变量。
*sync.Cond 类型的方法集合中有3个方法,即:Wait()、Signal() 和 Broadcast()。它们分别代表了等待通知、单发通知和广播通知的操作。
//src/sync/cond.go
func (c *Cond) Wait()
func (c *Cond) Signal()
func (c *Cond) Broadcast()
Wait()方法会自动地对与该条件变量关联的那个互斥锁进行解锁,并且是它所在的goroutine阻塞。一旦接收到通知,该方法所在的goroutine就会被唤醒,并且该方法会立即尝试锁定该互斥锁。方法Signal()和Broadcast() 的作用都是发送通知,以唤醒正在为此处于阻塞状态的goroutine。不同的是,前者的目标只有一个,而后者的目标则是所有。
使用注意事项:
条件变量是与读写锁的读锁有关联的。读写锁的 RLocker() 方法,它会返回当前读写锁的读锁。该读锁同时也是 sync.Locker接口的实现。因此,可以把它作为参数值传递给 sync.NewCond()函数。
//src/sync/mutex.go
// A Locker represents an object that can be locked and unlocked.
type Locker interface {
Lock()
Unlock()
}
//src/sync/rwmutex.go
// RLocker returns a Locker interface that implements
// the Lock and Unlock methods by calling rw.RLock and rw.RUnlock.
func (rw *RWMutex) RLocker() Locker {
return (*rlocker)(rw)
}
type rlocker RWMutex
func (r *rlocker) Lock() { (*RWMutex)(r).RLock() }
func (r *rlocker) Unlock() { (*RWMutex)(r).RUnlock() }
// 示例代码
var rw_mutex sync.RWMutex
var cond sync.Cond
cond := sync.NewCond(rw_mutex.RLocker())
现在,我们再次聚焦 sync包。除了我们上面介绍的互斥锁、读写锁和条件变量,该代码包还提供了几个非常有用的API,其中一个比较有特色的是就是 sync.Once 结构体类型和它的 Do()方法。
源码位置:src/sync/once.go
和互斥锁、读写锁一样,sync.Once 也是开箱即用,就像下面这样:
var once sync.Once
once.Do(func() {
fmt.Println("Once!")
})
《代码说明》这里首先声明了一个名为once的sync.Once 类型的变量,然后立刻就可以调用它的Do()方法。Do()方法接收一个无参数、无返回值的函数作为其参数。Do()方法一旦被调用,就会去调用作为参数的那个函数。
对同一个 sync.Once类型的变量,其Do()方法的有效调用次数永远都是1。也就是说,无论调用这个方法多少次,也无论在多次调用时传递给它的参数值是否相同,都仅有第一次的调用是有效的。无论怎样,只有第一次调用Do()方法时传递给它的那个函数会被执行。
示例代码:once.go
package main
import (
"fmt"
"math/rand"
"sync"
)
func main() {
var count int
var once sync.Once
max := rand.Intn(100)
for i:=0; i
运行结果:
Count: 1
《代码分析》上面的示例代码,无论你运行多少次,标准输出上出现的内容都只会是:Count: 1。
sync.Once 典型应用场景就是执行仅需执行一次的任务。这样的任务并不是都适合在 init 函数中执行,这时 sync.Once 类型就可以派上用场了。例如,数据库连接池的建立、全局变量的延迟初始化,等等。
除了可以使用通道(channel) 和 互斥锁 进行两个并发goroutine间的同步外,还可以使用等待组进行多个任务的同步。
等待组有下面几个方法可用,如下表所示。
方法名 | 功能 |
(wg * WaitGroup) Add(delta int) | 等待组的计数器 +1 |
(wg *WaitGroup) Done() | 等待组的计数器 -1 |
(wg *WaitGroup) Wait() | 等待组计时器不为0时阻塞直到变为0。 |
sync.WaitGroup 类型的值是并发安全的,也是开箱即用的。例如,在声明了 var wg sync.WaitGroup 之后,就可以直接使用wg变量了。sync.WaitGroup 是一个结构体类型。
WaitGroup 内部拥有一个计数器,计数器的值可以通过方法调用实现计数器的增加和减少。当我们添加N个并发任务进行工作时,就将等待组的计数器的值增加N。每个任务完成时,计数器的值减1。同时。在另外一个goroutine中等待这个等待组的计数器的值为0时,表示所有任务都已经完成。
例子:使用等待组同步多个并发goroutine示例。
package main
import (
"fmt"
"net/http"
"sync"
)
func main() {
//声明一个等待组
var wg sync.WaitGroup
//准备一系列的网站地址
var urls = []string{
"https://www.baidu.com",
"https://www.qq.com",
"https://www.github.com",
}
//遍历这些网址
for _, url := range urls {
//每一个任务开始时,将等待组加1
wg.Add(1)
//开启一个goroutine,使用的是匿名函数实现
go func(url string){
//使用defer,当函数完成时将等待组减1
defer wg.Done()
//访问http网址
_, err := http.Get(url)
//访问完成后,打印地址和可能发生的错误
fmt.Println(url, err)
//通过实参传递URL地址
}(url)
}
//等待所有任务完成
wg.Wait()
fmt.Println("main over...")
}
运行结果:
https://www.baidu.com
https://www.qq.com
https://www.github.com
main over...
在 src/sync包中,还有一个临时对象池,其类型是 sync.Pool。我们可以把 sync.Pool 类型值看作是存放临时值的容器。此类容器是自动伸缩的、高效的,同时也是并发安全的。为了描述方便,把sync.Pool类型的值称为“临时对象池”,而把存于其中的值称为“对象值”。
源码位置:src/sync/pool.go
Pool 结构体类型定义如下:
type Pool struct {
noCopy noCopy
local unsafe.Pointer // local fixed-size per-P pool, actual type is [P]poolLocal
localSize uintptr // size of the local array
victim unsafe.Pointer // local from previous cycle
victimSize uintptr // size of victims array
// New optionally specifies a function to generate
// a value when Get would otherwise return nil.
// It may not be changed concurrently with calls to Get.
New func() interface{}
}
可以看到,Pool 类型只有一个Public字段:New。该字段是一个函数类型。在用字面量初始化一个临时对象池的时候,可以为它唯一的公开字段New赋值。赋值给该New字段的函数会被临时对象池用来创建对象值。不过,该函数一般仅在池中无可用对象值的时候才被调用。
sync.Pool 类型有两个公开的指针方法:Get() 和 Put()。前者的功能是从池中获取一个interface{}类型的值,而后者的作用则是把一个interface{}类型的值放置于池中。
// src/sync/pool.go
func (p *Pool) Get() interface{}
func (p *Pool) Put(x interface{})
通过Get()方法获取到的值是任意的。如果一个临时对象池的Put()方法未被调用过,且它的 New 字段也未曾被赋予一个非 nil 的函数值,那么它的 Get() 方法返回的结果就一定是 nil。Get() 方法返回的值不一定就是存于池中的值。不过,如果这个结果值是池中的,那么在该方法返回它之前,就一定会把它从池中删除。
临时对象池的两个突出特性:
(1)临时对象池可以把由其中的对象值产生的存储压力进行分摊。
(2)对垃圾回收友好。垃圾回收的执行一般会使临时对象池中的对象值全部被移除。也就是说,即使我们永远不会显式地从临时对象池中取走某个对象值,该对象值也不会永远待在临时对象池中,它的生命周期垃圾回收任务下一次的执行时间。
Go语言基础之并发
《Go并发编程实战(第2版)》
《Go语言从入门到进阶实战(视频教学版)》
《Go语言核心编程》