Connect with us

Программирование

Когда отладчик Swift лжет: скрытая стоимость компиляторных оптимизаций

Эффективное управление памятью — прекрасная вещь. Пока вы не занимаетесь отладкой.

Опубликовано

/

     
     

Итак, я создаю приложение SwiftUI, а в нем параллельно вызываю две конечные точки бэкенда с помощью async let. Оба возвращают массивы пользователей, и я хочу объединить их результаты в операторе switch. Достаточно просто, верно?

Оказалось, что да, но при этом немного запутанно.

Во время отладки я заметил нечто странное: один из моих массивов (users2) оказался загадочно пуст прямо перед оператором возврата, хотя я только что перебирал его и видел все правильные данные!

Так в чем же дело? Неужели Swift съел мой массив? Это ошибка в моем API-клиенте? Или что-то более глубокое?

Ситуация

Вот упрощенная версия кода, о котором идет речь:

func getUserListForAdmin() async -> NetworkResponse<[OtherUser]> {
    async let type4Request = RestClient.shared.request(Constants.Network.ENDPOINT_USERS, parameters: ["userType": 4])
    async let type2Request = RestClient.shared.request(Constants.Network.ENDPOINT_USERS, parameters: ["userType": 2])

    let (result4, result2) = await (type4Request, type2Request)

    switch (result4, result2) {
    case (.success(let users4), .success(let users2)):
        for user in users4 {
            user.canSeeThisUsersRequests = users2.contains(where: { $0.id == user.id })
        }
        return .success(users4) // At this breakpoint, users2 appears EMPTY 
    case (.error(let error), _), (_, .error(let error)):
        return .error(error)
    }
}

В отладчике result4 и result2 отображались как правильные, с полными массивами — 16 и 13 элементов соответственно. Но к тому моменту, когда я переходил к строке возврата, users2 отображался как пустой.

Естественно, я предположил, что где-то ошибка.

Давайте погрузимся глубже.

Первые подозреваемые — обычные виновники.

Я подумал, что, возможно:

  • Бэкэнд вернул неверные или неполные данные? Нет, каждый раз один и тот же результат.
  • Мой RestClient что-то изменил или кэшировал? Нет, он stateless.
  • Я случайно изменил users2 во время цикла? По-прежнему нет.

Тот факт, что мой цикл по users4 с использованием users2.contains(...) отлично сработал, доказывает, что данные были.

Так почему же они «исчезли» сразу после этого?

Что происходит на самом деле

Именно здесь Swift становится умнее. Тайна кроется не в API и не в вашей логике — она в оптимизациях управления памятью в Swift, в частности:

  • Сокращении времени жизни
  • Перемещении семантики из Swift Concurrency

Давайте объясним это.

1. Сокращение времени жизни

Swift позволяет деструктурировать значения непосредственно в switch:

switch (response4, response2) {
case (.success(let users4), .success(let users2)):
    ...
}

Но эти биндинги letusers4 и users2 — относятся только к данному блоку case. Как только блок заканчивается, они исчезают.

Этого следовало ожидать. Но Swift идет дальше: он деаллоцирует или «отбрасывает» любую переменную, как только сможет доказать, что она больше не используется. В данном случае Swift видит, что users2 используется только в цикле, поэтому он уничтожает ссылку сразу после этого — еще до возврата.

Ваш отладчик, не обладающий полной поддержкой concurrency (и часто сбитый с толку оптимизацией), покажет массив пустым или nil, когда вы осмотрите его после удаления.

2. Семантика перемещения в async let

Async let в Swift работает с семантикой перемещения только под капотом. Когда вы выполняете:

let (result4, result2) = await (type4Request, type2Request)

а затем деструктурируете их следующим образом:

case (.success(let users4), .success(let users2)):

Swift эффективно перемещает результат в switch. result2 теперь считается потребленным, и то же самое относится к users2 после выполнения цикла.

Это означает:

  • Переменная result2 теперь является мусором
  • Биндинг users2 исчезает в тот момент, когда Swift видит, что вы с ним закончили

Следовательно, когда вы прерветесь на return, ваш отладчик не сможет показать содержимое users2. Оно уже оптимизировано из памяти.

TL;DR: На самом деле массив не был пуст — он просто был очищен, и ваш отладчик опоздал на вечеринку.

Как доказать это

Попробуйте так:

print("users2 count: \(users2.count)")

withExtendedLifetime(users2) {
    print("users2 still alive here: \(users2.count)")
}

Это говорит компилятору: «пока не уничтожайте users2». Вы увидите, что отладчик теперь показывает его правильно — потому что вы заставили его время жизни удлинниться.

Также: попробуйте собрать с параметром -Onone, чтобы временно отключить оптимизацию, и сравните вывод дебагера.

Как это обойти (если нужно)

Если вы хотите, чтобы users2 жил дольше по какой-либо причине (например, для дальнейших манипуляций, логирования или тестирования), не связывайте его внутри switch. Сделайте это явно заранее:

guard case .success(let users4) = response4,
      case .success(let users2) = response2 else {
    return .error(...)
}

// Now both are alive and happy until the function ends

Таким образом, биндинги остаются вне логики обрезания скоупа.

Заключение

Итак, тайна раскрыта.

Swift делает именно то, что должен: освобождает память, как только она больше не нужна — даже если это сбивает с толку ваш отладчик. Особенно ярко это поведение проявляется при выполнении async let и при сопоставлении шаблонов в коде с большим количеством concurrency.

  • Это не ошибка
  • Это не сломанный API
  • Это не исчезающий массив

Это просто Swift умный. Возможно, слишком умный.

Эффективное управление памятью — прекрасная вещь. Пока вы не занимаетесь отладкой.

Дайте мне знать, если вы сталкивались с подобными проблемами! Я буду рад добавить в этот пост больше реальных историй о параллелизме.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: