[Rustのはじめ方] Part27: async/awaitとTokioの基本

はじめに

現代のアプリケーション、特にネットワークサービスでは、同時に多くの処理をこなす必要があります。例えば、Webサーバーは何千ものクライアントからのリクエストを同時に処理しなければなりません。従来の同期的な処理方法では、一つの処理が終わるまで次の処理に進めないため、リソースを効率的に使うことが難しい場面があります。

そこで登場するのが非同期処理です。Rustでは、async/await構文を使って、見た目は同期的でありながら、効率的な非同期処理を記述できます。これにより、I/O操作(ファイル読み書きやネットワーク通信など)で待機時間が発生しても、その間に他のタスクを進めることができるようになります。

このセクションでは、Rustのasync/awaitの基本的な概念と、非同期処理を実行するためのランタイムであるTokioの基本的な使い方について学んでいきます。

asyncとは?

asyncキーワードは、関数やブロックを非同期にするために使います。async fnで定義された関数は、通常の関数とは異なり、呼び出された時点では実行されません。代わりに、Futureという特別な型を返します。


async fn my_async_function() -> u32 {
  // 何か非同期な処理...
  println!("Executing async function!");
  5 // Future<Output = u32> を返す
}

fn main() {
  let future = my_async_function();
  println!("Async function created, but not executed yet.");
  // ここで `future` を実行する必要がある
  // (実行方法は後述)
}
      

Futureトレイトは、将来のある時点で完了する可能性のある計算を表します。Futureは「遅延評価」であり、実行されるまで(具体的には.awaitされるか、ランタイムに渡されるまで)は何もしません。これは、async fnを呼び出すだけでは、その中のコードがすぐに実行されるわけではない、という重要な点です。

awaitとは?

.await演算子は、Futureの完了を待機するために使われます。async関数やasyncブロックの内部でのみ使用できます。

.awaitが重要なのは、Futureがまだ完了していない(例えば、ネットワークからの応答を待っている)場合に、現在のタスクの実行を一時停止し、他のタスクに実行を譲る点です。これにより、スレッドをブロックすることなく、効率的にリソースを利用できます。Futureが完了したら、中断した箇所から処理を再開します。


async fn learn_song() -> String {
  // 時間のかかる処理をシミュレート
  tokio::time::sleep(std::time::Duration::from_secs(1)).await;
  println!("Song learned!");
  "My Favorite Song".to_string()
}

async fn sing_song(song: String) {
  println!("Singing: {}", song);
}

async fn dance() {
  println!("Dancing!");
}

// このmain関数を実行するにはTokioランタイムが必要 (後述)
#[tokio::main]
async fn main() {
  let song = learn_song().await; // learn_songが完了するまで待機
  sing_song(song).await; // sing_songが完了するまで待機
  dance().await; // danceが完了するまで待機

  println!("All done!");
}
      

上記の例では、learn_song()の完了を.awaitで待ってから、その結果を使ってsing_song()を呼び出しています。.awaitを使わない場合(例えば同期的なblock_onを使う場合)、スレッド全体がブロックされてしまい、他のタスク(例えばdance)を同時に実行できなくなります。

非同期ランタイムとは?

async fnが返すFutureは、それ自体を実行する能力を持ちません。Futureを実行し、進捗を管理する役割を担うのが非同期ランタイム(またはエグゼキュータ)です。

ランタイムは以下のような機能を提供します:

  • タスクスケジューラ: 多数のFuture(タスク)を管理し、どれを実行するかを決定します。
  • リアクター: OSからのI/Oイベント(ネットワーク、ファイル、タイマーなど)を監視し、関連するタスクを再開させます。
  • スレッドプール (オプション): 複数のCPUコアを活用するために、タスクを複数のスレッドで実行します。

Rustの標準ライブラリには非同期ランタイムは含まれていません。これは、ユースケースに応じて最適なランタイムを選択できるようにするためです。代表的な非同期ランタイムには以下のようなものがあります。

  • Tokio: 最も広く使われている、高機能でパフォーマンス重視のランタイム。ネットワークアプリケーション開発に適しています。
  • async-std: 標準ライブラリのAPIに対応する非同期版を提供することを目指すランタイム。
  • smol: シンプルで軽量なランタイム。

この学習サイトでは、最も普及しているTokioに焦点を当てて解説します。

Tokioの基本

Tokioは、Rustで信頼性が高く、高速な非同期アプリケーションを構築するためのランタイムです。イベント駆動型でノンブロッキングなI/Oを提供し、TCP/UDP、タイマー、ファイルシステム操作、プロセス管理など、非同期プログラミングに必要な多くの機能を提供します。

セットアップ

まず、Cargo.tomlにTokioを追加します。多くの機能が含まれているため、必要な機能だけを選択することも可能ですが、初学者はfullフィーチャーフラグを有効にするのが簡単です。


[dependencies]
tokio = { version = "1", features = ["full"] }
# version は最新版を確認してください
      

現在のTokioの最新バージョンや推奨されるMSRV(Minimum Supported Rust Version)については、crates.ioTokioのGitHubリポジトリを確認してください。

#[tokio::main] アトリビュート

Tokioを使って非同期コードを実行する最も簡単な方法は、main関数に#[tokio::main]アトリビュートを付けることです。このマクロは、async fn main()を通常の同期的なfn main()に変換し、内部でTokioランタイムを初期化してasync main関数を実行します。


use tokio::time::{sleep, Duration};

#[tokio::main]
async fn main() {
    println!("Hello from async main!");
    // 1秒待機する非同期処理
    sleep(Duration::from_secs(1)).await;
    println!("After 1 second sleep.");
}
      

このコードを実行すると、Tokioランタイムが起動し、main関数内の非同期コードが実行されます。sleep関数は非同期な待機を行い、その間スレッドはブロックされません。

#[tokio::main]は、デフォルトではマルチスレッドのランタイムを起動します。シングルスレッドで実行したい場合などは、マクロに引数を渡して設定を変更できます。


// シングルスレッドランタイムを使用する例
#[tokio::main(flavor = "current_thread")]
async fn main() {
    println!("Running on a single-threaded Tokio runtime.");
}

// マルチスレッドランタイムでワーカースレッド数を指定する例
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
    println!("Running on a multi-threaded Tokio runtime with 4 worker threads.");
}
      

タスクのスポーン: tokio::spawn

複数の非同期処理を並行に実行したい場合、tokio::spawnを使います。spawnは新しい非同期タスク(軽量なグリーンスレッドのようなもの)を生成し、Tokioランタイムのスケジューラに実行を依頼します。spawnされたタスクは、呼び出し元のタスクとは独立して実行されます。


use tokio::time::{sleep, Duration};

async fn task_one() {
    println!("Task one starting...");
    sleep(Duration::from_secs(2)).await;
    println!("Task one finished.");
}

async fn task_two() {
    println!("Task two starting...");
    sleep(Duration::from_secs(1)).await;
    println!("Task two finished.");
}

#[tokio::main]
async fn main() {
    println!("Spawning tasks...");

    // タスクをスポーンする
    // spawnに渡すFutureは'staticライフタイムを持つ必要がある
    // moveキーワードで所有権をFuture内に移動させることが多い
    let handle1 = tokio::spawn(async move {
        task_one().await;
    });

    let handle2 = tokio::spawn(async move {
        task_two().await;
    });

    println!("Tasks spawned.");

    // 必要であればJoinHandleを使ってタスクの完了を待つ
    // .await は Result を返すので unwrap() などで処理する
    let _ = handle1.await;
    let _ = handle2.await;

    println!("All tasks finished.");
}
      

この例では、task_onetask_twoが並行して実行されます。task_twoの方が待機時間が短いため、先に終了するはずです。spawnJoinHandleを返し、これを使ってタスクの完了を待ったり、タスクの返り値を取得したりできます。

重要な注意点として、tokio::spawnで起動されるタスクはSendトレイトを実装し、かつ'staticライフタイムを持つ必要があります。これは、タスクが別のスレッドに移動される可能性があるためです。多くの場合、async move { ... }ブロックを使って、必要な変数の所有権をタスク内に移動させます。

async/awaitとTokioを使った非同期I/O

非同期処理が特に役立つのは、ファイルI/Oやネットワーク通信のような、時間がかかる可能性のあるI/O操作です。Tokioはこれらのための非同期APIを提供しています。

  • tokio::fs: 非同期ファイルシステム操作
  • tokio::net: 非同期TCP、UDP、Unixソケット
  • tokio::process: 非同期プロセス管理

これらのAPIを使うことで、I/O操作の待機中にスレッドをブロックすることなく、他のタスクを実行できます。


use tokio::fs::File;
use tokio::io::{self, AsyncReadExt};

#[tokio::main]
async fn main() -> io::Result<()> {
    // ファイルを非同期に開く
    let mut file = File::open("example.txt").await?;

    let mut contents = String::new();
    // ファイルの内容を非同期に読み込む
    file.read_to_string(&mut contents).await?;

    println!("File contents:\n{}", contents);

    Ok(())
}
      

この例では、File::openread_to_stringが非同期に実行されます。もしexample.txtが非常に大きなファイルだったとしても、読み込み中に他のタスク(もしあれば)が実行される可能性があります。

注意点とまとめ

  • ランタイムが必須: async fnFutureを実行するには、Tokioのような非同期ランタイムが必要です。#[tokio::main]を使うのが最も簡単な方法です。
  • .awaitの場所: .awaitasync関数またはasyncブロックの中でのみ使用できます。
  • ブロッキング操作の回避: asyncなコンテキスト内で、標準ライブラリの同期的なI/O関数(std::fs::readなど)や時間のかかるCPU処理を直接呼び出すと、ランタイムのスレッドをブロックしてしまい、非同期処理の利点が失われます。このような場合は、tokio::task::spawn_blockingを使って別のスレッドで実行することを検討してください。
  • Send'static: tokio::spawnでタスクを生成する場合、タスク(Future)は通常Send + 'staticである必要があります。async moveブロックがよく使われます。
  • 関数カラーリング: async関数はasyncなコンテキストからしか直接.awaitできません。同期関数から非同期関数を呼び出すには、ランタイムの機能(block_onなど)を使う必要がありますが、注意が必要です。

async/awaitとTokioは、Rustで効率的でスケーラブルなネットワークアプリケーションなどを構築するための強力なツールです。最初は少し複雑に感じるかもしれませんが、Futureasync/await、そしてランタイムの役割を理解することが重要です。

次のステップでは、より高度なTokioの機能(チャネル、同期プリミティブ、ストリームなど)や、非同期コードのエラーハンドリングについて学んでいきましょう!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です