본문 바로가기
프로그래밍/Swift

[Swift] Memory Safety

by 별준 2022. 3. 11.

References

Contents

  • Conflict Access 특징
  • Conflicting Access of In-Out Parameters
  • Conflicting Access to self in Methods
  • Conflicting Access to Properties

기본적으로 Swift는 코드에서 unsafe한 동작이 발생하지 않도록 방지합니다. 예를 들어, 변수가 사용되기 전에 초기화되도록 보장하며, 해제된 이후에는 메모리에 액세스할 수 없도록 보장합니다. 배열의 경우에는 경계 에러를 체크합니다.

 

또한 메모리의 어느 공간을 수정하는 코드가 그 메모리의 소유권을 가지도록 요구함으로써 동일한 메모리 영역을 동시에 접근할 때 충돌하지 않도록 해줍니다. Swift는 이렇게 메모리와 관련된 것들을 자동으로 관리하기 때문에 메모리 액세스에 대한 것들을 거의 생각하지 않아도 됩니다. 하지만, 잠재적으로 발생할 수 있는 충돌에 대해서 이해해야 메모리 액세스 충돌을 일으키는 코드를 작성하지 않습니다. 만약 코드에서 충돌이 발생하면 컴파일 에러나 런타임 에러가 발생합니다.

 


Understanding Conflicting Access to Memory

변수에 값을 설정하거나 함수에 값을 인수로 전달할 때 메모리 액세스가 발생합니다. 예를 들어, 다음 코드는 read 액세스와 write 액세스를 모두 포함하고 있습니다.

// A write access to the memory where one is stored.
var one = 1

// A read access from the memory where one is stored.
print("We're number \(one)!")

메모리 액세스 충돌은 코드 다른 부분에서 동시에 동일한 메모리 영역을 액세스할 때 발생할 수 있습니다. 어느 메모리 영역에 동시에 액세스하는 것은 예측불가능한 결과를 발생시킵니다.

 

종이에 적힌 예산을 어떻게 업데이트하는지에 대해 생각해보면 위와 같은 문제가 어떻게 발생하는지 이해할 수 있습니다. 먼저 항목의 이름과 가격을 추가하고, 그 후에 현재 리스트에 존재하는 항목이 반영된 총액을 변경합니다. 만약 업데이트하기 전이나 후에 예산에서 총액을 읽으면 올바른 값을 얻을 수 있습니다.

하지만, 예산에 항목과 가격을 추가하는 동안에는 총액에 새로운 항목이 반영되지 않았기 때문에 이는 임시적이고 유효하지 않은 상태입니다. 만약 항목을 추가하는 도중에 총액을 읽는다면 잘못된 정보를 읽게 됩니다.

 

동시성(concurrency) 또는 멀티스레딩(multithreading) 코드를 작성해봤다면 메모리 충돌이 익숙할 수 있습니다. 하지만 여기서 논의되는 메모리 충돌은 단일 스레드에서 발생할 수 있습니다.
단일 스레드 내에서 메모리 충돌이 발생하면 Swift는 컴파일 에러나 런타임 에러가 발생하도록 보장합니다. 멀티스레드인 경우에는 Thread Sanitinzer를 사용해서 스레드 간 메모리 충돌을 감지할 수 있습니다.

 

Characteristics of Memory Access

메모리 충돌이 발생할 수 있는 상황은 크게 3가지 특징을 가지고 있습니다. 다음 조건 중 2가지를 만족한다면 메모리 충돌이 발생합니다.

  • 적어도 하나의 write 액세스 또는 non-atomic 액세스가 있을 때
  • 메모리의 동일한 영역에 액세스할 때
  • 액세스 지속 시간이 겹칠 때

read 액세스와 write 액세스의 차이는 명확합니다. write 액세스는 메모리의 영역을 변경하지만, read 액세스는 변경하지 않습니다. 메모리 영역은 무엇을 참조하고 있는지를 나타내는데, 예를 들어, 변수, 상수, 또는 프로퍼티가 있습니다. 메모리 액세스의 지속시간은 instantaneous(순간적) 또는 long-term(장시간)일 수 있습니다.

 

만약 오직 C 아토믹 연산(?)만을 사용한다면 명령은 atomi이며, 그렇지 않다면 non-atomic입니다. 

 

만약 액세스가 끝나기 전에 다른 코드가 실행될 가능성이 없다면 이 액세스는 순간적(instantaneous)입니다. 기본적으로 순간적으로 발생하는 두 액세스는 동시에 발생할 수 없습니다. 예를 들어, 다음 코드에서 모든 read / write 액세스는 순간적으로 발생합니다.

func oneMore(than number: Int) -> Int {
    return number + 1
}

var myNumber = 1
myNumber = oneMore(than: myNumber)
print(myNumber)
// Prints "2"

그러나 다른 코드의 실행에 걸쳐서 메모리에 액세스하는 여러 방법들이 있는데, 이를 장기간(long-term) 액세스라고 합니다. 순간적인 액세스와 장기간 액세스의 차이점은 장기간 액세스가 시작된 후 종료되기 전에 다른 코드가 실행될 수 있다는 점입니다. 이를 오버랩(overlap)이라고 하며, 장기간 액세스는 다른 장기간 액세스 또는 순간적인 액세스와 오버랩될 수 있습니다.

 


Conflicting Access to In-Out Parameters

함수는 모든 in-out 파라미터에 장기간 write 액세스를 갖습니다. in-out 파라미터에 대한 write 액세스는 in-out이 아닌 모든 파라미터들이 평가된 후 시작하며 함수 호출의 전체 기간동안 지속됩니다. 여러 개의 in-out 파라미터가 있는 경우에는 매개변수가 나타나는 순서대로 write 액세스가 시작됩니다.

 

이러한 long-term write 액세스의 결과 중 하나는 비록 scoping rules와 액세스 제어가 허용하더라도 in-out으로 전달된 원래 변수에 액세스할 수 없다는 것입니다. 원본에 대한 다른 액세스는 충돌을 일으킵니다.

예를 들어, 다음 코드를 살펴보겠습니다.

var stepSize = 1

func increment(_ number: inout Int) {
    number += stepSize
}

increment(&stepSize)
// Error: conflicting accesses to stepSize

위 코드에서 stepSize는 저역 변수이며, increment(_:) 내에서 정상적으로 접근할 수 있습니다. 그러나 stepSize의 read 액세스는 number에 대한 write 액세스와 겹칩니다. 아래 그림은 number와 stepSize가 메모리 내에서 동일한 영역을 참조한다는 것을 보여줍니다.

read와 write 액세스가 동일한 메모리 영역을 참조하고, 그 기간이 겹치기 때문에 충돌이 발생합니다. 위 코드를 실행해보면 다음의 에러가 발생합니다.

 

이 충돌을 해결하는 한 가지 방법은 stepSize의 복사본을 만드는 것입니다.

// Make an explicit copy.
var copyOfStepSize = stepSize
increment(&copyOfStepSize)

// Update the original.
stepSize = copyOfStepSize
// stepSize is now 2

increment 함수를 호출하기 전에 stepSize의 복사본을 만들 때, copyOfStepSize의 값이 현재 스텝 사이즈만큼 증가한다는 것이 명확합니다. read 액세스는 write 액세스가 시작되기 전에 끝나므로 메모리 충돌이 발생하지 않습니다.

 

 

한 함수가 받는 여러 in-out 파라미터에 똑같은 변수를 전달할 때도 충돌이 발생합니다. 다음 코드를 살펴보겠습니다.

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)
// Error: conflicting accesses to playerOneScore

balance(_:_:) 함수는 전달받은 두 파라미터의 총 합을 균일하게 나누어 해당 파라미터에 다시 저장합니다. playerOneScore과 playerTwoScore를 인수로 전달하여 호출하는 것은 충돌을 일으키지 않습니다. 여기서 두 write 액세스의 기간이 겹치지만 메모리의 다른 영역에 액세스하고 있습니다. 이와 반대로 playerOneScore를 두 파라미터의 ㅇ니수로 전달하면 충돌이 발생할 수 있습니다. 이는 두 write 액세스가 동시에 동일한 메모리 영역에 액세스하기 때문입니다.

 


Conflicting Access to self in Methods

구조체에서 mutating 메소드는 메소드가 호출동안 self에 대해 write 액세스를 가지고 있습니다. 예를 들어, 각 플레이어가 데미지를 입으면 감소하는 health와 특별한 기술을 사용할 때 감소하는 energy를 가지고 있는 게임을 표현하는 코드를 살펴보겠습니다.

struct Player {
    var name: String
    var health: Int
    var energy: Int

    static let maxHealth = 10
    mutating func restoreHealth() {
        health = Player.maxHealth
    }
}

위 코드에서 restoreHealth() 메소드는 메소드가 시작할 때 self에 대한 write 액세스를 가지게 되고, 이 액세스는 메소드가 리턴될 때까지 지속됩니다. 위 코드의 경우,  restoreHealth() 코드 내 Player 인스턴스의 프로퍼티에 오버랩되는 액세스는 없습니다.

하지만 아래 코드에서 shareHealth() 메소드는 다른 Player 인스턴스를 in-out 파라미터로 전달받기 때문에 액세스가 오버랩될 가능성을 만듭니다.

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

위 예제 코드에서 oscar 플레이어의 shareHealth(with:) 메소드 호출은 maria 플레이어의 health를 공유하게 되며, 여기서 충돌은 발생하지 않습니다. 이 메소드가 진행되는 동안 mutating 메소드에서 oscar는 self의 값이므로 oscar에 대한 write 액세스가 발생하고, 같은 기간동안 in-out 파라미터로 전달된 maria로 인해 maria에 대한 write 액세스도 발생합니다. 아래 그림에서 보여주는 것처럼, 각 write 액세스는 메모리의 다른 영역에 접근합니다. 비록 액세스가 되는 기간이 겹치지만 충돌은 발생하지 않습니다.

 

그러나, shareHealth(with:)의 인수로 oscar를 전달하면 충돌이 발생합니다.

oscar.shareHealth(with: &oscar)
// Error: conflicting accesses to oscar

이 mutating 메소드는 메소드가 실행되는 동안 self에 대한 write 액세스를 필요로 하며, 같은 기간 동안 in-out 파라미터도 teammate에 대한 write 액세스를 필요로 합니다. 아래 그림에서 보여주는 것처럼 이 메소드 내에서 self와 teammate는 메모리의 같은 영역을 참조합니다. 동일한 메모리 영역에 대한 참조가 오버랩되므로, 충돌이 발생하게 됩니다.

 


Conflicting Access to Properties

구조체(structures), 튜플(tuples), 열거형(enumerations)와 같은 타입은 구조체의 프로퍼티나 튜플의 원소와 같은 개별 값들로 구성됩니다. 이들은 값 타입(value types)이기 때문에 그들의 값의 일부를 변경하면 전체 값이 변경됩니다. 즉, 프로퍼티 중 하나에 대한 write 액세스는 전체 값에 대한 read 또는 write 액세스가 필요합니다. 예를 들어, 한 튜플에 대한 write 액세스가 오버랩되면 메모리 충돌이 발생합니다.

var playerInformation = (health: 10, energy: 20)
balance(&playerInformation.health, &playerInformation.energy)
// Error: conflicting access to properties of playerInformation

위 예제 코드에서 튜플 요소에 대한 balance(_:_:) 함수 호출은 액세스 충돌을 일으키는데, 이는 playerInformation에 대한 write 액세스가 오버랩되기 때문입니다. playerInformation.health와 playerInformation.energy가 in-out 파라미터로 전달된다는 것은 balance(_:_:)가 함수가 호출되는 동안 그들에 대한 write 액세스를 필요로 한다는 것을 의미합니다. 이 경우에, 튜플 요소에 대한 write 액세스는 튜플 전체에 대한 write 액세스를 필요로 합니다. 따라서, playerInformation에 대한 두 write 액세스가 같은 기간동안 오버랩되고 충돌을 일으킵니다.

 

아래 코드도 전역 변수로 저장된 구조체의 프로퍼티에 대한 write 액세스가 오버랩되어서 동일한 에러가 발생하게 됩니다.

var holly = Player(name: "Holly", health: 10, energy: 10)
balance(&holly.health, &holly.energy)  // Error

 

하지만, 구조체의 프로퍼티에 대한 대부분의 액세스는 안전하게 오버랩될 수 있습니다. 예를 들어, 위 바로 위 코드에서 holl 변수가 전역이 아닌 로컬 변수로 변경되면, 컴파일러는 구조체의 저장 프로퍼티에 대한 액세스의 오버랩이 안전하다고 증명합니다.

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

위 코드에서 oscar의 health와 energy가 balance(_:_:)의 in-out 파라미터로 전달됩니다. 두 저장 프로퍼티는 어떤 식으로도 상호작용하지 않기 때문에 컴파일러는 메모리 safety가 유지된다고 증명합니다.

 

항상 메모리 safety가 보존되어야 구조체 프로퍼티의 액세스가 오버랩될 수 있는 것은 아닙니다. 메모리 safety는 보장되기를 원하는 것이고, 배타적 액세스(exclusive access)가 메모리 safety보다 더 엄격한 요구사항입니다. 즉, 일부 코드는 비록 배타적 액세스를 위반하더라도 메모리 safety를 유지합니다. 컴파일러가 메모리에 대한 nonexclusive(비독점적)인 액세스가 안전한다는 것을 증명하는 경우, Swift는 memory-safe 코드를 허용합니다. 특히, 다음 조건이 적용되는 경우에는 프로퍼티에 대한 액세스 오버랩이 안전한다는 것을 증명할 수 있습니다.

  • 오직 인스턴스의 저장 프로퍼티에만 액세스하고 연산 프로퍼티나 클래스 프로퍼티에는 액세스하지 않는 경우
  • 구조체가 전역이 아닌 지역 변수의 값인 경우
  • 구조체가 어떤 클로저로부터 캡처되지 않거나, 오직 nonescaping 클로저에 캡처되는 경우

만약 컴파일러에 의해서 액세스가 안전하다고 증명되지 않는다면 액세스는 허용되지 않습니다.

'프로그래밍 > Swift' 카테고리의 다른 글

[Swift] Automatic Reference Counting (ARC)  (0) 2022.03.10
[Swift] Opaque Types  (0) 2022.03.09
[Swift] Error Handling  (0) 2022.03.08
[Swift] Generics (제네릭)  (0) 2022.03.07
[Swift] Protocols (2)  (0) 2022.02.05

댓글