跳转到主要内容

介绍

Go语言的一个流行特性是它对并发性的一流支持,或者说一个程序同时做多件事情的能力。随着计算机从更快地运行单个代码流转向同时运行更多代码流,能够并发运行代码正成为编程的一个重要部分。为了更快地运行程序,程序员需要设计并发运行的程序,以便程序的每个并发部分可以独立于其他部分运行。Go中的两个特性,goroutines和channel,在一起使用时使并发更容易。Goroutines解决了在程序中设置和运行并发代码的困难,通道解决了并发运行的代码之间安全通信的困难。

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

先决条件

要遵循本教程,您需要:

  • 安装1.16或更高版本。要设置此设置,请遵循操作系统的“如何安装Go”教程。
  • 熟悉Go函数,可以在“如何定义和调用围棋函数”教程中找到。

使用Goroutines同时运行函数

在现代计算机中,处理器或CPU被设计为同时运行尽可能多的代码流。这些处理器有一个或多个“核”,每个核都能够同时运行一个代码流。因此,程序可以同时使用的内核越多,程序运行的速度就越快。然而,为了使程序能够利用多核提供的速度提升,程序需要能够被分成多个代码流。将一个程序拆分为多个部分可能是编程中最具挑战性的事情之一,但Go旨在使这一点变得更容易。

Go实现这一点的一种方法是使用一个名为goroutines的特性。goroutine是一种特殊类型的函数,可以在其他goroutines也在运行时运行。当一个程序设计为同时运行多个代码流时,该程序设计为并发运行。通常,当一个函数被调用时,它将在代码继续运行之前完成运行。这被称为在“前台”中运行,因为它会阻止程序在完成之前执行任何其他操作。使用goroutine时,函数调用将立即继续运行下一个代码,而goroutin在“后台”中运行。如果代码不阻止其他代码在完成之前运行,则认为代码在后台运行。

goroutines提供的强大功能是每个goroutine可以同时在处理器内核上运行。如果您的计算机有四个处理器内核,并且您的程序有四个goroutine,那么所有四个gooutine都可以同时运行。当多个代码流同时在不同的内核上运行时,称为并行运行。

要可视化并发和并行之间的差异,请考虑下图。当处理器运行一个函数时,它并不总是一次从开始运行到完成。有时,当一个函数在等待其他事情发生时,例如读取文件,操作系统会在CPU内核上交错其他函数、goroutine或其他程序。该图显示了为并发设计的程序如何在单核和多核上运行。它还显示了当并行运行时,与在单个内核上运行时相比,多个goroutine段(如图所示,9个垂直段)可以容纳在同一时间段中。

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.

图中的左列标记为“并发”,显示了一个围绕并发设计的程序如何通过运行goroutine1的一部分,然后运行另一个函数、goroutine或程序,然后运行goroutinge2,再运行goroutice1,以此类推,在单CPU核上运行。对用户来说,这看起来就像程序同时运行所有函数或goroutines,尽管它们实际上是一个接一个的小部分运行。

图表右侧的列标记为“并行性”,显示了同一程序如何在具有两个CPU核的处理器上并行运行。第一个CPU内核显示goroutine1与其他函数、goroutine或程序一起运行,而第二个CPU内核则显示goroutinge2与该内核上的其他函数或goroutines一起运行。有时goroutine1和goroutine 2都在同一时间运行,只是在不同的CPU内核上。

该图还显示了Go的另一个强大特性,即可伸缩性。当一个程序可以在任何地方运行,从一台有几个处理器核的小型计算机到一台有几十个核的大型服务器,并利用这些额外资源时,它就是可扩展的。该图显示,通过使用goroutines,您的并发程序能够在单个CPU内核上运行,但随着添加更多的CPU内核,可以并行运行更多的goroutine以加快程序速度。

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

如果您正在使用命令行,请首先创建项目目录并导航到该目录:

mkdir projects
cd projects

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

mkdir multifunc
cd multifunc

进入multifunc目录后,打开一个名为main的文件。使用nano或您最喜欢的编辑器:

nano main.go

在主菜单中粘贴或键入以下代码。转到文件开始。

package main

import (
    "fmt"
)

func generateNumbers(total int) {
    for idx := 1; idx <= total; idx++ {
        fmt.Printf("Generating number %d\n", idx)
    }
}

func printNumbers() {
    for idx := 1; idx <= 3; idx++ {
        fmt.Printf("Printing number %d\n", idx)
    }
}

func main() {
    printNumbers()
    generateNumbers(3)
}

这个初始程序定义了两个函数,generateNumbers和printNumbers,然后在主函数中运行这些函数。generateNumbers函数将要“生成”的数字量作为参数,在本例中为1到3,然后将这些数字打印到屏幕上。printNumbers函数还没有接受任何参数,但它也会打印出数字1到3。

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

go run main.go

输出如下:

Output
Printing number 1
Printing number 2
Printing number 3
Generating number 1
Generating number 2
Generating number 3

您将看到函数一个接一个地运行,printNumbers首先运行,generateNumbers其次运行。

现在,假设printNumbers和generateNumbers每个都需要三秒钟才能运行。当同步运行时,或者像上一个示例那样一个接一个地运行时,您的程序将需要6秒钟才能运行。首先,printNumbers将运行三秒,然后generateNumbers运行三秒。然而,在您的程序中,这两个函数是相互独立的,因为它们不依赖于其他函数的数据来运行。您可以利用这一点,通过使用goroutines并发运行函数来加速这个假设程序。当两个函数同时运行时,理论上,程序可以运行一半的时间。如果printNumbers和generateNumbers函数都需要三秒钟才能运行,并且两者都在同一时间启动,那么程序可以在三秒钟内完成。(实际速度可能会因外部因素而有所不同,例如计算机有多少内核,或者计算机上同时运行了多少其他程序。)

作为goroutine并发运行函数类似于同步运行函数。要将函数作为goroutine(与标准同步函数相反)运行,只需要在函数调用之前添加go关键字。

然而,为了让程序同时运行goroutines,您需要做一个额外的更改。您需要添加一种方法,让程序等待两个goroutine都运行完毕。如果您不等待goroutines完成并且主函数完成,goroutine可能永远无法运行,或者只有部分goroutins可以运行而无法完成运行。

要等待函数完成,您将使用Go同步包中的WaitGroup。同步包包含“同步原语”,如WaitGroup,用于同步程序的各个部分。在您的情况下,同步会跟踪两个函数何时完成运行,以便您退出程序。

WaitGroup原语通过使用Add、Done和wait函数来计算需要等待多少事情。Add函数按提供给函数的数字增加计数,Done函数将计数减少一。然后可以使用Wait函数等待,直到计数达到零,这意味着Done已经被调用了足够的次数来抵消对Add的调用。一旦计数达到零,等待函数将返回,程序将继续运行。

接下来,更新main中的代码。gofile使用go关键字作为goroutines运行两个函数,并添加一个同步。等待组加入程序:

package main

import (
    "fmt"
    "sync"
)

func generateNumbers(total int, wg *sync.WaitGroup) {
    defer wg.Done()

    for idx := 1; idx <= total; idx++ {
        fmt.Printf("Generating number %d\n", idx)
    }
}

func printNumbers(wg *sync.WaitGroup) {
    defer wg.Done()

    for idx := 1; idx <= 3; idx++ {
        fmt.Printf("Printing number %d\n", idx)
    }
}

func main() {
    var wg sync.WaitGroup

    wg.Add(2)
    go printNumbers(&wg)
    go generateNumbers(3, &wg)

    fmt.Println("Waiting for goroutines to finish...")
    wg.Wait()
    fmt.Println("Done!")
}

在声明WaitGroup之后,它需要知道要等待多少事情。包括一个wg。在启动goroutines之前,在main函数中添加(2)将告诉wg在考虑组完成之前等待两个Done调用。如果在goroutines启动之前没有这样做,那么可能会发生无序的事情,或者代码可能会因为wg不知道它应该等待任何done调用而恐慌。

然后,每个函数都将使用defer调用Done,以在函数完成运行后将计数减少一。主函数也被更新为包括对WaitGroup的Wait调用,因此主函数将等待两个函数调用Done以继续运行并退出程序。

保存主文件后。go文件,像以前一样使用go run运行:

go run main.go

输出如下:

Output
Printing number 1
Waiting for goroutines to finish...
Generating number 1
Generating number 2
Generating number 3
Printing number 2
Printing number 3
Done!

您的输出可能与此处打印的不同,甚至可能在每次运行程序时都会更改。两个函数同时运行时,输出取决于Go和操作系统为每个函数运行提供的时间。有时有足够的时间完全运行每个函数,你会看到两个函数都不间断地打印它们的整个序列。其他时候,你会看到文本像上面的输出一样穿插。

你可以尝试的一个实验是移除wg。Wait()调用主函数并运行程序几次,然后再次运行。根据您的计算机,您可能会看到generateNumbers和printNumbers函数的一些输出,但也可能根本看不到它们的任何输出。当您删除对Wait的调用时,程序将不再等待两个函数完成运行后再继续。由于主函数在Wait函数之后很快结束,所以程序很有可能会到达主函数的末尾,并在goroutines完成运行之前退出。当这种情况发生时,您会看到打印出来的几个数字,但您不会看到每个函数的全部三个数字。

在本节中,您创建了一个使用go关键字同时运行两个goroutine并打印一系列数字的程序。您还使用了同步。WaitGroup使您的程序在退出程序之前等待这些goroutine完成。

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

使用Channels在Goroutines之间安全通信

并发编程中比较困难的部分之一是在同时运行的程序的不同部分之间进行安全通信。如果不小心,可能会遇到只有并发程序才可能出现的问题。例如,当程序的两个部分同时运行时,一个部分试图更新变量,而另一部分试图同时读取变量时,可能会发生数据竞赛。当这种情况发生时,读取或写入可能会无序进行,导致程序的一个或两个部分使用错误的值。“数据竞赛”这个名称来自程序的两个部分,即相互“竞赛”以访问数据。

虽然在Go中仍有可能遇到并发问题,如数据竞赛,但该语言的设计使其更容易避免。除了goroutines,通道是另一个使并发更安全、更易于使用的特性。通道可以被看作是两个或多个不同的goroutine之间的管道,数据可以通过这些管道发送。一个goroutine将数据放入管道的一端,另一个gooutine将相同的数据输出。确保数据安全地从一个传输到另一个的困难部分是为您处理的。

在Go中创建通道类似于使用内置make()函数创建切片。通道的类型声明使用chan关键字,后跟要在通道上发送的数据类型。例如,要创建一个发送int值的通道,您可以使用chan int类型。如果您想要一个发送[]字节值的通道的话,它应该是chan[]字节,如下所示:

bytesChan := make(chan []byte)

创建通道后,可以使用箭头查找<-运算符在通道上发送或接收数据。<-运算符相对于通道变量的位置决定了您是从通道读取还是向通道写入。

要写入通道,请从通道变量开始,后跟<-运算符,然后是要写入通道的值:

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

要从通道中读取值,请从要将值放入的变量开始,或=或:=为变量赋值,然后是<-运算符,然后是要从中读取的通道:

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

为了保持这两个操作的正确性,请记住<-箭头始终指向左侧(与->相反),箭头指向值的方向。在写入通道的情况下,箭头将值指向通道。从通道读取时,箭头将通道指向变量。

与切片一样,通道也可以在for循环中使用range关键字读取。当使用range关键字读取通道时,循环的每次迭代都会从通道中读取下一个值,并将其放入循环变量中。然后,它将继续从通道读取,直到通道关闭或以其他方式退出for循环,例如中断:

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

在某些情况下,您可能只希望允许函数读取或写入通道,但不能同时读取和写入通道。为此,您需要在chan类型声明中添加<-运算符。与从通道读取和写入类似,通道类型使用<-箭头允许变量将通道约束为仅读取、仅写入或同时读取和写入。例如,要定义int值的只读通道,类型声明应为<-chan int:

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

如果您希望通道只写,您可以将其声明为chan<-int:

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

请注意,箭头指向读取通道外,指向写入通道内。如果声明没有箭头,就像chan int的情况一样,通道可以用于读取和写入。

最后,一旦一个通道不再使用,就可以使用内置的close()函数关闭它。这一步非常重要,因为当通道被创建,然后在程序中多次闲置时,可能会导致所谓的内存泄漏。内存泄漏是指一个程序在计算机上创建了一些耗尽内存的东西,但一旦使用完毕,就不会将内存释放回计算机。这会导致程序随着时间的推移慢慢(有时不是那么慢)消耗更多内存,就像漏水一样。当使用make()创建通道时,计算机的一些内存将用于该通道,然后当在通道上调用close()时,该内存将返回给计算机以用于其他用途。

现在,更新main。在程序中使用chan-int通道在goroutines之间进行通信。generateNumbers函数将生成数字并将其写入通道,而printNumbers功能将从通道读取这些数字并将它们打印到屏幕。在main函数中,您将创建一个新通道作为参数传递给其他每个函数,然后在通道上使用close()来关闭它,因为它将不再使用。generateNumbers函数也不应该再是goroutine了,因为一旦该函数运行完毕,程序将完成生成所需的所有数字。这样,close()函数只在两个函数运行完毕之前在通道上调用。

package main

import (
    "fmt"
    "sync"
)

func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()

    for idx := 1; idx <= total; idx++ {
        fmt.Printf("sending %d to channel\n", idx)
        ch <- idx
    }
}

func printNumbers(ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()

    for num := range ch {
        fmt.Printf("read %d from channel\n", num)
    }
}

func main() {
    var wg sync.WaitGroup
    numberChan := make(chan int)

    wg.Add(2)
    go printNumbers(numberChan, &wg)

    generateNumbers(3, numberChan, &wg)

    close(numberChan)

    fmt.Println("Waiting for goroutines to finish...")
    wg.Wait()
    fmt.Println("Done!")
}

在generateNumbers和printNumbers的参数中,您将看到chan类型使用只读和只写类型。由于generateNumbers只需要能够将数字写入通道,因此它是一种只写类型,带有指向通道的<-箭头。printNumbers只需要能够从通道中读取数字,因此它是一种只读类型,带有指向通道的<-箭头。

尽管这些类型可以是chan int,这将允许读取和写入,但将它们限制在函数所需的范围内是有帮助的,以避免意外导致程序从死锁中停止运行。当程序的一部分正在等待程序的另一部分执行某项操作,但程序的另部分也在等待程序的第一部分完成时,就会发生死锁。由于程序的两个部分相互等待,程序将永远不会继续运行,就像两个齿轮卡住一样。

由于Go中通道通信的工作方式,死锁可能会发生。当程序的一部分写入某个频道时,它将等待程序的另一部分从该频道读取后再继续。类似地,如果程序正在从某个频道读取,它将等到程序的另部分写入该频道后才继续。等待其他事情发生的程序的一部分被称为阻塞,因为它被阻止继续,直到其他事情发生。通道被写入或读取时会阻塞。因此,如果你有一个函数,你希望写入一个通道,但意外地从通道中读取,你的程序可能会进入死锁,因为通道永远不会被写入。确保这永远不会发生是使用chan<-int或<-chan int而不是chan int的一个原因。

更新代码的另一个重要方面是在generateNumbers写入通道后使用close()关闭通道。在这个程序中,close()会导致printNumbers中的for…range循环退出。由于使用range从通道读取一直持续到它读取的通道关闭,如果numberChan上没有调用close,那么printNumbers将永远不会完成。如果printNumbers从未完成,那么当printNumber退出时,延迟器将永远不会调用WaitGroup的Done方法。如果从未从printNumbers调用Done方法,程序本身将永远不会退出,因为主函数中的WaitGroup的Wait方法将永远不会继续。这是死锁的另一个例子,因为主函数正在等待一些永远不会发生的事情。

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

go run main.go

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

Output
sending 1 to channel
sending 2 to channel
read 1 from channel
read 2 from channel
sending 3 to channel
Waiting for functions to finish...
read 3 from channel
Done!

程序的输出显示generateNumbers函数正在生成数字1到3,同时将它们写入与printNumbers共享的通道。printNumbers收到号码后,会将其打印到屏幕上。generateNumbers生成所有三个数字后,它将退出,允许主函数关闭通道并等待printNumbers完成。当printNumbers打印完最后一个数字后,它在WaitGroup上调用Done,程序退出。与之前的输出类似,您看到的确切输出将取决于各种外部因素,例如操作系统或Go运行时选择运行特定的goroutine时,但应该相对接近。

使用goroutines和频道设计程序的好处是,一旦您设计了要拆分的程序,就可以将其扩展到更多的goroutine。由于generateNumbers只是写入一个频道,所以从该频道读取多少其他内容并不重要。它只会将数字发送给任何读取频道的内容。您可以通过运行多个printNumbers goroutine来利用这一点,这样它们中的每一个都将从同一通道读取并并发处理数据。

既然您的程序正在使用频道进行通信,请打开主频道。再次打开文件并更新程序,以便启动多个printNumbers goroutines。你需要调整对wg的调用。添加,以便为您开始的每个goroutine添加一个。您无需再为调用generateNumbers而向WaitGroup中添加一个函数,因为程序在没有完成整个函数的情况下不会继续运行,这与您将其作为goroutine运行时不同。为了确保它在完成时不会减少WaitGroup计数,您应该删除defer wg。函数的Done()行。接下来,将goroutine的编号添加到printNumbers,可以更容易地看到每个通道是如何读取的。增加生成的数字量也是一个好主意,这样更容易看到数字的分布:

...

func generateNumbers(total int, ch chan<- int, wg *sync.WaitGroup) {
    for idx := 1; idx <= total; idx++ {
        fmt.Printf("sending %d to channel\n", idx)
        ch <- idx
    }
}

func printNumbers(idx int, ch <-chan int, wg *sync.WaitGroup) {
    defer wg.Done()

    for num := range ch {
        fmt.Printf("%d: read %d from channel\n", idx, num)
    }
}

func main() {
    var wg sync.WaitGroup
    numberChan := make(chan int)

    for idx := 1; idx <= 3; idx++ {
        wg.Add(1)
        go printNumbers(idx, numberChan, &wg)
    }

    generateNumbers(5, numberChan, &wg)

    close(numberChan)

    fmt.Println("Waiting for goroutines to finish...")
    wg.Wait()
    fmt.Println("Done!")
}

一旦你的主。go更新后,您可以使用go run with main.go再次运行程序。在继续生成数字之前,您的程序应该启动三个printNumbers goroutines。您的程序现在还应该生成五个数字,而不是三个,以便更容易看到数字在三个printNumbers goroutine中的每一个中的分布:

go run main.go

输出可能与此类似(尽管您的输出可能有很大差异):

Output
sending 1 to channel
sending 2 to channel
sending 3 to channel
3: read 2 from channel
1: read 1 from channel
sending 4 to channel
sending 5 to channel
3: read 4 from channel
1: read 5 from channel
Waiting for goroutines to finish...
2: read 3 from channel
Done!

当你这次查看你的程序输出时,很有可能会与上面看到的输出有很大的不同。由于有三个printNumbers goroutine正在运行,因此有一个机会因素决定哪一个收到了特定的号码。当一个printNumbers goroutine接收到一个数字时,它会花费少量的时间将该数字打印到屏幕上,而另一个goroutin会从通道读取下一个数字并执行相同的操作。当goroutine完成打印数字的工作并准备读取另一个数字时,它将返回并再次读取通道以打印下一个数字。如果没有更多的数字要从通道中读取,它将开始阻塞,直到可以读取下一个数字。一旦generateNumbers完成并在通道上调用close(),所有三个printNumbers goroutine将完成其范围循环并退出。当所有三个goroutine都退出并在WaitGroup上调用Done时,WaitGroup的计数将达到零,程序将退出。您还可以尝试增加或减少正在生成的goroutine或数字的数量,以了解这对输出的影响。

使用goroutines时,避免启动过多。理论上,一个程序可能有数百甚至数千个goroutine。然而,根据程序运行的计算机,使用更多的goroutine可能会更慢。由于goroutine数量众多,它有可能陷入资源匮乏。每次Go运行goroutine的一部分时,除了在下一个函数中运行代码所需的时间之外,还需要一点额外的时间来重新开始运行。由于需要额外的时间,计算机在运行每个goroutine之间切换的时间可能比实际运行goroutin本身的时间更长。当这种情况发生时,它被称为资源匮乏,因为程序及其goroutine没有获得运行所需的资源,或者得到的资源很少。在这些情况下,减少并发运行的程序中的部分数量可能会更快,因为这将减少在它们之间切换所需的时间,并为运行程序本身提供更多的时间。记住程序运行在多少内核上是决定要使用多少goroutine的一个很好的起点。

使用goroutines和频道的组合可以创建非常强大的程序,可以从小型台式计算机上运行扩展到大型服务器。正如您在本节中所看到的,可以使用通道在少量的goroutine到潜在的数千个goroutines之间进行通信,只需进行最小的更改。如果在编写程序时考虑到这一点,您将能够利用Go中可用的并发性,为用户提供更好的整体体验。

结论

在本教程中,您使用go关键字创建了一个程序,以开始并发运行goroutines,这些goroutine在运行时打印数字。该程序运行后,您使用make(chan-int)创建了一个新的int值通道,然后使用该通道在一个goroutine中生成数字,并将其发送到另一个gooutine以打印到屏幕上。最后,您同时开始了多个“打印”goroutine,作为一个示例,说明如何使用频道和goroutines来加快多核计算机上的程序。

如果您有兴趣了解更多关于Go并发性的信息,Go团队创建的Effective Go文档将详细介绍。Concurrency is not parallelism Go博客文章也是一篇关于并发和并行之间关系的有趣后续文章,这两个术语有时会被错误地混淆为相同的意思。

文章链接