Расширения PostgreSQL: разделяемая память и локи

24 октября 2022

Рассмотренные нами ранее ProcArray и CLOG реализованы поверх разделяемой памяти и LWLocks. Но напрямую использовать данные примитивы нам пока не доводилось. А жаль, ведь примитивы эти полезные, особенно в расширениях PostgreSQL. Давайте же заполним этот пробел.

Общие сведения

Если вы посмотрите на вывод pstree для процессов PostgreSQL, то увидете в нем примерно следующее:

$ pstree 10487
-+= 10487 eax /Users/eax/pginstall/bin/postgres -D ...пропущено...
 |--= 10488 eax postgres: checkpointer
 |--= 10489 eax postgres: background writer
 |--= 10491 eax postgres: walwriter
 |--= 10492 eax postgres: autovacuum launcher
 \--= 10493 eax postgres: logical replication launcher

Процесс, от которого fork()’аются все остальные процессы, называется postmaster. При запуске СУБД postgmaster выделяет разделяемую память под все структуры, используемые дочерними процессами. Дочерние процессы наследуют разделяемую память от postmaster’а.

В версиях PostgreSQL ≤ 9.2 память выделялась при помощи системного вызова shmget(). Это не очень хорошо работало. По умолчанию современные ОС не позволяют выделять много памяти таким образом. В связи с этим, пользователям приходилось менять настройки ОС. Новые версии PostgreSQL перешли на системный вызов mmap(). Вызов shmget() продолжает использоваться, но лишь для небольшого количества памяти. Подробности можно найти в sysv_shmem.c, а детали реализации под Windows — в файле win32_shmem.c.

Разделяемая память отображается на одинаковые адреса внутри всех процессов. Таким образом, указатели на разделяемую память можно безопасно передавать между процессами. Выделить дополнительной разделяемой памяти в ходе работы СУБД нельзя, ровно как и освободить ее.

Функция _PG_init()

Расширения PostgreSQL представляют собой обычные динамические библиотеки. По умолчанию они подгружаются бэкендом по мере надобности. Например, когда пользователь вызывает хранимку, предоставляемую расширением. При таком поведении системы выделить разделяемую память из расширения несколько затруднительно.

Обойти проблему можно при помощи конфигурационного параметра shared_preload_libraries. В нем указывается список динамических библиотек, которые postmaster должен подгрузить во время запуска СУБД. Если библиотека экспортирует функцию _PG_init(), происходит ее вызов. Из этой функции расширение может зарегистрировать колбэки, запустить фоновый процесс (background worker), и так далее.

Заметьте, что вызов _PG_init() происходит всегда при загрузке библиотеки. Определить, была ли библиотека загружена при помощи shared_preload_libraries, можно по значению process_shared_preload_libraries_in_progress.

Таким образом, типичный код _PG_init() будет выглядеть как-то так:

#include <postgres.h>
#include <miscadmin.h>
#include <utils/builtins.h>

/* ... */

void
_PG_init(void)
{
    if(!process_shared_preload_libraries_in_progress)
        elog(FATAL, "Please use shared_preload_libraries");

    elog(INFO, "extension loaded");
}

Вдумчивый читатель на данном этапе мог заметить небольшую проблему.

Дело в том, что для тестирования расширений до сих мор мы использовали простые SQL-тесты. Но для такого расширения применить их будет непросто. Ведь тест упадет при попытке сделать CREATE EXTENSION, и контроля над postgresql.conf у нас нет.

Это одна из причин, почему PostgreSQL поддерживает второй тип тестов, так называемые TAP-тесты. Эти тесты пишутся на языке Perl с использованием фреймворка Test::More. Писать их не намного сложнее SQL-тестов, даже если вы не знакомы с синтаксисом Perl. В рамках этого поста на TAP-тестах мы подробно останавливаться не будем. Заинтересованные читатели могут ознакомиться с полном кодом тестов здесь. Исходники классов, предоставляемых PostgreSQL для написания TAP-тестов, находятся в каталоге src/test/perl.

Fun fact! В самом PostgreSQL есть способ запускать SQL-тесты с измененным postgresql.conf. Однако это не работает для сторонних расширений.

Разделяемая память и локи

Может сложиться ошибочное впечатление, что в _PG_init() можно просто взять и выделить разделяемую память. Увы, все не так просто.

Реальный код будет выглядеть как-то так:

#include <postgres.h>
#include <miscadmin.h>
#include <storage/ipc.h>
#include <storage/shmem.h>
#include <storage/lwlock.h>
#include <utils/builtins.h>

static shmem_request_hook_type prev_shmem_request_hook = NULL;
static shmem_startup_hook_type prev_shmem_startup_hook = NULL;

/* ... */

#define MESSAGE_BUFF_SIZE 100
typedef struct SharedStruct {
    LWLock* lock;
    char message[MESSAGE_BUFF_SIZE];
} SharedStruct;

SharedStruct *sharedStruct;

static void
experiment_shmem_request(void)
{
    if(prev_shmem_request_hook)
        prev_shmem_request_hook();

    RequestAddinShmemSpace(MAXALIGN(sizeof(SharedStruct)));
    RequestNamedLWLockTranche("experiment", 1);
}

static void
experiment_shmem_startup(void)
{
    bool found;

    if(prev_shmem_startup_hook)
        prev_shmem_startup_hook();

    LWLockAcquire(AddinShmemInitLock, LW_EXCLUSIVE);

    sharedStruct = ShmemInitStruct("SharedStruct", sizeof(SharedStruct),
                                   &found);
    if(!found) {
        sharedStruct->message[0] = '\0';
        sharedStruct->lock =
            &(GetNamedLWLockTranche("experiment"))->lock;
    }

    LWLockRelease(AddinShmemInitLock);
}

void
_PG_init(void)
{
    if(!process_shared_preload_libraries_in_progress)
        elog(FATAL, "Please use shared_preload_libraries");

    prev_shmem_request_hook = shmem_request_hook;
    shmem_request_hook = experiment_shmem_request;

    prev_shmem_startup_hook = shmem_startup_hook;
    shmem_startup_hook = experiment_shmem_startup;
}

Сначала требуемое количество памяти нужно запросить при помощи функции RequestAddinShmemSpace(). В PostgreSQL < 15 вызов осуществлялся прямо из функции _PG_init(), но с этим были связаны некоторые неудобства. Поэтому в PostgreSQL ≥ 15 вызов делается из хука shmem_request_hook. В нашем колбэке мы также просим СУБД выделить один LWLock. Порции локов (траншу, tranche) присваивается имя "experiment".

Далее выделенную память нужно найти и присвоить ей имя при помощи функции ShmemInitStruct(). Это уже можно сделать и из кода какой-нибудь хранимки, но удобнее воспользоваться еще одним хуком, shmem_startup_hook. Хук позволяет сосредоточить весь код, связанный с инициализацией разделяемой памяти, в одном месте, и не мешать его с основной логикой.

Чтобы код корректно работал на всех поддерживаемых платформах, включая Windows, хуку необходимо захватывать AddinShmemInitLock. По этой же причине lock принято делать частью структуры в разделяемой памяти, а не хранить в локальной памяти процесса.

AddinShmemInitLock имеет тип LWLock*. LWLock расшифровывается, как lightweight lock. Это не локи операционной системы, а собственная реализация PostgreSQL на основе разделяемой памяти, спинлоков и семафоров. В первом приближении можно думать о LWLock’ах, как об RWLock’ах из C++. Интерфейс у LWLock тривиальный, поэтому не будем подробно на нем останавливаться.

ShmemInitStruct() аллоцирует структуру в разделяемой памяти (из того объема, что мы запросили ранее) и присваивает ей имя, переданное первым аргументом. Если место под структуру уже было выделено, возвращается found = true, иначе — found = false. Соответственно, по этому значению мы понимаем, нужно ли инициализировать структуру, или нет.

В этом же коде мы запрашиваем порцию (транш) локов при помощи функции GetNamedLWLockTranche(), если это не было сделано ранее. LWLock’и в PostgreSQL выравниваются до размера кэшлинии, поэтому функция возвращает массив не как LWLock*, а как LWLockPadded*. Соответственно, из него мы извлекаем значение LWLock*.

Поздравляю, самое сложное позади. Дальше работа с локами и разделяемой памятью осуществляется более-менее как и везде:

Datum
experiment_get_message(PG_FUNCTION_ARGS)
{
  text* result;

  LWLockAcquire(sharedStruct->lock, LW_SHARED);
  result = cstring_to_text(sharedStruct->message);
  LWLockRelease(sharedStruct->lock);

  PG_RETURN_TEXT_P(result);
}

Datum
experiment_set_message(PG_FUNCTION_ARGS)
{
  const char* msg = TextDatumGetCString(PG_GETARG_DATUM(0));

  LWLockAcquire(sharedStruct->lock, LW_EXCLUSIVE);
  strncpy(sharedStruct->message, msg, MESSAGE_BUFF_SIZE-1);
  LWLockRelease(sharedStruct->lock);

  PG_RETURN_VOID();
}

Характерно, что LWLock’и осведомлены об исключениях PostgreSQL. Поэтому такой код вполне законен:

Datum
experiment_lock_and_throw_error(PG_FUNCTION_ARGS)
{
  LWLockAcquire(sharedStruct->lock, LW_EXCLUSIVE);

  elog(ERROR, "error");

  PG_RETURN_VOID();
}

При возникновении исключения все захваченные локи будут автоматически отпущены. Делать это вручную через PG_TRY() не нужно. А вот deadlock detection не предусмотрен. Нужно внимательно следить, какие локи и в каком порядке вы захватываете.

Заключение

Сколько разделяемой памяти и подо что было выделено можно посмотреть через специальный VIEW:

SELECT * FROM pg_shmem_allocations;

Как отмечалось выше, в PostgreSQL еще есть и спинлоки. Интерфейс у них простой — SpinLockInit(), SpinLockAcquire() и SpinLockRelease(). Примеры использования можно посмотреть в pg_stat_statements.c. Имейте ввиду, что спинлоки являются низкоуровневым примитивом. Они ничего не знают про исключения, и съедают CPU при высоком contention. Их следует использовать, только если вы очень хорошо понимаете, что делаете.

Поверх разделяемой памяти в PostgreSQL также есть реализации очередей и хэш-таблиц. Заинтересованным читателям предлагается ознакомиться с ними самостоятельно, в качестве упражнения. Соответствующий код ищите в файлах shmem.c и shmem.h. UPD: реализация очередей поверх разделяемой памяти была удалена в PostgreSQL 16.

Полная версия исходников к посту доступна на GitHub.

Дополнение: В PostgreSQL 17 появился альтернативный способ выделения разделяемой памяти, через GetNamedDSMSegment(). Также вас могут заинтересовать посты Внутренности PostgreSQL: разделяемые буферы и Расширения PostgreSQL: фоновые процессы.

Метки: , , .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.