0%

Goroutine 调度 - 网络调用

上一篇文章 goroutine 调度概览 大概介绍了 Goroutine 调度的基本情况,这篇来看个具体的例子:网络调用。

协作式主动让出执行权

Goroutine 很多时候都是通过主动让出执行权的,主动让出的执行效率可以更高;就像自己主动搬砖和被人催着搬砖一样。
除了 runtime.Gosched 这种 go 代码里显式指定让出的(实际上应该会比较少用到),更多时候是由某些行为底层触发,网络调用则是一个典型的例子。

网络调用

对于网络的非阻塞实现,linux 下最常见的就是用 epoll,我们通过例子来看看 go 语言如何在语言级别把 epoll 的细节屏蔽。

以下是个简单例子,向 example.com:80 发起了一个 HTTP GET 请求,然后读取第一行响应。

1
2
3
4
5
6
7
8
9
10
11
12
conn, err := net.Dial("tcp", "example.com:80")
if err != nil {
// handle err
}
fmt.Fprintf(conn, "GET / HTTP/1.0\r\n\r\n")

line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil {
// handle err
}

fmt.Println("line: ", line)

挂起

我们重点介绍发送之后,第一次读取数据的行为

  1. 因为刚发送完,此时立马读取,肯定读不到数据,syscall.Read 会返回 EAGAIN
  2. 接下来会调用 gopark 将当前 Goroutine 挂起

通过 gdb 可以看到这样的一个调用栈:

1
2
3
4
5
6
7
8
9
#0  runtime.gopark (unlockf={void (runtime.g *, void *, bool *)} 0x0, lock=0x7fffd00a0700, reason=2 '\002', traceEv=27 '\033', traceskip=5) at /home/dou/work/go/src/runtime/proc.go:349
#1 0x000000000042e5fe in runtime.netpollblock (pd=<optimized out>, mode=<optimized out>, waitio=<optimized out>) at /home/dou/work/go/src/runtime/netpoll.go:445
#2 0x000000000045c489 in internal/poll.runtime_pollWait (pd=<optimized out>, mode=140736683706112) at /home/dou/work/go/src/runtime/netpoll.go:229
#3 0x000000000048b0b2 in internal/poll.(*pollDesc).wait (pd=<optimized out>, mode=140736683706112, isFile=2) at /home/dou/work/go/src/internal/poll/fd_poll_runtime.go:84
#4 0x000000000048ba1a in internal/poll.(*pollDesc).waitRead (isFile=2, pd=<optimized out>) at /home/dou/work/go/src/internal/poll/fd_poll_runtime.go:89
#5 internal/poll.(*FD).Read (fd=0xc0001a8000, p=..., ~r1=<optimized out>, ~r2=...) at /home/dou/work/go/src/internal/poll/fd_unix.go:167
#6 0x00000000004ac629 in net.(*netFD).Read (fd=0xc0001a8000, p=...) at /home/dou/work/go/src/net/fd_posix.go:56
#7 0x00000000004b6965 in net.(*conn).Read (c=0xc000094008, b=...) at /home/dou/work/go/src/net/net.go:183
#8 0x00000000004c1d2e in net.(*TCPConn).Read (b=...) at <autogenerated>:1

补充一点:gopark 的核心逻辑是,切换到 g0 栈,执行 park_m。在 park_m (g0 栈)中再把当前 G 挂起。

恢复执行

上一步的 park_m 函数,除了会挂起当前 G,另外一个重要的任务就是执行 schedule 函数,挑一个新的 G 开始运行。

在上一篇介绍过,挑选一个新的 G 的过程中,就有 检查 netpoll 这一步。
我们可以在 gdb 中看到如下的调用栈:

1
2
3
4
5
#0  runtime.netpollready (toRun=0x7fffcaffc6c8, pd=0x7fffd00606d8, mode=119) at /home/dou/work/go/src/runtime/netpoll.go:372
#1 0x000000000042f057 in runtime.netpoll (delay=<optimized out>) at /home/dou/work/go/src/runtime/netpoll_epoll.go:176
#2 0x000000000043abd3 in runtime.findrunnable () at /home/dou/work/go/src/runtime/proc.go:2947
#3 0x000000000043bdd9 in runtime.schedule () at /home/dou/work/go/src/runtime/proc.go:3367
#4 0x000000000043c32d in runtime.park_m (gp=0xc000202000) at /home/dou/work/go/src/runtime/proc.go:3516

netpoll 中的核心逻辑即是调用 epoll_wait,获取一批已经准备就绪的 events,恢复这批 G 到 runable 状态,并运行第一个 G。

总结

对于非阻塞网络的实现,核心点是 EAGAINepoll_wait
go 语言把这个细节隐藏到了语言/标准库内部,确实很大程度的降低了程序员的心智负担。