GhostCell 論文を読む
GhostCell という、shared reference と mutability のジレンマを解消するデータ構造を扱った論文が出てきた。ので、読む。
雑にアイディアを説明すると、「任意のID (Rust のライフタイムを利用する)を発行して、その中だったら安全に borrow / borrow_mut できる仕組みを作っちゃおう」という話。これを利用すると、ゼロコストで interior mutability を実現できる。コンパイルタイムで borrow できるかどうかの検証が走るので、RefCell
とかより安心して使える上に、パフォーマンスもいい。
雑に論文のポイントを整理すると、
- GhostCell<'id, T> と GhostToken<'id> という2つの型を用意する。
- GhostCell は T への shared reference を表現する。
- GhostToken は T というデータに対する権限(permission)を表現する。
- 実際は GhostCell のもつ borrow や borrow_mut に GhostToken が投げ込まれて、メソッドのライフタイムをブランドとは別個で定義できるようなマーカーとして使用されるイメージ。
- 実装を読むとわかりやすい: https://gitlab.mpi-sws.org/FP/ghostcell/-/blob/master/ghostcell/src/lib.rs
- 2つの異なる型に対して、ライフタイム
'id
をもたせる。これは後述するがbrand
と呼ぶ。- これは実際に使用されるライフタイムではない。
- いわゆる「幽霊ライフタイム(Phantom Lifetimes)」というものに該当する。
あとは実装を読んでみるかあという気持ち。結構コメントが詳しく書いてある。
構成
- 1章はモチベーションに関する話。
- 2章はBeingessner[2015]で提唱されている branded types を簡単に説明。
- 3章は
GhostCell
API を説明。doubly-linked list を再実装したサンプルを掲載。 - 4章は RustBelt を振り返ったあと、Coq で健全性を説明。
- 5章はパフォーマンスを検証。
- 6章は related work
GhostCell
そのものに興味がある人は3章を読むといい。branded types の前提知識が必要となるので、前提知識に不安のある人は2章も読む必要がありそう。
結果どうなったか、GhostCell
はなぜ速いのかみたいな話は5章を読むとよさそう。
4章は私は形式検証に関する知識がないので、説明はできなそう。他の詳しい日本語話者の人が解説してくれるのを期待しています。
一旦3章からまずは読む。GhostCell
は下記の2つの要素から構成される。
-
GhostCell<'id, T>
:T
型のデータそのものを表している。T
は brand type の'id
によって記しづけされている。Cell
型が共有される場合には、Cell
のもつ brand type'id
に対応するライフタイムをもつGhostToken<'id>
を使用してしか共有することができない。 -
GhostToken<'id>
: 同じ brand type'id
をもつGhostCell
のデータにアクセスする際に使用されるもので、データへのアクセス権限を調整する役割をもつ。
GhostCell
は、GhostToken
のもつ brand の範囲内であれば interior mutability を安全に確保できることを保証できる手法のように思われる。そして、その安全性の担保は Rust の Borrow Checker によってコンパイルタイムで行える。
brand と言っているが、実質なにかのタグと言ったほうがわかりやすいかもしれない。
私は TypeScript を書いている最中に Brand Type という概念を知りました。TypeScript の場合は構造的部分型が効いているので、フィールドとかが一致してしまえば同一の型とみなされてしまう問題があるが、それを別々の型として手軽に扱えるようにするための手法のひとつ。
Rust における branded types の実装を実現する際に大きな役割を果たすのが、Phantom Lifetimes を担う InvariantLifetimes
という構造体。
#[derive(Clone, Copy, Default)]
struct InvariantLifetime<'id>(PhantomData<*mut &'id ()>);
impl<'id> InvariantLifetime<'id> {
#[inline]
const fn new() -> InvariantLifetime<'id> {
InvariantLifetime(PhantomData)
}
}
中身はただの値のない PhantomData を保持させているが、ここに 'id
というライフタイムが付与されている。実質的に 'id
は brand type というものを示すことになる。
GhostToken
は、この InvariantLifetime
を中にもつ構造体。実装は new
関数のみをもっている。
pub struct GhostToken<'id> {
_marker: InvariantLifetime<'id>,
}
impl<'id> GhostToken<'id> {
#[inline]
pub fn new<F, R>(f: F) -> R
where
F: for<'new_id> FnOnce(GhostToken<'new_id>) -> R,
{
let token = GhostToken {
_marker: InvariantLifetime::new(),
};
f(token)
}
}
GhostCell
はこの GhostToken
を用いて、&T の取得と &mut T の取得を行えるように作られている。ポイントは、borrow
も borrow_mut
も &'a self
しか要求していないところだと思う。これによって、いわゆる interior mutability を実現できている。
#[derive(Default)]
#[repr(transparent)]
pub struct GhostCell<'id, T: ?Sized> {
_marker: InvariantLifetime<'id>,
value: UnsafeCell<T>,
}
impl<'id, T> GhostCell<'id, T> {
#[inline]
pub const fn new(value: T) -> Self {
GhostCell {
_marker: InvariantLifetime::new(),
value: UnsafeCell::new(value),
}
}
#[inline]
pub fn into_inner(self) -> T {
self.value.into_inner()
}
#[inline]
pub fn borrow<'a>(&'a self, _token: &'a GhostToken<'id>) -> &'a T {
unsafe {
&*self.value.get()
}
}
#[inline]
pub fn borrow_mut<'a>(&'a self, _token: &'a mut GhostToken<'id>) -> &'a mut T {
unsafe {
&mut *self.value.get()
}
}
}
- ちょっとわからない: GhostToken があると、なぜそうしたデータへのアクセス権限の調査に使えるのか?という点。
- 回答: ここに token をいれておくことで、「特定の brand 内に存在する GhostToken を使用できているか?」を常にチェックさせられるからだと思われる。
- なくてもコンパイル自体は通る。が、安全な貸し出しになるかどうかは保証できなくなってしまう。
'id
は、プログラムのレベルでは本当にただのマーカーというか、それ以上の役割はなさそう。形式検証のときとかに効いてくる感じなのかな。
さて、interior mutability というと思いつくものが Cell
や RefCell
だと思う。そっちとの旨味の差は一体なんなのか?というのが疑問として残る。私はちょっと疑問に思った。
いろいろ実装を眺めてみたところ、GhostCell
のおいしいところは、きちんとコンパイル時に borrow の検証をできることから、Borrow Checker を通じて interior mutability を実現できるところにあると思う。RefCell
は実行時に borrow できるかどうかを確かめるので、場合によっては実行時にパニックする。また、RefCell
を使用した箇所はゼロコストにならないので(要出典とチェック)、パフォーマンス上のオーバーヘッドが多少発生する。
RefCell
のオーバーヘッドを少したしかめておく。
RefCell
はご覧のように、BorrowFlag
というものを中に(モジュールに)持っている。value
部分については GhostCell と同じように
#[stable(feature = "rust1", since = "1.0.0")]
pub struct RefCell<T: ?Sized> {
borrow: Cell<BorrowFlag>,
value: UnsafeCell<T>,
}
BorrowFlag
は isize
のタイプエイリアス。モジュール内に UNUSED
という定数を保持していて、そのカウンタを見ながら borrow できるかどうかを後続の処理でチェックしている。
type BorrowFlag = isize;
const UNUSED: BorrowFlag = 0;
#[inline(always)]
fn is_writing(x: BorrowFlag) -> bool {
x < UNUSED
}
#[inline(always)]
fn is_reading(x: BorrowFlag) -> bool {
x > UNUSED
}
borrow する際には、一度 BorrowRef
という構造体を使って borrow できるかどうかのチェックをかけている。
// impl RefCell 内
pub fn borrow(&self) -> Ref<'_, T> {
self.try_borrow().expect("already mutably borrowed")
}
pub fn try_borrow(&self) -> Result<Ref<'_, T>, BorrowError> {
match BorrowRef::new(&self.borrow) {
// SAFETY: `BorrowRef` ensures that there is only immutable access
// to the value while borrowed.
Some(b) => Ok(Ref { value: unsafe { &*self.value.get() }, borrow: b }),
None => Err(BorrowError { _private: () }),
}
}
pub fn borrow_mut(&self) -> RefMut<'_, T> {
self.try_borrow_mut().expect("already borrowed")
}
pub fn try_borrow_mut(&self) -> Result<RefMut<'_, T>, BorrowMutError> {
match BorrowRefMut::new(&self.borrow) {
// SAFETY: `BorrowRef` guarantees unique access.
Some(b) => Ok(RefMut { value: unsafe { &mut *self.value.get() }, borrow: b }),
None => Err(BorrowMutError { _private: () }),
}
}
BorrowRef
内では Cell
を使用しているが、中で走る set
では std::mem::replace
が呼び出される。中では std::mem::swap
が呼び出されるが、ここでまずコピーが走る(ので、オーバーヘッドが少しある)。
というか、そもそもこのようなチェック機構が1ステップ増えることになるので、GhostCell
と比べるとオーバーヘッドがあるだろうし、何より実行時にしかこのチェック機構は走らないので、コンパイルタイムに安全に borrow できるのかどうかを判断できないという問題がある。
// borrow ref
struct BorrowRef<'b> {
borrow: &'b Cell<BorrowFlag>,
}
impl<'b> BorrowRef<'b> {
#[inline]
fn new(borrow: &'b Cell<BorrowFlag>) -> Option<BorrowRef<'b>> {
let b = borrow.get().wrapping_add(1);
if !is_reading(b) {
// Incrementing borrow can result in a non-reading value (<= 0) in these cases:
// 1. It was < 0, i.e. there are writing borrows, so we can't allow a read borrow
// due to Rust's reference aliasing rules
// 2. It was isize::MAX (the max amount of reading borrows) and it overflowed
// into isize::MIN (the max amount of writing borrows) so we can't allow
// an additional read borrow because isize can't represent so many read borrows
// (this can only happen if you mem::forget more than a small constant amount of
// `Ref`s, which is not good practice)
None
} else {
// Incrementing borrow can result in a reading value (> 0) in these cases:
// 1. It was = 0, i.e. it wasn't borrowed, and we are taking the first read borrow
// 2. It was > 0 and < isize::MAX, i.e. there were read borrows, and isize
// is large enough to represent having one more read borrow
borrow.set(b);
Some(BorrowRef { borrow })
}
}
}
さらにいうと、この GhostCell
自体に Send
と Sync
トレイトを実装できるので、スレッド間の所有権の送信やスレッド間のデータの共有ができるというところにメリットがありそう。RefCell
はたしかスレッドセーフじゃないはず。
GhostCell
の肝心の使い方だが、下記のコードがわかりやすいと思った。
GhostToken::new
で token を宣言しておく。GhostToken::new
はシグネチャは下記のようになっている。
impl<'id> GhostToken<'id> {
#[inline]
pub fn new<F, R>(f: F) -> R
where
F: for<'new_id> FnOnce(GhostToken<'new_id>) -> R,
{
let token = GhostToken {
_marker: InvariantLifetime::new(),
};
f(token)
}
}
ここがトリッキーだけど、払い出されたその token は実質 brand の保持のみに使用される。
borrow
や borrow_mut
などの操作は token の brand が正しいかをチェックしながら行われる。正しいうちは、特にオーバーヘッドなくそうした操作が行える。正しくない場合は、そもそもコンパイルエラーで落とされる。
最近この GhostCell をつかって実験をする機会があったのだが、その際におもしろい亜種をみつけた。その名も FrankenCell 。
GhostCell ではライフタイム識別子をブランドとして利用していたが、const generics でもいけるでしょというのがこの手法のアイディアにあたる。そして試してみた結果、どうやらワークしているようだ。
そのほかにはそもそも GhostCell 内に ID を表現するデータを持たせてしまう手がありそう。
crates.io には下記が登録されている。ほとんど論文を再現したものなので、とくに変更なく使える気がしている。
実際に使ってみて思ったが、GhostToken
を毎回何かの操作をしようとするたびに引数として求められると、いくつか制約が生まれてしまって実用的じゃない感じがする。
たとえば Debug トレイトが↑の実装には提供されていなかったので作ろうと思ったが、その際 borrow
して結果をデバッグさせようにも token
が引数として求められてしまう。が、Debug
トレイトの関数の都合上、token
を渡すことができない。
回避策としては get_mut
なる関数が生えており、それは token
を引数として求めないのでこれを利用することだと思った。が、今度は debug
関数は &self
なので、可変操作ができない。
というわけで、まず GhostCell::get
を下記で作り、
Debug
トレイトをようやく実装できた。
↑はどこかのタイミングで本体のリポジトリにプルリクエストを投げておこうと思う。
たとえば次のように #[derive(Debug)]
を追加することで、双方向リンクトリストの中身を書き出させることができるようになった。