Привет. Поймал себя на мысли, что слишком часто приходится обращаться к различным источникам в поисках информации про многопоточность в iOS. Такое себе удовольствие, поэтому собрал все самое полезное и не очень в ряд статей, посвященных многопоточности, ее особенностям и подкапотной жизни. Завариваем чаек и погнали.
Про многопоточность 1. Thread
Про многопоточность 4. Async/await (coming soon)
Про многопоточность 5. Железяки (coming soon)
Threads
Все операции, выполняемые в мобильном приложении, требуют некоторых ресурсов и имеют время выполнения. Эти операции by default выполняются поочередно в главном потоке.
Потоки - это одна из технологий, которые позволяют выполнять несколько операций внутри одного приложения одновременно. Хотя новые технологии, такие как Operations и Grand Central Dispatch (GCD), обеспечивают более современную и эффективную инфраструктуру для реализации параллелизма, OS X и iOS также предоставляют интерфейсы для создания потоков и управления ими.
Главным потоком называется поток, в котором стартует приложение. Обработка событий связанных со взаимодействием с UI происходит на главном потоке (такой операцией может быть обработка тапа по экрану, нажатия на клавишу клавиатуры, движение мыши и тд). Помимо этого, любая операция, написанная нами, будь то выполнение алгоритма, запрос в сеть или обращение к базе данных так же будет выполняться поочередно на главном потоке, что может негативно сказываться на отклике UI. Здесь к нам на помощь приходит многопоточность — возможность выполнять операции параллельно (одновременно) на разных потоках.
Run Loop
И начнем мы, нет, не с потоков, а с части, тесно связанной с их работой, а именно Run Loop. Документация гласит:
A run loop is an event processing loop that you use to schedule work and coordinate the receipt of incoming events. The purpose of a run loop is to keep your thread busy when there is work to do and put your thread to sleep when there is none
Run Loop — своего рода бесконечный цикл, предназначенный для обработки и координации всех событий, поступающих к нему на вход. В первую очередь стоит отметить, что у каждого потока есть свой ассоциированный Run Loop. Run Loop для главного потока приложение (UIApplication) инициализирует и стартует автоматически, в то время как для созданных потоков запускать и конфигурировать Run Loop необходимо самостоятельно.
Зачем же нужен этот ваш ранлуп? К примеру, Run Loop главного потока отлавливает все системные события и запускает их обработку на главном потоке, будь то нажатия на клавиши клавиатуры, если это macOS, или тап по экрану iOS устройства. Также Run Loop умеет управлять своим потоком: будить для выполнения некоторой работы и переводить в спячку после ее выполнения.
По большому счету, Run Loop — это то, что отличает интерактивное мобильное приложение от обычной программы. Когда Run Loop получает сообщение о событии, он запускает обработчик, ассоциированный с этим событием на своем потоке, а после выполнения усыпляет поток до следующего события, именно таким образом приложение узнает о происходящих интерактивных событиях. Разберемся, какие же события умеет обрабатывать Run Loop:
Существует несколько источников события:
Input sources — различные источники ввода (мышь, клавиатура, тачскрин и тп), кастомные источники (необходимы для передачи сообщений между потоками), а так же вызов
performSelector:onThread:
(метод, необходимый для вызова события по селектору на определенном потоке)Timer sources — все таймеры в приложении всегда обрабатываются ранлупом
Как уже говорилось ранее, Run Loop'ом главного потока управляет приложение, в то время, как мы сами управляем Run Loop'ом созданных нами потоков, таким образом мы можем явно указать, какие источники ввода должен обрабатывать Run Loop. Рассмотрим режимы работы Run Loop:
NSDefaultRunLoopMode — режим по умолчанию, который отслеживает все основные события
NSConnectionReplyMode — режим для работы NSConnection (документация гласит, что нам это никогда не понадобится
¯\_(ツ)_/¯
, но если сильно интересно — Distributed Objects Support)
NSModalPanelRunLoopMode — режим, отслеживающий события в модальных окнах (используется только в macOS)
NSEventTrackingRunLoopMode — режим, который отслеживает системные события связанные с UI (скроллинг, тап по экрану, движение мыши, нажатия на клавиатуре и тп.)
NSRunLoopCommonModes — режим, объединяющий в себе 3 других:
NSDefaultRunLoopMode
,NSModalPanelRunLoopMode
иNSEventTrackingRunLoopMode
.
В качестве примера использования режимов Run Loop может быть распространенная проблема, связанная с некорректной работой таймера. Давайте разбираться:
Timer.scheduledTimer(
timeInterval: 0.1,
target: self,
selector: #selector(onTimerUpdate),
userInfo: nil,
repeats: true)
Проблема данного кода заключается в том, что метод scheduledTimer
создает таймер и планирует выполнение события в текущем Run Loop только для режима NSDefaultRunLoopMode
(.default), в то время, как обработка UI событий (скроллинг, тап по дисплею и тп) происходит в режиме NSEventTrackingRunLoopMode
(.tracking). Таким образом, когда пользователь тригерит UI события, RunLoop обрабатывает события только из режима .tracking, а наш таймер тем временем, имея более низкий приоритет, не обрабатывается. Для решения данной проблемы необходимо добавить таймер в текущий Run Loop для режима NSRunLoopCommonModes
(.common), который в себя уже включает .tracking и .default:
let timer = Timer(
timeInterval: 0.1,
target: self,
selector: #selector(onTimerUpdate),
userInfo: nil,
repeats: true)
RunLoop.main.add(timer, forMode: .common)
pthread
pthread
- наиболее низкоуровневый примитив многопоточности. Взаимодействуя с pthread API мы взаимодействуем с низкоуровневым C
кодом. В C
, в отличии от Swift
, мы сами отвечаем за управление памятью, что делает его очень гибким, но в то же время подверженным ошибкам. Вызов C
функции так же прост, как и вызов Swift
функции, однако, для работы с C
и его гибким управлением памятью, нам приходится работать с указателями. (см. совместимость C
и Swift
).
Для создания потока, нам понадобится функция pthread_create
, взглянем на ее интерфейс:
public func pthread_create(_: UnsafeMutablePointer<pthread_t?>!, _: UnsafePointer<pthread_attr_t>?, _: @convention(c) (UnsafeMutableRawPointer) -> UnsafeMutableRawPointer?, _: UnsafeMutableRawPointer?) -> Int32
Рассмотрим аргументы данной функции:
_: UnsafeMutablePointer<pthread_t?>!
— указатель на существующийpthread
_: UnsafePointer<pthread_attr_t>?
— указатель на атрибуты потока, которые позволяют нам настроить поток (pthread_attr(3)
)_: @convention(c) (UnsafeMutableRawPointer) -> UnsafeMutableRawPointer?
— указатель на функцию, которая будет выполнена на потоке. Данная функция имеет одно ограничение - она должна быть глобальной, соответственно не может захватить контекст, поэтому для передачи контекста используется следующий аргумент_: UnsafeMutableRawPointer?
— указатель на аргументы, которые мы хотим передать в функцию
Рассмотрим пример создания pthread
:
// Создаем переменную потока
var thread = pthread_t(bitPattern: 0)
// Создаем переменную аттрибутов очереди
var threadAttributes = pthread_attr_t()
// Инициализируем аттрибуты
pthread_attr_init(&threadAttributes)
// Создаем поток, передав все аргументы
pthread_create(
&thread,
&threadAttributes,
{ _ in
print("Hello world")
return nil
},
nil)
Поток начнет свое выполнение сразу после создания (pthread_create
).
Thread
Thread — это objc обертка над pthread
, которая предоставляет более удобный способ взаимодействия с потоком.
Рассмотрим пример создания потока Thread
:
// Создаем объект Thread
let thread = Thread {
print("Hello world!")
}
// Стартуем выполнение операций на потоке
thread.start()
У Thread
есть альтернативный способ создания, с использованием Target-Action паттерна:
public convenience init(target: Any, selector: Selector, object argument: Any?)
Quality of service
Quality of service (QOS) - уникальный способ приоритизации задач. Задавая задаче QOS, мы говорим системе об ее важности и даем возможность приоритизировать ее.
Существует 5 типов QOS:
QOS_CLASS_USER_INTERACTIVE - используется для задач, связанных со взаимодействием с пользователем, таких как анимации, обработка событий, обновление интерфейса.
QOS_CLASS_USER_INITIATED - используется для задач, которые требуют немедленного фидбека, но не связанных с интерактивными UI событиями.
QOS_CLASS_DEFAULT - используется для задач, которые по умолчанию имеют более низкий приоритет, чем user interactive и user initiated задачи, но более высокий приоритет, чем utility и background задачи. QOS_CLASS_DEFAULT является QOS по умолчанию.
QOS_CLASS_UTILITY - используется для задач, в которых не требуется получить немедленный результат, например запрос в сеть. Наиболее сбалансированный QOS с точки зрения потребления ресурсов.
QOS_CLASS_BACKGROUND - имеет самый низкий приоритет и используется для задач, которые не видны пользователю, например синхронизация или очистка данных.
QOS_CLASS_UNSPECIFIED - используется только в том случае, если мы работаем со старым API, который не поддерживает QOS
Рассмотрим пример использования QOS:
// Создаем переменную потока
var thread = pthread_t(bitPattern: 0)
// Создаем переменную аттрибутов очереди
var threadAttributes = pthread_attr_t()
// Инициализируем аттрибуты
pthread_attr_init(&threadAttributes)
// Задаем QOS в атрибуты потока
pthread_attr_set_qos_class_np(&threadAttributes, QOS_CLASS_USER_INTERACTIVE, 0)
// Создаем поток, передав все аргументы
pthread_create(
&thread,
&threadAttributes,
{ _ in
print("Hello world")
// Переключаем QOS в ходе выполнения операции
pthread_set_qos_class_self_np(QOS_CLASS_BACKGROUND, 0)
return nil
},
nil)
Во фреймворке Foundation
так же, как и в pthread API есть возможность приоритезировать задачи с помощью QOS.
@available(iOS 8.0, *)
public enum QualityOfService : Int {
case userInteractive = 33
case userInitiated = 25
case utility = 17
case background = 9
case `default` = -1
}
Рассмотрим использование QOS в Thread
:
// Создаем объект Thread
let thread = Thread {
print("Hello world!")
}
// Задаем потоку QOS
thread.qualityOfService = .userInteractive
// Стартуем выполнение операций на потоке
thread.start()
Синхронизация
При работе с многопоточностью, часто встает вопрос синхронизации. Существуют ситуации, в которых несколько потоков имеют одновременный доступ к ресурсу, например Thread1 читает ресурс в то время, как Thread2 изменяет его, что приводит к коллизии.
Во избежание таких ситуации существует несколько способов синхронизации, такие как mutex и semaphore. Синхронизация позволяет обеспечить безопасный доступ одного или нескольких потоков к ресурсу.
Mutex
Mutex — примитив синхронизации, позволяющий захватить ресурс. Подразумевается, что как только поток обратиться к ресурсу, захваченному мьютексом, никакой другой поток не сможет с ним взаимодействовать до тех пор, пока текущий поток не освободит этот ресурс
Рассмотрим пример использования pthread mutex:
var mutex = pthread_mutex_t()
pthread_mutex_init(&mutex, nil)
func doSomething() {
// Захватываем ресурс
pthread_mutex_lock(&mutex)
// Выполняем работу
print("Hello World!")
// Освобождаем ресурс
pthread_mutex_unlock(&mutex)
}
Стоит отметить, что mutex работает по принципу FIFO, то есть потоки будут захватывать ресурс по освобождению в том порядке, в котором данные потоки обратились к ресурсу.
NSLock
NSLock - более удобная реализация базового mutex из фреймворка Foundation
.
Рассмотрим пример использования NSLock
:
let lock = NSLock()
func doSomething() {
lock.lock()
// Выполняем работу
print("Hello World")
lock.unlock()
}
Reqursive mutex
Reqursive mutex — разновидность базового mutex, которая позволяет потоку захватывать ресурс множество раз до тех пор, пока он не освободит его. Ядро операционной системы сохраняет след потока, который захватил ресурс и позволяет ему захватывать ресурс повторно. Рекурсивный мьютекс считает количество блокировок и разблокировок, таким образом ресурс будет захвачен до тех пор, пока их количество не станет равно друг другу. Чаще всего используется в рекурсивных функциях.
Рассмотрим пример использования Reqursive mutex:
var mutex = pthread_mutex_t()
// Создаем переменную атрибутов мьютекса
var mutexAttributes = pthread_mutexattr_t()
pthread_mutexattr_init(&mutexAttributes)
// Сетим рекурсивный тип мьютекса
pthread_mutexattr_settype(&mutexAttributes, PTHREAD_MUTEX_RECURSIVE)
// Инициализируем мьютекс с атрибутами
pthread_mutex_init(&mutex, &mutexAttributes)
func doSomething1() {
pthread_mutex_lock(&mutex)
doSomething2()
pthread_mutex_unlock(&mutex)
}
func doSomething2() {
pthread_mutex_lock(&mutex)
print("Hello World!")
pthread_mutex_unlock(&mutex)
}
Если бы в данном примере использовался обычный mutex, поток бы бесконечно ожидал, пока он же сам не освободит ресурс.
NSRecursiveLock
NSRecursiveLock — более удобная реализация reqursive mutex из фреймворка Foundation
.
Рассмотрим пример использования NSRecursiveLock
:
let recursiveLock = NSRecursiveLock()
func doSomething1() {
recursiveLock.lock()
doSomething2()
recursiveLock.unlock()
}
func doSomething2() {
recursiveLock.lock()
print("Hello World!")
recursiveLock.unlock()
}
Condition
Condition - еще один примитив синхронизации. Задача, закрытая condition, не начнет свое выполнение до тех пор, пока не получит сигнал из другого потока. Сигнал является неким триггером для condition, который говорит о том, что поток должен выйти из состояния ожидания.
Рассмотрим пример использования condition:
// Создаем переменную condition
var condition = pthread_cond_t()
var mutex = pthread_mutex_t()
// Создаем булевый предикат
var booleanPredicate = false
// Инициализируем condition
pthread_cond_init(&condition, nil)
pthread_mutex_init(&mutex, nil)
func doSomething1() {
pthread_mutex_lock(&mutex)
// Проверяем булевой предикат
while !booleanPredicate {
// Переход в состояние ожидания
pthread_cond_wait(&condition, &mutex)
}
// Выполняем работу
print("Hello World!")
pthread_mutex_unlock(&mutex)
}
func doSomething2() {
pthread_mutex_lock(&mutex)
booleanPredicate = true
// Выпускаем сигнал в condition
pthread_cond_signal(&condition)
pthread_mutex_unlock(&mutex)
}
Для успешной работы codition всегда необходима дополнительная проверка в виде предиката (booleanPredicate
, как показано в примере выше), так как в iOS возможны ложные срабатывания метода pthread_cond_signal
.
NSCondition
NSCondition — более удобная реализация condition из фреймворка Foundation
. Удобство заключается в том, что используя NSCondition
, в отличии от pthread_cond_t
, у нас нет необходимости дополнительно создавать mutex, так как NSCondition
самостоятельно поддерживает методы lock()
и unlock()
.
Рассмотрим пример использования NSCondition
:
// Создаем булевый предикат
var boolPredicate = false
// Создаем condition
let condition = NSCondition()
func test1() {
condition.lock()
// Проверяем булевой предикат
while(!boolPredicate) {
// Переход в состояние ожидания
condition.wait()
}
// Выполняем работу
print("Hello World!")
condition.unlock()
}
func test2() {
condition.lock()
boolPredicate = true
// Выпускаем сигнал в condition
condition.signal()
condition.unlock()
}
Read Write Lock
Read Write Lock — примитив синхронизации, который предоставляет потоку доступ к ресурсу на чтение, в это время закрывая возможность записи в ресурс из других потоков.
Необходимость использовать rwlock появляется тогда, когда много потоков читают данные, и только один поток их пишет (Reader-writers problem). На первый взгляд кажется, что данную проблему можно легко решить простым mutex, однако этот подход будет требовать больше ресурсов, нежели простой rwlock, так как фактически нет необходимости блокировать доступ к ресурсу полностью. rwlock имеет достаточно простое API:
// Создаем rwlock
var lock = pthread_rwlock_t()
// Создаем rwlock атрибуты
var attr = pthread_rwlockattr_t()
// Инициализируем rwlock
pthread_rwlock_init(&lock, &attr)
// Блокируем чтение
pthread_rwlock_rdlock(&lock)
// ...
// Освобождаем ресурс
pthread_rwlock_unlock(&lock)
// Блокируем запись
pthread_rwlock_wrlock(&lock)
// ...
// Освобождаем ресурс
pthread_rwlock_unlock(&lock)
// Очищаем rwlock
pthread_rwlock_destroy(&lock)
Рассмотри пример практического использования rwlock:
class RWLock {
// Создаем rwlock
var lock = pthread_rwlock_t()
// Создаем rwlock аттрибуты
var attr = pthread_rwlockattr_t()
// Создаем ресурс
private var resource: Int = 0
init() {
// Инициализируем rwlock
pthread_rwlock_init(&lock, &attr)
}
var testProperty: Int {
get {
// Блокируем ресурс на чтение
pthread_rwlock_rdlock(&lock)
// Создаем временную переменную
let tmp = resource
// Освобождаем ресурс
pthread_rwlock_unlock(&lock)
return tmp
}
set {
// Блокируем ресурс на запись
pthread_rwlock_wrlock(&lock)
// Записываем ресурс, гарантируя, что в данный момент времени он не будет перезаписан из другого потока
resource = newValue
// Освобождаем ресурс
pthread_rwlock_unlock(&lock)
}
}
}
Существует только unix вариация rwlock, во фреймворке Foundation
нет альтернативы. В следующей статье мы рассмотрим альтернативы из более высокоуровневых библиотек.
Spin Lock
Spin lock — наиболее быстродействующий, но в то же время энергозатратный и ресурсотребователный mutex. Быстродействие достигается за счет непрерывного опрашивания, освобожден ресурс в данный момент времени или нет.
// Создаем spinlock
var lock = OS_SPINLOCK_INIT
// Блокируем ресурс
OSSpinLockLock(&lock)
// Выполняем работу
print("Hello World!")
// Освобождаем ресурс
OSSpinLockUnlock(&lock)
Рекомендуется использовать spin lock лишь в редких случаях, когда к ресурсу обращается небольшое количество потоков непродолжительное время.
Unfair Lock
Unfair lock (iOS 10+) — примитив многопоточности, позволяющий наиболее эффективно захватывать ресурс. По большому счету, unfair lock является более производительной заменой spin lock. Производительность достигается путем максимального сокращения возможных context switch.
Context switch — процесс переключения между потоками. Для того, чтобы переключаться между потоками, необходимо прекратить работу на текущем потоке, сохранив при этом состояние и всю необходимую информацию, а далее восстановить и загрузить состояние задачи, к выполнению которой переходит процессор. Является энергозатратной и ресурсотребовательной операцией
Вспоминаем, что обычный mutex работает по принципу FIFO, в то время, как unfair lock отдаст предпочтение тому потоку, который чаще обращается к ресурсу, таким образом и достигается сокращение context switch. Имеет достаточно простое API:
// Создаем unfair lock
var lock = os_unfair_lock_s()
// Блокируем ресурс
os_unfair_lock_lock(&lock)
// Выполняем работу
print("Hello World!")
// Освобождаем ресурс
os_unfair_lock_unlock(&lock)
Проблемы
Многопоточность предназначена для решения проблем, но как и любые другие технологии может порождать новые. В большинстве случаев проблемы связанны с доступом к ресурсам. Самые распространенные из них:
Deadlock — ситуация, в которой поток бесконечно ожидает доступ к ресурсу, который никогда не будет освобожден
Priority inversion — ситуация, в которой высокоприоритетная задача ожидает выполнения низкоприоритетной задачи.
Race condition — ситуация, в которой ожидаемый порядок выполнения операций становится непредсказуемым, в результате чего страдает закладываемая логика
В данной статье мы рассмотрим только Deadlock, так как остальные проблемы стоит решать используя более высокоуровневые библиотеки.
Deadlock
Deadlock — это ситуация в многозадачной среде, при которой несколько потоков находятся в состоянии ожидания ресурса, занятого друг другом, и ни один из них не может продолжать свое выполнение. Таким образом оба потока бесконечно ожидая друг друга никогда не выполнят задачу, что может привести к неожиданному поведению приложения.
Попробуем воспроизвести самый примитивный кейс бесконечной блокировки ресурса:
class Deadlock {
let lock = NSLock()
var boolPredicate = true
var counter = 0
func test() {
lock.lock()
counter += 1
boolPredicate = counter < 10
if boolPredicate { test() }
lock.unlock()
}
}
Проблема легко решается, если мы будем использовать NSRecursiveLock
вместо NSLock
. Таким образом поток сможет множество раз захватывать ресурс и не будет ожидать, пока сам же не освободит его.
Воспроизведем deadlock с использованием вложенных блокировок ресурсов:
class DeadLock {
let lock1 = NSLock()
let lock2 = NSLock()
var resource1 = 0
var resource2 = 0
func test() {
let thread1 = Thread(block: {
self.lock1.lock()
self.resource1 = 1
self.lock2.lock()
self.resource2 = 1
self.lock2.unlock()
self.lock1.unlock()
})
let thread2 = Thread(block: {
self.lock2.lock()
self.resource2 = 1
self.lock1.lock()
self.resource1 = 1
self.lock1.unlock()
self.lock2.unlock()
})
thread1.start()
thread2.start()
}
}
let deadLock = DeadLock()
deadLock.test()
Потоки thread1
и thread2
будут бесконечно ожидать освобождения ресурсов resource1
и resource2
, захваченных мьютексами lock1
и lock2
. Deadlock возможен только тогда, когда в используется вложенный многопоточный доступ к ресурсам. Избегать подобные ситуации давольно просто, старайтесь не проектировать заведомо сложные решения там, где можно обойтись и избегайте вложенные доступы к ресурсам как в примере выше.
Заключение
В заключении хочется сказать, что тема concurrency достаточно большая и сложная, но по мере приближения к высокому уровню, конструкции будут становится проще и понятнее. Скорее всего вам никогда не придется создавать pthread
руками, но полезно знать, как работают более высокоуровневые обертки под капотом. В следующих статьях рассмотрим библиотеку GCD, научимся крутить вертеть Operations, прямо как Гудини, а так же копнем еще глубже и изучим все тонкости работы процессора в контексте данной темы. Спасибо за внимание!