Go程序设计语言 学习笔记 第八章 goroutine和通道

并发编程表现为程序由若干个自主的活动单元组成,它从来没有像今天这样重要。Web服务器可以同时处理数千个请求。平板电脑和手机应用在渲染用户界面的同时,后端还同步进行着计算和处理网络请求。甚至传统的批处理任务------读取数据、计算、将结果输出------也使用并发来隐藏IO操作的延迟,并充分利用现代的多核计算机,内核每年的增长点在于数量而非速度。

Go有两种并发编程的风格。这一章展示goroutine和通道(channel),它们支持通信顺序进程(Communicating Sequential Process,CSP),CSP是一个并发的模式,在不同的执行体(goroutine)之间传递值,但是变量本身大多局限于单一的执行体(goroutine)。第9章涵盖一些共享内存多线程的传统模型,它们和在其他语言中使用线程类似。第9章也会指出一些关于并发编程的重要难题和陷阱,这里暂不深入介绍。

即使Go对并发的支持是其长处,但并发编程在本质上比顺序编程要困难一些。

8.1 goroutine

在Go里,每一个并发执行的活动成为goroutine。考虑一个程序,它有两个函数,一个做一些计算工作,另一个将结果输出,假设它们不互相调用。顺序程序可能调用一个函数,然后调用另一个,但是在有两个或多个goroutine的并发程序中,两个函数可以同时执行。

如果你使用过操作系统或其他语言中的线程,为了写出正确的程序,可以假设goroutine类似于线程。goroutine和线程之间在数量上有非常大的差别,这将在9.8节讨论。

但一个程序启动时,只有一个goroutine来调用main函数,称它为主goroutine。新的goroutine通过go语句进行创建。语法上,一个go语句是在普通的函数或方法调用前加上go关键字前缀。go语句使函数在一个新创建的goroutine中调用。go语句本身的执行立即完成:

go 复制代码
f()    // 调用f();等待它返回
go f() // 新建一个调用f()的goroutine,不用等函数返回

下例中,主goroutine计算第45个斐波那契数。因为它使用非常低效的递归算法,所以它需要大量时间来执行,在此期间我们提供一个可见的提示,显示一个字符串"spinner"来指示程序依然在运行。

go 复制代码
package main

import (
	"fmt"
	"time"
)

func main() {
	go spinner(100 * time.Microsecond)
	const n = 45
	fibN := fib(n) // slow
	fmt.Printf("\rFibonacci(%d) = %d\n", n, fibN)
}

func spinner(delay time.Duration) {
	for {
		for _, r := range `-\|/` {
			fmt.Printf("\r%c", r)
			time.Sleep(delay)
		}
	}
}

func fib(x int) int {
	if x < 2 {
		return x
	}
	return fib(x-1) + fib(x-2)
}

若干秒后,fib(45)返回,main函数输出结果:

然后main函数返回,当它发生时,所有的goroutine都暴力地直接终结,然后程序退出。除了从main返回或者退出程序外,没有程序化的方法让一个goroutine来停止另一个,但我们将看到,有办法和goroutine通信来要求它自己停止。

注意上例有两个自主的活动(指示器和斐波那契数计算)。它们写成独立的函数,但是同时在运行。

8.2 例子:并发时钟服务器

网络是一个自然使用并发的领域,因为服务器通常一次处理很多来自客户端的连接,每个客户端通常和其他客户端保持独立。本节介绍net包,它提供构建客户端和服务器程序的组件,这些程序通过TCP、UDP或UNIX套接字进行通信。第一章用过的net/http包是在net包基础上构建的。

第一个例子是顺序时钟服务器,它以每秒一次的频率向客户端发送当前时间:

go 复制代码
// clock1是一个定期报告时间的TCP服务器
package main

import (
	"io"
	"log"
	"net"
	"time"
)

func main() {
	listener, err := net.Listen("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Print(err) // 例如,connection aborted
			continue
		}
		handleConn(conn) // 一次处理一个连接
	}
}

func handleConn(c net.Conn) {
	defer c.Close()
	for {
		_, err := io.WriteString(c, time.Now().Format("15:04:05\n"))
		if err != nil {
			return // 例如,连接断开
		}
		time.Sleep(1 * time.Second)
	}
}

Listen还是创建一个net.Listener对象,它在一个网络端口上监听进来的连接,这里是TCP端口localhost:8000。监听器的Accept方法被阻塞,直到有连接请求进来,然后返回net.Comm对象来代表一个连接。

handleConn函数处理一个完整的客户连接。在循环里,它将time.Now()获取的当前时间发送给客户端。因为net.Conn满足io.Writer接口,所以可以直接向它写入。当写入失败时循环结束,很多时候是客户端断开连接,这时handleComm还是使用延迟的Close调用关闭自己这边的连接,然后继续等待下一个连接请求。

time.Time.Format方法(time.Now()的类型是time.Time)提供了格式化日期和时间的方式,它的参数是一个模板,指示如何初始化一个参考时间。time包定义了很多标准时间格式的模板,如time.RFC1123(RFC1123 = "Mon, 02 Jan 2006 15:04:05 MST")。当解析一个代表时间的字符串时使用相同的机制。

为了连接到服务器,需要一个像nc(netcat)这样的程序,它是一个用来操作网络连接的标准工具:

客户端显示每秒从服务器发来的时间,直到使用Control+C快捷键中断它,UNIX系统shell将该快捷键回显为^C。如果系统上没有安装nc或netcat,可以使用telnet或一个使用net.Dial实现的Go版的netcat来连接TCP服务器:

go 复制代码
// netcat1是一个只读的TCP客户端程序
package main

import (
	"io"
	"log"
	"net"
	"os"
)

func main() {
	conn, err := net.Dial("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()
	mustCopy(os.Stdout, conn)
}

func mustCopy(dst io.Writer, src io.Reader) {
	if _, err := io.Copy(dst, src); err != nil {
		log.Fatal(err)
	}
}

这个程序从网络连接中读取,然后写到标准输出,直到到达EOF或者出错。mustCopy函数是本节的多个例子中都会使用的一个实用程序。在不同终端上同时运行两个客户端,一个显示在左边,一个在右边:

killall命令是UNIX的一个实用程序,用来终止所有指定名字的进程。

第二个客户端必须等到第一个结束才能正常工作,因为服务器是顺序的,一次只能处理一个客户请求。让服务器支持并发只需要一个很小的改变:在调用handleConn的地方添加一个go关键字,使它在自己的goroutine内执行。

go 复制代码
for {
    conn, err := listener.Accept()
    if err != nil {
        log.Print(err)
        continue
    }
    go handleConn(conn) // 并发处理连接
}

现在多个客户端可以同时接收到时间:

8.3 例子:并发回声服务器

时钟服务器每个连接使用一个goroutine。在本节,我们构建一个回声服务器,每个连接使用多个goroutine来处理。大多数的回声服务器仅仅将读到的内容写回去,它可以使用下面简单的handleConn版本完成:

go 复制代码
func handleConn(c net.Conn) {
    io.Copy(c, c) // 注意,忽略错误
    c.Close()
}

更有趣的回声服务器可以模仿真实的回声,第一次大的回声("HELLO!"),在一定延迟后中等音量的回声("Hello!"),然后安静的回声("hello!"),最后什么都没了,如下面这个handleConn所示:

go 复制代码
// gopl.io/ch8/reverb1
package main

import (
	"bufio"
	"fmt"
	"log"
	"net"
	"strings"
	"time"
)

func main() {
	listener, err := net.Listen("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Print(err) // 例如,connection aborted
			continue
		}
		handleConn(conn) // 一次处理一个连接
	}
}

func echo(c net.Conn, shout string, delay time.Duration) {
	fmt.Fprintln(c, "\t", strings.ToUpper(shout))
	time.Sleep(delay)
	fmt.Fprintln(c, "\t", shout)
	time.Sleep(delay)
	fmt.Fprintln(c, "\t", strings.ToLower(shout))
}

func handleConn(c net.Conn) {
	input := bufio.NewScanner(c)
	for input.Scan() {
		echo(c, input.Text(), 1*time.Second)
	}
	// 注意:忽略input.Err()中可能的错误
	c.Close()
}

我们需要升级客户端程序,使它可以在终端上向服务器输入,还可以将服务器的回复复制到输出,这里有另一个使用并发的机会:

go 复制代码
// gopl.io/ch8/netcat2
package main

import (
	"io"
	"log"
	"net"
	"os"
)

func main() {
	conn, err := net.Dial("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close()
	go mustCopy(os.Stdout, conn)
	mustCopy(conn, os.Stdin)
}

func mustCopy(dst io.Writer, src io.Reader) {
	if _, err := io.Copy(dst, src); err != nil {
		log.Fatal(err)
	}
}

当主goroutine从标准输入读取并发送到服务器端的时候,第二个goroutine读取服务器的回复并且输出。当主goroutine的输入结束时,例如用户在终端键入Control+D(^D)(或者在Windows平台上键入Control+Z)时,这个客户程序停止,即使其他goroutine还有工作要做。

以下场景中,客户的输入左对齐,服务器的回复是缩进的。客户端向回声服务器呼叫三次:

注意,第三次客户端的呼叫直到第二次呼叫的回声全部结束才进行处理,这不是特别切合现实。真实的回声会由三个独立的呼喊回声叠加组成。为了模仿它,我们需要更多的goroutine。可以在调用echo时加入go关键字:

go 复制代码
package main

import (
	"bufio"
	"fmt"
	"log"
	"net"
	"strings"
	"time"
)

func main() {
	listener, err := net.Listen("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Print(err) // 例如,connection aborted
			continue
		}
		handleConn(conn) // 一次处理一个连接
	}
}

func echo(c net.Conn, shout string, delay time.Duration) {
	fmt.Fprintln(c, "\t", strings.ToUpper(shout))
	time.Sleep(delay)
	fmt.Fprintln(c, "\t", shout)
	time.Sleep(delay)
	fmt.Fprintln(c, "\t", strings.ToLower(shout))
}

func handleConn(c net.Conn) {
	input := bufio.NewScanner(c)
	for input.Scan() {
		go echo(c, input.Text(), 1*time.Second)
	}
	// 注意:忽略input.Err()中可能的错误
	c.Close()
}

当go语句执行的时候,会对echo函数的参数求值;因此input.Text()是在主goroutine中求值的。

现在的回声是并发的,在时间上相互重合:

使服务器变成并发只需使用go关键字,并发不仅可以用来处理来自多个客户端的连接,还可以用于一个连接内部实现并发。

但在添加go关键字的同时,要考虑并发调用net.Conn的方法是否安全,这对大多数类型来说是不安全的。

8.4 通道

如果说goroutine是Go程序并发的执行体,通道就是它们之间的连接。通道是可以让一个goroutine发送值到另一个goroutine的通信机制。每个通道都是一种特定类型的值的通道,称为通道的元素类型。通道的元素类型为int的通道类型被写作chan int

使用内置的make函数来创建一个通道:

go 复制代码
ch := make(chan int) // ch的类型是chan int

像map一样,通道是一个使用make创建的数据结构的引用。当复制或作为参数传递到一个函数时,复制的是引用,这样调用者和被调用者都引用同一份数据结构。和其他引用类型一样,通道的零值是nil。

同种类型的通道可以用==符号进行比较。当两个通道是同一通道数据结构时返回true。通道也可以和nil进行比较。

通道有两个主要操作:发送(send)和接收(receive),两者统称为通信。send语句从一个goroutine传输一个值到另一个在执行接收表达式的goroutine。两个操作都使用<-操作符。发送语句中,通道在<-的左边,要发送的值在<-的右边。在接收表达式中,<-放在通道的左边。在接收表达式中,其结果未被使用也是合法的。

go 复制代码
ch <- x // 发送语句
x = <- ch // 赋值语句中的接收表达式
<- ch // 接收语句,丢弃结果

通道支持的第三个操作:关闭,它设置一个标志位来指示当前已经发送完毕,这个通道后面没有值了;关闭后的发送操作将导致宕机。在一个已经关闭的通道上进行接收操作,将获取所有已经发送的值,直到通道为空;这时接收操作将会完成,同时获取到一个通道元素类型对应的零值。

调用内置的close函数来关闭通道:

go 复制代码
close(ch)

使用简单的make调用创建的通道叫无缓冲(unbuffered)通道,但make还可以接受第二个可选参数,一个表示通道容量的整数。如果容量是0,make创建一个无缓冲通道:

go 复制代码
ch = make(chan int) // 无缓冲通道
ch = make(chan int, 0) // 无缓冲通道
ch = make(chan int, 3) // 容量为3的通道

8.4.1 无缓冲通道

无缓冲通道上的发送操作将会阻塞,直到另一个goroutine在对应的通道上执行接收操作,这时值传送完成,两个goroutine都可以继续执行。相反,如果接收操作先执行,接收方goroutine将阻塞,直到另一个goroutine在同一个通道上发送一个值。

使用无缓冲通道进行的通信导致发送和接收goroutine同步化。因此,无缓冲通道也称为同步通道。当一个值在无缓冲通道上传递时,接收值后发送方goroutine才被再次唤醒。

在讨论并发的时候,我们说x早于y发生时,并不仅仅是说x发生的时间早于y,而是说x保证在时间上先于y发生,并且其所有先前的影响,如对变量的更新,都已完成。

当x既不比y早也不比y晚时,我们说x和y并发。这并不意味着x和y一定同时发生,只说明我们不能假设它们的顺序。

8.3节中的客户端程序在主goroutine中将输入复制到服务器,这样客户端在输入流被关闭后就退出,即使还有后台goroutine在工作。为了让程序等待后台的goroutine在完成后再退出,可使用一个通道来同步两个goroutine:

go 复制代码
package main

import (
	"io"
	"log"
	"net"
	"os"
)

func main() {
	conn, err := net.Dial("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	done := make(chan struct{})
	go func() {
		io.Copy(os.Stdout, conn) // 注意:忽略错误
		log.Println("done")
		done <- struct{}{} // 指示主goroutine
	}()
	mustCopy(conn, os.Stdin)
	conn.Close()
	<-done // 等待后台goroutine完成
}

func mustCopy(dst io.Writer, src io.Reader) {
	if _, err := io.Copy(dst, src); err != nil {
		log.Fatal(err)
	}
}

当用户关闭标准输入流时,mustCopy函数返回,主goroutine调用conn.Close()来关闭两端网络连接。关闭写半边的连接会导致服务器看到EOF。关闭读半边的连接导致后台goroutine调用io.Copy返回特定的错误信息"read from closed connection",这也是为什么我们不用错误日志。

在go调用的字面量函数返回前,它会记录一条消息,然后发送一个值到done通道。主goroutine在退出前一直等待,直到它接收到这个值。最终效果是程序总是在退出前记录"done"消息。

通过通道发送消息有两个重要的方面需要考虑。每一条消息有一个值,但有时候通信本身以及通信发生的时间也很重要。当我们强调这方面的时候,把消息叫做事件(event)。当事件没有携带额外的信息时,它单纯地只是进行同步。我们通过使用一个struct{}元素类型的通道来强调它,尽管通常使用bool或int类型的通道来做同样的事情,因为done <- 1done <- struct{}{}要短。

8.4.2 管道

通道可以用来连接goroutine,这样一个的输出是另一个的输入。这个叫管道(pipeline)。下面的程序由三个goroutine组成,它们被两个通道连接起来:

第一个goroutine是counter,产生一个整数数列,然后通过一个管道发送给第二个goroutine(叫square),计算数值的平方,然后奖结果通过另个一个通道发送给第三个goroutine(叫printer),接收值并输出它们。为了简化例子,我们特意选择了非常简单的函数。

go 复制代码
package main

import "fmt"

func main() {
	naturals := make(chan int)
	squares := make(chan int)

	// counter
	go func() {
		for x := 0; ; x++ {
			naturals <- x
		}
	}()

	// squarer
	go func() {
		for {
			x := <-naturals
			squares <- x * x
		}
	}()

	// printer(在主goroutine中)
	for {
		fmt.Println(<-squares)
	}
}

正如所期望的那样,程序输出无限的平方序列。像这样的管道出现在长期运行的服务器程序中,其中通道用于在整个生命期内进行通信。如果要通过管道发送有限的数字怎么办?发送方如果知道数据已经发送完毕,就可以告诉接收者所在goroutine停止等待,这可以通过调用内置的close函数来关闭通道来实现。

在通道关闭后,任何后续的发送操作都将导致应用崩溃。当关闭的管道被读完(即最后一个发送的值被接收)后,所有后续的接收操作可以进行,但收到的都是零值。关闭naturals管道将导致计算平方的循环快速运转,并将结果0传递给printer goroutine。

没有直接的方式来判断通道是否关闭,但接收操作有一个产生两个结果的变种:一个是接收到的通道元素,一个是布尔值(按惯例称其为ok),它为true代表接收成功,false表示当前的接收操作在一个关闭且已经读完的通道上。利用它,我们可以修改square的循环,当naturals通道读完以后,关闭squares通道。

go 复制代码
// squarer
go func() {
    for {
        x, ok := <-naturals
        if !ok {
            break // 通道关闭且已读完
        }
        squares <- x * x
    }
    close(squares)
}()

因为以上语法比较笨拙,而模式又比较通用,所以也提供了range循环语法以在通道上迭代。这个语法可以更方便地接收通道中的值,接收完最后一个值后会循环结束。

下面的管道中,当counter goroutine在发送完100个元素后结束循环时,它关闭naturals通道,导致squarer结束循环并关闭squares通道(在更复杂的程序中,将counter和squarer的goroutine的close调用在一开始就defer是合理的)。最终,主goroutine结束,然后程序退出。

go 复制代码
package main

import "fmt"

func main() {
	naturals := make(chan int)
	squares := make(chan int)

	// counter
	go func() {
		for x := 0; x < 100; x++ {
			naturals <- x
		}
		close(naturals)
	}()

	// squarer
	go func() {
		for x := range naturals {
			squares <- x * x
		}
		close(squares)
	}()

	// printer(在主goroutine中)
	for x := range squares {
		fmt.Println(x)
	}
}

在发送完数据后,关闭通道不是必需的。只有在通知接收方goroutine所有的数据都发送完毕时才需要关闭通道。通道也可以通过垃圾回收器根据它是否可以访问来决定是否回收它,而不是根据它是否关闭(不要将通道的close和文件的close相混淆,对打开的文件调用close是非常重要的)。

试图关闭一个已经关闭的通道会导致宕机,就像关闭一个空通道一样。关闭通道还可以作为一个广播机制,将在8.9节进行讨论。

8.4.3 单向通道类型

随着程序规模的增长,将大型函数分解为较小的部分是很自然的。之前的例子中使用了三个goroutine,两个通道用来通信,它们都是main的局部变量。程序自然划分为三个函数:

go 复制代码
func counter(out chan int)
func squarer(out, in chan int)
func printer(in chan int)

squarer函数处于管道的中间,使用两个参数:输入通道和输出通道。尽管名称in和out传达了这种意图,但没有什么能够阻止发送数据到in或从out接收数据。

这种安排很典型。当通道作为函数参数提供时,几乎总是有意将其专门用于发送或专门用于接收。

为了文档化这种意图且防止误用,Go提供了单向通道类型,仅能发送或仅能接收。类型chan<- int是一个只能发送的通道,<-chan int是只能接收的通道。违反这个原则会在编译时被检测出来。

因为close操作用于说明没有数据要发送了,因此它只能在发送方才能调用,所以试图关闭一个仅能接收的通道在编译时会报错。

将上例改为使用单向通道类型:

go 复制代码
package main

import "fmt"

func counter(out chan<- int) {
	for x := 0; x < 100; x++ {
		out <- x
	}
	close(out)
}

func squarer(in <-chan int, out chan<- int) {
	for v := range in {
		out <- v * v
	}
	close(out)
}

func printer(in <-chan int) {
	for v := range in {
		fmt.Println(v)
	}
}

func main() {
	naturals := make(chan int)
	squares := make(chan int)

	go counter(naturals)
	go squarer(naturals, squares)
	printer(squares)
}

counter(naturals)的调用隐式地将chan int类型转化为chan<- int类型。调用printer(squares)做了类似转变。在赋值操作中将双向通道转换为单向通道是允许的,但反过来是不行的。有一个像chan<- int的单向通道,是没有办法通过它获取到引用同一个数据结构的chan int数据类型的。

8.4.4 缓冲通道

缓冲通道有一个元素队列,队列的最大长度在创建的时候通过make的容量参数来设置。下面的语句创建一个可以容纳三个字符串的缓冲通道。

go 复制代码
ch = make(chan string, 3)

缓冲通道上的发送操作在队列的尾部插入一个元素,接收操作从队列的头部移除一个元素。如果通道满了,发送操作会阻塞所在的goroutine直到另一个goroutine对它进行接收操作来留出可用空间。如果通道是空的,执行接收操作的goroutine阻塞,直到另一个goroutine在通道上发送数据。

可以在当前通道上无阻塞地发送三个值:

go 复制代码
// 发送的值是字符串,字符串的长度没有要求,可以发多于一个字符的字符串
ch <- "A"
ch <- "B"
ch <- "C"

这时,通道是满的,如下所示,第四个发送语句将会阻塞。

如果接收一个值:

go 复制代码
fmt.Println(<-ch) // "A"

此时通道既不空也不满,如下图所示,这时接收或发送操作都不会阻塞。通过这个方式让发送和接收的goroutine解耦。

有时程序需要知道通道缓冲区的容量,可调用内置的cap函数获取它:

go 复制代码
fmt.Println(cap(ch)) // "3"

使用内置的len函数时,可以获取当前通道内的元素个数。由于在并发程序中这个信息会很快过时,所以它的价值很低,在故障诊断或性能优化期间,它可能很有用。

go 复制代码
fmt.Println(len(ch)) // "2"

通过接下来的两次接收操作,通道又变空了,第四次接收会阻塞:

go 复制代码
fmt.Println(<-ch) // "B"
fmt.Println(<-ch) // "C"

这个例子中,发送和接收操作都由同一个goroutine执行,但在真实的程序中通常由不同的goroutine执行。因为语法简单,新手有时候粗暴地将缓冲通道当做队列在单个goroutine中使用,这是错误的。通道和goroutine的调度深度关联,如果没有另一个goroutine从通道进行接收,发送者------甚至整个程序------都有可能永远被阻塞。如果你只需要一个简单的队列,可以使用slice来实现。

下面的示例展示了对带缓冲通道的应用。它向三个镜像服务器(等效但在不同地理位置的服务器)在不同goroutine中发出并行请求。在每个发出请求的goroutine中,都将服务器的响应发送到带缓冲的通道,然后主goroutine接收并仅返回第一个响应,即最快到达的响应。因此,mirroredQuery在两个较慢的服务器响应之前就返回了结果(顺便说一下,像本例中这样,几个goroutine同时向同一个通道发送值,或从同一个通道接收几个goroutine发送的值,是很常见的)。

go 复制代码
func mirroredQuery() string {
    responses := make(chan string, 3)
    go func() { responses <- request("asia.gopl.io") }()
    go func() { responses <- request("europe.gopl.io") }()
    go func() { responses <- request("americas.gopl.io") }()
    return <-responses // return the quickest response
}

func request(hostname string) (response string) { /* ... */ }

如果使用无缓冲通道,两个较慢的goroutine将被阻塞,因为它们发送响应结果到通道的时候没有goroutine来接收。这个情况叫做goroutine泄漏,它属于一个bug。不像回收变量,泄漏的goroutine不会自动回收。

无缓冲和缓冲通道的选择、缓冲通道容量大小的选择,都会对程序的正确性产生影响。无缓冲通道提供强同步保障,因为每一次发送都需要一次对应的接收同步;对于缓冲通道,这些操作是解耦的。如果我们知道要发送的值数量的上限,通常会创建一个容量为上限值的通道,并在接收到第一个值之前进行所有发送操作。

通道的缓冲也可能影响程序的性能。想象蛋糕店里的三个厨师,在生产线上,一个烤,一个加糖衣,一个雕刻。在空间比较小的厨房,每个厨师完成一步后,必须等待下一个厨师准备好接受它;这个场景类似于使用无缓冲通道来通信。

如果在厨师之间有可以放一个蛋糕的位置,一个厨师可以将做好的蛋糕放到这里,然后立即开始制作下一个,这类似于使用容量为1的缓冲通道。只要厨师们以大致相同的速度工作,这种方式可以平滑地消除它们各自速度上的短暂差异。厨师之间的空间越大------即缓冲区越大------就可以平滑地消除它们速度上更大的短暂变化,而不会导致生产线停滞,就像当一个厨师稍作休息,然后匆忙赶上时那样。

另一方面,如果生产线的上游持续比下游快,缓冲区满的时间占大多数。如果后续的流程更快,缓冲区通常是空的。这时缓冲区的存在是没价值的。

组装流水线是对通道和goroutine的合适的比喻。例如,如果第二段更复杂,第二段只有一个厨师可能跟不上第一个厨师的供应,或者跟不上第三个厨师的需求。为了解决这个问题,我们可以雇佣另一个厨师来帮助第二段流程,独立地执行同样的任务。这个类似于创建另一个goroutine,然后使用同一个通道来通信。

这里没有空间来展示细节,但gopl.io/ch8/cake包模拟蛋糕店,并且有几个参数可以调节。它包含了上面描述场景的一些性能基准参照(参考11.4节)。

8.5 并行循环

本节探讨一些通用的并行模式,来并行执行循环的所有迭代。考虑生成一批全尺寸图像的缩略图问题。gopl.io/ch8/thumbnail包提供ImageFile函数,它可以缩放单个图像。这里不展示它的细节,它可从gopl.io进行下载。

go 复制代码
package thumbnail

// ImageFile从infile中读取一幅图像并把它的缩略图写入同一目录中
// 它返回生成的文件名,比如"foo.thumb.jpg"
func ImageFile(infile string) (string, error)

下面的程序在一个图像文件名字列表上进行循环,然后给每一个图像生成一幅缩略图:

go 复制代码
// makeThumbnails生成指定文件的缩略图
func makeThumbnails(filenames []string) {
    for _, f := range filenames {
        if _, err := thumbnail.ImageFile(f); err != nil {
            log.Println(err)
        }
    }
}

很明显,处理文件的顺序没有关系,因为每一个缩放操作都相互独立。像这样由一些完全独立的子问题组成的问题称为高度并行。高度并行的问题是最容易实现并行的,有许多并行机制来实现线性扩展。

让我们并行执行所有这些操作,从而隐藏文件I/O的延迟,并利用多个CPU进行图像缩放计算。我们第一次尝试编写并发版本时只需添加一个go关键字。我们暂时忽略错误,并稍后处理它们。

go 复制代码
// NOTE: incorrect!
func makeThumbnails2(filenames []string) {
    for _, f := range filenames {
        go thumbname.ImageFile(f) // NOTE: ignoring errors
    }
}

这一版在没有完成想要完成的事情前就返回了。它启动了所有goroutine,每个文件一个,但没有等它们执行完毕。

在Go中没有直接的方法来等待一个goroutine完成,但我们可以改变内部goroutine,使其完成后在共享通道上发送事件,以将其完成情况报告给外部goroutine。由于我们知道内部goroutine的数量正好是len(filenames),因此外部goroutine只需计数相同数量的事件,达到目标数量后再返回。

go 复制代码
// makeThumbnails3并行生成指定文件的缩略图
func makeTunmbnails3(filenames []string) {
    ch := make(chan struct{})
    for _, f := range filenames {
        go func(f string) {
            thumbnail.ImageFile(f) // 注意:此处忽略了可能的错误
            ch <- struct{}{}
        }(f)
    }
    // 等待goroutine完成
    for range filenames {
        <-ch
    }
}

注意,以上代码中的字面量函数有一个string参数,而不是直接使用循环变量f:

go 复制代码
for _, f := range filenames {
    // 与上例不同,此处func没有参数
    go func() {
        thumbnail.ImageFile(f) // 注意:不正确
        // ...
    }
}

回想5.6.1节描述的在内部匿名函数中获取循环变量的问题。以上代码中的f被所有匿名函数共享并且被后续的迭代更新。新的goroutine执行以上字面量函数时,for循环可能已经更新f,并且开始另一个迭代;或者已经完全结束,从而所有goroutine读取f的值时,看到的都是slice的最后一个元素。通过为字面量函数添加参数,可以确保当go语句执行时,使用的是当前迭代时的值。

如果工作goroutine在调用thumbnail.ImageFile时无法创建一个文件,则thumbnail.ImageFile函数会返回一个错误。下一个版本的makeThumbnails会返回第一个从通道中接收到的错误:

go 复制代码
// makeThumbnails4为指定文件并行地生成缩略图
// 如果任何步骤出错它返回一个错误
func makeThumbnails4(filenames []string) error {
    errors := make(chan error)
    
    for _, f := range filenames {
        go func(f string) {
            _, err := thumbnail.ImageFile(f)
            errors <- err
        }(f)
    }
    for range filenames {
        if err := <-errors; err != nil {
            return err // 注意:不正确,goroutine泄露
        }
    }
    return nil
}

这个函数有一个缺陷,当遇到第一个非nil错误时,它将错误返回给调用者,这样就没有goroutine继续从errors通道上接收。之后每个现存的工作goroutine都会阻塞在此通道的发送操作上,永不终止。这种情况下goroutine泄漏(参考8.4.4节)可能导致整个程序卡柱或者系统内存耗尽。

最简单的方案是使用一个有足够容量的缓冲通道,这样就不会有工作goroutine阻塞在发送操作上(另一个解决方案是在主goroutine返回第一个错误的同时,创建另一个goroutine来读完通道)。

下个版本的makeThumbnails使用一个缓冲通道来返回生成的图像文件的名称和错误信息:

go 复制代码
// makeThumbnails5为指定的文件列表并行地生成缩略图
// 它以任意顺序返回生成的文件名
// 如果任何步骤出错就返回一个错误
func makeThumbnails5(filename []string) (thumbfiles []string, err error) {
    type item struct {
        thumbfile string
        err       error
    }
    
    ch := make(chan item, len(filenames))
    for _, f := range filenames {
        go func(f string) {
            var it item
            it.thumbfile, it.err = thumbnail.ImageFile(f)
            ch <- it
        }(f)
    }
    
    for range filenames {
        it := <-ch
        for it.err != nil {
            return nil, it.err
        }
        thumbfiles = append(thumbfiles, it.thumbfile)
    }
    return thumbfiles, nil
}

下面是makeThumbnails的终极版本,它返回新文件占用的总字节数。不像前一个版本,它不使用slice接收文件名,而是借助一个字符串通道,这种情况下我们不能预测迭代的次数。

为了直到什么时候最后一个goroutine结束(它不一定是最后启动的),需要在每个goroutine启动前递增计数,在每个goroutine结束时递减计数。这需要一个特殊类型的计数器,它可以被多个goroutine安全地操作,并提供一种等待它变为0的方法。这种计数器类型被称为 sync.WaitGroup,下面的代码显示了如何使用它:

go 复制代码
// makeThumbnails6为从通道接收的每个文件生成缩略图
// 它返回其生成的文件占用的字节数
func makeThumbnails6(filenames <-chan string) int64 {
    sizes := make(chan int64)
    var wg sync.WaitGroup // 工作goroutine的个数
    for f := range filenames {
        wg.Add(1)
        // worker
        go func(f string) {
            defer wg.Done()
            thumb, err := thumbnail.ImageFile(f)
            if err != nil {
                log.Println(err)
                return
            }
            info, _ := os.Stat(thumb) // 可以忽略错误
            size <- info.Size()
        }(f)
    }
    // closer
    go func() {
        wg.Wait()
        close(sizes)
    }()
    var total int64
    for size := range sizes {
        total += size
    }
    return total
}

注意Add和Done方法的不对称性。Add递增计数器,它必须在goroutine开始之前执行,而不能在其中调用;否则我们无法确保Add在closer goroutine调用Wait之前发生。另外,Add有一个参数,但Done没有,它等价于Add(-1)。使用defer来确保即使在错误情况下也会递减计数器。上面代码的结构是一种常见和惯用的模式,用于在我们不知道迭代次数时并行循环。

sizes通道将每个文件大小传回主goroutine,主goroutine使用range循环接收它们并计算它们的总和。请注意我们创建了一个closer goroutine,closer goroutine在关闭sizes通道之前等待workers完成。这两个操作(等待和关闭),必须与sizes循环并发执行。如果不这样做,让我们考虑替代方案:如果等待操作在循环之前串行放置在主goroutine中,等待操作将永远不会结束(因为对于工作goroutine来说,只有主goroutine接收它发送的消息后,工作goroutine才会递减计数器,此时等待操作和下面的接收操作发生了死锁);如果在循环之后串行放置,则等待操作将是无法访问到的,因为通道不会被关闭,循环将永远不会终止。

图8-5说明makeThumbnails6函数中的事件序列。垂直线表示goroutine。细片段表示休眠,粗片段表示活动。斜箭头表示goroutine通过事件进行了同步。时间从上向下流动。注意,主goroutine把大多数时间花在range循环休眠上,等待工作goroutine发送值或等待closer来关闭通道:

8.6 例子:并发的Web爬虫

在5.6节中,我们制作了一个简单的网页爬虫,它以广度优先的顺序来搜索网页的链接图。这一节中,我们将使其并发,这样并发独立调用的crawl的可以利用网络中可用的I/O并行性。crawl函数还是gopl.io/ch5/findlinks3中的:

go 复制代码
func crawl(url string) []string {
    fmt.Println(url)
    list, err := links.Extract(url)
    if err != nil {
        log.Print(err)
    }
    return list
}

main函数类似于breadthFirst(参考5.6节)。与以前一样,工作列表记录需要处理的项目队列,每个项目都是要爬取的URL列表,但这次,我们不再使用切片来表示项目队列,而是使用一个通道。每次对crawl的调用都在其自己的goroutine中发生,并将其发现的链接发送回工作列表。

go 复制代码
func main() {
    worklist := make(chan []string)

    // 从命令行参数开始
    go func() { worklist <- os.Args[1:] }()
    
    // 并发爬取Web
    seen := make(map[string]bool)
    for list := range worklist {
        for _, link := range list {
            if !seen[link] {
                seen[link] = true
                go func(link string) {
                    worklist <- crawl(link)
                }(link)
            }
        }
    }
}

注意,以上代码中goroutine的参数是显式的link,以避免5.6.1节第一次看到的循环变量捕获问题。要注意,发送给任务列表的命令行参数必须创建一个goroutine进行,以避免死锁(此例中,如果在主goroutine中将命令行参数发送给任务列表,由于是不带缓冲的通道,因此发送操作会阻塞到读端读任务列表,而读任务列表在发送操作结束后,形成死锁)。另一种解决方案是使用带缓冲的通道。

这个爬虫高度并发,输出巨量URL。但它有两个问题,第一个问题是在执行若干秒后,会出现错误日志:

最初的错误消息是对可靠域名的DNS查找失败,这是意料之外的错误。随后的错误消息揭示了原因:程序一次创建了太多的网络连接,达到了进程的打开文件数限制,从而导致诸如DNS查找和net.Dial调用等操作的失败。

程序的并行性过高。无限并行性不是一个好主意,因为系统总是存在限制因素,比如对于计算密集型工作来说,是CPU核心的数量;对于本地磁盘I/O操作来说,是磁盘的磁头和磁盘转速;对于网络带宽来说,是网络的带宽,用于流媒体下载;或者是网络服务的服务能力。解决方案是限制对资源访问并行性,直到达到可用的并行性水平。在我们的例子中,一个简单的方法是确保一次不超过n个对links.Extract的调用处于活动状态,其中n小于文件描述符限制,比如说20。这类似于拥挤的夜总会在有其他客人离开时才允许新客人进入。

我们可以使用容量为n的带缓冲通道来限制并行性,以模拟一种称为计数信号量的并发原语。在概念上,通道缓冲区中的每个空槽都代表着一个令牌,使持有者有权继续进行。向通道发送一个值相当于获取一个令牌,从通道接收一个值相当于释放一个令牌,从而创建一个新的空槽。这确保了最多n个发送可以在没有接收的情况下发生。由于通道元素的类型不重要,我们将使用struct{},其占用的空间大小为零。

让我们重写crawl函数,以便用获取和释放令牌的动作来包围调用links.Extract的部分,从而确保最多同时有20个对它的调用处于活动状态。将信号量操作尽可能地与它要限制的操作紧密结合在一起是一个良好的实践。

go 复制代码
// 令牌是一个计数信号量
// 确保并发请求限制在20个以内
var tokens = make(chan struct{}, 20)

func crawl(url string) []string {
    fmt.Println(url)
    tokens <- struct{}{} // 获取令牌
    list, err := links.Extract(url)
    <-tokens // 释放令牌
    
    if err != nil {
        log.Print(err)
    }
    return list
}

第二个问题是,即使程序已经发现了从初始URL可达的所有链接,它也永远不会终止。为了让程序终止,我们需要在工作列表为空且没有爬虫goroutine处于活动状态时跳出主循环:

go 复制代码
func main() {
    worklist := make(chan []string)
    var n int // 等待发送到任务列表的数量
    
    // 从命令行参数开始
    n++
    go func() { worklist <- os.Args[1:] }()
    
    // 并发爬取Web
    seen := make(map[string]bool)
    for ; n > 0; n-- {
        list := <-worklist
        for _, link := range list {
            if !seen[link] {
                seen[link] = true
                n++
                go func(link string) {
                    worklist <- crawl(link)
                }(link)
            }
        }
    }
}

这个版本中,计数器n跟踪发送到任务列表中的任务个数。每次一个条目被发送到任务列表时,就递增变量n,第一次递增是在发送命令行参数之前,第二次递增是在每次启动一个新的爬取goroutine的时候。主循环从n减到0,这时再没有任务需要完成。

现在,并发爬虫的速度大约比5.6节中串行的广度优先版本快20倍(我们限定了最多有20个爬取goroutine)。

以下程序是解决过度并发问题的替代方案。它使用最初的crawl函数,它没有计数信号量,但是通过20个长期存活的爬虫goroutine来调用它,这样确保最多只有20个HTTP请求并发执行:

go 复制代码
func main() {
    worklist := make(chan []string) // 可能有重复的URL列表
    unseenLinks := make(chan string) // 去重后的URL列表
    
    // 向任务列表中添加命令行参数
    go func() { worklist <- os.Args[1:] }()
    
    // 创建20个爬虫goroutine来获取每个未处理过的链接
    for i := 0; i < 20; i++ {
        go func() {
            for link := range unseenLinks {
                foundLinks := crawl(link)
                go func() { worklist <- foundLinks }()
            }
        }()
    }
    
    // 主goroutine对URL列表进行去重
    // 并把没有爬取过的条目发送给爬虫程序
    seen := make(map[string]bool)
    for list := range worklist {
        for _, link := range list {
            if !seen[link] {
                seen[link] = true
                unseenLinks <- link
            }
        }
    }
}

爬虫goroutine使用同一个通道unseenLinks进行接收。主goroutine负责对从任务列表接收到的条目进行去重,然后发送每一个没有爬取过的条目到unseenLinks通道,然后被爬虫goroutine接收。

seen map被限制在主goroutine里面,因为它仅仅需要被主goroutine访问。与其他形式的信息隐藏一样,范围限制有助于程序的正确性。例如,局部变量不能在声明它的函数之外通过名字引用;没有从函数中逃逸(见2.3.4节)的变量不能从函数外面访问;一个对象的封装域智能杯对象自己的方法访问。这些场景中,信息隐藏可防止程序不同部分之间不经意的交互。

8.7 使用select多路复用

以下程序对火箭发射进行倒计时。time.Tick函数返回一个通道,它定期发送事件,像一个节拍器。每个事件的值是一个时间戳:

go 复制代码
func main() {
    fmt.Println("Commencing countdown.")
    tick := time.Tick(1 * time.Second)
    for countdown := 10; countdown > 0; countdown-- {
        fmt.Println(countdown)
        <-tick
    }
    launch()
}

我们增加一个功能,在倒计时时,可以按下回车键来取消发射过程。第一步,启动一个goroutine试图从标准输入读取字符,如果成功,发送一个值到abort通道:

go 复制代码
abort := make(chan struct{})
go func() {
    os.Stdin.Read(make([]byte, 1)) // 读取单个字节
    abort <- struct{}{}
}()

现在,倒计时循环的每次迭代都需要等待两个通道中的事件之一到达:如果一切正常(NASA术语中的"正常"),则等待tick通道的事件,如果出现"异常"(从abort通道收到消息),则等待中止事件。我们不能只从单个通道接收,因为无论我们先尝试哪个操作,都会一直阻塞直到收到消息。我们需要对这些操作进行多路复用,为此,我们需要使用select语句。

go 复制代码
select {
case <-ch1:
    // ...
case x := <-ch2:
    // ...use x...
case ch3 <- y:
    // ...
default:
    // ...
}

上面展示的是select语句的通用形式。像switch语句一样,它有一系列的case和一个可选的默认分支。每一个case指定了一个通信操作(在某个通道上发送或接收操作)和一个关联的语句块。接收表达式可以单独出现,就像第一个case中一样,或者在短变量声明中出现,就像第二个case中一样;第二种形式允许引用接收到的值。

select语句会等待直到某个case中的通信操作准备就绪。然后它执行该通信操作并执行该case关联的语句块;其他通信操作则不会发生。一个没有case的select语句,即select{},会永远等待。

让我们回到我们的火箭发射程序。time.After函数立即返回一个通道,并启动一个新的goroutine,在指定的时间后在该通道上发送一个值。下面的select语句等待第一个到达的事件,要么是中止事件,要么是表示已经过去10秒的事件。如果10秒钟过去了而没有中止事件发生,发射就会继续进行。

go 复制代码
func main() {
    // 创建abort通道
    fmt.Println("Commencing countdown. Press return to abort.")
    select {
    case <-time.After(10 * time.Second):
        // 不执行任何操作
    case <-abort:
        fmt.Println("Launch aborted!")
        return
    }
    launch()
}

下面的示例更加微妙。通道ch的缓冲区大小为1,它交替地为空然后为满,因此只有一个case可以继续,要么是当i为偶数时的发送操作,要么是当i为奇数时的接收操作。它总是打印0 2 4 6 8。

go 复制代码
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
    select {
    case x := <-ch:
        fmt.Println(x) // "0" "2" "4" "6" "8"
    case ch <- i:
    }
}

如果有多个case准备就绪,select会随机选择其中一个,这确保了每个通道被选择的机会相等。增加上例的缓冲区大小会使其输出变得不确定,因为当缓冲区既不满也不为空时,select语句像是抛了一个硬币,随机选择一个case。

接下来修改我们的发射程序来打印倒计时。下面的select语句导致循环的每次迭代等待最多1秒钟的时间来等待中止事件,而不是上面的十秒。

go 复制代码
func main() {
    // ...create abort channel...
    fmt.Println("Commencing countdown. Press return to abort.")
    tick := time.Tick(1 * time.Second)
    for countdown := 10; countdown > 0; countdown-- {
        fmt.Println(countdown)
        select {
        case <-tick:
            // Do nothing.
        case <-abort:
            fmt.Println("Launch aborted!")
            return
        }
    }
    launch()
}

time.Tick函数的行为就像它创建了一个goroutine,在循环中调用time.Sleep,并在每次唤醒时发送一个事件。当上例函数返回时,它不再接收来自tick的事件,但是tick的goroutine仍然存在,徒劳地尝试在一个没有goroutine接收的通道上发送事件------这是一个goroutine泄漏(8.4.4)。

Tick函数很方便,但只有在应用程序的整个生命周期内都需要ticks时才适合使用。否则,我们应该使用以下模式:

go 复制代码
ticker := time.NewTicker(1 * time.Second)
<-ticker.C // receive from the ticker's channel
ticker.Stop() // cause the ticker's goroutine to terminate

有时我们想要尝试在一个通道上发送或接收,但是如果通道还没有准备好,我们希望避免阻塞------这就是非阻塞通信。select语句也可以做到这一点。select语句可能有一个default子句,它指定了当没有其他通信可以立即进行时该如何处理。

下面的select语句从abort通道接收一个值(如果有值可接收的话);否则它什么也不做。这是一个非阻塞接收操作;反复执行对通道的操作被称为轮询一个通道。

go 复制代码
select {
case <-abort:
    fmt.Printf("Launch aborted!\n")
    return
default:
    // do nothing
}

通道的零值是nil。也许令人惊讶的是,nil通道有时会很有用。因为在nil通道上的发送和接收操作会永远阻塞,所以在select语句中通道为nil的case永远不会被选中。这让我们可以使用nil来启用或禁用一些case。我们将在下一节中看到一个例子。

8.8 例子:并发目录遍历

在本节中,我们将构建一个程序,类似于Unix du命令,报告命令行中指定的一个或多个目录的磁盘使用情况。大部分工作由下面的walkDir函数完成,该函数使用我们自己编写的dirents函数枚举目录dir的条目。

go 复制代码
// walkDir recursively walks the file tree rooted at dir
// and sends the size of each found file on fileSizes.
func walkDir(dir string, fileSizes chan<- int64) {
    for _, entry := range dirents(dir) {
        if entry.IsDir() {
            // filepath.Join函数根据当前操作系统的路径分隔符来拼接路径
            subdir := filepath.Join(dir, entry.Name())
            walkDir(subdir, fileSizes)
        } else {
            fileSizes <- entry.Size()
        }
    }
}

// dirents returns the entries of directory dir.
func dirents(dir string) []os.FileInfo {
    entries, err := ioutil.ReadDir(dir)
    if err != nil {
        fmt.Fprintf(os.Stderr, "du1: %v\n", err)
        return nil
    }
    return entries
}

ioutil.ReadDir函数返回一个os.FileInfo切片,它包含了与调用os.Stat返回单个文件相同的信息。对于每个子目录,walkDir递归地调用自身,并对于每个文件,walkDir会在fileSizes通道上发送一条消息。该消息是文件的大小,以字节为单位。

主函数如下所示,使用了两个goroutine。后台goroutine为命令行中指定的每个目录调用walkDir,最后关闭fileSizes通道。主goroutine计算从通道接收到的文件大小的总和,最后打印总和。

go 复制代码
// du1计算目录中文件占用的磁盘空间大小
package main

import (
	"flag"
	"fmt"
	"io/ioutil"
	"os"
	"path/filepath"
)

func main() {
	// 确定初始目录
	flag.Parse()
	// roots是未被解析为标志的参数
	roots := flag.Args()
	if len(roots) == 0 {
		roots = []string{"."}
	}

	// 遍历文件树
	fileSizes := make(chan int64)
	go func() {
		for _, root := range roots {
			walkDir(root, fileSizes)
		}
		close(fileSizes)
	}()

	// 输出结果
	var nfiles, nbytes int64
	for size := range fileSizes {
		nfiles++
		nbytes += size
	}
	printDiskUsage(nfiles, nbytes)
}

func printDiskUsage(nfiles, nbytes int64) {
	fmt.Printf("%d files  %.1f GB\n", nfiles, float64(nbytes)/1e9)
}

运行以上程序,输出结果前会等待很长时间:

如果程序让我们了解其进度会更好。然而,简单地将printDiskUsage调用移入循环会导致它打印过多行的输出。

下面的du程序变种会定期打印总计,但只有在指定了-v标志时才会这样做,因为并非所有用户都希望看到进度消息。循环遍历根目录的后台goroutine保持不变。现在,主goroutine使用一个ticker每500毫秒生成一个事件,并使用select语句等待文件大小消息或tick事件。如果没有指定-v标志,则tick通道为nil,从而在select语句中的tick通道相关的case被禁用。

go 复制代码
var verbose = flag.Bool("v", false, "show verbose progress messages")

func main() {
    // ...启动后台goroutine...
    
    // 定期输出结果
    var tick <-chan time.Time
    if *verbose {
        tick = time.Tick(500 * time.Millisecond)
    }
    var nfiles, nbytes int64

// go中, label可以放在循环的上一行,如果有多重循环,可以直接跳出到最外层
loop:
    for {
        select {
        case size, ok := <-fileSizes:
            if !ok {
                break loop // fileSizes关闭
            }
            nfiles++
            nbytes += size
        case <-tick:
            printDiskUsage(nfiles, nbytes)
        }
    }
    printDiskUsage(nfiles, nbytes) // 最终总数
}

由于程序不再使用range循环,第一个select case必须显式地测试fileSizes通道是否已关闭,使用有两个结果的接收操作的形式。如果通道已关闭,则程序退出循环。带标签的break语句同时退出select和for循环;没有标签的break语句将只退出select,导致循环从下一次迭代开始。

程序现在会给我们一个更新流:

然而,它仍然需要很长时间才能完成。所有对walkDir的调用都可以并发进行,从而利用磁盘系统中的并行性。下面的第三个版本的du程序为每个对walkDir的调用创建一个新的goroutine。它使用sync.WaitGroup(8.5节)来计算仍然活动的walkDir调用的数量,并使用一个闭包的goroutine在计数器降至零时关闭fileSizes通道。

go 复制代码
func main() {
    // ...确定根目录...

    // 并行遍历每一个文件树
    fileSizes := make(chan int64)
    var n sync.WaitGroup
    for _, root := range roots {
        n.Add(1)
        go walkDir(root, &n, fileSizes)
    }
    go func() {
        n.Wait()
        close(fileSizes)
    }()
    // ...select循环...
}

func walkDir(dir string, n *sync.WaitGroup, fileSizes chan<- int64) {
    defer n.Done()
    for _, entry := range dirents(dir) {
        if entry.IsDir() {
            n.Add(1)
            subdir := filepath.Join(dir, entry.Name())
            go walkDir(subdir, n, fileSizes)
        } else {
            fileSizes <- entry.Size()
        }
    }
}

由于该程序在峰值时创建了成千上万个goroutine,我们必须修改dirents函数,使用一个计数信号量来防止它一次打开太多的文件,就像我们在第8.6节为网络爬虫所做的那样:

go 复制代码
// sema is a counting semaphore for limiting concurrency in dirents.
var sema = make(chan struct{}, 20)

// dirents returns the entriesof directory dir.
func dirents(dir string) []os.FileInfo {
    sema <- struct{}{} // acquire token
    defer func() { <-sema }() // release token

这个使用goroutine进行并发的版本的运行速度比之前的版本快了几倍,尽管在不同的系统上会有很大的变化。

8.9 取消

有时我们需要指示一个goroutine停止正在做的事情,例如,在为客户端执行计算的Web服务器中,客户端已经断开连接。

一个goroutine不能直接终止另一个goroutine,因为那样会使得所有共享的变量处于未定义的状态。在火箭发射程序(8.7节)中,我们在名为abort的通道上发送了一个值,倒计时的goroutine将其解释为停止自身的请求。但是如果我们需要取消两个goroutine或任意数量的goroutine呢?

一种可能的做法是在abort通道上发送与要取消的goroutine数量相同的事件。然而,如果其中一些goroutine已经自行终止,那么我们的计数将会过大,发送将会被阻塞。另一方面,如果这些goroutine已经生成了其他goroutine,我们的计数将会过小,一些goroutine依然不知道要取消。总的来说,很难知道有多少goroutine正在工作。此外,当一个goroutine从abort通道接收到一个值时,它会消耗这个值,这样其他goroutine就看不到它了。对于取消操作,我们需要的是一种可靠的机制,可以通过一个通道广播一个事件,以便许多goroutine可以在事件发生时看到它,且在事件发生后也可以看到它。

回想一下,当一个通道被关闭并且所有已发送的值都被接收之后,随后的接收操作会立即进行,产生零值。我们可以利用这一点来创建一个广播机制:不在通道上发送值,而是关闭它。

我们可以通过几个简单的更改,为上一节中的du程序添加取消功能。首先,我们创建一个取消通道,不在其上发送任何值,但其关闭表示程序应该停止当前操作。我们还定义了一个实用函数cancelled,用于检查或轮询是否要取消。

go 复制代码
var done = make(chan struct{})

func cancelled() bool {
    select {
    case <-done:
        return true
    default:
        return false
    }
}

接下来,我们创建一个goroutine,它将从标准输入(通常是终端)中读取数据。一旦读取到任何输入(例如,用户按下回车键),此goroutine通过关闭done通道进行广播,从而激活取消机制。

go 复制代码
// 当检测到输入时取消遍历
go func() {
    os.Stdin.Read(make([]byte, 1)) // 读一个字节
    close(done)
}()

现在我们需要让我们的goroutine响应取消。在主goroutine中,我们在select语句中添加了第三个case,尝试从done通道接收。如果此case被选中,函数将返回,但在返回之前,它必须首先从fileSizes通道中读取所有值,直到通道关闭。它这样做是为了确保任何对walkDir的活动调用都可以完成而不会因为无法发送到fileSizes而被阻塞。

go 复制代码
for {
    select {
    case <-done:
        // Drain fileSizes to allow existing goroutines to finish.
        for range fileSizes {
            // Do nothing.
        }
        return
    case size, ok := <-fileSizes:
        // ...
    }
}

walkDir goroutine会轮询取消状态,并在取消状态被设置时立即返回而不执行任何操作。这样做将所有在取消后创建的goroutine的行为都变成了无操作:

go 复制代码
func walkDir(dir string, n *sync.WaitGroup, fileSizes chan<- int64) {
	defer n.Done()
	if cancelled() {
		return
	}
	for _, entry := range dirents(dir) {
		// ...
	}
}

在walkDir的循环内再次轮询取消状态是有用的,可避免在取消事件发生后还会创建goroutine。取消涉及到一个权衡;通常,更快的响应需要对程序逻辑进行更多的干预性更改。确保在取消事件发生后不再执行任何昂贵的操作可能需要更新代码中的许多地方,但通常大部分收益可以通过在一些重要的地方检查取消状态来获得。

对该程序的性能分析显示,瓶颈在于在dirents函数中获取信号量令牌。下面的select语句使这个操作可以被取消,并将程序的典型取消延迟从几百毫秒减少到几十毫秒:

go 复制代码
func dirents(dir string) []os.FileInfo {
	select {
	case sema <- struct{}{}: // acquire token
	case <-done:
		return nil // cancelled
	}
	defer func() { <-sema }() // release token

	// ...read directory...
}

现在,当取消事件发生时,所有后台goroutine会快速停止,主函数返回。当然,当主函数返回时,程序就会退出,因此很难知道main函数是否成功进行了清理。在测试期间,我们可以使用一个方便的技巧:如果在取消事件发生时,主函数不是返回而是执行panic调用,那么运行时会打印程序中每个goroutine的堆栈。如果主goroutine是唯一剩下的一个,那么它已经清理了自己。但是如果还有其他goroutine保留下来,它们可能没有被正确取消,或者它们可能已经被取消,但取消操作需要一些时间;进行一些调查可能是值得的。panic转储通常包含足够的信息来区分这些情况。

8.10 例子:聊天服务器

我们将用一个聊天服务器结束这一章节,这个服务器可以让多个用户向彼此广播文本消息。这个程序中有四种类型的goroutine。主goroutine和广播器goroutine各有一个实例,对于每个客户端连接,都有一个handleConn和一个clientWriter goroutine。广播服务器是如何使用select的一个很好的例子,因为它必须响应三种不同类型的消息。

主goroutine的工作是监听并接受来自客户端的网络连接。对于每个连接,它会创建一个新的handleConn goroutine,就像我们在本章开头看到的并发回声服务器一样。

go 复制代码
func main() {
	listener, err := net.Listen("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(nil)
	}

	go broadcaster()
	for {
		conn, err := listener.Accept()
		if err != nil {
			log.Print(err)
			continue
		}
		go handleConn(conn)
	}
}

接下来是broadcaster。它的局部变量clients记录了当前连接的客户端集合。关于每个客户端记录的唯一信息是它输出消息的通道,稍后会详细介绍。

go 复制代码
type client chan<- string // 对外发送消息的通道

var (
	entering = make(chan client)
	leaving  = make(chan client)
	messages = make(chan string) // 所有接收的客户消息
)

func broadcaster() {
	clients := make(map[client]bool) // 所有连接的客户端
	for {
		select {
		case msg := <-messages:
			// 把接收到的消息广播给所有客户
			for cli := range clients {
				cli <- msg
			}

		case cli := <-entering:
			clients[cli] = true

		case cli := <-leaving:
			delete(clients, cli)
			close(cli)
		}
	}
}

broadcaster监听全局的entering和leaving通道,用于接收客户端到达和离开的通知。当它接收到这些事件之一时,它会更新客户端集合,如果事件是一个离开事件,它会关闭客户端的输出消息通道。broadcaster还监听全局的消息通道上的事件,每个客户端都会将消息发送到该通道。当broadcaster接收到这些事件之一时,它会将消息广播给每个已连接的客户端。

现在让我们来看看每个客户的goroutine。handleConn函数为其客户创建一个新的输出消息通道,并通过entering通道向broadcaster宣布此客户的到达。然后,它每从客户读取一行文本,就将一行消息发送到全局的传入消息通道,每条消息前缀是其发送者的标识。一旦从客户端没有更多可读取的内容,handleConn就会通过leaving通道宣布客户的离开,并关闭连接。

go 复制代码
func handleConn(conn net.Conn) {
	ch := make(chan string) // outgoing client messages
	go clientWriter(conn, ch)

	who := conn.RemoteAddr().String()
	ch <- "You are" + who
	messages <- who + " has arrived"
	entering <- ch

	input := bufio.NewScanner(conn)
	for input.Scan() {
		message <- who + ": " + input.Text()
	}
	// NOTE: ignoring potential errors from input.Err()

	leaving <- ch
	messages <- who + " has left"
	conn.Close()
}

func clientWriter(conn net.Conn, ch <-chan string) {
	for msg := range ch {
		fmt.Fprintln(conn, msg) // NOTE: ignoring network errors
	}
}

此外,handleConn为每个客户端创建一个clientWriter goroutine,用于接收广播到客户端输出消息通道的消息,然后将它们从管道读出再写入客户端的网络连接。当broadcaster在收到leaving通知后关闭通道时,客户端写入网络连接的程序循环终止。

下面展示了在同一台计算机上使用netcat在两个不同的窗口中进行聊天的服务器操作:

在为n个客户端托管聊天会话时,此程序运行2n+2个并发通信的goroutine,但它不需要任何显式的锁操作(9.2节)。client map被限制在单个goroutine(即broadcaster)中,因此不能同时访问。唯一由多个goroutine共享的变量是通道和net.Conn的实例,它们都是并发安全的。在下一章中,我们将更多地讨论封装、并发安全性以及在goroutine之间共享变量的影响。

相关推荐
Mephisto.java29 分钟前
【大数据学习 | Spark-Core】详解Spark的Shuffle阶段
大数据·学习·spark
南宫生34 分钟前
力扣-位运算-3【算法学习day.43】
学习·算法·leetcode
xnuscd38 分钟前
Milvus概念
数据库·学习·mysql
我想探知宇宙1 小时前
L1G1000 书生大模型全链路开源开放体系笔记
笔记·开源
神秘的土鸡1 小时前
基于预测反馈的情感分析情境学习
学习
麻瓜也要学魔法1 小时前
Linux关于vim的笔记
linux·笔记·vim
Clown952 小时前
go-zero(十) 数据缓存和Redis使用
redis·缓存·golang
HABuo2 小时前
【数据结构与算法】合并链表、链表分割、链表回文结构
c语言·开发语言·数据结构·c++·学习·算法·链表
AI完全体2 小时前
【AI日记】24.11.25 学习谷歌数据分析初级课程-第6课
学习·数据分析
码到成龚3 小时前
《数字图像处理基础》学习06-图像几何变换之最邻近插值法缩小图像
图像处理·学习