Несколько недель назад я прочитал статью Войцеха Кулика, в которой он рассказывает о некоторых подводных камнях во фреймворке Swift Concurrency. В одном из разделов Войцех кратко упомянул взрыв потока и то, как Swift Concurrency может предотвратить его, ограничивая нас от чрезмерной загрузки системы бОльшим количеством потоков, чем ядер ЦП.
Это заставило меня задуматься… Так ли это на самом деле? Как это работает за кулисами? Можем ли мы как-то обмануть систему, чтобы она создавала больше потоков, чем ядер ЦП?
На все эти вопросы мы собираемся ответить в этой статье. Итак, без лишних слов, давайте сразу приступим.
Понимание взрыва потока 💥
Итак, что такое взрыв потока? Взрыв потока - это ситуация, когда в системе одновременно выполняется огромное количество потоков, что в конечном итоге приводит к проблемам с производительностью и накладным расходам (*перегрузке) памяти.
Нет четкого ответа на вопрос, какое количество потоков считать слишком большим. В качестве общего эталона мы можем сослаться на пример, приведенный в этом видео WWDC, согласно которому система, выполняющая в 16 раз больше потоков, чем количество ее ядер ЦП, считается подверженной взрыву потока.
Поскольку Grand Central Dispatch (GCD) не имеет встроенного механизма, предотвращающего взрыв потока, его довольно легко создать с помощью очереди отправки. Рассмотрим следующий код:
final class HeavyWork {
static func dispatchGlobal(seconds: UInt32) {
DispatchQueue.global(qos: .background).async {
sleep(seconds)
}
}
}
// Execution:
for _ in 1...150 {
HeavyWork.dispatchGlobal(seconds: 3)
}
После выполнения приведенный выше код создаст в общей сложности 150 потоков, что приведет к взрыву потока. В этом можно убедиться, приостановив выполнение и проверив навигатор отладки.
Навигатор отладки, показывающий взрыв потока
Теперь, когда вы узнали, как вызвать взрыв потока, давайте попробуем выполнить тот же код, используя Swift Concurrency, и посмотрим, что произойдет.
Как Swift Concurrency управляет потоками
Как мы все знаем, в Swift Concurrency есть 3 уровня приоритета задач, основная userInitiated, utility и background, где userInitiated имеет наивысший приоритет, за которым следуют utility и background с самым низким приоритетом. Итак, давайте продолжим и обновим наш класс HeavyWork соответствующим образом:
class HeavyWork {
static func runUserInitiatedTask(seconds: UInt32) {
Task(priority: .userInitiated) {
print("🥸 userInitiated: \(Date())")
sleep(seconds)
}
}
static func runUtilityTask(seconds: UInt32) {
Task(priority: .utility) {
print("☕️ utility: \(Date())")
sleep(seconds)
}
}
static func runBackgroundTask(seconds: UInt32) {
Task(priority: .background) {
print("⬇️ background: \(Date())")
sleep(seconds)
}
}
}
Каждый раз при создании задачи мы будем распечатывать время создания. Потом мы сможем использовать его для визуализации того, что происходит за сценой.
Имея обновленный класс HeavyWork, давайте приступим к первому тесту.
Тест 1. Создание Задач с Одинаковым Уровнем Приоритета
Этот тест в основном похож на пример с очередью отправки, который мы видели ранее, но вместо использования GCD для создания потока мы будем использовать Task из Swift Concurrency.
// Test 1: Creating Tasks with Same Priority Level
for _ in 1...150 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
Ниже приведены журналы (*логи), полученные из консоли Xcode.
Swift concurrency, запускающий одновременно максимум 6 потоков
Как видно (по времени создания задачи), создание потоков прекратилось, когда количество потоков достигло 6, что идеально соответствует количеству ядер процессора моего 6-ядерного iPhone 12. Создание задач продолжится только после того, как одна из запущенных задач завершит свое выполнение. В результате одновременно может выполняться не более 6 потоков.
Примечание: Симулятор iOS всегда будет ограничивать максимальное количество потоков до 1 независимо от выбранного устройства. Поэтому обязательно запустите вышеупомянутый тест на реальном устройстве для более точного результата.
Чтобы иметь более четкое представление о том, что на самом деле происходит за кулисами, давайте приостановим выполнение.
Задачи с приоритетом «userInitiated», работающие в параллельной очереди
Похоже, все, что мы только что видели, контролируется параллельной очередью с именем «com.apple.root.user-initiated-qos.cooperative».
Основываясь на приведенном выше наблюдении, можно с уверенностью сказать, что именно так Swift Concurrency предотвращает взрыв потока: создайте специально назначенную параллельную очередь, чтобы ограничить максимальное количество потоков количеством ядер ЦП.
Тест 2: Одновременное создание задач от высокого до низкого уровня приоритета
Теперь давайте углубимся, добавив в тест задачи с разным приоритетом.
// Test 2: Creating Tasks from High to Low Priority Level All at Once
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
Обратите внимание, что мы сначала создаем задачи с наивысшим уровнем приоритета (userInitiated), за которыми следуют utility и background. Основываясь на нашем предыдущем наблюдении, я ожидал увидеть 3 очереди с 6 потоками, работающими параллельно в каждой очереди, что означает, что мы увидим в общей сложности 18 вызванных потоков. Удивительно, но это не так. Взгляните на следующие скриншоты:
Распределение задач при одновременном запуске с высокого на низкий уровень приоритета
Как видите, и utility, и background очереди ограничивают максимально допустимый поток до 1, тогда как очередь с более высоким приоритетом (userInitiated) переполнена. Другими словами, максимальное количество потоков, которые мы можем иметь в этом тесте, равно 8.
Какая интересная находка! Насыщение очереди с высоким приоритетом каким-то образом предотвращает вызов остальных очередей с более низким приоритетом.
Но что произойдет, если мы поменяем порядок уровней приоритета? Давай выясним!
Тест 3: Одновременное создание задач от низкого до высокого уровня приоритета
Прежде всего, давайте обновим исполняемый код:
// Test 3: Creating Tasks from Low to High Priority Level All at Once
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
И вот результат:
Распределение задач при одновременном запуске с низкого на высокий уровень приоритета
Результат, который мы получаем, точно такой же, как в «Тесте 2».
Кажется, что система достаточно умна, чтобы для выполнения в первую очередь уступить место задачам с более высоким приоритетом, хотя мы сначала запускали задачи с самым низким приоритетом. Кроме того, система по-прежнему не позволяет нам создавать более 8 параллельных потоков, поэтому мы по-прежнему не можем создать взрыв потока для этого теста. Молодцы, Apple! 👍🏻
Тест 4. Создание задач от низкого до высокого уровня приоритета с перерывами между ними
В реальных ситуациях очень маловероятно, что мы одновременно запускаем кучу задач с разными уровнями приоритета. Итак, давайте создадим более реалистичное условие, добавив небольшой перерыв между каждым циклом for. Обратите внимание, что в этом тесте мы по-прежнему используем порядок от низкого до высокого.
// Test 4: Creating Tasks from Low to High Priority Level with Break in Between
for _ in 1...30 {
HeavyWork.runBackgroundTask(seconds: 3)
}
sleep(3)
print("⏰ 1st break...")
for _ in 1...30 {
HeavyWork.runUtilityTask(seconds: 3)
}
sleep(3)
print("⏰ 2nd break...")
for _ in 1...30 {
HeavyWork.runUserInitiatedTask(seconds: 3)
}
Результат, который мы получаем, довольно интересный.
Распределение задач при переходе от низкого к высокому уровню приоритета с перерывами между ними
Как видите, после второго перерыва все 3 очереди работают в несколько потоков. Похоже, если мы сначала запустим очередь с более низким приоритетом и позволим ей поработать какое-то время, очередь с наивысшим приоритетом не будет подавлять производительность очереди с наименьшим приоритетом.
Я выполнил этот тест пару раз, максимальное количество потоков может немного отличаться, но оно более или менее равно 3-кратному количеству ядер ЦП.
Можно рассматривать это как взрыв потоков?
Я так не думаю, потому что в 3 раза больше потоков, чем ядер ЦП, все же намного меньше 16-кратного превышения порога, о котором я упоминал ранее. На самом деле, я думаю, что Apple намеренно позволяет этому происходить, чтобы иметь лучший баланс между производительностью выполнения и перегрузкой многопоточности. Напишите мне в Твиттере, если у вас есть другая точка зрения, мне бы очень хотелось услышать ваши мысли.
Вывод
Параллелизм в Swift довольно хорошо справляется с предотвращением взрыва потока, но мы не можем отрицать тот факт, что если мы продолжим насыщать userInitiated очередь, он вызовет эффект «бутылочного горлышка» (в ближайшее время я углублюсь в эту проблему, так что следите за обновлениями).
Основываясь на результате, который мы получили в «Тесте 4», можно с уверенностью сказать, что нам следует чаще использовать background и utility очереди, а userInitiated очередь использовать только при необходимости.
Хотите проверить пример кода? Ну вот!
Если вам понравилась эта статья, обязательно ознакомьтесь с другими моими статьями про Swift Concurrency. Вы также можете следить за мной в Твиттере и подписаться на мою рассылку, чтобы не пропустить ни одной из моих будущих статей.
Спасибо за чтение. 👨🏻💻
Оригинал статьи