Привет, Хабр! На связи Александр Пиманов и Камиль Ишмуратов, мы 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. А вы сталкивались с подобной проблемой? Если да, пишите в комментах ваши решения, будет интересно почитать) Ну и конечно, любой фидбек по статье приветствуется!