8 июля в 12:35

Libdispatch. Как сделать приложение отзывчивым

image



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


  • Pthreads, или потоки POSIX. Библиотека для низкоуровневой работы с многопоточностью. Определена как набор типов и функций на языке C. Подробнее можно ознакомиться тут.
  • Background selectors. Это отправка сообщения объекту, которое будет исполнено на указанном потоке. В коде это селектор с названием performSelector и различными параметрами (например, performSelectorOnMainThread:withObject:waitUntilDone:). Документация
  • NSThread. Представлены как базовые средства для работы с потоками. Ознакомьтесь с концептуальным документом по работе с потоками и документацией.
  • Grand Central Dispatch. Библиотека, основанная на блоках — анонимных участках кода, иначе замыканиях. Рабочее название — libdispatch.
  • NSOperation. Построен на основе GCD. Стоит заметить, что сама операция является абстрактной сущностью и на практике стоит использовать NSInvocationOperation и NSBlockOperation.

В этой статье поговорим о вопросах GCD.


Libdispatch — это библиотека компании Apple, предназначенная для работы с многопоточностью. GCD впервые была представлена в Mac OS X 10.6. Исходные коды библиотеки libdispatch, реализующей сервисы GCD, были выпущены под лицензией Apache 10 сентября 2009. Также существуют версии библиотеки для других операционных систем семейства Unix, таких как FreeBSD и Linux. Для остальных пока поддержки нет. Правда есть неофициальные сборки libdispatch от пользователей.


Поговорим о внутреннем устройстве библиотеки. Сделаем предположение, на основе какой технологии она была разработана. Варианты: pthreads, background selectors, NSThread. Второй вариант однозначно не подходит — поскольку основу libdispatch составляет работа с блоками. Тогда из предположений остается NSThread или pthreads. А теперь рассмотрим поподробнее.


Устройство GCD


Заголовочные файлы «всея iOS»


Все началось с того, что был обнаружен сборник заголовочных файлов всех библиотек и протоколов в Obj-C для одной из самых последних версий операционной системы (на тот момент это была iOS 10). В проекте присутствуют публичные фреймворки — большинство тех, с которыми знакомы практически все разработчики, начиная от AVFoundation и заканчивая WebKit. К удивлению, даже в публичных фреймворках присутствуют такие свойства и методы, которые недоступны в оригинальной документации Apple. Например, свойство trustedTimestamp у объекта CLLocation.


Далее обнаруживается большой раздел приватных библиотек, например, PhysicsKit. К слову, есть интересный timeline жизни приватных фреймворков — рекомендую ознакомиться. Это стоит того, чтобы потратить несколько часов и поизучать интересные и частично вскрытые внутренности iOS (сильно не радуйтесь, там только сгенерированные заголовочные файлы). Оставшаяся часть отведена библиотекам и протоколам. Библиотек там не так много, да и именование у них похожие: lib + название. Например, libobjc или libxpc. А вот протоколов там настолько много, что даже github не отображает их все.


И да, среди всего прочего была обнаружена libdispatch. Как и для остальных библиотек в репозитории, для нее присутствуют только заголовочные файлы. Среди них намеков на устройство библиотеки нет. Сгенерированные заголовочные файлы для классов в большинстве случаев содержат несколько стандартных методов, среди которых: debugDescription, description, hash и superclass. В таком случае единственным вариантом остается исследование открытых исходников Apple.


Обзор открытого репозитория


Рассмотрим из чего состоит репозиторий libdispatch. Это исходники и заголовочные файлы нескольких уровней. Уровни включают в себя публичный (то, о чем вы привыкли думать как о libdispatch), внутренний и приватный уровень доступа. Стоит обратить внимание на документацию, которая предоставляется для утилиты командной строки. Среди всего прочего можно наткнуться на файлы конфигурации cmake и xcodeconfig, а также тесты в большом количестве.


Наиболее интересные места для нас:


  • обертка для Swift (так как стандартный dispatch никуда не делся и никто его не переделывал), которая находится в исходниках проекта
  • проект Xcode, в котором можно более удобно рассмотреть структуру и устройство библиотеки.

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


Описание структуры проекта. Базовые объекты


Рассмотрим, что такое очередь в libdispatch. Очередь определена тремя макросами, определение можно найти в файле — queue_internal.h.


Определение очереди начинается с включения DISPATCH_STRUCT_HEADER — так сделано для всех объектов проекта. Этот общий заголовок состоит из определения OS_OBJECT_HEADER (сам OS_OBJECT_HEADER необходим для виртуальной таблицы операций — vtable и подсчета ссылок), нескольких полей, включая поле целевой очереди. Целевая очередь (target queue) представляется одной из базовых очередей — обычно очередью по умолчанию.


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


Базовый тип для работы представлен как continuation. Он определен как включение единственного макроса DISPATCH_CONTINUATION_HEADER. В определение макроса входят указатель на таблицу операций, различные флаги, приоритет, указатели на контекст, функции, данные и следующую операцию.


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


Обзор программного интерфейса библиотеки libpqw


Итак, последняя версия GCD построена над оберткой над библиотекой pthread — libpwq, в состав разработчиков которых записана и компания Apple. Главная идея библиотеки состоит в добавлении уровня абстракции. Первая версия вышла в 2011 году, на данный момент последней стабильной является версия 0.9 от 2014 года.


Библиотека является прямой надстройкой над <pthread.h>, внося новый уровень абстракции. Он включает в себя работу не с потоками, а с очередями задач: создание, установка приоритетов, добавление задач на исполнение. Например, добавление задачи осуществляется вызовом pthread_workqueue_additem_np, где передается очередь, указатель на функции для задачи и её аргументы.


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


Конечно, возможна компиляция libdispatch без библиотеки libpwq, тогда в таком случае будут использоваться pthreads. Это обусловлено тем, что он был анонсирован значительно раньше выхода этой библиотеки (в Mac OS X Snow Leopard в 2009 году).


Дерево вызовов от dispatch_async до создания потока или отправки элемента в очередь


Давайте для примера реализации рассмотрим какое-нибудь существующее решение в libdispatch. Возьмем всеми любимый вызов


DispatchQueue.main.async {
    // some asynchronous code...
}

На самом деле реализация тривиальна. Про саму swift'овую обертку будет рассказано чуть позже в данной статье. Скажем только, что CDispatch — скомпилированная библиотека GCD на С для Swift проекта.


public class DispatchQueue : DispatchObject {
    ...
}

public extension DispatchQueue {

    ...

    public class var main: DispatchQueue {
        return DispatchQueue(queue: _swift_dispatch_get_main_queue())
    }

    ...

    @available(OSX 10.10, iOS 8.0, *)
    public func async(execute workItem: DispatchWorkItem) {
        CDispatch.dispatch_async(self.__wrapped, workItem._block)
    }

    ...
}

В приведенном выше участке кода мы видим, как создается главная очередь и что из себя представляет асинхронный вызов кода. Разбор устройства GCD под капотом будет начинаться от всем известного dispatch_async.


Базовое дерево вызовов от момента запуска асинхронной задачи до момента создания потока (pthread_create) или же отправки задачи в более низкоуровневую библиотеку (libpwq) будет следующим:


  • dispatch_async
  • _dispatch_continuation_async
  • _dispatch_continuation_async2
  • _dispatch_async_f2
  • _dispatch_continuation_push
  • макрос dx_push
  • _dispatch_queue_push
  • _dispatch_queue_push_inline
  • макрос dx_wakeup
  • _dispatch_queue_class_wakeup
  • _dispatch_queue_class_wakeup_with_override
  • _dispatch_queue_class_wakeup_with_override_slow
  • _dispatch_root_queue_push_override_stealer
  • _dispatch_root_queue_push_inline
  • _dispatch_global_queue_poke
  • _dispatch_global_queue_poke_slow
  • вызов pthread_create или pthread_workqueue_additem_np

Пройдемся по структуре наиболее интересных вызовов. Оригинальный метод dispatch_async:


void
dispatch_async(dispatch_queue_t dq, dispatch_block_t work)
{
    dispatch_continuation_t dc = _dispatch_continuation_alloc();
    uintptr_t dc_flags = DISPATCH_OBJ_CONSUME_BIT;

    _dispatch_continuation_init(dc, dq, work, 0, 0, dc_flags);
    _dispatch_continuation_async(dq, dc);
}

Что же здесь происходит? Во-первых, выделяется память на ранее определенный тип — continuation. Стоит напомнить принятую концепцию, согласно которой под типом тип_t понимается указатель на структуру тип_s. В таком случае, где-то в заголовочных файлах будет находиться определение (например, typedef struct dispatch_queue_s *dispatch_queue_t;). Во-вторых, устанавливаем флаги для инициализации данной структуры, которые передаются вместе с типом блока и очередью для исполнения инструкций блока. Например, четвертый параметр определяет приоритет, который по умолчанию устанавливается в 0.


Выделив память на структуру и проинициализировав ее, управление передается дальше в две функции (_dispatch_continuation_async и _dispatch_continuation_async2). Первая функция представляет собой невстраиваемую (noinline) заглушку для вызова другой уже встраиваемой (inline) функции, попутно разыменовывая флаги, и проверяя наличие барьера. Задача второй функции — выполнить соотвествующие проверки и отправить continuation на асинхронное выполнение в очередь. Под отправкой подразумевается использование функции _dispatch_continuation_push. Это происходит только в случае того, что очередь не переполнена или при отсутствии барьера.


В случае попадания в барьер, управление может передаться функции _dispatch_async_f2, где осуществляется проверка и устанавливается уровень QoS для continuation — иначе приоритет. Однако следующей все равно вызывается функция _dispatch_continuation_push, которая под собой вызывает макрос dx_push. Макрос разворачивается в довольно громоздкую конструкцию, а в конечном итоге это ведет к вызову функции _dispatch_queue_push_inline. Ее невстраиваемая обертка намеренно пропускается.


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


Функция _dispatch_queue_push_inline построена на большом количестве макросов. Среди наиболее интересных низкоуровневых конструкций (которые, кстати, используются по всему исходному коду libdispatch) можно отметить следующие:


  • функция atomic_load_explicit находится в стандартной библиотеке для атомарной работы и обеспечивает атомарное разыменование указателя. Любая логика указателей в проекте использует вызовы из заголовочного файла — <stdatomic.h>;
  • функции __builtin_expect() и __builtin_unreachable(), равно как и остальные вызовы __builtin-подобных конструкций имеют отношение к низкоуровневым оптимизациям для компилятора — к branch prediction.

Главная задача этой функции — выполнить проверку на переполнение очереди или на заблокированный барьер и передать управление. Далее управление попадает в функцию _dispatch_async_f_redirect и выполняется проверка был ли continuation перенаправлен в эту же очередь. В данной функции также происходит обновление начала и конца очереди — атомарная смена указателей.


Далее следует еще один макрос dx_wakeup — или вызов _dispatch_queue_class_wakeup. Это один из главных методов, в котором происходит обработка задач в очереди. Он проверяет условия барьеров, состояния очереди, а в случае несоблюдения условий, задача снова может повторно отправиться в очередь через уже известный dx_push.


В случае соблюдения условий задача передается в метод _dispatch_queue_class_wakeup_with_override, который является оберткой над _dispatch_queue_class_wakeup_with_override_slow с проверкой на изменение приоритетов задачи и возможностью их перезаписи. Наличие slow в названии соотносится с механизмом встраивания функций — логика разбивается на несколько функций с целью упрощения ее поддержки.


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


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


Пишем на Swift


А теперь давайте вкратце посмотрим, в чем особенности промежуточной swift-библиотеки, которая непосредственно взаимодействует с libdispatch. Как известно, появилась она с третьей версии Swift. Представляет из себя обертку над оригинальным libdispatch c добавлением приятных swift'овых перечислений и вынесением функциональности в расширения. Все это, конечно же, входит в главную задачу библиотеки — предоставление удобного API для работы с GCD.


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


Общий подход состоит в том, что создаются несложные оболочки для большинства объектов. Объекты оригинального libdispatch, такие как dispatch_group_t или dispatch_queue_t, хранятся в объектах-обертках в свойстве __wrapped. Большинство функций делают один-единственный вызов непосредственно функций оригинального libdispatch над свойствами __wrapped.


Рассмотрим простенький пример:


public class DispatchQueue : DispatchObject {

    // объект для работы с libdispatch
    internal let __wrapped:dispatch_queue_t

    ...

    final internal override func wrapped() -> dispatch_object_t {
        return unsafeBitCast(__wrapped, to: dispatch_object_t.self)
    }

    ...

    public func sync(execute workItem: ()->()) {
        // вызов функции с одноименным названием
        dispatch_sync(self.__wrapped, workItem)
    }

    ...
}

С другой стороны, существуют и вызовы, которые не состоят из одной строчки. В них происходит приведение типов, подсчет промежуточных значений, проверка на версию системы и вызов соответствующих методов. Стоит также упомянуть, что в файле Private.swift происходит запрет прямых вызовов методов библиотеки libdispatch. Пример приведен ниже. Поэтому Вы никак уже не сможете писать менее swift'овый код (кроме, конечно старых версий свифта или собранной самостоятельно библиотеки libdispatch).


@available(*, unavailable, renamed:"DispatchQueue.async(self:group:qos:flags:execute:)")
public func dispatch_group_async(_ group: DispatchGroup, _ queue: DispatchQueue, _ block: @escaping () -> Void)
{
    fatalError()
}

Предупрежден — значит, вооружен


Итого, получилось описание о принципах работы libdispatch. Предположения относительно ее внутреннего устройства подтвердились. libdispatch действительно построен на POSIX Thread API — как на самом минимальном API для обеспечения работы с многопоточностью.


Последняя версия libdispatch использует другую библиотеку (libpwq), но суть остается та же.


И вот у вас возник вопрос — а зачем вообще понимать что там на низком уровне? Понимание низкоуровневых вещей аналогично знанию базовых концепций. С их помощью вы не сделаете что-то быстро, но будете избегать глупых ошибок в будущем.


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


Ссылки


Автор: @glyerk
Touch Instinct
рейтинг 175,96
Разрабатываем мобильные приложения
Похожие публикации

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

  • +3
    А при чем тут Саша Грей?
    • +7
      Многопоточность. :)
      • +6
        Еще и с высокой степенью паралелизма :)
    • 0

      Повышенная отзывчивость.


      Возможно, позиционируется как целевой уровень отзывчивости для многопоточных приложений!

  • +1
    Интересная статья, спасибо.
    Было недосуг ковыряться с некоторыми вещами, а тут уже готовое к прочтению ;).
  • 0
    del.
  • 0

    Потроха — это, конечно, хорошо, но в реальной практике нужны более конкретные знания. Например, с ходу вспомните разницу в поведении между dispatch_async на главном потоке и performSelectorOnMainThread (под MacOS)? А она существенна, и заменив второе на первое — легко словить баг.


    (если не вспомнили: серьёзное отличие — GCD работает только в главном цикле обработки сообщений, поэтому, если вызвать runModalForWindow:- до закрытия окна блоки не отработают)

Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

Самое читаемое Разработка