제라스의 Swift 공부/Swift 지식

[Swift 지식] 너 동시성 프로그래밍 제대로 알아? - GCD의 DispatchQueue

Xerath(제라스) 2024. 7. 1. 00:44
728x90
반응형

서론

반갑숭구리당당~~ 동시성 프로그래밍을 부수러 온 개발자 제라스입니다! 👋🏻 🤖 👋🏻

 

오늘 벌써 2번째 포스팅인데...!

최근들어 시간이 조금 나는 듯하면서도 바쁜 나날들입니다...

 

이번엔 애플이 제공하는 동시성 프로그래밍 방식 중 하나죠?

'GCD'에 대해서 부숴보는 시간을 가지려고 합니다!

 

그럼 함께 GCD를 부수러 가보시죠~!!

GCD가 뭔데요~???

스피드웨건 등-장~!

GCD란 Grand Cenral Dispatch의 줄임말로 멀티 코어, 스레드 환경에서 최적화된 프로그래밍을 할 수 있도록 애플이 개발한 기술입니다.

 

이전 포스팅에서 언급했듯이,

물리적으로 코어에는 1-2개의 쓰레드가 있고 작업들이 이것들을 갖고서 진행되는데 이건 알아서 시스템적으로 분배해서 해준다고 했습니다. 우리가 집중할 것은 딱 하나. SW적으로 어떤 작업을 동일한 쓰레드에서 할지 아니면 이 쓰레드와 다른 쓰레드에서 할지 + 동기로 할지, 비동기로 할지 정도만 정해주면 시스템이 알아서 얘네를 배분해서 처리할 겁니다.

 

그렇다는 뜻은!!!

GCD. 이 친구가 알아서 다해주니까...!!

어떤 작업을 동일한 쓰레드에서 할지 아니면 이 쓰레드와 다른 쓰레드에서 할지 + 동기로 할지, 비동기로 할지!

요것만 정해주면 되겠다~~~ 이겁니다! 😆 😆

아하!

일단 기본적으로 이 GCD라는 애를 쓰려면 Dispatch라는 프레임워크를 사용해야 합니다.

이때 Dispatch 프레임워크 안에 있는 것들 중 제일 많이 쓰는게 DispatchQueue라는 클래스거든요?

한번 어떻게 쓰는지 봅시다!

DispatchQueue! 어떻게 사용하죠?!?

일단 DispatchQueue라는 말부터가 '어떤 Queue에 무언가를 보낸다'는 뜻입니다.

보내는 건 아마도 작업이겠죠!

 

즉, 우리는 DispatchQueue라는 대기열 Queue가 있고,

여기에 작업이 FIFO로 들어오게 됩니다.

그러면 시스템은 이 Queue에 들어오는 애들을 먼저 들어온 애들부터 순서대로 작업 처리를 할 겁니다.

 

이때, 우리는 이 Queue에 대해 4가지 종류를 줄 수 있습니다.

먼저, 단일 쓰레드로 쓸지, 다중 쓰레드로 쓸지를 선택할 수 있고

동기로 작업을 처리할지, 비동기로 작업을 처리할지 선택할 수 있습니다.

그래서 2X2로 총 4가지의 종류가 가능합니다.

 

그럼 각 상황을 봅시다!

Serial(직렬, 단일 쓰레드) vs Concurrent(동시성, 다중 쓰레드)

우리 직렬성 프로그래밍과 동시성 프로그래밍. 이 두가지 차이를 이전 포스팅에서 얘기했는데,

제목에 적어둔 것처럼 하나의 쓰레드에서 모두 처리하냐 아니면 여러 쓰레드에 분산시켜 처리하냐의 차이입니다.

그럼 어떻게 코드를 쓰는지 봅시다.

 

Serial Queue

// Serial Queue 사용법

// Custom한 Serial Queue
DispatchQueue(label: "SerialQueue") // label에 따라 다른 Queue가 됩니다.

// 기본 main Queue
DispatchQueue.main

 

아니 이게 무슨 소리여~~~하고 싶은 코드...일 수 있어요 ㅎㅎ

먼저 Serial Queue를 봅시다!

Serial이니까 하나의 쓰레드에서 처리할 겁니다?! ㅇㅋ

 

근데 그런 Serial한 Queue 중에서 main Queue라는 것에서 할 수도 있고,

혹은 아예 새로 하나 만든 Serial Queue에서 처리할 수도 있습니다.

 

이게 뭔 소리냐??

일단 main이라는 애가 겁나 중요하고 특이한 아이입니다.

main Queue는 커스텀한 Serial Queue와 마찬가지로 Serial Queue, 즉, 단일 쓰레드에 작업을 부여하는 큐가 맞습니다.

다만, 이 main Queue는 main 쓰레드에 이 작업을 부여하고 커스텀한 Serial Queue는 새로운 하나의 쓰레드에 작업을 부여합니다.

 

ㅇㅋ main Queue랑 커스텀 Serial Queue가 각각 개념적으로 다른 Thread에 작업을 부여하는 것까진 알겠어~!!

근데 둘이 어떤 차이를 보이는데???

 

코드에서 사용 시엔 딱 2가지만 생각하면 좋을 것 같습니다.

 

1. DispatchQueue 선언

 

일단 DispatchQueue를 선언하는 방식이 다릅니다.

 

main Queue는 앱이 실행되고 있는 동안엔 항상 메모리에 올라와있고, 전역적으로 사용될 수 있기에 어디서든 Dispatch.main.~ 이런 식으로 작성하면 바로 쓸 수 있습니다.

 

반면, 커스텀한 Serial Queue는 선언을 통해 접근이 가능하다는 점이 차이가 있습니다.

 

2. 사용 용도

 

사용을 하는 타이밍도 다릅니다.

 

main Queue의 경우엔

 

1. UI 요소를 업데이트할 때

2. 사용자 인터페이스와 관련된 작업을 실행할 때

3. 사용자 입력 이벤트(터치 이벤트, 버튼 클릭 이벤트 등)를 처리해야 할 때


커스텀 Serial Queue의 경우엔

 

1. main이 아닌 곳에서 작업을 해야하는 경우(UI 작업이 아니면 웬만하면 main Queue를 안쓰는게 좋습니다!)

2. 순서를 보장한 작업을 처리해야 할 경우
- 작업의 순서를 보장하고 데이터 무결성을 유지해야 할 때

- 어떤 데이터의 경합 조건을 해결해야 할 때

- 어떤 작업보다 먼저 실행되어야 할 때

 

이렇게 상황에 따라 사용하는 Queue 종류를 나눌 수 있습니다!

 

커스텀 Serial Queue동기/비동기에 따라 나눠볼게유~~~

 

sync를 쓰면 완전히 자기 순서를 지키는 반면,

 

async를 쓰면 작업 완료 순서는 보장이 안됩니다.

다만, 같은 async인 커스텀 Serial Queue 내에서는 작업 순서가 보장이 됩니다!

 

(뭔 소리여...???)

 

이건 뒤에서 코드로 예시를 보면서 설명하겠습니다!!!

 

그럼 이젠 Concurrent Queue로 넘어갑시다요~~!!

Concurrent Queue

// Concurrent Queue

DispatchQueue(label: "Concurrent", attributes: .concurrent)

// 또는

DispatchQueue.global()

 

Concurrent Queue는 이렇게 두가지 방식으로 쓸 수 있어요!

처음 방식처럼 label을 붙여주고, 뒤에 attributes를 .concurrent로 둠으로써(이거 안 쓰거나 .serial로 두면 Serial Queue가 됨!)

동시성 큐로 사용을 할 수도 있구요~~

 

두번째 방식처럼 그냥 간단하게 global()로 해줘도 됩니다!

이러면 알아서 새로운 쓰레드를 만들고 메모리에 올라와서, 작업을 처리하고

작업이 끝나면 알아서 메모리에서 파-사-사-삭 제거됩니다!

 

main과 global()의 sync, async

이게 제일 중요하죠...!!

우리는 결국 개념들을 다 알아도 코드로 녹여내야 하니까요~~!!

그럼 main부터 봐볼게요!

 

main.sync

너무 중요합니다!

main.sync를 코드에 써보신 분들은 아시겠지만 Warning이 뜨고, 빌드하면 앱이 팍 죽어버리는 현상을 목격하셨을 거에요.

 

에??? 이게 왜 발생하는데??

 

바로 이걸 쓰면 deadlock(교착상태)이 발생하기 때문입니다.

전공 수업 때 정말 매번 들어왔던... 서로가 서로를 끝나기 기다리는 현상이죠!

아...짤을 가져왔는데 우울해졌어요 ㅠㅠ

그럼 누가 누굴 기다리냐?

메인 쓰레드가 sync 작업이 끝나길 기다리고, sync가 메인 쓰레드의 Block-wait가 풀리길 기다립니다.

 

요게 무슨 말이냐면...

우리는 메인쓰레드에서 기본적으로 작업을 수행합니다. 그러다가 main.sync 작업블록을 만나면...!

 

메인쓰레드: "어? main.sync네? 그럼 메인 큐에 넣고 기다려야징~~"

 

이러고서 메인 쓰레드는 멈추고 기다립니다.

무엇을?? sync 작업이 끝나기를요!

 

그래서 메인 큐에 들어간 이 작업을 실행하려고 메인 쓰레드에 올려요.

However... 메인 쓰레드는 멈춰있고... 그럼 이 sync 작업블록은 결국 수행이 안되는 거죠!

 

sync: "아니, 메인 쓰레드 이 양반아 나도 너가 block이 풀려야 수행되는데, 나를 왜 기다려!?!?!?!?!"

즉, 이 sync 작업블록이 다 끝나야 메인 쓰레드는 다시 실행되기에...

서로가.서로를.기다리는 모습...!

근데 그렇다고 얘를 아예 못쓰냐...?

그렇지 않아요!!

다음과 같이 이 sync블록을 global() 즉, 동시성 Queue에서 불러주면 됩니다.

import Foundation

DispatchQueue.global().async {
    DispatchQueue.main.sync {
        for _ in 1...3 {
            print("Xerath입니다")
            sleep(1)
        }
    }
}

for _ in 1...3 {
    print("안녕하세요")
    sleep(2)
}

 

이런 식으로 DispatchQueue.global().async에서 main.sync를 수행하기에 메인 쓰레드가 멈추고서 sync를 기다리는게 아니라 global()로 만들어진 쓰레드가 sync를 기다립니다.

그렇기에 deadlock이 발생할 일은 없죠!

 

이 코드의 결과는

안녕하세요
(2초)
안녕하세요
(2초)
안녕하세요
(2초)
Xerath입니다
(1초)
Xerath입니다
(1초)
Xerath입니다
(1초)

 

이렇게 수행이 됩니다!

 

main.sync는 웬만하면 안 쓰는게 좋은데 이런 코드는 언제 쓰는게 좋을까요?

 

이건 보통 백그라운드(메인 쓰레드가 아닌 곳)에서 작업을 수행하다가 완료된 결과가 있는데,

이 결과를 즉시 메인 쓰레드에서 처리해야 할 때(ex. UI업데이트 등) 사용하면 좋습니다.

 

main.async

이건 메인 쓰레드에서 작업을 수행하는 거죠?

근데 async니까 비동기라서 기다리지 않고 다음 작업도 start를 걸어두는 겁니다.

 

다만! 다만! 다만! 다음 2가지 상황을 봅시다.

 

import Foundation

DispatchQueue.main.async {
    for _ in 1...3 {
        print("안녕하세요")
        sleep(1)
    }
}

DispatchQueue.main.async {
    for _ in 1...3 {
        print("Xerath입니다")
        sleep(2)
    }
}

 

이렇게 하면 첫번째 블록("안녕하세요" 블록)이 다 실행이 되고 나서 두번째 블록("Xerath입니다" 블록)이 실행됩니다.

결국엔 두 블록이 하나의 쓰레드(메인 쓰레드)에서 처리되기 때문에

뒤에 있는 작업은 앞의 작업이 끝나야 수행이 될 수 있기 때문입니다!

 

결과를 보면...

안녕하세요
(1초)
안녕하세요
(1초)
안녕하세요
(1초)
Xerath입니다
(2초)
Xerath입니다
(2초)
Xerath입니다
(2초)

 

또 다른 상황을 보면

import Foundation

DispatchQueue.main.async {
    for _ in 1...3 {
        print("안녕하세요")
        sleep(1)
    }
}

for _ in 1...3 {
    print("Xerath입니다")
    sleep(2)
}

 

이런 경우는 어떨까요?

어떤 건 main.async에 있는데 밑에 있는 건 아무것도 안 감쌌어요.

 

두번째 블록 같이 아무것도 안 감싸져 있는 코드들은 메인쓰레드에서 바로 수행이 됩니다.

그렇기에 이 코드에선 보통...보오오오오통은 웬만하면 두번째 블록이 먼저 수행되고 그 뒤에 첫번째 블록이 수행됩니다.

다음과 같이요!

Xerath입니다
(2초)
Xerath입니다
(2초)
Xerath입니다
(2초)
안녕하세요
(1초)
안녕하세요
(1초)
안녕하세요
(1초)

 

다만, 아주 가끔은 첫번째 블록이 메인 큐에 갔다가 메인 쓰레드에 먼저 쇼로로록 배정되어서

먼저 수행되고 그후에 두번째 블록이 수행될 수도 있습니다!

(Like 줄서있다가 잠깐 밥먹고 다시 바로와서 자리 차지하는 양아치...😩😩)

 

global().sync

이 친구는 일단 concurrent Queue에 작업이 들어갈 거에요.

다만 sync로 되어있으니 Dispatch.global().sync를 실행한 쓰레드는 block된 채 기다립니다.

즉, 다른 곳에 작업을 보내놓고 돌아오기를 기다리는 거죠.

아무것도 안하면서요...!!

Like 망부석~~~

 

그럼 코드로 봐볼까요?

import Foundation

DispatchQueue.global().sync {
    for _ in 1...3 {
        print("안녕하세요")
        sleep(1)
    }
}

DispatchQueue.global().sync {
    for _ in 1...3 {
        print("Xerath입니다")
        sleep(2)
    }
}

for _ in 1...3 {
    print("인사드립니다")
    sleep(1)
}

 

이러면 global 쓰레드1에서 첫번째 블록이 수행이 되고,

global 쓰레드2에서 두번째 블록이 수행이 되고,

마지막으로 메인 쓰레드에서 마지막 3번째 블록이 수행됩니다.

 

결과는 다음과 같아요.

안녕하세요
(1초)
안녕하세요
(1초)
안녕하세요
(1초)
Xerath입니다
(2초)
Xerath입니다
(2초)
Xerath입니다
(2초)
인사드립니다
(1초)
인사드립니다
(1초)
인사드립니다
(1초)

 

즉, 서로 다른 큐에 보내가는 하지만,

 

"안녕하세요" 블록을 sync로 해뒀기에 이 작업이 끝나야 다음으로 진행이 가능합니다.

그래서 다른 global 쓰레드에 가서 이 작업을 수행하고 마친 후, 돌아와서야 겨우 "Xerath입니다" 블록을 수행할 수 있습니다.

 

"Xerath입니다" 블록도 결국은 global().sync이기에 다른 global 쓰레드에 가서 작업을 수행하고 돌아와서야

마지막 "인사드립니다" 블록을 수행할 수 있습니다.

 

아니 그러면...어차피 딴 데 보내고서 기다려야 하는거면 이거 왜씀???

 

맞아요...! 결국 다른 곳에서의 작업을 기다리는 거면 효율성이 떨어질 텐데 왜 쓰나 싶을 수 있습니다.

 

하지만 "동기화 작업"이 필요한 상황에는 필요로 할 겁니다.

우리가 어떤 작업을 마치고 데이터를 동기화 시켜서 보여주고 싶을 때가 있습니다.

마치 데이터가 업데이트가 된 후 그 내용을 화면에 보여줘야 하는 경우처럼요!

 

그럴 때 이 global().sync로 감싸서 수행하면 안에 감싸진 아이가 다 끝날 때까지 보장이 됩니다!

 

이제 대망의 마지막으로 가보시죠!

 

global().async

이 친구는 정말 많이 쓰이죠...!

다른 global 쓰레드에 보내두고서 안 기다리는 거에요.

그러다보니 이 친구는 언제 실행될지 예상을 못하죠!!!!

 

(우와...기약없는 놈이구나~~~)

 

할 일을 다른 쓰레드에 던져두고 언제 돌아올지 모르지만 하겠지 뭐~~~~~~~

이런 녀석입니다.

 

그래서 이 친구가 정말 쓰임이 많은데...

 

1. 시간이 오래 걸리는 작업(Network 요청, 파일 I/O, DB 접근 등)을 수행할 때

DispatchQueue.global().async {
    let data = fetchData()
    DispatchQueue.main.async {
        updateUI(with: data)
    }
}

 

이런 식으로 받아오면 알아서 main에 반영하는 방식으로 할 수도 있구요.

 

2. CPU를 집약적으로 많이 사용해야 하는 작업(이미지 처리, data 압축/해제, 암호화/복호화 등)을 수행할 때

DispatchQueue.global().async {
    let processedImage = processImage(image)
    DispatchQueue.main.async {
        imageView.image = processedImage
    }
}

 

위 코드처럼 어떤 image 처리를 하고 그걸 반영할 때 쓸 수도 있습니다.

 

3. 긴 작업 처리를 통한 앱의 응답성 높이기

func buttonTapped() {
    DispatchQueue.global().async {
        let result = performLongRunningTask()
        DispatchQueue.main.async {
            showResult(result)
        }
    }
}

 

 

이건 2번을 포함한 얘길 수도 있지만 긴 작업들에 대한 처리가 완료된 뒤 이뤄지도록 구현하고 싶다면 위처럼 사용할 수도 있습니다.

 

4. 여러 병렬적인 작업들이 모두 처리되고 적용되어야 할 때

DispatchQueue.global().async {
    let group = DispatchGroup()
    
    var results = [ResultType]()
    
    for task in tasks {
        group.enter()
        DispatchQueue.global().async {
            let result = performTask(task)
            results.append(result)
            group.leave()
        }
    }
    
    group.notify(queue: .main) {
        useResults(results)
    }
}

 

 

이런 식으로 구현한다면 여러 작업들이 수행되어야 하고,

group에 들어간 모든 작업들이 완료된 뒤에서야 notify를 통해 결과값들을 가지고 작업을 진행할 수 있습니다.

 

위에 경우들처럼 작업이 수행되고 끝나면

이 global 쓰레드들은 메모리에서 자동으로 제거가 됩니다.(메인 쓰레드와는 달리 이후엔 안 쓸 거니까~!!)

 

 

이렇게 다양한 상황들을 가지고 예시를 들었는데

사실 이게 정답은 아닙니다!

단지 이런 경우들이 있고, 이건 상황에 따라 우리가 어떤 걸 쓸지 참고할 예시 정도로 생각하면 좋을 것 같습니다 ㅎㅎㅎ

 

 

커스텀한 DispatchQueue 파라미터 뜯어보기

일단 앞서 적어둔 것처럼

우리는 DispatchQueue를 main, global()처럼 이미 만들어진 걸 사용해서 생성할 수도 있고,

직접 커스텀해서 만들 수도 있습니다!

 

일단 DispatchQueue의 초기화 형태를 공식문서에 적힌대로 보면

convenience init(
    label: String,
    qos: DispatchQoS = .unspecified,
    attributes: DispatchQueue.Attributes = [],
    autoreleaseFrequency: DispatchQueue.AutoreleaseFrequency = .inherit,
    target: DispatchQueue? = nil
)

 

요렇게 생겼습니다.

 

그럼 각 파라미터들이 무슨 의미를 갖는지 살펴보겠습니다!

 

label

얘는 간단히 말하면 해당 DispatchQueue의 id와 같은 거에요!!

응~DispatchQueue야 너의 이름은 요거란다~~ 하는 너낌임다 ㅎㅎ

 

qos

이건 사실 전공 시간에 많이 들어본... 특히 OS에서...! 고런 개념인데요

Quality of Service의 약자로 해당 DispatchQueue의 우선순위를 의미하는 놈입니다.

그 종류는 무지 다양한데 

참고: https://yagom.net/courses/%eb%8f%99%ec%8b%9c%ec%84%b1-%ed%94%84%eb%a1%9c%ea%b7%b8%eb%9e%98%eb%b0%8d-concurrency-programming/lessons/gcd-%ec%8b%ac%ed%99%94/topic/qos/

이건 솔직히 저는 외울 자신이...ㅠㅠ

그래도 이런게 있구나 정도로만 하고 나중에 쓸 때 참고를 해보려구요 ㅎㅎㅎ

 

attributes

요 친구는 해당 DispatchQueue가 어떤 속성인지 정해주는 애에요.

종류로는

.serial : 직렬성 queue

.concurrent: 동시성 queue

.initiallyInactive: 큐가 생성될 때 자동으로 실행되지 않고, 명시적으로 활성화될 때까지 대기 상태에 있도록 해주는 속성

 

이거 사용 예시를 보시죠!

// 직렬 Queue(아무것도 안써주면 default로 .serial이 적용됨)
let serialQueue = DispatchQueue(label: "com.example.serialQueue")

-------------------------

// 동시성 Queue
let concurrentQueue = DispatchQueue(label: "com.example.concurrentQueue", attributes: .concurrent)

-------------------------

// 직렬인데 작업 초기에 비활성화된 Queue
let initiallyInactive_SerialQueue = DispatchQueue(label: "com.example.serialQueue", attributes: .initiallyInactive)

-------------------------

// 동시성인데 작업 초기에 비활성화된 Queue
let initiallyInactive_ConcurrentQueue = DispatchQueue(label: "com.example.concurrentQueue", attributes: [.concurrent, .initiallyInactive])

// 작업 배정
initiallyInactive_ConcurrentQueue.async {
    print("잇츠오케이 괜차나 띵딩딩딩딩")
}

// 큐 활성화 - 이때서야 작업이 진행됨.
initiallyInactive_ConcurrentQueue.activate()

 

위 코드를 통해서 왜 이 attributes가 배열 형태인지 아시겠죠??ㅎㅎ

 

autoreleaseFrequency

DispatchQueue가 객체를 자동으로 해제하는 빈도를 의미하는데...

솔직히 이건 사용법이 몸소 와닿진 않는 ㅠㅠ

이건 좀 더 사용해보면서 알아가야겠습니다 ㅎㅎㅎ (사실 잘 안 쓰지 않나 싶기도...합니다요...)

 

target

코드 블록을 실행할 큐를 이걸로 설정할 수 있는데,

이건 코드로 한번 보시죠!

import Foundation

// 동시성 queue A
let a = DispatchQueue(label: "A", attributes: .concurrent)

// 동시성 queue A를 타겟팅하고 있는 직렬성 queue B
let b = DispatchQueue(label: "B", target: a)

a.async {
    print("1")
}
a.async {
    print("2")
}

b.async {
    print("3")
}
b.async {
    print("4")
}

 

이때, b는 serial하잖아요?(attributes를 명시하지 않았기 때문!)

그럼 순서대로 진행해야 하니까...!

반드시 3보다 4가 뒤에 나와야 합니다.

 

반면 이런 직렬성 queue가 동시성 queue에서 1,2 이런 애들과 경쟁을 하면서 실행되는데

1,2는 언제 출력될 지 모른단 말이죠??!!

그렇기에 1,2와 관계 없이 '3보다 4가 뒤!'라는 순서만 지키면 됩니다.

그래서 결과값은

1,2,3,4 / 3,2,1,4 / 3,4,1,2 / 1,3,2,4 ... 등등 많은 경우가 되겠죠...??👍🏻😃👍🏻

 

근데 사실 이게 중요한게 아니고 ㅋㅋㅋㅋㅋㅋㅋ

B라는 직렬성 queue가 A라는 동시성 queue를 타겟팅하고 있다.

즉, A 위에서 B가 돌아간다는 겁니다!

 

DispatchGroup

얘는 뭐냐?? 비동기적으로 처리되는 작업들을 그룹으로 묶어, 그룹 단위로 작업 상태를 추적할 수 있는 기능입니다.

이건 아까 앞서 global().async에서 여러 병렬적인 작업들이 모두 처리되고 적용되어야 할 때 쓰는 아이라고 했습니다.

 

어떤 비동기적인 작업들이 언제 끝날지 모르잖아요??

그런데 언제 끝날지 모르는 얘네들이 다 끝나게 되면,

그때 모아서 최종 처리 작업을 해야할 때!

그때 이 DispatchGroup을 사용하면 됩니다.

 

예시도 다음과 같이 보면

import Foundation

let a = DispatchWorkItem {
    for _ in 1...3 {
        print("A")
        sleep(1)
    }
}

let b = DispatchWorkItem {
    for _ in 1...3 {
        print("B")
        sleep(1)
    }
}

let c = DispatchWorkItem {
    for _ in 1...3 {
        print("C")
        sleep(1)
    }
}

let group = DispatchGroup()

// --------------------실행방법----------------------

DispatchQueue.global().async(group: group, execute: a)
DispatchQueue.global().async(group: group, execute: b)

// 또는

group.enter()
DispatchQueue.global().async(execute: a)
DispatchQueue.global().async(execute: b)
group.leave()


// ------------------------------------------
// notify에는 상단의 DispatchGroup 작업들이 모두 끝나는 시점에 동작을 할 것을 구현.
group.notify(queue: .main) {
    print("모든 작업 완료")
}

// 또는

// wait는 notify와는 다르게 오직 기다리는 작업만을 함. 동작 구현 X
group.wait()
print("모든 작업 완료")

// group.wait(timeout: 10)
// print("10초 내로 완료가 되든 안되든 출력되기 시작하겠습니다.")

// ------------------------------------------

 

이런 식으로 작성하면 됩니다!

 

 

Race Condition이 뭔데요??

정말 길어지는데... 거의 다 왔습니다!

 

이번엔 Race Condition이에요 ㅎㅎ

사실 이 친구도 학교 수업 때 많이 들은 개념인데...

 

마치 하나의 깃발을 두고 다같이 달려가서 그걸 낚아채는 미션을 하는 걸 떠올려 봅시다!

물건은 하나인데 누구는 깃을 잡고, 누구는 막대를 잡고, 누구는 봉을 잡고...

그러고선 자기가 깃발을 잡았다고 하는 겁니다.

 

근데!!

우리는 딱 한명만 이걸 가져가도록 해야하거든요?

그럴 땐 어떻게 해야 할까요?

 

예를 들면 어떤 배열이 1~10까지 있는데, 하나씩 값을 뺀다고 생각해봅시다.

이때 다같이 하나에 접근하지만 가장 먼저 누가 배열에서 그 값을 빼면,

나머지들은 빼려고 할 때 에러가 날 거에요.

 

배열 : '그 값 없는디???'

 

난감한 상황이 되는거죠.

이런 걸 Race Condition이라고 하는데 '스레드가 여러 개일 때 어떤 코드가 동시에 실행되어서 하나의 값에 동시에 접근하는 경우가 발생'하는 것을 말합니다.

 

우리는 이렇게 여러 스레드에서 동시에 접근이 가능한 것을 막아야 하는데, 불가능하도록 해둔 것들을 Thread Safe하다 라고 합니다.

 

그럼 Thread Safe하도록 만들어봅시다.

방법 1. DispatchSemaphore로 쓰레드 개수 조절하기

DispatchSemaphore라는 애를 통해서 조절이 가능합니다.

 

얘도 맨날 전공 시간에 많이 들어본 아이죠?ㅋㅋㅋㅋㅋㅋㅋ

DispatchSemaphore는 공유 자원에 접근 가능한 쓰레드의 수를 정해두어서 제한을 거는 방식입니다.

이때 우리가 그 수를 1로 둔다면 딱 지금 Race Condition 해결이 가능하죠!

 

코드를 봅시다!

import Foundation

var cards = [1, 2, 3, 4, 5, 6, 7, 8, 9]
let semaphore = DispatchSemaphore(value: 1) // count를 1로 설정(count: 1)

DispatchQueue.global().async {
    for _ in 1...3 {
        semaphore.wait() // count -= 1(count: 0)
        let card = cards.removeFirst()
        print("야곰: \(card) 카드를 뽑았습니다!")
        semaphore.signal() // count += 1(count: 1)
    }
}

DispatchQueue.global().async {
    for _ in 1...3 {
        semaphore.wait() // count -= 1(count: 0)
        let card = cards.removeFirst()
        print("노루: \(card) 카드를 뽑았습니다!")
        semaphore.signal() // count += 1(count: 1)
    }
}

DispatchQueue.global().async {
    for _ in 1...3 {
        semaphore.wait() // count -= 1(count: 0)
        let card = cards.removeFirst()
        print("오동나무: \(card) 카드를 뽑았습니다!")
        semaphore.signal() // count += 1(count: 1)
    }
}

 

이렇게 출입 가능한 수(value)를 정해두고 입장 시(wait) -1, 퇴장 시(signal) +1 해줌으로써 접근 가능한 숫자를 제어합니다!

 

방법 2. Serial Queue를 활용하기

이건 무지 간단한데...!!

우리가 위 예시에선 global()로 각각각 보내서 동시성 프로그래밍을 하니까 순서가 정해지지 않는 이슈가 발생하는 거였잖아요??

이걸 순서를 정해버리는 겁니다!

 

import Foundation

var cards = [1, 2, 3, 4, 5, 6, 7, 8, 9]
let pickCardsSerialQueue = DispatchQueue(label: "PickCardsQueue")

DispatchQueue.global().async {
    for _ in 1...3 {
        pickCardsSerialQueue.sync {
            let card = cards.removeFirst()
            print("야곰: \(card) 카드를 뽑았습니다!")
        }
    }
}

DispatchQueue.global().async {
    for _ in 1...3 {
        pickCardsSerialQueue.sync {
            let card = cards.removeFirst()
            print("노루: \(card) 카드를 뽑았습니다!")
        }
    }
}

DispatchQueue.global().async {
    for _ in 1...3 {
        pickCardsSerialQueue.sync {
            let card = cards.removeFirst()
            print("오동나무: \(card) 카드를 뽑았습니다!")
        }
    }
}

 

이런 식으로 custom SerialQueue를 만들어서 sync로 돌려버리니까 작업들이 순서가 매겨지는 겁니다.

마무리

으아아아아앙아아아...!!

이거 정리하는 데에 꽤 긴 포스팅이 되어버렸네요 ㅋㅋㅋㅋㅋㅋㅋ

종종 추가되거나 수정할 때마다 분리를 해보도록 시도해야겠네요...!

 

일단, DispatchQueue는 사실상 너무너무너무 중요하기에 이렇게 길게 썼는데,

저는

1. 언제 main vs global()를 쓸지
2. 언제 동기/비동기를 쓰는게 좋을지? 적절할지? 해야만 할지?

 

이 두가지를 잘 알아두어야 네트워킹 코드를 쓸 때, 다룰 때 매번 중요한 부분으로 느껴진다고 생각합니다ㅎㅎㅎ

(늘 다시 한번씩 봐야 기억이 나거든요...!!)

이런 동시성 프로그래밍은 늘 중요한 것 같습니다!

 

그럼 다음에는 OperationQueue를 들고 돌아오겠습니다!

긴 포스팅 읽어주셔서 감사합니다~!!

728x90
반응형