Многопоточное программирование в среде Cocoa, Mac OS, iOS

Синхронизация

Наличие нескольких потоков в приложении, открывает потенциальные вопросы относительно безопасного доступа к ресурсам из нескольких выполняющихся потоков. Два потока, изменяющие один и тот же ресурс могут мешать друг другу. Например, один поток может переписать чужие изменения или поместить приложение в неизвестное и, возможно, недопустимое состояние. Если вам повезет, поврежденные ресурсы могут привести к очевидным проблемам с производительностью или сбоям, которые относительно легко отследить и исправить. Если вам не повезло, повреждения могут привести к тонким ошибкам, которые не проявляются довольно долго, или ошибки могут потребовать значительного переписания кода вашего предположения.

Наилучшим решением будет исключение взаимодействия потоков между собой и общими данными, но это недостижимо, так как иначе отпадает вообще надобность в этих потоках. Все, что можно сделать - это минимизировать общение между потоками и использовать механизмы синхронизации, для исключения повреждения данных.

Инструменты синхронизации

Atomic операции

Atomic операция - простая форма синхронизации, которая работает на простых типах данных. Преимуществом атомарных операций является то, что они не блокируют конкурирующие потоки. Для простых операций, таких как увеличение переменной счетчика, это может привести к гораздо более высокой производительности, чем приминение замка.

OS X и iOS включают в себя многочисленные операции для выполнения основных математических и логических операций над 32-разрядными и 64-разрядными значениями. Среди этих операций являются atomic версии compare-and-swap, test-and-set и test-and-clear операций. Список поддерживаемых атомарных операций см. в заголовочном файле /usr/include/libkern/OSAtomic.h.

Список атомарных операций:

OSAtomicAdd32, OSAtomicAdd32Barrier, OSAtomicIncrement32, OSAtomicIncrement32Barrier,
OSAtomicDecrement32, OSAtomicDecrement32Barrier, OSAtomicOr32, OSAtomicOr32Barrier, OSAtomicOr32Orig,
OSAtomicOr32OrigBarrier, OSAtomicAnd32, OSAtomicAnd32Barrier, OSAtomicAnd32Orig,
OSAtomicAnd32OrigBarrier, OSAtomicXor32, OSAtomicXor32Barrier, OSAtomicXor32Orig,
OSAtomicXor32OrigBarrier, OSAtomicAdd64, OSAtomicAdd64Barrier, OSAtomicIncrement64,
OSAtomicIncrement64Barrier, OSAtomicDecrement64, OSAtomicDecrement64Barrier, OSAtomicCompareAndSwapInt,
OSAtomicCompareAndSwapIntBarrier, OSAtomicCompareAndSwapLong, OSAtomicCompareAndSwapLongBarrier,
OSAtomicCompareAndSwapPtr, OSAtomicCompareAndSwapPtrBarrier, OSAtomicCompareAndSwap32,
OSAtomicCompareAndSwap32Barrier, OSAtomicCompareAndSwap64, OSAtomicCompareAndSwap64Barrier,
OSAtomicTestAndSet, OSAtomicTestAndSetBarrier, OSAtomicTestAndClear, OSAtomicTestAndClearBarrier,
OSSpinLockTry, OSSpinLockLock, OSSpinLockUnlock, OSAtomicEnqueue, OSAtomicDequeue

Барьеры памяти и летучие (volatile) переменные

Для достижения оптимальной производительности, компиляторы часто в процессе сборки на уровне инструкций держат конвейер команд для процессора как можно более полным. В рамках этой оптимизации, компилятор может изменить порядок инструкций с доступом к основной памяти, когда он думает, что это не будет генерировать неправильные данные. К сожалению, компилятор не всегда может обнаружить всю память, зависящую от операции. Если окажется, что отдельные переменные на самом деле влияют друг на друга, оптимизация компилятора может обновить эти переменные в неправильном порядке, создавая потенциально неверные результаты.

Барьер памяти является одним из видов неблокирующего средства синхронизации используемого для обеспечения того, чтобы операции с памятью происходили в правильном порядке. Барьер памяти действует как забор, заставляя процессор завершить любую операцию загрузки и сохранения расположенной в передней части барьера до того, как ему разрешается выполнять операции загрузки и сохранения помещенные после барьера. Барьеры памяти, как правило, используются для обеспечения того, чтобы операции с памятью одним потоком (но видной другому) всегда происходили в ожидаемом порядке. Отсутствие барьера памяти в такой ситуации могло бы позволить другим потокам, увидеть невозможное результаты. Для реализации барьера памяти, вы просто вызываете OSMemoryBarrier функцию в соответствующей точке в коде.

Для летучих переменных применяется другой тип памяти, ограничивающий отдельные переменные. Компилятор часто оптимизирует код на загрузку значения переменных в регистры. Для локальных переменных, это обычно не проблема. Если переменная видна из другого потока, такая оптимизация может помешать другим потокам, которые не заметят в ней никаких изменений. Применение ключевого слова volatile, заставляет компилятор загружать переменную из памяти каждый раз при ее использовании. Вы можете объявить переменную как volatile, если ее значение может быть изменено в любое время с помощью внешнего источника, так, что компилятор не сможет этого обнаружить.

Поскольку и барьеры памяти и летучие переменные уменьшают количество оптимизаций компилятора, они должны использоваться экономно и только там, где необходимы для обеспечения корректности.

Замки

Замки являются одним из наиболее часто используемых инструментов синхронизации. Вы можете использовать замки для защиты критической секции вашего кода, который является сегментом кода, к которому разрешен доступ только одному потоку одновременно. Например, критическая секция может манипулировать определенной структурой данных или использовать некоторый ресурс, который поддерживает не более одного клиента за раз. Размещая замок в этом разделе, вы исключите другие потоки, от внесения изменений, которые могут повлиять на правильность кода.

В таблице ниже перечислены некоторые замки, которые обычно используются программистами. OS X и iOS предоставляют реализацию для большинства из этих типов блокировок, но не для всех. Для неподдерживаемых типов замков, в колонке с описанием приведены причины, почему эти замки не осуществляются непосредственно на платформе.

ЗамокОписание
MutexВзаимоисключающая (или мьютекс) блокировка действует как защитный барьер вокруг ресурса. Мьютекс является одним из видов семафора, который предоставляет доступ одновременно только одному потоку. Если мьютекс используется и другой поток пытается получить его, что поток блокируется до тех пор пока мьютекс не освободится от своего первоначального владельца. Если несколько потоков соперничают за одни и те же мьютексы, только одному будет разрешен к нему доступ.
Recursive lockРекурсивные замки являются вариантом блокировки мьютекса. Рекурсивные замки позволяют одному потоку осуществить блокировку несколько раз, прежде чем отпустить ее. Другие потоки остаются заблокированными, пока владелец замка не снимет блокировку столько же раз, сколько он ее поставил. Рекурсивные замки используются, в первую очередь, во время рекурсивной итерации, но также могут быть использованы в тех случаях, когда нескольким методам необходимо поставить замок отдельно.
Read-write lockЗамки чтения-записи также называют общим эксклюзивным замком. Этот тип блокировки обычно используется в крупномасштабных операциях и может значительно улучшить производительность, если данные защищаемой конструкции считываются часто и изменяются только время от времени. Во время нормальной работы несколько читатели могут получить доступ к структуре данных одновременно. Когда поток захочет записать в структуру, он заблокируется, пока все читатели не снимут блокировку, после чего он ставит блокировку и обновляет структуру. Система поддерживает замки чтения-записи только с использованием POSIX потоков.
Distributed lockРаспределенный замок обеспечивает взаимоисключающий доступ на уровне процесса. В отличие от истинного мьютекса, распределенный замок не блокирует процесс или предотвращает его запуск. Он просто сообщает, когда блокировка установлена, и позволяет процессу решить, как поступить.
Spin lockСпин-замок переходит в состояние блокировки, когда условие блокировки становится истинным. Спин замки чаще всего используются на многопроцессорных системах, где время ожидания для блокировки мало. Система не обеспечивает реализации спин-замков, исходя из их избирательного характера, но вы можете легко реализовать их в конкретной ситуации на уровне ядра.
Double-checked lockЗамок с двойной проверкой является попыткой сократить издержки взятия замка, проверяя критерии замка до его принятия. Поскольку замки с двойной проверкой являются потенциально опасными, система не обеспечивает явную поддержку для них и их использование не рекомендуется.

Примечание: Большинство типов замков также включают барьер памяти, чтобы убедиться, что любая предыдущая команда загрузки и сохранения будет завершена перед входом в критическую секцию.

Условия

Условие - другой тип семафора, который позволяет потокам, сигнализировать друг другу при определенных условиях установленных в истину. Условия, как правило, используются для обозначения наличия ресурса или, чтобы обеспечить выполнение задач в определенном порядке. Когда поток проверяет условие, он блокируется, если это условие уже установлено в истину. Он остается заблокированным, пока другой поток не произведет явного изменения и сигнала условия. Разница между условием и замком мьютекса является то, что нескольким потокам может быть разрешен доступ к условию, в одно и то же время. Условие является скорее привратником, который позволяет различным потокам, пройти через ворота в зависимости от некоторых заданных критериев.

Одним из способов использования условий, может быть управление пулом в ожидании событий. Очереди событий будут использовать переменную условия для ожидания сигнала потока, когда возникнут события в очередь. Если одно событие приходит, очередь будет сигнализировать условию надлежащим образом. Если поток уже ждал, он проснется после чего он будет тянуть события из очереди и обрабатывать их. Если два события пришли на очереди примерно в одно и то же время, очередь будет сигнализировать состояние два раза, чтобы разбудить два потока.

Выполнение селектора процедуры

Cocoa приложения имеют удобный способ доставки сообщений синхронным образом, в одном потоке. Класс NSObject объявляет методы для осуществления выбора одного из активных потоков приложения. Эти методы позволяют вашим потокам доставлять сообщения асинхронно с гарантией, что они будут выполняться синхронно на целевом потоке. Например, вы можете использовать выполнение селектора сообщения для доставки результатов распределенных вычислений в главном потоке приложения или назначенным координатором потоке. Каждый запрос на выполнение селектора находится в очереди на цикле выполнения целевого потока и запросы обрабатываются последовательно в том порядке, в котором они были получены.

Синхронизация. Затраты и производительность.

Синхронизация помогает обеспечить правильность кода, но делает это в ущерб производительности. Использование средств синхронизации вводит задержки, даже в неоспоримых случаях. Замки и атомарные операции, как правило, предполагают использование барьеров памяти и уровня ядра для обеспечения синхронизации кода защищенным надлежащим образом. А если есть конкуренция за блокировку, ваши потоки могли бы блокировать и испытать еще большие задержки.

В таблице ниже перечислены некоторые приблизительные расходы, связанные с мьютексами и атомарными операциями в неоспоримом случае. Эти измерения представляют среднее время принятое в течение нескольких тысяч замеров. Как и создание потоков, время на создание мьютекса (даже в бесспорном случае) может сильно варьироваться в зависимости от загрузки процессора, скорости работы компьютера, и количества доступной памяти системе и программам.

СпособОриентировочная стоимостьЗамечание
Mutex, время захватаОколо 0.2 микросекундЭто время захвата в замок в бесспорном случае. Если блокировка удерживается другим потоком, время сбора данных может быть намного больше. Эти цифры были определены на основе анализа средних и медианных значений образующихся при мьютекс на основе Intel iMac с 2-ГГц Core Duo процессором и 1 ГБ оперативной памяти под управлением OS X v10.5.
Atomic compare-and-swapОколо 0.05 микросекундЭти цифры были определены на основе анализа средних и медианных значений образующихся при мьютекс на основе Intel iMac с 2-ГГц Core Duo процессором и 1 ГБ оперативной памяти под управлением OS X v10.5.

При разработке параллельных задач, правильность всегда самый важный фактор, но вы должны также рассмотреть фактор производительности. Код, который выполняется правильно на нескольких потоках, но медленнее, чем тот же код работает на одном потоке, вряд ли будет лучшим решением.

Потокобезопасность и сигналы

Когда дело доходит до многопоточных приложений, ничто не вызывает больше страха и замешательства, чем проблема обработки сигналов. Сигналы низкого уровня - BSD механизм, который может быть использован для доставки информации к процессу или управлять им в некотором роде. Некоторые программы используют сигналы для обнаружения определенных событий, таких, как прекращение дочернего процесса. Система использует сигналы для прекращения беглых процессов и других видов общения информацией.

Проблема с сигналами не то, что они делают, а их поведение, когда приложение имеет несколько потоков. В однопоточных приложениях, все обработчики сигналов работают на основном потоке. В многопоточных приложениях, сигналы, которые не привязаны к конкретной ошибке аппаратных средств (таких, как незаконная инструкция) поставляются в зависимости от того, какой поток, работает в данный момент. Если работают несколько потоков одновременно, сигнал подается на тот, который системе предстоит выбрать. Иными словами, сигналы могут быть доставлены в любой поток вашего приложения.

Первое правило для реализации обработчиков сигналов в приложениях, избежать предположений о том какой поток обработчик сигнала. Если конкретный поток хочет, справиться с данным сигналом, необходимо выработать какой-то способ, указывающий, на поток при поступлении сигнала. Вы не можете считать, что просто установка обработчика сигнала из этого потока приведет к доставке сигнала в тот же поток.

 
 
homeЗаметили ошибкукарта сайта 
   Made on a Mac