ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Lock, thread-safe
    앱등이에게 살충제를 뿌린다./iOS 2022. 5. 24. 01:12

    Originated from: https://swiftrocks.com/thread-safety-in-swift

     

    Thread Safety in Swift

    Concurrency is the entry point for the most complicated and bizarre bugs a programmer will ever experience. In this article, I'll share my favorite methods of ensuring thread-safety, as well as analyzing the performance of the different mechanisms.

    swiftrocks.com

     

    Thread Safety in Swift

    Concurrency는 많은 개발자들이 겪는 괴상한 버그를 생산하는 주범이다. 어플리케이션 레벨에서는 스레드와 하드웨어를 제어하기 어렵기 때문에 유닛테스트 작성이 어렵고 여러 스레드가 동시에 작업을 할 때 어떻게 동작하는지 예상하기도 어렵다. 이 문서에서는 Thread safety를 구현하는 몇 가지 방법에 대해서 소개하고 메커니즘을 성능관점에서 비교해보겠다.

    What is Thread Safety?

    개인적으로 Thread safety란 ‘동시에 여러 스레드에서 인스턴스(class)에 접근할 때 “정확성(correctness)”를 보장할 수 있음’이라고 생각한다. 동시에 여러 스레드에서 공유된 영역에 접근할 때, 예상치 못하거나 망가진 상태를 만들지 않는다면 그 코드는 Thread-safe하다고 할 수 있겠다. 그렇지 않다면 OS에서 제공하는 API들을 사용하여 공유된 영역이 항상 정확(correct)하고 예상가능한 상황으로 유지되도록 해야한다.

    Thread Safety costs

    더 들어가기 전에 알아둬야할 것은 동기작업(synchronization)은 성능이슈를 마주할 수 밖에 없다는 것이다. 가끔은 이 성능이슈가 눈에 띌 정도이지만 나는 항상 이 Trade-off가 가치가 있다고 생각한다. 내가 겪었던 최악의 이슈는 항상 Thread-safe하지 않은 코드 때문이었고, 유닛테스트 작성이 힘들기 때문에 어떤 괴상한 상황을 겪게될지 예상하기도 힘들다.

    이런 이유로 나는 병렬 작업을 수행하는 대신 Atomic and Serial 큐를 사용하는 것을 추천한다. 정말 병렬 작업이 필요한 상황이라면 발생 가능한 모든 상황을 생각하고 대처해두는 것이 좋다. 만약 그 과정이 너무 복잡하다면 너가 구현하려는 시스템에 병렬 작업을 적용하는 것이 잘못된 것일수도 있다.

    Goal: A thread-safe queue of events

    Locking API를 사용하기 전에 Thread-safe한 큐를 생성하는 class를 만들어보자. 이 class는 event를 Serial하게 수행하는 큐를 관리한다. 만약 여러 스레드에서 동시에 이벤트를 수행하려고 하더라도 하나씩 순차적으로 수행될 것이다.

    이 큐를 class내부에서만 관리한다면 여러 스레드에서 이 큐에 접근할 수 없기 때문에 특정 코드에 동시에 접근하는 것이 불가능하다. 즉, 상태(state)를 항상 예측가능하고 정확하게 유지할 수 있다. 이 때, 이 클래스는 Thread-safe한 클래스이다.

    final class EventQueue {    
    	func synchronize(action: () -> Void) {        
    		// Missing: thread orchestration!        
    		action()    
    	}
    }
    

    Serial DispatchQueues

    서로 다른 스레드에서 작업을 async하게 수행하도록 하려면 Serial DispatchQueue를 사용하는 것이 좋다.

    let queue = DispatchQueue(label: "my-queue", qos: .userInteractive)
    
    func synchronize(action: @escaping () -> Void) {    
    	queue.async {        
    		action()    
    	}
    }
    

    DispatchQueue의 강력한 점 중 하나는 Locking, 우선순위 등 스레드와 관련된 모든 일들을 처리할 수 있다는 것이다. 애플은 당신만의 Thread를 생성하지 않을 것을 권장한다. 스레드는 결코 가볍지 않고 서로간에 우선순위도 잘 매겨져야 한다. DispatchQueue는 이 모든것을 알아서 해주고 Serial Queue인 경우 Thread-safe하게 작업 수행 순서, 큐의 상태 등도 알아서 잘 관리해준다.

    우리는 위에서 생성한 EventQueue가 Async한 것을 원치 않는다. 작업을 등록한 스레드가 작업이 끝나기를 기다리도록 만들고 싶다.

    이 경우 DispatchQueue가 최선은 아니다. 코드를 Sync하게 동작시키는 것은 자원을 많이 소모하는 Threading Feature가 필요한 동작도 아니고, DispatchQueue.sync는 자신이 이미 큐 안에 존재하는지 알 수 없기 때문에 위험한 API이기도 하다. (DispatchQueue.main.sync 쓰면 deadlock걸리는 이슈를 떠올려보면 될 듯)

    func synchronize(action: @escaping () -> Void) {
        queue.sync {
            action()
        }
    }
    
    func logEntered() {
        synchronize {
            print("Entered!")
        }
    }
    
    func logExited() {
        synchronize {
            print("Exited!")
        }
    }
    
    func logLifecycle() {
        synchronize {
            logEntered()
            print("Running!")
            logExited()
        }
    }
    
    logLifecycle() // Crash!
    

    Serial DispatchQueue에 재귀적으로 진입한다면 두 스레드가 동시에 서로를 기다리게 된다. 전형적인 데드락(Deadklock)이다.

    DispatchQueue.getSpecific을 사용하면 이를 방지할 수 있다. 하지만 여전히 DispatchQueue가 좋은 선택은 아니다. Sync한 작업을 수행할 때, 구식의 Mutex를 사용하여 성능을 개선할 수 있다.

    os_unfair_lock

    Note: Swift에서 이 lock은 쓰지 않는 것이 좋다고 들었다. &연산자가 lock의 레퍼런스를 참조하는 것이 아닌 lock을 복사하기 때문이다. 흥미로운 지식이라고 생각해 이 섹션은 그대로 남겨두겠다. 미리 스포하자면 이 방법이 해결책은 아니고 우린 다른 방법을 사용할 것이다.

    os_unfair_lock Mutex(mutual exclusion)는 iOS에서 가장 빠른 lock이다. 만약 두 개의 스레드에서 특정 영역에 동시 접근을 방지하는 것만이 유일한 목적이라면 훌륭한 성능의 이 lock을 사용하는 것이 좋다.

    var lock = os_unfair_lock_s()
    
    func synchronize(action: () -> Void) {
        os_unfair_lock_lock(&lock)
        action()
        os_unfair_lock_unlock(&lock)
    }
    

    DispatchQueue보다 빠른건 놀랄 일도 아니다. C로 작성된 low-level임에도 불구하고 다른 스레드로 코드를 Dispatch하지 않기 때문에 시간을 많이 절약할 수 있다.

    이 lock의 단점은 이게 전부라는 것이다. os_unfair_lock_trylock and os_unfair_lock_assert_owner 같은 다른 util기능을 제공하는 API도 있다. 다른 특별한 기능이 필요한 것이 아니라면 이 lock으로 문제를 해결할 수 있다.

    이 lock을 사용하면 Thread-safe한 Serial Queue를 구현할 수 있다. 하지만 여전히 Recursion은 해결할 수 없다. 만약 재귀적으로 lock을 한다면 deadlock에 빠진다. 이를 해결하기 위해 다른 수단이 여전히 필요하다.

    NSLock

    os_unfair_lock과 같이 NSLock도 Mutex이다. 차이가 있다면 NSLock는 Obj-c pthread_mutex lock라는 mutex의 Obj-c 추상화이고 매우 유용한 기능을 제공한다. 바로 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, avoiding deadlock by ignoring the lock")
            action()
        }
    }
    

    DispatchQueue에서 데드락이 발생하면 이를 감지하고 앱은 크래쉬된다. 하지만 Lower-level API에서는 아무일도 일어나지 않고 영원히 기다리게 된다.

    하지만 NSLock는 Timeout 기능을 이용하여 문제를 해결할 수 있다. 위에서 구현한 이벤트 큐에서 Fallback하여 lock을 무시할 수 있게된다. 이 시나리오는 작업 시간이 오래걸리는 코드에도 적용될 수 있기 때문에 이를 감지하고 개선할 수 있어야 한다.

    NSLock은 os_unfair_lock와 같은 종류의 lock이지만 Obj-C의 메시징 시스템을 사용하는 비용으로 인해 더 느리다.

    NSLock은 Lock을 사용하며 몇 가지 기능이 추가로 필요할 때 유용하다. 하지만 여전히 Recursion을 적절히 해결하지 못하는 문제점이 있다. 코드가 정상적으로 작동하더라도 Timeout을 계속 기다리는 경우가 발생할 수 있다. 우리가 원하는 것은 재귀적으로 호출된 lock을 무시하는 기능이다. 다른 종류의 lock이 필요하다.

    NSRecursiveLock

    NSRecursiveLock은 완전히 NSLock와도 같다. 다만 재귀(recursion)를 다룰 수 있다. 심플하다.

    우리가 완전히 바라던 lock이다. 같은 스레드에서 lock을 여러번 호출하면 데드락이 발생하지만 이 recursive lock은 lock의 소유자에게 여러번 lock을 할 수 있게 해준다. 이는 Critical section이 스스로를 다시 호출하는 경우에 사용할 수 있게 고안된 것이다.

    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!
    

    만약 Critical section에 진입하는 스레드가 이미 lock을 소유하고 있다면 Critical section에 다시 진입할 수 있다. lock을 호출할만큼 unlock을 호출해주기만 하면 된다. 추가 스레드 체크가 이루어지기 때문에 NSLock보다는 느리다.

    DispatchSemaphore

    우리 문제게 세마포어가 적절하지는 않지만 알아보기로 하자. 세마포어는 lock과 unlock이 서로 다른 스레드에서 일어날 때 사용하기 좋다.

    let semaphore = DispatchSemaphore(value: 0)
    
    mySlowAsynchronousTask {
        semaphore.signal()
    }
    
    semaphore.wait()
    print("Task done!")
    

    예시와 같이 주로 세마포어는 주로 한 스레드에서 다른 스레드의 작업이 완료될 때까지 lock을 하기위해서 사용한다. iOS에서 세마포어의 사용은 단순한 DispatchQueue.sync의 사용과 많이 유사하다 - 다른 스레드에서 코드를 수행하고 완료되기를 기다린다. 이 예제는 세마포어를 생성한다는 것만 제외하면 정확히 DispatchQueue.sync의 기능과 동일하다.

    DispatchSemaphore는 빠르고 NSLock과 동일한 기능을 제공한다.

    DispatchGroup

    DispatchGroup은 DispatchSemaphore와 많이 닮았지만 작업의 group을 위한 기능을 제공한다. 세마포어는 하나의 이벤트의 완료만 기다리지만 그룹은 여러개의 작업이 완료되기를 기다릴 수 있다.

    let group = DispatchGroup()
    
    for _ in 0..<6 {
        group.enter()
        mySlowAsynchronousTask {
            group.leave()
        }
    }
    
    group.wait()
    print("ALL tasks done!")
    

    이 예제에서는 6개의 작업이 모두 완료되어야 unlock된다.

    DispatchGroups는 group.notify을 사용하여 작업의 완료를 async하게 기다릴 수 있게 해준다.

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

    이는 스레드를 Block하지 않고 의 DispatchQueue작업들의 결과를 받아볼 수 있게 해준다. Sync한 작업일 필요가 없다면 굉장히 유용한 기능이다.

    group메커니즘으로 인해 세마포어보다는 느린 성능을 보여준다.

    만약 하나의 이벤트만을 기다린다면 세마포어를 쓰는 것이 좋다. 하지만 DispatchSemaphore는 notify기능을 제공하지 않아 대부분 DispatchGroup를 사용하게 된다.

     

    Thanks for reading! If you want to see more Swift / iOS content like this, follow me on Twitter!

     

    댓글 0

Designed by Tistory.