Безопасность хранения

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

17 ноября 2022

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

Что такое конфликт доступа к памяти

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


// Доступ к памяти с правами записи, где хранится данная переменная
var one = 1

// Доступ к памяти с правами чтения, где хранится данная переменная
print("We're number \(one)!”)

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

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

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

Этот пример также отражает проблему, с которой вы можете столкнуться при устранении конфликта доступа к памяти: иногда есть несколько способов исправить конфликт, порождающий разные ответы, и не всегда очевидно, какой ответ правильный. В этом примере, в зависимости от того, хотите ли вы получить исходную общую текущую сумму или обновленную общую сумму, правильный ответ может быть равен $5 или $320. Прежде чем вы сможете исправить конфликт доступа, вы должны определить, в чем именно он заключается.

Заметка

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

Если у вас есть конфликт доступа к памяти в одном потоке, Swift гарантирует, что вы получите сообщение об ошибке во время компиляции или выполнения. Для многопоточного кода используйте Санитайзер потоков, чтобы выявить конфликт доступа по потокам.

Характеристики доступа к памяти

Существует три характеристики доступа к памяти (в контексте рассматриваемого конфликта доступа): является ли доступ с правами чтения или записи, продолжительностью доступа и местоположением в памяти, к которой обращаются. В частности, конфликт возникает, если у вас есть два доступа, отвечающие полностью всем следующим условиям:

  • По крайней мере, один из них является доступом на запись или неатомарным доступом.
  • Они получают доступ к одному и тому же фрагменту в памяти.
  • Их длительность перекрывается друг другом.

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

Операция является атомарной, если она использует атомарные операции языка C, в противном случае операция неатомарная. Список атомарных операций можно найти на справочной странице stdatomic(3).

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


func oneMore(than number: Int) -> Int {
    return number + 1
}
 
var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// Выведет "2"

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

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

Конфликт доступа к сквозным параметрам

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

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


var stepSize = 1
 
func increment(_ number: inout Int) {
    number += stepSize
}
 
increment(&stepSize)
// Ошибка: conflicting accesses to stepSize

В приведенном выше коде stepSize является глобальной переменной, и она обычно доступна из increment(_:). Тем не менее, доступ для чтения к stepSize перекрывается доступом для записи к number. Как показано на рисунке ниже, и number, и stepSize относятся к одному и тому же фрагменту памяти. Доступ для чтения и записи относится к одной и той же памяти, и они перекрываются, создавая конфликт.

Один из способов решения этого конфликта - сделать явную копию stepSize:


// Создадим явную копию
var copyOfStepSize = stepSize
increment(&copyOfStepSize)
 
// Обновим оригинал
stepSize = copyOfStepSize
// stepSize равен 2

Когда вы создаете копию stepSize перед вызовом increment(_:) то становится ясно, что значение copyOfStepSize увеличивается на текущий размер шага. Доступ для чтения заканчивается до начала доступа для записи, поэтому конфликт не возникает.

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


func balance(_ x: inout Int, _ y: inout Int) {
    let sum = x + y
    x = sum / 2
    y = sum - x
}
var playerOneScore = 42
var playerTwoScore = 30
balance(&playerOneScore, &playerTwoScore)  // OK
balance(&playerOneScore, &playerOneScore)
// Ошибка: Conflicting accesses to playerOneScore

Функция balance(_:_:) изменяет два своих параметра, чтобы равномерно распределять общее значение между ними. Вызов с помощью playerOneScore и playerTwoScore в качестве аргументов не приводит к конфликту - есть два доступа для записи, которые перекрываются во времени, но они получают доступ к различным фрагментам в памяти. Напротив, передача playerOneScore в качестве значения для обоих параметров вызывает конфликт, поскольку он пытается одновременно выполнить два доступа для записи в одно и то же место в памяти.

Заметка

Так как операторы это функции, то они также могут иметь долгосрочный доступ к своим сквозным параметрам. Например, если balance(_:_:) это операторная функция с именем <^>, то запись playerOneScore <^> playerOneScore приведет к такому же конфликту, что и balance(&playerOneScore, &playerOneScore)

Конфликт доступа к self в методах

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


struct Player {
    var name: String
    var health: Int
    var energy: Int
    
    static let maxHealth = 10
    mutating func restoreHealth() {
        health = Player.maxHealth
    }
}

В вышеописанном методе restoreHealth() доступ для записи к self начинается с начала метода и продолжается до тех пор, пока метод не вернется. В этом случае внутри restoreHealth() нет другого кода, который может иметь перекрывающий доступ к свойствам экземпляра Player. Метод shareHealth(with:) (см. ниже) принимает другой экземпляр Player как сквозной параметр, создавая возможность перекрытия доступа.


extension Player {
    mutating func shareHealth(with teammate: inout Player) {
        balance(&teammate.health, &health)
    }
}
 
var oscar = Player(name: "Oscar", health: 10, energy: 10)
var maria = Player(name: "Maria", health: 5, energy: 10)
oscar.shareHealth(with: &maria)  // OK

В приведенном выше примере вызов shareHealth(with:) для игрока Оскара для совместного использования здоровья с игроком Марией не вызывает конфликт. Во время вызова метода есть доступ для записи и к oscar, потому что oscar - это значение self в изменяющем методе, и есть доступ на запись к maria в течение той же продолжительности, потому что maria была передана как сквозной параметр. Как показано на рисунке ниже, они получают доступ к различным фрагментам памяти. Несмотря на то, что два доступа для записи перекрываются во времени, они не конфликтуют.

Однако, если вы передадите oscar в качестве аргумента shareHealth(with:), то возникает конфликт:


oscar.shareHealth(with: &oscar)
// Ошибка: conflicting accesses to oscar

Mutating метод требует доступа для записи к self в течение всего метода, а сквозной параметр требует доступа для записи к teammate в это же время. Внутри метода и self, и teammate относятся к одному и тому же фрагменту в памяти - как показано на рисунке ниже. Два доступа для записи относятся к одному и тому же фрагменту в памяти, и они перекрываются, создавая конфликт.

Конфликт доступа к свойствам

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


var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// Ошибка: conflicting access to properties of playerInformation

В приведенном выше примере вызов balance(_:_:) по элементам кортежа создает конфликт, потому что есть перекрывающий доступ для записи к playerInformation. И playerInformation.health, и playerInformation.energy передаются как сквозные параметры, что означает, что balance(_:_:) требует доступ для записи во время вызова функции. В обоих случаях доступ для записи к элементу кортежа требует доступа для записи ко всему кортежу. Это означает, что есть два доступа для записи в playerInformation с длительностью, которая перекрывается, вызывая конфликт.

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


var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy)  // Ошибка

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


func someFunction() {
    var oscar = Player(name: "Oscar", health: 10, energy: 10)
    balance(&oscar.health, &oscar.energy)  // OK
}

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

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

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

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

Содержание