我的 Channel 在 Select 语句中的 Bug

zuoguoyao · 2018-11-18 23:39:28 · 784 次点击 · 预计阅读时间 6 分钟 · 大约8小时之前 开始浏览    
这是一个创建于 2018-11-18 23:39:28 的文章,其中的信息可能已经有所发展或是发生改变。

我当时正在测试一个已经上线运行的项目的新功能,忽然代码表现得非常糟糕。我看到后很惊讶,后来搞清楚了原因。

接下来提供这份代码的简化版本,包含两个 bug。

package main

import (
    "fmt"
    "os"
    "os/signal"
    "time"
)

var Shutdown bool = false

func main() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt)

    for {
        select {
        case <-sigChan:
            Shutdown = true
            continue

        case <-func() chan struct{} {
            complete := make(chan struct{})
            go LaunchProcessor(complete)
            return complete
        }():
            return
        }
    }
}

func LaunchProcessor(complete chan struct{}) {
    defer func() {
        close(complete)
    }()

    fmt.Printf("Start Work\n")

    for count := 0; count < 5; count++ {
        fmt.Printf("Doing Work\n")
        time.Sleep(1 * time.Second)

        if Shutdown == true {
            fmt.Printf("Kill Early\n")
            return
        }
    }

    fmt.Printf("End Work\n")
}

这份代码的功能是运行一项任务然后中止它,它允许操作系统申请提前终止程序。我一向喜欢尽可能的彻底关闭一个程序。

上述代码创建了一个绑定到操作系统 signal 的 channel,并且在终端窗口查找 ctrl + c,如果它被按下,那么 Shutdown 就会被设置为 true,并且程序跳转回 select 语句。

第一个 Bug

观察如下代码

case <-func() chan struct{} {
    complete := make(chan struct{})
    go LaunchProcessor(complete)
    return complete
}():

我在写这段代码的时候觉得自己很聪明,我认为执行一个函数来生成 Go routine 会很棒。它会返回一个 channel,在 select 处等待运行完成,一旦 Go routine 运行完毕它会关闭这个 channel 然后终止程序。

让我们运行一下:

Start Work
Doing Work
Doing Work
Doing Work
Doing Work
Doing Work
End Work

正如预期的那样,程序启动并生成 Go routine。一旦 Go routine 完成,程序就终止。

接下来我会在运行时按下 ctrl + c

Start Work
Doing Work
^CStart Work
Doing Work
Kill Early
Kill Early

当我按下 ctrl + c 的时候 Go routine 又启动了一遍!

我原以为在这个 case 下的函数只会被执行一次,然后一直等待 channel 继续运行,没想到每次函数运行到 select 都会再执行。

要修复代码,我需要把生成 Go routine 部分从 select 语句中移出来,在循环外生成它。

func main() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt)

    complete := make(chan struct{})
    go LaunchProcessor(complete)

    for {

        select {
        case <-sigChan:
            Shutdown = true
            continue

        case <-complete:
            return
        }
    }
}

现在我们运行程序会得到一个更好的结果。

Start Work
Doing Work
Doing Work
^CKill Early

这次当我按下 ctrl + c 后程序提前终止并且不再生成新的 Go routine。

第二个 Bug

这里还有一个不大明显的 bug 潜伏在代码中,我们来看一下:

var Shutdown bool = false

if whatSig == syscall.SIGINT {
    Shutdown = true
}

if Shutdown == true {
    fmt.Printf("Kill Early\n")
    return
}

该代码使用包层变量通知运行的 Go routine 在 ctrl + c 按下时关闭。每当我按下它时,代码都在工作,那么为什么会有 bug 呢?

首先让我们运行数据竞争检测:

go build -race
./test

运行时我们再按一下 ctrl + c

Start Work
Doing Work
^C==================
WARNING: DATA RACE
Read by Goroutine 5:// 译注 : 被 Go routine 5 读取
    main.LaunchProcessor()
        /Users/bill/Spaces/Test/src/test/main.go:46 +0x10b
    Gosched0()
        /Users/bill/go/src/pkg/runtime/proc.c:1218 +0x9f

Previous write by Goroutine 1:// 译注:被 Go routine 1 写入
    main.main()
        /Users/bill/Spaces/Test/src/test/main.go:25 +0x136
    runtime.main()
        /Users/bill/go/src/pkg/runtime/proc.c:182 +0x91

Goroutine 5 (running) created at:// 译注:生成 Go routine  5
    main.main()
        /Users/bill/Spaces/Test/src/test/main.go:18 +0x8f
    runtime.main()
        /Users/bill/go/src/pkg/runtime/proc.c:182 +0x91

Goroutine 1 (running) created at:// 译注:生成 Go routine 1
    _rt0_amd64()
        /Users/bill/go/src/pkg/runtime/asm_amd64.s:87 +0x106

==================
Kill Early
Found 1 data race(s)

我使用的 Shutdown 变量在数据竞争检测器中显现出来,这是由于有两个 Go routine 在尝试用不安全的方法访问它。

我不用安全的方法访问该变量的初衷是实用的,但是是错的。我认为由于该变量只在必要时用以关闭程序,所以我不介意脏读,但是万一脏读恰好出现在读写该变量的一瞬间呢?如果脏读出现,我可以在下次循环捕获它,看起来没损失对吧?为什么要像这样增加一个复杂的 channel,或者为代码加锁呢。

这就涉及到 Go 内存模型了。

Go Memory Model

Go 语言中文网翻译 Go 内存模型

Go 内存模型不保证 Go routine 读取 Shutdown 变量时会察觉到 main routine 的写入操作,main routine 只写 Shutdown 变量一次并且该变量不会被读取回主内存,因为 main routine 永远不会去读 Shutdown 变量。

虽然这次不会出什么问题,但是随着 Go 语言编译器变得越来越复杂,有可能它会决定完全废止对 Shutdown 的写入。虽然这种行为被 Go 内存模型所允许,然而,我们不希望代码不能通过数据竞争检测。即使是出于实用的原因,这也不是一个好的例子。

接下来是最终版本,修复了所有 bug。

package main

import (
    "fmt"
    "os"
    "os/signal"
    "sync/atomic"
    "time"
)

var Shutdown int32 = 0

func main() {
    sigChan := make(chan os.Signal, 1)
    signal.Notify(sigChan, os.Interrupt)

    complete := make(chan struct{})
    go LaunchProcessor(complete)

    for {

        select {
        case <-sigChan:
            atomic.StoreInt32(&Shutdown, 1)
            continue

        case <-complete:
            return
        }
    }
}

func LaunchProcessor(complete chan struct{}) {
    defer func() {
        close(complete)
    }()

    fmt.Printf("Start Work\n")

    for count := 0; count < 5; count++ {
        fmt.Printf("Doing Work\n")
        time.Sleep(1 * time.Second)

        if atomic.LoadInt32(&Shutdown) == 1 {
            fmt.Printf("Kill Early\n")
            return
        }
    }

    fmt.Printf("End Work\n")
}

我喜欢用 if 语句来检查 Shutdown 是否被设置,这样我能在需要的时候使用它。这个解决方案把 Shutdown 变量从 boolean 变成了 int32,并且用原子函数来存储读取。

在 main routine 如果 ctrl + c 被检测到,Shutdown 变量会安全的从 0 变成 1。在 LanuchProcessor Go routine 中,它会和 1 比较。如果条件为真 Go rontine 就会返回。

有时候确实很让人惊奇,一个如此简单的问题包含了几个陷阱,在一开始有些层面你从来没有考虑过或者意识到。尤其是那些看起来正常工作的代码。


via: https://www.ardanlabs.com/blog/2013/10/my-channel-select-bug.html

作者:William Kennedy  译者:zuoguoyao  校对:polaris1119

本文由 GCTT 原创编译,Go语言中文网 荣誉推出


有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

784 次点击  
加入收藏 微博
被以下专栏收入,发现更多相似内容
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传