Всем привет, на связи Никита и Технократия! В прошлой статье мы уже обсудили проблемы текущего состояния concurrency в Swift. Давайте двигаться дальше и сегодня мы начнем свое знакомство с необходимой базой для async/await в Swift 5.5

Для начала обратимся к Proposal SE-0296, в котором рассказывают базовые аспекты нового подхода.

Сфокусируемся на одном из первых предложений: «This design introduces a coroutine model to Swift» и заметим новое для большинства iOS-разработчиков словосочетание coroutine model.

Что же это такое? Давайте разбираться.

Терминология

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

Multitasking и Multithreading

Multitasking -  это возможность программы работать над разным задачами, task-ами, в одну единицу времени.

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

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

На картинке приведен пример с операционной системой, на которой запущены сразу три программы - Pages, Safari, Xcode. Мы можем сказать, что у операционной системы есть 3 задачи, которые она выполняет, что делает ее многозадачной.

Помимо это многозадачность бывает двух видов: preemptive и cooperative.

1. Preemptive - система может остановить выполнение текущего процесса и отдать выполнение другому процессу, в таких случаях происходит вытеснение (preempting). 

Пример: Threads, а именно context switch, когда меняется поток, задача приостанавливает свое выполнение в месте, неизвестном для для нас заранее. Проиллюстрируем этот пример. В нашей визуальной интерпретации процессор (система) сам меняет потоки со своими стеками:

2. Cooperative - система останавливает выполнение текущего процесса в заранее известных местах, когда процесс (задача) может «добровольно» отдать управление. 

Пример: Новая поточка (coroutine) ???? Проиллюстрируем по аналогии - здесь наши задачи сами отдают управление системе, которая сама решает, что делать с ней дальше - продолжить выполнение задачи или отложить ее.

Multithreading - это возможность программы исполняться на нескольких потоках, thread-ах, одновременно. Если рассматривать приложение Xcode, то отрисовка UI, компилирование кода, работа с git-ом, все это может происходить на разных потоках, между которыми будет переключаться процессор

Concurrency и Parallelism

Parallelism - возможность программы исполнять несколько независящие друг от друга задачи в один момент времени. 

Concurrency - возможность программы выполнять задачи в перекрывающиеся периоды времени. 

Проще всего понять их разницу с помощью картинок.

Concurrency:

Parallelism:

Теперь мы разобрались, что Multitasking != Multithreading и Concurrency != Parallelism

История модели корутин

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

С течением времени необходимость в параллелизме возрастала, а текущая модель не удовлетворяла по производительности. Таким образом мы и вернулись к новой, но на самом деле, хорошо забытой, модели корутин. На сегодняшний день уже большое количество языков добавила возможность работы с ними. Имплементацию данной технологии можно найти, например, в Kotlin, Scala, C#,  а теперь и в Swift.

Устройство

Так как существуют coroutine, значит, что существуют и routine. Давайте разберемся сначала с ними.

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

Рассмотрим на примере:

func routine() -> Int {
    var result = 0
    for i in 0...1_000 {
        result += i
    }
    return result
}

У нас есть функция routine, которая создает переменную, прогоняет цикл 1000 раз и добавляет индекс итерации к числу, после чего возвращает его. Она легка для понимания и, если посмотреть ее низкоуровневую интерпретацию, то выполнение инструкций происходит последовательно, вызовы хранятся на стеке с привязкой к конкретному потоку, на котором началось исполнение. 

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

У Coroutine есть, так называемые, suspension points - возможные точки остановки выполнения функции, в Swift они отмечены словом await. Почему «возможные»? Потому что все зависит от конкретного момента времени, загруженности приложения и выбору Runtime. Мы можем либо ожидать завершение подзадачи, либо решить ее сразу.

Пример

Попробуем решить в лоб задачу с приостановкой выполнения функции. Для этого создадим routineSleep и  coroutineSleep с новым синтаксисом.

func routineSleep() {
    print("Start")
    Thread.sleep(forTimeInterval: 10) // Поток "засыпает" на 10 секунд
    print("Finish")
}

func coroutineSleep() async {
    print("Start")
    try? await Task.sleep(nanoseconds: 10_000_000_000) // Задача "засыпает" на 10 секунд
    print("Finish")
}

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

Если запустить первую функцию на main потоке, то интерфейс нашего приложения зафризит на 10 секунд, а во втором случае - нет. Это происходит из-за того, что наша функция routineSleep потокозависимая, а  Thread.sleep блокирует наш поток  для других задач на указанное время.

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

Можно заметить, что выполнение coroutine может быть продолжено на совершенно другом потоке, в том числе и на стартовом потоке или даже на main, если повезет. Попробуем проверить это и добавим вывод потока до «сна» и после: 

func coroutineSleep() async {
	print(Thread.current)
    try? await Task.sleep(nanoseconds: 10_000_000_000)
	print(Thread.current)
}

Запустив данный код, мы получим следующий лог в консоли: 

<NSThread: 0x6000020ee100>{number = 5, name = (null)}
<NSThread: 0x6000020322c0>{number = 8, name = (null)}

Да, действительно, в процессе выполнения функции поток был изменен ????

В чем преимущества над Thread model?

У сoroutine функций свой скоуп, поэтому она не зависят от потока и умеет сохранять состояние, «засыпая» в определенных местах. Благодаря этому мы спокойно может откладывать их выполнение в runtime и запускать их на потоке в нужное для нас время, руководствуясь приоритетом задачи. Этим все занимается обновленный Swift Runtime с Executor-ом. 

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

Таким образом: 

  1.  Становится легче писать асинхронный код.

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

  3. Появляется гибкая многозадачность.

  4. Уменьшается количество смены исполняемых Thread-ов, context switch.

  5. Уменьшается количество использований механизмов синхронизации, блокирующих поток.

Вывод

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

В следующей статье начнем знакомиться с ее имплементацией, познакомимся с синтаксисом async/await и разберем его на примерах.

Никита Сосюк

iOS Разработчик Технократии

Также подписывайтесь на наш телеграм-канал «Голос Технократии». Каждое утро мы публикуем новостной дайджест из мира ИТ, а по вечерам делимся интересными и полезными мастридами.

Комментарии (5)


  1. DevilDimon
    15.06.2022 09:20

    Как введение статья хорошая, однако разбор понятий concurrency и parallelism здесь приделан как-то сбоку и вообще непонятно, зачем нужен.

    То же самое про корутины. В официальной документации и литературе по теме (Modern Swift Concurrency от Ray Wenderlich, например) этот термин не используется.

    В голове от этого может сложиться ложное впечатление о связи свифтовского async/await с корутинами в Go и Kotlin, когда как на деле кроме синтаксиса в них мало что похоже.


    1. technokratiya Автор
      15.06.2022 10:08

      Разбор определений нужен был для более подробного описания модели и для использования этих терминов в будущих статьях.

      В первом абзаце есть ссылка на proposal, в котором упоминается о использовании данной модели в Swift 5.5 Если обратиться к википедии, то мы увидим следующую терминологию:

      "Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed."

      Swift с новой поточкой подходит под все характеристики, а именно: У нас non-preemptive multitasking, мы можем приостанавливать задачи и продолжать их выполнение.

      Возможно, мы ошибаемся, тогда хотелось бы услышать, в чём фундаментально заключается отличие async/await от Kotlin.

      Никита Сосюк, автор статьи


      1. DevilDimon
        16.06.2022 03:25

        В официальной документации, сессиях WWDC и книге SPL термин "корутина" не встречается. Думаю, неспроста. Не сбивайте людей, потому что корутины в Свифте официально так не называются.

        Википедию может редактировать кто угодно, это не аргумент. Многозадачность у них все-таки вытесняющая - в любой момент выполняющийся на ядре код может быть приостановлен операционной системой. Не забывайте, что Darwin не DOS. Вытеснения нет только от других таких же "partial tasks", и кооперация у них только между собой, а не на уровне потоков ОС.

        Фундаментального отличия от Kotlin нет, но есть мелкие, которые очень важны - компиляция (аналога Sendable, насколько я понял, нет, то есть компилятор полезть в shared state запретить не сможет, особенно если через java/инструментацию), в Свифте нельзя управлять Executor'ами, в отличие от котлина (попробуйте, например, заставить таски выполняться на нужных потоках в нужном порядке - в Свифте это сейчас невозможно), ну и вместо каналов используется совсем иная идея с акторами.

        Может, в чем-то неточен, на котлине ежедневно не пишу, но в целом различия для меня достаточно разительные и влияют на ежедневную мобильную разработку непосредственно - точно управлять потоками в тестах, например, необходимо очень часто. Вы пишете "мы можем приостанавливать задачи и продолжать их исполнение", но на деле в Свифте "мы" мало что можем, все partials отдаются компилятору как есть и влияние на них программиста ограничивается cancel'ом и yield'ом. В котлине поближе к народу все-таки.


        1. NikitaShelov
          16.06.2022 13:08

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

          Однако есть пару моментов: 

          1. Согласно propasal новая поточка базируется (берет основы) этой модели. В рамках данной статьи мы рассказали именно о принципах работы модели корутин. Четкой аналогии с языком Swift я не проводил и не называл асинхронные функциями в Swift корутинами. 

          2. Давайте все-таки не спускаться до устройства операционной системы, чтобы оставить низкий порог входа у статьи. Мною сделан вывод о том, что новая поточка реализует cooperative multitasking, исходя из того, что мы сами указываем, в какой момент выполнения может остановиться наша функция. Безусловно, система сама управляет потоками, решать таким моменты у разработчика нет возможности. Однако раньше мы могли лишь гадать, а сейчас указываем эти места благодаря новому синтаксису ????

          3. Сама модель общая (остановка и продолжение выполнения задач, стек вызовов и т.д.) Различия, безусловно, есть. Решения разных языков не идентичны и на это есть свои причины. Специфику Swift мы разберем в следующих статьях (Sendable и т.п).


          Сама статья как раз подводит к тому, как реализована работа с async/await в Swift, а именно к реализации Task и всему соответствующему. 

          Предлагаю дождаться следующей статьи и обсудить там. 

          Спасибо за комментарий!


          1. DevilDimon
            16.06.2022 16:29

            Благодарю за ответ! Жду следующих статей. В интернете сейчас мало информации по теме, за пределами простейших примеров мало кто ходит, а в продакшене и того меньше можно встретить.