Если вы новичок в Swift Concurrency, вы попали по адресу. Вот все, что вам нужно знать, в самой сжатой доступной форме.
Идеальная аудитория этого поста — опытный программист, знакомый с концепциями параллелизма и заинтересованный в изучении синтаксиса и API Swift Concurrency.
Усаживайтесь поудобнее, потому что нам предстоит многое охватить.
Async/await синтаксис
Асинхронные функции определяются путем добавления ключевого слова async после скобок в определении функции:
func loadIssues() async -> [Issue] { ... }
Асинхронные функции могут вызывать другие асинхронные функции с помощью ключевого слова await:
let issues = await loadIssues()
Ключевое слово await отмечает потенциальную точку приостановки. Асинхронная функция, выполняющая этот вызов, может или не может быть приостановлена в этот момент. Приостановка в этом контексте означает, что функция временно прекращает выполнение. Если он приостановлен, он продолжит выполнение когда-нибудь в будущем после того, как вызываемый объект вернет результат.
Асинхронные функции, которые могут выдать ошибку, отмечены async throws:
func loadIssues() async throws -> [Issue] { ... }
Выбрасывающие асинхронные функции вызываются с помощью try await:
let issues = try await loadIssues()
или с одним из двух других вариантов: try? await и try! await
Если вы хотите, чтобы несколько асинхронных функций выполнялись одновременно, вы назначаете результат вызова асинхронной функции константе с помощью async let, а затем ждете результатов позже, используя await каждый раз, когда вы используете эту константу:
func loadIssues() async -> [Issue] { ... }
func loadProjects() async -> [Project] { ... }
func loadMessages() async -> [Message] { ... }
// these three calls execute concurrently
async let issues = loadIssues()
async let projects = loadProjects()
async let messages = loadMessages()
let allData = await (issues, projects, messages)
let issuesFromAwaitedConstant = allData.0
let issuesFromAsyncConstant = await issues
Методы, вычисляемые свойства и индексы также могут быть помечены async и async throws:
protocol IssuesStore {
var issuesCount: Int { get async throws }
subscript(_ index: Int) -> Issue { get async throws }
func loadIssues() async throws -> [Issue]
}
class RemoteIssuesStore: IssuesStore {
var issuesCount: Int {
get async throws { ... }
}
subscript(_ index: Int) -> Issue {
get async throws { ... }
}
func loadIssues() async throws -> [Issue] { ... }
}
let issuesStore = RemoteIssuesStore()
let issueCount = try? await issuesStore.issuesCount
let lastIssue = try? await issuesStore[issueCount! - 1]
let issues = try? await issuesStore.loadIssues()
Continuations
При интеграции с API, который по-прежнему использует обработчики завершения, вы можете использовать функцию withCheckedContinuation(_:):
func loadCurrentUser(completion: @escaping (User) -> Void) { ... }
let currentUser = await withCheckedContinuation {
(continuation: CheckedContinuation) in
loadCurrentUser { user in
continuation.resume(returning: user)
}
}
Вы несете ответственность за возобновление продолжения ровно один раз. CheckedContinuation гарантирует, что во время выполнения он будет возобновлен ровно один раз. Если вам нужна опция с более низкими издержками, не применяющая это правило, вы можете использовать функцию withUnsafeContinuation(_:), которая работает с UnsafeContinuation. CheckedContinuation и UnsafeContinuation имеют одинаковый интерфейс, поэтому их легко поменять местами.
Если вызываемый вами API может возвращать ошибки, вы можете использовать их эквиваленты генерации: withCheckedThrowingContinuation(_:) и withUnsafeThrowingContinuation(_:).
Функции продолжения также можно использовать для интеграции с API-интерфейсами на основе делегатов с отдельными методами успешного выполнения и обработки ошибок. Вы можете сохранить объект продолжения в локальном свойстве при вызове API и использовать его позже в методах делегата обработки результатов.
class Store {
func purchaseProduct(withId productId: String) {}
var delegate: StoreDelegate?
}
protocol StoreDelegate {
func store(_ store: Store, didCompletePurchaseForProduct product: Product)
func store(_ store: Store, didFailWithError error: Error)
}
class PurchaseService: StoreDelegate {
private let store: Store
private var continuation: CheckedContinuation<Product, Error>?
init(store: Store) {
self.store = store
weak var storeDelegate = self
store.delegate = storeDelegate
}
func purchaseProduct(withId productId: String) async throws -> Product {
try await withCheckedThrowingContinuation { [weak self] continuation in
self?.continuation = continuation
store.purchaseProduct(withId: productId)
}
}
// MARK: StoreDelegate
func store(_ store: Store, didCompletePurchaseForProduct product: Product) {
continuation?.resume(returning: product)
}
func store(_ store: Store, didFailWithError error: Error) {
continuation?.resume(throwing: error)
}
}
Структурированные и неструктурированные задачи.
Асинхронные функции нельзя вызывать из синхронных функций:
func doSomeAsyncWork() async { ... }
func doSomeWork() {
await doSomeAsyncWork() // Compilation error: 'async' call in a function that does not support concurrency
}
Чтобы вызвать асинхронную функцию, нам нужно использовать await. Поскольку для await требуется, чтобы программа могла приостановить выполнение, его можно использовать только в асинхронной функции.
Так как же нам войти в исходную асинхронную функцию? Вот доступные варианты:
- Статический метод main() структуры, класса или перечисления, отмеченный @main (для программ командной строки).
- Модификаторы представления SwiftUI .task() и .refreshable()
- Неструктурированная задача
Давайте распакуем этот третий вариант.
task — это единица асинхронной работы. Все асинхронные функции выполняются как часть некоторой задачи. Задача может запускать только одну функцию за раз, но в одной задаче может выполняться серия вызовов функций. Когда асинхронная функция вызывает другую асинхронную функцию с ожиданием, вызываемая функция по-прежнему выполняется как часть той же задачи. Когда вызываемая функция возвращается, вызывающая функция продолжает работать в той же задаче до тех пор, пока она также не вернется.
Задача может иметь одну или несколько дочерних задач. Дочерние задачи могут выполняться одновременно с родительской задачей. Программа может состоять из сложного дерева задач. Может быть сложно правильно распространять отмены и гарантировать, что после завершения родительской задачи не останется свободных дочерних задач. Вот почему был введен структурированный параллелизм.
Структурированный параллелизм — это явные отношения родитель-потомок, которые позволяют компилятору Swift заранее вызывать ошибки, а среде выполнения Swift — помогать нам управлять задачами. При структурированном параллелизме дочерняя задача должна завершиться до завершения родительской задачи, и если родительская задача выдает ошибку, все дочерние задачи отменяются.
Отмена является совместной, поэтому, если дочерние задачи не соответствуют требованиям, родительская задача будет заблокирована и не вернется, пока не будут выполнены все дочерние задачи. Родительская задача не может заставить дочернюю задачу завершиться.
Задача верхнего уровня
В каждом дереве задач есть задача верхнего уровня. Когда мы используем модификаторы SwiftUI для запуска асинхронного кода или аннотацию @main в программах командной строки, Swift создает для нас задачу верхнего уровня. Но мы можем сами создать и запустить задачу верхнего уровня, инициализировав Task:
func doSomeAsyncWork() async { ... }
func doSomeWork() {
Task {
await doSomeAsyncWork()
}
}
Эта задача неструктурирована, поскольку у нее нет родительской задачи. Мы можем сохранить ссылку на эту задачу и отменить ее позже, если состояние приложения изменится так, что нам не нужно выполнять задачу:
func doSomeWork() {
let task = Task {
await doSomeAsyncWork()
}
task.cancel()
}
Если ее не отменить, неструктурированная задача переживет контекст, в котором она была создана, и будет выполняться до завершения независимо, даже если у нас нет ссылки на нее.
Мы не можем вернуть какие-либо значения из задачи, созданной в синхронном коде, поскольку синхронный код не может использовать точку приостановки для ожидания результата.
func doSomeAsyncWork() async -> String { ... }
func doSomeWork() {
let task = Task {
await doSomeAsyncWork()
}
let result = await task.value // Compilation error: 'async' property access in a function that does not support concurrency
}
Но мы можем захватывать ссылки из синхронного кода и изменять их состояние внутри задачи.
class Label {
var text: String?
}
var label = Label()
func doSomeWork() {
Task {
let result = await doSomeAsyncWork()
label.text = result
}
}
Мы можем создать новую Task внутри Task:
func doSomeWork() {
Task {
Task { ... }
}
}
Хотя может показаться, что внутренняя задача является дочерней задачей внешней задачи, это не так. Это две несвязанные задачи верхнего уровня; просто так получается, что одно создает другое.
Мы можем создать новую задачу внутри async функции:
func doSomeAsyncWork() async {
Task { ... }
}
Как мы установили ранее, async функция уже выполняется как часть задачи, но задача, созданная в функции, не является дочерней задачей задачи, выполняющей эту функцию. Это, опять же, новая и несвязанная задача высшего уровня.
Всякий раз, когда Task создается, она является задачей верхнего уровня, независимо от того, создается ли она в синхронном или асинхронном контексте. Именно это делает его неструктурированным.
Дочерние задачи
Неструктурированная задача — это просто корневой узел структурированного дерева параллелизма. Любые дочерние задачи, созданные в рамках задачи верхнего уровня, подпадают под структурированные правила параллелизма.
Существует два способа создания дочерних задач:
- async let
- TaskGroup API
Между этими двумя вариантами async let менее подробный, но API TaskGroup позволяет динамически создавать дочерние задачи и некоторый контроль над их приоритетом и отменой.
С помощью выражения async let создается дочерняя задача для запуска вызываемой функции:
func loadIssues() async -> [Issue] { ... }
func useData() async {
async let issues = loadIssues() // creates a child task
...
}
С помощью API TaskGroup мы можем создать динамическое количество дочерних задач:
struct Document {}
func fetchDocument(from url: URL) async -> Document { ... }
func fetchDocuments(from urls: [URL]) async -> [Document] {
await withTaskGroup(of: Document.self) { taskGroup in
for url in urls {
taskGroup.addTask {
await fetchDocument(from: url)
}
}
let documents = await taskGroup.reduce([]) { partialResult, document in
partialResult + [document]
}
return documents
}
}
Каждая дочерняя задача в группе задач возвращает один и тот же тип — тот, который предоставляется в качестве аргумента функции withTaskGroup(of:body:). В приведенном примере это Document. Вы можете использовать перечисление для поддержки различных результатов отдельных дочерних задач.
Группа задач может возвращать результат, отличный от результатов отдельных дочерних задач. В нашем примере это массив Document.
Давайте посмотрим, как мы собрали результаты из целевой группы:
let documents = await taskGroup.reduce([]) { partialResult, document in
partialResult + [document]
}
TaskGroup — это AsyncSequence. AsyncSequence похожа на обычную Sequence, но мы перебираем ее элементы асинхронно:
for await element in sequence {
...
}
Асинхронные последовательности также поддерживают многие функции более высокого порядка, и именно поэтому мы могли использовать reduce() в предыдущем примере для сбора результатов.
Порядок выполнения дочерних задач определяет порядок появления результатов в последовательности: самая быстрая задача создаст первый элемент последовательности, а самая медленная задача создаст последний элемент.
Чтобы создать дочерние задачи, которые могут вызвать ошибку, используйте функцию withThrowingTaskGroup(of:body:).
Контроль выполнения задач
Как структурированные, так и неструктурированные задачи полностью контролируют свое выполнение — они могут временно приостановить выполнение или завершить его в какой-то момент после отмены.
Задача может использовать Task.isCancelled и Task.checkCancellation(), чтобы проверить, отменена ли она, и решить, хочет ли она продолжить выполнение на разных этапах своей работы.
Task {
// do some work
if Task.isCancelled {
return
}
// do some additional work
}
// or
Task {
// do some work
try Task.checkCancellation() // throws `CancellationError` if the task is cancelled
// do some additional work
}
Длительно выполняемая задача может добровольно приостановить себя, вызвав Task.yield(), чтобы Swift мог дать другим задачам возможность добиться прогресса в своей работе:
Task {
for intenseWorkItem in arrayOfIntenseWorkItems {
doWork(from: intenseWorkItem)
await Task.yield()
}
}
Задача также может временно остановить выполнение, вызвав Task.sleep(), который приостанавливает ее как минимум на заданное количество наносекунд:
Task {
try await Task.sleep(nanoseconds: 1_000_000_000) // sleeps for a second
}
Спящая задача возобновит выполнение, как только будет отменена. Это может показаться неожиданным, но имейте в виду, что отмена в Swift является совместной, поэтому задача активируется при отмене, и задача может завершиться раньше, если она хочет:
let task = Task {
// some work
do {
try await Task.sleep(nanoseconds: 1_000_000_000) // sleeps for a second
} catch {
let cancellationError = error as? CancellationError // cancellationError is not nil
try Task.checkCancellation() // will complete the task early with CancellationError
}
// some more work
}
task.cancel()
Приоритет задачи
Задачи могут иметь разные приоритеты, влияющие на порядок их выполнения. Мы можем указать приоритет для задачи верхнего уровня в инициализаторе и проверить его внутри задачи:
Task.init(priority: .high) {
print(Task.currentPriority) // prints "TaskPriority(rawValue: 25)"
Task.init(priority: .low) {
print(Task.currentPriority) // prints "TaskPriority(rawValue: 17)"
}
}
Значение Task.currentPriority может отличаться от значения, присвоенного задаче при создании, если система повышает приоритет задачи для устранения инверсии приоритета. Лучше рассматривать приоритет как предложение для среды выполнения Swift и не ожидать, каким будет значение Task.currentPriority.
Дочерние задачи наследуют приоритет своей родительской задачи. Также можно определить приоритет для дочерней задачи с помощью TaskGroup API, но, судя по моим экспериментам и обсуждениям на форуме, итоговый приоритет может быть неожиданным. В предложении Swift Evolution не рекомендуется указывать приоритет вручную для дочерних задач.
Actors
Если в программе есть общее состояние, к которому можно получить одновременный доступ и которое необходимо защитить, мы можем использовать тип Actor:
actor Inbox {
private (set) var messages: [Message]
func markMessageAsRead(at index: Int) { ... }
}
Actors похожи на классы, но без наследования. Они защищают свое изменяемое состояние изоляцией actor. Изоляция actor — это набор ограничений на то, как можно использовать actors. По сути, только одна задача может изменить состояние actor в любой момент. Это обеспечивается требованием использования await с доступом к свойствам и методам, изолированным от actor:
let inbox = Inbox()
if await !inbox.messages.isEmpty {
await inbox.markMessageAsRead(at: 0)
}
Все методы, индексы, вычисляемые свойства и изменяемые свойства по умолчанию изолированы от actor. Если они не обращаются к изменяемому состоянию, вы можете разрешить синхронный доступ с помощью nonisolated ключевого слова:
struct User {
let name: String
}
actor Inbox {
let refreshRate: TimeInterval
private let user: User
...
nonisolated var userName: String {
user.name
}
}
let inbox = Inbox()
print(inbox.refreshRate)
print(inbox.userName)
Неизменяемые свойства не изолированы от субъекта и могут быть доступны синхронно.
Методы, изолированные от actor, являются реентерабельными. Это означает, что хотя только одна задача может выполнять изолированные методы в любой момент времени, выполнение методов может чередоваться в точках приостановки. Лучше всего разрабатывать actors с синхронными методами, работающими с изменяемым состоянием, и небольшими асинхронными методами, вызывающими эти синхронные методы.
Глобальные actors и Main Actor
Actors предоставляют нам одновременную защиту доступа для состояния отдельного экземпляра, но иногда нам нужна эта защита для глобального состояния или внешних ресурсов. Глобальные actors удовлетворяют эту потребность.
Глобальный актор — это глобально уникальный актор, определяемый типом. Этот тип также является настраиваемым атрибутом, который можно применить к любому объявлению. Объявление, помеченное этим атрибутом, затем изолируется от этого актора, что гарантирует, что только одна задача может получить доступ к этому объявлению в каждый момент времени.
Основной мотивацией для введения глобальных участников является необходимость представления основного потока в Swift Concurrency. MainActor — это глобальный актор, который обеспечивает доступ к любому объявлению, отмеченному @MainActor, из основного потока:
@MainActor
func updateUI() { ... }
Кроме того, из-за вывода глобального субъекта объявления, не отмеченные напрямую @MainActor, также могут быть изолированы от MainActor, если они явно не аннотированы глобальным субъектом или не изолированы. Предложение Swift Evolution для глобальных субъектов определяет все правила вывода глобальных субъектов.
Swift Concurrency и потоки
Вы, наверное, заметили, что я не упоминал треды до раздела глобальных участников. Это было намеренно. Swift Concurrency предоставляет набор абстракций и API, которые скрывают управление потоками от разработчика. И в большинстве случаев разработчику не нужно думать о потоках и о том, как они управляются изнутри. MainActor является исключением. Итак, давайте кратко рассмотрим, как задачи и потоки работают вместе.
Swift управляет группой потоков, называемой кооперативным пулом потоков. В этом пуле потоков может быть столько потоков, сколько ядер ЦП умножается на число приоритетов задачи. Но не более того. (В Swift Concurrency взрыв потока невозможен, ура!)
Задачи запланированы для выполнения в совместных потоках. Задачи могут выполнять несколько асинхронных вызовов подряд, пока они не будут заблокированы вызовом инфраструктуры или занятым субъектом. В этот момент задача приостанавливается, и другая задача получает возможность запуститься в этом потоке. Нет никакой гарантии, что код до и после точки приостановки будет выполняться в одном и том же потоке, поэтому позже, когда задача будет разблокирована и снова запущена, она может выполняться в другом потоке.
Как и весь асинхронный код, асинхронные методы Актера выполняются внутри задачи. Задача будет запускать серию асинхронных вызовов актора в одном и том же потоке до тех пор, пока она не будет приостановлена для ожидания результата. Переключение между акторами называется переключением между акторами, и, как правило, это очень эффективно, так как под капотом нет переключения потоков. Это если не задействован MainActor.
Основной поток не является частью кооперативного пула потоков, и для переключения акторов с участием MainActor требуется переключение контекста потока:
- Если асинхронный код выполняется изолированно от MainActor и, как следствие, в основном потоке, при выполнении асинхронного вызова другому субъекту задача будет приостановлена и запланирована для запуска в совместном потоке.
- Если код, изолированный от актера, выполняется в задаче в совместном потоке и выполняет асинхронный вызов, изолированный от MainActor, это приведет к приостановке задачи и запланированному запуску в основном потоке.
Такое поведение может вызвать проблемы с производительностью, если мы не будем осторожны. Давайте посмотрим на пример:
actor Incrementor {
private var counter: Int = 0
func increment() async -> Int {
counter += 1
return counter
}
}
class IncrementingViewController: UIViewController {
private let label = UILabel()
private let incrementor = Incrementor()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
Task { [weak self] in
for i in 0..<100 {
let count = await self?.increment() // runs on the cooperative thread
self?.label.text = "\(count!)" // runs on the main thread
}
}
}
Этот пример иллюстрирует реальную ситуацию, когда у вас есть сотни элементов, которые нужно обновить на экране за короткий промежуток времени, а извлечение данных происходит на неосновном актере. Реализация, показанная в примере, делает 200 переключений контекста, что является значительными накладными расходами. Частые переключения контекста снижают производительность программы. Старайтесь избегать повторяющихся переключений актеров с участием MainActor.
Уход от главного актера
Поток управления в приложении начинается с MainActor, и если вы хотите немедленно перенести работу с MainActor, есть два способа сделать это:
- Вызвать актора
- создать отдельную задачу
В отличие от Task, созданной с помощью инициализатора, который наследует контекст актора, отсоединенная задача не выполняет:
Task.detached {
// runs on the cooperative thread
}
Sendable
Благодаря Swift Concurrency у нас есть два способа защитить данные от параллельного доступа: задачи и акторы. Локальная область действия задачи и объявления, изолированные от акторов, являются безопасными областями для изменяемого состояния. Невозможно получить одновременный доступ к этому состоянию. Мы называем эти области доменами параллелизма или доменами изоляции.
Данные могут пересекать домены изоляции, когда:
- задача получает значения через аргументы, захватывает значения из внешней области или возвращает значения
- метод актора получает значения через аргументы или возвращает значения
Если изменяемое состояние передается между доменами изоляции, существует риск гонки данных.
Swift Concurrency обеспечивает поддержку компилятора, чтобы гарантировать передачу только безопасных данных через изолированные домены. Типы, реализующие протокол Sendable и функции, помеченные атрибутом @Sendable, считаются безопасными.
// Sendable protocol conformance
final class Post: Sendable { ... }
// Sendable function
@Sendable func does(_ string: String, occurIn post: Post) -> Bool { ... }
// Sendable closure
let footer: String = "..."
let appendFooter = { @Sendable (post: String) -> String in
post + footer
}
Следующие типы могут быть помечены как Sendable:
- типы значений
- ссылочные типы без изменяемого хранилища
- ссылочные типы, которые внутренне управляют доступом к своему состоянию
- функции и замыкания
Протокол Sendable не имеет каких-либо обязательных свойств или методов, но у него есть семантические требования, гарантирующие, что не будут задействованы изменяемые данные. Полные требования определены в документации Sendable.
Если тип, не подлежащий отправке, передается через домены изоляции, компилятор выдаст предупреждения в Swift 5.x, которые станут ошибками в Swift 6.
Можно объявить о соответствии протоколу Sendable без применения компилятора:
final class Post: @unchecked Sendable { ... }
При использовании @unchecked Sendable разработчик отвечает за защиту изменяемого состояния внутри этого типа с помощью доступных методов синхронизации доступа, таких как очереди и блокировки.
Заключительные мысли
Хорошо. Это было много. Swift Concurrency — нетривиальная тема. Он предлагает множество новых и мощных API для освоения. Нам предстоит узнать больше, чем мы рассмотрели здесь, но я надеюсь, что это даст вам преимущество.
Если у вас есть какие-либо вопросы или неточности, о которых нужно сообщить, дайте мне знать @srstanic. Ниже вы можете найти все ссылки, которые я использовал для этой статьи.
- https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
- https://github.com/apple/swift-evolution/blob/main/proposals/0304-structured-concurrency.md
- https://github.com/apple/swift-evolution/blob/main/proposals/0310-effectful-readonly-properties.md
- https://github.com/apple/swift-evolution/blob/main/proposals/0306-actors.md
- https://github.com/apple/swift-evolution/blob/main/proposals/0316-global-actors.md
- Meet async/await in Swift (WWDC21)
- Swift concurrency: Behind the scenes(WWDC21)
- Eliminate data races using Swift Concurrency (WWDC22)
- Building Responsive and Efficient Apps with GCD (WWCD15)
- https://www.hackingwithswift.com/quick-start/concurrency/
- https://swiftsenpai.com/swift/swift-concurrency-prevent-thread-explosion/
- https://forums.swift.org/t/task-priority-elevation-for-task-groups-and-async-let/61100/
- https://forums.swift.org/t/priority-of-child-tasks/57865/