При работе со Swift Concurrency часто хочется посмотреть, как работают созданные асинхронные задачи в приложении: узнать количество задач, время создания и длительность их выполнения. Посмотреть, на каких потоках и с какими приоритетами они выполняются.
В Xcode 14 появился специальный шаблон профилирования в Xcode Instruments — Swift Concurrency. Он позволяет наглядно визуализировать работу с асинхронным кодом.
Я — Светлана Гладышева, iOS-разработчик компании Surf. Давайте разберёмся, что нам может показать Swift Concurrency шаблон и как его использовать. А также на простых примерах посмотрим, какие ошибки можно обнаружить с его помощью.
Обзор инструмента
Чтобы воспользоваться шаблоном Swift Concurrency, нужно в меню Xcode выбрать пункт Product->Profile или нажать ⌘I. Затем в появившемся окне нужно выбрать Swift Concurrency.
![](https://habrastorage.org/getpro/habr/upload_files/b2b/8c0/d1e/b2b8c0d1e93ddcd8a32785805d0d6c6d.png)
Нам нужно записать действия, которые хотим исследовать. Как и в других инструментах из Xcode Instruments, чтобы начать запись, нажимаем на кнопку Start recording.
![](https://habrastorage.org/getpro/habr/upload_files/c41/fc5/fb5/c41fc5fb5b8891c6798c31b8af8356c3.png)
В приложении совершаем действия, которые нужно отследить. Останавливаем запись той же кнопкой.
В итоге увидим вот такое окно:
![](https://habrastorage.org/getpro/habr/upload_files/3ff/408/000/3ff4080002e7d564b3e9e3de57181fee.png)
Окно инструмента разделено на две части. В верхней части — временная шкала, с помощью которой можно узнать общую информацию о задачах, акторах и потоках, а также об использовании CPU. В нижней части — более подробная информацию о том, что выделено синим цветом в верхней части.
Рассмотрим верхнюю часть. Из раздела Swift Tasks можем узнать, сколько задач выполнялось в определенный момент времени.
![](https://habrastorage.org/getpro/habr/upload_files/089/fa6/337/089fa6337fee3b262c7a8026dacc958c.png)
Running tasks — количество выполняющихся одновременно задач.
Alive tasks — количество незавершённых задач. Помимо выполняющихся задач, сюда также входят задачи, которые чего-либо ожидают.
Total tasks — общее число созданных задач до определённого момента времени.
Swift actors — показывает количество акторов в определённый момент времени.
Также бывает полезно рядом с информацией о задачах и акторах увидеть информацию об использовании CPU: она здесь тоже есть.
Ещё на временную шкалу можно прикрепить отдельную задачу, актора или поток, чтобы увидеть информацию только об одной этой сущности. Для этого нужно кликнуть по сущности, которую хотим прикрепить, правой кнопкой мыши и выбрать пункт «Pin in timeline».
![](https://habrastorage.org/getpro/habr/upload_files/1ec/5cb/d73/1ec5cbd738e95666a944c0d49b1907db.png)
После этого сущность появится на временной шкале в средней части окна.
![](https://habrastorage.org/getpro/habr/upload_files/9f7/904/5a0/9f79045a04f605d628842d737a16daec.png)
Нижняя часть показывает более подробную информацию о задачах, акторах, потоках или CPU. Для каждой сущности есть несколько режимов. Давайте рассмотрим те из них, которые, на мой взгляд, наиболее полезные.
Task Forest показывает графическое представление об иерархии задач: можно посмотреть, какие задачи родительские, какие дочерние.
![](https://habrastorage.org/getpro/habr/upload_files/5ef/ea7/fd1/5efea7fd104b215171ccc963550dd53d.png)
Task Lifetime показывает время жизни каждой задачи, а также время её создания.
![](https://habrastorage.org/getpro/habr/upload_files/46f/338/e87/46f338e87b456242791bf621323e1d3a.png)
Task States показывает, сколько времени каждая задача проводит в различных состояниях: creating, running, continuation, ending.
![](https://habrastorage.org/getpro/habr/upload_files/74c/312/48d/74c31248db0399508fdb5415601f5465.png)
Если прикрепить к временной шкале задачу, для неё становится доступен режим Narrative. В нём можно узнать самую подробную информацию о задаче, включая то, на каких потоках и с каким приоритетом она выполнялась. Также тут можно увидеть, чего задача ожидает и как долго.
![](https://habrastorage.org/getpro/habr/upload_files/fe4/773/e66/fe4773e664b22bee380c128a74802343.png)
Для акторов есть полезный режим Actor Execution. В нём можно посмотреть, какие задачи выполнялись на этом акторе, как долго и в каком потоке.
![](https://habrastorage.org/getpro/habr/upload_files/3fd/57e/be2/3fd57ebe235aff343ce04f144a5c5cbe.png)
Пример 1
Предположим, мы хотим выполнять параллельно сложные и долгие вычисления. Есть такой код:
func onButtonTap() {
Task {
let calculatedValues = await withTaskGroup(of: Int.self) { group in
for _ in 0 ..< numberOfValues {
group.addTask {
return await self.calculator.calculateValue()
}
}
var result = [Int]()
for await value in group {
result.append(value)
}
return result
}
processValues(calculatedValues)
}
}
Запустим для этого кода инструмент и увидим вот такую картину:
![](https://habrastorage.org/getpro/habr/upload_files/1d4/3bf/919/1d43bf919b491febcedd6a7c8454c36e.png)
Посмотрев на строку Running Tasks, мы сразу же видим, что большую часть времени выполняется только одна задача. Мы же хотим, чтобы выполнялось несколько задач одновременно.
В нижней части выберем одну из задач и прикрепим её к временной шкале. Теперь в нижней части можем посмотреть для неё Narrative.
![](https://habrastorage.org/getpro/habr/upload_files/290/98c/a50/29098ca50edab256a0adeb5495c47a58.png)
Мы видим, что задача выполнялась на акторе Calculator. Чтобы обеспечить потокобезопасность, акторы в Swift допускают одновременное выполнение только одной задачи. Таким образом мы выяснили: этот актор и есть причина того, что задачи выполнялись последовательно по одной.
На простом примере мы убедились, что Swift Concurrency Instrument может помочь обнаружить проблему в коде. Если бы в вашем коде задач и акторов было намного больше, разобраться в коде было бы сложнее: визуализация упростила бы понимание того, что происходит.
Пример 2
Усложним предыдущий пример. Теперь мы хотим сохранять каждое посчитанное значение сразу после того, как оно вычислилось.
func onButtonTap() {
Task {
let calculatedValues = await withTaskGroup(of: Int.self) { group in
for _ in 0 ..< numberOfValues {
group.addTask {
let value = self.calculateValue()
await self.save(value)
return value
}
}
var result = [Int]()
for await value in group {
result.append(value)
}
return result
}
processValues(calculatedValues)
}
}
Метод save
выглядит так:
func save(_ value: Int) async {
Task {
await store.save(value)
}
}
Давайте запустим Instruments и посмотрим, что происходит. В Task States мы видим вот такую картину:
![](https://habrastorage.org/getpro/habr/upload_files/bc1/b01/806/bc1b01806f14d109efed6b24eae2d796.png)
![](https://habrastorage.org/getpro/habr/upload_files/42b/0e0/c17/42b0e0c175a02e50816b167175db5fa7.png)
Большинство задач на сохранение выполняются в конце — в начале выполняются в основном только задачи на вычисление. Но нам надо сохранять значения сразу же, как только они будут вычислены.
Давайте прикрепим несколько задач к временной шкале и посмотрим на их Narrative:
![](https://habrastorage.org/getpro/habr/upload_files/e75/4c6/cf9/e754c6cf935379adf5ad6b3e224c6098.png)
Мы видим, что приоритет у них одинаковый: User Initiated. Задачи на сохранение создаются позже, а приоритет у них такой же, как и у задач на вычисление. Поэтому очередь до них доходит только после завершения задач на вычисление.
Наглядное представления о работе задач и их приоритетах помогло обнаружить проблему неправильных приоритетов.
Пример 3
Давайте посмотрим на такой код:
func getMessages() async -> [Message] {
await withCheckedContinuation { continuation in
fetchMessages { messages in
continuation.resume(returning: messages)
}
}
}
На первый взгляд здесь всё хорошо: мы вызываем continuation.resume ровно один раз. Но, запустив Swift Concurrency Instrument, увидим вот такую картину:
![](https://habrastorage.org/getpro/habr/upload_files/c21/bf4/aec/c21bf4aec83f4eabee55180cb41851d7.png)
Есть одна задача, которая выполняется очень долго. В Task States мы видим, что эта задача висит в состоянии continuation. Задача должна была быстро завершиться, но не завершилась.
В правой нижней части окна инструмента есть Creation Backtrace, с помощью которого можно легко найти место в коде, где эта задача создается. Давайте перейдём в это место в коде. Так как в методе getMessages
все хорошо, посмотрим на метод fetchMessages
, который вызывается внутри withCheckedContinuation
:
func fetchMessages(completion: @escaping ([Message]) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
guard
let data = data,
let messages = try? JSONDecoder().decode([Message].self, from: data)
else {
return
}
completion(messages)
}.resume()
}
В этом методе при ошибке не вызывается completion callback. Из-за этого задача долго не завершалась и висела в статусе сontinuation.
В таком простом примере ошибку использования continuation видно сразу. Но в реальном проекте логика может быть намного более сложной, и это уже не будет так очевидно.
Если вы используете checked continuation, в такой ситуации консоль выведет ошибку. Если вам по каким-то причинам нужно использовать unchecked continuation, в консоли не будет ошибок. В таком случае этот инструмент незаменим, чтобы отслеживать долго висящие continuation.
Шаблон Swift Concurrency в Xcode Instruments был создан, чтобы выявлять проблемы в асинхронном коде. Если вы используете Swift Concurrency, этот инструмент поможет лучше понимать, что происходит. Особенно эффективен он будет, если в приложении есть сложная логика с большим количеством асинхронных задач.
Демонстрацию использования инструмента также можно посмотреть в видео с WWDC 2022.
Больше полезного про iOS — в нашем телеграм-канале Surf iOS Team. Публикуем кейсы, лучшие практики, новости и вакансии Surf. Присоединяйтесь >>