并发是Go语言的一项基础能力,所以写代码时不费吹灰之力就能利用多个goroutines,有时甚至不知道。
举个例子,如果你曾经使用过net/http 包来创建一个网络服务,你就使用过goroutines。
为了处理传入的HTTP流量,HTTP服务器将为每个连接生成一个goroutine,而在HTTP/2下,每个请求都会生成一个goroutine。这些goroutine对用户来说是透明的;除非你读过文档,否则你不会知道它们是goroutine。
这种易用性使得创建多goroutine应用程序变得简单,它也使得创建数据竞赛条件变得容易。
数据竞赛
数据竞赛是指至少有两个线程或在此情况下,goroutine试图访问相同的数据。例如,一个goroutine试图读取数据,而另一个正在写数据。这种情况会导致Go程序的恐慌和崩溃。
为了更好地解释,我们假设在我们的应用程序中创建了一个简单的Global,持有一个计数器。
var counter int
还有一个HTTP处理程序来递增这个计数器。
因为每个HTTP请求都会启动自己的goroutine,所以很有可能两个goroutine(HTTP请求)会同时试图增加计数器。这将导致恐慌情况的发生。
这个例子的不幸之处在于,这种数据竞赛可能无法通过基本测试发现。数据竞赛是关于时间的,两个请求必须在同一时间增加计数器。
这个例子的错误完全有可能通过标准测试。
启用数据竞赛检测
然而,Go有一个简单的方法来检测数据竞赛情况。在执行测试时,可以使用--race 标志来开启数据竞赛检测:
$ go test --race ./...
设置此标志后,执行 HTTP 处理程序的测试将触发 Go 的能力,以识别跨 goroutine 的数据访问。
作为一个最佳实践,我们建议始终使用--race 标志。我的建议是,只要你能在项目中启用--race 标志。时间过的越久,就会有更多的竞争条件出现。最好的办法是尽快解决这些问题,这样它们就不会出现在生产中。
构建线程安全(goroutine-safe)的包
现在我们知道了数据竞赛的危险,本文将介绍一些使包的线程安全的最佳实践。本文主要关注的是如何构建包以避免数据竞赛的情况,但也涉及到Go的并发性实践。
创建一个目录包
在这篇文章中,我们将创建一个最初不安全的实例包,然后使该包成为goroutine安全的。
我们要创建的包是一个人的目录包。这个包的目标相当简单,就是为用户提供一种存储和查找新的 "人 "的方法。
从一个结构开始
每当我创建一个新包时,我喜欢从创建包的基本结构开始。这个结构包括初始类型、方法、函数和注释。
我以这种方式开始,因为我觉得这有助于我注意到用户将如何与我的包互动。它允许我思考所需的功能以及如何使用它。
对于这个包,我们将从以下方面入手。
在这个基础结构中,有一个我想调用的函数,那就是New() 函数。
每次运行New() ,它将创建一个新的Directory 的实例,可以存储一组独特的数据。这与使用init() 函数为包创建一个单一的全局实例是相反的。
与使用init() 函数相比,我更喜欢拥有一个New() 函数的模式,因为New() 模式允许用户根据需要创建多个实例。这也使得用户更容易测试他们自己的代码,而不会因为在测试中重复使用同一个实例而得到意外的结果。
创建测试
随着基础包结构的定义,现在是创建测试案例的最佳时机。在添加逻辑之前添加测试可能看起来很奇怪,但在这个阶段添加测试有助于创建一个更完整的包。
你可能知道这种做法是TDD,即测试驱动开发。网上大多数关于TDD的例子显示了如何使用TDD来重构或增加现有代码的功能。很少有人描述如何在一个全新的代码库中使用这种做法。
我对新代码的TDD方法是创建一个基础包结构,然后添加测试,这些测试会因为缺乏逻辑而失败。当我写逻辑时,测试从失败到通过。
我发现这种方法可以帮助我思考用户将如何调用我的包,有时在写测试的时候,我意识到我做错了什么,不得不重新调整我的基础。
现在,我们将跳到下一步,因为如何编写实用的测试是另一篇文章。
添加逻辑(还不是goroutine-safe)
我们现在准备开始为我们的包添加逻辑。
为了更好地解释如何使代码对goroutine安全,下面的代码例子是不安全的,可以使用。如果你按原样运行这段代码,它就会出现恐慌。
现在我们有了功能化的代码,让我们把它分解开来,让它成为一个goroutine-safe。
读和写互斥
虽然上面的代码目前还不是goroutine安全的,但它的结构使goroutine安全更容易。
例如,Directory.name 这个变量不是公开的,而是有两个方法Directory.Name() &Directory.SetName() 。如果我们把Directory.name 公开,那么用户就有可能试图同时读和写它。
在我们的两个方法中,我们将实现一个读写互斥,允许我们 "锁定 "对Directory.name 的访问。
Mutex或Mutual Exclusion是一个作为特定资源锁的对象。它与线程一起使用,以便协调访问。读写互斥器对读和写操作的处理方式不同。一般来说,允许多个读操作同时发生,而写锁则阻止所有的访问,除了锁定它的线程。
导入Sync包
使用读/写互斥的第一步是从标准库中导入sync包。
通过导入sync ,我们现在可以使用sync.RWMutex 。
在目录中嵌入一个Mutex
在Go中,将一个类型定义在另一个类型中是一个叫做嵌入的过程。这将嵌入的类型的功能嵌入到定义的类型中。
上面将sync.RWMutex 嵌入到Directory 中,使Directory 的实例具有与读写互斥体相同的方法和功能。
我们可以在下面的代码中看到这个动作。
在这个例子中,我们在我们的Directory.SetName() 方法中添加了两行,第一行调用已经从sync.RWMutex 嵌入的Lock()方法。
Directory 类型没有定义Lock() 方法,但sync.RWMutex 有。这个sync.RWMutex.Lock() 方法创建了一个独占锁,这将导致任何其他对Lock() 的调用被阻止或等待,直到Unlock() 被调用。
这个独占锁对于协调对数据的访问是非常好的。每次执行Directory.SetName() ,独占锁确保设置了锁的goroutine是唯一一个改变d.name 值的人。
读取锁
虽然独占锁确保每次只有一个线程有访问权,但对于经常被读取的数据来说,这可能是非常低效的。如果我们对每次读取都使用独占锁,那么每次只有一个线程可以读取d.name 。
对于Directory.Name() 方法,我们使用了RLock() ,它创建了一个读锁。
如果已经有一个独占锁被启用,那么RLock() ,就像Lock() ,会阻塞。但如果此刻独占锁是关闭的,那么RLock() 将创建一个读锁。有了读锁,多个线程可以运行RLock() ,而不需要等到锁被释放。
就像RLock() 必须等待独占锁被释放一样,Lock() 也将等待,直到所有RLock() 的读锁被释放。
解锁时要注意
什么时候使用RLock() 与Lock() 是非常直接的。如果你只读数据,使用RLock() 。如果你要写数据,使用Lock() 。这在使用Mutex时提供了最好的性能。
然而,如果你不注意的话,互换RLock() 和Lock() 可能会有问题。一个常见的错误是使用RLock() 来添加一个读锁,但随后使用Unlock() 来释放锁,而不是RUnlock() 。
Unlock() 函数只释放一个由Lock() 创建的锁。如果在没有启用独占锁的情况下执行,应用程序就会出现恐慌。
记住对读锁使用RUnlock() ,这一点非常重要。
使用defer来释放互斥锁
从上面的代码例子中可以看出,在释放独占锁时,我们使用了defer 函数。
defer 函数使用户能够 "推迟 "或推迟所提供的函数的执行,直到周围的函数返回。
这基本上意味着,通过在上面的例子中使用defer ,在Name() 方法返回d.name 的值后,d.RUnlock() 函数将被执行。
使用defer 是一种释放资源的好方法,比如互斥锁。特别是当周围函数内的逻辑很复杂,可能有多条路径通往return 。
地图是指针
我学到的一个常见的Go错误是,Maps在Go中是指针。因此,Maps不仅在本质上不是goroutine-safe。当它们从一个函数中返回时也会有问题。
由于Mutex的存在,下面的方法可能看起来是goroutine-safe的,但它不是。
Directory() 方法不是goroutine-safe的,因为它直接向用户返回d.directory 地图。乍一看,很容易认为这个方法返回的地图是d.directory 的副本。通常,任何没有星号的返回值* 是一个副本。但是地图是不同的。
在Go中,Maps持有引用,其作用很像指针。因此,当我们返回一个地图作为我们方法的一部分时,我们返回的是对原始地图的引用。
如果用户在另一个线程对该地图进行其他操作时,对返回的地图进行操作,应用程序就会出现恐慌。
安全地返回一个地图
为了安全地返回一个地图,我们需要创建一个新的地图,并将旧地图的值复制到新地图上。我们可以通过简单地改变我们的方法来做到这一点。
总结
通过我们最后的修改,创建一个返回地图的副本,我们的包现在是goroutine-safe。
使其成为goroutine-safe的过程是通过三个简单的步骤完成的:
- 构建包以避免让用户直接访问数据。
- 用一个Mutex包住所有的数据访问。
- 不要返回指针;返回副本。
通过应用这些步骤并在你的测试过程中添加--race 标志。你可以避免未来的数据竞赛条件使你的生产应用程序崩溃。