👨‍🎓

Rustの並行処理入門:初心者のためのガイド

2025/03/25に公開

表紙

並行性と並列性

多くの人が並行性(Concurrence)と並列性(Parallel)の概念を混同しています。したがって、Rust における非同期プログラミングを学ぶ前に、まずこの二つの違いを理解しておく必要があります。

オペレーティングシステムに関する書籍では、よく次のように説明されています:

  • 並行性とは、2 つ以上のイベントが同じ時間間隔内に発生することを指します。

  • 並列性とは、システムが同時に計算や操作を行う能力を持っていることを指します。

  • 説明その 1:並行性は、2 つ以上のイベントが同じ時間間隔で発生することを指し、並列性は 2 つ以上のイベントが同一時点で発生することを意味します。

  • 説明その 2:並行性は 1 つのエンティティ上で複数のイベントが発生すること、並列性は異なるエンティティ上で複数のイベントが発生することです。

  • 説明その 3:並行性は、1 つのプロセッサ上で複数のタスクを「同時に」処理することであり、並列性は複数のプロセッサ上で複数のタスクを同時に処理することです。例えば分散クラスタのように。

Go 言語の創設者の一人である Rob Pike は、これについて非常に的確かつ直感的な説明をしています:

Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.

並行性は多くのことを同時に「扱う」能力であり、並列性は多くのことを同時に「実行する」手段です。

実行したい処理を複数のスレッドや非同期タスクに分けて処理するのが並行性の能力です。そして、それらのスレッドやタスクをマルチコアやマルチ CPU 環境で同時に実行することが並列性の手段です。言い換えれば、並行性は並列性を可能にする基盤であり、並行性が備わっていれば、並列性は自然と実現できるのです。

  • 並行性(concurrency):ある時点で同時に 1 つの命令しか実行できないが、複数のプロセスの命令が高速に切り替わって実行されることで、マクロ的には複数プロセスが同時に動いているように見える。しかしミクロ的には同時には動いていない。時間をいくつかのスライスに分け、それぞれのプロセスを順に実行しているに過ぎない。
  • 並列性(parallel):同じ時点で、複数の命令が複数のプロセッサ上で同時に実行されている。したがって、マクロ的にもミクロ的にも同時に処理されている。

複数のスレッドが動作している場合でも、システムに CPU が 1 つしかなければ、実際には複数のスレッドが同時に実行されることはありません。CPU の実行時間をいくつかの時間スライスに分け、それを各スレッドに割り当てます。ある時間スライス中に 1 つのスレッドが動作している間、他のスレッドは停止状態(スリープ)にあります。このような方式を「並行性(Concurrent)」と呼びます。

一方、システムに複数の CPU がある場合、スレッドの実行は並行性に限定されません。1 つの CPU が 1 つのスレッドを実行している間、別の CPU が他のスレッドを実行することが可能です。これにより、スレッド同士が CPU 資源を取り合うことなく、同時に実行できるようになります。このような方式を「並列性(Parallel)」と呼びます。

まず結論を述べると:並行性と並列性はいずれも「マルチタスク処理」に関する概念です。並行性はタスクを交互に実行(処理)するもので、処理能力(例えば同時処理数)に焦点を当てたものです。一方、並列性はタスクを同時に実行(処理)するもので、処理手段(例えば同時に何個のタスクを実行できるか)に焦点を当てています。

並行プログラミングモデル

プログラミング言語ごとに実装が異なるため、各言語が採用する並行プログラミングモデルもそれぞれ異なります。ある言語でプログラムを記述し、コンパイルして実行すると、そのプログラムは一つのプロセスを占有します。そしてこのプロセス内で、さらにいくつかのスレッドを生成することができます。これらのスレッドは OS(オペレーティングシステム)レベルのものです。

一方で、プログラミング言語内部で開発者が呼び出すスレッドは、言語レベルでのスレッドです。この二者が 1 対 1 で対応しているかどうかは、その言語の内部実装によって決まります:

  • OS ネイティブスレッド:たとえば Rust 言語は OS が提供する API を直接呼び出すため、最終的にプログラム内のスレッド数は OS が扱うスレッド数と一致します。
  • コルーチン(Coroutines):Go 言語のように、プログラム内部にある M 個のスレッドが、ある種のマッピング方式により N 個の OS スレッド上で動作するものです。
  • イベント駆動(Event Driven):イベント駆動はよくコールバック(Callback)と一緒に使われます。このモデルは非常に高いパフォーマンスを持ちますが、最大の問題点は「コールバック地獄(Callback Hell)」が発生するリスクがあることです。
  • アクターモデル(Actor Model):メッセージのやりとりに基づき、処理を小さな単位に分解して並行計算を行うモデルです。Erlang 言語の得意分野です。
  • async/await モデル:高い性能を持ち、かつ低レベルプログラミングもサポートできるモデルです。同時に、スレッドやコルーチンのように、プログラミングモデルを大きく変える必要がありません。ただし、得るものがあれば失うものもあり、async モデルの欠点は、内部実装が非常に複雑な点です。

まとめると、Rust はさまざまなトレードオフを考慮したうえで、マルチスレッドasync/awaitという 2 つの並行プログラミングモデルを同時に提供する設計を選びました:

  • マルチスレッドは標準ライブラリに実装されており、OS の低レベル API を直接呼び出す方式です。実装や使用方法がシンプルで、比較的少量の並行処理に適しています。
  • async/awaitは実装がやや複雑ですが、Rust は言語機能・標準ライブラリ・サードパーティライブラリによってこれを実現・抽象化しており、開発者は低レベルな実装ロジックを意識することなく使うことができます。大量の並行処理や非同期 IO に適しています。

Rust における非同期プログラミング

非同期プログラミングは、並行プログラミングモデルの一種です。非同期プログラミングを使うと、大量のタスクを同時に並行実行できるようになりますが、必要な OS スレッドや CPU コアの数はごくわずか、あるいは 1 つだけでも可能です。モダンな非同期プログラミングは、使用感においても同期プログラミングとほとんど違いがありません。

現在、多くの言語が async を使って非同期プログラミングをサポートしていますが、Rust の実装にはいくつか独特な特徴があります:

  • Future は Rust において遅延実行(レイジー)Futurepoll される(ポーリングされる)まで実行されません。そのため、Future を破棄すれば、それ以降実行されることはありません。Future は将来ある時点でスケジューリングされて実行される「タスク」のようなものと理解できます。
  • Rust の async はゼロ・オーバーヘッド:つまり、パフォーマンスコストが発生するのは自分が書いたコード部分だけであり、async の内部実装にはパフォーマンスコストが一切かかりません。例えば、ヒープメモリの割り当ても、動的ディスパッチも必要なく async を使うことができるため、ホットパス(高頻度に呼ばれる処理)において非常に高いパフォーマンスを実現できます。この特徴こそが Rust の非同期性能の高さの理由です。
  • Rust は非同期呼び出しに必要なランタイムを標準で内蔵していません。ですが、心配は無用です。Rust のコミュニティには非常に優れた非同期ランタイムがいくつも存在しており、例えば有名な tokio があります。
  • ランタイムはシングルスレッドとマルチスレッドの両方に対応:それぞれに利点と欠点があり、これについては後述します。

Async 非同期とマルチスレッドの選択

async とマルチスレッドはどちらも並行プログラミングを実現できます。マルチスレッドはスレッドプールを使うことでさらに並行処理能力を強化することも可能です。ただし、この二つの方式は互換性がなく、片方からもう一方へ移行するには大量のコードのリファクタリングが必要です。そのため、両者の違いと適用範囲を理解し、事前に正しい選択をすることが非常に重要です。

  • CPU 集約型タスク(例:並列計算など)には、マルチスレッドプログラミングが適しています。これは、こういった処理はスレッドが長時間 CPU をフルに使うことが多く、スレッド数を CPU コア数に合わせることで、CPU の並列能力を最大限に活用できるからです。このとき、スレッドの頻繁な作成・切り替えは不要です。スレッドの切り替えにはコストがかかるため、スレッドを CPU コアにバインドすることで、コンテキストスイッチを減らすのが効果的です。
  • IO 集約型タスク(例:Web サーバ、データベース接続などのネットワークサービス)には、非同期プログラミングの方が適しています。これらのタスクはほとんどの時間が「待ち状態」にあり、マルチスレッドを使うとスレッドが多くの時間アイドル状態になります。加えて、スレッド間のコンテキストスイッチのオーバーヘッドも大きく、パフォーマンスが大きく損なわれます。async を使えば、CPU やメモリの負担を効果的に下げつつ、大量のタスクを並行実行できます。あるタスクが IO や他のブロック状態に入った瞬間、すぐに次のタスクに切り替えられるため、その切り替えコストはマルチスレッドでのスレッドスイッチよりも遥かに小さくなります。

非同期処理の実装も最終的にはスレッドを使っています。しかし、それらはスレッドの上にランタイムを構築し、多数のタスクを少数のスレッドにマッピングして実行します。つまり、多数の IO 集約型のイベントを少数のスレッドで処理し、イベント駆動で効率的に通信しているということです。
欠点は、この方法によって Rust プログラムのランタイムサイズが増大し、コンパイル後のバイナリ実行ファイルのサイズが顕著に大きくなる点です。

簡単な例で両者の違いを見てみましょう。例えば、2 つのファイルをダウンロードしたいとします。これを一つずつ順番に download(逐次処理)することもできますが、明らかに効率は良くありません。このとき、自然に思いつくのはマルチスレッドで並列にダウンロードする方法です:

マルチスレッドプログラミング:

fn download_two_files() {
    // 2つの新しいスレッドを作成してタスクを実行
    let thread_one = thread::spawn(|| download("URL1"));
    let thread_two = thread::spawn(|| download("URL2"));
    // 2つのスレッドの完了を待つ
    thread_one.join().expect("thread one panic");
    thread_two.join().expect("thread two panic");
}

数個のファイルしかダウンロードしない場合、これでまったく問題ありません。ただし、何百・何千ものファイルを同時にダウンロードしたい場合、1 タスクあたり 1 スレッドを使うとスレッドのリソース消費が爆発的に増えます(スレッドは「重い」存在です)。このときに考慮すべきなのが async です:

async 非同期プログラミング:

async fn get_two_sites_async() {
    // 2つの異なる future を作成
    // future は将来のある時点で実行される予定のタスク、JS における Promise に似ています
    // 両方の future を同時に実行すれば、対象のページを並行でダウンロードすることになります
    let future_one = download_async("URL1");
    let future_two = download_async("URL2");
    // 2つの future を同時に実行し、完了するまで待つ
    join!(future_one, future_two);
}

このように、並列数を変えずに、スレッドの作成と切り替えコストを大幅に削減できるという点が、async モデルの大きな利点です。

まとめ

並行性並列性はいずれも「マルチタスク処理」に関する概念ですが、違いがあります。並行性はタスクを交互に処理することであり、並列性はタスクを同時に処理することです。

並行プログラミングとは、プログラムの各部分が互いに独立して実行されることを指し、並列プログラミングとは、プログラムの各部分が同時に実行されることを指します。

並行プログラミングモデルにおいて、Rust はその言語設計思想・安全性・パフォーマンスなど多くの要素を考慮し、Go 言語のような「シンプルイズベスト」な方式は採用しませんでした。代わりに、マルチスレッドasync/await の両方を提供するという設計を選びました。

このアプローチの利点は、制御性が高く、パフォーマンスも優れていることです。欠点は、実装が複雑になりがちなことです。とはいえ、これはシステムプログラミング言語としては当然の選択であり、「複雑さと引き換えに制御性と性能を得る」という哲学に基づいています。

実際には、async とマルチスレッドは「どちらか一方」ではなく、同じアプリケーション内で同時に使われることもよくあります。async もマルチスレッドも並行処理を実現できますし、後者はスレッドプールによってさらにスケーラブルになりますが、両者は互換性がなく、切り替えには大きな手間がかかるため、プロジェクト開始時に適切なモデルを選択することが極めて重要です。

結論として:

  • async プログラミングは IO 集約型に適しており、
  • マルチスレッドは CPU 集約型に適しています。

選択のルールを簡単にまとめると以下のようになります:

  • 大量の IO タスク を並行実行したい場合:async モデルを選ぶ
  • 一部の IO タスク を並行実行したい場合:マルチスレッドを選ぶ(スレッドの作成・破棄のコストを下げたいならスレッドプールを使う)
  • 大量の CPU 集約タスク を並列処理したい場合(例:並列計算):マルチスレッドモデルを選び、スレッド数を CPU コア数と等しいかやや多くする
  • 特に制約がない場合:マルチスレッドで統一するのが無難

私たちはLeapcell、Rustプロジェクトのホスティングの最適解です。

Leapcell

Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:

複数言語サポート

  • Node.js、Python、Go、Rustで開発できます。

無制限のプロジェクトデプロイ

  • 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。

比類のないコスト効率

  • 使用量に応じた支払い、アイドル時間は課金されません。
  • 例: $25で6.94Mリクエスト、平均応答時間60ms。

洗練された開発者体験

  • 直感的なUIで簡単に設定できます。
  • 完全自動化されたCI/CDパイプラインとGitOps統合。
  • 実行可能なインサイトのためのリアルタイムのメトリクスとログ。

簡単なスケーラビリティと高パフォーマンス

  • 高い同時実行性を容易に処理するためのオートスケーリング。
  • ゼロ運用オーバーヘッド — 構築に集中できます。

ドキュメントで詳細を確認!

Try Leapcell

Xでフォローする:@LeapcellHQ


ブログでこの記事を読む

Discussion