Rust の勉強
次らへんをやりたい
まずは基礎の勉強し直し、ということで Welcome to Comprehensive Rust をやる
Day 1
AsRef の使い所。関数の interafce の微妙な差異を吸収できる。&str と String, Vec と slice の両方を受け取りたい時に使える。
const と static の違い。const はコンパイル時にインライン展開される。static は、インライン展開されず、プログラムが実行中はずっと存在する値。Object の同一性が必要なときは static を使うのがよく、それ以外は基本は const を使うべきとのこと。static は singleton とかに使うのかな。
Rust は再宣言でき、以前の値にアクセスできないようにできるらしい。この機能をシャドーイングという。unwrap() とよく使うと書かれていて、確かに Biome のコードでも見たかもと思った。以下のブログが参考になる。
let else 構文が 1.65 から使える。便利そう
Day 2
Rust の String はヒープメモリにアロケートされる。裏では Vec を使っている。
Copy と Clone は明確に違う。
- Copy は挙動をカスタマイズできない (Clone は Clone トレイトを実装できる)
- Copy は Drop トレイトを実装している type には使えない
- String は Copy が使えない良い例
次の内容もわかりやすかった。
Copy 複製は暗黙的に行われる。マーカートレイトの一つで、ビット列のコピーが行われる。Sallow copyとなるため、参照をうまく扱えない。
Clone 複製は明示的に行う必要がある。cloneメソッドを実装することで、コピー内容を変更できる。ただしCopyトレイトを実装する場合は、*self を返すようにする。大抵の場合においてdeep copy。
マーカートレイトなのでderiveでの実装が可能。またCloneのサブトレイトであるので、Cloneも指定する。
deriveでの実装が可能。ただしジェネリクスの際は、型パラメーターTがCloneトレイトを実装していないと対象にならない。
スライスを使うためには Deref を実装する必要がある。スライスは関数の引数でよく使う。
Box の使いどころ
- コンパイル時にサイズがわからない型があるが、Rustコンパイラは正確なサイズを知りたがっている。
- ⇒ 再帰とか?
- 大量のデータの所有権を譲渡したい。スタックに大量のデータをコピーしないようにする。
Cell / RefCell は内部不変性のために使う。内部可変性というのは、外からは不変に見えたままだが、内部で可変に扱うこと。次の例は、内部不変性のユースケースがわかってよかった。
Cell と RefCell の違いについては、次にもまとまっていた。まとめると Cell のほうが Copy トレイトを実装していないと使えないといった不自由さはあるが安全という感じ。RefCell は、扱えるデータ型は増えるが、わずかにオーバーヘッドがあるのと panic する可能性もある。
長くなってきたので分ける
Day 3
ゼロコスト抽象化や単相化に付いては次のブログに詳しく書いてある
トレイトは、他のメソッドを使って定義できる。 super Trait というのもある。
trait Equals {
fn equals(&self, other: &Self) -> bool;
}
// super trait
trait NotEquals: Equals {
fn not_equals(&self, other: &Self) -> bool {
!self.equals(other)
}
}
ただ、super Trait よりブランケット実装をすることのほうが多そう。
trait NotEquals {
fn not_equals(&self, other: &Self) -> bool;
}
// ブランケット実装
// T トレイトを実装するすべての型に NotEquals を実装する
impl<T> NotEquals for T where T: Equals {
fn not_equals(&self, other: &Self) -> bool {
!self.equals(other)
}
}
Closure に関するトレイト
- Fn: capture した値を mutate も consume もできない
- FnMut: capture した値を mutate はできる
- FnOnce: 一度きり呼ばれる関数で、cpature した値を consume できる
最後の方は流し読んだ。エラーのところは、thiserror , anyhow とかにも触れていて良かった。unsafe な Rust は ffi 使う時によく使いそうだけど、それまでは知らなくて良さそう。
次は並行性
スレッドに関しては、OSのスレッドと言語側でスケジュールするスレッドの2つを意識する必要がある。
言語スレッドとOSスレッドのマッピングについて
- N:1 の場合: Java での green thread に対応する。OS のCPUコアが少ないときなどに重宝された
- 1:1 の場合:Java での native thread に対応する
一般的に、言語側でスケジュールするスレッドを green threads と呼ぶことが多い。ちなみに、Rust の thread::spwan は OS のスレッドを言語側で 1:1 で起動する。一方で、Go の goroutine (軽量スレッド) は、M:N で起動する。
チャネルとは、スレッド間でデータを送受信するための API のこと。
Rust でスレッドを扱うなかでよく見る trait の Send / Sync はそれぞれ次の意味を持つ。
Send: スレッド境界をまたいでの型Tのムーブが安全に行える場合、型TはSendである。
Sync: スレッド境界をまたいで&Tのムーブが安全に行える場合、型TはSyncである。
多くの既存の型は Send + Sync になっていてスレッドセーフに値を move できる。
- Send + !Sync:別のスレッドにムーブすることができるが、ムーブがスレッドセーフではない
- Cell / RefCell のような内部可変性を持つ場合に起こる
- !Send + Sync:スレッドセーフであるが、別のスレッドにムーブすることはできない
スレッド間でデータを共有するための型には次の2つが存在する
- Arc<T>:スレッド間で値の read only の参照を共有し、参照カウントがなくなったら drop する
- Mutex<T>:値への排他的な相互アクセスを保証する
Arc::new(Mutex::new(.... のパターンは、スレッド間での可変の共有データを保持するときに一般的なパターンである。
Rust の 非同期関数
Rust の async 関数は impl Future を返す。Future の定義は次のようになっている。
use std::pin::Pin;
use std::task::Context;
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
pub enum Poll<T> {
Ready(T),
Pending,
}
型定義みると Poll とかはかなり Promise っぽいなと感じる。Rust では Future は定義されているが Future を実行するランタイムについては、Rust 本体に導入されていない。そのため、一般的に tokio などのライブラリに依存することになる。
Future の実行方法には、まとめて並列実行する join というメソッドや select というメソッドがある。
join は Promise.all に、select は Promise.race に似ている。join は、nightly で Rust の std にも実装されている。
多くの非同期ランタイムの実装では、I/O タスクしか並列で実行されないことに注意する。CPU タスクも並列実行する場合には、tokio のメソッドを使うとよい。ちなみに、tokio を使った場合の並列処理は、それぞれが OS のスレッドに1対1で実行されているわけではないことに注意する。(Greenn thread を使っている)
非同期処理のキャンセルについて
- Future (async 関数の返り値) は drop することでキャンセルできる
- キャンセルが起きるポイントは
.awaitがされている場所 - .await の間の処理や最後の .await の処理ではキャンセルは起こらない
- キャンセルが起きるポイントは
- 実際のランタイムでは JoinHandle を drop することではキャンセルできない
- タスクをキャンセルするには JoinHandle::cancel, JoinHandle::abort を呼ぶ必要がある
MaybeUnInit
Rust は初期化されてない変数を関数に渡したりできないようになっているが、あえてそのようなことしたいときに使える。
Specialization
Rust の trait は同じ型に対して、複数の impl ブロックを持つことができない。つまり、他の言語の method の override のようなことができない。
use std::fmt::Display;
trait ToString {
fn to_string(&self) -> String;
}
// ジェネリックな実装
impl<T: Display> ToString for T {
fn to_string(&self) -> String {
todo!("generic implementation");
}
}
pub struct Sample;
impl Display for Sample {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
todo!("some implementation");
}
}
// Sample に最適化された to_string() を定義したいが、generics の実装と conflict してエラーがでる
// conflicting implementations of trait `ToString` for type `Sample`
impl ToString for Sample {
fn to_string(&self) -> String {
todo!("specific implementation");
}
}
これを可能にするのが、Specialization という RFC である。現在は、nightly には実装が入っていて、次のような記法で実装できる。RFC では、パフォーマンス面での優位性がかなり主張されていて、その気持ちはあまりわからなかったんだけど、実装のときに便利そうではある。
// ジェネリックな実装
impl<T: Display> ToString for T {
// default をつけて区別する
default fn to_string(&self) -> String {
todo!("generic implementation");
}
}
Manually Drop
ManuallyDropは、コンパイラに自動で値の破棄 (drop) をさせたくない場合に使う。drop の順番を変化させたいときとかにも使える。
コンパイラに自動で
通常は、値がスコープを出るときに Dref トレイトで実装された drop 処理を実行する。
Tokio のチュートリアル
Rust の非同期処理でよくみるタスクは、以下で説明されている
タスクは、スケジューラによって管理される実行単位です。タスクを spawn すると、Tokio のスケジューラにそれが登録され、適切なタイミングでタスクが実行されることが保証されます。
Redisのインメモリデータベースをプロセス間で共有する方法
- 同期的な解決:Mutex を利用して共有ステートを「ガード」する
- 非同期的な解決:ステートを管理するためのタスクを spawn し、メッセージの受け渡しによりステートを操作する
経験則としては、非同期のコードの中で同期的な mutex を使ってもよいケースは、競合[1]がそれほど多くなく、かつロックが .await をまたいで保持されないような場合に限られます
競合が大きく同期的な mutex が問題になる場合は、tokio の mutex などを使っても問題は解決しない。非同期的な解決方法を試すか、「mutex をシャーディングする」「mutex を使わずに済むようにコードを再構築する」などの対応をとる必要がある。
「mutex をシャーディングする」という方法については、dashmap という便利な crate がある。
mutex と async 関数には、次のような一般的なデザインパターンがある。
use std::sync::Mutex;
// .await を超えて Send が実装されてない値を扱うことができないため、
// 次のコードはコンパイル時にエラーになる
async fn increment_and_do_stuff(mutex: &Mutex<i32>) {
let mut lock = mutex.lock().unwrap();
*lock += 1;
do_something_async().await;
} // ここで lock が drop される
// そのため Mutex をラップした構造体を作るのが一般的である。
struct CanIncrement {
mutex: Mutex<i32>,
}
impl CanIncrement {
// この関数は `async` ではない
fn increment(&self) {
let mut lock = self.mutex.lock().unwrap();
*lock += 1;
}
}
async fn increment_and_do_stuff(can_incr: &CanIncrement) {
can_incr.increment();
do_something_async().await;
}
vec![0; 4096] は4096バイトの配列を確保し、それらを0で埋めます。バッファの大きさを拡張するときには、拡張された分の領域も0で初期化されます。この初期化のコストはタダではありません。しかし、BytesMut および BufMut を使うと、バッファのキャパシティは 未初期化 になります。
なるほど。こういう時に myabeuninit が使われたりしているかもしれない。
他の言語における "future" の実装のされ方とは異なり、Rust の "future" はバックグラウンドで実行されている計算のことを表してはいません。そうではなく、Rust の "future" は、計算そのもの なのです。"future" の所有者は、"future" に対してポーリングを行うことによって、計算を進める責任を負っています。これは Future::poll を呼び出すことによって行われます。
こういうの言語化されているのありがたい。
Rust の非同期処理の流れのまとめ
- Rust の非同期処理は "lazy" であり、処理の呼び出し側 (executer) が poll を行う必要がある
- poll したときの処理を実際に行う対象は reactor と呼ばれ、waker と呼ばれるメッセージをやり取りするチャネルでやり取りする。
- executer が future を poll すると、reactor に処理を開始するメッセージが送られる。
- future は一時的にここでは Poll::Pending を return する
- reactor が対象の処理を実行し、完了すると waker を使って executer にメッセージを送る
- executer はメッセージを受取り、再び future を poll する
- future は、ここでは処理結果とともに Poll::Ready を return する
Futures Explained in 200 Lines of Rust
バックグラウンド
OS スレッドの整理
- シンプルで簡単に利用できる
- タスクの切り替えが早い
- 多くの stack 領域を使用し、メモリリークのおそれがある
- システムコールをたくさん呼ぶため、作成にコストがかかる
Green thread は、OS スレッドと同じようにシンプルに扱うことができつつも、メモリの消費量やコンテキストスイッチのコストを削減できる手法。Rust の初期のバージョンでは、green thread は導入されていたが、ゼロコスト抽象化ではないため外されたらしい。
There's one difference you should know. JavaScript promises are eagerly evaluated. That means that once it's created, it starts running a task. Rust's Futures on the other hand are lazily evaluated. They need to be polled once before they do any work.
Rust の Future と JS の Promise の違いは、タスクの実行タイミング。JS は eager (作られたらすぐ実行される)一方で、Rust の Future が lazy (作られても明示的に poll しないと実行されない)
Future にについて
Future におけるタスクは Poll / Wait / Wake の3つの状態をとる。
- Poll: Future がポーリングされ、タスクが進行できなくなるまで進行している状態。
- Future をポーリングするランタイムを executerと呼ぶ。
- Wait: Future がイベントの発生を待っている状態
- イベントソース (reactor) が状態を登録し、イベントの準備ができたときに wake する
- Wake: イベントの準備ができ、再度 Future を Poll する状態
- Poll した結果により、Future を完了するか、再度進行させるかどうかが決まる
Runtimes
Rust は、他の言語と異なって、言語自体に非同期のランタイムが含まれていない。
非同期ランタイムは、大きく Reactor、Executor、Future の3つの要素に分解できる。これらは waker というオブジェクトを通じて関連づけられる。
- executer によって waker が作成される
- future は waker を clone し、それを reactor に渡す
- reactor は waker を使って、future を wake する
I/O・CPU bounded なタスク
非同期タスクの中でスレッドをブロックするような処理を呼び出さない方が良い。これをすると同じスレッドで実行されている非同期タスクの実行もブロックされてしまう。
async fn block_fn(...)> {
let s = std::fs::read(path)?; // ここでブロックしてしまう!
}
このような処理は、tokio::fs のような非同期に対応した関数を使うか、spawn_blocking を使って呼び出すのが良い。メインの処理が行われるスレッドとは別のスレッドで処理が行われる様になる。
generator / async の関連性
並行性を扱うためのデザインパターンとして、次の3つが主な選択肢になる。
- stackful な coroutines: いわゆる green threads
- combinators: JS の Promise のメソッドチェーンのようなやつ
- stackless な corooutines: いわゆる generator
generator の yield は、メソッドを呼び出すと内部状態が変化する点で async の poll と似ている。Rust では、コンテキストスイッチが無いことやメモリ効率が良いことなどから、stackless な corooutines が広く採用されている。
tokio のチュートリアルは、非同期処理についてかなり幅広く解説されていて良かった。
Futures Explained in 200 Lines of Rust は、Pin 周りで出てきたメモリの話がいまいちわかってないけど、一旦ここまでにしておく。
Fat Pointer とは...?
Fat pointerとは、「アドレス+制御情報」から構成されるポインタのこと。通常のポインターは、CPU が扱える bit 数と同じ大きさでアドレス情報のみを表現する。例えば、32bit の CPU だと 4 bytesで構成される。一方で Fat pointer は、通常の pointer の倍で構成される。
Rust だと、スライスや trait object などは fat pointer で表現される。ちなみに trait object は、fat pointer に vtable という情報を保持していて、これが動的ディスパッチを実現する。vtable とは、vtableはメソッドの名前と実装コードのアドレスが対になった辞書で、vtableを検索することで呼び出そうとしているメソッドの実装を見つけることができる。
動的ディスパッチは、メモリも多く専有するし、メソッドを vtable から探す分、パフォーマンスも悪くなることがよくわかった。