Go 系统编程(五)
原文:
zh.annas-archive.org/md5/2DB8F67A356AEFD794B578E9C4995B3C译者:飞龙
第九章:Goroutines - 基本特性
在上一章中,您学习了 Unix 信号处理,以及在 Go 中添加管道支持和创建图形图像。
这个非常重要的章节的主题是 goroutines。Go 使用 goroutines 和通道来以自己的方式编写并发应用程序,同时提供对传统并发技术的支持。Go 中的所有内容都使用 goroutines 执行;当程序开始执行时,其单个 goroutine 会自动调用main()函数,以开始程序的实际执行。
在本章中,我们将介绍 goroutines 的简单部分,并提供易于遵循的代码示例。然而,在接下来的第十章*,* Goroutines - 高级特性中,我们将讨论与 goroutines 和通道相关的更重要和高级的技术,因此,请确保在阅读下一章之前充分理解本章。
因此,本章将告诉您以下内容:
-
创建 goroutines
-
同步 goroutines
-
关于通道以及如何使用它们
-
读取和写入通道
-
创建和使用管道
-
更改
wc.go实用程序的 Go 代码,以便在新实现中使用 goroutines -
进一步改进
wc.go的 goroutine 版本
关于 goroutines
goroutine是可以并发执行的最小 Go 实体。请注意,这里使用“最小”一词非常重要,因为 goroutines 不是自主实体。Goroutines 存在于 Unix 进程中的线程中。简单来说,进程可以是自主的并独立存在,而 goroutines 和线程都不行。因此,要创建 goroutine,您需要至少有一个带有线程的进程。好处是 goroutines 比线程轻,线程比进程轻。Go 中的所有内容都使用 goroutines 执行,这是合理的,因为 Go 是一种并发编程语言。正如您刚刚了解的那样,当 Go 程序开始执行时,它的单个 goroutine 调用main()函数,从而启动实际的程序执行。
您可以使用go关键字后跟函数名或匿名函数的完整定义来定义新的 goroutine。go关键字在新的 goroutine 中启动函数参数,并允许调用函数自行继续。
然而,正如您将看到的,您无法控制或做出任何关于 goroutines 将以何种顺序执行的假设,因为这取决于操作系统的调度程序以及操作系统的负载。
并发和并行
一个非常常见的误解是并发和并行指的是同一件事,这与事实相去甚远!并行是多个事物同时执行,而并发是一种构造组件的方式,使它们在可能的情况下可以独立执行。
只有在并发构建时,您才能安全地并行执行它们:当且如果您的操作系统和硬件允许。很久以前,Erlang 编程语言就已经做到了这一点,早在 CPU 拥有多个核心和计算机拥有大量 RAM 之前。
在有效的并发设计中,添加并发实体使整个系统运行更快,因为更多的事情可以并行运行。因此,期望的并行性来自于对问题的更好并发表达和实现。开发人员在系统设计阶段负责考虑并发,并从系统组件的潜在并行执行中受益。因此,开发人员不应该考虑并行性,而应该考虑将事物分解为独立组件,这些组件在组合时解决最初的问题。
即使在 Unix 机器上无法并行运行函数,有效的并发设计仍将改善程序的设计和可维护性。换句话说,并发比并行更好!
同步 Go 包
sync Go 包包含可以帮助您同步 goroutines 的函数;sync的最重要的函数是sync.Add、sync.Done和sync.Wait。对于每个程序员来说,同步 goroutines 是一项必不可少的任务。
请注意,goroutines 的同步与共享变量和共享状态无关。共享变量和共享状态与您希望用于执行并发交互的方法有关。
一个简单的例子
在这一小节中,我们将介绍一个简单的程序,它创建了两个 goroutines。示例程序的名称将是aGoroutine.go,将分为三个部分;第一部分如下:
package main
import (
"fmt"
"time"
)
func namedFunction() {
time.Sleep(10000 * time.Microsecond)
fmt.Println("Printing from namedFunction!")
}
除了预期的package和import语句之外,您还可以看到一个名为namedFunction()的函数的实现,在打印屏幕上的消息之前会休眠一段时间。
aGoroutine.go的第二部分包含以下 Go 代码:
func main() {
fmt.Println("Chapter 09 - Goroutines.")
go namedFunction()
在这里,您创建了一个执行namedFunction()函数的 goroutine。这个天真程序的最后部分如下:
go func() {
fmt.Println("An anonymous function!")
}()
time.Sleep(10000 * time.Microsecond)
fmt.Println("Exiting...")
}
在这里,您创建了另一个 goroutine,它执行一个包含单个fmt.Println()语句的匿名函数。
正如您所看到的,以这种方式运行的 goroutines 是完全隔离的,彼此之间无法交换任何类型的数据,这并不总是所期望的操作风格。
如果您忘记在main()函数中调用time.Sleep()函数,或者time.Sleep()睡眠了很短的时间,那么main()将会过早地结束,两个 goroutines 将没有足够的时间开始和完成它们的工作;结果,您将无法在屏幕上看到所有预期的输出!
执行aGoroutine.go将生成以下输出:
$ go run aGoroutine.go
Chapter 09 - Goroutines.
Printing from namedFunction!
Exiting...
创建多个 goroutines
这一小节将向您展示如何创建许多 goroutines 以及处理更多 goroutines 所带来的问题。程序的名称将是moreGoroutines.go,将分为三个部分。
moreGoroutines.go的第一部分如下:
package main
import (
"fmt"
"time"
)
程序的第二部分包含以下 Go 代码:
func main() {
fmt.Println("Chapter 09 - Goroutines.")
for i := 0; i < 10; i++ {
go func(x int) {
time.Sleep(10)
fmt.Printf("%d ", x)
}(i)
}
这次,匿名函数接受一个名为x的参数,其值为变量i。使用变量i的for循环依次创建十个 goroutines。
程序的最后部分如下:
time.Sleep(10000)
fmt.Println("Exiting...")
}
再次,如果您将较小的值作为time.Sleep()的参数,当您执行程序时将会看到不同的结果。
执行moreGoroutines.go将生成一个有些奇怪的输出:
$ go run moreGoroutines.go
Chapter 09 - Goroutines.
1 7 Exiting...
2 3
然而,当您多次执行moreGoroutines.go时,大惊喜来了:
$ go run moreGoroutines.go
Chapter 09 - Goroutines.
Exiting...
$ go run moreGoroutines.go
Chapter 09 - Goroutines.
3 1 0 9 2 Exiting...
4 5 6 8 7
$ go run moreGoroutines.go
Chapter 09 - Goroutines.
2 0 1 8 7 3 6 5 Exiting...
4
正如您所看到的,程序的所有先前输出都与第一个不同!因此,输出不仅不协调,而且并不总是有足够的时间让所有 goroutines 执行;您无法确定 goroutines 将以何种顺序执行。然而,尽管您无法解决后一个问题,因为 goroutines 的执行顺序取决于开发人员无法控制的各种参数,下一小节将教您如何同步 goroutines 并为它们提供足够的时间完成,而无需调用time.Sleep()。
等待 goroutines 完成它们的工作
这一小节将向您演示正确的方法来创建一个等待其 goroutines 完成工作的调用函数。程序的名称将是waitGR.go,将分为四个部分;第一部分如下:
package main
import (
"fmt"
"sync"
)
除了time包的缺失和sync包的添加之外,这里没有什么特别的。
第二部分包含以下 Go 代码:
func main() {
fmt.Println("Waiting for Goroutines!")
var waitGroup sync.WaitGroup
waitGroup.Add(10)
在这里,您创建了一个新变量,类型为sync.WaitGroup,它等待一组 goroutines 完成。属于该组的 goroutines 的数量由一个或多个对sync.Add()函数的调用定义。
在 Go 语句之前调用sync.Add()以防止竞争条件是很重要的。
另外,sync.Add(10)的调用告诉我们的程序,我们将等待十个 goroutines 完成。
程序的第三部分如下:
var i int64
for i = 0; i < 10; i++ {
go func(x int64) {
defer waitGroup.Done()
fmt.Printf("%d ", x)
}(i)
}
在这里,您可以使用for循环创建所需数量的 goroutines,但也可以使用多个顺序的 Go 语句。当每个 goroutine 完成其工作时,将执行sync.Done()函数:在函数定义之后立即使用defer关键字告诉匿名函数在完成之前自动调用sync.Done()。
waitGR.go的最后一部分如下:
waitGroup.Wait()
fmt.Println("\nExiting...")
}
这里的好处是不需要调用time.Sleep(),因为sync.Wait()会为我们做必要的等待。
再次应该注意的是,您不应该对 goroutines 的执行顺序做任何假设,这也由以下输出验证:
$ go run waitGR.go
Waiting for Goroutines!
9 0 5 6 7 8 2 1 3 4
Exiting...
$ go run waitGR.go
Waiting for Goroutines!
9 0 5 6 7 8 3 1 2 4
Exiting...
$ go run waitGR.go
Waiting for Goroutines!
9 5 6 7 8 1 0 2 3 4
Exiting...
如果您调用waitGroup.Add()的次数超过所需次数,当执行waitGR.go时,将收到以下错误消息:
Waiting for Goroutines!
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc42000e28c)
/usr/local/Cellar/go/1.8.3/libexec/src/runtime/sema.go:47 +0x34
sync.(*WaitGroup).Wait(0xc42000e280)
/usr/local/Cellar/go/1.8.3/libexec/src/sync/waitgroup.go:131 +0x7a
main.main()
/Users/mtsouk/ch/ch9/code/waitGR.go:22 +0x13c
exit status 2
9 0 1 2 6 7 8 3 4 5
这是因为当您告诉程序通过调用sync.Add(1) n+1 次来等待 n+1 个 goroutines 时,您的程序不能只有 n 个 goroutines(或更少)!简单地说,这将使sync.Wait()无限期地等待一个或多个 goroutines 调用sync.Done()而没有任何运气,这显然是一个死锁的情况,阻止您的程序完成。
创建动态数量的 goroutines
这次,将作为命令行参数给出要创建的 goroutines 的数量:程序的名称将是dynamicGR.go,并将分为四个部分。
dynamicGR.go的第一部分如下:
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
)
dynamicGR.go的第二部分包含以下 Go 代码:
func main() {
if len(os.Args) != 2 {
fmt.Printf("usage: %s integer\n",filepath.Base(os.Args[0]))
os.Exit(1)
}
numGR, _ := strconv.ParseInt(os.Args[1], 10, 64)
fmt.Printf("Going to create %d goroutines.\n", numGR)
var waitGroup sync.WaitGroup
var i int64
for i = 0; i < numGR; i++ {
waitGroup.Add(1)
正如您所看到的,waitGroup.Add(1)语句是在创建新的 goroutine 之前调用的。
dynamicGR.go的 Go 代码的第三部分如下:
go func(x int64) {
defer waitGroup.Done()
fmt.Printf(" %d ", x)
}(i)
}
在前面的部分中,创建了每个简单的 goroutine。
程序的最后一部分如下:
waitGroup.Wait()
fmt.Println("\nExiting...")
}
在这里,您只需告诉程序使用waitGroup.Wait()语句等待所有 goroutines 完成。
执行dynamicGR.go需要一个整数参数,这是您想要创建的 goroutines 的数量:
$ go run dynamicGR.go 15
Going to create 15 goroutines.
0 2 4 1 3 5 14 10 8 9 12 11 6 13 7
Exiting...
$ go run dynamicGR.go 15
Going to create 15 goroutines.
5 3 14 4 10 6 7 11 8 9 12 2 13 1 0
Exiting...
$ go run dynamicGR.go 15
Going to create 15 goroutines.
4 2 3 6 5 10 9 7 0 12 11 1 14 13 8
Exiting...
可以想象,您想要创建的 goroutines 越多,输出就会越多样化,因为没有办法控制程序的 goroutines 执行顺序。
关于通道
通道,简单地说,是一种通信机制,允许 goroutines 交换数据。但是,这里存在一些规则。首先,每个通道允许特定数据类型的交换,这也称为通道的元素类型,其次,为了使通道正常运行,您需要使用一些 Go 代码来接收通过通道发送的内容。
您应该使用chan关键字声明一个新的通道,并且可以使用close()函数关闭一个通道。此外,由于每个通道都有自己的类型,开发人员应该定义它。
最后,一个非常重要的细节:当您将通道用作函数参数时,可以指定其方向,即它将用于写入还是读取。在我看来,如果您事先知道通道的目的,请使用此功能,因为它将使您的程序更健壮,更安全:否则,只需不定义通道函数参数的目的。因此,如果您声明通道函数参数仅用于读取,并尝试向其写入,您将收到一个错误消息,这很可能会使您免受讨厌的错误。
当你尝试从写通道中读取时,你将得到以下类似的错误消息:
# command-line-arguments
./writeChannel.go:13: invalid operation: <-c (receive from send-only type chan<- int)
向通道写入
在本小节中,你将学习如何向通道写入。所呈现的程序将被称为writeChannel.go,并分为三个部分。
第一部分包含了预期的序言:
package main
import (
"fmt"
"time"
)
正如你所理解的,使用通道不需要任何额外的 Go 包。
writeChannel.go的第二部分如下:
func writeChannel(c chan<- int, x int) {
fmt.Println(x)
c <- x
close(c)
fmt.Println(x)
}
尽管writeChannel()函数向通道写入数据,但由于当前没有人从程序中读取通道,数据将丢失。
程序的最后一部分包含以下 Go 代码:
func main() {
c := make(chan int)
go writeChannel(c, 10)
time.Sleep(2 * time.Second)
}
在这里,你可以看到使用chan关键字定义了一个名为c的通道变量,用于int数据。
执行writeChannel.go将创建以下输出:
$ go run writeChannel.go
10
这不是你期望看到的!这个意外的输出的原因是第二个fmt.Println(x)语句没有被执行。原因很简单:c <- x语句阻塞了writeChannel()函数的其余部分的执行,因为没有人从c通道中读取。
从通道中读取
本小节将通过允许你从通道中读取来改进writeChannel.go的 Go 代码。所呈现的程序将被称为readChannel.go,并分为四个部分呈现。
第一部分如下:
package main
import (
"fmt"
"time"
)
readChannel.go的第二部分包含以下 Go 代码:
func writeChannel(c chan<- int, x int) {
fmt.Println(x)
c <- x
close(c)
fmt.Println(x)
}
再次注意,如果没有人收集写入通道的数据,发送数据的函数将在等待有人读取其数据时停滞。然而,在第十章*,* Goroutines - Advanced Features中,你将看到这个问题的一个非常好的解决方案。
第三部分包含以下 Go 代码:
func main() {
c := make(chan int)
go writeChannel(c, 10)
time.Sleep(2 * time.Second)
fmt.Println("Read:", <-c)
time.Sleep(2 * time.Second)
在这里,fmt.Println()函数中的<-c语句用于从通道中读取单个值:相同的语句也可以用于将通道的值存储到变量中。然而,如果你不存储从通道中读取的值,它将会丢失。
readChannel.go的最后一部分如下:
_, ok := <-c
if ok {
fmt.Println("Channel is open!")
} else {
fmt.Println("Channel is closed!")
}
}
在这里,你看到了一种技术,可以让你知道你想要从中读取的通道是否已关闭。然而,如果通道是打开的,所呈现的 Go 代码将因为在赋值中使用了_字符而丢弃通道的读取值。
执行readChannel.go将创建以下输出:
$ go run readChannel.go
10
Read: 10
10
Channel is closed!
$ go run readChannel.go
10
10
Read: 10
Channel is closed!
解释 h1s.go
在第八章*,* Processes and Signals中,你看到了 Go 如何使用许多示例处理 Unix 信号,包括h1s.go。然而,现在你更了解 goroutines 和通道,是时候更详细地解释一下h1s.go的 Go 代码了。
正如你已经知道的,h1s.go使用通道和 goroutines,现在应该清楚了,作为 goroutine 执行的匿名函数使用无限的for循环从sigs通道读取。这意味着每次有我们感兴趣的信号时,goroutine 都会从sigs通道中读取并处理它。
管道
Go 程序很少使用单个通道。一个非常常见的使用多个通道的技术称为pipeline。因此,pipeline 是一种连接 goroutines 的方法,使得一个 goroutine 的输出成为另一个 goroutine 的输入,借助通道。使用 pipeline 的好处如下:
-
使用 pipeline 的好处之一是程序中有一个恒定的流动,因为没有人等待所有事情都完成才开始执行程序的 goroutines 和通道
-
此外,你使用的变量更少,因此占用的内存空间也更少,因为你不必保存所有东西。
-
最后,使用管道简化了程序的设计并提高了可维护性
pipelines.go的代码将以五个部分呈现;第一部分如下:
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
)
第二部分包含以下 Go 代码:
func genNumbers(min, max int64, out chan<- int64) {
var i int64
for i = min; i <= max; i++ {
out <- i
}
close(out)
}
在这里,您定义了一个函数,它接受三个参数:两个整数和一个输出通道。输出通道将用于写入将在另一个函数中读取的数据:这就是创建管道的方式。
程序的第三部分如下:
func findSquares(out chan<- int64, in <-chan int64) {
for x := range in {
out <- x * x
}
close(out)
}
这次,函数接受两个都是通道的参数。但是,out是一个输出通道,而in是一个用于读取数据的输入通道。
第四部分包含另一个函数的定义:
func calcSum(in <-chan int64) {
var sum int64
sum = 0
for x2 := range in {
sum = sum + x2
}
fmt.Printf("The sum of squares is %d\n", sum)
}
pipelines.go的最后一个函数只接受一个用于读取数据的通道作为参数。
pipelines.go的最后一部分是main()函数的实现:
func main() {
if len(os.Args) != 3 {
fmt.Printf("usage: %s n1 n2\n", filepath.Base(os.Args[0]))
os.Exit(1)
}
n1, _ := strconv.ParseInt(os.Args[1], 10, 64)
n2, _ := strconv.ParseInt(os.Args[2], 10, 64)
if n1 > n2 {
fmt.Printf("%d should be smaller than %d\n", n1, n2)
os.Exit(10)
}
naturals := make(chan int64)
squares := make(chan int64)
go genNumbers(n1, n2, naturals)
go findSquares(squares, naturals)
calcSum(squares)
}
在这里,main()函数首先读取其两个命令行参数并创建必要的通道变量(naturals和squares)。然后,它调用管道的函数:请注意,通道的最后一个函数不会作为 goroutine 执行。
以下图显示了pipelines.go中使用的管道的图形表示,以说明特定管道的工作方式:
pipelines.go 中使用的管道结构的图形表示
运行pipelines.go将生成以下输出:
$ go run pipelines.go
usage: pipelines n1 n2
exit status 1
$ go run pipelines.go 3 2
3 should be smaller than 2
exit status 10
$ go run pipelines.go 3 20
The sum of squares is 2865
$ go run pipelines.go 1 20
The sum of squares is 2870
$ go run pipelines.go 20 20
The sum of squares is 400
wc.go 的更好版本
正如我们在第六章中讨论的,在本章中,您将学习如何创建一个使用 goroutines 的wc.go的版本。新实用程序的名称将是dWC.go,将分为四个部分。请注意,dWC.go的当前版本将每个命令行参数都视为一个文件。
实用程序的第一部分如下:
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sync"
)
第二部分包含以下 Go 代码:
func count(filename string) {
var err error
var numberOfLines int = 0
var numberOfCharacters int = 0
var numberOfWords int = 0
f, err := os.Open(filename)
if err != nil {
fmt.Printf("%s\n", err)
return
}
defer f.Close()
r := bufio.NewReader(f)
for {
line, err := r.ReadString('\n')
if err == io.EOF {
break
} else if err != nil {
fmt.Printf("error reading file %s\n", err)
}
numberOfLines++
r := regexp.MustCompile("[^\\s]+")
for range r.FindAllString(line, -1) {
numberOfWords++
}
numberOfCharacters += len(line)
}
fmt.Printf("\t%d\t", numberOfLines)
fmt.Printf("%d\t", numberOfWords)
fmt.Printf("%d\t", numberOfCharacters)
fmt.Printf("%s\n", filename)
}
count()函数完成所有处理,而不向main()函数返回任何信息:它只是打印其输入文件的行数、单词数和字符数,然后退出。尽管count()函数的当前实现完成了所需的工作,但这并不是设计程序的正确方式,因为无法控制程序的输出。
实用程序的第三部分如下:
func main() {
if len(os.Args) == 1 {
fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n",
filepath.Base(os.Args[0]))
os.Exit(1)
}
dWC.go的最后一部分如下:
var waitGroup sync.WaitGroup
for _, filename := range os.Args[1:] {
waitGroup.Add(1)
go func(filename string) {
count(filename)
defer waitGroup.Done()
}(filename)
}
waitGroup.Wait()
}
正如您所看到的,每个输入文件都由不同的 goroutine 处理。如预期的那样,您无法对输入文件的处理顺序做出任何假设。
执行dWC.go将生成以下输出:
$ go run dWC.go /tmp/swtag.log /tmp/swtag.log doesnotExist
open doesnotExist: no such file or directory
48 275 3571 /tmp/swtag.log
48 275 3571 /tmp/swtag.log
在这里,您可以看到,尽管doesnotExist文件名是最后一个命令行参数,但它是dWC.go输出中的第一个命令行参数!
尽管dWC.go使用了 goroutines,但其中并没有巧妙之处,因为 goroutines 在没有相互通信和执行任何其他任务的情况下运行。此外,输出可能会混乱,因为无法保证count()函数的fmt.Printf()语句不会被中断。
因此,即将呈现的部分以及将在第十章中呈现的一些技术,即Goroutines - 高级特性,将改进dWC.go。
计算总数
dWC.go的当前版本无法计算总数,可以通过使用awk处理dWC.go的输出来轻松解决:
$ go run dWC.go /tmp/swtag.log /tmp/swtag.log | awk '{sum1+=$1; sum2+=$2; sum3+=$3} END {print "\t", sum1, "\t", sum2, "\t", sum3}'
96 550 7142
然而,这离完美和优雅还有很大差距!
dWC.go的当前版本无法计算总数的主要原因是其 goroutines 无法相互通信。这可以通过通道和管道的帮助轻松解决。新版本的dWC.go将被称为dWCtotal.go,将分为五个部分呈现。
dWCtotal.go的第一部分如下:
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
)
type File struct {
Filename string
Lines int
Words int
Characters int
Error error
}
在这里,定义了一个新的struct类型。新结构称为File,有四个字段和一个额外的字段用于保存错误消息。这是管道循环多个值的正确方式。有人可能会认为File结构的更好名称应该是Counts、Results、FileCounts或FileResults。
程序的第二部分如下:
func process(files []string, out chan<- File) {
for _, filename := range files {
var fileToProcess File
fileToProcess.Filename = filename
fileToProcess.Lines = 0
fileToProcess.Words = 0
fileToProcess.Characters = 0
out <- fileToProcess
}
close(out)
}
process()函数的更好名称应该是beginProcess()或processResults()。您可以尝试在整个dWCtotal.go程序中自行进行更改。
dWCtotal.go的第三部分包含以下 Go 代码:
func count(in <-chan File, out chan<- File) {
for y := range in {
filename := y.Filename
f, err := os.Open(filename)
if err != nil {
y.Error = err
out <- y
continue
}
defer f.Close()
r := bufio.NewReader(f)
for {
line, err := r.ReadString('\n')
if err == io.EOF {
break
} else if err != nil {
fmt.Printf("error reading file %s", err)
y.Error = err
out <- y
continue
}
y.Lines = y.Lines + 1
r := regexp.MustCompile("[^\\s]+")
for range r.FindAllString(line, -1) {
y.Words = y.Words + 1
}
y.Characters = y.Characters + len(line)
}
out <- y
}
close(out)
}
尽管count()函数仍然计算计数,但它不会打印它们。它只是使用File类型的struct变量将行数、单词数、字符数以及文件名发送到另一个通道。
这里有一个非常重要的细节,就是count()函数的最后一条语句:为了正确结束管道,您应该关闭所有涉及的通道,从第一个开始。否则,程序的执行将以类似以下的错误消息失败:
fatal error: all goroutines are asleep - deadlock!
然而,就关闭管道的管道而言,您还应该注意不要过早关闭通道,特别是在管道中存在分支时。
程序的第四部分包含以下 Go 代码:
func calculate(in <-chan File) {
var totalWords int = 0
var totalLines int = 0
var totalChars int = 0
for x := range in {
totalWords = totalWords + x.Words
totalLines = totalLines + x.Lines
totalChars = totalChars + x.Characters
if x.Error == nil {
fmt.Printf("\t%d\t", x.Lines)
fmt.Printf("%d\t", x.Words)
fmt.Printf("%d\t", x.Characters)
fmt.Printf("%s\n", x.Filename)
}
}
fmt.Printf("\t%d\t", totalLines)
fmt.Printf("%d\t", totalWords)
fmt.Printf("%d\ttotal\n", totalChars)
}
这里没有什么特别的:calculate()函数负责打印程序的输出。
dWCtotal.go的最后部分如下:
func main() {
if len(os.Args) == 1 {
fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n",
filepath.Base(os.Args[0]))
os.Exit(1)
}
files := make(chan File)
values := make(chan File)
go process(os.Args[1:], files)
go count(files, values)
calculate(values)
}
由于files通道仅用于传递文件名,它本可以是一个string通道,而不是一个File通道。但是,这样代码更一致。
现在dWCtotal.go即使只处理一个文件也会自动生成总数:
$ go run dWCtotal.go /tmp/swtag.log
48 275 3571 /tmp/swtag.log
48 275 3571 total
$ go run dWCtotal.go /tmp/swtag.log /tmp/swtag.log doesNotExist
48 275 3571 /tmp/swtag.log
48 275 3571 /tmp/swtag.log
96 550 7142 total
请注意,dWCtotal.go和dWC.go都实现了相同的核心功能,即计算文件的单词、字符和行数:不同之处在于信息处理的方式不同,因为dWCtotal.go使用了管道而不是孤立的 goroutines。
第十章*,* Goroutines - Advanced Features,将使用其他技术来实现dWCtotal.go的功能。
进行一些基准测试
在本节中,我们将比较第六章*,* 文件输入和输出中的wc.go与wc(1)、dWC.go和dWCtotal.go的性能。为了使结果更准确,所有三个实用程序将处理相对较大的文件:
$ wc /tmp/*.data
712804 3564024 9979897 /tmp/connections.data
285316 855948 4400685 /tmp/diskSpace.data
712523 1425046 8916670 /tmp/memory.data
1425500 2851000 5702000 /tmp/pageFaults.data
285658 840622 4313833 /tmp/uptime.data
3421801 9536640 33313085 total
因此,time(1)实用程序将测量以下命令:
$ time wc /tmp/*.data /tmp/*.data
$ time wc /tmp/uptime.data /tmp/pageFaults.data
$ time ./dWC /tmp/*.data /tmp/*.data
$ time ./dWC /tmp/uptime.data /tmp/pageFaults.data
$ time ./dWCtotal /tmp/*.data /tmp/*.data
$ time ./dWCtotal /tmp/uptime.data /tmp/pageFaults.data
$ time ./wc /tmp/uptime.data /tmp/pageFaults.data
$ time ./wc /tmp/*.data /tmp/*.data
以下图显示了使用time(1)实用程序测量上述命令时的实际领域的图形表示:
绘制time(1)实用程序的实际领域
原始的wc(1)实用程序是迄今为止最快的。此外,dWC.go比dWCtotal.go和wc.go都要快。除了dWC.go,其余两个 Go 版本的性能相同。
练习
-
创建一个管道,读取文本文件,找到给定单词的出现次数,并计算所有文件中该单词的总出现次数。
-
尝试让
dWCtotal.go更快。 -
创建一个简单的 Go 程序,使用通道进行乒乓球比赛。您应该使用命令行参数定义乒乓球的总数。
总结
在本章中,我们讨论了创建和同步 goroutines,以及创建和使用管道和通道,以使 goroutines 能够相互通信。此外,我们开发了两个使用 goroutines 处理其输入文件的wc(1)实用程序的版本。
在继续下一章之前,请确保您充分理解本章的概念,因为在下一章中,我们将讨论与 goroutines 和通道相关的更高级特性,包括共享内存、缓冲通道、select关键字、GOMAXPROCS环境变量和信号通道。
第十章:Goroutines-高级功能
这是本书的第二章,涉及 goroutines:Go 编程语言的最重要特性,以及大大改进 goroutines 功能的通道,我们将从第九章*,* Goroutines-基本功能中停止的地方继续进行。
因此,您将学习如何使用各种类型的通道,包括缓冲通道、信号通道、空通道和通道的通道!此外,您还将学习如何在 goroutines 中利用共享内存和互斥锁,以及如何在程序运行时间过长时设置超时。
具体来说,本章将讨论以下主题:
-
缓冲通道
-
select关键字 -
信号通道
-
空通道
-
通道的通道
-
设置程序超时并避免无限等待其结束
-
共享内存和 goroutines
-
使用
sync.Mutex来保护共享数据 -
使用
sync.RWMutex来保护您的共享数据 -
更改
dWC.go代码,以支持缓冲通道和互斥锁
Go 调度程序
在上一章中,我们说内核调度程序负责执行 goroutines 的顺序,这并不完全准确。内核调度程序负责执行程序的线程。Go 运行时有自己的调度程序,负责使用一种称为m:n 调度的技术执行 goroutines,其中m个 goroutines 使用n个操作系统线程进行多路复用。由于 Go 调度程序必须处理单个程序的 goroutines,其操作比内核调度程序的操作要便宜和快得多。
sync Go 包
在本章中,我们将再次使用sync包中的函数和数据类型。特别是,您将了解sync.Mutex和sync.RWMutex类型及支持它们的函数的用处。
select 关键字
在 Go 中,select语句类似于通道的switch语句,并允许 goroutine 等待多个通信操作。因此,使用select关键字的主要优势是,同一个函数可以使用单个select语句处理多个通道!此外,您可以在通道上进行非阻塞操作。
用于说明select关键字的程序的名称将是useSelect.go,并将分为五个部分。useSelect.go程序允许您生成您想要的随机数,这是在第一个命令行参数中定义的,直到达到某个限制,这是第二个命令行参数。
useSelect.go的第一部分如下:
package main
import (
"fmt"
"math/rand"
"os"
"path/filepath"
"strconv"
"time"
)
useSelect.go的第二部分如下:
func createNumber(max int, randomNumberChannel chan<- int, finishedChannel chan bool) {
for {
select {
case randomNumberChannel <- rand.Intn(max):
case x := <-finishedChannel:
if x {
close(finishedChannel)
close(randomNumberChannel)
return
}
}
}
}
在这里,您可以看到select关键字如何允许您同时监听和协调两个通道(randomNumberChannel和finishedChannel)。select语句等待通道解除阻塞,然后在该通道上执行。
createNumber()函数的for循环将不会自行结束。因此,只要select语句的randomNumberChannel分支被使用,createNumber()将继续生成随机数。当finishedChannel通道中获取到布尔值true时,createNumber()函数将退出。
finishedChannel通道的更好名称可能是done甚至是noMoreData。
程序的第三部分包含以下 Go 代码:
func main() {
rand.Seed(time.Now().Unix())
randomNumberChannel := make(chan int)
finishedChannel := make(chan bool)
if len(os.Args) != 3 {
fmt.Printf("usage: %s count max\n", filepath.Base(os.Args[0]))
os.Exit(1)
}
n1, _ := strconv.ParseInt(os.Args[1], 10, 64)
count := int(n1)
n2, _ := strconv.ParseInt(os.Args[2], 10, 64)
max := int(n2)
fmt.Printf("Going to create %d random numbers.\n", count)
这里没有什么特别的:你只是在启动所需的 goroutine 之前读取命令行参数。
useSelect.go的第四部分是您将启动所需的 goroutine 并创建一个for循环以生成所需数量的随机数:
go createNumber(max, randomNumberChannel, finishedChannel)
for i := 0; i < count; i++ {
fmt.Printf("%d ", <-randomNumberChannel)
}
finishedChannel <- false
fmt.Println()
_, ok := <-randomNumberChannel
if ok {
fmt.Println("Channel is open!")
} else {
fmt.Println("Channel is closed!")
}
在这里,您还可以向finishedChannel发送一条消息,并在向finishedChannel发送消息后检查randomNumberChannel通道是open还是closed。由于您向finishedChannel发送了false,因此finishedChannel通道将保持open。请注意,向closed通道发送消息会导致 panic,而从closed通道接收消息会立即返回零值。
请注意,一旦关闭通道,就无法向该通道写入。但是,您仍然可以从该通道读取!
useSelect.go的最后一部分包含以下 Go 代码:
finishedChannel <- true
_, ok = <-randomNumberChannel
if ok {
fmt.Println("Channel is open!")
} else {
fmt.Println("Channel is closed!")
}
}
在这里,您向finishedChannel发送了true值,因此您的通道将关闭,createNumber() goroutine 将退出。
运行useSelect.go将创建以下输出:
$ go run useSelect.go 2 100
Going to create 2 random numbers.
19 74
Channel is open!
Channel is closed!
正如您将在解释缓冲通道的bufChannels.go程序中看到的,select语句也可以防止缓冲通道溢出。
信号通道
信号通道是仅用于发出信号的通道。将使用signalChannel.go程序来说明信号通道,该程序将使用一个相当不寻常的示例来呈现五个部分。该程序执行四个 goroutines:当第一个完成时,它通过关闭信号通道向信号通道发送信号,这将解除第二个 goroutine 的阻塞。当第二个 goroutine 完成其工作时,它关闭另一个通道,解除其余两个 goroutine 的阻塞。请注意,信号通道与携带os.Signal值的通道不同。
程序的第一部分如下:
package main
import (
"fmt"
"time"
)
func A(a, b chan struct{}) {
<-a
fmt.Println("A!")
time.Sleep(time.Second)
close(b)
}
A()函数被存储在a参数中定义的通道阻塞。这意味着在关闭此通道之前,A()函数无法继续执行。函数的最后一条语句关闭了存储在b变量中的通道,该通道将用于解除其他 goroutines 的阻塞。
程序的第二部分是B()函数的实现:
func B(b, c chan struct{}) {
<-b
fmt.Println("B!")
close(c)
}
同样,B()函数被存储在b参数中的通道阻塞,这意味着在关闭b通道之前,B()函数将在其第一条语句中等待。
signalChannel.go的第三部分如下:
func C(a chan struct{}) {
<-a
fmt.Println("C!")
}
再次,C()函数被存储在其a参数中的通道阻塞。
程序的第四部分如下:
func main() {
x := make(chan struct{})
y := make(chan struct{})
z := make(chan struct{})
将信号通道定义为空struct而不带任何字段是一种非常常见的做法,因为空结构不占用内存空间。在这种情况下,您可以使用bool通道。
signalChannel.go的最后一部分包含以下 Go 代码:
go A(x, y)
go C(z)
go B(y, z)
go C(z)
close(x)
time.Sleep(2 * time.Second)
}
在这里,您启动了四个 goroutines。但是,在关闭a通道之前,它们都将被阻塞!此外,A()将首先完成并解除B()的阻塞,然后解除两个C() goroutine 的阻塞。因此,这种技术允许您定义 goroutines 的执行顺序。
如果您执行signalChannel.go,您将获得以下输出:
$ go run signalChannel.go
A!
B!
C!
C!
正如您所看到的,尽管A()函数由于time.Sleep()函数调用而花费更多时间来执行,但 goroutines 正在按预期顺序执行。
缓冲通道
缓冲通道允许 Go 调度程序快速将作业放入队列,以便能够处理更多请求。此外,您可以使用缓冲通道作为信号量,以限制吞吐量。该技术的工作原理如下:传入的请求被转发到一个通道,该通道一次处理一个请求。当通道完成时,它向原始调用者发送一条消息,表明它已准备好处理新的请求。因此,通道缓冲区的容量限制了它可以保留和处理的同时请求的数量:这可以很容易地使用for循环和在其末尾调用time.Sleep()来实现。
缓冲通道将在bufChannels.go中进行说明,该程序将分为四个部分。
程序的第一部分如下:
package main
import (
"fmt"
)
序言证明了您在 Go 程序中不需要任何额外的包来支持缓冲通道。
程序的第二部分包含以下 Go 代码:
func main() {
numbers := make(chan int, 5)
在这里,您创建了一个名为numbers的新通道,它有5个位置,这由make语句的最后一个参数表示。这意味着您可以向该通道写入五个整数,而无需读取其中任何一个以为其他整数腾出空间。但是,您不能将六个整数放在具有五个整数位置的通道上!
bufChannels.go的第三部分如下:
counter := 10
for i := 0; i < counter; i++ {
select {
case numbers <- i:
default:
fmt.Println("Not enough space for", i)
}
}
在这里,您尝试将10个整数放入具有5个位置的缓冲通道。但是,使用select语句可以让您知道是否有足够的空间来存储所有整数,并相应地采取行动!
bufChannels.go的最后一部分如下:
for i := 0; i < counter*2; i++ {
select {
case num := <-numbers:
fmt.Println(num)
default:
fmt.Println("Nothing more to be done!")
break
}
}
}
在这里,您还使用了select语句,尝试从一个通道中读取 20 个整数。但是,一旦从通道中读取失败,for循环就会使用break语句退出。这是因为当从numbers通道中没有剩余内容可读时,num := <-numbers语句将被阻塞,这使得case语句转到default分支。
从代码中可以看出,bufChannels.go中没有 goroutine,这意味着缓冲通道可以自行工作。
执行bufChannels.go将生成以下输出:
$ go run bufChannels.go
Not enough space for 5
Not enough space for 6
Not enough space for 7
Not enough space for 8
Not enough space for 9
0
1
2
3
4
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
Nothing more to be done!
关于超时
您能想象永远等待某件事执行动作吗?我也不能!因此,在本节中,您将学习如何使用select语句在 Go 中实现超时。
具有示例代码的程序将被命名为timeOuts.go,并将分为四个部分进行介绍;第一部分如下:
package main
import (
"fmt"
"time"
)
timeOuts.go的第二部分如下:
func main() {
c1 := make(chan string)
go func() {
time.Sleep(time.Second * 3)
c1 <- "c1 OK"
}()
goroutine 中的time.Sleep()语句用于模拟 goroutine 执行其真正工作所需的时间。
timeOuts.go的第三部分包含以下代码:
select {
case res := <-c1:
fmt.Println(res)
case <-time.After(time.Second * 1):
fmt.Println("timeout c1")
}
这次,使用time.After()是为了声明您希望在超时之前等待的时间。这里的奇妙之处在于,如果time.After()的时间到期,而select语句没有从c1通道接收到任何数据,那么time.After()的case分支将被执行。
程序的最后一部分将包含以下 Go 代码:
c2 := make(chan string)
go func() {
time.Sleep(time.Second * 3)
c2 <- "c2 OK"
}()
select {
case res := <-c2:
fmt.Println(res)
case <-time.After(time.Second * 4):
fmt.Println("timeout c2")
}
}
在前面的代码中,您会看到一个操作,它不会超时,因为它在期望的时间内完成了,这意味着select块的第一个分支将被执行,而不是表示超时的第二个分支。
执行timeOuts.go将生成以下输出:
$ go run timeOuts.go
timeout c1
c2 OK
实现超时的另一种方法
本小节的技术将让您不必等待任何顽固的 goroutines 完成它们的工作。因此,本小节将向您展示如何通过timeoutWait.go程序来设置 goroutines 的超时,该程序将分为四个部分进行介绍。尽管timeoutWait.go和timeOuts.go之间存在代码差异,但总体思想是完全相同的。
timeoutWait.go的第一部分包含了预期的序言:
package main
import (
"fmt"
"sync"
"time"
)
timeoutWait.go的第二部分如下:
func timeout(w *sync.WaitGroup, t time.Duration) bool {
temp := make(chan int)
go func() {
defer close(temp)
w.Wait()
}()
select {
case <-temp:
return false
case <-time.After(t):
return true
}
}
在这里,您声明了一个执行整个工作的函数。函数的核心是select块,其工作方式与timeOuts.go中的相同。timeout()的匿名函数将在w.Wait()语句返回时成功结束,这将在执行适当数量的sync.Done()调用时发生,这意味着所有 goroutines 都将完成。在这种情况下,select语句的第一个case将被执行。
请注意,temp通道在select块中是必需的,而在其他地方则不需要。此外,temp通道的元素类型可以是任何类型,包括bool。
timeOuts.go的第三部分包含以下代码:
func main() {
var w sync.WaitGroup
w.Add(1)
t := 2 * time.Second
fmt.Printf("Timeout period is %s\n", t)
if timeout(&w, t) {
fmt.Println("Timed out!")
} else {
fmt.Println("OK!")
}
程序的最后一个片段包含以下 Go 代码:
w.Done()
if timeout(&w, t) {
fmt.Println("Timed out!")
} else {
fmt.Println("OK!")
}
}
在预期的w.Done()调用执行后,timeout()函数将返回true,这将防止超时发生。
正如在本小节开头提到的,timeoutWait.go实际上可以防止您的程序无限期地等待一个或多个 goroutine 结束。
执行timeoutWait.go将生成以下输出:
$ go run timeoutWait.go
Timeout period is 2s
Timed out!
OK!
通道的通道
在本节中,我们将讨论创建和使用通道的通道。使用这样的通道的两个可能原因如下:
-
用于确认操作已完成其工作
-
用于创建许多由相同通道变量控制的工作进程
在本节中将开发的简单程序的名称是cOfC.go,将分为四个部分呈现。
程序的第一部分如下:
package main
import (
"fmt"
)
var numbers = []int{0, -1, 2, 3, -4, 5, 6, -7, 8, 9, 10}
程序的第二部分如下:
func f1(cc chan chan int, finished chan struct{}) {
c := make(chan int)
cc <- c
defer close(c)
total := 0
i := 0
for {
select {
case c <- numbers[i]:
i = i + 1
i = i % len(numbers)
total = total + 1
case <-finished:
c <- total
return
}
}
}
f1()函数返回属于numbers变量的整数。当它即将结束时,它还使用c <- total语句将发送回到caller函数的整数数量。
由于您不能直接使用通道的通道,因此您应该首先从中读取(cc <- c)并获取实际可以使用的通道。这里方便的是,尽管您可以关闭c通道,但通道的通道(cc)仍将保持运行。
cOfC.go的第三部分如下:
func main() {
c1 := make(chan chan int)
f := make(chan struct{})
go f1(c1, f)
data := <-c1
在这段 Go 代码中,您可以看到可以使用chan关键字连续两次声明通道的通道。
cOfC.go的最后一部分包含以下 Go 代码:
i := 0
for integer := range data {
fmt.Printf("%d ", integer)
i = i + 1
if i == 100 {
close(f)
}
}
fmt.Println()
}
在这里,通过关闭f通道,您限制了将创建的整数数量,当您拥有所需的整数数量时。
执行cOfC.go将生成以下输出:
$ go run cOfC.go
0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 -1 2 3 -4 5 6 -7 8 9 10 0 100
通道的通道是 Go 的高级功能,您可能不需要在系统软件中使用。但是,了解其存在是很好的。
空通道
本节将讨论nil 通道,这是一种特殊类型的通道,它将始终阻塞。程序的名称将是nilChannel.go,将分为四个部分呈现。
程序的第一部分包含了预期的序言:
package main
import (
"fmt"
"math/rand"
"time"
)
第二部分包含addIntegers()函数的实现:
func addIntegers(c chan int) {
sum := 0
t := time.NewTimer(time.Second)
for {
select {
case input := <-c:
sum = sum + input
case <-t.C:
c = nil
fmt.Println(sum)
}
}
}
addIntegers()函数在time.NewTimer()函数定义的时间过去后停止,并将转到case语句的相关分支。在那里,它将使c成为 nil 通道,这意味着通道将停止接收新数据,并且函数将在那里等待。
nilChannel.go的第三部分如下:
func sendIntegers(c chan int) {
for {
c <- rand.Intn(100)
}
}
在这里,sendIntegers()函数会继续生成随机数并将它们发送到c通道,只要c通道是打开的。但是,这里还有一个永远不会被清理的 goroutine。
程序的最后一部分包含以下 Go 代码:
func main() {
c := make(chan int)
go addIntegers(c)
go sendIntegers(c)
time.Sleep(2 * time.Second)
}
执行nilChannel.go将生成以下输出:
$ go run nilChannel.go
162674704
$ go run nilChannel.go
165021841
共享内存
共享内存是线程之间进行通信的传统方式。Go 具有内置的同步功能,允许单个 goroutine 拥有共享数据的一部分。这意味着其他 goroutine 必须向拥有共享数据的单个 goroutine 发送消息,这可以防止数据的损坏!这样的 goroutine 称为监视器 goroutine。在 Go 术语中,这是通过通信进行共享,而不是通过共享进行通信。
这种技术将在sharedMem.go程序中进行演示,该程序将分为五个部分呈现。sharedMem.go的第一部分包含以下 Go 代码:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
第二部分如下:
var readValue = make(chan int)
var writeValue = make(chan int)
func SetValue(newValue int) {
writeValue <- newValue
}
func ReadValue() int {
return <-readValue
}
ReadValue()函数用于读取共享变量,而SetValue()函数用于设置共享变量的值。此外,程序中使用的两个通道需要是全局变量,以避免将它们作为程序所有函数的参数传递。请注意,这些全局变量通常被封装在一个 Go 库或带有方法的struct中。
sharedMem.go的第三部分如下:
func monitor() {
var value int
for {
select {
case newValue := <-writeValue:
value = newValue
fmt.Printf("%d ", value)
case readValue <- value:
}
}
}
sharedMem.go的逻辑可以在monitor()函数的实现中找到。当你有一个读取请求时,ReadValue()函数尝试从readValue通道读取。然后,monitor()函数返回value参数中保存的当前值。同样,当你想要改变存储的值时,你调用SetValue(),它会写入writeValue通道,也由select语句处理。再次,select块起着关键作用,因为它协调了monitor()函数的操作。
程序的第四部分包含以下 Go 代码:
func main() {
rand.Seed(time.Now().Unix())
go monitor()
var waitGroup sync.WaitGroup
for r := 0; r < 20; r++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
SetValue(rand.Intn(100))
}()
}
程序的最后部分如下:
waitGroup.Wait()
fmt.Printf("\nLast value: %d\n", ReadValue())
}
执行sharedMem.go将生成以下输出:
$ go run sharedMem.go
33 45 67 93 33 37 23 85 87 23 58 61 9 57 20 61 73 99 42 99
Last value: 99
$ go run sharedMem.go
71 66 58 83 55 30 61 73 94 19 63 97 12 87 59 38 48 81 98 49
Last value: 49
如果你想共享更多的值,你可以定义一个新的结构,用来保存所需的变量和你喜欢的数据类型。
使用 sync.Mutex
Mutex是mutual exclusion的缩写;Mutex变量主要用于线程同步和保护共享数据,当多个写操作可能同时发生时。互斥锁的工作原理类似于容量为 1 的缓冲通道,最多允许一个 goroutine 同时访问共享变量。这意味着没有两个或更多的 goroutine 可以同时尝试更新该变量。虽然这是一种完全有效的技术,但一般的 Go 社区更倾向于使用前一节介绍的monitor goroutine 技术。
为了使用sync.Mutex,你必须首先声明一个sync.Mutex变量。你可以使用Lock方法锁定该变量,并使用Unlock方法释放它。sync.Lock()方法为你提供了对共享变量的独占访问,这段代码区域在调用Unlock()方法时结束,被称为关键部分。
程序的每个关键部分在使用sync.Lock()进行锁定之前都不能执行。然而,如果锁已经被占用,每个人都应该等待其释放。虽然多个函数可能会等待获取锁,但只有当它被释放时,其中一个函数才会获取到它。
你应该尽量将关键部分设计得尽可能小;换句话说,不要延迟释放锁,因为其他 goroutines 可能想要使用它。此外,忘记解锁Mutex很可能会导致死锁。
用于演示sync.Mutex的 Go 程序的名称将是mutexSimple.go,并将以五个部分呈现。
mutexSimple.go的第一部分包含了预期的序言:
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
)
程序的第二部分如下:
var aMutex sync.Mutex
var sharedVariable string = ""
func addDot() {
aMutex.Lock()
sharedVariable = sharedVariable + "."
aMutex.Unlock()
}
请注意,关键部分并不总是显而易见,你在指定时应该非常小心。还要注意,当两个关键部分使用相同的Mutex变量时,一个关键部分不能嵌套在另一个关键部分中!简单地说,几乎要以所有的代价避免在函数之间传递互斥锁,因为这样很难看出你是否嵌套了互斥锁!
在这里,addDot()在sharedVariable字符串的末尾添加一个点字符。但是,由于字符串应该同时被多个 goroutine 改变,所以您使用sync.Mutex变量来保护它。由于关键部分只包含一个命令,获取对互斥体的访问的等待时间将非常短,甚至是瞬时的。但是,在现实世界的情况下,等待时间可能会更长,特别是在诸如数据库服务器之类的软件上,成千上万的进程同时发生许多事情:您可以通过在关键部分添加对time.Sleep()的调用来模拟这一点。
请注意,将互斥体与一个或多个共享变量相关联是开发人员的责任!
mutexSimple.go的第三个代码段是另一个使用互斥体的函数的实现:
func read() string {
aMutex.Lock()
a := sharedVariable
aMutex.Unlock()
return a
}
尽管在读取共享变量时锁定共享变量并不是绝对必要的,但这种锁定可以防止在读取时共享变量发生更改。在这里可能看起来像一个小问题,但想象一下读取您的银行账户余额!
第四部分是您定义要启动的 goroutine 数量的地方:
func main() {
if len(os.Args) != 2 {
fmt.Printf("usage: %s n\n", filepath.Base(os.Args[0]))
os.Exit(1)
}
numGR, _ := strconv.ParseInt(os.Args[1], 10, 64)
var waitGroup sync.WaitGroup
mutexSimple.go的最后一部分包含以下 Go 代码:
var i int64
for i = 0; i < numGR; i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
addDot()
}()
}
waitGroup.Wait()
fmt.Printf("-> %s\n", read())
fmt.Printf("Length: %d\n", len(read()))
}
在这里,您启动所需数量的 goroutine。每个 goroutine 调用addDot()函数来访问共享变量:然后等待它们完成,然后使用read()函数读取共享变量的值。
执行mutexSimple.go将生成类似以下的输出:
$ go run mutexSimple.go 20
-> ....................
Length: 20
$ go run mutexSimple.go 30
-> ..............................
Length: 30
使用 sync.RWMutex
Go 提供了另一种类型的互斥体,称为sync.RWMutex,它允许多个读取者持有锁,但只允许单个写入者 - sync.RWMutex是sync.Mutex的扩展,添加了两个名为sync.RLock和sync.RUnlock的方法,用于读取目的的锁定和解锁。对于独占写入,应分别使用Lock()和Unlock()来锁定和解锁sync.RWMutex。
这意味着要么一个写入者可以持有锁,要么多个读取者可以持有锁:不能同时两者都有!当大多数 goroutine 想要读取一个变量而您不希望 goroutine 等待以获取独占锁时,您很可能会使用这样的互斥体。
为了让sync.RWMutex变得更加透明,您应该发现sync.RWMutex类型是一个 Go 结构,当前定义如下:
type RWMutex struct {
w Mutex
writerSem uint32
readerSem uint32
readerCount int32
readerWait int32
}
所以,这里没有什么可害怕的!现在,是时候看一个使用sync.RWMutex的 Go 程序了。该程序将被命名为mutexRW.go,并将分为五个部分呈现。
mutexRW.go的第一部分包含预期的序言以及全局变量和新的struct类型的定义:
package main
import (
"fmt"
"sync"
"time"
)
var Password = secret{counter: 1, password: "myPassword"}
type secret struct {
sync.RWMutex
counter int
password string
}
secret结构嵌入了sync.RWMutex,因此它可以调用sync.RWMutex的所有方法。
mutexRW.go的第二部分包含以下 Go 代码:
func Change(c *secret, pass string) {
c.Lock()
fmt.Println("LChange")
time.Sleep(20 * time.Second)
c.counter = c.counter + 1
c.password = pass
c.Unlock()
}
此函数对其一个参数进行更改,这意味着它需要一个独占锁,因此使用了Lock()和Unlock()函数。
示例代码的第三部分如下:
func Show(c *secret) string {
fmt.Println("LShow")
time.Sleep(time.Second)
c.RLock()
defer c.RUnlock()
return c.password
}
func Counts(c secret) int {
c.RLock()
defer c.RUnlock()
return c.counter
}
在这里,您可以看到使用sync.RWMutex进行读取的两个函数的定义。这意味着它们的多个实例可以获取sync.RWMutex锁。
程序的第四部分如下:
func main() {
fmt.Println("Pass:", Show(&Password))
for i := 0; i < 5; i++ {
go func() {
fmt.Println("Go Pass:", Show(&Password))
}()
}
在这里,您启动五个 goroutine 以使事情更有趣和随机。
mutexRW.go的最后一部分如下:
go func() {
Change(&Password, "123456")
}()
fmt.Println("Pass:", Show(&Password))
time.Sleep(time.Second)
fmt.Println("Counter:", Counts(Password))
}
尽管共享内存和使用互斥体仍然是并发编程的有效方法,但使用 goroutine 和通道是一种更现代的方式,符合 Go 的哲学。因此,如果可以使用通道和管道解决问题,您应该优先选择这种方式,而不是使用共享变量。
执行mutexRW.go将生成以下输出:
$ go run mutexRW.go
LShow
Pass: myPassword
LShow
LShow
LShow
LShow
LShow
LShow
LChange
Go Pass: 123456
Go Pass: 123456
Pass: 123456
Go Pass: 123456
Go Pass: 123456
Go Pass: 123456
Counter: 2
如果Change()的实现也使用了RLock()调用以及RUnlock()调用,那将是完全错误的,那么程序的输出将如下所示:
$ go run mutexRW.go
LShow
Pass: myPassword
LShow
LShow
LShow
LShow
LShow
LShow
LChange
Go Pass: myPassword
Pass: myPassword
Go Pass: myPassword
Go Pass: myPassword
Go Pass: myPassword
Go Pass: myPassword
Counter: 1
简而言之,你应该充分了解你正在使用的锁定机制以及它的工作方式。在这种情况下,决定Counts()将返回什么的是时间:时间取决于Change()函数中的time.Sleep()调用,它模拟了实际函数中将发生的处理。问题在于,在Change()中使用RLock()和RUnlock()允许多个 goroutine 读取共享变量,因此从Counts()函数中获得错误的输出。
重新审视 dWC.go 实用程序
在本节中,我们将改变在上一章中开发的dWC.go实用程序的实现。
程序的第一个版本将使用缓冲通道,而程序的第二个版本将使用共享内存来保持你处理的每个文件的计数。
使用缓冲通道
这个实现的名称将是WCbuffered.go,并将分为五个部分呈现。
实用程序的第一部分如下:
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
)
type File struct {
Filename string
Lines int
Words int
Characters int
Error error
}
File结构将为每个输入文件保留计数。WCbuffered.go的第二部分包含以下 Go 代码:
func monitor(values <-chan File, count int) {
var totalWords int = 0
var totalLines int = 0
var totalChars int = 0
for i := 0; i < count; i++ {
x := <-values
totalWords = totalWords + x.Words
totalLines = totalLines + x.Lines
totalChars = totalChars + x.Characters
if x.Error == nil {
fmt.Printf("\t%d\t", x.Lines)
fmt.Printf("%d\t", x.Words)
fmt.Printf("%d\t", x.Characters)
fmt.Printf("%s\n", x.Filename)
} else {
fmt.Printf("\t%s\n", x.Error)
}
}
fmt.Printf("\t%d\t", totalLines)
fmt.Printf("%d\t", totalWords)
fmt.Printf("%d\ttotal\n", totalChars)
}
monitor()函数收集所有信息并打印出来。monitor()内部的for循环确保它将收集到正确数量的数据。
程序的第三部分包含了count()函数的实现:
func count(filename string, out chan<- File) {
var err error
var nLines int = 0
var nChars int = 0
var nWords int = 0
f, err := os.Open(filename)
defer f.Close()
if err != nil {
newValue := File{
Filename: filename,
Lines: 0,
Characters: 0,
Words: 0,
Error: err }
out <- newValue
return
}
r := bufio.NewReader(f)
for {
line, err := r.ReadString('\n')
if err == io.EOF {
break
} else if err != nil {
fmt.Printf("error reading file %s\n", err)
}
nLines++
r := regexp.MustCompile("[^\\s]+")
for range r.FindAllString(line, -1) {
nWords++
}
nChars += len(line)
}
newValue := File {
Filename: filename,
Lines: nLines,
Characters: nChars,
Words: nWords,
Error: nil }
out <- newValue
}
当count()函数完成时,它会将信息发送到缓冲通道,因此这里没有什么特别的。
WCbuffered.go的第四部分如下:
func main() {
if len(os.Args) == 1 {
fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n",
filepath.Base(os.Args[0]))
os.Exit(1)
}
values := make(chan File, len(os.Args[1:]))
在这里,你创建了一个名为values的缓冲通道,其位置数与你将处理的文件数相同。
实用程序的最后一部分如下:
for _, filename := range os.Args[1:] {
go func(filename string) {
count(filename, values)
}(filename)
}
monitor(values, len(os.Args[1:]))
}
使用共享内存
共享内存和互斥锁的好处在于,理论上它们通常只占用很小一部分代码,这意味着其余的代码可以在没有其他延迟的情况下并发工作。然而,只有在你实现了某些东西之后,你才能看到真正发生了什么!
这个实现的名称将是WCshared.go,并将分为五个部分:实用程序的第一部分如下:
package main
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sync"
)
type File struct {
Filename string
Lines int
Words int
Characters int
Error error
}
var aM sync.Mutex
var values = make([]File, 0)
values切片将是程序的共享变量,而互斥变量的名称将是aM。
WCshared.go的第二部分包含以下 Go 代码:
func count(filename string) {
var err error
var nLines int = 0
var nChars int = 0
var nWords int = 0
f, err := os.Open(filename)
defer f.Close()
if err != nil {
newValue := File{Filename: filename, Lines: 0, Characters: 0, Words: 0, Error: err}
aM.Lock()
values = append(values, newValue)
aM.Unlock()
return
}
r := bufio.NewReader(f)
for {
line, err := r.ReadString('\n')
if err == io.EOF {
break
} else if err != nil {
fmt.Printf("error reading file %s\n", err)
}
nLines++
r := regexp.MustCompile("[^\\s]+")
for range r.FindAllString(line, -1) {
nWords++
}
nChars += len(line)
}
newValue := File{Filename: filename, Lines: nLines, Characters: nChars, Words: nWords, Error: nil}
aM.Lock()
values = append(values, newValue)
aM.Unlock()
}
因此,在count()函数退出之前,它会使用临界区向values切片添加一个元素。
WCshared.go的第三部分如下:
func main() {
if len(os.Args) == 1 {
fmt.Printf("usage: %s <file1> [<file2> [... <fileN]]\n",
filepath.Base(os.Args[0]))
os.Exit(1)
}
在这里,你只需要处理实用程序的命令行参数。
WCshared.go的第四部分包含以下 Go 代码:
var waitGroup sync.WaitGroup
for _, filename := range os.Args[1:] {
waitGroup.Add(1)
go func(filename string) {
defer waitGroup.Done()
count(filename)
}(filename)
}
waitGroup.Wait()
在这里,你只需启动所需数量的 goroutine,并等待它们完成工作。
实用程序的最后一部分如下:
var totalWords int = 0
var totalLines int = 0
var totalChars int = 0
for _, x := range values {
totalWords = totalWords + x.Words
totalLines = totalLines + x.Lines
totalChars = totalChars + x.Characters
if x.Error == nil {
fmt.Printf("\t%d\t", x.Lines)
fmt.Printf("%d\t", x.Words)
fmt.Printf("%d\t", x.Characters)
fmt.Printf("%s\n", x.Filename)
}
}
fmt.Printf("\t%d\t", totalLines)
fmt.Printf("%d\t", totalWords)
fmt.Printf("%d\ttotal\n", totalChars)
}
当所有 goroutine 都完成时,就该处理共享变量的内容,计算总数,并打印所需的输出。请注意,在这种情况下,没有任何类型的共享变量,因此不需要互斥锁:你只需等待收集所有结果并打印它们。
更多的基准测试
本节将使用方便的time(1)实用程序来测量WCbuffered.go和WCshared.go的性能。然而,这一次,我不会呈现图表,而是会给你time(1)实用程序的实际输出:
$ time go run WCshared.go /tmp/*.data /tmp/*.data
real 0m31.836s
user 0m31.659s
sys 0m0.165s
$ time go run WCbuffered.go /tmp/*.data /tmp/*.data
real 0m31.823s
user 0m31.656s
sys 0m0.171s
正如你所看到的,这两个实用程序的性能都很好,或者如果你愿意的话,也可以说都很糟糕!然而,除了程序的速度之外,还有其设计的清晰度以及对其进行代码更改的易用性也很重要!此外,所呈现的方式还会计算这两个实用程序的编译时间,这可能会使结果不太准确。
这两个程序之所以能够轻松生成总数,是因为它们都有一个控制点。对于WCshared.go实用程序,控制点是共享变量,而对于WCbuffered.go,控制点是在monitor()函数内收集所需信息的缓冲通道。
检测竞争条件
如果在运行或构建 Go 程序时使用-race标志,将启用 Go 竞争检测器,这将使编译器创建典型可执行文件的修改版本。这个修改版本可以记录对共享变量的访问以及发生的所有同步事件,包括对sync.Mutex、sync.WaitGroup等的调用。在对事件进行一些分析后,竞争检测器会打印一个报告,可以帮助您识别潜在问题,以便您可以纠正它们。
为了展示竞争检测器的操作,我们将使用rd.go程序的代码,它将分为四个部分呈现。对于这个特定的程序,数据竞争将会发生,因为两个或更多的 goroutine 同时访问同一个变量,并且其中至少一个以某种方式改变了变量的值。
请注意,main()程序在 Go 中也是一个 goroutine!
程序的第一部分如下:
package main
import (
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
)
这里没有什么特别的,所以如果程序有问题,那就不是在前言中。
rd.go的第二部分如下:
func main() {
arguments := os.Args
if len(arguments) != 2 {
fmt.Printf("usage: %s number\n", filepath.Base(arguments[0]))
os.Exit(1)
}
numGR, _ := strconv.ParseInt(os.Args[1], 10, 64)
var waitGroup sync.WaitGroup
var i int64
在这个特定的代码中,没有任何问题。
rd.go的第三部分具有以下 Go 代码:
for i = 0; i < numGR; i++ {
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
fmt.Printf("%d ", i)
}()
}
这段代码非常可疑,因为您试图打印一个由于for循环而不断变化的变量的值。
rd.go的最后一部分如下:
waitGroup.Wait()
fmt.Println("\nExiting...")
}
最后一部分代码中没有什么特别的。
为rd.go启用 Go 竞争检测器将生成以下输出:
$ go run -race rd.go 10 ================== WARNING: DATA RACE
Read at 0x00c420074168 by goroutine 6:
main.main.func1()
/Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:25 +0x6c
Previous write at 0x00c420074168 by main goroutine:
main.main()
/Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:21 +0x30c
Goroutine 6 (running) created at:
main.main()
/Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:26 +0x2e2
==================
==================
WARNING: DATA RACE
Read at 0x00c420074168 by goroutine 7:
main.main.func1()
/Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:25 +0x6c
Previous write at 0x00c420074168 by main goroutine:
main.main()
/Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:21 +0x30c
Goroutine 7 (running) created at:
main.main()
/Users/mtsouk/Desktop/goBook/ch/ch10/code/rd.go:26 +0x2e2
==================
2 3 4 4 5 6 7 8 9 10
Exiting...
Found 2 data race(s)
exit status 66
因此,竞争检测器发现了两个数据竞争。第一个发生在数字1根本没有被打印出来时,第二个发生在数字4被打印两次时。此外,尽管i的初始值是数字0,但数字0并没有被打印出来。最后,你不应该在输出中得到数字10,但你确实得到了,因为i的最后一个值确实是10。请注意,在前面的输出中找到的main.main.func1()表示 Go 谈论的是一个匿名函数。
简而言之,前两条消息告诉您的是,i变量有问题,因为当程序的 goroutine 尝试读取它时,它一直在变化。此外,您无法确定地告诉会先发生什么。
在没有竞争检测器的情况下运行相同的程序将生成以下输出:
$ go run rd.go 10
10 10 10 10 10 10 10 10 10 10
Exiting...
rd.go中的问题可以在匿名函数中找到。由于匿名函数不带参数,它使用i的当前值,这个值无法确定,因为它取决于操作系统和 Go 调度程序:这就是竞争情况发生的地方!因此,请记住,最容易出现竞争条件的地方之一是在从匿名函数生成的 goroutine 内部!因此,如果您必须解决这种情况,请首先将匿名函数转换为具有定义参数的常规函数!
使用竞争检测器的程序比没有竞争检测器的程序更慢,需要更多的 RAM。最后,如果竞争检测器没有任何报告,它将不会生成任何输出。
关于 GOMAXPROCS
GOMAXPROCS环境变量(和 Go 函数)允许您限制可以同时执行用户级 Go 代码的操作系统线程的数量。
从 Go 版本 1.5 开始,默认值GOMAXPROCS应该是您的 Unix 系统上可用的核心数。
尽管在 Unix 机器上使用小于核心数的GOMAXPROCS值可能会影响程序的性能,但指定大于可用核心数的GOMAXPROCS值不会使程序运行更快!
goMaxProcs.go的代码允许您确定GOMAXPROCS的值-它将分为两部分呈现。
第一部分如下:
package main
import (
"fmt"
"runtime"
)
func getGOMAXPROCS() int {
return runtime.GOMAXPROCS(0)
}
第二部分如下:
func main() {
fmt.Printf("GOMAXPROCS: %d\n", getGOMAXPROCS())
}
在支持超线程的 Intel i7 机器上执行goMaxProcs.go并使用最新的 Go 版本会得到以下输出:
$ go run goMaxProcs.go
GOMAXPROCS: 8
然而,如果您在运行旧版 Go 的 Debian Linux 机器上执行goMaxProcs.go并且有一个旧处理器,它将生成以下输出:
$ go version
go version go1.3.3 linux/amd64
$ go run goMaxProcs.go
GOMAXPROCS: 1
动态更改GOMAXPROCS的值的方法如下:
$ export GOMAXPROCS=80; go run goMaxProcs.go
GOMAXPROCS: 80
但是,设置大于256的值将不起作用:
$ export GOMAXPROCS=800; go run goMaxProcs.go
GOMAXPROCS: 256
最后,请记住,如果您使用单个核心运行诸如dWC.go之类的并发程序,则并发版本的程序可能不会比没有 goroutines 的程序版本运行得更快!在某些情况下,这是因为 goroutines 的使用以及对sync.Add、sync.Wait和sync.Done函数的各种调用会减慢程序的性能。可以通过以下输出来验证:
$ export GOMAXPROCS=8; time go run dWC.go /tmp/*.data
real 0m10.826s
user 0m31.542s
sys 0m5.043s
$ export GOMAXPROCS=1; time go run dWC.go /tmp/*.data
real 0m15.362s
user 0m15.253s
sys 0m0.103s
$ time go run wc.go /tmp/*.data
real 0m15.158sexit
user 0m15.023s
sys 0m0.120s
练习
-
仔细阅读可以在
golang.org/pkg/sync/找到的sync包的文档页面。 -
尝试使用与本章节中使用的不同的共享内存技术来实现
dWC.go。 -
实现一个
struct数据类型,它保存您的账户余额,并创建读取您拥有的金额并对金额进行更改的函数。创建一个使用sync.RWMutex和另一个使用sync.Mutex的实现。 -
如果你在
mutexRW.go中到处使用Lock()和Unlock()而不是RLock()和RUnlock(),会发生什么? -
尝试使用 goroutines 从第五章*,* 文件和目录中实现
traverse.go。 -
尝试使用 goroutines 从第五章*,* 文件和目录中创建
improvedFind.go的实现。
摘要
本章讨论了与 goroutines、通道和并发编程相关的一些高级 Go 特性。然而,本章的教训是通道可以做很多事情,并且可以在许多情况下使用,这意味着开发人员必须能够根据自己的经验选择适当的技术来实现任务。
下一章的主题将是 Go 中的 Web 开发,其中将包含非常有趣的材料,包括发送和接收 JSON 数据,开发 Web 服务器和 Web 客户端,以及从您的 Go 代码与 MongoDB 数据库交互。