Pull to refresh
40.38

Как работает async/await в Swift

Level of difficultyHard
Reading time27 min
Views7.4K
Original author: Bruno Rocha

Привет! Мы – студия мобильной разработки CleverPumpkin

Недавно мы читали статью Бруно Роша — инженера-программиста из Spotify о паттерне async/await. Материал показался нам интересным и полезным с точки зрения разработки, поэтому решили её перевести и поделиться с вами. Приятного чтения! 


Функция async/await в Swift появилась в iOS 15, и я полагаю, что на данный момент вы уже знаете, как ее использовать. Но задумывались ли вы когда-нибудь о том, как работает async/await изнутри? Или, может быть, почему выглядит и ведет себя именно так? Или даже почему вообще была представлена?

В типичной для SwiftRocks манере мы углубимся в компилятор Swift, чтобы ответить на вопросы о том, как работает async/await внутри. Это не инструкция по использованию async/await, а глубокое погружение в историю и реализацию этой функции, чтобы понять, как она работает, почему она работает, чего можно добиться с ее помощью и, самое главное, какие нюансы следует учитывать при работе с ней.

От автора: я никогда не работал в компании Apple и не имею никакого отношения к разработке async/await. Это результат моих собственных исследований и реверс-инжиниринга, поэтому предполагаю, что некоторая информация, представленная здесь, не будет на 100% точной.

Swift и цель обеспечения безопасности памяти

Async/await в Swift привнес в язык совершенно новый способ работы с асинхронным кодом. Но прежде, чем пытаться понять, как он работает, необходимо сделать шаг назад, чтобы понять, зачем Swift вообще его ввёл.

Концепция неопределенного поведения в языках программирования — это то, что наверняка преследует каждого, кому приходилось работать с так называемыми «языками-предшественниками», такими как C++ или Obj-C.

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

С одной стороны, такое поведение языков делало их чрезвычайно мощными, но с другой — превращало любой программный продукт, написанный с их помощью, в минное поле. Рассмотрим следующий код на языке С, в котором мы пытаемся прочитать несуществующий индекс массива:

// arr содержит всего 2 элемента
void readArray(int arr[]) {
    int i = arr[20];
    printf("%i", i);
}

Мы знаем, что в Swift это приведет к возникновению исключения, но мы говорим пока не о Swift, а о языке-предшественнике, который полностью доверяет разработчику. Что же произойдет здесь? Произойдет ли сбой? Будет ли это работать?

Ответ заключается в том, что мы не знаем. Иногда вы получите 0, иногда — случайное число, а иногда — сбой. Это полностью зависит от того, каким будет содержимое этого конкретного адреса памяти в этот конкретный момент времени. Так что, другими словами, поведение этого выражения не определено.

Но, опять же, это было сделано намеренно. Язык предполагает, что вы знаете, что делаете, и позволяет вам продолжать, даже если это грозит серьезной ошибкой.

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

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

Можно было бы возразить, что за это приходится расплачиваться тем, что Swift уступает по мощности и потенциалу, но интересный аспект Swift заключается в том, что он по-прежнему позволяет при необходимости использовать возможности, заложенные в языках-предшественниках. Такие операции называются «небезопасными», и когда вы имеете дело с такими операциями, поскольку они явно подписаны ключевым словом «unsafe».

let ptr: UnsafeMutablePointer<Int> = ...

Проблема конкурентности в Swift

Однако несмотря на то, что Swift был разработан для обеспечения безопасности доступа к памяти, он никогда не был действительно на 100% безопасным: конкурентность всё еще являлась серьезным источником неопределенного поведения.

Основная причина заключается в том, что Grand Central Dispatch (GCD) – основное решение Apple для обеспечения конкурентности в iOS приложениях, не является функцией самого компилятора Swift, а представляет собой библиотеку на языке C (libdispatch), которая поставлялась в iOS в составе Foundation. Как и следовало ожидать от такой библиотеки, GCD предоставляла большую свободу в отношении работы с конкурентностью, что затрудняло предотвращение самим Swift таких распространенных проблем, как data race, race condition, deadlock, priority inversion и thread explosion.

Если вы не знакомы с одним или несколькими терминами, приводим краткий глоссарий:

  • Data race (гонка за ресурсы): одновременный доступ двух потоков к общим данным, что приводит к непредсказуемым результатам.

  • Race Condition (состояние гонки): нарушение синхронизации выполнения двух или более потоков приводит к тому, что события происходят в неправильном порядке.

  • Deadlock (взаимная блокировка): два потока ожидают друг друга, в  результате чего ни один из них не может продолжить работу.

  • Priority Inversion (инверсия приоритетов): низкоприоритетная задача удерживает ресурс, необходимый высокоприоритетной задаче, что приводит к задержкам в выполнении.

  • Thread Explosion (переизбыток потоков): чрезмерное количество потоков в программе, приводящее к исчерпанию ресурсов и снижению производительности системы.

let semaphore = DispatchSemaphore(value: 0)
highPrioQueue.async {
    semaphore.wait()
    // …
}

lowPrioQueue.async {
    semaphore.signal()
    // …
}

Приведенный выше пример является классическим примером инверсии приоритетов в iOS. Хотя вы, как разработчик, знаете, что вышеуказанный семафор заставит одну очередь ждать другую, GCD не обязательно согласится с этим и не сможет правильно повысить приоритет очереди. Следует отметить, что GCD может саморегулироваться в некоторых ситуациях, но на шаблоны, подобные этому примеру, это не распространяется.

Поскольку компилятор не мог помочь в решении подобных проблем, конкурентность (и потокобезопасность, в частности) исторически была одним из самых неприятных моментов в разработке под iOS, и Apple это прекрасно понимала. В 2017 году Крис Латтнер, один из создателей Swift, изложил свое видение обеспечения безопасной конкурентности в своем Swift Concurrency Manifesto, а в 2020 году появилась «дорожная карта», предусматривающая новые ключевые возможности Swift, в том числе:

  • Шаблон async/await (The async/await pattern)

  • Task API и концепция структурированного параллелизма

  • Система акторов

Но хотя то, что предлагалось в «дорожной карте», было новым для языка, оно не было новым для индустрии. Шаблон async/await, впервые появившийся в 2007 году в языке F#, стал отраслевым стандартом с 2012 года (когда C# его популяризировал) благодаря тому, что позволяет писать асинхронный код как традиционный синхронный, что делает код, более легким для чтения.

Например, раньше вы могли написать:

func loadWebResource(_ path: String, completionBlock: (result: Resource) -> Void) { ... }
func decodeImage(_ r1: Resource, _ r2: Resource, completionBlock: (result: Image) -> Void)
func dewarpAndCleanupImage(_ i : Image, completionBlock: (result: Image) -> Void)

func processImageData1(completionBlock: (result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}

тогда как сейчас можно написать:

func loadWebResource(_ path: String) async -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async -> Image
func dewarpAndCleanupImage(_ i : Image) async -> Image

func processImageData1() async -> Image {
    let dataResource  = await loadWebResource("dataprofile.txt")
    let imageResource = await loadWebResource("imagedata.dat")
    let imageTmp      = await decodeImage(dataResource, imageResource)
    let imageResult   = await dewarpAndCleanupImage(imageTmp)
    return imageResult
}

Внедрение этого шаблона именно в Swift не только улучшило опыт работы с колбеками, но и позволило компилятору обнаруживать и предотвращать распространенные ошибки конкурентного программирования. И понятно почему Крис Латтнер сделал этот шаблон центральным элементом своего манифеста, заявив: «Я предлагаю сделать очевидную вещь и добавим поддержку этого в Swift».

В течение нескольких лет эти возможности постепенно интегрировались в Swift, кульминацией чего стал выпуск Swift 5.5 и «официальный» релиз async/await в Swift.

Async/await изнутри

Теперь, поняв, почему async/await стал частью Swift, мы готовы взглянуть на то, как он работает внутри!

Но сначала нужно оговорить с вами некоторые моменты. Поскольку то, что мы называем «async/await», на самом деле представляет собой несколько различных функций компилятора, работающих в унисон. Каждая из этих функций достаточно сложна и заслуживает отдельной статьи (статей), поскольку охватить все детали в одной может грозить взрывом мозга уже после первого раздела. 

Поэтому я решил, что хорошей идеей будет рассказать только о том, что, по моему мнению, является «основной» функциональностью async/await, а остальные детали оставить для будущих статей. Хоть мы и не будем подробно останавливаться на таких деталях, я все же постараюсь упомянуть некоторые из них в соответствующих разделах, чтобы вы знали, где они могут пригодиться. Для простоты мы также не будем рассматривать здесь режимы совместимости и бриджинг с Obj-C.

Итак, давайте начнем! Когда я занимаюсь реверс-инжинирингом, чтобы узнать о чем-то больше, я всегда начинаю на самом низком уровне и двигаюсь вверх. 

Поэтому, когда я решил, что хочу понять, как работает async/await, моим первым вопросом было: «Кто управляет фоновыми потоками программы?»

Cooperative Thread Pool

Прежде всего мы должны рассмотреть один из важнейших аспектов работы async/await в Swift: хотя технически async/await использует GCD под капотом, он не использует DispatchQueue, с которыми мы знакомы. Вместо этого в async/await используется совершенно новая функция libdispatch под названием Cooperative Thread Pool.

В отличие от традиционных DispatchQueue, которые создают и завершают потоки динамически, по мере необходимости, Cooperative Thread Pool управляет фиксированным количеством потоков, которые постоянно помогают друг другу в решении задач.

Фиксированное число потоков, которое в данном случае равно количеству ядер процессора — это намеренный ход, призванный предотвратить проблему thread explosion и повысить производительность системы в целом, с чем DispatchQueue, как известно, не очень хорошо справлялись.

Другими словами, Cooperative Thread Pool похож на традиционный GCD с точки зрения интерфейса (сервис получает задачу и организует поток для ее выполнения), но он более эффективен и разработан с учетом особенностей рантайма Swift.

Вы можете узнать, как именно работает Cooperative Thread Pool, изучив репозиторий libdispatch с открытым исходным кодом, и я хотел бы рассказать об этом в будущей статье. Хотя я считаю, что и доклады WWDC от 2021 года прекрасно описывают, как пул работает внутри системы.

Нюанс: Starvation или «голодание» потоков

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

Давайте поднимемся на один уровень абстракции выше. Как компилятор «разговаривает» с пулом?

Исполнители

В Swift вы не взаимодействуете с Cooperative Thread Pool напрямую. Он скрыт несколькими слоями абстракций, и на самом нижнем из этих слоев мы можем найти исполнителей (executors).

Исполнители, как и сам пул, представляют собой сервисы, которые принимают задачи и организуют выполнение их каким-либо потоком. Основное различие между ними заключается в том, что если пул — это просто пул, то исполнители могут иметь различные формы. Все они в конечном итоге пересылают задачи в пул, но вот как это происходит — может меняться в зависимости от типа используемого исполнителя. На данный момент исполнители могут быть конкурентными (задачи будут выполняться параллельно), так и последовательными (по одной), а компилятор предоставляет встроенные реализации для обоих типов.

Встроенный конкурентный исполнитель

Встроенный конкурентный исполнитель внутри системы называется Global Concurrent Executor.

Реализация этого исполнителя в компиляторе Swift по большей части является не более чем абстракцией поверх Cooperative Thread Pool, о котором мы говорили в предыдущем разделе. Она стартует с создания экземпляра нового пула, что, как мы видим, осуществляется вызовом старого доброго GCD API с новым специальным флагом:

constexpr size_t dispatchQueueCooperativeFlag = 4;
queue = dispatch_get_global_queue((dispatch_qos_class_t)priority,
                                  dispatchQueueCooperativeFlag);

Затем, когда исполнителю предлагается выполнить задачу, он направляет ее в пул через специальный API dispatch_async_swift_job:

JobPriority priority = job->getPriority();
auto queue = getGlobalQueue(priority);
  dispatch_async_swift_job(queue, job, (dispatch_qos_class_t)priority,
                  DISPATCH_QUEUE_GLOBAL_EXECUTOR);

Не буду углубляться в подробности libdispatch и dispatch_async_swift_job, но, как уже говорилось в предыдущем разделе, это должен быть специальный / более эффективный вариант обычного dispatch_async, с которым iOS разработчики знакомы, который лучше подходит для особых потребностей рантайма Swift.

Еще стоит упомянуть, что этот исполнитель является «глобальным», то есть существует только один его экземпляр для всей программы. Причина почему последовательная DispatchQueue будет передавать свои задачи глобальным очередям та же: хотя с точки зрения системы имеет смысл разделять обязанности, – с точки зрения производительности было бы кошмаром, если бы каждый компонент имел свои собственные потоки. Поэтому разумнее иметь одного глобального исполнителя, который будет распределять большую часть задач в системе, а все остальные будут лишь переадресовывать свои задачи ему.

Global Concurrent Executor — это исполнитель по умолчанию в Swift. Если в вашем асинхронном коде нет явного запроса на выполнение через конкретного исполнителя, то именно этот исполнитель будет его обрабатывать (мы разберем несколько примеров далее).

На платформах, не поддерживающих libdispatch Swift использует другой глобальный исполнитель, но не будем углубляться, это все же статья об iOS.

Встроенный последовательный исполнитель

В отличие от параллельного исполнителя назначение последовательного исполнителя — это следить за тем, чтобы задачи выполнялись поочередно и в том порядке, в котором они были отправлены.

Встроенный последовательный исполнитель называется Актором по умолчанию (Default Actor) (осторожно, спойлер!) и по своей сути является абстракцией конкурентного исполнителя, который отслеживает связный список задач:

class DefaultActorImpl : public HeapObject {
public:
  void initialize();
  void destroy();
  void enqueue(Job *job);
  bool tryAssumeThread(RunningJobInfo runner);
  void giveUpThread(RunningJobInfo runner);
}

struct alignas(2 * sizeof(void*)) State {
  JobRef FirstJob;
  struct Flags Flags;
};

enum class Status {
  Idle,
  Scheduled,
  Running,
};

swift::atomic<State> CurrentState;

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

static void setNextJobInQueue(Job *job, JobRef next) {
  *reinterpret_cast<JobRef*>(job->SchedulerPrivate) = next;
}

Полное описание того, что происходит, когда задача ставится в очередь к последовательному исполнителю, мы опустим, чтобы не углубиться в дебри, поскольку этот исполнитель отвечает за управление множеством вещей, относящихся к другим функциям async/await (один только Actor.cpp содержит 2077 строк кода).

Но обращу ваше внимание на то, как он пытается предотвратить инверсию приоритетов (priority inversion). Когда высокоприоритетная задача заносится в список, в котором до этого были только низкоприоритетные задачи, исполнитель повышает приоритет всех предшествующих ему задач.

if (priority > oldState.getMaxPriority()) {
  newState = newState.withEscalatedPriority(priority);
}

Как следует из названия, последовательный исполнитель Default Actor используется при написании асинхронного кода с помощью акторов. Прежде чем перейти к рассмотрению акторов, нам еще предстоит разобрать еще несколько вещей.

Кастомные исполнители

Помимо двух встроенных исполнителей, в Swift можно создать свой собственный исполнитель, создав тип, реализующий протокол Executor:

public protocol Executor: AnyObject, Sendable {
    func enqueue(_ job: consuming Job)
}

А специально для последовательных исполнителей Swift предоставляет более конкретный протокол SerialExecutor:

public protocol SerialExecutor: Executor { ... }

Такая возможность была добавлена в Swift 5.9 вместе с опцией передачи кастомных исполнителей в определенные API, но причин для использования этого практически нет. Это инструмент поддержки разработчиков, использующих Swift на других платформах, а не то, с чем придется иметь дело iOS разработчику. Тем не менее, мы рассмотрим далее одну очень важную функцию, которая основывается на этой возможности. Но прежде, чем перейти к ней, давайте ответим еще на несколько вопросов.

Для этого продолжим двигаться вверх по уровням абстракции. Мы узнали, что встроенные в Swift исполнители передают задачи в Cooperative Thread Pool, но откуда берутся эти задачи?

Дайте мне эти задачи: паттерн async/await

Следующая часть головоломки заключается в самом паттерне async/await.

Как вы, наверное, заметили, шаблон async/await состоит из двух новых ключевых слов (async и await), которые позволяют определить асинхронную функцию и ожидать возврата из неё соответственно:

func example() async {
    let fooResult = await foo()
    let barResult = await bar()
    doSomething(fooResult, barResult)
}

func foo() async -> FooResult {
    // Некий async код
}

func bar() async -> BarResult {
    // Некий async код
}

Одна из основных задач паттерна async/await – позволить писать асинхронный код так, как если бы это был синхронный код. И может создаться впечатление, что эта функция на самом деле – всего лишь отдельный проход компилятора, который делит функцию на несколько компонентов. Такое определение неплохо подходит для понимания принципа работы, но в действительности все несколько сложнее.

Вместо того, чтобы воспринимать асинхронную функцию как синтаксический сахар для объявления нескольких замыканий, подумайте о ней как об обычной функции, которая обладает особым свойством – она может «отказаться» от своего потока и ждать, пока что-то произойдет. По завершении этого процесса функция загружается обратно и возобновляет выполнение.

Это означает, что асинхронные и синхронные функции, за исключением того как они ожидают наступления событий, в Swift являются (как бы) одним и тем же. Разница лишь в том, что если синхронная функция полностью использует поток и его стек, то асинхронные функции имеют дополнительную возможность «отказаться» от стека и поддерживать свое собственное, отдельное хранилище.

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

func foo() async throws {
    // …
    throw MyError.failed // Так нельзя сделать без async/await!
}

Но хватит теории. Как это работает?

Контексты исполнения

Понимание того, как реализован этот паттерн, можно начать с рассмотрения того, что делает компилятор Swift при обработке строки кода, помеченной как await. При компиляции приведенного выше кода из примера с флагом -emit-sil, мы увидим, что результат на языке Swift Intermediate Language выглядит примерно так (упрощено для удобства чтения):

// example()
sil hidden @$s4test7exampleyyYaF : $@convention(thin) @async () -> () {
bb0:
  hop_to_executor foo
  foo()
  hop_to_executor example
  hop_to_executor bar
  bar()
  hop_to_executor example
  return void
} // end sil function '$s4test7exampleyyYaF'

SIL асинхронной функции выглядит точно так же, как и обычной синхронной, с той лишь разницей, что до и после вызова await-функции Swift вызывает что-то с названием hop_to_executor. Согласно документации компилятора, этот символ предназначен для того, чтобы убедиться, что код выполняется в нужном исполнителе. 

Одним из важных аспектов безопасности доступа к памяти в async/await в Swift является то, что называется контекстами исполнения. Как мы уже упоминали, каждый раз, когда что-то выполняется асинхронно в Swift через async/await, оно должно пройти через определенного исполнителя. Большая часть кода будет по умолчанию проходить через глобальный конкурентный исполнитель, но некоторые API могут использовать другие исполнители.

Причина, по которой некоторые API могут предъявлять особые требования к исполнителю, заключается в предотвращении гонки за ресурсы. Мы еще не готовы к рассмотрению этой темы, поэтому пока просто запомним, что это причина существования различных исполнителей.

На практике hop_to_executor проверяет текущий контекст исполнения. Если исполнитель, на котором в данный момент выполняется функция, совпадает с ожидаемым у функции, которую мы вызываем через await, код будет выполняться синхронно. Если же это не так, то создается suspension point (точка приостановки); функция запрашивает запуск необходимого кода в правильном контексте и «отказывается» от своего потока, пока ожидает результата. Этот «запрос» — та самая задача, которую мы искали, и то же самое произойдет, когда задача завершится, для возврата в исходный контекст и выполнения оставшейся части кода.

func example() async {
    (original executor)
    let fooResult = await foo() // ПОТЕНЦИАЛЬНАЯ задача 1 (переход к исполнителю foo)
    // ПОТЕНЦИАЛЬНАЯ задача 2 (обратно к начальному контексту)
    let barResult = await bar()  // ПОТЕНЦИАЛЬНАЯ задача 3 (переход к исполнителю bar)
    // ПОТЕНЦИАЛЬНАЯ задача 4 (обратно к начальному контексту)
    doSomething(fooResult, barResult)
}

Слово «ПОТЕНЦИАЛЬНАЯ» здесь очень важно: как было сказано, точка приостановки создается только в том случае, если мы находимся в неподходящем контексте. Если прыгать по контекстам не нужно, то код будет выполняться синхронно. Это то, чего не могла делать DispatchQueue, и это очень полезная возможность.

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

protocol MyProto {
    func asyncFunction() async
}

struct MyType: MyProto {
    func asyncFunction() {
        // Это не async функция, но для Swift это окей,
        // потому что `async` и `await` не означают, что
        // функция _на самом деле_ асинхронная, а только то,
        // что она _может быть_ таковой
    }
}

Именно поэтому можно вызывать синхронные функции из асинхронных, но не наоборот: асинхронные функции умеют синхронно ожидать чего-то, а синхронные не умеют создавать точки остановки.

Точки остановки – это серьезное преимущество для безопасности доступа к памяти в Swift: поскольку они приводят к освобождению потока (в отличие от того, как lock, семафор или DispatchQueue.sync удерживали бы его до получения результата), это означает, что в async/await не может возникнуть deadlock! Пока вы не смешиваете код async/await с другими механизмами потокобезопасности (чего, по мнению Apple на сессии в 2021 году не следует делать), ваш код всегда будет иметь поток, в котором он может исполняться.

Нюанс: Реентерабельность

Следует, однако, отметить, что такое поведение имеет важную загвоздку с точки зрения архитектуры кода. Поскольку точки приостановки могут «отказаться» от своего потока в ожидании результата, может произойти (и произойдет) так, что поток, в котором был отправлен запрос, может начать выполнять другие задачи в ожидании результата! Более того, если вы не используете Main Actors (что мы подробно рассмотрим далее), то нет никакой гарантии, что поток, который будет обрабатывать результат, будет тем же самым, который инициировал запрос!

func example() async {
    doSomething() // Выполняется на потоке A
    await somethingElse()
    doSomethingAgain() // Это МОЖЕТ тоже выполнятся на потоке А, но скорее всего не будет!
    // Также поток А скорее всего продолжил выполнять другие задачи, пока мы ждали somethingElse()!
}

Это означает, что для реализации потокобезопасных объектов в async/await необходимо структурировать код таким образом, чтобы он никогда не предполагал и не переносил состояние через точки приостановки, поскольку любые предположения, сделанные о состоянии программы до такой точки, могут оказаться неверными после нее. Такое поведение async/await называется реентерабельностью (reentrancy), и более подробно мы расскажем об этом ниже, когда начнем говорить конкретно о race condition. Одним словом, реентерабельность в async/await в Swift является преднамеренной, и об этом необходимо постоянно помнить при работе с кодом async/await в Swift.

От автора: Я хотел бы показать, как именно работают эти точки приостановки и перезагрузка в коде компилятора, но на момент написания статьи мне не удалось как следует разобраться в этом. Тем не менее, я хотел бы это сделать, так что я обновлю эту статью, как только разберусь с этим. 

Однако нам еще предстоит решить одну важную головоломку. Если синхронные функции не могут вызывать асинхронные, поскольку не имеют возможности создать точку приостановки, то что же является «точкой входа» для асинхронной функции?

Задачи и структурированный параллелизм

В async/await способ первого вызова асинхронной функции заключается в создании объекта Task:

Task {
    await foo()
}

Поскольку замыкание с задачей само по себе помечено как async, его можно использовать для вызова других асинхронных функций. Это и есть та самая «точка входа», которую мы искали.

Структура Task в Swift играет гораздо более важную роль, чем просто возможность вызова асинхронного кода; она является фундаментальной частью того, что в Swift называется «структурированным параллелизмом», когда асинхронный код строится как иерархия «задач». Такая структура позволяет родительским задачам управлять своими дочерними задачами, обмениваясь информацией о состоянии, контексте, приоритете и локальных значениях, а также создавать дочерние «группы задач», состоящие из нескольких параллельно выполняющихся задач. Структурированный параллелизм является основой архитектуры async/await в Swift, и это тоже достаточно большая тема для отдельной статьи.

Вернемся к исходному вопросу. Как Task умудряется создавать асинхронное замыкание из ниоткуда?

Ключ к пониманию того, как Task создает такое async замыкание, лежит в его инициализаторе. Когда создается Task, замыкание, которое он получает, управляется не самой структурой Task, а функцией, которая находится глубоко внутри рантайма Swift:

extension Task where Failure == Never {
  public init(
    priority: TaskPriority? = nil,
    @_inheritActorContext @_implicitSelfCapture operation: __owned @Sendable @escaping () async -> Success
  ) {
    let flags = taskCreateFlags(
      priority: priority, isChildTask: false, copyTaskLocals: true,
      inheritContext: true, enqueueJob: true,
      addPendingGroupTaskUnconditionally: false,
      isDiscardingTask: false)

    let (task, _) = Builtin.createAsyncTask(flags, operation)
    self._task = task
  }
}

Вызов Builtin.createAsyncTask в конечном итоге приведет к вызову swift_task_create в рантайме Swift, которая создаст задачу на основе нескольких флагов, определяющих ее поведение. Компилятор автоматически берет эту настройку на себя, и после настройки задачи она сразу же направляется на выполнение соответствующему исполнителю.

static AsyncTaskAndContext swift_task_create_commonImpl(…) {
  // Реальная функция гораздо более сложная, чем этот вариант.
  // Это всего лишь упрощенный псевдокод для изучения.

  task.executor = task.parent.executor ?? globalConcurrentExecutor;

  task.checkIfItsChildTask(flags);
  task.checkIfItsTaskGroup(flags);
  task.inheritPriorityFromParentIfNeeded(flags);

  task.asJob.submitToExecutor();
}

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

Интересно, что Swift фактически предоставляет вам API, которые позволяют вам получать доступ к этим графам в вашем коде, хотя они ясно дают понять, что их следует использовать только в особых случаях. Одним из примеров является функция UnsafeCurrentTask, которая позволяет функциям определить, были ли они вызваны как часть задачи.

func synchronous() {
  withUnsafeCurrentTask { maybeUnsafeCurrentTask in 
    if let unsafeCurrentTask = maybeUnsafeCurrentTask {
      print("Seems I was invoked as part of a Task!")
    } else {
      print("Not part of a task.")
    }
  }
}

Нюанс: случайное наследование задач

Поскольку дочерние задачи по умолчанию наследуют свойства родительской, а рантайм обрабатывает это автоматически, могут возникнуть ситуации, когда задача наследует не то, что нужно:

func example() async {
  Task {
    // У этой задачи ЕСТЬ родитель, хотя это не выглядит так!
  }
}

В приведенном примере то, что выглядит как «простая» задача, на самом деле является дочерней задачей той задачи, которая привела к вызову example()! Это означает, что данная задача наследует те свойства родительской задачи, которые нам не нужны, например, исполнителя. Одним из примеров того, когда это может стать проблемой, является код, взаимодействующий с MainActor, который мы рассмотрим ниже.

Чтобы избежать этого, необходимо использовать альтернативные инициализаторы задачи, такие как Task.detached, который определяет «неструктурированные» задачи без родителя, но обращаю ваше внимание, что они также имеют свои «заморочки», поэтому перед их использованием обязательно ознакомьтесь с документацией по API.

Мы рассмотрели все основные механизмы работы async/await, но нам осталось ответить на один вопрос. Мы увидели, как async/await может предотвратить переизбыток потоков (thread explosion), инверсию приоритетов (priority inversion) и взаимную блокировку (deadlock), но как быть с гонкой за ресурсы? Мы знаем, что концепция «контекстов исполнения» должна предотвращать их, но на практике мы этого еще не видели.

Мы также не говорили о пресловутом состояния гонки, от которой страдают все приложения для iOS. Что же делает async/await для защиты от этого?

Защита общего изменяемого состояния: акторы

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

В Swift «actor» — это особый тип класса, который обозначается ключевым словом actor:

actor MyExample {
    var fooInt = 0
}

Акторы похожи на классы, но они обладают особым свойством: любое изменяемое состояние, управляемое актором, может быть изменено только им самим:

func foo() {
    let example = MyExample()
    example.fooInt = 1 // Ошибка: Actor-isolated `fooInt`
    // cannot be mutated from a non-isolated context
}

В приведенном примере, чтобы изменить значение fooInt , мы должны каким-то образом абстрагировать это действие, чтобы оно происходило в пределах актора:

actor MyExample {
    var fooInt = 0
    func mutateFooInt() {
        fooInt = 1
    }
}

Казалось бы, это ничего не меняет, но именно здесь проявляется вторая особенность акторов: только актор может синхронно обращаться к своим методам и свойствам, все остальные должны делать это асинхронно:

func foo() {
    let example = MyExample()
    Task {
        await example.mutateFooInt()
        // Самому актору можно вызывать mutateFooInt() синхронно
        // Но из функции foo() нельзя
    }
}

Это понятие называется изоляцией акторов, и в сочетании с рассмотренной выше концепцией контекстов исполнения async/await позволяет предотвратить потенциальные гонки за ресурсы в вашей программе. Более того, эти проверки происходят во время компиляции!

Если говорить точнее, то при выполнении await с использованием актора ваш код будет перенаправлен не в глобальный конкурентный исполнитель по умолчанию, а в последовательный, созданный специально для этого экземпляра актора. Это приводит к тому, что вы не можете вызвать две функции актора одновременно (одна завершится раньше, чем начнется другая), а в сочетании с тем фактом, что компилятор не позволяет «утечь» изменяемому состоянию актора, вы получаете ситуацию, когда состояние актора не может быть изменено двумя потоками одновременно. Но как это работает внутри?

Когда дело доходит до реализации, акторы оказываются на удивление простыми. В Swift объявление актора — это просто синтаксический сахар для объявления класса, который наследуется от протокола Actor:

public protocol Actor: AnyObject, Sendable {
    nonisolated var unownedExecutor: UnownedSerialExecutor { get }
}

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

public struct UnownedSerialExecutor: Sendable {
    internal var executor: Builtin.Executor
    public init<E: SerialExecutor>(ordinary executor: __shared E) {
      self.executor = Builtin.buildOrdinarySerialExecutorRef(executor)
    }
}

Когда вы объявляете актор с помощью синтаксического сахара, Swift автоматически генерирует реализацию протокола:

// Что пишете вы:
actor MyActor {}

// Что получается в итоге:
final class MyActor: Actor {

    nonisolated var unownedExecutor: UnownedSerialExecutor {
        return Builtin.buildDefaultActorExecutorRef(self)
    }

    init() {
        _defaultActorInitialize(self)
    }

    deinit {
        _defaultActorDestroy(self)
    }

}

Мы уже знаем, что делает этот сгенерированный код: он инициализирует последовательного исполнителя Default Actor, о котором мы говорили в начале. Поскольку акторы реализованы глубоко внутри Swift, компилятор знает, что всякий раз, когда кто-то ссылается на них, конечный вызов hop_to_executor должен перенаправлять на свойство актора unownedExecutor, а не на глобальный исполнитель.

Нюанс: Реентерабельность акторов (акторы и потокобезопасность)

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

Когда создается точка приостановки, актор разрешает выполнение других задач в последовательной очереди. Это означает, что, когда результат выполнения исходной задачи наконец-то будет получен, возможно, что состояние актора изменится таким образом, что все предположения, которые вы сделали до точки приостановки, уже не будут верны!

Применительно к акторам это называется Actor Reentrancy, и это то, о чем необходимо постоянно помнить при попытке написать потокобезопасный код с использованием async/await. Как уже говорилось в разделе о реентерабельности в целом, для того, чтобы акторы были потокобезопасными, необходимо структурировать код таким образом, чтобы никакое состояние не предполагалось и не переносилось через точку приостановки.

Sendable и nonisolated

Как и в случае с взаимными блокировками, решение проблемы гонок за ресурсы в акторах имеет важные последствия с точки зрения архитектуры кода. Если изменяемое состояние актора не может «утечь», то как вообще что-то может произойти?

В async/await предусмотрены две фичи для решения этой задачи. Первая — это протокол Sendable, который отмечает типы, которые могут безопасно покинуть actor:

public protocol Sendable { }

Этот протокол не имеет кода,  это просто маркер, используемый компилятором для определения того, каким типам разрешено покидать акторы, в которых они были созданы. Это не означает, что вы можете помечать что угодно как Sendable. Swift на самом деле не хочет, чтобы вы добавляли возможности для гонки за ресурсы в свои программы, поэтому компилятор предъявляет очень строгие требования к тому, что может реализовать этот протокол:

  • Акторы (по умолчанию)

  • Value типы

  • final классы без изменяемых свойств

  • Функции и замыкания (помечая их @Sendable )

Нюанс: Sendable contagion

Хоть Sendable и решает эту проблему следует отметить, что этот протокол стал объектом критики в сообществе Swift. Поскольку необходимость помечать «безопасные» типы в сочетании с тем, что компилятор имеет склонность вести себя как слишком заботливая мать (он будет жаловаться, что тип должен быть Sendable даже в ситуациях, когда никакой гонки за ресурсы произойти не может), может быстро привести к тому, что Sendable будет «заражать» всю структуру вашей программы. Были сделаны попытки потенциальных улучшений в этой области, но на момент написания статьи официальных предложений доработок еще не поступало.

Помимо Sendable, ключевое слово nonisolated также призвано помочь в решении проблемы «утечки» состояния актора. Как следует из названия, оно позволяет помечать функции и свойства, которым разрешено игнорировать механизм изоляции актора:

actor BankAccount {
    nonisolated let accountNumber: Int
}

При обращении к нему компилятор будет делать вид, что тип не находится внутри актора, и пропускать все механизмы защиты, которые обычно применяются. Однако, как и в случае с Sendable, не все можно пометить как nonisolated. Только типы, которые являются Sendable, могут быть помечены таковыми.

Акторы и главный поток (Main Thread)

На данный момент мы рассмотрели все, что нужно в отношении async/await в Swift. Но остается еще один момент, связанный с разработкой для iOS. Где же во всём этом главный поток?

Мы много говорили о новом пуле потоков (Cooperative Thread Pool) и о том, как исполнители взаимодействуют с ними. Но разработчики знают, что работа с UI всегда должна выполняться в главном потоке. Как это сделать, если в Cooperative Thread Pool нет понятия «главный» поток?

В Swift именно здесь вступает в игру возможность создавать кастомные исполнители, которую мы видели в начале статьи. В стандартную библиотеку Swift входит тип MainActor, который, как следует из названия, представляет собой специальный тип актора, синхронизирующий все свои задачи на main поток:

@globalActor public final actor MainActor: GlobalActor {
  public static let shared = MainActor()

  public nonisolated var unownedExecutor: UnownedSerialExecutor {
    return UnownedSerialExecutor(Builtin.buildMainActorExecutorRef())
  }

  public nonisolated func enqueue(_ job: UnownedJob) {
    _enqueueOnMain(job)
  }
}

MainActor достигает этого путем переопределения стандартного unownedExecutor на кастомный Builtin.buildMainActorExecutorRef(). Поскольку мы говорим Swift, что не хотим использовать стандартный последовательный исполнитель для этого актора, это заставит среду выполнения Swift вызвать вместо него переопределенный метод enqueue у MainActor.

В случае MainActor вызов _enqueueOnMain приведет к тому, что задание, как обычно, будет передано глобальному конкурентному исполнителю. Но на этот раз с помощью специальной функции, которая заставит задачу быть переданной в main очередь GCD, а не в Cooperative Thread Pool.

// Функция, куда попадают "обычные" async/await задачи
static void swift_task_enqueueGlobalImpl(Job *job) {
  auto queue = getCooperativeThreadPool();
  dispatchEnqueue(queue, job);
}

// Функция, куда попадают задачи MainActor
static void swift_task_enqueueMainExecutorImpl(Job *job) {
  auto mainQueue = dispatch_get_main_queue();
  dispatchEnqueue(mainQueue, job);
}

Другими словами код, выполняемый main актором, по сути то же самое, что и вызов DispatchQueue.main.async, хотя и не буквально то же самое из-за двух фактов, которые мы уже рассмотрели: того, что рантайм Swift использует «специальную» версию DispatchQueue.async для отправки задач, и того, что переключения технически не произойдет, если мы уже находимся внутри main потока («контекст выполнения» MainActor).

// Что пишете вы:
Task {
    await myMainActorMethod()
}

// Что (в некотором смысле) на самом деле происходит:
// (Реальное поведение объясняется ниже)
Task {
    DispatchQueue.main.async {
        myMainActorMethod()
    }
}

Глобальные акторы

Последнее, что я хотел бы показать, — это практическое использование таких акторов, как MainActor. Мы знаем, что обычные акторы создаются и передаются как обычные объекты, но в случае с MainActor это было бы не очень удобно. Несмотря на то, что MainActor доступен как синглтон, в iOS есть много вещей, которые должны выполняться в main потоке. Поэтому, если бы мы обращались с ним, как с обычным объектом, то в итоге получили бы много кода, выглядящего примерно так.

extension MainActor {
    func myMainActorMethod() {}
}

func example() {
    Task {
        await MainActor.shared.myMainActorMethod()
    }
}

///////////// или:

func example() {
    Task {
        await MainActor.run {
            myMainActorMethod()
        }
    }
}

func myMainActorMethod() {}

Хотя оба решения «работают», Swift увидел потенциал для улучшения, создав концепцию «глобальных акторов». Она описывает акторы, на которые можно не только ссылаться, но и расширять их из любой точки программы. Вместо того, чтобы заставлять всех повсюду ссылаться на синглтоны, фича глобальных акторов в Swift позволяет легко указать, что определенный фрагмент кода должен выполняться в рамках конкретного глобального актора, пометив его специальной аннотацией:

@MainActor
func myMainActorMethod() {}

По сути, это то же самое, что и в приведенных выше примерах, но с гораздо меньшим количеством кода. Вместо того чтобы ссылаться на синглтон MainActor, мы можем напрямую обратиться к этому методу и быть уверенными, что он будет выполнен в контексте MainActor.

func example() {
    await myMainActorMethod() // Этот метод помечен как @MainActor,
    // поэтому он выполнится в контексте MainActor
}

Для этого актор должен быть помечен ключевым словом @globalActor, так можно сделать и для своих собственных акторов, если такое поведение окажется для них полезным. Как и следовало ожидать, MainActor сам по себе является глобальным актором. 

Пометка актора как @globalActor — это синтаксический сахар для объявления актора, наследующего протокол GlobalActor. Этот протокол по сути является вариацией обычного протокола Actor с дополнительным определением проперти синглтона, на который Swift может ссылаться, когда находит одну из этих специальных аннотаций в программе.

public protocol GlobalActor {
  associatedtype ActorType: Actor
  static var shared: ActorType { get }
}

Затем, во время компиляции, когда Swift встречает одну из этих аннотаций, он выполняет вызов hop_to_executor со ссылкой на синглтон этого актора.

func example() {
    // SIL: hop_to_executor(MainActor.shared)
    await myMainActorMethod()
    // SIL: hop_to_executor(DefaultExecutor)
}

Заключение: Async/await в Swift упрощает конкурентное программировение, но не обязательно облегчает работу с ним

В целом, мне нравится async/await. Я считаю, что это хорошее дополнение к Swift, и оно делает работу с конкурентностью намного интереснее.

Но не стоит заблуждаться на этот счет. Хотя Swift и защищает от ошибок, связанных с памятью, он НЕ защищает от логических ошибок и написания откровенно неправильного кода. А то, как работает async/await сегодня, позволяет очень легко допускать такие ошибки. В этой статье мы рассмотрели некоторые проблемы шаблона, но их гораздо больше, и они относятся к функциям, которые мы не успели рассмотреть в этой статье.

Доклад Мэтта Массикотта «The Bleeding Edge of Swift Concurrency» на Swift TO 2023 подробно описывает проблемы в async/await, и я считаю, что это выступление должен посмотреть каждый, кто работает с async/await в Swift.

Более подробную информацию о безопасности потоков в Swift  можно найти в этой статье

Если вам понравилась статья от Бруно Роша, можно подписаться на Twitter автора, где он анонсирует выпуски новых материалов. 

Tags:
Hubs:
Total votes 11: ↑11 and ↓0+11
Comments0

Articles

Information

Website
cleverpumpkin.ru
Registered
Founded
Employees
11–30 employees
Location
Россия
Representative
Денис Германенко