Как стать автором
Обновить

Про многопоточность 2. GCD

Время на прочтение17 мин
Количество просмотров44K

Привет! Вот и новая часть серии статей про многопоточность дождалась своей очереди (ну вы поняли, да, типа очередь статей последовательная (͡° ͜ʖ ͡°) ). В этот раз мы подымимся на ступеньку выше, рассмотрим фреймфорк Dispatch, разберем большую часть GCD примитивов, распространенные проблемы и поищем решения. Завариваем чаек и погнали.

  1. Про многопоточность 1. Thread

  2. Про многопоточность 2. GCD

  3. Про многопоточность 3. Operation

  4. Про многопоточность 4. Async/await (coming soon)

  5. Про многопоточность 5. Железяки (coming soon)

Содержание

  1. Grand Central Dispatch

  2. Methods

  3. Dispatch work item

  4. Semaphore

  5. Dispatch group

  6. Dispatch barrier

  7. Проблемы

Вступление

В прошлой статье мы познакомились с низкоуровневыми примитивами многопоточности, научились создавать потоки, захватывать ресурсы и решать простейшие проблемы. В этот раз мы взглянем на многопоточность через призму мощного Grand Central Dispatch.

Grand Central Dispatch

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

GCD реализован на языке C, поэтому фактически является низкоуровневым фреймворком. Однако начиная со Swift 3.0 обзавелся читаемым Swift API. GCD поддерживается как в iOS, так и в macOS, watchOS и tvOS

Queue

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

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

People вектор создан(а) pch.vector - ru.freepik.com
https://ru.freepik.com/vectors/people
People вектор создан(а) pch.vector - ru.freepik.com https://ru.freepik.com/vectors/people

Говоря об очередях, очень хочется заострить внимание на следующем. Очередь (queue) и поток(thread) – не одно и то же. Очередь использует поток или несколько потоков для выполнения поступающих к ней задач.

Очереди делятся на 2 типа:

  • serial (последовательная) – выполняет задачи последовательно (поочередно). До тех пор, пока задача не будет выполнена, поток не приступит к выполнения следующей задачи в очереди.

  • concurrent (параллельная) – выполняет задачи параллельно. Задачи, поступающие в concurrent очередь, могут выполняться одновременно на разных потоках.

Serial очередь обрабатывает задачи строго в порядке поступления, при этом задача всегда будет ожидать выполнения в очереди до тех пор, пока поток не освободится от выполнения предыдущей задачи (ровно как в примере с очередью на кассу). В отличии от serial очереди, concurrent очередь не гарантирует, что задачи будут выполнены в строгом порядке очереди. Таким образом, к примеру, Task 6 (на иллюстрации выше) начнет свое выполнение не дождавшись выполнения даже Task 4.

Для того, чтобы создать очередь, нам необходимо создать объект типа DispatchQueue. Взглянем на декларацию инициализатора данного типа:

convenience init(label: String, qos: DispatchQoS = .unspecified, attributes: DispatchQueue.Attributes = [], autoreleaseFrequency: DispatchQueue.AutoreleaseFrequency = .inherit, target: DispatchQueue? = nil)

Рассмотрим аргументы инициализатора:

  • label – строка, необходимая для идентификации очереди. Так как приложение, библиотеки и фреймворки могут создавать свои собственные очереди, необходимо придерживаться DNS стиля, например ru.denisegaluev.queue для достижения уникальности. Так же идентификатор поможет определить очередь во время отладки.

  • qos – необходим для приоритизации очереди уже знакомым нам Quality Of Service.

  • attributesатрибуты, определяющие поведение очереди. Такими атрибутами могут быть .concurrent, определяющий очередь, как параллельную или .initiallyInactive, определяющий очередь неактивной, до тех пор, пока не будет вызван метод очереди activate().

  • autoreleaseFrequency – частота автоосвобождения объебктов очереди. (см. DispatchQueue.AutoreleaseFrequency)

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

Создадим serial очередь:

let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

Для создания последовательной очереди достаточно передать в инициализатор label (идентификатор очереди). Таким образом очередь последовательная by default. Для того, чтобы создать параллельную очередь, необходимо передать соответствующий атрибут в аргумент инициализатора attributes:

let concurrentQueue = DispatchQueue(label: "ru.denisegaluev.concurrent-queue", attributes: .concurrent)

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

public class func global(qos: DispatchQoS.QoSClass = .default) -> DispatchQueue

Все очереди из пула системных очередей являются concurrent очередями. Label глобальной очереди является строкой com.apple.root.default-qos (последнее слово лейбла зависит от переданного QoS, например для DispatchQueue.global(qos: .background) label будет равен com.apple.root.background-qos). Хочется заострить внимание на том, что глобальные очереди являются системными, а значит их создает сама система и использует для выполнения системных задач. Таким образом данные очереди являются by default загруженными, что сказывается на эффективности выполнения задач. Старайтесь не отдавать таким очередям тяжелые задачи.

В качестве единственного аргумента метод global() требует передать уже знакомый нам QoS. Таким образом мы можем использовать очередь с учетом приоритета текущей задачи. Освежим память и еще раз взглянем на qos, только уже через призму GCD. Фреймворк Dispatch имеет собственное перечисление приоритетов. Названия и задачи приоритетов совпадают с qos из Thread и pthread api, поэтому не будем тут долго задерживаться:

public enum QoSClass {

    @available(macOS 10.10, iOS 8.0, *)
    case background

    @available(macOS 10.10, iOS 8.0, *)
    case utility

    @available(macOS 10.10, iOS 8.0, *)
    case `default`

    @available(macOS 10.10, iOS 8.0, *)
    case userInitiated

    @available(macOS 10.10, iOS 8.0, *)
    case userInteractive

    case unspecified
}

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

// DispatchQueue.global вернет системную concurrent очередь с приоритетом default.
let globalQueue = DispatchQueue.global()

// DispatchQueue.main вернет главную очередь.
let mainQueue = DispatchQueue.main

Главная очередь main является serial очередью. Для выполнения задач главная очередь использует главный поток. Лейблом главной очереди является строка com.apple.main-thread.

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

На запуске приложения UIApplication инициализирует Main RunLoop на главном потоке, который в свою очередь обрабатывает все UI активности в приложении. Обновление UIView не происходит сразу после изменения этой view. View будет перерисована в конце текущей итерации RunLoop. Этот механизм гарантирует, что приложение успеет обработать все изменения view, и применить их одновременно (перерисовать). Данный процесс называется View Drawing Cycle. Предположим, мы обновляем какой-либо UI элемент в фоновом потоке. Пользователь переводит девайс в альбомную ориентацию и системе необходимо перерисовать все view с учетом новых размеров девайса. В силу того, что каждый тред работает на своем собственном RunLoop, view, которые мы изменяем в фоновом потоке не смогут обновиться одновременно со всеми остальными view. Резюмируя, мы получаем рассинхрон на уровне Main RunLoop и фонового RunLoop. Но даже это не приводит к крашу, по большому счету мы бы просто получили рассинхрон обновления разных view на экране. Проблема лежит глубже и связана напрямую с процессом рендеринга. Как бы мне не хотелось про это рассказать, это точно не тема данной статьи. Если интересно копнуть глубже, рекомендую посмотреть доклад Михаила Сорокина UI Rendering в iOS.

Context switch

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

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

Не смотря на то, что context switch оптимизирован на уровне ОС, он все равно требует больших вычислительных ресурсов. Эти ресурсы в основном тратятся на сохранение контекста текущего процесса (что на самом деле задействовано в переключении контекста, зависит от архитектуры, операционной системы и количества совместно используемых ресурсов). В отличии от concurrent, serial очередь использует единственный поток, таким образом выполнение задач в очереди не приводит к context switch.

Methods

Существует два основных способа взаимодействия с очередями. Данные способы подразумевают под собой методы, в которые мы будем передавать наши задачи в виде замыканий.

Sync

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

@available(iOS 4.0, *)
public func sync(execute block: () -> Void)

Как мы видим, данный метод требует передать единственный аргумент execute block: () -> Void.

Как это работает? Представим, что у нас есть 7 задач, которые нам необходимо выполнить последовательно. Задачи в нашем случае представлены в виде функций:

func task1() {
    print(1)
}

func task2() {
    print(2)
}

func task3() {
    print(3)
}

func task4() {
    print(4)
}

func task5() {
    print(5)
}

func task6() {
    print(6)
}

func task7() {
    print(7)
}

Выполним эти задачи:

task1()
task2()
task3()
task4()
task5()
task6()
task7()

Все выполняемые задачи by default будут выполнятся в главном потоке, а если точнее на главной очереди:

Ничего сложного, каждая задача дожидается своей очереди в порядке их вызова, так как главная очередь является последовательной. Усложним задачу и выполним task3 на другой serial очереди. Для этого нам необходимо создать новую последовательную очередь:

let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

и увести выполнение task3 на только что созданную очередь. Для этого мы воспользуемся методом sync:

task1()
task2()
serialQueue.sync(execute: task3)
task4()
task5()
task6()
task7()

Визуализируем:

Как мы можем видеть, задача task3 действительно выполняется на очереди serialQueue, в то время как основной поток ожидает ее выполнения. В этом и заключется суть метода sync, вызывающая очередь (в нашем случае main) будет ожидать до тех пор, пока выполняющая очередь (в нашем случае serialQueue) не вернет управление. Но что делать, если мы не хотим, чтобы вызывающая очередь дожидалась выполнения задачи task3? Для таких целей существует метод async.

Async

async – метод, позволяющий выполнять задачи асинхронно по отношению к текущей очереди

Рассмотрим декларацию данного метода:

public func async(group: DispatchGroup? = nil, qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = [], execute work: @escaping @convention(block) () -> Void)

Как мы можем видеть, метод async требует передать большее количество аргументов, но сейчас нас интересует лишь execute work

Мы воспользуемся примером с семью задачами, но заменим вызов метода sync на async:

task1()
task2()
serialQueue.async(execute: task3)
task4()
task5()
task6()
task7()

И снова визуализируем:

Как мы можем видеть, задача task3 все так же выполняется на очереди serialQueue, но при этом main не дожидается ее выполнения и продолжает свою работу асинхронно. В этом и заключется суть метода async, вызывающая очередь (в нашем случае main) не будет ожидать выполнения задач на выполняющей очереди (в нашем случае serialQueue), а сразу же приступит к выполнения стоящих в очереди задач.

Закрепляем

Рассмотрим ряд простых задачек. Я не буду фиксировать консоль лог в ответе, вместо этого приложу наглядные иллюстрации. Настоятельно рекомендую поиграться с задачками в плейграунде самостоятельно. Для большей наглядности консоль лога, нагрузите каждую функцию task работой, например (0...10).forEach { _ in print([Порядковый номер задачи]) }

Задача 1

let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

task1()
task2()
serialQueue.sync(execute: task3)
task4()
task5()
serialQueue.async(execute: task6)
task7()
Ответ

Задача 2
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

task1()
task2()
serialQueue.async(execute: task3)
task4()
serialQueue.async(execute: task5)
task6()
serialQueue.sync(execute: task7)
task8()
Ответ

Задача 3
let concurrentQueue = DispatchQueue(label: "ru.denisegaluev.concurrent-queue", attributes: .concurrent)

task1()
task2()
concurrentQueue.async(execute: task3)
task4()
concurrentQueue.async(execute: task5)
task6()
concurrentQueue.sync(execute: task7)
task8()
Ответ

Задача 4*
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")
let concurrentQueue = DispatchQueue(label: "ru.denisegaluev.concurrent-queue", attributes: .concurrent)

concurrentQueue.async {
    task1()
    
    serialQueue.async(execute: task2)
}

concurrentQueue.sync {
    serialQueue.sync(execute: task3)
    
    serialQueue.sync(execute: task4)
    
    serialQueue.async(execute: task5)
}

task6()

serialQueue.async {
    concurrentQueue.sync(execute: task7)
}

task8()

Async after

asyncAfter – метод, позволяющий отложить асинхронное выполнение задачи на определенное время. Данный метод идентичен async, за исключением аргумента deadline.

Рассмотрим декларацию метода:

public func asyncAfter(deadline: DispatchTime, qos: DispatchQoS = .unspecified, flags: DispatchWorkItemFlags = [], execute work: @escaping @convention(block) () -> Void)

Для того, чтобы отложить задачу, необходимо передать в аргумент deadline объект типа DispatchTime. Рассмотрим пример использования метода asyncAfter:

// Создаем последовательную очередь
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

// Откладываем выполнение задачи в очереди serialQueue на 3 секунды и сразу же возвращаем управление
serialQueue.asyncAfter(deadline: .now() + 3) {
    // ...
}

Dispatch work item

Фреймворк Dispatch позволяет ставить в очередь на выполнение не только замыкания, но и объекты типа DispatchWorkItem.

DispatchWorkItem – класс, являющийся абстракцией над выполняемой задачей, который предоставляет нам ряд полезных методов. Например метод notify, позволяющий уведомить какую-либо очередь о выполнении задачи и следом выполнить какую-либо работу на уведомленной очереди. Рассмотрим пример реализации DispatchWorkItem:

// Создаем очередь
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

// Создаем DispatchWorkItem и передаем в него замыкание (задачу)
let workItem = DispatchWorkItem {
    print("DispatchWorkItem task")
}

// Реализуем метод notify, передаем в него очередь, на которой необходимо будет выполнить задачу после завершения выполнения этого DispatchWorkItem
workItem.notify(queue: DispatchQueue.main) {
    print("DispatchWorkItem completed")
}

// Выполняем DispatchWorkItem на очереди serialQueue
serialQueue.async(execute: workItem)

Попробуем реализовать данную логику без использования DispatchWorkItem:

let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

serialQueue.async {
    print("task")
    
    DispatchQueue.main.sync {
        print("completed")
    }
}

Сравнивая данные примеры видно, что DispatchWorkItem позволяет нам более явно задать логику, без использования вложенных друг в друга замыканий и хаотичных вызовов методов async / sync.

Помимо notify, DispatchWorkItem дает нам возможность отменять задачу с помощью метода cancel. Важно понимать, что задачу можно отменить только в том случае, если она на момент отмены ожидает в очереди. Если поток уже начал выполнять задачу, она не будет отменена. Рассмотрим пример реализации метода cancel:

// Создаем очередь
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

// Создаем DispatchWorkItem и передаем в него замыкание (задачу)
let workItem = DispatchWorkItem {
    print("DispatchWorkItem task")
}

// Усыпляем serialQueue на 1 секунду и сразу возвращаем управление
serialQueue.async {
    print("zzzZZZZ")
    sleep(1)
    print("Awaked")
}

// Ставим workItem в очередь serialQueue и сразу возвращаем управление
serialQueue.async(execute: workItem)

// Отменяем workItem
workItem.cancel()

Пока serialQueue будет спать, мы успеем отменить workItem, тем самым удалив его из очереди serialQueue.

Semaphore

Semaphore – базовый инструмент синхронизации в GCD. Semaphore позволяет нам ограничить количество потоков, которые могут единовременно обращаться к очереди. Для этого необходимо передать количество потоков в инициализатор класса DispatchSemaphore:

@available(iOS 4.0, *)
public init(value: Int)

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

// Создаем очередь
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

// Создаем семафор
let semaphore = DispatchSemaphore(value: 0)

// Усыпляем serialQueue на 5 секунд, после вызываем метод signal тем самым
serialQueue.async {
    sleep(5)
    
    // Разблокировавыем семафор
    semaphore.signal()
}

// Блокируем очередь
semaphore.wait()

Методы signal и wait работают по принципу инкрементирования / декрементирования внутреннего каунтера семафора (аналогично рекурсивному mutex). Это означает, что поток будет разблокирован только тогда, когда каунтер равен значению value, которое мы передаем в инициализатор.

Dispatch group

DispatchGroup – объект, позволяющий объединить задачи в группу и синхронизировать их поведение. Группа позволяет присоединить к ней несколько задачь или DispatchWorkItem и запланировать их асинхронное выполнение на одной или нескольких очередях. Когда все задачи в группе будут выполнены, группа уведомит об этом какую-либо очередь и выполнит на ней completion handler. Так же группа позволяет нам дождаться выполнения задач в группе синхронно, без использования уведомления.

Рассмотрим пример использования DispatchGroup:

// Создаем очередь
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

// Создаем 2 DispatchWorkItem
let workItem1 = DispatchWorkItem {
    print("workItem1: zzzZZZ")
    sleep(3)
    print("workItem1: awaked")
}

let workItem2 = DispatchWorkItem {
    print("workItem2: zzzZZZ")
    sleep(3)
    print("workItem2: awaked")
}

// Создаем группу
let group = DispatchGroup()

// Добавляем workItem в группе, планируем его выполнение на очереди serialQueue и сразу возвращаем управление
serialQueue.async(group: group, execute: workItem1)
serialQueue.async(group: group, execute: workItem2)

// Устанавливаем уведомление. Замыкание будет выполнено на главной очереди сразу после того, как все задачи в группе будут выполнены.
group.notify(queue: DispatchQueue.main) {
    print("All tasks on group completed")
}

// Console: 
// workItem1: zzzZZZ
// workItem1: awaked
// workItem2: zzzZZZ
// workItem2: awaked
// All tasks on group completed

Рассмотрим, как добиться такого же поведения, но уже использую enter и leave вместо уведомления:

// Создаем параллельную очередь
let concurrentQueue = DispatchQueue(label: "ru.denisegaluev.concurrent-queue", attributes: .concurrent)

// Создаем группу
let group = DispatchGroup()

// Создаем DispatchWorkItem
let workItem1 = DispatchWorkItem {
    print("workItem1: zzzZZZ")
    sleep(3)
    print("workItem1: awaked")
    
    // Покидаем группу
    group.leave()
}

let workItem2 = DispatchWorkItem {
    print("workItem2: zzzZZZ")
    sleep(3)
    print("workItem2: awaked")
    
    group.leave()
}

// Входим в группу
group.enter()
// Вызы
concurrentQueue.async(execute: workItem1)

group.enter()
concurrentQueue.async(execute: workItem2)

// Ожидаем, пока все задачи в группе закончат свое выполнение
group.wait()
print("All tasks on group completed")

// Console:
// workItem1: zzzZZZ
// workItem2: zzzZZZ
// workItem1: awaked
// workItem2: awaked
// All tasks on group completed

Обратите внимание, что в данном случае нам не нужно добавлять задачи в группу (в аргумент group метода async). Вместо этого мы вызываем метод группы enter, тем самым указывая явно, что задача вошла в группу, а в конце выполнения задачи вызываем метод leave, тем самым явно указывая, что задача завершила свое выполнение. Таким образом очередь в которой был вызван wait (в нашем случае главная очередь), будет ожидать до тех пор, пока все задачи в группе не завершат свое выполнение и не вызовут метод leave.

Dispatch barrier

Dispatch barrier – механизм синхронизации задач в очереди. Для того, чтобы добавить барьер, необходимо передать соответствующий флаг в метода async:

// Создаем параллельную очередь
let concurrentQueue = DispatchQueue(label: "ru.denisegaluev.concurrent-queue", attributes: .concurrent)

// Помечаем асинхронный вызов флагом .barrier
concurrentQueue.async(flags: .barrier) {
    // ...
}

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

Разберемся, как работать с барьером на примере реализации read write lock:

class DispatchBarrierTesting {
    // Создаем параллельную очередь
    private let concurrentQueue = DispatchQueue(label: "ru.denisegaluev.concurrent-queue", attributes: .concurrent)
    
    // Создаем переменную _value для внутреннего использования
    private var _value: String = ""
    
    // Создаем thread safe переменную value для внешнего использования
    var value: String {
        get {
            var tmp: String = ""
            
            concurrentQueue.sync {
                tmp = _value
            }
            
            return tmp
        }
        
        set {
            concurrentQueue.async(flags: .barrier) {
                self._value = newValue
            }
        }
    }
}

Данная реализация позволяет гарантировать, что в момент чтения, свойство value не будет изменено из другой очереди.

Проблемы

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

  • Deadlock — ситуация, в которой поток бесконечно ожидает доступ к ресурсу, который никогда не будет освобожден

  • Race condition — ситуация, в которой ожидаемый порядок выполнения операций становится непредсказуемым, в результате чего страдает закладываемая логика

Race Condition

// 1
var value: Int = 0
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

// 2
func increment() { value += 1 }

// 3
serialQueue.async {
    // 4
    sleep(5)
    increment()
}

// 5
print(value)

// 6
value = 10

// 7
serialQueue.sync {
    increment()
}

// 8
print(value)
  1. Создаем свойство value и последовательную очередь serialQueue

  2. Описываем функцию инкрементирования value

  3. Планируем задачу и сразу же возвращаем управление вызывающей очереди

  4. Имитируем продолжительную работу усыпляя поток и тут же вызываем функцию increment

  5. Выводим в консоль значение переменной value, получаем 0 и вот тут начинается самое интересное. Для полноты картины представьте, что начиная с этого пункта и до конца сниппета, код находится в другой части приложения, а зависимости (value, serialQueue) переданы через DI. То есть вы и понятия не имеете, что через 5 секунд value будет инкрементирован. Мы получаем в консоли значение 0 и для нас это своего рода source of truth.

  6. Передаем в переменную value новое значение

  7. На этот раз инкрементируем синхронно

  8. Снова выводим значение value в консоль. Ожидаем получить 11, но получаем 12.

Такую ситуацию называют race condition:

Состояние гонки (англ. race condition), также конкуренция[1] — ошибка проектирования многопоточной системы или приложения, при которой работа системы или приложения зависит от того, в каком порядке выполняются части кода. Своё название ошибка получила от похожей ошибки проектирования электронных схем (см. Гонки сигналов).

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

Состояние гонки — «плавающая» ошибка (гейзенбаг), проявляющаяся в случайные моменты времени и «пропадающая» при попытке её локализовать.

Попробуем визуализировать наш пример:

Чтобы решить нашу, достаточно синхронизировать вызывающую очередь и serialQueue, тогда мы сможем гарантировать работу с актуальным значением value:

var value: Int = 0
let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

func increment() { value += 1 }

serialQueue.sync {
    sleep(5)
    increment()
}

print(value)

value = 10

serialQueue.sync {
    increment()
}

print(value)

И снова визуализируем:

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

Deadlock

Взаи́мная блокиро́вка (сокращённо взаимоблокировкаангл. deadlock) — ситуация в многозадачной среде или СУБД, при которой несколько процессов находятся в состоянии ожидания ресурсов, занятых друг другом, и ни один из них не может продолжать свое выполнение[1].

Мы уже знаем, как воспроизвести и решить deadlock с помощью Thread. Используя GCD добиться такой ситуации проще чем кажется. Самый простой способ – запланировать выполнение задачи из serial очереди синхронно на той же самой очереди:

let serialQueue = DispatchQueue(label: "ru.denisegaluev.serial-queue")

print("Start")

serialQueue.sync {
    serialQueue.sync {
        print("Deadlock")
    }
}

print("Finish")

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

serialQueue.sync {
    serialQueue.async {
        // ...
    }
}

Управление в очередь serialQueue вернется немедленно, что позволит ей продолжить работу и выполнение задач на своем потоке. Так или иначе задачи будут выполняться последовательно и если у нас нет потребности в использовании последовательной очереди, проблему можно решить использованием параллельной очереди:

let concurrentQueue = DispatchQueue(label: "ru.denisegaluev.concurrent-queue", attributes: .concurrent)

print("Start")

concurrentQueue.sync {
    concurrentQueue.sync {
        print("No deadlock")
    }
}

print("Finish")

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

Исходя из всего вышеперечисленного можно сделать несколько полезных выводов:

  • Стоит избегать сложные вложенные планирования задач на одной последовательной очереди

  • Вызов sync у последовательно очереди из той же очереди всегда приведет к взаимной блокировке

Заключение

Фреймворк Dispatch и его абстракция над потоками в виде очередей избавили нас от огромного количества бойлеплейта, а с точки зрения понимания, на мой взгляд, превратили работу с многопоточностью в давольно простой процесс. Тем не менее, весь Dispatch работает с низкоуровневыми потоками, поэтому полезно знать, как все это работает underhood. В данной статье мы рассмотрели очереди, способы взаимодействия с ними, основные инструменты синхронизации GCD, а так же самые распространенные проблемы. В следующих статьях мы взглянем на еще более высокоуровневый Operation, научимся отменять задачи даже если поток уже начал их выполнение, а так же познакомимся с нюансами работы процессора в контексте многопоточности. Спасибо за внимание :3

Полезные ссылки

Теги:
Хабы:
Всего голосов 7: ↑7 и ↓0+7
Комментарии4

Публикации

Истории

Работа

iOS разработчик
24 вакансии
Swift разработчик
31 вакансия

Ближайшие события