Foundation.NSLock

강동희
cashwalk
Published in
18 min readDec 17, 2018

--

이번 스토리에서는 멀티스레딩 프로그래밍시에 공유자원영역에 대하여 쓰레드 동기화 처리에 쓰이는 Foundation Framework에 있는 NSLock에 대해서 알아보겠습니다.

RxSwift 내부를 들여다보던 중 NSRecursiveLock을 서브클래싱한 RecursiveLock을 이곳 저곳에서 사용하는 걸 발견하게 되었습니다. 기존에는 Deadlock에 대한 위험때문에 다중 Thread 간 공유자원 동기화는 NSLock을 사용하지 않고 GCD sync를 사용했었기에 RxSwift에서 쓰이는 lock, unlock 코드들이 굉장히 낯설게 다가왔습니다.

그래서 이참에 NSLock에 대해서 알아보면 좋을 것 같아서 이 스토리를 작성하게 되었습니다.

NSLock.h에는 1개의 protocol과 4개의 class가 선언되어 있습니다.

선언되어 있는 protocol은 NSLocking이고,

class는 NSLock, NSRecursiveLock, NSConditionLock, NSCondition입니다.

1. NSLocking

NSLock에 선언되어 있는 모든 class가 따르고 있는 protocol입니다.

Lock 객체가 하나의 앱에서 여러 Thread 실행 동작을 조정하는데 사용됩니다.

선언되어 있는 func는 lock()과 unlock()입니다.

  • lock과 unlock을 사용하여 앱이 코드의 Critical Section을 별도의 Thread에서 동시에 실행하지 못하게 함으로써 공유 데이터 및 기타 공유 리소스를 손상으로부터 보호할 수 있습니다.
  • lock() : Thread가 lock을 획득할 때 까지 Thread의 실행을 차단합니다.
  • unlock() : Critical Section이 완료되면 Thread는 unlock()을 호출하여 lock을 해제합니다.

Critical Section

둘 이상의 Thread가 동시에 접근해서는 안되는 공유 자원(자료구조 or 장치)을 접근하는 코드의 일부를 의미합니다.

2. NSLock

  • NSLock은 NSObject를 상속받고 NSLocking을 준수하고 있습니다.
  • Apple Documentation에는 다음과 같은 주의사항이 적혀있습니다.

NSLock 클래스는 POSIX Thread를 사용하여 잠금 동작을 구현합니다. unlock 메시지를 NSLock 객체로 보낼 때 초기 lock 메시지를 보낸 동일한 Thread에서 메시지를 보내야합니다. 다른 Thread에서 lock을 잠금해제하면 정의되지 않은 동작이 발생할 수 있습니다.

  • NSLock을 사용하여 재귀적 lock을 구현하면 안됩니다. 동일한 Thread에서 lock을 두 번 호출하면 Thread가 영구적으로 잠길 수 있습니다. 대신 NSRecursiveLock 클래스를 사용하여 재귀 lock을 구현해야합니다.

func lock(before limit: Date) -> Bool

  • 파라미터로 넘겨준 date 이전에 lock을 획득하려고 시도하고 시도가 성공했는지 여부를 나타내는 Bool 값을 반환합니다.
  • 객체가 lock을 획득하거나 limt에 이를 때까지 Thread의 실행을 차단합니다.

func `try`() -> Bool

  • lock을 획득하려고 획득여부를 반환합니다.

var name: String?

  • name을 사용하여 코드내의 lock을 식별할 수 있습니다.
  • Cocoa는 lock과 관련된 오류 설명의 일부로 name을 사용합니다.

Example

NSLock을 사용한 간단한 예제를 보겠습니다.

우선 NSLock을 사용하지 않아서 ISSUE가 발생한 코드를 보겠습니다.

  • 같은 number의 값을 100000번 1씩 증가시키는 두 개의 Thread를 실행시켰습니다.
  • 결과 로그를 보면 Thread가 동시에 Start한 것을 볼 수 있습니다.
  • 5초 뒤에 number를 출력해보면 100000번 1씩 증가시키는 코드를 두 번 동작시켰으므로 200000을 기대하겠지만 실제 number의 값은 190462입니다. 왜 이런 현상이 나타날까요?
  • 두 개의 Thread는 동시에 number에 값을 더하게 됩니다. 예를 들어 number가 1000인 경우 두 개의 Thread가 동시에 number을 읽어서 1000을 가져온 후 1을 더해 1001을 기록할 것 입니다. 즉 1002가 되어야 할 값이 실제로는 1001이 되어버리게 됩니다. 이러한 현상을 컴퓨터과학에서는 Race Condition (경쟁 상태)라 부릅니다.
  • Race Condition을 해결하려면 어떻게 하면 될까요? NSLock을 사용하면 됩니다.
  • 상기 코드에 NSLock을 적용시켜 Race Condition을 해결해보겠습니다.
  • NSLock 객체를 생성하여 Thread 안에 number에 1을 더해주는 코드를 lock()과 unlock()을 사용하여 Critical Section으로 만들었습니다.
  • 앞선 코드와 다르게 두 개의 Thread가 동시에 실행되지 않고 순차적으로 실행한 것을 볼 수 있습니다.
  • 5초 뒤에 number을 출력해보면 기대한대로 number 값은 200000이 출력됩니다.
  • 첫 번째 Thread가 lock을 획득하여 number에 100000을 모두 더한 후 lock을 해제합니다.
  • 두 번째 Thread는 lock을 획득하지 못하여 첫 번째 Thread가 lock을 해제할 때 까지 기다리다가 lock을 획득한 후 number에 100000을 모두 더하게 됩니다.

3. NSRecursiveLock

  • NSRecursiveLock 역시 NSObject의 자식 클래스이며 NSLocking을 준수합니다.
  • 선언된 func과 property의 기능은 NSLock과 같습니다.
  • 동일한 Thread가 Deadlock 없이 여러 번 lock을 획득할 수 있습니다.
  • Locking Thread에 하나 이상의 lock이 있는 동안 다른 모든 Thread는 lock으로 보호된 Critical Section에 접근할 수 없습니다.

그렇다면 NSLock과 NSRecursiveLock의 차이점은 Deadlock을 예방하는 차이인데 Deadlock이란 과연 무엇일까요?

Deadlock

  • Deadlock(교착상태)란 두 개 이상의 작업이 서로 상대방의 작업이 끝나기만을 기다리고 있기 때문에 결과적으로 아무것도 완료되지 못하는 상태를 가리킵니다.
  • 예를 들어 하나의 사다리가 있고, 두 명의 사람이 각각 사다리의 위쪽과 아래쪽에 있다고 가정합시다. 이때 아래에 있는 사람은 위로 올라가려고 하고, 위에 있는 사람은 아래로 내려오려고 한다면, 두 사람은 서로 상대방이 사다리에서 비켜줄 때까지 하염없이 기다리고 있을 것이고 결과적으로 아무도 사다리를 내려오거나 올라가지 못하게 되듯이, 컴퓨터과학에서 Deadlock은 다중 프로그래밍 환경에서 흔히 발생할 수 있는 문제입니다.

Example

그러면 이제 Deadlock을 발생시켜 보고 NSRecursiveLock으로 해결해보겠습니다.

  • 상기 코드는 NSLock으로 Deadlock을 발생시켰습니다.
  • 두 개의 NSLock 객체를 생성하고 두 개의 Thread를 실행하겠습니다.
  • 코드는 간단합니다. 첫 번째 Thread는 lock1을 lock하고 두 번째 Thread는 lock2를 lock한 뒤 첫 번째 Thread는 lock2의 lock을 획득하려고 하고 두 번째 Thread는 lock1의 lock을 획득하려고 합니다. 하지만 lock1과 lock2 모두 unlock 되지 않았으므로 두 개의 Thread는 모두 서로의 unlock을 기다리는 무한한 대기상태가 됩니다.
  • 위와 같은 결과가 출력될 수도 있습니다.
  • 두 번째 Thread가 먼저 실행되고 첫 번째 Thread가 뒤늦게 실행되었지만 첫 번째 Thread가 두 개의 lock을 모두 획득한 후 unlock하여 종료되고 두 번째 Thread가 실행되어 Deadlock 없이 종료되는 경우입니다.
  • 상기 코드에서 NSLock 대신 NSRecursiveLock을 사용하여도 여전히 Deadlock은 발생합니다.
  • 그렇다면 도대체 NSLock과 NSRecursiveLock의 차이는 무엇일까요? 다음 예제를 보겠습니다.
  • Deadlock이 발생하였습니다. NSLock 객체를 생성하여 testDeadlock()에서 lock을 획득한 후 다시 한번 lock을 획득하려고 하면 아직 lock객체가 unlock되지 않았기 때문에 두 번째 lock을 획득하려는 코드에서 Deadlock이 발생합니다.
  • 상기 코드에서 NSLock을 NSRecursiveLock으로 변경해보겠습니다.
  • lock 클래스를 NSRecursiveLock으로 변경한 후 Deadlock이 발생하지 않는 것을 볼 수 있습니다.
  • 이처럼 NSRecursiveLock은 이름 그대로 이미 lock을 획득한 Thread에서 여러번 lock을 획득할 수 있습니다.
  • 하지만 lock과 unlock의 횟수가 맞지 않으면 Deadlock이 발생합니다. 관련 예제를 보겠습니다.
  • 상기 코드는 NSRecursiveLock을 활용하여 동일 Thread에서 여러번 lock을 획득하는 예제입니다.
  • NSRecursiveLock은 재귀함수나 함수에서 동일한 lock을 획득하는 여러 함수 호출시 유용합니다.
  • 하지만 callMe()에서 unlock을 주석처리하여 lock과 unlock의 횟수가 일치하지 않으면 어떻게 될까요?
  • 3개의 Thread가 실행되었지만 세 번째 Thread만 종료되고 나머지 두 개의 Thread는 종료되지 않게되면서 Deadlock에 걸리게 됩니다.

4. NSConditionLock

  • NSConditionLock 역시 NSObject를 상속하고 NSLocking을 준수하고 있습니다.
  • NSLock과 같이 String type의 name이 선언되어 있습니다.
  • NSLock과 달리 꽤 많은 메소드가 선언되어 있습니다.
  • NSConditionLock은 사용자가 정의한 condition를 사용하는 lock입니다.
  • NSConditionLock 객체를 사용하면 특정 condition이 충족되는 경우에만 Thread가 lock을 획득할 수 있습니다. 일단 lock을 획득하고 코드의 Critical Section을 실행하면 Thread는 lock을 해제하고 condition을 새로운 내용으로 설정할 수 있습니다. condition 자체는 임의적입니다. 즉 앱에 필요한 조건을 정의합니다.

init(condition: Int)

  • 새로 할당된 NSConditionLock 객체를 초기화하고 condition을 설정합니다.
  • condition : lock에 대한 사용자 정의 Int 값입니다.

var condition: Int

  • 생성자에서 설정된 사용자 정의 Int 값입니다.
  • condition이 설정되지 않은 경우에는 0을 반환합니다.

func lock(before limit: Date) -> Bool

  • 지정된 시간 이전에 lock을 획득하려고 시도하고 획득여부를 반환합니다.
  • limit : lock을 획득하거나 시도해야하는 제한시간입니다.
  • 해당 func에서는 condition은 고려되지 않습니다. 객체가 lock을 획득하거나 limit에 이를 때까지 Thread의 실행을 차단합니다.

func lock(whenCondition condition: Int, before limit: Date) -> Bool

  • 객체의 condition과 지정된 condition이 일치하는지 체크하여 lock을 획득하려고 시도하고 획득여부를 반환합니다.
  • lock을 얻을 수 있을 때까지 Thread의 실행을 차단합니다.

func `try`() -> Bool

  • 객체의 condition과 관계없이 lock을 획득하려고 시도하여 획득여부를 반환합니다.

func tryLock(whenCondition condition: Int) -> Bool

  • 객체의 condition이 지정된 condition과 같으면 lock을 획득하려고 시도하고 획득여부를 반환합니다.
  • 구현의 일부로 이 func은 lock(whenCondition:before:)을 호출합니다.

func unlock(withCondition condition: Int)

  • lock을 포기하고 객체의 condition을 설정합니다.

func name: String?

  • NSLock의 name과 같습니다.

Example

  • condition이 1인 NSConditionLock 객체를 생성하고 number에 값을 1씩 100000번 증가시키는 두 개의 Thread를 실행하였습니다.
  • 첫 번째 Thread에서 conditionLock의 condition이 1인 경우 lock을 획득하고 unlock을 합니다.
  • 두 번째 Thread에서 conditionLock의 condition이 2인 경우 lock을 획득하고 unlock을 합니다.
  • 결과적으로 conditionLock의 condition이 1이기 때문에 첫 번째 Thread의 코드만 실행하고 condition이 2인 lock 인스턴스를 기다리는 두 번째 Thread는 영원히 실행되지 않게 됩니다.
  • 올바른 NSConditionLock 사용 예제를 보겠습니다.
  • 첫 번째 Thread는 conditionLock의 condition이 1인 경우 lock을 획득한 후 conditionLock의 condition을 2로 변경하고 lock을 해제합니다.
  • 두 번째 Thread는 conditionLock의 condition이 2인 경우 lock을 획득한 후 conditionLock의 condition을 1로 변경하고 lock을 해제합니다.
  • 결과는 첫 번째 Thread가 실행된 후 종료되고 두 번째 Thread가 실행되어 종료되는 것을 볼 수 있습니다.

5. NSCondition

  • NSCondition 역시 NSObject의 자식 클래스이며, NSLocking을 준수하고 있습니다.
  • 기존의 lock 클래스들과는 조금 다른 형태의 func이 선언되어 있습니다.
  • NSCondition 객체는 주어진 Thread에서 lock과 checkpoint의 역할을 합니다.
  • lock은 condition을 테스트하는 동안 코드를 보호하고 condition에 의해 트리거된 작업을 수행합니다.
  • checkpoint는 Thread가 작업을 실행하기 전에 condition이 true이어야 합니다. condition이 true가 아닌 동안 Thread가 차단됩니다. 다른 Thread가 condition객체에 신호를 보낼 때까지 차단된 상태로 유지됩니다.

NSCondition 객체를 사용하기 위한 의미는 다음과 같습니다.

  1. condition 객체를 잠급니다.
  2. boolean 조건자를 테스트하십시오. (이 조건자는 boolean flag 또는 condition에서 보호되는 작업을 수행하는 것이 안전한지 여부를 나타내는 코드의 다른 변수입니다.
  3. boolean 조건자가 거짓인 경우 condition 객체의 wait() 또는 wait(until:)을 호출하여 Thread를 차단합니다. 이 func에서 리턴되면 2단계로 진행하여 boolean 조건자를 다시 테스트하십시오.
  4. boolean 조건자가 참이면 작업을 수행하십시오.
  5. 선택적으로 작업의 영향을 받는 조건자 (또는 condition을 알리는 신호)를 업데이트하십시오.
  6. 작업이 완료되면 condition 객체의 잠금을 해제하십시오.

상기 단계를 수행하기 위한 pseudocode는 다음과 유사합니다.

condition을 잠근다
while (!(boolean 조건자)) {
condition을 기다린다
}
do 보호된 작업을 수행한다
(선택적으로 condition을 다시 신호 또는 브로드 캐시트 하거나 조건값을 변경)
condition을 푼다
  • condition 객체를 사용할 때마다 첫 번째 단계는 condition을 잠그는 것입니다. condition을 잠그면 조건자 또는 보호된 코드가 다른 Thread 간섭으로부터 보호됩니다. 일단 작업을 완료하면, 다른 조건을 설정하거나 코드의 필요에 따라 다른 condition을 알릴 수 있습니다. condition 객체의 잠금을 유지하면서 항상 조건자 및 신호 condition을 설정해야합니다.
  • Thread가 condition에서 대기할 때 condition 객체는 lock을 해제하고 Thread를 차단합니다. condition이 신호되면 시스템은 Thread를 깨웁니다. 그런 다음 condition 객체는 wait() 또는 wait(until:)에서 돌아오기 전에 lock을 다시 가져옵니다. 따라서 Thread 관점에서 볼 때 항상 lock을 유지하는 것처럼 보입니다.
  • boolean 조건자는 condition이 작동하는 방식 때문에 condition을 알리는 것이 condition자체가 true라는 것을 보장하지 않습니다. 잘못된 신호가 나타날 수 있는 신호 전달과 관련된 타이밍 문제가 있습니다. 조건자를 사용하면 이러한 가짜 신호가 안전하게 수행되기 전에 작업을 수행하지 않도록 합니다. 조건자 자체는 boolean 결과를 얻기 위해 테스트하는 코드의 플래그 또는 다른 변수일 뿐입니다.

위와 같은 설명은 Apple Documentation NSCondition에 나와있는 설명입니다. 설명만 봐서는 정확히 NSCondition에 대해서 알기가 어렵습니다. 선언된 func에 대하여 살펴본 후 바로 예제를 살펴보도록 하겠습니다.

func wait()

  • condition이 통지되기 전까지 현재 Thread를 차단합니다.
  • 이 func을 호출하기 전에 condition을 lock 해야합니다.

func wait(until limt: Date) -> Bool

  • condition이 통지되거나 지정된 시간 제한에 도달할 때까지 현재 Thread를 차단합니다.
  • condition이 통지된 경우 true를 반환하고 시간 제한에 도달한 경우 false를 반환합니다.
  • wait()가 마찬가지로 이 func을 호출하기 전에 condition을 lock해야합니다.

func signal()

  • condition을 통지하며, 대기중인 하나의 Thread를 깨웁니다.
  • 이 func을 사용하여 condition을 기다리고 있는 하나의 Thread를 깨울 수 있습니다. condition을 대기중인 Thread가 없으면 이 메소드는 아무것도 수행하지 않습니다.
  • Race Condition을 피하려면 객체가 lock을 획득했을 때만 이 func을 호출해야합니다.

func broadcast()

  • condition을 통지하며, 대기중인 모든 Thread를 깨웁니다.
  • condition을 대기중인 Thread가 없으면 이 func은 아무것도 수행하지 않습니다.
  • Race Condition을 피하려면 signal()과 마찬가지로 condition이 lock을 획득했을 때만 이 func을 호출해야합니다.

var name: String?

  • condition의 이름
  • name을 사용하여 코드 내의 condition 객체를 식별할 수 있습니다.

Example

자 그럼 이제 지긋지긋한 설명은 끝내고 예제를 보도록 하겠습니다.

  • NSCondition 객체(condition), 조건 변수(available), 공유 문자열(sharedString)을 선언하겠습니다.
  • 쓰기(WriterThread)와 출력(PrinterThread)을 담당하는 두 개의 Thread를 선언하였습니다.
  • 출력 Thread를 먼저 실행한 후 1초 뒤에 쓰기 Thread를 실행시키겠습니다.
  • 출력 Thread는 condition을 잠그고 while문에서 wait를 호출하여 무한대기상태가 되었습니다.
  • 이후 1초뒤 쓰기 Thread가 실행되어 condition을 잠그고 sharedString에 문자열을 입력한 뒤 조건 변수를 true로 변경하고 signal을 호출하여 대기중인 하나의 Thread를 깨우고 unlock을 호출하고 1초 대기를 합니다.
  • 대기중인 출력 Thread가 깨어나서 공유 문자열을 출력한 뒤 조건 변수를 false로 변경하고 unlock 후 다음 반복문에서 lock을 하고 wait을 호출하여 다시 대기상태가 됩니다.
  • 이후 쓰기 Thread가 실행되어 lock, signal, unlock을 호출하여 쓰기 Thread를 깨우는 방식이 반복됩니다.
  • 즉 wait로 Thread를 대기시키고 조건 변수를 이용하여 다른 Thread에서 signal로 대기중인 Thread를 깨우는 예제입니다.
  • 실행 결과는 다음과 같습니다.
  • 출력 Thread가 먼저 lock을 획득한 뒤 while문에서 조건 변수가 true일 때까지 wait하게 됩니다.
  • 이후 쓰기 Thread가 실행되어 lock을 획득하고 공유 문자열에 값을 입력하고 조건자 변수를 변경하여 condition의 signal을 호출하여 condition으로 대기중인 Thread를 깨우고 unlock을 하여 lock을 해제합니다.
  • 대기중인 출력 Thread는 쓰기 Thread에서 호출한 signal에 의해서 깨어나게 되고 변경된 조건 변수를 이용하여 공유 문자열을 출력하고 반복문을 통하여 다시 lock을 획득하고 wait을 호출하여 Thread를 차단시킵니다.

--

--