Actix Web の Websockets handler の裏側にいるアクターを理解する
Actix Web
Actix Web は Rust で書かれた Web フレームワークです。Actix Web はアクターモデルを基盤に採用していることで有名ですが、利用者がアクターを意識することはあまり無いと思います。実際、Actix Web のサンプルで "Actor" と検索して出てくるのはほとんどが websockets ディレクトリ以下のサンプルです。例外として RedisActorSessionStore
もありますが、これも利用者がアクターを意識することはほとんどないと思います。もちろん、利用者が意図的にアクターを活用してプログラムを書くことも可能です。
WebSocket のような非同期通信を行う場合、アクターを使うことで排他制御を意識せずに比較的容易にプログラムを書くことができます。また、WebSocket の場合は普通の HTTP ハンドラと比べてアクターの存在が少しだけ見えています。
この記事では Actix のアクターモデルを説明した上で、Actix Web の Websockets handler の裏側にいるアクターを理解し、アクターを使わない例と比較しながら、アクターを使うメリットを考えてみます。
アクターモデル
アクターモデルは並行処理を行うためのモデルの一つです。アクターモデルでは、アクターと呼ばれる独立した処理単位がメッセージを送り合うことで通信を行います。アクターは他のアクターにメッセージを送ることができますが、直接他のアクターの状態を変更することはできません。アクターはメッセージを受け取った際に自身の状態を変更することができます。
(👆 Copilot が勝手に書いてくれました)
アクターモデルの登場人物で重要なものは「アクター」と「メッセージ」の2つです。
アクターは独立した処理単位であり、自身の状態とメールボックスを持ち、メッセージを受け取るとそのメッセージに応じて状態を変更したり、他のアクターにメッセージを送るなどします。
メッセージはアクター間で送受信される情報です。
アクターモデルの例: カウンター
Actix Web の説明の前に Actix 単体でのアクターモデルの使い方を見てみましょう。
単純なカウンターをアクターとして実装してみます。
Counter
構造体が actix::Actor
トレイトを実装しているのでアクターとして振る舞います。このアクターは内部状態として count
を持ち、Increment
メッセージを受け取ると count
をインクリメントします。また、Get
メッセージを受け取るとその時点の count
を返します。
アクターの定義
impl Actor for T
でアクターを定義します。type Context
を定義しますが、まあおまじない程度に考えておいてください。fn started
などのライフサイクルメソッドは必要に応じて実装します。
メッセージの定義
メッセージとして actix::Message
トレイトを実装する構造体を定義します。ここではフィールドを持たない型だけの構造体ですが、メッセージの内容を表すフィールドを持つこともできます。Get
と同様に Increment
メッセージも定義しています。
メッセージハンドラの定義
impl actix::Handler<Get> for Counter
で Get
メッセージに対するメッセージハンドラを定義します。type Result
はメッセージハンドラの戻り値の型です。handle
メソッドでメッセージを受け取った際の処理を実装します。ここでは Counter
アクターの count
フィールドを返しています。同様に Increment
メッセージを受け取った際の処理も実装しています。
アクターの生成とメッセージの送信
アクターとして振る舞う構造体は start
メソッドでアクターとして起動できます。start
の戻り値はアドレスを表す actix::Addr<Counter>
型です。
actix::Actor::start
でアクターを生成します。send
メソッドでメッセージを送信します。send
メソッドは非同期でメッセージを送信します。戻り値を得るには await
します。
アクターモデルで書くメリット
アクターモデルのメリットはいくつかあると思いますが、最大のメリットは排他制御が基本的に不要であることです。上記の例では Mutex
やチャンネル・アトミック変数などの並行処理に必要なはずの要素が登場しません。これはアクターシステムが一つのアクターに対するハンドラを一度に一つしか実行しないためです。アクターはメッセージを受け取るとそのメッセージに対する処理を行い、その間他のメッセージを受け取ることはできません。そのため、アクターの状態を変更する処理は排他制御が不要です。
次の節では Actix Web のハンドラでアクターを使い、排他制御なしにカウンターがきちんと動作していることを確認します。
例: Actix Web でアクターを使う
後で説明するアクターを使用しない例と合わせるため、先ほどのカウンターと少し変えて IncrementAndGet
メッセージでインクリメントした後の値を返すようにしています。
start
したアクターのアドレスを actix_web::Data
でラップして状態として持ち、"/" にアクセスすると IncrementAndGet
メッセージを送信して結果を表示するようにしています。
試しに動かしてみる
サンプルリポジトリ をクローンしてくれば、cargo run
で動作するはずです。
❯ cargo run --bin counter-web
Finished dev [unoptimized + debuginfo] target(s) in 0.08s
Running `target/debug/counter-web`
別のターミナルから curl
でアクセスしてみます。
❯ curl http://localhost:8080/
count: 1
2回・3回とアクセスするとカウントが増えていくのが確認できると思います。
並列アクセスしてみる
カウンターの値をリセットするため、cargo run
したプロセスをCTRL-Cで止め、再度起動し直します。
今度は並列にアクセスしてカウンターの値が正しくインクリメントされているか確認します。ab
コマンドでも良かったのですが、今回は Rust 製の rsb を使ってみます。
インストールは cargo install rsb
で出来ると書いてあったのですがなにやら nightly の機能を要求されているっぽいエラーが出たので cargo +nightly install rsb
でインストールしました(詳しく調べてはいません)。
並列数はデフォルトと同じ50、リクエスト数は100000としてみます。
❯ rsb -c 50 -n 100000 http://localhost:8080/
Get "http://localhost:8080/" with 100000 requests using 50 connections
▪▪▪▪▪ [00:00:02] [####################] 100000/100000 (49698/s, 100%, 0s) Statistics Avg Stdev Max
Reqs/sec 50000.00 4552.00 54552.00
Latency 896.67µs 656.09µs 31.77ms
HTTP codes:
1XX - 0, 2XX - 100000, 3XX - 0, 4XX - 0, 5XX - 0
others - 0
Throughput: 55761.87/s
10万回のリクエストを50並列で行い、全てのリクエストが成功していることが確認できました。もう一度 curl
コマンドでリクエストしてカウンターが正しく進んでることを確認します。
❯ curl http://localhost:8080/
count: 100001
rsb
の10万回に curl
の1回が足された 100001 が表示されているので、正しくカウンターがインクリメントされていることが確認できました。
アクターを使用しない例
アクターを使用しない例を actix/examples の /basics/state を参考に作成してみました。
行数自体は短いですが、Mutex
を使って排他制御するため、ロックを取得したり、スコープを抜けたときに開放されることを気にしたり、*counter += 1
という見慣れない記法が出てきたりと、アクターを使った例と比べて複雑になっています。
こちらも rsb
で並列アクセスしてみます。
❯ rsb -c 50 -n 100000 http://localhost:8080/
Get "http://localhost:8080/" with 100000 requests using 50 connections
▪▪▪▪▪ [00:00:02] [####################] 100000/100000 (49708/s, 100%, 0s) Statistics Avg Stdev Max
Reqs/sec 49999.50 14309.50 64309.00
Latency 680.77µs 623.97µs 18.21ms
HTTP codes:
1XX - 0, 2XX - 100000, 3XX - 0, 4XX - 0, 5XX - 0
others - 0
Throughput: 73445.92/s
アクターを使った例と同じく、10万回のリクエストを50並列で行い、全てのリクエストが成功していることが確認できました。この場合はアクターを使った例よりスループットが1.3倍程度高いです。
アクターを使用した WebSocket の例
最後に Actix Web の Websockets handler の裏側にいるアクターを理解するために、actix/examples の websockets/chat サンプルを見ようと思いましたが、このサンプルではアクターだけでなくアトミック変数を使って訪問者のカウンターを実装していました。せっかくのアクターモデルの分かりやすさが犠牲になっていると感じたので、アクターだけで実現できるよう、少し修正したのが上記のリンクです。
ソースファイルは main.rs
, server.rs
, session.rs
の3つです。main.rs
はエントリーポイントで HTTP ハンドラの定義とサーバの起動を行っています。server.rs
には複数のチャットルームと訪問者のセッションを管理する ChatServer
アクターが定義されています。session.rs
には訪問者の WebSocket セッションを表す WsChatSession
アクターが定義されています。
以下、それぞれのソースファイルを見ていきます。
main.rs
先に main
関数から見ていきます。
ChatServer
アクターを生成しています。ChatServer
は actix::Actor
トレイトを実装しているのでアクターとして振る舞います。server
にはアドレスが入っています。プロセス全体で1つの ChatServer
アクターを使います。
51行目以降で HTTP サーバを生成して起動しています。
先ほど生成した ChatServer
アクターのアドレスを HTTP サーバ全体で使うよう app_data
として登録しています。
"/" のハンドラとして index
を、"/count" のハンドラとして get_count
を、"/ws/" のハンドラとして chat_route
を登録しています。
あとはそれぞれのハンドラです。
http://localhost:8080/
にアクセスしたときに index.html を返します。
actix_web_actors::ws::start
で WebSocket 接続に対応するアクターを生成し、WebSocket としての処理はそのアクターに任せます(レスポンスとしては HTTP の 101 を返します)。
ChatServer
が把握している現在の訪問者数(visitor_count
)を返します。
シーケンス図
ChatServer
と WsChatSession
のアクターのコードを個別に見る前に、まずは全体のメッセージの流れを追いかけてみます。シーケンス図風に描くとこんな感じです。ここではAとBの2つのクライアントがいるとしています。クライアントの数だけ WsChatSession
アクターが生成されます。ChatServer
は何人接続しようが、何ルーム作られようが1つだけです。
server.rs
行数は多いですが、それほど難しいことはしていません。ChatServer
アクターとそれが処理するいくつかのメッセージ型、そのハンドラを定義しています。メッセージ型を列挙してみます。
-
Message
メッセージ(ややこしい) -
Connect
メッセージ -
Disconnect
メッセージ -
ClientMessage
メッセージ -
GetVisitorCount
メッセージ -
ListRooms
メッセージ
最初の Message
メッセージだけは ChatServer
から WsChatSession
に送られるため、ChatServer
にはハンドラがありません。
ListRooms
メッセージは derive による自動実装じゃなくてなぜか手動で実装しています。といっても戻り値の型を定義するだけなので簡単ですね。
引数で指定したルーム内の訪問者全員(の WebSocket クライアント)に(電文の意味での)メッセージを送るため、それぞれの WsChatSession
に Message
型の(アクターモデルの意味での)メッセージを送信しています。
他のメッセージハンドラはそれぞれのメッセージに応じて ChatServer
アクターの状態を書き換えたり、先ほどの send_message
メソッドを使ってクライアントに(電文の意味での)メッセージを送ったりします。
繰り返しになりますが、アクターシステムが一つのアクターに対するハンドラを一度に一つしか実行しないため、排他制御が不要ですので、それぞれの処理は理解が容易です。
serssion.rs
session.rs
では WsChatSession
アクターを定義しています。このアクターにはいくつかの機能があります。
- WebSocket セッションの管理
- クライアントに定期的にpingを送る死活監視
-
ChatServer
アクターからのメッセージの受信とクライアントへの転送 - クライアントからの WebSocket メッセージの受信
まずは WsChatSession
構造体をアクターにするための impl Actor
の辺りを見ていきます。
impl Actor for WsChatSession
までは普通のアクターと同じですが、 type Context
が ws::WebsocketContext<Self>
になっています。これは WebSocket セッションを管理するための特別なコンテキストです。このコンテキストを使うことで WebSocket メッセージの送信が出来ます。
例えばテキストメッセージの送信は次のように ctx.text()
メソッドを使って行います。
バイナリメッセージ、ping、pong なども同様にコンテキストから簡単に送信できます。
一方、受信はどうなっているかというと、アクターと同じく下記のようにハンドラを定義します。少しシグネチャが違うので Result をはがす必要がありますが、その後はメッセージの種別で場合分けしてどういう処理をするかを記述するだけです。
例えば上記の部分では Ping メッセージに対して Pong 応答を返しています。
以上のように、WebSocket のハンドラーではアクターと同じようにメッセージを受け取り、そのメッセージに応じて処理を行います。
アクターを使用しない WebSocket の例
Actix Web で WebSocket を使う場合、アクターを使わない方法もあります。本家のサンプルに元々収録されています(リンク先は私のフォーク版:何も変えてないですが)
詳細な解説はしませんが、tokio の task や mpsc チャンネル、アトミック変数などを駆使して WebSocket の処理を行っています。アクターを使った例と比べると様々な要素が登場し、それらの充分な知識が必要になっています。
また、Actix Web 以外の Web アプリケーションフレームワークでもほぼ同じような機能を実現する WebSocket Chat の例が提供されています。それらも同じように排他制御のために意識しなければならないことが多く複雑になっています。
まとめ
アクターシステムは一見とっつきにくいですが、理解出来れば排他制御を意識せずにプログラムを書くことができます。さすがに細かく排他制御に留意して書かれたプログラムにはパフォーマンスで劣ることもありますが、それでもアクターシステムのメリットは大きいと思います。
WebSocket は非同期にメッセージの送受信を行うため、プログラムが複雑になりやすいですが、アクターモデルを用いることで比較的理解しやすい書き方に出来ます。
Discussion