Connect with us

Разработка

Запретная сторона Swift, которую мы, как правило, вообще не видим

У всех этих «запретных артефактов» Swift есть одна общая черта: пользоваться ими стоит как можно реже.

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

/

     
     

Я пишу код на Swift ежедневно уже почти 10 лет. Синдром самозванца меня не пугает. Я всё повидал.

Но иногда… не знаю.

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

Сегодня мы откопаем кое-что из того, что Apple хранит в шкафу под лестницей. Мы выясним, есть ли у этих ключевых слов и функций какое-либо реальное применение, и прольем на них немного света.

unowned(unsafe)

Всем известны strong, weak и unowned ссылки… но есть четвёртый тип ссылок, который Apple боится (в основном) добавлять в свои библиотеки из-за его опасности.

Unowned ссылки, хотя и немного более производительны, чем weak, на самом деле влекут за собой некоторые накладные расходы. Среда выполнения Swift проверяет, действительна ли ссылка, и если нет, то приводит к сбою кода (мне они не очень нравятся).

Unowned(unsafe) пропускает эти надоедливые проверки безопасности памяти во время выполнения, чтобы сэкономить несколько дополнительных тактов. При обращении к деаллоцированной unowned(unsafe) ссылке мы получаем неопределённое поведение.

Это означает, что иногда программа может просто зависнуть…

Или, просто ради интереса, возможно, вы сможете получить доступ к памяти внутри поврежденной ссылки.

Вам это может показаться достаточно безобидным, но помните, что большинство эксплойтов на системном уровне (например, dismemberment) основаны на манипуляциях с поврежденной памятью.

withoutActuallyEscaping

Именно из-за этого я и начал писать эту статью. Я увидел этот фрагмент кода в очень интересной статье в блоге Уэйда Трегаскиса о создании синхронной версии Task.

import Dispatch

extension Task {
    static func sync(_ code: sending () async throws(Failure) -> Success) throws(Failure) -> Success {
        let semaphore = DispatchSemaphore(value: 0)
        nonisolated(unsafe) var result: Result<Success, Failure>? = nil
        withoutActuallyEscaping(code) {
            nonisolated(unsafe) let sendableCode = $0
            let coreTask = Task<Void, Never>.detached(priority: .userInitiated) { @Sendable () async -> Void in
                do {
                    result = .success(try await sendableCode())
                } catch {
                    result = .failure(error as! Failure)
                }
            }
            
            Task<Void, Never>.detached(priority: .userInitiated) {
                await coreTask.value
                semaphore.signal()
            }
            
            semaphore.wait()
        }
        return try result!.get()
    }
}

Что, черт возьми, такое withoutActuallyEscaping?!

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

  • Escaping closures (экранирующее замыкание, @escaping () -> Void) хранятся в куче и могут выполняться за пределами области видимости функции, из которой были переданы.
  • Non-escaping closures (неэкранирующее замыкание, () -> Void) выполняются синхронно, поэтому позволяют избежать размещения в куче. Они также легче по весу, потому что не копируют захваченные переменные.

@escaping () -> Void и () -> Void — это два разных типа, и они несовместимы между собой. Нельзя просто взять и передать non-escaping closure туда, где ожидается escaping closure — например, в инициализатор Task.

Именно здесь и нужен withoutActuallyEscaping. Он реализован как специальный кейс в Swift type checker: внутри его замыкания правила системы типов временно становятся менее строгими.

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

Apple использует это в стандартной библиотеке, например, для реализации MainActor.assumeIsolated:

Запретная сторона Swift, которую мы, как правило, вообще не видим

dlopen()

Это еще одна штука, про которую я узнал из статьи в блоге — причем той самой, которая появилась после того, как я усомнился в гениальности Скотта Йелвингтона. Он доказал, что динамические библиотеки действительно можно подгружать лениво и тем самым строить плагинную архитектуру в iOS-приложениях. Признаю, было приятно ошибиться.

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

Как это работает: вы убираете фреймворк из фазы компиляции Link Binary With Libraries, создаете интерфейсный фреймворк, который служит мостом к динамическому модулю, а затем загружаете сам фреймворк в рантайме по его пути.

Запретная сторона Swift, которую мы, как правило, вообще не видим

dlopen — это Darwin-интерфейс к динамическому загрузчику dyld. Он загружает бинарник в память по рандомизированному адресу, рекурсивно подтягивает все его зависимости, а затем возвращает UnsafeMutableRawPointer, указывающий на адрес загруженного модуля в памяти.

Серьезно, почитай статью Скотта — она просто огонь.

malloc, free и withUnsafeTemporaryAllocation

Как объясняется в этом proposal из Swift Evolution, чтобы дать нам тот самый высокоуровневый и memory-safe интерфейс, с которым мы работаем каждый день, Swift все равно вынужден опираться на некоторое количество низкоуровневой небезопасности. Например, Data использует UnsafeRawPointer, а ArrayUnsafeMutableRawBufferPointer.

Это та сторона Swift, которую мы, как правило, вообще не видим. Пока разработчики приложений ездят по конференциям и спорят о просадках FPS в SwiftUI, инженеры библиотек копаются в шахтах C и Builtin, вручную раскладывая биты так, чтобы мы получали нужную производительность.

Что интересно, функции стандартной библиотеки C можно вызывать напрямую, потому что на платформах Apple Swift-код неявно импортирует Darwin.

let size = MemoryLayout<Int64>.size // 8
let rawPointer = malloc(size)! // UnsafeMutableRawPointer

memset(rawPointer, 0, size)
for i in 0..<size {
    rawPointer.assumingMemoryBound(to: UInt8.self)[i] = UInt8(i)
}
let buffer = UnsafeRawBufferPointer(start: rawPointer, count: size)
for byte in buffer {
    print(byte) // 0, 1, 2, 3, 4, 5, 6, 7
}

free(rawPointer) // don't forget this!

withUnsafeTemporaryAllocation — это глобальная функция, которую предложили как способ реализовать оптимизацию, давно популярную в C и C++: прямое выделение временного буфера памяти в куче.

withUnsafeTemporaryAllocation(of: UInt8.self, capacity: size) { tempBuffer in
    for i in 0..<tempBuffer.count {
        tempBuffer[i] = UInt8(i * 2)
    }
    for byte in tempBuffer {
        print(byte) // 0, 2, 4, 6, 8, 10, 12, 14
    }
}

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

Запретная сторона Swift, которую мы, как правило, вообще не видим

_modify

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

  • получить копию значения
  • изменить эту копию
  • заменить оригинальное значение

Для большинства случаев это работает нормально, но такая схема может скрывать серьезные проблемы с производительностью — особенно если речь идет о строках, словарях или любых типах с буфером в куче, которые опираются на оптимизацию copy-on-write.

Во время set создается копия, а затем в нее записываются изменения — и это может спровоцировать полное копирование всего буфера кучи. А это уже операция O(n), поэтому любой цикл, в котором используется сеттер вычисляемой строки или subscript словаря, может незаметно скатиться в квадратичную сложность или даже хуже.

Аксессор _modify позволяет избежать этой ловушки, потому что дает возможность изменять значение на месте, без создания копий.

Вместо того чтобы копировать значение и тем самым потенциально создавать дополнительную ссылку, которая активирует copy-on-write для объекта в куче, _modify отдает (yield) ссылку на базовое хранилище. Yield здесь — это контекстное ключевое слово, которое позволяет аксессору временно «одолжить» эту ссылку и работать напрямую с памятью объекта в куче, не увеличивая его reference count.

struct Container {
    private var backingString: String = "Hello"

    var value: String {
        get { 
            backingString
        }
        _modify {
            yield &backingString
        }
    }
}

var container = Container()
container.value.append(", world")

Это может быть очень полезным инструментом в арсенале, особенно если вы пишете библиотеки и пытаетесь сделать коллекции максимально эффективными. Но использовать _modify везде подряд, где есть property accessors, не стоит — он дает выигрыш только в определенных сценариях.

Большая часть этой информации взята из легендарного proposal от Бена Коэна про _modify, который, похоже, уже совсем скоро может вырасти из скромного underscored-свойства в полноценное ключевое слово.

autoreleasepool

На самом деле это, пожалуй, самый полезный инструмент из всех.

На первый взгляд может показаться, что это какой-то пыльный артефакт времен до ARC, из той же эпохи, что и retain() с release(). Но и сегодня autoreleasepool вполне практичен: он помогает устранять out-of-memory крэши и проблемы с производительностью. Серьезно, я сам пользовался им буквально сегодня.

autoreleasepool оборачивает замыкание. Внутри его области видимости он незаметно отслеживает все heap-объекты, созданные в этом скоупе, а при выходе из блока отправляет каждому из них сообщение RELEASE. Благодаря этому временные объекты живут ровно столько, сколько нужно, и деаллоцируются сразу, как только перестают быть нужны.

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

Давайте посмотрим на практике: профилируем этот код в дебаггере Xcode.

private func loadImages() {
    let urls = (0..<50_000).compactMap { _ in 
        Bundle.main.url(forResource: "monalisa", withExtension: "jpg") 
    }
    for url in urls {
        autoreleasepool {
            if let data = try? Data(contentsOf: url),
               let image = UIImage(data: data) {
                print(image.size)
            }
        }
    }
}

С блоком autoreleasepool каждый кусок данных и каждый UIImage освобождаются сразу после выхода из области видимости пула. Поэтому итерации цикла не накапливают объекты друг на друге, а пиковое потребление памяти остается ровным и предсказуемым.

Запретная сторона Swift, которую мы, как правило, вообще не видим

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

Запретная сторона Swift, которую мы, как правило, вообще не видим

Использовать autoreleasepool стоит везде, где вы в цикле работаете с большим количеством тяжелых reference-counted объектов.

Лично я применял его в таких сценариях:

  • обработка большого количества изображений с последующей сборкой в видео
  • массовая трансформация и сохранение множества объектов Core Data
  • совсем недавно — миграция данных с десятками bitmap-изображений по 40 МБ каждое

Напоследок

Возможно, тебя по-настоящему увлек этот список загадочных и почти запретных возможностей языка. А возможно, ты сам пишешь низкоуровневые библиотеки и считаешь, что такой обзор звучит слишком уж наивно. Ты работаешь с той стороной языка, о существовании которой большинство смертных даже не задумывается.

А всем остальным остается только поднять бокал за тех, кто трудится в шахтах SIL, C++ и стандартной библиотеки, делая наш язык по-настоящему быстрым (Swift).

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

И вот в такие редкие моменты умение достать один из этих инструментов буквально из ничего и применить его по делу — это как раз то, что и показывает твой уровень как инженера.

Спасибо за чтение!

Источник

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

Популярное

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

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