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

2. Actor Reentrancy


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


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

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

Есть человек(Person). У него есть друг и мнение человека(opinion). Есть две асинхронные функции - подумать о хорошем и о плохом - это позволяет зафиксировать своё мнение и рассказать его другу. Что будет выведено на экран в коде ниже?

actor Person {
    let friend: Friend
    var opinion: Judgment = .noIdea

    init(friend: Friend) {
        self.friend = friend
    }

    func thinkOfGood() async -> Decision {
        opinion = .goodIdea  // 1
        await friend.tell(opinion) // 2
        return opinion
    }

    func thinkOfBad() async -> Decision {
        opinion = .badIdea // 3
        await friend.tell(opinion) // 4
        return opinion
    }
}

// Запустим две задачи одновременно
async let goodThink = person.thinkOfGood()
async let badThink = person.thinkOfBad()

let (shouldBeGood, shouldBeBad) = await (goodThink, badThink)
print(shouldBeGood)
На экран выведется или .goodIdea или .badIdea

Теория и объяснение

На самом деле такое поведение логично для Actor. Почему? Напомню, что вызов await - это точка приостановки исполнения когда, на данном этапе формируется continuation и в этот момент _Actor освобождается и он сразу же берёт в работу другую задачу._ Это и приводит к высокоуровневым гонкам. Обсудим пример выше по шагам

/// Предположим первым попал на исполнение метод thinkOfGood(), тогда:
opinion = .goodIdea     // 1 - Меняем общее состояние await friend.tell(...)
await friend.tell(...)  // 2 - После чего происходит приостановка

/// В данный момент Actor берёт на исполнение другую задачу thinkOfBad()
opinion = .badIdea     // 3 - СНОВА Меняем общее состояние
await friend.tell(...) // 4 - После чего происходит приостановка

/// и теперь возвращается и продолжает своё исполнение thinkOfGood()
/// ей остаётся вернуть общий стейт и она возвращает неверный
Внимательно следите за общим состоянием Actor. Особенно если у него есть асинхронные функции.

Как с этим бороться?

  • Изменять стейт после вызова всех асинхронных функций
  • Реорганизовывать логику работы функций и освобождать Actor от async функций
  • Добавление в код Task

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

Посмотрите внимательно на код и ответьте, что не так в этом коде? И если есть ошибки, то как их исправить?

actor ActivitiesStorage {
    /// Изолированный кеш
    var cache = [UUID: Data?]()

    func retrieveHeavyData(for id: UUID) async -> Data? {
        if let data = cache[id] {
            return data
        }
        let data = await requestDataFromDatabase(for: id)
        cache[id] = data
        return data
  }

  private func requestDataFromDatabase(for id: UUID) async -> Data? {
        print("Выполняется загрузка из базы данных!")
        try? await Task.sleep(for: .seconds(7))
        return nil
    }
}

// Запуск кода
let id = UUID()
let storage = ActivitiesStorage()
Task {
    let data = await storage.retrieveHeavyData(for: id)
}

Task {
    let data = await storage.retrieveHeavyData(for: id)
}

Самая большая ошибка здесь заключается в том, что если мы запустим 2 функции одновременно, то есть большая вероятность сходить в базу данных 2 раза. То есть мы зашли в функцию - проверили кеш - там ничего - освободили Actor и ушли в базу. Аналогично при втором заходе в функцию. И так может продолжаться, до тех пор пока мы не положим значение в кеш. Как же нам необходимо изменить код?

actor ActivitiesStorage {
    /// Изолированный кеш
    var cache = [UUID: Task<Data?, Never>]()

    func retrieveHeavyData(for id: UUID) async -> Data? {
        /// Проверяем в кеше наличие задачи - если она есть
        /// то мы либо возвращаем значение, которое лежит в ней,
        /// либо ждём пока завершится выполнение задачи
        if let dataTask = cache[id] {
            return await dataTask.value
        }
        let dataTask = Task { 
            await requestDataFromDatabase(for: id)
        }
        cache[id] = dataTask 
        /// После чего уже вызываем await тем самым особождая эктор
        return await dataTask.value
    }

  private func requestDataFromDatabase(for id: UUID) async -> Data? {
        print("Выполняется загрузка из базы данных!")
        try? await Task.sleep(for: .seconds(7))
        return nil
    }
}

Мы с вами нашли прекрасное применение Task - она помогла нам решить проблему с Actor Reentrancy и сделать код безопасным и лаконичным.

Итого

  1. Синхронные функции на Actor выполняются синхронно, в этих функциях состояние корректное
  2. async функции на actor могут освобождать его от выполнения текущей задачи (при обнаружении ожидания), что дает возможность для запуска других задач на акторе - по возвращению стейт уже может быть другм.