ノンブロッキングI/Oがよく解っていなかったので作りながら調べてみた

モチベーション

WEBエンジニアとして活動してきてますが、技術的な棚卸しとして、普段良く使っているツールがどういったものかを少し深掘りしてみようという活動の一環。

nginx軽くて早くてお世話になっていますが、どうして軽くて早いのかよく解っておらず、ノンブロッキングI/Oという言葉もふわっと流していたので、深掘りしてみようという感じです。

ざっと色々なブログなどを見てみたのですが、いまいち消化できずにいたので、せっかくなので少し手を動かしながらやるか!ということで、少し勉強中のgolangで書いてみた。

WEBサーバーにおいてのノンブロッキングI/Oの概要

C10K問題 の解決策になる技術。

1スレッド1コネクションだとメモリ空間の圧迫やCPUのコンテキストスイッチなどで、1万を超えるような多数のコネクションを捌ききれないという問題があった。

そこで、I/O待ちが起こらないような実装(I/O処理が即時に行えるようになったた時に通知を受けて、それを処理していく)をとり、

1スレッドで多数のコネクションを処理できるような設計をとることで、1万を超えるような多数のコネクションが来てもスケールしやすくすることができる。

色々な記事で解りやすく説明されています。

TheC10kProblem - 「C10K問題」(クライアント1万台問題)とは、ハードウェアの性能上は問題がなくても、あまりにもクライアントの数が多くなるとサーバがパンクする問題のこと

blog.takanabe.tokyo

takezoe.hatenablog.com

また、C10K問題が話題になってからだいぶ時代が変わってきているので、どうしても実装が複雑になってしまうノンブロッキングI/Oはもうそれほど需要がないんじゃ?という記事もありました。

Kazuho@Cybozu Labs: 「サーバ書くなら epoll 使うべき」は、今でも正しいのか

コードを書いてみる

ノンブロッキングだとか、イベント駆動とか、I/O多重化とか色々なワードが使われていて、いまひとつ解っていないなあという気がしたので、作りながら理解してみることに。

TCPで送られた内容をそのまま返す、エコーサーバーをブロッキング、ノンブロッキング的な形でそれぞれ実装してみます。

コードの全容は

golang_labo/tcp_echo_server at master · kitabatake/golang_labo · GitHub

にアップしてあります。

"golang epoll" でぐぐって出てきた Golang example for using epoll · GitHub をかなり参考にしました。感謝。

ブロッキングI/Oの実装

新しいTCPの接続依頼はメインスレッドで処理し、接続確立後のコネクションのエコー処理はgoroutineで別スレッドで処理する形。 コネクションごとにスレッドが用意され、その中でソケットの送信待ち(ブロッキング)が起こる。

package main

import (
    "syscall"
    "net"
    "fmt"
    "os"
)

func exit(err error) {
    fmt.Println(err)
    os.Exit(1)
}

// 接続待ちソケットの準備
// 指定されたIPアドレス、ポート番号のソケットのファイルディスクリプタを返す
func initListenFd(ipAddr string, port int) (int, error){
    listenFd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_STREAM, 0)
    if err != nil {
        return -1, err
    }

    addr := syscall.SockaddrInet4{Port: port}
    copy(addr.Addr[:], net.ParseIP(ipAddr).To4())

    syscall.Bind(listenFd, &addr)
    syscall.Listen(listenFd, 10)
    return listenFd, nil
}

// コネクションごとにgoroutineで呼び出され、
// ソケットからreadした内容をそのまま返す
func echo(fd int) {
    defer syscall.Close(fd)
    var buf [32 * 1024]byte
    for {
        fmt.Println("waiting Read:", fd)
        nbytes, e := syscall.Read(fd, buf[:]) // blocking!
        if nbytes > 0 {
            fmt.Printf(">>> %s", buf)
            syscall.Write(fd, buf[:nbytes]) // blocking!
            fmt.Printf("<<< %s", buf)
        }
        if e != nil {
            fmt.Println("echo error:", e)
            break
        }
    }
}

func main() {
    var listenFd int
    var err error

    listenFd, err = initListenFd("0.0.0.0", 3000)
    if err != nil {
        exit(err)
    }
    defer syscall.Close(listenFd)

    for {
        fmt.Println("waiting new connection")
        connFd, _, err := syscall.Accept(listenFd) // blocking!
        if err != nil {
            exit(err)
        }
        fmt.Println("connection accepted:", connFd)
        go echo(connFd)
    }
}

コメントで // blocking! と書いたところでI/O待ちが生じます。

下記が実行結果のスクショです。

f:id:sissoko:20180118194720p:plain

syscall.Accept, syscall.Read でそれぞれ処理が止まっているのが解ると思います。

この実装だと、1スレッドで1つの役割しかこなせないですね。

ノンブロッキングI/O イベント駆動の実装

続いてノンブロッキングでイベント駆動の実装を見てみようと思います。

上記ブロッキングでの実装に比べてだいぶ複雑になっています。

まず大事なのが epoll というシステムコールです。

何かというと、ソケットファイルなどのファイルディスクリプタを登録しておくと、ファイルディスクリプタが読み込み可能になったときなどに通知してくれるものです。

これを使うと、ReadやAcceptで待つのではなく、 すぐに処理できる状態の イベント を待ち、イベント内容、ファイルディスクリプタに応じて適切に処理をするという形になります。

main処理の可読性を上げるために、epoll周りの処理はepoll.goに分割する形にしてみました。

epoll.go

// epoll周りの処理をまとめた感じ
package main

import (
    "syscall"
    "fmt"
)

const (
    EPOLLET        = 1 << 31
    MaxEpollEvents = 32
)

type epoll struct {
    fd int
}

func initEpoll() (epoll, error) {
    epfd, err := syscall.EpollCreate1(0)
    if err != nil {
        return epoll{}, err
    }
    return epoll{fd: epfd}, nil
}

func (ep *epoll) close() {
    syscall.Close(ep.fd)
}

func (ep *epoll) wait() ([]syscall.EpollEvent, error) {
    var events [MaxEpollEvents]syscall.EpollEvent
    nevents, err := syscall.EpollWait(ep.fd, events[:], -1)
    if err != nil {
        return []syscall.EpollEvent{}, err
    }

    return events[:nevents], nil
}

func (ep *epoll) add(fd int, eventOperations uint32, edgeMode bool) error {
    fmt.Println("epoll add:", fd)
    var event syscall.EpollEvent
    event.Events = eventOperations
    if edgeMode {
        event.Events |= EPOLLET
    }
    event.Fd = int32(fd)
    if err := syscall.EpollCtl(ep.fd, syscall.EPOLL_CTL_ADD, fd, &event); err != nil {
        return err
    }
    return nil
}

main.go一部抜粋

// listenFdなどの準備処理は上記のブロッキング実装と同じです

// イベントハンドラー
// syscall.EpollEventのeventsによって実行する処理を切り替える
// とりあえずechoを動くようにした手抜き実装
func handleConnectedEvent(event syscall.EpollEvent)  {
    fmt.Println("event:", event)
    switch event.Events {
    case syscall.EPOLLIN:
        echo(int(event.Fd))
    case syscall.EPOLLIN | syscall.EPOLLRDHUP:
        fmt.Println("connection close: ", event.Fd)
        syscall.Close(int(event.Fd))
    }
}

// ソケットからreadした内容をそのまま返す
func echo(fd int) {
    var buf [32 * 1024]byte
    nbytes, _ := syscall.Read(fd, buf[:])
    if nbytes > 0 {
        fmt.Printf(">>> %s", buf)
        syscall.Write(fd, buf[:nbytes])
    }
}

func main() {
    var listenFd int
    var err error

    listenFd, err = initListenFd("0.0.0.0", 3000)
    if err != nil {
        exit(err)
    }
    defer syscall.Close(listenFd)

    var ep epoll
    ep, err = initEpoll()
    if err != nil {
        exit(err)
    }
    defer ep.close()

    add := ep.add(listenFd, syscall.EPOLLIN,false)
    if err = add; err != nil {
        exit(err)
    }

    var events []syscall.EpollEvent
    for {
        // イベント通知待ち
        events, err = ep.wait()
        if err != nil {
            exit(err)
        }

        for _, event := range events {
            // ファイルディスクリプタが新規接続待ちのやつだったら、新規接続処理
            if int(event.Fd) == listenFd {
                connFd, _, err := syscall.Accept(listenFd)
                if err != nil {
                    exit(err)
                }
                defer syscall.Close(connFd)

                // コネクション確立後の通信用ソケットのファイルディスクリプタを epoll に登録する
                if e := ep.add(connFd, syscall.EPOLLIN | syscall.EPOLLRDHUP,true); e != nil {
                    exit(err)
                }
            } else {
                // コネクション確率済みの処理は handleConnectedEvent を呼び出す
                handleConnectedEvent(event)
            }
        }
    }
}

goroutineを使っていないので、全てメインスレッドで処理しています。

ただこの実装はイベント駆動ではあるんですが、ノンブロッキングではない。。

ノンブロッキングとは、ファイルディスクリプタに対して、 set_nonblocking システムコールを呼び出すことで設定ができ、

そうすると、ReadやAcceptなどで、ファイルディスクリプタが即時に処理ができない状態のときは一切待ち状態がおきず、EAGAIN(今は処理できないよ)というエラーが帰ってきます。

このハンドリングが若干面倒だったのと、設定しなくても epoll でのイベント駆動の実装は、ちょっと動かしてみる程度なら問題なく動いたので妥協しました。

ちゃんと実装する場合は、

Man page of EPOLL

の内容をきっちり反映するのかなと思います。

まとめ

ノンブロッキング、イベント駆動、I/O多重化、nginxでの実装、epoll, kqueue などのような雑多なものを、まるっと無理やり理解しようとしてもやっとしていただけで、

それぞれのワードが表していることや、それぞれのシステムコールが実現していることはシンプルでした。

落ち着いて整理するのが大事だなと。

結局ノンブロッキングの設定で実装はしませんでしたが、腑に落ちたでの良しとします。

C10K問題のオリジナルの記事が書かれたのが 2002年らしいということで、今更感がある内容な気もしますが、

ほぼブラックボックスだったnginxの事が少し解ったり、普段読まないような低レベルのシステムコールのマニュアルを読んだりと、意義はあったんじゃないかなと思います。