Полное руководство по параллелизму и многопоточности в iOS

Перевод статьи от автора

Основной поток и фоновый поток. Async/await и Actor. GCD и OperationQueue. DispatchGroup, как усилить фоновый поток и многое другое

Введение

В этой статье мы узнаем следующее:

ОГЛАВЛЕНИЕ

Что такое многопоточность 

Последовательная очередь против параллельной очереди 

Параллелизм  параллелизм

Основы многопоточности 

Основной поток (поток пользовательского интерфейса) и фоновый поток (глобальный поток)

GCD (Grand Central Dispatch)

DispatchGroup

DispatchSemaphore

DispatchWorkItem

DispatchBarrier

AsyncAfter

(NS)Operation и (NS)OperationQueue

DispatchSource (Как работать с файлами и папками)

Deadlock (вопрос, которого следует избегать)

Средство проверки основного потока (как обнаружить проблемы с потоком)

Потоки в Xcode (как отлаживать потоки)

Async / Await / Actor iOS13+

Я знаю, что есть много тем. Если что-то вам уже знакомо, пропускайте это и читайте неизвестные части. Есть хитрости и советы.

Многопоточность в реальных примерах

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

В какой-то момент многие люди будут заказывать кофе и еду. Это требует больше времени, чтобы подготовиться. Внезапно раздается звонок о том, что в 5 раз больше еды и в 4 раза больше кофе. Официанту нужно будет обслуживать столики один за другим, даже если все продукты приготовлены. Это Serial Queue. С последовательной очередью вы ограничены только одним официантом.

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

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

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

Вы можете увидеть задачу «Serial 6», если она запланирована в текущий момент времени. Она будет добавлена ​​в список в порядке FIFO и ожидает выполнения в будущем.

Основы многопоточности

Одна вещь, с которой я боролся, это отсутствие стандартной терминологии. Чтобы помочь с этим для этих тем, я сначала запишу синонимы, примеры. Если вы пришли из какой-то другой технологии, кроме iOS, вы все равно можете понять концепцию и перенести ее, поскольку основы те же. Мне повезло, что в начале своей карьеры я работал с C, C++, C#, node.js, Java (Android) и т. д., поэтому я привык к этому переключению контекста.

  • Основной поток/UI поток: это поток, который запускается вместе с приложением, предварительно определенный последовательный поток. Он прослушивает взаимодействие с пользователем и изменения пользовательского интерфейса. На все изменения немедленно нужна реакция. Будьте осторожны, чтобы не добавлять в эту ветку огромную работу, так как приложение может зависнуть.
DispatchQueue.main.async {
    // Run async code on the Main/UI Thread. E.g.: Refresh TableView
}
  • Фоновая очередь (глобальная): предопределена. В основном мы создаем задачи в новых потоках исходя из наших потребностей. Например, если нам нужно загрузить какое-то изображение большого размера. Это делается в фоновом потоке. Или любой вызов API. Мы не хотим запрещать пользователям ждать завершения этой задачи. Мы вызовем вызов API для получения списка данных о фильмах в фоновом потоке. Когда он прибывает и выполняется синтаксический анализ, мы переключаемся и обновляем пользовательский интерфейс в основном потоке.
DispatchQueue.global(qos: .background).async {
     // Run async on the Background Thread. E.g.: Some API calls.
}

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

  1. Вы можете увидеть имя DispatchQueue (метка: «com.kraken.serial»). Метка является идентификатором.
  2. Эти кнопки могут быть полезны для отключения/фильтрации вызовов системных методов, чтобы видеть только вызовы, инициированные пользователем.
  3. Как видите, мы добавили sleep(1). Это останавливает выполнение кода на 1 секунду.
  4. А если смотреть порядок то он все равно срабатывает серийно.

На основе предыдущей версии iOS одним из двух наиболее часто используемых терминов является последовательная очередь и параллельная очередь.

  1. Это результат одного из Concurrent Queue. Вы также можете увидеть выше Serial/Main Thread (com.apple.main-thread).
  2. К этому моменту добавляется sleep(2).
  3. Вы видите, что нет порядка. Это было завершено асинхронно в фоновом потоке.
let mainQueue = DispatchQueue.main
let globalQueue = DispatchQueue.global()
let serialQueue = DispatchQueue(label: “com.kraken.serial”)
let concurQueue = DispatchQueue(label: “com.kraken.concurrent”, attributes: .concurrent)

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

GCD (Grand Central Dispatch)

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

«DispatchQueue — это объект, который управляет выполнением задач последовательно или одновременно в основном потоке вашего приложения или в фоновом потоке». — Разработчик Apple

Если вы заметили в приведенном выше примере кода, вы можете увидеть «qos». Это означает качество обслуживания. С помощью этого параметра мы можем определить приоритет следующим образом:

  • Background — мы можем использовать это, когда задача не зависит от времени или когда пользователь может выполнять какое-то другое взаимодействие, пока это происходит. Например, предварительная загрузка некоторых изображений, загрузка или обработка некоторых данных в этом фоне. Эта работа занимает значительное время, секунды, минуты и часы.
  • Utility — долгоиграющая задача. Некоторые обрабатывают то, что может видеть пользователь. Например, загрузка некоторых карт с индикаторами. Когда задача занимает пару секунд, а в итоге пару минут.
  • UserInitiated — когда пользователь запускает какую-то задачу из UI и ждет результата, чтобы продолжить взаимодействие с приложением. Эта задача занимает пару секунд или мгновение.
  • UserInteractive — когда пользователю нужно немедленно завершить какую-то задачу, чтобы иметь возможность перейти к следующему взаимодействию с приложением. Мгновенная задача.

Полезно также пометить DispatchQueue. Это может помочь нам идентифицировать поток, когда он нам понадобится.

DispatchGroup

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

«Группа задач, которые вы отслеживаете как единое целое», — Apple Docs.

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

// 1. Create Dispatch Group
let group = DispatchGroup()

// 2.a. Long running Task 1
group.enter()
runLongRunningTask1(completion: {
    print("DispatchGroup: Long running Task 1 finished")
    group.leave()
})

// 2.b. Long running Task 2
group.enter()
runLongRunningTask2(completion: {
    print("DispatchGroup: Long running Task 2 finished")
    group.leave()
})

// 2.b. Long running Task 3
group.enter()
runLongRunningTask3(completion: {
    print("DispatchGroup: Long running Task 3 finished")
    group.leave()
})

// 3. When all are finished Notify
let queueType = DispatchQueue.global(qos: .userInitiated)
group.notify(queue: queueType) {
    print("DispatchGroup - notify: All task Finished.")
}
  • Шаг 1. Создайте DispatchGroup.
  • Шаг 2. Затем для этой группы необходимо вызвать событие group.enter() для каждой запущенной задачи.
  • Шаг 3. Для каждой группы group.enter() необходимо вызвать также group.leave() после завершения задачи.
  • Шаг 4. Когда все пары ввода-вывода завершены, вызывается group.notify. Если вы заметили, что это делается в фоновом потоке. Вы можете настроить в соответствии с вашими потребностями.

Стоит упомянуть опцию wait(timeout:). Он подождет некоторое время, пока задача завершится, но после истечения времени ожидания продолжится.

DispatchSemaphore

«Объект, который управляет доступом к ресурсу в нескольких контекстах выполнения с помощью традиционного счетного семафора». — Документы Apple

let semaphore = DispatchSemaphore(value: 1)
semaphore.wait()
task { (result) in
    semaphore.signal()
}

Вызовите wait() каждый раз, когда обращаетесь к некоторому общему ресурсу.


Вызовите signal(), когда мы будем готовы освободить общий ресурс.


Значение в DispatchSemaphore указывает количество одновременных задач.

DispatchWorkItem

Распространено мнение, что когда задача GCD запланирована, ее нельзя отменить. Но это не так. Это было верно до iOS8.

«Работа, которую вы хотите выполнить, инкапсулирована таким образом, что позволяет прикрепить дескриптор завершения или зависимости выполнения». — Документы Apple

Например, если вы используете строку поиска. Каждый набор букв вызывает вызов API, чтобы запросить у сервера список фильмов. Итак, представьте, что вы печатаете «Бэтмен». «Б», «Ба», «Летучая мышь»… каждая буква будет вызывать сетевой вызов. Мы не хотим этого. Мы можем просто отменить предыдущий вызов, если, например, в течение этого односекундного диапазона будет набрана другая буква. Если время проходит одну секунду, а пользователь не набирает новую букву, то мы считаем, что нужно выполнить вызов API.

Конечно, используя функциональное программирование, такое как RxSwift / Combine, у нас есть лучшие варианты, такие как debounce(for:scheduler:options:).

Dispatch Barrier

Dispatch Barriers решает проблему с блокировкой чтения/записи. Это гарантирует, что будет выполнен только этот DispatchWorkItem.

«Это делает небезопасные для потоков объекты потокобезопасными». — Документы Apple

Например, если мы хотим сохранить игру, мы хотим записать в какой-то открытый общий файл, ресурс.

AsyncAfter

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

// 1. Time
let delay = 2.0

// 2. Schedule 
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
    // Execute some task with delay
}

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

(NS)Operation and (NS)OperationQueue

Если вы используете NSOperation, это означает, что вы используете GCD под поверхностью, поскольку NSOperation построен поверх GCD. Некоторые преимущества NSOperation заключаются в том, что он имеет более удобный интерфейс для зависимостей (выполняет задачу в определенном порядке), он Observable (KVO для наблюдения за свойствами), имеет Pause, Cancel, Resume и Control (вы можете указать количество задач в очереди).

var queue = OperationQueue()
queue.addOperationWithBlock { () -> Void in  
    // Background URL 1 
    OperationQueue.mainQueue().addOperationWithBlock({
        // Update UI with URL 1 response
    })
}
queue.addOperationWithBlock { () -> Void in
    // Background URL 2
    OperationQueue.mainQueue().addOperationWithBlock({
        // Update UI with URL 2 response
    })
 }

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

queue.maxConcurrentOperationCount = 1
let task1 = BlockOperation {
    print("Task 1")
}
let task2 = BlockOperation {
    print("Task 2")
}

task1.addDependency(task2)
let serialOperationQueue = OperationQueue()
let tasks = [task1, task2]
serialOperationQueue.addOperations(tasks, waitUntilFinished: false)
let task1 = BlockOperation {
    print("Task 1")
}
let task2 = BlockOperation {
    print("Task 2")
}
let concurrentOperationQueue = OperationQueue()
concurrentOperationQueue.maxConcurrentOperationCount = 2
let tasks = [task1, task2]
concurrentOperationQueue.addOperations(tasks, waitUntilFinished: false)
let task1 = BlockOperation {
    print("Task 1")
}
let task2 = BlockOperation {
    print("Task 2")
}
let taskCombine = BlockOperation {
    print("taskCombine")
}
taskCombine.addDependency(task1)
taskCombine.addDependency(task2)
let operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 2
let tasks = [task1, task2, taskCombine]
operationQueue.addOperations(tasks, waitUntilFinished: false)

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

DispatchSource

DispatchSource используется для обнаружения изменений в файлах и папках. Он имеет множество вариаций в зависимости от наших потребностей. Я просто покажу один пример ниже:

let urlPath = URL(fileURLWithPath: "/PathToYourFile/log.txt")
do {
    let fileHandle: FileHandle = try FileHandle(forReadingFrom: urlPath)

    let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileHandle.fileDescriptor,
                                                           eventMask: .write, // .all, .rename, .delete ....
                                                           queue: .main) // .global, ...
    source.setEventHandler(handler: {
        print("Event")
    })

    source.resume()
} catch {
    // Error
}

Deadlock

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

// 1. Deadlock
serialQueue.sync {
   serialQueue.sync {
      print(“Deadlock”)
   }
}

Никогда не вызывайте синхронные задачи в основной очереди; это вызовет взаимоблокировку.

Средство проверки основного потока

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

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

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

Threads in Xcode

Во время отладки есть пара трюков, которые могут нам помочь.Если вы добавите точку останова и остановитесь на какой-то строке. В терминале Xcode вы можете ввести информацию о командном потоке. Он распечатает некоторые детали текущего потока.

Вот еще несколько полезных команд для терминала:

po Thread.isMainThread
po Thread.isMultiThreaded()
po Thread.current
po Thread.main

Возможно, у вас была похожая ситуация — когда приложение вылетало и в логе ошибок было что-то вроде com.alamofire.error.serialization.response. Это означает, что фреймворк создал какой-то пользовательский поток, и это идентификатор.

Async / Await

С iOS13 и Swift 5.5 были представлены долгожданные Async/Await. Со стороны Apple было приятно, что они признали проблему, заключающуюся в том, что когда появляется что-то новое, возникает длительная задержка, прежде чем это можно будет использовать в производстве, поскольку нам обычно требуется поддержка большего количества версий iOS. 

Async/Await — это способ запуска асинхронного кода без обработчиков завершения.

func exampleAsyncAwait() {
    print("Task 1")
    Task { // 2. Create Task {} Block to be in regular method to handle the Async method 'make'
        let myBool = await make() // 3. Await the method result
        print("Task 2: \(myBool)")
    }
    print("Task 3")
}

func make() async -> Bool { // 1. Create method what rsult is async
    sleep(2)
    return true
}

Вот код, который стоит упомянуть:

Task.isCancelled
Task.init(priority: .background) {}
Task.detached(priority: .userInitiated) {}
Task.cancel()

Я бы выделил TaskGroup. Это «DispatchGroup» в мире Await/Async. Я обнаружил, что у Пола Хадсона есть действительно хороший пример по этой ссылке

func printMessage() async {
    let string = await withTaskGroup(of: String.self) { group -> String in
        group.addTask { "Hello" }
        group.addTask { "From" }
        group.addTask { "A" }
        group.addTask { "Task" }
        group.addTask { "Group" }

        var collected = [String]()

        for await value in group {
            collected.append(value)
        }

        return collected.joined(separator: " ")
    }

    print(string)
}

await printMessage()

Actor

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

«Actor позволяют только одной задаче получить доступ к своему изменяемому состоянию за раз». — Документы Apple

actor TemperatureLogger {
    let label: String
    var measurements: [Int]
    private(set) var max: Int

    init(label: String, measurement: Int) {
        self.label = label
        self.measurements = [measurement]
        self.max = measurement
    }
}

let logger = TemperatureLogger(label: "Outdoors", measurement: 25)
print(await logger.max) // Access with await
// Prints "25"

Заключение

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

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

Если вы дошли до этого места, спасибо за чтение. Вы заслуживаете кофе ☕️. 🙂 Если вам понравился контент пожалуйста 👏, поделитесь, подпишитесь, это значит для меня. Если у вас есть предложения или вопросы, пожалуйста, не стесняйтесь комментировать.