Go并发编程-并发编程难在哪里

一、前言

编写正确的程序本身就不容易,编写正确的并发程序更是难中之难,那么并发编程究竟难道哪里那?本节我们就来一探究竟。

二、数据竞争

https://yourbasic.org/golang/data-races-explained/
当两个或者多个线程(goroutine)在没有任何同步措施的情况下同时读写同一个共享资源时候,这多个线程(goroutine)就处于数据竞争状态,数据竞争会导致程序的运行结果超出写代码的人的期望。下面我们来看个例子:

package main
import (
    "fmt"
)

var a int

//goroutine1
func main() {
    
    //1,gouroutine2
    go func(){
        a = 1//1.1
    }()
    
    //2
    if 0 == a{//2.1
        fmt.Println(a)//2.2
    }
}
  • 如上代码首先创建了一个int类型的变量,默认被初始化为0值,运行main函数会启动一个进程和这个进程中的一个运行main函数的goroutine(轻量级线程)
  • 在main函数内使用go语句创建了一个新的goroutine(该goroutine运行匿名函数里面的内容)并启动运行,匿名函数内给变量赋值为1
  • main函数里面代码2判断如果变量a的值为0,则打印a的值。

运行main函数后,启动的进程里面存在两个并发运行的线程,分别是开启的新goroutine(起名为goroutine2)和main函数所在的goroutine(起名为goroutine1),前者试图修改共享变量a,后者试图读取共享变量a,也就是存在两个线程在没有任何同步的情况下对同一个共享变量进行读写访问,这就出现了数据竞争,由于数据竞争存在,导致上面程序可能会有下面三种输出:

  • 输出0,由于运行时调度系统的随机性,会存在goroutine1的2.2代码比goroutine2的代码1.1先执行
  • 输出1,当存在goroutine1先执行代码2.1,然后goroutine2在执行代码1.1,最后goroutine1在执行代码2.2的时候
  • 什么都不输出,当goroutine2执行先于goroutine1的2.1代码时候。

由于数据竞争的存在上面一段很短的代码会有三种可能的输出,究其原因是goroutine1和groutine2的运行时序是不确定的,也就是没有对他们的操作做同步,以便让这些内存操作变为可以预知的顺序执行。

这里编写程序者或许受单线程模型的影响认为代码1.1会先于代码2.1执行,当发现输出不符合预期时候,或许会在代码2.1前面让goroutine1 休眠一会确保goroutine2执行完毕1.1后在让goroutine1执行2.1,这看起来或许有效,但是这是非常低效,并且并不是所有情况下都可以解决的。

正确的做法可以使用信号量等同步措施,保证goroutine2执行完毕再让goroutine1执行代码2.1,如下面代码,我们使用sync包的WaitGroup来保证goroutine2执行完毕代码2.1后,goroutine1才可以执行步骤4.1,关于WaitGroup后面章节我们具体会讲解:

package main

import (
    "fmt"
    "sync"
)

var a int
var wg sync.WaitGroup//信号量
//goroutine1
func main() {
    //1.
    wg.Add(1);//一个信号
    
    //2. goroutine1
    go func(){
        a = 1//2.1
        wg.Done()
    }()
    
    wg.Wait()//3. 等待goroutine1运行结束
    
    //4
    if 0 == a{//4.1
        fmt.Println(a)//4.2
    }
}

三、操作的原子性

所谓原子性操作是指当执行一系列操作时候,这些操作那么全部被执行,那么全部不被执行,不存在只执行其中一部分的情况。在设计计数器时候一般都是先读取当前值,然后+1,然后更新,这个过程是读-改-写的过程,如果不能保证这个过程是原子性,那么就会出现线程安全问题。如下代码是线程不安全的,因为不能保证a++是原子性操作:

package main

import (
    "fmt"
    "sync"
)

var count int32
var wg sync.WaitGroup //信号量
const THREAD_NUM = 1000

//goroutine1
func main() {
    //1.信号
    wg.Add(THREAD_NUM) 

    //2. goroutine
    for i := 0; i < THREAD_NUM; i++ {
        go func() {
            count++//2.1
            wg.Done()//2.2
        }()
    }

    wg.Wait() //3. 等待goroutine运行结束

    fmt.Println(count) //4输出计数
}
  • 如上代码在main函数所在为goroutine内创建了THREAD_NUM个goroutine,每个新的goroutine执行代码2.1对变量count计数增加1。
  • 这里创建了THREAD_NUM个信号量,用来在代码3处等待THREAD_NUM个goroutine执行完毕,然后输出最终计数,执行上面代码我们 期望输出1000,但是实际却不是。

这是因为a++操作本身不是原子性的,其等价于b := count;b=b+1;count=b;是三步操作,所以可能导致导致计数不准确,如下表:

image.png

假如当前count=0那么t1时刻线程A读取了count值到变量countA,然后t2时刻递增countA值为1,同时线程B读取count的值0放到内存countB值为0(因为countA还没有写入主内存),t3时刻线程A才把countA为1的值写入主内存,至此线程A一次计数完毕,同时线程B递增CountB值为1,t4时候线程B把countB值1写入内存,至此线程B一次计数完毕。明明是两次计数,最后结果是1而不是2。

上面的程序需要保证count++的原子性才是正确的,后面章节会知道使用sync/atomic包的一些原子性函数或者锁可以解决这个问题。

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

var count int32
var wg sync.WaitGroup //信号量
const THREAD_NUM = 1000

//goroutine1
func main() {
    //1.信号
    wg.Add(THREAD_NUM) 

    //2. goroutine
    for i := 0; i < THREAD_NUM; i++ {
        go func() {
            //count++//
            atomic.AddInt32(&count, 1)//2.1
            wg.Done()//2.2
        }()
    }

    wg.Wait() //3. 等待goroutine运行结束

    fmt.Println(count) //4输出计数
}

如上代码使用原子性操作可以保证每次输出都是1000

三、内存访问同步

上节原子性操作第一个例子有问题是因为count++操作是被分解为类似b := count;b=b+1;count =b; 的三部操作,而多个goroutine同时执行count++时候并不是顺序执行者三个步骤的,而是可能交叉访问的。所以如果能对内存变量的访问添加同步访问措施,就可以避免这个问题:

package main

import (
    "fmt"
    "sync"
)

var count int32
var wg sync.WaitGroup //信号量
var lock sync.Mutex   //互斥锁
const THREAD_NUM = 1000

//goroutine1
func main() {
    //1.信号
    wg.Add(THREAD_NUM)

    //2. goroutine
    for i := 0; i < THREAD_NUM; i++ {
        go func() {
            lock.Lock()   //2.1
            count++       //2.2
            lock.Unlock() //2.3
            wg.Done()     //2.4
        }()
    }

    wg.Wait() //3. 等待goroutine运行结束

    fmt.Println(count) //4输出计数
}
  • 如上代码创建了一个互斥锁lock,然后goroutine内在执行count++前先获取锁,执行完毕后在释放锁。
  • 当1000个goroutine同时执行到代码2.1时候只有一个线程可以获取到锁,其他的线程被阻塞,直到获取到锁的goroutine释放了锁。也就是这1000个线程的并发行使用锁转换为了串行执行,也就是对共享内存变量的访问施加了同步措施。

四、总结

本文我们从数据竞争、原子性操作、内存同步三个方面探索了并发编程到底难在哪里,后面章节我们会结合go的内存模型和happen-before原则在具体探索这些难点如何解决。

image.png
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容