Это перевод статьи Concurrency Step-by-Step: A Network Request
Это было ошибкой, что я не уделял больше внимания людям, которые только начинают. Я попытаюсь исправить это, углубившись в некоторые основы. Давайте шаг за шагом рассмотрим сетевой запрос с помощью SwiftUI.
Предисловие
Несколько быстрых заметок. Во-первых, я практически опустил всю обработку ошибок. Я сделал это, чтобы сосредоточиться на теме многопоточности. Я также не особо искушенный разработчик SwiftUI, поэтому здесь могут быть некоторые неоптимальные шаблоны.
Важно, что этот пост был написан для Xcode 16. Если вы используете более раннюю версию, некоторые вещи будут работать не так.
Расставляем все по местам
Давайте рассмотрим очень простую программу SwiftUI, которая загружает что-то из сети. Мне нужно было найти бесплатный API для использования, и я остановился на Robohash. Это восхитительное сочетание простого, интересного и необычного.
Поскольку наши данные будут загружаться из сети, нам нужно обработать случай, когда нам нечего отображать. Начнем с небольшого View
, которое может обрабатывать Optional
изображение cgImage
.
struct LoadedImageView: View {
let cgImage: CGImage?
var body: some View {
if let cgImage {
Image(cgImage, scale: 1.0, label: Text("Robot"))
} else {
Text("no robot yet")
}
}
}
Я использовал CGImage
здесь, поэтому этот код может работать без изменений на всех платформах Apple.
Теперь мы можем перейти к более интересным вещам. Давайте создадим View
, которое фактически загружает некоторые данные из сети.
struct RobotView: View {
@State private var cgImage: CGImage?
var body: some View {
LoadedImageView(cgImage: cgImage)
.onAppear {
loadImageWithGCD()
}
}
private func loadImageWithGCD() {
let request = URLRequest(url:
URL(string: "https://robohash.org/hash-this-text.png")!)
let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in
guard let data else { return }
DispatchQueue.main.async {
let provider = CGDataProvider(data: data as CFData)!
self.cgImage = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
}
}
dataTask.resume()
}
}
Я использовал GCD (Grand Central Dispatch). Надеюсь, это выглядит очень привычно, даже если вы вообще не разбираетесь в многопоточности. Но я все равно хочу отметить, что я использую здесь режим компиляции использования языка Swift 6, и этот код компилируется без ошибок.
Вот что происходит:
- когда
View
становится видимым, мы вызываемloadImageWithGCD
- создается
URLRequest
URLSessionDataTask
конфигурируется так, чтобы выполнить запрос- ответ
response
в конечном итоге возвращается в фоновом потоке (on a background thread) - мы же возвращаемся на основной поток (main thread) для обработки полученных данных
data
и обновления нашего состояния
(Если вы хотите заняться подробным изучением всего этого, то есть отличный пост, в котором объясняется, как компилятор может определить, что этот GCD код на безопасен.)
I/O против CPU
В этой операции есть две ключевые фазы. Запрос и обработка. Запрос связан с вводом-выводом (I/O). После отправки запроса нам вообще не нужно участвовать, пока не будет доступен ответ response
. Это означает, что наш UI отзывчив, и приложение может выполнять больше работы.
Вторая фаза связана с обработкой (CPU). Преобразование данных PNG в CGImage
— это синхронная работа, которая привязана к потоку (thread) до тех пор, пока она не будет выполнена. Мы делаем все это в основном потоке (main thread), используя DispatchQueue.main.async
. Наш UI не реагирует во время выполнения этой второй фазы.
Мне нравится думать о том, когда мы связаны с вводом-выводом (I/O), как об ожидании (waiting). Мы можем делать другие вещи, пока ждем. С другой стороны, я думаю о том, когда мы связаны с обработкой (CPU), как о реальной работе (working). Мы не можем делать другие вещи, потому что мы заняты.
Давайте посмотрим, сможем ли мы реструктурировать все так, чтобы приложение было более отзывчивым во время всего этого.
Неправильный способ
Как оказалось, преобразование данных в CGImage
происходит чертовски быстро. По крайней мере, это происходит на компьютере, с его ОС и с данными, которые я запрашиваю прямо сейчас. Но все эти переменные могут потенциально повлиять на производительность. Ладно, ладно, это может быть немного надуманно, но мы все равно не хотим привыкать блокировать основной поток (main thread).
Помните, что обратный вызов из dataTask
выполняется в фоновом потоке (background thread)? Давайте просто сделаем все там!
private func loadImageWithGCD() {
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in
guard let data else { return }
let provider = CGDataProvider(data: data as CFData)!
// ERROR: Main actor-isolated property 'cgImage' can not be mutated from a Sendable closure
self.cgImage = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
}
dataTask.resume()
}
Однако у нас есть проблема. Свойство self.cgImage
является компонентом нашего UI и с ним можно безопасно работать только в основном потоке (main thread). Компилятор не позволит нам обойти это стороной и у нас ошибка.
Main actor-isolated property ‘cgImage’ can not be mutated from a Sendable closure
(Main actor-isolated свойство cgImage’ не может быть изменено из замыкания Sendable)
Однако то, что нам на самом деле говорит это сообщение, немного сложно понять. «Sendable
замыкание» здесь — это замыкание, которое мы предоставляем dataTask(with:completionHandler:).
Давайте посмотрим на его определение.
func dataTask (
with request: URLRequest,
completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void
) -> URLSessionDataTask
Чтобы узнать, в каком потоке (thread) выполняется completionHandler
, нам пришлось бы рыться в документации. Но Swift concurrency
переместил эту информацию в систему ТИПов. И вы можете увидеть это прямо здесь: completionHandler
— это @Sendable
. Это означает, что «Я могу запустить это замыкание в фоновом потоке (background thread)».
Давайте попробуем еще раз.
Правильный способ
Нам нужно быть более осторожными с тем, где мы делаем нашу работу, связанную с UI. Для этого мы вернем main.async
.
private func loadImageWithGCD() {
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
let dataTask = URLSession.shared.dataTask(with: request) { data, _, _ in
guard let data else { return }
let provider = CGDataProvider(data: data as CFData)!
let image = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
// Must make sure we do this on the main thread
DispatchQueue.main.async {
self.cgImage = image
}
}
dataTask.resume()
}
Теперь у нас есть безопасная версия, и она соответствует очень распространенному шаблону. Основная часть работы выполняется в теле замыкания, которое, как мы знаем, находится в фоновом режиме (background). А затем мы создаем окончательное результирующее изображение, которое передается обратно в основной поток (main thread) для обновления нашего UI.
Хорошо, теперь давайте попробуем сделать это с помощью async / await
.
Async
Прежде чем начать, нам нужна небольшая инфраструктура. Чтобы запустить асинхронный код, нам нужен асинхронный контекст async context. Вы не можете делать асинхронные вызовы из обычных синхронных функций. SwiftUI дает нам модификатор .task
, чтобы сделать именно это.
struct RobotView: View {
@State private var cgImage: CGImage?
var body: some View {
LoadedImageView(cgImage: cgImage)
.task {
// you are allowed to use await in here
await loadImageAsync()
}
}
}
Это действительно похоже на модификатор .onAppear,
который мы использовали ранее. Оба запускаются, как только View
становится видимым. Но эта версия дает нам асинхронный контекст, который нам нужен для вызова асинхронных функций.
Теперь давайте напишем loadImageAsync
.
private func loadImageAsync() async {
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return
}
let provider = CGDataProvider(data: data as CFData)!
self.cgImage = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
}
URLSession
имеет асинхронную версию dataTask
, которую мы можем здесь использовать, что делает это довольно простым. В отличие от версии на основе GCD, это выполняется линейно сверху вниз. И хотя эта часть определенно лучше, есть важная деталь, которую теперь сложнее понять.
В каком потоке все это работает?
Изоляция Isolation
Если посмотреть на версию GCD, то на самом деле там есть только два состояния. Мы либо запускаем код в главном потоке (очереди) (main thread (queue)), либо нет. Такая организация кода довольно распространена среди разработчиков приложений. Я думаю, это замечательно, потому что это просто и часто вполне достаточно. Многим приложениям никогда не понадобится более сложная модель.
Мы собираемся использовать ту же идею в мире многопоточного Swift concurrency. За исключением того, что нам придется выучить неrкоторую терминологии, потому что Swift concurrency не работает с потоками или очередями напрямую. Вместо этого он построен вокруг этой концепции изоляции isolation.
Но я хочу подчеркнуть, что вовсе не критично, чтобы вы понимали, как работает изоляция isolation..
Раньше у нас было «главный поток или нет» (“main thread or not”). Главный поток (main thread) никуда не девается! Мы просто поговорим о нем в терминах MainActor или нет. Вот и все.
Акторы actors — это то, что обеспечивает изоляцию isolation. MainActor защищает свое изменяемое состояние, “изолируя” его в главном потоке (main thread). Когда мы говорим, что код выполняется «на MainActor», он будет выполняться в главном потоке (main thread). Не на MainActor означает в каком-то другом потоке.
(Изоляция подразумевает гораздо больше, чем просто это. Так что если вы хотите пойти глубже или развить больше интуиции, дерзайте. Но сейчас это определенно не обязательно.)
Итак, что запускается на MainActor?
Вооружившись этой новой терминологией, мы хотим понять, что работает и что не работает на MainActor. Мы можем получить необходимую нам информацию из определения функции.
И этом нет ничего особенного.
private func loadImageAsync() async
Вот здесь и происходит поворот сюжета. Если вы нажмете option-click на loadImageAsync
в Xcode, вы увидите что-то похожее на это!
// Xcode popup
@MainActor
private func loadImageAsync() async
(Xcode на самом деле не включает модификатор видимости private
по какой-то причине, но я включил его, чтобы выделить важные вещи.)
Каким-то образом появился @MainActor
!
Но почему?
Это, опять же, сводится к определениям. В данном случае это определение ТИПа RobotView
, содержащее этот метод. MainActor
появляется, потому что ТИП соответствует SwiftUI View
. Процесс распространения MainActor
через понятие ТИПа называется «выводом актора (actor
) из контекста».
Вы можете проследить его весь путь, посмотрев все определения. Или вы используете этот инструмент Xcode. Или вы можете просто помнить, что всякий раз, когда вы создаете View
, это здесь будет MainActor
.
struct AnyViewYouMake: View {
// everything here will be MainActor automatically
}
А если вы не уверены, то совершенно безвредно добавить @MainActor
самостоятельно. Просто, возможно, это излишне.
Так что же выполняется НЕ на MainActor?
Много чего, поэтому давайте подведем итоги. Мы знаем, что MainActor
означает основной поток (main thread). И мы знаем, что loadImageAsync
неявно является MainActor
, потому что он определен внутри соответствия протоколу View
. Но эта аннотация применяется к функции в целом. Означает ли это, что все будет работать в основном потоке (main thread)?
На самом деле, это так!
Изоляция isolation
применяется ко всей функции. Не к некоторым частям — ко всему. Но я хочу рассмотреть один конкретный фрагмент. Это не означает, что сетевой запрос будет выполняться в основном потоке (main thread).
private func loadImageAsync() async {
// on the MainActor...
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return
}
// ... and on the MainActor here too
}
Видите это ключевое слово await
?
Это важно. Прямо здесь оно станет определением URLSession.data(for:),
которая решает, какая изоляция isolation будет действовать. Этот вызов разблокирует MainActor
и освободит его для выполнения другой работы. Только после того, как ответ response
будет доступен, функция будет перезапущена, снова на MainActor
.
Вам нужно знать, что функция, которую вы пишете, — это MainActor
. Вы здесь главный, но вы не отвечаете за то, как работает URLSession.data(for:).
Это радикально отличается от того, как обычно работает вызов функций, поэтому стоит уделить время этим размышлениям.
Возвращаемся к async функции
Давайте вернемся к рассматриваемой проблеме. Вот несколько комментариев, которые помогут вам следить за ходом рассуждений.
// actually MainActor because we're using View
private func loadImageAsync() async {
// MainActor here
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
// this call might switch to something else,
// that's up to URLSession
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return
}
// back on the MainActor now
let provider = CGDataProvider(data: data as CFData)!
self.cgImage = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
}
Похоже, у нас есть довольно похожее решение на нашу исходную реализацию на основе GCD. Вся наша работа происходит в основном потоке MainActor
, за исключением сетевого запроса.
Давайте улучшим это!
Nonisolated
Мы хотим выполнять только основную работу на MainActor
и переместить все остальное на фоновый поток (background). До сих пор мы знали, что наша функция loadImageAsync
является MainActor
. Но мы хотим чего-то, что определенно не является MainActor
.
Для этого есть инструмент: ключевое слово nonisolated
.
Что делает nonisolated
, так это останавливает любой вывод из контекста (inference) для actor
и гарантирует, что функция не будет иметь никакой изоляции isolation
. Отсутствие изоляции isolation
означает отсутствие MainActor
, а это означает фоновый поток (background). Давайте настроим нашу функцию таким образом без каких либо дополнительных изменений.
private nonisolated func loadImageAsync() async {
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return
}
let provider = CGDataProvider(data: data as CFData)!
// ERROR: Main actor-isolated property 'cgImage' can not be mutated from a nonisolated context
self.cgImage = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
}
Ага, но компилятору это не нравится!
Main actor-isolated property ‘cgImage’ can not be mutated from a nonisolated context
(Main actor-isolated property ‘cgImage’ не может быть изменено из nonisolated context)
Теперь мы создали функцию, которая гарантированно не будет в MainActor
, но мы пытаемся получить доступ к нашему UI состоянию. Это по сути та же проблема, которую мы представили с нашей неправильной системой на основе GCD выше. Она просто выдает другую ошибку.
Мы определенно можем это исправить. И исправление будет похожим!
Сначала давайте изменим сигнатуру функции. Вместо того, чтобы делать запрос и изменение, давайте просто создадим изображение CGImage
.
private nonisolated func loadImageAsync() async -> CGImage? {
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return nil
}
let provider = CGDataProvider(data: data as CFData)!
return CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
}
Это работает, потому что мы удалили неверный доступ к MainActor
cgImage
. Это тонко, но если подумать, эта функция делала две очень разные вещи. Более явным именем для этой функции могло бы быть loadImageAndUpdateUI
. Небольшое отступление, но есть пища для размышлений.
В любом случае, теперь нам нужно настроить место вызова:
struct RobotView: View {
@State private var cgImage: CGImage?
var body: some View {
LoadedImageView(cgImage: cgImage)
.task {
self.cgImage = await loadImageAsync()
}
}
}
Теперь мы берем изображение CGImage?
, полученное в результате нашего фонового (background) запроса и присваиваем его нашему MainActor self.cgImage
. Легко?
Что происходит в этом task?
Это работает. Но причина, почему это работает, на самом деле довольно сложная. Мы знаем, что тело этой задачи task
должно быть MainActor
, потому что нам разрешено касаться только MainActor
состояния. Но откуда это берется?
Давайте снова используем этот трюк с option-click, сначала на переменной body
.
// Xcode popup
@MainActor
var body: some View { get }
Это имеет смысл, не так ли? Это определение body
, которое находится внутри ТИПа, который соответствует протоколу View
. Оно просто следует тому же правилу, которое мы узнали выше. Но на самом деле здесь происходит больше, чем просто ”вывод из контекста” (inference) actor
. Но есть еще одно правило изоляции isolation
, которое вам не обязательно понимать прямо сейчас.
(Но вы можете, если хотите!)
Все, что вам нужно знать, это то, что замыкание task
будет подстраиваться под функцию, которая его вызвала. MainActor
снаружи означает MainActor
внутри.
И я действительно хочу еще раз подчеркнуть, что задача task
, является MainActor
и никак не влияет на то, как выполняется loadImageAsync
. Определение этой функции — nonisolated
, и определения — это то, что имеет значение.
Является ли nonisolated безопасным?
Нам нужно уделить немного времени, чтобы поговорить о nonisolated
подробнее.
Люди очень часто видят слово nonisolated
и думают “unsafe
” («небезопасный»). Но ключевое слово nonisolated
просто управляет тем, как работает “вывод из контекста” (inference) для актора actor. Компилятор не позволит вам внести какую-либо “небезопасность” “unsafe
” . И если вы по какой-то причине в это не верите, попробуйте! Как мы только что увидели, вы не можете читать или записывать значения, которые требуют isolation
из nonisolated
функции. Существуют даже строгие правила относительно того, что вы можете получить внутрь и отдать вовне в nonisolated
функции. Язык Swift 6 в целом работает над тем, чтобы сделать безопасностным data race в Swift.
(Теперь есть вариант, называемый nonisolated (unsafe
), который позволяет вам отказаться от безопасности во время компиляции. Но вы не можете применить его к функциям.)
(Кроме того, компилятор чертовски умен. Есть некоторые места, где он позволяет вам «обмананывать», потому что это действительно удобно и может доказать, что безопасность все еще может быть гарантирована.)
Будьте уверены, nonisolated
— не только удобна, но и безопасна.
Альтернатива 1: Tasks
Есть (как минимум) два других варианта, которые можно использовать для реализации этого сетевого запроса. Я хочу показать вам оба, чтобы вы поняли, почему я их не выбрал.
Вот действительно распространенная схема, которую я вижу все время.
private func loadImage() {
Task.detached {
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
guard let (data, _) = try? await URLSession.shared.data(for: request)
else { return}
let provider = CGDataProvider(data: data as CFData)!
let image = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
Task { @MainActor in
self.cgImage = image
}
}
}
Это своего рода “калька” GCD версии. В обычном режиме Task
будет использовать ту же изоляцию isolation
, что и включающая его функция. Точно так же, как это происходило с модификатором .task
выше. Но .detached
изменяет это поведение, что дает нам нужный фоновый поток (background thread). А затем настраивается новый Task
, который возвращается в MainActor
для обновления UI состояния.
Это довольно сложно.
Одна из проблем заключается в том, что Task.detached
на самом деле делает больше, чем просто игнорирует изоляцию isolation
включающей функции. Необязательно знать все детали, но я считаю это довольно продвинутым инструментом. Но я признаю, что он выполняет свою работу. И, вероятно, это привлекательно, потому что это почти идеально соответствует более знакомым шаблонам из GCD.
Что мне действительно не нравится в этой версии, так это то, что в конце этой функции loadImage
работа еще не завершена. Модель программирования async / await
позволяет вам писать код, который выполняется сверху вниз, а этот код все еще идет извне вовнутрь. Мы можем исправить это, ожидая await
задачи Task. Но на самом деле это просто добавление еще кучу кода и вложенности.
В общем, я думаю, что это просто неудобный шаблон, которого вам следует избегать.
Альтернатива 2: MainActor.run
А вот вариант, который значительно лучше предыдущего.
private nonisolated func loadImage() async {
let request = URLRequest(url: URL(string: "https://robohash.org/hash-this-text.png")!)
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
return
}
let provider = CGDataProvider(data: data as CFData)!
let image = CGImage(
pngDataProviderSource: provider,
decode: nil,
shouldInterpolate: false,
intent: .defaultIntent
)
await MainActor.run {
self.cgImage = image
}
}
Мы вернулись к выражению нашей потребности в фоновом потоке (background thread) в сигнатуре функции. Это убирает много шума. Но вместо того, чтобы возвращать изображение, мы используем новый инструмент для выполнения присвоения значения UI состоянию непосредственно в самой функции. Это намного лучше!
Но есть и недостаток. Этот MainActor.run
классный, потому что он дает нам возможность перейти к нужному актору и выполнить некоторую работу. За исключением того, если вы помните, именно для этого и предназначено ключевое слово await! Когда вы впервые начинаете работать с многопоточностью, вы можете подумать: «О, мне это нравится, потому что это явно».
В этом примере мы работаем со свойством. К сожалению, свойства немного странные в этом отношении. Но представьте, если бы вместо этого у нас была небольшая вспомогательная функция.
@MainActor // or possibly through inference
func updateImage(_ image: CGImage?) {
// ...
}
Эта функция должна манипулировать некоторым UI состоянием, поэтому это будет MainActor
. И это означает следующее.
Мы начали так …
await MainActor.run {
self.cgImage = image
}
… но вместо этого мы могли бы использовать функцию…
await MainActor.run {
updateImage(image)
}
… и это на самом деле полностью эквивалентно этому!
await updateImage(image)
Нет необходимости в MainActor.run
, потому что компилятор знает, что updateImage
— это MainActor
. И это главное, что мне не нравится в MainActor.run
. Даже не всегда очевидно, когда он вам нужен! Не забывайте, что многопоточность (concurrency)— это часть системы ТИПов.
Мы сделали это
Уф. Это оказалось намного длиннее, чем я ожидал, когда начинал писать этот пост. Я очень ценю, что вы дочитали, и надеюсь, что это было полезно. Нам пришлось пропустить некоторые детали о том, как работает изоляция isolation
. Но я честно думаю, что это хорошо! Начните с основ понимания того, когда что-то будет и не будет работать в основном потоке (main thread). Это поможет вам продвинуться очень далеко!
Если вы хотите начать углубляться в подробности, у меня есть куча других материалов, охватывающих некоторые из тех вещей, которые мы пропустили.
Но я действительно рекомендую вам сначала сосредоточиться на основах. Начать с прочного фундамента поможет свести разочарование к минимуму.
Если вы хотите продолжить, то теперь вышла и вторая часть этой серии!