Какое ключевое последствие может возникнуть при удержании синхронной блокировки через await?

Какое ключевое последствие может возникнуть при удержании синхронной блокировки через await?

Проходите собеседования с ИИ помощником Hintsage

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

Удержание синхронной блокировки через await может привести к взаимной блокировке, голоданию задач или исчерпанию потоков исполнителя. При приостановке задачи Swift не освобождает автоматически NSLock, Mutex или другую блокировку: она остаётся захваченной до явного освобождения.

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

Синхронные блокировки появились для коротких критических секций, которые выполняются без приостановки: поток захватывает блокировку, быстро изменяет состояние и освобождает её. async/await добавляет возможность приостановить задачу, причём во время ожидания поток может быть отдан другим задачам.

Проблема возникает из-за смешения двух моделей: блокировка привязана к ресурсу и удерживается синхронно, а await разделяет выполнение задачи на участки, между которыми может пройти произвольное время.

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

Если задача захватила блокировку, а затем выполнила await, другая задача может попытаться получить ту же блокировку. Она не приостановится как async-операция, а заблокирует поток исполнителя на синхронном ожидании.

Это опасно по нескольким причинам:

  • задача, удерживающая блокировку, может ждать результат работы, которой нужна та же блокировка;
  • заблокированные потоки уменьшают число потоков, доступных другим задачам;
  • даже без настоящего deadlock система может испытывать голодание и резкое падение производительности.

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

await не является границей критической секции и не вызывает unlock. Более того, после возобновления задача может продолжить работу на другом потоке, поэтому рассчитывать на привязку блокировки к конкретному потоку нельзя.

import Foundation final class Counter { private let lock = NSLock() private var value = 0 func increment() async { lock.lock() defer { lock.unlock() } await Task.yield() value += 1 } }

В примере блокировка удерживается во время приостановки. Сам по себе Task.yield() не гарантирует deadlock, но в реальном коде ожидаемая операция может косвенно потребовать тот же ресурс, что создаст циклическое ожидание.

Безопасный принцип: не пересекать await с синхронной критической секцией. Нужно выполнить асинхронную часть до захвата блокировки, затем быстро изменить защищённое состояние и сразу освободить блокировку.

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

Для полностью синхронной короткой операции подходят Mutex или другая синхронная блокировка, но внутри такой критической секции также нельзя выполнять await. Компромисс состоит в том, что actor удобнее для асинхронного владения состоянием, а mutex может быть эффективнее для очень коротких синхронных операций.

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

Сервис кэширования получает данные по сети и обновляет общий словарь. Разработчик захватывает mutex перед сетевым запросом, чтобы «защитить весь сценарий», а затем выполняет await.

Вариант с удержанием mutex через сетевой await прост, но блокирует доступ к кэшу на всё время запроса и может исчерпать потоки. Вариант с полным отказом от синхронизации допускает гонки данных. Вариант с actor сериализует доступ к словарю, но требует учитывать повторное вхождение и возможное изменение состояния после каждого await.

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

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

1. Освобождает ли await автоматически NSLock?

Нет. await может приостановить задачу и освободить поток, но состояние внешней синхронной блокировки не меняется. Блокировка будет удерживаться до вызова unlock, включая весь период ожидания.

2. Всегда ли удержание mutex через await приводит к deadlock?

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

3. Можно ли заменить mutex на actor и сохранить ту же атомарность?

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