Rust 勉強メモ - Tokio による非同期処理、mini-redis チュートリアル
非同期処理
ひとまず Tokio のチュートリアルをやっていく
リポジトリ作った
References
RustのTokioで非同期とグリーンスレッドを理解する
Rustのスレッドとチャネルと共有メモリの話
Mini-Redis Tutorialからはじめるtokio
並行性と並列性
並行(concurrency)と並列(parallelism)は同じものではありません。もしあなたが、2つのタスクを交互に行うのなら、それはそれらのタスクを「並行して」こなしているということになりますが、「並列」ではありません。これを「並列」にしたいのであれば、2人の人間が必要で、それぞれのタスクにそれぞれの人間を専任させることになるでしょう。
Tokio を使うことによる利点の1つは、非同期コードを書くことによって、普通のスレッドを利用して多くのタスクを並列に処理するのではなく、「並行に」処理することができるようになる、という点です。実際、Tokio を使えば、たとえ単一のスレッドであったとしても、多くのタスクを並行して実行することができるのです!
Tokio の非同期タスクのスレッド間の移動
これどういう仕組み?<s>main 実行時のスレッドってどうなってるの?</s> ←何いってんだおまえ。スレッドについて理解できていなかったときに出た疑問。解消済み。
spawn されたタスクは、spawn されたスレッドと同一のスレッド上で実行されることもあれば、ランタイム上の異なるスレッドで実行されることもあります。spawn されたあとにスレッド間を行き来することもあります。
ランタイムが非同期タスクをスレッド間で移動するために必要な条件:
-
Send トレイト
- タスク内の全てのデータ(=
tokio::spawn()
関数の引数であるasync {}
ブロックに存在する、使用されている変数)はSend
トレイトを実装している必要がある。これは、データが所有権を持っている別のスレッドに安全に送信できることを意味する。
- タスク内の全てのデータ(=
-
Sync トレイト
- タスク内の全てのデータが複数のスレッドから同時に参照される場合、それらのデータは
Sync
トレイトを実装している必要がある。これは、データが複数のスレッドから安全に同時アクセスできることを意味する。
- タスク内の全てのデータが複数のスレッドから同時に参照される場合、それらのデータは
-
非同期ランタイムの特性
- 使用している非同期ランタイム(例: tokio, async-std など)によって、スレッド間のタスクの移動の振る舞いが異なる場合がある。例えば、
tokio::task::spawn
はタスクをスレッド間で移動することができるが、tokio::task::spawn_local
はそのような移動を許可しない。
- 使用している非同期ランタイム(例: tokio, async-std など)によって、スレッド間のタスクの移動の振る舞いが異なる場合がある。例えば、
移動できない例
use tokio::task::yield_now;
use std::rc::Rc;
#[tokio::main]
async fn main() {
tokio::spawn(async {
let rc = Rc::new("hello");
// `rc` は `.await` のあとに使用されている。
// つまり、タスクのステートとして保持されなければならない
yield_now().await;
println!("{}", rc);
});
}
非同期タスクの構成要素
非同期タスクの構成要素について。「非同期タスクを別のスレッドへ移動する」ときに何を移動しているのか。移動している実体は?
非同期タスクは、基本的に以下の要素から構成される:
-
ステートマシン
- Rust の非同期関数や非同期ブロックは、コンパイル時にステートマシンに変換される。このステートマシンは、タスクが再開されるたびにどのステップを次に実行するかを知っている。
-
キャプチャされた変数
- 非同期ブロックやクロージャ内で使用される変数は、生成されるステートマシンにキャプチャされる。これにより、非同期タスクが再開されるときに、これらの変数の値が保持される。
-
ワーカースレッドやエグゼキュータ
- タスクは特定のエグゼキュータ(例: tokio のエグゼキュータ)上で実行される。このエグゼキュータは、タスクをワーカースレッド上でスケジューリングし、実行する。
-
コンテキスト
- タスクが再開されるときには、現在の
Waker
や他のコンテキスト情報が必要。Waker
はタスクが再度「起きる」ためのメカニズムを提供する。
- タスクが再開されるときには、現在の
総じて、非同期タスクは、
- 実行の状態
- キャプチャされた変数
- タスクがどのように実行されるかを制御するコンテキストやエグゼキュータ
に関連する情報を持っている。
非同期タスクの生成に必要なリソース
なるほど。Go の goroutine みたなもの?
(Go の goroutine はグリーンスレッドって言って良い?)
Tokio におけるタスクはとても軽量です。内部的には、1回のアロケーションしか必要としませんし、64バイトのメモリしか消費しません。アプリケーションは気軽に、数百万とまではいかないものの、数千のタスクを spawn することができます。
参照カウント Rc, Arc の基本
参照カウントとは
参照カウントはメモリ管理のテクニックの一つで、特定のリソースへの「参照」または「ポインタ」の数を追跡する。リソースが最初に作成されたとき、カウントは 1 から始まる。新しい参照が作成されるたびにカウントは増加し、参照がドロップ(スコープを抜けるなど)されるたびにカウントは減少する。カウントが 0 になると、そのリソースはメモリから解放される。
Rc
と Arc
Rustの Rust には、参照カウントのための2つの主要な型、Rc
と Arc
がある。
-
Rc (Reference Counted)
-
Rc
は、シングルスレッド環境での参照カウントを提供する - 複数の所有者を持つことができるが、これはシングルスレッド環境での使用でのみ安全
-
-
Arc (Atomic Reference Counted)
-
Arc
は、複数のスレッド間でデータを共有するためのアトミックな参照カウントを提供する - 複数のスレッドから同時にアクセスされる可能性があるデータに対して安全
-
基本的な使い方
-
Rc の使用
use std::rc::Rc; let foo = Rc::new(vec![1.0, 2.0, 3.0]); // この時点で、参照カウントは 1 let a = foo.clone(); let b = foo.clone(); // この時点で、参照カウントは 3 // a, b, foo はすべて同じデータにアクセスする println!("{:?}", a);
-
Arc の使用:
use std::sync::Arc; use std::thread; let foo = Arc::new(vec![1.0, 2.0, 3.0]); // この時点で、参照カウントは 1 let a = foo.clone(); let b = foo.clone(); // この時点で、参照カウントは 3 thread::spawn(move || { println!("{:?}", a); }).join().unwrap(); thread::spawn(move || { println!("{:?}", b); }).join().unwrap();
Rc
と Arc
は、メソッドや関数がデータの所有権を持つ必要がない場合や、複数の場所でデータを共有する必要がある場合に特に役立つ。ただし、これらの型を使用する際は、参照カウントのオーバーヘッドを考慮する必要がある。また、Rc
や Arc
でラップされたデータは、データが変更される可能性がある場合、RefCell
や Mutex
などの内部可変性を提供するメカニズムと組み合わせて使用する必要がある。
Rc
型の foo
を foo.clone()
した値を代入した変数 a
について、 a
の型は何になるか?なぜ println!
で値が取り出せるのか?
foo.clone()
を使用すると、foo
の参照カウントが増加するが、実際のデータはクローンされない。結果として、a
の型も Rc<Vec<f64>>
になる。したがって、foo
と a
は同じ Rc
インスタンスを指している。
println!
で a
の値を直接表示できるのは、Rc<T>
が Deref
トレイトを実装しているため。このトレイトのおかげで、Rc<T>
は自動的に内部の T
への参照にデリファレンス(透過的にアクセス)される。
Deref
トレイトは、型が *
演算子を通じて他の型にどのようにデリファレンスされるかを定義するものである。Rc<T>
の場合、Deref
トレイトは T
にデリファレンスする。そのため、Rc<T>
上で呼び出されるメソッドやアクセスされるフィールドは、実際には内部の T
上で動作する。
この振る舞いのおかげで、Rc
や Box
、Ref
などのスマートポインタを使用するときに、実際のデータに直接アクセスしているかのようにコードを書くことができる。
例:
use std::rc::Rc;
let foo = Rc::new(vec![1.0, 2.0, 3.0]);
let a = foo.clone();
// a は Rc<Vec<f64>> 型だが、Deref トレイトのおかげで Vec<f64> のメソッドに透過的にアクセスできる。
println!("{:?}", a.len()); // -> 3
この例では、a.len()
は、Rc<T>
が内部の Vec<f64>
にデリファレンスされた結果、Vec<f64>
の len
メソッドが呼び出されている。
(Go のメソッドレシーバが値レシーバかポインタレシーバに限らずメソッドのアクセスが recv.method()
みたいに書けるのと似てる)
「透過的」とは
「透過的にアクセス」というフレーズの意味は、プログラマが特定の操作を明示的に行わなくても、あたかもその操作が行われているかのように振る舞うことを指す。
具体的には、Rc<T>
に対して何らかの操作を行うと、Rust の Deref
トレイトの実装により、その操作は Rc
の内部の T
に自動的に適用される。この動作は、プログラマから見ると「透明」であるため、直接 T
にアクセスしているかのように感じる。
例えば、Rc<String>
に対して .len()
メソッドを呼び出すと、実際には String
の .len()
メソッドが呼び出される。プログラマは Deref
の動作を意識することなく、Rc
を介して直接 String
にアクセスしているようにコードを書くことができる。
この「透明性」は、スマートポインタや参照型を使用する際の利便性を高めるために提供されている。
Arc<Mutex<T>>
のミニマムサンプル
HashMap
を Arc<Mutex<T>>
でラップする場合、Mutex<T>
のロックを取得する必要がある。ロックを取得することで、そのリソースに対する排他的なアクセス権を得ることができ、データの変更などの操作を安全に行うことができる。
以下は、Arc<Mutex<HashMap<K, V>>>
を使用して HashMap
にデータを挿入する基本的な方法:
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
fn main() {
let db = Arc::new(Mutex::new(HashMap::new()));
{
let mut locked_db = db.lock().unwrap();
locked_db.insert("key", "value");
}
}
この例では、db.lock()
を使用して Mutex
のロックを取得する。このメソッドは Result
を返すので、.unwrap()
を使用してエラーを処理している(実際のアプリケーションでは、適切なエラー処理を行うことを推奨)。
locked_db
は MutexGuard
という型で、スコープの終わりに自動的にドロップされ、ロックが解放される。これにより、他のスレッドやタスクが db
にアクセスすることができるようになる。
注意点として、長い時間ロックを保持すると、他のスレッドやタスクがそのリソースにアクセスできなくなるため、パフォーマンスの問題やデッドロックのリスクが高まる可能性がある。ロックを取得する必要がある操作は、できるだけ短時間で完了するように注意することが重要。
Into トレイト
.into()
は Rust において非常に便利なメソッドで、Into
トレイトに関連している。このトレイトとその関連トレイト、そしてその使用方法について説明する。
Into
トレイト
1. Into
トレイトは、ある型を別の型に変換するための機能を提供する。これは非常に一般的なトレイトで、多くの標準ライブラリの型に実装されている。
例:
let s: String = "hello".into(); // &str を String に変換
From
トレイト
2. From
トレイトは Into
の逆。From
を実装することで、自動的に Into
も実装される。
例:
let s = String::from("hello"); // &str から String への変換
into
の使用
3. 下記のコードの中で、value.into()
は、value
の型(この場合は &str
)を、client.set
メソッドが期待する型に変換している。具体的には、&str
を String
などの所有権を持つ文字列型に変換している。
use mini_redis::{client, Result};
#[tokio::main]
async fn main() -> Result<()> {
// Open a connection to the mini-redis address.
let addr = "127.0.0.1:6379";
// Set the key "hello" with value "world"
let key = "hello";
let value = "world";
println!("Setting key {} to {}.", key, value);
let mut client = client::connect(addr).await?;
client.set(key, value.into()).await?;
// Get key "hello"
let mut client = client::connect(addr).await?;
let result = client.get(key).await?;
println!("Got value from mini-redis server; result = {:?}", result);
Ok(())
}
into
が便利か
4. なぜ -
型推論
- Rust の型推論システムと組み合わせることで、
into
を使うときに変換先の型を明示的に指定する必要がなくなる。コンパイラがコンテキストから適切な型を推論してくれる。
- Rust の型推論システムと組み合わせることで、
-
コードの簡潔性
- 変換関数を都度書く代わりに、一貫したメソッドを使用して様々な型の間の変換を行うことができる。
Into
や From
トレイトは、カスタム型に対しても実装することができる。これにより、異なるカスタム型間での変換を簡潔に行うことができる。
ChatGPT: なぜ &str 型の to_string メソッドは into で実装されないのか?
&str
型の.to_string()
メソッドとinto
メソッドの背後にあるメカニズムは異なりますが、両方とも同じ結果、つまりString
のインスタンスを返します。しかし、&str
型の.to_string()
メソッドがInto
で実装されていない理由にはいくつかの側面があります。
明確さ:
.to_string()
メソッドはその名前から、&str
をString
に変換することが明確にわかります。この明確さは、コードの読みやすさと理解のしやすさに寄与します。多機能性:
Into
トレイトはある型を別の型に変換する一般的なメカニズムを提供します。しかし、ある型を別の多くの型に変換できる場合、into
メソッドを使用すると、コンパイラがどの変換を適用すべきかを自動的に推論するのが難しくなる場合があります。実装の詳細: 実際には、
&str
の.to_string()
メソッドはToString
トレイトに基づいています。このトレイトは、Display
トレイトを実装するすべての型に対してデフォルトで実装されています。したがって、.to_string()
は実際には&str
型に固有のものではなく、Display
トレイトを実装する任意の型に対して使用できます。歴史的背景: Rustの初期のバージョンでは、
to_string
やinto
などのメソッドやトレイトが現在の形になるまでにいくつかの変更が加えられました。これにより、現在の設計が最も一貫性があり、使いやすいと判断されたものです。要するに、
.to_string()
とinto
の違いは、前者が特定の変換(文字列への変換)に特化しているのに対し、後者は一般的な変換メカニズムを提供しているという点にあります。この設計は、Rustの型システムの厳格さと組み合わせて、明確で安全なコードの記述をサポートしています。
シャーディング
Rust でのシンプルなシャーディングの例
シャーディングにおける「ホットスポット」
ChatGPT: なるほど、じゃあキーのハッシュ値によっては特定のシャードへの insert が偏ることもありえるんだね。
はい、その通りです。キーのハッシュ値の分布が一様でない場合、特定のシャードに対するアクセスや挿入が偏る可能性があります。これは「ホットスポット」として知られ、一部のシャードが過度に使用される一方で、他のシャードがほとんど使用されないという状況を引き起こすことがあります。
ホットスポットが発生すると、次のような問題が起こる可能性があります:
- パフォーマンスの低下:特定のシャードが過度に使用されると、そのシャードに対するアクセスが頻繁にブロックされる可能性があります。
- リソースの非効率的な使用:一部のシャードが過度に使用される一方で、他のシャードが十分に活用されない場合、リソースの非効率的な使用となります。
ホットスポットを避けるための方法:
- 良いハッシュ関数の使用:ハッシュ関数が一様な分布のハッシュ値を生成することで、シャードへのデータの分散を最適化することができます。
- 動的リバランシング:シャード間のデータの分布を定期的に監視し、必要に応じてデータを再分散することで、偏りを軽減することができます。
- シャーディング戦略の再評価:キーのハッシュ値だけでなく、他の属性や戦略を考慮してシャーディングを行うことで、データの分散を最適化することができます。
シャーディングは非常に強力なテクニックですが、設計や実装の段階で考慮すべき多くの要因があります。適切なシャーディング戦略を選択し、適切にモニタリングと調整を行うことで、データの効率的な管理と高いパフォーマンスを維持することができます。
Tokio のチャンネルの種類
-
mpsc
- multi-producer, single-consumer 型のチャネル。たくさんの値を送ることができる。
-
oneshot
- single-producer, single-consumer 型のチャネル。1つの値を送ることができる。
-
broadcast
- multi-producer, multi-consumer 型のチャネル。受信側はすべての値を見ることができる。
-
watch
- single-producer, multi-consumer 型のチャネル。たくさんの値を送ることができるが、履歴は残らない。受信側は最新の値のみを見ることができる。
非同期 I/O
I/O プリミティブとは
I/O プリミティブとは、基本的な入出力操作を行うための基本的なソフトウェアコンポーネントを指す。Rust の非同期プログラミングの文脈では、特に非同期の I/O 操作(たとえば、非同期のファイル読み書きやネットワーク通信)をサポートする基本的なソフトウェアコンポーネントを指すことが多い。
具体的には、Tokio に含まれる非同期のソケット、ファイル、タイマーなどが I/O プリミティブの例となる。
ストリームとは
(DeepL 和訳)
コンピュータ・サイエンスにおいてストリームとは、時間の経過とともに利用可能になる一連のデータ要素のことである。ストリームは、大きなバッチではなく、一度に一つずつ処理されるベルトコンベア上のアイテムと考えることができる。
ストリームは、バッチ・データとは異なる方法で処理される。通常の関数は、ストリーム全体に対して操作することができない。なぜなら、ストリームには無限のデータが存在する可能性がからである。ストリームを操作して別のストリームを生成する関数はフィルタと呼ばれ、関数の合成と同様にパイプラインで接続することができる。フィルターは、一度にストリームの 1 つの項目を操作することもあれば、移動平均のように複数の入力項目に基づいて出力することもある。
バイトストリーム
バイトストリームとは、データが連続的なバイトのシーケンスとして流れることを指す。Tokio の文脈では、非同期I/Oに関連したトピックであるため、バイトストリームは非同期に読み書きされることを意味している。
具体的には、ネットワーク接続やファイルなどの I/O ソース/シンクから、バイトの連続的な流れを非同期に読み取ったり、そのようなソース/シンクにバイトを非同期に書き込んだりすることを指す。
Tokio の非同期 I/O のためのトレイト(AsyncRead
と AsyncWrite
)は、このような非同期のバイトストリーム操作を抽象化し、様々な非同期 I/O ソースやシンクで共通のインターフェースを提供することを目的としている。
- Node.js の stream の仕組みまとめ
- DynamoDB はバッチ処理よりストリーム処理との相性が良いという話
- ほぼ関係ないけど気になった記事のメモ
非同期タスクにおけるメモリ使用量の注意
これ重要。
分かりにくい文章になってしまったと思うので補足させてください。
ここでは、バッファの確保を [u8; 1024] (スタック上に確保される配列型)で行うよりも Vec<u8> (ヒープ上に確保されるベクタ)で行ったほうが効率が良い、ということを説明しています。その理由として、以下の2つが挙げられています。a) スタック配列だと .await をまたぐときに全体のムーブが必要になること(ベクタであればヒープへのポインタ + 補助的な僅かなデータをムーブするだけで済む)
b) スタック配列の場合、タスクを表す構造体がバッファデータをすべて含むことになるため、構造体サイズが大きくなってしまうこと(ベクタであればヒープへのポインタ + 補助的な僅かなデータ を含むだけでよく、軽量に保てる)
非同期プログラミングとメモリの確保場所(スタック vs ヒープ)に関する高度なトピック。特にRust の非同期コードの文脈において、どのようにデータがメモリ上で配置され、どのようにそれが非同期タスクの性能やメモリ使用量に影響するかについて説明している。
簡単に言えば、非同期タスク内で大きなデータ構造を持つと、その非同期タスクが await したりスレッド間を跨ぐとき、データ構造全体をコピーする必要があるため、パフォーマンスの低下やメモリの無駄遣いを引き起こす可能性がある。このコピーオーバーヘッドは、データがスタック上に確保されている場合、特に顕著になる。
逆に、ヒープ上にデータを確保すると、非同期の「境界」をまたぐときにデータの実際の内容をコピーする必要はない。代わりに、ヒープ上のデータへのポインタだけがコピーされるため、オーバーヘッドが少なくなる。つまり、スタック領域でなくバッファ専用のアロケーションを都度行ったほうが効率が良い。
サンプルコード
- ポイント
- バッファの確保を [u8; 1024] (スタック上に確保される配列型)で行うよりも Vec<u8> (ヒープ上に確保されるベクタ)で行ったほうが効率が良い。つまり、このケースではスタック領域でなくバッファ専用のアロケーションを都度行ったほうが効率が良い。
use tokio::io::{self, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> io::Result<()> {
let mut listener = TcpListener::bind("127.0.0.1:6142").await.unwrap();
loop {
let (mut socket, _) = listener.accept().await?;
tokio::spawn(async move {
let mut buf = vec![0; 1024];
loop {
match socket.read(&mut buf).await {
// `Ok(0)` が返ってきたらリモート側が閉じられたことを意味する
Ok(0) => return,
Ok(n) => {
// データをソケットへとコピーする
if socket.write_all(&buf[..n]).await.is_err() {
// 予期しないソケットエラーが発生した場合。
// ここで何かできることはさほどないので、処理を停止する
return;
}
}
Err(_) => {
// 予期しないソケットエラーが発生した場合。
// ここで何かできることはさほどないので、処理を停止する
return;
}
}
}
});
}
}
Q. なぜ TcpStream を BufWriter でラップし、中間バッファを作ったのか?なぜ中間バッファが必要なのか?
ここで利用している関数は AsyncWriteExt トレイトが提供しているものです。これらは TcpStream でも利用可能ですが、中間バッファなしに単一のバイト書き込みを行うのは賢明ではありません。
引用元↓
A. 非効率的なネットワーク I/O 操作を避けるため。
中間バッファが必要な主な理由は、非効率的なI/O操作を避けるためである。直接バイトを書き込む場合、各バイト書き込みに対してシステムコールが発生し、これにはオーバーヘッドが伴う。特に、非同期 I/O の場合、各書き込み操作がタスクの切り替えを引き起こす可能性があり、これがパフォーマンスのボトルネックになる可能性がある。
中間バッファを使用することで、いくつかの主な利点がある:
- バッファリング: 小さな書き込み操作をバッファに蓄積し、一度に大量のデータを書き込むことで、システムコールの数を減らすことができる。
- 効率的なメモリ使用: 一度に大量のデータを送信する場合、中間バッファを使用すると、メモリ使用量を効率的に管理することができる。
- ブロッキングの防止: 非同期 I/O では、バッファを使用することで、I/O 操作のブロッキングを防ぐことができ、全体のシステムのパフォーマンスを向上させることができる。
したがって、中間バッファを使用することは、I/O 操作の効率とパフォーマンスを向上させる重要な手段である。
注意:flush の呼び出し
ただし、mini-redis チュートリアルでは Connection.write_frame()
メソッドの最後に毎回 self.stream.flush().await
を実行しており、フレームをソケットへ書き込むたびにバッファの中身を毎回ソケットへ書き込んでいるため、実質的には意味がない。
flush().await
を毎回呼び出すと、バッファの内容が毎回出力ストリームにフラッシュされる。これにより、出力操作の効率が大幅に低下する可能性がある。
通常、バッファリングされた出力は、バッファが一杯になった時点で自動的にフラッシュされる。しかし、flush().await
を使用すると、その時点でバッファの内容が強制的にフラッシュされ、出力ストリームに書き出される。これは、特定のタイミングでデータを確実に出力する必要がある場合に役立つが、頻繁に使用するとパフォーマンスのオーバーヘッドが発生する。
したがって、flush().await
の呼び出しは、必要な場合にのみ行うことが重要です。不要な場合は、バッファが自動的にフラッシュされるのを待つか、またはバッファのサイズを調整して、適切なタイミングでフラッシュされるようにすることが効率的である。
Redis で使用される通信プロトコル RESP(Redis Serialization Protocol)
概要
(DeepL で翻訳)
Redis シリアライゼーションプロトコル(RESP)は、クライアントが実装するワイヤプロトコルである。
Redis サーバーと通信するために、Redis クライアントは REdis Serialization Protocol(RESP)と呼ばれるプロトコルを使用する。このプロトコルは Redis 専用に設計されたものだが、他のクライアント・サーバー・ソフトウェアにも使用できる。
RESPは、以下の考慮事項の妥協点である:
- 実装が簡単
- 解析が速い
- 人間が読める
RESP は、整数、文字列、配列などのさまざまなデータ型をシリアライズ(メモリ→ネットワーク)することができる。エラー固有の型もある。クライアントはリクエストを文字列の配列として Redis サーバに送信する。配列の中身は、サーバーが実行すべきコマンドとその引数である。サーバーの応答型はコマンド固有。
RESP はバイナリセーフで、バルクデータの転送に前置長を使用するため、あるプロセスから別のプロセスへ転送されるバルクデータを処理する必要はない。
RESP は Redis クライアントに実装すべきプロトコルである。
注意:
RESP プロトコルは Redis サーバ・クライアント間の通信に使用されるものである。Redis Cluster はノード間でメッセージを交換するために RESP とは異なるバイナリプロトコルを使用する。
RESP の整理
- RESP, Redis Serialization Protocol
- Redis サーバ・クライアント間の通信に使用される Redis 専用のプロトコル。
- TCP/IP におけるアプリケーション層のプロトコルであり、主に TCP 上で使用される。
- リクエスト / レスポンスモデル
RESP で使用されるデータ型(mini-redis で Frame として定義されているもの)
RESP の主なデータ型(フレーム):
-
単純文字列 (Simple Strings)
-
+
で始まる - エラーではない文字列の情報を示す
- 例:
+OK\r\n
は、多くの Redis コマンドの成功を示す標準的な応答
-
-
エラー (Errors)
-
-
で始まる - エラーメッセージを示す
- 例:
-ERR unknown command 'foobar'\r\n
-
-
整数 (Integers)
-
:
で始まる - 32ビット符号付き整数を示す
- 例:
:1000\r\n
-
-
バルク文字列 (Bulk Strings)
-
$
で始まり、バイト数が続く - 特定のバイト数の文字列を示す
- 例:
$6\r\nfoobar\r\n
は "foobar" という6文字の文字列を示します - バルク文字列が存在しない場合は
$-1\r\n
となります
-
-
配列 (Arrays)
-
*
で始まり、続く要素数が続く - その後、要素ごとにRESPデータ型が続く
- 例:
*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n
は "foo" と "bar" という2つのバルク文字列からなる配列を示します - 配列が存在しない場合は
*-1\r\n
となります
-
RESPは、クライアントとサーバ間でコマンドを交換するためのプロトコルとして設計されている。クライアントは、コマンドとその引数を配列としてサーバに送信する。サーバは、応答として単純文字列、エラー、整数、バルク文字列、または配列を返すことができる。
このような形式は、Redis のようなキー-バリュー型のデータストアに特に適しており、シンプルさと効率性を兼ね備えている。
Redis の仕組み
Rust の非同期ランタイムモデル
Rust の Future に関する資料
- Rust の Future について
- What are Tasks? (Tokio)
- (2019/07) Rust の未来いわゆる Future
- (Zenn, 2022/10) 「Futures Explained in 200 Lines of Rust」を読む
- Understanding Rust futures by going way too deep
-
読み切ったらRustのFutureを完全に理解できそう。驚異の107 minute read😳/Understanding Rust futures by going way too deep
-
- (2018/02) Rust における future と gRPC(和訳記事)
- (2019/06) AWS Kinesis ひたすら読みにいく k-iter の複数 shard 対応(と、Rust の Future/Stream/Tokio 話) Programming Rust AWS
- (2020/09, Qiita) Rust でお気楽非同期プログラミング