Node.js[1]では,コールバックを用いてI/Oを非同期的に扱って擬似的にマルチタスクにする機構が備わっている[2]。我々はC++で同様の機構を実装し,Zackernel(ザッカーネル)として公開した[3][4]。このような仕組みにより,ウェブサーバーがリクエストを受け付ける際に消費するメモリ量を大幅に削減でき,その結果,同時セッション最大数とレイテンシが改善される。そこで,我々はElixir[5]にこのような仕組み,軽量コールバックスレッド(lightweight callback thread)を実装することを着想した。これによりElixirベースのウェブサーバープラットフォームであるPhoenix[6]の同時セッション最大数とレイテンシが改善されることを期待している。
本発表では,先行して開発したZackernelの実装について紹介し,Elixirで軽量コールバックスレッドを実装する方針を提案する。次に軽量コールバックスレッドを,従来のマルチタスクの機構とどのように統合していくか,メモリ管理機構との関係をどのように位置づけるかについての方針を提案する。さらにPhoenixで軽量コールバックスレッドをどのように活用するかの方針についても提案する。
今後,我々はElixirに軽量コールバックスレッドのプロトタイプを実装し,性能を評価して前述の提案の実現可能性について検討する。
Apache[7]などの現状のウェブサーバーでは,図1に示すようにスレッドやプロセス,軽量プロセスなどを用いて同時に接続要求された複数のセッションを処理している。この方式ではセッションごとに数10MB程度のスタックメモリを消費するため,セッション数が極端に多くなると実メモリが不足してパフォーマンスが悪化し,その結果,同時セッション最大数が大きく制約され,レイテンシが悪化する。
図1: 従来のマルチスレッド/マルチプロセス方式
そこで,Node.js[1]では,コールバックを用いてI/Oを非同期的に扱って擬似的にマルチタスクにする機構が備わっている[2]。これによりNode.jsでは図2に示すように,1つのスレッドで複数のセッションを処理することができる。この結果,同時セッションが増えてもスタックメモリを消費せず,1つのセッションあたり数〜数百KB程度のタスク管理ブロック(TCB)を必要とする程度で済むため,セッション数が相当多くなっても耐えられるシステムを構築することができる。
図2: Node.jsのコールバック方式
Node.jsを記述しているプログラミング言語Javascriptでは,匿名関数を利用できる。Node.jsを用いると次のように匿名関数を用いて非同期I/Oの処理を記述することができる。なお,このコード例では Sleep-Async [8]を用いた。sleep.sleep(1000)
で1000ミリ秒間スリープし,その後,.then()
の中に記述された匿名関数を呼び出している。
const sleep = require('sleep-async')().Promise;
const startTime = new Date().getTime();
console.log('startTime: ' + startTime);
sleep.sleep(1000)
.then(() => new Date().getTime())
.then(stopTime => {
console.log('stopTime: ' + stopTime);
console.log('Difference: '+((stopTime-startTime)/1000)+' [s]');
});
我々は同様の機構を2016年にZackernelとしてC++に実装した[3][4]。Zackernelで狙った領域は,RFIDのような極端に小規模で消費電力の少ないIoT用途である。
そこで我々はZackernelで得た経験を踏まえ,極端に大きな同時セッション接続数と高いレスポンス性能を要求されるクラウドサーバー用途として,このような用途に向くElixir[5]ベースで開発されたPhoenix[6]に同様の機構を適用できないかを検討する。
本発表の目的は次の通りである。
本発表の目的を達成するために次のようなアプローチで研究を行う。
この後,本発表を次のように構成する:
Zackernel[3][4]は,C++11以降で採用された匿名関数を用い,Node.js[1][2]同様の機構を実装している。
Zackernelのサンプルプログラムを下記に示す。digitalWrite(LED1, HIGH);
はLED1を点灯し,digitalWrite(LED1, LOW);
はLED1を消灯する。sleep
メソッドは,TIC
ミリ秒の間休止した後,第2引数で与えられた関数を呼び出す。[&] {}
はC++11で導入された匿名関数の表記である。zLoop
メソッドは,第1引数で与えられた関数を繰り返し呼び出す。したがってこのプログラムは全体として,LED1をTICミリ秒間隔で繰り返し点滅するプログラムである。
void blinkLed1() {
zLoop([&] {
digitalWrite(LED1, HIGH);
sleep(TIC, [&] {
digitalWrite(LED1, LOW);
sleep(TIC, [&] {});
});
});
}
Zackernelの内部構成について説明する。Zackernel のSchedule
クラスはコールバックする関数を保持し,Schedule
同士を線形リスト構造でつないでいる。ZackernelのZackernel
クラスはSchedule
のキューを保持する。核心となるdispatch
メソッドは,次に呼び出すべき関数をキューから読み込んで呼び出す。dispatch
に再入している時にはコールバックする関数を呼び出さない,そうでない場合のみコールバックする関数を呼び出すロジックにすることで,スタックオーバーフローにならないようにしている。
Node.jsやZackernelを参考にしながら,Elixirで軽量コールバックスレッドを実装する方針を次のように提案する:
dispatch
関数では,キューから取り出した関数を末尾再帰になるようにして呼び出す。Elixirで特徴的なのは,3である。Elixirでは第4章で後述するように,非同期スレッドでI/Oを集中的に管理し,他スレッドによるI/O処理は,非同期スレッドへの非同期通信で実現する。 これにより,Elixirで並列プログラミングした時に,I/O処理がらみで排他制御を行う必要がほとんどなくなる。この点がElixirの高い並列処理性能につながっている。
Elixirの実行環境であるErlang VMでは,従来マルチタスク機構としてスレッド(軽量プロセス)を提供している。Erlang VMのスレッドそれぞれにGCを含むメモリ管理機構が独立して備わっている。そのため,スレッドの実行に不具合があった場合,スレッドを再起動しても他に影響がない。Elixirではこの特性を生かして高い耐障害性を実現している。
SMP環境の場合の Erlang VM のスレッド構成を表1に示す[9]。
表1: SMP環境での Erlang VM のスレッド構成
スレッド名 | 関数名 | 個数 |
---|---|---|
Main Thread | erts_sysmainthread | 1 |
Signal Handling Thread | signal_dispatcher_thread_func | 1 |
System Message Handling Thread | sys_msg_dispatcher_func | 1 |
Async Thread | async_main | 10 |
Child Waiting Thread | child_waiter | 1 |
Scheduling Thread | sched_thread_func | 論理コア数 |
Aux Thread | aux_thread | 1 |
それぞれの役割は次の通りである[9]:
erl
起動時の '+B'
オプションでシグナル受信の挙動を変更できる。futex()
システムコールで行う。Async Thread の個数は erl
起動時に '+A'
オプションで変更できる。例えば、'erl +A 5'
とすると、Async Thread は5個になる。waitpid()
で待ち受ける。process_main()
を実行し,バイトコード解釈実行とプロセススケジューリングを行う。デフォルトでは論理コアと同じ数だけ生成される。'+S'
オプションでスレッド数を調整できる。他の Scheduling Thread と比較して負荷が偏らないようにバランシングとプロセスマイグレーションを行う。'elrang:statistics(garbage_collection)'
でのGC統計情報取得は,aux_thread
で行う。他にNIF(Native Implemented Functions)やdriver
関係のスレッドを起動することがある。
このように,システムの実行を司る Scheduling Thread と,I/Oを司る Async Thread が独立して存在することもElixirの特徴である。I/O処理をしたい時には Scheduling Thread からプロセス間通信で Async Thread に処理を委ね,1つのI/O処理は1つの Async Thread が独占的に行う。このため,マルチコア環境下でもI/Oに関して排他制御をする必要性がほとんど生じなくなる。
これを踏まえて,軽量コールバックスレッドは従来の軽量スレッドの Scheduling Thread をさらに細分するように実現する。軽量コールバックスレッドの実現に欠かせない非同期I/O処理は,Async Threadとの非同期通信で実現する。このことから,従来のプロセス間通信だけでなく,軽量コールバックスレッド同士の通信の仕組みが必要となる
Elixirの従来マルチタスク機構の特徴は,軽量プロセスごとにGCなどのメモリ管理が独立している点である。これにより,軽量プロセスに不具合が生じた時に再起動してメモリごと再初期化することが可能になる。
一方,軽量コールバックスレッドを使っている場合は,原理上,一連の軽量コールバックスレッドでメモリ管理を共有することになる。したがって,1つの軽量コールバックスレッドに不具合が生じた場合に再起動すると,同時に起動していた軽量コールバックスレッド全てを道連れにして異常終了させてしまうことに注意すべきである。
現状のPhoenixでの接続要求の受付の流れは次のようになっている。
ここで問題となるのが,2で接続要求ごとにセッション処理プロセスを起動するので,メモリを消費してしまう点である。そこで,2,3, 4 を次のように変更する。
このようにすると,接続要求ごとに数KB程度しかメモリを消費しないで済む。これにより,同時セッション最大数とレイテンシを改善することができると考えられる。
このような方式にした際に留意すべきは次の2点である。
本発表のまとめは次の通りである。
dispatch
関数では,キューから取り出した関数を末尾再帰になるようにして呼び出す。今後,我々はElixirに軽量コールバックスレッドのプロトタイプを実装し,性能を評価して前述の提案の実現可能性について検討する。