阅读 2617

【译】如何使用 Golang 中的 Go-Routines 写出高性能的代码

如何使用 Golang 中的 Go-Routines 写出高性能的代码

为了用 Golang 写出快速的代码,你需要看一下 Rob Pike 的视频 - Go-Routines

他是 Golang 的作者之一。如果你还没有看过视频,请继续阅读,这篇文章是我对那个视频内容的一些个人见解。我感觉视频不是很完整。我猜 Rob 因为时间关系忽略掉了一些他认为不值得讲的观点。不过我花了很多的时间来写了一篇综合全面的关于 go-routines 的文章。我没有涵盖视频中涵盖的所有主题。我会介绍一些自己用来解决 Golang 常见问题的项目。

好的,为了写出很快的 Golang 程序,有三个概念你需要完全了解,那就是 Go-Routines,闭包,还有管道。

Go-Routines

让我们假设你的任务是将 100 个盒子从一个房间移到另一个房间。再假设,你一次只能搬一个盒子,而且移动一次会花费一分钟时间。所以,你会花费 100 分钟的时间搬完这 100 个箱子。

现在,为了让加快移动 100 个盒子这个过程,你可以找到一个方法更快的移动这个盒子(这类似于找一个更好的算法去解决问题)或者你可以额外雇佣一个人去帮你移动盒子(这类似于增加 CPU 核数用于执行算法)

这篇文章重点讲第二种方法。编写 go-routines 并利用一个或者多个 CPU 核心去加快应用的执行。

任何代码块在默认情况下只会使用一个 CPU 核心,除非这个代码块中声明了 go-routines。所以,如果你有一个 70 行的,没有包含 go-routines 的程序。它将会被单个核心执行。就像我们的例子,一个核心一次只能执行一个指令。因此,如果你想加快应用程序的速度,就必须把所有的 CPU 核心都利用起来。

所以,什么是 go-routine。如何在 Golang 中声明它?

让我们看一个简单的程序并介绍其中的 go-routine。

示例程序 1

假设移动一个盒子相当于打印一行标准输出。那么,我们的实例程序中有 10 个打印语句(因为没有使用 for 循环,我们只移动 10 个盒子)。

package main

import "fmt"

func main() {
    fmt.Println("Box 1")
    fmt.Println("Box 2")
    fmt.Println("Box 3")
    fmt.Println("Box 4")
    fmt.Println("Box 5")
    fmt.Println("Box 6")
    fmt.Println("Box 7")
    fmt.Println("Box 8")
    fmt.Println("Box 9")
    fmt.Println("Box 10")
}复制代码

因为 go-routines 没有被声明,上面的代码产生了如下输出。

输出

Box 1
Box 2
Box 3
Box 4
Box 5
Box 6
Box 7
Box 8
Box 9
Box 10复制代码

所以,如果我们想在在移动盒子这个过程中使用额外的 CPU 核心,我们需要声明一个 go-routine。

包含 Go-Routines 的示例程序 2

package main

import "fmt"

func main() {
    go func() {
        fmt.Println("Box 1")
        fmt.Println("Box 2")
        fmt.Println("Box 3")
    }()
    fmt.Println("Box 4")
    fmt.Println("Box 5")
    fmt.Println("Box 6")
    fmt.Println("Box 7")
    fmt.Println("Box 8")
    fmt.Println("Box 9")
    fmt.Println("Box 10")
}复制代码

这儿,一个 go-routine 被声明且包含了前三个打印语句。意思是处理 main 函数的核心只执行 4-10 行的语句。另一个不同的核心被分配去执行 1-3 行的语句块。

输出

Box 4
Box 5
Box 6
Box 1
Box 7
Box 8
Box 2
Box 9
Box 3
Box 10复制代码

分析输出

在这段代码中,有两个 CPU 核心同时运行,试图执行他们的任务,并且这两个核心都依赖标准输出来完成它们相应的任务(因为这个示例中我们使用了 print 语句)
换句话来说,标准输出(运行在它自己的一个核心上)一次只能接受一个任务。所以,你在这儿看到的是一种随机的排序,这取决于标准输出决定接受 core1 core2 哪个的任务。

如何声明 go-routine?

为了声明我们自己的 go-routine,我们需要做三件事。

  1. 我们创建一个匿名函数
  2. 我们调用这个匿名函数
  3. 我们使用 「go」关键字来调用

所以,第一步是采用定义函数的语法,但忽略定义函数名(匿名)来完成的。

func() {
    fmt.Println("Box 1")
    fmt.Println("Box 2")
    fmt.Println("Box 3")
}复制代码

第二步是通过将空括号添加到匿名方法后面来完成的。这是一种叫命名函数的方法。

func() {
  fmt.Println("Box 1")
  fmt.Println("Box 2")
  fmt.Println("Box 3")
} ()复制代码

步骤三可以通过 go 关键字来完成。什么是 go 关键字呢,它可以将功能块声明为可以独立运行的代码块。这样的话,它可以让这个代码块被系统上其他空闲的核心所执行。

#细节 1:当 go-routines 的数量比核心数量多的时候会发生什么?

单个核心通过上下文切换并行执行多个go程序来实现多个核心的错觉。

#自己试试之1:试着移除示例程序2中的 go 关键字。输出是什么呢?

答案:示例程序2的结果和1一模一样。

#自己试试之 2:将匿名函数中的语句从 3 增加至 8 个。结果改变了吗?

答案:是的。main 函数是一个母亲 go-routine(其他所有的 go-routine 都在它里面被声明和创建)。所以,当母亲 go-routine 执行结束,即使其他 go-routines 执行到中途,它们也会被杀掉然后返回。

我们现在已经知道 go-routines 是什么了。接下来让我们来看看闭包

如果之前没有在 Python 或者 JavaScript 中学过闭包,你可以现在在 Golang 中学习它。学到的人可以跳过这部分来节省时间,因为 Golang 中的闭包和 Python 或者 JavaScript 中是一样的。

在我们深入理解闭包之前。让我们先看看不支持闭包属性的语言比如 C,C++ 和 Java,在这些语言中,

  1. 函数只访问两种类型的变量,全局变量和局部变量(函数内部的变量)。
  2. 没有函数可以访问声明在其他函数里的变量。
  3. 一旦函数执行完毕,这个函数中声明的所有变量都会消失。

对 Golang,Python 或者 JavaScript 这些支持闭包属性的语言,以上都是不正确的,原因在于,这些语言拥有以下的灵活性。

  1. 函数可以声明在函数内。
  2. 函数可以返回函数。

推论 #1:因为函数可以被声明在函数内部,一个函数声明在另一个函数内的嵌套链是这种灵活性的常见副产品。

为了了解为什么这两个灵活性完全改变了运作方式,让我们看看什么是闭包。

所以什么是闭包?

除了访问局部变量和全局变量,函数还可以访问函数声明中声明的所有局部变量,只要它们是在之前声明的(包括在运行时传递给闭包函数的所有参数),在嵌套的情况下,函数可以访问所有函数的变量(无论闭包的级别如何)。

为了理解的更好,让我们考虑一个简单的情况,两个函数,一个包含另一个。

package main

import "fmt"

var zero int = 0

func main() {
    var one int = 1
    child := func() {
        var two int = 3
        fmt.Println(zero)
        fmt.Println(one)
        fmt.Println(two)
        fmt.Println(three) // causes compilation Error
    }
    child()
    var three int = 2
}复制代码

这儿有两个函数 - 主函数和子函数,其中子函数定义在主函数中。子函数访问

  1. zero 变量 - 它是全局变量
  2. one 变量 - 闭包属性 - one 属于主函数,它在主函数中且定义在子函数之前。
  3. two 变量 - 它是子函数的局部变量

注意:虽然它被定义在封闭函数「main」中,但它不能访问 three 变量,因为后者的声明在子函数的定义后面。

和嵌套一样。

package main

import "fmt"

var global func()

func closure() {
    var A int = 1
    func() {
        var B int = 2
        func() {
            var C int = 3
            global = func() {
                fmt.Println(A, B, C)
                fmt.Println(D, E, F) // causes compilation error
            }
            var D int = 4
        }()
        var E int = 5
    }()
    var F int = 6
}
func main() {
    closure()
    global()
}复制代码

如果我们考虑一下将一个最内层的函数关联给一个全局变量「global」。

  1. 它可以访问到 A、B、C 变量,和闭包无关。
  2. 它无法访问 D、E、F 变量,因为它们之前没有定义。

注意:即使闭包执行完了,它的局部变量任然不会被销毁。它们仍然能够通过名字是 「global」的函数名去访问。

下面介绍一下 Channels

Channels 是 go-routines 之间通信的一种资源,它们可以是任意类型。

ch := make(chan string)复制代码

我们定义了一个叫做 ch 的 string 类型的 channel。只有 string 类型的变量可以通过此 channel 通信。

ch <- "Hi"复制代码

就是这样发送消息到 channel 中。

msg := <- ch复制代码

这是如何从 channel 中接收消息。

所有 channel 中的操作(发送和接收)本质上是阻塞的。这意味着如果一个 go-routine 试图通过 channel 发送一个消息,那么只有在存在另一个 go-routine 正在试图从 channel 中取消息的时候才会成功。如果没有 go-routine 在 channel 那里等待接收,作为发送方的 go-routine 就会永远尝试发送消息给某个接收方。

最重要的点是这里,跟在 channel 操作后面的所有的语句在 channel 操作结束之前是不会执行的,go-routine 可以解锁自己然后执行跟在它后面的的语句。这有助于同步其他代码块的各种 go-routine。

免责声明:如果只有发送方的 go-routine,没有其他的 go-routine。那么会发生死锁,go 程序会检测出死锁并崩溃。

注意:所有以上讲的也都适用于接收方 go-routines。

缓冲 Channels

ch := make(chan string, 100)复制代码

缓冲 channels 本质上是半阻塞的。

比如,ch 是一个 100 大小的缓冲字符 channel。这意味着前 100 个发送给它的消息是非阻塞的。后面的就会阻塞掉。

这种类型的 channels 的用处在于从它中接收消息之后会再次释放缓冲区,这意味着,如果有 100 个新 go-routines 程序突然出现,每个都从 channel 中消费一个消息,那么来自发送者的下 100 个消息将会再次变为非阻塞。

所以,一个缓冲 channel 的行为是否和非缓冲 channel 一样,取决于缓冲区在运行时是否空闲。

Channels 的关闭

close(ch)复制代码

这就是如何关闭 channel。在 Golang 中它对避免死锁很有帮助。接收方的 go-routine 可以像下面这样探测 channel 是否关闭了。

msg, ok := <- ch
if !ok {
  fmt.Println("Channel closed")
}复制代码

使用 Golang 写出很快的代码

现在我们讲的知识点已经涵盖了 go-routines,闭包,channel。考虑到移动盒子的算法已经很有效率,我们可以开始使用 Golang 开发一个通用的解决方案来解决问题,我们只关注为任务雇佣合适的人的数量。

让我们仔细看看我们的问题,重新定义它。

我们有 100 个盒子需要从一个房间移动到另一个房间。需要着重说明的一点是,移动盒子1和移动盒子2涉及的工作没有什么不同。因此我们可以定义一个移动盒子的方法,变量「i」代表被移动的盒子。方法叫做「任务」,盒子数量用「N」表示。任何「计算机编程基础 101」课程都会教你如何解决这个问题:写一个 for 循环调用「任务」N 次,这导致计算被单核心占用,而系统中的可用核心是个硬件问题,取决于系统的品牌,型号和设计。所以作为软件开发人员,我们将硬件从我们的问题中抽离出去,来讨论 go-routines 而不是核心。越多的核心就支持越多的 go-routines,我们假设「R」是我们「X」核心系统所支持的 go-routines 数量。

FYI:数量「X」的核心数量可以处理超过数量「X」的 go-routines。单个核心支持的 go-routines 数量(R/X)取决于 go-routines 涉及的处理方式和运行时所在的平台。比如,如果所有的 go-routine 仅涉及阻塞调用,例如网络 I/O 或者 磁盘 I/O,则单个内核足以处理它们。这是真的,因为每个 go-routine 相比运算来说更多的在等待。因此,单个核心可以处理所有 go-routine 之间的上下文切换。

因此我们的问题的一般性的定义为

将「N」个任务分配给「R」个 go-routines,其中所有的任务都相同。

如果 N≤R,我们可以用以下方式解决。

package main

import "fmt"

var N int = 100

func Task(i int) {
    fmt.Println("Box", i)
}
func main() {
    ack := make(chan bool, N) // Acknowledgement channel
    for i := 0; i < N; i++ {
        go func(arg int) { // Point #1
            Task(arg)
            ack <- true // Point #2
        }(i) // Point #3
    }

    for i := 0; i < N; i++ {
        <-ack // Point #2
    }
}复制代码

解释一下我们做了什么...

  1. 我们为每个任务创建一个 go-routine。我们的系统能同时支持「R」个 go-routines。只要 N≤R 我们这么做就是安全的。
  2. 我们确认 main 函数在等待所有 go-routine 完成的时候才返回。我们通过等待所有 go-routine(通过闭包属性)使用的确认 channel(「ack」)来传达其完成。
  3. 我们传递循环计数「i」作为参数「arg」给 go-routine,而不是通过闭包属性在 go-routine 中直接引用它。

另一方面,如果 N>R,则上述解决方法会有问题。它会创建系统不能处理的 go-routines。所有核心都尝试运行更多的,超过其容量的 go-routines,最终将会把更多的时间话费在上下文切换上而不是运行程序(俗称抖动)。当 N 和 R 之间的数量差异越来越大,上下文切换的开销会更加突出。因此要始终将 go-routine 的数量限制为 R。并将 N 个任务分配给 R 个 go-routines。

下面我们介绍 workers 函数

var R int = 100
func Workers(task func(int)) chan int { // Point #4
 input := make(chan int)                // Point #1
 for i := 0; i < R; i++ {               // Point #1
   go func() {
     for {
       v, ok := <-input                   // Point #2
       if ok {
         task(v)                           // Point #4
       } else {
         return                            // Point #2
       }
     }
   }()
 }
 return input                          // Point #3
}复制代码
  1. 创建一个包含有「R」个 go-routines 的池。不多也不少,所有对「input」channel 的监听通过闭包属性来引用。
  2. 创建 go-routines,它通过在每次循环中检查 ok 参数来判断 channel 是否关闭,如果 channel 关闭则杀死自己。
  3. 返回 input channel 来允许调用者函数分配任务给池。
  4. 使用「task」参数来允许调用函数定义 go-routines 的主体。

使用

func main() {
ack := make(chan bool, N)
workers := Workers(func(a int) {     // Point #2
  Task(a)
  ack <- true                        // Point #1
 })
for i := 0; i < N; i++ {
  workers <- i
 }
for i := 0; i < N; i++ {             // Point #3
  <-ack
 }
}复制代码

通过将语句(Point #1)添加到 worker 方法中(Point #2),闭包属性巧妙的在任务参数定义中添加了对确认 channel 的调用,我们使用这个循环(Point #3)来使 main 函数有一个机制去知道池中的所有 go-routine 是否都完成了任务。所有和 go-routines 相关的逻辑都应该包含在 worker 自己中,因为它们是在其中创建的。main 函数不应该知道内部 worker 函数们的工作细节。

因此,为了实现完全的抽象,我们要引入一个『climax』函数,只有在池中所有 go-routine 全部完成之后才运行。这是通过设置另一个单独检查池状态的 go-routine 来实现的,另外不同的问题需要不同类型的 channel 类型。相同的 int cannel 不能在所有情况下使用,所以,为了写一个更通用的 worker 函数,我们将使用空接口类型重新定义一个 worker 函数。

package main

import "fmt"

var N int = 100
var R int = 100

func Task(i int) {
    fmt.Println("Box", i)
}
func Workers(task func(interface{}), climax func()) chan interface{} {
    input := make(chan interface{})
    ack := make(chan bool)
    for i := 0; i < R; i++ {
        go func() {
            for {
                v, ok := <-input
                if ok {
                    task(v)
                    ack <- true
                } else {
                    return
                }
            }
        }()
    }
    go func() {
        for i := 0; i < R; i++ {
            <-ack
        }
        climax()
    }()
    return input
}
func main() {

    exit := make(chan bool)

    workers := Workers(func(a interface{}) {
        Task(a.(int))
    }, func() {
        exit <- true
    })

    for i := 0; i < N; i++ {
        workers <- i
    }
    close(workers)

    <-exit
}复制代码

你看,我已经试图展示了 Golang 的力量。我们还研究了如何在 Golang 中编写高性能代码。

请观看 Rob Pike 的 Go-Routines 视频,然后和 Golang 度过一个美好的时光。

直到下次...

感谢 Prateek Nischal


掘金翻译计划 是一个翻译优质互联网技术文章的社区,文章来源为 掘金 上的英文分享文章。内容覆盖 AndroidiOSReact前端后端产品设计 等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

关注下面的标签,发现更多相似文章
评论