Go语言并发编程2 - 同步

0 前言

Go语言除了可以使用通道进行多个goroutine间数据交换的方式之外,还提供了传统的同步工具。它们都在Go的标准代码包 sync 和 sync/atomic 中,包括原子操作、互斥锁、条件变量以及等待组。

1 原子操作

原子操作是指执行过程不能被中断的操作。在针对某个值的原子操作执行过程中,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)

2 互斥锁(sync.Mutex)

互斥锁是传统并发程序对共享资源进行访问控制的主要手段。在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

2.1 读写锁(sync.RWMutex)

在读比写多的场景下,可以优先使用读写互斥锁,这比使用普通读写更高效。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尝试进行写锁定操作时会被阻塞,只有当所有的读锁定都解锁后,写锁定操作才能进行。

3 条件变量(sync.Cond)

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。不同的是,前者的目标只有一个,而后者的目标则是所有。

使用注意事项:

  • 一定要在调用Wait()方法之前锁定与之关联的读锁,否则调用Wait()方法时就会引发不可恢复的运行时panic。
  • 一定不要忘记在读操作完成后及时解锁与条件变量关联的那个读锁,否则对读写锁的写锁定操作将会阻塞相关的goroutine。其根本原因是,条件变量对应的Wait()方法在返回之前会重新锁定与之关联的那个读锁。因此,应该确保调用Wait()方法的那个函数/方法在返回之前调用读解锁方法 RUnlock()。

条件变量是与读写锁的读锁有关联的。读写锁的 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())

4 只会执行一次(sync.Once)

现在,我们再次聚焦 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 类型就可以派上用场了。例如,数据库连接池的建立、全局变量的延迟初始化,等等。

5 等待组(sync.WaitGroup)

除了可以使用通道(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...

6 临时对象池(sync.Pool)

在 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语言核心编程》

 

你可能感兴趣的:(#,Go语言学习笔记,go语言,golang,并发编程,同步)