Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

Shinjuku.rs で actix-web の話をしました (ちょっとした解説付き)

11/21 に Shinjuku.rs で登壇しました.今回は actix-web に関する話をしました.もちろん LT ではすべてを話しきることができませんでしたので,今回も裏話を記事にして書き留めておこうかと思います (自身の頭の整理にもなるためです).

forcia.connpass.com

基礎知識編

同期 I/O vs 非同期 I/O,ブロッキング I/O vs ノンブロッキング I/O

非同期 I/O については,ユーザー数の多いサービスや,あるいはアドテクなどではとくに意識することが多いのですが,案外他の種類のアプリケーションを作っている方には馴染みのない概念かもしれないと思い説明を加えました.ただ実はこれがすべてというわけではなく,他の対となる概念と比較対象しないとよさや何をしたいかがよくわからないものでもあります.なので,今回の記事では,非同期 I/O について学ぶ際に登場してくる (であろう) 4つの概念を一気に説明していきます.

まずこの話を始める際の前提として,2人の登場人物が存在します.

  • プロセス
  • OS

プロセスが OS に I/O タスクを投げます.このイメージを忘れないでください.

プロセスが OS にタスクを投げ,そのタスクに関するなんらかの返答を受け取る方式の種別が同期・非同期です.同期 I/O の場合は,OS に I/O タスクを投げ,入出力の準備ができたらアプリケーションから実データを受け取ります.非同期 I/O の場合には,OS に I/O タスクを投げ,入出力の準備ができたら通知を受け取ります.両者の違いはそこにあります.

プロセスが OS にタスクを投げ,そのタスクの結果をどのように受け取るかの種別がブロッキング・ノンブロッキングですブロッキング I/O の場合には,プロセスが依頼したタスクを OS が終えるまで,プロセスはその結果を待ちます.一方でノンブロッキング I/O に場合には,プロセスは依頼したタスクを OS がこなしている間,別の処理をこなすなどしてその返りを待ちません.OS から通知があった際に結果を受け取ります.

そして,2つの種別は掛け算になります.つまり,「同期・ブロッキング」「同期・ノンブロッキング」「非同期・ブロッキング」「非同期・ノンブロッキング」の4種類の方式が存在します.それら4つの方式は,それぞれ異なるシステムコールを使用します.表にすると下記のようになるでしょう:

ブロッキング ノンブロッキング
同期 read/write read/write (O_NONBLOCK)
非同期 select/poll (Linux なら epoll を使うなど) AIO

ここまでの話をまとめておきます.

  • 同期 I/O は OS にタスクを投げたあと,実データを受け取る.
  • 非同期 I/O は OS にタスクを投げたあと,通知を受け取る.
  • ブロッキング I/O はタスクを OS に依頼するとその結果を待つ.
  • ノンブロッキング I/O はタスクを依頼しても結果を待たず別の処理をこなす.OS から通知があって初めて結果を受け取る.
  • 同期・ブロッキングreadwrite というシステムコールを使用する.
  • 同期・ノンブロッキングは,readwrite というシステムコールを使用するが,O_NONBLOCK というフラグがついている.
  • 非同期・ブロッキングselectpoll というシステムコールを使用するが,効率が悪いので epollkqueue などを用いる.
  • 非同期・ノンブロッキングは AIO というシステムコールを用いると言われているが実装が成熟していないらしい.

並行処理と並列処理

これはいわゆる言葉の定義の問題になってきて,前提次第によってはさまざまな定義が出てきてしまうかもしれませんが,一般には次のように区別されることが多いです.

  • 並行処理: CPU数,コア数の限界を超えて,複数の仕事を行うこと (1コア内で複数処理を行うことをイメージするとわかりやすいです)
  • 並列処理: 複数の処理を同時に起こすことによって,効率よく仕事を行うこと

並行処理がわかりにくいと思うので簡単に解説します.簡単化のために,CPU を1コアだと仮定します.1コアだったとすると,たとえばブラウザを開きながら調べごとをしつつ, IntelliJ を使ってプログラミングを行うということは現実的に不可能なように思えます.が,これは並行処理を使うと仮定するとできます.なぜかというと,1コア CPU の中で,瞬間的にブラウザの処理と IntelliJ の処理を切り替えながら処理を行っているためです.

人間が気づかないくらい短い間隔で,複数の処理を切り替えながら実行しているのです.これが,並行処理が論理的に複数の処理を実行可能であるゆえんです.

並列処理はそうではなくて,物理的に同時に複数の処理を行います.つまり,そのまま複数人で複数の仕事を行うということです.なお,並列処理は並行処理の中に含まれます.

参考

いくつかあるとは思いますが,私は次の本に載っている内容で理解しました.

Goならわかるシステムプログラミング

Goならわかるシステムプログラミング

アクターモデルについて

アクターモデルに関する説明は,上述のスライドの中で十分かなとは思っています.Akka に関する説明ではありますが,次の記事が図も説明もとてもわかりやすいと思うので,イマイチイメージが掴めなかった方はぜひご覧ください.

enterprisegeeks.hatenablog.com

ところで,アクターモデルのいいところとは一体なんでしょうか?並行処理をする上で避けては通れない問題の中に,いわゆる競合状態というものがあります.アクターモデルは,その競合状態に対する有効な解決策として考案されました.

競合状態は銀行口座の例がよく出されるのでそれを使用して考えてみます.次の銀行口座をイメージした疑似コードをご覧ください.

もし預金残高が $100 以上あれば {
    預金残高 - $100
    $100 分のお金を引き出す
}

シングルタスクの状況下においては,上記のプログラムは常に正しく動くはずです.しかし,マルチタスクのは以下ではどうなるでしょうか.次のような状況を考えてみましょう.

(預金残高が $150 あるとします.まず,プログラム A が走っているものとします)
A: 預金残高が $100 以上あるか?→YES
(プログラムを切り替えます.プログラム B に切り替わります)
B: 預金残高が $100 以上あるか?→YES
B: 預金残高 - $100 して,$100 分のお金を引き出す
(預金残高は $50 になっています.ここで,プログラム A に切り替わります)
A: 預金残高 - $100 して,$100 分のお金を引き出す
(あれ…?すでに残高は $50 しかないのに,$100 引き出されてしまった〜!不整合発生!)

このような状況のことを競合状態 (race condition) と呼びます.並行処理で同期的な処理を求められる場合に起こることが多く,スレッドセーフでない実装という呼び方をしたりもします.

競合状態は,2つ以上の処理が変数を共有しているような場合に起こります.上述の例ですと,預金残高という変数を,プログラム A とプログラム B が共有しているために競合状態が発生しました.

よくある解決のアプローチは,Lock (あるいは Mutex や Semaphore) を用いる方法です.メモリ共有されている箇所にアクセスする際には必ずロックを用いてアクセスするようにするということです.Java などではこちらをよく用いますね.Rust でも,Mutex が用意されている通りです.しかしロックにはロック特有の問題があります.デッドロックです.他にもいろいろな問題点があります.

あるいは,Rust でも登場してきますが,メモリを共有するとしても書き換えを不可能にしてしまえばいいのではないか?というアプローチを取る場合もあります.イミュータブルなものを扱おうという思想です.最近では多くの言語にこの方法も取り入れられてきているように思います.

アクターモデルのアプローチはロックではなく,メモリをそもそも共有させないことでした.アクターはそのかわりにメッセージを送ることで解決をはかりました.メッセージを投げるだけ投げて,相手側の処理は非同期的に行わせ,最終的に終わったときに終わったメッセージを受け取ることで処理を完了するようにしました.

アクターモデルにデメリットはあるのか?という話ですが,なくはない (でも大体の場合は回避策が用意されているものです) ようです.私は正直概要をさらっと知っている程度です.次のスライドが参考になるかなと思いましたので,興味のある方はご覧ください.

https://techno-tanoc.github.io/ex_slide/#/

※ Elixir は Erlang VM の上に乗っている Ruby のような文法をもった新しい言語です.

tokio

この記事がとても詳しいです.私もこの記事で理解をしました.

Tokio internals: Understanding Rust's asynchronous I/O framework from the bottom up : Caffeinated Bitstream

tokio, mio, future の関係性なども完璧に説明されておりすばらしいです.

まとめ

同期・非同期,あるいは並行処理なのか並列処理なのかといった微妙な話題を取り扱ったので,簡単にまとめとして記事を書いてみた次第です.説明が足りない部分については,ぜひ私にダイレクトメッセージをください.正直この分野は私もまだ勉強中です.

actix-web の内部実装についても軽く見てみたのですが,結構興味深かったので後日あげようと思います.

リポジトリ

github.com