Обложка: Multithreading в сейлинге. Ларнака, июнь 2025
Первые компьютеры были простыми: единственный юзер, запускающий единственный процесс с единственным потоком (даже термина такого ещё не было), которому доступен весь единственный процессор с единственным ядром и вся оперативная память системы. Данные существуют в одном экземпляре и обрабатываются одним процессом, ничего больше не может изменить данные или поменять их порядок. Каждая операция проходит в то время, когда это нужно и порядок операций ровно такой, как описано в коде.
Потом появляются многозадачные операционные системы, когда “одновременно” могут выполняться несколько процессов так, что каждый из них считает себя единственным в однозадачной системе. К слову “одновременно” тут может быть много претензий, потому что по факту в момент времени процессор выполняет одну задачу, принадлежащую одному процессу. Но пусть в нашем случае “одновременно” будет означать “на протяжении одной секунды” или даже “минуты”, потому что минутами проще оперировать человеку. Теперь процессор должен выполнять несколько программ “на протяжении одной секунды” и понимать когда переключаться между ними и сколкьо времени потратить на каждую. А ещё у нас есть память. Её тоже нужно поделить между программами, при этом не давать возможности читать/писать чужие данные, предоставить иллюзию, что вся память системы принадлежит процессу. А ещё диск, сеть и так далее. Многозадачность - первый вызов.
Второй вызов - многопоточность. Тут появляется второй процессор или новое ядро. Когда одна программа работает в несколько потоков и эти потоки обмениваются данными или могут читать/писать в общую для них область памяти. Тут мы встречаем таких зверей как race condition (состояние гонки), deadlock (взаимная блокировка), starvasion (один поток съедает все ресурсы) и других всадников апокалипсиса, приходящих в самое неподходящее время и часто вызывающие трудновоспроизводимые ошибки.
Одну из таких ошибок мне пришлось дебажить совсем недавно и в этом посте я хочу показать ход процесса
Как всё начиналось
Повис гитлаб: пайплайны висят в Pending, MR’ы не мержатся, индикатор загрузки крутится без остановки. При этом git push/pull работает, UI тоже в порядке. Посмотрел на внутренние метрики: очередь Sidekiq 654 задачи, latency 24 минуты, dead jobs 9999. Такое уже случалось, разбираться было некогда и я тупо рестартил весь гитлаб через gitlab-ctl restart. Ещё тогда я заметил, что sidekiq не может завершиться нормально и убивается SIGTERM.
Тут надо сделать небольшое отступление об архитектуре Gitlab CI. Это большой монстр, написанный на ruby. Состоит из нескольких компонентов:
- Puma - Ruby web-сервер, обслуживает HTTP/API запросы
- Sidekiq - Фоновые задачи: pipeline scheduling, notifications, housekeeping
- GitLab Shell - SSH-доступ к репозиториям
- Workhorse - Reverse proxy перед Puma, обрабатывает тяжёлые запросы (git clone/push, артефакты)
- PostgreSQL - Основная БД (пайплайны, джобы, проекты)
- Redis - Очереди Sidekiq, кеш сессий, ActionCable
- Gitaly - gRPC-сервис для операций с Git-репозиториями
Нас тут будет интересовать sidekiq. Это Ruby либа для работы с очередями из Redis в несколько потоков. Да - тот самый прародитель всех всадников апокалипсиса, появляющихся благодаря борьбе за ресурсы.
Осмотр места происшествия
Хочу показать саму суть дедлока, поэтому скрою под спойлером некоторые команды, релевантные только для гитлаба.
▼ Очередь sidekiq'а
# Состояние процесса
ps aux | grep sidekiq
# sidekiq 7.3.9 ... [20 of 20 busy]
# Очередь
gitlab-rails runner "
queue = Sidekiq::Queue.new('default')
puts 'Size: ' + queue.size.to_s
puts 'Latency: ' + queue.latency.round.to_s + 's'
"
# Size: 654
# Latency: 1447s
20 воркеров заняты, ни один не завершает задачи. Похоже, что-то зависло.
Смотрю что именно выполняется:
gitlab-rails runner "
Sidekiq::Workers.new.each do |pid, tid, work|
puts work['payload']['class']
end
" | sort | uniq -c | sort -rn
19 ProcessCommitWorker
1 UpdateMergeRequestsWorker
Все 19 воркеров выполняют ProcessCommitWorker. Уже интересно.
▼ Нахожу триггер
gitlab-rails runner "
event = Event.where(action: Event::PUSHED)
.order(created_at: :desc)
.first
puts event.created_at
puts event.data[:commits].length rescue puts event.push_event_payload.commit_count
"
# 160
160 коммитов. Полтора часа назад кто-то запушил ветку с 160 коммитами — результат rebase на актуальный master.
Почему воркеры не завершаются
Замерший процесс с потоками, которые не двигаются — это классический признак дедлока или бесконечного цикла. Чтобы понять что именно происходит внутри Ruby-процесса, нужно заглянуть в стектрейсы всех тредов.
▼ Под катом стектрейс sidekiq'a
В Sidekiq для этого есть встроенный механизм — сигнал TTIN:
kill -TTIN $(cat /var/run/gitlab/sidekiq.pid)
Этот сигнал заставляет Sidekiq дампнуть стектрейсы всех Ruby-тредов в лог.
grep -A 30 "TTIN" /var/log/gitlab/sidekiq/current | head -100
Одинаковая картина для каждого из 19 тредов:
Thread #7 [ProcessCommitWorker] (pid=2190208 tid=0x00007f...)
ProcessCommitWorker#perform
app/workers/process_commit_worker.rb:25
→ process_commit_message
app/services/git/process_commit_service.rb:51
→ create_cross_references!
app/models/concerns/mentionable.rb:112
→ referenced_mentionables
→ all_references
→ updated_cached_html_for
app/models/concerns/cache_markdown_field.rb:84
→ refresh_markdown_cache!
→ cacheless_render (Banzai renderer)
lib/banzai/renderer.rb:43
→ html/pipeline.rb instrument (outer)
→ perform_filter
→ html/pipeline.rb instrument (nested)
← БЛОКИРОВКА: mutex_m
Девять из девятнадцати тредов застряли на mutex_m. Остальные десять — на другом месте:
Thread #3 [ProcessCommitWorker] (pid=2190208 tid=0x00007f...)
...
→ html/pipeline.rb instrument
→ ActiveSupport::Notifications::Fanout#publish
→ concurrent-ruby MapBackend#[]
← БЛОКИРОВКА: Concurrent::Map (internal mutex)
Два разных mutex. Две группы тредов. Каждая группа держит один mutex и ждёт другой.
Анатомия дедлока
Вот что происходило внутри Banzai — GitLab’овского Markdown-рендерера:
Тред A:
1. Захватывает mutex_m (ruby Mutex#synchronize)
2. Вызывает ActiveSupport::Notifications.instrument
3. Внутри instrument — publish в Fanout
4. Fanout вызывает concurrent-ruby MapBackend
5. MapBackend пытается захватить Concurrent::Map mutex
6. ← ЖДЁТ — этот mutex держит Тред B
Тред B:
1. Захватывает Concurrent::Map mutex
2. Вызывает html-pipeline instrument (вложенный вызов)
3. Внутри — снова обращение к Mutex#synchronize
4. ← ЖДЁТ — этот mutex держит Тред A
Классический circular deadlock. Ни один тред не может продвинуться, потому что каждый ждёт ресурс который держит другой.
Это race condition: при одиночном вызове всё работает, но при 20 одновременных тредах выполняющих одинаковый код — возникает это конкретное переплетение порядка захватов.
▼ Почему именно этот push
ProcessCommitWorker запускается для каждого коммита в пуше — он ищет ссылки на разные элементы, вызывает интеграции (Jira), создаёт ссылки между объектами.
Push из 160 коммитов создал 100 задач ProcessCommitWorker.
Все 20 воркеров подхватили их одновременно. Каждый запустил Banzai-рендеринг для поиска ссылок в сообщении коммита. При 20 одновременных рендерингах возникло то самое переплетение — и все 20 замерли.
Пока они замерли, новые задачи (запуск пайплайнов, PostReceive) продолжали поступать в очередь. Но обрабатывать их было некому.
Как найти дедлок без TTIN
TTIN — специфичная фича Sidekiq/Ruby. Как быть с другими процессами?
/proc/<tid>/wchan
for tid in /proc/$PID/task/*; do
echo "$(basename $tid): $(cat $tid/wchan)"
done
При дедлоке все потоки будут в состоянии вроде lock, down, wait.
strace
strace -p <tid> -e trace=futex
# futex(0x..., FUTEX_WAIT, 1, NULL) = ?
# Process ... suspended
Тред навечно завис в FUTEX_WAIT — ждёт mutex который никогда не освободится.
gdb
gdb -p $PID
(gdb) thread apply all bt
Как предотвратить
В нашем случае предлагают разделить очереди Sidekiq. Вообще это выглядит рабочей рекомендацией для любой системы. Только надо понимать, что это не предотвратит саму возможность дедлока. Так мы просто вешаем на каждый поток свои задачи. И если несколько потоков заблокируются, то остальные продолжат выполнять свою работу и это не вызовет полного зависания системы. В нашем случае весь гитлаб не завис потому что он “микросервисный” - как я писал в начале состоит из нескольких компонентов. У нас завис sidekiq и перестали выполняться задачи, зависящие от него. А если бы очереди были разделены, то зависли бы только некоторые задачи из sidekiq’a. Надеюсь, описал понятно.
Дебаг был очень интересным. Главное, что теперь есть понимание подхода к дебагу таких проблем. И случилось это очень вовремя - как раз в то время, когда я решил изучать архитектуру распределенных приложений. Распределенности именно в этом примере, конечно нет, но есть типичная проблема - гонка за ресурсами и многопоточность. В следующем посте напишу про основные понятия и вызовы распределенных систем.

