Go 协程 Tips

自己总结的一些 Go 协程的小技巧

以下的代码,除特殊说明外,均由我编写,如有错误,还请大佬多多指正

控制协程

我在学习 Go 时,在别人的代码里时常看到类似make(chan struct{})的语句,后来明白这样的值可以用来控制协程,下面的例子使用通道来控制协程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package main

import (
"fmt"
"strconv"
"sync"
"time"
)

var (
stop chan struct{}
wait sync.WaitGroup
run chan string
)

func goRun() {
for {
select {
case in := <-run:
// 打印收到的消息
fmt.Println(in)
case <-stop:
// 收到停止的消息,退出
fmt.Println("I am stop")
wait.Done()
return
}
}
}

func main() {
stop = make(chan struct{})
run = make(chan string)
// 等待另外的协程完成
wait.Add(1)

// 我们要进行控制的协程
go goRun()

go func() {
for i := 1; i <= 10; i++ {
// 每隔一秒向协程的 channel 发送一条消息
run <- "I am running x" + strconv.Itoa(i)
time.Sleep(time.Second)
}
// 向协程发送停止的消息
// stop <- struct{}{} 也可以
close(stop)
}()

// 等待协程(goRun)完成运行
wait.Wait()
}

其中struct{}是空结构体,占用空间为零,close(stop)时 select 的case <-stop:不再阻塞,返回struct{}的零值(struct 零值就是本身),协程返回。如果是stop <- struct{}{}协程同样会收到消息进而退出,另外也可以使用 Context:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package main

import (
"context"
"fmt"
"strconv"
"time"
)

var (
run chan string
)

func goRun(ctx context.Context) {
for {
select {
case in := <-run:
// 打印收到的消息
fmt.Println(in)
case <-ctx.Done():
// 收到停止的消息,退出
fmt.Println("I am stop")
return
}
}
}

func main() {
run = make(chan string)
ctx, cancel := context.WithCancel(context.Background())

// 我们要进行控制的协程
go goRun(ctx)

go func() {
for i := 1; i <= 10; i++ {
// 每隔一秒向协程的 channel 发送一条消息
run <- "I am running x" + strconv.Itoa(i)
time.Sleep(time.Second)
}
// 向协程发送停止的消息
cancel()
}()

// 等待所有协程完成
time.Sleep(time.Second * 12)
}

使用 Context 可以方便一些,Context 还有其它用法,这里就不展开了

定时任务

前段时间有个需求,需要每天定时执行重置任务(运行某个函数),然后发现了一种方法可以满足要求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func reset() {
fmt.Println("do something...")
}

func AutoReset(hour int) {
if hour < 0 || hour > 23 {
return
}

fmt.Printf("每天在 %d 时重置\n", hour)
for {
timeLocation, _ := time.LoadLocation("Asia/Shanghai")
next := time.Date(time.Now().Year(), time.Now().Month(), time.Now().Day(), hour, 0, 0, 0, timeLocation)
// 计算现在距定时任务执行时间相差的时间
subTime := next.Sub(time.Now())
if subTime <= 0 {
// 相差小于等于零表明这个时间点在今天已经过去了,我们需要往后推一天
subTime += 24 * 60 * 60 * 1000000000
}
t := time.NewTimer(subTime)
// 在这里阻塞
<-t.C
// do reset
reset()
}
}

使用方法(每天在 19 时重置):

1
go AutoReset(19)

上面的代码虽然只可以精确到小时执行,但只要稍加改造,就可以控制到其它的时间单位

range 陷阱

举一个例子,下面的例子想通过 chan 接受十个数据,然后每个数据启动一个 goroutine 来打印它们:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func Server(queue chan int) {
for req := range queue {
go func() {
fmt.Println(req)
}()
}
}

func main() {
q := make(chan int)
go Server(q)

for i := 1; i <= 10; i++ {
q <- i
}

time.Sleep(time.Second * 20)
}

预期的结果应该是包含1-10的打印结果,但是实际发现打印的值里有很多重复的,也就是说有不同的 goroutine 访问了同一个数据,而如果我们添加这样一行代码,变成这样,发现问题就解决了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Server(queue chan int) {
for req := range queue {
go func() {
fmt.Println(req)
}()
}
}

func main() {
q := make(chan int)
go Server(q)

for i := 1; i <= 10; i++ {
q <- i
// 添加的代码
time.Sleep(time.Second)
}

time.Sleep(time.Second * 20)
}

如果注释掉time.Sleep(time.Second),就有可能出现多个 goroutine 访问同一个 req,原因在于(我理解的):由于发送给 queue 数据的速率很快,导致 req 的值更新时不同的 goroutine 还在访问这唯一的 req,在更深的层次上,我们应该由地址考虑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func Server(queue chan int) {
for req := range queue {
fmt.Println("Out: ", &req, " ", req)
go func() {
fmt.Println("In: ", &req, " ", req)
}()
}
}

func main() {
q := make(chan int)
go Server(q)

for i := 1; i <= 10; i++ {
q <- i
time.Sleep(time.Second * 2)
}

time.Sleep(time.Second * 20)
}

可以发现所有的地址是相同的,即都是这唯一的 req,正确的修复方法有些两种(我更喜欢第二种):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 第一种
func Server(queue chan int) {
for req := range queue {
req := req
fmt.Println("Out: ", &req, " ", req)
go func() {
fmt.Println("In: ", &req, " ", req)
}()
}
}

// 第二种
func Server(queue chan int) {
for req := range queue {
fmt.Println("Out: ", &req, " ", req)
go func(req int) {
fmt.Println("In: ", &req, " ", req)
}(req)
}
}

可以看到无论是哪种方法,传入 goroutine 的参数的地址是不一样的,即参数不是同一个参数,而在错误的示范里,这些参数都相同,导致不同 goroutine 访问的是同一个变化的参数