Перевод статьи Мэтта Галлагера.

В этой статье речь пойдёт об отсутствии потокового выполнения (threading) и инструментов синхронизации потоков в Swift. Мы обсудим предложение о внедрении «многопоточности» (concurrency) в Swift и то, как до появления этой возможности потоковое выполнение в Swift будет подразумевать использование традиционных мьютексов и общего изменяемого состояния (shared mutable state).

Использовать мьютекс в Swift не особенно сложно, но на этом фоне хотелось бы выделить тонкие нюансы производительности в Swift — динамическое выделение памяти во время захвата замыканиями. Мы хотим, чтобы наш мьютекс был быстрым, но передача замыкания для исполнения внутри мьютекса может снизить производительность в 10 раз из-за дополнительных расходов памяти. Давайте рассмотрим несколько способов решения данной проблемы.

Отсутствие потокового выполнения (threading) в Swift


Когда Swift был впервые анонсирован в июне 2014 года, у него было два очевидных упущения:

  • обработка ошибок,
  • потоковое выполнение и синхронизация потоков.

Обработка ошибок была реализована в Swift 2 и являлась одной из ключевых особенностей этого релиза.

А потоковое выполнение по большей части всё ещё игнорируется Swift. Вместо языковых средств обеспечения потокового выполнения, Swift включает в себя модуль Dispatch (libdispatch, aka Grand Central Dispatch) на всех платформах, и неявно предлагает нам использовать Dispatch вместо того, чтобы ожидать помощи от языка.

Делегирование ответственности библиотеке, идущей в поставке, кажется особенно странным по сравнению с другими современными языками, такими как Go и Rust, в которых примитивы (primitives) потокового выполнения и строгая потокобезопасность (соответственно) стали основными свойствами своих языков. Даже свойства @synchronized и atomic в Objective-C кажутся щедрым предложением по сравнению с отсутствием чего-либо подобного в Swift.

Какова причина такого очевидного упущения в этом языке?

Будущая «многопоточность» в Swift


Ответ вкратце рассмотрен в предложении о внедрении «многопоточности» в репозитории Swift.

Я упоминаю это предложение, чтобы подчеркнуть, что разработчики Swift в будущем хотели бы сделать что-то в отношении многопоточности, но прошу иметь в виду, что говорит разработчик Swift Джо Грофф: «этот документ — всего лишь предложение, а не официальное заявление о направлении развития».

Это предложение появилось с целью описать ситуацию, когда, например, в Cyclone или Rust ссылки не могут быть разделены между потоками выполнения. Независимо от того, похож ли результат на эти языки, кажется, в Swift планируется устранить общую память потоков, за исключением типов, реализующих Copyable и передаваемых через строго управляемые каналы (в предложении называемые Stream’ами). Также появится разновидность сопрограммы (coroutine) (в предложении называемая Task’ами), которая будет вести себя как асинхронные блоки отправки (asynchronous dispatch blocks), которые можно ставить на паузу/возобновлять.

Далее в предложении утверждается, что в библиотеках поверх примитивов Stream / Task / Copyable могут быть реализованы самые распространенные языковые средства потокового выполнения (по аналогии с chan в Go, async / await в .NET, actor в Erlang).

Звучит хорошо, но когда ожидать многопоточность в Swift? В Swift 4? В Swift 5? Не скоро.

Таким образом, сейчас нам это не помогает, а скорее даже мешает.

Влияние будущих функций на текущую библиотеку


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

Можно найти этому явные свидетельства, читая список рассылок Swift-Evolution:

  • Ссылки на объекты (как сильные, так и слабые) не определены «при наличии в переменной гонок данных read/write, write/write или anything/destroy». Здесь нет намерения изменить такое поведение или предложить встроенный «атомный» подход, поскольку это «одно из немногих неопределенных правил поведения, которые мы принимаем». Возможное «исправление» этого неопределенного поведения будет новой моделью многопоточности.
  • Получившиеся типы (или другие средства перебрасывания (throw), отличные от функциональных интерфейсов) были бы полезны для многочисленных алгоритмов «в стиле передачи продолжений» (continuation passing style) и были бы тщательно обсуждены, но в конечном счете они будут игнорироваться до тех пор, пока Swift не «обеспечит надлежащую языковую поддержку [для сопрограмм или асинхронных промисов]» как часть изменений в сфере многопоточности.

Пытаемся найти быстрый мьютекс мьютекса общего назначения


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

Стандартный совет по мьютексам в Swift: используйте DispatchQueue и вызывайте применительно к нему sync.

Мне нравится libdispatch, но в большинстве случаев использование DispatchQueue.sync в качестве мьютекса — это самый медленный способ устранения проблемы, более чем на порядок медленнее других решений из-за неизбежных издержек на захват замыканием, которое передаётся функции sync. Это происходит из-за того, что замыкание мьютекса должно захватывать окружающее состояние (в частности, захватывать ссылку на защищенный ресурс), и этот захват подразумевает использование контекста замыкания, находящегося в динамической памяти. Пока Swift не получит возможность оптимизировать изолирующие (non-escaping) замыкания в стеке, единственный способ избежать дополнительных расходов на помещение замыканий в динамическую память — убедиться, что они встроены. К сожалению, это невозможно в границах модуля, таких как границы модуля Dispatch. Это делает DispatchQueue.sync излишне медленным мьютексом в Swift.

Следующим по частоте предлагаемым вариантом можно считать objc_sync_enter / objc_sync_exit. Будучи в 2-3 раза быстрее, чем libdispatch, он всё ещё немного медленнее идеала (потому что он всегда является мьютексом повторного входа (re-entrant)), и зависит от рантайма Objective-C (поэтому ограничен платформой Apple).

Самый быстрый вариант для мьютекса — OSSpinLock — более чем в 20 раз быстрее, чем dispatch_sync. Помимо общих ограничений спин-блокировки (высокая нагрузка на ЦП, если несколько потоков пытаются войти одновременно), в iOS есть серьёзные проблемы, которые делают его полностью непригодным для использования на этой платформе. Соответственно, он может быть использован только на Mac.

Если вы нацелены на iOS 10 или macOS 10.12, или на нечто более новое, то можете использовать os_unfair_lock_t. Это решение по производительности должно быть близко к OSSpinLock, при этом лишено его самых серьёзных проблем. Тем не менее, эта блокировка не является FIFO. Вместо этого мьютекс предоставляется произвольному ожидающему (следовательно, «нечестно» (unfiar)). Вам нужно решить, является ли это проблемой для вашей программы, хотя в целом означает, что этот вариант не должен быть вашим первым выбором для мьютекса общего назначения.

Все эти проблемы делают pthread_mutex_lock / pthread_mutex_unlock единственным разумным, производительным и портируемым вариантом.

Мьютексы и подводные камни захвата замыканием


Как и большинство вещей в чистом языке C, pthread_mutex_t имеет довольно неуклюжий интерфейс, что помогает использовать обертку Swift (особенно для построения и автоматической очистки). Кроме того, полезно иметь “scoped”-мьютекс — который принимает функцию и выполняет её внутри мьютекса, обеспечивая сбалансированную «блокировку» и «разблокировку» с любой стороны функции.

Назовём нашу обертку PThreadMutex. Ниже приведена реализация простой функции scoped-мьютекса в этой обёртке:

public func sync<R>(execute: () -> R) -> R {
 pthread_mutex_lock(&m)
 defer { pthread_mutex_unlock(&m) }
 return execute()
}

Должно работать быстро, но это не так. Вы видите, почему?

Проблема возникает из-за реализации повторно используемых функций, вроде той, что представлена в отдельном модуле CwlUtils. Это приводит к точно такой же проблеме, что и в случае с DispatchQueue.sync: захват замыканием приводит к выделению динамической памяти. Из-за накладных расходов в ходе этого процесса функция будет работать более чем в 10 раз медленнее, чем нужно (3,124 секунды для 10 миллионов вызовов, по сравнению с идеальным 0,263 секунды).

Что именно «захвачено»? Давайте рассмотрим следующий пример:

mutex.sync { doSomething(&protectedMutableState) }

Чтобы сделать что-то полезное внутри мьютекса, ссылка на protectedMutableState должна храниться в «контексте замыкания», который представляет собой данные, находящиеся в динамической памяти.

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

А мы этого не хотим. Чтобы избежать этого, передадим замыканию соответствующий параметр вместо того, чтобы его захватывать.

ПРЕДУПРЕЖДЕНИЕ: следующие несколько примеров кода становятся всё более нелепыми, и в большинстве случаев я предлагаю не следовать им. Я делаю это, чтобы продемонстрировать глубину проблемы. Прочтите главу «Другой подход», чтобы увидеть, что я использую на практике.

public func sync_2<T>(_ p: inout T, execute: (inout T) -> Void) {
 pthread_mutex_lock(&m)
 defer { pthread_mutex_unlock(&m) }
 execute(&p)
}

Так-то лучше… теперь функция работает на полной скорости (0,282 секунды для теста из 10 миллионов вызовов).

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

public func sync_3<T, R>(_ p: inout T, execute: (inout T) -> R) -> R {
 pthread_mutex_lock(&m)
 defer { pthread_mutex_unlock(&m) }
 return execute(&p)
}

демонстрирует такую же низкую скорость оригинала, даже когда замыкание ничего не захватывает (на 1,371 секунде скорость падает ещё ниже). Для обработки своего результата это замыкание выполняет динамическое выделение памяти.

Мы можем исправить это, внеся в результат параметр inout.

public func sync_4<T, U>(_ p1: inout T, _ p2: inout U, execute: (inout T, inout U) -> Void) -> Void {
 pthread_mutex_lock(&m)
 execute(&p, &p2)
 pthread_mutex_unlock(&m)
}

и вызвать так,

// Предполагается, что `mutableState` и `result` являются валидными, изменяемыми значениями в текущей области видимости
mutex.sync_4(&mutableState, &result) { $1 = doSomething($0) }

Мы вернулись к полной скорости, или достаточно близкой к ней (0,307 секунды для 10 миллионов вызовов).

Другой подход


Одним из преимуществ захвата замыканием является то, насколько легким он кажется. Элементы внутри захвата имеют одинаковые имена внутри и снаружи замыкания, и связь между ними очевидна. Когда мы избегаем захвата замыканием, и вместо этого пытаемся передать все значения в качестве параметров, мы вынуждены либо переименовывать все наши переменные, либо давать им теневые имена (shadow names) — что не способствует простоте понимания — и мы по-прежнему рискуем случайно захватить переменную, опять ухудшив производительность.

Давайте всё отложим и решим проблему по-другому.

Мы можем создать в нашем файле свободную функцию sync, которая берёт мьютекс в качестве параметра:

private func sync<R>(mutex: PThreadMutex, execute: () throws -> R) rethrows -> R {
 pthread_mutex_lock(&mutex.m)
 defer { pthread_mutex_unlock(&mutex.m) }
 return try execute()
}

Если поместить функцию в файл, из которого она будет вызываться, то всё почти работает. Мы избавляемся от расходов на динамическое выделение памяти, при этом скорость выполнения падает с 3,043 до 0,374 секунды. Но мы до сих пор не достигли уровня в 0,263 секунды, как при прямом вызове pthread_mutex_lock / pthread_mutex_unlock. Что опять не так?

Оказывается, несмотря на наличие приватной функции в том же файле, — где Swift может полностью инлайнить эту функцию, — Swift не устраняет избыточные удержания и освобождения (retains and releases) параметра PThreadMutex (типом которого является class, чтобы pthread_mutex_t не ломался при копировании).

Мы можем заставить компилятор избежать этих удержаний и освобождений, сделав функцию расширением PThreadMutex, а не свободной функцией:

extension PThreadMutex {
 private func sync<R>(execute: () throws -> R) rethrows -> R {
  pthread_mutex_lock(&m)
  defer { pthread_mutex_unlock(&m) }
  return try execute()
 }
}

Это заставляет Swift обрабатывать параметр self как @guaranteed, устраняя расходы на удержание/освобождение, и мы наконец-то доходим до значения в 0,264 секунды.

Семафоры, не мьютексы?


Почему было бы не использовать dispatch_semaphore_t? Преимущество dispatch_semaphore_wait и dispatch_semaphore_signal заключается в том, что для них не требуется замыкание — это отдельные, unscoped-вызовы.

Вы можете использовать dispatch_semaphore_t для создания конструкции наподобие мьютекса:

public struct DispatchSemaphoreWrapper {
 let s = DispatchSemaphore(value: 1)
 init() {}
 func sync<R>(execute: () throws -> R) rethrows -> R {
  _ = s.wait(timeout: DispatchTime.distantFuture)
  defer { s.signal() }
  return try execute()
 }
}

Оказывается, это примерно на треть быстрее мьютекса pthread_mutex_lock / pthread_mutex_unlock (0,168 секунды против 0,244). Но, несмотря на увеличение скорости, использование семафора для мьютекса является не самым лучшим вариантом для общего мьютекса.

Семафоры склонны к ряду ошибок и проблем. Наиболее серьезными из них являются формы инверсии приоритета. Инверсия приоритета — это тот же тип проблемы, из-за которого OSSpinLock стал использоваться под iOS, но проблема для семафоров немного сложнее.

При спин-блокировке инверсия приоритета означает:

  1. Высокоприоритетный поток активен, вращается (spinning) и ожидает снятия блокировки, удерживаемой потоком с более низким приоритетом.
  2. Низкоприоритетный поток никогда не снимает блокировку, потому что он истощён потоком с более высоким приоритетом.

При наличии семафора инверсия приоритета означает:

  1. Высокоприоритетный поток ожидает семафора.
  2. Среднеприоритетный поток не зависит от семафора.
  3. Ожидается, что низкоприоритетный поток сигнализирует семафором, что высокоприоритетный поток может продолжать.

Среднеприоритетный поток будет истощать низкоприоритетный (это нормально для приоритета потоков). Но поскольку высокоприоритетный поток ждёт, когда низкоприоритетный поток сигнализирует семафором, то высокоприоритетный тоже истощается среднеприоритетным. В идеале этого не должно происходить.

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

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

Всё это может показаться немного запутанным — поскольку вы, вероятно, не создаете намеренно в своих программах потоки с разными приоритетами. Тем не менее, фреймворки Cocoa добавляют немного сложностей: они повсеместно используют очереди отправки (dispatch queues), и каждая очередь имеет «класс QoS». А это может привести к тому, что очередь будет работать с другим приоритетом потока. Если вы не знаете очередность каждой задачи в программе (включая пользовательский интерфейс и другие задачи, поставленные в очередь с помощью фреймворков Cocoa), то неожиданно можете столкнуться с ситуацией многопоточного приоритета. Лучше этого избегать.

Применение


Проект, содержащий реализации PThreadMutex и DispatchSemaphore, доступен на Github.

Файл CwlMutex.swift полностью самодостаточен, поэтому можно просто скопировать его, если это всё, что вам нужно.

Или же файл ReadMe.md содержит подробную информацию о клонировании всего репозитория и добавлении создающего его фреймворка в ваши проекты.

Заключение


Лучшим и безопасным вариантом мьютекса в Swift как под Mac, так и под iOS, остается pthread_mutex_t. В будущем Swift, вероятно, обзаведётся возможностью оптимизировать изолирующие (non-escaping) замыкания в стеке, или инлайнить за границами модулей. Любое из этих нововведений устранит присущие проблемы с Dispatch.sync, вероятно, сделав его лучшим вариантом. Но пока что он слишком неэффективен.

В то время как семафоры и другие «легкие» блокировки являются обоснованными подходами в некоторых сценариях, они не являются мьютексами общего назначения и при проектировании подразумевают дополнительные соображения и риски.

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

Потоковое выполнение, инлайнинг и оптимизация — всё это темы, в которых мы можем ожидать значительных изменений за рамками Swift 3. Однако, текущим пользователям Swift приходится работать в Swift 2.3 и Swift 3 — и в этой статье описывается текущее поведение в этих версиях при попытке получить максимум производительности при использовании scoped-мьютекса.

Дополнение: показатели производительности


10 миллионов раз был прогнан простой цикл: ввод мьютекса, увеличение счетчика и вывод мьютекса. «Медленные» версии DispatchSemaphore и PThreadMutex оказались скомпилированы как часть динамической структуры, отдельно от тестового кода.

Результаты:

Вариант мьютекса Секунды (Swift 2.3) Секунды (Swift 3)
PThreadMutex.sync (захват замыканием) 3,043 3,124
DispatchQueue.sync 2,330 3,530
PThreadMutex.sync_3 (возвращаемый результат) 1,371 1,364
objc_sync_enter 0,869 0,833
sync(PThreadMutex) (функция в том же файле) 0,374 0,387
PThreadMutex.sync_4 (Двойные параметры inout) 0,307 0,310
PThreadMutex.sync_2 (единичный параметр inout) 0,282 0,284
PThreadMutex.sync (инлайновый не-захват) 0,264 0,265
Прямые вызовы pthread_mutex_lock/unlock 0,263 0,263
OSSpinLockLock 0,092 0,108

Использованный тестовый код является частью связанного проекта CwlUtils, но тестовый файл, содержащий эти тесты производительности (CwlMutexPerformanceTests.swift), по умолчанию не подключен к тестовому модулю и должен быть включен намеренно.

Комментарии (0)