Day 048 — トレイトにSend + Syncを付けても、async fnが返すFutureまでSendにはならない。tokio::spawnの「future cannot be sent between threads safely」を2段階で直す

2026-09-14 🟣 上級者 / Phase 3 デバッグ・コードレビュー async trait(async-trait / RPITIT)

📚 背景知識(読んでから問題へ)

Day 046・047では「ネイティブのasync fn in traitはトレイトオブジェクト(dyn Trait)にはできない」という制約を確認しました。しかし実務でもっと頻繁に踏むのは、実は別の壁です。ジェネリクス経由で使っているのに、tokio::spawnに渡した瞬間だけ怒られるというパターンです。

tokio::spawnのシグネチャを思い出してください。

pub fn spawn(future: F) -> JoinHandle
where
    F: Future + Send + 'static,

マルチスレッドランタイム(#[tokio::main]のデフォルト)は、実行中のタスクをワーカースレッド間で自由に移動させます(work-stealing)。そのためタスクの中身であるFutureは、あるスレッドで生成されて別のスレッドでpollされる可能性があり、コンパイラはそれを型システムで保証させるためにSendを要求します。

ここで問題になるのが、「トレイト自身がSendであること」と「トレイトメソッドが返すFutureがSendであること」は、Rustの型システム上まったく別の主張であるという点です。トレイトにSend + Syncというスーパートレイト境界を付ければ「このトレイトを実装する型はSendだ」とは言えますが、async fnが返す匿名のFuture型については、そのトレイト境界は一切関与しません。この2つを混同すると、「スーパートレイトにSendを付けたのに、まだtokio::spawnが通らない」という、原因の切り分けが難しいバグにぶつかります。

📌
今日の問題は2つのコンパイルエラーが同時に出るケースです。片方だけ直して安心してしまうと、もう片方が残っていることに気づかず時間を浪費します。「エラーメッセージの見出しが同じでも、指している対象が違えば別の原因」という読み方を今日の核心として身につけます。

📝 問題

以下のコードは、リポジトリの検索結果をバックグラウンドタスクで取得しようとしていますが、コンパイルが通りません。

use std::collections::HashMap;

trait Repository {
    async fn find(&self, id: u64) -> Option;
}

struct InMemoryRepo {
    data: HashMap,
}

impl Repository for InMemoryRepo {
    async fn find(&self, id: u64) -> Option {
        self.data.get(&id).cloned()
    }
}

fn spawn_lookup(repo: R, id: u64) {
    tokio::spawn(async move {
        let result = repo.find(id).await;
        println!("{:?}", result);
    });
}

#[tokio::main]
async fn main() {
    let mut data = HashMap::new();
    data.insert(1, "alice".to_string());
    let repo = InMemoryRepo { data };
    spawn_lookup(repo, 1);
}
  1. まずcargo buildでどんなエラーが出るか予想し、実際にコンパイルしてエラーメッセージを確認してください(2種類のエラーが同時に出ます)
  2. 1つ目のエラー(R型自体がSendでない)を、RepositoryトレイトにSend + Syncのスーパートレイト境界を追加することで直してください
  3. スーパートレイト境界を追加しただけでは2つ目のエラー(findが返すFutureがSendでない)が消えないことを確認し、そのエラーメッセージを読んでください
  4. findメソッドの宣言を、async fn糖衣構文をやめてfn find(&self, id: u64) -> impl Future<Output = Option<String>> + Sendという展開後の形に書き換えることで完全に直してください(impl側はasync fnのままでよいことも確認してください)
  5. 修正後、spawn_lookupがJoinHandleを返すように変更し、mainと#[tokio::test]の両方で検索結果を検証するテストを書いてください(見つかる場合・見つからない場合の両方)

🔍 ヒント(段階的開示)

ヒント1 — 方向性

エラーメッセージは1つではなく2つ出ます。それぞれが「何がSendでないと言っているか」を注意深く読み分けてください。1つはRという型パラメータそのものについて、もう1つはfindが返す匿名のFuture型について指摘しています。この2つは原因も直し方も別物です。

ヒント2 — アプローチ

1つ目はtrait Repository: Send + Sync { ... }のようにスーパートレイトを追加すれば直ります。2つ目は、トレイト定義側のasync fn find(...)という書き方のままでは+ Sendという追加境界を付ける文法がないため、fn find(&self, id: u64) -> impl std::future::Future<Output = Option<String>> + Send;という「展開した形」に書き換える必要があります。rustcのエラーメッセージ自体が、この書き換え案をhelp:として提示してくれることを確認してください。

ヒント3 — コード骨格
use std::future::Future;

trait Repository: Send + Sync {
    fn find(&self, id: u64) -> impl Future> + Send;
}

impl Repository for InMemoryRepo {
    // impl側はasync fnの糖衣構文のままでよい。
    // トレイト側の宣言に + Send が付いているおかげで、
    // ここで生成される匿名Futureにも + Send が要求される。
    async fn find(&self, id: u64) -> Option {
        self.data.get(&id).cloned()
    }
}

✅ 模範解答

// Cargo.toml に以下が必要:
// [dependencies]
// tokio = { version = "1", features = ["full"] }

use std::collections::HashMap;
use std::future::Future;

/// `Send + Sync` をスーパートレイトとして要求することで、
/// 「`Repository` を実装する型 `R` 自体」がスレッド間で送受信
/// 可能であることを保証する。ただしこれだけでは、
/// `find` が返す匿名の Future 型が Send であることまでは
/// 保証されない(このあとの `impl Future<...> + Send` が別途必要)。
trait Repository: Send + Sync {
    /// `async fn find(...)` という糖衣構文では戻り値の匿名Futureに
    /// 追加の境界を書けないため、あえて展開後の形で書いて
    /// `+ Send` を明示する。
    fn find(&self, id: u64) -> impl Future> + Send;
}

struct InMemoryRepo {
    data: HashMap,
}

impl Repository for InMemoryRepo {
    // impl側は通常どおり async fn 糖衣構文で書ける。
    // トレイト側の宣言に + Send が付いているおかげで、
    // ここで生成される匿名Futureにも + Send の制約がかかる。
    async fn find(&self, id: u64) -> Option {
        self.data.get(&id).cloned()
    }
}

/// ジェネリクス `R: Repository + 'static` は、`Repository: Send + Sync`
/// というスーパートレイト境界のおかげで自動的に `R: Send` も満たす。
/// 加えて `find` の戻り値が `+ Send` を明示しているため、
/// `tokio::spawn` の `F: Future + Send + 'static` を満たせる。
fn spawn_lookup(
    repo: R,
    id: u64,
) -> tokio::task::JoinHandle> {
    tokio::spawn(async move { repo.find(id).await })
}

#[tokio::main]
async fn main() {
    let mut data = HashMap::new();
    data.insert(1, "alice".to_string());
    let repo = InMemoryRepo { data };

    let handle = spawn_lookup(repo, 1);
    let result = handle.await.unwrap();
    println!("{:?}", result);
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn spawn_lookup_returns_found_value_from_another_task() {
        let mut data = HashMap::new();
        data.insert(1, "alice".to_string());
        let repo = InMemoryRepo { data };

        let result = spawn_lookup(repo, 1).await.unwrap();
        assert_eq!(result, Some("alice".to_string()));
    }

    #[tokio::test]
    async fn spawn_lookup_returns_none_for_missing_id() {
        let repo = InMemoryRepo { data: HashMap::new() };

        let result = spawn_lookup(repo, 999).await.unwrap();
        assert_eq!(result, None);
    }
}
🧪
cargo build・cargo test・cargo runのすべてで動作確認済みです。cargo runの出力はSome("alice")、cargo testは2件ともokでした。

🪜 Step-by-Step 解説

1
まず素直にコンパイルしてエラーを2つとも読む
問題のコードをそのままcargo buildすると、error: future cannot be sent between threads safelyが2つ出ます。1つ目はcaptured value is not Send / has type R which is not Send、2つ目はthe trait Send is not implemented for impl Future<Output = Option<String>>です。同じ「Sendでない」という文言でも、指している対象(型パラメータR vs findの戻り値の匿名型)がまったく違うことを、まず自分の目で確認してください。
2
1つ目のエラーを直す(トレイトにSend + Syncを追加)
trait Repository: Send + Sync { ... }とスーパートレイト境界を追加すると、「Repositoryを実装する型は必ずSendかつSyncである」という制約がトレイト自身に刻まれます。これによりR: Repositoryという境界を持つジェネリック関数は、追加の記述なしにR: Sendも同時に満たすようになり、1つ目のエラーは消えます。
3
それでも2つ目のエラーが残ることを確認する
再度cargo buildすると、1つ目のエラーは消えていますが、2つ目の「findが返すFutureがSendでない」エラーはまだ残ります。ここが今日の核心です。スーパートレイトのSendは「型R自身」についての主張であり、async fn findが生成する匿名のFuture型については何も保証しません。この匿名Future型は、findの実装ごとに生成される「まったく別の型」であり、トレイト定義のSend + Syncはその型の性質にノータッチだからです。
4
2つ目のエラーを直す(RPITITを手動で展開してSendを明示)
async fn find(&self, id: u64) -> Option<String>;という糖衣構文には、戻り値の匿名Future型に境界を追加する文法がありません(async fn ... + Sendという書き方はできません)。そこで、コンパイラが提示するhelp:どおりに、糖衣構文が展開した後の姿であるfn find(&self, id: u64) -> impl Future<Output = Option<String>> + Send;という形に書き換えます。これが「Return-Position Impl Trait In Trait(RPITIT)」を手動で書き下した形です。重要なのは、トレイト定義側をこの形にしても、impl側は従来どおりasync fnの糖衣構文のままでよいことです。コンパイラはimplブロックのasync fn findが生成する匿名Future型に対して、トレイト宣言で要求された+ Sendを満たすかどうかを個別にチェックします。
5
JoinHandleを返す形に整理し、テストを書く
spawn_lookupが()ではなくtokio::task::JoinHandle<Option<String>>を返すようにすると、呼び出し側で.awaitして結果を検証できるようになります。#[tokio::test]を使えばasync fnをそのままテスト関数として実行でき、見つかるケース・見つからないケースの両方を素直にアサーションで書けます。

💡 設計思想・なぜこう書くのか

RustのSend/Syncは「マーカートレイト」であり、コンパイラは型の構造(フィールドの型・キャプチャする変数の型)を再帰的に見て自動的に実装の有無を判定します(auto trait)。これは通常の型には強力に働きますが、async fnが返す「匿名のFuture型」は少し事情が違います。この匿名型の中身(ステートマシンとしてどんなフィールドを持つか)はimplブロックごとに実装依存で決まるため、トレイト定義の時点では「その匿名型がSendかどうか」を確定させられません。だからこそRustは、トレイト定義者に対して「この匿名Future型にはSendを要求する」という意思表示を、impl Future<...> + Sendという形で明示的に書かせる設計にしています。もし黙って自動判定に任せてしまうと、ある実装だけが内部でRc<T>のような非Send型を使ったせいで、トレイトを跨いだ抽象化全体がSendになったりならなかったりする、という予測不能な状態が生まれてしまいます。「境界は明示的に宣言し、実装側にそれを守らせる」というのは、Rustの型システム全体を貫く一貫した思想です。

🛑 コンパイルエラーが出た場合

問題のコード(スーパートレイトなし・RPITIT展開なし)をそのままビルドすると、次の2つのエラーが同時に出ます。

error: future cannot be sent between threads safely
  --> src/main.rs:17:5
  |
17  |    tokio::spawn(async move {
  |    ^^^^^^^^^^^^ future created by async block is not `Send`
  |
note: captured value is not `Send`
  --> src/main.rs:18:22
18  |        let result = repo.find(id).await;
  |                ^^^^ has type `R` which is not `Send`
help: consider further restricting type parameter `R` with trait `Send`
16  | fn spawn_lookup<R: Repository + 'static + std::marker::Send>(repo: R, id: u64) {
  |                                     +++++++++++++++++++
error: future cannot be sent between threads safely
  --> src/main.rs:17:5
  |
17  |    tokio::spawn(async move {
  |    ^^^^^^^^^^^^ future created by async block is not `Send`
  |
  = help: within `{async block}`, the trait `Send` is not implemented for `impl Future<Output = Option<String>>`
note: future is not `Send` as it awaits another future which is not `Send`
  --> src/main.rs:18:22
18  |        let result = repo.find(id).await;
  |                ^^^^^^^^^^^^^ await occurs here on type `impl Future<Output = Option<String>>`, which is not `Send`
help: `Send` can be made part of the associated future's guarantees for all implementations of `Repository::find`
4  -    async fn find(&self, id: u64) -> Option<String>;
4  +    fn find(&self, id: u64) -> impl std::future::Future<Output = Option<String>> + Send;
⚠️
読み方: rustcは単に「ダメです」と言うだけでなく、help:の行で具体的な修正コードまで提示してくれます。1つ目のエラーのhelp:は「型パラメータRにSend境界を足せ」、2つ目のhelp:は「トレイト定義自体をimpl Future<...> + Sendという形に書き換えろ」と、まったく異なる修正を提案していることに注目してください。エラーメッセージが2つあるときに片方だけ直して満足せず、必ずもう一度cargo buildし直して「本当に全部消えたか」を確認する習慣が重要です。
📌
なお、Repository: Send + Syncというスーパートレイト境界だけを追加し、findの宣言をasync fnのままにした場合、1つ目のエラーだけが消えて2つ目は残り続けます。これも実際に試して確認しておくと、「2つのエラーは独立している」という理解が体に入ります。

🌐 他言語との比較

観点Go(goroutine)Java(CompletableFuture)TypeScript(Promise)Rust(async fn in trait)
スレッド間移動の安全性チェックなし。共有データ競合はランタイムが検知せず、go run -raceという別ツールに頼る参照型はデフォルトで共有可能。スレッドセーフ性は実装者の自己申告(Javadocのコメント程度)シングルスレッド前提(Node.jsのイベントループ)なのでこの問題自体が存在しないコンパイラが型ごとにSend/Syncを自動導出し、境界を満たさない限りコンパイルが通らない
「戻り値は別スレッドに渡せるか」の表明方法型システムでは表現できない。ドキュメントとレビューに依存型システムでは表現できない該当なしトレイト定義にimpl Future<...> + Sendと明示的に書く
見つけるタイミング実行時(競合状態が発生して初めて気づく、しばしば本番障害)実行時該当なしコンパイル時(cargo buildの時点でブロックされる)

GoやJavaでは「このFutureや戻り値は他のスレッド・ゴルーチンに安全に渡せるか」という性質は、型システムの外側(コメント・命名規則・レビュー)でしか表現できません。RustはこれをSendという型システム上の制約として扱い、満たさない限りそもそもコンパイルが通らないという形で、実行時まで問題が持ち越されることを防ぎます。今回のような「一見動きそうなのにコンパイルが通らない」という体験は、他言語出身のエンジニアには煩わしく感じられがちですが、裏を返せば「本番でしか再現しないデータ競合」を設計段階で潰しているという恩恵でもあります。

🏆 実務での使いどころ

  • Webサーバーのリクエストハンドラ: axumやactix-webのハンドラは基本的にマルチスレッドランタイム上で動くため、ハンドラ内から呼び出すリポジトリ層・外部APIクライアント層のトレイトは、今回のようにSend + Sync境界と+ Sendな戻り値を意識して設計する必要がある
  • バックグラウンドジョブキュー: ジョブをどのワーカースレッドが処理するか事前に決まっていないシステムでは、ジョブが内部で呼ぶトレイトメソッドの戻り値がSendでないと、そもそもキューに積めない(tokio::spawnできない)
  • トレイトを外部クレートとして公開する場合: 自分のクレートの利用者がマルチスレッドランタイムで使うことを想定するなら、トレイト定義時点でSend + SyncとFutureの+ Sendを意識して設計しておかないと、利用者側がtokio::spawnしようとした瞬間に原因のわかりにくいエラーに直面する
  • 段階的な導入判断: シングルスレッドランタイム(#[tokio::main(flavor = "current_thread")])だけで完結するアプリケーションなら、Send境界はそもそも不要になる場合もある。マルチスレッドランタイムを使うかどうかを先に決めてから、トレイト設計にSend境界を織り込むかを判断する

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
トレイトにSend + Syncを付ければ、そのトレイトのasync fnが返すFutureも自動的にSendになると思うどちらも「Send」という同じ単語が出てくるため、同じ制約の話だと錯覚するスーパートレイトのSendは「型R自身」についての制約であり、async fnが生成する匿名Future型には別途impl Future<...> + Sendという明示が必要。2つは独立した主張
async fnのままで+ Sendを書けると思い込み、構文エラーの原因が分からなくなる通常の関数なら-> impl Trait + Sendと書けるので、async fnでも同様に書けそうに見えるasync fn foo() -> Tという糖衣構文には戻り値の境界を追加する文法がない。境界を追加したい場合は、糖衣構文をやめてfn foo() -> impl Future<Output = T> + Sendという展開形で書く必要がある
エラーメッセージが1つ消えたら全部直ったと思い込み、2つ目のエラーを見落とすエラーの見た目(future cannot be sent between threads safely)が2つとも同じ文言で始まるため、1つのエラーだと誤認しやすい同じ見出し文言でも、note:やhelp:の内容が別々なら別々の原因。必ず全てのエラーブロックを読み、修正後に再度ビルドして「0件になったか」を確認する
シングルスレッドのcurrent_threadランタイムでも同じ制約がかかると思い込むtokio::spawnという同じAPIを使っているため、常に同じ境界が要求されると考えてしまうcurrent_threadランタイムではtokio::task::spawn_localを使えばSend境界そのものが不要になる。「マルチスレッドで動かす前提かどうか」によって必要な境界が変わることを理解しておく

🚀 次のステップ

  • 発展: tokio::task::spawn_localとcurrent_threadランタイムの組み合わせを試し、同じRepositoryトレイトからSend境界を外した場合にどこまでコンパイルが通るか(そしてマルチスレッドランタイムに戻すとどこで壊れるか)を確認する
  • 次回予告: unsafe Rustの基礎(生ポインタ操作) — 5つのunsafe操作と、コンパイラが保証してくれなくなる境界線がどこかを学ぶ

🎯 自己評価

自分の回答

気づき・メモ