Узнайте о потоках на iOS

Если вы разрабатываете приложения для iOS более нескольких недель, то, вероятно, раньше имели дело с параллельным кодом. Если у вас нет опыта работы с операционными системами, вы, возможно, задали себе один из следующих вопросов:

  • Что это за DispatchQueues?
  • Почему я должен использовать его для отправки кода пользовательского интерфейса в основной поток? Ясно, что он по-прежнему работает, даже если я ничего не делаю.
  • В чем смысл этих очередей за «Качество услуг»? Я использую .main для всего, и у меня никогда не было проблем.
  • Почему у меня вылетает звонок, если я звоню DispatchQueue.main.sync? Какой в ​​этом смысл?
  • В любом случае, что это за основная ветка?

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

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

Что такое процесс?

Определение процесса довольно простое: это работающая программа. Ваше приложение - это процесс, Slack - это процесс, Safari - это процесс и так далее. Он содержит список инструкций (ваш код в формате ассемблера) и находится на вашем диске до тех пор, пока пользователь не захочет его запустить. Затем ОС загрузит этот процесс в память, запустит указатель инструкции, который сообщает нам, какая инструкция программы выполняется в данный момент, и заставит ЦП последовательно выполнять свои инструкции до их завершения, завершая процесс.

Address space of a single-thread process
|- - - - - - - - - - - - - - - - - - - - - - - - - - |
|                    Instructions                    |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
|                    Global Data                     |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
|           malloc'd data (Reference Types)          |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
| Nothing (Stack and malloc'd data grow towards here)|
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
|   Stack (Value Types (if possible), args, returns) |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -

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

Как центральный процессор может одновременно запускать несколько процессов?

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

ЦП просто не может делать две вещи одновременно. Для ЦП с несколькими ядрами все немного по-другому, но для простоты предположим, что у нас только один ЦП. Случается, что он что-то выполняет в Safari, затем что-то в Spotify, затем что-то в iOS, затем снова что-то в Safari и так далее. ОС сохранит все, что ЦП делал для определенного процесса в памяти (в виде регистров и указателей), решит, какой будет следующий процесс, который будет запущен, получит то, что он делал для этого процесса, пусть ЦП запустит его для некоторое время и повторите. Это называется переключением контекста, и происходит это очень и очень быстро, создавая впечатление, что на самом деле он может запускать несколько вещей одновременно.

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

Точный способ, которым ОС определяет, какой процесс должен быть запущен в следующий раз, довольно сложен, но вы должны знать, что можно вручную указать «приоритет» чего-либо в вашем приложении. Обретает ли смысл понятие «Качество услуг» iOS?

Что такое поток?

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

Address space of a multi-threaded process
|- - - - - - - - - - - - - - - - - - - - - - - - - - |
|                    Instructions                    |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
|                    Global Data                     |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
|           malloc'd data (Reference Types)          |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
| Nothing (Stack and malloc'd data grow towards here)|
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
|                 Stack of Thread 2                  |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -
|                 Stack of Thread 1                  |
|- - - - - - - - - - - - - - - - - - - - - - - - - - -

Как и процессы, ЦП не может запускать два потока одновременно. Вместо этого они, как и процессы, нацелены на переключение контекста. ЦП выполняет что-то в потоке 1 Safari (который выполняет некоторые обновления пользовательского интерфейса), затем что-то в потоке 3 Spotify (который загружает песню), затем что-то в потоке 2 Safari (который проверяет DNS) и так далее.

iOS: основная тема

В вашем приложении для iOS есть несколько потоков. Основной поток - это просто начальная отправная точка выполнения в вашем приложении (начиная с didFinishLaunchingWithOptions). Основной поток выполняет цикл в каждом кадре (RunLoop), который при необходимости рисует текущий экран, обрабатывает события пользовательского интерфейса (например, касания) и выполняет содержимое DispatchQueue.main. Он продолжает делать это до тех пор, пока приложение не будет закрыто. Он имеет чрезвычайно высокий приоритет - почти все, что в нем, выполняется немедленно. Вот почему вам нужно направить код пользовательского интерфейса в основной поток. Выполняя некоторый код, изменяющий пользовательский интерфейс, за его пределами, ваш код может начать работать должным образом только для того, чтобы внезапно переключиться на контекст на несколько миллисекунд, потому что в ОС поступило что-то более важное (например, уведомление). В этом случае обновления пользовательского интерфейса будут отложены, что негативно скажется на работе ваших пользователей.

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

@IBAction func actionOne(_ sender: Any) {
    //Button actions are in the main thread.
    //This takes about 5 seconds to finish
    var counter = 0
    for _ in 0..<1000000000 {
        counter += 1
        //The screen is totally frozen here. How can I scroll my screen (an UI action)
        //If I blocked the thread by doing this meaningless thing?
        //The scroll action is waiting to be run, but it can't because it's also a main thread action.
        //You can't simply context-switch actions on the same thread.
        //This needs to be run in a different thread.
    }
}

iOS: фоновые потоки и диспетчерские очереди

Фоновый поток - это все, что не является основным потоком. Он может работать вместе с основным потоком (как будто это другой процесс, но помните определение потока!), Работая со сложными задачами, не мешая обновлениям пользовательского интерфейса основного потока.

В iOS самый безопасный способ создания фонового потока - использовать DispatchQueues. Однако имейте в виду, что DispatchQueues не являются потоками. Это просто очереди закрытий, которые в конечном итоге будут перенаправлены в соответствующий поток. DispatchQueue автоматически создает и повторно использует пул потоков по своему усмотрению, отвлекая от вас хлопоты по созданию потоков вручную и решая возможные проблемы, связанные с этим.

Основной поток будет последовательно запускать содержимое DispatchQueue.main (то есть действие 2 происходит только после завершения действия 1), в то время как содержимое DispatchQueue.global(qos:) одновременно запускается в фоновый поток (или несколько, если есть несколько действий) с равным приоритетом. выбранного QoS. Если вам нужно настраиваемое поведение (например, очередь, которая перенаправляет закрытия в фоновый поток, но поочередно), вы можете создать свой собственный DispatchQueue.

Приоритеты фоновой очереди (QoS)

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

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

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

  • UserInteractive: для работы, которая должна выполняться мгновенно.
  • UserInitiated: для почти мгновенной работы (несколько секунд или меньше).
  • Утилита: для работы, которая может занять некоторое время, например для вызова API.
  • Предыстория: для работы, требующей очень много времени.

Визуализация влияния различных уровней QoS

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

Тяжелая задача в основном потоке

@IBAction func actionOne(_ sender: Any) {
    //We are already in the main thread, but we will use a dispatch operation
    //to see how long it takes for the task to begin.
    DispatchQueue.main.async { [unowned self] in
        self.timeIntensiveTask()
    }
}

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

Тяжелая задача в инициированном пользователем потоке QoS

@IBAction func actionOne(_ sender: Any) {
    DispatchQueue.global(qos: .userInitiated).async { [unowned self] in
        self.timeIntensiveTask()
    }
}

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

Тяжелая задача в фоновом потоке QoS

@IBAction func actionOne(_ sender: Any) {
    DispatchQueue.global(qos: .background).async { [unowned self] in
        self.timeIntensiveTask()
    }
}

Так же, как UserInitiated, был создан поток. Однако в этом случае для запуска задачи требовалось некоторое время, а для ее завершения - почти десять секунд! Этот поток с более низким приоритетом задерживает и сокращает доступ к системным ресурсам. Но это хорошо. Если вы отправляете задачу в фоновую очередь QoS, это означает, что вы не хотите разрушать ЦП пользователя, сосредоточившись на ней.

Визуализация последовательных очередей и параллельных очередей

DispatchQueue.sync против DispatchQueue.async

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

Распространенное заблуждение - думать, что DispatchQueue.async означает выполнение чего-то в фоновом режиме, а это неверно.

Какой будет выход на actionOne()?

Ответ всегда будет:

sync task started
sync task ended
async task started
async task ended

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

Асинхронная задача также будет выполняться только после задачи синхронизации (и никогда раньше), потому что DispatchQueue.main задачи начнут выполняться только в конце RunLoop основного потока, который заблокирован нашей задачей синхронизации. Если actionOne оказался в другом потоке или задача async оказалась в другом DispatchQueue, задачи будут запускаться вместе в порядке, зависящем от того, как быстро будет отправляться задача async.

DispatchQueue.async означает следующее: «Убедитесь, что эта задача в конечном итоге будет выполнена в потоке X (основном или любом другом глобальном фоновом потоке, в зависимости от того, какую очередь вы используете), но меня не интересуют детали. Я буду продолжать заниматься своим делом ».

Напротив, DispatchQueue.sync означает: «Убедитесь, что эта задача в конечном итоге будет выполнена в потоке X. Пожалуйста, предупредите меня, когда вы это сделаете, потому что я также заблокирую себя (вызывающий поток), пока эта задача не завершится».

Учитывая это, как вы думаете, что будет результатом следующего actionOne()?

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

Что еще?

Надеюсь, это ответит на вопросы из начала статьи. Параллелизм - это очень широкая проблема, и в iOS DispatchQueues - лишь один из способов решения проблем параллелизма. Есть еще много всего в форме атомарности, OperationQueues, Locks и Semaphores. Однако DispatchQueues являются наиболее часто используемыми инструментами параллелизма в iOS и, если понимать их, являются одним из ключей к написанию эффективного многопоточного кода.