{"сайт": "voidbytes.dev", "обновлено": "2026-10-06", "записей": 9, "записи": [{"url": "/futex.html", "title": "Фьютекс в профиле: p99 держал глобальный мьютекс", "date": "2026-10-06", "tags": ["конкурентность", "профилирование"], "summary": "Сервис раз в сотню запросов отвечал на три миллисекунды дольше обычного. Я неделю подозревал аллокатор, а виноват был один общий мьютекс и очередь на нём.", "text": "6 октября 2026 7 мин конкурентность профилирование Фьютекс в профиле: p99 держал глобальный мьютекс Разбор того, как медианная задержка была в норме, а хвост p99 жил своей жизнью. С замерами, выводом strace -c , двумя неверными гипотезами и переходом на шардирование блокировки. Что болело Сервис-агрегатор принимает по сети записи и складывает их в общий индекс в памяти. Медиана ответа 0,3 мс, p50 и p90 ровные, а p99 скачет до 2,9 мс и обратно, без привязки к объёму запроса. На графике это выглядело как редкие иглы раз в сотню-другую запросов, и жалобы шли именно на них. Первая мысль была про аллокатор - индекс растёт, значит где-то malloc под замком арены glibc. Я потратил на эту гипотезу три дня и был неправ: арена glibc шардирована по потокам, и под нагрузкой в 16 потоков конфликт там был, но на хвост он не влиял. Где горит Хвост задержки - это почти всегда ожидание, а не счёт. Поэтому правильный первый прибор здесь не профиль по циклам, а состав системных вызовов: если поток спит, циклы он не жжёт, и perf record его не покажет. strace -f -c за десять секунд под нагрузкой: $ strace -f -c -p $(pidof aggr) -- sleep 10 % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 71.4 4.812051 40 120301 812 futex 12.1 0.815402 6 132044 recvfrom 8.9 0.599774 4 131002 sendto 4.2 0.283001 9 30112 epoll_wait Семьдесят процентов времени в futex с ошибками ( ETIMEDOUT и прерванные ожидания) - это не работа, это очередь на блокиров"}, {"url": "/arena.html", "title": "Арена вместо malloc: девять раз и цена в RSS", "date": "2026-09-18", "tags": ["аллокаторы", "память"], "summary": "Заменил поштучные malloc на bump-аллокатор и получил 1,42 с → 0,158 с. А потом алерт по памяти показал, что я заплатил за это полутора гигабайтами RSS.", "text": "18 сентября 2026 7 мин аллокаторы Арена вместо malloc: девять раз на парсере и полтора гигабайта, которые я проглядел Разбор замены поштучных malloc на bump-аллокатор в парсере журналов. С числами, кодом, выравниванием и описанием того, как я чуть не отправил в прод ускорение, которое утроило бы потребление памяти. Что болело Утилита parse читает журнальные файлы по строке, разбирает каждую в дерево узлов и складывает агрегаты. Один файл за сутки - это 2,3 млн строк и примерно 11,9 млн узлов, то есть столько же вызовов malloc и столько же free в конце. На моём стенде прогон занимал 1,42 с, и это никого не устраивало, потому что файлов за месяц тридцать. Профиль был необычно честным - ровно тот случай, когда вершина и есть причина: $ perf record -e cycles:pp -g ./parse logs/2026-09-14.jsonl $ perf report --stdio --percent-limit 3 32,1% parse libc.so.6 [.] _int_malloc 18,7% parse libc.so.6 [.] _int_free 10,4% parse parse [.] node_new 6,2% parse libc.so.6 [.] malloc_consolidate Половина времени уходит в сам аллокатор. Узлы живут до конца разбора файла и умирают все вместе - классическая ситуация, под которую и придумана арена. Что я написал Арена - это список блоков и смещение внутри текущего. Выделение сдвигает смещение, освобождения по одному объекту нет вовсе. typedef struct Block { struct Block *next; size_t used, cap; unsigned char mem[]; } Block; typedef struct { Block *head; size_t blk; } Arena; static void *arena_alloc(Arena *a, size_t n, size_t align) { size_t off = (a-"}, {"url": "/tlb.html", "title": "Промахи TLB: шесть гигабайт случайного доступа", "date": "2026-08-12", "tags": ["mmu", "кэш", "профилирование"], "summary": "Хеш-таблица на шесть гигабайт тормозила на ровном месте. Я был уверен, что это кэш последнего уровня, а счётчики показали обход страниц.", "text": "12 августа 2026 6 мин mmu кэш профилирование Промахи TLB: шесть гигабайт случайного доступа Почему хеш-таблица на шесть гигабайт работала вдвое медленнее, чем должна, при идеальном по виду профиле. Про обход страниц, счётчик dTLB-load-misses и большие страницы на 2 МиБ. Что болело Оффлайн-джоб строит хеш-таблицу на 400 млн записей, около 6 ГиБ, и потом делает по ней 2 млрд случайных поисков. Прогон занимал 3,1 с, и это казалось нормой, пока я не прикинул нижнюю границу: при латентности памяти ~90 нс и случайном доступе выходило около 1,8 с. Полтора раза куда-то девались. Гипотеза про кеш Случайный доступ к 6 ГиБ - очевидный кандидат на промахи кеша последнего уровня. perf stat это подтвердил, но не до конца: $ perf stat -e cycles,instructions,LLC-load-misses,dTLB-load-misses ./probe 9 412 004 551 cycles 3 010 882 110 instructions # 0,32 insn per cycle 1 904 221 013 LLC-load-misses 410 882 004 dTLB-load-misses Промахи LLC ожидаемы - по одному почти на каждый поиск. Но dTLB-load-misses в 410 млн меня остановили: это промах буфера трансляции адресов, и каждый из них запускает обход таблицы страниц, отдельные обращения к памяти ещё до самого доступа к данным. 0,32 инструкции за такт - процессор почти всё время ждёт, и часть этого ожидания не LLC, а именно обход страниц. Обход страниц Таблица на 6 ГиБ при страницах по 4 КиБ - это 1,5 млн страниц. TLB второго уровня держит порядка полутора тысяч записей; при случайном доступе почти каждый поиск промахивается и мимо TLB, и мимо кеша"}, {"url": "/false-sharing.html", "title": "Ложное разделение: счётчики в одной кеш-линии", "date": "2026-05-05", "tags": ["конкурентность", "кэш"], "summary": "Добавил потоков - стало медленнее. Восемь счётчиков лежали в одной 64-байтной линии, и ядра дрались за неё по шине.", "text": "5 мая 2026 6 мин конкурентность кэш Ложное разделение: счётчики в одной кеш-линии История о том, как больше потоков дали меньше скорости. Про массив счётчиков в одной 64-байтной линии, perf c2c , состояние HITM и выравнивание на кеш-линию. Что болело Параллельный сборщик статистики: N потоков, каждый инкрементит свой счётчик в массиве uint64_t cnt[N] . По логике - идеальное распараллеливание, потоки не трогают чужие ячейки. На деле на восьми потоках было медленнее, чем на двух, и это первый признак, что потоки всё-таки дерутся за общий ресурс, которого я не вижу в коде. perf c2c Когда потоки конкурируют за память, которой в коде не делят, это почти всегда ложное разделение - разные данные в одной кеш-линии. Прибор ровно под это - perf c2c , он ловит передачи модифицированной линии между ядрами (HITM, hit-modified): $ perf c2c record ./collect && perf c2c report --stdio Shared Data Cache Line Table # Total Tot ----- HITM ----- # Index records Hitm LclHitm RmtHitm Cacheline 0 284113 96.4% 14201M 0 0x55a1c0004a00 Shared Cache Line Distribution Off Node PID cpu code addr symbol 0x00 0 2041 1 collect+0x41 cnt[0] 0x08 0 2042 3 collect+0x41 cnt[1] 0x10 0 2043 5 collect+0x41 cnt[2] Одна кеш-линия по адресу cnt[0] , 96 % всех HITM, и в её распределении видно восемь разных PID по смещениям 0x00, 0x08, 0x10… Восемь счётчиков uint64_t по 8 байт лежат подряд и укладываются ровно в одну 64-байтную линию. Каждый инкремент любого из них делает линию грязной у одного ядра, и остальным семи пр"}, {"url": "/strace.html", "title": "Девяносто два процента сисколлов - мои же", "date": "2026-04-02", "tags": ["сисколлы", "профилирование"], "summary": "Демон жёг 140 % CPU без полезной работы. Я начал с strace, прочитал его вывод неправильно и три часа подозревал epoll.", "text": "2 апреля 2026 6 мин системные вызовы Девяносто два процента системных вызовов оказались моими же Демон жёг 140 % процессора, не делая ничего полезного. Разбор того, как я неправильно прочитал вывод strace, чем померил по-настоящему и почему прибор, который замедляет объект в девять раз, не годится для проверки «стало ли лучше». Симптом Фоновый сервис, принимающий события по сети и складывающий их в файлы. Нагрузка - около 900 событий в секунду, работа копеечная. При этом top показывал 140 % CPU на процесс, а ядро - 48 100 системных вызовов в секунду. На событие приходилось больше пятидесяти обращений к ядру, и это выглядело абсурдно. Первый прибор и первая ошибка Взял strace , как и положено: $ strace -f -c -p 2841 ^C % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------- 61,44 4,118702 437 9 420 epoll_wait 19,02 1,275061 42 29 884 write 12,88 0,863404 53 16 344 8 102 futex 3,71 0,248719 27 9 102 read ... И три часа подозревал epoll_wait , потому что он стоял первым. Это чистая ошибка чтения: колонка % time у блокирующих вызовов означает время ожидания, а не работы. Поток, который честно спит в epoll_wait , всегда будет на вершине такой таблицы и никогда не будет виноват в загрузке процессора. Правильная колонка здесь - calls . По ней картина другая: write и futex дают 46 228 вызовов из 50 тысяч, то есть 92 %. Вторая ошибка: мерил прибором, который меняет объект За десять секунд под strace набралось около 50 тысяч вызовов "}, {"url": "/perf-lie.html", "title": "Профиль показал memcpy, а дело было в кеше", "date": "2025-11-20", "tags": ["профилирование", "кэш"], "summary": "Три дня оптимизировал копирование, которое стояло на вершине профиля, и получил минус один процент. Счётчики кеша объяснили, почему.", "text": "20 ноября 2025 7 мин профилирование Профиль показал memcpy, а виноват был обход массива Три дня я оптимизировал функцию, которая стояла на вершине профиля, и получил минус один процент. Разбор того, почему сэмплирующий профиль показывает место ожидания, а не причину, и почему порядок «сначала счётчики» у меня теперь жёсткий. Задача Слияние двух отсортированных наборов записей. Запись - 48 байт: идентификатор, метка времени, девять полей, которые при слиянии не нужны. Всего 40 млн записей, прогон 2,31 с. typedef struct { uint64_t id; /* 0 */ uint64_t ts; /* 8 */ uint32_t flags; /* 16 */ char tag[12]; /* 20 */ double v[2]; /* 32 */ } Rec; /* sizeof = 48 */ Сравниваются id и ts , то есть шестнадцать байт из сорока восьми. Остальное просто переносится в выходной массив. Что сказал профиль $ perf record -e cycles:pp -g ./merge in1.bin in2.bin out.bin $ perf report --stdio --percent-limit 4 18,4% merge libc.so.6 [.] __memmove_avx_unaligned_erms 14,9% merge merge [.] merge_run 11,2% merge merge [.] rec_less ... Вершина - копирование. Дальше три дня честной работы: убрал одно лишнее копирование целиком, оставшееся переписал на запись по 32 байта вместо вызова, выровнял выходной буфер. Итог 2,28 с вместо 2,31 - минус 1,3 %, то есть ничего. Правка от 24.11.2025: в первой версии записи я утверждал, что cycles:pp «устраняет смещение сэмпла». Это сказано слишком сильно - точный режим привязывает сэмпл к инструкции, но стоимость ожидания памяти всё равно приписывается той инструкции, котор"}, {"url": "/sigbus.html", "title": "SIGBUS раз в три дня: packed и атомарный доступ", "date": "2025-06-11", "tags": ["падения", "выравнивание", "arm"], "summary": "Падало на двух узлах из девяти, раз в двое-четверо суток, и только на aarch64. Санитайзер нашёл причину за сорок миллисекунд.", "text": "11 июня 2025 8 мин отладка SIGBUS раз в три дня: шесть недель и одна упакованная структура Сервис падал на двух узлах из девяти, раз в двое-четверо суток, только на aarch64 и только под нагрузкой. Я три недели проверял неверную гипотезу, а санитайзер нашёл причину за сорок миллисекунд работы. Что было известно вначале Девять узлов: семь x86-64 и два aarch64. Падает только на двух последних. Сигнал - SIGBUS , а не привычный SIGSEGV , и это первое, что стоило прочитать внимательно, а не пролистать. Интервал между падениями от 44 до 91 часа, корреляции со временем суток и с нагрузкой на первый взгляд нет. Из dmesg на узле: svc[2841]: unhandled signal 7 code 0x1 at 0x0000ffff8c1d20b5 CPU: 2 PID: 2841 Comm: svc Not tainted 6.6.32 #1 aarch64 Сигнал 7 - это SIGBUS , код 0x1 - BUS_ADRALN , то есть ошибка выравнивания. Адрес …b5 не кратен даже двум. В этот момент можно было закончить, но я туда не посмотрел и ушёл не в ту сторону. Неверная гипотеза, три недели Я знал второй способ получить SIGBUS : обращение к отображённому файлу за его фактическим концом. Сервис как раз отображает индексный файл через mmap , а внешний процесс его периодически перестраивает - то есть теоретически мог усечь под нами. Гипотеза ложилась на всё: редкость, зависимость от узла (на aarch64 стояли узлы с индексом), отсутствие видимой закономерности. Я добавил fstat после отображения, проверку длины перед каждым доступом, перехват SIGBUS с переотображением. Падения продолжились с той же частотой. Это был хорош"}, {"url": "/abi.html", "title": "Восьмой аргумент: прототип короче определения", "date": "2024-10-08", "tags": ["abi", "падения"], "summary": "Функция возвращала правильный результат ровно до тех пор, пока на стеке под ней лежал нуль. Разбор по SysV AMD64 и дизассемблеру.", "text": "8 октября 2024 6 мин ABI Восьмой аргумент: прототип без одного параметра и мусор в ответе Функция возвращала правильный результат до тех пор, пока на стеке под ней случайно лежал нуль. Разбор по соглашению вызова SysV AMD64, дизассемблеру и объяснение, почему компоновщик про это молчит. Симптом Внутренняя библиотека, функция записи события. В журнале время от времени появлялись строки с чудовищным значением приоритета - то 140 733 193 388 032, то ноль, то осмысленное число. Воспроизводилось на одной сборке и не воспроизводилось на другой из того же дерева. Определение жило в emit.c и принимало восемь параметров. В заголовке, которым пользовались все остальные единицы трансляции, стояло семь: когда-то добавили параметр и поправили не везде. /* emit.h - то, что видели вызывающие */ int svc_emit(int fd, const char *tag, uint64_t ts, int level, unsigned flags, size_t len, const void *data); /* emit.c - то, чем функция была на самом деле */ int svc_emit(int fd, const char *tag, uint64_t ts, int level, unsigned flags, size_t len, const void *data, int prio) { ... } Почему это компилируется и собирается В C имя функции в объектном файле - это просто имя. Типы параметров в символ не входят, манглирования как в C++ нет, поэтому компоновщик видит svc_emit с одной стороны и svc_emit с другой и совершенно справедливо их соединяет. Несоответствие прототипов между единицами трансляции - неопределённое поведение, но диагностировать его на этапе компоновки нечем, если не включён LTO. Что про"}, {"url": "/rss.html", "title": "RSS после free: фрагментация, а не утечка", "date": "2024-03-19", "tags": ["память", "аллокаторы"], "summary": "Два гигабайта остались в резидентной памяти после освобождения всего. Valgrind говорил «ноль потерь», и прав был он.", "text": "19 марта 2024 6 мин память RSS не падает после free, и это не утечка После пиковой нагрузки процесс держал два гигабайта резидентной памяти, хотя всё освободил. Я три дня искал утечку, не поверив прибору, который с первой попытки сказал правду: потерянных байт ноль. Наблюдение Сервис обрабатывает пачки документов: на пике держит в памяти около двух гигабайт, после обработки освобождает всё и ждёт следующую пачку. Проблема в том, что между пачками RSS оставался 2,0 ГиБ при 2,1 ГиБ на пике. Монитор узла считал это занятой памятью, и на четвёртом сервисе узел уходил в своп. Три дня, потраченные на недоверие Первым делом прогнал под valgrind и получил честное definitely lost: 0 bytes in 0 blocks . Я решил, что прибор чего-то не видит, потому что память-то вот она, занята. Потом прогнал massif , который показал пик выделений, совпадающий с ожидаемым, и спад до нуля в конце. Я и ему не поверил. Это и была ошибка: оба прибора ответили правильно на вопрос «есть ли недостижимая невозвращённая память». Её не было. Я же искал ответ на другой вопрос - «почему ядро считает память занятой», и к утечкам он отношения не имеет. Как на самом деле устроен возврат памяти Аллокатор glibc берёт память у ядра двумя путями. Крупные запросы (от порога M_MMAP_THRESHOLD , по умолчанию он начинается со 128 КиБ и подстраивается вверх по ходу работы) идут через mmap и при освобождении возвращаются ядру сразу. Всё остальное живёт в арене, которая растёт через brk - и вот её можно уменьшить только с конца. "}]}