Программирование
Основы параллельного программирования в Swift: часть 2
Существует так много возможностей для параллельного программирования, и этот пост раскрыл вам только самые общие положения. В то же время, существует множество механизмов и много примеров для рассмотрения.
Параметры для контроля конкурентности
Мы рассмотрели все элементы, предоставляемые операционной системой, которые могут использоваться для создания конкурентных программ. Но, как упоминалось в предыдущей статье, они могут создавать множество проблем. Самая очевидная проблема (и в то же время наиболее сложная в определении) — несколько конкурентных задач, обращающихся к одному и тому же ресурсу. Если нет механизма для обработки этих доступов, это может привести к тому, что одна задача запишет одно значение, а другая другое значение. Когда первая задача попытается считать данные, она будет ожидать, что они будут теми, которые записаны в первый раз – однако значение будет уже изменено. Таким образом, по умолчанию используется блокировка доступа к ресурсу и предотвращение доступа к ним других тредов, если оно заблокировано.
Приоритетная инверсия (Priority Inversion)
Чтобы понять различные механизмы блокировки, нам также необходимо понять приоритеты тредов. Как вы можете догадаться, треды могут выполняться с высоким или низким приоритетами — высокие раньше, а низкие позже. Типичным примером является процесс с низким приоритетом, получающий ресурс, который требуется высокоприоритетному процессу, а затем он вытесняется процессом среднего приоритета, поэтому процесс высокоприоритетного процесса блокируется на ресурсе, в то время как средний приоритет фактически выполняется, даже обладая более низким приоритеом. Это называется Приоритетной инверсией (Priority Inversion) и может привести к тому, что тред с более высоким приоритетом будет «голодать до смерти», так как он никогда не будет выполнен. Поэтому, определенно, надо избегать этого.
Представьте себе наличие двух высокоприоритетных тредов (1 и 2) и тред с низким приоритетом (3). Если 3 блокирует ресурс, к которому 1 хочет получить доступ, 1 придется ждать. Поскольку 2 имеет более высокий приоритет, вся его работа будет выполнена в первую очередь. В случаях, когда процесс не заканчивается, тред 3 не будет выполнен, и, таким образом, поток 1 будет заблокирован до бесконечности.
Наследование приоритета (Priority Inheritance)
Решение для Priority Inversion – это наследование приоритета (Priority Inheritance). В этом случае тред 1 отдаст приоритет треду 3, если он заблокирован. Таким образом, тред 3 и 2 имеет высокий приоритет и оба выполняются (в зависимости от ОС). Как только 3 разблокирует ресурс, высокий приоритет возвращается к треду 1, и он будет продолжать свою первоначальную работу.
Atomic
Atomic содержит ту же идею, что и транзакция в контексте базы данных. Вы наверняка захотите написать значение сразу, как одну операцию. Приложения, скомпилированные для 32 бит, могут иметь довольно странное поведение при использовании int64_t и не иметь его в atomic. Почему? Давайте подробно рассмотрим, что происходит:
int64_t x = 0
Thread1: x = 0xFFFF
Thread2: x = 0xEEDD
Наличие неатомной операции может привести к тому, что первый поток начнет записывать в x. Но поскольку мы работаем в 32-разрядной операционной системой, мы должны разделить значение, которое мы записываем в x, на две очереди 0xFF.
Когда в то же время Thread2 попытается записать значение в x, может произойти планирование операций в следующем порядке:
Thread1: part1 Thread2: part1 Thread2: part2 Thread1: part2
В итоге мы получим:
x == 0xEEFF
который не равен ни 0xFFFF, ни 0xEEDD.
Используя atomic, мы создаем единую транзакцию, которая приведет к следующему поведению:
Thread1: part1 Thread1: part2 Thread2: part1 Thread2: part2
В результате x содержит значение, установленное Thread2. Сам Swift не работает с atomic. В Swift Evolution предлагается добавить его, но на данный момент вам придется реализовать его самостоятельно.
Lock
Lock — это простой способ предотвратить доступ нескольких тредов к ресурсу. Сначала тред проверяет, может ли он войти в защищенную часть или нет. Если он может войти, он блокирует защищенную часть и продолжает работу. Как только он выйдет, то разблокирует его. Если при входе тред встречает заблокированную часть, то он будет ждать. Обычно это делается при помощи сна и регулярного пробуждения, что позволяет проверить, заблокирован все еще ресурс, или нет.
В iOS это можно сделать с помощью NSLock. Но имейте в виду, что при разблокировке тред должен быть тем же самым, что и блокировал.
https://gist.github.com/olbrichj/78a52ba96ceb8050c3cd6585f2d1cda8#file-lock-swift
Существуют также другие типы блокировок, такие как рекурсивные блокировки (recursive locks). С их помощью тред может блокировать ресурс несколько раз и должен отпускать его так часто, как он заблокирован. В течение всего этого времени исключается работа других тредов.
Другим типом является блокировка чтения-записи (read-write lock). Это полезно для больших приложений, когда многие треды читают ресурсы и иногда пишут. Пока тред не пишет в ресурс, все треды могут получить к нему доступ. Как только тред хочет писать, он блокирует ресурс для всех тредов. Они не могут читать, пока блокировка не будет отпущена.
На уровне процессов существует также распределенная блокировка (distributed lock). Разница заключается в том, что в случае блокирования процесса он просто сообщает об этом процессу, и процесс может решить, как справиться с этой ситуацией.
Циклическая блокировка (Spinlock)
Блокировка состоит из нескольких операций, которые «усыпляют» треды, пока они снова не включатся. Это приводит к изменениям контекста для CPU (переключением регистров и так далее для сохранения состояния тредов). Эти изменения требуют большого времени вычислений. Если у вас действительно небольшие операции, которые вы хотите защитить, вы можете использовать спинлоки. Основная идея в том, потоки опрашивают блокировку в процессе ожидания. Для этого требуется больше ресурсов, чем просто в спящих тредах. В то же время они наблюдают за изменением контекста и, таким образом, быстрее работают при небольших операциях.
Это звучит неплохо в теории, но в iOS всегда все по другому. iOS имеет концепцию Quality of Service (QoS). С QoS может случиться так, что треды с низким приоритетом не будут выполняться вообще. Наличие спинлока на таком треде и более приоритетный тред, пытающийся получить доступ к его ресурсу, приведут к тому, что более приоритетный тред будет «голодать» по нижнего треда, не разблокируя требуемый ресурс и блокируя самого себя. Как результат, спинлоки являются незаконными на iOS.
Mutex
Mutex подобен замку. Разница в том, что это могут быть разные процессы, а не только треды. К сожалению, вам придется реализовать свой собственный Mutex, поскольку Swift его не поддерживает. Это можно сделать с помощью pthread_mutex от C.
https://gist.github.com/olbrichj/c5c31ba24ba7e21a6c4bd81716d69094#file-mutex-swift
Semaphore
Семафор — это структура данных, обеспечивающая взаимную эксклюзивность в синхронизации тредов. Он состоит из счетчика, очереди FIFO и методов wait() и signal().
Каждый раз, когда тред хочет войти в защищенную часть, он вызывает wait () на семафоре. Семафор уменьшит счетчик, и пока он не равен 0, треду будет разрешено работать. В противном случае он сохранит тред в очереди. Всякий раз, когда тред выходит из защищенной части, он будет вызывать signal(), чтобы информировать семафор. Семафор сначала проверяет, есть ли очередь ожидания. Если есть, то из нее вызывается тред, который сможет продолжить работу. Если нет, он снова увеличит счетчик.
В iOS мы можем использовать DispatchSemaphores для реализации такого поведения. Предпочтительно использовать именно их, чем семафоры по умолчанию, так как только они работают на уровне ядра, если это действительно необходимо. В противном случае он просто работает намного быстрее.
https://gist.github.com/olbrichj/ad19cc14c47c586bfbe3aeb50ccb52e3#file-semaphore-swift
Можно рассматривать двоичный семафор (семафор со значением счетчика 0 или 1) как Mutex. Но в то время как Mutex связан с механизмом блокировки, семафор является сигнальным механизмом. Это особо не помогает, так где же разница?
Механизм блокировки — это защита и управление доступом к ресурсу. Таким образом, он предотвращает одновременное обращение нескольких тредов к одному ресурсу. Сигнальная система больше напоминает вызов «Эй, я закончил! Продолжай!». Например, если вы слушаете музыку на своем мобильном телефоне и вам звонят, общий ресурс (наушники) будет задействован на телефоне. Когда звонок закончится, система проинформирует ваш mp3-плеер при помощи сигнала для продолжения. Это тот случай, когда следует предпочесть семафор мьютексу.
Так в чем же фишка? Представьте, что у вас есть тред с низким приоритетом (1), который находится в защищенной области, и у вас есть тред с высоким приоритетом (2), который просто вызывает wait() на семафоре. 2 спит и ждет, когда семафор разбудит его. Теперь у нас есть тред (3), который имеет более высокий приоритет, чем 1. Этот тред в сочетании с QoS блокирует сигнал от 1 к семафору и, таким образом, голодают оба других потока. Таким образом, семафоры в iOS не имеют приоритетного наследования (Priority Inheritance).
Synchronized
В Objective-C существует также опция использования @synchronized. Это простой способ создания мьютекса. Поскольку у Swift его нет, мы должны копнуть глубже. Вы можете познакомиться с @synchronized, вызвав objc_sync_enter.
Поскольку я неоднократно видел этот вопрос в интернете, давайте ответим на него тоже. Насколько я знаю, это не конфиденциальный метод, поэтому его использование не исключает вас из App Store.
Concurrency Queues Dispatching
Поскольку в Swift нет мьютекса, и синхронизация также была удалена, он стал золотым стандартом для разработчиков Swift, которые используют DispatchQueues. Если вы используете его синхронно, то получите такое же поведение, как и мьютекс, так как все действия помещаются в одну очередь. Это предотвращает одновременное выполнение.
Недостатком является большое количество времени, поскольку нужно много перемещений и изменений контекста. Это не имеет значения, если ваше приложение не нуждается в высокой вычислительной мощности, но в случае, если у вас возникнут потери фреймов, вы можете рассмотреть другое решение (например, Mutex).
Dispatch Barriers
Если вы используете GCD, у вас есть больше возможностей для синхронизации вашего кода. Один из них — Dispatch Barriers. С их помощью мы можем создавать блоки защищенных частей, которые должны выполняться вместе. Мы также можем контролировать, в каком порядке выполняется асинхронный код. Это звучит странно, но представьте, что вам предстоит выполнить долгую задачу, которую можно разделить на части. Эти части необходимо запускать по порядку, но их можно снова разделить на более мелкие куски. Эти меньшие куски части могут выполняться асинхронно. Dispatch Barriers теперь можно использоваться для синхронизации больших частей, в то время как отдельные куски могут работать сами по себе.
Trampoline
Trampoline на самом деле не является механизмом, предоставляемым ОС. Это шаблон, который вы можете использовать, чтобы гарантировать, что метод вызывается в правильном треде. Идея проста: метод проверяет в начале, находится ли он в правильном треде, иначе он вызывает себя в правильном треде и возвращается. Иногда вам будет необходимо использовать вышеуказанные блокирующие механизмы для реализации процедуры ожидания. Это имеет значение только в том случае, если вызванный метод возвращает значение. В противном случае вы можете просто вернуться.
https://gist.github.com/olbrichj/b0ba51fa274df114c98f4744aa813c38#file-trampoline-swift
Не используйте этот шаблон слишком часто. Это заманчиво, но в то же время это путает ваших коллег. Они могут не понять, почему вы меняете треды повсюду. В какой-то момент это захламляет код и тратит ваше время.
Заключение
Вау, это был довольно тяжелый пост. Существует так много возможностей для параллельного программирования, и этот пост раскрыл вам только самые общие положения. В то же время, существует множество механизмов и много примеров для рассмотрения. Я, наверное, раздражаю всех на работе всякий раз, когда говорю о тредах, но они важны, и медленно, но верно, но мои коллеги начинают соглашаться. Как раз сегодня я должен был исправить ошибку, в которой операции получали асинхронный доступ к массиву, а Swift, как мы узнали, не поддерживает атомарные операции. Угадайте, что? Это заканчивалось сбоем и падением. Возможно, этого не произошло бы, если бы все мы знали больше о конкурентности, но, честно говоря, я тоже этого не заметил.
Изучить свои инструменты — лучший совет, который я могу вам дать. Надеюсь мой пост дал вам отправную точку для изучения параллелизма, а также предоставил способ контролировать хаос, который проявится, как только вы погрузитесь глубже. Удачи!