(この記事は「fukuoka.ex x ザキ研 Advent Calendar 2017」の4日目です)
昨日は @zumin さんの「Elixirで一千万行のJSONデータで遊んでみた」 でしたね。
おしらせ
Elixirの研究に日夜励んでいるZACKYです。好評いただいた「ElixirでGPU駆動」の連載記事のまとめを,今度のfukuoka.ex#11でプレゼンテーションします! まだ若干の空席があります!
さて本題〜はじめに
今回から新しいシリーズを始めます。
Node.js ってご存知ですか? Node.js はなかなか面白い仕組みでして,OSの歴史から見ると,とても興味深いメカニズムをしています。
今回ご紹介するのは,Node.js と同じ原理のものを,Elixirで実装してみた,という試みです。これが何の役に立つのかというのは追い追いお話しするとして,まずはOSの歴史を軽くふりかえってみましょう。
マルチタスクの黎明期
大昔のコンピュータは一度に1つの処理しかできませんでした。シングルタスクと言います。
あるとき「CPUの実行を細かい時間に分割して,ある瞬間にはタスク1を,別の瞬間にはタスク2を実行するようにすれば,見た目にはタスク1とタスク2が同時に動いているように見えるのでは?」と思いついた人がいました。大変な苦労の末,実装したところ,たしかに複数のタスクが同時に動いているように見えるではありませんか!
これがマルチタスクの誕生です。マルチタスクの概念の最初の登場は1960年代の話です。
仮想記憶・メモリ保護の登場
マルチタスクの登場と,ほぼ同じ頃に,仮想記憶とメモリ保護という重要な技術が発明されました。
仮想記憶というのは,メモリだけでは記憶容量が不足する時に,HDDなどの補助記憶装置にメモリの内容を書き出して,メモリの内容を入れ替えながら実行することで,見かけの記憶容量を実メモリよりも広く使えるようにするという技術です。
メモリ保護というのは,ユーザープログラムがメモリを読み書きできる範囲を限定することで,不用意にメモリを参照したり書き換えてしまったりすることで起こる事故を防ぐ仕組みです。
仮想記憶とメモリ保護も,大変な苦労の末,実装されました。その後,やがて一体となって実装されるようになりました。
Unixの登場〜マルチプロセスシステム
1970年前後に登場したUnixは,当初はシングルタスクで仮想記憶もないOSでしたが,徐々に拡張されてマルチタスクになり仮想記憶もサポートされるようになりました。
Unix では,タスクのことをプロセスと呼んでいます。プロセスは,処理の単位であると同時に,他プロセスと基本的にメモリを共有しないモデルを採用していました。すなわちメモリ保護としては,カーネルを含む他のプロセスのメモリへの直接アクセスができないように実装されています。仮想記憶もこのモデルに準じて実装されています。
このことから,Unix では,プロセスを切り替える処理(コンテキストスイッチ)をするときには,CPUのレジスタなどを入れ替えるだけでなく,仮想記憶やメモリ保護などのメモリを管理する情報も切り替えています。このようにマルチタスクを実現することをマルチプロセス方式と言います。
軽量プロセス(スレッド)
時代が下って,ウェブブラウザの登場により,画像の表示やダウンロードなどのいくつもの処理を同時並行に行うようなアプリケーションが一気に花開きました。1つ1つの処理をプロセスに分割して実行すると,メモリを共有しないことと,メモリを管理する情報も切り替えるためコンテキストスイッチに時間がかかることによる,実行効率の悪化の問題がありました。
そこで,メモリをそのまま共有しつつ,実行コンテキストだけを切り替える方式が発明されました。このような方式だと,処理が軽くなることから,軽量プロセス,あるいは縫い糸のように処理を細かく切り替えることからスレッド(thread: 縫い糸)と呼びます。
軽量プロセス(スレッド)をどのように実装するかというと,それぞれのスレッドに対しスタックメモリを割り当てて,CPU情報をメモリ上に退避し,スタックメモリを切り替えて,次に実行するスレッドのCPU情報をメモリ上から読み込む,というようにコンテキストを切り替えます。
このような方式でマルチタスクを実現することを,マルチスレッド方式と言います。
そしてNode.js
さらに時代が下って,1つのウェブサーバーにアクセスが集中すると受け付けなくなる現象が頻発することが問題になりました。よく Twitter でクジラマークが出るアレです。
これはどのような現象が起こっているのかというと,1つのリクエストに1つのスレッドを生成して対応していることが原因です。1つリクエストがあるたびにスタックメモリを数MB程度確保することから,同時に1000リクエスト程度あると数GBのメモリをあっという間に食い尽くしてしまい,仮想記憶の働きで補助記憶装置に読み書きが発生するので極端にパフォーマンスが落ちてしまいます。さらにアクセスが集中して同時に100万リクエスト程度あると,数TBのメモリを食い尽くすことになるので,仮想記憶をもってしても補助記憶装置を食い尽くしてしまって,それ以上リクエストを受け付けられなくなるということになります。
そこで,Node.js では,スタックメモリを消費せずにマルチタスクを実現する方法を編み出しました。
Node.js のサイトに掲載されているコード例で説明します。
node-sample.js
const http = require('http');
const hostname = '127.0.0.1';
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World\n');
});
server.listen(port, hostname, () => {
console.log(`Server running at http://${hostname}:${port}/`);
});
このプログラムの実行方法は次の通りです。(Node.js がインストールされている前提)
$ node node-sample.js
ウェブブラウザで http://localhost:3000にアクセスします。すると,Hello World
と表示されると思います。
このウェブサーバープログラムへの接続があるごとに,下記のコールバック関数が呼び出されます。スレッドを生成してスタック領域を確保するようなことはしません。
(req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hello World\n');
}
もし Elixir / Phoenix で Node.js と同じ仕組みを実装したら?〜それが軽量コールバックスレッドだ!
Phoenix は現状,マルチスレッド方式で実装されています。したがって,極端に大量のアクセスがあった時に,メモリを大きく消費します。もし Node.js と同じ仕組みを実装して,Phoenix で活用できるようになったら,メモリ消費を抑えることができることから,同時セッション最大数を大きく伸ばすことができ,レイテンシも改善されると期待できます。
私たちはその提案をする研究論文「Elixirの軽量コールバックスレッドの実装とPhoenixの同時セッション最大数・レイテンシ改善の構想」を書きました。
現状の Phoenix では次のように接続要求を処理しています。
- 1つの受付プロセスがポート待機している。
- 受付プロセスが1つの接続要求を受理すると,1つのセッション処理プロセスを起動し,以降の接続処理をセッション処理プロセスに委ねて,次の接続要求をポート待機する。
- セッション処理プロセスが,接続要求を処理するためにネットワークやデータベースにI/Oアクセスするが,その際にあらかじめ起動している複数の非同期スレッドにI/O処理を委ね,続きの処理を行う。
- 非同期スレッドがそれぞれI/Oにアクセスして結果をセッション処理プロセスに返す。セッション処理プロセスは結果を非同期的に受け取り,続きの処理を行う。
これを軽量コールバックスレッドの導入により,次のように処理するように変更することを提案します。
- 1つの受付プロセスがポート待機している。
- 受付プロセスが1つの接続要求を受理すると,あらかじめ起動しているセッション処理プロセスに非同期的に接続要求を送信して以降の接続処理を委ねて,次の接続要求をポート待機する
- セッション処理プロセスは接続要求を受けると軽量コールバックスレッドを起動する
- 1つの軽量コールバックスレッドで1つの接続要求を処理する
- 軽量コールバックスレッドが接続要求を処理するためにネットワークやデータベースにI/Oアクセスするが,その際にあらかじめ起動している複数の非同期プロセスにI/O処理を委ね,続きの処理を行う
- 非同期スレッドがそれぞれI/Oにアクセスして結果をセッション処理する軽量コールバックスレッドに返す
- 軽量コールバックスレッドが結果を受け取り,続きの処理を行う
このようにすると,接続要求ごとに数KB程度しかメモリを消費しないで済みます。これにより,同時セッション最大数とレイテンシを改善することができると考えられます。
というわけで,まとめと次回予告
- マルチタスクを実現する方式が進化し続けています。
- Unix ではマルチプロセス方式により,メモリ管理と一体となった形でコンテキストスイッチをしていました。
- ウェブブラウザの登場とともに マルチスレッド方式が発案され,メモリ管理情報を切り替えずにコンテキストスイッチすることで効率化するようになりました。
- Node.js では,コールバック方式により,スタックメモリを確保せずに接続要求を処理する方式が発案されました。
- 私たちは Elixir に軽量コールバックスレッドを実装し,メモリ消費を抑えて Phoenix の同時セッション最大数とレイテンシを格段に改善する方式を提案します。
というわけで,次の記事「ZEAM開発ログ v.0.2.1 Node.js と同じ原理の軽量コールバックスレッドを Elixir に実装してみた (実装編)」でいよいよ軽量コールバックスレッドのコードを紹介してみたいと思います。
p.s.「いいね」よろしくお願いします
よろしければ,ページ左上の や のクリックをお願いしますー
ここの数字が増えると,書き手としては「ウケている」という感覚が得られ,連載を更に進化させていくモチベーションになりますので,もっとElixirネタを見たいというあなた,私たちと一緒に盛り上げてください!