Тестирование потокобезопасных ресурсов и актеры
При работе над большими приложениями нам часто приходится обмениваться данными между различными компонентами. Независимо от того, являются ли они модулями или просто экранами, им может потребоваться чтение и запись данных из одного и того же источника.
Это создает проблему: если одна и та же структура даты используется в разных потоках, нам нужно убедиться, что они не обращаются к данным в одно и то же время. В противном случае мы можем столкнуться с различными проблемами:
- Обновления могут поступать в другом порядке (проблема только в том случае, если мы ценим порядок).
- Мы можем пропустить некоторые обновления.
- Приложение может вылететь.
Когда структура данных реализована таким образом, что предотвращает эти проблемы, мы называем ее поточно-безопасной структурой данных.
Существуют различные способы обеспечения безопасности потоков во всех версиях iOS. Начиная с Swift 5.5, actors
передайте эту возможность непосредственно компилятору. Однако актеры будут доступны только для iOS 13 после выпуска Xcode 13.2.
В сегодняшней статье я хотел бы показать, как протестировать потокобезопасное хранилище, совместимое со всеми версиями iOS. После этого мы увидим, как упростить тесты и код с помощью акторов.
Базовое поведение хранилища
Начнем с определения базового интерфейса Storage
. Нам нужно установить значение для ключа и получить значение, связанное с ключом.
Мы хотим применить подход Разработка через тестирование (TDD), поэтому давайте начнем писать наш первый тест:
Это самый простой тест, который мы можем написать для операции get
.
Тест не будет построен, потому что класса Storage
еще не существует. Итак, давайте напишем это, чтобы пройти тест.
Это минимальный код, необходимый для прохождения теста. Помните, что в TDD мы выполняем три шага:
- Мы создаем провальный тест.
- Мы пишем минимальный код, чтобы он прошел.
- В конце концов, мы рефакторим код, чтобы сделать его более читабельным и удобным для сопровождения.
Сейчас проходит тест для метода get
. Давайте добавим новый тест для метода set
.
Этот тест не скомпилируется, потому что у Storage
нет метода set
. Давайте обновим класс минимальным кодом, необходимым для его работы.
Мы сохраняем String
, который начинается с nil
. Если мы запустим тесты, они оба пройдут. Это важное наблюдение: с помощью TDD мы добавляем функциональные возможности, чтобы убедиться, что то, что мы ранее реализовали, продолжает работать.
В третьем тесте мы проверяем, можем ли мы добавить несколько пар ‹ключ, значение› и что мы можем получить правильное значение для правильного ключа.
В этом тесте мы используем случайные значения, чтобы не использовать жестко заданные значения. Если мы запустим тест сейчас, он не пройдёт, потому что мы запоминаем только одно значение за раз, а второе set
изменяет сохраненное значение.
Мы можем обновить наш тип, чтобы использовать словарь для связывания значений с ключами.
С этим изменением все тесты пройдены.
Нас устраивает базовое поведение класса. Теперь давайте проверим потокобезопасность.
Добавить потокобезопасность
Чтобы имитировать многопоточную среду в тестах, мы можем асинхронно отправить несколько заданий на DispatchQueue
и использовать DispatchGroup
, чтобы дождаться их завершения. Эти API являются частью платформы под названием Grand Central Dispatch (GCD).
Тест выглядит так:
Данный тест состоит из следующих блоков:
- Мы объявляем
sut
,dispatchGroup
иexpectation
.expectation
требуется дождаться завершения всех потоков. - Мы создаем 100 потоков, используя функцию
DispatchQueue.global().async
внутри цикла. Перед отправкой единицы работы мы проверяем группу с помощью функцииdispatchGroup.enter()
. - Работа потока заключается в
set
паре ключ-значение в нашем хранилище. После его установки мыexit
из группы. - Наконец, мы просим группу подождать, пока все потоки внутри нее прекратят свою работу. Это делается с помощью функции
dispatchGroup.notify()
. Как только это происходит, мы оправдываем ожидания. - На последнем шаге мы утверждаем, что все ключи вставлены в файл
Storage
.
Если мы запустим тесты сейчас, они вылетят со следующей ошибкой
Несколько потоков пытаются set
значение в словаре одновременно , и Swift не знает, как это сделать.
Чтобы это исправить, нам нужно обновить код нашего хранилища. Существует несколько способов обеспечения безопасности потоков. Самый простой и читаемый — синхронизировать доступ к ресурсу с помощью приватного DispatchQueue
: по умолчанию очереди отправки выполняют одну операцию за раз, и никакая другая операция не выполняется, пока предыдущая не будет завершена. Принята политика «первым пришел, первым обслужен» (FIFO): обслуживается первый поступивший запрос.
Теперь код выглядит так:
В строке 4 мы объявили частный DispatchQueue
и дали ему имя storage.synchronization
. Мы используем его в строке 7, заключая тело set
в метод sync
.
Если мы запустим тесты сейчас, они пройдут без сбоев.
Обеспечение согласованности
Хотя теперь код выглядит хорошо, он стабилен и не дает сбоев, у нас все еще есть проблема согласованности. Представьте, что мы хотим:
- Чтение значения.
- Измените его.
- Сохраните его снова.
Что произойдет, если другой поток изменит значение, связанное с тем же ключом, между шагами 1 и 2? Первый поток обновит старое значение, а обновление второго потока будет потеряно.
Мы можем протестировать этот вариант использования с помощью следующего теста:
Тест немного сложный, поэтому давайте разберем его шаг за шагом.
- Первая часть кода подготавливает тестовые переменные и
Storage
с сотней пар ключ-значение. - Затем мы создаем группу, которая обновляет значения, добавляя суффикс
_read
. Для этого вводимdispatchGroup
, выполняем работу и выходим из нее. - Мы создаем второй набор потоков, который одновременно добавляет суффикс
_disturb
к той же паре ключ-значение, чтобы имитировать кучу одновременных обновлений. - Мы создаем
expectation
и ждем завершенияdispatchGroup
. - Наконец, мы пишем наши утверждения. Два набора потоков выполняются одновременно, поэтому мы не знаем, в каком порядке они будут выполняться. Утверждение проходит независимо от того, выполняем ли мы сначала «append
_read
» или сначала «appenddisturb
». Мы не можем смириться с тем, что одна из двух операций будет потеряна.
Если мы запустим тесты сейчас, они могут вылететь с той же ошибкой, с которой мы столкнулись при установке значения.
Несколько потоков пытаются читать из словаря, в то время как другие потоки пытаются записывать в него. Опять же, Swift не знает, как с этим справиться. Мы можем обернуть операцию чтения в sync
вызове dispatchQueue
.
Единственное изменение происходит в строке 13: мы завернули операцию чтения в метод sync
(метод sync
может возвращать то же значение, что и при его закрытии).
Если мы запустим тесты сейчас, они не рухнут, но и не пройдут. Утверждение не удалось: мы теряем некоторые операции, потому что потоки из разных групп читают одни и те же переменные, а затем пытаются их обновить. Чтобы исправить это, нам нужно ввести другую операцию: update
.
Для этой операции требуется замыкание, в котором Storage
передает значение замыканию. Затем вызывающий объект может работать в заблокированном контексте, и это предотвращает доступ к нему других потоков.
Давайте сначала обновим тесты, следуя подходу TDD:
Тест точно такой же, но мы заменили операции get
и set
на операцию update
.
Код не будет собран, потому что нам не хватает метода в классе. Добавим.
Как описано, функция обновления принимает key
, который мы хотим обновить, и замыкание, которое предоставляет новое значение для этого ключа, передавая старое, если оно есть. Затем результат закрытия устанавливается как новое значение. Все завернуто в метод dispatchQueue.sync
для обеспечения потокобезопасности.
Если мы, наконец, запустим тест сейчас, набор тестов пройдет.
Использование актеров
Начиная с iOS 15 мы можем использовать actor
s. Субъекты гарантируют, что операции внутри них выполняются контролируемым и сериализованным образом.
Давайте изменим тип Storage
с class
на actor
:
Компилятор начинает выдавать разного рода ошибки, как в actor
, так и в тестовом коде. Начнем с анализа ошибок actor
:
actor
уже работает во внутренней очереди отправки и не позволит нам отправить его операцию в другую очередь. Компилятор предлагает удалить приватный dispatchQueue
: он нам больше не нужен.
Потом у нас куча ошибок в тестах. Actor
s можно использовать в изолированных контекстах: это асинхронный контекст, управляемый средой выполнения. Чтобы исправить эти ошибки, мы должны выполнить следующие шаги:
- Все методы тестирования должны стать
async
. - Все вызовы
get
иset
должны начинаться с префиксаawait
. - Если в тесте используется
DispatchGroup
, его необходимо преобразовать вTaskGroup
с помощьюwithTaskGroup
API.
Давайте рассмотрим каждый тест по отдельности.
Тест Получить
Новый тестовый код выглядит следующим образом:
Мы применили шаги 1 и 2 списка преобразования: мы пометили метод как async
, мы выделили get
в отдельную строку, мы добавили префикс вызова get
с ключевым словом await
.
Тест SetAndGet
Этот тест обновляется как предыдущий. Мы пометили тест как async
и добавили вызов set
. И set
, и get
помечены ключевым словом await
, чтобы заставить их работать с новой парадигмой параллелизма.
Тест SetMultipleValues
Этот тест очень похож на своего близнеца: мы set
две разные переменные, а затем get
их. Как и раньше, мы пометили метод как async
и использовали ключевое слово await
для всех операций get
и set
.
Тестовый параллельный набор
Этот тест требует от нас использования третьего шага преобразования. В этих тестах у нас есть DispatchGroup
, который мы должны преобразовать в TaskGroup
.
Новый тест короче исходного: всего 15 строк против 22. Мы смогли отбросить expectation
, потому что можем await
выполнить TaskGroup
.
API withTaskGroup
предоставляет группу для добавления асинхронных операций. Они выполняются одновременно, и ключевое слово await
перед функцией withTaskGroup
позволяет нам дождаться их всех.
Тестировать ConcurrentUpdate
В последнем тесте мы запускаем две группы задач параллельно. Мы используем синтаксис async let
для объявления двух переменных, которые мы можем выполнять одновременно:
Новый тест короче исходного: 36 строк против 44. Нам не нужны ни DispatchGroup
, ни expectation
. Вместо этого мы создаем нашу TaskGroups
с помощью API withTaskGroup
и сохраняем эти группы в определенных переменных. Переменные group1
и group2
— это async let
: мы откладываем их оценку и вычисление до тех пор, пока они нам не понадобятся.
В строке 28 мы await
массив, состоящий из двух групп, просим систему выполнить их.
После всех этих преобразований все испытания должны пройти.
Заключение
В сегодняшней статье мы оценили, как создать потокобезопасную структуру данных, реализующую основные операции чтения, записи и обновления. Мы применили разработку через тестирование и изучили типичные проблемы, с которыми мы сталкиваемся при работе с потокобезопасными структурами данных.
Затем мы рассмотрели, как создавать сложные тесты для проверки наиболее распространенных проблем, которые могут возникнуть в многопоточной среде: сбои и потеря обновлений. После того, как мы запишем эти тесты, у нас также появятся некоторые подстраховки от регрессии. Благодаря этим тестам подобные проблемы не возникнут в будущем.
Наконец, мы изучили новый тип actor
и проанализировали, как мы можем преобразовать набор тестов из модели Grand Central Dispatch (GCD) в новую модель с асинхронным ожиданием. После обновления код выглядит проще и элегантнее.