Help us understand the problem. What is going on with this article?

Go言語でTCPのEchoサーバーを実直に実装する

More than 1 year has passed since last update.

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リポジトリで公開しています。

https://github.com/dmmlabo/tcpserver_go

echo(エコー)サーバー とは

echoサーバーとは、受け取ったリクエスト内容をそのままレスポンスするサーバーのことです。
普通のサーバーは受け取ったリクエストに対してレスポンス内容を生成して送りますが、echoサーバーでは内部の処理を行いません。
そのため、利用する言語のTCPサーバーとしての理論的な最高のパフォーマンスを出すことができます。

サーバーを構築するための基盤となる第一歩と言えるでしょう。

https://github.com/methane/echoserver

このリポジトリには、echoサーバーが様々な言語で実装されています。
また、ベンチツール(bench.sh)も、内包されているため、言語ごとの最高値を試すことができると思います。

実装していく

https://github.com/methane/echoserver では、io.Copy を使って、echoサーバーを実装しています。

しかし、これでは後々手を入れにくいので、ReadWriteをそれぞれ使ったechoサーバーの実装をしました。

とりあえず書いてみた。

これらは、リポジトリtcp1ディレクトリにあります。

main.go
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.TCPListenerAcceptTCPして得られる、*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() boolfunc (e *OpError) Temporary() bool のメソッドを持っており、回復可能なエラーを簡単に見分けることができます。

また、net.Errorは、

libexec/src/net/net.go
// 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 にも

libexec/src/syscall/syscall_unix.go
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 のシステムコールエラー

https://linuxjm.osdn.jp/html/LDP_man-pages/man2/accept.2.html

こちらの 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しているコネクションのReadWriteには影響を与えないエラーです。
通常であれば、エラーログを吐き、一定の時間SleepしてからAcceptを再試行するなどの処理をして落とさないことが好ましいですが、今回はプロセスを落とすことにします。

Read のシステムコールエラー

https://linuxjm.osdn.jp/html/LDP_man-pages/man2/read.2.html

こちらの 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 を実現するためにシグナルのハンドリング処理の実装を行なっていきます。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away