Устройство многопоточности в iOS
В 2000 году Apple выпустила открытую unix-like ОС Darwin, которая уже в следующем году послужит базой для первой версии Mac OS X — 10.0, которая, в свою очередь, в будущем станет прародителем всех операционных систем Apple, начиная от современных macOS и iOS, заканчивая watchOS в часах и audioOS в «умных» колонках.
🎓
Эта статья написана по мотивам сессии «Устройство многопоточности в iOS» проходившей в рамках третьего сезона Podlodka iOS Crew, спикером которой был Александр Андрюхин. Я позволили себе изменить последовательность повествования, а так же более полно раскрыть некоторые темы, а некоторые вовсе убрать.
В любом случае, статья не будет заменой видео, а видео — статье. Залетайте на остаток сезона — впереди целая неделя живых докладов, посвященых скиллам, которые нужны, чтобы сделать из обычного приложения крутой продукт! Приятным бонусом будет доступ к видео с сессиями первой недели про многопоточность.
Darwin построен на XNU — гибридном ядре, включающим в себя микроядро Mach и некоторые части ОС семейства BSD. В контексте этой заметке нам важно что Darwin получил от BSD модель процессов unix и модель тредов POSIX, а от Mach — слегка переосмысленное предствление процессов как задач.
Для работы с потоками ОС разработчикам доступна С библиотека pthread — первый слой абстракции в наших операционных системах. Несмотря на то, что использовать её в своём коде можно и по сей день, Apple никогда не рекомендовала использовать pthread напрямую. Уже с первых версий версий Mac OS, разработчикам была доступна абстракция Apple поверх phread — NSThread.
NSThread — второй слой абстракции, которая нам заботливо предоставила Apple. Помимо более привычного Objective-C синтаксиса для создания и управления потоками, корпорация предоставила RunLoop — цикл обслуживания задач и событий. RunLoop использует инструменты микроядра Mach (порты, XPC, ивенты и задачи) для управления потоком POSIX и может перевести поток в режим сна, если ему нечего делать и пробудить, когда появиться работа.
Конечно, пользоваться NSThread можно и по сей день, но стоит помнить, что создание потока — дорогая операция, т.к. сам поток, мы должны запросить у самой ОС. Кроме того, синхронизация потоков и доступа к ресурсам несколько неудобна для повседневной разработки, поэтому разработчики Apple задумались о решении проблемы удобства работы с асинхронным кодом.
Grand Central Dispatch
С выходом iOS 4, Apple подняла разработчиков выше еще на один уровень абстракции и представила Grand Central Dispatch и вводит понятие очередей и задач для организации асинхронного кода. GCD — это высокоуровневый API, позволяющее создавать пользовательские очереди, управлять задачами в них, решать вопросы синхронизации и делать это максимально эффективно.
Т.к. очереди — это всего лишь слой абстракции, под капотом они используют всё те же системные треды, но механизм их создания и использования оптимизирован. CGD имеет пул предсозданных потоков и распределяет задачи эффективно, максимально утилизируя процессор если это необходимо. Разработчикам более не нужно думать о самих потоках, их создании и управлении.
Помимо создании новой очереди вручную, GCD предоставляет доступ к главной очереди, на которой работает UI и доступ к нескольким системным (глобальным) очередям.
Очереди GCD бывают двух типов:
- serial — последовательные
- concurrent — параллельные
Не сложно догадаться, что на serial очереди задачи будут выполняться последовательно, друг за другом, а на параллельной будут выполнятся одновременно. По умолчанию очередь создаётся с последовательными выполнением задач, а чтобы создать concurrent очередь, необходимо явно это указать
let queue = DispatchQueue("com.company.name.app", attributes: .concurrent)
Как уже было сказано, GCD предоставляет уже созданные, глобальные очереди, которые отличаются приоритетом:
- global(qos: .userInteractive) — Для заданий, которые взаимодействуют с пользователем в данный момент и занимают очень мало времени.
- global(qos: .userInitiated) — Для заданий, которые инициируются пользователем и требуют обратной связи.
- global(qos: .utility) — Для заданий, которые требуют некоторого времени для выполнения и не требуют немедленной обратной связи.
- global(qos: .background) — Для заданий, не связанных с визуализацией и не критичных ко времени исполнения.
⚠️ Все глобальные очереди — очереди с параллельным выполнением задач.
Постановка задачи в очередь
Задачи в любою очередь, параллельную или последовательную, могут быть поставлены синхронно и асинхронно. При асинхронной постановке задачи в очередь, код, следующий за постановкой задачи в очередь, продолжит выполняться.
...
DispatchQueue.global().async {
processImage()
}
doRequest() // выполнется сразу, не дожидаясь processImage()
А в случае с синхронной постановкой, код, следующей за ней, не продолжит своё выполнение, пока не будет выполнена поставленная в очередь задача.
...
DispatchQueue.global().sync {
processImage()
}
doRequest() // будет ждать processImage()
DispatchWorkItem
DispatchWorkItem — специальный класс GCD, более объектно-ориентированную альтернативу замыканию (блоку) для постановки задачи в очередь. В отличии от обычной постановки задачи в очередь, DispatchWorkItem имеет возможность:
- указать приоритет задачи
- получить уведомление о завершении задачи
- отменить задачу
☝️
Важно понимать, что отмена задания работает до момента старта задачи, то есть пока она находиться в очереди. Если GCD начал исполнять код в блоке DispatchWorkItem отмена задания не приведет ни к какому результату, код продолжит своё выполнение.
NSOperation
GCD представляет удобную абстракцию для написания асинхронного кода, как с точки зрения идеи, так и с точки зрения синтаксиса. Но так было не всегда. В Objective-C (да и в первых версиях Swift) оперирование очередями и задачами было не таким удобным как в современном Swift да и вообще шло вразрез самому названию языка программирования. Apple необходимо было предоставить объектно ориентированную альтернативу GCD, и она это сделала представив NSOperation.
NSOperations — это по сути те же очереди, вместо DispatchQueue здесь OperationQueue, а вместо DispatchWorkItem — Operation. Но помимо ООПшного синтаксиса API Operations предоставляет два крутых перимущества:
- Возможность указывать максимальное кол-во выполняемых одновременно задач в очереди.
- Указывать зависимые операции, выстраивая таким образом иерархию операций. В таком случае операция пойдет на выполнение только тогда, когда все операции, на которых она зависит, завершатся.
Последний пункт очень удобен для построения цепочки запросов, когда для выполнения одного запроса нужна информация от нескольких других.
class CustomOperation: Operation {
var outputValue: Int?
var inputValue: Int {
return dependencies
.filter({ $0 is CustomOperation })
.first as? CustomOperation
.outputValue ?? 0
}
...
}
let operation1 = CustomOperation()
operation1.start()
let operation2 = CustomOperation()
operation2.addDependency(operation1)
operation2.start()
Ошибки и проблемы
Говоря об асинхронности и многопоточности, нельзя обойти тему основных основных ошибок, которые разрабочики могут совершить при написании кода, выполняющегося параллельно.
Race Condition или состояние гонки
Race Condition или состояние гонки — это ошибка проектирования многопоточных систем, при которой доступ к ресурсу не синхронизирован и результат выполнения может зависеть от последовательности выполнения кода. Определение сложно понять без примера, поэтому лучше разобраться опираясь на какой-то пример.
На иллюстрации изображено два потока, в которых происходит увеличение какого-то кода. Чтобы инкрементировать значение переменной, процессору нужно будет совершить три действия:
- считать значение переменной из общей памяти в регистр
- увеличить значение в регистре на 1
- записать значение из регистра обратно в общую память
Как можно заметить на иллюстрации, считывание значения переменной вторым потом происходит до того, как первый поток успел записать увеличенное значение. В итоге теряется одно увеличение счетчика что может быть как причиной безобидного бага, так и серьезной проблемы приводящей к аварийному завершению.
💡
Самым серьёзным последствием Race Condition считается кейс ПО медицинсокого аппарата для лучевой терапии Therac-25. Состояние гонки приводило к неправильным значениям в переменой, используемой для определения режима работы лучевого механизма.
Deadlock
Deadlock или взаимная блокировка — ошибка многопоточного ПО, при которой несколько потоков могут взаимно ожидать освобождения некоторого ресурса бесконечное время.
Разберем на примере. Допустим задача, в потоке А блокирует доступ к некому ресурсу А, пусть это будет некий SettingsStorage. Задача А блокирует доступ к стораджу, читает оттуда некоторые значения, и что-то вычисляет. В это время стартует задача B и блокирует доступ к ресурсу B, пусть это будет база данных. Чтобы выполнить некоторые вычисления задаче В тоже потребовался доступ к SettingsStorage и задача начинает ждать, когда задача А его освободит. В это время, задаче А понадобился доступ к БД, но он уже заблокирован задачей В. Происходит взаимна блокировка: задача А ожидает БД, которая заблокирована задачей В, которая ожидает сторадж, заблокированный задачей А.
Инверсия приоритетов
Инверсия приоритетов — ошибка, приводящая к смене приоритетов у потоков, которой не предполагался разработчиком.
Пусть у нас есть всего две задачи с разным приоритетом и всего 1 ресурс, пусть это снова будет БД. Первым в очередь помещается задача с низким приоритетом. Она выполняет свою работу и в момент времени Т1 ей понадобилась БД и она блокирует доступ к ней. Почти сразу же после этого, стартует высокоприоритетная задача и вытесняет низкоприоритетную. Все идет по плану до момента Т3, где высокоприоритетная задача пытается завладеть БД. Так как ресурс заблокирован, задача высокоприоритетная задача переводится в ожидание, а низкоприоритетная получает процессорное время. Временной промежуток T3-T4 называют ограниченной инверсией приоритетов. В этом промежутке наблюдается логическое несоответствие с правилами планирования — задача с более высоким приоритетом находится в ожидании в то время как низкоприоритетная задача выполняется.