一道有趣的golang排错题

好久没写博客了,不得不说go语言爱好者周刊是个宝贝,原本想随便看看打发时间的,没想到一会儿给了我久违的灵感。golang

go语言爱好者周刊78期出了一道很是有意思的题目。express

咱们来看看题目。先给出以下的代码:c#

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int)
    go fmt.Println(<-ch1)
    ch1 <- 5
    time.Sleep(1 * time.Second)
}

请问这串代码的输出是什么。bash

我最早想到的是5,毕竟代码很简单,反应比较快的话代码看完结果也就推断出来了。app

然而题目给出的其中一个选项是输出死锁报错,这个选项引发了个人好奇,因而我运行了一下:函数

$ go run a.go

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive]:
main.main()
        /tmp/a.go:10 +0x65
exit status 2

啊这。真的死锁了。那么我猜会不会和执行顺序有关呢?因而我写了个脚本运行1000次看看:google

#!/bin/bash

for i in {0..1000}
do
    go run a.go &> /dev/null
    if [ $? -eq 0 ]
    then
        echo 'success!'
        break
    fi
done

结果天然是一次也没成功,即便你改为10000哪怕是1000000也是同样的。执行顺序带来的影响咱们能够排除了。lua

若是你仔细观察的话,全部的报错也都是同样的:goroutine 1 [chan receive]:,在这里死锁了。code

那么会不会是由于使用了无缓冲chan的缘由呢?golang的内存模型规定了无缓冲chan的接受happens before发送操做,这会不会带来影响呢(其实仔细想一想就很快排除了,happens before肯定的是内存的可见性,而不是指令执行的时间顺序),因此我改了下代码:内存

func main() {
    ch1 := make(chan int, 100)
    go fmt.Println(<-ch1)
    ch1 <- 5
    time.Sleep(1 * time.Second)
}

此次咱们使用了一个有容纳100个元素的buff的channel,然而结果仍是没有一点改变。

到这里个人思路中断了。

不过我还有google啊,因此我用“golang channel deadlock”为关键词搜索了一下,而后发现了一些有意思的结果。

那就是全部的chan的死锁的代码基本都能抽象成下面的形式:

func main() {
    ch1 := make(chan int) // 是否有buff无影响
    _ = <-chan
    ch1 <- 5
}

这个代码毫无疑问是会死锁的,由于从chan接收值而chan里是空的会致使当前goroutine进入等待,而当前goroutine不能继续运行的话就永远没办法向chan里写入值,死锁就在这里产生了。

在仔细观察一下,你就会发现题目的代码和这很像:

func main() {
    ch1 := make(chan int)
    go fmt.Println(<-ch1)
    ch1 <- 5
    // sleep是为了main routine不会过早退出
}

答案只有一个,<-ch1发生在main goroutine里了。

为了佐证这一观点,我有查阅了golang language spec,关于go语句有以下的描述:

The function value and parameters are evaluated as usual in the calling goroutine, but unlike with a regular call, program execution does not wait for the invoked function to complete.

函数和它的参数会像一般那样在使用go语句的那个goroutine里被执行,但不像常规的函数调用,程序不会同步等待这个函数执行完毕。

若是在看看有关求值的部分:

calls f with arguments a1, a2, … an. Except for one special case, arguments must be single-valued expressions assignable to the parameter types of F and are evaluated before the function is called.

用参数a1, a2等调用函数f,出了一个特例以外他们都必须是单值表达式,而且在函数运行前被求值。

上面说的特例是方法调用,方法的receiver会用特定的位置传给method。

这样事情的前因后果就清晰明了了,咱们来梳理一下。

假设咱们在main goroutine里启动一个子goroutine叫b,那么实际上在main goroutine里发生的事情是这样的:

  1. main goroutine执行到go语句
  2. go语句发现后面的函数表达式须要传递参数
  3. 因而被传递的参数在main goroutine里求值
  4. 新的goroutine b被建立,刚求值的参数传递给须要执行的函数(假设叫f),f在goroutine b中开始执行
  5. go语句结束,控制流程回到main goroutine

因此go fmt.Println(<-ch1)里的chan接收操做是在main goroutine里执行的,所以死锁是板上钉钉的事情。

若是改为下面这样,死锁就不会发生:

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan int)
    go func() {
        fmt.Println(<-ch1)
    }()
    ch1 <- 5
    time.Sleep(1 * time.Second)
}

这是由于<-ch1这回货真价实地发生在了不一样的goroutine里,死锁天然也不存在了。

这题很坏,坏就坏在fmt.Println(...)这样的形式容易让人迷惑,觉得这个调用自己在新的goroutine里执行,然而真正在新goroutine里执行的倒是fmt.Println内部的函数实现代码,而不是fmt.Println(...)这句,参数会在这以前就被求值。

那么这能让咱们学到什么呢?答案是永远也不要写出题目里那样的代码,对于chan的操做应该确保是在和执行go语句的goroutine不一样的routine中运行的。

不过万事不绝对,带buff的chan会有些例外,固然这些之后有机会再说吧:P

相关文章
相关标签/搜索