Откуда берётся гонка данных
Когда несколько задач работают параллельно, рано или поздно у них появляется общее значение, с которым обе хотят что-то сделать: один и тот же массив, один и тот же счётчик, один и тот же объект. Если обе задачи меняют его одновременно без всякой защиты, получается гонка данных: результат зависит от того, кто именно первым успел прочитать или записать значение в конкретный момент времени. Такой код может месяцами работать правильно на одном железе и внезапно ломаться на другом или под нагрузкой — воспроизвести баг специально почти невозможно.
До сих пор в курсе от гонок данных спасал actor: он изолирует своё состояние и пропускает обращения по одному. Но остаётся вопрос — а что вообще можно передавать между задачами без риска, а что нет? Именно на этот вопрос отвечает Sendable.
Протокол Sendable
Sendable — это протокол-маркер: у него нет ни одного метода, который нужно реализовать. Он просто помечает тип как «безопасный для передачи между задачами». Компилятор использует эту пометку, чтобы решать, какие значения можно захватывать в замыканиях, которые уйдут в другую задачу или актор, а какие — нельзя.
Большинство типов, с которыми вы уже работали, соответствуют Sendable сами по себе, без единой строчки кода:
- простые значения —
Int,Double,Bool,String; - структуры, у которых все свойства сами являются
Sendable— компилятор выводит соответствие автоматически; - перечисления без associated values или с
Sendable-значениями внутри; - сами
actor— ониSendableпо определению, потому что их состояние уже изолировано.
struct Order {
let id: Int
let amount: Int
}
Order — структура из двух Int, поэтому она Sendable автоматически: копию такой структуры можно спокойно передать в другую задачу, ведь копии не пересекаются друг с другом. Именно поэтому обычные структуры-значения — самый простой и безопасный способ передавать данные между задачами.
Почему классы — это проблема
С class ситуация другая. Экземпляр класса — это ссылка, а не копия: если передать один и тот же объект в две задачи, обе задачи получат доступ к одной и той же области памяти. Если хотя бы одна из них меняет изменяемое свойство, а другая в это время его читает или тоже меняет — это и есть гонка данных, только теперь она может случиться незаметно, через самый обычный объект.
class Basket {
var itemsCount = 0
}
Такой класс с изменяемым свойством Sendable не является — и правильно, потому что передавать его напрямую между задачами небезопасно. Класс можно сделать Sendable только в особых случаях: например, если все его свойства неизменяемы (let), или если он сам является актором. Обычный изменяемый класс для параллельной работы не годится — вместо него и берут actor, который вы уже видели: он выглядит похоже на класс, но сам берёт на себя всю защиту состояния.
Строгая проверка в Swift 6
В официальной документации Apple вы встретите термин «строгая проверка конкурентности» (strict concurrency checking). Когда она включена, компилятор Swift 6 проверяет каждую границу между задачами: если в замыкание, уходящее в другую задачу, попадает что-то не-Sendable, компиляция останавливается с ошибкой, а не молчаливо пропускает потенциальную гонку. Это касается не только классов — предупреждение получит, например, и захват переменной var из внешней области, потому что её можно случайно поменять из двух мест одновременно.
Типичные предупреждения строгого режима звучат примерно так: «capture of non-sendable type in a closure crossing actor boundary» — то есть «в замыкании, пересекающем границу актора, захвачен не-Sendable тип». Это не абстрактная придирка компилятора: за каждым таким предупреждением стоит реальная возможность гонки данных, которая в обычном режиме была бы просто багом, ожидающим своего часа.
Песочница этого курса компилирует код в языковом режиме по умолчанию, где строгая проверка не включена целиком, поэтому здесь такие предупреждения не появятся сами по себе. Это не отменяет правило: в реальном проекте на Swift 6 со строгим режимом код, зависящий от общего изменяемого состояния без защиты, не пройдёт компиляцию. Поэтому уже сейчас стоит проектировать код так, будто строгая проверка включена: передавать между задачами значения, а не ссылки на изменяемые объекты, а для общего состояния использовать actor.
Как это выглядит на практике
Собрать оба приёма вместе можно так: данные, которые «летают» между задачами, оформляют структурой, а место, где они складываются в общий итог, — актором.
struct Measurement {
let value: Int
}
actor Accumulator {
private var total = 0
func add(_ value: Int) {
total += value
}
func result() -> Int {
total
}
}
func sumAll(_ measurements: [Measurement]) async -> Int {
let accumulator = Accumulator()
await withTaskGroup(of: Void.self) { group in
for measurement in measurements {
group.addTask {
await accumulator.add(measurement.value)
}
}
}
return await accumulator.result()
}
Каждая дочерняя задача получает свою копию Measurement — это Sendable структура, копии независимы, делить им нечего. А единственное общее изменяемое состояние, total, спрятано внутри актора и меняется по одному обращению за раз. Гонке данных здесь просто негде возникнуть: ни одно значение не расшарено в обход защиты.
Частые ошибки
- Заводить рядом с актором обычный класс с изменяемым состоянием и думать, что раз где-то рядом есть
actor, всё автоматически стало безопасным — актор защищает только собственные данные, а не всё, до чего дотягивается код вокруг. - Передавать в задачи ссылку на один общий изменяемый объект вместо того, чтобы передать его данные как независимую структуру — тогда задачи делят память, которую делить не собирались.
- Считать, что раз песочница или локальная сборка скомпилировали код без строгого режима, значит с конкурентностью всё в порядке — отсутствие ошибки в нестрогом режиме не означает отсутствие гонки данных, это лишь означает, что компилятор её не искал.
- Забывать, что
Sendableу структуры выводится автоматически только пока все её свойства самиSendable— если добавить в структуру свойство типа обычного изменяемого класса, автоматический вывод перестанет срабатывать.
Резюме
- Гонка данных возникает, когда несколько задач без защиты одновременно читают и меняют одно и то же изменяемое состояние.
Sendable— протокол-маркер, которым компилятор помечает типы, безопасные для передачи между задачами.- Структуры и перечисления со значимыми свойствами получают
Sendableавтоматически; обычные классы с изменяемым состоянием — нет. - В строгом режиме Swift 6 компилятор останавливает сборку, если в замыкание, пересекающее границу задачи или актора, попадает не-
Sendableзначение; песочница курса эту строгую проверку не включает, но проектировать код стоит так, будто она включена. - Практический приём: данные передавайте структурами-значениями, а общее изменяемое состояние держите внутри
actor.
Проверьте себя
4 вопроса
Выручка из параллельных заказов
Дана структура заказа и актор для учёта выручки:
struct Order {
let amount: Int
}
actor Ledger {
private var total = 0
func add(_ amount: Int) {
total += amount
}
func sum() -> Int {
total
}
}
Реализуйте функцию totalRevenue(_ orders: [Order]) async -> Int, которая обрабатывает каждый заказ из orders в отдельной параллельной задаче и возвращает итоговую сумму всех заказов.
Требования:
- каждый заказ должен передаваться в свою задачу как самостоятельное значение
Order(структура ужеSendable, копировать её безопасно); - общий итог должен накапливаться только через методы актора
Ledger, ни в каком другом изменяемом месте; - функция обязана вернуть корректную сумму независимо от порядка завершения задач, в том числе когда заказов много и они выполняются одновременно.
Пустой список заказов должен давать сумму 0. Суммы заказов могут быть отрицательными (возврат) — их тоже нужно учитывать.