Skip to content

Latest commit

 

History

History
1134 lines (901 loc) · 35 KB

File metadata and controls

1134 lines (901 loc) · 35 KB

十二、将同步与原子性用于同步

本章将继续 Go 并发的旅程,介绍syncatomic包,这是为协调 Goroutine 之间的同步而设计的两个其他工具。这将使编写优雅而简单的代码成为可能,允许并发使用资源并管理 goroutine 的生命周期。sync包含高级同步原语,atomic包含低级同步原语。

本章将介绍以下主题:

  • 储物柜
  • 等待组
  • 其他同步组件
  • atomic

技术要求

本章要求安装 Go 并设置您最喜爱的编辑器。更多信息,请参见第 3 章Go概述。

同步原语

我们了解了通道是如何关注 goroutine 之间的通信的,现在我们将重点关注sync包提供的工具,其中包括 goroutine 之间同步的基本原语。我们将看到的第一件事是如何使用储物柜实现对同一资源的并发访问。

并行存取和储物柜

Go 为可以锁定和解锁的对象提供了通用接口。锁定一个对象意味着控制它,而解锁则释放它供其他人使用。此接口为每个操作公开一个方法。以下是代码中的示例:

type Locker interface {
    Lock()
    Unlock()
}

互斥

locker 最简单的实现是sync.Mutex。因为它的方法有一个指针接收器,所以它不应该被复制或按值传递。如果可能,Lock()方法将控制互斥体,或者在互斥体可用之前阻止 goroutine。Unlock()方法释放互斥锁,如果对未锁定的互斥锁调用,则返回运行时错误。

下面是一个简单的示例,其中我们使用锁启动了一系列 goroutine,以查看哪个先执行:

func main() {
    var m sync.Mutex
    done := make(chan struct{}, 10)
    for i := 0; i < cap(done); i++ {
        go func(i int, l sync.Locker) {
            l.Lock()
            defer l.Unlock()
            fmt.Println(i)
            time.Sleep(time.Millisecond * 10)
            done <- struct{}{}
        }(i, &m)
    }
    for i := 0; i < cap(done); i++ {
        <-done
    }
}

完整示例可在以下网址获得:https://play.golang.org/p/resVh7LImLf

我们正在使用一个通道在一项工作完成时向主 goroutine 发送信号,并退出应用程序。让我们创建一个外部计数器,并使用 goroutines 同时递增它。

在不同的 goroutine 上执行的操作不是线程安全的,如下面的示例所示:

done := make(chan struct{}, 10000)
var a = 0
for i := 0; i < cap(done); i++ {
    go func(i int) {
        if i%2 == 0 {
            a++
        } else {
            a--
        }
        done <- struct{}{}
    }(i)
}
for i := 0; i < cap(done); i++ {
    <-done
}
fmt.Println(a)

我们希望有 5000 加 1 和 5000 减 1,并在最终指令中打印一个0。但是,每次运行应用程序时,我们得到的是不同的值。发生这种情况是因为此类操作不是线程安全的,因此其中两个或多个操作可能同时发生,最后一个操作会隐藏其他操作。这种现象称为竞态条件;也就是说,当多个操作试图写入相同的结果时。

这意味着没有任何同步,结果是不可预测的;如果我们检查上一个示例并使用锁来避免竞争条件,则整数的值将为零,这是我们期望的结果:

m := sync.Mutex{}
for i := 0; i < cap(done); i++ {
    go func(l sync.Locker, i int) {
        l.Lock()
        defer l.Unlock()
        if i%2 == 0 {
            a++
        } else {
            a--
        }
        done <- struct{}{}
    }(&m, i)
    fmt.Println(a)
}

一种非常常见的做法是在数据结构中嵌入互斥体,以表示容器就是要锁定的容器。以前的计数器变量可以表示为:

type counter struct {
    m     sync.Mutex
    value int
}

计数器执行的操作可以是在主操作之前已经锁定的方法,以及在主操作之后解锁的方法,如以下代码块所示:

func (c *counter) Incr(){
    c.m.Lock()
    c.value++
    c.m.Unlock()
}

func (c *counter) Decr(){
    c.m.Lock()
    c.value--
    c.m.Unlock()
}

func (c *counter) Value() int {
    c.m.Lock()
    a := c.value
    c.m.Unlock()
    return a
}

这将简化 goroutine 循环,从而生成更清晰的代码:

var a = counter{}
for i := 0; i < cap(done); i++ {
    go func(i int) {
        if i%2 == 0 {
            a.Incr()
        } else {
            a.Decr()
        }
        done <- struct{}{}
    }(i)
}
// ...
fmt.Println(a.Value())

RWMutex

竞态条件的问题是由并发写入而不是读取操作引起的。实现 locker 接口的另一个数据结构sync.RWMutex用于支持这两种操作,具有与读锁唯一且互斥的写锁。这意味着互斥锁可以由单个写锁或一个或多个读锁锁定。当读卡器锁定互斥锁时,其他试图锁定互斥锁的读卡器将不会被阻止。它们通常被称为共享独占锁。这允许读取操作同时进行,而无需等待时间。

写锁操作使用 locker 接口的LockUnlock方法完成。读取操作使用两种其他方法执行:RLockRUnlock。还有另一种方法,RLocker,它返回一个用于读取操作的锁。

我们可以通过创建一个并发字符串列表来快速说明它们的用法:

type list struct {
    m sync.RWMutex
    value []string
}

我们可以迭代切片以找到所选值,并在读取时使用读锁延迟写入:

func (l *list) contains(v string) bool {
    for _, s := range l.value {
        if s == v {
            return true
        }
    }
    return false
}

func (l *list) Contains(v string) bool {
    l.m.RLock()
    found := l.contains(v)
    l.m.RUnlock()
    return found
}

我们可以在添加新元素时使用写锁:

func (l *list) Add(v string) bool {
    l.m.Lock()
    defer l.m.Unlock()
    if l.contains(v) {
        return false
    }
    l.value = append(l.value, v)
    return true
}

然后,我们可以尝试使用几个 goroutine 在列表上执行相同的操作:

var src = []string{
    "Ryu", "Ken", "E. Honda", "Guile",
    "Chun-Li", "Blanka", "Zangief", "Dhalsim",
}
var l list
for i := 0; i < 10; i++ {
    go func(i int) {
        for _, s := range src {
            go func(s string) {
                if !l.Contains(s) {
                    if l.Add(s) {
                        fmt.Println(i, "add", s)
                    } else {
                        fmt.Println(i, "too slow", s)
                    }
                }
            }(s)
        }
    }(i)
}
time.Sleep(500 * time.Millisecond)

我们首先检查名称是否包含在锁中,然后尝试添加元素。这会导致多个例程尝试添加新元素,但由于写入锁是独占的,因此只有一个例程会成功。

写饥饿

在设计应用程序时,这种互斥锁并不总是显而易见的选择,因为在读锁数量较多而写锁数量较少的情况下,互斥锁将在第一个锁之后接受传入的更多读锁,让写操作在没有读锁活动的情况下等待一段时间。这是一种被称为写饥饿的现象。

为了验证这一点,我们可以定义一个同时具有写入和读取操作的类型,这需要一些时间,如以下代码所示:

type counter struct {
    m sync.RWMutex
    value int
}

func (c *counter) Write(i int) {
    c.m.Lock()
    time.Sleep(time.Millisecond * 100)
    c.value = i
    c.m.Unlock()
}

func (c *counter) Value() int {
    c.m.RLock()
    time.Sleep(time.Millisecond * 100)
    a := c.value
    c.m.RUnlock()
    return a
}

我们可以尝试在不同的 goroutine 中以相同的步调执行写操作和读操作,使用的持续时间低于方法的执行时间(50 ms 对 100 ms)。我们还将检查他们在锁定状态下花费了多少时间:

var c counter
t1 := time.NewTicker(time.Millisecond * 50)
time.AfterFunc(time.Second*2, t1.Stop)
for {
    select {
    case <-t1.C:
        go func() {
            t := time.Now()
            c.Value()
            fmt.Println("val", time.Since(t))
        }()
        go func() {
            t := time.Now()
            c.Write(0)
            fmt.Println("inc", time.Since(t))
        }()
    case <-time.After(time.Millisecond * 200):
        return
    }
}

如果我们执行应用程序,我们会看到,对于每个写操作,都会执行多个读操作,并且下一个调用比上一个调用花费更多的时间来等待锁。对于读操作来说,情况并非如此,因为读操作可能同时发生,因此一旦读卡器成功锁定资源,所有其他等待的读卡器都将执行相同的操作。将RWMutex替换为Mutex将使两个操作具有相同的优先级,如上例所示。

锁定陷阱

在锁定和解锁互斥锁时必须小心,以避免应用程序中出现意外行为和死锁。以以下片段为例:

for condition {
    mu.Lock()
    defer mu.Unlock()
    action()
}

这段代码乍一看似乎还可以,但它不可避免地会阻止 goroutine。这是因为defer语句不是在每次循环迭代结束时执行,而是在函数返回时执行。因此,第一次尝试将锁定而不释放,第二次尝试将保持卡住状态。

一点重构可以帮助修复此问题,如以下代码段所示:

for condition {
    func() {
        mu.Lock()
        defer mu.Unlock()
        action()
    }()
}

我们可以使用闭包来确保延迟的Unlock得到执行,即使action恐慌。

如果在互斥体上执行的操作不会引起恐慌,那么最好放弃延迟,在执行操作后使用它,如下所示:

for condition {
    mu.Lock()
    action()
    mu.Unlock()
}

defer是有成本的,所以在不需要时最好避免,例如在进行简单的变量读取或赋值时。

同步 goroutines

到目前为止,为了等待 goroutines 完成,我们使用了一个空结构的通道,并通过该通道发送了一个值作为最后一个操作,如下所示:

ch := make(chan struct{})
for i := 0; i < n; n++ {
    go func() {
        // do something
        ch <- struct{}{}
    }()
}
for i := 0; i < n; n++ {
    <-ch
}

这种策略是有效的,但它不是完成任务的首选方法。这在语义上是不正确的,因为我们使用的是一个通道,它是一种通信工具,用来发送空数据。这个用例是关于同步而不是通信的。这就是为什么有sync.WaitGroup数据结构,它涵盖了这些情况。它有一个称为计数器的主状态,表示等待的元素数:

type WaitGroup struct {
    noCopy noCopy
    state1 [3]uint32
}

noCopy字段防止结构被panic值复制。状态是由三个int32组成的数组,但只使用第一个和最后一个条目;剩下的一个用于编译器优化。

WaitGroup提供了三种方法来实现相同的结果:

  • Add:这会使用给定值更改计数器的值,该值也可能为负值。如果计数器低于零,则应用程序会死机。
  • Done:这是Add的缩写,以-1为参数。它通常在 goroutine 完成其工作时调用,以将计数器减量 1。
  • Wait:此操作阻止当前 goroutine,直到计数器达到零。

使用 wait 组会产生更干净、可读性更强的代码,如下面的示例所示:

func main() {
    wg := sync.WaitGroup{}
    wg.Add(10)
    for i := 1; i <= 10; i++ {
        go func(a int) {
            for i := 1; i <= 10; i++ {
                fmt.Printf("%dx%d=%d\n", a, i, a*i)
            }
            wg.Done()
        }(i)
    }
    wg.Wait()
}

对于等待组,我们正在添加一个等于 goroutines 的 delta,我们将提前启动它。在每个 goroutine 中,我们都使用Done方法来减少计数。如果不知道 goroutine 的数量,可以在启动每个 goroutine 之前执行Add操作(以1为参数),如下所示:

func main() {
    wg := sync.WaitGroup{}
    for i := 1; rand.Intn(10) != 0; i++ {
        wg.Add(1)
        go func(a int) {
            for i := 1; i <= 10; i++ {
                fmt.Printf("%dx%d=%d\n", a, i, a*i)
            }
            wg.Done()
        }(i)
    }
    wg.Wait()
}

在前面的例子中,我们有 10%的机会完成for循环的每个迭代,因此我们在开始 goroutine 之前向组中添加一个。

一个非常常见的错误是在 goroutine 中添加值,这通常会导致在没有执行任何 goroutine 的情况下过早退出。这是因为应用程序在例程开始并添加它们自己的增量之前创建 goroutine 并执行Wait函数,如下例所示:

func main() {
    wg := sync.WaitGroup{}
    for i := 1; i < 10; i++ {
        go func(a int) {
            wg.Add(1)
            for i := 1; i <= 10; i++ {
                fmt.Printf("%dx%d=%d\n", a, i, a*i)
            }
            wg.Done()
        }(i)
    }
    wg.Wait()
}

此应用程序不会打印任何内容,因为它在启动任何 goroutine 和调用Add方法之前到达Wait语句。

Go 单打

单例模式是软件开发中常用的策略。这涉及到在整个应用程序中使用同一实例,将特定类型的实例数限制为一个。该概念的一个非常简单的实现可以是以下代码:

type obj struct {}

var instance *obj

func Get() *obj{
    if instance == nil {
        instance = &obj{}
    }
    return instance
}

这在连续场景中是非常好的,但在并发场景中,就像在许多 Go 应用程序中一样,这不是线程安全的,可能会产生竞争条件。

前面的示例可以通过添加一个可以避免任何争用条件的锁来实现线程安全,如下所示:

type obj struct {}

var (
    instance *obj
    lock     sync.Mutex
)

func Get() *obj{
    lock.Lock()
    defer lock.Unlock()
    if instance == nil {
        instance = &obj{}
    }
    return instance
}

这是安全的,但速度较慢,因为每次请求实例时,Mutex都将同步。

实现此模式的最佳解决方案,如以下示例所示,是使用sync.Once结构,该结构负责使用Mutexatomic读数组合执行一次函数(我们将在本章第二部分中看到):

type obj struct {}

var (
    instance *obj
    once     sync.Once
)

func Get() *obj{
    once.Do(func(){
        instance = &obj{}
    })
    return instance
}

生成的代码是惯用且清晰的,并且与互斥解决方案相比具有更好的性能。因为操作只执行一次,所以我们也可以去掉前面示例中对实例所做的nil检查。

一次复位

sync.Once函数用于执行另一个函数一次,不再执行。有一个非常有用的第三方库,它允许我们使用Reset方法重置单例的状态。

包源代码可在以下网址找到:github.com/matryer/resync

典型的使用包括一些需要在特定错误时再次进行的初始化,例如获取 API 密钥或在连接中断时再次拨号。

资源回收

在前一章中,我们已经看到了如何通过一个缓冲通道和一个工人池来实现资源回收。将有以下两种方法:

  • 尝试从通道接收消息或返回新实例的Get方法。
  • 一种Put方法,尝试将实例返回到通道或放弃它。

这是一个带有通道的池的简单实现:

type A struct{}

type Pool chan *A

func (p Pool) Get() *A {
    select {
    case a := <-p:
        return a
    default:
        return new(A)
    }
}

func (p Pool) Put(a *A) {
    select {
    case p <- a:
    default:
    }
}

我们可以使用sync.Pool结构来改进这一点,该结构实现了一组线程安全的对象,这些对象可以被保存或检索。唯一需要定义的是创建新对象时池的行为:

type Pool struct {
    // 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{}
    // contains filtered or unexported fields
}

该池提供两种方式:GetPut。这些方法从池中返回一个对象(或创建一个新对象),然后将该对象放回池中。由于Get方法返回一个interface{},因此需要将该值转换为特定类型才能正确使用。我们广泛讨论了缓冲区回收,在下面的示例中,我们将尝试使用sync.Pool实现一个缓冲区回收。

我们需要定义池和函数来获取和释放新的缓冲区。我们的缓冲区的初始容量为 4KB,Put功能将确保在将缓冲区放回池中之前重置缓冲区,如下代码示例所示:

var pool = sync.Pool{
    New: func() interface{} {
        return bytes.NewBuffer(make([]byte, 0, 4096))
    },
}

func Get() *bytes.Buffer {
    return pool.Get().(*bytes.Buffer)
}

func Put(b *bytes.Buffer) {
    b.Reset()
    pool.Put(b)
}

现在我们将创建一系列 goroutine,它们将使用WaitGroup在完成时发出信号,并将执行以下操作:

  • 等待一定的时间(1-5 秒)。
  • 获取缓冲区。
  • 在缓冲区中写入信息。
  • 将内容复制到标准输出。
  • 释放缓冲区。

我们将使用一个等于1秒的睡眠时间,加上循环每4次迭代的另一秒,直到5

start := time.Now()
wg := sync.WaitGroup{}
wg.Add(20)
for i := 0; i < 20; i++ {
    go func(v int) {
        time.Sleep(time.Second * time.Duration(1+v/4))
        b := Get()
        defer func() {
            Put(b)
            wg.Done()
        }()
        fmt.Fprintf(b, "Goroutine %2d using %p, after %.0fs\n", v, b, time.Since(start).Seconds())
        fmt.Printf("%s", b.Bytes())
    }(i)
}
wg.Wait()

打印的信息还包含缓冲存储器地址。这将帮助我们确认缓冲区始终相同,并且不会创建新的缓冲区。

废物回收问题

对于具有底层字节片的数据结构,例如bytes.Buffer,我们在将它们与sync.Pool或类似的回收机制结合使用时应该小心。让我们更改前面的示例,收集缓冲区的字节,而不是将它们打印到标准输出。以下是此示例代码:

var (
    list = make([][]byte, 20)
    m sync.Mutex
)
for i := 0; i < 20; i++ {
    go func(v int) {
        time.Sleep(time.Second * time.Duration(1+v/4))
        b := Get()
        defer func() {
            Put(b)
            wg.Done()
        }()
        fmt.Fprintf(b, "Goroutine %2d using %p, after %.0fs\n", v, b, time.Since(start).Seconds())
        m.Lock()
        list[v] = b.Bytes()
        m.Unlock()
    }(i)
}
wg.Wait()

那么,当我们打印字节片列表时会发生什么?我们可以在以下示例中看到这一点:

for i := range list {
    fmt.Printf("%d - %s", i, list[i])
}

由于缓冲区被覆盖,我们得到了一个意外的结果。这是因为缓冲区正在重用相同的底层切片,并在每次使用时覆盖内容。

此问题的解决方案通常是执行字节的副本,而不仅仅是分配它们:

m.Lock()
list[v] = make([]byte, b.Len())
copy(list[v], b.Bytes())
m.Unlock()

条件

在并发编程中,条件变量是一种同步机制,其中包含等待相同条件验证的线程。在 Go 中,这意味着有一些 goroutine 正在等待某些事情发生。我们已经使用具有单个 goroutine 等待的通道实现了此功能,如以下示例所示:

ch := make(chan struct{})
go func() {
    // do something
    ch <- struct{}{}
}()
go func() {
    // wait for condition
    <-ch
    // do something else
}

此方法仅限于一个 goroutine,但可以对其进行改进,以支持更多侦听器从发送消息切换到关闭通道:

go func() {
    // do something
    close(ch)
}()
for i := 0; i < n; i++ {
    go func() {
        // wait for condition
        <-ch
        // do something else
    }()
}

关闭通道适用于多个侦听器,但不允许他们在通道关闭后进一步使用通道。

sync.Cond类型是一种可以更好地处理所有这些行为的工具。它在实现中使用了一个锁,并公开了三种方法:

  • Broadcast:这会唤醒所有等待条件的 goroutine。
  • Signal:如果至少有一个 goroutine,则会唤醒等待该条件的单个 goroutine。
  • Wait:解锁锁柜,暂停 goroutine 的执行,然后恢复执行并再次锁定,等待BroadcastSignal

不需要,但BroadcastSignal操作可以在握住储物柜、前后锁定和释放储物柜的同时进行。Wait方法要求在调用之前先持有锁柜,在使用条件后解锁锁柜。

让我们创建一个并发应用程序,它使用sync.Cond来编排更多的 goroutine。我们将从命令行得到一个提示,每条记录将被写入一系列文件。我们将有一个主结构,用于保存所有数据:

type record struct {
    sync.Mutex
    buf string
    cond *sync.Cond
    writers []io.Writer
}

我们将监测的情况是buf字段中的变化。在Run方法中,record结构将启动几个 goroutine,每个 writer 对应一个 goroutine。每个 goroutine 将等待条件触发,并将在其文件中写入:

func (r *record) Run() {
    for i := range r.writers {
        go func(i int) {
            for {
                r.Lock()
                r.cond.Wait()
                fmt.Fprintf(r.writers[i], "%s\n", r.buf)
                r.Unlock()
            }
        }(i)
    }
}

我们可以看到,我们在使用Wait之前锁定了条件,在使用了我们的条件引用的值之后解锁了条件。main 函数将根据提供的命令行参数创建一条记录和一系列文件:

// let's make sure we have at least a file argument
if len(os.Args) < 2 {
    log.Fatal("Please specify at least a file")
}
r := record{
    writers: make([]io.Writer, len(os.Args)-1),
}
r.cond = sync.NewCond(&r)
for i, v := range os.Args[1:] {
    f, err := os.Create(v)
    if err != nil {
        log.Fatal(err)
    }
    defer f.Close()
    r.writers[i] = f
}
r.Run()

然后我们将使用bufio.Scanner读取行并广播buf字段的变化。我们还将接受一个特殊值\q作为退出命令:

scanner := bufio.NewScanner(os.Stdin)
for {
    fmt.Printf(":> ")
    if !scanner.Scan() {
        break
    }
    r.Lock()
    r.buf = scanner.Text()
    r.Unlock()
    switch {
    case r.buf == `\q`:
        return
    default:
        r.cond.Broadcast()
    }
}

我们可以看到,buf的更改是在保持锁的同时完成的,之后是对Broadcast的调用,这会唤醒所有等待条件的 goroutine。

同步地图

Go 中的内置映射不是线程安全的,因此,尝试从不同的 Goroutine 写入可能会导致运行时错误:concurrent map writes。我们可以使用一个简单的程序来验证这一点,该程序尝试同时进行更改:

func main() {
    var m = map[int]int{}
    wg := sync.WaitGroup{}
    wg.Add(10)
    for i := 0; i < 10; i++ {
        go func(i int) {
            m[i%5]++
            fmt.Println(m)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

边读边写也是一个运行时错误,concurrent map iteration and map write,我们可以通过运行以下示例看到这一点:

func main() {
    var m = map[int]int{}
    var done = make(chan struct{})
    go func() {
        for i := 0; i < 100; i++ {
            time.Sleep(time.Nanosecond)
            m[i]++
        }
        close(done)
    }()
    for {
        time.Sleep(time.Nanosecond)
        fmt.Println(len(m), m)
        select {
        case <-done:
            return
        default:
        }
    }
}

有时,尝试迭代映射(如Print语句所做的)可能会导致恐慌,例如index out of range,因为内部切片可能已分配到其他地方。

使映射并发的一个非常简单的策略是将其与sync.Mutexsync.RWMutex耦合。这使得在执行以下操作时可以锁定映射:

type m struct {
    sync.Mutex
    m map[int]int
}

我们使用映射来获取或设置值,例如:

func (m *m) Get(key int) int {
    m.Lock()
    a := m.m[key]
    m.Unlock()
    return a
}

func (m *m) Put(key, value int) {
    m.Lock()
    m.m[key] = value
    m.Unlock()
}

我们还可以传递一个函数,该函数接受一个键值对,并对每个元组执行它,同时锁定映射:

func (m *m) Range(f func(k, v int)) {
    m.Lock()
    for k, v := range m.m {
        f(k, v)
    }
    m.Unlock()
}

Go 1.9 引入了一个名为sync.Map的结构,它正是这样做的。它是一个非常通用的map[interface{}]interface{},可以使用以下方法执行线程安全操作:

  • Load:从映射中获取给定键的值。
  • Store:在地图中为给定的键设置一个值。
  • Delete:从地图中删除给定密钥的条目。
  • LoadOrStore:返回键的值(如果存在)或存储的值。
  • Range:调用一个函数,该函数为映射中的每个键值对返回一个布尔值。如果返回false,则迭代停止。

我们可以在下面的代码段中看到这是如何工作的,在该代码段中,我们尝试同时尝试几次写入:

func main() {
    var m = sync.Map{}
    var wg = sync.WaitGroup{}
    wg.Add(1000)
    for i := 0; i < 1000; i++ {
        go func(i int) {
            m.LoadOrStore(i, i)
            wg.Done()
        }(i)
    }
    wg.Wait()
    i := 0
    m.Range(func(k, v interface{}) bool {
        i++
        return true
    })
   fmt.Println(i)
}

此应用程序与具有常规Map的版本不同,不会崩溃并执行所有操作。

信号量

在前一章中,我们看到了如何使用通道来创建加权信号量。在实验性的sync包中有一个更好的实现。这可以在以下网址找到:golang.org/x/sync/semaphore

这个实现使得创建一个新的信号量成为可能,通过semaphore.NewWeighted指定权重。

可以使用Acquire方法获取配额,指定要获取的配额数量。可以使用Release方法释放,如下例所示:

func main() {
    s := semaphore.NewWeighted(int64(10))
    ctx := context.Background()
    for i := 0; i < 20; i++ {
        if err := s.Acquire(ctx, 1); err != nil {
            log.Fatal(err)
        }
        go func(i int) {
            fmt.Println(i)
            s.Release(1)
        }(i)
    }
    time.Sleep(time.Second)
}

获取配额需要除数字之外的另一个参数,即context.Context。这是 Go 中提供的另一个并发工具,我们将在下一章中看到如何使用它。

原子操作

sync包提供同步原语,并且在幕后,它对整数和指针使用线程安全操作。我们可以在另一个名为sync/atomic的包中找到这些功能,该包可用于创建特定于用户用例的工具,具有更好的性能和更少的内存使用。

整数运算

有一系列函数用于指向不同类型整数的指针:

  • int32
  • int64
  • uint32
  • uint64
  • uintptr

这包括表示指针uintptr的特定类型的整数。这些类型的可用操作如下所示:

  • Load:从指针获取整数值
  • Store:将整数值存储在指针中
  • Add:将指定的增量添加到指针值
  • Swap:在指针中存储新值并返回旧值
  • CompareAndSwap:仅当新值与指定值相同时,才将新值替换为旧值

点击器

这个函数对于非常容易地定义线程安全组件非常有帮助。一个非常明显的例子是一个简单的整数计数器,它使用Add更改计数器,Load检索当前值,Store重置当前值:

type clicker int32

func (c *clicker) Click() int32 {
    return atomic.AddInt32((*int32)(c), 1)
}

func (c *clicker) Reset() {
    atomic.StoreInt32((*int32)(c), 0)
}

func (c *clicker) Value() int32 {
    return atomic.LoadInt32((*int32)(c))
}

我们可以在一个简单的程序中看到它的作用,该程序尝试同时读取、写入和重置计数器。

我们定义了clickerWaitGroup,并将正确数量的元素添加到等待组中,如下所示:

c := clicker(0)
wg := sync.WaitGroup{}
// 2*iteration + reset at 5
wg.Add(21)

我们可以启动一系列 goroutine 来执行不同的操作,例如:10 次读取、10 次添加和一次重置:

for i := 0; i < 10; i++ {
    go func() {
        c.Click()
        fmt.Println("click")
        wg.Done()
    }()
    go func() {
        fmt.Println("load", c.Value())
        wg.Done()
    }()
    if i == 0 || i%5 != 0 {
        continue
    }
    go func() {
        c.Reset()
        fmt.Println("reset")
        wg.Done()
    }()
}
wg.Wait()

我们将看到 clicker 按其预期的方式运行,在没有竞争条件的情况下执行并发求和。

线程安全浮动

atomic包只提供整数原语,但由于float32float64存储在int32int64使用的相同数据结构中,因此我们使用它们来创建原子浮点值。

诀窍是使用math.Floatbits函数将浮点表示为无符号整数,使用math.Floatfrombits函数将无符号整数转换为浮点。让我们看看这是如何与float64一起工作的:

type f64 uint64

func uf(u uint64) (f float64) { return math.Float64frombits(u) }
func fu(f float64) (u uint64) { return math.Float64bits(f) }

func newF64(f float64) *f64 {
    v := f64(fu(f))
    return &v
}

func (f *f64) Load() float64 {
  return uf(atomic.LoadUint64((*uint64)(f)))
}

func (f *f64) Store(s float64) {
  atomic.StoreUint64((*uint64)(f), fu(s))
}

创建Add函数有点复杂。我们需要用Load获取值,然后进行比较和交换。由于此操作可能会失败,因为加载是一个atomic操作,比较和交换CAS)是另一个操作,所以我们一直在尝试,直到它在循环中成功:

func (f *f64) Add(s float64) float64 {
    for {
        old := f.Load()
        new := old + s
        if f.CompareAndSwap(old, new) {
            return new
        }
    }
}

func (f *f64) CompareAndSwap(old, new float64) bool {
    return atomic.CompareAndSwapUint64((*uint64)(f), fu(old), fu(new))
}

线程安全布尔值

我们还可以使用int32表示布尔值。我们可以使用整数0作为false,使用1作为true,创建一个线程安全的布尔条件:

type cond int32

func (c *cond) Set(v bool) {
    a := int32(0)
    if v {
        a++
    }
    atomic.StoreInt32((*int32)(c), a)
}

func (c *cond) Value() bool {
    return atomic.LoadInt32((*int32)(c)) != 0
}

这将允许我们使用cond类型作为线程安全的布尔值。

指针操作

Go 中的指针变量存储在intptr变量中,这些变量是足以容纳内存地址的整数。atomic包可以对其他整数类型执行相同的操作。有一个包允许不安全的指针操作,它提供原子操作中使用的unsafe.Pointer类型。

在下面的示例中,我们定义了两个整数变量及其相对整数指针。然后执行第一个指针与第二个指针的交换:

v1, v2 := 10, 100
p1, p2 := &v1, &v2
log.Printf("P1: %v, P2: %v", *p1, *p2)
atomic.SwapPointer((*unsafe.Pointer)(unsafe.Pointer(&p1)), unsafe.Pointer(p2))
log.Printf("P1: %v, P2: %v", *p1, *p2)
v1 = -10
log.Printf("P1: %v, P2: %v", *p1, *p2)
v2 = 3
log.Printf("P1: %v, P2: %v", *p1, *p2)

交换之后,两个指针现在都指向第二个变量;对第一个值的任何更改都不会影响指针。更改第二个变量会更改指针引用的值。

价值

我们可以使用的最简单的工具是atomic.Value。这将保持interface{},并使其能够以线程安全的方式读写。它公开了两种方法,StoreLoad,可以设置或检索值。碰巧,对于其他线程安全工具,sync.Value首次使用后不得复制。

我们可以尝试使用多个 goroutine 来设置和读取相同的值。每个加载操作都会获取最新的存储值,并发不会引发任何错误:

func main() {
    var (
        v atomic.Value
        wg sync.WaitGroup
    )
    wg.Add(20)
    for i := 0; i < 10; i++ {
        go func(i int) {
            fmt.Println("load", v.Load())
            wg.Done()
        }(i)
        go func(i int) {
            v.Store(i)
            fmt.Println("store", i)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

这是一个非常通用的容器;它可以用于任何类型的变量,并且变量类型应该从一种类型更改为另一种类型。如果具体类型发生变化,会使方法死机;同样的事情也适用于nil空接口。

幕后

sync.Value类型将其数据存储在非导出接口中,如源代码所示:

type Value struct {
    v interface{}
}

它使用一种类型的unsafe包将该结构转换为另一种结构,该结构具有与接口相同的数据结构:

type ifaceWords struct {
    typ unsafe.Pointer
    data unsafe.Pointer
}

可以通过这种方式转换具有相同内存布局的两种类型,从而跳过 Go 的类型安全性。这使得使用指针的atomic操作和执行线程安全的StoreLoad操作成为可能。

为了获得写入值的锁,atomic.Value使用与类型中的unsafe.Pointer(^uintptr(0))值(即0xffffffff值)的比较和交换操作;它会更改值并用正确的类型替换类型。

同样,在尝试读取值之前,加载操作循环直到类型与0xffffffff不同。

使用此权宜之计,atomic.Value能够使用其他atomic操作存储和加载任何值。

总结

在本章中,我们看到了 Go 标准包中提供的用于同步的工具。它们位于两个包中:sync提供互斥体等高级工具,以及sync/atomic执行低级操作。

首先,我们了解了如何使用储物柜同步数据。我们了解了如何使用sync.Mutex来锁定资源,而不考虑操作类型,以及sync.RWMutex如何允许并发读取和阻塞写入。我们应该小心使用第二个,因为连续读取可能会延迟写入。

接下来,我们看到了如何使用sync.WaitGroup跟踪运行操作,以等待一系列 goroutine 的结束。这充当当前 goroutine 的线程安全计数器,并使用Wait方法使当前 goroutine 进入睡眠状态,直到达到零。

此外,我们还检查了用于执行一次功能的sync.Once结构,例如,它允许实现线程安全的单例。然后我们使用sync.Pool重用实例,而不是尽可能创建新实例。池唯一需要的是返回新实例的函数。

sync.Condition结构表示一个特定的条件,并使用一个锁来更改它,允许 goroutine 等待更改。这可以使用Signal传递到单个 goroutine,也可以使用Broadcast传递到所有 goroutine。该软件包还提供了线程安全版本的sync.Map

最后,我们检查了atomic的功能,这些功能主要是整数线程安全操作:加载、保存、添加、交换和 CAS。我们还看到了atomic.Value,这使得可以同时更改接口的值,并且不允许在第一次更改后更改类型。

下一章将介绍 Go 并发中引入的最新元素:Context,这是一个处理截止日期、取消等的接口。

问题

  1. 什么是比赛条件?
  2. 当您尝试与映射同时执行读写操作时会发生什么?
  3. MutexRWMutex有什么区别?
  4. 为什么等待组有用?
  5. Once的主要用途是什么?
  6. 您如何使用Pool
  7. 使用原子操作有什么好处?