竟险
竟险(竞争条件、Race Condition)是指多个协程(goroutine)同时访问共享数据,其结果取决于指令执行顺序的情况。
考虑如下售票程序。该程序模拟两个售票窗口,一个执行购票,一个执行退票。
package main
import (
"fmt"
"time"
)
var tickCount = 200 // 总票数
// 购票
func buy() {
tickCount = tickCount - 1 // A
}
// 退票
func refund() {
tickCount = tickCount + 1 // B
}
func main() {
go buy() // 购票协程
go refund() // 退票协程
time.Sleep(time.Second * 1) // 为了简单,这里睡眠1秒,等待上面两个协程结束
fmt.Println("tick count:", tickCount) // 输出结果是什么?
}
考虑到一共200张票,买了一张,卖了一张,应该还是剩余200张票。事实却不总是这样,多次运行程序发现有如下输出:
tick count: 201
和如下输出:
tick count: 199
多的一张和少的一张到哪里去了?无论是先执行买票(语句A)还是卖票(语句B),结果都应该是200才对啊!
NO!!!
在计算机看来语句A和语句B并不是一条不可分割的语句,而是两条语句:
A1: … = tickCount - 1
A2: tickCount = …
B1: … = tickCount + 1
B2: tickCount = …
它们的实际执行顺序有如下四种可能:
- A1->A2->B1->B2 结果为200
- B1->B2->A1->A2 结果为200
- B1->A1->A2->B2 结果为201
- A1->B1->B2->A2 结果为199
可见第三种和第四种执行顺序产生了意想不到的结果。原因在于两个协程同时访问并修改了共享变量(tickCount),而语句之间的顺序无法保证,导致意外的情况发生,这便是竟险。
竟险显然不是我们想要的结果。那么如何规避竟险呢?有三种方式:1. 禁止修改共享变量。2. 限制在同一个协程中访问共享变量。3. 利用互斥。下面分别来看看这三种方式。
禁止修改共享变量
可以通过禁止修改共享变量来达到规避竟险的目的。
考虑如下程序:
package main
var config = map[string]string{}
func loadConfig(key string) string { /*...*/ }
// 惰性加载
func getConfig(key string) string {
value, ok := config[key]
if !ok {
value = loadConfig(key)
config[key] = value
}
return value
}
func main() {
go func() {
user := getConfig("userName") // A 修改共享变量的值,发生竟险
// ...
}()
go func() {
address := getConfig("address") // B 修改共享变量的值,发生竟险
// ...
}()
// ...
}
注意该例中getConfig()为惰性加载,也就是在需要加载时再加载,这样便在语句A和语句B中发生了竟险,两条语句同时修改了共享变量config。如果修改为提前加载所有配置,则可规避竟险:
package main
// 提前加载所有配置
var config = map[string]string{
"userName": loadConfig("userName"),
"address": loadConfig("address"),
}
func loadConfig(key string) string { /*...*/ }
func getConfig(key string) string {
return config[key]
}
func main() {
go func() {
user := getConfig("userName") // 访问共享变量,但不修改其值,不发生竟险
// ...
}()
go func() {
address := getConfig("address") // 访问共享变量,但不修改其值,不发生竟险
// ...
}()
// ...
}
这种方式仅仅可以用于协程不需要修改共享变量的情况。这显然满足不了我们的所有需求。在很多情况下协程必须修改共享变量。
限制在同一个协程中访问共享变量
将共享变量的访问限制在一个协程中,就避免了竟险。 不过这种方式略微有些复杂,必须要建立一个监听线程,来专门处理共享变量的修改。
package main
import (
"fmt"
"sync"
)
var tickCount = 200 // 总票数
var ch = make(chan int, 10) // 用来控制tickCount的同步,10表示模拟10个售/退票窗口
var n sync.WaitGroup // 用来等待购票和售票动作完成
var done = make(chan struct{}) // 用来等待监听协程退出
// 购票
func buy() {
ch <- -1
}
// 退票
func refund() {
ch <- 1
}
func main() {
// 监听协程
go func() {
for amount := range ch {
tickCount += amount
n.Done() // 每次调用Done(),n的计数减1
}
done <- struct{}{} // 监听线程结束,发送消息
}()
n.Add(2) // 因为要执行两个动作,所以使n的计数加2
go buy() // 购票协程
go refund() // 退票协程
n.Wait() // 等待购票和退票动作完成
// Wait()会一直等待,直到n的计数为0
close(ch) // 关闭管道
<-done // 等待监听线程结束
fmt.Println("tick count:", tickCount)
}
这种方式类似于在Windows的线程中处理消息循环,用PostThreadMessage来发送消息。可以对比一下:
#include <Windows.h>
#include <iostream>
#define WM_AMOUNT (WM_USER + 1)
#define WM_DONE (WM_USER + 2)
int tickCount = 200; // 总票数200
DWORD dwThreadId = 0; // 监听线程ID
HANDLE hEventState = NULL; // 用于同步监听线程的开始和结束
// 监听线程
DWORD WINAPI Monitor(LPVOID lpParameter) {
// 确保建立消息循环
MSG msg = {};
PeekMessage(&msg, NULL, WM_USER, WM_USER, PM_NOREMOVE);
// 通知主线程消息循环建立完成
SetEvent(hEventState);
// 处理消息
BOOL bRet;
bool done = false;
while (!done && (bRet = GetMessage(&msg, NULL, 0, 0)) != 0) {
if (bRet == -1) {
break;
} else {
switch (msg.message)
{
case WM_AMOUNT:
tickCount += (int)msg.wParam;
break;
case WM_DONE:
done = true;
break;
default:
break;
}
}
}
SetEvent(hEventState);
return 0;
}
// 购票
void buy() {
PostThreadMessage(dwThreadId, WM_AMOUNT, (WPARAM)-1, NULL);
}
// 退票
void refund() {
PostThreadMessage(dwThreadId, WM_AMOUNT, (WPARAM)1, NULL);
}
int main() {
hEventState = CreateEvent(NULL, FALSE, FALSE, NULL); // 创建同步监听线程的事件
// 开启监听线程
HANDLE hThread = CreateThread(NULL, 0, Monitor, NULL, 0, &dwThreadId);
CloseHandle(hThread);
WaitForSingleObject(hEventState, INFINITE); // 确保监听线程已开启
buy(); // 购票
refund(); // 退票
PostThreadMessage(dwThreadId, WM_DONE, NULL, NULL); // 退出监听线程
WaitForSingleObject(hEventState, INFINITE); // 等待监听线程退出
CloseHandle(hEventState); // 关闭同步监听线程的事件
std::cout << "tick count: " << tickCount << std::endl;
}
可见C++的版本复杂了一些,不过也差不了多少。
利用互斥
第三种方式是使用sync包中提供的互斥锁sync.Mutex。sync.Mutex是一个结构体,提供了Lock和Unlock两个方法,Lock用来锁定,Unlock用来解锁。 利用互斥锁,上面的程序变得更简单了:
package main
import (
"fmt"
"sync"
)
var (
tickCount = 200 // 总票数
mu sync.Mutex // 互斥锁
n sync.WaitGroup
)
// 购票
func buy() {
defer n.Done() // 计数减1
mu.Lock()
defer mu.Unlock() // 用defer保证函数返回时解锁
tickCount += 1
}
// 退票
func refund() {
defer n.Done() // 计数减1
mu.Lock()
defer mu.Unlock() // 用defer保证函数返回时解锁
tickCount -= 1
}
func main() {
n.Add(2) // 有两个动作,所以计数加2
go buy() // 购票协程
go refund() // 退票协程
n.Wait() // 等待购票和退票动作完成
// Wait一直阻塞,直到n的计数为0返回
fmt.Println("tick count:", tickCount)
}
总结
- 多个协程同时访问共享数据时会造成竟险。
- 可以有三种方式规避竟险:禁止修改共享变量、限制在同一个协程中访问共享变量、 利用互斥对象。