Go并发:访问共享数据

来源:互联网 发布:水果软件12 编辑:程序博客网 时间:2024/06/06 03:19

竟险

竟险(竞争条件、Race Condition)是指多个协程(goroutine)同时访问共享数据,其结果取决于指令执行顺序的情况。

考虑如下售票程序。该程序模拟两个售票窗口,一个执行购票,一个执行退票。

package mainimport (    "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 mainvar 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 mainimport (    "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;  // 总票数200DWORD dwThreadId = 0;  // 监听线程IDHANDLE 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 mainimport (    "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)}

总结

  • 多个协程同时访问共享数据时会造成竟险。
  • 可以有三种方式规避竟险:禁止修改共享变量、限制在同一个协程中访问共享变量、 利用互斥对象。
原创粉丝点击