Elixirの軽量コールバックスレッドの実装とPhoenixの同時セッション最大数・レイテンシ改善の構想

北九州市立大学 山崎 進

(有)デライトシステムズ 森 正和,上野 嘉大

京都大学 高瀬 英希

Plan to Implementation of Lightweight Callback Thread for Elixir and Improvement of Maximum Concurrent Sessions and Latency of Phoenix

Susumu Yamazaki (University of Kitakyushu)

Node.js[1]では,コールバックを用いてI/Oを非同期的に扱って擬似的にマルチタスクにする機構が備わっている[2]。我々はC++で同様の機構を実装し,Zackernel(ザッカーネル)として公開した[3][4]。このような仕組みにより,ウェブサーバーがリクエストを受け付ける際に消費するメモリ量を大幅に削減でき,その結果,同時セッション最大数とレイテンシが改善される。そこで,我々はElixir[5]にこのような仕組み,軽量コールバックスレッド(lightweight callback thread)を実装することを着想した。これによりElixirベースのウェブサーバープラットフォームであるPhoenix[6]の同時セッション最大数とレイテンシが改善されることを期待している。

本発表では,先行して開発したZackernelの実装について紹介し,Elixirで軽量コールバックスレッドを実装する方針を提案する。次に軽量コールバックスレッドを,従来のマルチタスクの機構とどのように統合していくか,メモリ管理機構との関係をどのように位置づけるかについての方針を提案する。さらにPhoenixで軽量コールバックスレッドをどのように活用するかの方針についても提案する。

今後,我々はElixirに軽量コールバックスレッドのプロトタイプを実装し,性能を評価して前述の提案の実現可能性について検討する。

1. はじめに

Apache[7]などの現状のウェブサーバーでは,図1に示すようにスレッドやプロセス,軽量プロセスなどを用いて同時に接続要求された複数のセッションを処理している。この方式ではセッションごとに数10MB程度のスタックメモリを消費するため,セッション数が極端に多くなると実メモリが不足してパフォーマンスが悪化し,その結果,同時セッション最大数が大きく制約され,レイテンシが悪化する。

従来のマルチスレッド/マルチプロセス方式

図1: 従来のマルチスレッド/マルチプロセス方式

そこで,Node.js[1]では,コールバックを用いてI/Oを非同期的に扱って擬似的にマルチタスクにする機構が備わっている[2]。これによりNode.jsでは図2に示すように,1つのスレッドで複数のセッションを処理することができる。この結果,同時セッションが増えてもスタックメモリを消費せず,1つのセッションあたり数〜数百KB程度のタスク管理ブロック(TCB)を必要とする程度で済むため,セッション数が相当多くなっても耐えられるシステムを構築することができる。

Node.jsのコールバック方式

図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]に同様の機構を適用できないかを検討する。

1.1 本発表の目的

本発表の目的は次の通りである。

1.2 本発表のアプローチ

本発表の目的を達成するために次のようなアプローチで研究を行う。

  1. Zackernelの実装についてふりかえる
  2. Elixirで軽量コールバックスレッドを実装する方針を提案する
  3. 軽量コールバックスレッドを,従来のマルチタスクの機構とどのように統合するか方針を提案する
  4. 軽量コールバックスレッドとメモリ管理機構との関係の位置付けの方針を提案する
  5. 3,4を踏まえ,Phoenixで軽量コールバックスレッドをどのように活用するかの方針について提案する

1.3 この後の本発表の構成

この後,本発表を次のように構成する:

2. Zackernel

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に再入している時にはコールバックする関数を呼び出さない,そうでない場合のみコールバックする関数を呼び出すロジックにすることで,スタックオーバーフローにならないようにしている。

3. Elixirでの軽量コールバックスレッドの実装方針

Node.jsやZackernelを参考にしながら,Elixirで軽量コールバックスレッドを実装する方針を次のように提案する:

  1. キューとして,関数のリスト構造を用いる。
  2. dispatch関数では,キューから取り出した関数を末尾再帰になるようにして呼び出す。
  3. 非同期I/Oについては,非同期スレッド(Async Thread)との非同期通信として実現する。

Elixirで特徴的なのは,3である。Elixirでは第4章で後述するように,非同期スレッドでI/Oを集中的に管理し,他スレッドによるI/O処理は,非同期スレッドへの非同期通信で実現する。 これにより,Elixirで並列プログラミングした時に,I/O処理がらみで排他制御を行う必要がほとんどなくなる。この点がElixirの高い並列処理性能につながっている。

4. 従来マルチタスク機構との統合

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]:

他に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との非同期通信で実現する。このことから,従来のプロセス間通信だけでなく,軽量コールバックスレッド同士の通信の仕組みが必要となる

5. 軽量コールバックスレッドとメモリ管理の関係

Elixirの従来マルチタスク機構の特徴は,軽量プロセスごとにGCなどのメモリ管理が独立している点である。これにより,軽量プロセスに不具合が生じた時に再起動してメモリごと再初期化することが可能になる。

一方,軽量コールバックスレッドを使っている場合は,原理上,一連の軽量コールバックスレッドでメモリ管理を共有することになる。したがって,1つの軽量コールバックスレッドに不具合が生じた場合に再起動すると,同時に起動していた軽量コールバックスレッド全てを道連れにして異常終了させてしまうことに注意すべきである。

6. Phoenixにおける軽量コールバックスレッドの活用方針

現状のPhoenixでの接続要求の受付の流れは次のようになっている。

  1. 1つの受付プロセスがポート待機している。
  2. 受付プロセスが1つの接続要求を受理すると,1つのセッション処理プロセスを起動し,以降の接続処理をセッション処理プロセスに委ねて,次の接続要求をポート待機する。
  3. セッション処理プロセスが,接続要求を処理するためにネットワークやデータベースにI/Oアクセスするが,その際にあらかじめ起動している複数の非同期スレッドにI/O処理を委ね,続きの処理を行う。
  4. 非同期スレッドがそれぞれI/Oにアクセスして結果をセッション処理プロセスに返す。セッション処理プロセスは結果を非同期的に受け取り,続きの処理を行う。

ここで問題となるのが,2で接続要求ごとにセッション処理プロセスを起動するので,メモリを消費してしまう点である。そこで,2,3, 4 を次のように変更する。

このようにすると,接続要求ごとに数KB程度しかメモリを消費しないで済む。これにより,同時セッション最大数とレイテンシを改善することができると考えられる。

このような方式にした際に留意すべきは次の2点である。

7. おわりに

本発表のまとめは次の通りである。

今後,我々はElixirに軽量コールバックスレッドのプロトタイプを実装し,性能を評価して前述の提案の実現可能性について検討する。

参考文献