システムコールにみるGo言語のnetパッケージの実装

netパッケージのコードを読む機会があったのでメモ。TCPのエコーサーバーを実行して、呼ばれているシステムコールとその引数を確認した。Go言語がシンプルなインターフェースを提供している裏側で、ノンブロッキングIOやIO多重化を駆使している様子がわかって面白かった。

動作確認用プログラムと実行環境

動作確認用に以下のエコーサーバーとクライアントを書いた。あくまで実験用。

TCP echo server/client to be used with strace

今回はこのエコーサーバーをstraceを通して実行することで、サーバー実行時に呼ばれるシステムコールを確認した。

実行環境はgolang:1.10イメージベースのdockerコンテナ。IPv6をenableにしている。また、コンテナ実行時に--security-opt=apparmor=unconfined --cap-add=SYS_PTRACEオプションを与えてstraceが動くようにしている。

実行結果

以下のようにエコーサーバーを実行した後、別シェルでgo run echo_client.goを実行した。

# go build echo_server.go && strace -e 'trace=!pselect6,futex,sched_yield' ./echo_server
// net.Listen()を呼ぶ箇所までスキップ
...
write(1, "\n===== net.Listen() =====\n", 26
===== net.Listen() =====
) = 26
socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 3
close(3)                                = 0
socket(AF_INET6, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 3
setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [1], 4) = 0
bind(3, {sa_family=AF_INET6, sin6_port=htons(0), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
socket(AF_INET6, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 5
setsockopt(5, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
bind(5, {sa_family=AF_INET6, sin6_port=htons(0), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
close(5)                                = 0
close(3)                                = 0
socket(AF_INET6, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3
setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
listen(3, 128)                          = 0
epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=4234977024, u64=140591399476992}}) = 0
getsockname(3, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28]) = 0
write(1, "\n===== ln.Accept() =====\n", 25
===== ln.Accept() =====
) = 25
accept4(3, 0xc420057bd0, [112], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(4, [], 128, 0)               = 0
epoll_wait(4, [{EPOLLIN, {u32=4234977024, u64=140591399476992}}], 128, -1) = 1
accept4(3, {sa_family=AF_INET6, sin6_port=htons(37268), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5
epoll_ctl(4, EPOLL_CTL_ADD, 5, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=4234976816, u64=140591399476784}}) = 0
getsockname(5, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28]) = 0
setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0
write(1, "\n===== io.Copy() =====\n", 23
===== io.Copy() =====
) = 23
read(5, 0xc4200b6000, 32768)            = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(4, [{EPOLLOUT, {u32=4234976816, u64=140591399476784}}], 128, 0) = 1
epoll_wait(4, [{EPOLLIN|EPOLLOUT, {u32=4234976816, u64=140591399476784}}], 128, -1) = 1
read(5, "test", 32768)                  = 4
write(5, "test", 4)                     = 4
read(5, 0xc4200b6000, 32768)            = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(4, [], 128, 0)               = 0
epoll_wait(4, [{EPOLLIN|EPOLLOUT|EPOLLRDHUP, {u32=4234976816, u64=140591399476784}}], 128, -1) = 1
read(5, "", 32768)                      = 0
write(1, "\n===== conn.Close() =====\n", 26
===== conn.Close() =====
) = 26
epoll_ctl(4, EPOLL_CTL_DEL, 5, 0xc420057d34) = 0
close(5)                                = 0
write(1, "\n===== ln.Accept() =====\n", 25
===== ln.Accept() =====
) = 25
accept4(3, 0xc420057bd0, [112], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(4, [], 128, 0)               = 0
epoll_wait(4,

ここからはnet.Listen()、ln.Accept()、conn.Read()、conn.Write()、conn.Close()について、呼ばれているシステムコールを確認していく。

net.Listen()が呼ぶシステムコール

以下のシステムコールを呼んでいた。大別すると、IPv4/IPv6/IPV4 mapped IPv6サポートの確認、ソケットの準備、epollの設定をしている。

socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 3
close(3)                                = 0
socket(AF_INET6, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 3
setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [1], 4) = 0
bind(3, {sa_family=AF_INET6, sin6_port=htons(0), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
socket(AF_INET6, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_TCP) = 5
setsockopt(5, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
bind(5, {sa_family=AF_INET6, sin6_port=htons(0), inet_pton(AF_INET6, "::ffff:127.0.0.1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
close(5)                                = 0
close(3)                                = 0
socket(AF_INET6, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3
setsockopt(3, SOL_IPV6, IPV6_V6ONLY, [0], 4) = 0
setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0
setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
bind(3, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
listen(3, 128)                          = 0
epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=4234977024, u64=140591399476992}}) = 0
getsockname(3, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28]) = 0

前半では、ソケットを作って、bindして、すぐにcloseする作業を繰り返している。最初は謎だったけど、ここでシステムのIPv4/IPv6/IPV4 mapped IPv6サポート状況を確認しているらしい。コード上はこのあたり。確認結果はメモリ上にキャッシュされているので、net.Listen()のたびに確認するわけではない。

後半が本筋で、socket()、bind()、listen()を呼んでいる。コード上はこのあたり。定番の手順だけど、socket作成時のオプションにSOCK_NONBLOCKが付いているので、ノンブロッキングなソケットが作成されていることがわかる。

また、socket作成後にsetsockopt()を3回呼び、IPV6_V6ONLY、SO_BROADCAST、SO_REUSEADDRオプションを設定している。最初の2つはソケット作成時にTCP/UDP/Listen/Dial問わず設定している。最後のSO_REUSEADDRは、bindしたいポートについて、過去の接続がまだ残っていてもbindできるようにするオプション。TCPサーバーではセットしておくのが普通らしい。netパッケージでもTCPソケットがlistenするときに設定している。

最後のepoll_ctl()はIO多重化のための設定。ソケットとイベントをepollインスタンスに登録している。コード上はこのあたり。read/writeイベントに加えて、相手ソケットのcloseイベントも登録している。ちなみにepollインスタンスはプロセス全体で共有されていて、既に作成済みなので、ここでは作成されていない。straceの結果を遡ってみると、以下のように作成されていた。

openat(AT_FDCWD, "/proc/sys/net/core/somaxconn", O_RDONLY|O_CLOEXEC) = 3
epoll_create1(EPOLL_CLOEXEC)            = 4
epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=4234977024, u64=140591399476992}}) = 0

netパッケージの初期化中に作成している。Go 1.9から、file IOでもnetwork pollerを使うようになったみたい。

ln.Accept()が呼ぶシステムコール

以下のシステムコールを呼んでいた。接続の待ち受けと、確立した接続の初期設定をしている。

accept4(3, 0xc420057bd0, [112], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(4, [], 128, 0)               = 0
epoll_wait(4, [{EPOLLIN, {u32=4234977024, u64=140591399476992}}], 128, -1) = 1
accept4(3, {sa_family=AF_INET6, sin6_port=htons(37268), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5
epoll_ctl(4, EPOLL_CTL_ADD, 5, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=4234976816, u64=140591399476784}}) = 0
getsockname(5, {sa_family=AF_INET6, sin6_port=htons(8080), inet_pton(AF_INET6, "::1", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [112->28]) = 0
setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0

前半では、スレッドレベルのブロックをできるだけ避けつつ接続を待ち受けている。コード上はこのあたり。最初はとりあえずaccept4()を呼んで、確立した接続がないか確認している。今回は接続がなかったようなので、goルーチンレベルで一度ブロックしている。コード上はこのあたり。ちなみに少し紛らわしいけど、accept4()のSOCK_NONBLOCKオプションは、接続確立したソケットをノンブロッキングにするためのもの。リッスンソケットはノンブロッキングになるよう既に設定済み。

次に、スレッドが実行可能なgoルーチンを探すところで、epoll_wait()を2回呼んでいる。1回目はノンブロッキング、2回目はブロッキング。コード上はこのあたり。いきなり2回呼ぶわけではなく、実行キューやGCや他のスレッドのキューの様子を見て、余裕があれば呼んでいるっぽい。特に2回目のepoll_wait()はブロッキングになっているので、他の処理が全くない時にだけ呼んでいる様子。

2回目のepoll_wait()は2番目の引数にEPOLLINイベントが入っているので、readできるようになったことがわかる。Readできるようになると、ここここのコードが呼ばれてgoルーチンがアンブロックされる。アンブロックするgoルーチンはイベント内のデータから辿れるようになっている。

conn.Read()、conn.Write()が呼ぶシステムコール

io.Copy()を通して以下のシステムコールを呼んでいた。read()で相手からのパケットを読み、write()で相手にパケットを送り、最後にread()でEOFを受け取っている。

read(5, 0xc4200b6000, 32768)            = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(4, [{EPOLLOUT, {u32=4234976816, u64=140591399476784}}], 128, 0) = 1
epoll_wait(4, [{EPOLLIN|EPOLLOUT, {u32=4234976816, u64=140591399476784}}], 128, -1) = 1
read(5, "test", 32768)                  = 4
write(5, "test", 4)                     = 4
read(5, 0xc4200b6000, 32768)            = -1 EAGAIN (Resource temporarily unavailable)
epoll_wait(4, [], 128, 0)               = 0
epoll_wait(4, [{EPOLLIN|EPOLLOUT|EPOLLRDHUP, {u32=4234976816, u64=140591399476784}}], 128, -1) = 1
read(5, "", 32768)                      = 0

io.Copy()はEOFが返るまで、conn.Read()からの読み込みとconn.Write()への書き込みを繰り返す。conn.Read()はノンブロッキングにread()を呼び、パケットが来てなければepoll_wait()で待つ。コード上はこのあたり。Accept()と似た処理になっていて、呼ばれるシステムコールも似ている。

Accept()と比べて異なるのは、epoll_wait()の結果。見ての通り、どちらのepoll_wait()にもEPOLLOUTイベントが返っている。これは、fdが接続済みソケットを指していて、ソケットが書込み可能な状態になっているためだと思う。

最初見たときは、1回目のepoll_wait()がEPOLLOUTイベントを返しているので、そこでgoルーチンがアンブロックされるのかと思った。それなのに2回目のepoll_wait()が呼ばれていて不思議だったが、read待ちのgoルーチンとwrite待ちのgoルーチンは別々に管理されるため、EPOLLOUTイベントではread待ちのgoルーチンはアンブロックされないようだった。

conn.Write()もAccept()と似た処理だけど、既に書込み可能な状態なので、一発目のwrite()で成功している。コード上はこのあたり

2回目のconn.Read()は1回目のconn.Read()と大体同じ。今度は相手がソケットをclose()したので、2回目のepoll_wait()でEPOLLRDHUPイベントが返っている。その後のread()でもEOFが返っている。

conn.Close()が呼ぶシステムコール

以下のシステムコールを呼んでいた。

epoll_ctl(4, EPOLL_CTL_DEL, 5, 0xc420057d34) = 0
close(5)                                = 0

比較的単純で、epollの登録解除とソケットのclose()をしているのみ。コード上はこのあたり

まとめ

TCPのエコーサーバー実行時に呼んでいるシステムコールを確認して、ノンブロッキングIOとIO多重化を多用していることがわかった。Goはスケールするプログラムをシンプルに書けるイメージがあるけど、今回はその裏側にある泥臭い処理の一部を垣間見られた気がする。