Понимание механизмов “копирования при записи” (Copy-on-Write, COW)
В Swift у нас есть ссылочные типы (Reference Type) - это классы, функции, замыкания - и типы значений (Value Type) - это структуры, кортежи, перечисления. Типы значений имеют семантику копирования. Это означает, что если вы присваиваете тип значения переменной или передаете его в качестве параметра функции (если это не входной параметр), базовые данные этого значения будут скопированы. У вас будет два значения с одинаковым содержимым, но размещенные по двум разным адресам памяти.
Как объясняется в блоге Apple Swift, мы можем определить это следующим образом:
“Types in Swift fall into one of two categories: first, “value types”, where each instance keeps a unique copy of its data, usually defined as a struct, enum, or tuple. The second, “reference types”, where instances share a single copy of the data, and the type is usually defined as a class.”
“Типы в Swift попадают в одну из двух категорий: первый, «тип значений», где каждый экземпляр хранит уникальную копию своих данных, обычно определяемых как структура, перечисление или кортеж. Второй, «ссылочный тип», где экземпляры совместно используют одну копию данных, а тип обычно определяется как класс.”
Можно наблюдать это конкретное поведение при копировании переменных. Проверьте примеры ниже.
Пример типа значения (Value Type)
Давайте рассмотрим, как ведет себя тип значения, в данном случае структура, когда вы присваиваете его другой новой переменной.
func address(o: UnsafeRawPointer) -> Int {
return Int(bitPattern: o)
}
struct Car1 { var name: Int = -1 }
var a1 = Car1()
var b1 = a1 // a is copied to b
print("\(a1.name), \(b1.name)")
print(NSString(format: "%p", address(o: &a1)))
print(NSString(format: "%p", address(o: &b1)))
// Output
// -1, -1
// 0x100008240
// 0x100008248
a1.name = 20 // Changes a, not b
print("\(a1.name), \(b1.name)")
// Output
// 20, -1
Как видно, когда копируем структуру - буквально копируем значения в другие переменные, и адрес памяти тоже меняется, как только он копируется. Изменив значение одного объекта (a1.name = 20
), значение второго - не меняется (b1.name = -1
).
Теперь перейдем к ссылочным типам.
Пример ссылочного типа (Reference Type)
Если бы Car
был классом, результат был бы совсем другим, давайте посмотрим:
func addressHeap<T: AnyObject>(o: T) -> Int {
return unsafeBitCast(o, to: Int.self)
}
class Car2 { var name: Int = -1 }
var a2 = Car2()
var b2 = a2 // a is copied to b
print("\(a2.name), \(b2.name)")
print(NSString(format: "%p", addressHeap(o: a2)))
print(NSString(format: "%p", addressHeap(o: b2)))
// Output
// -1, -1
// 0x10122ff50
// 0x10122ff50
a2.name = 20 // Changes a and b
print("\(a2.name), \(b2.name)")
// Output
// 20, 20
Здесь видно, что адрес памяти один и тот же, и значения двух переменных одинаковы, потому что они имеют ссылку на один и тот же объект в памяти. Изменив значение одного объекта (a2.name = 20
), значение второго - тоже меняется (b2.name = 20
), так как они ссылаются на один и тот же экземпляр класса.
Обратите внимание, что даже функция получения адреса в памяти отличается для ссылочных типов, поскольку они находятся в памяти кучи (Heap), а типы значений — в стеке (Stack).
Теперь, когда мы уже разобрались со значениями и ссылочными типами, мы можем продолжить наше исследование. Поскольку мы собираемся говорить о копировании при записи, очень важно понимать семантику значений Swift. Итак, начнем
Что же такое “копирование при записи” (Copy-on-Write)?
В Swift, когда у вас есть большой по размеру тип значения (Value Type) и необходимо назначить или передать в функцию в качестве параметра, копирование может быть затратным с точки зрения производительности, потому что придется копировать все базовые данные в другое место в памяти.
Пытаясь свести к минимуму проблему, стандартная библиотека Swift реализует этот набор механизмов для некоторых типов значений, где значение будет скопировано только при мутации (изменении). Да и то, только если на него есть более одной ссылки. Потому что, если на это значение имеется уникальная ссылка, его не нужно копировать, его можно просто мутировать по ссылке. Таким образом, простое присвоение переменной или передача массива в функцию не обязательно означает, что она будет скопирована, и это действительно улучшит производительность.
Что действительно важно знать, так это то, что “копирование при записи” не является поведением по умолчанию для типов значений. Это то, что реализовано в стандартной библиотеке Swift для определенных типов, таких как массивы и словари. Таким образом, это означает, что не каждый тип значения в стандартной библиотеке имеет такое поведение. Кроме того, типы значений, которые вы создаете, не имеют его, если только вы не реализуете его самостоятельно.
Посмотрим на практике как работает Copy-on-Write.
Допустим, есть источник данных А
(переменная, константа), который указывает на какие-то данные Data
A = Data
A -> Data
Далее, возникла необходимость скопировать данные из источника А
в источник В
B = A
Но на самом деле произойдет так называемое “псевдо-копирование” и оба источника будут указывать на одни и те же данные
A -> Data
B -> Data
Если после этого возникает необходимость изменить (мутировать) для источника В
данные, то в этот момент произойдёт неявное копирование и источник В
будет указывать на новую область памяти с новыми данными
A -> Data
B -> Data New
Итог. Когда вы указываете две переменные на один и тот же массив, они обе указывают на одни и те же базовые данные. Swift обещает, что такие структуры, как массивы и словари, копируются как значения, как и числа, поэтому наличие двух переменных, указывающих на одни и те же данные, может показаться противоречащим этому. Решение простое, но умное: если вы измените вторую переменную, Swift сделает полную копию в этой точке, так что будет изменена только вторая переменная, а первая не изменится.
Таким образом, откладывая операцию копирования до тех пор, пока она действительно не понадобится, Swift может гарантировать, что работа не будет выполнена впустую.
Копирование при записи (COW) представляет собой метод управления ресурсами, используемый в компьютерном программировании для эффективной реализации операции «дублирования» или «копирования» на изменяемых ресурсах. Если ресурс дублируется, но не изменяется, нет необходимости создавать новый ресурс; ресурс может быть разделен между копией и оригиналом. Модификации по-прежнему должны создавать копию, отсюда и метод: операция копирования откладывается до первой записи. Разделяя ресурсы таким образом, можно значительно сократить потребление ресурсов немодифицированными копиями, добавляя при этом небольшие накладные расходы на операции по изменению ресурсов.
Предупреждение: копирование при записи — это функция, специально добавленная в массивы и словари Swift.
Зачем нужен механизм “копирование при записи” (Copy-on-Write)
С небольшими данными (String, Int) не возникает проблем, так как размер занимаемой памяти не большой. Но в случае с массивами, которые могут достигать нескольких мегабайт, копирование данных может вызывать определённые проблемы.
func address(of object: UnsafeRawPointer) -> String {
return String(format: "%p", Int(bitPattern: object))
}
var arrayOne = [1, 2, 3, 4, 5]
print(address(of: arrayOne))
// Address arrayOne
//0x101504e30
var arrayTwo = arrayOne
print(address(of: arrayTwo))
// Address arrayTwo - before mutation
// 0x101504e30
// Mutation
arrayTwo.append(6)
print(address(of: arrayTwo))
// Address arrayTwo - after mutation
// 0x1007371c0
var arrayThree = arrayOne as [Any]
print(address(of: arrayThree))
// Address arrayThree
// 0x100736fe0
В комментариях показаны примеры адресов памяти.
Это простой способ показать, как работает “копирование при записи”. Сначала создается arrayOne
со значениями и назначается arrayTwo
, где из-за “копирования при записи” он не копируется, поэтому оба указывают на один и тот же адрес.
Когда arrayTwo
изменяется, тогда данные arrayTwo
копируются и меняется адрес памяти для этого массива.
Также адрес памяти меняется, когда массив arrayOne
копируется с преобразованием, например, в какой-то массив arrayThree
с типом Any
.
Плюсы Copy-on-Write
-
Экономия ресурсов (памяти и процессорного времени). Пока копия не будет модифицирована - не будет настоящего копирования и память не будет тратиться на сохранение копии данных.
-
Скорость. Для библиотек и фреймворков требуется скорость. Поэтому механизм “копирования при записи” очень пригодится.
ArraySlice и Substring
Допустим есть массив данных и требуется создать второй массив из части первого массива:
let fibonacci = [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
let lessTen = fibonacci[..<7]
print(lessTen)
// Output
// [0, 1, 1, 2, 3, 5, 8]
Новая память под массив lessTen
не будет выделятся, данные не будут копироваться.
Или из строки необходимо создать подстроку:
let griting = "Hello! Nice to meet you!"
let endOfSentence = griting.firstIndex(of: "!")
let firstSentence = griting[...(endOfSentence!)]
print(firstSentence)
// Output
// Hello!
Новая память для firstSentence
не будет выделятся, данные не будут копироваться.
Плюсы:
- не выделяется помять до изменения второго элемента (
lessTen
,firstSentence
); - интерфейс
ArraySlice
такой же, как и у обычного массива, и уSubstring
- как у строки; ArraySlice
иSubstring
указывают на изначальные данные и делают некоторое представление с указателями на начало и конец данных.
Пользовательский Copy-on-Write
Допустим, что в проекте есть структуры с большим количеством данных. И явное копирование может быть ресурсозатратным.
Задача: реализовать структуру, которая не будет обладать свойствами класса. Будет копироваться, но не сразу, а при изменении.
struct SomeStruct {
var value: String
}
Для этой структуры создадим механизм Copy-on-Write.
Необходима ссылка, которой нет у структур. И этой ссылкой будет класс Reference
. Класс Reference
- это некая обёртка, в которую будем помещать нашу структуру.
Структура Container
будет реализовывать сам механизм Copy-on-Write. В инициализатор структуры Container
будем передавать нашу структуру, для которой будем использовать Copy-on-Write - SomeStruct
.
// Manual CoW
final class Reference<T> {
var value: T
init(value: T) {
self.value = value
}
}
struct Container<T> {
var ref: Reference<T>
init(value : T) {
ref = Reference(value: value)
}
var value: T {
get {
ref.value
}
set {
// Check uniqueness for a strong link
guard isKnownUniquelyReferenced(&ref) else {
// create a new instance
ref = Reference(value: newValue)
return
}
ref.value = newValue
}
}
}
let myStruct = SomeStruct(value: "CoW")
var container1 = Container(value: myStruct)
var container2 = container1 // shares container1.ref
func addressHeap<T>(o: T) -> Int {
return unsafeBitCast(o, to: Int.self)
}
print(container1.value, container2.value)
print(NSString(format: "%p", addressHeap(o: container1)))
print(NSString(format: "%p", addressHeap(o: container2)))
// Output
// SomeStruct(value: "CoW") SomeStruct(value: "CoW")
// 0x1007a7250
// 0x1007a7250
container2.value.value = "WOW" // create container2.ref
print(container1.value, container2.value)
print(NSString(format: "%p", addressHeap(o: container1)))
print(NSString(format: "%p", addressHeap(o: container2)))
// Output
// SomeStruct(value: "CoW") SomeStruct(value: "WOW")
// 0x1007a7250
// 0x105204340
При обращении к свойству value
структуры Container
для чтения, будет возвращаться экземпляр нашей структуры.
При обращении к свойству value
структуры Container
для установки, будет происходит реализация Copy-on-Write.
Происходит проверка с помощью метода isKnownUniquelyReferenced()
, который вернет true
, если будет только одна сильная ссылка (узнаём, указывает ли на ref
хоть одна сильная ссылка).
Если есть только одна сильная ссылка, то выполняется код после конструкции guard
и значение value
будет установлено newValue
.
Если ссылка не уникальна (несколько сильных ссылок на один объект ref
), то создаём новый экземпляр класса Reference
(оборачиваем newValue
в новый Reference
) и его возвращаем. Тогда не меняется ref
у всех экземпляров Container
, а создаётся новый, копируется в новую область памяти и меняется значение.
Это пример кода, показывающий, как можно использовать ссылочный тип для реализации “копирования при записи” для универсального типа значений T
. По сути, это оболочка, которая управляет ссылочным типом и просто возвращает новый экземпляр, если на значение нет уникальной ссылки. В противном случае он просто изменяет значение ссылочного типа.
Copy-on-Write — это очень интеллектуальный способ оптимизации копирования типов значений, и этот механизм широко используется в Swift. Хотя в большинстве случаев мы не видим его явно, потому что его реализация сделана для нас в стандартной библиотеке Swift.
В этой статье было показано, как Swift реализует “копирование при записи”. Когда мы КОПИРУЕМ массив в другую переменную, копируется только ссылка.
Благодаря функции “копирования при записи”, только когда мы ЗАПИСЫВАЕМ в массив, Swift копирует это новое значение в новый адрес памяти. Это позволяет значительно снизить потребление ресурсов, а когда мы говорим об iPhone - ресурсы, такие как батарея/сеть/емкость, ограничены.
Массив является типом значения, поэтому он всегда должен копировать значение. Но если вы не измените данные, массив не будет копироваться. И в этом вся прелесть. Можно копировать и совместно использовать экземпляры массивов и экземпляры словарей, не задумываясь о производительности памяти.