web-dev-qa-db-ja.com

Java高負荷NIO TCPサーバー

研究の一環として、Javaで高負荷のTCP/IPエコーサーバーを作成しています。約3〜4kのクライアントにサービスを提供し、1秒あたりに絞り出せる最大のメッセージを確認したいと思います。メッセージサイズは非常に小さく、最大100バイトです。この作品には実用的な目的はなく、研究だけです。

私が見た多くのプレゼンテーション(HornetQベンチマーク、LMAX Disruptorトークなど)によると、実際の高負荷システムは1秒あたり数百万のトランザクションを処理する傾向があります(Disruptorは約6ミルとHornet-8.5について言及したと思います)。たとえば、 この投稿 は、最大4,000万のMPSを達成できることを示しています。それで、私はそれを現代のハードウェアが何ができるべきかについての大まかな見積もりとしてとらえました。

最も単純なシングルスレッドNIOサーバーを作成し、負荷テストを開始しました。ローカルホストで約100kMPSしか取得できず、実際のネットワークでは25kしか取得できないことに少し驚いていました。数字はかなり小さく見えます。私はWin7x64、コアi7でテストしていました。 CPU負荷を見ると、1つのコアのみがビジーであり(シングルスレッドアプリで予想されます)、残りはアイドル状態です。ただし、8つのコアすべて(仮想を含む)をロードしたとしても、MPSは80万を超えず、4000万に近くさえありません:)

私の質問は、クライアントに大量のメッセージを配信するための典型的なパターンは何ですか?ネットワーク負荷を単一のJVM内の複数の異なるソケットに分散し、HAProxyなどの何らかのロードバランサーを使用して負荷を複数のコアに分散する必要がありますか?または、NIOコードで複数のセレクターを使用することを検討する必要がありますか?または、複数のJVM間で負荷を分散し、Chronicleを使用してそれらの間のプロセス間通信を構築することもできますか? CentOSのような適切なサーバーサイドOSでテストすると、大きな違いが生じますか(おそらく、速度が低下するのはWindowsです)。

以下は私のサーバーのサンプルコードです。受信データには常に「ok」で応答します。現実の世界では、メッセージのサイズを追跡し、1つのメッセージが複数の読み取りに分割される可能性があることを準備する必要があることはわかっていますが、今のところは非常にシンプルにしたいと思います。

public class EchoServer {

private static final int BUFFER_SIZE = 1024;
private final static int DEFAULT_PORT = 9090;

// The buffer into which we'll read data when it's available
private ByteBuffer readBuffer = ByteBuffer.allocate(BUFFER_SIZE);

private InetAddress hostAddress = null;

private int port;
private Selector selector;

private long loopTime;
private long numMessages = 0;

public EchoServer() throws IOException {
    this(DEFAULT_PORT);
}

public EchoServer(int port) throws IOException {
    this.port = port;
    selector = initSelector();
    loop();
}

private void loop() {
    while (true) {
        try{
            selector.select();
            Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
            while (selectedKeys.hasNext()) {
                SelectionKey key = selectedKeys.next();
                selectedKeys.remove();

                if (!key.isValid()) {
                    continue;
                }

                // Check what event is available and deal with it
                if (key.isAcceptable()) {
                    accept(key);
                } else if (key.isReadable()) {
                    read(key);
                } else if (key.isWritable()) {
                    write(key);
                }
            }

        } catch (Exception e) {
            e.printStackTrace();
            System.exit(1);
        }
    }
}

private void accept(SelectionKey key) throws IOException {
    ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();

    SocketChannel socketChannel = serverSocketChannel.accept();
    socketChannel.configureBlocking(false);
    socketChannel.setOption(StandardSocketOptions.SO_KEEPALIVE, true);
    socketChannel.setOption(StandardSocketOptions.TCP_NODELAY, true);
    socketChannel.register(selector, SelectionKey.OP_READ);

    System.out.println("Client is connected");
}

private void read(SelectionKey key) throws IOException {
    SocketChannel socketChannel = (SocketChannel) key.channel();

    // Clear out our read buffer so it's ready for new data
    readBuffer.clear();

    // Attempt to read off the channel
    int numRead;
    try {
        numRead = socketChannel.read(readBuffer);
    } catch (IOException e) {
        key.cancel();
        socketChannel.close();

        System.out.println("Forceful shutdown");
        return;
    }

    if (numRead == -1) {
        System.out.println("Graceful shutdown");
        key.channel().close();
        key.cancel();

        return;
    }

    socketChannel.register(selector, SelectionKey.OP_WRITE);

    numMessages++;
    if (numMessages%100000 == 0) {
        long elapsed = System.currentTimeMillis() - loopTime;
        loopTime = System.currentTimeMillis();
        System.out.println(elapsed);
    }
}

private void write(SelectionKey key) throws IOException {
    SocketChannel socketChannel = (SocketChannel) key.channel();
    ByteBuffer dummyResponse = ByteBuffer.wrap("ok".getBytes("UTF-8"));

    socketChannel.write(dummyResponse);
    if (dummyResponse.remaining() > 0) {
        System.err.print("Filled UP");
    }

    key.interestOps(SelectionKey.OP_READ);
}

private Selector initSelector() throws IOException {
    Selector socketSelector = SelectorProvider.provider().openSelector();

    ServerSocketChannel serverChannel = ServerSocketChannel.open();
    serverChannel.configureBlocking(false);

    InetSocketAddress isa = new InetSocketAddress(hostAddress, port);
    serverChannel.socket().bind(isa);
    serverChannel.register(socketSelector, SelectionKey.OP_ACCEPT);
    return socketSelector;
}

public static void main(String[] args) throws IOException {
    System.out.println("Starting echo server");
    new EchoServer();
}
}
18
Juriy
what is a typical pattern for serving massive amounts of messages to clients?

考えられるパターンはたくさんあります。複数のjvmを経由せずにすべてのコアを利用する簡単な方法は次のとおりです。

  1. シングルスレッドで接続を受け入れ、セレクターを使用して読み取ります。
  2. 単一のメッセージを構成するのに十分なバイトができたら、リングバッファなどの構造を使用して別のコアにメッセージを渡します。 Disruptor Javaフレームワークはこれに適しています。これは、完全なメッセージが何であるかを知るために必要な処理が軽量である場合に適したパターンです。たとえば、長さのプレフィックスが付いたプロトコルがある場合は、予想されるバイト数が得られるまで待ってから、別のスレッドに送信します。プロトコルの解析が非常に重い場合は、この単一のスレッドを圧倒して、接続の受け入れやネットワークのバイトの読み取りを妨げる可能性があります。
  3. リングバッファからデータを受信するワーカースレッドで、実際の処理を行います。
  4. ワーカースレッドまたは他のアグリゲータースレッドを介して応答を書き出します。

それがその要点です。ここにはさらに多くの可能性があり、答えは実際に作成しているアプリケーションのタイプによって異なります。いくつかの例は次のとおりです。

  1. CPUの重いステートレスアプリケーションは画像処理アプリケーションと言います。リクエストごとに実行されるCPU/GPU作業の量は、非常に単純なスレッド間通信ソリューションによって生成されるオーバーヘッドよりも大幅に高くなる可能性があります。この場合の簡単な解決策は、単一のキューから作業をプルする一連のワーカースレッドです。これがワーカーごとに1つのキューではなく、単一のキューであることに注意してください。利点は、これが本質的に負荷分散されていることです。各ワーカーは作業を終了してから、単一のプロデューサーの複数のコンシューマーキューをポーリングするだけです。これが競合の原因ですが、画像処理作業(秒?)は、他の同期よりもはるかにコストがかかるはずです。
  2. 純粋なIOアプリケーション例:リクエストに対していくつかのカウンターをインクリメントするだけの統計サーバー:ここではCPUはほとんどありません重い作業。ほとんどの作業はバイトの読み取りと書き込みだけです。マルチスレッドアプリケーションでは、ここで大きなメリットが得られない場合があります。実際、アイテムのキューに入れる時間がそれよりも長い場合は、処理速度が低下する可能性があります。シングルスレッドのJavaサーバーは、1Gリンクを簡単に飽和させることができるはずです。
  3. 適度な量の処理を必要とするステートフルアプリケーション。典型的なビジネスアプリケーション:ここでは、すべてのクライアントに、各要求の処理方法を決定する状態があります。処理が簡単ではないためマルチスレッド化すると仮定すると、クライアントを特定のスレッドにアフィニティ化できます。これは、アクターアーキテクチャの変形です。

    i)クライアントが最初にハッシュをワーカーに接続するとき。クライアントIDを使用してこれを実行し、切断して再接続した場合でも同じワーカー/アクターに割り当てられるようにすることができます。

    ii)リーダースレッドが完全なリクエストを読み取ったら、それを適切なワーカー/アクターのリングバッファーに配置します。同じワーカーが常に特定のクライアントを処理するため、すべての状態はスレッドローカルである必要があり、すべての処理ロジックが単純でシングルスレッドになります。

    iii)ワーカースレッドはリクエストを書き出すことができます。常にwrite()を実行しようとします。すべてのデータを書き出すことができなかった場合は、OP_WRITEに登録しますか。ワーカースレッドは、実際に未解決のものがある場合にのみ、select呼び出しを行う必要があります。ほとんどの書き込みは成功するはずであり、これは不要です。ここでの秘訣は、選択した呼び出しのバランスを取り、リングバッファをポーリングしてさらに多くの要求を探すことです。リクエストを書き出すことだけを担当する単一のライタースレッドを使用することもできます。各ワーカースレッドは、この単一のライタースレッドに接続するリングバッファーに応答を配置できます。シングルライタースレッドのラウンドロビンは、着信する各リングバッファをポーリングし、データをクライアントに書き込みます。ここでも、selectの前に書き込みを試みることに関する警告が適用され、複数のリングバッファとselect呼び出しの間のバランスを取ることに関するトリックも適用されます。

あなたが指摘するように、他の多くのオプションがあります:

Should I distribute networking load over several different sockets inside a single JVM and use some sort of load balancer like HAProxy to distribute load to multiple cores?

これは可能ですが、私見では、これはロードバランサーの最適な使用法ではありません。これは、それ自体で失敗する可能性がある独立したJVMを購入しますが、マルチスレッドの単一のJVMアプリを作成するよりもおそらく遅くなります。ただし、アプリケーション自体はシングルスレッドになるため、作成しやすい場合があります。

Or I should look towards using multiple Selectors in my NIO code?

あなたもこれを行うことができます。これを行う方法のヒントについては、Ngnixアーキテクチャをご覧ください。

Or maybe even distribute the load between multiple JVMs and use Chronicle to build an inter-process communication between them?これもオプションです。 Chronicleには、メモリマップファイルが途中で終了するプロセスに対してより回復力があるという利点があります。すべての通信は共有メモリを介して行われるため、依然として十分なパフォーマンスが得られます。

Will testing on a proper serverside OS like CentOS make a big difference (maybe it is Windows that slows things down)?

私はこれについて知りません。ありそうもない。 JavaがネイティブのWindowsAPIを最大限に使用している場合、それはそれほど重要ではありません。4,000万トランザクション/秒の数値(ユーザースペースネットワークスタック+ UDPなし)には非常に疑問があります。しかし、私がリストしたアーキテクチャはかなりうまくいくはずです。

これらのアーキテクチャは、スレッド間通信に有界配列ベースのデータ構造を使用するシングルライターアーキテクチャであるため、うまく機能する傾向があります。マルチスレッドがその答えであるかどうかを判断します。多くの場合、それは必要ではなく、速度低下につながる可能性があります。

調べるべきもう1つの領域は、メモリ割り当てスキームです。具体的には、バッファを割り当てて再利用する戦略は、大きなメリットにつながる可能性があります。適切なバッファ再利用戦略は、アプリケーションによって異なります。バディメモリアロケーション、アリーナアロケーションなどのスキームを見て、それらがあなたに利益をもたらすことができるかどうかを確認してください。 JVM GCは、ほとんどの作業負荷に対して十分に機能しますが、このルートを進む前に必ず測定してください。

プロトコルの設計は、パフォーマンスにも大きな影響を及ぼします。バッファのリストやバッファのマージを回避して適切なサイズのバッファを割り当てることができるため、長さのプレフィックスが付いたプロトコルを好む傾向があります。長さのプレフィックスが付いたプロトコルを使用すると、リクエストをいつハンドオーバーするかを簡単に決定できます。num bytes == expectedを確認するだけです。実際の解析は、ワーカースレッドによって実行できます。シリアル化と逆シリアル化は、長さのプレフィックスが付いたプロトコルを超えて拡張されます。ここでは、割り当てではなく、バッファ上のフライウェイトパターンのようなパターンが役立ちます。これらの原則のいくつかについては、 [〜#〜] sbe [〜#〜] を参照してください。

ご想像のとおり、論文全体をここに書くことができます。これはあなたを正しい方向に導くはずです。警告:常に測定し、最も単純なオプションよりも高いパフォーマンスが必要であることを確認してください。パフォーマンス改善の終わりのないブラックホールに夢中になるのは簡単です。

21
Rajiv

書き込みに関するロジックに誤りがあります。書き込むデータがある場合は、すぐに書き込みを試みる必要があります。 write()がゼロを返す場合は、then OP_WRITEに登録し、チャネルが書き込み可能になったときに書き込みを再試行し、書き込みが成功したときにOP_WRITEの登録を解除します。ここでは、大量のレイテンシーを追加しています。そのすべてを実行している間にOP_READの登録を解除することで、さらにレイテンシーを追加しています。

4
user207421

通常のハードウェアを使用すると、1秒あたり数十万件のリクエストを達成できます。少なくとも、それは同様のソリューションを構築しようとした私の経験であり、 Tech Empower Web Frameworks Benchmark も同意しているようです。

一般に、最善のアプローチは、io-boundまたはcpu-boundのどちらの負荷があるかによって異なります。

Ioバウンドロード(高レイテンシー)の場合、多くのスレッドで非同期ioを実行する必要があります。最高のパフォーマンスを得るには、スレッド間のハンドオフを可能な限り無効にするようにしてください。したがって、専用のセレクタスレッドと処理用の別のスレッドプールを使用する方が、すべてのスレッドが選択または処理を実行するスレッドプールを使用するよりも時間がかかるため、最良の場合(ioがすぐに利用できる場合)は単一のスレッドによって要求が処理されます。このタイプのセットアップはコーディングがより複雑ですが高速であり、非同期Webフレームワークがこれを完全に活用しているとは思いません。

CPUにバインドされたロードの場合、コンテキストスイッチを回避するため、通常、要求ごとに1つのスレッドが最速です。

2