Гонка за данными (Data Race) — одна из самых неприятных ошибок, с которыми может столкнуться разработчик. Она возникает в многопоточной среде, когда несколько операций одновременно пытаются получить доступ к одним и тем же данным, при этом хотя бы одна из этих операций выполняет их изменение. Последствия таких гонок могут быть крайне серьезными: в лучшем случае приложение может упасть с ошибкой, а в худшем — произойдет искажение данных, с которыми работает пользователь. Это может негативно сказаться на дальнейшей работе с этими данными и привести к непредсказуемым результатам работы приложения.
Обеспечение корректного доступа к общим ресурсам в многопоточных приложениях — ключевая задача, требующая внимательного подхода и изучения. В этой статье мы рассмотрим как используя инструменты, доступные нам в языке Swift, предотвратить наличие гонки за данными.
Суть проблемы
Для того, чтобы эффективно бороться с проблемой, необходимо в первую очередь понять ее суть. Поэтому прежде чем переходить к тому, какими инструментами в Swift решается проблема с гонкой за данными, для начала более подробно разберемся, что она из себя представляет.
Гонка данных (Data Race) — это состояние в многопоточных программах, когда два или более потоков одновременно пытаются читать и записывать в одну и ту же область памяти без должной синхронизации.
Вот, что нам нужно понять в первую очередь:
Нет конкурентности – нет гонки за данными.
Data race происходит только в конкурентной среде. Если ваш код не использует операции, которые в одно и то же время конкурируют за доступ к данным, то никаких гонок в рамках одного потока выполнения произойти не может.Должна присутствовать операция записи.
Беспокоится о Data Race нужно в том случае, если в нескольких параллельных операциях есть хотя бы одна такая, которая может вносить изменения в данные. Если данные сами по себе неизменяемые или все используемые параллельные операции осуществляют только их чтение, то проблему с гонкой данных вы не получите.
Почему это вообще важно? Гонки за данными нередко становятся источником трудноуловимых ошибок, которые сложно выявить на этапе тестирования. Однако в реальной работе такие проблемы могут приводить к сбоям, нарушению логики приложения или даже утрате данных. Более того, они способны стать уязвимостью для безопасности системы — последствия, которой ни один разработчик не может игнорировать.
Представим ситуацию: вы продаёте товары онлайн покупателям по всему миру. У вас есть программа, которая проверяет наличие товара, обновляет остатки на складе и подтверждает заказы. Всё работает прекрасно, пока внезапно популярность вашего товара резко не возрастает. Поток заказов становится настолько интенсивным, что несколько клиентов одновременно оформляют покупку одного и того же товара.
И тут начинаются проблемы:
Несколько пользователей одновременно заказывают последний экземпляр товара.
Программа неверно определяет доступное количество, подтверждая заказы всем клиентам.
В итоге на складе образуется отрицательный остаток, а недовольным покупателям приходится сообщать об отмене заказов.

Что пошло не так? Это классическая гонка за данными (Data Race). Когда два или более процесса одновременно пытаются читать и изменять одни и те же данные без должной синхронизации. В таком виде система перестаёт работать надёжно. В нашем случае отсутствие синхронизации приводит к тому, что склад не способен корректно учитывать остатки товаров, что в реальной жизни оборачивается финансовыми потерями и недовольством клиентов.
Проиллюстрируем эту ситуацию в коде. Представим, что у нас есть всего 3 доступных товара, и одновременно 5 пользователей из разных уголков мира нажимают кнопку «Оформить заказ» в приложении:
// Используется Swift 5
class OnlineStore {
// Количество товаров, доступных для заказа
private var stock: Int = 3
// Метод оформления заказа
func placeOrder(forUserWithID userID: Int) {
// Проверяем, что есть доступные товары
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
// Сообщаем об успешном оформлении заказа
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
// Уменьшаем количество доступных товаров
stock -= 1
}
}
let store = OnlineStore()
// Имитируем ситуацию одновременной покупки товара несколькими пользователями
for userID in 0...4 {
Thread.detachNewThread {
store.placeOrder(forUserWithID: userID)
}
}В идеальной ситуации только трое из покупателей смогут успешно совершить покупку, а оставшиеся двое получат уведомление о том, что товар закончился. Предвосхищая ваше любопытство, скажу, что итоговый результат скорее всего вас удивит, поскольку он будет значительно отличаться от ожидаемого поведения программы:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#1 Заказ подтвержден! В наличии осталось товаров: 2.
#3 Заказ подтвержден! В наличии осталось товаров: 2.
#2 Заказ подтвержден! В наличии осталось товаров: 2.
#4 Заказ подтвержден! В наличии осталось товаров: 2.Концепция решения проблемы
Итого, проблема гонки за данными возникает, когда несколько потоков или процессов одновременно обращаются к одним и тем же данным без должной синхронизации. В такой ситуации доступ не ограничивается одним потоком в единицу времени, что может привести к непредсказуемому поведению программы. Основная задача для решения этой проблемы — обеспечить эксклюзивный доступ к данным, чтобы работать с ними за раз могла только одна параллельная операция.
К счастью, современные языки программирования, включая Swift, уже предоставляют мощные инструменты для решения этой задачи. Благодаря этому вам не нужно изобретать велосипед и далее я расскажу о ключевых подходах и средствах, которые помогут вам эффективно предотвращать гонки за данными в ваших проектах на Swift.
Мьютексы
Самым простым и широко используемым инструментом для синхронизации данных является мьютекс. Его задача предельно ясна: обеспечить, чтобы в каждый момент времени только один поток мог выполнять критическую секцию. Мьютекс добивается этого, блокируя доступ других потоков к секции до тех пор, пока текущий поток не завершит свою работу.
Мьютекс (англ. mutex, от mutual exclusion — «взаимное исключение») — это примитив синхронизации, обеспечивающий исключительный доступ к общему ресурсу для одного потока или процесса.
Работает мьютекс следующим образом: изначально он находится в неактивном (разблокированном) состоянии, то есть никакие потоки им не заблокированы. Когда поток начинает выполнять критическую секцию, мьютекс переходит в активное состояние (блокируется) и «закрывает доступ» другим потокам, которые могут попытаться обратиться к тем же данным. Лишь после завершения работы текущего потока мьютекс снова разблокируется, позволяя следующему потоку войти в секцию.
Простыми словами, можно представить это так: если у вас есть данные, к которым могут обращаться несколько потоков, вы говорите мьютексу: «Эти данные сейчас используются. Пожалуйста, подожди и не пускай других, пока текущий поток не закончит работу с ними». Это упорядочивает работу потоков, предотвращая ситуации, в которых данные могут быть изменены одновременно, что могло бы привести к конфликтам.
Важное помнить
Большинство мьютексов требуют, чтобы разблокировка происходила в том же потоке, в котором была выполнена блокировка. Нарушение этого правила может привести к неопределённому поведению программы.
При работе с мьютексами количество вызванных блокировок и разблокировок должно быть одинаковым, иначе это приведет к deadlock.
NSLock
Класс NSLock является частью фреймворка Foundation, наверняка знакомому каждому, кто имел дело со Swift. У этого класса есть два основных метода: lock() и unlock(), которые отвечают за блокировку и разблокировку доступа к ресурсам соответственно. Используя NSLock, мы можем синхронизировать доступ к критическим секциям данных. Для нашего примера используем этот класс следующим образом:
class OnlineStore {
private var stock: Int = 3
// Создаем экземпляр `NSLock`
private let lock = NSLock()
func placeOrder(forUserWithID userID: Int) {
// Блокируем доступ к критической секции
lock.lock()
// Обязательно снимаем блокировку после завершения работы с критической секцией
defer { lock.unlock() }
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}Для того, чтобы не забыть случайно вызвать unlock() после того, как была завершена работа с критической секцией, у NSLock есть метод withLock(body:), который блокирует доступ к переданному блоку кода и автоматически разблокирует после его выполнения:
func placeOrder(forUserWithID userID: Int) {
// Изолируем доступ к критической секции через метод `withLock`
lock.withLock {
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}Запускаем программу и получаем корректный результат:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#1 Заказ подтвержден! В наличии осталось товаров: 1.
#3 Заказ подтвержден! В наличии осталось товаров: 0.
#2 Товар закончился.
#4 Товар закончился.OSAllocatedUnfairLock (ранее os_unfair_lock)
Фреймворк os предоставляет ещё одну реализацию мьютекса в виде структуры OSAllocatedUnfairLock. Подобно NSLock, она обеспечивает исключительный доступ к общим ресурсам. У OSAllocatedUnfairLock есть методы, знакомые нам по NSLock: lock(), unlock() и withLock(). Эти методы также выполняют блокировку и разблокировку ресурса, позволяя синхронизировать доступ к критическим секциям.
Несмотря на то, что этот мьютекс реализован в виде структуры, все скопированные экземпляры OSAllocatedUnfairLock ссылаются на одну и ту же внутреннюю блокировку, что означает, что независимо от того, сколько раз вы создаёте новые копии этого объекта, все они будут управлять одной и той же ресурсной блокировкой.
Использование OSAllocatedUnfairLock выглядит следующим образом:
import os.lock
class OnlineStore {
private var stock: Int = 3
// Создаем экземпляр `OSAllocatedUnfairLock`
private let lock = OSAllocatedUnfairLock()
func placeOrder(forUserWithID userID: Int) {
// Изолируем доступ к критической секции через метод `withLock`
lock.withLock {
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}
}В OSAllocatedUnfairLock также есть полезный метод withLockIfAvailable, который проверяет, можно ли выполнить блокировку в текущий момент, и, если это невозможно, просто пропускает выполнение переданного блока.
Еще одно преимущество OSAllocatedUnfairLock заключается не только в возможности синхронизации отдельных участков кода, но и в способности создавать защищённые состояния для отдельных переменных. Это позволяет избежать избыточной блокировки и сделать код более эффективным и читаемым. На нашем примере это будет выглядеть следующим образом:
import os.lock
class OnlineStore {
// Создаем экземпляр `OSAllocatedUnfairLock` со значением количества доступных для заказа товаров
private let stockLocker = OSAllocatedUnfairLock(initialState: 3)
func placeOrder(forUserWithID userID: Int) {
// Изолируем доступ к количеству товаров
stockLocker.withLock { stock in
// Работаем внутри блока с переменной `stock` типа `Int`
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}
}Результат работы программы при таком использовании OSAllocatedUnfairLock также будет правильным:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#2 Заказ подтвержден! В наличии осталось товаров: 1.
#1 Заказ подтвержден! В наличии осталось товаров: 0.
#4 Товар закончился.
#3 Товар закончился.Mutex (Swift 6)
С выходом Swift 6 появился фреймворк Synchronization, предлагающий разработчикам новые инструменты для синхронизации доступа к данным. Одним из таких инструментов стал новый тип — Mutex. Его работа во многом схожа с использованием OSAllocatedUnfairLock, где мы изолируем состояние переменной для безопасного доступа.
У Mutex есть два метода: withLock и withLockIfAvailable. Главное отличие второго метода в том, что, как и в случае с OSAllocatedUnfairLock, он предварительно проверяет возможность блокировки. Если блокировка недоступна, переданный блок не будет выполнен.
Для нашего примера использование Mutex будет выглядеть так:
import Synchronization
class OnlineStore {
// Создаем экземпляр `Mutex` со значением количества доступных для заказа товаров
private let stockMutex = Mutex<Int>(3)
func placeOrder(forUserWithID userID: Int) {
// Изолируем доступ к количеству товаров
stockMutex.withLock { stock in
// Работаем внутри блока с переменной `stock` типа `Int`
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}
}#2 Заказ подтвержден! В наличии осталось товаров: 2.
#0 Заказ подтвержден! В наличии осталось товаров: 1.
#1 Заказ подтвержден! В наличии осталось товаров: 0.
#3 Товар закончился.
#4 Товар закончился.Grand Central Dispatch
Один из самых популярных подходов к работе с многопоточностью в Swift — это использование инструментов Grand Central Dispatch (GCD), где не ведется работа с потоками напрямую. Вместо этого мы используем очереди задач, выбирая их тип (последовательная или параллельная) и способ выполнения задачи в этой очереди (синхронно или асинхронно), а система сама распределяет наши задачи по доступным потокам. Это позволяет эффективно управлять многозадачностью, минимизируя сложности с ручным управлением потоками и большой затратой ресурсов на их создание.
Serial DispatchQueue
Вкачестве инструмента, позволяющего синхронизировать доступ к данным при параллельном обращении, можно воспользоваться последовательной очередью. Особенность этой очереди в том, что независимо от того, каким образом было вызвано выполнение задачи на ней (синхронно или асинхронно), предыдущая задача не начнет выполнение до тех пор, пока не завершится предыдущая:
import Dispatch
let queue = DispatchSerialQueue(label: "com.malil.dugulubgov.serial.queue")
queue.async {
print("Task 1 completed")
}
queue.sync {
print("Task 2 completed")
}
queue.sync {
print("Task 3 completed")
}
queue.async {
print("Task 4 completed")
}
/*
Результат всегда будет:
Task 1 completed
Task 2 completed
Task 3 completed
Task 4 completed
*/То есть по сути, мы буквально создаем очередь, аналогичную той, что вы можете встретить в реальной жизни. И из-за того, что в очереди каждый обслуживается по порядку, это отлично подходит для того, чтобы упорядочить и синхронизировать обращение к данным в программе.
Чтобы убедиться в этом, вернемся к нашему примеру с заказами товаров. Для того, чтобы исправить гонку за данными инструментами GCD сделаем небольшие изменения:
class OnlineStore {
private var stock: Int = 3
// Создаем очередь синхронизации
private let orderPlaceQueue = DispatchSerialQueue(label: "online.store.order.place.queue")
func placeOrder(forUserWithID userID: Int) {
// Добавляем в очередь задачу на выполнение
orderPlaceQueue.async { [weak self] in
guard let self else { return }
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}
}
let store = OnlineStore()
// Имитируем ситуацию одновременной покупки товара несколькими пользователями
DispatchQueue.concurrentPerform(iterations: 5) { userID in
store.placeOrder(forUserWithID: userID)
}Как будет видно по результату, организовав все обращения в последовательную очередь, мы обеспечим обращение к данным поочередно, благодаря чему и решим проблему с гонкой:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#1 Заказ подтвержден! В наличии осталось товаров: 1.
#3 Заказ подтвержден! В наличии осталось товаров: 0.
#2 Товар закончился.
#4 Товар закончился.Concurrent DispatchQueue + barrier task
Тот же эффект можно получить, используя и параллельную очередь, если отметить задачу, выполняемую на ней, как барьерную. Благодаря этому подходу, даже учетом, что все задачи в очереди выполняются параллельно, задачи, добавленные в очередь до запуска барьерной задачи, не смогут завершиться раньше неё. Это позволяет гарантировать, что определённая задача выполнится без вмешательства других, создавая строгую последовательность выполнения.

class OnlineStore {
private var stock: Int = 3
// Создаем параллельную очередь
private let storeQueue = DispatchConcurrentQueue(label: "online.store.queue")
func placeOrder(forUserWithID userID: Int) {
// Добавляем задачу в очередь с флагом `barrier`
storeQueue.async(flags: .barrier) { [weak self] in
guard let self else { return }
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}
}Этот подход позволяет использовать параллельную очередь для повышения эффективности выполнения независимых задач (т.е. когда вам не требуется соблюдать порядок выполнения и эффективнее будет выполнение некоторых задач параллельно), одновременно гарантируя, что конкретная выбранная вами задача будет выполняться без вмешательства.
Применяя этот метод, мы получим результат, аналогичный тому, который был достигнут с помощью последовательной очереди:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#2 Заказ подтвержден! В наличии осталось товаров: 1.
#3 Заказ подтвержден! В наличии осталось товаров: 0.
#1 Товар закончился.
#4 Товар закончился.Однако важно учитывать, что если задача, которую вы пытаетесь изолировать от одновременного доступа, имеет длительное время выполнения, её использование в качестве барьерной может негативно повлиять на выполнение других задач в параллельной очереди. Все задачи, добавленные после неё, будут вынуждены ждать, пока «барьер» не будет снят. В таких случаях лучше использовать отдельную последовательную очередь, которая обеспечит изоляцию именно нужной критической секции.
Semaphore
Еще одним инструментом для контроля доступа к критическим секциям при параллельных обращениях является класс DispatchSemaphore. Семафор работает на основе счетчика, который ограничивает количество потоков, одновременно имеющих доступ к определенной секции кода или ресурсу.
При инициализации семафору задается целое число — это будет максимальное количество потоков, которым разрешен одновременный доступ к некоторой критической секции. Например, DispatchSemaphore(value: 1) ограничивает доступ только до одного потока, превращая семафор в своеобразный аналог мьютекса. Когда поток пытается получить доступ, выполняемая на этом потоке операция уменьшает счетчик семафора с помощью метода wait(). Если счетчик становится равен нулю, остальные потоки, пытающиеся получить доступ к ресурсу блокируются в ожидании, пока другой поток не освободит этот доступ посредством вызова у семафора метода signal(), который увеличивает счетчик и позволяет еще одному потоку приступить к выполнению.
Важно помнить
Как и в случае с мьютексом, количество вызовов wait() должно совпадать с таким же числом вызовов signal(), чтобы избежать deadlock.
class OnlineStore {
private var stock: Int = 3
// Создаем семафор с ограничением на число потоков в количестве 1
private let semaphore = DispatchSemaphore(value: 1)
func placeOrder(forUserWithID userID: Int) {
// Уменьшаем счетчик доступных для вхождения в критическую секцию потоков
semaphore.wait()
// Обязательно увеличиваем счетчик после завершения работы с критической секцией
defer { semaphore.signal() }
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}Результат применения семафора:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#1 Заказ подтвержден! В наличии осталось товаров: 1.
#2 Заказ подтвержден! В наличии осталось товаров: 0.
#3 Товар закончился.
#4 Товар закончился.Actor
Концепция акторов не является новой, но в Swift она появилась относительно недавно, с введением модели Swift Concurrency. С этим нововведением разработчики получили тип actor, главной задачей которого является предотвращение гонок за данными и обеспечение безопасного доступа к общему изменяемому состоянию.
Основные принципы работы актора:
Принцип почтового ящика (mailbox).
Акторы функционируют как объекты, которые обрабатывают все обращения к себе в порядке их поступления. Каждое обращение помещается в своеобразный «почтовый ящик» и выполняется по очереди. Это гарантирует упорядоченность операций, что помогает избежать состояния гонки.Однако здесь важно также учитывать проблему actor reentrancy (реентерабельность актора). Настоятельно рекомендую самостоятельно ознакомиться с этим.
Изоляция данных от прямого доступа.
Все данные внутри актора изолированы от прямого обращения извне. Единственный способ взаимодействия с данными актора — это выполнение операций в среде Concurrency. Такие операции должны быть явно обозначены с помощьюawait, что позволяет разработчикам чётко видеть места, где может произойти приостановка выполнения.
Используем этот тип для предотвращения гонки в уже знакомом примере с заказами:
// Меняем тип `class` на `actor`
actor OnlineStore {
private var stock: Int = 3
func placeOrder(forUserWithID userID: Int) {
guard stock > 0 else {
return print("#\(userID) Товар закончился.")
}
print("#\(userID) Заказ подтвержден! В наличии осталось товаров: \(stock - 1).")
stock -= 1
}
}
let store = OnlineStore()
// Имитируем ситуацию одновременной покупки товара несколькими пользователями
for userID in 0...4 {
Task {
// Вызываем метод актора через `await`
await store.placeOrder(forUserWithID: userID)
}
}А вот и результат такого преобразования:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#1 Заказ подтвержден! В наличии осталось товаров: 1.
#2 Заказ подтвержден! В наличии осталось товаров: 0.
#3 Товар закончился.
#4 Товар закончился.Вот таким образом, преобразование ваших типов, которые предоставляют доступ к общему изменяемому состоянию, в actor поможет решить большинство потенциальных проблем, связанных с Data Race.
Sendable
Хочется также отдельно затронуть использование протокола Sendable в обеспечении потокобезопасности данных в Swift. Этот протокол является ключевым компонентом современной модели Concurrency в языке и помогает указать гарантию того, что данные, передаваемые между различными контекстами, безопасны для использования в конкурентной среде.
Sendable — это протокол, встроенный в стандартную библиотеку Swift, который не имеет никаких методов или свойств, требуемых для реализации на стороне типов. Этот протокол лишь маркирует типы как "безопасные для передачи". Его задача — указать компилятору и разработчикам, что экземпляры типа можно безопасно использовать в конкурентной среде без риска гонок за данными.
Для простоты понимания, можно в контексте языка ассоциировать, что если что-то является
Sendable, то оно же является и потокобезопасным.
Неявное соответствие Sendable
Большинство базовых типов Swift, в особенности value типы (Int, String, Bool и т.д.) уже соответствуют Sendable. Типы значений в этом плане отличные кандидаты на соответствие этому протоколу, поскольку одной из их ключевых особенностей является то, что они передаются по значению, а не по ссылке. Это означает, что при передаче экземпляра каждый контекст выполнения получает свою независимую копию, изменение которой никак не отражается на состоянии других экземпляров этого объекта.
Также если любой пользовательский тип состоит из свойств, где каждое удовлетворяет протоколу Sendable, то компилятор автоматически назначит вашему типу соответствие этому протоколу:
Любой actor по умолчанию также соответствует протоколу Sendable. Это вполне логично, поскольку сам механизм работы актора уже изначально подразумевает потокобезопасность данных, с которыми он взаимодействует. Благодаря строгой изоляции состояния актора и последовательной обработке запросов, акторам не требуется дополнительная логика для обеспечения безопасности при работе с многопоточностью. Изоляция всего типа через какой-либо глобальный актор также автоматически делает тип соответсвующим Sendable.
// Conforms to Swift.Sendable
actor MyActor {}
// Conforms to Swift.Sendable
@MyGlobalActor
class MyClass {}Использование Sendable со ссылочными типами
Со ссылочными типами ситуация обстоит немного сложнее. Они являются главным камнем преткновения при работе с многопоточностью, поскольку, в отличие от value типов, они передаются по ссылке. Это означает, что каждая параллельная операция будет работать с одними и теми же данными из heap (кучи), что увеличивает риск возникновения гонки.
"Стоп, но ведь мы можем всегда передавать наши ссылочные типы внутри акторов, которые обеспечивают потокобезопасность. Получается, все наши данные в этом случае будут защищены!" – может уверенно сказать какой-нибудь читатель.
И вот тут вы и попались в ловушку. Несмотря на то, что акторы на самом деле предназначены для устранения проблемы гонок за данными, передача между акторами ссылочных типов все равно не предотвращает проблему. Напротив, в таком случае вы самостоятельно создаете простор для гонки.
Почему использование actor не решает проблему? Да, актор сам по себе гарантирует изолированное выполнение операций в своем контексте. Это означает, что он будет обрабатывать каждую задачу по очереди и не позволит параллельным операциям изменять его внутреннее состояние одновременно. Однако это касается состояния актора. Когда вы передаете ссылочный тип между акторами (а именно так, грубо говоря и работают async/await операции), вы передаете ссылку на один и тот же объект, и если два актора работают с этим объектом одновременно, возможна ситуация параллельного обращения к ним с разных сторон. В такой ситуации может произойти гонка, если эти данные не защищены дополнительными мерами.
Рассмотрим применение протокола Sendable для ссылочных типов на следующем примере:
// Ссылочный тип в виде класса, который хотим отметить как `Sendable`
class Example: Sendable {
// Изменяемое свойство
var value: Int
}Если вы используете версию языка ниже, чем Swift 6, то компилятор выдаст два предупреждения (в шестой версии языка это уже будут ошибки):
Non-final class 'DataStorage' cannot conform to 'Sendable'; use '@unchecked Sendable'; this is an error in the Swift 6 language mode.
Это предупреждение возникает потому, что наш класс не являетсяfinal, то есть от него могут наследоваться другие классы. В этом случае компилятор не может быть уверен в том, что дочерний класс будет поддерживать потокобезопасность, даже если родительский класс соблюдает эту гарантию. Каждый класс-наследник может переопределить методы родительского класса, нарушая потокобезопасность. В этом случае мы можем либо все-таки сделать невозможным наследования от этого класса с помощьюfinal; либо указать соответствие протоколу как@unchecked Sendable– это будет означать, что мы берем на себя полную ответственность за гарантию того, что этот тип и все его дочерние классы являются безопасными.Еще один вариант
Не забывайте, о том, что мы можем реализовать соответствие типа протоколу
Sendableпутем изоляции его через любой доступныйglobalActor. Тогда он и все его классы-наследники автоматически будут изолированы на этом акторе (напримерUIViewControllerиз iOS SDK изолирован с помощьюMainActor).Stored property 'data' of 'Sendable'-conforming class 'DataStorage' is mutable; this is an error in the Swift 6 language mode
Здесь компилятор дает нам понять, что изменяемое свойство внутри класса не потокобезопасно. Для исправления этого нам необходимо применить один из тех инструментов, которые мы рассмотрели ранее в этой статье, тем самым самостоятельно обеспечив потокобезопасность данных. После этого можно будет отметить тип как@unchecked Sendable.
При этом, если наш класс содержит только неизменяемые свойства (например, если все свойства объявлены с использованием let), то его можно без труда отметить как Sendable (ответ на то, почему компилятор позволяет так делать можно найти в самом начале статьи):
final class Example: Sendable {
// Делаем свойство недоступным для изменения
let value: Int
init(value: Int) {
self.value = value
}
}Cсылочные типов, отмеченных как Sendable уже позволительно использовать между разными акторами, поскольку соответствие протоколу как бы гарантирует, что либо мы, либо компилятор уже позаботились о том, чтобы такие типы было безопасно использовать при одновременном обращении.
Swift 6 и Strict Concurrency
В Swift 6 появилась полная поддержка обнаружения потенциальных проблем с гонкой за данными прямо на этапе компиляции. На эту функцию влияет специальный флаг компилятора strict-concurrency, который имеет следующие доступные значения:
Minimal
Компилятор применяет базовые проверки для соответствия типов протоколуSendableесли в коде явно указано требование на соответствие типов этому протоколу, а также для соблюдения изоляции кода контекстом актора там, где используется Concurrency.Targeted
Компилятор применяет проверки на соответствие типов протоколуSendableи соблюдения изоляции кода контекстом актора везде, где используется Concurrency. Не все нарушения потокобезопасности могут быть обнаружены.Complete
Компилятор обеспечивает полное соблюдение правил потокобезопасности, накладывая требования соответствияSendableдля типов, которые передаются в среде Concurrency и выдает предупреждения и ошибки, если доступ к данным имеет потенциальный риск наличия гонки за ними.
Давайте разберем, как Swift помогает предотвращать гонки за данными с полной проверкой на этапе компиляции. Вернемся к нашему примеру с заказами товаров. Если использовать код из примера в в Swift 6, то там, где мы вызываем метод placeOrder у экземпляра OnlineStore, компилятор выдаст следующую ошибку:
Main actor-isolated let 'store' can not be referenced from a nonisolated context
Эта ошибка сообщает, что мы не можем обращаться к экземпляру OnlineStore, изолированному через MainActor, из неизолированного контекста. Причина в том, что Swift теперь по умолчанию изолирует весь код, выполняемый на главном потоке, с помощью MainActor. Компилятор четко указывает, что нельзя обращаться к объекту из другого контекста без явного соблюдения правил изоляции.
Исправить эту ошибку мы можем двумя способами:
Убрать обращение к
storeиз блокаThread.detachNewThread.
В этом случае мы можем выполнять весь код в основном цикле, не создавая параллельных операций. Однако это лишает нас возможности воспроизвести ситуацию гонки за данными, поэтому нам этот вариант не подходит.Обернуть код внутри
Thread.detachNewThreadвTask.
ИспользованиеTaskпозволяет добиться параллелизма без явного создания нового потока, т.к. запущенные задачи будут выполняться параллельно друг другу. Поэтому в этом случае создание новогоThreadнапрямую нам не понадобится.
let store = OnlineStore()
// Имитируем ситуацию одновременной покупки товара несколькими пользователями
for userID in 0...4 {
Task {
store.placeOrder(forUserWithID: userID)
}
}Ура, компилятор перестал ругаться. Попробуем запустить программу и посмотреть результат:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#1 Заказ подтвержден! В наличии осталось товаров: 1.
#2 Заказ подтвержден! В наличии осталось товаров: 0.
#3 Товар закончился.
#4 Товар закончился.Почему это работает? Когда мы создаем Task, то она по‑умолчанию наследует тот контекст в котором начинает свое выполнение, в том числе и текущий актор. Если код, где запускается Task, не изолирован никаким другим актором, то по‑умолчанию это будет MainActor. В результате компилятор может гарантировать, что раз операция с объектом выполняется на том же акторе, на котором изолирован сам объект, то он таким образом защищен от проблемы с Data Race.
Но что будет, если мы попытаемся обратиться к экземпляру OnlineStore из асинхронного контекста, который не изолирован MainActor? Например, это можно сделать, используя обращение к store:
Внутри
detached Task, которая не наследует актор, откуда была запущена.for userID in 0...4 { // Создаем асинхронную задачу, не наследующую контекст выполнения Task.detached { store.placeOrder(forUserWithID: userID) } }При явном указании, на каком акторе выполняется
Task:// Наш собственный глобальный `actor` @globalActor actor MyActor { static let shared = MyActor() } for userID in 0...4 { Task { @MyActor in // Явно указыываем выполнение `Task` на `MyActor` store.placeOrder(forUserWithID: userID) } }
В таком случае компилятор выдаст следующую ошибку при обращении к экземпляру OnlineStore:
Expression is 'async' but is not marked with 'await'
На этот раз компилятор сам подскажет, как исправить ситуацию. Проблема связана с тем, что мы пытаемся получить доступ к объекту, изолированному MainActor, из контекста, который не связан с этим актором. Как мы уже обсуждали в разделе про акторы как тип, любое взаимодействие с данными, изолированными актором требует явного указания приостановки выполнения программы, поэтому для таких обращений обязательно использование ключевого слова await.
for userID in 0...4 {
Task.detached {
// Обращаемся к экземпляру `OnlineStore` через await
await store.placeOrder(forUserWithID: userID)
}
}Однако даже после добавления await мы столкнемся с еще одной ошибкой:
Non-sendable type 'OnlineStore' in implicitly asynchronous access to main actor-isolated let 'store' cannot cross actor boundary
Эта ошибка указывает, что для безопасной работы с объектом типа OnlineStore, он должен соответствовать протоколу Sendable. Ранее мы уже подробно рассматривали, как реализовать соответствие типу Sendable, поэтому оставлю это вам в качестве возможности попрактиковаться.
Если выполнить все требования компилятора, реализовать потокобезопасность типа и запустить программу, результат будет корректным:
#0 Заказ подтвержден! В наличии осталось товаров: 2.
#1 Заказ подтвержден! В наличии осталось товаров: 1.
#2 Заказ подтвержден! В наличии осталось товаров: 0.
#3 Товар закончился.
#4 Товар закончился.Это лишь пара примеров того, как Swift 6 и Strict Concurrency помогают защитить ваш код от потенциальных гонок за данными. Надеюсь, что даже на этих примерах удалось наглядно продемонстрировать, насколько удобные и мощные инструменты предоставляет новая версия языка для написания безопасного и предсказуемого кода.
Тем не менее, важно углубляться в тему и изучать дополнительные материалы, чтобы лучше понимать, какие потенциальные проблемы с многопоточностью могут возникнуть у вас в будущем, и как Swift поможет с ними справляться. Чем больше вы разберетесь в механизмах, предлагаемых языком, тем легче вам будет проектировать и реализовывать безопасные и надежные приложения.
Заключение

Многопоточность — это неизбежный вызов для любого разработчика, стремящегося к высокой производительности и эффективности своих приложений. Swift предлагает мощный арсенал инструментов для борьбы с проблемами многопоточности, включая гонки за данными. Каждый из них — от примитивов синхронизации, таких как мьютексы и семафоры, до более современных подходов, таких как акторы и Strict Concurrency — предоставляют уникальные возможности для управления конкурентным доступом к данным.
Главное, что можно вынести из изучения этой темы — это понимание, что защита данных в условиях многопоточности требует осознанного подхода. В рамках этой статьи мы не смогли охватить все имеющиеся нюансы и особенности инструментов, о которых шла речь. Поэтому я настоятельно рекомендую всем, кто заинтересовался темой, не останавливаться на прочитанном: обязательно изучайте документацию, пробуйте применить разные инструменты на практике и разбирайтесь в их поведении в различных сценариях.
На этом у меня все, и я совсем никуда не спешу, ведь мой код полностью защищен от состояния гонки. Спасибо за прочтение!
