actix_web は Actor モデルを採用した Rust の web frameworkです。
「Actor モデルでwebリクエストを捌く」仕組みを雑に想像すると、
TCPリスナーがHTTPリクエストをメールボックスに貯め、それをワーカーであるActorが拾ってレスポンスを返す?
この仕組だと複数のActorで1つのメールボックスを共有する機構が必要になってきます。
しかし、Actorシステムを提供する actix にはこの機構はなさそうに見えます。
それに、恐らくこの仕組みでパフォーマンスを出すのは難しいでしょう。
そこで、actix_web がどのようにHTTPリクエストを捌いているのか、ソースコードを追ってみることにしました。
※ 読み間違え等ありましたらコメント頂けると大変嬉しいです… 🙏
actix_web の構成
actix_web の依存関係を図示するとおおよそ下記のようになっています(バージョン0.7.16時点)。
actix エコシステムとしては、
という役割をそれぞれが担っていますが、 非同期イベントの管理には tokio , mio, futures の 0.1系 を利用しています。
では、actix_web のシンプルなサンプル・コードをエントリポイントとして、ソースコードを読んでいきます。
TCPコネクションのリスニング開始
actix_web で HTTPサーバを開始するには、 actix_web::server::HttpServer の start メソッドを実行します。
ここがTCPコネクション・リスニングのエントリ・ポイントになるのですが、 TCPソケットの生成自体は bind メソッド内で行われます。
よって当然ですが、 bind メソッドの呼び出しを行わずに start してしまうと、 panic します。
start メソッドは下記のようになっています。
actix_web::server::HttpServer.start は 内部的に actix_net::server::Server の start を実行しています。
socket.handler.register (図3の2) では HTTPサービス・ファクトリの生成とトークンによるTCPリスナーとの紐付けを行っているのですが、一旦ここは割愛して、 actix_net::server::Server の start (図3の1)を見ていきます。
ワーカー・スレッドの生成
actix_net::server::Server.start では、actix_web::server::HttpServer.workers に指定された数のワーカー・スレッドを開始します。
ワーカー数として指定された数だけ実行している start_worker (図4の3)で何をやっているかというと、ストリーム用インメモリ・チャネルを開き、 WorkerClient に送信側を、 Worker に受信側を渡しています。
start_worker メソッドは、送信側を持つ WorkerClient を戻り値として返します。
各ワーカーとの通信チャネルの送信側をメインスレッドが保持し、チャネルへのコマンドを通じてワーカーを制御しているわけですね。
start_worker メソッドのもう1つの特筆すべき点として、 Arbiter::new の中で Worker::start を行っていることです。
Arbiter はイベントループ・コントローラで、 new されるとスレッドを生成しイベントループ・コントローラを起動します。
ワーカー 1つにつき、 1つの Arbiter が生成されているので、マルチスレッドでワーカー数分のイベントループが動作することになります。
Arbiter の中身も見ていきたいところなのですが、話が広がりすぎてしまうので端折ります💦
(tokioランタイム上でイベントループを制御しています。)
※ Arbiter については下記の記事で詳しく説明されていました。
Arbiter | Actor model by Rust with Actix
受信側を渡された Worker の内部
Woker::start では、socket.handler.register (図3の2)で生成した HTTPサービス・ファクトリを使って、 HTTPサービスの生成を行っています。
TCPリッスン
続いて、図4の4, self.accept.start を見ていきます。
self.accept.start は、workers を受け取っていますが、これは送信チャネルを持つ WorkerClient です。
self.accept.start は AcceptLoop.start を指していますが、 結果 Accept.start が呼び出されるので、 こちらの方のコードを見ます。
TCPコネクション受付用に、もう1つスレッドを起動しています。
このスレッドはTCP接続リクエスト・ポーリング専用スレッドです。
図7の5, accept.poll の中身を見てみます。
accept.poll の中では、 システム・イベントをポーリングし、TCPリスナーから TCPストリームを受け取ると accept_one メソッドを実行します。
accept_one では、ワーカーのコマンド・チャネルに受け取ったTCPストリームを送信します。
これにより、ワーカーのサービスがTCPストリームを解釈してレスポンスの生成を行います。
ここまでのシーケンスをまとめてみると
こんな感じになります。
TCPリスニングのコア部分においては、ほとんどActorシステムが使われておりませんでした💦
※ 唯一、 Workerを起動する Arbiter が Actor であることくらいでしょうか…
余談ですが・・・OSプロセスから見たactix_web
スレッド観点から見ると、 actix_web はメイン・スレッドの他に (ワーカー数 * ワーカー・スレッド)+1(TCP接続ポーリング・スレッド) 数のスレッドが起動します。
例えば、ワーカー数を 4 とすると、 1(メイン) + 4(ワーカー・スレッド) + 1(TCP) = 6 スレッド起動することになります。
actix_web の簡単なサンプルを実行して確認してみます。
サンプル・ソースコード
公式READMEのexampleに .workers(4) を追加しています。
extern crate actix_web; use actix_web::{http, server, App, Path, Responder}; fn index(info: Path<(u32, String)>) -> impl Responder { format!("Hello {}! id:{}", info.1, info.0) } fn main() { server::new( || App::new() .route("/{id}/{name}/index.html", http::Method::GET, index)) .keep_alive(server::KeepAlive::Timeout(0)) .workers(4) .bind("127.0.0.1:8080").unwrap() .run(); }
これを起動すると、 メインを含む6スレッド立つはずです。
ps -L で実行スレッドを確認すると、たしかに6スレッド起動しています。
次に、 lsof でプロセスの詳細を確認します。
名前付きパイプ(FIFO Read & Write)と、eventpoll の組み合わせが6個使われています。
1スレッドにつき、 FIFO(r), FIFO(w), eventpoll の3つが使われると考えて良さそうです。
つまり、 workers に指定するワーカー数は OS のulimit の 1/3 以下でなくではならないということですね。
名前付きパイプと、eventpollがスレッド数分使われるのは、actix_web が tokio ランタイムをベースにしているからです。
tokioコンテキストにおいてスレッドを実行した場合とRustの標準ライブラリのみでスレッドを実行した場合とで、プロセスの比較実験を行ってみました。
tokio
標準ライブラリ
tokio ではスレッド数のFIFO(r), FIFO(w), eventpollが使用されているのに対し、標準ライブラリではスレッド毎のFDはありません。
(こういう違いがありますよって話でした。)
スレッドの実験に使用したソースコードは下記に掲載しています。
x1-/rust_comparison_of_threads
まとめ
冒頭の仮説
TCPリスナーがHTTPリクエストをメールボックスに貯め、それをワーカーであるActorが拾ってレスポンスを返す?
は、全く異なっておりました😅
実際には、TCPリスナー は ストリーム・チャネルを介してTCPストリームをHTTPサービス・チェインに送信し、処理していました。
Actorシステム の出番はもう少し高次のレイヤーになります。
しかし、 futures::sync::mpsc::unbounded の使い方など、個人的にはいろいろ発見があり勉強になりました。