Swift/Swift 가이드

[Swift] 공식 문서 - Memory Safety

유정주 2022. 8. 30. 23:29
반응형

새로 배운 점

  • Swift는 특정 메모리 공간을 수정하는 코드가 그 메모리의 소유권을 가지도록 요구함으로써 동일한 메모리 영역을 동시에 접근할 때 충돌하지 않도록 해줍니다.
  • 메모리 액세스의 지속시간은 instantaneous(순간적) 또는 long-term(장시간)일 수 있습니다.
  • 만약 액세스가 끝나기 전에 다른 코드가 실행될 가능성이 없다면 이 액세스는 순간적(instantaneous)입니다.
  • 다른 코드의 실행에 걸쳐서 메모리에 액세스하는 여러 방법들이 있는데, 이를 장기간(long-term) 액세스라고 합니다.
  • 오버랩(overlap)이란 장기간 액세스가 시작된 후 종료되기 전에 다른 코드가 실행되는 것입니다.
  • 장기간 액세스는 다른 장기간 액세스 또는 순간적 액세스와 오버랩 될 수 있습니다.
  • 함수는 모든 in-out 파라미터에 장기간 write 액세스를 갖습니다.
  • 구조체에서 mutating 메서드는 메서드가 호출동안 self에 대해 write 엑세스를 가지고 있습니다.
  • 값 타입은 프로퍼티 중 하나에 대한 write 액세스는 전체 값에 대한 read 또는 write 액세스가 필요합니다.
  • 구조체의 프로퍼티에 대한 대부분의 액세스는 안전하게 오버랩될 수 있습니다.

 

Memory Safety

기본적으로 Swift는 코드에서 안전하지 않은(unsafe) 동작을 방지합니다.

변수가 사용되기 전에 초기화되도록 보장하며, 해제도니 이후에는 메모리에 액세스할 수 없도록 보장합니다.

배열의 경우에는 out-of-bounds 에러를 체크합니다.

 

또한 특정 메모리 공간을 수정하는 코드가 그 메모리의 소유권을 가지도록 요구함으로써 동일한 메모리 영역을 동시에 접근할 때 충돌하지 않도록 해줍니다.

이렇게 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(장시간)일 수 있습니다.

 

만약 액세스가 끝나기 전에 다른 코드가 실행될 가능성이 없다면 이 액세스는 순간적(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 액세스가 시작됩니다.

 

이런 장기간 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를 인수로 전달하여 호출하는 것은 충돌하지 앟습니다.

이와 반대로 playerOneScore를 두 파라미터의 인수로 전달하면 충돌이 발생합니다.

이는 두 write 액세스가 동일한 메모리 영역에 액세스하기 때문입니다.

 

연산자는 함수이기 때문에 인아웃 매개변수에 장기간 액세스할 수도 있습니다.
예를 들어, 만약 밸런스(_:_:)가 연산자 함수라면 플레이어 원스코어 <^> playerOneScore를 쓰면 밸런스(&playerOneScore, &playerOneScore)와 동일한 충돌이 발생합니다.

 

Conflicting Access to self in Methods

구조체에서 mutating 메서드는 메서드가 호출동안 self에 대해 write 엑세스를 가지고 있습니다.

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

구조체, 튜플, 열거형과 같은 타입은 구조체의 프로퍼티나 튜플의 원소와 같은 개별 값들로 구성됩니다.

이들은 값 타입이기 때문에 그들의 값의 일부를 변경하면 전체 값이 변경됩니다.

즉, 프로퍼티 중 하나에 대한 write 액세스는 전체 값에 대한 read 또는 write 액세스가 필요합니다.

예를 들어, 한 튜플에 대한 write 액세스가 오버랩되면 메모리 충돌이 발생합니다.

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

위 예제 코드에서 튜플 요소에 대한 balance(_:_:) 호출은 액세스 충돌을 일으키는데, playerInfomation에 대한 write 액세스가 오버랩되기 때문입니다.

palyerInformation.health와 playerInfomation.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

 

하지만, 구조체의 프로퍼티에 대한 대부분의 액세스는 안전하게 오버랩될 수 있습니다.

예를 들어, 바로 위 코드에서 holly 변수가 로컬 변수면 컴파일러는 구조체의 저장 프로퍼티에 대한 액세스의 오버랩이 안전하다고 판단합니다.

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

위 코드에서 oscar의 health와 energy가 balance(_:_:)의 in-out 파라미터로 전달됩니다.

두 저장 프로퍼티는 어떤 식으로도 상호작용하지 않기 때문에 컴파일러는 메모리 safety가 유지된다고 판단합니다.

 

컴파일러가 메모리에 대한 nonexclusive(비독점적)인 액세스가 안전하다는 것을 증명하는 경우 Swift는 memory-safe 코드를 허용합니다.

특히, 다음 조건이 적용되는 경우에는 프로퍼티에 대한 액세스 오버랩이 안전하다는 것으로 판단합니다.

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

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

 

 

참고

https://docs.swift.org/swift-book/

 

 

반응형