6月からDMM.comラボの六本木オフィスでミドルウェアを作るエンジニアインターンをしている@kawasin73です。
DMM.comラボではluaで実装されたKVS(キーバリューストア)を利用しています。
これは、TCPの上で独自プロトコルで通信しており、URIのPathがKeyとなり最長共通接頭辞検索をするKVSで、社内でluaの皮を被ったC言語で実装されたものが運用されています。
この度、このKVSをGo言語で再実装することになり、設計は既存のミドルウェアを踏襲した形で DMM.com ラボの方が行い、実装は僕がすることになりました。
Go言語の実装手法(goroutine や channel等)については僕が学びながらそれについて都度相談するというスタイルで行なっています。
その開発記を連載しています。
今回は、実装の第1ステップとして、TCPのエコーサーバーを実装していきます。
第1回 GoでTCPサーバー上で独自プロトコルKey-Value Storeを実装する(知識編)
第2回 GolangでハイパフォーマンスなTCPサーバーを実装する(下準備編)
第3回 Go言語でTCPのEchoサーバーを実直に実装する
第4回 Go言語でシグナルハンドリングをするTCPのEchoサーバーを実装する
第5回 Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その1)
第6回 Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その2)
第7回 GolangでTCPサーバーに再起動とGraceful Shutdownを実装する
はじめに
開発環境は以下の通りです。
- OS: macOS 10.12.5
- Go version: 1.8.3
- IDE: Gogland
今回開発した内容は、こちらのgithubリポジトリで公開しています。
echo(エコー)サーバー とは
echoサーバーとは、受け取ったリクエスト内容をそのままレスポンスするサーバーのことです。
普通のサーバーは受け取ったリクエストに対してレスポンス内容を生成して送りますが、echoサーバーでは内部の処理を行いません。
そのため、利用する言語のTCPサーバーとしての理論的な最高のパフォーマンスを出すことができます。
サーバーを構築するための基盤となる第一歩と言えるでしょう。
このリポジトリには、echoサーバーが様々な言語で実装されています。
また、ベンチツール(bench.sh
)も、内包されているため、言語ごとの最高値を試すことができると思います。
実装していく
https://github.com/methane/echoserver では、io.Copy
を使って、echoサーバーを実装しています。
しかし、これでは後々手を入れにくいので、Read
とWrite
をそれぞれ使ったechoサーバーの実装をしました。
とりあえず書いてみた。
これらは、リポジトリのtcp1
ディレクトリにあります。
package main
import (
"log"
"net"
)
func handleConnection(conn *net.TCPConn) {
defer conn.Close()
buf := make([]byte, 4*1024)
for {
n, err := conn.Read(buf)
if err != nil {
if ne, ok := err.(net.Error); ok {
switch {
case ne.Temporary():
continue
}
}
log.Println("Read", err)
return
}
n, err = conn.Write(buf[:n])
if err != nil {
log.Println("Write", err)
return
}
}
}
func handleListener(l *net.TCPListener) error {
defer l.Close()
for {
conn, err := l.AcceptTCP()
if err != nil {
if ne, ok := err.(net.Error); ok {
if ne.Temporary() {
log.Println("AcceptTCP", err)
continue
}
}
return err
}
go handleConnection(conn)
}
}
func main() {
tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:12345")
if err != nil {
log.Println("ResolveTCPAddr", err)
return
}
l, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
log.Println("ListenTCP", err)
return
}
err = handleListener(l)
if err != nil {
log.Println("handleListener", err)
}
}
解説
net.ListenTCP
Goのnet
パッケージのnet.Listen
関数を利用することでListener
を生成することができます。
しかし、今回はnet.ListenTCP
を使っています。
net.Listen
関数の返り値は、net.Listen
というインターフェースであり、net.ListenTCP
で得られる*net.TCPListener
より使えるAPIが少ないからです。
具体的には、*net.TCPListener
をAcceptTCP
して得られる、*net.TCPConn
は、CloseWrite
,CloseRead
メソッドがあり、ソケットのClose
をより細かく制御できます。
これらのAPIを後々利用するため、最初からnet.ListenTCP
を使っています。
net.Error
AcceptTCP()
, Read(buf)
それぞれの内部実装(libexec/src/net/tcpsock.go
)を見ると、発生しうるエラーは、syscall.EINVAL
, *net.OpError
のどちらかです。
*net.OpError
は、func (e *OpError) Timeout() bool
と func (e *OpError) Temporary() bool
のメソッドを持っており、回復可能なエラーを簡単に見分けることができます。
また、net.Error
は、
// An Error represents a network error.
type Error interface {
error
Timeout() bool // Is the error a timeout?
Temporary() bool // Is the error temporary?
}
というインターフェイスであり、*net.OpError
は、net.Error
を満たしています。
そのため、net.Error
にキャストしてエラー種類の判別を行いました。
一方、syscall.Errno
にも
func (e Errno) Temporary() bool {
return e == EINTR || e == EMFILE || e == ECONNRESET || e == ECONNABORTED || e.Timeout()
}
func (e Errno) Timeout() bool {
return e == EAGAIN || e == EWOULDBLOCK || e == ETIMEDOUT
}
というメソッドがあり、Temporary()
はTimeout()
を内包していることがわかります。
AcceptTCP()
は内部的にはAccept
システムコールを、 Read(buf)
は内部的には、Read
システムコールを呼んでいます。
それぞれのシステムコールから発生しうるエラーは決まっています。それらが、Goの中でどのようにハンドリングするのかを調査し決定しました。
Accept
のシステムコールエラー
こちらの man page を参考にしました。
ハンドリングの「落とす」とは、回復不能なエラーのためプロセスを終了させるということです。
ハンドリングの「timeout」 と 「temporary」はそれぞれ、Timeout()
, Temporary()
に当てはまるため、for
文内でcontinue
するということです。
エラー名 | 説明 | ハンドリング |
---|---|---|
EAGAIN | ソケットが非停止になっていて、 かつ受付け対象の接続が存在しない。 POSIX.1-2001 は、この場合にどちらのエラーを返すことも認めており、 これら 2 つの定数が同じ値を持つことも求めていない。 したがって、移植性が必要なアプリケーションでは、両方の可能性を 確認すべきである。 | timeout. golangではAcceptTCP() の中で待つため、ハンドリング必要なし |
EWOULDBLOCK | EAGAINと同じ | timeout |
EBADF | ディスクリプターが不正。 | 落とす |
ECONNABORTED | 接続が中止された。 | temporary golangではAcceptTCP() の中で待つため、ハンドリング必要なし |
EFAULT | addr 引き数がユーザーアドレス空間の書き込み可能領域にない。 | 落とす |
EINTR | 有効な接続が到着する前に捕捉されたシグナルによって システムコールが中断された。 signal(7) 参照。 | temporary |
EINVAL | ソケットが接続待ち状態ではない。もしくは、 addrlen が不正である (例えば、負の場合など)。 | 落とす |
EINVAL | (accept4()) flags に不正な値が指定されている。 | 落とす |
EMFILE | 1プロセスがオープンできるファイルディスクリプター数の上限に達した。 | temporary |
ENFILE | オープンされたファイルの総数がシステム全体の上限に達していた。 | temporary |
ENOBUFS, ENOMEM | メモリーが足りない。 多くの場合は、システムメモリーが足りないわけではなく、 ソケットバッファーの大きさによるメモリー割り当ての制限である。 | ? |
ENOTSOCK | ディスクリプターはソケットではなくファイルを参照している。 | 落とす |
EOPNOTSUPP | 参照しているソケットの型が SOCK_STREAM でない。 | 落とす |
EPROTO | プロトコルエラー。 | 落とす |
EPERM | 上記に加えて、Linux の accept() は以下のエラーで失敗する:ファイアウォールのルールにより接続が禁止された。 | 落とす |
AcceptTCP()
から発生しうるエラーで回復可能なエラーは、全てTemporary() == true
になることがわかりますので、追加のハンドリングは必要ありません。
ENOBUFS
, ENOMEM
については、Accept
だけが失敗し、既にAccept
しているコネクションのRead
とWrite
には影響を与えないエラーです。
通常であれば、エラーログを吐き、一定の時間Sleep
してからAccept
を再試行するなどの処理をして落とさないことが好ましいですが、今回はプロセスを落とすことにします。
Read
のシステムコールエラー
こちらの man page を参考にしました。
エラー名 | 説明 | ハンドリング |
---|---|---|
EAGAIN | ファイルディスクリプター fd がソケット以外のファイルを参照していて、 非停止 (nonblocking) モード (O_NONBLOCK) に設定されており、読み込みを行うと停止する状況にある。 | timeout. golangではRead() の中で待つため、ハンドリング必要なし |
EAGAIN または EWOULDBLOCK | ファイルディスクリプター fd がソケットを参照していて、非停止 (nonblocking) モード (O_NONBLOCK) に設定されており、読み込みを行うと停止する状況にある。 POSIX.1-2001 は、この場合にどちらのエラーを返すことも認めており、 これら 2 つの定数が同じ値を持つことも求めていない。 したがって、移植性が必要なアプリケーションでは、両方の可能性を 確認すべきである。 | timeout |
EBADF | fd が有効なファイルディスクリプターでないか、読み込みのために オープン (open) されていない。 | 終了 |
EFAULT | buf がアクセス可能なアドレス空間の外にある。 | 終了 |
EINTR | 何のデータも読み込まないうちにシグナルに割り込まれた。 signal(7) 参照。 | temporary |
EINVAL | fd は読み込みに適していないオブジェクトを参照している。 もしくは、ファイルが O_DIRECT フラグを指定してオープンされているが、 buf に指定されたアドレス、 count に指定された値、 現在のファイルオフセットのいずれかの アラインメントが不適切である。 | 終了 |
EINVAL | fd が timerfd_create(2) の呼び出しで作成されたが、 read() に間違ったサイズのバッファーが渡された。 さらなる情報は timerfd_create(2) を参照のこと。 | 終了 |
EIO | I/O エラー。これは例えばプロセスがバックグランドプロセスグループで、それを制御している端末から読み込もうとし、 SIGTTIN が無視 (ignore) または禁止 (blocking) されている場合や、 そのプロセスグループが孤立 (orphan) している場合に起こる。 またディスクやテープを読んでいる時に低レベル I/O エラー が発生した場合にも起こる。 | 起こり得ない |
EISDIR | fd がディレクトリを参照している。 | 終了 |
Read
も、Accept
と同様に、発生しうるエラーで回復可能なエラーは、全てTemporary() == true
になることがわかりますので、追加のハンドリングは必要ありません。
バッファーサイズ
Read
のバッファーサイズは、
buf := make([]byte, 4*1024)
と、4KB に設定しました。
このバッファーは、コネクションごとに作成されるため、あまり大きな値を設定するとメモリを無駄に消費してしまいます。
逆に、小さな値を設定すると、何度もRead
を行うことになり、オーバーヘッドが生じてしまいます。
それぞれのサービスの特性(プロトコルや利用用途など)を考慮して、バッファーのサイズを決定するのが良いです。
どのように動くか
TCPサーバーがどのような順序で動くかは以下の図の通りです。
Client Connection Flow
+--------+ +----------------+ +------------------+
| client | | handleListener | | handleConnection |
+----+---+ +-------+--------+ +---------+--------+
| | |
| Connect | |
+------------------> |
| | |
| Accept() |
| | goroutine |
| +--------------------->
| | |
| | +--------------------------------------+
| send("hello") | | | |
+----------------------------------------> |
| | | | |
| | | Read() Echo Loop |
| | | | |
| | | Write() |
| | | Reply Message | |
<----------------------------------------+ |
| | | | |
| | +--------------------------------------+
| | |
詳しくは、第1回のTCPサーバーの立ち上げ方の章を参照してください。
動かしてみる
以下のコマンドでechoサーバーが立ち上がります。
go run main.go
ターミナルを複数画面開いて、
telnet localhost 12345
とすると、telnetで接続できます。何か文字列を打ち込んで送信し(Enter)同じ文字列がレスポンスされることを確認しましょう。
接続を切断するためには、Ctrl + ]
を押した後に、q
を入力してEnterを押すと切断できます。
切断すると、サーバー側には、
2017/07/18 17:57:07 Read EOF
というログが表示されると思います。
最後に
ここまでで、echoサーバーの最小限の実装を行いました。
次回は、Graceful Shutdown を実現するためにシグナルのハンドリング処理の実装を行なっていきます。