[iOS] GCD (7) - Thread-Safety 한 코드 구현 방법

전성훈·2023년 11월 1일
0

iOS/Concurrency

목록 보기
7/11
post-thumbnail

주제: Thread-Safety 한 코드의 구현 방법


Thread-Safety

  • 여러 스레드가 동시에 공유 자원(메모리, 파일)에 접근하거나 수정할 때, 프로그램이 올바르게 동작하는 것을 의미한다.
  • 동시적 처리를 하면서(여러 스레드를 사용하면서도) 문제없이 스레드를 안전하게 사용한다는 의미이다.
  • 데이터에 여러 스레드를 사용하여 접근하여도, 한번에 한개의 스레드만 접근가능하도록 처리하여 경쟁상황의 문제없이 사용이 가능하다.

구현 방법

Lock 코드 사용 (비 선호)

  • Lock 코드로 구현 할 수는 있지만, Lock으로 올바르게 구현하는 것은 어렵기도 하고, 교착상태(DeadLocks)이 생길 가능성이 높다.(낮은 수준의 해결책)
  • Swift에서는 'NSLock' 클래스를 사용하여 Lock을 구현할 수 있다.
class Counter {
    var value = 0
    private let lock = NSLock()

    func increment() {
        lock.lock()
        value += 1
        print("Value: \(value)")
        lock.unlock()
    }

    func decrement() {
        lock.lock()
        value -= 1
        print("Value: \(value)")
        lock.unlock()
    }
}

let counter = Counter()
let queue = DispatchQueue(label: "com.example.queue", attributes: .concurrent)
let group = DispatchGroup()

for _ in 0..<100 {
    group.enter()
    queue.async {
        counter.increment()
        group.leave()
    }
    group.enter()
    queue.async {
        counter.decrement()
        group.leave()
    }
}

group.notify(queue: .main) {
    print(counter.value)
}
// 0
  • 위 코드에서는 Counter라는 클래스를 정의하고, increment()decrement() 메서드를 사용하여 값을 증가시키거나 감소시킵니다. NSLock 클래스를 사용하여 Lock을 생성하고, lock() 메서드로 잠그고 unlock() 메서드로 잠금을 해제한다.
  • 이렇게 하면 여러 스레드에서 increment()decrement() 메서드가 동시에 호출되더라도 값의 증감이 올바르게 동작한다.

TSan(thread sanitizer tool) 사용

  • 앱스토어 출시전 반드시 확인해야 할 일
  • TSan은 XCode 및 Swift에서 사용할 수 있는 도구로, 멀티스레딩 프로그램에서 발생하는 데이터 경쟁 상황을 감지하는 데 도움을 준다. 이를 사용하여 애플리케이션의 동시성 문제를 식별하고 수정할 수 있다.
  1. Xcode를 실행하고 프로젝트를 연다.
  2. 상단의 메뉴에서 Product > Scheme > Edit Scheme을 선택한다.
  3. 왼쪽 패널에서 Run을 선택하고, 오른쪽의 Diagnostics 탭으로 이동한다.
  4. Thread Sanitizer 체크박스를 활성화하고 Close 버튼을 클릭한다.
  • 이렇게 하면 프로젝트가 실행될 때 TSan이 활성화되어 데이터 경쟁 상황을 감지하고 경고를 표시한다.

시리얼 큐와 sync

  • 일반적으로 Race Condition이 발생한다는 것을 알고 있다면, Serial Queue를 사용하여 문제를 해결할 수 있다.
  • 동시에 접근해야 하는 변수가 있는 프로그램의 경우, 다음과 같이 private 큐로 읽기와 쓰기 작업을 감싸주면 된다.
private let threadSafeCountQueue = DispatchQueue(label: "...")
private var _count = 0 
public var count: Int { 
	get { 
		return threadSafeCountQueue.sync { 
			_count
		}
	}
	set { 
		threadSafeCountQueue.sync { 
			_count = newValue
		}
	}
}

  • 별도로 지정하지 않았기 때문에, threadSafeCountQueue는 시리얼 큐이다.
  • 변수에 대한 접근을 제어하고 한 번에 하나의 스레드만 변수에 접근할 수 있도록 하였으며, 위와 같이 간단한 읽기/쓰기 작업을 수행하는 경우, 이 방법이 가장 적합하다.
  • 여기서의 sync의 의미는 "글로벌 디폴트큐"에서 "시리얼 큐"로 보낸 작업을 기다린다는 의미
  • 유일한 객체에 접근 할때는 시리얼큐 및 sync 메서드를 사용하여 일관된 값을 얻게 해야한다.
  • thread-safe하다의 정의는 print하는 작업과는 별개로 객체에 한번에 한개의 쓰레드만 접근할 수 있으니 Thread-Safe하게 처리된 것은 맞음

Dispatch Barrier

  • 공유 리소스가 단순한 변수 수정보다 복잡한 로직을 필요로하는 getter와 setter를 사용해야 할 때가 있다. 이와 관련된 질문을 온라인에서 자주 보게 되며, 종종 lock과 semaphore와 관련된 해결책이 제시된다.
  • 락 구현은 매우 어렵다. 대신 Apple의 Dispatch Barrier의 솔루션을 사용할 수 있다.
  • concurrent Queue를 생성하면서 동시에 실행할 수 있는 읽기 작업을 원하는 만큼 처리할 수 있다.
  • 변수에 쓰기 작업이 필요한 경우, 이미 제출된 작업이 완료되기 전까지 큐를 잠가야 하며, 업데이트가 완료될 때까지 새로운 작업을 실행하지 않는다.
private let threadSafeCountQueue = DispatchQueue(label: "...", attributes: .concurrent)
private var _count = 0
public var count: Int {
  get {
    return threadSafeCountQueue.sync {
      return _count
    }
  }
  set {
    threadSafeCountQueue.async(flags: .barrier) { [unowned self] in
      self._count = newValue
    }
  }
}
  • 베리어가 실행되면, 큐는 마치 Serial인 것처럼 동작하고 베리어 작업만 완료될 때까지 실행 할 수 있다.
  • 베리어 작업이 완료되면, 베리어 작업 이후에 제출된 모든 작업은 다시 동시에 실행할 수 있다.
  • 즉, 베리어를 사용하면, 특정 작업을 동시에 수행하는 다른 작업들과 분리하여 실행할 수 있다. 이를 통해 공유 리소스에 대한 동시 접근을 제어하고, 데이터 경쟁 및 일관성 문제를 방지할 수 있다.

객체 설계 시 주의할 점

  • Thread-safety: 동시에 여러 스레드에서 객체에 액세스할 수 있는 경우, thread-safe한 코드를 작성하여 경쟁 조건이나 데드락이 발생하지 않도록 해야 한다. 동기화 메커니즘을 사용하거나, DispatchQueue, NSLock, DispatchSemaphore 등을 활용하여 동시성 문제를 해결할 수 있다.
  • State management: 비동기 상황에서 객체의 상태를 관리하고 변경하는 것이 복잡할 수 있다. 상태 변경을 최소화하고, 가능한 한 immutable하게 설계해야한다. 필요한 경우, 상태를 관리하는 별도의 객체를 사용하여 코드를 분리하고 캡슐화한다.
  • Resource management: 비동기 작업 중에는 자원을 공유하거나 관리해야 할 수도 있다. 자원이 제대로 해제되도록 코드를 작성하고, 메모리 누수가 발생하지 않도록 주의해야 한다.
  • Error handling: 비동기 코드에서 발생할 수 있는 에러를 적절하게 처리해야 한다. 에러 처리를 위해 completion handler, Result 타입, async/await, 또는 error propagation과 같은 방법을 사용한다.
  • Testing: 비동기 코드를 테스트하려면 다양한 상황과 타이밍을 고려해야 한다. 비동기 작업의 완료를 기다리는 방법으로 XCTestExpectation, async/await 등을 사용하고, 가능한 한 단위 테스트에서 비동기 작업을 분리하여 테스트의 복잡성을 줄여야 한다..
  • Reentrancy: 비동기 작업이 중복 실행되거나 재진입되는 것을 피해야 한다. 작업의 실행 상태를 추적하고, 진행 중인 작업이 완료되기 전에 다시 시작되지 않도록 코드를 작성한다.
  • Cancellation: 비동기 작업이 길어지거나 사용자에 의해 취소될 수 있다. 작업을 취소할 수 있는 기능을 제공하고, 취소될 때 필요한 정리 작업을 수행해야 한다.

Lazy Var에 관한 이슈

  • Lazy 작업은 쓰기 작업과 유사하게, 메모리에 로딩되는 시간이 조금 걸리는 작업이다.
  • lazy var는 기본적으로 Thread-safe 하지 않다. 이는 동시에 여러 스레드에서 lazy var에 접근할 때 race condition이 발생할 수 있다는 것을 의미한다.
  1. 명확하게, 작업을 하기 전 lazy 변수 생성 후, 작업 실시
  2. 시리얼 큐 + Sync로 작업 실시
  3. Dispatch Barrier로 작업 실시
  4. 세마포어 이용, 작업의 동시 실행 갯수 제한하기 등이 있다.

해결방법

class SharedInstanceClass {
    lazy var testVar = {
        return Int.random(in: 0..<99)
    }()
}

let queue = DispatchQueue(label: "test", qos: .default, attributes: [.initiallyInactive, .concurrent], autoreleaseFrequency: .workItem, target: nil)
let group = DispatchGroup()


let instance = SharedInstanceClass()
for i in 0 ..< 10 {
    group.enter()
    queue.async(group: group, qos: .default) {
        let id = i
        print("\(id) \(instance.testVar)")
        group.leave()
    }
}
queue.activate()
group.wait()
  • 이렇게 했을 때 lazy var를 생성 할 때 여러 쓰레드에서 동시에 접근하기 때문에 여러 값을 출력할 수 있다.
  • 엄격한 thread-safe 처리
class SharedInstanceClass2 {
    let serialQueue = DispatchQueue(label: "serial")
    
    lazy var testVar = {
        return Int.random(in: 0...100)
    }()
    
    // 객체 내부에서 시리얼큐로 다시 testVar에 접근하도록
    var readVar: Int {
        serialQueue.sync {
            return testVar
        }
    }
}

// 디스패치그룹 생성
let group2 = DispatchGroup()

// 객체 생성
let instance2 = SharedInstanceClass2()

// 실제 비동기 작업 실행
for i in 0 ..< 100 {
    group2.enter()
    queue.async(group: group2) {
        print("id:\(i), lazy var 이슈:\(instance2.readVar)")
        group2.leave()
    }
}

group2.notify(queue: DispatchQueue.global()){
  print("시리얼큐로 해결한 모든 작업의 완료.")
}

queue.activate()
group1.wait()
  • DispatchSemaphore로 해결
class SharedInstanceClass4 {
    lazy var testVar = {
        return Int.random(in: 0...100)
    }()
}


// 디스패치그룹 생성
let group4 = DispatchGroup()

// 객체 생성
let instance4 = SharedInstanceClass4()

let semaphore = DispatchSemaphore(value: 1)


// 실제 비동기 작업 실행
for i in 0 ..< 100 {
    group4.enter()
    semaphore.wait()
    queue.async(group: group4) {
        print("id:\(i), lazy var 이슈:\(instance4.testVar)")
        group4.leave()
        semaphore.signal()
    }
}

group4.notify(queue: DispatchQueue.global()){
    print("디스패치 세마포어로 해결한 모든 작업의 완료.")
}
  • DispatchBarrier로 해결
class SharedInstanceClass3 {
    lazy var testVar = {
        return Int.random(in: 0...100)
    }()
}


// 디스패치그룹 생성
let group3 = DispatchGroup()

// 객체 생성
let instance3 = SharedInstanceClass3()

// 실제 비동기 작업 실행
for i in 0 ..< 100 {
    group3.enter()
    queue.async(flags: .barrier) {
        print("id:\(i), lazy var 이슈:\(instance3.testVar)")
        group3.leave()
    }
}

group3.notify(queue: DispatchQueue.global()){
  print("디스패치 배리어로 해결한 모든 작업의 완료.")
}

출처(참고문헌)

제가 학습한 내용을 요약하여 정리한 것입니다. 내용에 오류가 있을 수 있으며, 어떠한 피드백도 감사히 받겠습니다.

감사합니다.

0개의 댓글