Согласованность

17 ноября 2022

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

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

В остальной части этой главы термин параллелизм используется для обозначения этой общей комбинации асинхронного и параллельного кода.

Заметка

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

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


listPhotos(inGallery: "Summer Vacation") { photoNames in
    let sortedNames = photoNames.sorted()
    let name = sortedNames[1]
    downloadPhoto(named: name) { photo in
        show(photo)
    }
}

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

Определение и вызов асинхронных функций

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

Чтобы указать, что функция или метод является асинхронным, вы пишете ключевое слово async в его объявлении после его параметров, аналогично тому, как вы используете throw для отметки функции с исключением. Если функция или метод возвращает значение, вы пишете async перед стрелкой возврата (->). Например, вот как можно получить имена фотографий в галерее:


func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... some asynchronous networking code ...
    return result
}

Для функции или метода, которые одновременно являются как асинхронными, так и исключающими (throw), вы пишете async перед throw.

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

Например, приведенный ниже код извлекает имена всех изображений в галерее, а затем показывает первое изображение:


let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[1]
let photo = await downloadPhoto(named: name)
show(photo)

Поскольку обе функции listPhotos(inGallery :) и downloadPhoto(named :) должны выполнять сетевые запросы, их выполнение может занять относительно много времени. Делая их асинхронными, записывая async перед стрелкой возврата, позволяет остальной части кода приложения продолжать работу, пока этот код ожидает завершения.

Чтобы понять параллельный характер приведенного выше примера, вот один из возможных порядков выполнения:

  1. Код запускается с первой строки и доходит до первого await. Он вызывает функцию listPhotos(inGallery :) и приостанавливает выполнение, пока ожидает возврата этой функции.
  2. Пока выполнение этого кода приостановлено, выполняется другой параллельный код в той же программе. Например, может быть, длительная фоновая задача продолжает обновлять список новых фото. Этот код также выполняется до следующей точки приостановки, отмеченной как await, или до ее завершения.
  3. После возврата из listPhotos(inGallery :) этот код продолжает выполнение, начиная с этой точки. Он присваивает значение, которое было возвращено photoNames.
  4. Строки, определяющие sortedNames и name, представляют собой обычный синхронный код. Поскольку на этих строках ничего не отмечено ожиданием, нет никаких возможных точек приостановки.
  5. Следующее ожидание отмечает вызов функции downloadPhoto(named :). Этот код снова приостанавливает выполнение до тех пор, пока функция не вернется, давая возможность другому параллельному коду работать.
  6. После возврата downloadPhoto(named :) его возвращаемое значение присваивается фото и затем передается в качестве аргумента при вызове show(_ :).

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

  • Код в теле асинхронной функции, метода или свойства.
  • Код в статическом методе main() структуры, класса или перечисления, помеченных @main.
  • Код в отдельной дочерней задаче, как показано в разделе «Неструктурированный параллелизм» ниже.

Заметка

Метод Task.sleep(_ :) полезен при написании простого кода, чтобы узнать, как работает параллелизм. Этот метод ничего не делает, но ждет, по крайней мере, заданное количество наносекунд, прежде чем он вернется. Вот версия функции listPhotos(inGallery :), которая использует sleep() для имитации ожидания сетевой операции:


func listPhotos(inGallery name: String) async -> [String] {
    await Task.sleep(2 * 1_000_000_000)  // Two seconds
    return ["IMG001", "IMG99", "IMG0404"]
}

Асинхронные последовательности

Функция listPhotos(inGallery :) в предыдущем разделе асинхронно возвращает весь массив сразу после того, как все элементы массива готовы. Другой подход - дождаться одного элемента коллекции за раз, используя асинхронную последовательность. Вот как выглядит итерация асинхронной последовательности:


import Foundation

let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
    print(line)
}

Вместо использования обычного цикла for-in в приведенном выше примере записывается for с await после него. Как и при вызове асинхронной функции или метода, запись await указывает на возможную точку приостановки. Цикл for-await-in потенциально приостанавливает выполнение в начале каждой итерации, когда он ожидает, когда будет доступен следующий элемент.

Точно так же, как вы можете использовать свои собственные типы в цикле for-in, добавив соответствие протоколу Sequence, вы можете использовать свои собственные типы в цикле for-await-in, добавив соответствие протоколу AsyncSequence.

Параллельный вызов асинхронных функций

Вызов асинхронной функции с помощью await запускает только один фрагмент кода за раз. Пока выполняется асинхронный код, вызывающая сторона ожидает завершения этого кода, прежде чем перейти к выполнению следующей строки кода. Например, чтобы получить первые три фотографии из галереи, вы можете дождаться трех вызовов функции downloadPhoto(named :) следующим образом:


let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

У этого подхода есть важный недостаток: несмотря на то, что загрузка является асинхронной и позволяет выполнять другую работу во время ее выполнения, одновременно выполняется только один вызов функции downloadPhoto(named :). Каждая фотография полностью загружается до начала загрузки следующей. Однако этим операциям не нужно ждать - каждую фотографию можно загружать независимо или даже одновременно.

Чтобы вызвать асинхронную функцию и позволить ей работать параллельно с кодом вокруг нее, напишите async перед let при определении константы, а затем напишите await каждый раз, когда вы используете константу.


async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

В этом примере все три вызова downloadPhoto(named :) запускаются без ожидания завершения предыдущего. Если доступно достаточно системных ресурсов, они могут работать одновременно. Ни один из этих вызовов функций не помечен как await, потому что код не приостанавливается в ожидании результата функции. Вместо этого выполнение продолжается до тех пор, пока не будет определена строка, в которой определены фотографии - в этот момент программе требуются результаты этих асинхронных вызовов, поэтому вы пишете await, чтобы приостановить выполнение, пока не завершится загрузка всех трех фотографий.

Вот как вы можете подумать о различиях между этими двумя подходами:

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

Вы также можете смешать оба этих подхода в одном коде.

Задачи и группы задач

Задача - это единица работы, которая может выполняться асинхронно как часть вашей программы. Весь асинхронный код выполняется как часть некоторой задачи. Синтаксис async-let, описанный в предыдущем разделе, создает для вас дочернюю задачу. Вы также можете создать группу задач и добавить в нее дочерние задачи, что дает вам больше контроля над приоритетом и отменой, а также позволяет создавать динамическое количество задач.

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


await withTaskGroup(of: Data.self) { taskGroup in
    let photoNames = await listPhotos(inGallery: "Summer Vacation")
    for name in photoNames {
        taskGroup.async { await downloadPhoto(named: name) }
    }
}

Дополнительные сведения о группах задач см. в разделе TaskGroup.

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

В дополнение к структурированным подходам к параллелизму, описанным в предыдущих разделах, Swift также поддерживает неструктурированный параллелизм. В отличие от задач, которые являются частью группы задач, неструктурированная задача не имеет родительской задачи. У вас есть полная гибкость для управления неструктурированными задачами любым способом, который требуется вашей программе, но вы также несете полную ответственность за их правильность. Чтобы создать неструктурированную задачу, выполняемую текущим актором, вызовите функцию async(priority: operation :). Чтобы создать неструктурированную задачу, которая не является частью текущего актора, более конкретно называемую отдельной задачей, вызовите asyncDetached(priority: operation :). Обе эти функции возвращают дескриптор задачи, который позволяет вам взаимодействовать с задачей, например, дождаться ее результата или отменить его.


let newPhoto = // ... какие-то данные по фото ...
let handle = async {
    return await add(newPhoto, toGalleryNamed: "Spring Adventures")
}
let result = await handle.get()

Дополнительные сведения об управлении отключенными задачами см. в разделе Task.Handle.

Отмена задачи

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

  • Выдает ошибку, например CancellationError
  • Возврат nil или пустой коллекции
  • Возврат частично выполненной работы

Чтобы проверить отмену, либо вызовите Task.checkCancellation(), который выбрасывает CancellationError, если задача была отменена, либо проверьте значение Task.isCancelled и обработайте отмену в своем собственном коде. Например, для задачи загрузки фотографий из галереи может потребоваться удалить частичные загрузки и закрыть сетевые подключения.

Чтобы распространить отмену вручную, вызовите Task.Handle.cancel().

Акторы

Как и классы, акторы являются ссылочными типами, поэтому сравнение типов значений и ссылочных типов в разделе «Классы являются ссылочными типами» применяется как к акторам, так и к классам. В отличие от классов, акторы позволяют только одной задаче получать доступ к своему изменяемому состоянию за раз, что делает безопасным взаимодействие кода в нескольких задачах с одним и тем же экземпляром актора. Например, вот актор, который записывает температуру:


actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int

    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [measurement]
        self.max = measurement
    }
}

Вы вводите актора с ключевым словом actor, за которым следует его определение в фигурных скобках. Актор TemperatureLogger имеет свойства, к которым может получить доступ другой код за пределами актора, и ограничивает свойство max, поэтому только код внутри актора может обновлять максимальное значение.

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


let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max)
// Выведет "25"

В этом примере доступ к logger.max является возможной точкой приостановки. Поскольку субъект позволяет только одной задаче одновременно получать доступ к своему изменяемому состоянию, если код из другой задачи уже взаимодействует с регистратором, этот код приостанавливается, пока он ожидает доступа к свойству.

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


extension TemperatureLogger {
    func update(with measurement: Int) {
        measurements.append(measurement)
        if measurement > max {
            max = measurement
        }
    }
}

Метод update(with :) уже запущен для актора, поэтому он не отмечает его доступ к таким свойствам, как max, с помощью await. Этот метод также показывает одну из причин, по которой акторы позволяют одновременно взаимодействовать со своим изменяемым состоянием только одной задаче: некоторые обновления состояния актора временно нарушают инварианты. Актор TemperatureLogger отслеживает список температур и максимальную температуру, а также обновляет максимальную температуру при записи нового измерения. В середине обновления, после добавления нового измерения, но перед обновлением max, регистратор температуры находится во временном несогласованном состоянии. Предотвращение одновременного взаимодействия нескольких задач с одним и тем же экземпляром предотвращает такие проблемы, как следующая последовательность событий:

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

Если вы попытаетесь получить доступ к этим свойствам извне актора, как в случае с экземпляром класса, вы получите ошибку компиляции. Например:


print(logger.max)  // Ошибка

Доступ к logger.max без записи await завершается ошибкой, поскольку свойства субъекта являются частью изолированного локального состояния этого субъекта. Swift гарантирует, что только код внутри актора может получить доступ к локальному состоянию актора. Эта гарантия известна как изоляция актора.

Содержание