Состояния гонки данных и ограничения ThreadSanitizer
- title
- Состояния гонки данных и ограничения ThreadSanitizer
- type
- summary
- summary
- Фил Итон воссоздаёт детектор гонок вроде FastTrack и показывает пропуски явных гонок в C и Golang в TSan при исчерпании его лимитов
- tags
- concurrency, golang, c, testing, debugging
- created
- 2026-09-13
- updated
- 2026-09-14
- lang
- ru
- translation_of
- threadsanitizer-limits
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Статья Фила Итона за сентябрь 2026 года для The Consensus объясняет, что такое data-race, как ThreadSanitizer находит гонки и в каких случаях перестаёт их видеть. TSan важен, потому что на нём завязано почти всё: Clang, GCC, флаг -race в Golang, Swift и OCaml - все поставляют ThreadSanitizer от LLVM. При этом он скудно документирован. Для второй версии (2012) есть описанный алгоритм; для третьей версии (2021) её автор предложил просто читать исходники. Часть описанных ниже слепых зон также упоминается в главе 6 докторской диссертации Фарзама Доросткара (Farzam Dorostkar, 2025 год) об ограничениях реализации TSan v3.
Итон прямо подчёркивает: это не выпад против TSan. Он сам не хотел бы работать без него. Суть в том, чтобы понимать, чего именно успешный прогон без предупреждений не гарантирует.
Гонки данных и гонки общего вида
Стандарт C11 определяет гонку данных (data race) как два конфликтующих действия в разных потоках, из которых хотя бы одно неатомарно, и ни одно из них не предшествует другому (happens-before). Модель памяти Golang говорит примерно то же самое: запись, происходящая одновременно с другим чтением или записью того же участка памяти, если только каждое обращение не идёт через sync/atomic.
Сквозной пример - два потока, выполняющие v = counter; counter = v + 1. При обычной сборке код проходит CI почти всегда и падает при N=10000. Собранный с -fsanitize=thread или go build -race, он выявляется с первого же запуска. В Golang ошибка отлавливается даже при GOMAXPROCS=1, поскольку рантайм Golang сообщает TSan о каждой горутине так, будто это отдельный поток.
Оборачивание чтения и записи в две отдельные секции с блокировкой устраняет гонку данных, но оставляет ошибку. Программа всё так же теряет инкременты, но ни один детектор гонок ничего не скажет: каждое обращение теперь упорядочено мьютексом. Это уже гонка общего вида (general race), а вся остальная часть статьи посвящена исключительно гонкам данных.
Как работает обнаружение
Компиляция с -fsanitize=thread вставляет вызовы __tsan_read* и __tsan_write* вокруг обращений к памяти и __tsan_func_entry/__tsan_func_exit вокруг тел функций, после чего компонует бинарник с libtsan. Компилятор никак специально не обрабатывает pthreads; TSan перехватывает эти вызовы сам, а любая другая библиотека потоков должна явно сообщать TSan о своих примитивах.
Итон разбирает два семейства алгоритмов. Eraser (1997) отслеживает набор удерживаемых блокировок (lockset) при каждом обращении к разделяемому адресу и сообщает об ошибке, когда пересечение этих наборов становится пустым. Он даёт много ложноположительных срабатываний и не видит гонок в коде, который вообще не использует блокировки. TSan v1 сочетал lockset'ы с векторными часами; в v2 от lockset'ов отказались. Подход FastTrack, на который похожи v2 и v3 (не будучи при этом точной копией), выдаёт каждому потоку логические часы и вектор значений часов, известных ему для всех остальных потоков. Освобождение примитива (снятие блокировки, завершение потока) публикует вектор освобождающего; захват (блокировка, join) вычисляет покомпонентный максимум. Обращение считается состоянием гонки, если зафиксированное время последней записи новее того, что текущий поток знает об этом писателе.
Чтобы показать это наглядно, в статье реализуется интерпретатор крошечного подмножества C на Python, к которому подключается детектор в стиле FastTrack. Этот детектор ловит ту же гонку, что и TSan, на программе, где один поток пишет x = 1, а другой читает.
Где TSan пропускает гонки
Первая брешь - избыточное упорядочивание (over-ordering), пример из рисунка 2 в статье про Eraser. Один поток пишет в x, а затем захватывает и освобождает мьютекс; второй поток захватывает и освобождает тот же мьютекс, а затем пишет в x. Мьютекс ничего не защищает, но если захват случайно происходит именно в таком порядке, между двумя записями возникает ребро happens-before. TSan сообщил о гонке лишь 14 раз за 1000 прогонов.
Остальные проблемы связаны с фиксированными лимитами, удерживающими накладные расходы TSan в разумных границах, и обычная программа способна исчерпать каждый из них.
TSan отслеживает 255 слотов для потоков, а затем переиспользует их. Долгоживущий пишущий поток, затем поток короткоживущих worker'ов, а затем чтение переменной писателя: в C при 253 worker'ах отчёт ещё выдаётся, а при 254 - уже нет. Рантайм Golang забирает один слот под свои нужды, поэтому отсечка наступает на одну горутину раньше. Итон отмечает, что для сервиса на Golang, создающего по горутине на каждый запрос, это вполне обычная ситуация.
Часы каждого слота 14-битные. Поток, который достаточно много раз захватывает и освобождает блокировку, исчерпывает счётчик часов и заставляет TSan выделить новый слот. В C гонка ещё ловится при 4,1 миллиона пар захват/освобождение и уже не ловится при 4,2 миллиона. В Golang пара захват/освобождение мьютекса обходится в три операции release, поэтому порог срабатывания проходит между 1,3 и 1,4 миллиона операций.
История обращений хранится с гранулярностью 8 байт по четыре ячейки на гранулу. Записи другого потока в соседние байты той же гранулы не могут конфликтовать с исходной записью, но они занимают ячейки, и пятое уникальное обращение вытесняет запись, которая сформировала бы отчёт.
sync.Pool в Golang сообщает детектору гонок о захвате и освобождении адреса при каждом get и put, чтобы повторное использование объектов из пула между горутинами не выглядело как гонка. Он не может использовать собственный адрес объекта, у которого может быть своя синхронизация, поэтому хеширует указатель в один из 128 слотов. Два объекта в независимых пулах, попавшие в один слот хеш-таблицы, создают фиктивный порядок между своими горутинами, из-за чего реальная гонка между ними становится невидимой. По предположению Итона, таблица сделана столь маленькой потому, что накладные расходы на неё несёт каждый бинарник, со флагом -race или без него.
В качестве финального дополнительного упражнения предлагается найти пропуск обнаружения при использовании chan struct{}.
Связанные страницы
Это уже второй материал Итона в базе после golang-green-tea-gc, и ему свойственна та же манера воспроизводить утверждения на реальной машине, а не ограничиваться чтением документации. Он уточняет число из message-passing-shared-mutable-state, где детектор гонок поймал примерно половину неблокирующих багов в исследовании Golang от Tu et al: часть оставшейся половины составляют гонки общего вида, которые ни один детектор гонок данных в принципе не способен заметить. В go-channel-bug-patterns перечислены такие сценарии отказов. gosentry-go-fuzzing-fork запускает фаззинг-таргеты Golang со включённым детектором гонок, наследуя все упомянутые выше лимиты. О смысле "happens before" на уровне железа, заимствованном стандартом C11, см. shared-memory-consistency-causality. О горутинах, которые зависают навсегда, а не находятся в состоянии гонки, см. goroutine-leak-profiler.