Один mmap для вас — два SIGBUS для кого-то ещё?
Коротко. Amber может работать как встроенная библиотека внутри другого процесса, и это повышает требования к семантике отказов: любая ошибка I/O, которую нельзя безопасно обработать, должна остановить систему чисто, локально и явно, а не позволить ей молча продолжить работу, повредить данные и остаться в неопределённом состоянии. Это важно. Очень.
1. Коротко о mmap
mmap берёт файл и делает его байты доступными как обычный slice в адресном пространстве. После этого мы «читаем файл» обращениями к памяти, а ядро лениво подгружает нужные страницы и хранит их в page cache.
import "syscall"
f, _ := os.Open("segment.fidx")
defer f.Close()
fi, _ := f.Stat()
// data — это []byte размером с файл, но в RAM пока ничего нет:
data, err := syscall.Mmap(int(f.Fd()), 0, int(fi.Size()),
syscall.PROT_READ, syscall.MAP_SHARED)
if err != nil { /* ... */ }
defer syscall.Munmap(data)
// Это и есть «чтение». Выглядит как наносекундный доступ к памяти:
v := binary.BigEndian.Uint64(data[off : off+8])
Та же операция через явный I/O. pread — в Go это ReadAt — выполняет позиционное чтение: один syscall читает n байт со смещения off, не меняет общий offset файла и возвращает (n, err). Последняя часть даёт именно то, что нам нужно: ошибка становится значением, и её видно.
var buf [8]byte
if _, err := f.ReadAt(buf[:], off); err != nil {
return err // ошибка — значение, и её видно
}
v := binary.BigEndian.Uint64(buf[:])
Соблазн очевиден: бинарный поиск по mmap-slice ничем не отличается от поиска по обычному slice. Никаких syscall в горячем цикле, никакого управления буферами, page cache ОС бесплатно.
Что на самом деле происходит при data[off]
Строка v := data[off] врёт о своей стоимости. Если страница с адресом off не находится в памяти:
- CPU вызывает page fault.
- Управление переходит в ядро. Ядро проверяет, есть ли страница в page cache.
- Если её там нет, ядро синхронно читает страницу с диска. Поток ждёт.
- Страница отображается в память, инструкция запускается заново, и
data[off]«возвращает значение».
В исходном коде всё это невидимо. То, что выглядит как обращение к памяти, может превратиться в блокирующий дисковый I/O длительностью в миллисекунды. Мы не управляем моментом этого события: всё зависит от состояния page cache, которым управляет ядро.
Спасибо, планировщик
При вызове file.ReadAt goroutine входит в syscall через entersyscall и переходит в состояние _Gsyscall, а её P — в _Psyscall. P не отсоединяется сразу: runtime оптимистично сохраняет его на случай, если syscall быстро завершится. Но если чтение действительно блокируется на диске, sysmon видит P, застрявший в syscall, и через handoffp передаёт его другому M. Другие goroutines могут продолжить работу на этом P. sysmon сделает это не мгновенно, но такая возможность есть, потому что состояние goroutine видно runtime.
Page fault при обращении к mmap-памяти — не syscall Go. Это исключение CPU, которое ядро обрабатывает прозрачно. Runtime о нём не знает: нет ни entersyscall, ни перехода goroutine в другое состояние. С точки зрения runtime goroutine всё ещё находится в _Grunning на P со статусом _Prunning, хотя M застрял в обработчике page fault. Здесь sysmon бессилен: он не может передать P, потому что тот не находится в _Psyscall, и не может вытеснить M, потому что тот застрял в ядре и не дойдёт до safepoint, пока page fault не будет разрешён.
Поэтому mmap не просто скрывает I/O. Он скрывает его и от runtime Go, лишая планировщик информации, вокруг которой тот построен.
2. Зачем мне был нужен mmap в amber
В FTS-индексе есть «уникальная секция»: отсортированные 64-битные хеши токенов с df==1, около 13 МБ на сегмент. Сегментов десятки. Если держать всё это в памяти, получаются сотни мегабайт RAM для данных, к которым обращаются редко.
mmap решил бы проблему «бесплатно»: вся секция доступна по адресу, в память попадают только реально затронутые страницы, холодные страницы вытесняются ядром, а бинарный поиск по отсортированным хешам остаётся тривиальным кодом без syscall на каждом шаге.
Это учебный пример для mmap: большой отсортированный файл только для чтения, точечные случайные запросы и желание использовать page cache ОС. LMDB построен на этом и работает. Поэтому вопрос честный: почему бы и нам так не сделать?
Компромиссы по версии CIDR 2022
У mmap есть четыре класса проблем:
- Транзакционная безопасность. mmap может сбрасывать грязные страницы в произвольном порядке;
msyncне обеспечивает строгой очерёдности и способен нарушить гарантии WAL. Наш случай — только чтение, поэтому нас это почти не касается. - Задержки I/O. Любое обращение может вызвать page fault и неявно заблокироваться; мы теряем контроль над моментом I/O и не можем осмысленно делать prefetch.
- Обработка ошибок. Ошибки I/O во время page fault приходят как SIGBUS, а не как возвращаемые значения. Для нас это фатально.
- Производительность при вытеснении. Ядро вытесняет страницы в одном потоке; на многоядерной системе каждое вытеснение может потребовать TLB shootdown через межпроцессорные прерывания. Transparent huge pages способны добавлять скачки задержки.
Важно быть честными: mmap сам по себе не зло. Для данных с преобладанием чтения, помещающихся в RAM, на одном узле со стабильным локальным хранилищем это отличный инструмент. Даже если вынести fail-stop за скобки, mmap не даёт очевидного выигрыша для нашего профиля. Но это лишь контекст. Решающий фактор — наш собственный инвариант.
3. Требование amber: fail-stop
Fail-stop — это контракт: обнаружив ошибку, которую нельзя безопасно обработать, система останавливается чисто, локально и явно, а не продолжает работу в искажённом или неоднозначном состоянии. Amber построен вокруг этого принципа.
mmap несовместим с таким контрактом. SIGBUS — противоположность fail-stop.
Fail-stop говорит: обнаружь ошибку, чисто остановись и верни её как значение. SIGBUS говорит: процесс умирает на произвольной инструкции, а превратить это в аккуратно обработанную ошибку практически невозможно. В C можно попробовать signal handlers и sigsetjmp/siglongjmp; в runtime Go со стеками goroutines и собственной обработкой сигналов это несерьёзный вариант. Во встроенной библиотеке всё ещё хуже: пришлось бы перехватывать сигналы в чужом процессе.
А amber работает именно как встроенная библиотека внутри процесса пользователя. SIGBUS из-за нестабильного диска убивает не только amber, но и приложение-хост. Нельзя поставлять embedded-библиотеку, которая завершает чужой процесс из-за краткого сбоя сетевого диска.
Поэтому mmap категорически исключён самим контрактом: эта абстракция делает I/O невидимым. Мы построили хранилище вокруг инварианта, согласно которому I/O и его ошибки должны быть видимыми и возвращаться как значения.
К чему это привело
К формату индекса, в котором горячая секция по требованию читается через явный pread: каждый probe возвращает (n, err). Плохой блок становится ошибкой Go, которую мы поднимаем выше; reader использует подсчёт ссылок; отказ локализуется; процесс-хост продолжает жить. Этот формат называется AFT2.
Важный момент: pread не лишает нас page cache. Ядро кэширует страницы файла при ReadAt точно так же, как при mmap. Мы не теряем главное, ради чего собирались использовать mmap. Платим только за syscalls: O(log n) чтений на запрос вместо нуля при mmap. Для секции с df==1 это примерно 20 чтений на холодном и редком пути — небольшая цена за то, чтобы каждая ошибка стала возвращаемым значением, а I/O снова был виден планировщику.
4. AFT2, наш формат индекса
AFT2 заменил radix snapshot из fts-engine.
Путь к формату
- Мы начали с radix snapshot из fts-engine. Каждый posting в нём хранился как десятичная строка DocID внутри сериализованных узлов дерева. Для сегмента на 100 тысяч записей получалось 38 МБ на диске, 150 МБ в RAM и 400 мс на разбор. Файлы
.fidxзанимали 92% хранилища amber и определяли основной объём RSS. - Ключевым оказалось распределение токенов: в логах около 80% токенов имеют
df==1, то есть встречаются ровно в одной записи. Это части UUID, trace ID и другие идентификаторы. Обычный словарь строк требует около 40 байт заголовков string/slice на токен — примерно 50 МБ заголовков на сегмент в LRU исполнителя. - Разделяем по document frequency. Две популяции — два представления.
Структура .fidx (AFT2)
[ "AFT2" magic ]
[ секция df>=2: отсортированный словарь + delta-varint uint64 posting lists ]
[ секция df==1: два плоских массива 8-байтных значений: ]
[ fnv64a(token), по возрастанию ]
[ || параллельный массив полных entry ID ]
[ CRC32 ]
df>=2 — это десятки тысяч токенов, в основном слова из шаблонов сообщений. Секция находится в памяти, а поиск выполняется бинарно по словарю. Таких токенов немного, поэтому это дёшево.
df==1 — те самые 80%. Никаких string headers: только хеши и ID по 8 байт каждый. Коллизии не теряют записи: одинаковые хеши лежат рядом, а поиск возвращает все entries с таким хешем. Коллизия 64-битного хеша внутри сегмента — для миллиона токенов birthday probability составляет около 3e-8 — в худшем случае добавит одну постороннюю запись, но не потеряет нужную; проверки равенства числа результатов ловят это в бенчмарках.
Механика
В загруженном режиме секция df==1 не находится в памяти. Поиск читает её из файла через pread. После Save() индекс переводится в file-backed режим: массивы хешей в памяти — около 13 МБ на сегмент — очищаются, остаются только path + offset + count. Только что sealed индекс занимает в кэше исполнителя те же пару мегабайт, что и загруженный.
Так выглядит бинарный поиск из lookupUnique в file-backed режиме:
h := tokenHash(token)
var buf [8]byte
readU64 := func(off int64) (uint64, bool) {
if _, err := file.ReadAt(buf[:], off); err != nil {
return 0, false // плохой блок — значение, а не SIGBUS
}
return binary.BigEndian.Uint64(buf[:]), true
}
lo, hi := 0, n
for lo < hi {
mid := (lo + hi) / 2
v, ok := readU64(uniqOff + int64(mid)*8)
if !ok { return nil } // fail-stop в миниатюре
if v < h { lo = mid + 1 } else { hi = mid }
}
Вся суть — в строке if !ok. При mmap это обращение выглядело бы как data[mid*8], никакого !ok не существовало бы, а единственным режимом отказа была бы смерть процесса. pread превращает «процесс умер» в «функция вернула ошибку».