Multithreading. Part 1 Overview, GCD
- Basic concepts
- Grand Central Dispatch (GCD)
- Dispatch group
- Multithreading pitfalls
- Synchronization tools
- Useful materials 🤓
Basic concepts
Process (процесс) - простыми словами это наше запущенное приложение. Под процесс выделяется какой-то блок памяти, адресное пространство которого может быть использовано только внутри этого процесса. Процесс имеет потоки. Thread (поток) — это последовательность инструкций, которые могут быть выполнены во время выполнения приложения.
Каждый процесс имеет хотя бы один поток. Каждый поток может выполнять только одну операцию в один момент времени. Все потоки процесса имеют общее адресное пространство, которое принадлежит этому процессу. Вы можете иметь столько потоков, сколько имеет ядер в процессоре вашего устройства.
Можно ли реализовать многопоточность на одноядерном процессоре? Да, можно. Однако это будет многопоточность основанная на смене контекста, это значит, что одновременно смогут работать только то количество потоков, которое соответствует количеству ядер и периодически система будет приостанавливать эти потоки и запускать в работу другие и передавая им процессорное время. Т.е. потоки работают не на 100%, а с некоторыми прерываниями и ожиданиями для возвращения в работу.
При старте iOS приложение имеет только один поток — это главный поток(main). На главном потоке обновляется пользовательский интерфейс и выполняются все операции, непосредственно связанные с пользовательским интерфейсом. Соответственно не нужно выполнять все задачи на главном потоке и “тормозить” интерфейс. Задачи, которые требуют некоторого времени, целесообразно выносить в отдельный поток.
Multiple threads advantages
Преимущества многопоточности
- Быстрое выполнение Если мы будем выполнять разные задачи на разных потоках одновременно эти задачи могут быть выполнены быстрее чем последовательно выполнение этих задач.
- Отзывчивость Если на главном потоке вы выполняете только работу по обновлению интерфейса, то приложения остается отзывчивым и пользователь может даже не заметить, что ваше приложение выполняет в этот момент что-то “тяжелое” в другом потоке.
- Оптимизированное потребление ресурсов
Thread types in iOS
Типы потоков:
- Mach (or Kernel) threads Самый низкоуровневая реализация потоков. Все что о них нужно знать - вы никогда не будете с ними работать напрямую.
- POSIX threads C API позволяющий создавать потоки. Позволяет полностью контролировать работу с потоками манипулируя ими напрямую.
- NSThread Класс, который предоставляется Foundation и позволяет на высоком уровне создавать потоки. При их использовании Apple добавляет оптимизацию при использовании этих потоков. Не имеет API для отслеживания завершения задач.
Звучит классно, да? Больше ядер, больше потоков, осталось только распаралелить наше приложение как можно сильнее. Но не так быстро. Реальность такова что существует очень маленький шанс что нам придется манипулировать потоками напрямую. Apple предоставляет API для манипулирования потоками без вашего участия.
Grand Central Dispatch (GCD)
Цель GCD - поставить в очередь задачи, метод, либо блок - которые могут выполняться параллельно, в зависимости от доступности ресурсов. GCD управляет пулом потоков под капотом. GCD решает сам, в каком конкретном потоке будут выполняться ваши блоки кода.
Queue creation
dispatch_queue_t queue = dispatch_queue_create("com.rsschool.concurrency.demo", 0);
Когда вы создаете очередь, операционная система создает, или назначает для этой очереди один или несколько потоков. Если существующие потоки доступны, они могут быть переиспользованы. Функция создания очереди dispatch_queue_create принимает уникальный идентификатор и тип очереди. Типов очереди бывает два.
Queue types: Оба типа реализуют принцип FIFO.
- Serial (серийная/последовательная)
- С серийной очередью может быть связан только один поток.
- Только одна задача может выполняться в данный момент времени.
- Текущая задача должна быть выполнена перед тем, как начнется следующая. Т.е. все задачи выполняются последовательно.
dispatch_queue_t queue = dispatch_queue_create("com.rsschool.concurrency.demo", 0);
// or
dispatch_queue_t queue = dispatch_queue_create("com.rsschool.concurrency.demo",DISPATCH_QUEUE_SERIAL);
- Concurrent (конкурентная/параллельная)
- Могут использовать столько потоков сколько им может предоставить система.
- Невозможно предсказать, в каком порядке задачи, переданные в очередь, будут завершены.
dispatch_queue_t queue = dispatch_queue_create("com.rsschool.concurrency.demo", DISPATCH_QUEUE_CONCURRENT);
Concurrent queues priorities
В системе есть четыре глобальные очереди с разными приоритетами.
- High
- Default
- Low
- Background
Все глобальные очереди являются конкурентными.
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0);
Чтобы получить глобальную очередь воспользуемся функцией dispatch_get_global_queue которая в качестве первого параметра принимает значение приоритета для очереди, второй параметр должен всегда принимать значение 0, это зарезервированный параметр.
Quality of service (QoS)
Начиная с iOS 8 на замену обычным приоритетам, Apple предоставила другой способ приоритезации очередей. По большому счету как таковых отличий нет, просто абстрактные приоритеты приобрели более осмысленные названия.
- userInteractive Рекомендуется для задач, с которыми пользователь взаимодействует напрямую (расчет интерфейса, анимации). Если задача выполняется долго, то она может привести к тормозам в пользовательском интерфейсе.
- userInitiated Очередь должна быть использована, когда пользователь взаимодействует с интерфейсом и это действие допускает некую задачу, которая должна быть выполнена быстро, но выполнение может занять несколько секунд. Например: открытие документа, чтение базы данных и т.д
- utility Рекомендуется использовать для задач, которые требуют показ progress индикатора. Например вычисления, работа с сетью и.т.д. Система старается сохранить баланс между отзывчивостью, производительностью и сохранением энергии.
- background Рекомендуется использовать для задач, с которыми пользователь не взаимодействует. Например, загрузка данных в фоне, синхронизация с сервером и.т.д. С данным приоритетом система больше сконцентрирована на энергоэффективности, нежели на скорости выполнения задач.
- default (do not use it!) Промежуточное значение между userInitiated и utility.
- unspecified (do not use it!) Используется для поддержки legacy api
Получить глобальную очередь используя QoS можно точно так-же как и с обычным приоритетом, только вместо приоритета в качестве первого параметра передаем тип QoS:
dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0);
Adding tasks to queues
Задачи в очередь можно добавить двумя способами:
- Synchronously (синхронно) Приложение будет ожидать и блокировать текущую очередь до завершения выполнения, прежде чем перейти к следующей задаче
- Asynchronously (асинхронно) Текущая очередь запускает задачу и не дожидаясь ее завершения запускает следующую задачу.
!!!Важно!!! Асинхронно не значит конкуренто!!!
Люди часто путают понятия синхронности и асинхронности, серийности и конкурентности. Вы можете добавить асинхронно как на серийную, так и на конкурентную очередь. Синхронное или асинхронное добавление задачи определяет лишь то, будет ли вызывающий поток ждать завершение задачи или нет. Серийность или конкурентность означают, связан ли с данной очередью один поток или несколько.
Sync adding tasks to queues
Чтобы добавить задачу синхронно, нам нужно получить очередь, на которую мы хотим добавить задачу, вызвав функцию dispatch_sync, где первым параметром будет целевая очередь, а вторым - блок, представляющий из себя задачу.
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_sync(queue, ^{
// Some task
});
Async adding tasks to queues
Процесс аналогичен, только для добавление задачи используем функцию dispatch_async
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_async(queue, ^{
// Some task
});
Dispatch group
Иногда, вместо того чтобы добавлять одну задачу в очередь, нам необходимо отследить завершение группы задач. Эти задачи могут быть запущены в разное время, синхронно и асинхронно. Но нам нужно знать, когда они все завершатся. Для этих целей используется Dispatch group.
Dispatch group creation
Работа с группой начинается с ее создания:
dispatch_group_t group = dispatch_group_create();
Если вы создали группу и хотите отследить выполнение задач в этой группе, вы можете передать группу как первый параметр в функцию dispatch_group_async, вторым параметром указывается очередь, третьим параметром это блок который представляет собой саму задачу.
dispatch_group_t group = dispatch_group_create();
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_group_async(group, queue, ^{
// Some task
});
То, что мы передаем очередь в качестве параметра, говорит о том, что группа не привязана к какой-то конкретной очереди. Задачи в группе могут выполняться на разных очередях. Dispatch group предоставляет функцию dispatch_group_notify
, которую можно использовать чтобы узнать, что все задачи в группе были выполнены. В данную функцию в качестве первого параметра передается отслеживаемая группа, вторым параметром передается очередь, на которой должен быть выполнен блок, передаваемый третьим параметром.
dispatch_group_notify(group, dispatch_get_main_queue(), ^{
// Some completion task
});
Вызов функции является асинхронным, но если вам нужно заблокировать текущую очередь на время пока задачи в группе выполняются, можно использовать функцию:
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
Функция блокирует текущую очередь на время передаваемое в качестве второго параметра.
Wrapping async methods
DispatchQueue
знает, как работает DispatchGroup
и знает, как отслеживать завершение задач. Однако это касается синхронных блоков кода. Асинхронные вызовы не отслеживаются автоматически. Для того чтобы сообщить группе что асинхронный вызов начался и закончился, для этого можно использовать функции dispatch_group_enter
и dispatch_group_leave
. Вызов dispatch_group_enter
увеличивает счетчик выполняемых операций, а dispatch_group_leave
уменьшает этот счетчик.
dispatch_group_async(group, queue, ^{
// Some task
dispatch_group_enter(group);
dispatch_async(queue, ^{
// Perform some work
dispatch_group_leave(group);
});
});
Как только счетчик становится равным нулю, задачи в группе считаются завершенной. Количество вызовов dispatch_group_leave
должно быть равно количеству вызовов dispatch_group_enter
. Если вызвать dispatch_group_leave
без вызова dispatch_group_enter
случится крэш приложения.
Multithreading pitfalls
Несмотря на то, что многопоточность позволяет создавать отзывчивое и производительное приложение, она также может создать ряд проблем. Основные проблемы:
- Race condition
- Deadlock
- Priority inversion
Race condition
Одновременное чтение и запись данных из разных потоков.
Например, у нас есть значение, также имеются два потока. Каждый поток в единицу времени может выполнять одну операцию. В единицу времени поток А читает значение, на второй единице времени поток А делает сложение. В это время поток В считывает значение переменной, так как поток А сделал сложение, но еще не записал значение, поток 2 считал значение 10. На третьем промежутке времени поток А записывает результат сложения в переменную, а поток В одновременно делает операцию сложения значению которое прочитал ранее. На четвертом промежутке времени поток В записывает получившееся значение. В результате мы получаем неверное значение. Работа потоков зависит от множества факторов, поэтому каждый запуск приложения может давать разные результаты.
Deadlock
Взаимная блокировка потоков.
Например, у нас есть два ресурса и два потока. Для того чтобы избежать проблемы Race Condition, обычно потоки синхронизируют, устанавливая на ресурсы замки, т.е. закрывая к нему доступ из другого потока. Поток 1 получает доступ к ресурсу 1, и поток 2 получает доступ к ресурсу 2. В результате оба ресурса заблокированы. Для завершения работы потоку 1 нужно получить доступ к ресурсу 2, обратная ситуация у потока 2. Однако оба ресурса заблокированы для доступа и другого потока, в итоге оба потока вечно ждут освобождения ресурса.
Priority inversion
Скорость работы высоко-приоритетного потока равна или ниже скорости работы потока с низким приоритетом
У нас есть два потока, с низким и высоким приоритетом и общий ресурс, с которым работают два потока. Поток с низким приоритетом в процессе выполнения своей задачи получил доступ к ресурсу и работает с ним, в результате этот ресурс заблокирован. Он может работать долго и медленно, так как у потока низкий приоритет. Далее, на поток с высоким приоритетом поступает на выполнение задача, для выполнения этой задачи потоку с высоким приоритетом также требуется доступ к общему ресурсу. Однако ресурс занят, в результате поток с высоким приоритетом будет ждать пока поток с низким приоритетом освободит ресурс.
Synchronization tools
Для того чтобы избежать ситуации, когда несколько потоков одновременно и неожиданно для вас отменяют какие-то данные, можно использовать средства синхронизации. Основные из них:
- Barriers
- Semaphore
- Mutex
- Recursive mutex
Barriers
Например, у нас есть поток задач, например задача чтения и задача записи. Эти задачи вы асинхронно добавляете на конкурентную очередь, т.е. все задачи могут выполняться параллельно. Если для задач чтения это нормально, то для задач записи совместно с задачи чтения нет. Т.е типичная проблема Race Condition. Нам нужно сделать так, чтобы при выполнении задачи записи, никакие другие задачи не выполнялись.
Мы можем сделать задачу записи т.н. барьером. Когда выполняется барьер, никакие другие задачи выполняются не могут. Если при добавлении барьера какие-то задачи были в процессе выполнения, система завершит эти задачи, и только после этого стартует барьер. Никакие новые задачи не начнут свою работу пока не завершится барьер. Т.е. можно сказать, что наша конкурентная очередь на время выполнения стала серийной.
Для добавления барьера используем функцию dispatch_barrier_async
.
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_barrier_async(queue, ^{ // Some barrier task
});
Semaphore
Семафор позволяет установить, какое количество потоков могут одновременно обращаться к ресурсу. Например, у нас есть ресурс, и мы устанавливаем ему максимальное количество потоков для одновременного доступа равное 2. После того как первый поток обратился к ресурсу, счетчик уменьшается на единицу, сейчас может обратится еще один поток. Поток номер 2 обращается к этому ресурсу и счетчик семафора стал равен нулю. Теперь третий поток не может обратится к этому ресурсу пока ресурс не освободят потоки, которые уже его заняли.
Для создания семафора используем функцию dispatch_semaphore_create
, в качестве параметра передаем количество потоков, которые одновременно могут получить доступ к ресурсу. Добавляем задачу в очередь, используя, как обычно, dispatch_async
, в начале задачи вызываем функцию dispatch_semaphore_wait
, в которую передаем семафор и время ожидания. Данная функция блокирует очередь на указанное время если количество одновременных разрешенных поток в очереди достигнуто. В конце функции вызываем функцию dispatch_semaphore_signal
, в которую передаем семафор. С помощью данной функции мы сигналиризуем что мы закончили работу с ресурсом и поток освобождает эту очередь.
dispatch_semaphore_t semaphore = dispatch_semaphore_create(2);
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_async(queue, ^{
dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER);
// Some task with shared resource
dispatch_semaphore_signal(semaphore);
});
Mutex
Mutex — это частный случай семафора. Это семафор, при котором только один поток может занимать ресурс в единицу времени.
Создать мьютекс можно разными способами, можно использовать POSIX мьютекс, можно использовать семафор со значение 1. В данном примере используется класс NSLock
.
NSLock *theLock = [NSLock new];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_async(queue, ^{
if ([theLock tryLock]) {
// Some task
[theLock unlock];
}
});
Создаем объект NSLock. Для блокировки ресурса в начале задачи вызывает tryLock, для разблокировки ресурса в конце задачи вызываем unlock.
Recursive mutex
Давайте рассмотрим пример с обычным мьютексом. Поток 1 пытается обратится к ресурсу, который он уже занял, мьютекс разрешает обращение только один раз. Например, у нас есть рекурсивный вызов какого-то метода, внутри которого мы обращаемся к одному и тому же ресурсу, эта попытка приведет к deadlock. Чтобы разрешить эту проблему можно использовать recursive mutex. Recursive mutex это тот же мьютекс, который разрешает одному и тому же потоку обращатся к ресурсу несколько раз.
Для использования рекурсивного мьютекса используем класс NSRecursiveLock
, подход по его использованию точно такой же, как и при использовании NSLock
.
NSRecursiveLock *theRecursiveLock = [NSRecursiveLock new];
dispatch_queue_t queue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0);
dispatch_async(queue, ^{
if ([theRecursiveLock tryLock]) {
// Some task
[theRecursiveLock unlock];
}
});