Разберите ошибку в адаптере callback API: какое нарушение контракта конкурентности произойдёт при выполнени...

Разберите ошибку в адаптере callback API: какое нарушение контракта конкурентности произойдёт при выполнении этого кода?

func load() async -> String {
    await withCheckedContinuation { continuation in
        LegacyAPI.fetch { value in
            continuation.resume(returning: value)
            continuation.resume(returning: value + "!")
        }
    }
}

enum LegacyAPI {
    static func fetch(_ completion: @escaping (String) -> Void) {
        completion("ok")
        completion("again")
    }
}
Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Одна continuation должна быть возобновлена ровно один раз. В примере второй вызов resume нарушает этот контракт и приводит к диагностике времени выполнения с аварийным завершением, а не к возврату двух результатов.

withCheckedContinuation не выбирает первый результат и не подавляет повторный callback: она проверяет корректность использования continuation.

Исторический контекст

Continuation появилась как мост между старой моделью callback API и моделью async/await. Callback сообщает результат позже, а continuation позволяет временно приостановить async-функцию и продолжить её после этого сообщения.

Главная исходная проблема — преобразовать однократный асинхронный результат в линейный код без ручного управления состоянием ожидания. При этом callback API может содержать ошибки адаптации: не вызвать completion, вызвать его несколько раз или вызвать одновременно из разных потоков.

Постановка проблемы

У withCheckedContinuation есть однозначный жизненный цикл: до вызова resume async-функция приостановлена, после первого вызова она готова продолжить выполнение. Второй вызов пытается завершить уже завершённое ожидание.

В реальном API такая ошибка может возникнуть, например, когда один путь сообщает об успехе, а другой — об ошибке, и оба не защищены общей синхронизацией. Если completion вообще не будет вызван, ожидающая задача останется приостановленной.

Подробное решение

withCheckedContinuation регистрирует continuation у текущей async-задачи. resume(returning:) передаёт результат задаче и делает continuation недействительной для дальнейшего возобновления. Поэтому допустима ровно одна операция завершения: успешная или, для throwing-варианта, с ошибкой.

Проверка не делает callback API безопасным автоматически. Она обнаруживает нарушение контракта, но не решает гонку между двумя callback-вызовами и не отменяет внешнюю операцию. Если callback может приходить из нескольких потоков, адаптер должен сам обеспечить однократное завершение через блокировку, атомарный флаг или отдельный синхронизированный объект.

withUnsafeContinuation отличается тем, что предназначена для случаев, где разработчик гарантирует корректный контракт самостоятельно. Она не превращает повторный или отсутствующий resume в допустимое поведение: при ошибке результатом может стать зависание, повреждение логики или неопределённое поведение. Поэтому для обычного адаптера предпочтительнее checked-вариант.

Исправленный вариант должен устранить повторный callback на уровне LegacyAPI либо добавить потокобезопасный механизм «завершить только один раз». Простая несинхронизированная переменная wasResumed недостаточна: два параллельных callback могут одновременно увидеть значение false.

Ситуация из практики

Сетевой SDK иногда вызывает completion дважды: первый раз после получения ответа, второй — из общего обработчика тайм-аута. Рассматривались два решения. Использовать withUnsafeContinuation проще и не добавляет проверок, но скрывает ошибку и оставляет риск зависания или повреждения состояния. Игнорировать второй вызов обычным флагом дешевле, но такой флаг должен изменяться атомарно или под блокировкой.

Выбран адаптер с потокобезопасным однократным завершением и withCheckedContinuation. Первый callback возобновляет задачу, последующие отбрасываются, а checked-проверка сохраняет диагностику ошибок самого адаптера. В результате async-код получает один результат, а гонка между таймером и ответом не приводит к повторному resume.

Что кандидаты часто упускают

  1. Что произойдёт, если completion не вызовет resume вообще?

    Async-функция останется приостановленной, поэтому ожидающая задача не продолжит выполнение сама по себе. У checked continuation при уничтожении невозобновлённой continuation обычно появляется диагностическое сообщение о нарушении контракта, но это не является механизмом автоматического тайм-аута или отмены операции.

    Практический вывод: адаптер должен явно обработать все ветви — успех, ошибку, отмену и возможный отказ внешнего API вызывать completion. Если внешняя операция поддерживает отмену, её нужно связать с отменой async-задачи отдельно.

  2. Чем отличается withCheckedThrowingContinuation от обычной continuation?

    Она позволяет завершить ожидание либо значением, либо ошибкой, например через resume(returning:) и resume(throwing:). Но правило жизненного цикла не меняется: допустим ровно один вызов resume любого типа.

    Нельзя сначала вернуть значение, а затем сообщить ошибку как альтернативный исход. Для такой адаптации нужно выбрать один результат под синхронизацией до возобновления continuation.

  3. Достаточно ли проверить флаг перед resume, если callback приходит из разных потоков?

    Нет. Последовательность «прочитать флаг — понять, что он ложен — записать истину» не является атомарной. Два потока могут пройти проверку одновременно и оба вызвать resume.

    Нужен механизм, сериализующий проверку и изменение состояния: например, блокировка, атомарная операция или actor. Actor также должен защищать сам переход состояния, а не только отдельные чтения и записи; после принятия решения continuation следует возобновить ровно для одного победившего вызова.