Представьте, что одно @Sendable-замыкание передали двум конкурентным задачам: гарантирует ли Swift, что его тело будет выполняться строго последовательно?
Нет. @Sendable не сериализует вызовы замыкания и не делает его внутреннее состояние потокобезопасным. Атрибут описывает, какие значения замыкание может безопасно захватывать и передавать между конкурентными контекстами; взаимное исключение нужно обеспечивать отдельно.
@Sendable появился в модели конкурентности Swift как часть статической проверки передачи данных между задачами. Исходная проблема заключалась в том, что замыкание могло незаметно захватить изменяемое состояние и затем выполняться одновременно в разных конкурентных контекстах.
Компилятор проверяет безопасность захватов, но не пытается вывести из самого типа замыкания порядок его вызовов. Это разделяет две задачи: проверку передачи данных и синхронизацию доступа к общему состоянию.
Одно и то же @Sendable-замыкание можно передать нескольким задачам. Если его тело обращается к общему изменяемому объекту, два вызова могут выполняться одновременно, даже если замыкание успешно прошло проверку Sendable.
Ошибочное предположение о последовательном выполнении приводит к гонкам данных, повреждению инвариантов и непредсказуемым результатам. Особенно опасно захватывать ссылочный объект, который формально можно передать между задачами, но у которого нет собственной синхронизации.
@Sendable означает, что значение замыкания предназначено для безопасной передачи между конкурентными задачами. Захватываемые значения должны соответствовать правилам передачи: например, быть Sendable, неизменяемыми безопасными значениями или изолированными объектами вроде actor.
При этом @Sendable не означает, что:
Если нужна сериализация, её добавляют явно. Для состояния, доступного из асинхронного кода, обычно подходит actor; для коротких синхронных критических секций — подходящая блокировка или Mutex.
Оба вызова operation могут быть запущены конкурентно, но изменение value сериализуется самим Counter. Без actor или другого механизма синхронизации наличие @Sendable не сделало бы составную операцию над общим состоянием безопасной.
Есть важное различие между безопасностью захвата и безопасностью поведения. Например, ссылка на actor безопасна для передачи, потому что доступ к его изолированному состоянию контролируется самим актором; ссылка на произвольный класс не становится безопасной только из-за помещения в @Sendable-замыкание.
Сервис аналитики принимает @Sendable-замыкание для записи события и вызывает его из нескольких задач. Внутри замыкания находится обычный счётчик количества событий.
Вариант с обычным изменяемым свойством прост, но допускает гонку при одновременном увеличении счётчика. Вариант с глобальной блокировкой устраняет гонку, однако усложняет управление временем удержания блокировки и опасен, если внутри критической секции выполняются потенциально блокирующие операции.
Выбранный вариант — вынести счётчик в actor, а замыкание оставить @Sendable. Это сохраняет возможность безопасно передавать операцию между задачами, а сериализацию изменения состояния локализует в одном типе. Цена решения — асинхронный переход к актору и необходимость учитывать приостановки при проектировании API.
Дополнительный вопрос 1: Может ли @Sendable-замыкание захватывать экземпляр обычного изменяемого класса?
В режиме строгой проверки конкурентности такой захват обычно будет диагностирован, если класс не удовлетворяет требованиям безопасной передачи. Само объявление замыкания как @Sendable не добавляет классу синхронизацию и не делает его автоматически Sendable.
Исключения возможны для типов, явно помеченных @unchecked Sendable, но тогда ответственность за отсутствие гонок полностью переходит к разработчику. Более безопасная альтернатива — захватывать actor или неизменяемое значение, а не произвольный разделяемый объект.
Дополнительный вопрос 2: Если замыкание @Sendable, гарантирует ли это последовательность его побочных эффектов?
Нет. Атрибут не задаёт порядок вызовов и не определяет планирование задач. Две задачи могут одновременно войти в тело одного замыкания, если вызывающий код не установил дополнительную сериализацию.
Порядок нужно задавать архитектурно: отправлять операции в один actor, использовать последовательную очередь или защищать синхронный участок блокировкой. Выбор зависит от того, содержит ли операция await и требуется ли асинхронная композиция.
Дополнительный вопрос 3: Достаточно ли сделать захваченное свойство let, чтобы замыкание стало потокобезопасным?
Нет. let запрещает переназначить саму ссылку или значение, но не обязательно запрещает изменение объекта, на который ссылка указывает. Неизменяемая ссылка на изменяемый класс всё ещё может вести к конкурентной записи в его свойства.
Для безопасности нужно анализировать тип захваченного значения и операции над ним. Это может быть действительно неизменяемый Sendable-тип, изолированный actor или объект с корректной внутренней синхронизацией.