ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Swift] Foundation에서 제공하는 동기화를 위한 Lock에 대해서 알아보기 | NSLock | NSRecursiveLock | NSConditionLock | NSCondition | NSDistributedLock
    테크 2023. 10. 25. 01:56

     

    아직까지도 thread-safe한 코드를 작성하는 것은 어렵다. 그래서 Swift에서 지원하는 동기화 도구(synchronization tool)에 대해서 알아보고 멀티쓰레드 프로그래밍(multithread programming)에 대한 자신감을 키워보도록 하겠다. 첫걸음으로 Foundation에서 제공하는 lock과 관련된 클래스들을 알아보도록 하겠다.

     

    thread-safe

    임계 영역(critical section)에 여러 쓰레드가 동시에 접근하더라도 실행 결과가 올바르게 나오는 것을 뜻한다.

     

    동기화(synchronization)

    한 쓰레드가 특정 임계 영역을 실행하고 있을 때 다른 쓰레드가 해당 영역을 실행하지 못하도록 막는 것을 뜻한다.

    100% 정확한 설명이라고 보기는 어렵지만 일단 이 정도로 이해하고 lock을 공부해보자.

     

    NSLocking

    protocol NSLocking

     

    Lock을 정의하는 클래스가 따르는 베이스 프로토콜이다.

     

    이 프로토콜을 따르게 되면 반드시 lock()unlock() 메소드를 구현해야 한다.

    • lock() 메소드: lock을 획득하기를 시도하는 메소드로, lock을 획득할 때까지 해당 쓰레드 실행은 차단(block)됨
    • unlock() 메소드: 이전에 획득했던 lock을 포기하는 메소드

    참고로 Foundation에서 제공하는 lock들은 공평하지 않다(unfair). 수많은 쓰레드가 동시에 lock을 획득하려고 할 때, 실행 순서를 예상할 수 없기 때문에 특정 쓰레드들은 기아 상태(starvation)에 빠질 수 있다.

     

    NSLock

    class NSLock : NSObject

     

    NSLock은 Foundation이 제공하는 lock의 기본 타입으로 POSIX 쓰레드의 locking behavior를 중재한다.

    NSLocking 프로토콜을 따르고 있기 때문에 lock() 메소드와 unlock() 메소드는 기본적으로 탑재되어있다.

     

    추가로 try() 메소드가 있는데, lock을 획득하면 true를 반환하고 획득하지 못하면 false를 반환한다.

    try() 메소드는 lock() 메소드와는 달리 lock을 획득하지 못하면 쓰레드 실행이 차단되지 않는다. 

     

    주의점!

    1. unlock() 메소드 호출 쓰레드는 반드시 lock() 메소드 호출 쓰레드와 동일한 것이 좋다. 그렇지 않으면 undefined behavior가 일어날 수 있다.
    2. NSLock은 recursive lock으로 사용하면 안된다. 같은 쓰레드에서 lock() 메소드를 두 번 호출하면 데드락(deadlock)에 빠져 쓰레드가 영원히 잠기기 때문이다. 동일한 쓰레드에서 lock() 메소드를 여러 번 호출하더라도 문제없게 하기 위해서는 NSRecursiveLock을 사용해야 한다.
    3. 잠겨있지 않은 lock을 unlock하는 것은 오류로 간주되기 때문에 코드에서 수정해야 한다.

    예제 코드를 살펴보자.

    Reader 쓰레드에서 sharedBuffer가 비어있거나 lock을 획득하지 못하는 경우 busy waiting을 한다. 만약 sharedBuffer가 비어있지 않고 동시에 lock을 획득하는 것에 성공하면 busy waiting을 하는 while문을 빠져나와 print(sharedBuffer.removeFirst())가 정상적으로 실행된 후 unlock 된다.

    import Foundation
    
    let lock = NSLock()
    
    var sharedBuffer: [String] = []
    
    class Writer: Thread {
        override func main() {
            for i in 0..<5{
                lock.lock()
                self.write(i)
                lock.unlock()
            }
        }
    
        func write(_ i: Int) {
            sharedBuffer.append("[\(i)] Hello, NSLock!")
        }
    }
    
    class Reader: Thread {
        override func main() {
            for _ in 0..<5 {
                while(sharedBuffer.isEmpty || !lock.try()) {} // busy waiting
                print(sharedBuffer.removeFirst())
                lock.unlock()
            }
        }
    }
    
    let readert = Reader()
    let writert = Writer()
    
    readert.start()
    writert.start()
    
    while(writert.isExecuting || readert.isExecuting) {} // busy waiting
    print("SharedBuffer: \(sharedBuffer)")

     

    NSRecursiveLock

    NSLock에서 잠깐 설명했듯이 같은 쓰레드에서 여러번 lock을 획득하더라도 데드락에 빠지지 않게 하는 lock이다.

    import Foundation
    
    let lock = NSLock()
    
    var sharedBuffer: [String] = []
    
    class Writer: Thread {
        override func main() {
            for i in 0..<5{
                lock.lock()
                self.write(i)
                lock.unlock()
            }
        }
        
        func write(_ i: Int) {
            lock.lock()
            sharedBuffer.append("[\(i)] Hello, NSLock!")
            lock.unlock()
        }
    }
    
    ...

     

    만약 NSLock을 이용할 때, main() 함수에서 lock을 걸고 write(_:) 함수에서 lock을 또 걸게 되면 데드락에 빠져 함수가 더 이상 진행되지 않는다.

     

    import Foundation
    
    let lock = NSRecursiveLock()
    
    var sharedBuffer: [String] = []
    
    class Writer: Thread {
        override func main() {
            for i in 0..<5{
                lock.lock()
                self.write(i)
                lock.unlock()
            }
        }
    
        func write(_ i: Int) {
            lock.lock()
            sharedBuffer.append("[\(i)] Hello, NSRecursiveLock!")
            lock.unlock()
        }
    }
    
    ...

    반면, NSRecursiveLock을 사용하게 되면 같은 쓰레드에서 lock을 중복 획득하더라도 데드락에 빠지지 않고 정상 진행된다.

     

    NSConditionLock

    Lock을 획득하기 위한 조건(condition)을 설정할 수 있다. NSConditionLock은 NSLock과 NSRecursiveLock과 동일하게 mutex lock의 한 종류이기 때문에 여러 쓰레드가 동일 조건을 만족한다고 하더라도 임계 영역에는 하나의 쓰레드만 진입할 수 있다. 조건은 단일 Int형 값으로 나타내는데, 초깃값 혹은 unlock을 할 때 조건에 해당하는 값을 바꿔줄 수 있다. 참고로 NSConditionLock을 초기화할 때 초깃값을 설정하지 않으면 0으로 세팅된다. 초기화 뿐만 아니라 lock을 걸 때 조건을 따로 설정하지 않으면 NSLock처럼 무조건적으로 lock을 얻을 수 있다. 또한 NSLock과 마찬가지로 동일한 쓰레드에서 lock을 중복 호출하면 데드락에 빠진다.

    import Foundation
    
    let WRITE_DATA = 1
    let READ_DATA  = 2
    
    let lock = NSConditionLock(condition: WRITE_DATA)
    
    var sharedBuffer: [String] = []
    
    class Writer: Thread {
        override func main() {
            for i in 0..<5{
                lock.lock(whenCondition: WRITE_DATA)
                sharedBuffer.append("[\(i)] Hello, NSConditionLock!")
                lock.unlock(withCondition: READ_DATA)
            }
        }
    }
    
    class Reader: Thread {
        override func main() {
            for _ in 0..<5 {
                lock.lock(whenCondition: READ_DATA)
                print(sharedBuffer.removeFirst())
                lock.unlock(withCondition: WRITE_DATA)
            }
        }
    }
    
    let readert = Reader()
    let writert = Writer()
    
    readert.start()
    writert.start()
    
    while(writert.isExecuting || readert.isExecuting) {} // busy waiting
    print("SharedBuffer: \(sharedBuffer)")

    메소드를 호출할 때 매개변수 이름을 주의해야 한다. lock() 메소드를 호출할 때는 whenCondition이라는 매개변수를 사용하는데, 주어진 상태(영어로는 condition이라고 표현하긴 하지만 상태라는 단어가 좀 더 자연스러운 것 같아서 단어를 바꿔 사용했음)일 때만 lock을 획득할 수 있다. unlock() 메소드를 호출할 때는 withCondition이라는 매개변수를 사용하며, 입력받은 상태로 바꿔준다.

    자, 그러면 예제 코드를 뜯어보자. 제일 처음 NSConditionLock을 선언할 때, 상태를 WRITE_DATA로 설정했다. 즉, 현재 상태는 WRITE_DATA라는 뜻이다. 이런 경우에 Reader 쓰레드가 실행됐다고 가정해보자. 상태가 READ_DATA인 경우에만 lock을 획득할 수 있기 때문에, 현재 상태는 WRITE_DATA 이므로 해당 쓰레드는 차단된다. Writer 쓰레드가 실행되면 현재 상태가 WRITE_DATA이기 때문에 lock을 획득할 수 있다. sharedBuffer에 값을 append하면, READ_DATA라는 상태로 바뀌게 되면서 lock을 해제한다. 그 후 Reader 쓰레드는 상태가 READ_DATA로 바뀌었기 때문에 lock을 얻게 되고 sharedBuffer에 있는 맨 앞 원소를 제거할 수 있다. 그리고 출력이 끝나면 WRITE_DATA로 상태가 바뀌면서 lock이 해제된다.

     

    NSCondition

    이제까지와는 다르게 Lock이라는 이름이 붙어있지 않다. NSLock, NSRecursiveLock, NSConditionLock은 mutex lock의 한 종류였지만 NSCondition은 condition의 한 종류이다. 여기서 condition은 일종의 세마포어(semaphore)이며, 특정 조건이 만족되면 signal을 보내서 쓰레드를 깨우는 방식이다. Mutex lock은 임계 영역에 한 쓰레드만 접근할 수 있지만, condition은  조건만 만족하면 여러 쓰레드가 동시에 임계 영역에 접근할 수 있다.

    NSCondition은 lock이기도 하며 checkpoint이기도 하다. NSCondition이 lock인 경우, 임계 영역이 실행될 때 올바르게 동작할 수 있도록 보호하는 역할을 한다. NSCondition이 checkpoint인 경우, 임계 영역에 진입하기 전 조건을 살펴볼 때 조건이 참이 아니라면 쓰레드를 차단시키는 역할을 한다. 다른 쓰레드가 signal을 보낼 때까지 차단된 상태로 유지된다.

    정리하자면,

    1. Condition 객체에 lock을 건다. 이 작업은 무조건 선행되어야 한다.
    2. Bool predicate를 테스트한다. 이는 보호되어야 할 작업을 수행하는 것이 안전한지 아닌지를 판단하는 기준 역할을 한다. 특히 잘못된 signal로 인해 차단된 쓰레드가 깨어나 임계 영역에 진입할 수 있기 때문에 이를 막기 위해서라도 반드시 필요하다.
    3. 만약 bool predicate이 거짓인 경우 condition 객체의 wait() 또는 wait(until:) 메소드를 호출하여 쓰레드를 차단한다. 다른 쓰레드에 의해 signal을 받으면 깨어나게 되고 다시 bool predicate이 참인지 테스트한다.
    4. 만약 bool predicate가 참인 경우 임계 영역에 진입한다.
    5. 선택적으로 broadcast() 메소드를 통해 잠들어있는 모든 쓰레드들을 깨우거나, signal() 메소드를 통해 한 개의 쓰레드를 깨울 수 있다.
    6. 작업이 완료되면 condition 객체를 unlock한다.

    이를 수도 코드로 나타내면 아래와 같다.

    lock the condition
    
    // If the predicate is already set, then the while loop is bypassed.
    // Otherwise, the thread sleeps until the predicate is set.
    while(!(boolean_predicate)) {
    	wait on condition
    }
    execute protected work // critical section
    optionally, signal or broadcast the condition again or change a predicate value
    unlock the condition

     

    Writer 쓰레드와 Reader 쓰레드가 동시에 sharedBuffer에 접근하고자 할 때, NSCondition을 아래처럼 사용할 수 있다.

    import Foundation
    
    let cond = NSCondition()
    
    var sharedBuffer: [String] = []
    
    class Writer: Thread {
        override func main() {
            for i in 0..<5 {
                cond.lock()
                sharedBuffer.append("[\(i)] Hello, NSCondition!")
                cond.signal() // Notify and wake up the waiting threads
                cond.unlock()
            }
        }
    }
    
    class Reader: Thread {
        override func main() {
            for _ in 0..<5 {
                cond.lock()
                while(sharedBuffer.isEmpty) { // Protect spurious signal
                    cond.wait()
                }
                print(sharedBuffer.removeFirst())
                cond.unlock()
            }
        }
    }
    
    let readert = Reader()
    let writert = Writer()
    
    readert.start()
    writert.start()
    
    while(writert.isExecuting || readert.isExecuting) {} // busy waiting
    print("SharedBuffer: \(sharedBuffer)")

     

    NSDistributedLock

    여러 호스트들의 여러 애플리케이션들이 파일과 같은 일부 공유 리소스에 대한 액세스를 제한하는데 사용하는 lock이다. 다른 lock들과는 달리 lock() 메소드가 존재하지 않는다. 이를 통해 NSLocking 프로토콜을 따르지 않는다는 것을 알 수 있다. 왜 lock() 메소드를 사용하지 않을까? lock() 메소드는 쓰레드의 실행을 차단을 할 수 있어야 하는데, 이는 일정 시간동안 파일 시스템이 폴링(polling)해야한다는 것을 의미한다. 이런 패널티를 부과하는 대신 try() 메소드를 통해서 단발적으로 파일 시스템 폴링 여부를 결정하는 것이 쓰레드를 차단시키는 것보다 더 효율적이라고 판단한 것이다. 어쨌든 try() 메소드를 사용하면 lock을 얻기 위한 시도를 반복문을 통해서 지속적으로 하게 될텐데 너무 많은 반복은 오버헤드가 있을 수 있으니 적절한 딜레이를 두고 연속적으로 시도하는 것이 좋다.

    NSDistributedLock 객체는 lock을 건 쓰레드가 명시적으로 lock을 풀지 않는 한 unlock되지 않는다. 만약 특정 애플리케이션에서 crash가 발생하여 lock을 건 쓰레드가 예기치않게 종료된다면? 특히 종료된 쓰레드가 unlock()을 실행하지 못하고 종료되면 파일 시스템은 영원히 잠기게 된다. 이런 상황을 해결하기 위해 break() 메소드를 사용하여 기존 lock()을 강제로 풀 수 있다. 하지만 break() 메소드를 쓰는 것보다는 최대한 unlock()을 쓰는 것이 좋다.

Designed by Tistory.