company_banner

Память в Swift от 0 до 1

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

    Содержание

    Что такое память?

    Основной единицей информации является бит, который равен 1 или 0. Традиционно мы организовываем биты в группы по восемь, называемые байтами. Память  —  это просто длинная последовательность байтов, один за другим уходящих в даль. Но они расположены в определенном порядке. Каждый байт получает число, называемое его адресом.

    1 байт = 8 бит
    1 байт = 8 бит

    Мы рассматриваем память, организованную по словам (word), а не по байтам. Слово — это расплывчатый термин в информатике, но обычно он означает единицу размером с указатель. На современных устройствах для 64-битного процессора (который и стоит на iPhone) одно слово (word) равняется 8 байтам (64 бита = 8 байтам). Именно столько мы можем получить байт за одно обращение к памяти.

    1 слово = 8 байт (на 64 битных процессорах)
    1 слово = 8 байт (на 64 битных процессорах)

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

    Memory Layout

    Size

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

    struct FullResume {
        let id: String
        let age: Int
        let hasVehicle: Bool
    }

    Проверить ответ нам поможет MemoryLayout, который позволяет узнать информацию о размере структуры. Через статические свойства мы можем получить информацию о размере, выравнивании и шаге.

    MemoryLayout<FullResume>.size // 25

    Размер вычисляется достаточно просто — это сумма всех его полей. Как мы можем увидеть, String занимает 16 байт, Int — 8 байт, а Bool — 1 байт.

    MemoryLayout<String>.size // 16
     + MemoryLayout<Int>.size // 8
     + MemoryLayout<Bool>.size // 1

    Ради интереса попробуем переставить наше Bool свойство на первое место, остальные поля просто сместим ниже. Сколько теперь? Кажется, 25. Или нет? По логике размер не должен измениться, ведь он считается по сумме всех полей. Проверяем!

    struct FullResume {
        let hasVehicle: Bool
        let id: String
        let age: Int
    }
    
    MemoryLayout<FullResume>.size // 32 ???
    
    MemoryLayout<Bool>.size // 1
        + MemoryLayout<String>.size // 16
        + MemoryLayout<Int>.size // 8

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

    Посмотрим, как наша структура разместилась в памяти, в этом нам поможет метод withUnsafeBytes(_:), который возвращает нам UnsafeRawBufferPointer, позволяющий итерироваться по каждому байту. Получаем следующую картину — Bool занял все 8 байтов, String как и ожидалось свои 16 байтов и Int занял 8 байтов.

    Расположение байтов для FullResume
    Расположение байтов для FullResume

    Чтобы разобраться с этим, нам понадобится понять еще два термина  —  stride (шаг) и alignment (выравнивание). Для начала познакомимся со stride.

    Stride

    Я выделю простую структуру ShortResume. Простую в том плане, что она имеет меньший размер (Int32  —  4 байта, Bool  —  1 байт) и будет проще восприниматься на изображениях.

    struct ShortResume {
        let age: Int32
        let hasVehicle: Bool
    }
    Шаг между двумя ShortResume
    Шаг между двумя ShortResume

    Итак, как видно на изображении выше, шаг определяет промежуток между элементами, который всегда будет больше или равен размеру объекта. Благодаря шагу мы знаем, на сколько байтов нужно двигать указатель, чтобы добраться до следующего объекта. Можно заметить, что между первым и вторым резюме остается свободные 3 байта. Но зачем и для чего нужны эти 3 пустые байта? Вопросов становится больше, чем ответов, но мы уже близки к разгадке.

    Alignment

    Первое, что нам хочется понять — для чего нужно выравнивание? В начале статьи выделялся такой термин как word, который на примере ShortReume выглядит так:

    Выравнивание между двумя ShortResume
    Выравнивание между двумя ShortResume

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

    Для ясности рассмотрим пример не выровненных (смещенных) данных. Это грозит тем, что для получения значения Int32 нужно сделать два обращения к памяти — сначала прочитать слово 0, затем слово 1, соединить два прочитанных массива байтов, и только затем получить окончательное значение. На всё это накладывается непонятки: откуда читать данные у слова 0 и до куда у слова 1.

    Дабы избежать таких ситуаций, у нас и есть такое значение  —  alignment (выравнивание).

    У всех простых типов в Swift есть свое выравнивание. Простой Int или String должен выравниваться по 8 байт, Int32 и Int16 требуют меньше выравнивания — 4 и 2 байта соответственно, а для Bool достаточно одного. Как можно заметить, для простых типов выравнивание равно размеру. Но давайте рассмотрим, как эти числа влияют на memory layout структуры.

    MemoryLayout<Int>.alignment // 8
    MemoryLayout<Int32>.alignment // 4
    MemoryLayout<Int16>.alignment // 2
    MemoryLayout<Bool>.alignment // 1

    Возвращаясь к нашему FullResume (из которого был убран только String), можно заметить следующее: размер  —  9, выравнивание  —  8, шаг  —  16. Каждое свойство выровнено, мы можем получить любое значение свойства из резюме за один цикл чтения памяти.

    Выравнивание для FullResume
    Выравнивание для FullResume

    И немного иная картина получится если переставить Bool на первое место. Из-за того что Int имеет выравнивание равное 8, он должен начинаться с байта, кратный 8, поэтому и образовывается пустое место между Bool и Int, что за собой влечет увеличения размера структуры, которая становится 16 вместо 9.

    Выравнивание для FullResume c Bool на первом месте
    Выравнивание для FullResume c Bool на первом месте

    Выравнивание всей структуры рассчитывается достаточно просто  —  это наибольшее выравнивание из всех свойств. Если мы заменим Int на Int16, у которого выравнивание равно 2, то и вся структура будет иметь выравнивание 2.

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

    Проверь себя

    struct Test {
        let firstBool: Bool
        let array: [Bool]
        let secondBool: Bool
        let smallInt: Int32
    }

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

    Ответ
    MemoryLayout<Test>.size       // 24
    MemoryLayout<Test>.alignment  // 8
    MemoryLayout<Test>.stride     // 24

    Если распечатать байты, то получим следующую схему:

    Class

    MemoryLayout работает и для классов. Попробуем вывести для них всё то же самое.

    class PaidService {
        let id: String
        let name: String
        let isActive: Bool
        let expiresAt: Date?
    }
    
    MemoryLayout<PaidService>.size       // 8
    MemoryLayout<PaidService>.alignment  // 8
    MemoryLayout<PaidService>.stride     // 8

    Что ж, везде будет 8, потому что классы — ссылочный тип, а все ссылки равны 8 байтам (на 64-битной машине).

    Чтобы узнать реальный размер, занимаемый в куче, нужно воспользоваться Objective-C runtime функцией  —  class_getInstanceSize(_:). В этом случае получится:

    16 * 2 String + 8 Bool (1 + 7 alignment) + 8 Date + 8 Optional (1 + 7 alignment) + 16 metadata (isa ptr + ref count)

    Расположение в памяти для класса PaidService
    Расположение в памяти для класса PaidService

    Deep dive

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

    1. Дампить память

    2. Найти указатели

    3. Визуализировать

    Поначалу были попытки написать такую программу самостоятельно, пока не нашелся интересный доклад от Mike Ash.

    Доклад от Mike Ash
    Доклад от Mike Ash

    Суть в том, что Mike Ash написал программу на Swift, которая может прыгать по указателям и уходить в глубину, учитывая то, что в какой-то момент указатель может стать конечным. Для того, чтобы не словить краш при обращении к указателю, которого нет, он использует вспомогательные функции из языка C. Исходный код открыт, и с ним можно ознакомиться.

    Слайды о программе MemoryDumper из доклада
    Слайды о программе MemoryDumper из доклада

    Вспомним наш FullResume и попробуем прогнать его через dumper.

    Самая прелесть заключается в том, что этот dumper строит граф памяти, и имеется возможность оценить всё визуально.

    FullResume через dumper
    FullResume через dumper

    Получилось довольно изящно. Здесь отображается как адрес памяти самого объекта, так и адреса его внутренностей.

    Попробуем изменить структуру на класс и снова прогоним через наш Memory Dumper.

    class FullResume {
        let id: String
        let age: Int
        let hasVehicle: Bool
    }
    class FullResume через dumper
    class FullResume через dumper

    Немного сложней, чем структура, да.

    Это и логично, классы в Swift сами по себе сложнее, так как связаны с Objective-C, хранятся в куче, имеют свои метаданные для указателей, счетчики ссылок и так далее.

    Теперь нам удалось очень наглядно разглядеть эту разницу.

    Управление памятью

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

    Но есть еще интересные моменты, связанные с тем, как и сколько живут классы в памяти. Давайте посмотрим, как счетчики ссылок влияют на управление памятью.

    Reference Counters

    Всего в Swift три счетчика ссылок:

    • Strong

    • Weak

    • Unowned

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

    До Swift 4

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

    До Swift 4, счетчики ссылок располагались до свойств класса прямо в объекте. Класс имел только два счетчика  —  weak и strong.

    На объект начинает ссылаться два внешних объекта  —  один сильно, другой слабо, счетчики прибавляются по одному.

    В один момент времени объект с сильной ссылкой удаляется из памяти, и теперь у нас осталась только одна слабая ссылка. Что происходит в этот момент?

    Данные объекта уничтожаются, но память не освобождается, так как счетчик еще требуется хранить. В памяти остается так называемый «зомби объект», на который ссылается слабая ссылка. Только при обращении по слабой ссылке в runtime будет выполнена проверка: «зомби» (NSZombie) этот объект или нет. Если да, счетчик ссылок уменьшается.

    Xcode умеет находить такие объекты и сообщать о них, плюс имеет инструмент для этого.

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

    Встречался еще один достаточно критичный баг: получение (загрузка) объекта по слабой ссылке было не потокобезопасным!

    import Foundation
    
    class Target {}
    
    class WeakHolder {
       weak var weak: Target?
    }
    
    for i in 0..<1000000 {
       print(i)
       let holder = WeakHolder()
       holder.weak = Target()
       dispatch_async(dispatch_get_global_queue(0, 0), {
           let _ = holder.weak
       })
       dispatch_async(dispatch_get_global_queue(0, 0), {
           let _ = holder.weak
       })
    }

    Данный кусок кода может получить ошибку в Runtime. Суть именно в том механизме, который был рассмотрен ранее. Два потока могут одновременно обратиться к объекту по слабой ссылке. Перед тем, как получить объект, они проверяют, является ли проверяемый объект «зомби». И если оба потока получат ответ true, они отнимут счётчик и постараются освободить память. Один из них сделает это, а второй просто вызовет краш, так как попытается освободить уже освобожденный участок памяти.

    Такая реализация не очень хороша и с этим нужно что-то делать.

    Side Table

    В новой реализации появляется такое понятие как Side Table   или, если на русском  — «Боковая Таблица».

    Боковая таблица  —  это область в памяти, содержащая некоторую дополнительную информацию об объекте, которую не нужно хранить в нем самом. В текущей реализации в боковой таблице хранятся счетчики ссылок, но в некоторых статьях мелькала мысль, что там можно было бы хранить associated objects. Сейчас они хранятся в глобальной таблице, доступ к которой замедлен из-за потокобезопасности.

    Стоит разобраться как сегодня Swift работает с боковой таблицей. Потому что в новой реализации объект должен как-то ссылаться на боковую таблицу и работать со счетчиками ссылок.

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

    До создания боковой таблицы
    До создания боковой таблицы

    Первоначально объект содержит pointer и имеет только два счетчика ссылок. Боковой таблицы нет, ибо объект в ней никак не нуждается. При увеличении счетчика сильных ссылок всё работает как обычно, и ничего особенного не происходит.

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

    Как только мы начинаем ссылаться на объект слабо (weak reference), то создается боковая таблица, и теперь объект вместо сильного счетчика ссылок хранит ссылку на боковую таблицу. Сама боковая таблица также имеет ссылку на объект.

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

    С таким механизмом слабые ссылки ссылаются не напрямую на объект, а на боковую таблицу, которая указывает на объект. Это решает две предыдущие проблемы:

    1. Экономие памяти. Объект удаляется из памяти, если на него больше нет сильных ссылок.

    2. Это позволяет безопасно обнулять слабые ссылки, поскольку слабая ссылка теперь не указывает напрямую на объект и не является предметом race condition.

    Object lifecycle

    Такой механизм немного усложняет понимание жизненного цикла объекта, но в самом исходном коде Swift в комментариях он расписан хорошо и представляет из себя конечную машину состояний.

    Итак, машина заводится с самого первого состояния сразу, как только мы создали объект. Объект жив, его счетчики инициализируются со значениями strong  —  1, unowned  —  1, weak  —  1 (weak появляется только с боковой таблицей). На данный момент нет боковой таблицы. Операции с unowned переменными работают нормально.

    Когда strong RC достигает нуля, вызывается deinit(), и объект переходит в следующее состояние.

    Deiniting состояние
    Deiniting состояние

    Это состояние Deiniting. На данном этапе операции со strong ссылками не действуют. При чтении через unowned ссылку будет срабатывать assertion failure. Но новые unowned ссылки еще могут добавляться. Если есть боковая таблица, то weak операции будут возвращать nil. Далее из этого состояния уже можно перейти в два других.

    Deiniting без weak и unowned
    Deiniting без weak и unowned

    Первое: если нет боковой таблицы (то есть нет weak ссылок) и нет unowned ссылок, то объект переходит в Dead состояние и сразу удаляется из памяти.

    Deinited состояние
    Deinited состояние

    Второе: если у нас есть unowned или weak ссылки, объект переходит в состояние Deinited. В этом состоянии функция deinit() завершена. Сохранение и чтение сильных или слабых ссылок невозможно. Как и сохранение новых unowned ссылок. При попытке чтения unowned ссылки вызывается assertion failure. Из этого состояния также возможно два исхода.

    Deinited без weak ссылок
    Deinited без weak ссылок

    В том случае, если нет слабых ссылок, объект переходит непосредственно в состояние Dead, которое было описано выше.

    В случае наличия weak ссылок, а значит и боковой таблицы, осуществляется переход в состояние Freed (Освобожден). В Freed состоянии объект уже полностью освобожден и не занимает места в памяти, но его боковая таблица остается жива.

    Dead состояние
    Dead состояние

    После того как счетчик слабых ссылок достигает нуля, боковая таблица также удаляется и освобождает память, и осуществляется переход в финальное состояние  —  Dead.

    В мертвом состоянии от объекта ничего не осталось, кроме указателя на него. Указатель на HeapObject освобождается из кучи, не оставляя следов объекта в памяти.

    Инварианты счетчиков ссылок

    Весь жизненный цикл сопровождается инвариантами счетчиков ссылок. Инвариантность  —  это выражение, определяющее непротиворечивое внутреннее состояние объекта.

    • Если счетчик strong ссылок становится равен нулю, то объект всегда переходит в состояние deiniting. Unowned ссылки выкидывают ошибку в runtime, а чтение weak ссылок возвращает nil.

    • Счетчик unowned ссылок получает +1 от счетчика strong ссылок, который впоследствие уменьшается после завершения функции deinit() объекта.

    • Счетчик weak ссылок получает +1 от счетчика unowned ссылок. Он уменьшается после освобождения (freed) объекта из памяти.

    Используемые материалы

    HeadHunter
    HR Digital

    Comments 16

      +1
      Большое спасибо за статью! А каков размер SideTable? Указатель + 3 числа. 32 байта (на 64-битной системе)?
      Правильно ли я понимаю, что слабые ссылки зануляются только при обращении к ним (они обращаются к SideTable, видят, что объект удалён, и зануляются, счётчик слабых ссылок уменьшается)? Если да, то я правильно понимаю, что, если в цикле создать 10^6 объектов + по 1 слабой ссылке на объект, а потом удалить все объекты, то у нас, если мы не будем трогать слабые ссылки, останется висеть в памяти 32 мегабайта SideTables?
        +1

        Спасибо за хороший вопрос! Насчет размера — я затрудняюсь сразу ответить, но могу предположить, что меньше, использую int32 для счетчиков. Сама SideTable представляет из себя такое хранилище, то есть может иметь еще дополнительные флаги:


        HeapObjectSideTableEntry {
          SideTableRefCounts {
            object pointer
            atomic<SideTableRefCountBits> {
              strong RC + unowned RC + weak RC + flags
            }
          }   
        }

        Саму реализацю SideTable можно посмотреть тут
        Я попробую покопать и узнать примерные размеры и вернусь к вам с ответом)


        Второй вопрос, про то, когда удаляются боковые таблицы — да, проверка осуществляется при обращении по ссылке, поэтому важно, чтобы SideTable была максимально маленькой.

        0
        Спасибо за статью! Очень познавательно для новичков
          0
          Большое спасибо за статью. Многие моменты прояснились после прочтения.
          Глаз зацепился за фразу:
          На объект также может быть unowned ссылка, которая прибавляет +1 к unowned и +1 к strong.

          Правильно ли я понял что unowned ссылка увеличивает и unowned счетчик и strong счетчик?
            0

            Да, все так
            Об этом еще описано в блоке Инварианты счетчиков ссылок

              0
              То есть deinit не вызовется пока не пропадут все unowned ссылки?
                +1
                Аааа походу понял. Не каждая unowned ссылка прибавляет +1, а просто к счетчику strong ссылок прибавляется +1, а потом после того как deinit отработал у счетчика делается -1.
            0
            Можно еще попросить у вас пару слов о том как устроены коллекции и тд?
            Например struct Test -> array: [Bool] = 8 байт. Почему так и что будет если он меняется.
              +1

              Тема коллекций на самом деле достаточная большая, но если кратко, то Array в Swift представляет из себя некую обертку над данными, и позволяет соблюсти value семантику языка (данные неизменяемые, копируемые и тп). Это структура, у которой всегда известен заранее размер для MemoryLayout. А сами данные уже размещаются динамично в других участках памяти, на которые Array имеет ссылки. Именно поэтому MemoryLayout нам не отдает "настоящий" размер массива.


              Также об этом сказано в документации метода size(ofValue:)


              The result does not include any dynamically allocated or out of line storage. In particular, pointers and class instances all have the same contiguous memory footprint, regardless of the size of the referenced data.
              +2
              Спасибо за статью! Есть пара вопросов.

              1) Вы пишете:
              счетчики инициализируются со значениями strong  —  1, unowned  —  0, weak  —  0

              В статьях, которые прикладываете, по-другому:
              Object's refcounts are initialized as 1 strong, 1 unowned, 1 weak. Кто прав?

              2) Вы также пишете
              На объект также может быть unowned ссылка, которая прибавляет +1 к unowned и +1 к strong.

              и
              Счетчик unowned ссылок добавляет +1 к strong, который впоследствие уменьшается после завершения deinit-a объекта.

              Если счетчик unowned ссылок добавляет +1 к счетчику сильных ссылок, то счетчик сильных ссылок объекта, имеющего unowned ссылку, никогда не опустится ниже 1. Получается, что объект на который нет сильных ссылок, но есть unowned ссылка, никогда не deinit-нится.

              Вообще судя по тому, что написано в хэдере RefCount.h:

              The unowned RC also has an extra +1 on behalf of the strong references; this +1 is
              decremented after deinit completes.
              The weak RC also has an extra +1 on behalf of the unowned references; this +1 is decremented
              after the object's allocation is freed.

              Кажется это означает, что:
              Unowned RC получает +1 пока у нас есть сильные ссылки и не вызван deinit;
              Weak RC получает +1 пока у нас есть unowned references и объект не будет освобожден(freed);

              Насколько я понимаю, эти +1 как раз и нужны, чтобы обеспечить корректную работу стэйт машины (потому это и инварианты собственно).

              Поправьте меня если я не прав.
                +1
                собственно поэтому у нас при создании объекта стартовые значения 1 strong, 1 unowned, 1 weak, хотя unowned и weak ссылок пока нет. У unowned +1 за счет 1 strong, у weak +1 за счет 1 unowned.
                  0
                  Вот вопрос про
                  1 weak
                  как у нас может быть 1 weak, если при создании у объекта нет side table, которая создастся после появления первой weak ссылки.
                  Или подразумевается, что это не физический, а логический 1 weak?
                    0
                    В RefCount.h
                    LIVE without side table
                    The object is alive.
                    Object's refcounts are initialized as 1 strong, 1 unowned, 1 weak.
                    No side table. No weak RC storage.

                    Как будто противоречие
                    1 weak
                    и
                    No weak RC storage

                      +2
                      side tablе нет пока не добавится слабая ссылка(ну или выполнится одно из других описанных условий типа переполнения счетчика или наличия assosiated объектов), все верно:

                      Weak variable store adds the side table, becoming LIVE with side table.

                      Но значение weak каунтера 1 на старте.

                      Получается weak и unowned счетчики не совсем честные, а +1 для инвариантности
                    +1

                    1) Ох, это моя ошибка/недоглядка, тут вы совершенно правы, спасибо, что подметили, я поправлю!
                    2) Да, еще раз спасибо за замечание, помогаете сделать статью еще лучше :)
                    Вы верно описали, я ошибся тут, что unowned прибавляет к strong, как раз такие наоборот, strong прибавляет к unowned, а unowned к weak. Также поправлю


                    И да, это все для корректной работы state машины только необходимо.
                    С меня плюсы причитаются :)

                    0

                    Спасибо за статью, теперь стало понятнее почему лучше использовать структуры чем объекты для хранения данных:)

                    Only users with full accounts can post comments. Log in, please.