Проверка контекста Swift TaskLocal и изоляции задач на облачном Mac

Проверка контекста Swift TaskLocal и изоляции задач на облачном Mac

Проверка контекста Swift TaskLocal и изоляции задач на облачном Mac

Один запрос проходит три асинхронных этапа: загрузку, разбор и запись на диск, — но его идентификатор то появляется в логах, то пропадает. Если сделать идентификатор глобальной переменной, при параллельном запуске тестов значения начинают смешиваться. Разбирая такую проблему на облачном Mac, не спешите менять число потоков: сначала нужно проверить, в какие задачи передаётся контекст и на какой границе его распространение прекращается.

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

Сначала определите правила распространения контекста

Определим идентификатор запроса как опциональную строку со значением по умолчанию nil. Если контекста запроса нет, лучше сохранить это значение: так проще заметить пропущенную передачу контекста, чем при автоматической генерации внешне корректного идентификатора.

Зафиксируем пять правил: внутри области действия значение доступно для чтения; вложенная область может временно его переопределить; после выхода восстанавливается внешнее значение; обычные дочерние задачи и дочерние задачи группы наследуют контекст на момент создания; отделённые задачи по умолчанию его не наследуют. При выбросе ошибки внешнее состояние тоже должно восстанавливаться.

TaskLocal — не локальное хранилище потока и не безопасная оболочка над глобальной переменной. Передаётся контекст задачи, а не последнее значение, записанное в каком-либо потоке.

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

Создайте пакет для независимого запуска тестов

Выполните следующие команды в обычном рабочем каталоге на облачном Mac. Сначала зафиксируйте версию инструментария: тогда при разборе сбоев можно будет отличить последствия изменений кода от последствий смены компилятора.

mkdir TraceScopeLab
cd TraceScopeLab
swift --version
swift package init --type library --name TraceScopeLab
mkdir -p Tests/TraceScopeLabTests

Создайте в каталоге тестов файл ContextTests.swift и добавьте в него приведённый ниже код целиком. Тестовый файл, созданный по умолчанию, можно оставить: эти файлы не зависят друг от друга. Пример использует тестовый фреймворк из состава инструментария и не требует дополнительных зависимостей.

import XCTest

private enum TraceContext {
    @TaskLocal static var requestID: String?
}

private enum ProbeError: Error {
    case expected
}

final class ContextTests: XCTestCase {
    func testNestedScopeRestoresAfterThrow() {
        XCTAssertNil(TraceContext.requestID)

        TraceContext.$requestID.withValue("outer") {
            XCTAssertEqual(TraceContext.requestID, "outer")

            XCTAssertThrowsError(
                try TraceContext.$requestID.withValue("inner") {
                    XCTAssertEqual(TraceContext.requestID, "inner")
                    throw ProbeError.expected
                }
            )

            XCTAssertEqual(TraceContext.requestID, "outer")
        }

        XCTAssertNil(TraceContext.requestID)
    }

    func testTaskBoundaries() async {
        let observed = await TraceContext.$requestID
            .withValue("request-a") {
                let child = Task {
                    TraceContext.requestID
                }
                let detached = Task.detached {
                    TraceContext.requestID
                }

                let inherited = await child.value
                let absent = await detached.value

                let groupValue = await withTaskGroup(
                    of: String?.self,
                    returning: String?.self
                ) { group in
                    group.addTask {
                        TraceContext.requestID
                    }

                    var result: String?
                    for await value in group {
                        result = value
                    }
                    return result
                }

                return (inherited, absent, groupValue)
            }

        XCTAssertEqual(observed.0, "request-a")
        XCTAssertNil(observed.1)
        XCTAssertEqual(observed.2, "request-a")
        XCTAssertNil(TraceContext.requestID)
    }
}

Здесь мы сначала получаем результат через await и лишь затем передаём его в проверку, чтобы не помещать асинхронное чтение в синхронное выражение утверждения. Мы дожидаемся завершения всех созданных задач: после окончания теста никакая работа не должна оставаться в фоне.

Явно выбирайте контекст для отделённых задач

Task {} наследует локальные значения задачи, но создаёт неструктурированную задачу: её жизненный цикл не привязывается автоматически к области действия создателя. Наследование контекста и автоматическое ожидание завершения — разные механизмы.

Поэтому задача, созданная ранее, может сохранять исходный идентификатор и после выхода из родительской области. Это не означает, что восстановление не сработало. Для работы в рамках запроса отдавайте предпочтение структурированной конкурентности; если неструктурированная задача действительно нужна, сохраняйте её дескриптор и явно дожидайтесь завершения либо отменяйте её.

Для независимой фоновой работы отсутствие контекста запроса по умолчанию, напротив, делает поведение понятнее. Если связь для трассировки всё же нужна, сначала захватите поля, которые разрешено передавать, а затем установите их внутри отделённой задачи:

let capturedID = TraceContext.requestID
let worker = Task.detached {
    TraceContext.$requestID.withValue(capturedID) {
        TraceContext.requestID
    }
}
let observedID = await worker.value

Этот фрагмент следует разместить в асинхронном тестовом методе в указанном выше тестовом файле. Захват выполняется на стороне создателя, а привязка значения — уже в новой задаче. Не пытайтесь прочитать прежний контекст только после входа в отделённую задачу. Для несвязанной работы, например периодической очистки, оставляйте nil, чтобы целый набор записей в логах не оказался ошибочно привязан к одному пользовательскому запросу.

Добавьте проверку изоляции при чередовании выполнения

Успешной проверки одного запроса недостаточно. Добавьте асинхронный тестовый метод, который одновременно создаёт две задачи и устанавливает для них request-a и request-b соответственно. Внутри области действия значения каждая задача должна вызвать await Task.yield(), после возобновления вернуть текущий идентификатор, а затем нужно отдельно проверить оба результата.

Не требуйте, чтобы задачи завершались в заданном порядке. Task.yield() лишь предлагает уступить возможность выполнения и не гарантирует определённого чередования. Критерий проверки — «независимо от того, кто завершится первым, каждая задача получает своё значение», а не «первая запись в логе обязательно относится к A».

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

Сделайте отчёт о сбое пригодным для диагностики

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

swift test --filter ContextTests
swift test --parallel --filter ContextTests

Если обычная дочерняя задача получает nil, сначала проверьте, действительно ли она создаётся внутри withValue. Если отделённая задача получает идентификатор, поищите явную привязку значения или дополнительную обёртку. Если после выхода из вложенной области значение не восстановилось, убедитесь, что читаете именно TaskLocal, а не одноимённое глобальное свойство.

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

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

Часто задаваемые вопросы

Наследует ли Task.detached значения TaskLocal?

Нет. Сначала прочитайте нужный идентификатор в родительской задаче, затем явно создайте область withValue внутри отделённой задачи.

Можно ли принимать решения о правах только по TaskLocal?

Нет. Такой контекст подходит для трассировки, но не заменяет проверенную личность. Передавайте и проверяйте данные авторизации явно.

Поручите следующую сборку выделенному облачному Mac

MacFTP предоставляет выделенные физические компьютеры на Apple Silicon с оригинальной macOS. Выберите срок аренды: день, неделя, месяц или квартал. Сначала проверьте конфигурацию, затем подключите к узлу привычные инструменты. Фактический статус доступности отображается в панели управления.

Арендовать облачный Mac

От чтения — к проекту

Сначала проверьте конфигурацию, затем планируйте запуск

Сравните модели и сроки аренды или вернитесь в блог за другими техническими материалами.