본문 바로가기
iOS 일반/Stack Overflow

Swift의 Array가 멀티쓰레드에서 안전하지 않은데 어떻게 하면 될까요? (Stack Overflow)

by 토끼찌짐 2019. 12. 30.

원문

Adding items to Swift array across multiple threads causing issues (because arrays aren't thread safe) - how do I get around that?

뜻을 이해하기 쉽도록 의역/추가설명을 붙인 부분들이 있습니다.


Question

Swift 멀티 쓰레드 환경에서 Array에 아이템을 추가하려고 했더니 이슈가 발생했습니다(Array가 쓰레드에 안전하지 않음). 어떻게 하면 될까요?

저는 블럭을 저장하는 Array를 만들고, 요청을 받았을 때 그 Array 안의 블럭을 전부 실행시켜주고 싶었습니다. 그래서 다음과 유사한 코드를 작성했습니다.

class MyArrayBlockClass {
    private var blocksArray: Array<() -> Void> = Array()

    private let blocksQueue: NSOperationQueue()

    func addBlockToArray(block: () -> Void) {
        self.blocksArray.append(block)
    }

    func runBlocksInArray() {
        for block in self.blocksArray {
            let operation = NSBlockOperation(block: block)
            self.blocksQueue.addOperation(operation)
        }

        self.blocksQueue.removeAll(keepCapacity: false)
    }
}


문제는 addBlockToArray 함수가 여러 쓰레드에서 호출되며 발생했습니다. 서로 다른 쓰레드들에서 이 함수가 연속적으로 빠르게 호출되었을 때, 파라미터로 넘어온 블럭 중 오직 하나만이 배열에 추가되었습니다. 그래서 runBlocksInArray 함수가 호출되었을 때 다른 블럭들은 실행되지 않았습니다.

아래의 시도도 해보았지만 소용없었습니다.

private let blocksDispatchQueue = dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)

func addBlockToArray(block: () -> Void) {
    dispatch_async(blocksDispatchQueue) {
        self.blocksArray.append(block)
    }
}

Answer

질문의 코드에서는 blockDispatchQueue를 global queue로 정의하고 있습니다. 이 코드를 Swift3의 문법으로 다시 작성해보면, 이렇게 됩니다.

private let queue = DispatchQueue.global()

func addBlockToArray(block: @escaping () -> Void) {
    queue.async {
        self.blocksArray.append(block)
    }
}


문제는 global queue가 concurrent queue라는 것입니다.(역주: 동시에 task가 실행되는 queue. Serial queue와 반대) 따라서 동기화(synchronization)가 되지 않습니다. 이럴 때는 자신만의 custom serial queue를 만들어 사용하면 됩니다. Swift3에서는 이런 식으로 만들 수 있습니다.

private let queue = DispatchQueue(label: "com.domain.app.blocks")

 

이런 custom queue는 기본적으로 serial queue입니다. 그러므로, 동기화가 가능하게 됩니다.

참고로, blocksQueue와의 인터랙션을 동기화하기 위해 이 blockDispatchQueue를 사용할 경우, blocksArray와의 모든 인터랙션은 이 queue를 통해 이루어져야 합니다. 예를 들어 operation을 blocksQueue에 추가할 때도 같은 queue안에서 이루어져야 합니다.

func runBlocksInArray() {
    queue.async {
        for block in self.blocksArray {
            let operation = BlockOperation(block: block)
            self.blocksQueue.addOperation(operation)
        }

        self.blocksArray.removeAll()
    }
}

Reader/writer 패턴을 사용하는 방법도 있습니다.(역주: Read-Write Lock 패턴: Read/Write 시마다 계속 Lock을 걸면 성능이 떨어지므로, Read만이 동시에 이루어지는 것은 허용해도 되지 않을까, 라는 패턴. 자세한 것은 위키 참고.)  자신만의 concurrent queue를 만드세요.

private let queue = DispatchQueue(label: "com.domain.app.blocks", attributes: .concurrent)

 

Reader-writer 패턴에서 쓰기 동작은 barrier 속성(쓰기가 순차적으로 수행될 수 있게 지켜주는 속성)으로 수행되어야 합니다.

func addBlockToArray(block: @escaping () -> Void) {
    queue.async(flags: .barrier) {
        self.blocksArray.append(block)
    }
}

 

그러나 데이터를 읽는 것은 그냥 이렇게 할 수 있습니다.

let foo = queue.sync {
    blocksArray[index]
}

 

이 패턴의 장점은 쓰기 동작을 동기화할 수 있으며, 읽는 동작은 여러 곳에서 동시에 가능하다는 것입니다. 이 케이스만 두고 보면 그런 성능향상이 꼭 필요하지 않을 수도 있지만 (그래서 심플한 serial queue로도 충분할 수 있지만) 완벽을 기하기 위해 이 패턴을 답변에 포함했습니다.


또 다른 방법으로는 NSLock이 있습니다.

extension NSLocking {
    func withCriticalSection<T>(_ closure: () throws -> T) rethrows -> T {
        lock()
        defer { unlock() }
        return try closure()
    }
}

 

이렇게 적용합니다.

let lock = NSLock()

func addBlockToArray(block: @escaping () -> Void) {
    lock.withCriticalSection {
        blocksArray.append(block)
    }
}

 

읽는 동작은 이렇게 할 수 있습니다.

let foo = lock.withCriticalSection {
    blocksArray[index]
}


NSLock은 그동안 항상 성능이 좋지 않다고 무시되었지만, 요즘은 심지어 GCD보다 빠릅니다.