如何在 Go 中同时运行多个函数

作者选择了 多样性在技术基金作为 写给捐款计划的一部分接受捐款。

介绍

Go 语言的最受欢迎的特征之一是它对 concurrency,或一个程序能够同时执行多个事情的能力。能够同时运行代码正在成为编程的一大部分,因为计算机从运行单一的代码流更快地运行更多的代码流同时运行。为了更快地运行程序,程序员需要设计他们的程序同时运行,以便该程序的每个并行部分可以独立运行。Go 中的两种功能, goroutineschannels,在一起使用时使并行更容易。 Goroutines 解决了在程序中设置并运行并行代码的困难,渠道解决了同时运行代码之间安全通信的困难。

在本教程中,您将探索 goroutines 和 渠道. 首先,您将创建一个使用 goroutines 同时运行多个函数的程序. 然后,您将向该程序添加渠道,以便在运行 goroutines 之间进行通信. 最后,您将向程序添加更多的 goroutines 来模拟一个与多个工人 goroutines 运行的程序。

前提条件

要遵循本教程,您将需要:

与Goroutines同时运行函数

在现代计算机中,处理器,或CPU(https://en.wikipedia.org/wiki/Central_processing_unit),旨在同时运行尽可能多的代码流。这些处理器有一个或多个核心,每个能够同时运行一个代码流。因此,程序可以同时使用的核心越多,程序的运行速度越快。然而,为了使程序能够利用多个核心(https://en.wikipedia.org/wiki/Multi-core_processor)提供的速度增加,程序需要能够分成多个代码流。

一种方法 Go 可以做到这一点是使用一个称为 goroutines 的功能。 goroutine 是一种特殊的函数类型,可以在其他 goroutines 同时运行时运行。 当一个程序设计同时运行多个代码流时,该程序旨在运行 concurrently。 通常情况下,当一个函数被调用时,它会在代码继续运行后完全完成运行。 这被称为在foreground中运行,因为它阻止你的程序在完成之前做任何其他事情。

功率goroutines提供的是,每个goroutine可以同时运行在一个处理器内核上。如果您的计算机有四个处理器内核,而您的程序有四个goroutines,那么所有四个goroutines都可以同时运行。

要想想象并行性和平行性之间的差异,请考虑下面的图表。当处理器运行函数时,它并不总是从开始运行到完成一次. 有时操作系统会将其他函数、 goroutines 或其他程序插入 CPU 内核,当函数等待其他事情发生时,例如阅读文件。 该图表显示了设计为并行而设计的程序如何在单个内核以及多个内核上运行。

Diagram split into two columns, labeled Concurrency and Parallelism. The Concurrency column has a single tall rectangle, labeled CPU core, divided into stacked sections of varying colors signifying different functions. The Parallelism column has two similar tall rectangles, both labeled CPU core, with each stacked section signifying different functions, except it only shows goroutine1 running on the left core and goroutine2 running on the right core.

图表中的左列,标记为竞争,显示了围绕并行设计的程序如何在一个单一的CPU核心上运行goroutine1的一部分,然后是另一个函数,goroutine或程序,然后是goroutine2,然后是goroutine1,等等。

图表右侧的列标记为平行,显示了同一个程序如何在具有两个CPU内核的处理器上并行运行。第一个CPU内核显示goroutine1与其他函数、goroutine或程序交叉运行,而第二个CPU内核显示goroutine2在该内核上运行其他函数或goroutine。

该图表还显示了Go的另一个强大的特征,scalability。一个程序是可扩展的,当它可以运行任何东西,从一个小型计算机与几个处理器核心到一个拥有数十个核心的大型服务器,并利用这些额外的资源。

要开始使用你的新的并行程序,在您选择的位置创建一个多功能目录. 您可能已经为您的项目有一个目录,但在本教程中,您将创建一个名为项目的目录. 您可以通过IDE或通过命令行创建项目目录。

如果您正在使用命令行,请开始创建项目目录并导航到它:

1mkdir projects
2cd projects

项目目录中,使用mkdir命令创建程序目录(multifunc),然后导航到它:

1mkdir multifunc
2cd multifunc

一旦进入多功能目录,请使用nano或您最喜欢的编辑器打开名为main.go的文件:

1nano main.go

在 main.go 文件中插入或输入以下代码以开始。

 1[label projects/multifunc/main.go]
 2package main
 3
 4import (
 5    "fmt"
 6)
 7
 8func generateNumbers(total int) {
 9    for idx := 1; idx <= total; idx++ {
10    	fmt.Printf("Generating number %d\n", idx)
11    }
12}
13
14func printNumbers() {
15    for idx := 1; idx <= 3; idx++ {
16    	fmt.Printf("Printing number %d\n", idx)
17    }
18}
19
20func main() {
21    printNumbers()
22    generateNumbers(3)
23}

这个初始程序定义了两个函数,即生成数字打印数字,然后在主要函数中运行这些函数。 生成数字函数将生成的数量作为参数,在这种情况下一到三,然后将每个数字打印到屏幕上。

一旦您保存了 main.go 文件,请使用 go run 运行它以查看输出:

1go run main.go

结果将看起来像这样:

1[secondary_label Output]
2Printing number 1
3Printing number 2
4Printing number 3
5Generating number 1
6Generating number 2
7Generating number 3

您将看到函数一个接一个运行,‘printNumbers’运行第一,‘generateNumbers’运行第二。

现在想象一下,‘printNumbers’ 和‘generateNumbers’ 都需要三秒钟才能运行。当运行 synchronously 时,或者就像上一个例子一样,你的程序需要六秒钟才能运行。首先,‘printNumbers’ 会运行三秒钟,然后‘generateNumbers’ 会运行三秒钟。在你的程序中,然而,这两个函数是相互独立的,因为它们不依赖于对方的数据来运行。你可以利用这一点来加速这个假设的程序,同时使用 goroutines 运行函数。当两个函数同时运行时,该程序理论上可以在半个小时内运行。如果‘printNumbers’ 和 ‘generate Numbers’ 两个函数都需要三秒钟才能运行,并且两者都能在同一时间

同时运行一个函数作为 goroutine 类似于同步运行一个函数. 要运行一个函数作为 goroutine (与标准同步函数不同),您只需要在函数调用之前添加Go关键字。

但是,要让程序同时运行 goroutines,你需要做一个额外的更改。你需要为你的程序添加一种方式,等到两个 goroutines 都完成运行。如果你不等到你的 goroutines 完成并且你的主要函数完成,则 goroutines 可能永远不会运行,或者只有一部分可以运行并且无法完成运行。

要等待函数完成,您将从 Go 的 sync 包中使用 WaitGroup

原始的等待组通过计算需要等待使用添加完成等待函数的次数工作。添加函数增加了该函数提供的数目,而完成函数减少了数目。

接下来,更新您的main.go 文件中的代码以使用go关键字来运行您的两个函数作为goroutines,并将sync.WaitGroup添加到程序中:

 1[label projects/multifunc/main.go]
 2package main
 3
 4import (
 5    "fmt"
 6    "sync"
 7)
 8
 9func generateNumbers(total int, wg *sync.WaitGroup) {
10    defer wg.Done()
11
12    for idx := 1; idx <= total; idx++ {
13    	fmt.Printf("Generating number %d\n", idx)
14    }
15}
16
17func printNumbers(wg *sync.WaitGroup) {
18    defer wg.Done()
19
20    for idx := 1; idx <= 3; idx++ {
21    	fmt.Printf("Printing number %d\n", idx)
22    }
23}
24
25func main() {
26    var wg sync.WaitGroup
27
28    wg.Add(2)
29    go printNumbers(&wg)
30    go generateNumbers(3, &wg)
31
32    fmt.Println("Waiting for goroutines to finish...")
33    wg.Wait()
34    fmt.Println("Done!")
35}

在宣布WaitGroup后,它将需要知道需要等待多少事情。在主要函数中包含一个wg.Add(2)goroutines开始之前会告诉wg在考虑组结束之前等待两个完成呼叫。

然后,每个函数将使用推迟来调用完成,以便在函数完成运行后减少一个次数。主要函数也被更新为在等待组中调用等待,因此主要函数将等到两个函数调用完成继续运行并退出程序。

保存「main.go」檔案後,像以前一樣使用「go run」執行它:

1go run main.go

结果将看起来像这样:

1[secondary_label Output]
2Printing number 1
3Waiting for goroutines to finish...
4Generating number 1
5Generating number 2
6Generating number 3
7Printing number 2
8Printing number 3
9Done!

您的输出可能与在这里打印的内容不同,甚至可能每次运行程序都会发生变化. 随着两个函数同时运行,输出取决于 Go 和您的操作系统为每个函数提供多少时间。

您可以尝试的一种实验是,在函数中删除wg.Wait()呼叫,然后再运行Go Run程序几次。 根据您的计算机,您可能会看到一些generateNumbersprintNumbers函数的输出,但您也可能不会看到任何输出。 当您删除Wait呼叫时,该程序将不再等待两个函数完成运行,然后继续运行。 由于函数在Wait函数结束后不久,您的程序有很好的机会在函数结束之前到达函数的尽头,然后输出。

在本节中,您创建了一个程序,该程序使用go关键字同时运行两个goroutines并打印一个数字序列,您还使用了一个sync.WaitGroup,让您的程序在离开程序之前等待这些goroutines完成。

您可能已经注意到生成数字打印数字函数没有返回值。在Go中,goroutines无法像标准函数那样返回值。您仍然可以使用go关键字来调用返回值的函数,但这些返回值将被丢弃,您将无法访问它们。所以,当您需要从一个goroutine返回另一个goroutine的数据时,如果无法返回值时,您该怎么办? 解决方案是使用一个名为渠道的Go功能,允许您从一个goroutine发送数据到另一个goroutine。

通过频道安全地在Goroutines之间进行通信

如果你不小心,你可能会遇到只有在同步程序中可能出现的问题,例如,当一个程序的两个部分同时运行时,可能会发生 data race,而一个部分试图同时更新一个变量,而另一个部分试图同时读取它。当这种情况发生时,读取或写作可能会发生失序,导致程序的一个或两个部分使用错误的值。

虽然在Go中仍然有可能遇到同步问题,如数据竞赛,但该语言旨在使其更容易避免。除了goroutines之外,渠道是使同步更安全和更易于使用的另一个功能。一个渠道可以被认为是两个或多个不同的goroutines之间的管道,可以通过数据发送。

在 Go 中创建一个频道类似于使用内置的 make() 函数创建一个 slice。 一个频道的类型声明使用了 chan 关键字,然后是你想要在频道上发送的 数据类型

1bytesChan := make(chan []byte)

一旦创建一个频道,您可以通过使用看似箭头的 <- 操作员在频道上发送或接收数据。

要写入一个频道,请从频道变量开始,然后是 <- 操作员,然后是要写入频道的值:

1intChan := make(chan int)
2intChan <- 10

若要从频道读取一个值,请从您要将该值放入的变量开始,或者是 =:= 将值分配给变量,然后是 <- 运算符,然后是您想要读取的频道:

1intChan := make(chan int)
2intVar := <- intChan

要保持这两种操作平行,有助于记住‘<-’箭头总是指向左侧(与‘->’相反),箭头指向值的方向。

就像一个片段一样,一个频道也可以使用范围关键字在一个循环中读取(LINK0)。当使用范围关键字读取一个频道时,循环的每一次迭代都会从频道中读取下一个值,并将其放入循环变量中。

1intChan := make(chan int)
2for num := range intChan {
3    // Use the value of num received from the channel
4    if num < 1 {
5    	break
6    }
7}

在某些情况下,您可能只希望允许一个函数从一个频道读取或写入,但不是两者。 要做到这一点,您会将<-运算符添加到chan类型声明中。 类似于从一个频道读取和写入,该频道类型使用<-箭头来允许变量限制一个频道只读、只写或只读和写入。

1func readChannel(ch <-chan int) {
2    // ch is read-only
3}

如果你想让频道只写字,你会将其声明为chan<- int:

1func writeChannel(ch chan<- int) {
2    // ch is write-only
3}

注意,箭头指向阅读渠道,指向写字渠道. 如果声明没有箭头,如在chan int的情况下,该渠道可以用于阅读和写字。

最后,一旦一个频道不再被使用,它可以使用内置的关闭()函数关闭。这个步骤是必不可少的,因为当频道被创建,然后在程序中多次未使用时,它可以导致被称为_memory leak_(https://en.wikipedia.org/wiki/Memory_leak)的记忆漏洞。记忆漏洞是当一个程序创建某些东西,在计算机上使用记忆,但没有释放该记忆回计算机,一旦它完成使用它。

现在,更新你的程序中的main.go文件以使用chan int频道在你的goroutines之间进行通信。generateNumbers函数将生成数字并写入频道,而printNumbers函数将从频道读取这些数字并打印到屏幕上。在main函数中,你将创建一个新的频道作为参数传递给其他每个函数,然后使用close()在频道上关闭它,因为它将不再使用。

 1[label projects/multifunc/main.go]
 2package main
 3
 4import (
 5    "fmt"
 6    "sync"
 7)
 8
 9func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) {
10    defer wg.Done()
11
12    for idx := 1; idx <= total; idx++ {
13    	fmt.Printf("sending %d to channel\n", idx)
14    	ch <- idx
15    }
16}
17
18func printNumbers(ch <-chan int, wg *sync.WaitGroup) {
19    defer wg.Done()
20
21    for num := range ch {
22    	fmt.Printf("read %d from channel\n", num)
23    }
24}
25
26func main() {
27    var wg sync.WaitGroup
28    numberChan := make(chan int)
29
30    wg.Add(2)
31    go printNumbers(numberChan, &wg)
32
33    generateNumbers(3, numberChan, &wg)
34
35    close(numberChan)
36
37    fmt.Println("Waiting for goroutines to finish...")
38    wg.Wait()
39    fmt.Println("Done!")
40}

在「generateNumbers」和「printNumbers」的参数中,你会看到「chan」类型正在使用只读和只写类型.因为「generateNumbers」只需要能够将数字写入渠道,它是一种只写的类型,指向渠道的「<-」箭头。

儘管這些類型可能是一個「chan int」,這將允許閱讀和寫作,但可能有助於將其限制在該函數需要的部分,以避免意外地導致您的程式停止運行從被稱為 deadlock 的東西。 當一個程式的一部分等待該程式的另一部分做某事時,可能會發生僵局,但該程式的另一部分也在等待該程式的第一部分完成。

当程序的一部分正在写到一个频道时,它会等到另一个部分在继续之前从该频道读到。同样,如果程序正在从一个频道读到,它会等到另一个部分在继续之前写到该频道。当程序的一部分在等待其他事情发生时,它会被称为blocking因为它被阻止继续,直到其他事情发生。当它们被写到或读到时,频道将被阻止。

更新代码的另一个重要方面是使用close()关闭频道,一旦它被编写到generateNumbers。在这个程序中,close()会导致printNumbers中的for... range循环退出,因为使用range从一个频道读取,直到它正在读取的频道关闭,如果close没有在numberChan上被调用,那么printNumbers就永远不会结束,如果printNumbers从未结束,WaitGroup的Done方法就永远不会被defer调用,当printNumbers退出。如果Done方法从未被称为printNumbers,这个程序本身就永远不会退出,因为WaitGroup的`WaitNumb

现在,使用在main.go上的go run命令再次运行更新的代码。

1go run main.go

您的输出可能略有不同于下面所示,但总体上应该是相似的:

1[secondary_label Output]
2sending 1 to channel
3sending 2 to channel
4read 1 from channel
5read 2 from channel
6sending 3 to channel
7Waiting for functions to finish...
8read 3 from channel
9Done!

该程序的输出显示,生成数字函数正在生成数字一到三,同时将它们写入与printNumbers共享的频道。一旦printNumbers收到数字,然后将其打印到屏幕上。在generateNumbers生成了所有三个数字后,它将退出,允许函数关闭频道,等到printNumbers完成。一旦printNumbers完成打印最后一个数字,它在WaitGroup上呼叫Done,并且程序退出。类似于以前的输出,您看到的准确输出将取决于各种外部因素,例如操作系统或Go Runtime选择运行特定的goroutines,但它应该相对接近。

使用goroutines和频道设计你的程序的好处在于,一旦你设计了你的程序被分割,你可以将其扩展到更多的goroutines。因为generateNumbers只是写到一个频道上,它不重要有多少其他东西正在从该频道中读取。它只会将数字发送到任何读取频道的东西。你可以通过运行多个打印Numbers的goroutine来利用这一点,所以他们每个人都会从同一个频道读取并同时处理数据。

现在你的程序正在使用渠道进行通信,重新打开main.go文件,并更新你的程序,以便它启动多个printNumbers goroutines. 你需要调整调用到wg.Add以便它在你开始的每一个goroutine中添加一个。你不需要担心将一个添加到WaitGroup调用到generateNumbers,因为程序不会继续没有完成整个函数,与你运行它作为一个goroutine时不同。 为了确保它在完成时不会减少WaitGroup的数目,你应该从函数中删除deferg w.Done()的行。 接下来,将goroutine的数目添加到PrintNumbers使您更容易看到每一个人的频道如何读取。 增加

 1[label projects/multifunc/main.go]
 2...
 3
 4func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) {
 5    for idx := 1; idx <= total; idx++ {
 6    	fmt.Printf("sending %d to channel\n", idx)
 7    	ch <- idx
 8    }
 9}
10
11func printNumbers(idx int, ch <-chan int, wg *sync.WaitGroup) {
12    defer wg.Done()
13
14    for num := range ch {
15    	fmt.Printf("%d: read %d from channel\n", idx, num)
16    }
17}
18
19func main() {
20    var wg sync.WaitGroup
21    numberChan := make(chan int)
22
23    for idx := 1; idx <= 3; idx++ {
24    	wg.Add(1)
25    	go printNumbers(idx, numberChan, &wg)
26    }
27
28    generateNumbers(5, numberChan, &wg)
29
30    close(numberChan)
31
32    fmt.Println("Waiting for goroutines to finish...")
33    wg.Wait()
34    fmt.Println("Done!")
35}

一旦您的 main.go 更新,您可以使用 go runmain.go 重新运行您的程序. 您的程序应该在继续生成数字之前启动三个printNumbers goroutines. 您的程序现在也应该生成五个数字而不是三个,以便更容易看到三个printNumbers goroutines中的每个数字分布:

1go run main.go

输出可能看起来类似于此(尽管您的输出可能会有所不同):

 1[secondary_label Output]
 2sending 1 to channel
 3sending 2 to channel
 4sending 3 to channel
 53: read 2 from channel
 61: read 1 from channel
 7sending 4 to channel
 8sending 5 to channel
 93: read 4 from channel
101: read 5 from channel
11Waiting for goroutines to finish...
122: read 3 from channel
13Done!

当你这次看您的程序输出时,很有可能它会与上面看到的输出大相径庭. 由于有3个"印数""出行"出行"出行"出行",因此有一个机会因素来确定哪一个收到具体数字. 当一个 " 印数 " goroutine收到一个数字时,它花费了一小段时间将这个数字打印到屏幕上,而另一个goroutine读取频道的下一个数字并做同样的事情。 当一个goroutine完成打印数字的工作并准备读取另一个数字时,它会回去再读取频道再打印下一个数字. 如果频道没有更多数字需要读取,则会开始屏蔽,直到下一个数字可以读取. 一旦完成基因数接近() 这三个 " 印数 " goroutines将完成 " 距离 " 循环并退出。 当所有三个行进程序都离开并在等待小组上称为Done'时,等待小组`的计数将达到零,程序将退出。 也可以实验增减出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出入出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出出.

使用"出道"时,避免开始过多. 在理论上,一个程序可能 拥有上百甚至上千个goroutines。 然而,取决于程序运行的计算机,实际上,拥有较多的出行道道可能比较慢. 由于有高量的出行道,它有可能会跑入[资源饥饿 (https://en.wikipedia.org/wiki/Starvation(computer_science). 每次都这样 去运行一个goroutine的一部分,除了在下个函数中运行代码所需的时间外,还需要一些额外的时间重新开始运行. 由于需要额外的时间,计算机在运行每个goroutine之间切换的时间可能比实际运行goroutine本身要长. 当这种情况发生时,它被称为资源饿死,因为这个程序及其走道没有获得它们运行所需的资源,或者越来越少. 在这种情况下,降低程序同时运行的部件数量可能更快,因为它会缩短它们之间切换的时间,并给更多的时间来运行程序本身. 记住程序运行中有多少个核心 可以成为一个很好的起点 决定你想要使用多少个goroutines.

使用 goroutine 和渠道的组合,可以创建非常强大的程序,可以从小型桌面计算机运行到大型服务器。正如您在本节中所看到的,渠道可以用来在几几 goroutine 到潜在数千 goroutine 之间进行通信,而变化很小。

结论

在本教程中,您创建了一个使用go关键字的程序,以启动同时运行的goroutines,在运行时打印出数字。一旦该程序运行,您使用make(chan int)创建了一个新的int值的渠道,然后使用该渠道在一个goroutine中生成数字,并将它们发送到另一个goroutine来打印到屏幕上。最后,您开始同时使用多个打印goroutines,以示例如何使用渠道和goroutines来加速您在多核计算机上的程序。

如果你有兴趣了解更多关于同步在Go的信息,由Go团队创建的 Effective Go文件会深入了解更多细节。

本教程也是 DigitalOcean How to Code in Go系列的一部分,该系列涵盖了许多 Go 主题,从首次安装 Go 到如何使用语言本身。

Published At
Categories with 技术
Tagged with
comments powered by Disqus