使golang包成为线程安全的

290 阅读9分钟

并发是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的过程是通过三个简单的步骤完成的:

  1. 构建包以避免让用户直接访问数据。
  2. 用一个Mutex包住所有的数据访问。
  3. 不要返回指针;返回副本。

通过应用这些步骤并在你的测试过程中添加--race 标志。你可以避免未来的数据竞赛条件使你的生产应用程序崩溃。