본문 바로가기

Swift

atomic VS nonatomic

728x90

atomic

atomic의 사전적인 정의는 '중단되지 않는'을 의미합니다. 

atomic : an operation appears to occur at a single instant between its invocation and its response

 

즉, atomic 하다는 것은 프로그래밍에서 데이터의 변경이 한 번에 일어난 것처럼 보이게 하는 것을 의미합니다.

 

데이터의 값을 변경하는 작업은 반드시 값 변경하는 시간이 필요합니다. atomic 한 데이터는 이러한 값 변경 시간이 0초인 것처럼 느끼게 합니다. 다시 말해서, 데이터의 값에 접근하는 여러 데이터 소비자(= 프로세스, 스레드)의 관점에서 데이터 값 변경에 걸리는 시간이 0초인 것처럼 느끼게 하는 것입니다. 

 

이러한 일이 가능한 이유는 다음과 같습니다. ⬇️

Objective-C에서는 atomic 한 데이터를 set 할 때 lock 을 걸어서 atomic 데이터를 만들 수 있도록 합니다. 

if (!atomic) {
    oldValue = *slot;
    *slot = newValue;
} else {
    spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)];
    _spin_lock(slotlock);
    oldValue = *slot;
    *slot = newValue;
    _spin_unlock(slotlock);
}

스레드에 lock을 건다는 것은 데이터를 변경할 때, 데이터 변경 작업 이외의 다른 모든 작업이 멈추도록 합니다. (= Spin Lock을 걸게 되면, 크리티컬 섹션의 동작이 모두 끝날 때까지 스레드가 루프를 돌면서 busy waiting을 하게 됩니다.) 그래서 이는 다르게 말하면, 데이터 변경 시에 데이터 업데이트 이외의 작업은 일어나지 않기 때문에 데이터가 시간 소모 없이 바로 변경된 것처럼 보이게 합니다. 

 

이런 방식으로 atomic 데이터는 멀티 쓰레드 환경에서 데이터가 반드시 변경 전, 변경 후의 상황에서만 접근할 수 있도록 보장합니다. 즉, 데이터 변경 중에는 해당 데이터의 접근이 불가능하도록(lock을 걸었기 때문에) 합니다. 

 

lock에 대한 자세한 내용은 문서를 참고하면 좋습니다. 

 

atomic과 Objective-C

Objective-C의 프로퍼티는 기본적으로 atomic으로 선언됩니다. 다만, atomic 프로퍼티는 앞서 말한 것과 같이 데이터 변경 도중에 접근하지 못하도록 lock을 걸기 때문에 프로퍼티 접근 성능이 느려집니다. 

 

✅ 그래서 멀티 쓰레드에서 접근될 이유가 없는 Objective-C 프로퍼티에는 noatomic annotation을 설정하는 것이 좋습니다.

@interface Asset : NSObject
// name을 nonatomic으로 사용
@property (nonatomic, strong) NSString *name;
@end

 

atomic과 Swift

반면, Swift는 Thread-Safe 를 고려하고 디자인된 언어가 아니므로, 모든 프로퍼티가 nonatomic입니다. 그리고 별도로 atomic 옵션을 지정할 수 없습니다. 

 

Thread Safe가 무엇인지는 아래를 통해서 확인할 수 있습니다. 

더보기

Thread Safe

Thread Safe의 여부를 판단하는 것은 다중 쓰레드 환경에서 코드를 작성할 때, 중요합니다. 해당 글에서는 개념과 함께 어떻게 Thread Safe를 판단하는지 살펴보겠습니다. 

Thread Safe란 무엇인가?

Thread Safe의 정의는 다음과 같습니다.

A data type or static method is threadsafe if it behaves correctly when used from multiple threads, regardless of how those threads are executed, and without demanding additional coordination from the calling code.

데이터의 타입 또는 static 메소드가 Thread Safe 하다는 것은 다음의 조건을 만족할 때를 의미합니다.

1. 다중 쓰레드의 동작과 상관없이 항상 올바르게 동작한다.

🤔 올바르게 동작한다?

명세를 만족 시키고 객체의 표현 불변성을 유지하는 것을 의미합니다. 

 

2. 호출에 있어서 추가적인 조건이 없다.

🤔 추가적인 조건이 없다?

데이터 타입이 타이밍과 관련하여 호출자에 전제 조건을 지정할 수 없다는 것을 의미합니다. 

 

.. 이렇게만 보아서는 제대로 이해하기 어려워 설명을 덧붙이겠습니다.

✅ 표현 불변성(= representation invariant) 은 어떤 클래스에서 항상 변하지 않는 것을 지칭합니다.

주사위를 굴릴 때 눈금은 항상 1에서 6사이 입니다. 1개의 주사위를 굴리는 행위로는 1에서 6 이외의 자연수를 내놓을 수 없습니다. 다시 말해, Dice 클래스에서 throwDice라는 메서드는 Dice 클래스의 representation invariant를 유지한다고 할 수 있습니다. 

 

✅ 타이밍과 관련되어서 호출자에 전제 조건이 있다는 것은 아래와 같은 경우입니다.

UITableView는 특정 cell만 reload하기 위해서 다음과 같은 코드를 작성합니다.

func updateCell() {
    tableView.beginUpdates()
    // cell update
    tableView.endUpdates()
}

이 경우에, endUpdates 메소드는 반드시 beginUpdates 메서드 이후에 호출되어야 한다는 전제 조건이 있습니다. 이러한 전제 조건이 없는 경우를 호출자에 전제 조건이 없다고 말합니다.

Thread Safe 판단 예시 

1. Swift  Class Instance

참조 타입의 인스턴스를 새로 만드는 것은 메모리 delloc(해제) 이후에 alloc(할당)이 이루어져야 합니다. 하지만, 다중 스레드 환경에서 참조 타입 인스턴스의 해제/할당 정보는 스레드 단위로 공유되지 않습니다.

 

class Bird {}
var single = Bird()

DispatchQueue.global().async {
    while true { single = Bird() }
}

while true { single = Bird() }

위의 코드는 실행 시 빠르게 크래시가 발생합니다. 이는 다중 쓰레드 환경에서 하나의 스레드가 다른 스레드의 인스턴스 해제 정보를 알지 못하기 때문에 발생합니다.

 

Swift의 Class 인스턴스 reference count 값은 atomic하게 업데이트되어, racing condition에 빠지지 않습니다. 하지만, atomic하게 데이터를 업데이트하는 것이 보장하는 것은 reference type 인스턴스의 생성(해제) 도중에 다른 스레드가 인스턴스에 접근하지 못하도록 하는 것입니다. 이 경우 여전히 인스턴스 생성 이후, 생성 이전에는 다른 스레드에서 인스턴스에 접근이 가능합니다. 이때, 다른 스레드는alloc/dealloc 정보를 모르기 때문에 reference type 인스턴스의 생성과 해제는 Thread Safe하지 않습니다.

🧐 racing condition
공유 자원에 대해 여러 개의 프로세스가 동시에 접근을 시도할 때 접근의 타이밍이나 순서 등이 결괏값에 영향을 줄 수 있는 상태

2. File read/write

파일에 데이터를 읽고 쓰는 작업이 Thread Safe 한 가를 고려할 때 타이밍 이슈를 생각할 수 있습니다. 파일의 내용이 read(), wirte()의 순서에 따라서 결과가 바뀝니다. 즉, 아래의 경우에 대해서 결과가 같음을 보장할 수 없습니다.

  • read() 이후에 write()
  • write() 이후에 read()
  • read() 도중에 write()
  • write() 도중에 read()

그러므로 File의 read/write는 Thread Safe 하지 않습니다.

 

여기서 Thread Safe 하지 않도록 만드는 주요인은 File의 내용이 Mutable 하다는 것입니다. 

만약, File의 데이터가 Immutable 하다면, 타이밍 이슈는 해결됩니다. 

 

Thread Safe 달성하기

Thread Safe를 달성하기 위해 제안되는 몇 가지 방법이 있습니다.

  1. Mutable Exclusion
    • 스레드에 lock 또는 semaphore를 걸어서 공유 자원에 하나의 쓰레드만 접근할 수 있도록 합니다.
  2. Thread Local Storage
    • 특정 쓰레드에서만 접근할 수 있는 저장소를 만듭니다.
  3. Reentrancy
    • 쓰레드에서 동작하는 코드가 동일 쓰레드에서 재수행되거나 다른 쓰레드에서 해당 코드를 동시에 수행해도 동일한 결과값을 얻을 수 있도록 합니다.
    • 이는 쓰레드 진입 시 local state를 저장하고 이를 atomic 하게 사용하여 구현할 수 있습니다. 
  4. Atomic Operation
    • 데이터 변경 시, atomic하게 데이터에 접근 되도록 만듭니다. 
  5. Immutable Object
    • 객체 생성 이후로 값을 변경할 수 없도록 합니다. 

 

Swift와 Thread Safe

일반적으로 Thread Safe 하지 않는 경우는 공유된 mutable 자원이 존재할 때 발생합니다. 그러므로 Thread Safe를 달성하기 위해 쓰레드 간의 메모리 공유를 방지하는 방법이 몇 가지 있습니다. ⬇️

1. Copyable Protocol

Copyable Protocol은 해당 타입들이 쓰레드 context 단위로 안전한 복사가 가능한 것을 명시합니다.

흔히 다른 언어에서 primitive type으로 알려져 있는 Int, Float, Double 등 타입 안에 reference를 포함하고 있지 않은 것들이 Copyable Protocol을 따르고 있습니다.

또한, String, Array(Copyable 타입을 담은 Array이어야 합니다.)처럼 실제로 reference는 가지고 있지만, value type으로 만들어진 것들도 쓰레드 단위로 복사가 허용됩니다.

 

2. Reentrant code

Reentrant code란, 주어진 arguments를 통해서만 접근할 수 있는 코드로, Reentrant code는 전역 변수, 공유 자원에 접근할 수 없습니다. Swift는 코드 작성 시에는 하나의 스레드에서는 다른 스레드의 논리적 복사 데이터에만 접근할 수 있도록 허용합니다. 전역 변수에 접근할 때나 unsafe 한 데이터에 접근할 때, Swift 컴파일러는 이를 반드시 확인합니다.(DispatchQueue를 사용할 때, queue 변경 시 self를 명시적으로 작성해야 하는 것을 생각하면 됩니다.)

 

3. Gateway Annotation

Swift 항상 새로운 스레드를 만들어서 함수가 동작하도록 하는 annotation을 지원합니다. 바꿔 말하면 Swift에는 Thread verifier가 존재하여 Copyable Protocol 조건과 ReEntrant code 조건이 충족되는지를 컴파일 단계에서 확인합니다.

 

그래서, Swift의 프로퍼티가 atomic을 지원하기 위해서는 GCD를 통해서 구현해야 합니다.

class AtomicValue<T> {
    let queue = DispatchQueue(label: "queue")

    private(set) var storedValue: T

    init(_ storedValue: T) {
        self.storedValue = storedValue
    }

    var value: T {
        get {
            return queue.sync {
                self.storedValue
            }
        }
        set { // read, write 접근 자체는 atomic하지만,
              // 다른 쓰레드에서 데이터 변경 도중(read, write 사이)에 접근이 가능하여, 완벽한 atomic이 아닙니다.
            queue.sync {
                self.storedValue = newValue
            }
        }
    }

    // 올바른 방법
    func mutate(_ transform: (inout T) -> ()) {
        queue.sync {
            transform(&self.storedValue)
        }
    }
}

let atomicInComplete = AtomicValue<Int>(0)
let atomicComplete = AtomicValue<Int>(0)
DispatchQueue.concurrentPerform(iterations: 100) { (idx) in

    atomicInComplete.value += idx

    atomicComplete.mutate { $0 += idx }
}

print(atomicInComplete.storedValue) // 결과: 돌릴 때마다 다름
print(atomicComplete.storedValue) // 결과: 4950

 

'Swift' 카테고리의 다른 글

mutating  (0) 2022.04.29
Generic  (0) 2022.04.29
Initialization - 구조체/클래스 초기화  (0) 2022.04.21
Initialization - 그 외  (0) 2022.04.20
Initialization - 상속과 초기화  (0) 2022.04.20