Проверка повторного входа в Swift actor и резервирования квот
Два запроса на экспорт на облачном Mac прошли проверку в одном и том же actor, но оба получили последнее свободное место. Причина не обязательно в гонке данных: первый запрос проверил доступную ёмкость и приостановился на асинхронной подготовке, а в этот промежуток вошёл второй запрос. Проверять нужно бизнес-инвариант, который должен сохраняться при переходе через await, а не способность actor последовательно читать и изменять переменные.
Сначала определите инвариант ёмкости
Допустим, сервис экспорта разрешает только одной задаче занимать временную рабочую область. При подсчёте занятых мест нужно учитывать и подготовку, и выполнение, а не только задачи, уже завершившие подготовку. Иначе медленная подготовка станет способом обойти ограничение.
Опасная последовательность выглядит так: «проверить свободное место, дождаться подготовки, увеличить число занятых мест». Пока вызов ожидает, actor может обрабатывать другие вызовы, поэтому результат проверки уже не отражает состояние на момент возобновления. Правильный порядок — сначала проверить ёмкость и зарезервировать место в участке кода без точек приостановки, а затем выполнить асинхронную операцию.
Изоляция actor защищает доступ к состоянию, но не превращает весь асинхронный метод в неделимую транзакцию. На каждом
awaitстоит задавать вопрос: останутся ли верны условия, на которые я полагаюсь, когда выполнение возобновится?
В этом примере при исчерпании ёмкости вызов сразу отклоняется, без очереди ожидания. Очередь, справедливое распределение и тайм-ауты — отдельный контракт; бесконечные повторы со стороны вызывающего кода его не заменят.
Обозначьте владельца резерва с помощью токена
Создайте на облачном Mac проект macOS с именем ActorQuota и добавьте целевой модуль модульных тестов XCTest. Поместите следующий код в тестовый файл вместо примера по умолчанию. Здесь проверяется общая логика конкурентного выполнения на Swift, поэтому запускать симулятор iOS не требуется.
import Foundation
import XCTest
enum QuotaError: Error {
case full
}
actor ReservationPool {
private let capacity: Int
private var active: Set<UUID> = []
init(capacity: Int) {
precondition(capacity > 0)
self.capacity = capacity
}
var count: Int { active.count }
func run(
_ operation: @Sendable () async throws -> Void
) async throws {
try Task.checkCancellation()
guard active.count < capacity else {
throw QuotaError.full
}
let token = UUID()
active.insert(token)
defer { active.remove(token) }
try await operation()
try Task.checkCancellation()
}
}
Между проверкой и вставкой нет await, поэтому другой вызов не может вклиниться между ними. У каждого вызова свой токен, и при выходе он освобождает только собственный резерв. Это не позволяет обработке ошибки случайно уменьшить число мест, занятых другими вызовами.
defer срабатывает и при нормальном возврате, и при выбросе ошибки, но не заменяет управление ресурсами во всех случаях. Переданная операция должна возвращаться только после фактического завершения работы. Если она запустит задачу с независимым жизненным циклом и сразу вернётся, место всё равно освободится раньше времени. Контракт вызова нужно проверять вместе с реализацией.
Задайте порядок повторного входа без случайных задержек
Добавьте в первую операцию управляемую точку приостановки. Второй вызов должен произойти после резервирования, но до освобождения места. Задержка фиксированной длительности лишь позволяет гадать о ходе выполнения: при изменении нагрузки на машину тест может проходить случайно.
Добавьте следующий одноразовый вспомогательный объект и класс тестов в тот же файл. Объект можно открыть ещё до начала ожидания: это предотвращает бесконечную приостановку, если сигнал пришёл раньше.
actor PauseGate {
private var opened = false
private var waiter: CheckedContinuation<Void, Never>?
func pause() async {
if opened { return }
await withCheckedContinuation { continuation in
waiter = continuation
}
}
func open() {
opened = true
waiter?.resume()
waiter = nil
}
}
final class ReservationTests: XCTestCase, @unchecked Sendable {
func testRejectsOverlapAndReleases() async throws {
let pool = ReservationPool(capacity: 1)
let gate = PauseGate()
let entered = expectation(description: "Operation entered")
let first = Task {
try await pool.run {
entered.fulfill()
await gate.pause()
}
}
await fulfillment(of: [entered], timeout: 2.0)
let during = await pool.count
XCTAssertEqual(during, 1)
do {
try await pool.run {}
XCTFail("Overlapping operation was accepted")
} catch QuotaError.full {
}
await gate.open()
try await first.value
let after = await pool.count
XCTAssertEqual(after, 0)
try await pool.run {}
}
}
Выполнение ожидания означает, что первый вызов уже вошёл в тело операции, а значит, место гарантированно зарезервировано. Две секунды — это предельное время ожидания нужной точки в тесте, а не требование к производительности. Вспомогательный объект поддерживает только одного ожидающего; не используйте его без изменений как компонент широковещательного оповещения в рабочем коде.
Проверяйте ошибку и отмену по отдельности
Добавьте тест с ошибкой: пусть операция сразу выбрасывает специальную тестовую ошибку. Перехватите её, проверьте тип, а затем прочитайте count. Значение должно быть нулевым, а следующий вызов должен завершиться успешно. Если проверять только наличие ошибки, можно пропустить ситуацию, когда бизнес-операция завершилась неудачей, а место осталось занятым.
Для проверки отмены используйте ту же точку приостановки: дождитесь входа в первую операцию и вызовите first.cancel(). Если прочитать число занятых мест в этот момент, оно по-прежнему должно равняться единице. Затем откройте вспомогательный объект и дождитесь результата задачи: ожидается CancellationError, а итоговое число занятых мест должно стать нулевым.
Такая консервативная семантика выбрана намеренно. Отмена лишь подаёт сигнал, который задача должна обработать совместно с вызывающим кодом; используемый здесь объект приостановки не возобновляет выполнение автоматически при отмене. Если освобождать ёмкость до выхода из нижележащей операции, новая задача сможет выполняться одновременно со старой.
Если реальный интерфейс экспорта поддерживает отмену, передайте ему сигнал отмены и дождитесь подтверждения завершения. Если операция уже вызвала необратимые побочные эффекты, проверка отмены в конце не сможет откатить результат. Точку фиксации и способ запроса результата нужно определить отдельно: выброшенная ошибка отмены не означает, что ничего не произошло.
Включите тесты в сборку и сохраните контрпример
Выполните следующую команду из корневого каталога проекта. Имя проекта и общая схема должны совпадать с указанными при создании; класс тестов должен находиться в целевом модуле ActorQuotaTests.
set -euo pipefail
xcodebuild \
-project ActorQuota.xcodeproj \
-scheme ActorQuota \
-destination 'platform=macOS' \
-only-testing:ActorQuotaTests/ReservationTests \
test
Сначала сохраните результат успешного прогона с правильной реализацией, а затем во временной ветке намеренно перенесите резервирование за асинхронную операцию. Тест на перекрытие операций должен упасть. Если он по-прежнему проходит, значит, тест на самом деле не охватывает промежуток между проверкой и занятием места.
В конце проверьте четыре условия: резерв создаётся до ожидания; отклонённый вызов не меняет число занятых мест; при ошибке освобождается только собственный токен; после отмены ёмкость возвращается лишь по фактическом завершении операции. Затем увеличьте ёмкость до двух, приостановите две операции и проверьте отказ для третьей, чтобы убедиться, что ограничение не зашито в реализацию как булев переключатель.
Добавьте эти тесты в коммит вместе с реализацией пула ресурсов. При дальнейшей переработке асинхронной подготовки они защитят реальные ограничения ёмкости надёжнее, чем утверждение «здесь используется actor, значит, всё безопасно».
Часто задаваемые вопросы
Почему actor не гарантирует соблюдение квоты?
На await выполнение может уступить место другому вызову. Если место проверено, но ещё не зарезервировано, оба вызова могут увидеть одну и ту же свободную ёмкость.
Нужно ли освобождать резерв сразу при отмене?
Только если операция действительно перестала использовать ресурс. Сигнал отмены сам по себе этого не гарантирует, поэтому резерв сохраняют до завершения операции.
Подключите выделенный Mac к своему рабочему процессу
Собирайте проекты, запускайте тесты и проводите эксперименты в оригинальной macOS. Сравните конфигурации и периоды оплаты, а затем выберите подходящий физический узел.