笔者在前文《Golang 入门 : 理解并发与并行》和《Golang 入门 : goroutine(协程)》中介绍了 Golang 对并发的原生支持以及 goroutine 的用法。本文我们来聊聊并发与并行带来的一些副作用。
并行编程之所以难道较高,根本的原因是需要处理共享资源的同步访问。比如在 Golang 中如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争条件(race candition)。竞争条件的存在是让并发程序变得复杂的地方,十分容易引起潜在问题。对一个共享资源的读和写操作必须是原子化的,换句话说,同一时刻只能有一个 goroutine 对共享资源进行读和写操作。
让我们来通过下面的 demo 来观察 goroutine 引入的竞争条件,为了让观察结果明显,我们采取了一些极端措施:
运行上面的代码,输出结果如下:
上面的程序中会对变量 counter 会进行 4 次读和写操作,每个 goroutine 执行两次。但是,程序终止时,counter 变量的值为 2。我们可以通过下面的图解来理解该程序的执行过程(此图来自互联网):
每个 goroutine 都会覆盖另一个 goroutine 的工作。这种覆盖发生在 goroutine 切换的时候。每个 goroutine 创造了一个 counter 变量的副本,之后就切换到另一个 goroutine。当 这个 goroutine 再次运行的时候,counter 变量的值已经改变了,但是 goroutine 并没有更新自己的那个副本的值,而是继续使用这个副本的值,用这个值递增,并存回 counter 变量,结果覆盖了另一个 goroutine 完成的工作。 下面是对程序执行过程的解释:
程序中通 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语言实战》