SQLite использует динамическое выделение памяти для различных объектов, таких как соединения с базой данных, подготовленные запросы и кэширование результатов. Подсистема выделения памяти в SQLite разрабатывалась с акцентом на надежность и эффективность

- Возможности подсистемы выделения памяти
- Тестирование и верификация
- Использование reallocarray()
- Конфигурация выделения памяти
- Альтернативные низкоуровневые распределители памяти
- Память кэша страниц
- Аллокатор памяти lookaside
- Двухразмерный lookaside
- Статистика использования памяти и ограничения
- Математические гарантии защиты от сбоев выделения памяти
- Вычисление и управление параметрами M и n
- Пластичный отказ
- Практические рекомендации по настройке памяти
- Стабильность интерфейсов памяти
- Ответы на эти вопросы могут быть для вас полезными
Возможности подсистемы выделения памяти
Ядро SQLite и его подсистема выделения памяти предоставляют следующие возможности
Устойчивость к сбоям выделения памяти. Если выделение памяти не удается (например, когда malloc() или realloc() возвращают NULL), SQLite восстанавливает состояние, освобождая память из незакрепленных страниц кэша и повторяя запрос. Если это не помогает, SQLite завершает текущую операцию и возвращает код ошибки SQLITE_NOMEM, либо продолжает работать без запрашиваемой памяти
Отсутствие утечек памяти. Приложение должно очищать все созданные объекты, вызывая sqlite3_finalize() и sqlite3_close(). При соблюдении этих требований SQLite избегает утечек памяти даже при ошибках выделения
Ограничения использования памяти. С помощью sqlite3_soft_heap_limit64() приложение может установить предел использования памяти. При приближении к этому пределу SQLite стремится использовать имеющуюся память из кэшей
Режим без вызовов malloc. Приложение может предоставить SQLite несколько буферов памяти при запуске. SQLite будет использовать эти буферы для своих нужд, избегая обращений к системным malloc() или free()
Пользовательские аллокаторы памяти. Приложение может предоставить SQLite указатели на альтернативные аллокаторы памяти при запуске, которые будут использоваться вместо системных
Защита от деградации и фрагментации. SQLite может быть настроен так, чтобы исключить сбои выделения памяти и фрагментацию кучи при соблюдении определенных условий, что критично для надежных встраиваемых систем
Статистика использования памяти. Приложения могут видеть, сколько памяти они используют, и обнаруживать, когда использование памяти приближается к проектным границам или превышает их
Совместимость с отладчиками памяти. Выделение памяти в SQLite спроектировано так, чтобы стандартные сторонние отладчики памяти, такие как dmalloc или valgrind, могли использоваться для проверки работы с памятью
Минимальное количество обращений к аллокатору. Реализации системных malloc() и free() неэффективны на многих платформах. SQLite стремится сократить общее время обработки, минимизируя использование malloc() и free()
Открытый доступ. Подключаемые расширения SQLite или само приложение могут получить доступ к тем же базовым процедурам выделения памяти, которые использует SQLite, через интерфейсы sqlite3_malloc(), sqlite3_realloc() и sqlite3_free()
По этой теме полезно отдельно посмотреть EXPLAIN QUERY PLAN: план выполнения SQL-запроса в SQLite, чтобы расширить контекст и сравнить подходы
По этой теме полезно отдельно посмотреть Создание Flutter-приложения с SQLite, BLoC и Streams, чтобы расширить контекст и сравнить подходы
Тестирование и верификация
Большая часть кода в дереве исходников SQLite посвящена исключительно тестированию и верификации. Надёжность имеет для SQLite первостепенное значение. Среди задач тестовой инфраструктуры — обеспечение того, чтобы SQLite не использовал динамически выделенную память некорректно, не допускал утечек памяти и правильно реагировал на сбои динамического выделения памяти
Тестовая инфраструктура проверяет корректность использования динамически выделенной памяти в SQLite с помощью специально инструментированного аллокатора памяти. Инструментированный аллокатор памяти включается на этапе компиляции с помощью опции SQLITE_MEMDEBUG. Инструментированный аллокатор памяти значительно медленнее аллокатора по умолчанию, поэтому его использование в производственной среде не рекомендуется
Однако при включении во время тестирования инструментированный аллокатор памяти выполняет следующие проверки
Проверка границ. Инструментированный аллокатор памяти размещает сторожевые значения на обоих концах каждого выделенного блока памяти, чтобы убедиться, что ничто внутри SQLite не записывает данные за пределами выделенного блока
Использование памяти после освобождения. При освобождении каждого блока памяти каждый байт перезаписывается бессмысленным битовым шаблоном. Это помогает гарантировать, что никакая память не используется после освобождения
Освобождение памяти, не полученной через malloc. Каждое выделение памяти инструментированным аллокатором содержит сторожевые значения, используемые для проверки того, что каждый освобождаемый блок был получен через предшествующий вызов malloc
Неинициализированная память. Инструментированный аллокатор памяти инициализирует каждый выделенный блок памяти бессмысленным битовым шаблоном, чтобы исключить предположения пользователя о содержимом выделенной памяти
Независимо от того, используется ли инструментированный аллокатор памяти, SQLite отслеживает объём памяти, находящейся в текущем использовании. Для тестирования SQLite применяются сотни тестовых скриптов. В конце каждого скрипта все объекты уничтожаются и выполняется проверка того, что вся память была освобождена. Именно так обнаруживаются утечки памяти
Следует отметить, что обнаружение утечек памяти активно всегда — как в тестовых, так и в производственных сборках. Каждый раз, когда кто-либо из разработчиков запускает отдельный тестовый скрипт, обнаружение утечек памяти активно. Поэтому утечки памяти, возникающие в процессе разработки, быстро обнаруживаются и устраняются
Реакция SQLite на ошибки нехватки памяти (OOM, out-of-memory) тестируется с помощью специализированного оверлея (overlay) аллокатора памяти, способного симулировать сбои выделения памяти. Оверлей — это слой, вставляемый между аллокатором памяти и остальной частью SQLite. Оверлей передаёт большинство запросов на выделение памяти напрямую к нижележащему аллокатору и возвращает результаты запрашивающей стороне
Однако оверлей можно настроить так, чтобы N-е выделение памяти завершалось неудачей. Для проведения OOM-теста оверлей сначала настраивается на сбой при первой попытке выделения. Затем запускается некоторый тестовый скрипт и проверяется, что сбой был корректно перехвачен и обработан. Затем оверлей настраивается на сбой при втором выделении, и тест повторяется
Точка сбоя продолжает смещаться на одно выделение за раз до тех пор, пока вся тестовая процедура не завершится без возникновения ошибки выделения памяти. Вся эта последовательность тестов выполняется дважды. При первом проходе оверлей настраивается на сбой только N-го выделения. При втором проходе оверлей настраивается на сбой N-го и всех последующих выделений
Логика обнаружения утечек памяти продолжает работать даже при использовании OOM-оверлея. Это подтверждает, что SQLite не допускает утечек памяти даже при возникновении ошибок выделения памяти. Также следует отметить, что OOM-оверлей может работать с любым нижележащим аллокатором памяти, включая инструментированный аллокатор, проверяющий некорректное использование памяти
Таким образом подтверждается, что OOM-ошибки не вызывают других видов ошибок использования памяти
Наконец, инструментированный аллокатор памяти и детектор утечек памяти работают на всём наборе тестов SQLite, а набор тестов TCL обеспечивает покрытие операторов более 99%, тогда как тестовый стенд TH3 обеспечивает 100% покрытие ветвей без каких-либо утечек. Это является весомым свидетельством того, что динамическое выделение памяти используется корректно повсюду в SQLite
Использование reallocarray()
Интерфейс reallocarray() — это относительно недавнее нововведение (около 2014 года) от сообщества OpenBSD, появившееся в рамках усилий по предотвращению уязвимостей типа «heartbleed» за счёт исключения переполнения 32-битной целочисленной арифметики при вычислении размеров выделяемой памяти. Функция reallocarray() принимает два параметра: размер единицы и количество элементов
Чтобы выделить память, достаточную для хранения массива из N элементов, каждый из которых занимает X байт, вызывается reallocarray(0, X, N). Это предпочтительнее традиционного подхода с вызовом malloc(XN), поскольку reallocarray() устраняет риск того, что умножение XN переполнится и malloc() вернёт буфер размера, отличного от ожидаемого приложением
SQLite не использует reallocarray(). Причина в том, что reallocarray() не приносит SQLite никакой пользы. Оказывается, SQLite никогда не выполняет выделение памяти, являющееся простым произведением двух целых чисел. Вместо этого SQLite выполняет выделения вида X+C, NX+C, MNX+C, NX+M*Y+C и так далее. Интерфейс reallocarray() не помогает избежать целочисленного переполнения в подобных случаях
Тем не менее целочисленное переполнение при вычислении размеров выделяемой памяти — это проблема, которую SQLite стремится решить. Для предотвращения ошибок все внутренние выделения памяти в SQLite выполняются через тонкие функции-обёртки, принимающие знаковый 64-битный целочисленный параметр размера
Исходный код SQLite проверяется на предмет того, что все вычисления размеров также выполняются с использованием 64-битных знаковых целых чисел. SQLite откажется выделять более примерно 2 ГБ памяти за один раз. В обычном использовании SQLite редко выделяет более примерно 8 КБ памяти за раз, поэтому ограничение в 2 ГБ не является обременительным
Таким образом, 64-битный параметр размера предоставляет большой запас для обнаружения переполнений. Та же проверка, которая удостоверяет, что все вычисления размеров выполняются как 64-битные знаковые целые числа, также подтверждает, что переполнение 64-битного целого числа в ходе вычисления невозможно
Проверки кода, используемые для обеспечения того, что вычисления размеров выделяемой памяти не переполняются в SQLite, повторяются перед каждым выпуском SQLite
Конфигурация выделения памяти
Настройки выделения памяти по умолчанию в SQLite подходят для большинства приложений. Однако приложения с нестандартными или особенно строгими требованиями могут захотеть скорректировать конфигурацию, чтобы точнее привести SQLite в соответствие со своими нуждами. Доступны как параметры конфигурации времени компиляции, так и параметры конфигурации времени запуска
Альтернативные низкоуровневые распределители памяти
Исходный код SQLite включает несколько различных модулей выделения памяти, которые можно выбрать во время компиляции или, в ограниченной степени, во время запуска
Распределитель памяти по умолчанию. По умолчанию SQLite использует процедуры malloc(), realloc() и free() из стандартной библиотеки C для своих нужд в выделении памяти. Эти процедуры окружены тонкой обёрткой, которая также предоставляет функцию memsize(), возвращающую размер существующего выделения
Функция memsize() необходима для точного подсчёта количества байт выделенной памяти: она определяет, сколько байт нужно вычесть из текущего счётчика при освобождении выделения. Распределитель по умолчанию реализует memsize(), всегда выделяя 8 дополнительных байт при каждом запросе malloc() и сохраняя размер выделения в этом 8-байтовом заголовке
Распределитель памяти по умолчанию рекомендуется для большинства приложений. Если у вас нет веских оснований использовать альтернативный распределитель памяти, используйте распределитель по умолчанию
Отладочный распределитель памяти. Если SQLite скомпилирован с параметром компиляции SQLITE_MEMDEBUG, то вокруг системных malloc(), realloc() и free() используется другая, тяжёлая обёртка. Тяжёлая обёртка выделяет около 100 дополнительных байт при каждом выделении. Дополнительное пространство используется для размещения сторожевых значений на обоих концах выделения, возвращаемого ядру SQLite
При освобождении выделения эти сторожевые значения проверяются, чтобы убедиться, что ядро SQLite не вышло за пределы буфера ни в одном из направлений. Когда системной библиотекой является GLIBC, тяжёлая обёртка также использует функцию GNU backtrace() для анализа стека и записи функций-предков вызова malloc(). При запуске набора тестов SQLite тяжёлая обёртка также записывает имя текущего тестового случая
Эти два последних свойства полезны для отслеживания источника утечек памяти, обнаруженных набором тестов
Тяжёлая обёртка, используемая при установке SQLITE_MEMDEBUG, также обеспечивает заполнение каждого нового выделения бессмысленными данными перед возвратом выделения вызывающей стороне. И как только выделение освобождается, оно снова заполняется бессмысленными данными. Эти два действия помогают гарантировать, что ядро SQLite не делает предположений о состоянии вновь выделенной памяти и что выделения памяти не используются после их освобождения
Тяжёлая обёртка, применяемая SQLITE_MEMDEBUG, предназначена для использования только во время тестирования, анализа и отладки SQLite. Тяжёлая обёртка имеет значительные накладные расходы по производительности и памяти и, вероятно, не должна использоваться в производственной среде
Нативный распределитель памяти Win32. Если SQLite скомпилирован для Windows с параметром компиляции SQLITE_WIN32_MALLOC, то вокруг HeapAlloc(), HeapReAlloc() и HeapFree() используется другая, тонкая обёртка. Тонкая обёртка использует настроенную кучу SQLite, которая будет отличаться от кучи процесса по умолчанию, если используется параметр компиляции SQLITE_WIN32_HEAP_CREATE
Кроме того, при выделении или освобождении памяти будет вызываться HeapValidate(), если SQLite скомпилирован с включёнными assert() и параметром компиляции SQLITE_WIN32_MALLOC_VALIDATE
Распределитель памяти без использования malloc (memsys5). Когда SQLite скомпилирован с параметром SQLITE_ENABLE_MEMSYS5, в сборку включается альтернативный распределитель памяти, не использующий malloc(). Разработчики SQLite называют этот альтернативный распределитель памяти «memsys5». Даже когда он включён в сборку, memsys5 по умолчанию отключён. Чтобы включить memsys5, приложение должно вызвать следующий интерфейс SQLite во время запуска:
sqlite3_config(SQLITE_CONFIG_HEAP, pBuf, szBuf, mnReq);
В приведённом выше вызове pBuf — это указатель на большой непрерывный блок памяти, который SQLite будет использовать для удовлетворения всех своих потребностей в выделении памяти. pBuf может указывать на статический массив или на память, полученную каким-либо другим специфичным для приложения механизмом. szBuf — это целое число, представляющее количество байт памяти, на которую указывает pBuf. mnReq — ещё одно целое число, являющееся минимальным размером выделения
Любой вызов sqlite3_malloc(N), где N меньше mnReq, будет округлён до mnReq. mnReq должен быть степенью двойки. Параметр mnReq важен для уменьшения значения n и, следовательно, минимального требования к размеру памяти в доказательстве Робсона
Распределитель memsys5 разработан для использования во встраиваемых системах, хотя ничто не препятствует его использованию на рабочих станциях. Значение szBuf обычно составляет от нескольких сотен килобайт до нескольких десятков мегабайт, в зависимости от системных требований и бюджета памяти
Алгоритм, используемый memsys5, можно назвать «степень двойки, первый подходящий» (power-of-two, first-fit). Размеры всех запросов на выделение памяти округляются до степени двойки, и запрос удовлетворяется первым свободным слотом в pBuf, который достаточно велик. Смежные освобождённые выделения объединяются с использованием системы двойников
При правильном использовании этот алгоритм обеспечивает математические гарантии против фрагментации и деградации, как описано далее
Экспериментальные распределители памяти. Название «memsys5», используемое для распределителя памяти без использования malloc, подразумевает, что существует несколько дополнительных распределителей памяти, и действительно это так. Распределитель памяти по умолчанию — «memsys1». Отладочный распределитель памяти — «memsys2»
Если SQLite скомпилирован с SQLITE_ENABLE_MEMSYS3, то в дерево исходников включается ещё один распределитель памяти без использования malloc, аналогичный memsys5. Распределитель memsys3, как и memsys5, должен быть активирован вызовом sqlite3_config(SQLITE_CONFIG_HEAP, ...). Memsys3 использует предоставленный буфер памяти в качестве источника для всех выделений памяти
Разница между memsys3 и memsys5 состоит в том, что memsys3 использует другой алгоритм выделения памяти, который, по всей видимости, хорошо работает на практике, но не предоставляет математических гарантий против фрагментации и деградации памяти. Memsys3 был предшественником memsys5
Разработчики SQLite теперь считают, что memsys5 превосходит memsys3 и что все приложения, которым нужен распределитель памяти без использования malloc, должны использовать memsys5 предпочтительно перед memsys3. Memsys3 считается как экспериментальным, так и устаревшим и, вероятно, будет удалён из дерева исходников в будущем выпуске SQLite
Memsys4 и memsys6 были экспериментальными распределителями памяти, введёнными примерно в 2007 году и впоследствии удалёнными из дерева исходников примерно в 2008 году, после того как стало ясно, что они не добавляют никакой новой ценности. В будущих выпусках SQLite могут быть добавлены другие экспериментальные распределители памяти — ожидается, что они будут называться memsys7, memsys8 и так далее
Распределители памяти, определяемые приложением. Новые распределители памяти не обязательно должны быть частью дерева исходников SQLite или включаться в амальгамацию (amalgamation) sqlite3.c. Отдельные приложения могут предоставлять SQLite собственные распределители памяти во время запуска
Чтобы заставить SQLite использовать новый распределитель памяти, приложение просто вызывает:
sqlite3_config(SQLITE_CONFIG_MALLOC, pMem);
В приведённом выше вызове pMem — это указатель на объект sqlite3_mem_methods, определяющий интерфейс к специфичному для приложения распределителю памяти. Объект sqlite3_mem_methods — это, по сути, структура, содержащая указатели на функции для реализации различных примитивов выделения памяти
В многопоточном приложении доступ к sqlite3_mem_methods сериализуется тогда и только тогда, когда включён SQLITE_CONFIG_MEMSTATUS. Если SQLITE_CONFIG_MEMSTATUS отключён, то методы в sqlite3_mem_methods должны самостоятельно обеспечивать свои потребности в сериализации
Оверлеи распределителя памяти. Приложение может вставлять слои или «оверлеи» между ядром SQLite и базовым распределителем памяти. Например, логика тестирования нехватки памяти для SQLite использует оверлей, который может имитировать сбои выделения памяти
Оверлей можно создать, используя интерфейс sqlite3_config(SQLITE_CONFIG_GETMALLOC, pOldMem) для получения указателей на существующий распределитель памяти. Существующий распределитель сохраняется оверлеем и используется в качестве запасного варианта для реального выделения памяти. Затем оверлей вставляется на место существующего распределителя памяти с помощью sqlite3_config(SQLITE_CONFIG_MALLOC, ...), как описано выше
Заглушка распределителя памяти без операций. Если SQLite скомпилирован с параметром SQLITE_ZERO_MALLOC, то распределитель памяти по умолчанию опускается и заменяется заглушкой распределителя памяти, которая никогда не выделяет никакой памяти. Любые вызовы заглушки распределителя памяти будут сообщать, что память недоступна
Распределитель памяти без операций сам по себе бесполезен. Он существует только как заполнитель, чтобы у SQLite был распределитель памяти для компоновки в системах, которые могут не иметь malloc(), free() или realloc() в своей стандартной библиотеке
Приложение, скомпилированное с SQLITE_ZERO_MALLOC, должно будет использовать sqlite3_config() вместе с SQLITE_CONFIG_MALLOC или SQLITE_CONFIG_HEAP для указания нового альтернативного распределителя памяти перед началом использования SQLite
Память кэша страниц
В большинстве приложений подсистема кэша страниц (page cache) базы данных внутри SQLite использует больше динамически выделяемой памяти, чем все остальные части SQLite вместе взятые. Нередко можно наблюдать, как кэш страниц базы данных потребляет более чем в 10 раз больше памяти, чем весь остальной SQLite в совокупности
SQLite можно настроить таким образом, чтобы выделение памяти для кэша страниц производилось из отдельного и обособленного пула памяти с фиксированным размером слотов. Это может давать два преимущества
Поскольку все выделения имеют одинаковый размер, аллокатор памяти может работать значительно быстрее. Аллокатору не нужно беспокоиться об объединении смежных свободных слотов или поиске слота подходящего размера. Все невыделенные слоты памяти можно хранить в связном списке. Выделение памяти сводится к удалению первого элемента из списка. Освобождение — к добавлению элемента в начало списка
При единственном размере выделения параметр n в доказательстве Робсона равен 1, и общий объём памяти, требуемый аллокатором (N), в точности равен максимально используемой памяти (M). Никакой дополнительной памяти для покрытия накладных расходов на фрагментацию не требуется, что снижает потребности в памяти. Это особенно важно для памяти кэша страниц, поскольку кэш страниц составляет наибольшую часть потребностей SQLite в памяти
Аллокатор памяти кэша страниц по умолчанию отключён. Приложение может включить его на этапе запуска следующим образом:
sqlite3_config(SQLITE_CONFIG_PAGECACHE, pBuf, sz, N);
Параметр pBuf — это указатель на непрерывный диапазон байт, который SQLite будет использовать для выделения памяти кэша страниц. Буфер должен иметь размер не менее sz*N байт. Параметр sz — это размер каждого выделения кэша страниц. N — максимальное количество доступных выделений
Обратите внимание, что каждое выделение кэша страниц больше, чем размер страницы базы данных. Если размер страницы базы данных равен Z, каждое выделение памяти кэша страниц будет составлять Z+Y байт, где Y — константа, зависящая от параметров компиляции и целевого процессора. Для конкретной платформы и сборки SQLite значение Y можно определить с помощью параметра SQLITE_CONFIG_PCACHE_HDRSZ в sqlite3_config()
Если SQLite требуется запись кэша страниц размером более sz байт или если ему требуется более N записей, он возвращается к использованию аллокатора памяти общего назначения
Аллокатор памяти lookaside
Соединения с базой данных SQLite выполняют множество небольших и кратковременных выделений памяти. Чаще всего это происходит при компиляции SQL-операторов с помощью sqlite3_prepare_v2(), а также в меньшей степени при выполнении подготовленных операторов с помощью sqlite3_step()
Эти небольшие выделения памяти используются для хранения таких данных, как имена таблиц и столбцов, узлы дерева разбора, значения отдельных результатов запросов и объекты курсора B-дерева. Следовательно, происходит очень много вызовов malloc() и free() — настолько много, что malloc() и free() в итоге занимают значительную долю процессорного времени, отведённого SQLite
В SQLite версии 3.6.1 (2008-08-06) был введён аллокатор памяти lookaside, призванный снизить нагрузку на выделение памяти. В аллокаторе lookaside каждое соединение с базой данных заранее выделяет один большой блок памяти (как правило, в диапазоне от 60 до 120 килобайт) и делит этот блок на небольшие «слоты» фиксированного размера — примерно от 100 до 1000 байт каждый. Это становится пулом памяти lookaside
В дальнейшем выделения памяти, связанные с соединением с базой данных и не слишком большие по размеру, удовлетворяются с использованием одного из слотов пула lookaside, а не путём вызова аллокатора памяти общего назначения. Более крупные выделения по-прежнему используют аллокатор памяти общего назначения, как и выделения, происходящие в тот момент, когда все слоты пула lookaside уже заняты
Но во многих случаях выделения памяти достаточно малы и количество незакрытых выделений невелико, так что новые запросы на память могут быть удовлетворены из пула lookaside
Поскольку выделения lookaside всегда имеют одинаковый размер, алгоритмы выделения и освобождения памяти работают очень быстро. Нет необходимости объединять смежные свободные слоты или искать слот определённого размера. Каждое соединение с базой данных поддерживает однонаправленный связный список неиспользуемых слотов. Запросы на выделение просто извлекают первый элемент этого списка
Освобождение просто возвращает элемент в начало списка. Кроме того, предполагается, что каждое соединение с базой данных уже выполняется в одном потоке (для этого уже предусмотрены мьютексы), поэтому никакой дополнительной синхронизации с помощью мьютексов для сериализации доступа к списку свободных слотов lookaside не требуется. Следовательно, выделение и освобождение памяти lookaside происходят очень быстро
В тестах производительности на рабочих станциях Linux и Mac OS X SQLite демонстрировал общее улучшение производительности до 10–15%, в зависимости от конфигурации рабочей нагрузки и lookaside
Размер пула памяти lookaside имеет глобальное значение по умолчанию, но может также настраиваться для каждого соединения отдельно. Чтобы изменить размер пула памяти lookaside по умолчанию на этапе компиляции, используйте параметр -DSQLITE_DEFAULT_LOOKASIDE=SZ,N. Чтобы изменить размер пула памяти lookaside по умолчанию на этапе запуска, используйте интерфейс sqlite3_config():
sqlite3_config(SQLITE_CONFIG_LOOKASIDE, sz, cnt);
Параметр sz — это размер каждого слота lookaside в байтах. Параметр cnt — общее количество слотов памяти lookaside на одно соединение с базой данных. Общий объём памяти lookaside, выделяемой каждому соединению с базой данных, составляет sz*cnt байт
Пул lookaside можно изменить для отдельного соединения с базой данных db с помощью следующего вызова:
sqlite3_db_config(db, SQLITE_DBCONFIG_LOOKASIDE, pBuf, sz, cnt);
Параметр pBuf — это указатель на область памяти, которая будет использоваться для пула памяти lookaside. Если pBuf равен NULL, SQLite самостоятельно получит пространство для пула памяти с помощью sqlite3_malloc(). Параметры sz и cnt — это размер каждого слота lookaside и количество слотов соответственно. Если pBuf не равен NULL, он должен указывать на область памяти размером не менее sz*cnt байт
Конфигурацию lookaside можно изменить только при отсутствии незакрытых выделений lookaside для данного соединения с базой данных. Поэтому конфигурацию следует задавать сразу после создания соединения с базой данных с помощью sqlite3_open() (или эквивалентной функции) и до выполнения каких-либо SQL-операторов в этом соединении
Двухразмерный lookaside
Начиная с SQLite версии 3.31.0 (2020-01-22), lookaside поддерживает два пула памяти, каждый с собственным размером слота. Пул малых слотов использует слоты размером 128 байт, а пул больших слотов использует размер, указанный в SQLITE_DBCONFIG_LOOKASIDE (по умолчанию 1200 байт)
Такое разделение пула на два позволяет чаще покрывать выделения памяти с помощью lookaside, одновременно снижая использование кучи на одно соединение с базой данных со 120 КБ до 48 КБ
Конфигурация по-прежнему использует параметры конфигурации SQLITE_DBCONFIG_LOOKASIDE или SQLITE_CONFIG_LOOKASIDE, как описано выше, с параметрами sz и cnt. Общий объём пространства кучи, используемого для lookaside, по-прежнему составляет sz*cnt байт. Однако это пространство распределяется между lookaside с малыми слотами и lookaside с большими слотами, с приоритетом для lookaside с малыми слотами
Общее количество слотов, как правило, превышает cnt, поскольку sz обычно значительно больше размера малого слота в 128 байт
Конфигурация lookaside по умолчанию изменилась со 100 слотов по 1200 байт каждый (120 КБ) до 40 слотов по 1200 байт каждый (48 КБ). Это пространство в итоге распределяется как 93 слота по 128 байт каждый и 30 слотов по 1200 байт каждый. Таким образом, доступно больше слотов lookaside, но используется значительно меньше пространства кучи
Конфигурация lookaside по умолчанию, размер малых слотов и детали распределения пространства кучи между малыми и большими слотами могут изменяться от одного выпуска к другому
Статистика использования памяти и ограничения
По умолчанию SQLite ведёт статистику использования памяти. Эта статистика помогает определить, сколько памяти приложению действительно требуется. Статистику также можно использовать в высоконадёжных системах для определения того, приближается ли потребление памяти к пределам, установленным доказательством Робсона, или превышает их, а следовательно, подсистема выделения памяти рискует выйти из строя
Большинство статистических показателей являются глобальными, поэтому отслеживание статистики должно сериализоваться с помощью мьютекса. Статистика включена по умолчанию, однако существует возможность её отключить. При отключении статистики памяти SQLite избегает захвата и освобождения мьютекса при каждом выделении и освобождении памяти. Это даёт заметный выигрыш в системах, где операции с мьютексами обходятся дорого. Для отключения статистики памяти при запуске используется следующий интерфейс:
sqlite3_config(SQLITE_CONFIG_MEMSTATUS, onoff);
Параметр onoff принимает значение true для включения отслеживания статистики памяти и false для её отключения
При условии, что статистика включена, для доступа к ней можно использовать следующую функцию:
sqlite3_status(verb, ¤t, &highwater, resetflag);
Аргумент verb определяет, к какому показателю осуществляется доступ. Определено несколько различных значений verb. Ожидается, что их список будет расширяться по мере развития интерфейса sqlite3_status(). Текущее значение выбранного параметра записывается в целое число current, а наибольшее историческое значение — в целое число highwater
Если resetflag равен true, то после возврата из вызова отметка максимального значения сбрасывается до текущего значения
Для получения статистики, связанной с отдельным соединением с базой данных, используется другой интерфейс:
sqlite3_db_status(db, verb, ¤t, &highwater, resetflag);
Этот интерфейс аналогичен предыдущему, за исключением того, что первым аргументом принимает указатель на соединение с базой данных и возвращает статистику об этом конкретном объекте, а не обо всей библиотеке SQLite. В настоящее время интерфейс sqlite3_db_status() распознаёт только одно значение verb — SQLITE_DBSTATUS_LOOKASIDE_USED, хотя в будущем могут быть добавлены дополнительные значения
Статистика на уровне соединения не использует глобальные переменные и поэтому не требует мьютексов для обновления или доступа. Следовательно, статистика на уровне соединения продолжает работать даже при отключённом SQLITE_CONFIG_MEMSTATUS
Интерфейс sqlite3_soft_heap_limit64() позволяет установить верхнюю границу суммарного объёма памяти, которую аллокатор памяти общего назначения SQLite может удерживать одновременно. Если предпринимаются попытки выделить памяти больше, чем задано мягким ограничением кучи, SQLite сначала попытается освободить кэш-память, прежде чем продолжить выполнение запроса на выделение
Механизм мягкого ограничения кучи работает только при включённой статистике памяти и функционирует наилучшим образом, если библиотека SQLite скомпилирована с параметром времени компиляции SQLITE_ENABLE_MEMORY_MANAGEMENT
Мягкое ограничение кучи является «мягким» в следующем смысле: если SQLite не может освободить достаточно вспомогательной памяти, чтобы остаться в пределах ограничения, она всё равно выделяет дополнительную память и превышает установленный предел. Это происходит исходя из того, что лучше использовать дополнительную память, чем полностью завершить работу с ошибкой
Начиная с версии SQLite 3.6.1 (2008-08-06), мягкое ограничение кучи применяется только к аллокатору памяти общего назначения. Мягкое ограничение кучи не знает об аллокаторе памяти кэша страниц и аллокаторе памяти lookaside и не взаимодействует с ними. Этот недостаток, вероятно, будет устранён в одном из будущих выпусков
Математические гарантии защиты от сбоев выделения памяти
Проблема динамического выделения памяти, и в частности проблема сбоя аллокатора памяти, была исследована Дж. М. Робсоном, а результаты опубликованы в работе:
J. M. Robson. «Bounds for Some Functions Concerning Dynamic Storage Allocation». Journal of the Association for Computing Machinery, Volume 21, Number 8, July 1974, pages 491–499
Введём следующие обозначения (схожие, но не идентичные обозначениям Робсона):
| Обозначение | Значение |
|---|---|
| N | Объём сырой памяти, необходимый системе выделения памяти для гарантии того, что ни один запрос на выделение памяти никогда не завершится неудачей |
| M | Максимальный объём памяти, который приложение получило в своё распоряжение |
| n | Отношение наибольшего выделения памяти к наименьшему; предполагается, что размер каждого выделения памяти кратен наименьшему размеру выделения |
Робсон доказывает следующий результат:
N = M * (1 + (log₂ n) / 2) - n + 1
Говоря простым языком, доказательство Робсона показывает, что для гарантии безотказной работы любой аллокатор памяти должен использовать пул памяти размером N, превышающим максимальный объём памяти, когда-либо использованный M, на множитель, зависящий от n — отношения наибольшего к наименьшему размеру выделения
Иными словами, если только все выделения памяти не имеют в точности одинаковый размер (n=1), система нуждается в доступе к большему объёму памяти, чем она когда-либо будет использовать одновременно
Кроме того, мы видим, что требуемый объём избыточной памяти быстро растёт по мере увеличения отношения наибольшего размера выделения к наименьшему, что создаёт сильный стимул поддерживать все выделения как можно ближе к одному размеру
Доказательство Робсона является конструктивным. Он предоставляет алгоритм для вычисления последовательности операций выделения и освобождения памяти, которая приведёт к сбою выделения из-за фрагментации памяти, если доступная память хотя бы на один байт меньше N
И Робсон показывает, что аллокатор памяти с первым подходящим блоком степени двойки (такой как реализованный в memsys5) никогда не откажет в запросе на выделение памяти при условии, что доступная память составляет N байт или более
Значения M и n являются свойствами приложения. Если приложение построено таким образом, что оба значения M и n известны или по меньшей мере имеют известные верхние границы, и если приложение использует аллокатор памяти memsys5 и ему предоставлено N байт доступного пространства памяти с помощью SQLITE_CONFIG_HEAP, то Робсон доказывает, что ни один запрос на выделение памяти в приложении никогда не завершится неудачей
Иными словами, разработчик приложения может выбрать значение N, которое гарантирует, что ни один вызов какого-либо интерфейса SQLite никогда не вернёт SQLITE_NOMEM. Пул памяти никогда не окажется настолько фрагментированным, что новый запрос на выделение памяти не сможет быть удовлетворён
Это важное свойство для приложений, в которых программный сбой может причинить вред, физический ущерб или привести к потере невосстановимых данных
Вычисление и управление параметрами M и n
Доказательство Робсона применяется отдельно к каждому из распределителей памяти, используемых в SQLite: распределителю памяти общего назначения (memsys5), распределителю памяти pagecache и распределителю памяти lookaside
Для распределителей, отличных от memsys5, все выделения памяти имеют одинаковый размер. Следовательно, n=1 и поэтому N=M. Иными словами, пул памяти не должен быть больше, чем наибольший объём памяти, используемый в любой конкретный момент времени
Управление памятью pagecache несколько сложнее в SQLite версии 3.6.1, хотя в последующих релизах планируются механизмы, которые значительно упростят управление памятью pagecache. До введения этих новых механизмов единственным способом управления памятью pagecache является использование прагмы cache_size
Приложения, критичные к безопасности, как правило, захотят изменить конфигурацию lookaside-памяти по умолчанию таким образом, чтобы при выделении начального буфера lookaside-памяти во время sqlite3_open() результирующее выделение памяти не было настолько большим, чтобы вынудить параметр n принять слишком большое значение
Для того чтобы удерживать n под контролем, лучше всего стараться держать наибольшее выделение памяти ниже 2 или 4 килобайт. Таким образом, разумной конфигурацией по умолчанию для распределителя lookaside-памяти может быть любая из следующих:
sqlite3_config(SQLITE_CONFIG_LOOKASIDE, 32, 32); /* 1K */
sqlite3_config(SQLITE_CONFIG_LOOKASIDE, 64, 32); /* 2K */
sqlite3_config(SQLITE_CONFIG_LOOKASIDE, 32, 64); /* 2K */
sqlite3_config(SQLITE_CONFIG_LOOKASIDE, 64, 64); /* 4K */
Другой подход — изначально отключить распределитель lookaside-памяти:
sqlite3_config(SQLITE_CONFIG_LOOKASIDE, 0, 0);
Затем позволить приложению поддерживать отдельный пул более крупных буферов lookaside-памяти, которые оно может распределять между соединениями с базой данных по мере их создания. В типичном случае приложение будет иметь только одно соединение с базой данных, и поэтому пул lookaside-памяти может состоять из одного большого буфера:
sqlite3_db_config(db, SQLITE_DBCONFIG_LOOKASIDE, aStatic, 256, 500);
Распределитель lookaside-памяти задуман прежде всего как оптимизация производительности, а не как метод обеспечения безотказного выделения памяти, поэтому полное отключение распределителя lookaside-памяти для операций, критичных к безопасности, вполне оправдано
Распределитель памяти общего назначения является наиболее сложным пулом памяти для управления, поскольку он поддерживает выделения различных размеров. Поскольку n является множителем для M, мы хотим удерживать n как можно меньшим. Это говорит в пользу того, чтобы минимальный размер выделения для memsys5 был как можно большим
В большинстве приложений распределитель lookaside-памяти способен обрабатывать небольшие выделения. Поэтому разумно установить минимальный размер выделения для memsys5 равным 2, 4 или даже 8 максимальным размерам выделения lookaside. Минимальный размер выделения в 512 байт является разумной настройкой
Помимо удержания n на малом уровне, желательно держать под контролем размер наибольших выделений памяти. Крупные запросы к распределителю памяти общего назначения могут поступать из нескольких источников:
- Строки SQL-таблиц, содержащие большие строки или BLOB-объекты
- Сложные SQL-запросы, компилируемые в большие подготовленные операторы
- Объекты SQL-парсера, используемые внутри
sqlite3_prepare_v2() - Пространство для хранения объектов соединения с базой данных
- Выделения памяти кэша страниц, переполняющиеся в распределитель памяти общего назначения
- Выделения буфера lookaside для новых соединений с базой данных
Последние два выделения можно контролировать и/или устранить путём соответствующей настройки распределителя памяти pagecache и распределителя lookaside-памяти, как описано выше. Пространство для хранения, требуемое объектами соединения с базой данных, в некоторой степени зависит от длины имени файла базы данных, но редко превышает 2 КБ на 32-битных системах
На 64-битных системах требуется больше пространства из-за увеличенного размера указателей. Каждый объект парсера использует около 1,6 КБ памяти. Таким образом, элементы с 3 по 6 из приведённого выше списка легко поддаются контролю, позволяя удерживать максимальный размер выделения памяти ниже 2 КБ
Если приложение разработано для управления данными небольшими фрагментами, то база данных никогда не должна содержать больших строк или BLOB-объектов, и следовательно, первый элемент из приведённого выше списка не должен быть фактором
Если база данных всё же содержит большие строки или BLOB-объекты, их следует читать с использованием инкрементального BLOB I/O, а строки, содержащие большие строки или BLOB-объекты, никогда не должны обновляться никаким иным способом, кроме инкрементального BLOB I/O
В противном случае процедуре sqlite3_step() в какой-то момент потребуется прочитать всю строку в непрерывную область памяти, что повлечёт за собой как минимум одно крупное выделение памяти
Последним источником крупных выделений памяти является пространство для хранения подготовленных операторов, получаемых в результате компиляции сложных SQL-операций. Продолжающаяся работа разработчиков SQLite направлена на сокращение объёма пространства, требуемого здесь. Однако большие и сложные запросы всё ещё могут требовать подготовленных операторов размером в несколько килобайт
Единственным обходным решением на данный момент является разбиение приложением сложных SQL-операций на две или более меньших и более простых операций, содержащихся в отдельных подготовленных операторах
В целом, приложения, как правило, должны быть в состоянии удерживать максимальный размер выделения памяти ниже 2 КБ или 4 КБ. Это даёт значение log₂(n), равное 2 или 3. Это ограничит N до значения от 2 до 2,5 раз M
Максимальный объём памяти общего назначения, необходимый приложению, определяется такими факторами, как количество одновременно открытых соединений с базой данных и объектов подготовленных операторов, используемых приложением, а также сложностью подготовленных операторов
Для любого конкретного приложения эти факторы, как правило, фиксированы и могут быть определены экспериментально с помощью SQLITE_STATUS_MEMORY_USED. Типичное приложение может использовать лишь около 40 КБ памяти общего назначения. Это даёт значение N порядка 100 КБ
Пластичный отказ
Если подсистемы выделения памяти внутри SQLite настроены на безотказную работу, но фактическое использование памяти превышает проектные ограничения, установленные доказательством Робсона, SQLite обычно продолжает работать в штатном режиме. Распределитель памяти pagecache и распределитель lookaside-памяти автоматически переключаются на распределитель памяти общего назначения memsys5
И, как правило, распределитель памяти memsys5 продолжает функционировать без фрагментации даже в том случае, если M и/или n превышает ограничения, налагаемые доказательством Робсона
Доказательство Робсона показывает, что в этих обстоятельствах выделение памяти может дать сбой и завершиться неудачей, однако такой сбой требует особенно неблагоприятной последовательности выделений и освобождений — последовательности, которую SQLite никогда не наблюдался следующим
Поэтому на практике, как правило, ограничения, налагаемые Робсоном, могут быть превышены со значительным запасом без каких-либо негативных последствий
Тем не менее разработчикам приложений настоятельно рекомендуется отслеживать состояние подсистем выделения памяти и поднимать тревогу, когда использование памяти приближается к ограничениям Робсона или превышает их. Таким образом, приложение будет заблаговременно предупреждать операторов о надвигающемся сбое. Интерфейсы статистики памяти SQLite предоставляют приложению все необходимые механизмы для выполнения задачи мониторинга
Практические рекомендации по настройке памяти
Исходя из всего описанного выше, можно сформулировать несколько практических ориентиров для инженеров, которые настраивают SQLite под строгие требования
Если приложение работает в среде с жёсткими ограничениями памяти и недопустимостью сбоев, стоит использовать memsys5 в связке с SQLITE_CONFIG_HEAP и заранее рассчитать N по формуле Робсона. Я рекомендую начинать с экспериментального измерения M через SQLITE_STATUS_MEMORY_USED на реальной нагрузке, а затем закладывать коэффициент от 2 до 2,5 в зависимости от ожидаемого n
Для большинства встраиваемых приложений разумная стратегия выглядит так: настроить pagecache через SQLITE_CONFIG_PAGECACHE, ограничить lookaside конфигурацией не более 4 КБ на соединение, а для memsys5 установить минимальный размер выделения в 512 байт. Это позволяет удерживать n в диапазоне, при котором N не превышает M более чем в 2,5 раза
Для приложений, где производительность важнее гарантий безотказности, lookaside даёт прирост до 10–15% на Linux и Mac OS X без каких-либо изменений в логике приложения — достаточно правильно выбрать sz и cnt при инициализации соединения
Мониторинг через sqlite3_status() и sqlite3_db_status() стоит включать даже в производственных сборках: накладные расходы минимальны, а возможность заблаговременно обнаружить приближение к пределам Робсона окупается с лихвой
Стабильность интерфейсов памяти
Начиная с SQLite версии 3.7.0 (2010-07-21), все интерфейсы выделения памяти SQLite считаются стабильными и будут поддерживаться в будущих релизах
Ответы на эти вопросы могут быть для вас полезными
Что такое memsys5 и когда его стоит использовать?
Memsys5 — это встроенный в SQLite распределитель памяти, не использующий системный malloc(). Он активируется через SQLITE_CONFIG_HEAP и работает по алгоритму «степень двойки, первый подходящий». Его стоит использовать во встраиваемых системах и приложениях с высокими требованиями к надёжности, где необходимы математические гарантии отсутствия сбоев выделения памяти, подтверждённые доказательством Робсона
Как рассчитать необходимый объём памяти N для гарантии безотказной работы?
По формуле Робсона: N = M × (1 + (log₂ n) / 2) − n + 1, где M — максимальный объём памяти, одновременно используемый приложением, а n — отношение наибольшего выделения к наименьшему. Значение M можно измерить экспериментально через SQLITE_STATUS_MEMORY_USED. При удержании максимального выделения ниже 2–4 КБ значение N, как правило, не превышает M более чем в 2,5 раза
Зачем нужен аллокатор lookaside и насколько он ускоряет работу?
Lookaside снижает нагрузку на системный malloc() за счёт предварительно выделенного пула слотов фиксированного размера на каждое соединение с базой данных. Поскольку все слоты одинакового размера, выделение и освобождение сводятся к операциям со связным списком без мьютексов. На практике это даёт прирост производительности SQLite до 10–15% на Linux и Mac OS X
Можно ли использовать SQLite без каких-либо вызовов системного malloc()?
Да. Для этого нужно скомпилировать SQLite с SQLITE_ENABLE_MEMSYS5 и передать собственный буфер памяти через sqlite3_config(SQLITE_CONFIG_HEAP, ...). После этого SQLite будет удовлетворять все потребности в памяти из предоставленного буфера, не обращаясь к системным malloc() и free()
Как убедиться, что SQLite не допускает утечек памяти в моём приложении?
SQLite гарантирует отсутствие утечек памяти при условии, что приложение корректно уничтожает все созданные объекты: вызывает sqlite3_finalize() для каждого подготовленного запроса и sqlite3_close() для каждого соединения с базой данных. Это свойство проверяется на всём наборе тестов SQLite, включая сценарии с симулированными OOM-ошибками, и подтверждается как набором тестов TCL (покрытие операторов >99%), так и стендом TH3 (100% покрытие ветвей)



