Фьютекс в профиле: 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 и прерванные ожидания) - это не работа, это очередь на блокировке. Фьютекс лежит под каждым pthread_mutex_lock, который не смог взять замок сразу и ушёл спать в ядро. 120 тысяч таких вызовов за десять секунд при 16 потоках - значит потоки регулярно упираются в один и тот же мьютекс.
Почему один мьютекс
Индекс был защищён единственным pthread_mutex_t на всю структуру. Под чтением это ещё терпимо, но вставка держит замок на время перестройки корзины, а она изредка задевает рехеш. Пока один поток рехешит, пятнадцать стоят в очереди - и те из них, чей запрос пришёл в это окно, получают свой p99.
Проверил это прямым замером времени под замком через CLOCK_MONOTONIC вокруг критической секции: медиана удержания 0,4 мкс, максимум 2,6 мс - ровно хвост, который я видел снаружи. Совпадение максимума удержания и p99 ответа - это и есть доказательство, что виновата блокировка, а не сеть и не аллокатор.
Что сделал
Структуру я разбил на 64 независимых шарда по хешу ключа, каждый со своим мьютексом. Рехеш теперь локальный и блокирует 1/64 запросов, а не все. Код сводится к выбору шарда до взятия замка:
#define NSHARD 64
static inline struct shard *pick(uint64_t h) {
return &g_index.shard[h & (NSHARD - 1)];
}
void index_put(uint64_t h, item_t *it) {
struct shard *s = pick(h);
pthread_mutex_lock(&s->mu);
bucket_insert(&s->buckets, h, it); /* рехеш теперь трогает только s */
pthread_mutex_unlock(&s->mu);
}
64 выбрано не с потолка: это первая степень двойки выше числа потоков с запасом, чтобы вероятность двух потоков в одном шарде была низкой, но без лишней памяти на мьютексы. Маска & (NSHARD-1) вместо остатка - потому что деление на горячем пути дороже самой вставки.
Числа
Стенд: x86-64, 8 ядер, 16 потоков нагрузки, ядро 6.6, glibc 2.39, сборка -O2 -g -fno-omit-frame-pointer, пять прогонов по минуте, медиана.
| Метрика | Один мьютекс | 64 шарда |
|---|---|---|
| p50 ответа | 0,30 мс | 0,29 мс |
| p99 ответа | 2,90 мс | 0,41 мс |
| futex / с | 1 203 000 | 47 800 |
| конфликт на замке | 38 % | 2,1 % |
| пропускная способность | 181 k/с | 472 k/с |
Медиана не сдвинулась, и это правильно: шардирование не ускоряет одиночную вставку, оно убирает ожидание. Пропускная способность выросла в 2,6 раза - ровно потому, что потоки перестали стоять в очереди в ядре.
Где я ошибся
Три дня на аллокаторе - это цена того, что я начал с профиля по циклам, а не с состава вызовов. Профиль честно показывал bucket_insert и malloc на вершине, потому что они жгут циклы; ожидание в futex циклов не жжёт и в профиле по cycles невидимо. Хвост задержки нужно было с самого начала мерить как ожидание - strace -c или off-CPU профиль, - и тогда futex вылез бы в первую минуту. Правило себе записал: если болит p99, а не медиана, сначала смотри, где потоки спят, и только потом, где считают.
Темы: конкурентность профилирование
Комментарии (1)
Костыль - 7 октября 2026, 10:14
Классика жанра: неделю копаешь аллокатор, а виноват contention на одном мьютексе. Держу теперь perf lock-contention под рукой.
Войти, чтобы комментировать.
Регистрация закрыта.