テーブルアクセスメソッドの構築、パート3:Postgresの共有メモリ
Postgresに手書きでテーブルアクセスメソッド(TAM)を書く連載第3回。3つの共有メモリ機構の選び方、DSAのポインタ間接参照がなぜシーケンシャルスキャンを4〜5倍遅くするのか、pinするのはセグメントかマッピングか、そして_PG_initとshmem_startup_hookの役割分担。
日本語
コピー
テーブルアクセスメソッドを作る 第3回:Postgres の共有メモリ
第2回では CREATE TABLE ... USING memtable に必要な TAM コールバックを繋いだが、まだストレージが存在しないので、INSERT、DELETE、UPDATE、SELECT を動かしても意味がない。pg_memtable はテーブルデータを共有メモリに置くので、pg_memtable 関連のコードを一行でも書く前に、Postgres が backend 間でどうやってメモリを共有しているのかを押さえておく価値がある。
この記事では、Postgres が実装している共有メモリの方式と、それぞれの長所短所を紹介する。その後、DSM システム——pg_memtable の実装で使うことになる——に焦点を当て、共有メモリセグメントの作成方法、常駐させる方法、そしてシステムに返却する方法を順に見ていく。
Postgres における共有メモリの3つの方式
Postgres はデータを共有メモリに置く方法を3つ提供しており、それぞれ別の問題を解決する。
従来の共有メモリは最も単純で、postmaster の起動時に固定サイズのセグメントを一度だけ確保し、_PG_init の間に RequestAddinShmemSpace で申請する。各 backend は fork の時点で自動的にこれを継承するので、attach の手順も、backend ごとのセットアップコストもない。代償は、最初の backend が起動する前にサイズを確定しなければならないことだ。拡張も解放もできず、誰も使っていなくてもサーバーのライフサイクル全体にわたって領域を占有し続ける。backend 間の通信に小さな共有メモリを少し使いたいだけなら非常に便利だが、汎用の共有メモリアロケータとしては物足りない。
動的共有メモリ(DSM) セグメントは逆の性質を持つ。実行時に dsm_create/dsm_destroy で作成・破棄し、サイズは用途に応じて決め、backend が明示的に dsm_attach を呼んだときにだけそのアドレス空間にマップされる。事前に何かを予約する必要はなく、それぞれ独立したライフサイクルを持つセグメントを同時にいくつでも存在させられる。
動的共有メモリ領域(DSA) は DSM の上に成り立つ。dsa_area は汎用アロケータで、dsa_allocate/dsa_free を提供し、1つ以上の DSM セグメントの内部で動作し、必要になればセグメントを追加で attach して拡張する。共有メモリ版の palloc/pfree だと思えばいい。小さく、サイズも解放も独立したオブジェクトを大量に扱うのに向いている。Postgres 自身も内部でこれを使い、パラレルハッシュ結合や dshash テーブル、その他本当に動的な確保が必要な構造を実現している。ただし便利さにはコストが伴う。アクセスのたびにアロケータ自身の管理処理を通り、さらに間接参照が1段増える。ポインタを辿るたびに dsa_pointer を実アドレスに解決しなければならないからだ。
pg_memtable の実装は当初 DSA を使う路線だった。以前のバージョンではタプルを DSA 上の赤黒木に格納しており、順次スキャンが4〜5倍遅かった。原因はまさにこの間接参照で、木を1段辿るたびに dsa_pointer を1つ余分に解決する必要があった。解決策は DSA を完全に捨て、生の DSM セグメントを直接使い、バイトオフセットでアクセスしてアロケータの間接参照を一切挟まないことだった。
DSM セグメントの作成と共有
DSM セグメントの作成と共有では、押さえておくべき関数が4つある。
extern dsm_segment *dsm_create(Size size, int flags);
extern dsm_segment *dsm_attach(dsm_handle h);
extern void dsm_detach(dsm_segment *seg);
extern dsm_handle dsm_segment_handle(dsm_segment *seg);
DSM セグメントの作成は単純で、dsm_create を必要なサイズとともに呼ぶと dsm_segment* が返る。これはセグメント情報へのポインタで、セグメントのベースアドレスなどを取得するのに使える。ただしこれは backend ローカルのポインタであり、そのアドレスは現在の backend のアドレス空間でしか意味を持たないため、他の backend には渡せない。セグメントを他の backend に渡すには セグメントハンドル を渡し、受け取った側が dsm_attach(handle) を呼べば自分の backend 用のセグメントポインタを得られる。
dsm_handle は実質ただの uint32 なので、通常は他の backend へ簡単に渡せる。よくあるのは、起動時に固定サイズの従来型共有メモリを少し予約しておき、そこにハンドルを置く方法だ。各 backend は fork の時点でこの予約領域をすでにマップしているので、ハンドルの発見に困らない。
pg_memtable ではこれは不要で、pg_memtable はハンドルを backend 間で共有する別の手段を使っている。これは次回のテーマだ。
最後の呼び出しは dsm_detach で、backend をセグメントからデタッチし、そのセグメントを使っている backend が他になければ Postgres が解放できるようにする。
セグメントの pin と unpin
新しく作った DSM セグメントは、デフォルトでは自動的に生き残らない。ほとんどの Postgres リソースと同様、作成した resource owner に紐づいており、resource owner が消えると Postgres が自動的に片付けてしまう。
これを避けてセグメントを無期限に残したい場合は、pin する。dsm_pin_segment でそのライフサイクルを pin するか、dsm_pin_mapping で現在の backend におけるマッピングを pin するかだ。この2つは別の問題を解決する。
dsm_pin_segmentはセグメントのライフサイクルを backend から切り離す。これがないと、作成した backend が終了した時点でセグメントも消える。dsm_pin_mappingは 現在の backend におけるセグメントのマッピングを扱う。これがないと、dsm_attachが作ったマッピングはトランザクション終了時に resource owner に片付けられる。基盤のセグメントは生きているが、backend 内の次のステートメントで再びdsm_attachを呼ぶ必要がある。
後戻りできるのは dsm_pin_segment だけだ。dsm_unpin_segment がこの pin を解放し、そのセグメントを使う backend がなくなれば Postgres が片付ける。pin したマッピングには対応する unpin 呼び出しはなく、backend が生きている限りその backend 内では pin されたままになる。
pin したセグメントは最終的に 何かが 明示的に unpin しないと、サーバーのライフサイクルが終わるまでリークし続ける点に注意してほしい。以降の記事で pg_memtable 用のストレージを作成・破棄する際、これは覚えておく価値がある。
_PG_init と共有メモリ起動フック
_PG_init については以前の記事で簡単に触れた。Postgres が共有ライブラリをロードする際に自動で呼び出す関数だ。shared_preload_libraries に挙げたライブラリでは、この呼び出しはごく早い段階で起きる。postmaster が CreateSharedMemoryAndSemaphores で共有メモリの設定を終える前のことだ。この時点では実際の共有状態に触れることはまだできないが、Postgres には2つ目のフック shmem_startup_hook があり、これは共有メモリサブシステムの構築後に実行される。まさにこうした初期化のために用意されている。
pg_memtable 自身も共有メモリの初期化が必要で、_PG_init からインストールする memtable_shmem_startup_hook 関数を使っている。
static shmem_startup_hook_type prev_shmem_startup = NULL;
void _PG_init(void) {
...
prev_shmem_startup = shmem_startup_hook;
shmem_startup_hook = memtable_shmem_startup_hook;
...
}
prev_shmem_startup は他の拡張がすでにインストールしたフックを(あれば)保存しておく。こうして memtable_shmem_startup_hook はそれを黙って置き換えるのではなく、チェーンできるようになる。
Postgres における共有メモリロック
共有メモリが複数の backend から同時に書き換えられるのを防ぐには、何らかのロックが要る。ありがたいことに Postgres は LWLock(“LightWeight Lock”、軽量ロック)をサポートしている。
とはいえ、デバッグと性能の両面から、これらのロックを監視できると便利だ。ロックを種類ごとにまとめ、pg_stat_activity で意味のある情報として表示できるようにするのも同様に有用である。
そのために LWLockNewTrancheId で共有の LWLock tranche 識別子 を作り、LWLockRegisterTranche で名前を登録する。使うのは先ほど導入した shmem_startup_hook だ。
static int MemTableArenaTranche = -1;
static void memtable_shmem_startup_hook(void) {
if (prev_shmem_startup)
prev_shmem_startup();
MemTableArenaTranche = LWLockNewTrancheId();
LWLockRegisterTranche(MemTableArenaTranche, MEMTABLE_EXTNAME "_arena");
}
以降の記事で、各 arena ロックをこの tranche に関連付けていく。そうすれば正しい名前でそこに現れるようになる。MemTableArenaTranche 自体はただの int で、起動時に一度保存し、その後は arena ヘッダを初期化するたびに読み出す。
次の記事
次回は、pg_memtable テーブルの作成と削除に伴って共有セグメントをどう確立し、解体するかを扱う。