Go并发:访问共享数据

竟险

竟险(竞争条件、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)
}

总结

  • 多个协程同时访问共享数据时会造成竟险。
  • 可以有三种方式规避竟险:禁止修改共享变量、限制在同一个协程中访问共享变量、 利用互斥对象。
  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值