Go 并发与按值传递引发的血案

前段时间出去面试的时候,被一道题难住了,问下面这段代码的运行输出是什么:

func wg1() {
    wg := sync.WaitGroup{}

    for i := 0; i < 10; i++ {
        go func(wg sync.WaitGroup) {
            wg.Add(1)
            println(i)
            wg.Done()
        }(wg)
    }
    wg.Wait()
}

当时没有搞清楚 sync.WaitGroup 的特性,结果很尴尬。面试官给的答案是没有任何输出,原因是 wg 按值传递。事实上,情况不是这么简单,正确的答案是「不确定」,可能没有输出,可能打印少于 10 个数字,可能打印了几个重复的数字。

这段代码的问题很大,涉及按值传递、并发,以及变量作用域等方面的内容,改了几次才改出正确的代码。

wg2():不仅仅是传值的问题

如果只是按值传递的问题,即 wg 被复制了 10 份导致 wg.Wait() 没有等待,那么换用指针应该能解决问题:

func wg2() {
    wg := sync.WaitGroup{}

    for i := 0; i < 10; i++ {
        go func(wg *sync.WaitGroup) {
            wg.Add(1)
            println(i)
            wg.Done()
        }(&wg)
    }
    wg.Wait()
}

结论是不行!运行结果不稳定,数字有重复,而且有时还会 panic:

$ ./waitgroup
10
10
10
10
10

$ ./waitgroup
8
10
10
...

panic: sync: WaitGroup is reused before previous Wait has returned

wg3():简化场景直接使用 wg

先简化场景,把按值传递的问题排除,直接使用 wg:

func wg3() {
    wg := sync.WaitGroup{}

    for i := 0; i < 10; i++ {
        go func() {
            wg.Add(1)
            println(i)
            wg.Done()
        }()
    }
    wg.Wait()
}

大多数情况下 会打印 10 个数字,但是数字有重复:

$ ./waitgroup
4
4
4
10
10
10
10
10
10
10

这段代码在 goroutine 方面还是有问题的,wg5() 中说明

wg4():i 通过参数传递,消除重复

wg3() 有两个问题,第一输出的数字重复,第二偶尔输出的数字不足 10 个,先解决数字重复的问题。

数字重复是因为 goroutine 中直接使用了变量 i,打印的是 goroutine 执行时的 i 值,而不是创建 goroutine 时的 i 值。

将 i 改为参数传递:

func wg4() {
    wg := sync.WaitGroup{}

    for i := 0; i < 10; i++ {
        go func(i int) {
            wg.Add(1)
            println(i)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

这时候,输出结果中没有重复的数字:

$ ./waitgroup
1
6
5
7
0
4
2
8
3
9

第二个问题没有解决,有时候输出的数字不足 10 个,例如:

$ ./waitgroup
0
2
1
$ ./waitgroup
1%

wg5():将 wg 的操作移出 goroutine

wg3() 和 wg4() 中的主函数有可能在 goroutine 之前退出,导致输出的数字不足 10 个。为什么会这样,wg.wait() 无效吗?

问题在于 wg.Add() 的执行时机,仔细看 wg3() 和 wg4() 中的 wg.Add(),它是在 goroutine 中执行的。

因为 goroutine 是并发的,wg.Add() 还没执行, 主函数可能就执行到了 wg.wait() ,从而直接退出。

需要将 wg.Add() 移出 goroutine,确保 wg.Wait() 有效,注意 wg.Done() 不能移出,否则主函数又会先于 goroutine 退出:

func wg5() {
    wg := sync.WaitGroup{}

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int) {
            println(i)
            wg.Done()
        }(i)
    }
    wg.Wait()
}

这时候稳定输出 10 个不重复的数字,数字不是递增排列,因为 goroutine 并发执行,顺序不固定:

1
0
4
3
5
6
9
7
8
2

wg6():重新用指针传递

回顾 wg2(),因为不确定指针的效用,wg3()~wg5() 中没有使用指针,在简化的场景下解决了并发和数字重复的问题。

现在验证一下使用指针是否可行:

func wg6() {
    wg := sync.WaitGroup{}

    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(i int, wg *sync.WaitGroup) {
            println(i)
            wg.Done()
        }(i, &wg)
    }
    wg.Wait()
}

结论是可以,而且这种情况下必须使用指针,如果不用指针是有问题的。

如果不用指针,goroutine 中的 wg.Done() 是无效的,因为 goroutine 中的 wg 是一个副本,结果会是这样:

1
0
5
2
9
6
4
8
7
3
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc000068008)
	/Users/lijiao/Work/Bin/go-1.13/go/src/runtime/sema.go:56 +0x42
sync.(*WaitGroup).Wait(0xc000068000)
	/Users/lijiao/Work/Bin/go-1.13/go/src/sync/waitgroup.go:130 +0x64
main.wg7()
	/Users/lijiao/Work/go-code-example/waitgroup/waitgroup.go:97 +0xbb
main.main()
	/Users/lijiao/Work/go-code-example/waitgroup/waitgroup.go:107 +0x20

Process finished with exit code 2

参考

  1. 李佶澳的博客