В этой статье мы рассмотрим принципы работы памяти в 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) объекта из памяти.

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