Объяснение Thread Sanitizer: гонки данных в Swift

Thread Sanitizer, также известный как TSan, представляет собой инструмент на основе LLVM для проверки проблем с потоками в написанном коде на языках Swift и C. Впервые он был представлен в Xcode 8 и может быть отличным инструментом для поиска менее заметных ошибок в вашем коде, таких как гонки данных.

В WeTransfer Thread Sanitizer помог нам устранить ненадежные тесты и странные сбои, которые мы не могли определить по определенной причине. Возможно, вы раньше не использовали Tsan, так как может быть немного неясно, что этот инструмент делает и как он работает. Поэтому пришло время погрузиться и объяснить, как улучшить ваш код с помощью Thread Sanitizer.

Что такое гонки данных?

Прежде чем мы погрузимся в Sanitizer: гонки данных в Swift, нам сначала нужно знать, что мы на самом деле ищем. Мы собираемся исправить то, что называется Data Race.

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

  • Непредсказуемое поведение
  • Повреждение памяти
  • Ненадежные тесты
  • Странные сбои

Поскольку гонка данных непредсказуема, она может возникать непоследовательно при тестировании вашего приложения. У вас может возникнуть сбой при запуске, который не повторяется при втором запуске приложения. Если это так, возможно, вы имеете дело с гонкой данных.

Примеры гонки данных в Swift

Как только вы узнаете, что такое гонка данных, вы сможете лучше выявлять потенциальные проблемы в своем коде. Однако иногда менее очевидно, что код потенциально может привести к гонке данных. Поэтому несколько примеров, чтобы показать вам, что такое Data Race.

В следующем фрагменте кода два разных потока обращаются к одному и тому же свойству String:

Поскольку фоновый поток записывает имя, у нас есть как минимум один доступ для записи. Поведение непредсказуемо, поскольку оно зависит от того, выполняется ли сначала оператор печати или запись. Это пример Data Race, который подтверждает Thread Sanitizer.

Гонка данных, вызванная ленивой переменной

Ленивая переменная откладывает инициализацию экземпляра до момента его первого вызова. Это означает, что запись данных произойдет в первый момент доступа к ленивой переменной. Когда два потока обращаются к одной и той же ленивой переменной в первый раз, может произойти гонка данных:

Использование Thread Sanitizer для обнаружения гонок данных

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

Вы можете сделать то же самое для своих тестовых схем, которые могут быть эффективным способом обнаружения ошибок, связанных с доступом к данным.

Как включить Thread Sanitizer

Вы можете включить Thread Sanitizer из конфигурации схемы:

Вы можете сделать то же самое для своих тестовых схем, которые могут эффективно отлавливать гонки данных.

Как работает Thread Sanitizer?

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

func updateName() {
    DispatchQueue.global().async {
        self.recordAndCheckWrite(self.name) // Added by the compiler
        self.name.append("Antoine van der Lee")
    }

    // Executed on the Main Thread
    self.recordAndCheckWrite(self.name) // Added by the compiler
    print(self.name)
}

Метод recordAndCheckWrite будет хранить метку времени для каждого доступа и каждого потока, используемого дезинфицирующим средством для обнаружения гонки данных.

Существуют ли какие-либо ограничения для Thread Sanitizer?

Thread Sanitizer имеет несколько ограничений:

  • Он поддерживается только для 64-битных симуляторов macOS и 64-битных iOS и tvOS.
  • watchOS не поддерживается
  • Вы не можете использовать Tsan на устройстве

ВЛИЯНИЕ НА ПРОИЗВОДИТЕЛЬНОСТЬ

Как указано в документации Apple, использование Tsan может привести к снижению производительности:

Выполнение кода с помощью этих диагностических средств также приводит к замедлению работы вашего приложения в 2–20 раз. Чтобы улучшить использование памяти вашим кодом, скомпилируйте свой код с оптимизацией -O1.

Как решить гонку данных?

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

private let lockQueue = DispatchQueue(label: "name.lock.queue")
private var name: String = "Antoine van der Lee"

func updateNameSync() {
    DispatchQueue.global().async {
        self.lockQueue.async {
            self.name.append("Antoine van der Lee")
        }
    }

    // Executed on the Main Thread
    lockQueue.async {
        // Executed on the lock queue
        print(self.name)
    }
}

// Prints:
// Antoine van der Lee
// Antoine van der Lee

Используя очередь блокировки, мы синхронизировали доступ и гарантировали, что только один поток за раз обращается к переменной name. Это фундаментальное решение нашей проблемы. Если вы хотите узнать больше о том, как решить эту проблему более эффективно и с большей производительностью, я рекомендую вам прочитать мою запись в блоге, посвященную объяснению Concurrent in Swift.

Использование Актеров для решения проблем с гонкой данных

Платформа параллелизма, анонсированная на WWDC 2021, представила участников для синхронизации доступа к данным. Это лучшее решение для устранения ошибок, связанных с доступом к данным, и оно должно быть более простым, чем очереди отправки.

Чтобы узнать больше об актерах в Swift, я рекомендую вам прочитать мою статью «Актеры в Swift: как использовать и предотвращать гонки данных». Приведенный выше пример можно переписать с использованием акторов, создав контроллер имен:

actor NameController {
    private(set) var name: String = "My name is: "
    
    func updateName(to name: String) {
        self.name = name
    }
}

Актер контроллера имен отвечает за любой доступ к свойству имени. Наш метод updateName будет переписан следующим образом:

func updateName() async {
    DispatchQueue.global(qos: .userInitiated).async {
        Task {
            await self.nameController.updateName(to: "Antoine van der Lee")
        }
    }
    
    // Executed on the Main Thread
    print(await nameController.name)
}

Как видите, нам нужно использовать ключевые слова async/await, поскольку доступ становится асинхронным. Вы можете узнать больше об этом в моей статье Async await в Swift, объясненной с примерами кода.

Заключение

Вот оно! Глубокое погружение в гонки данных в Swift. Надеюсь, вы сможете начать ловить ошибки и исправить некоторые сбои, которые были в вашем коде в течение длительного времени. Регулярное использование Thread Sanitizer поможет вам предотвратить невидимые ошибки.