应用并发示例
到目前为止,我们已经了解了 goroutine 和通道的主要操作和行为。这两种并发机制非常重要,因为它们是 Go 实现并发的关键。然而,Go 标准库还在其 sync
包中提供了并发原语。它包含各种用途的同步原语:
-
sync.Map
是一个并发安全的映射实现。我们将在下一节中探讨如何创建其他线程安全的数据结构。 -
sync.Mutex
是一个排他锁。它允许我们控制资源,一次只能由一个 goroutine 使用。根据解决的问题,还可以使用只读或读写互斥锁。 -
sync.Once
是一个只能获取一次的特殊锁。这对于包装诸如清理代码之类的语句非常有用,这些代码应该只运行一次。 -
sync.Pool
是一个临时对象集合,可以单独保存和检索。它可以被视为对象缓存,使得创建线程安全的列表变得容易。 -
sync.WaitGroup
用于等待一组goroutine
完成。该原语内部有一个计数器和一个锁,使其能够跟踪在完成之前需要等待多少个goroutine
。这可以大大简化主goroutine
。
您可以在官方 Go 文档( https://pkg.go.dev/sync )中阅读 sync
包中同步原语的完整文档。这些设计良好的同步原语为我们提供了解决多种类型问题的工具。让我们在接下来的章节中看看其中一些的实际应用。
只关闭一次
正如我们在图9.5 中看到的,如果我们尝试多次关闭通道,通道会引发 panic
。这是使用 sync.Once
的一个很好的候选场景,尽管我们可以想象这种机制的其他重要用途,例如实现单例模式或执行清理函数。
这种专用锁很容易使用,以确保通道只关闭一次:
func safelyClose(once *sync.Once, ch chan struct{}) {
fmt.Println("Hello, friend!")
once.Do(func() {
fmt.Println("Channel closed.")
close(ch)
})
}
func main() {
var once sync.Once
ch := make(chan struct{})
for i := 0; i < 3; i++ {
go safelyClose(&once, ch)
}
<-ch
fmt.Println("Goodbye, friend!")
}
我们通过将关闭操作包装在 sync.Once
中来实现通道的安全关闭:
-
我们创建了
safelyClose
函数,该函数接收一个指向sync.Once
类型的指针和由main
函数创建的通道。请注意,与通道类型不同,我们需要使用显式参数指针类型传递Once
类型。 -
在
safelyClose
函数内部,我们在once.Do
方法中调用通道的关闭操作。Do
方法接收一个函数作为参数,因此我们将语句包装在一个匿名函数中。 -
在
main
函数内部,我们创建了一个零值的sync.Once
实例。同步原语不需要特殊的初始化,因此零值可以直接使用。 -
我们使用
for
循环创建多个执行safelyClose
函数的 goroutine。这些goroutine
共享相同的once
和通道实例。 -
最后,我们通过从通道接收操作来阻塞主
goroutine
。一旦第一个 goroutine 关闭通道,该操作将完成。
运行示例程序显示启动了多个 goroutine
,但通道只关闭一次:
$ go run chapter09/concurrency/once/main.go
Hello, friend!
Channel closed.
Hello, friend!
Goodbye, friend!
Hello, friend!
从输出中可以看出,启动了多个 goroutine
,但通道只关闭了一次。sync.Once
使用简单,但它可以帮助我们在只应执行一次的操作(例如关闭通道)周围构建安全性。
线程安全的数据结构
工程师经常解决的另一个问题是构建线程安全的数据结构。这些类型的数据结构可以安全地被多个 goroutine
读取和写入。默认情况下,Go 的切片和映射不是并发安全的,因此我们需要注意多个 goroutine
访问共享数据结构和资源的情况。这也是为什么使用通道(它们是线程安全的)进行通信比通过共享内存(由数据结构或变量表示)进行通信更受青睐的原因之一。
sync.Map
( https://pkg.go.dev/sync#Map )是一个线程安全的映射实现。该映射在底层使用锁,因此它的性能不如内置的映射类型。同步映射暴露了提供读写功能的方法:
const workerCount = 3
func greet(id int, smap *sync.Map, done chan struct{}) {
g := fmt.Sprintf("Hello, friend! I'm Goroutine %d.", id)
smap.Store(id, g)
done <- struct{}{}
}
func main() {
var smap sync.Map
done := make(chan struct{})
for i := 0; i < workerCount; i++ {
go greet(i, &smap, done)
}
for i := 0; i < workerCount; i++ {
<-done
}
smap.Range(func(key, value any) bool {
fmt.Println(value)
return true
})
fmt.Println("Goodbye, friend!")
}
我们通过包装方法与同步映射进行交互:
-
我们在程序顶部声明了
workerCount
常量,它表示我们将启动的goroutine
数量。 -
greet
函数接收三个参数:一个ID
、一个指向sync.Map
的指针以及一个用于通知主goroutine
工作完成的通道。我们格式化一个问候字符串,使用传入的 ID,然后使用Store
方法将其保存到映射中,并向done
通道写入一个值,以通知主 goroutine 该工作goroutine
已完成。 -
在
main
函数内部,我们初始化了映射。与之前看到的sync.Once
一样,该映射的零值可以直接使用。我们还初始化了一个通道,用于通知主 goroutine 工作goroutine
已完成。 -
然后,我们运行两个
for
循环。第一个循环在各自的 goroutine 中启动greet
函数,而第二个循环等待从done
通道接收值。这使我们能够等待所有goroutine
完成后再继续。 -
最后,我们使用
Range
方法读取映射中包含的所有值,该方法接收一个匿名函数作为参数。我们打印条目并返回true
,这将允许Range
方法继续循环。
该程序的输出显示问候语可以并发地保存和检索:
$ go run chapter09/concurrency/syncmap/main.go
Hello, friend! I'm Goroutine 2.
Hello, friend! I'm Goroutine 0.
Hello, friend! I'm Goroutine 1.
Goodbye, friend!
内置的映射类型在多个 goroutine
写入时会引发 panic
,因此在这种情况下应确保使用同步映射。
类似于 sync.Map
的方法,我们可以使用 sync.Mutex
锁来创建自己的线程安全自定义数据结构,以限制对底层数据结构的访问。例如,我们可以通过以下方法创建一个线程安全的 后进先出(LIFO)栈:
// 线程安全的 LIFO 栈实现
type Stack struct {
lock sync.Mutex
data []string
}
// Push 将给定元素添加到列表末尾
func (s *Stack) Push(el string) {
defer s.lock.Unlock()
s.lock.Lock()
s.data = append(s.data, el)
}
// Pop 移除并返回列表中的最后一个元素,
// 如果列表为空,则返回错误。
func (s *Stack) Pop() (*string, error) {
defer s.lock.Unlock()
s.lock.Lock()
if len(s.data) == 0 {
return nil, fmt.Errorf("stack is empty")
}
last := s.data[len(s.data)-1]
s.data = s.data[0 : len(s.data)-1]
return &last, nil
}
栈实现使用了 sync.Mutex
,它暴露了 Lock
和 Unlock
两个方法,以限制对底层数据切片的访问:
-
自定义的
Stack
结构有两个字段:一个锁和一个数据切片。这些是未导出的字段,因为它们应该仅由栈数据结构本身管理。 -
Stack
有两个方法。Push
将元素添加到数据切片的末尾,而Pop
从数据切片中移除最后一个元素并返回它。如果切片为空,则Pop
方法将返回错误。 -
两个函数都使用
sync.Mutex
类型的锁来确保这两个方法一次只能由一个 goroutine 调用。我们使用defer
关键字来确保无论方法通过哪个执行路径,锁都会被释放。
sync.Mutex
是一种多功能的锁定机制,可用于阻止访问任何访问共享资源或需要唯一控制资源的代码段。这被称为 关键代码段。
类似地,sync
包还提供了 sync.RWMutex
,它提供了分别控制读取和写入锁的能力。这种控制级别可能对创建由许多 goroutine 使用的线程安全数据结构很有用。
等待完成
在本节中,我们将探讨的最后一个同步原语是 sync.WaitGroup
。在底层,WaitGroup
管理一个内部计数器,该计数器维护还有多少资源需要完成。这种专用锁允许我们等待多个 goroutine 完成,从而使我们能够简化上一节中的同步映射示例:
const workerCount = 3
func greet(id int, smap *sync.Map, wg *sync.WaitGroup) {
defer wg.Done()
g := fmt.Sprintf("Hello, friend! I'm Goroutine %d.", id)
smap.Store(id, g)
}
func main() {
var smap sync.Map
var wg sync.WaitGroup
wg.Add(workerCount)
for i := 0; i < workerCount; i++ {
go greet(i, &smap, &wg)
}
wg.Wait()
smap.Range(func(key, value any) bool {
fmt.Println(value)
return true
})
fmt.Println("Goodbye, friend!")
}
我们进行了一些关键更改,大大简化了解决方案:
-
greet
函数接收一个指向sync.WaitGroup
的指针,而不是done
通道。在函数顶部,我们使用defer
调用WaitGroup
的Done
方法,该方法将其内部计数器减 1,表示该 goroutine 已完成。 -
在
main
函数内部,我们初始化了sync.WaitGroup
,它已准备好使用。我们将workerCount
添加到内部计数器中,向它发出信号,表明我们将启动多少个 goroutine。WaitGroup
将阻塞,直到此内部计数器达到 0,这将在每个子 goroutine 完成时调用一次Done
方法时发生。 -
最后,我们在
main
函数中调用Wait
方法。该方法将阻塞,直到WaitGroup
的内部计数器达到 0。这消除了在for
循环中为每个完成的 goroutine 从通道读取消息的需要。
至此,我们结束了对 Go 并发基础知识和应用的探索。正如我们所看到的,Go 并发利用了 goroutine
、通道和同步原语。我们可以轻松地使用锁来创建线程安全的数据结构,并确保关键代码段一次只能由一个 goroutine
访问。在下一节中,我们将学习并发引入的新问题给我们的程序带来了哪些挑战。