1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rustの`async`/`await`を完全に理解する:7つの落とし穴と解決パターン

1
Posted at

TL;DR

  • Rust の async は「ゼロコスト抽象化」だが、使い方を誤ると意図しないブロッキングが発生する
  • Future はポーリングモデルであり、await しない限り実行されない
  • Send 境界・MutexGuard のスコープ・タスク分割の 3 点が頻出の躓きポイント
  • 本記事では 7 つのよくあるミスと、tokio ベースの解決コードを示す

背景

Rust の非同期プログラミングは「速くて安全」と謳われますが、初見では挙動が掴みにくい落とし穴が多数あります。

2024〜2026 年にかけて tokio は 1.x 系が安定運用フェーズに入り、async-std との棲み分けも整理されてきました。今こそ「なぜそう動くのか」を一度体系的に整理するタイミングです。


前提知識

  • Rust 2021 Edition 以降
  • tokio = { version = "1", features = ["full"] } 使用
  • cargo ビルドが通る環境

await を付け忘れる(Future が実行されない)

最も初歩的、かつ最も気づきにくい罠です。

// ❌ NG: Future を生成しているだけで実行されていない
async fn fetch_data() -> String {
    reqwest::get("https://example.com/api")  // ← await がない
        .unwrap()
        .text()
        .await
        .unwrap()
}

Rust は Future遅延評価します。.await を呼ばなければ一切実行されません。コンパイラは警告を出してくれますが、チェーンの途中で await を忘れると警告が出ないケースがあります。

// ✅ OK
async fn fetch_data() -> String {
    reqwest::get("https://example.com/api")
        .await           // ← ここ
        .unwrap()
        .text()
        .await
        .unwrap()
}

教訓: async fn を呼んだ直後は必ず .await があるか目視確認する。


② 同期的な重い処理を async ランタイムで直接実行する

tokio のデフォルトランタイムはスレッドプールを持ちますが、1 タスクが CPU をブロックするとスレッドプール全体が詰まります

// ❌ NG: 重い同期処理を async タスク内で直接呼ぶ
#[tokio::main]
async fn main() {
    tokio::spawn(async {
        let result = heavy_cpu_work(); // ← 数秒かかる同期処理
        println!("{}", result);
    });
}

fn heavy_cpu_work() -> u64 {
    // 素数列挙など重い処理
    (2u64..10_000_000).filter(|&n| is_prime(n)).count() as u64
}
// ✅ OK: spawn_blocking でスレッドプールを分離する
#[tokio::main]
async fn main() {
    let result = tokio::task::spawn_blocking(|| {
        heavy_cpu_work()
    })
    .await
    .unwrap();

    println!("{}", result);
}

spawn_blocking は専用のブロッキングスレッドプールで実行されるため、async ランタイムを詰まらせません。


std::sync::Mutex の Guard を await をまたいで保持する

これはコンパイルエラーになる場合と、黙って動くが実はデッドロックを誘発する場合の両方があります。

use std::sync::Mutex;

// ❌ NG: MutexGuard が await をまたぐ
async fn update(data: Arc<Mutex<Vec<u32>>>) {
    let mut guard = data.lock().unwrap();
    some_async_fn().await; // ← ここで Guard を持ったまま待機
    guard.push(42);
}

std::sync::MutexGuardSend を実装していないため、tokio::spawn に渡すとコンパイルエラーになります。しかしシングルタスク文脈では通ってしまい、デッドロックの原因になります。

// ✅ OK 案① スコープを限定する
async fn update(data: Arc<Mutex<Vec<u32>>>) {
    {
        let mut guard = data.lock().unwrap();
        guard.push(42);
    } // ← Guard をここで drop
    some_async_fn().await;
}

// ✅ OK 案② tokio::sync::Mutex を使う
use tokio::sync::Mutex;

async fn update(data: Arc<Mutex<Vec<u32>>>) {
    let mut guard = data.lock().await; // ← async-aware なロック
    some_async_fn().await;
    guard.push(42);
}

tokio::sync::Mutexawait をまたいでも安全です。ただし、ロック時間が短いなら案① の方が効率的です。


tokio::spawnSend でない型を渡す

// ❌ NG: Rc は Send ではない
use std::rc::Rc;

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        let x = Rc::new(42); // コンパイルエラー
        println!("{}", x);
    });
}

エラーメッセージ:

error[E0277]: `Rc<i32>` cannot be sent between threads safely

tokio::spawn はタスクをスレッド間で移動できるよう、Future: Send を要求します。

// ✅ OK: Arc を使う
use std::sync::Arc;

#[tokio::main]
async fn main() {
    tokio::spawn(async {
        let x = Arc::new(42);
        println!("{}", x);
    });
}

シングルスレッドランタイムを使う場合は tokio::task::LocalSet + spawn_local!Send な型も扱えます。

// ✅ OK: シングルスレッド専用
#[tokio::main(flavor = "current_thread")]
async fn main() {
    let local = tokio::task::LocalSet::new();
    local.run_until(async {
        tokio::task::spawn_local(async {
            let x = std::rc::Rc::new(42);
            println!("{}", x);
        }).await.unwrap();
    }).await;
}

async fn を trait に定義しようとする(2024 以前の罠)

Rust 1.75 以前では、trait 内で async fn を直接宣言できませんでした。

// ❌ NG (Rust 1.74 以前)
trait Fetcher {
    async fn fetch(&self) -> String;
}

Rust 1.75 (2023-12-28 stable) から trait 内 async fn が安定化されました。ただし、オブジェクト安全性 (dyn Trait) との組み合わせにはまだ制限があります。

// ✅ OK: Rust 1.75+ での直接記述
trait Fetcher {
    async fn fetch(&self) -> String;
}

// ✅ OK: dyn が必要なら async-trait クレートを併用 (Rust 1.75 以前も含め安全)
use async_trait::async_trait;

#[async_trait]
trait Fetcher {
    async fn fetch(&self) -> String;
}

async-trait クレートは内部で Pin<Box<dyn Future>> に脱糖するため、dyn Fetcher が使えます。Rust 1.75+ でもオブジェクトセーフな trait が必要な場合は async-trait が現実解です。


⑥ 無限ループ内で await せずビジーループになる

// ❌ NG: yield ポイントがなく他タスクが飢餓状態になる
async fn polling_loop(flag: Arc<AtomicBool>) {
    loop {
        if flag.load(Ordering::Relaxed) {
            println!("flag set!");
            break;
        }
        // await がないため tokio は他タスクに切り替えられない
    }
}

tokio のコーオペラティブスケジューリングでは、await が yield ポイントになります。await のないループはランタイムを独占します。

// ✅ OK: tokio::task::yield_now() で明示的に yield
async fn polling_loop(flag: Arc<AtomicBool>) {
    loop {
        if flag.load(Ordering::Relaxed) {
            println!("flag set!");
            break;
        }
        tokio::task::yield_now().await; // ← 他タスクに実行権を渡す
    }
}

// ✅ より実用的: 短いインターバルを挟む
async fn polling_loop(flag: Arc<AtomicBool>) {
    loop {
        if flag.load(Ordering::Relaxed) {
            break;
        }
        tokio::time::sleep(Duration::from_millis(10)).await;
    }
}

⑦ エラー伝搬で ? を使うと型が合わない

// ❌ NG: tokio::spawn のクロージャ内で ? を使うと戻り値型が合わない
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    tokio::spawn(async {
        let text = reqwest::get("https://example.com")
            .await?       // ← ここで型不一致
            .text()
            .await?;
        println!("{}", text);
        Ok(())
    });
    Ok(())
}

tokio::spawn が返す JoinHandle<T>TResult<(), E> でも良いですが、? を使うにはクロージャの戻り値型を明示する必要があります。

// ✅ OK: 戻り値型を明示し、JoinHandle を await する
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let handle = tokio::spawn(async {
        let text = reqwest::get("https://example.com")
            .await
            .map_err(|e| e.to_string())?
            .text()
            .await
            .map_err(|e| e.to_string())?;
        println!("{}", text);
        Ok::<(), String>(())
    });

    handle.await??; // JoinError と内部 Error の二重 ? に注意
    Ok(())
}

handle.awaitResult<Result<(), E>, JoinError> を返すため、?? で二重に unwrap が必要です。anyhow クレートを使うと大幅にシンプルになります。

// ✅ anyhow を使う場合
use anyhow::Result;

#[tokio::main]
async fn main() -> Result<()> {
    let handle = tokio::spawn(async {
        let text = reqwest::get("https://example.com")
            .await?
            .text()
            .await?;
        println!("{}", text);
        anyhow::Ok(())
    });

    handle.await??;
    Ok(())
}

まとめ

# 解決策
await 忘れ チェーン全体を目視確認
重い同期処理のブロッキング spawn_blocking で分離
MutexGuardawait またぎ保持 スコープ分離 or tokio::sync::Mutex
!Send 型を spawn に渡す Arc に変換 or spawn_local
trait 内 async fn Rust 1.75+ 直書き or async-trait
await なし無限ループ yield_now or スリープ挿入
? の型不一致 戻り値型明示 or anyhow

Rust の async は「魔法」ではなく、ポーリングベースの協調スケジューリングという明確なモデルで動いています。モデルを理解していれば、エラーメッセージも「そりゃそうだ」と読めるようになります。

公式ドキュメントの Asynchronous Programming in Rust と tokio の tutorial は特に充実しているので、深掘りしたい方はぜひ参照してください。


参考リンク


✍️ 本記事の著者: 合同会社ジモラボ

ジモラボは、八王子を拠点に AI を活用した SaaS を多数開発しています。本記事の技術検証もそうした開発過程の副産物です。

興味を持っていただけたら、ぜひ各 SNS のフォローもお願いします!

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?