Потокобезопасность в Swift

Данная статья является переводом оригинальной статьи Thread Safety in Swift.

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

В этой статье мы рассмотрим, что такое потокобезопасность, какие инструменты iOS предоставляет для ее достижения и как они соотносятся с точки зрения производительности.

Что такое потокобезопасность?

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

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

final class Name {

    private(set) var firstName: String = ""
    private(set) var lastName: String  = ""

    func setFullName(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

Попробуйте задать себе тот же вопрос, что и раньше. Что произойдет, если два потока вызовут setFullName одновременно? Сработает ли это нормально или нет?

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

Thread 1: Call setFullName("Bruno", "Rocha")

Thread 2: Call setFullName("Onurb", "Ahcor")

Thread 1: Sets firstName to "Bruno"

Thread 2: Sets firstName to "Onurb"

Thread 2 (Again): Sets lastName to "Ahcor"

Thread 1: Sets lastName to "Rocha"

Final name: "Onurb Rocha". That's not right...

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

Короче говоря, это означает, что класс Name не является потокобезопасным. Чтобы исправить вышеуказанное условие гонки, нам нужно синхронизировать то, как потоки получают доступ к этому классу и изменяют его состояние. Если мы сделаем так, что поток 2 не сможет вызвать setFullName до тех пор, пока поток 1 не завершит свои действия, описанный выше сценарий станет невозможным.

На практике многим разработчикам трудно сделать это правильно, потому что они путают атомарность с потокобезопасностью. Рассмотрим следующую попытку исправить состояние гонки:

var dontLetSomeoneElseInPlease = false
func setFullName(firstName: String, lastName: String) {
    guard !dontLetSomeoneElseInPlease else {
        return
    }
    dontLetSomeoneElseInPlease = true
    self.firstName = firstName
    self.lastName = lastName
    dontLetSomeoneElseInPlease = false
}

Многие разработчики посмотрели бы на это и подумали, что это решает проблему, в то время как на самом деле это буквально ничего не дает. Прежде всего, логические значения в Swift не являются атомарными, как в Obj-C, что означает, что этот код приведет к точно такому же сбою при повреждении памяти, который был бы у нас, если бы у нас не было этой логики. Нам необходимо использовать API синхронизации на уровне операционной системы, которые мы подробно рассмотрим ниже в статье.

Во-вторых, даже если бы мы создали свой собственный пользовательский класс AtomicBool, мы бы все равно не решили условие гонки. Хотя создание don't Let Someone Else In Please atomic привело бы к тому, что само логическое значение было бы потокобезопасным, это не означает, что класс Name в целом является таковым. Что здесь трудно понять, так это то, что потокобезопасность относительна; в то время как что-то может быть потокобезопасным по отношению к самому себе, это может быть не так по отношению к чему-то другому. При оценке потокобезопасности с точки зрения Name, setFullName по-прежнему небезопасен, поскольку все еще возможно, что несколько потоков одновременно пройдут проверку защиты и вызовут тот же сценарий состояния гонки, что и ранее.

Чтобы предотвратить состояние гонки в состоянии класса Name, нам необходимо запретить параллельное выполнение всей логики setFullName. Вот один из возможных способов сделать это:

var stateLock = NSLock()
func setFullName(firstName: String, lastName: String) {
    stateLock.lock()
    self.firstName = firstName
    self.lastName = lastName
    stateLock.unlock()
}

Теоретически, то, что мы сделали, обернув логику вокруг вызовов lock() и unlock(), состояло в том, чтобы установить критическую область внутри setFullName, к которой только один поток может получить доступ в любой момент времени (в данном случае это гарантия, предоставляемая NSLock API). Логика внутри setFullName теперь потокобезопасна.

Означает ли это, что сам класс Name не является потокобезопасным? Это зависит от точки зрения. Хотя сам метод setFullName защищен от условий гонки, технически у нас все еще может возникнуть условие гонки, если какой-либо внешний объект попытается прочитать имя пользователя параллельно с записью нового имени. Вот почему самое важное, что вам следует иметь в виду, - это относительность этой проблемы: хотя мы могли бы сказать, что Name технически потокобезопасно по отношению к самому себе, вполне может быть так, что любой класс, который использовал бы это свойство в реальном приложении, мог бы делать это не потокобезопасным способом. Может произойти даже обратное: хотя строки в этом примере технически сами по себе не являются потокобезопасными, они являются таковыми, если учесть, что они не могут быть изменены вне setFullName. Чтобы устранить проблемы с потокобезопасностью в реальном мире, нам нужно сначала рационализировать проблему таким образом, чтобы определить, что именно необходимо сделать потокобезопасным, чтобы устранить проблему.

Цена потокобезопасности

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

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

API-интерфейсы потокобезопасности в iOS

Serial DispatchQueues

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

let queue = DispatchQueue(label: "my-queue", qos: .userInteractive)

queue.async {
    // Critical region 1
}

queue.async {
    // Critical region 2
}

Самой замечательной особенностью DispatchQueue является то, что она полностью управляет любыми задачами, связанными с потоками, такими как блокировка и определение приоритетов для нас. Apple советует нам никогда не создавать свои собственные типы потоков по соображениям управления ресурсами - потоки стоят недешево, и они должны быть распределены по приоритетам между собой. DispatchQueues справится со всем этим за нас, и, в частности, в случае последовательной очереди, состояние самой очереди и порядок выполнения задач также будут управляться за нас, что делает ее идеальной в качестве инструмента потокобезопасности.

Очереди, однако, хороши только в том случае, если предполагается, что задачи будут полностью асинхронными. Если нам нужно синхронно дождаться завершения задачи, прежде чем продолжить, нам, вероятно, следует вместо этого использовать один из низкоуровневых API, упомянутых ниже. Не только синхронный запуск кода означает, что мы не используем его функции многопоточности, что приводит к напрасной трате драгоценных ресурсов, но и DispatchQueue.sync синхронный вариант также является относительно опасным API, поскольку он не может определить факт того, что вы, возможно, уже находитесь внутри очереди:

func logEntered() {
    queue.sync {
        print("Entered!")
    }
}

func logExited() {
    queue.sync {
        print("Exited!")
    }
}

func logLifecycle() {
    queue.sync {
        logEntered()
        print("Running!")
        logExited()
    }
}

logLifecycle() // Crash!

Рекурсивная попытка синхронного входа в последовательную очередь приведет к тому, что поток будет ждать завершения самого себя, что не имеет смысла и приведет к зависанию приложения на вечность. Этот сценарий называется deadlock.

Технически это возможно исправить, но мы не будем вдаваться в подробности, поскольку просто не очень хорошая идея использовать DispatchQueues для синхронных целей. Для синхронного выполнения мы можем добиться лучшей производительности и более предсказуемой безопасности, используя старомодную блокировку взаимного исключения (mutex).

os_unfair_lock

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

Примечание: Из-за того, как работает модель памяти Swift, вам никогда не следует использовать этот API напрямую. Если вы ориентируетесь на iOS 15 или ниже, используйте приведенную ниже абстракцию UnfairLock при использовании этой блокировки в вашем коде. Если вы ориентируетесь на iOS 16, используйте новый встроенный тип OSAllocatedUnfairLock.

// Read http://www.russbishop.net/the-law for more information on why this is necessary
final class UnfairLock {
    private var _lock: UnsafeMutablePointer<os_unfair_lock>

    init() {
        _lock = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1)
        _lock.initialize(to: os_unfair_lock())
    }

    deinit {
        _lock.deallocate()
    }

    func locked<ReturnValue>(_ f: () throws -> ReturnValue) rethrows -> ReturnValue {
        os_unfair_lock_lock(_lock)
        defer { os_unfair_lock_unlock(_lock) }
        return try f()
    }
}

let lock = UnfairLock()
lock.locked {
    // Critical region
}

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

Обратите также внимание, что эта блокировка специально также не может обрабатывать сценарий рекурсии. Если ваша критическая область является рекурсивной, вам нужно будет использовать рекурсивную блокировку (рассмотрим далее).

NSLock

Несмотря на то, что Lock также является мьютексом, он отличается от os_unfair_lock в том смысле, что это объектная абстракция для совершенно другого API блокировки (в данном случае pthread_mutex). Хотя функциональность блокировки и разблокировки одинакова, вы можете захотеть выбрать NSLock вместо os_unfair_lock по двум причинам. Первая заключается в том, что, в отличие от os_unfair_lock, вы действительно можете использовать этот API без необходимости его абстрагировать.

Вторая, и, возможно, более интересная причина - NSLock содержит дополнительные функции, которые могут оказаться вам очень полезными. Что мне нравится больше всего, так это возможность установить тайм-аут:

let nslock = NSLock()

func synchronize(action: () -> Void) {
    if nslock.lock(before: Date().addingTimeInterval(5)) {
        action()
        nslock.unlock()
    } else {
        print("Took to long to lock, did we deadlock?")
        reportPotentialDeadlock() // Raise a non-fatal assertion to the crash reporter
        action() // Continue and hope the user's session won't be corrupted
    }
}

Мы видели, что взаимоблокировка с помощью очереди, по крайней мере, в конечном итоге приведет к сбою приложения, но это не относится к мьютексам. Если вы попадете с ними в deadlock, они ничего не предпримут и оставят вас с полностью не отвечающим приложением.

Однако, в случае с NSLock, функция тайм-аута позволяет нам быть умнее и внедрять свои собственные резервные варианты, когда все идет не так, как планировалось; в случае потенциальным deadlock одна вещь, которую я делал в прошлом с большим успехом, заключалась в том, чтобы сообщить об этом нашему crash reporter и фактически разрешить приложению работать в обычном режиме. Попытка не испортить работу пользователя сбоем. Однако обратите внимание, что причина, по которой я мог бы рискнуть сделать это, заключалась в том, что синхронизируемый код было чисто какой-то невинной клиентской логикой, которая не переносится на следующий сеанс. Для чего-то более серьезного это было бы неправильно.

NSRecursiveLock

Если ваш класс структурирован таким образом, что запрос блокировки может привести к тому, что поток рекурсивно попытается запросить его снова, вам нужно будет использовать рекурсивную блокировку, чтобы предотвратить взаимоблокировку вашего приложения. NSRecursiveLock - это в точности NSLock, но с дополнительной возможностью обрабатывать рекурсию:

let recursiveLock = NSRecursiveLock()

func synchronize(action: () -> Void) {
    recursiveLock.lock()
    action()
    recursiveLock.unlock()
}

func logEntered() {
    synchronize {
        print("Entered!")
    }
}

func logExited() {
    synchronize {
        print("Exited!")
    }
}

func logLifecycle() {
    synchronize {
        logEntered()
        print("Running!")
        logExited()
    }
}

logLifecycle() // No crash!

В то время как обычные блокировки приведут к взаимоблокировке при рекурсивной попытке запросить блокировку в том же потоке, рекурсивная блокировка позволяет владельцу блокировки повторно запросить ее снова. Из-за этой дополнительной возможности NSRecursiveLock работает немного медленнее, чем обычная NSLock.

DispatchSemaphore

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

getUserInformation {
    // Done
}

// Pause the thread until the callback in getUserInformation is called
print("Did finish fetching user information! Proceeding...")

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

let semaphore = DispatchSemaphore(value: 0)

mySlowAsynchronousTask {
    semaphore.signal()
}

semaphore.wait()
print("Did finish fetching user information! Proceeding...")

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

Семафор как правило, выполняется быстро и содержит те же функции, что и NSLock. Мы можем дополнительно использовать свойство value для управления количеством потоков, которым разрешено проходить через функцию wait(), прежде чем они будут заблокированы и должен быть вызван signal(); значение 0 означает, что они всегда будут заблокированы.

DispatchGroup

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

let group = DispatchGroup()

for _ in 0..<6 {
    group.enter()
    mySlowAsynchronousTask {
        group.leave()
    }
}

group.wait()
print("ALL tasks done!")

В этом случае поток будет разблокирован только после завершения всех 6 задач.

Одной из действительно полезных функций групп диспетчеризации является то, что у вас есть дополнительная возможность асинхронно ожидать, вызывая group.notify:

group.notify(queue: .main) {
    print("ALL tasks done!")
}

Это позволяет вам получать уведомления о результате в очереди вместо блокировки потока, что может быть чрезвычайно полезно, если вам не нужен результат синхронно.

Из-за группового механизма вы обнаружите, что группы обычно работают медленнее, чем обычные семафоры.

Это означает, что вам обычно следует использовать DispatchSemaphore, если вы ожидаете только одного события, но на практике наличие notify API заставляет многих людей использовать DispatchGroups также и для отдельных событий.

Async/Await

После того, как эта статья была первоначально опубликована, многие читатели обратились ко мне с очень актуальным вопросом: что насчет actors и async/await.

Как вы проницательно заметили, это также инструменты потокобезопасности, заслуживающие упоминания. Такие функции, как Actors, преследуют ту же цель синхронизации потоков, что и API, которые мы видели, но поскольку они являются абстракциями уровня компилятора, они значительно более мощные, чем ручные API.

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

Спасибо за чтение!

Комментарии (3)


  1. Gummilion
    30.07.2023 14:36

    Только это уже, можно сказать, legacy - нынче в Swift достаточно объявить Name как actor Name , а об остальном уже система позаботится. Более того, в функциях с async/await нельзя в явном виде использовать мьютексы и семафоры (но если очень хочется - есть @unchecked ).


    1. NineNineOne
      30.07.2023 14:36
      +1

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


  1. MeGaPk
    30.07.2023 14:36

    и походу это грубый перевод через гугл переводчик :( "DispatchQueue.синхронизация"