🤡

Actix Web の Websockets handler の裏側にいるアクターを理解する

2024/04/07に公開

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 単体でのアクターモデルの使い方を見てみましょう。
単純なカウンターをアクターとして実装してみます。

https://github.com/kounoike/actix-actor-example/blob/acd155f30cf1f8b2bba5b1137b530153b9488408/src/bin/counter-actor.rs

Counter 構造体が actix::Actor トレイトを実装しているのでアクターとして振る舞います。このアクターは内部状態として count を持ち、Increment メッセージを受け取ると count をインクリメントします。また、Get メッセージを受け取るとその時点の count を返します。

アクターの定義

https://github.com/kounoike/actix-actor-example/blob/acd155f30cf1f8b2bba5b1137b530153b9488408/src/bin/counter-actor.rs#L3-L7

impl Actor for T でアクターを定義します。type Context を定義しますが、まあおまじない程度に考えておいてください。fn started などのライフサイクルメソッドは必要に応じて実装します。

メッセージの定義

https://github.com/kounoike/actix-actor-example/blob/acd155f30cf1f8b2bba5b1137b530153b9488408/src/bin/counter-actor.rs#L9-L12

メッセージとして actix::Message トレイトを実装する構造体を定義します。ここではフィールドを持たない型だけの構造体ですが、メッセージの内容を表すフィールドを持つこともできます。Get と同様に Increment メッセージも定義しています。

メッセージハンドラの定義

https://github.com/kounoike/actix-actor-example/blob/acd155f30cf1f8b2bba5b1137b530153b9488408/src/bin/counter-actor.rs#L23-L29

impl actix::Handler<Get> for CounterGet メッセージに対するメッセージハンドラを定義します。type Result はメッセージハンドラの戻り値の型です。handle メソッドでメッセージを受け取った際の処理を実装します。ここでは Counter アクターの count フィールドを返しています。同様に Increment メッセージを受け取った際の処理も実装しています。

アクターの生成とメッセージの送信

https://github.com/kounoike/actix-actor-example/blob/acd155f30cf1f8b2bba5b1137b530153b9488408/src/bin/counter-actor.rs#L39-L48

アクターとして振る舞う構造体は start メソッドでアクターとして起動できます。start の戻り値はアドレスを表す actix::Addr<Counter> 型です。

actix::Actor::start でアクターを生成します。send メソッドでメッセージを送信します。send メソッドは非同期でメッセージを送信します。戻り値を得るには await します。

アクターモデルで書くメリット

アクターモデルのメリットはいくつかあると思いますが、最大のメリットは排他制御が基本的に不要であることです。上記の例では Mutex やチャンネル・アトミック変数などの並行処理に必要なはずの要素が登場しません。これはアクターシステムが一つのアクターに対するハンドラを一度に一つしか実行しないためです。アクターはメッセージを受け取るとそのメッセージに対する処理を行い、その間他のメッセージを受け取ることはできません。そのため、アクターの状態を変更する処理は排他制御が不要です。

次の節では Actix Web のハンドラでアクターを使い、排他制御なしにカウンターがきちんと動作していることを確認します。

例: Actix Web でアクターを使う

https://github.com/kounoike/actix-actor-example/blob/acd155f30cf1f8b2bba5b1137b530153b9488408/src/bin/counter-web.rs

後で説明するアクターを使用しない例と合わせるため、先ほどのカウンターと少し変えて 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 が表示されているので、正しくカウンターがインクリメントされていることが確認できました。

アクターを使用しない例

https://github.com/kounoike/actix-actor-example/blob/acd155f30cf1f8b2bba5b1137b530153b9488408/src/bin/counter-web-actorless.rs

アクターを使用しない例を 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 の例

https://github.com/kounoike/actix-examples/tree/simplify-chat-example/websockets/chat

最後に 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 関数から見ていきます。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/main.rs#L46-L47

ChatServer アクターを生成しています。ChatServeractix::Actor トレイトを実装しているのでアクターとして振る舞います。server にはアドレスが入っています。プロセス全体で1つの ChatServer アクターを使います。

51行目以降で HTTP サーバを生成して起動しています。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/main.rs#L53

先ほど生成した ChatServer アクターのアドレスを HTTP サーバ全体で使うよう app_data として登録しています。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/main.rs#L54-L56

"/" のハンドラとして index を、"/count" のハンドラとして get_count を、"/ws/" のハンドラとして chat_route を登録しています。

あとはそれぞれのハンドラです。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/main.rs#L13-L15

http://localhost:8080/ にアクセスしたときに index.html を返します。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/main.rs#L17-L34

actix_web_actors::ws::start で WebSocket 接続に対応するアクターを生成し、WebSocket としての処理はそのアクターに任せます(レスポンスとしては HTTP の 101 を返します)。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/main.rs#L36-L40

ChatServer が把握している現在の訪問者数(visitor_count)を返します。

シーケンス図

ChatServerWsChatSession のアクターのコードを個別に見る前に、まずは全体のメッセージの流れを追いかけてみます。シーケンス図風に描くとこんな感じです。ここではAとBの2つのクライアントがいるとしています。クライアントの数だけ WsChatSession アクターが生成されます。ChatServer は何人接続しようが、何ルーム作られようが1つだけです。

server.rs

行数は多いですが、それほど難しいことはしていません。ChatServer アクターとそれが処理するいくつかのメッセージ型、そのハンドラを定義しています。メッセージ型を列挙してみます。

  1. Message メッセージ(ややこしい)
  2. Connect メッセージ
  3. Disconnect メッセージ
  4. ClientMessage メッセージ
  5. GetVisitorCount メッセージ
  6. ListRooms メッセージ

最初の Message メッセージだけは ChatServer から WsChatSession に送られるため、ChatServer にはハンドラがありません。

ListRooms メッセージは derive による自動実装じゃなくてなぜか手動で実装しています。といっても戻り値の型を定義するだけなので簡単ですね。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/server.rs#L92-L105

引数で指定したルーム内の訪問者全員(の WebSocket クライアント)に(電文の意味での)メッセージを送るため、それぞれの WsChatSessionMessage 型の(アクターモデルの意味での)メッセージを送信しています。

他のメッセージハンドラはそれぞれのメッセージに応じて ChatServer アクターの状態を書き換えたり、先ほどの send_message メソッドを使ってクライアントに(電文の意味での)メッセージを送ったりします。

繰り返しになりますが、アクターシステムが一つのアクターに対するハンドラを一度に一つしか実行しないため、排他制御が不要ですので、それぞれの処理は理解が容易です。

serssion.rs

session.rs では WsChatSession アクターを定義しています。このアクターにはいくつかの機能があります。

  1. WebSocket セッションの管理
  2. クライアントに定期的にpingを送る死活監視
  3. ChatServer アクターからのメッセージの受信とクライアントへの転送
  4. クライアントからの WebSocket メッセージの受信

まずは WsChatSession 構造体をアクターにするための impl Actor の辺りを見ていきます。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/session.rs#L59-L60

impl Actor for WsChatSession までは普通のアクターと同じですが、 type Contextws::WebsocketContext<Self> になっています。これは WebSocket セッションを管理するための特別なコンテキストです。このコンテキストを使うことで WebSocket メッセージの送信が出来ます。

例えばテキストメッセージの送信は次のように ctx.text() メソッドを使って行います。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/session.rs#L102

バイナリメッセージ、ping、pong なども同様にコンテキストから簡単に送信できます。

一方、受信はどうなっているかというと、アクターと同じく下記のようにハンドラを定義します。少しシグネチャが違うので Result をはがす必要がありますが、その後はメッセージの種別で場合分けしてどういう処理をするかを記述するだけです。

https://github.com/kounoike/actix-examples/blob/026e75c688ce08093173294fff7194394997f584/websockets/chat/src/session.rs#L119-L122

例えば上記の部分では Ping メッセージに対して Pong 応答を返しています。

以上のように、WebSocket のハンドラーではアクターと同じようにメッセージを受け取り、そのメッセージに応じて処理を行います。

アクターを使用しない WebSocket の例

Actix Web で WebSocket を使う場合、アクターを使わない方法もあります。本家のサンプルに元々収録されています(リンク先は私のフォーク版:何も変えてないですが)

https://github.com/kounoike/actix-examples/tree/026e75c688ce08093173294fff7194394997f584/websockets/chat-actorless/src

詳細な解説はしませんが、tokio の task や mpsc チャンネル、アトミック変数などを駆使して WebSocket の処理を行っています。アクターを使った例と比べると様々な要素が登場し、それらの充分な知識が必要になっています。

また、Actix Web 以外の Web アプリケーションフレームワークでもほぼ同じような機能を実現する WebSocket Chat の例が提供されています。それらも同じように排他制御のために意識しなければならないことが多く複雑になっています。

まとめ

アクターシステムは一見とっつきにくいですが、理解出来れば排他制御を意識せずにプログラムを書くことができます。さすがに細かく排他制御に留意して書かれたプログラムにはパフォーマンスで劣ることもありますが、それでもアクターシステムのメリットは大きいと思います。

WebSocket は非同期にメッセージの送受信を行うため、プログラムが複雑になりやすいですが、アクターモデルを用いることで比較的理解しやすい書き方に出来ます。

Discussion