Chapter 03

Hello Tokio

magurotuna
magurotuna
2021.02.24に更新

原文: https://tokio.rs/tokio/tutorial/hello-tokio

はじめに、とても基本的な Tokio アプリケーションを書いていきます。Mini-Redis サーバーに接続し、hello というキーに world という値をセット、そしてセットした値を読む、というアプリです。ここでは Mini-Redis クライアントライブラリを使います。

コード

クレートを作る

まず、新しい Rust アプリケーションを生成しましょう:

$ cargo new my-redis
$ cd my-redis

依存を追加

次に、Cargo.toml を開いて 次の記述を [dependencies] の下に追加してください。

tokio = { version = "1", features = ["full"] }
mini-redis = "0.4"

コードを書いていく

そして main.rs を開いて、ファイルの中身を以下の内容に置き換えてください。

use mini_redis::{client, Result};

#[tokio::main]
pub async fn main() -> Result<()> {
    // mini-redis アドレスへのコネクションを開く
    let mut client = client::connect("127.0.0.1:6379").await?;

    // "hello" というキーに "world" という値をセット
    client.set("hello", "world".into()).await?;

    // "hello" の値を取得
    let result = client.get("hello").await?;

    println!("got value from the server; result={:?}", result);

    Ok(())
}

Mini-Redis サーバーは立ち上がっていますか?立ち上がっていない場合は、別のターミナルウィンドウを開いて、

$ mini-redis-server

を実行してください。

そして、my-redis アプリケーションを実行してみましょう。

$ cargo run
got value from the server; result=Some(b"world")

成功です!

全体のコードは こちら で確認できます。

噛み砕いた説明

書いたコードを詳しく見ていきましょう。コードはそれほど多くないですが、多くのことが起こっています。

let mut client = client::connect("127.0.0.1:6379").await?;

client::connect 関数は mini-redis クレートが提供しているものです。指定されたアドレスに対して、非同期に TCP コネクションを確立します。コネクションが確立したら、client ハンドルが返されます。この操作は非同期に行われますが、私たちが書いたコードは同期的なコードのように見えます。この操作が非同期であることを示す唯一の印は、.await 演算子です。

非同期プログラミングとは?

ほとんどのコンピュータ・プログラムは書いた通りの順番で実行されます。1行目が実行され、次の行が実行され……といった具合にです。同期的なプログラミングにおいては、プログラムが、即座に終了しない処理にさしかかった際、その処理が完了するまでブロックされます。例えば、TCP コネクションを確立するためには、ネットワークを介して相手側とやりとりをする必要がありますが、これは相当な時間を要します。この処理の間、スレッドはブロックされる、というわけです。

一方、非同期プログラミングでは、即座に完了しない処理はバックグラウンドで中断されます。スレッドはブロックされず、別の処理を実行し続けることができます。処理が完了したら、バックグラウンドに行っていたタスクが復帰し、中断されたところから処理が再開されます。上で書いた我々のコード例ではタスクが1つだけしかなかったので、中断されている間何も起こりませんでした。しかし非同期プログラムは一般に多くのタスクを持っています。

非同期プログラミングはアプリケーションをより早くすることに繋がりますが、プログラムがずいぶんと複雑なものになることもしばしばあります。非同期処理が完了したときにタスクを再開するために必要な状態について、プログラマがすべて追跡しなければなりません。歴史的にも、これは退屈で、なおかつエラーを引き起こしやすい作業です。

コンパイル時グリーンスレッド

Rust は async/await と呼ばれる機能を使って非同期プログラミングを実現しています。
非同期処理を実行する関数は async キーワードでラベルが付けられます。我々の例では、connect 関数は以下のように定義されていました:

use mini_redis::Result;
use mini_redis::client::Client;
use tokio::net::ToSocketAddrs;

pub async fn connect<T: ToSocketAddrs>(addr: T) -> Result<Client> {
    // ...
}

async fn という定義は通常の同期関数のように見えますが、非同期で実行されます。Rust はコンパイル時に async fn を非同期処理を行うルーチンへと変換します。 async fn の内部で .await を呼び出すと、スレッドに制御が戻ります。バックグラウンドで処理されている間、スレッドは別の仕事をすることができます。

別の言語でも async/await は実装されていますが、Rust のそれは独特なアプローチを採用しています。特に、Rust の非同期な処理は lazy であるということです。これによって、実行時には別の言語と異なる意味を持ちます。

まだ意味が分からなくても、心配はいりません。このガイドを通じて、async/await についてもっと詳しく見ていきます。

async/await を使う

Rust において、非同期関数は他の関数と同じように呼び出すことができます。しかし、非同期関数を呼び出しても、関数の中身が実行されるわけではないのです。代わりに、async fn を呼び出すと、処理を表す値が返ってきます。これは、概念的には、引数をもたないクロージャと類似しています。実際に処理を実行するためには、返り値に対して .await 演算子を使う必要があります。

例として、次のコードを考えてみましょう:

async fn say_world() {
    println!("world");
}

#[tokio::main]
async fn main() {
    // `say_world()` を呼び出しても、`say_world()` の中身は実行されない
    let op = say_world();

    // この println! が最初に実行される
    println!("hello");

    // `op` に対して `.await` を呼び出すことで、`say_world` の中身が実行される
    op.await;
}

以下のように出力されます:

hello
world

async fn の返り値は Future トレイトを実装する無名型です。

Async な main 関数

アプリケーションを開始するために使われる main 関数が、多くの Rust クレートのものとは異なっています。

  1. main 関数が async fn になっている
  2. #[tokio::main] というアトリビュートが付与されている

async fn は、非同期のコンテキストに入るために使われています。しかし、非同期関数は必ずランタイムによって実行されなければなりません。ランタイムは、非同期タスクのスケジューラを持ち、イベント駆動の I/O、タイマー、などを提供しています。ランタイムは自動的には開始されませんので、main 関数がランタイムをスタートさせる必要があります。

#[tokio::main] はマクロです。これをつけることで、async fn main() が同期的な fn main() へと変換されます。この変換後の fn main() 内で、ランタイムの初期化処理と、非同期処理の実行が行われます。

例えば、次のようなコードを書いたとすると

#[tokio::main]
async fn main() {
    println!("hello");
}

これは以下のように変換されます。

fn main() {
    let mut rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        println!("hello");
    })
}

Tokio のランタイムの詳細については、のちほど解説します。

Cargo の "features"

このチュートリアルで Tokio に依存をする際には、full という feature フラグを有効にします:

tokio = { version = "1", features = ["full"] }

Tokio は多くの機能をもっています(TCP、UDP、Unixソケット、タイマー、syncに関するユーティリティ、複数の種類のスケジューラ、などなど)。すべてのアプリケーションがすべての機能を必要とするわけではないでしょう。コンパイル時間や最終生成物のサイズを減らそうとするときには、必要な機能だけを選択することができるのです。

とりあえず、"full" feature を使うことにしましょう。