Kodewick
开始学 →
第 18 课,共 24 课目录

goroutine

第 18 课,共 24 课 · 第 5 部分 · 并发 · 约 15 分钟

到现在为止,程序都是一件一件往下做:上一件做完,才做下一件。下载三个文件,就是一个接一个地下:

prog.go
1package main2 3import "fmt"4 5func download(file string) {6	fmt.Println("下好了", file)7}8 9func main() {10	download("photo.jpg")11	download("music.mp3")12	download("video.mp4")13	fmt.Println("全部下完了")14}
输出
下好了 photo.jpg
下好了 music.mp3
下好了 video.mp4
全部下完了

真的下载要等网络,一个文件等几秒,三个就是三倍。这三件事互不相干,完全可以同时做。Go 让几件事同时做的办法是 goroutine:可以把它想成程序里另开的一条「做事的线」,main 是一条,你还可以再开很多条。

go:另开一个 goroutine

在一次函数调用前面写上 go,这次调用就放到一个新的 goroutine 里去跑。main 不等它,马上接着往下走:

prog.go
1package main2 3import "fmt"4 5func download(file string) {6	fmt.Println("下好了", file)7}8 9func main() {10	fmt.Println("开始下载")11	go download("photo.jpg")12}
输出
开始下载

只显示了「开始下载」,download 里的那一行没有出现。点单步看看:走过 go 那一行,右边多出一条泳道,这是新的 goroutine,它标着「等着轮到它」;main 那条标着「在跑」。接着 main 走到最后的 },程序结束了,新的 goroutine 一步都没走。

main 结束,程序就结束

main 自己也跑在一个 goroutine 里:前几课 panic 时见过的 goroutine 1 [running]: 就是它。main 一返回,整个程序就结束,别的 goroutine 不管做到哪儿,都跟着结束。Go 不会等它们。

注意:go 只是说「这件事可以开始了」,不保证它什么时候跑。在真的电脑上,几个 CPU 同时干活,新的 goroutine 偶尔也能抢在 main 结束前跑上几步。所以既不能指望它「跑不了」,也不能指望它「跑得完」:要等它,就得明明白白地等。

先睡一会儿?

最直接的想法:让 main 晚一点结束。time.Sleep(时长) 让当前的 goroutine 停一段时间;time.Second 是 1 秒,100 * time.Millisecond 是 0.1 秒:

prog.go
1package main2 3import (4	"fmt"5	"time"6)7 8func download(file string) {9	fmt.Println("下好了", file)10}11 12func main() {13	fmt.Println("开始下载")14	go download("photo.jpg")15	time.Sleep(time.Second)16	fmt.Println("main 结束")17}
输出
开始下载
下好了 photo.jpg
main 结束

这次三行都有了。点单步:走到 time.Sleep,main 那条泳道变成「在睡」,新的 goroutine 这才轮到,跑完 download;然后 main 醒过来,显示最后一行。

注意:网页上的 time.Sleep 不真的等:所有 goroutine 都停下来的时候,时钟直接拨到下一个该醒的时刻。在你自己的电脑上,它会真的等 1 秒。

问题是:等多久才够?下载要多久是说不准的。下面每个文件要下 0.3 秒,main 等 0.1 秒:

先猜一猜

这段程序显示什么?

1package main2 3import (4	"fmt"5	"time"6)7 8func download(file string) {9	time.Sleep(300 * time.Millisecond)10	fmt.Println("下好了", file)11}12 13func main() {14	for _, file := range []string{"a.txt", "b.txt", "c.txt"} {15		go download(file)16	}17	time.Sleep(100 * time.Millisecond)18	fmt.Println("不等了")19}

等短了,结果出不来;等长了,程序又白白多等。用 Sleep 等,其实是在猜时间。下一课的 channel 能让 main 正好等到 goroutine 做完,不多也不少。

几个 goroutine 一起做

go 后面也可以是一个匿名函数(第 16 课讲过),写完函数体马上用 () 调用它。下面给每个数开一个 goroutine,算它的平方,写进 squares 里属于它的那一格:

prog.go
1package main2 3import (4	"fmt"5	"time"6)7 8func main() {9	nums := []int{3, 5, 8, 10}10	squares := make([]int, len(nums))11	for i, n := range nums {12		go func() {13			squares[i] = n * n14		}()15	}16	time.Sleep(100 * time.Millisecond)17	fmt.Println(squares)18}
输出
[9 25 64 100]

点单步,看循环走完以后右边有五条泳道:main 和四个排队的 goroutine。main 一睡,它们一个接一个地跑,squares 的格子一个个填上。每一轮的 i 和 n 都是新的变量(Go 1.22 起),所以每个 goroutine 拿到的是自己那一轮的值。

不过 Sleep 并不是真正的同步:main 只是睡了一会儿,并没有确认它们写完。在自己电脑上用 go run -race(第 22 课)运行,这里会被报成数据竞争。从下一课起,就不再用 Sleep 等了。

为什么不让每个 goroutine 自己显示结果?因为先后说不准:

1for _, n := range nums {2	go func() {3		fmt.Println(n * n)4	}()5}

在真的电脑上,几个 goroutine 真的同时跑,谁先显示每次可能不一样:这次 9 25 64 100,下次也许 64 9 100 25。网页上的引擎一次只让一个 goroutine 跑(跑到它停下来为止),所以每次都一样,别被它骗了。这门课的例子都让结果和先后无关:每个 goroutine 写自己的那一格,最后由 main 一起显示。

go 后面是一次调用

go 开出去的函数,返回值没人接,直接扔掉。所以不能这样拿结果:

prog.go
1package main2 3import "fmt"4 5func square(n int) int {6	return n * n7}8 9func main() {10	result := go square(7)11	fmt.Println(result)12}
输出
# command-line-arguments
./prog.go:10:12: syntax error: unexpected keyword go, expected expression

发生了什么: go 语句没有值,不能放在 = 右边或者当参数:go 出去的函数的结果,要用 channel 拿回来。

go square(7) 是一条语句,不是一个值,放在 := 右边就是语法错误。另一个常见的错是匿名函数后面忘了 ():

prog.go
1package main2 3import "fmt"4 5func main() {6	go func() {7		fmt.Println("下好了")8	}9}
输出
# command-line-arguments
./prog.go:6:5: expression in go must be function call

发生了什么: go 后面要写一次函数调用(带括号):go f(),或者 go func() { … }()。

expression in go must be function call:go 后面要的是一次调用,这里只写了一个函数,没有调用它。在 } 后面加上 () 就好了。想从 goroutine 拿回结果,可以像上一节那样写进一个变量,更好的办法是下一课的 channel。

练一练

写好代码,点检查。提示随便看,实在想不出来再看答案。这一课还没有正式的等法,练习里先用 time.Sleep。它不是真正的同步,go run -race 会把这些程序报成数据竞争;从下一课起就不用 Sleep 等了。

练习 1

修好它:合计还是 0

showTotals 给每张订单开一个 goroutine 算合计,可显示出来都是 0。改好它,让它显示每张订单真正的合计。程序算了两批订单。

要显示
合计 [50 100]
合计 [10 40 20]
exercise1.go
1package main2 3import "fmt"4 5func sum(prices []int) int {6	total := 07	for _, p := range prices {8		total += p9	}10	return total11}12 13func showTotals(orders [][]int) {14	totals := make([]int, len(orders))15	for i, prices := range orders {16		go func() {17			totals[i] = sum(prices)18		}()19	}20	fmt.Println("合计", totals)21}22 23func main() {24	showTotals([][]int{{12, 30, 8}, {99, 1}})25	showTotals([][]int{{5, 5}, {40}, {7, 3, 10}})26}
输出
点「运行」或按 Ctrl+Enter
练习 2

一群工人算平方和

写好 sumOfSquares:给每个数开一个 goroutine,把它的平方写进 squares 里对应的那一格;等一会儿以后,把 squares 加起来返回。程序会显示两组数的平方和。

要显示
14
41
exercise2.go
1package main2 3import (4	"fmt"5	"time"6)7 8func sumOfSquares(nums []int) int {9	squares := make([]int, len(nums))10	// 在这里开 goroutine11	time.Sleep(100 * time.Millisecond)12	total := 013	// 在这里把 squares 加起来14	return total15}16 17func main() {18	fmt.Println(sumOfSquares([]int{1, 2, 3}))19	fmt.Println(sumOfSquares([]int{4, 5}))20}
输出
点「运行」或按 Ctrl+Enter
练习 3

修好它:go 拿不到返回值

countBoth 想同时数两句话各有几个词,可它编译不过。改成:两个 goroutine 各数一句,把结果写进 a 和 b,等一会儿再返回。程序数了两对句子。

要显示
4 6
1 4
exercise3.go
1package main2 3import (4	"fmt"5	"strings"6)7 8func countWords(s string) int {9	return len(strings.Fields(s))10}11 12func countBoth(s1, s2 string) (int, int) {13	a := go countWords(s1)14	b := go countWords(s2)15	return a, b16}17 18func main() {19	fmt.Println(countBoth("the order has shipped", "your download will start in seconds"))20	fmt.Println(countBoth("done", "please try again later"))21}
输出
点「运行」或按 Ctrl+Enter

这一课学会了

  • go f(x) 把这次调用放进一个新的 goroutine,main 不等它,接着往下走。
  • main 也是一个 goroutine;main 结束,整个程序结束,别的 goroutine 跟着结束。
  • 单步时每个 goroutine 一条泳道:「在跑」「等着轮到它」「在睡」。
  • go func() { … }():匿名函数后面要有 ();go 语句没有值,拿不到返回值。
  • time.Sleep 等是在猜时间,等短了结果没出来,等长了白等;下一课用 channel 正好等到做完。
  • 几个 goroutine 的先后说不准:让每个 goroutine 写自己的那一格,等它们都做完(下一课)再一起显示。
这一课学完了吗?点一下标记。进度存在这个浏览器里,随时可以撤销。