并发竞态条件?(Python + Go 模拟)

1,581 阅读4分钟
原文链接: zhuanlan.zhihu.com

先说点题外话

加入 UCloud 实验室半年多了,也许是项目比较成熟稳定了,除了对 Golang 有了一定的了解,在后台开发并未积累足够,仅限于搬砖,Go 语言的优点发挥不出,还不如用 Python 舒服(只是一个比喻),很着急。最近有机会直接负责一个后端模块,遂补一些分布式网络编程的知识,同时也会在私人时间总结分享出来,代码尽量干净,内容尽量简单(Simple is better.),同时欢迎大家订阅我的公众号:heart-devops,一起加油吧


目录:

  • 竞态条件的概念
  • 模拟竞态条件(Go 代码)
  • 模拟竞态条件(Python 代码)
  • 竞态条件解决方案
  • 解决竞态条件(Go 代码)
  • 解决竞态条件(Python 代码)

TL;DR:

竞态条件(race condition),也叫竞争条件,竞争冒险,它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出。

举例来说,如果计算机中的两个进程(线程,协程)同时试图修改一个共享内存的内容,在没有并发控制的情况下,最后的结果依赖于两个进程的执行顺序与时机。而且如果发生了并发访问冲突,则最后的结果是不正确的。

并发控制(英语:Concurrency control)是确保及时纠正由并发操作导致的错误的一种机制。

模拟竞态条件(Go 代码)

对 Go 语言不感兴趣可以往下拉,直接看 Python 版本

package main

import (
	"fmt"
	"sync"
)

// N 是内存里的一个对象
var (
	N         = 0
	waitgroup sync.WaitGroup
)

func counter(number *int) {
	*number++
	waitgroup.Done()
}

func main() {

	for i := 0; i < 1000; i++ {
		waitgroup.Add(1)
		go counter(&N)
	}
	waitgroup.Wait()
	fmt.Println(N)
}

程序比较简单,解释两点:

  1. 整体逻辑:并发 1000 个 goroutine,goroutine 去修改内存中的 N,使其 +1,程序中是通过指针的方式
  2. sync.WaitGroup 是利用锁机制(并发控制之一)来保证 goroutine 未返回之前不退出 main 函数,也就是使 goroutine 变为同步,它只有三个方法:Add()Done()Wait()。大概可以猜出来原理:每启动一个 goroutine 就 Add(1),每个 goroutine 结束就 Done(),其实就是 Add(-1),最后 Wait() 阻塞 main 直到又为零,即 goroutine 都执行完毕

运行结果:

运行 10 次

我运行了十次,结果不仅不是 1000,而且每次还不同,WTF???

没错,产生这个结果的原因就是协程们使用 N 对象时发生了竞态条件,我们可以使用 Go 提供的检测命令 -race 来进行方便的检测:

Go 提供的 race 检测

题外话:Go 提供了两种方式来进行并发控制,一种是上面用到的锁机制:例如 sync.WaitGroup;另一种是 channel,后面一定会专门来介绍。


模拟竞态条件(Python 代码)

# coding:utf8
import time
import threading

N = 0


def change_it(n):
    global N
    N = N + n


def run_thread(n):
    for i in range(100000):
        change_it(n)


t1 = threading.Thread(target=run_thread, args=(1,))
t2 = threading.Thread(target=run_thread, args=(1,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)

启动两个线程,都去给 N 加一100000次,结果因为竞态条件而不稳定:

运行 10 次

竞态条件解决方案

使用互斥锁

互斥锁(Mutual exclusion,缩写 Mutex)为资源引入一个状态:锁定/非锁定。某个线程要更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其他线程(进程、协程)不能更改;直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。


我们还是直接看代码:

解决竞态条件(Go 代码)

package main

import (
	"fmt"
	"sync"
)

// N 是内存里的一个对象
var (
	N         = 0
	mutex     sync.Mutex // 1
	waitgroup sync.WaitGroup
)

func counter(number *int) {
	mutex.Lock() // 2
	*number++
	mutex.Unlock() // 3
	waitgroup.Done()
}

func main() {

	for i := 0; i < 1000; i++ {
		waitgroup.Add(1)
		go counter(&N)
	}
	waitgroup.Wait()
	fmt.Println(N)
}

加了三行,注释为数字,结果如下:

运行 10 次

不信?检测 race condition:

检测竞态条件

通过 ✌️


解决竞态条件(Python 代码)

# coding:utf8
import time
import threading

N = 0
mutex = threading.Lock() # 1


def change_it(n):
    global N
    if mutex.acquire(1): # 2
        N = N + n
        mutex.release() # 3


def run_thread(n):
    for i in range(100000):
        change_it(n)


t1 = threading.Thread(target=run_thread, args=(1,))
t2 = threading.Thread(target=run_thread, args=(1,))
t1.start()
t2.start()
t1.join()
t2.join()
print(N)

修改还是三处,如数字注释,试试结果

运行 10 次

啧啧啧,全文完


卖萌