Rustの非同期ランタイム `#[tokio::main]`を深堀り


はじめに

はじめまして、Rust勉強中の人です。
Rustを書き書きしていると度々登場する手続き型マクロ
こいつはそれはそれは便利なやつらしいですが、「一体内部で何をしてくれているんだ?」ってなったので、一つ例をとって深堀りしてみようと思った次第です。

早速掘り下げてゆく〜

今回はactix-webHello World! する下記の簡単なコードを例にとって掘り下げていきます。

Hello World!
use actix_web::{web, App, HttpServer, Responder};

async fn hello() -> impl Responder {
    "Hello, World!"
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    HttpServer::new(|| {
        App::new()
            .route("/", web::get().to(hello))
        })
        .bind("127.0.0.1:8000")?
        .run()
        .await
}

1. tokio::mainの目的を知る

とりあえず#[tokio::main]を削除してcargo checkを実行してみます。
すると、コンパイラさんが次のエラーを吐きます。

error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:8:1
  |
8 | async fn main() -> std::io::Result<()> {
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

For more information about this error, try `rustc --explain E0752`.
error: could not compile `helloworld` due to previous error

前提として、HttpServer::runは非同期メソッドです。しかし、バイナリのエントリポイントであるmain非同期関数にできません。ですが、どうにかしてmainを非同期にしなければいけません。

Rustの非同期プログラミングはFutureトレイトの上に構築されています。Futureトレイトはpollメソッドを実装しており、これを非同期ランタイム側から呼び出し、非同期タスクをポーリングすることで、最終的な値が解決されるという仕組みです。

しかし、Rustは標準ライブラリで、非同期ランタイムをサポートしていません。Cargo.toml[dependencies]以下に依存関係として記述して、外部から取り込まなければいけません。
そこで登場するのが、非同期ランタイム tokio ってわけ。
非同期関数になりえないmain関数の先頭で非同期ランタイムを起動し、非同期タスクの最終的な値を解決します。

以上が大まかな#[tokio::main]の目的・役割になります。

2. コードを解剖する

手続き型マクロの主な役割は、入力としてコードを取り込み、コードを生成し、コンパイラに出力を渡すことです。
この展開後のコードを見ることができれば重要な何かが見えてきそうですが、まさにそれの目的を果たしてくれる便利ツールがあります。

ツールをインストール

次の2つを実行して、必要なツールをインストールします。

cargo-expand
$ cargo install cargo-expand

cargo-expandはコード内のマクロを展開しコンパイラに渡さずに、中身を出力してくれます。

nightly
$ rustup toolchain install nightly --allow-downgrade

cargo-expandは多くの方が普段使っているであろう安定版のstableコンパイラではなく、 nightly と呼ばれる毎晩リリースされるコンパイラ(詳しくはこちら)に依存してマクロを展開します。しかし、nightlyの最新のリリースでは、rustupによってインストールされるバンドルのいくつかのコンポーネントが欠けていたり、不具合があったりする可能性があるため、--allow-downgradeですべての必要なコンポーネントが利用可能な最新のリリースを見つけてインストールするようにrustupに指示します。

cargo listrustup showを実行して、インストールされていることが確認できたら、次に進みます。

実行

次を実行します。

$ cargo +nightly expand

cargo +[~] [command]とすることで、コマンド単位でツールチェーンを指定できます。
今回は先程説明したとおり、依存関係にあるnightlyを指定します。
すると、下のようにマクロ展開後のコードが出力されます。

/// ...

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2021::*;
#[macro_use]
extern crate std;
use actix_web::{web, App, HttpServer, Responder};
async fn hello() -> impl Responder {
    "Hello, World!"
}
fn main() -> std::io::Result<()> {
    let body = async {
        HttpServer::new(|| App::new().route("/", web::get().to(hello)))
            .bind("127.0.0.1:8000")?
            .run()
            .await
    };
    #[allow(clippy::expect_used)]
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()
        .expect("Failed building the Runtime")
        .block_on(body)
}

#[tokio::main]の展開後にコンパイラに渡されるmainは同期関数になっている〜!

tokio::runtime::Builder::new_multi_thread()
    .enable_all()
    .build()
    .expect("Failed building the Runtime")
    .block_on(body)

重要なのはこの部分で、
tokioの非同期ランタイムを起動し、main関数全体の処理をblock_onすることで、HttpServer::runが返すFutureが完了するまでブロックしています。

要は、#[tokio::main]の役割は、非同期のmainを定義しているかのように見せかけて、その裏では、非同期のmainを受け取り、tokioのランタイムの上でそれを実行するために必要な定型文に書き換えて、コンパイラに渡してくれているだけってことです!!!

はい、やりたかったことは以上です。
なんかまとめ方が下手すぎますが、おわりです。

さいごに

今回が初めてのQiitaへの初めての投稿でした。
順序立てて、解説を加えていく中で自分の中での理解もより一層深まった気がします。
また時間を見つけて積極的に投稿していけたらと思っています。

最後まで読んでいただきありがとうございました。

参考

今回の記事を書くにあたり、Rustの非同期処理について調べる際、こちらの記事がとても参考になりました。