Гонки данных с типами значений в Swift

На этой неделе у меня было интересное обсуждение возможного состояния гонки данных из-за неправильной синхронизации потоков при манипулировании типом значений (String в данном случае) в классе.

Ошибочный код

final class MyClass {
    var token: String

    init(_ token: String = "") {
        self.token = token
    }

    func myMethod() -> Bool {
        token.isEmpty
    }
}
Вход в полноэкранный режим Выйти из полноэкранного режима

На первый взгляд, это может показаться правильным. У нас есть только var с String, который является типом значения, и метод для проверки, пуст ли token, который только вызывает isEmpty из String. Простой код и безопасный, верно? Ну, это нормально до тех пор, пока вы не вводите потоковую передачу, но как только вы это сделаете, это не произойдет. Позвольте мне рассказать подробнее.

Тест

Если вы запустите этот тест с включенным Thread Sanitizer:

 func test_data_race() {
        let sut = MyClass()

        DispatchQueue.concurrentPerform(iterations: 1_000_000) { i in
            sut.token = "(i)"
            _ = sut.myMethod()
        }
    }
Войдите в полноэкранный режим Выйдите из полноэкранного режима

вы увидите следующий результат:

WARNING: ThreadSanitizer: data race (pid=8329)
  Read of size 8 at 0x000107c1aab8 by thread T2:
    #0 closure #1 in DataTests.test_data_race() DataTests.swift:69 (Tests:arm64+0xde354)
    #1 partial apply for closure #1 in DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde3e4)
    #2 partial apply for thunk for @callee_guaranteed (@unowned Int) -> () <null>:73675156 (libswiftDispatch.dylib:arm64+0x42f4)
    #3 _dispatch_client_callout2 <null>:73675156 (libdispatch.dylib:arm64+0x35dc)

  Previous write of size 8 at 0x000107c1aab8 by main thread:
    #0 closure #1 in DataTests.test_data_race() DataTests.swift:69 (Tests:arm64+0xde374)
    #1 partial apply for closure #1 in DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde3e4)
    #2 partial apply for thunk for @callee_guaranteed (@unowned Int) -> () <null>:73675156 (libswiftDispatch.dylib:arm64+0x42f4)
    #3 _dispatch_client_callout2 <null>:73675156 (libdispatch.dylib:arm64+0x35dc)
    #4 _swift_dispatch_apply_current <null>:73675156 (libswiftDispatch.dylib:arm64+0x43a0)
    #5 @objc DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde448)
    #6 __invoking___ <null>:73675156 (CoreFoundation:arm64+0x11c5ec)

  Location is heap block of size 32 at 0x000107c1aaa0 allocated by main thread:
    #0 __sanitizer_mz_malloc <null>:73675156 (libclang_rt.tsan_iossim_dynamic.dylib:arm64+0x51004)
    #1 _malloc_zone_malloc <null>:73675156 (libsystem_malloc.dylib:arm64+0x1527c)
    #2 DataTests.test_data_race() DataTests.swift:66 (Tests:arm64+0xde07c)
    #3 @objc DataTests.test_data_race() <compiler-generated> (Tests:arm64+0xde448)
    #4 __invoking___ <null>:73675156 (CoreFoundation:arm64+0x11c5ec)

  Thread T2 (tid=6246748, running) is a GCD worker thread

SUMMARY: ThreadSanitizer: data race DataTests.swift:69 in closure #1 in DataTests.test_data_race()
Войти в полноэкранный режим Выход из полноэкранного режима

Итак, ThreadSanitizer обнаруживает гонку данных в коде при обращении к маркеру.
Что это значит? По сути, вы неправильно используете переменную. Она получает одновременные операции чтения и записи, но сама переменная не защищена, и тот факт, что она является типом значения, не помогает.

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

Исправление

Хорошо, этот простой код может упасть при параллельном чтении и записи токена из разных потоков! Как мы можем это исправить? Нам просто нужно сделать последовательный доступ для чтения/записи. Есть несколько способов сделать это (с различными примитивами), но это может быть один из них:

final class MyClass {
    private let syncQueue = DispatchQueue(label: "com.test.myQueue", attributes: .concurrent)
    private var _token: String
    var token: String {
        get {
            syncQueue.sync {
                _token
            }
        }
        set {
            syncQueue.async(flags: .barrier) {
                _token = newValue
            }
        }
    }

    init(_ token: String = "") {
        _token = token
    }

    func myMethod() -> Bool {
        token.isEmpty
    }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

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

Заключительные мысли

Я хотел поделиться несколькими мыслями по этому вопросу, которые являются распространенными заблуждениями в сообществе Swift:

❌ Типы значений безопасны для потоков

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

❌ Типы значений всегда копируются.

Это семантика, но не совсем то, что происходит под капотом. При передаче типов значений компилятор Swift достаточно умен, чтобы понять, нужна ли копия, удаляя ненужные копии. На практике он использует стратегию CopyOnWrite(COW), при которой копия создается только при изменении значения, но не при передаче. В результате в большинстве ситуаций вы будете иметь указатель на один и тот же адрес нижележащей памяти даже при использовании типов значений.

❌ Тесты всегда ведут себя как производственный код

Тот факт, что тест не дает сбоев, не является гарантией того, что какой-то код не может дать сбой в производстве. Тесты работают в симулированном окружении и обычно имеют другие параметры компиляции, чем те, которые используются в ваших окончательных сборках. Например, ARC будет проводить агрессивную оптимизацию при компиляции с соответствующими опциями, поэтому из финальных сборок будет удалено много ненужных retain/releases. В данном конкретном случае, мой тестовый костюм не падал, и я смог увидеть некоторое неправильное использование только при активации Thread Sanitizer.

Дополнительное чтение

  • Понимание безопасности потоков типов значений в Swift
  • оптимизации ARC

Оцените статью
devanswers.ru
Добавить комментарий