Перейти к содержанию

2. Task(Detached) техническое определение


Изучение данного блока предполагает предварительное знание синтаксиса языка Swift. Для успешного освоения этого материала, необходимо иметь базовое понимание синтаксиса языка Swift. Это включает в себя знание основных структур данных, операторов, циклов, функций, абстракций и других ключевых элементов языка. Без этих фундаментальных знаний будет сложно понять более сложные концепции и примеры, которые будут рассматриваться в данном блоке.


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

Создание задачи

Для тех, кто знаком с GCD: Не стоит воспринимать создание задачи как аналог DispatchQueue.global().async - это совсем не так. Это не просто перенос работы на бекграунд поток - здесь наследуется контекст и не всегда происходит переход на бекграунд поток, поэтому иногда тяжёлые задачи могут попасть на главный поток и привести к фризам.

В Structured Concurrency eсть 2 способа создать задачу:

1. Создание родительской(Detached) таски.

/// Создаётся задача верхнего уровня, которая выкидывается из текущей иерархии задач,
/// Ответственность за задачу (отмену, локальное хранилище) будет лежать на разработчике
/// - Parameters:
///   - priority: приоритет задачи, по умолчанию - medium
///   - operation: код, который будет выполняться в таске
Task.detached(priority: TaskPriority?, operation: () async -> _)

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

Обработка нажатия на кнопку в контроллере

final class ViewController: UIViewController {

   @objc
   private func handleTapOnButton(_ sender: UIButton) {
      /// Здесь главный поток, на котором мы можем например показать спиннер
      showLoadingIndicator()
      /// Здесь всё также главной поток и мы начинаем скачивать картинку
      fetchImage(by: 1) 
   }

   func fetchImage(by id: Int) {
       /// Создание родительской задачи, необходимо для того, чтобы уйти
       /// с главного исполнителя на Глобальный, чтобы не перегружать его
       let task = Task.detached { 
       /// Внутри уже будет не главный исполнитель - а другой
       }
   }
}

2. Создание дочерней задачи. Task.

/// Создаётся задача, которая укладывается в текущую иерархию задач.
/// Так как задача будет находится в иерархии задач, значит она будет наследовать
/// исполнителя, его контекст, локальное хранилище.
/// - Parameters:
///   - priority: приоритет задачи, по умолчанию - medium
///   - operation: код, который будет выполняться в таске
Task(priority: TaskPriority?, operation: () async -> _)

Рассмотрим аналогичный пример:

final class ViewController: UIViewController {

    @objc
    private func handleTapOnButton(_ sender: UIButton) {
        /// Здесь главный поток, на котором мы можем например показать спиннер
        showLoadingIndicator()
        /// Здесь всё также главной поток и мы начинаем скачивать картинку
        fetchImage(by: 1)   
    }

    func fetchImage(by id: Int) {
        /// Создание родительской задачи, необходимо для того, чтобы уйти
        /// с главного исполнителя на Глобальный, чтобы не перегружать его
        let task = Task { 
            /// Здесь останется главный исполнитель, потому что задача наследует контекст
        }
    }
}

Задача с собеседования

Следующая задача предлагается на собеседованиях уровня Junior-Middle.

Есть два экрана. На одном контроллере есть кнопка, которая презентует TaskExampleViewController. В методе viewDidLoad, которого выполняется очень долгая задача. Не дожидаясь завершения задачи, пользователь смахивает экран вниз.

  • Что выведется в консоль?
  • Если что-то не так, то как это поправить?
import UIKit

final class TaskExampleViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .green
        print("TaskExampleViewController", #function)
        doHardWork()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        print("TaskExampleViewController", #function)
    }

    deinit {
        print("TaskExampleViewController", #function)
    }

    private func doHardWork() {
        Task {
            var array = [Int]()
            (0...100_000_000).forEach {
                array.append($0)
            }
            print(array.count)
        }
    }
}

Ответ

  1. Выведется только TaskExampleViewController* viewDidLoad***, а вот дальше произойдёт фриз UI главного потока. Весь наш интерфейс заблокируется пока не завершится выполнение задачи.
  2. Если мы получили фриз UI, значит код внутри задачи попал на главный исполнитель и стал выполняться на главном потоке, тем самым заблокировав какое-либо изменение интерфейса. Чтобы избежать этого, необходимо использовать родительскую задачу. И тогда она не будет наследовать контекст главного исполнителя и в консоль выведутся все три строки.

После данной задачи возникает вопрос. О каком главном исполнителе идёт речь?

У нас нет помеченных функций и нигде явно не указан исполнитель.

Указание исполнителей

Класс/структура/протоколы

  • Главный исполнитель

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

    import UIKit

    @MainActor
    final class MyClass { ... }

    @MainActor
    struct ScreenState { ... }

    @MainActor
    protocol ScreenDisplayLogic { ... } /// методы, содержащиеся в данном протоколе - работают с отрисовкой, поэтому им нужен главный поток.
С этим понятно, но наш контроллер же не помечен @MainActor - откуда у него главный контекст?

Здесь стоит обратить внимание на то от кого наследуется наш контроллер, и какой метод мы предопределяем. Мы не будем сейчас в это детально углубляться - рассмотрим это в теме Actors.

  • Глобальный исполнитель

Если же мы просто создаём класс - то он по умолчанию имеет @globalActor. Но писать его не имеет никакого смысла, поэтому он скрыт от глаз разработчика.

Поэтому ещё одним из вариантов решения задачи из собеседования будет создание какого-то сервиса. Опишем логику ниже:

    /// Сервис, который будет выполнять сложные задачи
    final class HardWorkService {

        func doHardWork() {
            Task {
                var array = [Int]()
                (0...100_000_000).forEach {
                    array.append($0)
                }
                print(array.count)
            }
        }
    }

    final class TaskExampleViewController: UIViewController {

        let service = HardWorkService()

        override func viewDidLoad() {
            super.viewDidLoad()
            view.backgroundColor = .green
            print("TaskExampleViewController", #function)
            doHardWork()
        }

        override func viewDidDisappear(_ animated: Bool) {
            super.viewDidDisappear(animated)
            print("TaskExampleViewController", #function)
        }

        deinit {
            print("TaskExampleViewController", #function)
        }

        private func doHardWork() {
                /// Здесь главный контекст, но когда мы обращаемся к сервису 
                /// и переходим в его для исполнения кода, то код метода уже будет
                /// иметь исполнителя класса
            service.doHardWork()
        }
    }
    /// В этом случае на экран выведуться все 3 принта

  • Создание своих Actor.

Мы можем создавать свои Actors и помечать ими класс/структуры/протоколы и другие сущности. Но к этой задаче мы подойдём в разделе Actor

Функции

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

/// Сервис, который будет выполнять сложные задачи
final class HardWorkService {

    @MainActor
    func doHardWork() {
        Task {
                /// Здесь будет главный поток
            var array = [Int]()
            (0...100_000_000).forEach {
                array.append($0)
            }
            print(array.count)
        }
    }
}

Итог

При создании задачи, нам всегда важно обращать внимание на:

  • Где создаётся задача?
  • Какой контекст имеет класс, в котором она находится или функция?
  • Есть ли у класса родительский класс и какой у него контекст?
  • При создании родительской задачи - не забывать, что ответственность по управлению задачей лежит теперь на нас.

💡 Рекомендация

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