act-act about projects rss

Java NIO I/O Multiplexing

Introduction

たまたま仕事でnioを使っているコードを読む機会があり、それをキッカケにnioのI/O多重化を試してみました。

Single Process, Single Thread, I/O Multiplexing

「シングルプロセス、シングルスレッドで多重化」について、以下のページにて説明されていました。

インフラ技術の実験室 - Linuxネットワークプログラミング(シングルプロセス、シングルスレッドで多重化)

このページを読んで、I/0多重化によってaccept/recvを複数待ち受けることにより、シングルスレッドでも複数のリクエストに応答できると理解しました。

Java NIO I/O Multiplexing

次に、nioでI/O多重化を実現する方法を調べてみました。

「New I/Oで高速な入出力」第6回 ノンブロッキングI/Oを使ってみる:ITpro

nioにおけるI/O多重化はSelectableChannel/Selector/SelectionKeyを使って実現できることが分かり、これらを用いてecho serverを書いてみました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
    package net.wrap_trap.example.nio;

    import java.io.IOException;
    import java.net.InetSocketAddress;
    import java.nio.ByteBuffer;
    import java.nio.channels.SelectionKey;
    import java.nio.channels.Selector;
    import java.nio.channels.ServerSocketChannel;
    import java.nio.channels.SocketChannel;
    import java.util.Iterator;
    import java.util.Set;

    public class EchoServer {

        private ServerSocketChannel serverChannel;
        private Selector selector;

        public EchoServer() throws IOException {
            serverChannel = ServerSocketChannel.open();
            serverChannel.socket().setReuseAddress(true);
            serverChannel.socket().bind(new InetSocketAddress(8000));

            serverChannel.configureBlocking(false);
            selector = Selector.open();
            serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        }

        public void start() throws IOException {
            System.out.println("start");
            try {
                while (selector.select() > 0) {
                    System.out.println("select loop");
                    Set<SelectionKey> keys = selector.selectedKeys();
                    for (Iterator<SelectionKey> it = keys.iterator(); it.hasNext();) {
                        SelectionKey key = it.next();
                        it.remove();

                        if (key.isAcceptable()) {
                            System.out.println("accept");
                            SocketChannel channel = serverChannel.accept();
                            channel.configureBlocking(false);
                            channel.register(key.selector(), SelectionKey.OP_READ);
                        } else {
                            SocketChannel channel = (SocketChannel) key.channel();
                            if (key.isReadable()) {
                                System.out.println("read");
                                ByteBuffer buffer = ByteBuffer.allocate(4096);
                                channel.read(buffer);
                                buffer.flip();
                                channel.write(buffer);
                            }
                        }
                    }
                }
            } finally {
                for (SelectionKey key : selector.keys()) {
                    try {
                        key.channel().close();
                    } catch (IOException ignore) {
                        ignore.printStackTrace();
                    }
                }
            }
        }

        public static void main(String[] args) throws IOException {
            EchoServer echoServer = new EchoServer();
            echoServer.start();
        }
    }

クライアントは以下のコードです。

1
2
3
4
5
6
7
8
9
10
11
12
13
    # -*- coding: utf-8 -*-


    require 'socket'

    s = Socket.new(Socket::AF_INET, Socket::SOCK_STREAM, 0)
    sockaddr = Socket.sockaddr_in(8000, '127.0.0.1')
    s.connect(sockaddr)
    s.write "Hello,"
    puts "Received #{s.recv(1024)}"
    s.write "world."
    puts "Received #{s.recv(1024)}"

    s.close

Run

client.rbを実行すると以下のように返ってきます。

1
2
3
    C:\Users\masayuki\workspace\nio-example>c:\Ruby\Ruby-1.9.3\bin\ruby client.rb
    Received Hello,
    Received world.

クライアントの方は期待通りの結果でしたが、サーバ側の標準出力にはずっと文字が出続けてしまいます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    select loop
    read
    select loop
    read
    select loop
    read
    select loop
    read
    select loop
    read
    select loop
    read
    select loop
    read

どうもbusy loopになっているようです。 Windows nativeの実装の問題かと考え、Linuxで同じコードを実行したものの、やはりbusy loopになってしまいます。

Why do the busy loop occur?

readに関する期待していた振る舞いは以下でした。

  1. SocketChannel#acceptしたchannelに対し、OP_READを指定してSelectorに登録
  2. Selector#select()を呼び出してクライアントからのsend(コード上ではs.write)を待つ(blocking)
  3. クライアントでsendするとSelector#select()から処理が戻る
  4. selectorからreadableなSelectionKeyを取り出して、sendされた内容を取得

2.の処理で、何故OP_READで登録したchannelがSelector#selectでブロッキングされないのかが理解できませんでした。サーバの出力内容を見る限り、OP_ACCEPTで登録した方はブロッキングされているように見えます。

When do the busy loop start?

サーバから見ると、accept後にOP_READで登録した後、すぐにbusy loopになっているように見えます。それを確認する為、クライアント側のコードでconnect後に数秒待つようにし、その間のサーバ側の状態を確認することにしました。

1
2
3
4
    s.connect(sockaddr)
    sleep 20 # when do the busy loop start?

    s.write "Hello,"
    

すると、クライアント側でsleepしてる間、サーバ側の出力は以下となっていました。

1
2
3
    start
    select loop
    accept

クライアントのsleepが終わった後、サーバ側でread loopが始まったことから、クライアントから最初のsendが来るまでの間はブロックできていることになります。つまり、クライアントが側の最初のsendに対してサーバ側でreadするところから、OP_READの状態がずっと続いてしまってます。

クライアントへのレスポンスを返した後、SocketChannel#close()で閉じ、またクライアントも1回のやり取りでsocketを閉じてしまえば問題はないのですが、今回の例にあるように、1本の接続で複数回やり取りを行うことができなくなってしまいます。

この例では1回のsendの終わりを明示的に示すものが無いので、サーバ側でsendの終わりを知ることができないのですが、仮にsendの終端を定義したとしても、「次のクライアントからのsendまで待つ」ということができない限りこのbusy loopの事象は解決できません。

もちろんread時にバッファに読み込んだサイズが0以下であればちょっとsleepする、というやり方でもbusy loopは回避できますが、そもそもSelectorにOP_READを監視させている意味が無くなってしまいます。

Cancel and Re-register OP_READ flag

最初のsendまではブロックされていることから、Selectorに登録しているOP_READの監視を一旦リセットできれば良いのではないかと考えました。そこで、サーバ側でクライアントに応答を返した後、SelectionKey#cancel()を一旦行い、Selectorに対するOP_READの監視を解除した後、再びSocketChannel#register(…, SelectionKey.OP_READ)を呼び出すようにしてみました。

1
2
3
4
5
6
7
8
9
10
    if (key.isReadable()) {
        System.out.println("read");
        ByteBuffer buffer = ByteBuffer.allocate(4096);
        channel.read(buffer);
        buffer.flip();
        channel.write(buffer);
        Selector selector = key.selector();
        key.cancel();
        channel.register(selector, SelectionKey.OP_READ);
    }

しかし、このコードを実行するとクライアントからの最初のsendの契機で以下の例外が発生します。

1
2
3
4
5
6
7
    Exception in thread "main" java.nio.channels.CancelledKeyException
            at sun.nio.ch.SelectionKeyImpl.ensureValid(Unknown Source)
            at sun.nio.ch.SelectionKeyImpl.interestOps(Unknown Source)
            at java.nio.channels.spi.AbstractSelectableChannel.register(Unknown Source)
            at java.nio.channels.SelectableChannel.register(Unknown Source)
            at net.wrap_trap.example.nio.EchoServer.start(EchoServer.java:53)
            at net.wrap_trap.example.nio.EchoServer.main(EchoServer.java:71)

なお、上記コードの最後のSocketChannel#registerの呼び出しをコメントアウトすると、OP_READの監視が解かれます。それ以降、クライアント側でsendしても、Selector#selectのブロッキングが解除されません。

Set OP_WRITE flag

行き詰ったのでググったところ、dWに以下のような記述がありました。

パフォーマンスの目: NIOでMegaJogosを強化する

実はコードの中に、NIOセレクタを使った事がない人には分かりにくい、ちょっとしたバグがあるのです。セレクタへのコールは常に即時に返るのですが、読むべきデータが無く、また受け付けるべき接続も無いので、このコールは単純にselect()コールにループして戻り、これでCPU使用率が100%になってしまうのです。面白い事に、I/OサービスのスレッドをループするとCPUを完全使用してしまうように見えるのですが、他のスレッドは必要と思われるだけ、いくらでもプロセッサー・サイクルが得られるので、目立つようなパフォーマンス低下を引き起こす事はないのです。

これを見る限り、busy loopによるCPU使用率の高騰は問題ではないようです。しかし、この後以下のように続いています。

ここでの鍵は、ソケットをWRITEモードで登録するのは、実際に書くべきデータがある場合に限定すべきだということです。書き込みのプロセスは部分的に成功しているだけかも知れないので、読み込みよりもやや複雑です。部分的に成功している場合にはソケットをWRITEモードで登録しておき、そのソケット・チャネルが書き込みできるようになる度に、さらに多くのデータを書き込むようにします。このように、やや複雑な手続きになってしまうのはソケットを多重化する欠点の一つです。長所としては当然ながら、複数ソケットを処理するのに必要なスレッドが少なくてすみ、つまりはサーバーがよりスケーラブルになるのです。スレッドが少なくなればVMが管理するスレッドも少なくなり、コンテキスト・スイッチも減り、メモリ使用も減るのです。 話をコードに戻すと、ソケットをREADモードでのみ登録すべきなのです:channel.register(selector, SelectionKey.OP_READ)。予期した通り、この修正を加えるとCPU利用率が大幅に減りました。これでMegaJogos は、何千ものスレッドを使う代わりに1つのスレッドだけで全ゲーム・プレーヤーにサービスすることができるようになったのです。

ということで、書き込む際にSelectionKeyにOP_WRITEを指定し、書き込みが終わったらOP_READに戻すようにしてみました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    } else if (key.isReadable()) {
        SocketChannel channel = (SocketChannel) key.channel();
        System.out.println("read");
        ByteBuffer buffer = ByteBuffer.allocate(4096);
        if (channel.read(buffer) > 0) {
            buffer.flip();
            buffers.add(buffer);
            key.interestOps(SelectionKey.OP_WRITE);
        }
    } else if (key.isWritable()) {
        System.out.println("write");
        SocketChannel channel = (SocketChannel) key.channel();
        for (ByteBuffer buffer : buffers) {
            channel.write(buffer);
        }
        key.interestOps(SelectionKey.OP_READ);
    }

read/writeの受け渡しにbuffersというインスタンス変数を用いてますが、本来であれば当然NGです。が、取り合えずbusy loopが無くなってCPU使用率が下がるかどうか確認してみましたが、残念ながらbusy loopはこのコードでも起きています。

Conclusion

このまま試行錯誤しても解決しそうにないので、nioでI/O多重化を行っているOSSの実装を何か見てみます。