Туториал по Grand Central Dispatch для Swift: Часть 1/2

Если вы нашли опечатку в тексте, выделите ее и нажмите CTRL + ENTER.

Несмотря на то, что Grand Central Dispatch (или GCD для краткости) уже какое-то время доступен, не каждый знает, как можно получить от него максимальную пользу. Это и понятно ведь параллельность - это не просто, а GCD API на основе языка C может показаться набором заостренных уголков, тыкающих гладкий мир Swift.

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

Начнем

GCD является маркетинговым названием для libdispatch, библиотеки Apple, обеспечивающей поддержку для согласованного выполнения кода на многоядерном оборудовании (hardware) iOS и OS X. Это дает следующие преимущества:

  • GCD может улучшить отзывчивость вашего приложения, помогая отложить затратные в вычислительном отношении задачи и запускать их в фоновом режиме.
  • GCD обеспечивает более простую модель согласованности, чем система замков и потоков (locks and threads) и помогает избежать багов из-за согласованности.

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

Последовательность (Serial) против Согласованности (Сoncurrency)

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

Задания

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

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

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

Синхронность (Synchronous) против Асинхронности (Asynchronous)

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

Функция синхронности возвращается только после завершения задачи, которые она заказывает.

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

Будьте осторожны - когда вы читаете, что синхронная функция "блокирует" текущий поток, или, что функция является функцией "блокировки" или блокирует операцию, не запутайтесь! Глагол "блокировать" описывает, как функция влияет на собственный поток и не имеет никакого отношения к существительному "блок", которое описывает безымянную функцию в Objective-C. Также имейте в виду, что всякий раз, когда документация GCD ссылается на блоки Objective-C их можно заменить замыканиями в Swift.

Критическая секция (Critical Section)

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

Состояние гонки (Race Condition)

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

Тупик (Deadlock)

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

Безопасный поток (Thread Safe)

Потокобезопасный код  может быть безопасно вызван из нескольких потоков или согласованных задач, не вызывая каких-либо проблем (повреждение данных, падение и т.д.). Код, не являющийся потокобезопасным, должен быть запущен только в одном контексте единовременно. Примером потокобезопасного кода является let a = ["thread-safe"]. Этот массив доступен только для чтения, и вы можете использовать его без проблем из нескольких потоков одновременно. С другой стороны, массив, объявленный с var a = ["thread-unsafe"], является изменяемым и может быть изменен. Это означает, что он не является потокобезопасным, потому что несколько потоков могут получить доступ и изменить массив в одно и то же время с непредсказуемыми результатами. Переменные и структуры данных тоже могут изменяться и по своей сути не являются потокобезопасными, и должны быть приняты только из одного потока единовременно.

Переключение контекста (Context Switch)

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

Согласованность против параллелизма (Concurrency vs Parallelism)

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

Отдельные части согласованного кода могут быть выполнены "одновременно". Тем не менее, это система решает, как именно это произойдет - и произойдет ли вообще.

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

Верхняя часть рисунка: параллелизм (Потоки 1 и 2).

Нижняя часть рисунка: согласованность (без параллелизма) с переключением контекста

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

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

Очереди (Queues)

GCD обеспечивает очереди отправки (dispatch queues) для обработки поставленных задач. Эти очереди управляют задачами, которые вы предоставляете для GCD и выполняют эти задачи в порядке их поступления. Это гарантирует, что первая задача, добавленная в очередь является первой задачей в очереди, вторая задача будет добавлена второй и так далее.

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

Последовательные очереди (Serial Queues)

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

 

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

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

Согласованные Очереди (Concurrent Queues)

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

На рисунке ниже показан образец выполнения плана из четырех параллельных задач при GCD:

Обратите внимание, что Задачи 1, 2, 3 быстро завершены, одна за другой, и потребовалось какое-то время для начала выполнения задачи 1 после Задачи 0. Кроме того, Задача 3 начала выполняться после начала выполнения Задачи 2, но закончилось выполнение первым у Задачи 3.

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

Для того, чтобы сделать процесс интереснее, GCD предоставляет вам, по крайней мере пять конкретных очередей, чтобы выбрать в пределах каждого типа очереди.

Типы очередей

Во-первых, система представляет вам специальную последовательную очередь - основную очередь (main queue). Как и в любой последовательной очереди, задачи в этой очереди выполняются по одной. Тем не менее, это гарантирует, что все задачи будут выполняться на главном потоке, который является единственным потоком, которому разрешено обновить ваш пользовательский интерфейс. Только эта очередь используется для отправки сообщений на объекты UIView или для размещения уведомлений.

Система также предоставляет вам несколько согласованных очередей. Эти очереди связаны с их собственным классом QoS (Quality of Service). Классы QoS предназначены, чтобы выразить намерения представленной задачи, так чтобы GCD смог определить, как лучше расставить приоритеты:

  • QOS_CLASS_USER_INTERACTIVE: Интерактивный пользовательский класс (user interactive) представляет задачи, которые необходимо сделать немедленно. Используйте его для обновления пользовательского интерфейса, обработки событий или небольших работ, которые должны быть выполнены с небольшими задержками. Общий объем работ, сделанный в этом классе во время выполнения вашего приложения, должен быть небольшим.
  • QOS_CLASS_USER_INITIATED: Инициированный пользовательский класс представляет задачи, которые инициируются из пользовательского интерфейса и могут быть выполнены асинхронно. Его нужно использовать, когда пользователь ждет немедленных результатов, и для задач, требующих продолжения взаимодействия с пользователем.
  • QOS_CLASS_UTILITY: Класс Утилит (utility) представляет длительные  по исполнению задачи, как правило, с видимым для пользователя индикатором загрузки. Используйте его для вычислений, I/O, при работе с сетями, непрерывных каналов передачи данных и подобных друг другу задач. Этот класс является энергоэффективным, к тому же имеет низкое энергопотребление.
  • QOS_CLASS_BACKGROUND: Класс background представляет задачи, о которых пользователь может не знать напрямую. Используйте его для предварительной выборки, технического обслуживания и других задач, которые не требуют взаимодействия с пользователем и не требовательны ко времени исполнения.

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

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

Вот такая картина вырисовывается об "очередях отправки"!

"Искусство" в GCD сводится к выбору правильной функции очереди отправки для добавления работы в очередь. Лучший способ научиться - это поэксперементировать в приведенных ниже примерах, где мы представили некоторые общие рекомендации.

Образец проекта (Sample Project)

Так как цель этого урока состоит в оптимизации и безопасного вывода кода из различных потоков, с использованием GCD, вы начнете с почти готового проекта под названием GooglyPuff.

GooglyPuff является неоптимизированным, потоко-небезопасным приложением, которое накладывает выпученные на выкате глаза на идентифицированные лица, используя API обнаружения лиц в Core Image. Для основного фото вы можете выбрать любое из библиотеки фотографий или из набора предопределенных URL изображений, загруженных из Интернета.

Скачайте начальный проект.

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

Обратите внимание, когда вы выбираете опцию Le Internet для загрузки фотографий, сразу всплывает оповещение через UIAlertController. Вы это исправите во второй части этой туториала.

В этом проекте есть четыре интересных момента:

  • PhotoCollectionViewController: Это первый view controller, c которого начинается приложение. Он демонстрирует все выбранные фотографии через миниатюры.
  • PhotoDetailViewController: Выполняет логику и добавляет "выпученные" глаза изображению и отображает полученное изображение в UIScrollView.
  • Photo: Это протокол, описывающий свойства фотографии. Он обеспечивает изображение, миниатюру и статус. Предоставляются два класса, реализующие протокол: DownloadPhoto, инициалиализирующий фотографии из экземпляра NSURL, и AssetPhoto, который создает экземпляр фото из экземпляра ALAsset.
  • PhotoManager: Управляет всеми объектами Photo.

Управление фоновыми задачами с dispatch_async

Вернитесь в приложение и добавьте несколько фотографий из библиотеки фотографий или используйте опцию Le Internet для загрузки нескольких изображений.

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

Возможно и достаточно просто перегрузить UIViewController’s viewDidLoad, но это часто приводит к более продолжительному ожиданию появления view контроллера. Если было бы возможно, то лучше разгрузить процессы и второстепенные задачи, не являющиеся абсолютно необходимым во время загрузки, их можно пустить в фоновом режиме.

Это работа для dispatch_async!

Откройте PhotoDetailViewController и замените viewDidLoad следующей реализацией:

override func viewDidLoad() {
    super.viewDidLoad()
    assert(image != nil, "Image not set; required to use view controller")
    photoImageView.image = image

    // Resize if neccessary to ensure it's not pixelated
    if image.size.height <= photoImageView.bounds.size.height &&
      image.size.width <= photoImageView.bounds.size.width {
        photoImageView.contentMode = .Center
    }

    dispatch_async(dispatch_get_global_queue(Int(QOS_CLASS_USER_INITIATED.rawValue), 0)) { // 1
      let overlayImage = self.faceOverlayImageFromImage(self.image)
      dispatch_async(dispatch_get_main_queue()) { // 2
        self.fadeInNewImage(overlayImage) // 3
      }
    }
  }

Вот что происходит в измененном выше коде:

  • Сначала переместите работу из главного потока в глобальную очередь. Так как это вызов dispatch_async, замыкание происходит асинхронно, что означает, что выполнение вызывающего потока еще продолжается. Это позволяет viewDidLoad завершить процессы в главном потоке раньше и делает загрузку более "живой". Между тем, процесс распознания лица уже начат и будет закончен через некоторое время.
  • На этот момент процесс распознавания лица закончен и вы сгенерировали новое изображение. Так как вы хотите использовать это новое изображение, чтобы обновить UIImageView, вы добавляете новое замыкание в основную очередь. Помните - вы всегда должны обращаться к классам UIKit в главном потоке (main thread)!
  • Наконец, вы обновляете пользовательский интерфейс fadeInNewImage, которое выполняет постепенный переход нового изображения "выпученных глаз".

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

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

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

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

Вот краткое руководство о том, как и когда использовать различные типы очередей с dispatch_async:

  • Custom Serial Queue (Пользовательская Последовательная Очередь): Хороший выбор, если вы хотите выполнить подготовительную работу последовательно и отслеживать ее. Здесь исключено искажение ресурсов, так как вы знаете, что только одна задача исполняется за раз. Обратите внимание, что если вам нужны данные из метода, вы должны встроить другое замыкание, чтобы извлечь или рассмотреть его, используя dispatch_async.
  • Main Queue (Serial) (Основная очередь (Последовательная)): Это общий выбор для обновления пользовательского интерфейса после завершения работы задачи в согласованной очереди. Чтобы это сделать, вам нужно написать в коде одно замыкание внутри другого. Кроме того, если вы находитесь в основной очереди и вызываете dispatch_async, ориентированную на основную (main) очередь, вы можете быть уверены, что это новая задача будет выполняться через некоторое время после завершения текущего метода.
  • Concurrent Queue (Согласованная Очередь): Это общий выбор для выполнения отличных от UI работ (работ связанных с элеметами интерфейса), в фоновом режиме.

Вспомогательные переменные для получения Глобальных очередей (Global Queues)

Вы, возможно, заметили, что параметр QoS для класса dispatch_get_global_queue немного громоздок для написания. Это связано с тем, что qos_class_t определен как структура со свойством rawValue типа UInt32, который должен быть приведен в Int. Добавьте некоторые глобальные вычисляемые свойства в Utils.swift, под URL переменными, чтобы попадание в глобальную очередь стало немного проще:

var GlobalMainQueue: dispatch_queue_t {
    return dispatch_get_main_queue()
  }

  var GlobalUserInteractiveQueue: dispatch_queue_t {
    return dispatch_get_global_queue(Int(QOS_CLASS_USER_INTERACTIVE.rawValue), 0)
  }

  var GlobalUserInitiatedQueue: dispatch_queue_t {
    return dispatch_get_global_queue(Int(QOS_CLASS_USER_INITIATED.rawValue), 0)
  }

  var GlobalUtilityQueue: dispatch_queue_t {
    return dispatch_get_global_queue(Int(QOS_CLASS_UTILITY.rawValue), 0)
  }

  var GlobalBackgroundQueue: dispatch_queue_t {
    return dispatch_get_global_queue(Int(QOS_CLASS_BACKGROUND.rawValue), 0)
  }

Вернитесь в viewDidLoad в PhotoDetailViewController и замените dispatch_get_global_queue и dispatch_get_main_queue вспомогательными переменными:

override func viewDidLoad() {
    super.viewDidLoad()
    assert(image != nil, "Image not set; required to use view controller")
    photoImageView.image = image

    // Resize if neccessary to ensure it's not pixelated
    if image.size.height <= photoImageView.bounds.size.height &&
      image.size.width <= photoImageView.bounds.size.width {
        photoImageView.contentMode = .Center
    }

    dispatch_async(GlobalUserInitiatedQueue) {
      let overlayImage = self.faceOverlayImageFromImage(self.image)
      dispatch_async(GlobalMainQueue) {
        self.fadeInNewImage(overlayImage)
      }
    }
  }

Это делает вызовы отправки (dispatch calls) гораздо более читабельными и становится легче понять, какая очередь находится в использовании в данное время.

Отсрочка Работы с dispatch_after

Давайте немного рассмотрим UX вашего приложения. Вполне возможно, что пользователи могут запутаться в том, что нужно делать, когда они открывают приложение впервые. Было бы хорошо отобразить подсказку для пользователя, если нет каких-либо фотографии в классе PhotoManager. Тем не менее, вы также должны думать о том, как глаза пользователя будут перемещаться по домашнему экрану: если подсказка появится слишком быстро, то они могут пропустить ее, потому что их взгляд будет на чем-то другом.

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

Добавьте следующий код в реализацию showOrHideNavPrompt в PhotoCollectionViewController.swift:

func showOrHideNavPrompt() {
  let delayInSeconds = 1.0
  let popTime = dispatch_time(DISPATCH_TIME_NOW,
                              Int64(delayInSeconds * Double(NSEC_PER_SEC))) // 1
  dispatch_after(popTime, GlobalMainQueue) { // 2
    let count = PhotoManager.sharedManager.photos.count
    if count > 0 {
      self.navigationItem.prompt = nil
    } else {
      self.navigationItem.prompt = "Add photos with faces to Googlyify them!"
    }
  }
}

showOrHideNavPrompt выполняется в viewDidLoad и каждый раз, когда ваш UICollectionView перезагружается. Теперь пошагово:

  1.  Вы объявляете переменную, которая определяет время задержки.

  2.  Вы ждете указанное в переменной delayInSeconds количество времени, а затем асинхронно добавляете замыкание в основную очеред.

Запустите приложение. Должна быть небольшая задержка, которая, мы надеемся, обратит внимание пользователя и покажет им, что нужно делать.

dispatch_after работает также как задержка dispatch_async. Вы до сих пор не контролируете реальное время исполнения, а так же не можете отменить возврат dispatch_after?

Хотите знать, когда уместно использовать dispatch_after?

  • Custom Serial Queue Пользовательская Последовательная Очередь: Будьте осторожны при использовании dispatch_after в пользовательских последовательных очередях. Лучше придерживаться основной (main) очереди.
  • Main Queue (Serial) Основная очередь (Последовательность): Это хороший выбор для dispatch_after. В Xcode для этого есть приятный шаблон автозаполнения.
  • Concurrent Queue Согласованная Очередь: Будьте осторожны при использовании dispatch_after в этом виде очереди. Эта очередь очень редко используется для dispatch_after. Придерживайтесь основной очереди для таких операций.

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

Синглтоны. Можно любить их или ненавидеть, но они так популярны в iOS, как кошки в Интернете. :]

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

Нужно рассмотреть два случая: безопасность потоков во время инициализации синглтона и во время чтения и написания экземпляру.

Рассмотрим сначала инициализацию. Оказывается, это все достаточно просто, исходя из того, как Swift инициализирует переменные в глобальном контексте. В Swift глобальные переменные инициализируются, в момент, когда к ним впервые обращаются, и они гарантированно инициализируется как atomic. Таким образом, код инициализации рассматривается как критический участок и гарантированно завершает процесс до того, как любой другой поток получит доступ к глобальной переменной. Что именно происходит в Swift? Сам Swift также использует GCD, используя функцию dispatch_once.

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

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

Управление проблемами чтения и записи

Потокобезопасность не единственная проблема при работе с синглтонами. Если свойство синглтона представляет собой изменяемый объект, например как массив photos в PhotoManager, то вам нужно понять, является ли сам объект потокобезопасным.

В Swift любая переменная, объявленная с ключевым словом let, считается константой и служит только для чтения и становится потокобезопасной. Однако, объявите переменную с ключевым словом var, и она становится изменяемой, а не потокобезопасной, пока типу данных это нужно. В Swift типы коллекций, такие как Array и Dictionary не потокобезопасные, когда они объявляются как изменяемые. А что на счет таких контейнеров как NSArray? Они потокобезопасны? Ответ - "вероятно, нет"! Apple представляет нам полезный список многочисленных классов Foundation, которые не являются потокобезопасными.

Хотя несколько потоков могут читать изменяемый экземпляр Array одновременно без возникновения проблем, является не безопасным позволять одному потоку изменять массив, в то время пока другой его читает. Синглтон не защищает вас от этого случая.

Чтобы увидеть проблему, посмотрите на addPhoto в PhotoManager.swift, которая приводится ниже:

func addPhoto(photo: Photo) {
  _photos.append(photo)
  dispatch_async(dispatch_get_main_queue()) {
    self.postContentAddedNotification()
  }
}

Это метод write, так как он изменяет массив объекта.

Теперь взгляните на свойство photos, которое приводится ниже:

private var _photos: [Photo] = []
var photos: [Photo] {
  return _photos
}

Геттер для этого свойства называется методом read, так как он читает изменяемый массив. Caller (вызывающий) получает копию массива, он защищен от некорректных изменений исходного массива, но ничто не дает защиты от того, что один поток вызывает метод записи addPhoto одновременно с другим потоком, вызывающим геттер для свойства photos.

Примечание: Почему в приведенном выше коде caller получает копию массива photos? В параметрах Swift и возвращаемых типах функций они либо передаются по ссылке или по значению. Передача по ссылке является тем же, что и передача указателя (pointer) в Objective-C, что означает, что вы получите доступ к исходному объекту и любые изменения будут видны другим частям кода, имеющим ссылку на тот же самый объект. Передача результатов по значению в копии объекта и изменения в копии не влияют на оригинал. По умолчанию в Swift экземпляры классов передаются по ссылке, а экземпляры структур по значению.

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

Это классическая разработка программного обеспечения для проблемы записи/чтения. GCD предлагает элегантное решение создание замок записи/чтения (read/write lock) через использование dispatch barriers (барьеры).

Dispatch barriers (барьеры) - группа функций, действующих в качестве узкого "прохода" при работе с согласованными очередями (concurrent queues). Использование GCD барьера API гарантирует, что предоставленное замыкание - единственный объект, который будет исполняться в указанной очереди в конкретный момент. Это означает, что все элементы, добавленные в очередь до dispatch barrier, должны быть завершены до выполнения замыкания.

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

Приведенная ниже диаграмма иллюстрирует влияние барьерных функций на различные асинхронные задачи:

 

Обратите внимание, как при нормальной работе очередь ведет себя как нормальная согласованная очередь. Но когда выполняется барьер, то он по большей части действует как последовательная очередь. То есть, единственным, что выполняется является сам этот барьер. После завершения выполнения барьера, очередь возвращается к обычной согласованной очереди.

Вот случаи, когда вы будете - и когда не будете - использовать барьерные функции:

  • Custom Serial Queue (Пользовательская Серийная (Последовательная) Очередь: плохой выбор; барьеры не будут делать ничего полезного, так как в последовательной очереди и так выполняется по одной операции за раз.
  • Global Concurrent Queue (Глобальная Согласованная Очередь): Здесь нужно соблюдать осторожность, так как это, вероятно, не самая лучшая идея, так как другие системы могут также использовать очереди, и у вас не получится монополизировать их для собственных целей.
  • Custom Concurrent Queue (Пользовательская Согласованная Очередь): Это отличный выбор для atomic или критических (critical) областей кода. Все, что вы присваиваете или создаете экземпляр, и что должно быть потокобезопасным, является отличным кандидатом для барьера.

Так как единственным достойным выбором является Custom Concurrent Queue (Пользовательская Согласованная Очередь), вам нужно создать вашу собственную очередь, чтобы обработать барьерную функцию и отделить функции read и write. Согласованная очередь позволит несколько операций чтения одновременно.

Откройте PhotoManager.swift и добавьте следующее private свойство классу, ниже свойства photos:

private let concurrentPhotoQueue = dispatch_queue_create("com.raywenderlich.GooglyPuff.photoQueue", DISPATCH_QUEUE_CONCURRENT)

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

Примечание: При поиске примеров в Интернете, вы будете часто видеть, как люди передают 0 или NULL в качестве второго параметра dispatch_queue_create. Это старый способ создания последовательной очереди отправки. На данный момент второй параметр является зарезервированным для дальнейшего расширения возможностей.

Найдите addPhoto и замените его следующей реализацией:

func addPhoto(photo: Photo) {
  dispatch_barrier_async(concurrentPhotoQueue) { // 1
    self._photos.append(photo) // 2
    dispatch_async(GlobalMainQueue) { // 3
      self.postContentAddedNotification()
    }
  }
}

Вот как работает ваша новая функция записи:

  • Добавляем операцию записи (write), используя вашу пользовательскую очередь. Если критическая секция будет выполнена позднее, то это будет единственным пунктом в очереди на выполнение.
  • Это фактический код, который добавляет объект массиву. Так как это барьер замыкания, то это замыкание никогда не будет работать одновременно с любым другим замыканием в concurrentPhotoQueue.
  • Наконец, вы публикуете уведомление, что добавили изображение. Это уведомление должно быть размещено из основного потока, потому что именно он работает с UI (пользовательским интерфейсом), так что здесь вы посылаете другую задачу асинхронно главной очереди для уведомления.

Все это делается для записи, но вы также должны реализовать метод чтения photos.

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

В этом случае, dispatch_sync будет отличным кандидатом.

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

Но вы должны быть осторожны. Представьте, что вы вызываете dispatch_sync и устанавливаете таргет на текущую очередь, в которой вы уже работаете. Это приведет вас в тупик, потому что вызов будет ждать момента завершения замыкания, а замыкание не сможет завершиться (оно не сможет даже начать!), пока не закончится начатое замыкание! Это должно заставить вас задуматься, из какой очереди вы совершаете вызов - а также в какую очередь вы передаете.

Вот краткий обзор того, когда и где использовать dispatch_sync:

  • Custom Serial Queue (Пользовательская Последовательная Очередь): Будьте очень осторожны в этой ситуации, если вы работаете в очереди и вызовете dispatch_sync  и поставите цель на эту же очередь, то вы, безусловно, попадаете в тупик.
  • Main Queue (Serial) Главная очередь (Последовательная): Будьте очень осторожны, по тем же причинам, что и выше, так как эта ситуация также имеет потенциал для тупика.
  • Concurrent Queue (Согласованная Очередь): Эта очередь является хорошим кандидатом для синхронизации работы через dispatch barriers или при ожидании завершения задачи, так что вы можете выполнять дальнейшую обработку.

Продолжаем работать в PhotoManager.swift, замените свойство photos следующей реализацией:

var photos: [Photo] {
  var photosCopy: [Photo]!
  dispatch_sync(concurrentPhotoQueue) { // 1
    photosCopy = self._photos // 2
  }
  return photosCopy
}

Пошагово:

  • Отправляем синхронно в concurrentPhotoQueue для выполнения чтения.
  • Сохраняем копию массива фото в photosCopy и возвращаем его.

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

Визуальный обзор Очередей (A Visual Review of Queueing)

Еще до конца не поняли, что такое GCD? Убедитесь, что вы четко понимаете основы, создайте самостоятельно простые примеры, используя функции GCD, используя контрольные точки (breakpoints) и выводы в консоль, чтобы убедиться, что вы понимаете, что происходит.

Ниже представлены два анимированных GIF-файлы, чтобы укрепить ваше понимание dispatch_async и dispatch_sync. Код включен в каждую GIF в качестве наглядного примера. Обратите внимание на каждый этап GIF, показывающий breakpoint в коде слева и связанное (related) состояние очереди справа.

Снова возвращаемся к dispatch_sync:

override func viewDidLoad() {
  super.viewDidLoad()
  dispatch_sync(dispatch_get_global_queue(
      Int(QOS_CLASS_USER_INTERACTIVE.rawValue), 0)) {
    NSLog("First Log")
  }
  NSLog("Second Log")
}

Разберем пошагово:

  • Основная очередь выполняет задачи по порядку - следующей стоит задача создать экземпляр  UIViewController, который включает в себя viewDidLoad.
  • viewDidLoad выполняется в основном потоке.
  • Замыкание dispatch_sync добавлено к глобальной очереди и будет выполняться. В главном потоке процессы остановлены, пока не завершится замыкание. Между тем, в глобальный очереди согласованно обрабатываются задачи. Напомним, что замыкания будут извлечены из очереди в порядке поступления в глобальную очередь и смогут быть выполнены согласованно. Глобальная очередь обрабатывает задачи, которые уже присутствует в очереди до добавления в нее замыкания dispatch_sync.
  • Наконец, наступает очередь замыкания dispatch_sync.
  • Замыкание завершено, и теперь наступает время задач в основном потоке.
  • Метод viewDidLoad завершен, а основная очередь освобождается для выполнения других задач.

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

Еще раз о dispatch_async

override func viewDidLoad() {
  super.viewDidLoad()
  dispatch_async(dispatch_get_global_queue(
      Int(QOS_CLASS_USER_INTERACTIVE.rawValue), 0)) {
    NSLog("First Log")
  }
  NSLog("Second Log")
}

  • Основная очередь выполняет задачи по порядку - следующей задачей является создание экземпляра UIViewController, который включает в себя метод viewDidLoad.
  • viewDidLoad выполняется в основном потоке.
  • Замыкание dispatch_async добавляется к глобальной очереди и будет выполнена позднее, так как она _async.
  • viewDidLoad продолжает двигаться дальше, после добавления dispatch_async к глобальной очереди и основной поток обращает свое внимание на оставшиеся задачи. Между тем, глобальная очередь согласованно обрабатывает свои нерешенные задачи. Помните, что замыкания будут извлечены из очереди в порядке в порядке поступления в глобальную очередь, но смогут быть выполнены согласованно.
  • Замыкание, добавленное dispatch_async сейчас выполняется.
  • Замыкание dispatch_async завершено и оба NSLog отобразились в консоли.

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

Конечный проект тут.

За основу урока взят туториал.

Урок подготовил: Акулов Иван

Что дальше?

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