📚 背景知識(読んでから問題へ)
Day 036では「async fnは呼び出しても即座には実行されない怠惰な値であり、誰か(ランタイム)がpollを呼ぶことで一歩ずつ前進する」というFutureのポーリングモデルを学びました。今日はその理解を一段深め、async fnというシンタックスシュガーを使わず、Futureトレイトを手書きで実装します。
なぜ手で実装する必要があるのか。実務でFutureを手書きすることは稀です(ほとんどの場合async fnで十分)。しかし、コールバックベースのC言語ライブラリやOSのI/O完了通知APIをasync/awaitで使える形にラップする場合、tokio・hyperなどのランタイム/ライブラリの内部実装を読む場合、独自のタイマー・独自のI/Oリアクターを実装する場合には避けて通れません。手書きすることで「.awaitの裏側で実際に何が起きているか」がコードとして目に見える形になります。
pollがPoll::Pendingを返す関数は、「いつ完了するかを知っている誰か」に対してcx.waker()から取得したWakerのクローンを渡しておく必要があります。Futureを実装する構造体自身と、完了を検知する側(別スレッドなど)の両方から読み書きされるため、多くの手書きFutureは SharedState { completed: bool, waker: Option<Waker> } を Arc<Mutex<_>> で包んで両者に持たせる、という定番パターンを取ります(Rust公式asyncブックのTimerFuture教材と同じ構造)。pollが「無駄に」何度も呼ばれてはいけない理由。Wakerの仕組みが正しく実装されていれば、あるFutureが完了するまでの間、pollは理論上「Pendingを返す1回」と「Readyを返す1回」の合計2回だけ呼ばれれば十分です。wake()を呼び忘れると再pollされずハングし、逆に的外れに呼びすぎると無意味なpollが連発するビジーポーリングが起きます。今日はこのpoll回数を実際にカウントして検証します。
📝 問題
以下の3つの要件を満たす、独自のTimerFutureを実装してください。
要件1(Future本体の実装): 指定したDurationが経過するとPoll::Ready(())になるTimerFutureを、tokio::time::sleepを使わずに実装してください。
- 内部で
std::thread::spawnを使い、別スレッドでstd::thread::sleep(duration)した後に完了フラグを立てる Futureトレイトのpollメソッドを手書きで実装する(Pin<&mut Self>・Context・Pollを正しく使う)- 完了前に
pollされた場合は、cx.waker()をクローンして保存し、別スレッドが完了時に.wake()を呼べるようにする
要件2(poll回数の検証): TimerFutureをラップし、pollが呼ばれた回数を数えるCountingFuture<F>を実装してください。このTimerFutureを.awaitした際、pollが正確に2回(1回目はPending、2回目はReady)しか呼ばれないことを、実際に実行して確認してください。
要件3(比較実験): 同じ待ち時間(200ms)をtokio::time::sleep(...).awaitで実装した場合と、要件1のTimerFutureとで、実行結果(完了までのおおよその経過時間)が同等になることをmain関数内で両方実行し、経過時間をログに出して確認してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
Day 036で見たFutureトレイトの定義を思い出してください。pollは「今すぐ完了しているかどうかを確認し、まだなら『準備ができたら起こしてね』という約束(Wakerの登録)をしてPendingを返す」という2つの役割を1つの関数でこなす必要があります。「完了フラグ」と「起こすためのハンドル」をどこに置けば、Future本体と別スレッドの両方からアクセスできるかを考えてください。
ヒント2 — アプローチ
SharedState { completed: bool, waker: Option<Waker> } を Arc<Mutex<SharedState>> で包み、TimerFuture構造体と、別スレッドに渡すクローンの両方に持たせます。pollの中では必ずMutexをロックし、completedを見てReadyかPendingかを判断します。Pendingのときはcx.waker().clone()をstate.wakerに毎回上書き保存してください。別スレッド側では、sleep後にcompleted = trueにしてからstate.waker.take()でWakerを取り出し.wake()を呼びます。CountingFuture<F: Future + Unpin>はinner: Fとpoll_count: Arc<AtomicUsize>を持ち、pollの冒頭でカウンタを+1してから委譲するだけです。
ヒント3 — コード骨格
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::{Duration, Instant};
struct SharedState {
completed: bool,
waker: Option<Waker>,
}
struct TimerFuture {
shared_state: Arc<Mutex<SharedState>>,
}
impl TimerFuture {
fn new(duration: Duration) -> Self {
// ここでスレッドをspawnし、sleep後にcompleted=trueとwake()する
todo!()
}
}
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// ロック → completedを確認 → Ready or (waker保存してPending)
todo!()
}
}
✅ 模範解答
// Cargo.toml に以下を追加して検証すること:
// [dependencies]
// tokio = { version = "1", features = ["full"] }
use std::future::Future;
use std::pin::Pin;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll, Waker};
use std::thread;
use std::time::{Duration, Instant};
/// TimerFuture と、完了を検知する別スレッドの間で共有する状態。
/// completed: タイマーが完了したかどうか
/// waker: pollが呼ばれるまではNone(まだ誰も待っていない)。
/// Pendingを返すたびに最新のWakerへ上書きしておく。
struct SharedState {
completed: bool,
waker: Option<Waker>,
}
/// 指定した時間が経過すると Ready(()) になる自前のFuture。
/// tokio::time::sleepを使わず、標準ライブラリのthread::sleepと
/// Waker手動管理だけで「ポーリングモデル」を再現する。
struct TimerFuture {
shared_state: Arc<Mutex<SharedState>>,
}
impl TimerFuture {
fn new(duration: Duration) -> Self {
let shared_state = Arc::new(Mutex::new(SharedState {
completed: false,
waker: None,
}));
let thread_shared_state = Arc::clone(&shared_state);
thread::spawn(move || {
thread::sleep(duration);
let mut state = thread_shared_state.lock().unwrap();
state.completed = true;
// take()で1度だけwake()する。既にpollされてWakerが
// 登録されている場合のみ起こす必要がある。
if let Some(waker) = state.waker.take() {
waker.wake();
}
});
TimerFuture { shared_state }
}
}
impl Future for TimerFuture {
type Output = ();
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let mut state = self.shared_state.lock().unwrap();
if state.completed {
Poll::Ready(())
} else {
// pollが呼ばれるたびに最新のWakerへ更新する。
// (異なるエグゼキュータ/タスクに移動する可能性があるため
// 古いWakerを使い回すのは不正確)
state.waker = Some(cx.waker().clone());
Poll::Pending
}
}
}
/// 内側のFutureへpollを委譲しつつ、呼ばれた回数を数えるラッパー。
/// Waker管理が正しく実装されているかを検証する目的で使う。
struct CountingFuture<F> {
inner: F,
poll_count: Arc<AtomicUsize>,
}
impl<F: Future + Unpin> Future for CountingFuture<F> {
type Output = F::Output;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.poll_count.fetch_add(1, Ordering::SeqCst);
Pin::new(&mut self.inner).poll(cx)
}
}
#[tokio::main]
async fn main() {
println!("=== 要件1・2: 手書きTimerFutureとpoll回数の検証 ===");
let start = Instant::now();
let poll_count = Arc::new(AtomicUsize::new(0));
let counted = CountingFuture {
inner: TimerFuture::new(Duration::from_millis(200)),
poll_count: Arc::clone(&poll_count),
};
println!("[{:>6?}] TimerFuture待機開始", start.elapsed());
counted.await;
let count = poll_count.load(Ordering::SeqCst);
println!(
"[{:>6?}] TimerFuture完了。poll()は{}回呼ばれた",
start.elapsed(),
count
);
assert_eq!(count, 2, "pollはPendingで1回・Readyで1回の合計2回だけ呼ばれるべき");
println!();
println!("=== 要件3: tokio::time::sleepとの経過時間比較 ===");
let start2 = Instant::now();
tokio::time::sleep(Duration::from_millis(200)).await;
println!("[{:>6?}] tokio::time::sleep(200ms) 完了", start2.elapsed());
let start3 = Instant::now();
TimerFuture::new(Duration::from_millis(200)).await;
println!("[{:>6?}] 手書きTimerFuture(200ms) 完了", start3.elapsed());
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn timer_future_completes_after_duration() {
let start = Instant::now();
TimerFuture::new(Duration::from_millis(50)).await;
assert!(start.elapsed() >= Duration::from_millis(50));
}
#[tokio::test]
async fn poll_is_called_exactly_twice() {
let poll_count = Arc::new(AtomicUsize::new(0));
let counted = CountingFuture {
inner: TimerFuture::new(Duration::from_millis(30)),
poll_count: Arc::clone(&poll_count),
};
counted.await;
assert_eq!(poll_count.load(Ordering::SeqCst), 2);
}
}
[0ns] TimerFuture待機開始 → [200.xxxms] TimerFuture完了。poll()は2回呼ばれたとなり、要件2の「pollはちょうど2回」が数値で裏付けられます。要件3ではtokio::time::sleep(200ms)と手書きTimerFuture(200ms)のどちらも約200msで完了し、外部から見た挙動が同等であることを確認できます。🪜 Step-by-Step 解説
SharedStateをArc<Mutex<_>>で共有するTimerFuture本体はpollが呼ばれたタイミングでしか動けませんが、完了検知は別スレッドで非同期に進みます。両者が同じcompletedフラグとwakerを読み書きできるよう、Arc<Mutex<SharedState>>のクローンを両方に持たせます。Arcは所有権の共有、Mutexは同時アクセスからの排他制御という、Day 034〜035で学んだ役割分担がそのまま再登場します。
pollの中で毎回Wakerを更新するstate.waker = Some(cx.waker().clone()) は初回だけでなくPendingを返すたびに実行します。Futureが実行途中で別のエグゼキュータ・別のタスクコンテキストに移動する可能性があるため、古いWakerをキャッシュし続けると、本来起こすべきタスクを起こし損ねる、あるいは誤ったタスクを起こすバグにつながります。
take()if let Some(waker) = state.waker.take() { waker.wake(); }。take()はOptionから値を取り出しつつNoneに戻す操作で、「一度取り出したWakerは二度と使わない(wake()は1回だけ呼ぶ)」という意図をコード上で明確にします。pollが一度も呼ばれずwakerがNoneのまま完了しても、if letがスキップされるだけで安全です。
CountingFutureによるpoll回数の可視化CountingFuture<F>はF: Future + Unpinという境界を持ちます。TimerFutureはセルフ参照フィールドを持たないため自動的にUnpinになっており、Pin::new(&mut self.inner)で安全にPinを作り直せます。1回目のpollはcompleted == falseでPendingを返しWakerを登録、バックグラウンドスレッドがwake()を呼ぶと再スケジュールされ、2回目のpollでReady(())を返します。
tokio::time::sleepとの比較両者とも約200msで完了し、外部から見た挙動は同等です。違いは内部実装のみで、
tokio::time::sleepは専用のタイマードライバ(タイマーホイール等)を使う効率的な実装であり、今回のTimerFutureは「スレッドを1つ余分に生成する」学習用の簡易実装であることに留意してください。
💡 設計思想・なぜこう書くのか
Futureトレイトがpollという単一のメソッドだけを要求する最小限のインターフェースになっているのは、「どのように完了を通知するか」を実装者に委ねることで、あらゆる非同期処理(タイマー・ソケットI/O・チャンネル受信・GPU計算の完了通知など)を同じ抽象の上に統一的に表現できるようにするためです。Wakerという「起こす手段」だけを抽象化し、「何によって起こされるか」(今回は別スレッドのsleep、実際のI/Oではepoll/kqueue/IOCPなどのOSのイベント通知)を実装側の自由に委ねる設計は、Rustの「メカニズムとポリシーの分離」という思想の典型例です。
WakerをArc<Mutex<SharedState>>のような共有状態経由で受け渡すパターンが「お作法」として広く定着しているのは、Futureと「完了を知らせる主体」が異なる実行文脈(別スレッド、別のコールバック)に存在することが構造的に避けられないためです。所有権システムはこの共有をArc/Mutexという型で明示させることで、「誰が今この状態を読み書きしているか」をコンパイル時・実行時の両方で追跡可能にしています。🛑 コンパイルエラーが出た場合
Wakerを保存せずにcx.waker()への参照だけを保持しようとすると、次のようなライフタイムエラーになります。
--> src/main.rs:NN:13
|
| fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
| -- `cx` is a reference that is only valid in the method body
...
| state.waker = Some(cx.waker());
| ^^^^^^^^^^^^^^^^ `cx` escapes the method body here
cx: &mut Context<'_>はpollが呼ばれている間だけ有効な借用です。関数を抜けた後もその値を保持したい(SharedStateというpollの外側に置きたい)場合、参照のままでは生存期間が足りません。cx.waker()は&Wakerを返しますが、Waker自体はCloneを実装しているため、.clone()することでpollの生存期間から独立した「所有権を持つ値」を作れます。これが模範解答でcx.waker().clone()と書いている理由です。「参照のままでは外に持ち出せないなら、クローンして所有権化する」というのは、借用チェッカーに遭遇したときの定石の1つです。🌐 他言語との比較
| 観点 | JavaScript (new Promise(executor)) | C(コールバックベースの非同期API) | Rust (Future手動実装) |
|---|---|---|---|
| 完了通知の仕組み | executor内でresolve/reject関数を後から呼ぶ(クロージャとしてキャプチャ) | 完了時に呼び出し元が渡した関数ポインタ+void*コンテキストをコールバックする | 完了検知元がWaker::wake()を呼び、ランタイムに「再pollしてよい」と伝える |
| 実行開始タイミング | executorは生成と同時に即座に実行される(eager) | APIを呼んだ瞬間に非同期処理が開始される | Futureはpollされるまで何も起きない(lazy) |
| 型安全性 | resolveに渡す値の型はJS側でチェックされない(緩い) | void*によるコンテキスト受け渡しが型安全でない・キャストミスが実行時バグになる | Future<Output = T>により完了値の型がコンパイル時に保証される |
| 多重起床の防止 | ランタイムが内部的に「1度だけ効く」よう保証 | プログラマが手動で「2回コールバックしない」ことを保証する必要がある | Option<Waker>::take()のようなパターンで意図を型システム上も明確にできる |
C言語でのコールバックベースAPIをRustのasyncにラップする作業(「asyncラッパーを書く」)は、本問で書いたTimerFutureと全く同じ構造(SharedState+Waker保存)を取ります。今日の実装は、実務でFFI境界を非同期化する際の直接的な雛形になります。
🏆 実務での使いどころ
- gRPC/HTTPクライアントライブラリの内部実装(
hyper・tonicなど)は、OSのソケットI/OイベントをFuture::pollに橋渡しするために、本問と同様のWaker管理パターンを使っている - レガシーなC言語ライブラリ(暗号化処理・ハードウェアドライバのSDKなど)をコールバックベースのまま
tokioエコシステムに統合する際、コールバックが呼ばれた瞬間に保存しておいたWakerをwake()する、というブリッジコードを書く必要がある - 独自のタイムアウト機構やレート制限器(rate limiter)を作る場合、
tokio::timeに頼らず本問のような手書きFutureでカスタムのスケジューリングロジックを組み込むことがある - ライブラリ内部の挙動を調査するとき、本問の
CountingFutureのようなpollカウンタを一時的に差し込んでデバッグする手法は実務でもそのまま使える
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
Pendingを返すときにWakerを保存し忘れる | 「そのうちまたpollされるだろう」と楽観する | Wakerを保存しなければ誰もwake()を呼べず、そのタスクは永遠に再スケジュールされない(ハングする)。Pendingを返す経路には必ずWakerの保存とセットで実装する |
初回poll時だけWakerを保存し、以降は更新しない | 「一度保存すればずっと有効」だと思い込む | タスクが別のエグゼキュータ・別のコンテキストに移った場合、古いWakerは無効な相手を起こしてしまう。Pendingを返すたびにcx.waker().clone()で上書きするのが正しい |
wake()を複数回呼んでも問題ないと思う | 「起こす」という操作が冪等に感じられる | 呼ぶこと自体は安全だが、take()で1度だけ確実に起こす設計にしておかないと「意図せず何度も再pollされる」無駄なポーリングにつながりやすい |
Futureを手書きする機会は実務で無いと思い込む | ほとんどのコードはasync fnで書けるため | FFIラッパー・カスタムタイマー・独自リアクター実装など、ライブラリ層のコードでは依然として手書きFutureが必要になる場面がある |
🚀 次のステップ
- 発展:
TimerFutureを複数同時にtokio::join!で待った場合、それぞれが独立したスレッドを生成してしまう非効率さに気づけるはずです。1本の管理スレッド(タイマーホイール)に統合して複数のタイマーをまとめて管理する設計に挑戦してみましょう - 次回予告: tokioタスク・並行実行パターン —
spawn・join!・select!の使い分けと、それぞれのキャンセル・エラー伝播の違いに進みます