Kotlin/NativeでHTTPサーバーを作るアドベントカレンダー(16日目:ノンブロッキングI/O)

この記事はkdnaktの1人 Advent Calendar 2020の16日目の記事です。

 

2020年は会社でKotlin dojoを主催して週1回30分Kotlinと戯れていました。12月はその集大成ということで、Kotlin/NativeでHTTPサーバーを作ってみたいと思います。どこまでできるか、お楽しみ……。

 

 

[echoServerの問題点]

これまで、kotlin-nativeリポジトリのサンプルプロジェクトであるechoServerをベースにHTTPサーバーの開発を進めてきました。

 

しかし、echoServerはtelnetでのHTTP接続を終了すると、echoServer自体が終了してしまう実装となっていました。そのため、実装したHTTPサーバーは1リクエストを処理すると停止してしまいます。

 

この問題を解決する方法を探していたところ、同じkotlin-nativeリポジトリに、nonBlockingEchoServerというプロジェクトを見つけました。

README.mdの説明によると、Kotlinの非同期処理の仕組みであるCoroutineとノンブロッキングI/Oを利用して、複数の接続を同時に処理できるとあります。このプロジェクトのソースコードを取り込めば、echoServerベースのHTTPサーバーを改善することができそうです。

 

[nonBlockingEchoServerを試す]

サンプルのnonBlockingEchoServerプロジェクトをビルドして起動し、実際にtelnetコマンドで接続して動作を確認します。

 

まずは、初回の接続です。helloと入力した場合、#1: helloという接続IDつきのレスポンスが返ります。

$ telnet localhost 3000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hello
#1: hello
^]
telnet> q
Connection closed.

 

初回接続を閉じたあと、再度nonBlockingEchoServerに接続できるかを試します。

$ telnet localhost 3000
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
hola
#2: hola
^]
telnet> q
Connection closed.

 

echoServerの場合と異なり、一度接続を閉じたあとでもアプリケーションが停止せず、継続してレスポンスを返していることが分かります。

 

ターミナルを2つ同時に起動し、それぞれtelnetでnonBlockingEchoServerに同時接続した場合も、問題なくレスポンスが返ってきました。

echoServerの抱えていた問題点が解消されています。

 

[echoServerとnonBlockingEchoServerの差分]

ソースコードを元に、差分を確認し、ノンブロッキングI/Oを実装するためのポイントを見ていきます。

 

まず目につくのは、fcntl()という新たなシステムコールがnonBlockingEchoServerで利用されています。O_NONBLOCKというフラグが渡されており、ソケットをノンブロッキングに設定しています。

fcntl(listenFd, F_SETFL, O_NONBLOCK)
        .ensureUnixCallResult { it == 0 }

linuxjm.osdn.jp

 

また、buffer.usePinned()のかわりに、acceptClientsAndRun()という新たな関数が追加されています。memScopedブロックの処理をみると、接続IDを管理している部分以外はechoServerのコードをほぼ同じであることがわかります。

var connectionId = 0
acceptClientsAndRun(listenFd) {
    memScoped {
        val bufferLength = 100uL
        val buffer = allocArray<ByteVar>(bufferLength.toLong())
        val connectionIdString = "#${++connectionId}: ".cstr
        val connectionIdBytes = connectionIdString.ptr

        try {
            while (true) {
                val length = read(buffer, bufferLength)

                if (length == 0uL)
                    break

                write(connectionIdBytes, connectionIdString.size.toULong())
                write(buffer, length)
            }
        } catch (e: IOException) {
            println("I/O error occured: ${e.message}")
        }
    }
}

 

acceptClientsAndRun()関数の引数は次のようになっています。

fun acceptClientsAndRun(serverFd: Int, block: suspend Client.() -> Unit)

 

Coroutineについてはまだ詳しく学べていないのですが、suspendキーワードを追加することで、該当の関数を中断し、他の処理にスレッドを利用できるようになるようです。今回の場合、複数のHTTPコネクションを同時に扱えます。

qiita.com

  

この辺りの関連する実装を移植し、いくつかの型を修正すると、一度リクエストを処理したあともHTTPサーバーが動き続けるようになりました。

Chromeで連続してアクセスした場合も問題なく動作しています。

f:id:kidani_a:20201216234343g:plain

 

[まとめ]

  • echoServerには1リクエスト処理したらアプリケーションが終了するという問題があった
  • nonBlockingEchoServerをベースにすることで、上記の問題点を解消した
  • 同時に、複数のHTTP接続を並行して処理できるようになった
  • 実装中のコードは以下のリポジトリにまとめてある

github.com