Программирование
Основы параллельного программирования в Swift: часть 1
Ян Олбрич разбирается с основами параллельного программирования в Swift.
Около года назад моя команда начала новый проект. Мы хотели использовать все наши знания из предыдущих проектов. Одно из решений, которое мы приняли – это сделать всю модель API асинхронной. Это позволит нам менять всю реализацию модели, не затрагивая остальные части приложения. Если наше приложение сможет обрабатывать асинхронные вызовы, то дальше уже будет неважно с чем связываться — с бэкендом, кэшем или нашей базой данных. Это также помогает нам работать над проектом параллельно.
Но у этого решения есть и последствия. Нам, как разработчикам, пришлось изучить такие темы, как конкурентность и параллелизм. В противном случае все бы просто не работало. Итак, давайте вместе изучим то, как реализовать конкурентно работающую программу.
Синхронная vs Асинхронная обработка
Так в чем же разница между синхронной и асинхронной обработкой? Представьте, что у нас есть список элементов. При обработке этих элементов синхронным образом, мы начинаем с первого элемента и заканчиваем работу с ним до того, как начинаем следующий. Он ведет себя так же, как FIFO Queue (First In, First Out).
В переводе на код это означает: каждый оператор метода будет выполнен по порядку.
https://gist.github.com/olbrichj/4808511637320c1839dd768c625858e4#file-synchronous-swift
Таким образом, синхронный код полностью обрабатывает один элемент за раз.
Для сравнения, асинхронная обработка обрабатывает несколько элементов одновременно. Например, обработка item1, пауза для работы с item2, а затем продолжение и завершение для item1.
Примером в коде может быть простой обратный вызов. Мы можем видеть, что код в конце выполняется до обработки обратного вызова.
https://gist.github.com/olbrichj/1035a72aa25b0d6903d8f8ba30493c34#file-asynchronous-swift
Конкурентность vs параллелизм
Часто конкурентность (concurrency) и параллелизм (parallelism) используются взаимозаменяемо (даже Википедия неправильно использует их в некоторых местах). Это приводит к проблемам, которых легко избежать, если различия станут понятными. Давайте объясним это на примере.
Попытайтесь представить, что у нас есть стопка ящиков в позиции A, и мы хотим перенести их в позицию B. Для этого мы можем использовать работников. В синхронной среде мы могли использовать только одного работника для этого. Он будет носить по одной коробке за раз, полностью, с позиции А до позиции Б.
Но давайте представим, что мы можем одновременно использовать нескольких работников. Каждый из них возьмет коробку и будет нести ее до позиции Б. Это увеличит нашу производительность довольно сильно, не так ли? Поскольку мы используем нескольких работников, производительность увеличится в столько же раз, сколько у нас работников. До тех пор, пока не менее двух рабочих несут ящики одновременно, они делают это параллельно.
Параллелизм — это выполнение работы одновременно.
Но что произойдет, если у нас всего один рабочий и, возможно, он способен на большее? Мы должны рассмотреть возможность использования нескольких ящиков в состоянии обработки. Вот что такое конкурентность. Это можно рассматривать как деление расстояния от A до B на несколько этапов. Работник может нести коробку от позиции А до половины пути, а затем вернуться в А, чтобы захватить следующий ящик. Используя нескольких работников, мы можем заставить их всех переносить коробки на разное расстояние. Таким образом мы обрабатываем ящики асинхронно. В случае, если у нас есть несколько работников, мы обрабатываем коробки параллельно.
Поэтому разница между concurrency и параллелизмом проста. Параллелизм — это делать работы одновременно. Конкурентность — это возможность выбора одновременного выполнения работ. Такая работа может быть параллельной, а может и нет. Большинство наших компьютеров и мобильных устройств могут работать параллельно (из-за количества ядер), но каждое программное обеспечение определенно работает конкурентно.
Механизмы конкурентности
Каждая операционная система предоставляет различные инструменты для использования конкурентности. В iOS у нас есть инструменты по умолчанию, такие как процессы и потоки (processes и threads), но из-за истории с Objective-C также существует Dispatch Queue.
Процессы (Process)
Процессы — это экземпляры вашего приложения. Они содержат все необходимое для исполнения вашего приложения, включая ваш стек, кучу и все остальные ресурсы.
Несмотря на то, что iOS является многозадачной ОС, она не поддерживает несколько процессов для одного приложения. Таким образом, у вас есть только один процесс. macOS немного отличается. Вы можете использовать класс Process для создания новых дочерних процессов. Они не зависят от родительского процесса, но содержат всю информацию, которая была у родителя во время создания дочернего процесса. В случае, если вы работаете с macOS, вот код для создания и выполнения процесса:
https://gist.github.com/olbrichj/5ca5e7814202905b66ab7086cf0c8380#file-customprocess-swift
Потоки (Thread)
Потоки – это своего рода облегченные процессы. По сравнению с процессами, треды делят свою память с родительским процессом. Это может привести к проблемам, таким как одновременное изменение ресурса сразу из двух потоков (например, изменение переменной). В результате мы получим непоследовательные результаты при повторном выполнении. Потоки — это ограниченный ресурс в iOS (или в любой другой совместимой с POSIX системе). Система ограничивается 64 тредами для одного процесса. Это достаточно много, однако иногда возникают причины увеличить это число. Вы можете создать и выполнить тред следующим образом:
https://gist.github.com/olbrichj/6bdde16cd98fec04f5dd3de79f6c534a#file-customthread-swift
Очередь отправки (Dispatch Queue)
Поскольку у нас есть только один процесс, а потоки ограничены количеством 64, должны быть другие варианты для запуска кода в режиме конкурентности. Решение Apple — это очереди отправки (dispatch queue). Вы можете добавить задачи в dispatch queue и ожидать, что она будет выполнена в какой-то момент. Существуют разные типы DQ. Один из них — SerialQueue. В этом типе все будет обработано в том же порядке, в каком было добавлено в очередь. Другой — ConcurrentQueue. Как следует из названия, задачи в этой очереди могут выполняться конкурентно.
Но это не реальная конкурентность, верно? Особенно если посмотреть на SerialQueue, то мы ничего не выигрываем. ConcurrentQueue не делает жизнь проще. У нас есть потоки, так в чем смысл?
Давайте рассмотрим, что произойдет, если у нас есть несколько очередей. Мы могли бы просто запустить очередь в потоке, а затем всякий раз, когда мы планируем задачу, добавлять ее в одну из очередей. Применив собственный интеллект, мы могли бы даже распределять входящие задачи для приоритетной и текущей рабочей нагрузки, тем самым оптимизируя наши системные ресурсы.
Эта реализация Apple называется Grand Central Dispatch (или GCD для краткости). Как она управляется в iOS?
https://gist.github.com/olbrichj/1d2ce8aa264424ad237d815d15d7eff6#file-dispatchqueue-swift
Большим преимуществом Dispatch Queues является то, что она изменяет вашу ментальную модель конкурентного программирования. Вместо того, чтобы думать в терминах тредов, вы рассматриваете их в качестве рабочих блоков, которые пушатся в разные очереди, что намного проще.
Operation Queues
Высокоуровневая абстракция GCD в Cocoa – это операционные очереди (Operation Queue). Вместо блока дискретных единиц работы вы создаете операции. Они будут помещены в очередь, а затем выполнены в правильном порядке. Существуют разные типы очередей: основная очередь, которая выполняется в основном треде, и пользовательские очереди, которые не выполняются в основном треде.
https://gist.github.com/olbrichj/261c318625e956ab9ae901ac5b009125#file-operationqueue-swift
Создание операции также можно выполнить несколькими способами. Вы можете либо создать операцию с блоком, либо подклассом. В случае, если вы выполняете подкласс, не забудьте вызвать окончание (call finish), иначе операция никогда не остановится.
https://gist.github.com/olbrichj/f67fb9f5472151ee90995e0feeb2be40#file-customoperation-swift
Главное преимущество операций в том, что вы можете использовать зависимости (dependencies). Если операция A зависит от операции B, B не выполняется раньше A.
https://gist.github.com/olbrichj/6962c5271b233ee48fd378e4d0bdad86#file-operationdependencies-swift
Циклы (Run Loops)
Run Loops похожи на очереди. Система проходит по всем работам в очереди и затем начинает с начала. Например, экранная перерисовка выполняется в Run Loop. Следует отметить, что на самом деле это не методы создания конкурентности. Скорее, они привязаны к одному треду. Тем не менее, они позволяют вам запускать код асинхронно, в то же время облегчая вам понимание конкурентности. Не каждый тред имеет Run Loop, он создается при первом запросе.
При использовании Run Loops вам нужно учитывать, что они имеют разные режимы. Например, когда вы скроллируете, Run Loop основного треда меняет и задерживает все входящие события. Как только скроллинг остановится, Run Loop вернется в свое состояние по умолчанию и все события будут обработаны. В Run Loop всегда нужен управляющий источник, в противном случае все выполняемое на нем будет исполняться немедленно. Поэтому не забывайте об этом.
Легковесные функции (Lightweight Routines)
Существует новая идея получить действительно простые потоки. Она еще не реализована в Swift, но есть предложение в Swift Evolution (см. «Streams»).