2

在 golang 中如何解决数据竞争 (data race) 问题?

 8 months ago
source link: https://hsiaofongw.notion.site/golang-data-race-6c5c20a400874f24bcf073cf6b66f1d1
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
photo-1488590528505-98d2b5aba04b?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&w=2400
Drag image to reposition

在 golang 中如何解决数据竞争 (data race) 问题?

Created
December 31, 2023 7:57 AM
golang
concurrency
data-race
Description
简单演示解决数据竞争问题的两种方法
Direct
Updated
December 31, 2023 10:37 AM
3 more properties
数据竞争 (data race) 是指多个线程/进程同时访问一个共享资源时,最终计算结果的正确性依赖于线程/进程的执行顺序的这种现象。
举例来说,线程 t1 和线程 t2 都要对一个全局变量 int x = 0 做“自增 1“操作,而线程 t 对变量 x 做出的一个(非原子性的)自增操作实际上会被编译器分解为下列三个更细粒度的、更加“基本”的操作:
读取变量 x 的值到寄存器中,例如 ldr r0, x
对寄存器加 1:add r0, r0, 1
把寄存器的值写回 x:str r0, x
对于单线程的程序来说,这样一个简单的自增过程几乎不可能出错。然而,假如有两个线程“同时”对同一个变量做这样的自增操作,事情就不一样了:因为自增操作默认情况下不是一个原子操作,一个线程要自增一个变量它要做三件事(执行以上三条指令),如果中间有了差错,最终结果就不对,例如:
线程 t1: ldr r0, x
线程 t2: ldr r0, x
线程 t1: add r0, r0, 1
线程 t2: add r0, r0, 1
线程 t1: str r0, x
线程 t2: str r0, x
假设 int x 的初始值是 0,那么容易验证,经过上述六条指令,最终 x 的值一定是 1 而不是 2,这就是两个线程的指令以错误的执行顺序去操作一个共享资源 (int x) 导致的最终计算结果的错误。
这 6 条指令当且仅当以下列这两种顺序执行时,最终结果才是正确的:
Plain Text
Plain Text
换个角度解释:在多线程情形中,一个线程 t 在自增一个变量 x 的时候,为了保证最终结果的正确性,t 必须某种程度上「独占」这个变量 x。
在 golang 中,我们可以创建一个简单的 http server 程序来复现上文中提到的 data race 问题。这个示例 http server 接近于一个 minimal working example(最小工作样本),因此它的逻辑比较简单。
假设这个 server 是用来提供”点赞“服务的,它提供两个接口 GET /get-upvotes 和 POST /upvotes,它把赞数存储在一个全局变量中,然后 GET /get-upvotes 的实现就是去读取这个全局变量的值、10 进制格式化后作为 http response 报文的 body 返回,POST /upvotes 的实现不接受任何参数,就是去自增这个变量:
我们可以首先运行并测试一下这两个接口:
Plain Text
其中的百分号是 terminal 自动填充的,可以发现目前是一切正常。
接下来我们修改一下代码,模拟 data race 情景的出现:
然后再重新运行:
Plain Text
这就暴露出了 data race 问题。
接下来我们介绍两种方法:基于 mutex 互斥锁的和基于 channel 的。

解法一:加锁

mutex 互斥锁可以用来保证一段代码最多只能同时被一个线程执行,一个 mutex 对象提供两个方法:加锁和解锁,设 m 是一个全局作用域的 mutex 互斥锁对象,那么:
当多个线程“同时”开始执行这个函数的时候,lock 和 unlock 调用中间的那部分代码在任意时刻最多只能由一个线程来执行,换句话说加入线程 t1 已经在执行中间的这一段代码了,那么在它调用 unlock 之前,其它任何线程都不可能执行 lock 和 unlock 语句中间夹住的代码块的任何一句。这就是用 mutex 保护共享资源的基本原理:确保一个代码段一次只能由一个线程“独占”,其它线程“非请莫入”。
具体来说,假如一个线程已经调用了 Lock 方法(进行加锁),那么后续的线程再调用 Lock 方法时(对于这个线程而言)就会遇到阻塞,直到加锁的那个线程调用 UnLock 方法(进行解锁)之后,其它线程才有机会竞争加锁,同样这时有剩下的其中一个线程还竞争不到锁,那它就只能继续等着。
就好比一个带从内反锁功能的卫生间,一个线程进这个卫生间后,它可以从内把门锁住,那么其它线程就进不来了。Lock 这个方法就相当于“进卫生间,然后从内锁门”,UnLock 这个方法就相当于 “解锁,然后开门出来“,当然只不过 Lock 和 UnLock 本身已经是(确保了)原子化的。那么马桶/蹲坑就相当于共享资源,在最开始的例子中马桶就是那个共享变量 int x 或者 golang 例子中的 upvotes int64。
mutex 的底层实现,一般来说是基于“信号量” (semaphore) 这种操作系统内核提供的数据结构的,semaphore 的性质读者可以在 CSAPP 书中找到详细的讨论。
现在,我们引入 mutex,并且把代码改成这样:
主要是把 handlePostUpvote 函数体的 doUpvote 改成了它的 atomic 版本,然后这个 doUpvote 本身的实现不变(把它看作一个不可更改的第三方 API 接口),在 doUpvoteAtomic 中把 doUpvote 包上,defer 关键字实际上是一个语法糖,defer 后面跟一个语句相当于让这个语句在函数退出时执行。
现在我们发现结果应该正确了:
Plain Text

解法二:go channel

channel 是 golang 内建的一种数据结构,你可以用
Plain Text
声明一个 chan T 类型的 channel,一个 chan T 类型的 channel 一次只能往里写入一个 T 类型的对象或者读取一个 T 类型的对象。channel 可以看作是一根管子,一般来说两个 goroutine 位于管子的两端,一个负责写,一个负责读。
在创建 channel 时你可以指定所创建 channel 的缓冲区大小,对于一个 un-buffered 的 channel 而言,若在没有线程读它的时候写入,则写入的那个线程会阻塞,如在没有线程写入的时候读取,则读取的那个线程也会阻塞。对于一个缓冲区大小为 n 的 chan T 类型 channel 对象,可以在没有进程读取它的时候写入,而且这样的写入是无阻塞的,但最多只能写入 n 次(假设只有 consumer 和 provider 两个线程),一次写入一个 T 类型对象,写了 n 个 T 类型对象后,缓冲区满了,再写入时就会阻塞那个写入的线程。
channel 的基本语法:
Plain Text
通过合理地利用 channel 什么时候会阻塞的规律,我们可以实现对关键共享资源的保护。
首先把 main 函数改成这样:
程序一开始执行我们就让它创建两个 chan any 类型的 channel 对象,启动一个执行 updateUpvotes 的 goroutine,这个 goroutine 负责同时监听 upvotesChan 和 quitChan,在 upvotesChan 有数据时调用 doUpvote 函数更新点赞,在 quitChan 有数据时退出,我们用 defer 关键字实现了在 main 函数退出时才对 quitChan 写入数据。
以下是 updateUpvotes 函数的实现:
select 语句可以用来同时监听多个 channel,哪个 channel 来了数据它就会执行哪一个 case. 在执行 updateUpvotes 的 goroutine 执行到 doUpvote() 时,由于此时没有任何线程尝试从 upvotesChan 这个 channel 里边取出数据,所以这时任何尝试往这个 channel 写入数据的线程都会阻塞,直到 updateUpvotes 的 goroutine 执行完 doUpvote() 后继续透过 select 侦听多个 channel,这时其它线程才有机会继续往 channel 里边写数据。
doUpvote 函数不用变,我们还是假设它是(把它看成是)一个不可更改的第三方 api 调用接口。
然后 handlePostUpvote 的实现改为返回一个闭包,这样处理 http 请求的函数就可以访问到那个 channel 了:
然后我们再运行一遍:
Plain Text
结果是正确的。
以下是最终的完整代码:

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK