PostgreSQLにはプロセス間でデータを共有する仕組みが備わっています。
PostgreSQLの拡張機能(Extension)を開発する過程で、動的に共有メモリを利用したくなったので、その使い方について紹介します。
調査で使用したPostgreSQLのバージョンは14.2です。
PostgreSQLの共有メモリ
PostgreSQLのメモリの種類は、PostgreSQL のメモリ管理関数の解説で説明されています。
プロセス間で共有可能なメモリは、Shared Memory、Shared BufferとDynamic Shared Memoryです。
- Shared Memory
先の解説記事に書かれている通り、PostgreSQLサーバ起動時にあらかじめ固定サイズの領域を確保して使用するメモリです。 - Shared Buffer
プログラマブルでなく、Extensionからの使用には適しません。 - Dynamic Shared Memory
今回このモジュールを使い、PostgreSQLサーバが稼働中に動的に共有メモリを確保する方法を解説します。
Dynamic Shared Memory
ソースコード src/backend/storage/ipc/dsm.c にdsmというモジュールが実装されています。
このモジュールではメモリ確保機能としてdsm_createという関数が提供されていて、使用するサイズを指定してセグメントと呼ばれる共有メモリ領域を作成します。
セグメントごとにハンドルが割り当てられていて、それを何らかの方法で別プロセスと共有することで、共通のメモリ領域に複数プロセスからアクセスできるようになります。
そのサイズでは足りなくなったら、再度dsm_createを実行して新たな領域を確保し、そのセグメントのハンドルを他プロセスと共有する必要があります。
そのためdsmは何度もメモリを確保するようなケースでの使用には適していなく、
このようなケース向けにDynamic Shared Memory Areas(dsa)というモジュールがあります。
Dynamic Shared Memory Areasとは
ソースコードでは src/backend/utils/mmgr/dsa.c に実装されています。
dsaはdsmを拡張したモジュールで、モジュール内部でdsmのセグメントの管理を行っています。
dsmセグメント内の使用領域・空き領域を管理していて、メモリ確保要求があった場合には、空き領域から割り当てを行います。
また、空き領域が不足した場合、新たにセグメントを作成してくれます。
このモジュールを使えば、最初に一回だけハンドルを他プロセスと共有するだけでよくなります。
このモジュールが提供する代表的な関数を以下に示します。
関数名 | 説明 |
---|---|
dsa_area *dsa_create(int tranche_id) | dsaを新たに作成する関数。 |
dsa_handle dsa_get_handle(dsa_area *area) | ハンドルを取得する関数。このハンドルを他のプロセスと共有して使用します。 |
dsa_area *dsa_attach(dsa_handle handle) | 他のプロセスが作成したdsaにアクセスできるようにする(アタッチする)関数。 |
dsa_pointer dsa_allocate_extended(dsa_area *area, size_t size, int flags) | メモリを取得する関数。返される値はdsa_pointerという位置情報(dsa内でのオフセットのようなイメージ)で、プロセス間で共有可能な(他プロセスと共有して使用可能な意味のある)データです。 |
dsa_pointer dsa_allocate0(dsa_area *area, size_t size) | 新たにメモリを確保し0埋めする関数(正確にはマクロで、実体はdsa_allocate_extended)です。0埋めしないdsa_allocateもあります。 |
void dsa_free(dsa_area *area, dsa_pointer dp) | dsa_allocate0等で確保したメモリを開放する関数。 |
void *dsa_get_address(dsa_area *area, dsa_pointer dp) | 論理アドレスを取得する関数。メモリアドレスはプロセスごとに異なるため、返されたアドレス値はプロセス間で共有不可能な(共有しても別プロセスでは意味のなさない)データです。 |
void dsa_pin(dsa_area *area) | バックエンドプロセスが終了しても、dsa領域を保持するようにする。 |
void dsa_pin_mapping(dsa_area *area) | バックエンドプロセス終了時まであるいは明示的にデタッチされるまで、dsa領域を保持するようにする。 |
Dynamic Shared Memory Areasの使い方
dsaの作成と他プロセスと共有
まず最初のプロセスがdsa_createでdsaを作成します。
dsaは名前引きできない(名前を付けることができない)ので、何らかの方法でそのプロセスが一番最初であることを保証することと、dsaハンドルを共有する(あるいは共有される)ことが必要です。
その実現には、例えば、Shared Memoryを使う方法があります。
Shared Memoryは名前引きできるため、Shared Memory上に該当の名前のデータがすでに存在するかを確認することで、最初のプロセスかどうかを判断できます。
ここで注意したいことは、dsa_areaの情報(変数g_areaの実体)はローカルメモリ上に確保されることです。dsa_areaのライフサイクル次第ですが、メモリコンテキストを変更しておく必要があります。また、PostgreSQLのドキュメント(37.10.10. 共有メモリとLWLocks)に記載のとおり、ShmemInitStructの競合を回避するためにロックを取得しておく必要があります。
static dsa_area *g_area;
static void
sample_get_dsa_area1()
{
dsa_handle *handle;
bool found;
MemoryContext oldMemoryContext;
/* dsa areaを永続化するために、TopMemoryContextを使用 */
oldMemoryContext = MemoryContextSwitchTo(TopMemoryContext);
/* 排他制御 */
LWLockAcquire(AddinShmemInitLock, LW_EXCLUSIVE);
/* dsaハンドルを置くための共有メモリ */
handle = (dsa_handle *) ShmemInitStruct("dsa handle",
sizeof(dsa_handle),
&found);
if (!found) /* 最初のプロセス */
{
/* dsaを作成 */
g_area = dsa_create(LWLockNewTrancheId());
/* dsaハンドルを共有 */
*handle = dsa_get_handle(g_area);
}
else /* 二番目以降のプロセス */
{
/* 他プロセスが作成したdsaにアタッチ */
g_area = dsa_attach(*handle);
}
LWLockRelease(AddinShmemInitLock);
MemoryContextSwitchTo(oldMemoryContext);
}
dsa上でメモリ確保
dsa_allocate0等でメモリを確保し、dsa_get_addressでメモリアドレスを取得します。
これによって得られたアドレスは、palloc0等で取得したときと同じように使えます。
static void *
sample_alloc0_dsa(size_t size)
{
dsa_pointer dp;
void *p;
dp = dsa_allocate0(area, size);
p = dsa_get_address(area, dp);
return p;
}
dsa上のデータへのアクセス
他のプロセスがこの領域にアクセスするには、(メモリアドレスでなく)dsa_pointerを共有します。
例えば、リスト構造などでよく利用する、"次の要素を指し示す変数"はポインタではなくdsa_pointerである必要があります。
typedef struct list_elem
{
int value;
dsa_pointer next; /* list_elem* ではない */
} list_elem;
/* Search the target in the list */
static bool
sample_search_list(list_elem *list, int target)
{
while (list != NULL)
{
if (list->value == target)
return true; /* Found */
list = dsa_get_address(area, list->next);
}
return false; /* Not found */
}
また、(上記リストの先頭要素のように)共有したい変数自体もdsa_pointerを共有する必要があります。
dsaハンドルの共有でShared Memoryを利用しましたが、それと同じようにやればよいです。
プロセス間で共有したいすべての変数をShared Memory上に置く必要はありません。
共有したい変数をひとつの構造体としてまとめ、その構造体のインスタンスはdsa上に置いて、それを指し示すdsa_pointerひとつをShared Memoryに置けば良いです。
static dsa_area *g_area;
typedef struct ShmemShared {
dsa_handle handle;
dsa_pointer dsa_shared_pos;
} ShmemShared;
typedef struct DsaShared {
dsa_pointer val1;
dsa_pointer val2;
dsa_pointer val3;
} DsaShared;
static void
sample_get_dsa_area2()
{
ShmemShared *shmem_shared;
DsaShared *dsa_shared;
bool found;
MemoryContext oldMemoryContext;
/* dsa areaを永続化するために、TopMemoryContextを使用 */
oldMemoryContext = MemoryContextSwitchTo(TopMemoryContext);
/* 排他制御 */
LWLockAcquire(AddinShmemInitLock, LW_EXCLUSIVE);
/* dsaハンドルを置くための共有メモリ */
shmem_shared = (ShmemShared *) ShmemInitStruct("dsa handle and location of shared data",
sizeof(ShmemShared),
&found);
if (!found) /* 最初のプロセス */
{
/* dsaを作成 */
g_area = dsa_create(LWLockNewTrancheId());
/* ここで、必要に応じて(g_areaのライフサイクル次第)、dsa_pinやdsa_pin_mappingを呼ぶ */
/* dsaハンドルを共有 */
shmem_shared->handle = dsa_get_handle(g_area);
/* 共有変数を作成し、位置を共有 */
shmem_shared->dsa_shared_pos = dsa_allocate0(sizeof(dsa_shared));
}
else /* 二番目以降のプロセス */
{
/* 他プロセスが作成したdsaにアタッチ */
g_area = dsa_attach(shmem_shared->handle);
}
/* 共有変数の構造体のアドレスを取得 */
dsa_shared = dsa_get_address(g_area, shmem_shared->dsa_shared_pos);
LWLockRelease(AddinShmemInitLock);
MemoryContextSwitchTo(oldMemoryContext);
/* dsa_shared->val1のようにアクセス可能 */
}
Dynamic Shared Memory Areas上のハッシュテーブル機能
前節で説明したように、dsa_pointerを介してメモリアドレスを取得する必要があるため、PostgreSQLが備えている通常のユーティリティ関数が使えません。
そのためデータ管理機能を実装しないといけませんが、dsa上にハッシュテーブルを作成してデータを管理する機能は存在したので紹介します。
プロセス上のローカルメモリ上にハッシュテーブルを管理するdynahash(src\backend\utils\hash\dynahash.c)の、dsa版です。
この機能は、ソースコード src/backend/lib/dshash.c に実装されています。
このモジュールが提供する代表的な関数を以下に示します。
関数名 | 説明 |
---|---|
dshash_table *dshash_create(dsa_area *area, const dshash_parameters *params, void *arg) | ハッシュテーブルを新たに作成する関数。 |
dshash_table_handle dshash_get_hash_table_handle(dshash_table *hash_table) | ハンドルを取得する関数。このハンドルを他のプロセスと共有して使用します。 |
dshash_table *dshash_attach(dsa_area *area, const dshash_parameters *params, dshash_table_handle handle, void *arg) | 他のプロセスが作成したハッシュテーブルにアクセスできるようにする(アタッチする)関数。 |
void *dshash_find(dshash_table *hash_table, const void *key, bool exclusive) | 指定のキーでハッシュテーブルを探索してエントリを返す関数。 |
void *dshash_find_or_insert(dshash_table *hash_table, const void *key, bool *found) | 指定のキーでハッシュテーブルを探索し、なければエントリを追加する関数。 |
void dshash_release_lock(dshash_table *hash_table, void *entry) | dshash_find等で見つかったエントリはロックを持った状態となるため、そのロックを開放する関数。 |
bool dshash_delete_key(dshash_table *hash_table, const void *key) | 指定のキーに対するエントリを削除する関数。 |
void dshash_delete_entry(dshash_table *hash_table, void *entry) | 指定のエントリを削除する関数。 |
ハッシュテーブル用のパラメータの作成
以下のように、ハッシュ作成時に必要となるパラメータを作成します。
他プロセスでのハッシュアタッチ時にも同値のパラメータが必要になるため、関数として実装しておきました。
/*
* ハッシュに格納するデータの構造体。ハッシュキーを含めます。
* 今回はint値をデータとして格納することとしました。
*/
typedef struct hashdata
{
int key; /* ハッシュキー */
int data;
} hashdata;
static dshash_parameters
sample_create_dshash_params(int tranche_id)
{
dshash_parameters params = {
sizeof(int), /* キーのサイズ */
sizeof(hashdata), /* データのサイズ */
dshash_memcmp, /* キーの比較する関数 */
dshash_memhash, /* ハッシュ値を計算する関数 */
tranche_id /* LWlockのID */
};
return params;
}
ハッシュテーブルの作成
まずLWLockNewTrancheIdで新たなIDを発行します。
その後、先のハッシュパラメータを指定してdshash_createでハッシュテーブルを作成します。
dsa areaと同様に、ハッシュテーブルの情報(変数g_hashの実体)はローカルメモリ上に確保されるので、必要に応じてメモリコンテキストを変更しておきます。
static int g_tranche_id;
static dshash_table *g_hash;
/*
* ハッシュテーブルを作成し、他プロセスとの共有のためのハッシュハンドル(dshash_table_handle)を返す関数
*/
static dshash_table_handle
sample_hash_create()
{
static dshash_parameters hash_params;
dshash_table_handle hash_handle;
MemoryContext oldMemoryContext;
oldMemoryContext = MemoryContextSwitchTo(TopMemoryContext);
g_tranche_id = LWLockNewTrancheId();
hash_params = sample_create_dshash_params(g_tranche_id);
g_hash = dshash_create(g_area, &hash_params, NULL);
hash_handle = dshash_get_hash_table_handle(g_hash);
MemoryContextSwitchTo(oldMemoryContext);
return hash_handle;
}
ハッシュテーブルにアタッチ
他プロセスが作成したハッシュテーブルにアクセスできるようにするには、作成時と同じパラメータを使用してdshash_attachを実行します。そのためには、ハッシュハンドルとLWlockのIDを、何らかの方法でハッシュテーブルを作成したプロセスから共有してもらう必要があります。共有方法としては、例えば先に説明した動的共有メモリを使用する方法があります。
/*
* 他プロセスが作成したハッシュテーブルにアタッチする関数
*/
static void
sample_hash_attach(dshash_table_handle hash_handle, int tranche_id)
{
static dshash_parameters hash_params;
MemoryContext oldMemoryContext;
oldMemoryContext = MemoryContextSwitchTo(TopMemoryContext);
hash_params = sample_create_dshash_params(tranche_id);
g_hash = dshash_attach(g_area, &hash_params, hash_handle, NULL);
MemoryContextSwitchTo(oldMemoryContext);
}
ハッシュテーブルにエントリを登録
ハッシュテーブルに新しくエントリを追加する場合は、dshash_find_or_insertを使用します。
指定のキーを持つエントリがすでに存在していた場合は、そのエントリへのポインタが返ります。
エントリが存在しなかった場合は、新たにエントリが作成され、そのエントリへのポインタが返ります。
いずれも、返されたエントリはロックを取得している状態となっているため、不要になったらdshash_release_lockでアンロックする必要があります。
エントリが存在しなかった場合に新規にエントリを作成しない(単純にエントリを探索する)ときは、dshash_find_or_insertの代わりにdshash_findを使います。
/*
* エントリを新規登録あるいは上書きする関数
*/
static void
sample_hash_upsert(int key, int data)
{
hashdata *entry;
bool found;
entry = (hashdata *) dshash_find_or_insert(g_hash, &key, &found);
if (found)
{
/* 指定のキーを持つエントリが存在した場合(今回は何もしない) */
}
else
{
/* 指定のキーを持つエントリが存在しなかった場合(今回は何もしない) */
}
entry->data = data;
dshash_release_lock(g_hash, entry);
}
おわりに
PostgreSQLの動的共有メモリの使い方を紹介しました。
動的共有メモリ上ではメモリアドレスを格納することはできないため(メモリアドレスを共有しても他プロセスでは意味がない)通常のデータ管理機構が使えませんが、動的共有メモリ上に構築できるハッシュテーブル機能があったので、今回それについても紹介しました。
本記事は、調査に基づく私の理解をまとめたものなので、もしかしたら間違っているところがあるかもしれません。その場合はコメントでご指摘いただければ幸いです。
参考文献
PostgreSQL のメモリ管理関数の解説 http://www.nminoru.jp/~nminoru/postgresql/pg-memory-management.html
Dynamic shared memory areas https://www.postgresql.org/message-id/CAEepm=1z5WLuNoJ80PaCvz6EtG9dN0j-KuHcHtU6QEfcPP5-qA@mail.gmail.com
PostgreSQL 13.1文書 37.10.10. 共有メモリとLWLocks
https://www.postgresql.jp/document/13/html/xfunc-c.html#XFUNC-SHARED-ADDIN