简介

Go语言使用go语句开启新的goroutine,由于goroutine非常轻量除了对其分配栈空间外,所占的空间也是微乎其微的。但当多个goroutine同时处理时会遇到比如同时抢占一个资源,某个goroutine会等待等一个goroutine处理完毕某才能继续执行的问题。对于这种情况,官方并不希望依靠共享内存的方式来实现进程的协同操作,而是希望通过channel信道的方式来处理。但在某些特殊情况下,依然需要使用到锁,为此sync包提供了锁。

sync包围绕着Locker锁接口展开,Locker接口中提供了两个方法Lock()Unlock()

  1. type Locker interface {
  2. Lock()
  3. Unlock()
  4. }

Go语言标准库sync中提供了两种锁分别是互斥锁sync.Mutex和读写互斥锁sync.RWMutex

sync.Mutex

sync.Mutex可能是sync包中使用最广泛的原语。它允许在共享资源上互斥访问(不能同时访问),sync.MutexGolang标准库提供的一个互斥锁,当一个goroutine获得互斥锁权限后,其他请求锁的goroutine会阻塞在Lock()方法的调用上,直到调用Unlock()方法被释放。

它由两个字段 statesema 组成,state 表示当前互斥锁的状态,而 sema 真正用于控制锁状态的信号量,这两个加起来只占 8 个字节空间的结构体就表示了 Go 语言中的互斥锁。

  1. type Mutex struct {
  2. state int32 //状态标识
  3. sema uint32 //信号量
  4. }

sync.Mutex不区分读写锁,只有Lock()Lock()之间才会导致阻塞的情况。若在一个地方Lock(),在另一个地方不Lock()而是直接修改或访问共享数据,对于sync.Mutext类型是允许的,因为mutex不会和goroutine进行关联。若要区分读锁和写锁,可使用sync.RWMutex类型。可以通过在代码前调用Lock方法,在代码后调用Unlock方法来保证一段代码的互斥执行,也可以使用defer语句来保证互斥锁一定会被解锁。当一个goroutine调用Lock方法获得锁后,其它请求的goroutine都会阻塞在Lock方法直到锁被释放。

  1. mutex := &sync.Mutex{}
  2. mutex.Lock()
  3. // Update共享变量 (比如切片,结构体指针等)
  4. mutex.Unlock()

示例代码:大家可以想一下如果不加锁,会发生什么呢?num最终会输出100吗?

  1. package main
  2. import (
  3. "fmt"
  4. "sync"
  5. "time"
  6. )
  7. func main() {
  8. var num = 0
  9. var locker sync.Mutex
  10. for i := 0; i < 100; i++ {
  11. go func(i int) {
  12. locker.Lock()
  13. defer locker.Unlock()
  14. num++
  15. fmt.Printf("goroutine %d: num = %d\n", i, num)
  16. }(i)
  17. }
  18. time.Sleep(time.Second)
  19. }

Go语言中的Mutex类型的互斥锁Lock()锁定与其它语言不同的是,Lock()锁定的是互斥锁而非一段代码。其他语言比如Java中使用同步锁锁定的是一段代码,以确保多线程并发誓只有一个线程可以控制运行此代码块直到释放同步锁。Go语言是在goroutine中锁定互斥锁,其它goroutine执行到有锁的位置时,由于获取不到互斥锁的锁定,因此会发生阻塞而等待,从而达到控制同步的目的。

sync.RWMutex

读写互斥锁在 Go 语言中的实现是 RWMutex,其中不仅包含一个互斥锁,还持有两个信号量,分别用于写等待读和读等待写:

  1. type RWMutex struct {
  2. w Mutex
  3. writerSem uint32
  4. readerSem uint32
  5. readerCount int32
  6. readerWait int32
  7. }

readerCount 存储了当前正在执行的读操作的数量,最后的 readerWait 表示当写操作被阻塞时等待的读操作个数。

sync.RWMutex读写锁是基于sync.Mutex实现的,读写锁的特点是针对读写操作的互斥锁,读写锁与互斥锁最大不同之处在于分别对读、写进行了锁定。一般用在大量读操作少量写操作中。

  • 同时只能具有一个goroutine能够获得写锁定
  • 同时可以具有任意多个goroutine获得读锁定
  • 同时只能存在写锁定或读锁定,即读和写互斥。

Go标准库sync.RWMutex读写互斥锁提供了四个方法

读写互斥锁 描述
Lock 添加写锁
Unlock 释放写锁
RLock 添加读锁
RUnlock 释放读锁

加锁同Mutex

  1. mutex := &sync.RWMutex{}
  2. mutex.Lock()
  3. // Update 共享变量
  4. mutex.Unlock()
  5. mutex.RLock()
  6. // Read 共享变量
  7. mutex.RUnlock()

sync.WaitGroup

sync.WaitGroup也是一个经常会用到的同步原语,它的使用场景是在一个goroutine等待一组goroutine执行完成。

sync.WaitGroup拥有一个内部计数器。当计数器等于0时,则Wait()方法会立即返回。否则它将阻塞执行Wait()方法的goroutine直到计数器等于0时为止。

要增加计数器,我们必须使用Add(int)方法。要减少它,我们可以使用Done()(将计数器减1),也可以传递负数给Add方法把计数器减少指定大小,Done()方法底层就是通过Add(-1)实现的。

WaitGroup 结构体中的成员变量非常简单,其中的 noCopy 的主要作用就是保证 WaitGroup 不会被开发者通过再赋值的方式进行拷贝,进而导致一些诡异的行为:

type WaitGroup struct {
    noCopy noCopy

    state1 [3]uint32
}

copylock 包就是一个用于检查类似错误的分析器,它的原理就是在 编译期间 检查被拷贝的变量中是否包含 noCopy 或者 sync 关键字,如果包含当前关键字就会报出以下的错误,需要注意的是只有在**go vet**命令下,才会提示出其中的**nocopy**问题整个sync下的常用锁都是不可复制

package main

import (
    "fmt"
    "sync"
)

func main() {
    wg := sync.WaitGroup{}
    yawg := wg
    fmt.Println(wg, yawg)
}

/**
输出:
.\syncTest.go:10:13: variable declaration copies lock value to yawg: sync.WaitGroup contains sync.noCopy
.\syncTest.go:11:14: call of fmt.Println copies lock value: sync.WaitGroup contains sync.noCopy
.\syncTest.go:11:18: call of fmt.Println copies lock value: sync.WaitGroup contains sync.noCopy
*/

在以下示例中,我们将启动八个goroutine,并等待他们完成

func main() {
    wg := &sync.WaitGroup{}

    for i := 0; i < 8; i++ {
        wg.Add(1)
        go func(i int) {
            fmt.Printf("goroutine %d running\n", i)
            // Do something
            wg.Done()
        }(i)
    }

    wg.Wait()
    // 继续往下执行...
    fmt.Println("main goroutine running")
}

每次创建goroutine时,我们都会使用wg.Add(1)来增加wg的内部计数器。我们也可以在for循环之前调用wg.Add(8)。与此同时,每个goroutine完成时,都会使用wg.Done()减少wg的内部计数器。main goroutine会在八个goroutine都执行wg.Done()将计数器变为0后才能继续执行。

Once

Go 语言在标准库的 sync 同步包中还提供了 Once 语义,它的主要功能其实也很好理解,保证在 Go 程序运行期间 Once 对应的某段代码只会执行一次。

作为 sync 包中的结构体,Once 有着非常简单的数据结构,每一个 Once 结构体中都只包含一个用于标识代码块是否被执行过的 done 以及一个互斥锁 Mutex

type Once struct {
    done uint32
    m    Mutex
}

在如下所示的代码中,Do 方法中传入的函数只会被执行一次,也就是我们在运行如下所示的代码时只会看见一次 only once 的输出结果:

func main() {
   o := &sync.Once{}
   for i := 0; i < 10; i++ {
      o.Do(func() {
         fmt.Println("only once")
      })
   }
}
/**
*输出:
*only once
*/

Cond

Go 语言在标准库中提供的 Cond 其实是一个条件变量,通过 Cond 我们可以让一系列的 Goroutine 都在触发某个事件或者条件时才被唤醒,每一个 Cond 结构体都包含一个互斥锁 L

Cond 的结构体中包含 noCopycopyChecker 两个字段,前者用于保证 Cond 不会再编译期间拷贝,后者保证在运行期间发生拷贝会直接 panic,持有的另一个锁 L 其实是一个接口 Locker,任意实现 LockUnlock 方法的结构体都可以作为 NewCond 方法的参数

type Cond struct {
    noCopy noCopy

    L Locker

    notify  notifyList
    checker copyChecker
}

结构体中最后的变量 notifyList 其实也就是为了实现 Cond 同步机制,该结构体其实就是一个 Goroutine 的链表:

type notifyList struct {
    wait uint32
    notify uint32

    lock mutex
    head *sudog
    tail *sudog
}

在这个结构体中,headtail 分别指向的就是整个链表的头和尾,而 waitnotify 分别表示当前正在等待的 Goroutine 和已经通知到的 Goroutine,我们通过这两个变量就能确认当前待通知和已通知的 Goroutine。

通过如下示例来展示Cond如何使用

func main() {
   c := sync.NewCond(&sync.Mutex{})

   for i := 0; i < 10; i++ {
      go listen(c)
   }

   go broadcast(c)

   ch := make(chan os.Signal, 1)
   signal.Notify(ch, os.Interrupt)
   <-ch
}

func broadcast(c *sync.Cond) {
   c.L.Lock()
   c.Broadcast()
   c.L.Unlock()
}

func listen(c *sync.Cond) {
   c.L.Lock()
   c.Wait()
   fmt.Println("listen")
   c.L.Unlock()
}

在上述代码中我们同时运行了 11 个 Goroutine,其中的 10 个 Goroutine 会通过 Wait 等待期望的信号或者事件,而剩下的一个 Goroutine 会调用 Broadcast 方法通知所有陷入等待的 Goroutine,当调用 Boardcast 方法之后,就会打印出 10 次 "listen" 并结束调用。