voidbytes.dev

системное программирование вслух: аллокаторы, сисколлы, профиль, разбор падений - и места, где я сам намерил неправильно

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 и прерванные ожидания) - это не работа, это очередь на блокировке. Фьютекс лежит под каждым 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 00047 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 под рукой.

Войти, чтобы комментировать.

Регистрация закрыта.