Инженерное руководство

Поиск гонок данных Swift через Thread Sanitizer в Cloud Mac CI

Поиск гонок данных Swift через Thread Sanitizer в Cloud Mac CI

Если один и тот же набор XCTest стабильно проходит локально, но после переноса в параллельный конвейер периодически падает при записи в словарь, обращении к индексу массива или проверке состояния, причина обычно не в «нестабильной машине». Чаще всего два пути выполнения одновременно обращаются к общему изменяемому состоянию. Гонка данных зависит от конкретного порядка планирования, поэтому даже один повторный запуск легко скрывает проблему. Более эффективный подход — создать на облачном Mac отдельное тестовое задание с Thread Sanitizer, повысить вероятность проявления гонки, сохранить пакет результатов и по первой паре конфликтующих обращений восстановить модель владения состоянием.

Сначала отделите проверку Sanitizer от обычных тестов

Thread Sanitizer регистрирует обращения к памяти и отслеживает связи между потоками, поэтому увеличивает как время выполнения, так и потребление памяти. Не включайте его сразу для всего конвейера. Сначала создайте Scheme или Test Plan с именем ConcurrencySanitizer и добавьте туда только тесты, в которых состояние может изменяться из нескольких потоков: кэши, очереди загрузок, обёртки баз данных, мосты для обратных вызовов и параллельный разбор данных.

Рекомендуется разделить выполнение на три уровня:

Уровень Область тестирования Время запуска
Быстрая проверка Чистые функции и обычные модульные тесты При каждом коммите
Проверка гонок Тесты с высоким риском проблем конкурентности При каждом запросе на слияние
Расширенная проверка Полный набор модульных и интеграционных тестов Отдельное периодическое задание

Scheme необходимо сделать общим и сохранить в репозитории, иначе среда командной строки его не найдёт. Перед коммитом имя можно проверить командой xcodebuild -list -workspace App.xcworkspace. Для тестовой цели также следует устранить неявную случайность, создаваемую параллельным выполнением тестов, а затем намеренно организовать конкурентное выполнение внутри самих тестов. В этом случае нагрузка будет исходить из понятного тестового кода, а не от неконтролируемого планировщика.

Зафиксируйте воспроизводимый запуск из командной строки

Сначала проверьте названия симуляторов, установленных на облачном Mac, а затем замените устройство в примере фактическим значением. Назначайте каждому заданию отдельный каталог DerivedData, чтобы два исполнителя не перезаписывали один и тот же индекс и промежуточные артефакты сборки.

set -euo pipefail

RUN_ID="${CI_RUN_ID:-local}"
RESULT_DIR="$PWD/Artifacts/tsan"
DERIVED_DATA="$PWD/.derived-data/tsan-$RUN_ID"

mkdir -p "$RESULT_DIR"

xcodebuild test \
  -workspace App.xcworkspace \
  -scheme ConcurrencySanitizer \
  -configuration Debug \
  -destination 'platform=iOS Simulator,name=iPhone 16' \
  -derivedDataPath "$DERIVED_DATA" \
  -enableThreadSanitizer YES \
  -resultBundlePath "$RESULT_DIR/result.xcresult"

Не включайте Address Sanitizer одновременно с Thread Sanitizer. Совмещение двух видов инструментирования повышает потребление ресурсов и объём лишних сообщений в журналах, а также затрудняет определение источника сбоя. Конфигурация Release обычно включает оптимизацию и другую политику проверок, поэтому первый этап диагностики следует проводить с Debug. После подтверждения исправления можно дополнительно проверить другие конфигурации, если это требуется проекту.

Один успешный результат означает лишь, что при этом порядке планирования гонка не проявилась. Он не доказывает безопасность общего состояния.

Намеренно повысьте вероятность чередования задач

Самый полезный нагрузочный тест не повторяет вслепую запуск всего App, а одновременно выполняет чтение, запись, отмену и сброс вокруг одного общего объекта. В следующем тесте несколько задач конкурируют за обновление счётчика, поэтому он подходит для проверки того, что механизм обнаружения действительно работает.

final class UnsafeCounter: @unchecked Sendable {
    private(set) var value = 0

    func increment() {
        value += 1
    }
}

func testConcurrentIncrement() async {
    let counter = UnsafeCounter()

    await withTaskGroup(of: Void.self) { group in
        for _ in 0..<100 {
            group.addTask {
                counter.increment()
            }
        }
    }

    XCTAssertEqual(counter.value, 100)
}

@unchecked Sendable — не исправление, а лишь перенос ответственности на разработчика. Если реальный код зависит от этой аннотации, каждый случай её использования следует включить в проверку. Чтобы повысить вероятность обнаружения проблемы, тесты с высоким риском можно повторять от 20 до 50 раз, однако для всего конвейера необходимо задать ограничение времени. Не добавляйте случайные длительные задержки. Короткий вызов Task.yield() лучше увеличивает вероятность чередования, сохраняя выполнение управляемым.

Исправьте модель владения состоянием

Для состояния бизнес-логики предпочтительно назначить один Actor единственным владельцем записи:

actor Counter {
    private var value = 0

    func increment() {
        value += 1
    }

    func currentValue() -> Int {
        value
    }
}

Вызывающий код должен обращаться к нему через await, поэтому граница владения становится частью системы типов. Если существующий интерфейс обязан оставаться синхронным, можно использовать одну явно определённую последовательную очередь или блокировку с очень узкой областью действия. Однако нельзя блокировать одни пути, оставляя другим прямой доступ для чтения. Блокировка защищает инвариант, а не только одну строку присваивания.

Начинайте чтение отчёта с первой пары конфликтующих обращений

Отчёты Thread Sanitizer часто бывают очень длинными. Сначала игнорируйте последующие каскадные ошибки и рассматривайте только первую пару Read и Write либо две операции Write. Для каждого обращения запишите поток, очередь, строку исходного кода и место создания объекта, а затем ответьте на три вопроса:

  1. Обращаются ли оба пути к одному и тому же экземпляру?
  2. Кто должен владеть этим состоянием?
  3. Охватывает ли граница синхронизации всю операцию чтения, изменения и записи?

Например, cache[key] = value выглядит как одна строка, но внутри может включать поиск, расширение ёмкости и запись. Отдельные отметки до и после вызова не обеспечивают взаимного исключения. Аналогично, проверка массива на непустое состояние и последующее получение первого элемента должны выполняться в одной области изоляции. В противном случае другая задача может очистить массив между проверкой и чтением.

Сохраняйте .xcresult, а не только последние несколько десятков строк терминала. Сначала можно экспортировать структурированную сводку:

xcrun xcresulttool get test-results summary \
  --path Artifacts/tsan/result.xcresult \
  --format json > Artifacts/tsan/summary.json

Доступность команды зависит от локальной версии Xcode, поэтому скрипт должен сначала выполнить xcrun xcresulttool help и проверить наличие нужной подкоманды. Пакет результатов, идентификатор коммита исходного кода, имя Scheme и версию среды выполнения симулятора необходимо архивировать вместе. Без этого впоследствии будет трудно воспроизвести исходные условия.

Превратите проверку исправления в стабильный шлюз CI

После исправления сначала запустите исходный нагрузочный тест и убедитесь, что Sanitizer больше не сообщает о гонках. Затем выполните обычный набор тестов без инструментирования, чтобы проверить, не привели ли изменения изоляции к взаимным блокировкам, изменению порядка выполнения или тайм-аутам. При проверке кода также необходимо выяснить:

  • удалены ли необоснованные @unchecked Sendable;
  • существует ли для изменяемой коллекции только одна точка записи;
  • может ли continuation возобновиться повторно при преобразовании обратного вызова в async;
  • обходит ли Task.detached существующий Actor;
  • используют ли тестовые дублёры и глобальные синглтоны общее состояние между тестами;
  • полностью ли загружается .xcresult для неудачного задания.

Thread Sanitizer предназначен для обнаружения гонок, реально возникших во время выполнения, а строгая проверка конкурентности Swift ограничивает изоляцию на этапе компиляции. Эти механизмы не заменяют друг друга. Если предупреждения компилятора, конкурентные нагрузочные тесты и результаты Sanitizer разместить на разных этапах, причины сбоев станут понятнее. Конечная цель состоит не в том, чтобы отчёт исчез, а в том, чтобы у каждого изменяемого состояния был единственный владелец, чью роль можно объяснить и проверить.

Часто задаваемые вопросы

Нужно ли запускать Thread Sanitizer для каждого коммита?

Для каждого коммита достаточно небольшого набора тестов с высоким риском конкурентных ошибок. Полный набор лучше вынести в отдельное более длительное задание.

Доказывает ли успешный тест отсутствие гонок данных?

Нет. Он показывает лишь то, что в данном запуске конфликт не проявился. Повторы и намеренное параллельное выполнение увеличивают вероятность обнаружения.

Что выбрать для исправления: Swift Actor или блокировку?

Actor предпочтителен для связанного изменяемого состояния приложения. Блокировку стоит применять только для короткого синхронного участка с ясными правилами владения.

Выделенный физический узел

Развёртывание облачного Mac рабочего процесса в OpsVM

Выберите одну из трёх конфигураций Apple Silicon и 6 доступных узлов; актуальный статус доступности отображается в консоли в реальном времени.

Выбрать конфигурацию и оформить заказ