Goでechoサーバーを書いた時のシステムコールはどうなってるのかなぁ、と思って調べてみた時の日記です。
前にCでechoサーバーを書いた時は以下のような実装をしました。
socket
でソケットを作成するbind
でソケットをバインドするlisten
でListen状態になるaccept
でコネクションを取り出すread
でクライアントから文字列を受け取るwrite
でクライアントへ文字列を返す
Goで書くとどうなるでしょうか?
$ cat /etc/lsb-release DISTRIB_ID=Ubuntu DISTRIB_RELEASE=16.04 DISTRIB_CODENAME=xenial DISTRIB_DESCRIPTION="Ubuntu 16.04.4 LTS"
$ go version go version go1.10.3 linux/amd64
以下のような無限ループするコードから始めます。
package main import "fmt" func main() { fmt.Println("wait...") for { } }
実行。
$ go run echo_server.go
$ lsof -i:30000
$ ps -x | grep echo_server 2200 pts/1 Sl+ 0:00 go run echo_server.go 2232 pts/1 Rl+ 0:24 /tmp/go-build303798012/b001/exe/echo_server 2287 pts/2 S+ 0:00 grep --color=auto echo_server
$ ll /proc/2232/fd total 0 dr-x------ 2 vagrant vagrant 0 Jul 7 05:48 ./ dr-xr-xr-x 9 vagrant vagrant 0 Jul 7 05:48 ../ lrwx------ 1 vagrant vagrant 64 Jul 7 05:48 0 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 05:48 1 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 05:48 2 -> /dev/pts/1
まだポートのバインドもしていなければソケットのディスクリプタもない。psの結果を見るとstatに l
が出力されていてGoのランタイムがマルチスレッドで動いてることが分かる。
Listen
netパッケージのListenを使う。
package main import ( "fmt" "net" ) func main() { l, err := net.Listen("tcp", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") for { } }
確認してみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2330 vagrant 3u IPv4 19936 0t0 TCP *:30000 (LISTEN)
$ ss -lt4 State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 *:30000 *:*
30000ポートでLISTENになってる。つまり、 Listen
関数で以下の3ステップが行われた模様。
socket
でソケットを作成するbind
でソケットをバインドするlisten
でListen状態になる
ディスクリプタも確認する。
$ ll /proc/2330/fd total 0 dr-x------ 2 vagrant vagrant 0 Jul 7 06:04 ./ dr-xr-xr-x 9 vagrant vagrant 0 Jul 7 06:04 ../ lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 0 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 1 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 2 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 3 -> socket:[19936] lrwx------ 1 vagrant vagrant 64 Jul 7 06:04 4 -> anon_inode:[eventpoll]
おや、ソケットの他に anon_inode:[eventpoll]
が。これはepollを使ったechoサーバー書いたときにもあったやつ。 epoll_create
システムコールで作られるファイルディスクリプタ。あの時は、 sock
, bind
, listen
で準備したソケットのディスクリプタとepoll_create
で作ったファイルディスクリプタを、epoll_ctl
の引数で指定する、という順序でechoサーバーを書いた。
straceしてみると確かにやってる。
$ go build echo_server.go $ strace ./echo_server ... epoll_create1(EPOLL_CLOEXEC) = 4 ... socket(PF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 3 setsockopt(3, SOL_SOCKET, SO_BROADCAST, [1], 4) = 0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0 bind(3, {sa_family=AF_INET, sin_port=htons(30000), sin_addr=inet_addr("0.0.0.0")}, 16) = 0 listen(3, 128) = 0 epoll_ctl(4, EPOLL_CTL_ADD, 3, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=880447232, u64=140669649325824}}) = 0 ...
通信する
net.ListenerのAccept
メソッドでコネクション(net.Conn)を取得して、net.ConnのRead
メソッド, Write
メソッドを使う。
package main import ( "io" "fmt" "net" ) func main() { l, err := net.Listen("tcp4", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") for { conn, err := l.Accept() if err != nil { panic(err) } _, err = conn.Write([]byte("Hello World!\r\n")) if err != nil { panic(err) } buf := make([]byte, 1024) n, err := conn.Read(buf) if err != nil { if err == io.EOF { break } panic(err) } _, err = conn.Write(buf[:n]) if err != nil { panic(err) } conn.Close() } }
クライアントからつなぐ。
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'. Hello World!
サーバー側で確認する。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2019 vagrant 3u IPv4 18087 0t0 TCP *:30000 (LISTEN) echo_serv 2019 vagrant 5u IPv4 18089 0t0 TCP 192.168.33.10:30000->192.168.33.1:52736 (ESTABLISHED)
$ ll /proc/2019/fd total 0 dr-x------ 2 vagrant vagrant 0 Jul 7 07:20 ./ dr-xr-xr-x 9 vagrant vagrant 0 Jul 7 07:19 ../ lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 0 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 1 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 2 -> /dev/pts/1 lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 3 -> socket:[18087] lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 4 -> anon_inode:[eventpoll] lrwx------ 1 vagrant vagrant 64 Jul 7 07:20 5 -> socket:[18089]
クライアントから適当な文字列を入力するとechoされて接続がサーバーから切られる。
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'. Hello World! hoge hoge Connection closed by foreign host.
echoサーバーできた。
複数クライアントから接続してみる
複数のクライアントで同時に接続してみる。
クライアント1
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'. Hello World!
クライアント2
$ telnet 192.168.33.10 30000 Trying 192.168.33.10... Connected to 192.168.33.10. Escape character is '^]'.
最初に接続したクライアント1はサーバーから Hello World!
が送られてきてるけど、後に接続したクライアント2には何も送られてきてない。
サーバー側で確認してみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2019 vagrant 3u IPv4 18087 0t0 TCP *:30000 (LISTEN) echo_serv 2019 vagrant 5u IPv4 18100 0t0 TCP 192.168.33.10:30000->192.168.33.1:54396 (ESTABLISHED) $ ss -at4 | grep 30000 LISTEN 1 128 *:30000 *:* ESTAB 0 0 192.168.33.10:30000 192.168.33.1:54396 ESTAB 0 0 192.168.33.10:30000 192.168.33.1:54405
C言語でechoサーバー書いた時と同じように、1クライアントずつしかさばけていない模様。あの時は read
のブロッキングI/Oで1クライアントずつしか捌けなかった。
今回はどうだろう。straceで確認してみる。
$ sudo strace -p 2019 strace: Process 2019 attached epoll_pwait(4,
read
じゃなくて epoll_pwait
でブロックしてる。
straceしながら確認してみる。
$ strace ./echo_server
クライアント1からつなぐ。
[{EPOLLIN, {u32=1587633920, u64=139927327170304}}], 128, -1, NULL, 139927327170304) = 1 futex(0x5bcd50, FUTEX_WAKE, 1) = 1 futex(0x5bcc70, FUTEX_WAKE, 1) = 1 accept4(3, {sa_family=AF_INET, sin_port=htons(57992), sin_addr=inet_addr("192.168.33.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5 epoll_ctl(4, EPOLL_CTL_ADD, 5, {EPOLLIN|EPOLLOUT|EPOLLRDHUP|EPOLLET, {u32=1587633712, u64=139927327170096}}) = 0 getsockname(5, {sa_family=AF_INET, sin_port=htons(30000), sin_addr=inet_addr("192.168.33.10")}, [16]) = 0 setsockopt(5, SOL_TCP, TCP_NODELAY, [1], 4) = 0 write(5, "Hello World!\r\n", 14) = 14 read(5, 0xc4200b2000, 1024) = -1 EAGAIN (Resource temporarily unavailable) epoll_pwait(4, [{EPOLLOUT, {u32=1587633712, u64=139927327170096}}], 128, 0, NULL, 0) = 1 epoll_pwait(4,
accept
するときにノンブロッキングの指定をしている。
accept4(3, {sa_family=AF_INET, sin_port=htons(57992), sin_addr=inet_addr("192.168.33.1")}, [16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 5
read
したけどノンブロッキングにしてるのですぐに EAGAIN
が返ってきている。つまり、 read
ではブロックしていない。
read(5, 0xc4200b2000, 1024) = -1 EAGAIN (Resource temporarily unavailable)
クライアント2からもつなぐ。
[{EPOLLIN, {u32=1587633920, u64=139927327170304}}], 128, -1, NULL, 0) = 1 epoll_pwait(4,
epollからGoへの通知は発生しているものの、 accept
が呼ばれない。
read
システムコールでブロッキングはしていないが、GoのRead
から先に進んでいない。1つのgoroutineで処理を行っておりRead
でブロッキングしてるため、クライアントずつしか捌けていない。OSから見るとノンブロッキングI/Oな read
だけれども、Goから見るとブロッキングな Read
という感じ。
複数クライアントを捌く
クライアントから接続が来たらgoroutineを生成して対応する。C言語で書いた時のブロッキングI/Oのままfork
を使ってマルチプロセスにしたコードの同じように書ける。
package main import ( "io" "fmt" "net" ) func main() { l, err := net.Listen("tcp4", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") for { conn, err := l.Accept() if err != nil { panic(err) } go func(conn net.Conn) { _, err = conn.Write([]byte("Hello World!\r\n")) if err != nil { panic(err) } buf := make([]byte, 1024) n, err := conn.Read(buf) if err != nil { if err == io.EOF { return } panic(err) } _, err = conn.Write(buf[:n]) if err != nil { panic(err) } conn.Close() }(conn) } }
これで複数クライアント同時に接続しても大丈夫。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2128 vagrant 3u IPv4 18169 0t0 TCP *:30000 (LISTEN) echo_serv 2128 vagrant 5u IPv4 18171 0t0 TCP 192.168.33.10:30000->192.168.33.1:64033 (ESTABLISHED) echo_serv 2128 vagrant 6u IPv4 18173 0t0 TCP 192.168.33.10:30000->192.168.33.1:64042 (ESTABLISHED) $ ss -at4 | grep 30000 LISTEN 0 128 *:30000 *:* ESTAB 0 0 192.168.33.10:30000 192.168.33.1:64033 ESTAB 0 0 192.168.33.10:30000 192.168.33.1:64042
プロセス数とスレッド数を見てみる。
$ ps -L x | grep echo_server 2128 2128 pts/1 Sl+ 0:00 ./echo_server 2128 2129 pts/1 Sl+ 0:00 ./echo_server 2128 2130 pts/1 Sl+ 0:00 ./echo_server 2128 2131 pts/1 Sl+ 0:00 ./echo_server 2128 2132 pts/1 Sl+ 0:00 ./echo_server
プロセスは1個、スレッドは5個ある。Goを実行すると勝手にできるみたい。同時に接続するクライアントを6つにしてみる。
$ lsof -i:30000 COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME echo_serv 2128 vagrant 3u IPv4 18169 0t0 TCP *:30000 (LISTEN) echo_serv 2128 vagrant 5u IPv4 18171 0t0 TCP 192.168.33.10:30000->192.168.33.1:64033 (ESTABLISHED) echo_serv 2128 vagrant 6u IPv4 18173 0t0 TCP 192.168.33.10:30000->192.168.33.1:64042 (ESTABLISHED) echo_serv 2128 vagrant 7u IPv4 18221 0t0 TCP 192.168.33.10:30000->192.168.33.1:65256 (ESTABLISHED) echo_serv 2128 vagrant 8u IPv4 18223 0t0 TCP 192.168.33.10:30000->192.168.33.1:65265 (ESTABLISHED) echo_serv 2128 vagrant 9u IPv4 18225 0t0 TCP 192.168.33.10:30000->192.168.33.1:65270 (ESTABLISHED) echo_serv 2128 vagrant 10u IPv4 18227 0t0 TCP 192.168.33.10:30000->192.168.33.1:65275 (ESTABLISHED) $ ps -L x | grep echo_server 2128 2128 pts/1 Sl+ 0:00 ./echo_server 2128 2129 pts/1 Sl+ 0:00 ./echo_server 2128 2130 pts/1 Sl+ 0:00 ./echo_server 2128 2131 pts/1 Sl+ 0:00 ./echo_server 2128 2132 pts/1 Sl+ 0:00 ./echo_server
接続が6個になったにも関わらず、スレッド数は変わってない。接続数を10にしても変わらず。あくまでも1クライアントを1goroutineで捌いてるのであって、1クライアント1スレッドというわけではない。
Goのランタイム
I/OやスレッドをGoのランタイムがOSとの間に入って管理してくれている。
以下の本で、 GoのランタイムはミニOS って書いてあってすごく分かりやすい。
Webの記事はこっち。
イベント駆動で書いてみる
goroutineとchannelを使ってイベント駆動っぽいのも書いてみた。
package main import ( "fmt" "io" "net" ) type Event struct { Conn net.Conn Msg []byte } func asyncAccept(l net.Listener) chan net.Conn { ch := make(chan net.Conn) go func() { for { conn, err := l.Accept() if err != nil { panic(err) } _, err = conn.Write([]byte("Hello World!\r\n")) if err != nil { panic(err) } ch<-conn } }() return ch } func asyncRead(ch chan *Event, conn net.Conn) { go func() { buf := make([]byte, 1024) _, err := conn.Read(buf) if err != nil { if err == io.EOF { return } panic(err) } event := &Event{ conn, buf } ch<-event }() } func main() { l, err := net.Listen("tcp4", "0.0.0.0:30000") defer l.Close() if err != nil { panic(err) } fmt.Println("wait...") listener := asyncAccept(l) events := make(chan *Event) for { select { case conn := <-listener: asyncRead(events, conn) case event := <-events: _, err = event.Conn.Write(event.Msg) if err != nil { panic(err) } event.Conn.Close() } } }
goroutineとchannelを使うとGoの世界の中で色々な書き方ができて楽しいですね!