[Obj-c/Swift] 동기화(Synchronization)

frogKing·2023년 2월 19일
0
post-thumbnail

동기화의 필요성

멀티 스레딩 방식으로 동작하는 앱에서는 의도치 않게 여러 스레드가 동시에 리소스를 수정하거나 접근하는 경우가 있다. 이를 동시성 문제라고 한다.

동시성 문제를 해결하기 위해 공유 리소스를 지양하고 스레드 간의 상호 작용을 최소화하는 방법도 있지만 항상 이렇게 설계하는 할 수 만은 없다. 그래서 스레드가 상호 작용해야 하는 경우에는 다음에 소개하는 동기화 방식을 적용하여 리소스에 안전하게 접근할 수 있도록 해보자.

동기화 방식(Obj-c)

Atomic Operations

어떤 작업이 실행될 때 항상 완전하게 실행된 후 종료되고, 그렇지 않으면 아예 실행을 하지 않는 것을 말한다.

원자 단위의 작업(Atomic Operation)을 이해하려면 기계어 수준의 실행 명령어를 생각하면 된다. 예를 들어, ADD와 LOAD 명령어 자체는 원자적이어서 두 명령어는 실행 도중에 인터럽트 등에 의해 중단될 수 없다.

따라서 Atomic Operation은 간단한 데이터 유형에서 작동하는 방식이라 볼 수 있다. Atomic Operation의 장점은 리소스에 접근할 때 경쟁 스레드를 차단하지 않는다는 것이다.

Memory Barriers and Volatile Variables

Memory Barrier

컴파일러는 성능 최적화를 위해 어셈블리 수준의 명령(Instructions)을 재정렬하여 CPU에 대한 명령 파이프라인을 가능한 한 가득 채우려고 한다. 물론 컴파일러는 이로 인해 문제가 나지 않는다고 판단될 때에만 재정렬을 하겠지만, 컴파일러가 메모리에 의존하는 작업을 제대로 파악하지 못하고 재정렬을 수행할 때도 있다.

Memory Barrier는 CPU나 컴파일러에게 barrier 명령문 전 후의 메모리 연산을 순서에 맞게 실행하도록 강제하는 기능이다. 즉, barrier 이전에 나온 연산이 barrier 이후에 나온 연산보다 먼저 실행되는 것을 보장하는 역할을 한다.

iOS에서 이를 사용하려면 OSMemoryBarrier 함수를 호출하면 된다.

Volatile variable

Volatile variable(휘발성 변수)은 변수에 다른 타입의 메모리 제약 조건을 설정하는 것을 말한다. 컴파일러는 종종 변수 값을 레지스터에 로드하여 코드를 최적화한다. 지역 변수의 경우 일반적으로 문제가 되지는 않지만, 다른 스레드에서도 접근할 수 있는 변수의 경우 이러한 최적화로 인해 다른 스레드에서 해당 변수의 변화를 인지하지 못할 수도 있다.

volatile 키워드를 변수에 적용하면 컴파일러는 변수가 사용될 때마다 메모리에서 해당 변수를 로드한다. 컴파일러가 감지할 수 없는 외부 소스에 의해 해당 값이 언제든지 변경될 수 있는 경우 변수를 volatile로 선언하면 된다.

Locks

Lock을 사용하여 한 번에 하나의 스레드만 접근할 수 있도록 하면 critical section(임계 구역)을 보호할 수 있다. 예를 들어, critical section은 특정 데이터 구조를 조작하거나 한 번에 최대 하나의 클라이언트를 지원하는 리소스를 사용하는 경우를 생각해볼 수 있다. 이 섹션 주위에 Lock을 설정하면 코드의 정확성에 영향을 줄 수 있는 변경 작업에서 다른 스레드를 제외할 수 있다.

💡 Critical Section(임계 구역)
멀티 스레드 환경에서 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원에 접근하는 코드를 말한다.

Lock Types

LockDescription
MutexMutex(mutual exclusion)lock는 리소스 주변의 보호막 역할을 한다. Mutex는 한 번에 하나의 스레드에만 액세스 권한을 부여하는 일종의 세마포어이다. Mutex가 사용 중이고 다른 스레드가 이를 얻으려고 하면 해당 스레드는 원래 소유자가 뮤텍스를 해제할 때까지 차단된다.
Recursive lockRecursive lock은 Mutex의 변형된 형태이다. Recursive lock을 사용하면 단일 스레드가 lock을 여러 번 획득할 수 있다. 다른 스레드는 lock 소유자가 잠금을 획득한 횟수만큼 lock을 해제할 때까지 차단된 상태로 유지된다. Recursive lock는 주로 재귀 반복 중에 사용되지만 여러 메서드가 각각 lock을 획득해야 하는 경우에도 사용할 수 있다.
Read-write lockRead-write lock은 shared-exclusive lock이라고도 한다. 이는 일반적으로 대규모 작업에 사용되며 보호되고 있는 데이터 구조를 자주 읽고 가끔씩만 수정하는 경우 성능을 크게 향상시킬 수 있다. Read-write lock을 사용하면 데이터를 읽으려는 스레드(이하 읽기 스레드)들은 동시에 데이터 구조에 접근할 수 있다. 하지만 데이터를 쓰려는 스레드(이하 쓰기 스레드)가 접근하면 모든 스레드가 lock을 해제할 때까지 차단되고, 이 시점에서 lock을 획득하고 데이터 구조를 업데이트할 수 있다. 쓰기 스레드는 lock을 기다리는 동안 다음 접근하는 읽기 스레드는 쓰기 스레드가 완료될 때까지 차단된다. 이를 사용하려면 pthread를 참고하도록 한다.
Spin lockSpin lock은 해당 조건이 참이 될 때까지 잠금 조건을 반복적으로 폴링한다. Spin lock은 lock에 대한 예상 대기 시간이 짧은 다중 프로세서에서 자주 사용되는데 대기 시간이 짧아 조금만 기다리면 바로 쓸 수 있는데 컨텍스트 스위칭 혹은 데이터 구조 업데이트와 관련된 스레드를 차단하는 것을 통해 부하를 줄 필요가 없다는 것이 Spin lock의 탄생 배경이다. 폴링이라는 특성 때문에 iOS에서 따로 제공하지는 않는다.
Double-checked lockDouble-checked lock은 lock을 수행하기 전에 lock 기준을 테스트하여 lock을 수행하는 오버헤드를 줄이고자 하는 방식이다. Double-checked lock는 안전하지는 않기 때문에 시스템에서 이를 명시적으로 지원하지 않으며 사용을 권장하지 않는다.
Distributed lockDistributed lock은 프로세스 단에서 mutually exclusive 접근을 제공한다. 실제 mutex와 달리 distributed lock은 프로세스를 차단하거나 실행을 방해하지 않는다. lock이 사용 중임을 전달받고 프로세스가 진행 방향을 결정하도록 한다.

Conditions

Condition은 특정 조건이 참일 때 스레드가 서로에게 신호를 보낼 수 있도록 하는 세마포어이다. Condition은 보통 리소스가 이용 가능한지를 나타내거나 작업이 특정 순서로 수행되도록 할 때 사용된다.

스레드가 Condition을 테스트할 때 해당 조건이 이미 참이 아니면 차단된다. 다른 스레드가 명시적으로 Condition을 변경하고 신호를 보낼 때까지 차단된 상태가 유지된다.

Condition과 mutex lock의 차이는 여러 스레드가 동시에 Condition에 접근할 수 있다는 것이다. Condition은 특정 기준에 따라 다른 스레드가 게이트를 통과하도록 허용하는 게이트키퍼에 가깝다고 보면 된다.

Condition 사용 방법

pending events 관리하는 pool(이하 이벤트 큐)을 만드는 것이다. 해당 이벤트 큐는 큐에 이벤트가 있을 때 조건 변수를 사용하여 대기 중인 스레드에 신호를 보낸다.하나의 이벤트가 도착하면 큐가 조건에 적절하게 신호를 보낸다. 스레드가 이미 대기 중인 경우 깨어나면 큐에서 이벤트를 가져와서 처리한다. 두 이벤트가 거의 동시에 큐에 들어온 경우 큐는 두 스레드를 깨우도록 조건을 두 번 신호한다.

Selector

Cocoa는 동기화된 방식으로 단일 스레드에 메시지를 전달하는 편리한 방법을 제공한다. NSObject 클래스는 애플리케이션의 활성 스레드 중 하나에서 selector를 실행하도록 메서드를 선언한다. 이러한 메서드를 사용하면 스레드가 대상 스레드에 의해 동기적으로 수행된다는 보장을 받을 수 있고 메시지를 비동기적으로 전달할 수 있다. 예를 들어, selector 메시지를 사용하여 분산 처리된 계산 결과를 애플리케이션의 메인 스레드 또는 designated coordinator 스레드로 전달할 수 있다. selector를 수행하기 위한 각각의 request는 대상 스레드의 run loop에서 대기하고 request는 수신된 순서대로 순차적으로 처리된다.

동기화 방식(Swift)

swift에서 제공하는 동기화 방식이 정말 많은데 자주 사용되는 것들 위주로 우선 설명하려고 한다.

다음과 같이 개인의 full name을 저장하는 클래스가 있다고 치자.

final class Name {
    private(set) var firstName: String = ""
    private(set) var lastName: String  = ""

    func setFullName(firstName: String, lastName: String) {
        self.firstName = firstName
        self.lastName = lastName
    }
}

만약 동시에 두 스레드에서 setFullName 메서드를 호출한다면 어떻게 될까? 아마 다음과 같은 상황이 발생할지도 모른다.

Thread 1: setFullName("개굴", "유") 호출

Thread 2: setFullName("코기", "웰시") 호출

Thread 1: self.firstName = “개굴” 실행

Thread 2: self.firstName = “코기” 실행

Thread 2: self.lastName = "웰시" 실행

Thread 1: self.lastName = "유" 실행

최종 Name: "코기 유". 내가 원하던 동작이 아닌데..?

이를 race condition이라고 한다.

race condition을 해결하려면 스레드가 이 클래스의 상태에 접근하고 수정하는 방법을 동기화해야 한다. 스레드 1이 완료할 때까지 스레드 2가 setFullName 실행을 시작할 수 없도록 만들면 위의 시나리오를 해결할 수 있다.

흔히 할 수 있는 실수

var dontLetSomeoneElseInPlease = false
func setFullName(firstName: String, lastName: String) {
    guard !dontLetSomeoneElseInPlease else {
        return
    }
    dontLetSomeoneElseInPlease = true
    self.firstName = firstName
    self.lastName = lastName
    dontLetSomeoneElseInPlease = false
}

이런 방식의 코드로는 race condition을 해결할 수 없다.

우선, Swift의 boolean은 objective-c와 달리 atomic하지 않기 때문에 똑같이 메모리 충돌이 발생할 가능성이 있다. 그래서 OS 수준의 동기화 API를 사용해야 하는데 이는 아래에서 언급하겠다.

두 번째로, custom AtomicBool 클래스를 만들었다고 해도 여전히 race condition을 해결하지 못할 것이다. 왜냐하면 dontLetSomeoneElseInPlease 변수를 atomic하게 만들어서 boolean 자체가 스레드로부터 안전해지더라도 이것이 Name 클래스 전체가 안전하도록 만들어 주는 것은 아니기 때문이다. 예를 들어, 여러 스레드가 동시에 가드 검사를 통과할 경우 이전과 동일한 race condition가 발생할 수 있다.

NSLock

var stateLock = NSLock()
func setFullName(firstName: String, lastName: String) {
    stateLock.lock()
    self.firstName = firstName
    self.lastName = lastName
    stateLock.unlock()
}

이렇게 NSLock으로 로직을 래핑하면 해당 로직은 NSLock API에 의해 주어진 시간에 하나의 스레드만 접근하는 것이 보장된다.

그렇다면 이제는 Name 클래스는 thread safety를 갖춘 것일까?

이는 관점에 따라 다르다. setFullName 메서드 자체는 race condition으로부터 안전하지만 외부 객체들이 새 이름이 작성되는 동시에 사용자의 이름을 읽으려고 하면 race condition이 발생할 수 있다.

따라서 Name 클래스 안에서 thread safety를 보장할 수 있는 노력은 할만큼 했다. 이제는 Name 클래스 밖에서 thread safety를 보장할 수 있는 방법에 대해 알아보자.

Serial DispatchQueues

thread safety와 직접적으로 관련이 없다고 생각할 수도 있지만 DispatchQueue는 스레드의 안정성을 높이는데 도움을 줄 수 있다. 주어진 시간에 하나의 작업만 처리할 수 있는 작업 Queue을 생성하여 queue를 사용하는 component에 thread safety를 간접적으로 도입할 수 있다.

let queue = DispatchQueue(label: "my-queue", qos: .userInteractive)

queue.async {
    // Critical region 1
}

queue.async {
    // Critical region 2
}

장점

DispatchQueue의 가장 큰 특징은 lock과 우선 순위 지정과 같은 스레드 관련 작업을 완벽하게 관리해 준다는 것이다.

Apple에서는 리소스 관리 상의 이유(스레드를 생성하는데 필요한 cost가 은근 높음, 작업 간에 우선 순위를 지정해야 한다)로 따로 스레드 타입을 만들지 말라고 조언한다. 따라서 DispatchQueue를 쓰는 것이 스레드를 직접 생성하는 것보다 이점이 많다. DispatchQueue에서 serial Queue의 경우 queue 자체의 상태와 작업의 실행 순서도 관리되므로 thread safety에 있어서 완벽한 도구라고 볼 수 있다.

단점

queue는 작업이 완전히 비동기인 경우에만 유용하다. 진행하기 전에 작업이 완료될 때까지 동기적으로 기다려야 하는 경우 아래의 하위 레벨 API를 사용해야 한다. 코드를 동기적으로 실행한다는 것은 스레딩 기능을 사용할 수 없다는 것을 의미할 뿐만 아니라 DispatchQueue.sync는 이미 자신이 queue 안에 있을 수도 있다는 가정을 처리할 수 없기 때문에 상대적으로 위험한 API이다.

let queue = DispatchQueue(label: "my-queue")
queue.sync {
  print("print this")

  queue.sync {
    print("deadlocked")
  }
}

재귀적으로 serial DispatchQueue에 동기적으로 진입하려고 하면 스레드가 자체적으로 완료될 때까지 기다리게 된다. 이러한 시나리오를 deadlock(교착상태)라고 한다.
(=> 1번 클로저가 완료되어야 그 안에 있는 2번 클로저가 수행될 수 있는데 2번 클로저가 완료되지 않아서 1번 클로저가 완료될 수 없다)

기술적으로 이 문제를 해결할 수 있지만 DispatchQueue를 동기 목적으로 사용하는 것은 바람직하지 않기 때문에 이는 mutex lock을 사용하여 더 나은 성능과 안전성을 확보할 수 있다.

os_unfair_lock

os_unfair_lock mutex는 iOS에서 가장 빠른 lock이다. 앞에서 Name 클래스의 setFullName 메서드에 thread safety를 부여하는 목적으로 해당 API를 사용하면 뛰어난 성능으로 목적을 이룰 수 있을 것이다.

Note: Swift의 메모리 모델 동작 방식 때문에 이 API를 직접 사용해서는 안된다. iOS 15 이하의 경우 코드에서 이 lock을 사용할 때 아래의 UnfairLock 추상화를 사용하자. iOS 16을 대상으로 할 경우 OSAllocatedUnfairLock 타입을 사용하자.

final class UnfairLock {
    private var _lock: UnsafeMutablePointer<os_unfair_lock>

    init() {
        _lock = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1)
        _lock.initialize(to: os_unfair_lock())
    }

    deinit {
        _lock.deallocate()
    }

    func locked<ReturnValue>(_ f: () throws -> ReturnValue) rethrows -> ReturnValue {
        os_unfair_lock_lock(_lock)
        defer { os_unfair_lock_unlock(_lock) }
        return try f()
    }
}

let lock = UnfairLock()
lock.locked {
    // Critical region
}

이러한 방식은 DispatchQueue보다 빠른 성능을 제공한다. 코드를 다른 스레드에 디스패치하지 않아서 시간을 많이 단축시킬 수 있다.

하지만 iOS에서 알아두어야 할 사실은 모든 mutex가 불공평하다는 것이다. 즉, 직렬 DispatchQueue와 달리 os_unfair_lock 같은 lock들은 다른 스레드가 접근하는 순서를 보장하지 않는다. (그래서 DispatchQueue가 조금 느리더라도 사용하는 것이 나을 수도 있군..)

이는 하나의 스레드가 이론적으로 계속해서 lock을 획득하고 나머지 스레드는 starving(기아 상태)에 빠져 프로세스에서 스레드를 무한 대기 상태로 둘 수 있음을 의미한다. 따라서 lock을 선택할 때 이러한 문제가 생길 수 있는지 염두에 둬야 한다.

NSLock

mutex이긴 하지만 NSLock은 pthread_mutex를 이용한 Obj-C 추상화라는 점에서 os_unfair_lock과 다르다.

locking, unlocking 기능은 동일하지만 두 가지 이유로 os_unfair_lock 대신 NSLock을 선택할 수 있다.

  1. os_unfair_lock과 달리 추상화하지 않고도 API를 사용할 수 있다.
  2. timeout이라는 편리한 기능이 포함되어 있다.
let nslock = NSLock()

func synchronize(action: () -> Void) {
    if nslock.lock(before: Date().addingTimeInterval(5)) {
        action()
        nslock.unlock()
    } else {
        print("Took to long to lock, did we deadlock?")
        reportPotentialDeadlock() // Raise a non-fatal assertion to the crash reporter
        action() // Continue and hope the user's session won't be corrupted
    }
}

DispatchQueue를 사용하면서 deadlock에 걸리면 앱이 충돌하는 것을 한 번쯤 경험해봤을 것이다. 하지만 mutex에서 deadlock에 빠지면 앱은 아무런 응답을 하지 않은 채 멈춰있게 된다.

NSLock의 경우 timeout 기능을 사용하면 일정 시간동안 원하는 동작이 수행되지 않을 때 자신만의 플랜 B를 구현할 수 있다.

NSRecursiveLock

lock을 요구하면 스레드가 재귀적으로 다시 lock을 요구할 수 있는 방식으로 클래스가 구성된 경우 앱이 deadlock에 빠지지 않도록 recursive lock을 사용해야 한다. NSRecursiveLock은 NSLock에서 재귀를 처리하는 추가 기능이 있다고 보면 된다.

let recursiveLock = NSRecursiveLock()

func synchronize(action: () -> Void) {
    recursiveLock.lock()
    action()
    recursiveLock.unlock()
}

func logEntered() {
    synchronize {
        print("Entered!")
    }
}

func logExited() {
    synchronize {
        print("Exited!")
    }
}

func logLifecycle() {
    synchronize {
        logEntered()
        print("Running!")
        logExited()
    }
}

logLifecycle() // No crash!

일반적인 lock은 동일 스레드에서 lock을 재귀적으로 요청할 경우 deadlock을 유발하지만 recursive lock을 사용하면 lock을 갖고 있는 스레드가 반복적으로 lock을 다시 요청할 수 있다.

DispatchSemaphore

지금까지는 두 스레드가 동시에 코드를 실행하는 것을 방지하는 것을 막는 경우만 살펴보았다. 이 외에 다른 스레드가 특정 작업을 완료할 때까지 한 스레드가 기다려야 하는 경우에 대해서도 알아보려고 한다.

getUserInformation {
    // Done
}

// Pause the thread until the callback in getUserInformation is called
print("Did finish fetching user information! Proceeding...")

이런 식으로 코드를 구성하면 될 것이라 생각하겠지만 lock으로는 이를 구현할 수 없다. 왜냐하면 이러한 방식은 lock과 반대되기 때문이다.

→ 자신의 스레드를 직접 차단하고 다른 스레드가 접근이 끝났을 때 차단된 스레드를 해제하는 방식이 필요하다. 이것이 세마포어의 동작 원리이다.

let semaphore = DispatchSemaphore(value: 0)

mySlowAsynchronousTask {
    semaphore.signal()
}

semaphore.wait()
print("Did finish fetching user information! Proceeding...")

iOS에서 가장 흔하게 찾아볼 수 있는 세마포어는 DispatchQueue.sync이다. 다른 스레드에서 코드를 실행중일 때 끝날 때 까지 기다렸다가 끝나면 스레드를 계속하게 된다. 그러한 점에서 위 코드는 세마포어를 직접 구축한다는 점을 제외하면 DispatchQueue.sync가 수행하는 것과 완전히 같다.

DispatchSemaphore는 빠르고 NSLock과 동일한 기능을 포함한다. value 프로퍼티를 사용하여 wait()가 차단되고 signal()이 호출되기 전에 통과할 수 있는 스레드의 개수를 제어할 수 있다. 이 경우 값 0은 항상 차단됨을 의미한다.

DispatchGroup

dispatchGroup은 dispatchSemaphore와 완전히 같지만 작업 그룹을 위한 것이다. 세마포어가 하나의 이벤트를 기다리는 동안 그룹은 무한한 개수의 이벤트를 기다릴 수 있다.

let group = DispatchGroup()

for _ in 0..<6 {
    group.enter()
    mySlowAsynchronousTask {
        group.leave()
    }
}

group.wait()
print("ALL tasks done!")

이 경우 스레드는 6개의 작업이 모두 완료된 경우에만 잠금이 해제된다.

DispatchGroups은 group.notify를 호출하여 비동기적으로 기다릴 수 있는 기능을 제공한다.

group.notify(queue: .main) {
    print("ALL tasks done!")
}

이렇게 하면 스레드를 차단하는 대신에 DispatchQueue에서 결과를 알릴 수 있고 결과가 동기적으로 필요하지 않는 경우 유용하게 사용할 수 있다.

DispatchSemaphore가 DispatchGroup보다 빠르기 때문에 단일 이벤트를 기다리는 경우에는 DispatchSemaphore를 사용하는 것이 좋다. 하지만 실제로는 notify API로 인해 많은 사람들은 개별 이벤트에 대해서도 DispatchGroups를 사용한다.

참고

apple developer guide
Thread Safety in Swift

profile
내가 이걸 알고 있다고 말할 수 있을까

0개의 댓글