[译]Go TCP Socket的实现

原文: TCP Socket Implementation On Golang by Gian Giovani.

译者注: 作者并没有从源代码级别去分析Go socket的实现,而是利用strace工具来反推Go Socket的行为。这一方法可以扩展我们分析代码的手段。
源代码级别的分析可以看其实现: net poll,以及一些分析文章:The Go netpoller, The Go netpoller and timeout

Go语言是我写web程序的首选, 它隐藏了很多细节,但仍然不失灵活性。最新我用strace工具分析了一下一个http程序,纯属手贱但还是发现了一些有趣的事情。

下面是strace的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
91.24 0.397615 336 1185 29 futex
4.13 0.018009 3 7115 clock_gettime
2.92 0.012735 19 654 epoll_wait
1.31 0.005701 6 911 write
0.20 0.000878 3 335 epoll_ctl
0.12 0.000525 1 915 457 read
0.02 0.000106 2 59 select
0.01 0.000059 0 170 close
0.01 0.000053 0 791 setsockopt
0.01 0.000035 0 158 getpeername
0.01 0.000034 0 170 socket
0.01 0.000029 0 160 getsockname
0.01 0.000026 0 159 getsockopt
0.00 0.000000 0 7 sched_yield
0.00 0.000000 0 166 166 connect
0.00 0.000000 0 3 1 accept4
------ ----------- ----------- --------- --------- ----------------
100.00 0.435805 12958 653 total

在这个剖析结果中有很多有趣的东东,但本文中要特别指出的是read的错误数和futex调用的错误数。

一开始我没有深思futex的调用, 大部分情况它无非是一个唤醒调用(wake call)。既然这个程序会处理每秒几百个请求,它应该包含很多go routine。另一方面,它使用了channel,这也会导致很多block情况,所以有很多futex调用也很正常。 不过后来我发现这个数也包含来自其它的逻辑,后面再表。

Why you no read

有谁喜欢错误(error)?短短一分钟就有几百次的错误,太糟糕了, 这是我看到这个剖析结果后最初的印象。那么 read call又是什么东东?

1
2
3
read(36, "GET /xxx/v3?q=xx%20ch&d"..., 4096) = 520
...
read(36, 0xc422aa4291, 1) = -1 EAGAIN (Resource temporarily unavailable)

每次read调用同一个文件描述符,总是(可能)伴随着一个 EAGAIN error。我记得这个错误,当文件描述符还没有准备(ready)某个操作的时候就会返回这个错,上面的例子中操作是read。问题是为什么Go会这样做呢?

我猜想这可能是epoll_wait的一个bug, 它为每一个文件描述符提供了错误的ready事件?每一个文件描述符? 看起来read事件是错误事件的两倍,为什么是两倍?

老实说,我的epoll知识很了了,程序只是一个简单的处理事件的socket handler(类似)。没有多线程,没有同步,非常简单。

通过Google我找到了一篇极棒的文章分析评论epoll,由Marek所写,。

这篇文章重要的摘要就是:在多线程中使用epoll, 不必要的唤醒(wake up)通常是不可避免的,因为我们想通知每个等待事件的worker。

这也正好解释了我们的futex 唤醒数。还是让我们看一个简化版本来好好理解怎么在基于事件的socket处理程序中使用epoll吧:

  1. Bind socket listenerfile descriptor, 我们称之为 s_fd
  2. 使用epoll_create创建 epoll file descriptor , 我们称之为 e_fd
  3. 通过epol_ctl bind s_fde_fd, 处理特殊的事件(通常EPOLLIN|EPOLLOUT)
  4. 创建一个无限循环 (event loop), 它会在每次循环中调用epoll_wait得到已经ready连接
  5. 处理ready的连接, 在多worker实现中会通知每一个worker

Using strace I found that golang using edge triggered epoll
使用strace我发现 golang使用 edge triggered epoll:

1
epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=2490298448, u64=140490870550608}}) = 0

这意味着下面的过程应该是go socket的实现:

1、Kernel: 收到一个新连接.
2、Kernel: 通知等待的线程 threads A 和 B. 由于level-triggered 通知的"惊群"(“thundering herd”)行为,kernel必须唤醒这两个线程.
3、Thread A: 完成 epoll_wait().
4、Thread B: 完成 epoll_wait().
5、Thread A: 执行 accept(), 成功.
6、Thread B: 执行 accept(), 失败, EAGAIN错误.

现在我有八成把握就是这个case,不过还是让我们用一个简单的程序来分析。

1
2
3
4
5
6
7
8
9
10
11
12
13
package main
import "net/http"
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/test", handler)
http.ListenAndServe(":8080", nil)
}
func handler(w http.ResponseWriter, r *http.Request) {
}

一个简单的请求后的strace结果:

1
2
3
4
5
6
7
8
epoll_wait(4, [{EPOLLIN|EPOLLOUT, {u32=2186919600, u64=140542106779312}}], 128, -1) = 1
futex(0x7c1bd8, FUTEX_WAKE, 1) = 1
futex(0x7c1b10, FUTEX_WAKE, 1) = 1
read(5, "GET / HTTP/1.1\r\nHost: localhost:"..., 4096) = 348
futex(0xc420060110, FUTEX_WAKE, 1) = 1
write(5, "HTTP/1.1 200 OK\r\nDate: Sat, 03 J"..., 116) = 116
futex(0xc420060110, FUTEX_WAKE, 1) = 1
read(5, 0xc4200f6000, 4096) = -1 EAGAIN (Resource temporarily unavailable)

看到epoll_wait有两个futex调用,我认为是worker执行以及一次 error read。

如果GOMAXPROCS设置为1,在单worker情况下:

1
2
3
4
5
6
7
8
9
10
11
12
13
epoll_wait(4,[{EPOLLIN, {u32=1969377136, u64=140245536493424}}], 128, -1) = 1
futex(0x7c1bd8, FUTEX_WAKE, 1) = 1
accept4(3, {sa_family=AF_INET6, sin6_port=htons(54400), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 6
epoll_ctl(4, EPOLL_CTL_ADD, 6, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1969376752, u64=140245536493040}}) = 0
getsockname(6, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 0
setsockopt(6, SOL_TCP, TCP_NODELAY, [1], 4) = 0
setsockopt(6, SOL_SOCKET, SO_KEEPALIVE, [1], 4) = 0
setsockopt(6, SOL_TCP, TCP_KEEPINTVL, [180], 4) = 0
setsockopt(6, SOL_TCP, TCP_KEEPIDLE, [180], 4) = 0
accept4(3, 0xc42004db78, 0xc42004db6c, SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
read(6, "GET /test?kjhkjhkjh HTTP/1.1\r\nHo"..., 4096) = 92
write(6, "HTTP/1.1 200 OK\r\nDate: Sat, 03 J"..., 139) = 139
read(6, "", 4096)

当使用1个worker,epoll_wait之后只有一次futex唤醒,并没有error read。然而我发现并不总是这样, 有时候我依然可以得到read error和两次futex 唤醒。

And then what to do?

在Marek的文章中他谈到Linux 4.5之后可以使用EPOLLEXCLUSIVE。我的Linux版本是4.8,为什么问题还是出现?或许Go并没有使用这个标志,我希望将来的版本可以使用这个标志。

从中我学到了很多知识,希望你也是。

[0] https://banu.com/blog/2/how-to-use-epoll-a-complete-example-in-c/
[1] https://idea.popcount.org/2017-02-20-epoll-is-fundamentally-broken-12/
[2] https://gist.github.com/wejick/2cef1f8799361318a62a59f6801eade8