Тестирование потокобезопасных ресурсов и актеры

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

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

  1. Обновления могут поступать в другом порядке (проблема только в том случае, если мы ценим порядок).
  2. Мы можем пропустить некоторые обновления.
  3. Приложение может вылететь.

Когда структура данных реализована таким образом, что предотвращает эти проблемы, мы называем ее поточно-безопасной структурой данных.

Существуют различные способы обеспечения безопасности потоков во всех версиях iOS. Начиная с Swift 5.5, actors передайте эту возможность непосредственно компилятору. Однако актеры будут доступны только для iOS 13 после выпуска Xcode 13.2.

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

Базовое поведение хранилища

Начнем с определения базового интерфейса Storage. Нам нужно установить значение для ключа и получить значение, связанное с ключом.

Мы хотим применить подход Разработка через тестирование (TDD), поэтому давайте начнем писать наш первый тест:

Это самый простой тест, который мы можем написать для операции get.

Тест не будет построен, потому что класса Storage еще не существует. Итак, давайте напишем это, чтобы пройти тест.

Это минимальный код, необходимый для прохождения теста. Помните, что в TDD мы выполняем три шага:

  1. Мы создаем провальный тест.
  2. Мы пишем минимальный код, чтобы он прошел.
  3. В конце концов, мы рефакторим код, чтобы сделать его более читабельным и удобным для сопровождения.

Сейчас проходит тест для метода get. Давайте добавим новый тест для метода set.

Этот тест не скомпилируется, потому что у Storage нет метода set. Давайте обновим класс минимальным кодом, необходимым для его работы.

Мы сохраняем String, который начинается с nil. Если мы запустим тесты, они оба пройдут. Это важное наблюдение: с помощью TDD мы добавляем функциональные возможности, чтобы убедиться, что то, что мы ранее реализовали, продолжает работать.

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

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

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

С этим изменением все тесты пройдены.

Нас устраивает базовое поведение класса. Теперь давайте проверим потокобезопасность.

Добавить потокобезопасность

Чтобы имитировать многопоточную среду в тестах, мы можем асинхронно отправить несколько заданий на DispatchQueue и использовать DispatchGroup, чтобы дождаться их завершения. Эти API являются частью платформы под названием Grand Central Dispatch (GCD).

Тест выглядит так:

Данный тест состоит из следующих блоков:

  1. Мы объявляем sut, dispatchGroup и expectation. expectation требуется дождаться завершения всех потоков.
  2. Мы создаем 100 потоков, используя функцию DispatchQueue.global().async внутри цикла. Перед отправкой единицы работы мы проверяем группу с помощью функции dispatchGroup.enter().
  3. Работа потока заключается в set паре ключ-значение в нашем хранилище. После его установки мы exit из группы.
  4. Наконец, мы просим группу подождать, пока все потоки внутри нее прекратят свою работу. Это делается с помощью функции dispatchGroup.notify(). Как только это происходит, мы оправдываем ожидания.
  5. На последнем шаге мы утверждаем, что все ключи вставлены в файл Storage.

Если мы запустим тесты сейчас, они вылетят со следующей ошибкой

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

Чтобы это исправить, нам нужно обновить код нашего хранилища. Существует несколько способов обеспечения безопасности потоков. Самый простой и читаемый — синхронизировать доступ к ресурсу с помощью приватного DispatchQueue: по умолчанию очереди отправки выполняют одну операцию за раз, и никакая другая операция не выполняется, пока предыдущая не будет завершена. Принята политика «первым пришел, первым обслужен» (FIFO): обслуживается первый поступивший запрос.

Теперь код выглядит так:

В строке 4 мы объявили частный DispatchQueue и дали ему имя storage.synchronization. Мы используем его в строке 7, заключая тело set в метод sync.

Если мы запустим тесты сейчас, они пройдут без сбоев.

Обеспечение согласованности

Хотя теперь код выглядит хорошо, он стабилен и не дает сбоев, у нас все еще есть проблема согласованности. Представьте, что мы хотим:

  1. Чтение значения.
  2. Измените его.
  3. Сохраните его снова.

Что произойдет, если другой поток изменит значение, связанное с тем же ключом, между шагами 1 и 2? Первый поток обновит старое значение, а обновление второго потока будет потеряно.

Мы можем протестировать этот вариант использования с помощью следующего теста:

Тест немного сложный, поэтому давайте разберем его шаг за шагом.

  1. Первая часть кода подготавливает тестовые переменные и Storage с сотней пар ключ-значение.
  2. Затем мы создаем группу, которая обновляет значения, добавляя суффикс _read. Для этого вводим dispatchGroup, выполняем работу и выходим из нее.
  3. Мы создаем второй набор потоков, который одновременно добавляет суффикс _disturb к той же паре ключ-значение, чтобы имитировать кучу одновременных обновлений.
  4. Мы создаем expectation и ждем завершения dispatchGroup.
  5. Наконец, мы пишем наши утверждения. Два набора потоков выполняются одновременно, поэтому мы не знаем, в каком порядке они будут выполняться. Утверждение проходит независимо от того, выполняем ли мы сначала «append _read» или сначала «append disturb». Мы не можем смириться с тем, что одна из двух операций будет потеряна.

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

Несколько потоков пытаются читать из словаря, в то время как другие потоки пытаются записывать в него. Опять же, Swift не знает, как с этим справиться. Мы можем обернуть операцию чтения в sync вызове dispatchQueue.

Единственное изменение происходит в строке 13: мы завернули операцию чтения в метод sync (метод sync может возвращать то же значение, что и при его закрытии).

Если мы запустим тесты сейчас, они не рухнут, но и не пройдут. Утверждение не удалось: мы теряем некоторые операции, потому что потоки из разных групп читают одни и те же переменные, а затем пытаются их обновить. Чтобы исправить это, нам нужно ввести другую операцию: update.

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

Давайте сначала обновим тесты, следуя подходу TDD:

Тест точно такой же, но мы заменили операции get и set на операцию update.

Код не будет собран, потому что нам не хватает метода в классе. Добавим.

Как описано, функция обновления принимает key, который мы хотим обновить, и замыкание, которое предоставляет новое значение для этого ключа, передавая старое, если оно есть. Затем результат закрытия устанавливается как новое значение. Все завернуто в метод dispatchQueue.sync для обеспечения потокобезопасности.

Если мы, наконец, запустим тест сейчас, набор тестов пройдет.

Использование актеров

Начиная с iOS 15 мы можем использовать actors. Субъекты гарантируют, что операции внутри них выполняются контролируемым и сериализованным образом.

Давайте изменим тип Storage с class на actor:

Компилятор начинает выдавать разного рода ошибки, как в actor, так и в тестовом коде. Начнем с анализа ошибок actor:

actor уже работает во внутренней очереди отправки и не позволит нам отправить его операцию в другую очередь. Компилятор предлагает удалить приватный dispatchQueue: он нам больше не нужен.

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

  1. Все методы тестирования должны стать async.
  2. Все вызовы get и set должны начинаться с префикса await.
  3. Если в тесте используется 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) в новую модель с асинхронным ожиданием. После обновления код выглядит проще и элегантнее.