$ sudo teach IT

Откуда берётся гонка данных

Когда несколько задач работают параллельно, рано или поздно у них появляется общее значение, с которым обе хотят что-то сделать: один и тот же массив, один и тот же счётчик, один и тот же объект. Если обе задачи меняют его одновременно без всякой защиты, получается гонка данных: результат зависит от того, кто именно первым успел прочитать или записать значение в конкретный момент времени. Такой код может месяцами работать правильно на одном железе и внезапно ломаться на другом или под нагрузкой — воспроизвести баг специально почти невозможно.

До сих пор в курсе от гонок данных спасал 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. Суммы заказов могут быть отрицательными (возврат) — их тоже нужно учитывать.