Goでechoサーバーを書いた時のメモ

Goでechoサーバーを書いた時のシステムコールはどうなってるのかなぁ、と思って調べてみた時の日記です。

前にCでechoサーバーを書いた時は以下のような実装をしました。

  1. socket でソケットを作成する
  2. bind でソケットをバインドする
  3. listen でListen状態になる
  4. accept でコネクションを取り出す
  5. read でクライアントから文字列を受け取る
  6. write でクライアントへ文字列を返す

Goで書くとどうなるでしょうか?

環境はVagrantUbuntuで。

$ 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ステップが行われた模様。

  1. socket でソケットを作成する
  2. bind でソケットをバインドする
  3. 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の記事はこっち。

ascii.jp

イベント駆動で書いてみる

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の世界の中で色々な書き方ができて楽しいですね!

ascii.jp