Go 的并发:Goroutine 与Channel 介绍

231 阅读5分钟

Goroutine 像是 Go 语言的 thread, 使 Go 建立多工处理, 搭配 Channel 使 Goroutine 操作简单化, 本文介绍 Goroutine 及 Channel 的使用方式。

单线程

在单线程下,每行代码都会依照顺序执行。

// single-thread.go
func say(s string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    say("world")
    say("hello")
}
world
world
world
world
world
hello
hello
hello
hello
hello

上例会先执行完 say("world") 后再执行say("hello")

单线程

但有时个别方法的处理是没有先后顺序的,这时善用多线程就可以大大的提升效率。

多线程

在多线程下,最多可以同时执行与 CPU 数相等的 Goroutine。

// multi-thread.go
func main() {
    go say("world")
    say("hello")
}
world
hello
hello
world
world
hello
hello
world
world
hello

如此一来,say("world")会跑在另一个线程(Goroutine)上,使其并行执行。

多线程

CPU 数可以使用 runtime.NumCPU() 取得。

Goroutine 介绍

可以想成建立了一个 Goroutine 就是建立了一个新的 Thread。

go f(x, y, z)
  • 以 go 开头的函式叫用可以使 f 跑在另一个Goroutine 上
  • fxyz 取自目前的 goroutine
  • main函式也是跑在 Goroutine 上
  • Main Goroutine 执行结束后, 其他的Goroutine 会跟着强制关闭

等待

多线程下,经常需要处理的是线程之间的状态管理,其中一个经常发生的事情是等待,例如 A 线程需要等 B 线程计算并取得数据后才能继续往下执行,在这情况下等待就变得十分重要。

应该等待的时机

func main() {
    go say("world")
    go say("hello")
}

这个状态下会有三个 Goroutine:

  • main
  • say("world")
  • say("hello")

这里的问题发生在mainGoroutine 结束时,另外两个sayGoroutine 会被强制关闭导致结果错误,这时就需要等待其他的Goroutine 结束后mainGoroutine 才能结束。

接下来会介绍三种等待的方式,并且分析其利弊:

  • time.Sleep: 休眠指定时间
  • sync.WaitGroup: 等待直到指定数量的 Done() 叫用
  • Channel 阻塞: 使用 Channel 阻塞机制,使用接收时等待的特性避免线程继续执行

time.Sleep

使Goroutine 休眠,让其他的Goroutine 在main 结束前有时间执行完成。

// sleep.go
func main() {
    go say("world")
    go say("hello")

    time.Sleep(5 * time.Second)
}

睡觉

缺点:

  • 休息指定时间可能会比Goroutine 需要执行的时间长或短,太长会耗费多余的时间,太短会使其他 Goroutine 无法完成

sync.WaitGroup

// wait-group.go
func say(s string, wg *sync.WaitGroup) {
    defer wg.Done()

    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
}

func main() {
    wg := new(sync.WaitGroup)
    wg.Add(2)

    go say("world", wg)
    go say("hello", wg)

    wg.Wait()
}

等待组

  • 产生与想要等待的Goroutine 同样多的WaitGroupCounter
  • 将 WaitGroup 传入Goroutine 中,在执行完成后叫用 wg.Done() 将Counter 减一
  • wg.Wait()会等待直到Counter 减为零为止

优点

  • 避免时间预估的错误

缺点

  • 需要手动配置对应的Counter

channel

最后介绍的是使用 Channel 等待, 原为 Goroutine 沟通时使用的,但因其阻塞的特性,使其可以当作等待 Goroutine 的方法。

// channel-wait.go
func say(s string, c chan string) {
    for i := 0; i < 5; i++ {
        time.Sleep(100 * time.Millisecond)
        fmt.Println(s)
    }
    c <- "FINISH"
}

func main() {
    ch := make(chan string)

    go say("world", ch)
    go say("hello", ch)

    <-ch
    <-ch
}

通道等待

起了两个 Goroutine( say("world", ch)say("hello", ch)) ,因此需要等待两个 FINISH 推入 Channel 中才能结束 Main Goroutine。

优点

  • 避免时间预估的错误
  • 语法简洁

Channel 阻塞的方法为 Go 语言中等待的主要方式。

多线程下的共享变量

在线程间使用同样的变量时,最重要的是确保变量在当前的正确性,在没有控制的情况下极有可能发生问题,下面有个例子:

// total-error.go
func main() {
    total := 0
    for i := 0; i < 1000; i++ {
        go func() {
            total++
        }()
    }
    time.Sleep(time.Second)
    fmt.Println(total)
}
958

总误差

假设目前加到28,在多线程的情况下:

  • goroutine1取值28 做运算
  • goroutine2 有可能在 goroutine1 做 total++ 前就取 total 的值,因此有可能取到 28
  • 这样的情况下做两次加法的结果会是29 而非30

在多个goroutine 里对同一个变数total做加法运算,在赋值时无法确保其为安全的而导致运算错误,此问题称为Race Condition

互斥锁(sync.Mutex)

在这种状况下,可以使用互斥锁( sync.Mutex)来保证变数的安全:

// total-mutex.go
type SafeNumber struct {
    v   int
    mux sync.Mutex // 互斥鎖
}

func main() {
    total := SafeNumber{v: 0}
    for i := 0; i < 1000; i++ {
        go func() {
            total.mux.Lock()
            total.v++
            total.mux.Unlock()
        }()
    }
    time.Sleep(time.Second)
    total.mux.Lock()
    fmt.Println(total.v)
    total.mux.Unlock()
}
1000

全互斥体

互斥锁使用在数据结构( struct)中,用以确保结构中变数读写时的安全,它提供两个方法:

  • Lock
  • Unlock

在 Lock 及 Unlock 中间,会使其他的 Goroutine 等待,确保此区块中的变量安全。

借由 Channel 保证变量的安全性

// total-channel.go
func main() {
    total := 0
    ch := make(chan int, 1)
    ch <- total
    for i := 0; i < 1000; i++ {
        go func() {
            ch <- <-ch + 1
        }()
    }
    time.Sleep(time.Second)
    fmt.Println(<-ch)
}
1000

全渠道

  • goroutine1 拉出 total 后,Channel 中没有数据了
  • 因为Channel 中没有数据,因此造成goroutine2 等待
  • goroutine1 计算完成后,将 total 推入Channel
  • goroutine2 等到Channel 中有数据,拉出后结束等待,继续做运算

因为Channel 推入及拉出时等待的特性,被拉出来做计算的值会保证是安全的。

因为此范例一定要拉出 Channel 数据才能做运算,所以使用非立即阻塞的Buffered Channel ,与Unbuffered Channel 的差别等下会说明。

上述的三个例子在main goroutine 中都使用 time.Sleep 避免程式提前结束。

Channel 介绍

上面借由两个在多线程中重要的话题:等待变数的共享,带出 Channel 强大的处理能力,接着来深入了解一下Channel。

Channel 可以想成一条管线,这条管线可以推入数值,并且也可以将数值拉取出来。

因为 Channel 会等待至另一端完成推入/拉出的动作后才会继续往下处理,这样的特性使其可以在 Goroutines 间同步的处理数据,而不用使用明确的lock,unlock等方法。

建立Channel

ch := make(chan int) // 建立 int 型別的 Channel

推入/拉出Channel 内的值,使用 <- 箭头运算子:

  • Channel 在 <- 左边:将箭头右边的数值推入Channel 中
ch <- v    // Send v to channel ch.
v := <-ch  // Receive from ch, and assign value to v.

Channel 的阻塞

Goroutine 使用 Channel 时有两种情况会造成阻塞:

  • 将数据推入 Channel,但其他 Goroutine 还未拉取数据时,将数据推入的 Goroutine 会被迫等待其他 Goroutine 拉取数据才能往下执行

频道睡眠推送

  • 当 Channel 中没有数据,但要从中拉取时,想要拉取数据的 Goroutine 会被迫等待其他 Goroutine 推入数据并自己完成拉取后才能往下执行

通道睡眠拉动

Goroutine 推数据入 Channel 时的等待情境

// channel-block-push.go
func main() {
    ch := make(chan string)

    go func() { // calculate goroutine
        fmt.Println("calculate goroutine starts calculating")
        time.Sleep(time.Second) // Heavy calculation
        fmt.Println("calculate goroutine ends calculating")

        ch <- "FINISH" // goroutine 执行会在此被迫等待

        fmt.Println("calculate goroutine finished")
    }()

    time.Sleep(2 * time.Second) // 使 main 比 goroutine 慢
    fmt.Println(<-ch)
    time.Sleep(time.Second)
    fmt.Println("main goroutine finished")
}
calculate goroutine starts calculating
calculate goroutine ends calculating
FINISH
calculate goroutine finished
main goroutine finished

此例使用 time.Sleep 强迫main 执行慢于calculate,现在来观察输出的结果:

  • calculate 会先执行并且计算完成
  • calculate 将 FINISH 讯号推入Channel
  • 但由于目前main 还未拉取Channel 中的数据,所以calculate 会被迫等待,因此calculate 的最后一行 fmt.Println("main goroutine finished") 没有马上输出在画面上
  • main 拉取了Channel 中的数据
  • calculate 执行fmt.Println("main goroutine finished") 并结束
  • main 执行完成

Goroutine 拉数据出Channel 时的等待情境

// channel-block-pull.go
func main() {
    ch := make(chan string)

    go func() {
        fmt.Println("calculate goroutine starts calculating")
        time.Sleep(time.Second) // Heavy calculation
        fmt.Println("calculate goroutine ends calculating")

        ch <- "FINISH"

        fmt.Println("calculate goroutine finished")
    }()

    fmt.Println("main goroutine is waiting for channel to receive value")
    fmt.Println(<-ch) // goroutine 执行会在此被迫等待
    fmt.Println("main goroutine finished")
}
main goroutine is waiting for channel to receive value
calculate goroutine starts calculating
calculate goroutine ends calculating
calculate goroutine finished
FINISH
main goroutine finished
  • main 因拉取的时候calculate 还没将数据推入Channel 中,因此main 会被迫等待,因此main 的最后一行 fmt.println 没有马上输出在画面上
  • calculate 执行并且计算完成
  • calculate 将 FINISH 推入Channel
  • calculate 执行完成
  • main 拉取了Channel 中的数据并且执行完成

无缓冲通道

前面一直提到的是Unbuffered Channel,此种Channel 只要

  • 推入一个数据会造成推入方的等待
  • 拉出时没有数据会造成拉出方的等待

使用Unbuffered Channel 的坏处是:如果推入方的执行一次的时间较拉取方短,会造成推入方被迫等待拉取方才能在做下一次的处理,这样的等待是不必要并且需要被避免的。

为了解决推入方等待问题,可以使用另一种Channel:Buffered Channel。

缓冲通道

ch: make(chan int, 100)

Buffered Channel 的宣告会在第二个参数中定义buffer 的长度,它只会在Buffered 中数据填满以后才会阻塞造成等待,以上例来说:第101个数据推入的时候,推入方的Goroutine 才会等待。

缓冲通道

下面的例子分别使用Buffered Channel 跟Unbuffered Channel 的差别:

// unbuffered-channel-error.go
func main() {
    ch := make(chan int)
    ch <- 1 // 等到天荒地老
    fmt.Println(<-ch)
}
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:
main.main()
        /go/unbuffered-channel-error.go:9 +0x59
exit status 2

上例使用Unbuffered Channel:

  • 只有一条Goroutine:main
  • 推入1 后因为还没有其他Goroutine 拉取Channel 中的数据,所以进入阻塞状态
  • 因为main 已经在推入数据时阻塞,所以拉取的程式永远不会被执行,造成死锁

无缓冲通道错误

在相同的情况下,Buffered Channel 并不会被阻塞:

// buffered-channel.go
func main() {
    ch := make(chan int, 1)
    ch <- 1
    fmt.Println(<-ch)
}
1

原因是:

  • 推入1 后Channel 内的数据数为1并没有超过Buffer 的长度1,所以不会被阻塞
  • 因为没有阻塞,所以下一行拉取的程式码可以被执行,并且完成执行

缓冲通道工作

Loop 中的Channel

在回圈中的Channel 可以借由第二个回传值 ok 确认Channel 是否被关闭,如果被关闭的话代表此Channel 已经不再使用,可以结束遍历。

// for-loop.go
func main() {
    c := make(chan int)
    go func() {
        for i := 0; i < 10; i++ {
            c <- i
        }
        close(c) // 关闭 Channel
    }()
    for {
        v, ok := <-c
        if !ok { // 判断 Channel 是否关闭
            break
        }
        fmt.Println(v)
    }
}
0
1
2
3
4
5
6
7
8
9

如果对Closed Channel 推入数据的话会造成Panic:

// closed-channel-panic.go
func main() {
    c := make(chan int)
    close(c)
    c <- 0 // Panic!!!
}
panic: send on closed channel

为了避免将数据推入已关闭的Channel 中造成Panic,Channel 的关闭应该由推入的Goroutine 处理。

range 中的Channel

range是可以便利Channel 的,终止条件为Channel 的状态为已关闭的(Closed):

// range.go
func main() {
    c := make(chan int, 10)
    go func() {
        for i := 0; i < 10; i++ {
            c <- i
        }
        close(c) // 关闭 Channel
    }()
    for i := range c { // 在 close 后跳出循环
        fmt.Println(i)
    }
}

使用select 避免等待

在Channel 推入/拉取时,会有一段等待的时间而造成Goroutine 无法回应,如果此Goroutine 是负责处理画面的,使用者就会看到画面lag 的情况,这是我们不想见的情况。

例如之前提到的例子:

// block.go
func main() {
    ch := make(chan string)

    go func() {
        fmt.Println("calculate goroutine starts calculating")
        time.Sleep(time.Second) // Heavy calculation
        fmt.Println("calculate goroutine ends calculating")

        ch <- "FINISH"

        fmt.Println("calculate goroutine finished")
    }()

    fmt.Println("main goroutine is waiting for channel to receive value")
    fmt.Println(<-ch) // goroutine 执行会在此被迫等待
    fmt.Println("main goroutine finished")
}
main goroutine is waiting for channel to receive value # main goroutine 阻塞
calculate goroutine starts calculating
calculate goroutine ends calculating
calculate goroutine finished
FINISH # main goroutine 解除阻塞
main goroutine finished

main goroutine 要拉取 ch 的数据时,会被迫等待,这时会无法回馈目前的状态给使用者,造成卡顿的清况。

这时可以使用Go 提供的 select 语法,让开发者可以很轻松的处理Channel 的多种情况,包括阻塞时的处理。

// select.go
func main() {
    ch := make(chan string)

    go func() {
        fmt.Println("calculate goroutine starts calculating")
        time.Sleep(time.Second) // Heavy calculation
        fmt.Println("calculate goroutine ends calculating")

        ch <- "FINISH"
        time.Sleep(time.Second)
        fmt.Println("calculate goroutine finished")
    }()

    for {
        select {
        case <-ch: // Channel 中有数据执行此case
            fmt.Println("main goroutine finished")
            return
        default: // Channel 阻塞的话执行此case
            fmt.Println("WAITING...")
            time.Sleep(500 * time.Millisecond)
        }
    }
}
WAITING... # main goroutine 在阻塞時可以回应
calculate goroutine starts calculating
WAITING... # main goroutine 在阻塞時可以回应
WAITING... # main goroutine 在阻塞時可以回应
calculate goroutine ends calculating
main goroutine finished # main goroutine 解除阻塞并結束程式

将刚刚的例子改为 select 来处理,可以使Channel 的推入/拉取不会阻塞:

  • 会在没有阻塞的情况下才会执行对应的区块
  • case <-ch: 会等到没有阻塞情况时(ch内有数据)才会执行
  • default: 在所有的 case 都阻塞的情况下执行

因为有 default 可以设置,当 Channel 阻塞时也可以借由 default 输出资讯让使用者知道。

总结

一开始提到了单线程跟多线程的差别,接着带出 Goroutine ,并介绍各种等待方式( time.Sleep,sync.WaitGroupChannel)和线程间分享变数的问题(Race Condition)及解决方法(sync.MutexChannel),从而带出 Channel 在线程中方便强大的能力。

再来讲述 Channel 的使用方式,及其阻塞的时机(推入阻塞及拉取阻塞),接着说明 Unbuffered 及 Buffered Channel 的差别,并且说明可以借由 Unbuffered Channel 降低效能上的损失。

Channel 传回的第二个参数:ok,可以判断此Channel 是否已经关闭,并被 range 用在结束遍历的判断中。

最后说明了 select 可以 Channel 在阻塞时让Goroutine 保持非阻塞的状态避免卡顿。

Goroutine 及 Channel 简单的语法但是强大的能力,使工程师开发多工程式的时候可以写出优雅又易于维护的代码,是 Go 语言的优势之一。