Golang 入门 : 竞争条件

笔者在前文《Golang 入门 : 理解并发与并行》和《Golang 入门 : goroutine(协程)》中介绍了 Golang 对并发的原生支持以及 goroutine 的用法。本文我们来聊聊并发与并行带来的一些副作用。

并行编程之所以难道较高,根本的原因是需要处理共享资源的同步访问。比如在 Golang 中如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争条件(race candition)。竞争条件的存在是让并发程序变得复杂的地方,十分容易引起潜在问题。对一个共享资源的读和写操作必须是原子化的,换句话说,同一时刻只能有一个 goroutine 对共享资源进行读和写操作。

goroutine 引入的竞争条件

让我们来通过下面的 demo 来观察 goroutine 引入的竞争条件,为了让观察结果明显,我们采取了一些极端措施:

package main

import (
    "sync"
    "fmt"
    "runtime"
)

var(
    // counter是所有goroutine都要增加其值的变量
    counter int

    // wg用来等待程序结束
    wg sync.WaitGroup
)

// main是所有Go程序的入口
func main(){
    runtime.GOMAXPROCS(1)
    // 计数加2,表示要等待两个goroutine
    wg.Add(2)

    // 创建两个goroutine
    go incCounter(1)
    go incCounter(2)

    // 等待goroutine结束
    wg.Wait()
    fmt.Println("Final Counter:", counter)
}

// incCounter增加包里counter变量的值
func incCounter(id int){
    // 在函数退出时调用Done来通知main函数工作已经完成
    defer wg.Done()

    for count := 0; count < 2; count++{
        // 捕获counter的值
        value := counter

        // 当前goroutine从线程退出,并放回到队列
        runtime.Gosched()

        // 增加本地value变量的值
        value++

        // 将该值保存回counter
        counter = value
    }
}

运行上面的代码,输出结果如下:

Final Counter: 2

上面的程序中会对变量 counter 会进行 4 次读和写操作,每个 goroutine 执行两次。但是,程序终止时,counter 变量的值为 2。我们可以通过下面的图解来理解该程序的执行过程(此图来自互联网):

每个 goroutine 都会覆盖另一个 goroutine 的工作。这种覆盖发生在 goroutine 切换的时候。每个 goroutine 创造了一个 counter 变量的副本,之后就切换到另一个 goroutine。当 这个 goroutine 再次运行的时候,counter 变量的值已经改变了,但是 goroutine 并没有更新自己的那个副本的值,而是继续使用这个副本的值,用这个值递增,并存回 counter 变量,结果覆盖了另一个 goroutine 完成的工作。 下面是对程序执行过程的解释:

// 创建两个 goroutine
go incCounter(1)
go incCounter(2)

程序中通 go 关键字和 incCounter 函数创建了两个 goroutine。在 incCounter 函数内部对变量 counter 进行了读和写操作,而 counter 变量是这个示例程序里的共享资源。每个 goroutine 都会先读出这个 counter 变量的值,并把 counter 变量的副本存入一个叫作 value 的本地变量。之后 incCounter 函数对 value 变量加 1,并最终将这个新值存回到 counter 变量。incCounter 函数在对本地变量 value加 1 前调用了 runtime 包的 Gosched 函数,这个调用会将 goroutine 从当前线程退出,给其他 goroutine 运行的机会。在两次操作中间这样做的目的是强制调度器切换两个 goroutine,以便让竞争条件的效果变得更明显。

如果不是我们通过调用 Gosched 函数让竞争条件的效果变得明显,那么多次运行这段程序输出的 counter 值很可能是不一样的,会是 2,3,4 中的一个值。这种情况下导致的问题往往非常难以定位。

和其它编程语言一样,Golang 提供了原子函数和锁等机制来解决同步问题。但是使用这些机制并不会使并发编程变得更简单。接下来笔者将介绍 Golang 中提供的 channel(通道)功能,看它是如何以简洁的方式解决同步问题的。

参考:
《Go语言实战》

posted @ 2019-06-04 12:55 sparkdev 阅读(...) 评论(...) 编辑 收藏