Многопоточность / 1.1. Основы многопоточности



Говори правду, и тогда не придется ничего запоминать.

Марк Твен

В этом уроке


Зачем это нужно?

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

После этого курса Ваша жизнь уже не будет прежней!


Конкуренция

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

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


Параллелизм

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


GCD

GCD или Grand Central Dispatch - система, созданная Apple, чтобы убрать у Вас необходимость вручную синхронизировать потоки выполнения и управлять ими подобно оркестру. GCD предоставляет очереди выполнения типа FIFO, в которые отправляются задачи на исполнение.

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


Подготовка задачи

Если Вы используете игровые площадки, то сперва импортируйте Foundation и PlaygroundSupport, а также сделайте выполнение площадки бесконечным.

import Foundation
import PlaygroundSupport

PlaygroundPage.current.needsIndefiniteExecution = true

Если же действие происходит в обычном приложении, то Вам нужно сделать его выполнение бесконечным, например, так:

//Этот код надо размещать в конце программы
let _ = readLine(strippingNewline: true)

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

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

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

Мы предлагаем следующую функцию, однако Вы можете изменить её на свой вкус:

func doSomething() {
    _ = pow(1_000_000, 2)
}

Данная функция просто возводит 1_000_000 в квадрат и отправляет результат в пустоту.

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

func performWorkLoad(_ workload: () -> (), withIterations count: Int, withTag tag: String) {
    let startDate = Date()
    let startTime = Date().timeIntervalSince(startDate)

    for _ in 0..<count {
       workload()
    }

    let finishTime = Date().timeIntervalSince(startDate)
    print("\(tag): \(finishTime - startTime)")
}

Из новинок здесь стоит отметить класс Date, позволяющий работать с датами. Вызов её конструктора без аргументов создаёт объект с текущей датой.

Кроме того нам очень полезен здесь метод timeIntervalSince(_:), возвращающий число секунд с даты, переданной в качестве аргумента. На самом деле это обычный объект Double, который скрывается под альясом TimeInterval.

Из этого можно догадаться, что в результате метод выведет время работы функции приближённо в виде секунд.


Создаём очереди

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

Давайте создадим объекты для обоих видов очередей. Для этого используется конструктор класса DispatchQueue(label:attributes:):

  1. Первый аргумент - текстовая метка для очереди, что позволит разделять их.
  2. Второй аргумент необязателен и определяет тип очереди, то есть то, как она будет запущена. Он принимает три разных вида - по-умолчанию, очередь создаётся последовательной, если ввести .concurrent, то она отработает как конкурентная; а если .initialInactive, то она будет запущена неактивной.

Заметьте, что это очередное чудо, доставшееся от Objective-C: атрибуты являются структурой типа DispatchQueue.Attributes, которая удовлетворяет протоколу OptionSet. Его можно использовать как отдельные опции, а также в виде массива. Очень удобно!

Создадим последовательную и конкурентную очереди:

let serialQueue = DispatchQueue(label: "serial_queue.gorloff")
let concurrentQueue = DispatchQueue(label: "concurrent_queue.gorloff", attributes: .concurrent)

Передача заданий в очередь

Для асинхронной передачи задания в очередь воспользуемся следующим большим и страшным методом, однако на деле он свернётся в название метода async{замыкание}:

func async(group: DispatchGroup? = default, qos: DispatchQoS = default, flags: DispatchWorkItemFlags = default, execute work: @escaping  @convention(block) () -> Void)

Здесь лишь важно, что мы передаём убегающее замыкание, которое имеет для нас новый атрибут @convention(block), допускающий передачу блока Objective-C (аналог замыкания).

Давайте отправим в очередь сразу три работы (заметьте, что они начнут своё выполнение сразу при передаче в очередь):

concurrentQueue.async {
    performWorkLoad(doSomething, withIterations: 1_000_0, withTag: "async1")
}

concurrentQueue.async {
    performWorkLoad(doSomething, withIterations: 1_00, withTag: "async2")
}

concurrentQueue.async {
    performWorkLoad(doSomething, withIterations: 1_000, withTag: "async3")
}

Выводом функции будет примерно следующее:

async2: 0.0463080406188965
async3: 0.470821022987366
async1: 3.56980395317078

Эти числа будут отличаться в зависимости от устройства, а также от каждого запуска. Однако порядок будет /почти/ всегда таким - вы легко поймёте, почему это так: по количеству итераций Вы сможете различить сложность выполнения нагрузки, так как она линейно зависит от числа итераций.

Теперь отправим эти же задания асинхронно в последовательную очередь. Вы можете просто скопировать старый код, закомментировать его, а затем вставить и заменить concurrentQueue на serialQueue:

serialQueue.async {
    performWorkLoad(doSomething, withIterations: 1_000_0, withTag: "async1")
}

serialQueue.async {
    performWorkLoad(doSomething, withIterations: 1_00, withTag: "async2")
}

serialQueue.async {
    performWorkLoad(doSomething, withIterations: 1_000, withTag: "async3")
}

Все задачи будут выполнены последовательно:

async1: 3.38737797737122
async2: 0.0413450002670288
async3: 0.376710057258606

Закоментируйте последние добавленные части кода.


Синхронность против асинхронности

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

Создадим следующий код:

concurrentQueue.sync {
    performWorkLoad(doSomething, withIterations: 1_000_0, withTag: "async3")
}

print("completed")

Заметьте, что сообщение completed не будет выведено, пока не отработает код в синхронной очереди.

Закоментируйте последние добавленные части кода.


Главная очередь

Вы можете получить доступ к главной очереди приложения через статическое свойство main класса DispatchQueue:

DispatchQueue.main.async {
    performWorkLoad(doSomething, withIterations: 1_000, withTag: "async1")
}

DispatchQueue.main.async {
    performWorkLoad(doSomething, withIterations: 1_00, withTag: "async2")
}

DispatchQueue.main.async {
    performWorkLoad(doSomething, withIterations: 1_000_0, withTag: "async3")
}

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

Закоментируйте последние добавленные части кода.

Учтите, что асинхронные функции не отработают, так как ожидание ввода пользователя блокирует выполнение главного потока

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

DispatchQueue.main.sync {
    performWorkLoad(doSomething, withIterations: 1_000_0, withTag: "sync")
}

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

Не используйте метод dispatchMain() для передачи блоков кода в главную очередь, так как на большинстве систем он приведёт к синхронной блокировке.

Закоментируйте последние добавленные части кода.


Очередь с ручным запуском

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

let concurrentManualQueue = DispatchQueue(label: "concurrent_manual_queue.gorloff", attributes: [.concurrent, .initiallyInactive])

Теперь отправьте в эту очередь задачу c 100 итерациями и любым тегом, а после неё выведите сообщение о завершении выполнения.

concurrentManualQueue.async {
    performWorkLoad(doSomething, withIterations: 1_00, withTag: "async")
}

print("completed")

Сколько бы вы не ждали, работа не будет выполнена.

Чтобы выполнить работу, вызовите на очереди метод activate. Это превратит её в обычную очередь:

concurrentManualQueue.activate()

Работа гарантированно будет выполнена после вывода сообщения.

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

Закоментируйте добавленный код.


Отложенное выполнение работы

Теперь мы научимся вызывать задачи в отложенном режиме спустя определенный промежуток времени. Для этого нам надо создать объект типа DispatchTime. Он содержит в себе два свойства (одно из которых до сих пор осталось функцией:

Время измеряется в секундах, однако сам объект имеет точность до наносекунд.

Для того, чтобы воспользоваться отложенным вызовом, используйте метод asyncAfter:

concurrentQueue.async {
    performWorkLoad(doSomething, withIterations: 1_000_0, withTag: "async1")
}

concurrentQueue.asyncAfter(deadline: .now() + 10) {
    performWorkLoad(doSomething, withIterations: 1_00, withTag: "async2")
}

concurrentQueue.async {
    performWorkLoad(doSomething, withIterations: 1_000, withTag: "async3")
}

Здесь мы отложили вторую работу на 10 секунд от момента диспатча. Вы увидите из вывода, что эта работа выполнилась последней.