Привет, Хабр! На связи Александр Пиманов и Камиль Ишмуратов, мы iOS-разработчики в IBS. В наших проектах мы активно используем новые технологии и стараемся покрывать наш код unit-тестами. В этой статье мы расскажем о проблемах тестирования асинхронного кода и как их можно попытаться решить.

Проблемы тестирования

Начиная со Swift 5.5, мы получили прекрасный инструмент, который позволяет писать асинхронный код, используя приятный и лаконичный синтаксис. И с самого начала были предоставлены базовые инструменты для его тестирования, но в повседневной работе эти инструменты не позволяют нам охватить все ситуации.

На одном из проектов мы столкнулись с тем, что получали сотни упавших тестов при использовании async/await, хотя проблем с этим кодом не было никаких. Мы надеялись, что на WWDC 23 Apple наконец представит инструмент для написания надежных тестов асинхронного кода, но этого не произошло, и мы все еще остаемся наедине с нашими проблемами.

Для демонстрации тестов мы будем использовать этот вспомогательный класс:

public final class Isolated<Value>: @unchecked Sendable {
    private var _value: Value
    private let lock = NSRecursiveLock()
    
    public init(_ value: @autoclosure @Sendable () throws -> Value) rethrows {
        self._value = try value()
    }
    
    public func withValue<T: Sendable>(_ operation: (inout Value) throws -> T) rethrows -> T {
        try self.lock.withLock {
            var value = self._value
            defer { self._value = value }
            return try operation(&value)
        }
    }
}
extension Isolated where Value: Sendable {
    public var value: Value {
        self.lock.withLock {
            self._value
        }
    }
}

Давайте начнем с такого теста:

func testAsyncBasic() async {
        let values = Isolated([String]())
        
        let task = Task {
            values.withValue { $0.append("World") }
        }
        values.withValue { $0.append("Hello") }
        await task.value
        
        XCTAssertEqual(values.value, ["Hello", "World"])
}

Он успешно проходит в большинстве случаев. 

Test Suite 'AsyncTestsTests' passed at 2023-08-03 16:32:21.053.

Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.004) seconds

Но если запустить его, к примеру, 1000 раз, то получим такой результат:

Test Suite 'AsyncTestsTests' failed at 2023-08-03 16:31:42.126.

Executed 1000 tests, with 7 failures (0 unexpected) in 4.992 (9.582) seconds

А если использовать task group? Мы внутри task group добавляем задачи и проверяем, что результат получился в том порядке, который нам нужен.

func testTaskGroup() async {
        let values = await withTaskGroup(of: [String].self) { group in
            group.addTask { ["Hello"] }
            group.addTask { ["World"] }
            
            return await group.reduce(into: [], +=)
        }
        
        XCTAssertEqual(values, ["Hello", "World"])
}

Получаем такое же успешное прохождение теста в единичном запуске:

Test Suite 'AsyncTestsTests' passed at 2023-08-03 16:33:16.817.

Executed 1 test, with 0 failures (0 unexpected) in 0.006 (0.007) seconds

При 1000 запусков результаты уже такие:

Test Suite 'AsyncTestsTests' failed at 2023-08-03 16:34:22.915.

Executed 1000 tests, with 66 failures (0 unexpected) in 1.799 (2.477) seconds

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

func testTaskGroupOrder() async {
      let values = await withTaskGroup(of: [Int].self) { group in
        for n in 1...100 {
          group.addTask { [n] }
        }
        return await group.reduce(into: [], +=)
      }
      XCTAssertEqual(values, Array(1...100))
 }

Аналогично проведем тестирование один раз и 1000 раз.

Однократное выполнение:

Test Suite 'AsyncTestsTests' passed at 2023-08-03 16:38:14.266.

Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.004) seconds

С 1000 выполнений результат совсем удручающий:

Test Suite 'AsyncTestsTests' failed at 2023-08-03 16:39:51.351.

Executed 1000 tests, with 921 failures (0 unexpected) in 31.802 (46.776) seconds

Разбираемся с причинами

Получается, что мы не можем зависеть от порядка начала выполнения задач, будь то unstructured task или task group. То же самое касается и async let. И если порядок начала выполнения задач четко не определен, то это означает, что также нет никаких гарантий относительно того, как асинхронная работа чередуется между другими задачами.

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

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

И тут вырисовывается вопрос: а можно ли вообще с этим бороться? Если да, то как? Итак, ответ — ДА! А как именно, мы сейчас и расскажем.

Мы можем полностью изменить порядок постановки задач в очередь и отправлять их обратно в исходную очередь, что означает, что поведение, по сути, не изменилось, по сравнению с дефолтной реализацией. Но у нас есть возможность отправить эти задачи другому executor (либо исполнителю), например, кастомному исполнителю, или даже вывести все задачи на главный экзекутор. 

Что вообще за Executor, о котором мы вам «втираем», спросите вы. Так вот в Swift есть такой протокол, который, по правде говоря, не особо популярный в комьюнити, поэтому про него мало кто знает, да и в документации Apple не особо много инфы о нем.

Вот так этот «зверь» выглядит в коде:

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public protocol Executor : AnyObject, Sendable {
    func enqueue(_ job: UnownedJob)
}

Как мы уже говорили, об этом протоколе чрезвычайно мало информации, кто-то даже называет его «мистическим» и относит к будущему параллелизма в Swift. Скажем больше, нет никаких публично-доступных соответствий данному протоколу)). Как тогда вообще мы можем его получить? Ответ на этот вопрос даст нам Actor, а именно MainActor. Этот товарищ имеет связанного с ним исполнителя, что мы можем увидеть, взглянув на определение протокола GlobalActor:

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
public protocol GlobalActor {
    associatedtype ActorType : Actor
    static var shared: Self.ActorType { get }
    static var sharedUnownedExecutor: UnownedSerialExecutor { get }
}

Но и здесь кроется подвох)). Обратите внимание на последнюю статическую переменную. Ее тип — UnownedSerialExecutor, однако, несмотря на свое название (которое включает слово Executor), он даже не конформит данный протокол)). Мистика чистой воды, убедитесь сами:

@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *)
@frozen public struct UnownedSerialExecutor : Sendable {
    @inlinable public init<E>(ordinary executor: E) where E : SerialExecutor
}

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

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

Круто! Но как нам получить нашего исполнителя? Через синглтон нашего актора:

MainActor.shared.tunownedExecutor

Но, как мы уже говорили, это не настоящий исполнитель, и мы ничего не можем с ним сделать. Однако, мы можем сделать следующее:

MainActor.shared.enqueue(job: UnownedJob)

Это выглядит очень похоже на executor в том смысле, что мы можем ставить в очередь неиспользуемые таски, но на самом деле это не он. Это просто метод в MainActor, имитирующий интерфейс executor, и под капотом он действительно вызывает executor, но все это скрыто от нас глубоко в дебрях, да еще и на С++.

А теперь перейдем к тестированию.

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

Правим тесты

Для начала нам нужен глобальный хук:

typealias Original = @convention(thin) (UnownedJob) -> Void
typealias Hook = @convention(thin) (UnownedJob, Original) -> Void

var swift_task_enqueueGlobal_hook: Hook? {
  get { _swift_task_enqueueGlobal_hook.pointee }
  set { _swift_task_enqueueGlobal_hook.pointee = newValue }
}

private let _swift_task_enqueueGlobal_hook =
  dlsym(dlopen(nil, RTLD_LAZY), "swift_task_enqueueGlobal_hook")
    .assumingMemoryBound(to: Hook?.self)

Давайте с учетом новых вводных немного изменим наш первый тест:

func testAsyncBasic() async {
        swift_task_enqueueGlobal_hook = { job, _ in
            MainActor.shared.enqueue(job)
        }
        
        let values = Isolated([String]())
        
        let task1 = Task {
            values.withValue { $0.append("Hello") }
        }
        let task2 = Task {
            values.withValue { $0.append("World") }
        }
        await (task1.value, task2.value)
        
        XCTAssertEqual(values.value, ["Hello", "World"])
 }

Test Suite 'AsyncTestsTests' passed at 2023-08-08 11:42:48.305.

Executed 1000 tests, with 0 failures (0 unexpected) in 0.960

Мы видим, что из 1000 запусков теста, провалилось 0. Какие выводы мы можем из этого сделать? А вот такие: глобальный исполнитель управляет целым пулом потоков, с которым он может работать, и использует сложный механизм, чтобы выяснить, как и когда назначать задачи различным потокам. Это означает, что, хотя существует четко определенный порядок постановки в очередь, не существует четко определенного порядка выполнения этих задач в очереди.

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

Теперь перейдем к следующему тесту, добавив к нему код из предыдущего:

func testTaskGroupOrder() async {
        swift_task_enqueueGlobal_hook = { job, _ in
            MainActor.shared.enqueue(job)
        }
        
        let values = await withTaskGroup(of: [Int].self) { group in
            for n in 1...100 {
                group.addTask { [n] }
            }
            return await group.reduce(into: [], +=)
        }
        XCTAssertEqual(values, Array(1...100))
}

Test Suite 'AsyncTestsTests' passed at 2023-08-08 11:47:43.292.

Executed 1000 tests, with 0 failures (0 unexpected) in 3.942

Это действительно удивительно, потому что ранее мы видели (без волшебного переопределения «глобального хука»), что этот тест провалился примерно в 80% случаев. Запуск такого количества дочерних задач очень редко приводил к ситуации, когда задачи запускались в том порядке, в котором они были созданы. Таким образом, даже порядок начала групп задач можно сделать предсказуемым, если мы переопределим глобальный механизм постановки в очередь.

Давайте подведем итог всему тому, о чем шла речь выше. Мы можем предсказать то, в какой последовательности добавляются в очереди наши задачи, однако совершенно нельзя гарантировать, что такой порядок будет соблюдаться при их выполнении. Поэтому если вам важен именно сам порядок, переопределяйте глобальный механизм постановки в очередь задач. Так вы сможете взять под контроль то, как выполняются асинхронные задачи в Swift. Перенаправляя все задачи основному последовательному исполнителю, мы получаем возможность предсказывать, как они ставятся в очередь, чередуются и выполняются, и это позволит написать 100% детерминированные, проходящие тесты. Мы, в свою очередь, будем ждать от Apple более прозрачной реализации протокола Executor и GlobalActor, так как, по нашему мнению, это очень сильное API, которое позволяет тестировать асинхронный код с точки зрения детерминированности.

P. S. А вы сталкивались с подобной проблемой? Если да, пишите в комментах ваши решения, будет интересно почитать) Ну и конечно, любой фидбек по статье приветствуется!

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


  1. Grigorii_K
    24.08.2023 16:54

    Появление протоколов Executor/SerialExecutor было одним из этапов развития идеи назначения кастомных исполнителей акторам в будущем, что в некотором виде уже реализовано в Swift 5.9

    Не увидел в примерах использование XCTestExpectation, который вроде как раз предназначен для тестирования асинхронного кода. Не пробовали?


    1. IBS_habrablog Автор
      24.08.2023 16:54

      Спасибо за коммент, да вы правы, что идея данного протокола - изменить или расширить функционал асинхронного программирования, в частности тестирования такого кода. Пока Swift 5.9 все же находится на стадии бета версии, поэтому мы постарались показать, как это можно использовать в будущих версиях языка в рамках текущей. Насчет XCTestExpectation, мы используем его в одном из наших тестов, и честно говоря, он не отличается большой производительностью и высоким прохождением. Да, вариант неплохой, но все равно не гарантирует успешное прохождение тестов в большинстве случаев.


  1. Grigorii_K
    24.08.2023 16:54
    +1

    Невольно закрадывается мысль, что было бы достаточно просто отключить параллельное выполнение тестов.


    1. IBS_habrablog Автор
      24.08.2023 16:54

      Параллельность отключать - не очень идея. Во-первых, это не решает проблему, к сожалению, а во-вторых, можно словить ошибку Xcode: An unknown error occurred when referencing the test plan, и тогда все отвалится))


  1. ws233
    24.08.2023 16:54

    А не проще ли было просто использовать Set вместо массива и сравнивать Set?


    1. IBS_habrablog Автор
      24.08.2023 16:54

      А что если нам важен порядок, который Set не может предоставить? Мы упоминали об этом в статье.


  1. NineNineOne
    24.08.2023 16:54

    Я правильно понял, что в статье показали как "поправить тесты", чтобы они всегда проходили успешно?

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

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

    Заранее благодарю за ответы


    1. IBS_habrablog Автор
      24.08.2023 16:54

      Можно написать собственный сервис, где переопределить глобальный хук, и сделать это через синглтон, например, ибо вряд ли у вас будет еще подобный сервис в приложении. И уже оттуда дергать методы для тестов. А насчет "такого количество работы", здесь примеры были для наглядности, чтобы показать как будут вести себя тесты при количестве их исполнения больше 1)) А ничто так не показывает реальную статистику, как большие цифры). Кстати, у Apple есть подобный механизм под капотом, но он работает через раз https://github.com/apple/swift/issues/63104
      P. S. Не говорю, что синглтон - панацея, ибо ухудшает тестирование в теории, можно обойтись и без него. Мы хотели донести идею написания собственного сервиса.


  1. yu_vl
    24.08.2023 16:54

    Возможно так было бы проще :)

    @MainActor
    func testAsyncBasic() async {...}

    Ну, или так, например

    Task { @MainActor in
     ...
    }


    1. IBS_habrablog Автор
      24.08.2023 16:54

      В целом если указать @MainActor внутри таски, а не помечать целиком весь метод как @MainActor, то работает плюс минус неплохо, однако тем не менее производительность у хука выше.