Go语言编程笔记7:goroutine和通道

Go语言编程笔记7:goroutine和通道

Go语言编程笔记7:goroutine和通道_第1张图片

图源:wallpapercave.com

goroutine

Python中并发的核心概念是协程,Go语言中类似的概念叫做goroutine。虽然两者在原理和使用方式等方面都有很大不同,但都是用于解决并发问题的核心概念。

协程(coroutine)与goroutine从名称上看就很相似。

我们知道,Python因为有全局线程锁的缘故,除了发生I/O的部分以外,大部分使用协程实现并发的时候实际上都是单线程在执行,事实上并不能挖掘多线程的全部性能,对于I/O密集型的应用的确是可以解决问题,但对于计算密集型的应用就无能为力了。

但是goroutine则不然,它更像是传统的多线程编程,在概念和功能上都与传统概念的“线程”更相似。

我们看这个示例代码:

// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan.
// License: https://creativecommons.org/licenses/by-nc-sa/4.0/

// See page 218.

// Spinner displays an animation while computing the 45th Fibonacci number.
package main

import (
	"fmt"
	"time"
)

//!+
func main() {
	go spinner(100 * time.Millisecond)
	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)
}

//!-

示例代码来自于《Go程序设计语言》。

主程序main通过调用递归函数fib来计算斐波那契数列,这是一个相当消耗时间的过程,在执行计算前,通过go spinner(100 * time.Millisecond)语句开启了一个额外的goroutine,用于在屏幕上输出一个“滚动的光标”。

这个“滚动的光标”是利用“回车符”\r来实现的,当字符终端接收到\r字符后,光标会移动到当前行的首部,并且清除当前行的内容,设定好时间间隔依次在\r后输出-\|/中的字符时,看起来就好像是一个光标在进行滚动一样。需要注意的是,只有在真正的字符终端(cmd或powershell)下才能看到这种效果,在VSC中输出结果时\r是不起作用的。

当主线程计算完毕并退出程序后,负责输出光标的额外goroutine也会被强制关闭。

实际上,goroutine可以看做是Go语言在操作系统的线程概念之上封装的一个Go线程,由Go语言自己来控制goroutine的调度和切换,以达到一个不错的多线程执行效率,而不像操作系统线程那样依赖操作系统进行调度。事实上,Go语言编写的程序,都是以goroutine为单位进行执行的,作为程序入口的main函数所在的goroutine,可以看做是主goroutine,通过主goroutine我们可以开启其它的goroutine,方式也很简单,就是示例中那样,通过go关键字:go spinner(100 * time.Millisecond)。这样可以简单地开启一个额外的goroutine,并在其中执行某个函数。此外,在主goroutine退出时,其它goroutine也会被关闭。

时钟服务器

《Go程序设计语言》中展示了一个时钟服务器作为示例:

// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan.
// License: https://creativecommons.org/licenses/by-nc-sa/4.0/

// See page 219.
//!+

// Clock1 is a TCP server that periodically writes the time.
package main

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

func main() {
	listener, err := net.Listen("tcp", ":8000")
	if err != nil {
		log.Fatal(err)
	}
	for {
		fmt.Println("server is listening...")
		conn, err := listener.Accept()
		if err != nil {
			log.Print(err) // e.g., connection aborted
			continue
		}
		fmt.Printf("get a request from %s \n", conn.RemoteAddr().String())
		handleConn(conn) // handle one connection at a time
	}
}

func handleConn(c net.Conn) {
	defer c.Close()
	for {
		_, err := io.WriteString(c, time.Now().Format("15:04:05\n"))
		if err != nil {
			return // e.g., client disconnected
		}
		time.Sleep(1 * time.Second)
	}
}

//!-

这个例子会创建一个简单的网络服务,开启一个对本机8000端口监听的TCP连接,如果有客户端访问,就会将当前时间回写到客户端进行输出。

在实际运行这个示例时,我发现有一些问题需要注意:

  • 我这里使用的是WSL中的nc工具作为TCP客户端,通过WSL访问外部Windows的网络应用并不能直接nc localhost 8000,这是不可以的,必须通过一个虚拟网络中分配给外部Windows的IP才行。可以通过cat /etc/resolv.conf命令查看该IP,详情可以阅读从 Linux(主机 IP)访问 Windows 网络应用。
  • 如果客户端需要请求的IP不是localhost127.0.0.1(比如我在WSL中的请求),则服务端的Go程序中不能指定监听地址,比如:listener, err := net.Listen("tcp", "localhost:8000")。否则服务端是不会响应客户端请求的,必须省略主机地址,比如net.Listen("tcp", ":8000"),这样就会对客户端进行响应。
  • 为了显示服务端运行情况和响应,我添加了一些显示代码。

对服务端代码编译运行后,就可以正常接收客户端请求:

❯ .\clock.exe
server is listening...
get a request from 172.23.244.190:46202
server is listening...
get a request from 172.23.244.190:46204
server is listening...
get a request from 172.23.244.190:46206
server is listening...

客户端请求的输出效果是这样的:

icexmoon@icexmoon-book:/mnt/c/Users/70748$ nc 172.23.240.1 8000
16:31:04
16:31:05
16:31:06
16:31:07
16:31:08

需要注意的是,当前服务端仅有一个主goroutine,所以一次仅能服务一个客户端,和一个客户端建立连接后就不会对其它客户端进行响应了,除非当前客户端断开连接。这点可以通过打开多个终端对服务器请求来进行验证。

当然,如果要能同时服务多个客户端,修改起来也很容易:

		go handleConn(conn) // handle one connection at a time

只要启动一个额外的goroutine来服务当前客户的请求即可,主goroutine会继续for循环,以监听其它的可能请求,具体的测试过程这里不再展示。

回声服务器

《Go程序设计语言》中还展示了一个有趣的回声服务器:

// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan.
// License: https://creativecommons.org/licenses/by-nc-sa/4.0/

// See page 223.

// Reverb1 is a TCP server that simulates an echo.
package main

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

//!+
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)
	}
	// NOTE: ignoring potential errors from input.Err()
	c.Close()
}

//!-

func main() {
	l, err := net.Listen("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	for {
		conn, err := l.Accept()
		if err != nil {
			log.Print(err) // e.g., connection aborted
			continue
		}
		go handleConn(conn)
	}
}

服务端的代码很简单,从客户端获取输入后,依次输出三条处理后的字符串,分别是全大写,原字符串,以及全小写,就像是在山谷里的回声一样。

需要搭配客户端程序使用:

// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan.
// License: https://creativecommons.org/licenses/by-nc-sa/4.0/

// See page 223.

// Netcat is a simple read/write client for TCP servers.
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负责从标准输入读取用户输入,并将信息写入TCP连接。额外启动的一个goroutinego mustCopy(os.Stdout, conn),负责从服务端读入输出,并写入到标准输出。

测试结果类似于:

❯ .\netcat.exe
hello
         HELLO
how      hello

         hello
         HOW
ss       how

         how
         SS
         ss
         ss

虽然服务端是使用单独的goroutine处理客户端的请求,这里并不存在不能同时服务多个客户端的问题,但是从上面的测试结果可以看出,在单个客户端内,如果短时间内输入多条信息,服务端必然要在处理完之前的回声后再处理当前的回声,并不会立即对你输入的信息进行回声。查看服务端代码也不难理解:

func handleConn(c net.Conn) {
	input := bufio.NewScanner(c)
	for input.Scan() {
		echo(c, input.Text(), 1*time.Second)
	}
	// NOTE: ignoring potential errors from input.Err()
	c.Close()
}

负责产生回声的echo函数和负责处理用户请求的handleConn是同一个goroutine,也就是说只能顺序地进行回声响应。

要能及时产生回声也很容易,只要:

func handleConn(c net.Conn) {
	input := bufio.NewScanner(c)
	for input.Scan() {
		go echo(c, input.Text(), 1*time.Second)
	}
	// NOTE: ignoring potential errors from input.Err()
	c.Close()
}

测试:

❯ .\netcat.exe
hello
         HELLO
h        hello
ow
         HOW
         hello
         how
ss       how

         SS
         ss
         ss

可以看到客户端输入how后服务端立即进行了响应。

通道

通道(channel)就像是goroutine之间通讯用的一个消息队列,事实上熟悉Web开发的朋友应该知道,在使用不能多线程的PHP之类的程序作为服务器应用时,对于密集计算型的服务,通常会用一个Redis之类的消息队列作为前端的消息收集装置,然后依据队列中的负载情况在服务器上启动多个Web应用来从消息队列中抓取消息进行处理。类比一下,在这里goroutine就像是服务器上启动的多个独立执行的Web应用,而通道就像是那个Redis实现的消息队列。

当然,通道和goroutine并不仅仅可以组成上述消息收集-分发处理的应用场景,通过使用通道和goroutine,可以实现更复杂的Go语言并发应用。

实际上我不喜欢“通道”这个翻译,在理念上channel更像是“频道”或者“信道”,它的核心作用是像收音机频道那样,给多个goroutine提供一个通信的工具。

要创建一个通道类型的变量,可以使用下面的语句:

	var chan1 chan int

这里chan int是具体的通道类型,chan说明这是一个通道,int说明这个通道中的元素类型为int。通道本身是引用类型,所以初始化后是nil

	if chan1 == nil {
		fmt.Println("chan1 is nil")
		// chan1 is nil
	}

map类似,也可以用make来创建一个切实可用的通道:

chan1 = make(chan int)

事实上通道是有容量的,在使用make时可以指定通道的容量:

	chan1 = make(chan int)
	chan2 := make(chan int, 3)
	fmt.Println(cap(chan1))
	// 0
	fmt.Println(cap(chan2))
	// 3

我们可以将通道的容量看做是一种“缓冲区”,所以容量是0的通道也被叫做“无缓冲通道”。

通道可以支持以下几种操作:

  • 写入

    可以通过chan1 <- 1这样的语句将数据写入通道。需要注意的是,可以向nil通道写入,这会造成永远阻塞。

  • 读取

    可以通过var num int = <-chan1这样的语句从通道读取数据。尝试从nil通道读取数据也会导致永远阻塞。

  • 关闭

    可以使用close函数关闭通道:close(chan1)。关闭通道后,读取通道将会依次获取通道内的所有元素,直到通道为空,此时再读取通道将会立即返回通道元素对应的零值。如果尝试往一个已关闭的通道写入信息,则会导致程序中断。

无缓冲通道

如果我们往一个无缓冲通道里写入信息,当前的goroutine会被阻塞,直到另一个goroutine读取该信息。当然,反过来也一样。只有顺利地完成这个对无缓冲通道的“写入、读取”行为后,相关的两个goroutine才能正常继续执行。这种操作很像是多线程编程领域的“线程同步”行为,所以无缓冲通道也叫做同步通道。我们可以利用这一特性来同步两个goroutine,比如前面作为示例的回声服务器的客户端程序:

func main() {
	conn, err := net.Dial("tcp", "localhost:8000")
	if err != nil {
		log.Fatal(err)
	}
	closeChan := make(chan struct{})
	go func() {
		io.Copy(os.Stdout, conn)
		log.Println("server print is closed")
		closeChan <- struct{}{}
	}()
	mustCopy(conn, os.Stdin)
	conn.Close()
	<-closeChan
	log.Println("user input is closed")
}

在之前的程序中,我都是粗暴地直接用ctrl+C来直接结束客户端程序,这样同样会粗暴的关闭负责从服务器输出信息到用户屏幕的goroutine。现在来考虑通过ctrl+z来关闭字符终端的输入流,此时主goroutine会退出mustCopy函数,并通过conn.Close()关闭客户端侧的TCP套接字,并退出程序。而负责打印服务端信息的goroutine在客户端侧TCP套接字关闭后,io.Copy会结束调用,并返回一个connect is closed之类的error

在这里,为了让主goroutine等待副goroutine,我们创建一个无缓冲通道,并且在主goroutine的结尾部分加上<-closeChan,从该通道读取数据。并且在副goroutine的结尾部分往该通道写入数据:closeChan <- struct{}{}。这样就可以让两个goroutine同步,保证让主goroutine一直等待到副goroutine执行完毕后再结束执行。

在这里有三点需要注意:

  • 虽然主goroutine是在conn.Close()后立即尝试读取通道,但并不能保证此时副goroutine没有走完退出io.Copy并写入通道这一整个过程,因为多线程本质上就是如此,在真正完成“线程同步”动作之前,你并不能保证哪个线程会“跑到前边”。但是我们并不需要关心这点,因为我们仅仅关心的是“主线程等待副线程执行完毕”这一结果。
  • 在同步主goroutine和副goroutine时,主goroutine读取通道,副goroutine写入通道,而不是相反,这是有意义的。因为如果是反着的话,主goroutine是可以在副goroutine读取数据后现行结束。
  • 在通道用于同步时,通道中传输的数据本身是没有什么意义的,所以通常的做法是传递相应元素的0值,在这里使用的是一个空结构的零值:struct{}{}

此外,示例中<-closeChan的写法是从通道中读取数据并丢弃,相当于_ := <-closeChan

管道

我们可以用通道将多个goroutine连接起来,这被称作是“管道”。事实上这种使用方式很像是shell中的管道符号的作用:cat xxx | grep xxx | cat -n 2。不过前者是连接多个goroutine,后者是连接多个shell程序。

package main

import "fmt"

func main() {
	chan1 := make(chan int)
	chan2 := make(chan int)
	go numbers(chan1)
	go quart(chan1, chan2)
	for {
		num, ok := <-chan2
		if !ok {
			break
		}
		fmt.Printf("%d ", num)
	}
}

func numbers(outChan chan<- int) {
	for i := 0; i < 10; i++ {
		outChan <- i + 1
	}
	close(outChan)
}

func quart(inputChan <-chan int, outChan chan<- int) {
	for {
		num, ok := <-inputChan
		if !ok {
			break
		}
		outChan <- num * num
	}
	close(outChan)
}

上面这段示例代码,用两个通道联通了三个goroutine,numbers函数运行的goroutine负责生产自然数到通道chan1quart函数运行的goroutine负责从chan1中获取自然数,并进行平方运算后输入到chan2通道,主goroutine负责从chan2通道中读取最终结果,并进行输出。

要说明的是,整个程序看上去和一个单一goroutine实现的生产平方数的程序没有太大区别,但是实际上这是一个多线程程序,三个goroutine是独自运行并且通过管道进行协作的。只不过这个示例并不能说明并发的优势而已。

此外,正常情况下我们是没法通过通道中输出的值来判断管道是否已经关闭的,因为前边说了,通道在关闭后也会依然输出元素的零值。但是我们可以通过多返回的方式来获取一个bool值来判断通道是否已经关闭:

	for {
		num, ok := <-inputChan
		if !ok {
			break
		}
		outChan <- num * num
	}

如果通道关闭,该bool值会是false

但这种写法有点繁琐,而管道读取又恨常见,所以Go提供一种更便捷的写法:

	for num := range inputChan {
		outChan <- num * num
	}
	close(outChan)

这里利用了传统的遍历语法for...range,在通道读取完毕后会自动结束遍历,两种写法是等价的。

单向通道

在上边关于管道的示例中,为了清晰地区分作为形参的通道,我们使用了单向通道形式:

func quart(inputChan <-chan int, outChan chan<- int) {

这里inputChan这个形参是一个只读通道,而outChan是一个只写通道。

此外需要注意的是,一个读写通道是可以转换为只读通道或只写通道的,但是反过来是不行的。而且close函数仅能关闭可写的通道,如果尝试关闭一个只读通道会产生程序中断。

如果不好记忆这种单向通道的定义,可以将chan看做是通道本体,<-chan就表示从通道读取,即只读通道,而chan<-则表示往通道中写入,是一个只写通道。在这基础上加上通道元素类型即可。

缓冲通道

我们利用无缓冲通道可以传递信息,对goroutine进行同步,而缓冲通道则可以将消息发送和消息接收进行解耦,它更像是传统意义上的消息队列。

缓冲通道的基本结构和队列没有区别,可以看做是一个先进先出的队列。如果容量没满,发送方可以在不阻塞地情况下给通道添加数据,直到通道被填满,此时再尝试添加数据会被阻塞。接收方从队列中获取数据的情况是,如果队列是空的,会被阻塞,直到队列中有数据,才会恢复执行,并获取。

此外,我们可以通过len函数获取当前通道中的元素数量,用cap函数获取通道的容量。

因为缓冲通道在某些情况下不会阻塞当前goroutine,所以我们可以在单个goroutine中将其作为普通队列结构来使用,比如:

package main

import "fmt"

func main() {
	cachedChan := make(chan int, 10)
	for i := 0; i < 10; i++ {
		cachedChan <- i
	}
	for num := range cachedChan {
		fmt.Printf("%d ", num)
		if len(cachedChan) <= 0 {
			break
		}
	}
	// 0 1 2 3 4 5 6 7 8 9
}

但这样使用是不正确的,因为通道的唯一用途就是多个goroutine之间的协作,如果在单个goroutine中这样使用,很容易造成某些情况下的死锁,与其这样,不如使用一个普通的数据类型作为队列,比如切片。

缓冲通道的最大用途是在某些情况下解决计算资源瓶颈的问题,比如说我们有一个生产蛋糕的流水线,流水线上有三个蛋糕师:ABC。如果他们处理蛋糕的效率完全一致,则传送带是否缓冲通道都不会有影响,但如果这三个蛋糕师需要轮流休息,或者偶尔放松一下,无缓冲通道就很影响性能了,此时另外两个蛋糕师也只能干等着。如果是缓冲通道则会好很多,至少在蛋糕师休息的时候,传送带上可能有“缓冲”的待处理蛋糕。

但这样也并非是可以解决所有问题的,比如说如果三个蛋糕师的处理速度就是不均等,那是否有缓冲都是会造成系统性能浪费或低效的,蛋糕都会在某处堆积或者某个蛋糕师经常处于空闲状态,缓冲通道本质上并不能解决这种问题。我们或许需要在某些低效处理环节添加额外的goroutine来并发处理以提高系统性能瓶颈。

并行循环

我们通常最多使用并发解决的问题其实是一类可以独立执行互不影响的问题,比如批量执行Web查询,或者批量对图片进行压缩等。这些批处理问题中,单次请求是相对独立的,并不会和其它请求互相影响,所以可以用并发来同时执行多个请求,以此提高整个程序的性能。

这里我以Python学习笔记34:使用Futures处理并发中的示例来说明,唯一的不同是会用Go语言代码替换Python代码:

package main

import (
	"encoding/json"
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/url"
	"time"
)

type RespObj struct {
	Status string `json:status`
	Result Person `json:result`
}

type Person struct {
	Name   string `json:name`
	Age    int    `json:"age,string"`
	Career string `json:career`
}

func (p Person) String() string {
	return fmt.Sprintf("Person(name:%s,age:%d,career:%s)", p.Name, p.Age, p.Career)
}

func main() {
	start := time.Now()
	var names = []string{"Han Meimei", "Brus Lee", "Jack Chen"}
	var results = make(map[string]Person)
	for _, name := range names {
		p, err := getPerson(name)
		if err != nil {
			log.Println(err)
			continue
		}
		results[name] = p
	}
	for _, result := range results {
		fmt.Println(result)
	}
	end := time.Now()
	usedTimes := end.Sub(start)
	fmt.Printf("used time is %.2fs\n", usedTimes.Seconds())
	// Person(name:Brus Lee,age:30,career:engineer)
	// Person(name:Jack Chen,age:50,career:actor)
	// Person(name:Han Meimei,age:20,career:student)
	// used time is 30.07s
}

func getPerson(name string) (p Person, err error) {
	hr, err := http.NewRequest("GET", "http://myweb.com/index.php", nil)
	params := make(url.Values)
	params.Add("name", name)
	hr.URL.RawQuery = params.Encode()
	resp, err := http.DefaultClient.Do(hr)
	if err != nil {
		return
	}
	defer resp.Body.Close()
	respText, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return
	}
	var ro RespObj
	err = json.Unmarshal(respText, &ro)
	if err != nil {
		return
	}
	if ro.Status == "success" {
		p = ro.Result
	}
	return
}

上面这个代码就是用Go改写的非并发批量查询代码,和Python代码相比需要注意这么几个方面:

  • 使用简单的http.Get请求时,指定的url中不能出现非法字符,即必须是url编码后的字符串。所以这里通过构建url.Values实例,并调用其Encode方法来生成查询字符串。更多的说明见Go语言HTTP客户端实现简述。
  • 进行Json解析时,Go的风格是构建对应结构的结构体,并通过字段附加的说明文字来进行解析,更多的说明见Go语言中处理json数据的方法。
  • 在进行时间计算时,获取到的时间是time.Time类型,该类型并不能直接使用运算符计算,而是要调用SubAdd方法,前者会返回一个time.Durine类型,后者会返回一个time.Time类型。

下面我们改写这个顺序执行的代码为并发:

type ChanResult struct {
	err error
	p   Person
}

func main() {
	start := time.Now()
	var names = []string{"Han Meimei", "Brus Lee", "Jack Chen"}
	var results = make(chan ChanResult, len(names))
	for _, name := range names {
		name := name
		go func() {
			p, err := getPerson(name)
			results <- ChanResult{err: err, p: p}
		}()
	}
	for range names {
		result := <-results
		if result.err != nil {
			log.Println(result.err)
			continue
		}
		fmt.Println(result.p)
	}
	end := time.Now()
	usedTimes := end.Sub(start)
	fmt.Printf("used time is %.2fs\n", usedTimes.Seconds())
	// Person(name:Han Meimei,age:20,career:student)
	// Person(name:Jack Chen,age:50,career:actor)
	// Person(name:Brus Lee,age:30,career:engineer)
	// used time is 10.04s
}

这里我们核心代码修改成了并发调用的方式:

	for _, name := range names {
		name := name
		go func() {
			p, err := getPerson(name)
			results <- ChanResult{err: err, p: p}
		}()
	}

需要注意的是,这里的name:=name是有意义的,生成了一个循环体内的局部变量,以确保匿名函数所在的协程引用的变量不会是变化的循环体变量。关于这点可以阅读Go语言编程笔记2:变量中变量作用域的部分。除了这么写以外,还可以:

	for _, name := range names {
		go func(name string) {
			p, err := getPerson(name)
			results <- ChanResult{err: err, p: p}
		}(name)
	}

总之就是让匿名函数能够使用一个脱离于循环的“稳定变量”。

此外,因为是使用了三个goroutine来进行HTTP查询,所以查询结果需要通过通道来返回给主goroutine,为了保存结果和可能出现的错误,这里创建了一个新的结构体ChanResult

这里主goroutine是以这样的方式收集结果:

	for range names {
		result := <-results
		if result.err != nil {
			log.Println(result.err)
			continue
		}
		fmt.Println(result.p)
	}

而非:

for result := range results{
	...
}

结果很简单,因为这里的results通道不会关闭,没有goroutine会关闭它,所以后者的代码会在读取完返回的三条数据后永远阻塞,整个程序也就无法结束。而因为我们知道只有三条查询请求,也只会返回三个查询结果,所以我们这里只要简单的遍历查询条件names并尝试从通道获取同样次数的结果数据即可。并不需要关心通道是否关闭的问题。

但这种方式是有条件的,即我们已知执行并发任务的goroutine的个数。如果我们不知道,那我们需要用另一种方式:

func main() {
	start := time.Now()
	var names = []string{"Han Meimei", "Brus Lee", "Jack Chen"}
	var results = make(chan ChanResult, len(names))
	var gNums sync.WaitGroup
	for _, name := range names {
		name := name
		gNums.Add(1)
		go func() {
			defer gNums.Done()
			p, err := getPerson(name)
			results <- ChanResult{err: err, p: p}
		}()
	}
	go func() {
		gNums.Wait()
		close(results)
	}()
	for result := range results {
		// result := <-results
		if result.err != nil {
			log.Println(result.err)
			continue
		}
		fmt.Println(result.p)
	}
	end := time.Now()
	usedTimes := end.Sub(start)
	fmt.Printf("used time is %.2fs\n", usedTimes.Seconds())
	// Person(name:Han Meimei,age:20,career:student)
	// Person(name:Jack Chen,age:50,career:actor)
	// Person(name:Brus Lee,age:30,career:engineer)
	// used time is 10.04s
}

这里使用了一个sync.WaitGroup用于记录并发任务的goroutine执行情况,在goroutine执行前,会调用gNums.Add(1)让计数增长,结束完goroutine调用后,再执行gNums.done()让计数减少。而gNums.wait()会阻塞程序,直到计数归零。

所以我们可以额外开启一个goroutine,通过在gNums.done()之后来调用close(results)关闭通道。这样在主gorouine就可以直接遍历通道了,因为通道的确会在所有并发任务完毕后被关闭。

当然,在这个示例中gNums.done()和稍后的关闭通道都是由单独的一个goroutine来完成,但是放在主goroutine中也是不影响结果的,这是因为我们使用的是缓冲通道,如果是非缓冲通道就会有问题,此时主goroutine在等待并发任务执行完毕后的计数器归零,而并发任务的goroutine却在等待缓冲通道内的数据被取走而阻塞,这变成了一个“鸡生蛋蛋生鸡”的问题。

事实上在Python的并发中我也看到过类似的“协程计数器”,不过我一时找不到出处了。

多路复用

可以使用select...case语句对通道进行多路复用,这里使用《Go程序设计语言》中的示例进行说明:

// Copyright © 2016 Alan A. A. Donovan & Brian W. Kernighan.
// License: https://creativecommons.org/licenses/by-nc-sa/4.0/

// See page 244.

// Countdown implements the countdown for a rocket launch.
package main

import (
	"fmt"
	"time"
)

//!+
func main() {
	fmt.Println("发射倒计时")
	tick := time.Tick(1 * time.Second)
	for countdown := 10; countdown > 0; countdown-- {
		fmt.Println(countdown)
		<-tick
	}
	launch()
}

//!-

func launch() {
	fmt.Println("火箭发射")
}

这是一个火箭发射倒计时程序,其中time.Tick会返回一个定时“产生”数据的通道,我们只需要读取该通道就会产生time.Sleep()那样的效果,会让程序阻塞固定的时间间隔,这样我们就可以看到火箭发射倒计时这样的效果。

如果我们要通过键盘输入字符来终止发射:

func main() {
	fmt.Println("发射倒计时")
	tick := time.Tick(1 * time.Second)
	terminate := make(chan struct{})
	go func() {
		os.Stdin.Read(make([]byte, 1))
		terminate <- struct{}{}
	}()
	for countdown := 10; countdown > 0; countdown-- {
		fmt.Println(countdown)
		select {
		case <-tick:
		case <-terminate:
			fmt.Println("发射终止")
			return
		}
	}
	launch()
}

测试这段代码需要在字符终端执行。

这里select...case中关联了两个通道,在执行select时,会检测每个通道是否可以读取或写入,如果一个通道可以执行操作且不会阻塞,则其它通道就不会被执行,如果所有通道都不能执行,就会被整个阻塞,直到某个通道可以执行操作。

此外,selectswitch不同,后者是顺序执行,前者为了避免某个通道一直不会被执行,采取的是随机执行,这样可以确保各个通道得到均等的“机会”。

select也是可以使用default的:

		select {
		case <-tick:
		case <-terminate:
			fmt.Println("发射终止")
			return
		default:
		}

此时select语句必然不会发生阻塞,在所有case关联的通道都阻塞时,会直接执行default中的语句。此时访问通道的行为更像是轮询。

《Go程序设计语言中》通道部分还有一些内容本篇笔记中并没有说明,因为这篇文章我已经写了两天,有点劳累,所以可能会以其他的方式补充,谢谢阅读。

本系列笔记的所有代码都可以在go-notebook中找到。

往期内容

  • Go语言编程笔记6:接口
  • Go语言编程笔记5:函数
  • Go语言编程笔记4:结构体和切片
  • Go语言编程笔记3:控制流
  • Go语言编程笔记2:变量
  • Go语言编程笔记1:Hello World

你可能感兴趣的:(Go语言,golang,开发语言,后端,goroutine,通道)