Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

同期Rustと非同期Rustで同じものを作るとどれくらい実装が変わるのか

同期Rustと非同期Rustの書き心地や使い心地の違いがRustのAsync WGでも課題として挙げられており、目下できるかぎり近づける取り組みが進行中です。詳しいところはRustが最近運用しているProject Goalsの非同期Rustに関する部分を参照してください。ここを見ると、概ね現状抱えている課題などが見えてくると思います。

理想を言えば、std::iostd::netではじまるものを、たとえばtokio::iotokio::netに書き換え、必要な箇所にasync.awaitを付与していくだけで作業が完結してほしいところではあります。他の多くのプログラミング言語では、ままそのようにするだけで済むものが多い印象を持っています。たとえば、私が業務で使用するKotlinが実際にそうで、suspendをつけるだけでほとんどの処理を楽に非同期化することができます。もちろんKotlinであっても状況や目的に完全に依存しますが、あまり関数やデータ構造を大きく曲げずとも、同期処理から非同期処理への切り替えが可能ではあります。

しかし現にRustはそうはなっていません。ひとつは、サードパーティ製の非同期ランタイムの提供するインタフェースが標準ライブラリ(stdと以降は表記)のそれと異なるようにデザインされていることが多いからだと思います。同期と非同期とでは使われる内部実装が相当異なるため、それに伴い最適化の仕方も異なる関係で、同期側で提供できていたインタフェースを非同期側で提供するのが難しいケースがあるのかもしれません。この部分について、stdの使い心地と異ならないような状態を目指したのがasync-stdという理解だったのですが、開発が止まってしまいました。

もうひとつは、今回のコード例ではまったく登場しませんが、非同期への変更時にRust特有の並行処理安全性の担保のためのワークアラウンド(たとえば、Sendのために諸々を修正する必要が出てくるなど; )やRustの所有権システムと非常に相性の悪い問題を回避するために発明された概念に由来するワークアラウンド(後述するPinに由来するもの)が追加で必要になることもままあります。要するにRust特有の安全性担保のための「モデル」が、書き心地の面で追加の修正を求める方向に働くことがあるのです。

今回は実際に簡単なHTTPサーバーの実装を通じて、同期と非同期でどれくらいの実装の違いが出るのかを確認してみたので、メモ書き程度に残しておきたいと思います。実装例としては単純過ぎて、本当に牙を剥くところがあまり見えないのが少し片手落ちかもしれませんが…。

要件

要件は、

  • 何らかのリクエストを受け取る。
  • 中身を読み取って、HTTP/1.1の仕様に従ったレスポンスを返す。

というものです。手抜きをしているので、リクエストがHTTPかどうかは問わないはずです。ただ、返すときだけHTTP/1.1 200 OKが返るように実装してあります。

コードはこのリポジトリにおいてあります。

github.com

同期Rustの場合

このサーバーを同期Rustで実装すると、たとえば次のようになります。これを基準点とします。

use std::{
    io::{BufRead, BufReader, Write},
    net::{TcpListener, TcpStream},
};

fn main() {
    let listener = TcpListener::bind("127.0.0.1:9999").unwrap();
    loop {
        let (stream, _) = listener.accept().unwrap();
        handle_connection(stream);
    }
}

fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&stream);

    let mut req = vec![];
    let mut lines = buf_reader.lines();
    while let Some(line) = lines.next() {
        let line = line.unwrap();
        if line.is_empty() {
            break;
        }
        req.push(line);
    }

    let res = format!("HTTP/1.1 200 OK\r\n\r\n{:#?}", req);
    stream.write_all(res.as_bytes()).unwrap();
    stream.flush().unwrap();
}

非同期Rust(tokio)の場合

さて、近年ほとんど一強になりつつあるtokioを使って実装し直してみます。

use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let listener = TcpListener::bind("127.0.0.1:9999").await.unwrap();
    loop {
        let (stream, _) = listener.accept().await.unwrap();
        handle_connection(stream).await;
    }
}

async fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);

    let mut req = vec![];
    let mut lines = buf_reader.lines();
    while let Some(line) = lines.next_line().await.unwrap() {
        if line.is_empty() {
            break;
        }
        req.push(line);
    }

    let res = format!("HTTP/1.1 200 OK\r\n\r\n{:#?}", req);
    stream.write_all(res.as_bytes()).await.unwrap();
    stream.flush().await.unwrap();
}

ここでいくつか変更が入っていることがわかります。

  • main関数がasync化している。さらに#[tokio::main]という謎のおまじないが追加されている。この点については後述する。
    • 各所に.awaitがついている。
  • handle_connection関数がasync化されている。
    • BufReader::new(&mut stream)に変わっている。ちなみにだが、BufReader::new(&stream)とすると&TcpStreamAsyncReadのトレイト制約を満たさないため、コンパイルエラーとなる。後述する。
    • buf_readerから取り出したlinesは、next_line()を呼び出すように変更になった。名前が変わった…。
    • やはり各所に.awaitがついている。

大きくは実装が変わらないように見えるのですが、いくつかのポイントで元コードから実装の修正が必要になっていることがわかります。

まず、#[tokio::main]についてです。これは、Rustでは、async fn main() { ... }という書き方はコンパイルエラーになるという事情があります(Rust Playgroundで試した例)。そして、.awaitasyncブロック関数ないしはブロックの中でしか呼び出せません。これでは不便なわけですが、#[tokio::main]はここを回避するために使用されるマクロです。

#[tokio::main]はマクロなので、裏でコードを生成しています。どのようなコードを生成しているかはcargo expandというコマンドで確認ができるので、実際に見てみると理解が進みます。生成されたコードをすべて展開してみたコードは下記です。

#![feature(prelude_import)]
#[prelude_import]
use std::prelude::rust_2024::*;
#[macro_use]
extern crate std;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{TcpListener, TcpStream};
fn main() {
    let body = async {
        let listener = TcpListener::bind("127.0.0.1:9999").await.unwrap();
        loop {
            let (stream, _) = listener.accept().await.unwrap();
            handle_connection(stream).await;
        }
    };
    #[allow(
        clippy::expect_used,
        clippy::diverging_sub_expression,
        clippy::needless_return
    )]
    {
        return tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .expect("Failed building the Runtime")
            .block_on(body);
    }
}
async fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);
    let mut req = ::alloc::vec::Vec::new();
    let mut lines = buf_reader.lines();
    while let Some(line) = lines.next_line().await.unwrap() {
        if line.is_empty() {
            break;
        }
        req.push(line);
    }
    let res = ::alloc::__export::must_use({
        let res = ::alloc::fmt::format(
            format_args!("HTTP/1.1 200 OK\r\n\r\n{0:#?}", req),
        );
        res
    });
    stream.write_all(res.as_bytes()).await.unwrap();
    stream.flush().await.unwrap();
}

main関数についていたasyncはなくなり、main関数に書かれていたコードは、let body = async { ... }というコードブロックに変わりました。その後そのコードブロックが、tokio::runtime::Runtime::block_onという関数に渡されていることがわかります。この関数はtokioランタイムの実行起点です。asyncブロック(正確にはstd::future::Future)を受け取り、その内容を実行する役割を持ちます。[*1]

次ですが、BufReader::new(&mut stream)と実装を少し変更する必要があります。最初この点に気づかず、stream.into_split()を使ってリーダーとライターをそれぞれ分割し、それらに対する操作を行う実装をしていました。しかし後にコンパイルエラーのヒントをきっかけに実装を修正したところ、コンパイルを通すことができました。なぜ通ったのかについて軽くまとめておきます。

先にBufReader::new()のトレイト制約を確認しておきます。実装を見ると、次のようになっています。R型にAsyncReadのトレイト制約が付与されていることがわかります。なのでまず、new関数に渡せる型はAsyncReadが実装されている必要があります。

impl<R: AsyncRead> BufReader<R> {
    /// Creates a buffered reader with the default buffer capacity.
    ///
    /// The default capacity is currently 8 KB, but that may change in the future.
    ///
    /// # Examples
    ///
    /// ```
    /// use futures_lite::io::BufReader;
    ///
    /// let input: &[u8] = b"hello";
    /// let reader = BufReader::new(input);
    /// ```
    pub fn new(inner: R) -> BufReader<R> {
        BufReader::with_capacity(DEFAULT_BUF_SIZE, inner)
    }

// ...続く

ところが、&TcpStream型にはAsyncReadトレイトは実装されません。これはtokioのrustdocを見ると確認することができるでしょう。どこにもimpl AsyncRead for &TcpStreamは見当たりません。

&mut TcpStreamであればコンパイルを通せてしまうわけです。ここには、Rustの非同期プログラミングを根底から支えるPinという概念が関わってきています。

AsyncReadトレイトは、実は次のようなブランケット実装を持っています。ちなみにですが、このデザインはtokioもsmolも共通しているようです。

impl<T: ?Sized + AsyncRead + Unpin> AsyncRead for &mut T { ... }

これにより、?SizedかつAsyncReadかつUnpinを満たすT型という条件下で、&mut T型に対してAsyncReadを実装する、という意味合いになります。&mut TcpStreamとしてBufReader::newのトレイト制約を通過できた理由がここにあります。Tには、トレイト制約を満たすあらゆる型が入ります。この記事に直接関係ある話題としてはUnpinが肝なので、Unpinだけ少し説明しておきます。

TcpStreamそれ自体は、内部に自己参照を持つような構造体ではなく、ファイルディスクリプタなどを中に持つだけなので、Unpinトレイトを実装しています。というより、Unpinトレイトは自動トレイトで、コンパイラが特定の条件を満たす型に対して自動的に実装されます。Unpinというのは、pinされても安全にムーブできる型を示すマーカートレイトで、Unpinが実装されているとPin<&mut T>Pin<Box<T>>から安全に&mut Tを取り出せる…なんですが、pinという概念をここで説明すると一記事仕上がってしまうので、さまざまな詳しい解説をご覧ください(ごめんなさい)。なので、上述したTに対するトレイト制約を満たしたことになります。そして、AsyncReadトレイトは、そのトレイト制約を満たした型Tかつ&mut T型に対して実装されるということになります。これにより、BufReaderTcpStreamが渡される時点で要求されるR: AsyncReadという型制約は満たされたことになり、結果BufReader::new(&mut stream)を呼び出すことができた、というわけです。

ではなぜBufReader::new(&stream)はできなかったかというと、それは&Tに対するAsyncReadトレイトが実装されていないからです。なぜ実装できないかというと、AsyncReadが持つ次の関数に原因があります。この関数は、self: Pin<&mut Self>という第一引数を持ちます。この第一引数の意味するところは、selfつまりこのトレイトの実装先が、Pin<&mut Self>という制約を満たせなければならないということを意味します。なので、&TにはAsyncReadを実装できないというわけです。

この記事の主題からかなり外れる関係でだいぶ説明を省いてしまいましたが、このあたりのpinに関する話は、Rust for Rustaceansという書籍にとても詳しく載っています。ご興味のある方はぜひご覧ください。

ただ、PinUnpinの概念を把握しつつ、このようなブランケット実装があるという事実を確認し、さらにそういう意味であると読み解くのは相当大変なように思われます。Rustのコンパイラはまずまず賢いので、&mut TcpStreamをこの箇所に渡すことのできる可能性をヘルプメッセージで示唆してくれてはいました。そして指示に従えば直せてしまえはしました。

しかし、これがなければどうやって見つけるのか、という気持ちになったのは事実です。そして、このロジックをどう把握すればよかったのでしょうか?私はRustをまずまず書いているのでこの話は理解できましたが、入門したてのユーザーには相当難易度が高いと思います。これが非同期Rustの難しいポイントのひとつなのではないかと私は考えています。

ちなみに私でさえ探すのに少し苦労しました。起点となったのは、AsyncReadの次の実装でした。これでピンときて、上述したブランケット実装を探すという次のステップに踏み出せたのでした。poll_readの第一引数にあるself: Pin<&mut Self>を見つけられなければ、おそらくブランケット実装には辿り着きもしませんでしたし、またstream.into_split()を使って実装を大きく修正する必要があるのか…という理解をでなかったように思われます。

impl AsyncRead for TcpStream {
    fn poll_read(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<io::Result<()>> {
        self.poll_read_priv(cx, buf)
    }
}

いわゆる自己参照構造体の問題を回避するために開発されたPinですが、このように思わぬ形で非同期Rustの各所に顔を出し、同期Rustとは異なった開発体験をユーザーに与える原因になっているという見方もできると私は考えています。Pin以上のうまい仕組みを思いつけるほどこの辺りの理解ができていないのが悔やまれますが、もしもこれより良い仕組みが考案されれば、将来的には上手に解決できる課題なのかもしれません。[*2]

非同期Rust(smol)の場合

近年各所で注目を集めつつあるsmolを使って実装してみました。smolの特徴としては、たとえばIOやネットワークといった細かい単位でクレートを提供しているため、非同期ランタイムの全部載せをせずとも一部パーツだけ利用して非同期プログラミングを行うことができるというものがありそうです。これを利用していたのが、最近開発を停止してしまったasync-stdという非同期ランタイムでした。ZedというRustで作られている最近話題のエディタがあるのですが、そこでもasync-taskというsmolの一部が利用されているというブログ記事が書かれていました。

smolによる実装も、tokioでの変更点とほとんど同じではありました。次のようなコードを書くことができます。

use smol::{
    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
    net::{TcpListener, TcpStream},
    stream::StreamExt,
};
use smol_macros::main;

main! {
    async fn main() {
        let listener = TcpListener::bind("127.0.0.1:9999").await.unwrap();
        loop {
            let (stream, _) = listener.accept().await.unwrap();
            handle_connection(stream).await;
        }
    }
}

async fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(&mut stream);

    let mut req = vec![];
    let mut lines = buf_reader.lines();
    while let Some(line) = lines.next().await {
        let line = line.unwrap();
        if line.is_empty() {
            break;
        }
        req.push(line);
    }

    let res = format!("HTTP/1.1 200 OK\r\n\r\n{:#?}", req);
    stream.write_all(res.as_bytes()).await.unwrap();
    stream.flush().await.unwrap();
}

変更点としては下記でしょうか。

  • main!マクロでmain関数が囲まれている。これはsmol-macrosの機能である。ちなみにこの手の宣言的マクロが嫌な場合(フォーマッタが効かないなどの理由で)、手続き的マクロでも実装することはできるらしい。
  • async.awaitを付与する箇所はtokioと変わらない。
  • BufReader::new(&mut stream)である点も、tokioと変わらない。
  • lines.next()とできるが、これは、futuresというクレートのStreamExtというトレイトのおかげ。これがnextという関数を持っている。

中の実装を見てもまあまあtokioと同じなんですが、TcpStreamの実装は微妙に異なります。tokioの場合はmioというクレートの提供するmio::net::TcpStreamに依存していますが、smolの場合は下記のようにstd::io::TcpStreamをベースとしているようです。smolの提供するAsync<T>というアダプタで囲まれています。これは、内部に入れたT型をノンブロッキングモードで利用できるようにするものです。少しデザインが違うことがわかりますね。

pub struct TcpStream {
    inner: Arc<Async<std::net::TcpStream>>,
    readable: Option<async_io::ReadableOwned<std::net::TcpStream>>,
    writable: Option<async_io::WritableOwned<std::net::TcpStream>>,
}

中身はArcなので、やりたいかどうかはさておき下記のようにcloneを呼んでもコンパイルは通りますし、動作もします。

use smol::{
    io::{AsyncBufReadExt, AsyncWriteExt, BufReader},
    net::{TcpListener, TcpStream},
    stream::StreamExt,
};
use smol_macros::main;

main! {
    async fn main() {
        let listener = TcpListener::bind("127.0.0.1:9999").await.unwrap();
        loop {
            let (stream, _) = listener.accept().await.unwrap();
            handle_connection(stream).await;
        }
    }
}


async fn handle_connection(mut stream: TcpStream) {
    let buf_reader = BufReader::new(stream.clone());

    let mut req = vec![];
    let mut lines = buf_reader.lines();
    while let Some(line) = lines.next().await {
        let line = line.unwrap();
        if line.is_empty() {
            break;
        }
        req.push(line);
    }

    let res = format!("HTTP/1.1 200 OK\r\n\r\n{:#?}", req);
    stream.write_all(res.as_bytes()).await.unwrap();
    stream.flush().await.unwrap();
}

感想など

場合によっては大きくは変えずとも済む…かもしれない

かなり大幅に処理を変える必要があるのかな?と最初思って始めたのですが、このコードの例だけであれば、思ったよりコードベースを変えずとも非同期化できるなという印象を持ちました。ただ、「変える必要がありません」と言い切るのは、この例からは難しいです。あまりにも話が単純すぎるからです。

実際のところ、非同期処理と同期処理は本質的にはやっている内容が異なります。前者は並行処理なわけですし、書いた処理が実行されるタイミングは同期処理のように逐次的ではありません。いつ実行されるかはスケジューラの気分次第なところがあります。そもそも本質的に異なるものを、同じシンタックスで書かせてしまってよいのかという話もあるでしょう。Rustは基本的に、本質的に内容が異なるものや複雑性を持つ概念については、追加でかかるコストを意図的にユーザーに書かせる傾向にはあり、並行処理にかかっているコストも単にその原則に従ってユーザー側に転化しているという見方をすることはできます。しかしながら、複雑なものをシンプルな方法で解決させてほしい、という気持ちもよくわかります。

使いこなすためにはRustそのものへの慣れと、さらには非同期Rustを支える概念への理解が必要不可欠

tokioBufReader::newの謎を読み解く部分で感じましたが、非同期Rustの理解は、そもそも同期Rustへの相当の慣れを要求されると思います。トレイト制約の読み解き方を身につけなければなりませんし、ブランケット実装の意味するところの理解を知らなければなりません。また、非同期Rustに登場する独特な概念であるところのPinと、Unpinに対する理解が必要です。マーカートレイトや自動トレイトといった概念も理解していなければなりません。

この手の話は、残念ながら文献やドキュメントを一度読み解かずに正確な理解に辿り着くことは難しいのではないかと考えています。TRPLではあまり解説されている印象はなく、いわゆるAsync Bookも役不足だと思います。2018年ごろからRustをやっていた人たちであればその頃Pinとか自己参照構造体といった話は大騒ぎになっていたので、用語等含めて頭の片隅にあるかもしれませんが、最近始められた方は、そもそも存在すら知らないと思います。

邦書では説明されているのを見たことはなく、洋書に頼るしか今は道がありません。その点で、オライリーの『プログラミングRust』は、上述したすべての話が網羅されていて一読の価値があると思います。非同期の章は後ろの方にあるのですが、ぜひ非同期Rustを始める前に必ず一読されることをおすすめします。

非同期Rustは難しいの…?

(他のプログラミング言語の同等の機構と比べて相対的に)難しいと思います。もうちょっと楽に書かせてほしい。私が思う、非同期Rustを書いていてたまによくわからなくなる理由に、「仮にコンパイルエラーが出たとして、次のアクションをどうしたらいいのかさっぱりわからない」というものがあります。ここで役に立つのはAIで、たしかにGeminiに聞くとかなりいい回答が返ってきます。これをベースに直すと直ることもあるし、謎が深まることもあります。ただ解決には、上述したようにRustそのものへの習熟と非同期Rustの裏側や概念の理解が求められるような感じがしてなりません。そもそも詰まりポイントを言語化できないとAIに聞いて的確な回答を得られませんし、言語化するためにはRustのタームに慣れている必要がありますからね。

ここは私もなんとかしたいと思っていて、最近Rustへのコントリビューションをしており、まずはエラーメッセージからなんとかできないかなと思っています。将来的にはちょっと改善に取り組みたいです。

これは厳しいと思ったのが、async fn main() { ... }を何も考えずに書いた際に出る下記のコンパイルエラーです。

   Compiling playground v0.0.1 (/playground)
error[E0752]: `main` function is not allowed to be `async`
 --> src/main.rs:1:1
  |
1 | async fn main() {
  | ^^^^^^^^^^^^^^^ `main` function is not allowed to be `async`

For more information about this error, try `rustc --explain E0752`.
error: could not compile `playground` (bin "playground") due to 1 previous error

rustc --explain E0752を叩いてみて!とのことなので、ためしに叩いてみましょう。すると…

The entry point of the program was marked as async.

Erroneous code example:

async fn main() -> Result<(), ()> { // error!
    Ok(())
}

fn main() or the specified start function is not allowed to be async. Not having a correct async runtime library setup may cause this
error. To fix it, declare the entry point without async:

fn main() -> Result<(), ()> { // ok!
    Ok(())
}

初見でこの説明で次に何をしたらいいのか、わかるの…か…?という話です。実際、こうした困りごとに直面するユーザーは多いようです。ユーザーからのおたよりによって構成される非同期Rustのつまりどころを解説したページにも、同様にエラーメッセージがわかりにくくて困った例が載っていたりします。

もちろん、tokioをリンクしておくとtokioのメンテナンスがなんらかの理由で突然終了した場合などに困ってしまうとか、Rust公式が公認っぽくtokioを載せてしまってよいのかとか、いろんな問題があるとは思います。が、せめてもう少し丁寧な誘導ができるといいですね、と思います。

「非同期プログラミングが本質的に難しい」という話はよくわかるのですが、それと「次のアクションがわからない」は別の話かなと思っています。非同期処理は難しく、他の言語でも詰まるときは詰まりますが、詰まり方のバリエーションが圧倒的に多いのがRustというイメージを持っています。ユーザーが詰まった時に次に何をしたらいいかをわかりやすくガイドできるようにした上で、本丸である、本来的に難しさを抱える非同期プログラミングへユーザーに立ち向かってもらうというのが、筋のいい解決策なのかなという仮説を持っています。

ただ、Pinに関連する話はなんとかしたほうがよさそうだなとも思います。現状のPinというレンズを通じて非同期プログラミングをデザインした際に、必要以上にユーザーに変更を強いたり、デバッグを難しくしていることがわかりはじめているころだと思います。Pinを別の角度から眺めて課題点を洗い出して解決したり、Pin以外のレンズが何かないかを探してみるのも手かもしれません。

他に手はないの?

Goのgoroutineのように、そもそも非同期プログラミングをしていることをユーザーにまったく表出させない手[*3]は考えられると思います。Goの場合、同期関数として一旦実装しておいて、必要になったらgoキーワードでgoroutineに回すという使い方ができます。これはいいですね。Goの並行処理機構開発者体験の面でも大変優れていて、Rustにとっても参考になることは多いのではないかと私は見ています。どうやったらいいのかはさっぱりわからないんですが。

別の非同期プログラミングをユーザーにまったく表出させない手として、アクターモデルとWebAssemblyを組み合わせたLunaticというランタイムがRustにはあります。これは以前記事を書いたことがあるのですが、記事をご覧いただくとわかるように、async.awaitなどは全く表に出てきません。同期関数を書いておき、裏でランタイムが非同期処理をがんばってくれる類のデザインになっているようです。最近ふと思い出してリポジトリを見てみたら、あまり開発が活発でなくなっていそうなのがとても残念です。WebAssemblyをターゲットとしたランタイムであれば、可能性があるかもしれません。

zenn.dev

実務で開発していて困らないの?

Webバックエンド開発の文脈です。

基本は、Webバックエンド開発ではAxumなどのライブラリに乗ってしまうことが多く、Axumはよくデザインされているので難しい部分は隠蔽されています。そもそも初手から非同期Rustを選択することになるため、今回のように同期から非同期へ処理を移行するというタスクが発生しません。最初から非同期Rustの頭で考えていくため、普通にプログラミングしているのとあまり変わらない感覚でいられることがほとんどではありました。

ただし、Arc<Mutex<T>>のようなものを導入し始めると一気に話は変わってきたように思います。そして意外とこれを導入したい場面はあるんですよね。そして、コンパイルエラーの言う内容が読み解けなくて苦労したことはあります。一度直面して解決すると慣れます。解決する際に、ちょっと周りに助けてくれる人が必要だなとは思います。

最後に

async/.awaitがなければ、非同期Rustは日の目を見ることはなかったと思います。asyncがRust 1.39でリリースされてから、主にはバックエンド開発でのRustの利用事例が大幅に拡大することになりました。これがリリースされる前の非同期Rustは相当大変でした。

Rust公式のとる毎年のアンケートでも、Rustの利用事例として最も件数が多そうなのは、Webのバックエンド開発となっています(WebサービスSaaSを提供する会社の数が多いのでは、とも思うんですが)。asyncがなければ、おそらくここまで利用事例は伸びなかったと思います。Rustの利用拡大に大きく貢献したのは間違いないはずです。

2024 Editionのリリースにかけてのタイミングで、長年の一貫性のなさとしてよく槍玉に上がっていたAsync Traitが一部ではありますが解禁されました。このように、非同期Rustの改善は目下取り組み中のようです。今後も改善活動は続くはずです。

あとは、誰かがわかりやすい非同期Rust入門の記事なり連載を書いてくださると思います!

*1:となるとちょっと不思議なのですが、なぜasync fn main()という記法は問題なくコンパイルを通っていたのでしょうか。コンパイラの該当の箇所を読んだわけではありませんが推察として、そもそもasync fn main()という関数定義はシンタックス上は可能で、mainという名前がついたときだけ特別にasyncをつけられないようにコンパイラがエラーとして落とすような実装になっているのではないかと思います。つまり、async fnを解析する段階と、関数名を引いてコンパイルエラーとする段階とが分かれているのではないかと思っていると言うことです。async fnを解析する段階の直後で、かつ関数名を引く前にマクロによる生成コードを挟んでおいて、コンパイルエラーを回避できている…という理屈なのかなと思いました。

*2:もしくは、原理的に無理なので、もはや非同期Rustは同期Rustとは別物と考えるべきですよという案内ができるかもしれません

*3:イメージとしては、asyncとかawaitをまったく使用しないとか、IOの際に同期関数をそのまま呼べばいいとかですかね。