-
Swift에서의 데이터 레이스 방지를 위한 동기화 기법Apple🍎/Swift 2025. 4. 16. 23:09
1. NSLock (Foundation 프레임워크)
NSLock은 Swift에서 사용할 수 있는 가장 기본적인 락 메커니즘입니다.
임계 구역(Critical Section)이란?
임계 구역은 여러 스레드가 동시에 접근하면 문제가 발생할 수 있는 코드 영역을 말합니다. 예를 들어, 다음과 같은 상황을 생각해봅시다.
- 두 개의 스레드가 동일한 변수에 동시에 접근하여 값을 변경하려고 할 때
- 여러 스레드가 동일한 파일에 동시에 쓰기를 시도할 때
- 다수의 스레드가 공유 자료구조(배열, 딕셔너리 등)를 동시에 수정하려고 할 때
이런 상황에서 적절한 동기화 없이 동시 접근이 일어나면 '경쟁 상태(Race Condition)'가 발생합니다. 이는 프로그램이 예측 불가능한 결과를 내거나 크래시를 일으키는 원인이 됩니다.
NSLock을 사용한 락(Lock) 메커니즘
NSLock은 임계 구역을 보호하기 위한 상호 배제(Mutual Exclusion) 메커니즘을 제공합니다. 주요 메서드는 다음과 같습니다.
- lock(): 락을 획득합니다. 이미 다른 스레드가 락을 보유하고 있다면, 해당 스레드가 락을 해제할 때까지 현재 스레드는 블록됩니다.
- unlock(): 락을 해제합니다. 다른 대기 중인 스레드가 이제 락을 획득할 수 있게 됩니다.
- try(): 락을 획득하려고 시도하지만, 즉시 결과를 반환합니다(블록 없음). 락을 획득했다면 true, 그렇지 않다면 false를 반환합니다.
NSLock 사용 예제 및 상세 설명
다음은 NSLock을 사용하는 기본적인 예제입니다.
import Foundation class SharedResource { private let lock = NSLock() private var sharedValue = 0 func incrementValue() { // 임계 구역 시작 전에 락 획득 lock.lock() // 임계 구역 시작 // 이 영역 내의 코드는 한 번에 하나의 스레드만 실행 가능 sharedValue += 1 print("Value incremented to \(sharedValue)") // 임계 구역 종료 // 임계 구역 종료 후 락 해제 lock.unlock() } }
임계 구역과 NSLock의 동작 상세 이해
- 락 획득 과정
- 스레드 A가 increment( ) 진입하면서 lock.lock()을 호출합니다.
- NSLock은 내부적으로 '잠금 상태'로 변경됩니다.
- 스레드 A는 임계 구역에 진입하여 코드를 실행합니다.
- 다른 스레드의 접근 시도
- 스레드 A가 아직 임계 구역 내에 있는 동안, 스레드 B가 increment( )로 진입하여 lock.lock()을 호출합니다.
- NSLock이 이미 잠금 상태이므로, 스레드 B는 블록됩니다(대기 상태에 들어갑니다).
- 운영체제는 스레드 B를 '실행 가능 스레드' 목록에서 제외하고 '대기 중인 스레드' 목록으로 이동시킵니다.
- 락 해제 과정
- 스레드 A가 임계 구역의 코드를 모두 실행한 후 lock.unlock()을 호출합니다.
- NSLock은 내부적으로 '잠금 해제 상태'로 변경됩니다.
- 운영체제는 NSLock을 기다리던 스레드들(이 경우 스레드 B) 중 하나를 깨워 실행할 준비를 합니다.
- 스레드 B는 '대기 중인 스레드' 목록에서 '실행 가능 스레드' 목록으로 이동됩니다.
- 스레드 B는 이제 락을 획득하고 임계 구역에 진입할 수 있습니다.
락을 사용할 때의 주의사항
1. 데드락(Deadlock) 방지
여러 락을 사용할 때 데드락이 발생할 수 있습니다. 예를 들어
// 스레드 1 lockA.lock() // 작업 수행 lockB.lock() // lockB가 스레드 2에 의해 이미 잠겨 있다면 대기 // ... lockB.unlock() lockA.unlock() // 스레드 2 (동시에 실행) lockB.lock() // 작업 수행 lockA.lock() // lockA가 스레드 1에 의해 이미 잠겨 있다면 대기 // ... lockA.unlock() lockB.unlock()
이 상황에서 두 스레드는 서로가 해제할 락을 기다리는 교착 상태에 빠집니다.
2. 균형 잡힌 lock/unlock 호출
모든 lock() 호출에는 반드시 unlock() 호출이 짝을 이루어야 합니다. Swift에서는 이를 보장하기 위해 defer 구문을 자주 사용합니다.
func criticalOperation() { lock.lock() defer { lock.unlock() // 함수가 어떤 경로로 종료되든 항상 실행됨 } // 임계 구역 코드 // 여기서 에러나 return이 발생해도 unlock은 보장됨 }
3. 세밀한 락 사용
락은 필요한 코드 부분만 둘러싸야 합니다. 락을 너무 넓은 범위에 적용하면 성능이 저하됩니다.
// 나쁜 예 func processData() { lock.lock() // 락이 필요하지 않은 시간이 오래 걸리는 계산 expensiveCalculation() // 실제로 락이 필요한 공유 자원 접근 updateSharedResource() lock.unlock() } // 좋은 예 func processData() { // 락이 필요하지 않은 작업은 밖에서 수행 let result = expensiveCalculation() // 실제로 락이 필요한 부분만 보호 lock.lock() updateSharedResource(with: result) lock.unlock() }
2. NSRecursiveLock
NSRecursiveLock 심층 분석
NSRecursiveLock은 멀티스레딩 환경에서 재귀적 코드를 안전하게 실행하기 위한 특수한 종류의 락입니다.
일반 락(NSLock)과 데드락 문제
일반적인 락(예: NSLock)은 한 번 획득하면 다른 스레드가 그 락을 획득하기 전에 해제해야 합니다. 가장 중요한 점은, 동일한 스레드조차도 이미 획득한 락을 다시 획득할 수 없다는 것입니다.
import Foundation class RegularLockTask { private let lock = NSLock() func performRecursiveTask(depth: Int) { lock.lock() // 첫 번째 락 획득 defer { lock.unlock() } print("깊이 \(depth)에서 작업 수행 중") if depth > 0 { // 여기서 재귀 호출할 때 동일한, 이미 획득한 락을 다시 획득하려고 시도 performRecursiveTask(depth: depth - 1) // 데드락 발생! } } }
이 코드에서 발생하는 시퀀스는 다음과 같습니다.
- 처음 performRecursiveTask(depth: 3) 호출 - 락을 획득함
- 재귀적으로 performRecursiveTask(depth: 2) 호출 - 이미 락이 획득된 상태에서 다시 락을 획득하려고 시도
- 데드락 발생 - 동일한 스레드가 이미 소유한 락을 다시 획득하려고 시도하지만, 일반 락은 이를 허용하지 않음
NSRecursiveLock의 작동 방식
NSRecursiveLock은 이 문제를 해결하기 위해 설계되었습니다. 주요 특징은 다음과 같습니다.
- 소유권 추적: 락은 현재 어떤 스레드가 소유하고 있는지 추적합니다.
- 락 카운팅: 락이 같은 스레드에 의해 몇 번 획득되었는지 카운트합니다.
- 재귀적 획득 허용: 이미 락을 소유한 스레드는 동일한 락을 다시 획득할 수 있습니다.
- 균형 잡힌 잠금 해제: 락이 완전히 해제되기 위해서는 획득한 횟수만큼 해제해야 합니다.
class RecursiveTask { private let lock = NSRecursiveLock() func performRecursiveTask(depth: Int) { lock.lock() // 락 획득 (카운트 증가) defer { lock.unlock() } // 함수 종료 시 락 해제 (카운트 감소) print("깊이 \(depth)에서 작업 수행 중") if depth > 0 { // 재귀 호출 - 같은 락을 다시 획득해도 문제 없음 performRecursiveTask(depth: depth - 1) } } }
이 코드가 depth: 3으로 실행될 때의 과정
- 첫 번째 호출 (depth=3)
- lock.lock() 호출: 락 획득, 락 카운트 = 1
- 작업 수행
- 재귀 호출 performRecursiveTask(depth: 2)
- 두 번째 호출 (depth=2)
- lock.lock() 호출: 이미 같은 스레드가 소유 중, 락 카운트 = 2
- 작업 수행
- 재귀 호출 performRecursiveTask(depth: 1)
- 세 번째 호출 (depth=1)
- lock.lock() 호출: 이미 같은 스레드가 소유 중, 락 카운트 = 3
- 작업 수행
- 재귀 호출 performRecursiveTask(depth: 0)
- 네 번째 호출 (depth=0):
- lock.lock() 호출: 이미 같은 스레드가 소유 중, 락 카운트 = 4
- 작업 수행
- 조건 불충족으로 재귀 종료
- lock.unlock() 호출: 락 카운트 = 3
- 순차적 반환
- 세 번째 호출 종료: lock.unlock() 호출, 락 카운트 = 2
- 두 번째 호출 종료: lock.unlock() 호출, 락 카운트 = 1
- 첫 번째 호출 종료: lock.unlock() 호출, 락 카운트 = 0 (락 완전 해제)
실제 사용 사례
재귀적 락은 다음과 같은 상황에서 유용합니다.
- 재귀적 알고리즘: 디렉토리 탐색, 트리 순회 등 재귀 알고리즘에서 공유 데이터에 접근할 때
- 상호 호출 메서드: 서로를 호출하는 여러 메서드가 있고, 각 메서드가 공유 자원에 안전하게 접근해야 할 때
- 중첩된 자원 접근: 복잡한 데이터 구조에서 하나의 작업이 여러 중첩된 자원에 접근해야 할 때
성능 고려사항
NSRecursiveLock은 일반 NSLock보다 더 많은 오버헤드가 있습니다.
- 스레드 소유권 추적
- 잠금 카운트 관리
- 추가적인 조건 검사
따라서, 재귀적 잠금이 필요하지 않은 상황에서는 일반 락을 사용하는 것이 더 효율적입니다.
주의사항
- 균형 잡힌 잠금/해제: 각 lock() 호출에 대응하는 unlock() 호출이 있어야 합니다.
- 데드락 가능성: 재귀적 락도 서로 다른 락들 간의 순환 의존성으로 인한 데드락은 예방하지 못합니다.
- 남용 방지: 재귀적 락이 있다고 해서 깊은 재귀 호출 스택을 만드는 것이 항상 좋은 설계는 아닙니다.
이러한 상세한 이해를 바탕으로, NSRecursiveLock은 복잡한 멀티스레딩 환경에서 재귀적 접근이 필요한 상황을 안전하게 처리할 수 있는 강력한 도구임을 알 수 있습니다.
3. DispatchSemaphore
NSLock이 상호 배제(mutual exclusion)를 위한 도구라면, DispatchSemaphore는 그보다 더 유연하고 강력한 동기화 매커니즘입니다. 가장 큰 차이점은 다음과 같습니다.
- 접근 제어 수: NSLock은 한 번에 하나의 스레드만 임계 영역에 접근할 수 있도록 허용합니다. 반면에 DispatchSemaphore는 지정된 수(N)의 스레드가 동시에 접근할 수 있습니다.
- 소유권: NSLock은 잠금을 획득한 스레드만이 잠금을 해제할 수 있습니다. DispatchSemaphore는 어떤 스레드든 signal()을 호출하여 세마포어 값을 증가시킬 수 있습니다.
- 사용 범위: NSLock은 주로 임계 영역 보호에 사용되지만, DispatchSemaphore는 리소스 카운팅, 작업 완료 신호, 스레드 제한 등 더 다양한 상황에 적용할 수 있습니다.
DispatchSemaphore의 구조와 작동 방식
DispatchSemaphore는 Grand Central Dispatch(GCD) 프레임워크의 일부로, 내부적으로 카운터 메커니즘을 가지고 있습니다.
// 세마포어 생성 (초기값 N으로 설정) let semaphore = DispatchSemaphore(value: N)
핵심 메서드
- wait(): 세마포어 값을 1 감소시킵니다.
- 세마포어 값이 0보다 크면 즉시 반환되고 값이 1 감소합니다.
- 세마포어 값이 0이면 다른 스레드가 signal()을 호출할 때까지 현재 스레드가 블록됩니다.
- 타임아웃을 설정하면 지정된 시간이 지난 후 결과값을 반환합니다(.success 또는 .timedOut).
semaphore.wait() // 기본 형태 semaphore.wait(timeout: .now() + .seconds(5)) // 타임아웃 설정
- signal(): 세마포어 값을 1 증가시킵니다.
- 대기 중인 스레드가 있으면 그 중 하나를 깨웁니다.
- 대기 중인 스레드가 없으면 세마포어 값만 증가합니다.
semaphore.signal()
DispatchSemaphore의 심층 작동 방식
DispatchSemaphore의 작동 흐름을 좀 더 자세히 살펴보겠습니다.
- 초기화 단계: DispatchSemaphore(value: N)으로 세마포어를 생성하면, 내부 카운터가 N으로 설정됩니다.
- wait() 호출 시
- 시스템은 세마포어의 현재 값을 확인합니다.
- 값이 0보다 크면 값을 1 감소시키고 즉시 반환합니다.
- 값이 0이면 현재 스레드를 wait queue에 추가하고 블록 상태로 전환합니다.
- 대기 중인 스레드는 CPU 시간을 소비하지 않고 효율적으로 대기합니다(블록 상태).
- signal() 호출 시
- 시스템은 wait queue에 대기 중인 스레드가 있는지 확인합니다.
- 대기 중인 스레드가 있으면 그 중 하나를 깨우고 실행 준비 상태로 전환합니다.
- 대기 중인 스레드가 없으면 세마포어 값을 1 증가시킵니다.
- 시스템 레벨에서의 작동
- DispatchSemaphore는 커널 레벨에서 구현된 동기화 프리미티브를 사용합니다.
- 이로 인해 효율적인 스레드 블로킹과 신호 처리가 가능합니다.
- 내부적으로 DispatchSemaphore는 Wait Queue (FIFO) 대기열을 사용하여 대기 중인 스레드를 관리합니다.
DispatchSemaphore의 실제 활용 사례
1. 동시 작업 수 제한 (스레드 풀 관리)
let maxConcurrentOperations = 3 let semaphore = DispatchSemaphore(value: maxConcurrentOperations) let queue = DispatchQueue.global() for i in 1...10 { queue.async { semaphore.wait() defer { semaphore.signal() } // 무거운 작업 수행 print("작업 \(i) 시작") Thread.sleep(forTimeInterval: 2) print("작업 \(i) 완료") } }
이 코드는 10개의 작업을 생성하지만, 세마포어를 통해 동시에 최대 3개의 작업만 실행되도록 제한합니다.
2. 비동기 작업 완료 대기
let semaphore = DispatchSemaphore(value: 0) // 초기값 0으로 설정 let queue = DispatchQueue.global() queue.async { // 비동기 작업 수행 print("비동기 작업 시작") Thread.sleep(forTimeInterval: 2) print("비동기 작업 완료") // 작업 완료 신호 semaphore.signal() } // 비동기 작업이 완료될 때까지 대기 semaphore.wait() print("메인 스레드에서 계속 진행")
이 예제에서는 세마포어의 초기값을 0으로 설정하여, 비동기 작업이 완료되어 signal()을 호출할 때까지 메인 스레드가 대기하도록 합니다.
3. 복수의 비동기 작업 완료 대기
let taskCount = 5 let semaphore = DispatchSemaphore(value: 0) let queue = DispatchQueue.global() for i in 1...taskCount { queue.async { // 비동기 작업 수행 print("작업 \(i) 시작") Thread.sleep(forTimeInterval: Double.random(in: 1...3)) print("작업 \(i) 완료") // 작업 완료 신호 semaphore.signal() } } // 모든 작업이 완료될 때까지 대기 for _ in 1...taskCount { semaphore.wait() } print("모든 작업 완료")
이 코드는 여러 비동기 작업이 모두 완료될 때까지 대기하는 패턴을 보여줍니다.
4. 리소스 풀 관리
class DatabaseConnectionPool { private let connections: [Database] // 가상의 데이터베이스 연결 객체들 private let semaphore: DispatchSemaphore init(connectionCount: Int) { // 데이터베이스 연결 생성 self.connections = (0..<connectionCount).map { _ in Database() } // 세마포어 초기화 (연결 수와 동일하게) self.semaphore = DispatchSemaphore(value: connectionCount) } func performDatabaseOperation(_ operation: (Database) -> Void) { semaphore.wait() // 연결 획득을 대기 // 연결 획득 성공, 사용 가능한 연결 찾기 if let connection = getAvailableConnection() { defer { releaseConnection(connection) semaphore.signal() // 연결 반환 및 신호 } // 데이터베이스 작업 수행 operation(connection) } else { // 연결을 찾지 못한 경우 (실제로는 발생하지 않아야 함) semaphore.signal() } } private func getAvailableConnection() -> Database? { // 사용 가능한 연결 찾기 로직 return connections.first } private func releaseConnection(_ connection: Database) { // 연결 반환 로직 } } // 가상의 데이터베이스 클래스 class Database { func query(_ sql: String) { // 쿼리 실행 } }
이 예제는 데이터베이스 연결 풀을 관리하는 방법을 보여줍니다. 세마포어는 동시에 사용할 수 있는 연결 수를 제한합니다.
DispatchSemaphore의 고급 기능
1. 타임아웃 설정
let semaphore = DispatchSemaphore(value: 0) let timeout = DispatchTime.now() + .seconds(5) // 비동기 작업 시작 DispatchQueue.global().async { // 시간이 오래 걸리는 작업 Thread.sleep(forTimeInterval: 10) semaphore.signal() } // 5초 타임아웃으로 대기 switch semaphore.wait(timeout: timeout) { case .success: print("작업이 완료되었습니다.") case .timedOut: print("타임아웃: 작업이 너무 오래 걸립니다.") }
이 코드는 wait() 메서드에 타임아웃을 설정하여, 지정된 시간이 지나면 대기를 중단하고 타임아웃 결과를 반환합니다.
2. 재귀적 사용
let semaphore = DispatchSemaphore(value: 1) func recursiveFunction(depth: Int) { guard depth > 0 else { return } semaphore.wait() defer { semaphore.signal() } print("깊이: \(depth)") // 재귀 호출 recursiveFunction(depth: depth - 1) } recursiveFunction(depth: 5)
DispatchSemaphore는 재귀 함수 내에서도 안전하게 사용할 수 있습니다. 단, 데드락 상황을 주의해야 합니다.
DispatchSemaphore 사용 시 주의사항
- 데드락(Deadlock): 세마포어 사용이 잘못되면 데드락이 발생할 수 있습니다. 예를 들어, wait()를 호출한 후 signal()을 호출하지 않으면 다른 스레드가 영원히 대기하게 됩니다.
- 메인 스레드 블로킹: 메인 스레드에서 wait()를 호출할 때는 UI 응답성에 영향을 줄 수 있으므로 주의해야 합니다.
- 우선순위 역전(Priority Inversion): 낮은 우선순위 스레드가 세마포어를 점유한 상태에서 높은 우선순위 스레드가 대기할 경우, 우선순위 역전 문제가 발생할 수 있습니다.
- 과도한 대기: 너무 많은 스레드가 하나의 세마포어에 대기하면 성능 저하가 발생할 수 있습니다.
- 신호 손실: signal()을 wait() 전에 호출하면, 신호가 손실될 수 있습니다. 특히 초기값이 0인 세마포어에서 유의해야 합니다.
4. DispatchQueue (직렬 큐)
GCD(Grand Central Dispatch)의 직렬 큐를 사용하면 락 없이도 스레드 안전성을 보장할 수 있습니다.
import Foundation class SafeCounter { private var count = 0 private let queue = DispatchQueue(label: "com.example.counter") // 직렬 큐 생성 func increment() { queue.sync { // 이 블록은 큐에서 직렬화되어 실행됨 count += 1 } } func incrementAndPrint() { queue.async { // 비동기로 실행되지만 여전히 직렬화됨 self.count += 1 print("현재 카운트: \(self.count)") } } func getCount() -> Int { // 현재 값을 읽기 위해 큐에서 동기적으로 실행 return queue.sync { count } } } let counter = SafeCounter() DispatchQueue.concurrentPerform(iterations: 100) { _ in counter.increment() } print("최종 카운트: \(counter.getCount())") // 항상 100이 출력됨
직렬 큐는 명시적인 락 없이도 작업을 순차적으로 실행하므로 동시성 문제를 방지하는 방법입니다. sync는 작업이 완료될 때까지 대기하고, async는 작업을 큐에 제출하고 바로 반환합니다.
5. os_unfair_lock (저수준 락)
우선순위 역전 문제의 본질
우선순위 역전은 멀티스레드 환경에서 발생하는 문제로, 고우선순위 스레드가 저우선순위 스레드에 의해 차단되는 현상입니다. 구체적으로
- 저우선순위 스레드(L)가 락을 획득합니다.
- 고우선순위 스레드(H)가 같은 락을 획득하려고 시도하지만, 이미 L이 락을 보유하고 있어 대기해야 합니다.
- 중간 우선순위 스레드(M)가 실행되면서 L의 실행을 방해합니다(L보다 우선순위가 높으므로).
- H는 L이 락을 해제할 때까지 기다려야 하지만, L은 M에 의해 선점되어 락을 해제할 기회를 얻지 못합니다.
- 결과적으로 가장 높은 우선순위 스레드 H가 중간 우선순위 스레드 M보다 늦게 실행되는 비정상적인 상황이 발생합니다.
이것이 바로 "우선순위 역전(priority inversion)"입니다.
OSSpinLock의 문제점
이전의 OSSpinLock이 이 문제에 취약했던 이유는 다음과 같습니다.
- 스핀 락의 특성: OSSpinLock은 순수한 스핀 락 방식으로, 락을 획득하지 못한 스레드가 CPU를 계속 점유하며 락이 해제될 때까지 반복적으로 확인(스핀)합니다.
- 스케줄러 개입 부재: 스핀 락은 스레드가 "바쁜 대기(busy waiting)"를 하므로, 운영 체제의 스케줄러가 우선순위 관련 문제를 해결할 기회가 없습니다.
- CPU 자원 낭비: 저우선순위 스레드가 락을 보유한 상태에서 고우선순위 스레드가 스핀하면, 고우선순위 스레드가 CPU 시간을 소비하느라 저우선순위 스레드가 실행될 기회를 얻지 못합니다.
os_unfair_lock의 해결 방법
os_unfair_lock은 다음과 같은 메커니즘으로 우선순위 역전 문제를 해결합니다.
1. 블로킹 메커니즘 사용
OSSpinLock과 달리, os_unfair_lock은 락을 획득하지 못한 스레드를 스핀 상태로 두지 않고 블록(sleep) 상태로 전환합니다. 이렇게 하면:
- 락을 기다리는 스레드가 CPU 시간을 소비하지 않습니다.
- 락을 보유한 스레드가 실행될 수 있는 기회가 더 많아집니다.
2. 우선순위 상속(Priority Inheritance) 구현
os_unfair_lock의 가장 중요한 특징은 우선순위 상속 메커니즘입니다.
- 고우선순위 스레드(H)가 락을 대기할 때, 현재 락을 보유한 저우선순위 스레드(L)의 우선순위가 일시적으로 H의 우선순위로 상승합니다.
- 이렇게 하면 L은 중간 우선순위 스레드(M)보다 높은 우선순위를 갖게 되어, 락을 신속하게 해제할 수 있습니다.
- 락이 해제되면 L의 우선순위는 원래대로 복원됩니다.
이 메커니즘은 다음과 같이 작동합니다.
초기 상태: 스레드 L(낮은 우선순위)이 락을 획득 1. 스레드 H(높은 우선순위)가 락을 요청 2. 시스템이 L의 우선순위를 H의 수준으로 일시적으로 상승 3. 이제 L은 M보다 높은 우선순위를 가지므로, M에 의해 방해받지 않음 4. L이 임계 영역을 완료하고 락을 해제 5. L의 우선순위가 원래대로 복원 6. H가 락을 획득하고 실행
3. 커널 수준 지원
os_unfair_lock은 macOS와 iOS 커널의 직접적인 지원을 받습니다:
- 락 상태가 커널에 의해 관리됩니다.
- 스레드 우선순위 조정이 커널 수준에서 이루어집니다.
- 락을 기다리는 스레드의 대기 큐가 우선순위에 따라 관리됩니다.
4. 유연한 스레드 스케줄링
os_unfair_lock은 스레드가 락을 기다릴 때 스케줄러에 제어권을 반환합니다.
- 락을 획득하지 못한 스레드는 커널에 의해 잠들게 됩니다(sleep).
- 락이 해제되면 커널은 대기 중인 스레드 중 가장 높은 우선순위의 스레드를 깨웁니다.
- 이는 CPU 사용률을 낮추고 전체 시스템 성능을 향상시킵니다.
실제 코드에서의 동작 예시
이해를 돕기 위해 우선순위 역전 상황이 os_unfair_lock에서 어떻게 처리되는지 예시를 들어보겠습니다.
import Foundation import os.lock // 락 생성 let lock = UnsafeMutablePointer<os_unfair_lock>.allocate(capacity: 1) lock.initialize(to: os_unfair_lock()) // 낮은 우선순위 스레드에서 락 획득 DispatchQueue.global(qos: .utility).async { // 낮은 우선순위 print("저우선순위 스레드: 락 획득 시도") os_unfair_lock_lock(lock) print("저우선순위 스레드: 락 획득 성공") // 시간이 오래 걸리는 작업 시뮬레이션 Thread.sleep(forTimeInterval: 2) // 이 시점에서 고우선순위 스레드가 락을 요청하면 // 이 스레드의 우선순위가 일시적으로 상승 print("저우선순위 스레드: 락 해제") os_unfair_lock_unlock(lock) } // 약간의 지연 후 고우선순위 스레드에서 락 요청 Thread.sleep(forTimeInterval: 0.5) DispatchQueue.global(qos: .userInteractive).async { // 높은 우선순위 print("고우선순위 스레드: 락 획득 시도") os_unfair_lock_lock(lock) print("고우선순위 스레드: 락 획득 성공") os_unfair_lock_unlock(lock) } // 중간 우선순위 스레드들이 실행되어도 // 우선순위 상속 덕분에 저우선순위 스레드가 방해받지 않음 for _ in 1...5 { DispatchQueue.global(qos: .userInitiated).async { // 중간 우선순위 print("중간 우선순위 스레드: 다른 작업 실행 중") // 시간이 오래 걸리는 작업 수행 Thread.sleep(forTimeInterval: 0.2) } }
- 저우선순위 스레드가 락을 획득합니다.
- 고우선순위 스레드가 락을 획득하려고 시도하지만 대기합니다.
- 이때 os_unfair_lock은 저우선순위 스레드의 우선순위를 고우선순위 스레드의 수준으로 일시적으로 상승시킵니다.
- 중간 우선순위 스레드들이 실행되어도, 우선순위가 상승된 저우선순위 스레드가 이들보다 먼저 실행됩니다.
- 저우선순위 스레드가 락을 해제하면, 고우선순위 스레드가 락을 획득합니다.
6. NSCondition (조건 변수)
조건 변수(Condition Variable)란?
조건 변수는 스레드 간 통신을 위한 동기화 메커니즘으로, 특정 조건이 만족될 때까지 스레드를 대기시키고, 조건이 만족되면 대기 중인 스레드에게 알림을 보내는 기능을 제공합니다. Swift에서는 NSCondition 클래스로 구현되어 있습니다.
1. DataQueue 클래스
class DataQueue { private var data: [Int] = [] // 데이터를 저장하는 배열 private let condition = NSCondition() // 스레드 동기화를 위한 조건 변수 private let maxItems = 10 // 큐의 최대 크기 // ... }
- data: 생산자가 추가하고 소비자가 가져가는 데이터를 저장하는 배열
- condition: 스레드 간 동기화를 위한 NSCondition 객체
- maxItems: 큐의 최대 크기를 정의하는 상수
2. 생산자 메서드 (addData)
func addData(_ item: Int) { condition.lock() defer { condition.unlock() } while data.count >= maxItems { print("큐가 가득 참. 생산자 대기 중...") condition.wait() } data.append(item) print("데이터 추가됨: \(item), 현재 크기: \(data.count)") condition.signal() }
- condition.lock(): 임계 영역(critical section) 진입 전 락을 획득
- defer { condition.unlock() }: 함수가 종료될 때 락 해제를 보장
- while 루프: 큐가 가득 찼는지 확인하고, 가득 찼다면 다른 스레드가 데이터를 소비할 때까지 대기
- condition.wait(): 현재 스레드를 일시 중지하고 락을 해제 (다른 스레드가 락을 획득할 수 있게 함)
- 조건이 만족되면(큐에 공간이 생기면) 데이터 추가
- condition.signal(): 대기 중인 다른 스레드(소비자)에게 알림
3. 소비자 메서드 (getData)
func getData() -> Int { condition.lock() defer { condition.unlock() } while data.isEmpty { print("큐가 비어 있음. 소비자 대기 중...") condition.wait() } let item = data.removeFirst() print("데이터 소비됨: \(item), 남은 크기: \(data.count)") condition.signal() return item }
- 생산자 메서드와 유사한 구조
- while 루프: 큐가 비어 있는지 확인하고, 비어 있다면 생산자가 데이터를 추가할 때까지 대기
- 데이터가 있으면 첫 번째 항목을 가져와 반환
- condition.signal(): 대기 중인 다른 스레드(생산자)에게 알림
4. 동작 과정
let queue = DataQueue() let producerQueue = DispatchQueue(label: "producer") let consumerQueue = DispatchQueue(label: "consumer") // 생산자 스레드 producerQueue.async { for i in 1...20 { queue.addData(i) Thread.sleep(forTimeInterval: 0.2) } } // 소비자 스레드 consumerQueue.async { for _ in 1...20 { let item = queue.getData() Thread.sleep(forTimeInterval: 0.5) } }
- 별도의 디스패치 큐(스레드)를 생성하여 생산자와 소비자 작업을 비동기적으로 실행
- 생산자는 0.2초마다 데이터를 생성하고, 소비자는 0.5초마다 데이터를 소비
- 생산 속도가 소비 속도보다 빠르므로 큐가 가득 차게 되고, 그때 생산자는 대기
- 소비자가 데이터를 소비하면 큐에 공간이 생기고, 생산자는 다시 데이터를 추가 가능
생산자가 더 빠르게 동작하므로 시간이 지남에 따라 큐가 가득 차게 되고, 생산자는 소비자가 일부 데이터를 소비할 때까지 대기하게 됩니다.
이 패턴은 많은 실제 시스템에서 사용되는 중요한 동시성 패턴으로, 네트워크 버퍼, 작업 큐, 이벤트 처리 시스템 등에서 활용됩니다.
중요한 개념
1. wait()와 signal() 메커니즘
- wait(): 스레드를 일시 중지하고 락을 해제합니다. 이를 통해 다른 스레드가 작업을 수행할 수 있습니다.
- signal(): 대기 중인 하나의 스레드에게 조건이 변경되었음을 알립니다.
2. while 루프를 사용하는 이유
단순히 if 조건문 대신 while 루프를 사용하는 이유는 "스퓨리어스 웨이크업(spurious wakeup)"을 처리하기 위함입니다. 스레드가 조건이 만족되지 않았는데도 깨어날 수 있는 경우가 있어, while 루프를 통해 조건을 다시 확인합니다.
3. lock()과 unlock()
lock()과 unlock()은 여러 스레드가 동시에 공유 자원에 접근하는 것을 방지하는 상호 배제(mutual exclusion) 메커니즘입니다. defer를 사용하여 함수가 어떻게 종료되든 락이 반드시 해제되도록 보장합니다.
7. DispatchGroup (작업 그룹 동기화)
DispatchGroup이란?
DispatchGroup은 여러 비동기 작업을 하나의 그룹으로 관리하여, 모든 작업이 완료될 때 알림을 받거나 완료될 때까지 대기할 수 있게 해주는 GCD(Grand Central Dispatch)의 기능입니다. 여러 독립적인 비동기 작업이 모두 끝난 후에 특정 동작을 수행해야 할 때 매우 유용합니다.
1. DispatchGroup 생성
let group = DispatchGroup() let queue = DispatchQueue.global()
- DispatchGroup: 여러 작업을 그룹화하는 객체
- DispatchQueue.global(): 시스템에서 관리하는 글로벌 큐로, 백그라운드에서 작업을 실행
2. 작업 추가 및 실행
// 첫 번째 다운로드 작업 group.enter() // 그룹에 작업 추가 queue.async { print("파일 1 다운로드 시작") Thread.sleep(forTimeInterval: 2) // 다운로드 시뮬레이션 print("파일 1 다운로드 완료") group.leave() // 작업 완료 표시 }
- group.enter(): 그룹에 작업이 추가됨을 명시적으로 표시
- queue.async { ... }: 비동기적으로 작업을 실행
- Thread.sleep(forTimeInterval: 2): 다운로드 작업을 시뮬레이션 (실제 코드에서는 실제 네트워크 요청 등이 들어감)
- group.leave(): 작업이 완료되었음을 그룹에 알림
3. 작업 완료 대기 방법
3.1 동기적 대기 (wait)
if group.wait(timeout: .now() + 5) == .timedOut { print("다운로드 시간 초과") } else { print("모든 파일 다운로드 완료!") }
- group.wait(timeout:): 지정된 시간(최대 5초) 동안 모든 작업이 완료될 때까지 현재 스레드를 블록
- 시간 내에 모든 작업이 완료되면 .success 반환, 시간을 초과하면 .timedOut 반환
- 주의: 이 방법은 메인 스레드에서 사용할 경우 UI가 멈출 수 있음
3.2 비동기적 대기 (notify)
group.notify(queue: .main) { print("모든 다운로드 작업이 완료되었습니다.") }
- group.notify(queue:execute:): 모든 작업이 완료되면 지정된 큐(여기서는 메인 큐)에서 클로저를 실행
- 이 방법은 현재 스레드를 블록하지 않으므로 UI 작업에 적합
8. DispatchBarrier (읽기-쓰기 동기화)
DispatchBarrier의 작동 원리
DispatchBarrier는 concurrent 큐에서 특별한 방식으로 작동합니다.
- 일반 작업: barrier 플래그가 없는 일반 작업들은 concurrent 큐에서 병렬로 실행됩니다.
- barrier 작업: barrier 플래그가 있는 작업은 다음과 같이 처리됩니다.
- 큐에 이미 제출된 모든 작업이 완료될 때까지 대기합니다.
- barrier 작업이 실행될 때는 다른 모든 작업이 실행되지 않습니다 (독점적 실행).
- barrier 작업이 완료된 후에야 다른 작업들이 다시 병렬로 실행될 수 있습니다.
이는 마치 "읽기는 여러 스레드가 동시에, 쓰기는 한 번에 하나의 스레드만" 수행하도록 하는 읽기-쓰기 락(reader-writer lock)과 유사한 패턴을 구현합니다.
// 읽기 작업 - 동시에 수행 가능 func value(forKey key: Key) -> Value? { return concurrentQueue.sync { return dictionary[key] } } // 쓰기 작업 - barrier로 단독 수행 func set(value: Value, forKey key: Key) { concurrentQueue.async(flags: .barrier) { [weak self] in self?.dictionary[key] = value } }
- 읽기 작업(value(forKey:))
- sync로 실행되어 값을 즉시 반환합니다.
- barrier 플래그가 없어 여러 읽기 작업이 동시에 실행될 수 있습니다.
- 쓰기 작업(set(value:forKey:))
- async로 실행되어 비동기적으로 처리됩니다.
- .barrier 플래그로 인해 이 작업이 실행될 때는 다른 모든 작업이 일시 중지됩니다.
- 작업이 완료된 후에야 다른 작업들이 실행될 수 있습니다.
활용 사례
- 스레드 안전 컬렉션: 예제 코드처럼 스레드 안전한 딕셔너리, 배열 등을 구현할 때 사용됩니다.
- 캐시 시스템: 캐시에서 읽기는 빈번하게 발생하고 쓰기는 상대적으로 드물게 발생하므로 barrier 패턴이 효율적입니다.
- 데이터베이스 접근: 여러 읽기 트랜잭션은 동시에 허용하고 쓰기 트랜잭션은 독점적으로 수행할 때 유용합니다.
- 상태 관리: 앱의 상태를 안전하게 관리하기 위해 사용될 수 있습니다.
'Apple🍎 > Swift' 카테고리의 다른 글
Swift 동시성 모델 (0) 2025.04.18 DispatchQueue와 DispatchWorkItem 비교하기 (0) 2025.04.17 Swift 6 : Typed Throws ( 에러도 타입을 줘서 더 명확히 처리하자.) (0) 2025.04.14 inout 파라미터 작동 방식 파해치기 (0) 2025.03.11 Swift의 컬렉션 타입 : 값 의미론과 실제 구현 (0) 2025.02.28