Grand Central Dispatch 101

devapploper·2024년 10월 23일
post-thumbnail

GCD는 애플에서 제공하는 멀티스레드 프로그래밍 프레임워크다. 멀티스레드 프로그래밍이란 여러 개의 스레드를 활용하여 프로그램을 짜는 것을 말한다. 따라서 GCD는 여러 개의 스레드를 활용하여 프로그램을 만들 수 있게 해주는 도구라고 할 수 있다.

누군가는 내가 그랬던 것처럼 멀티스레드 프로그래밍을 왜 해야하는지 궁금할 수 있다. 내 경우에는 굳이 멀티스레딩을 하지 않아도 앱을 만드는데에 전혀 문제가 없다고 느꼈었다. 그치만 멀티스레딩은 앱이 효율적으로 자원을 사용할 수 있게 하고 앱을 빠르게 돌아갈 수 있게 할 수 있기 때문에 고급진 처리를 하기 위한 앱개발자의 필수 덕목이라고 할 수 있다. 지금부터 하나씩 GCD에 대해 알아보도록 하자.

GCD

GCD는 여러 개의 스레드를 가지고 있다. 이는 스레드 풀(thread pool)이라고 한다. 그리고 GCD는 이 스레드들을 관리하는 역할도 한다. 필요에 따라 스레드를 생성하거나 제거하기도 한다는 얘기다. GCD는 스레드를 가지고 있다가 프로그래머가 작업을 생성해서 넘겨주면 스레드에 작업을 배분하는 역할을 한다. 덕분에 우리 프로그래머들은 스레드 관리를 일일이 하지 않아도 된다. 이런 면에서 GCD는 스레드 관리를 추상화한 것이라 볼 수 있다.

DispatchQueue

GCD를 이해하기 위해서는 디스패치큐가 무엇이고 어떻게 동작하는지 손바닥처럼 아는 것이 중요하다. 디스패치큐는 작업들이 실행되기 전에 보관되는 공간이다. 이름에서 볼 수 있듯이 Queue 자료구조와 동일하게 선입선출 (FIFO)을 따른다. 디스패치큐에 들어가고 차례가 되어 나온 작업은 GCD가 골라준 스레드에 할당되어 작업이 실행된다.

여기까지가 GCD를 이용해서 작업을 실행하는 전반적인 흐름이고, 이후부터는 좀 더 자세한 내용이 이어질 예정이다.

Serial Queue & Concurrent Queue

디스패치큐는 직렬큐(Serial Queue)와 병렬큐(Concurrent Queue) 두가지로 나뉜다. 이 속성에 따라 작업이 큐에 담긴 순차적으로 실행될지, 랜덤한 순서로 실행될지 정해진다. 이름에서도 알 수 있듯이 직렬큐에 담긴 작업은 담긴 순서대로 작업이 실행된다. 병렬큐는 담긴 순서와는 상관 없이 작업이 병렬적으로 실행된다.

직렬큐에서 나온 작업은 스레드에 어떤식으로 할당될까? 직렬큐에 담긴 여러 작업들은 같은 하나의 스레드에서 실행될 수도 있고, 실행되는 스레드가 작업 간에 바뀔 수도 있다. 따라서 직렬큐에서 나온 작업이 항상 같은 스레드에서 실행된다는 보장은 없다.

그렇다면 병렬큐에서 나온 작업 어떨까? 병렬큐에 담긴 작업은 여러 개의 스레드에 병렬적으로 할당된다. 이때 GCD는 10개의 작업에 대해 10개의 스레드를 할당할 수도 있고, 5개의 스레드를 할당해서 재사용할 수도 있다. 병렬큐에 작업을 맡겼을 때 몇 개의 스레드가 생성될지는 GCD가 정하는 부분이기 때문에 알 수 없다.

여기까지가 디스패치큐의 두가지 형태에 대한 내용이고 이어지는 섹션에서는 디스패치큐의 종류에 대해 알아보자,

Main Queue & Global Queue & Custom Queue

let mainQueue = DispatchQueue.main
let globalQueue = DispatchQueue.global(qos: .default)
let customQueue = DispatchQueue(label: "private 1", qos: .unspecified, attributes: [.concurrent])

디스패치큐의 종류에는 메인큐, 글로벌큐, 커스텀큐, 총 세가지가 있다. 여기서 메인큐는 직렬큐고, 글로벌큐는 병렬큐다. 커스텀큐는 직렬큐와 병렬큐 둘다 될 수 있다.

메인큐는 시스템에서 제공하는 전역 큐이며, 직렬큐 속성을 가진다. 메인큐는 특별한 큐여서 여기에 담긴 작업은 모두 메인 스레드에서 실행시킨다. 메인큐에서 나온 작업이 메인 스레드가 아닌 스레드에서 실행될 일이 없다고 볼 수 있다.

글로벌큐는 시스템에서 제공하는 전역 큐이며, 병렬큐 속성을 가진다. 병렬큐이기 때문에 각 작업은 GCD가 할당한 스레드에 실행된다. 글로벌큐의 또다른 특징은 실행 우선순위를 정할 수 있다는 점이다. 인자로 qos (quality of service의 약자) 를 받는데 이 부분을 이용해서 해당 큐에 담긴 작업들이 어떤 우선순위로 실행될지 정할 수 있다.

커스텀큐는 프라이빗큐(private queue) 라고도 불리며 사용자가 GCD에 요청하여 생성하는 큐다. 직렬큐와 병렬큐 중 골라 생성할 수 있는데 attributes 파라미터에 빈값을 내려주면 직렬큐로 생성되고, [.concurrent]를 내려주면 병렬큐로 생성된다. 그리고 글로벌큐와 마찬가지로 커스텀큐도 QoS를 설정할 수 있다.

디스패치큐의 속성과 종류에 대해 알아보았다. 조금만 더 힘을 내서 다음 내용인 sync 방식과 async 방식에 대해 알아보자.

Sync & Async

디스패치큐는 작업을 보관하는 공간에 대한 내용이었다면 async 와 sync는 작업의 실행과 관련된 내용이다. 좀 더 구체적으로 말하자면 해당 작업이 완료될 때까지 기다리고 다음 코드를 실행할지, 아니면 기다리지 않고 바로 다음 코드를 실행할지에 대한 얘기이다.

sync는 작업이 완료될 때까지 호출 스레드의 실행을 중단했다가, GCD가 작업을 맡긴 다른 스레드에서 작업이 완료되면 호출 스레드의 실행을 재개하는 작업 방식이다. 이와 반대로 async는 호출 스레드의 실행을 중단하지 않는 작업 방식이다. 여기서 호출 스레드는 디스패치큐에 작업을 맡기는 스레드를 말한다.

이해를 돕기 위해 우선 sync 방식부터 상황을 가정해보자. 코드는 현재 스레드A에서 동작하고 있다. sync 방식의 작업을 디스패치큐에 넣는다. 호출 스레드인 스레드A는 해당 작업이 완료될 때까지 실행을 중단한다. sync 작업은 GCD를 통해 스레드B가 할당되어 실행된다. 실행을 마친 후에는 스레드 A의 실행이 재개된다.

헷갈린다면 코드 예시도 함께 보면 좋다. 플레이그라운드에서 직접 실행해보고 코드를 수정해보면 이해에 도움된다.

// 스레드 A에서 실행 중 🔄
print("sync 작업 실행 전!")

DispatchQueue.global().sync {
    print("글로벌 큐에서 맡긴 작업 실행 중...")
    Thread.sleep(forTimeInterval: 2)
    print("글로벌 큐에서 맡긴 작업 실행 완료!")
}

print("sync 작업 완료 후 실행 재개!")

코드를 실행해보면 맨 마지막 줄은 sync 작업이 완료된 후에 실행되는 것을 볼 수 있다. 스레드 정보가 보이진 않지만, 스레드 A에서 실행하고 있다가 sync 작업을 맡기면서 실행이 중단되고, 완료되었을 때 실행을 재개해서 맨 아랫줄이 실행된다고 이해했다면 충분하다.

async 방식은 호출 스레드의 실행을 막지 않는다고 위에서 이야기했었다. 이해를 돕기 위해 아래 코드의 예시와 함께 보도록하자.

// 스레드 A에서 실행 중 🔄
print("async 작업 실행 전!")

DispatchQueue.global().async {
    print("글로벌 큐에서 맡긴 작업 실행 중...")
    Thread.sleep(forTimeInterval: 2)
    print("글로벌 큐에서 맡긴 작업 실행 완료!")
}

print("async 작업 완료 후 실행 재개!")

어떤 순서로 출력문이 나타날까? 코드를 실행하기 전에 한번 생각해보자. 스레드 A에서 실행하다가 async 작업을 디스패치큐에 맡긴다. 이때 작업이 완료될 때까지 기다리지 않기 때문에 async 코드 블락내 내용은 바로 실행되지 않는다. 그리고 실행을 이어가면서 마지막 줄의 출력문을 실행한다. 디스패치큐에서 나온 작업은 GCD가 할당한 스레드에서 실행된다.

사실 이 부분에서 중요한 것은 순서가 아니다. 순서는 상황과 환경에 따라 async 코드 블락 안의 내용이 먼저 실행될 수도 있다. 중요한 것은 스레드A의 실행이 중단되지 않고 이어진다는 내용이다. 일반적으로 async 코드 블락 안의 내용이 나중에 실행된다고 할 수는 있지만 100%는 아니다.

정리하자면, 디스패치큐에 작업을 맡긴 스레드가 자신이 맡긴 작업이 완료될 때까지 기다리는 것이 sync이고, 맡긴 작업이 완료 될 때까지 기다리지 않고 실행을 이어나가는 것이 async라고 할 수 있다.

여기까지 읽었다면 디스패치큐의 핵심적인 내용에 대해서 알아봤다고 해도 좋다.

마무리로 GCD의 몇가지 예외와 특징에 대해 알아보자.

sync와 최적화

아래 코드에서 A 출력문과 B 출력문에서 스레드는 다를까, 같을까?

// 메인 스레드에서 실행 중 🔄
print("A - 실행 스레드는 \(Thread.current)")

DispatchQueue.global().sync {
	print("B - 실행 스레드는 \(Thread.current)")
}

우선 비교적 직관적인 A 출력문부터 보자. 스레드를 변경한게 아닌 이상 메인 스레드가 나타날 것이라 짐작할 수 있다. 다음으로 B 출력문을 보자. 위에서 얘기한 대로라면 디스패치큐에 맡긴 작업은 GCD가 스레드풀에서 스레드를 골라 할당한다고 했다. 이 내용에 따르면 어떤 스레드인지는 몰라도 GCD가 관리하는 어떤 스레드이긴 할 것이라는 것을 짐작할 수 있다.

근데 실행해보면 두 출력문에서 동일한 스레드인 메인 스레드가 출력되는 것을 볼 수 있다. 이게 도대체 어떻게 된 일일까?

Documentation에 따르면, 성능 최적화를 위해 GCD는 sync 작업을 가능한 경우에는 호출 스레드에서 실행한다고 이야기하고 있다. 그러니까, GCD의 스레드풀에서 고른 스레드가 아닌, 작업을 맡긴 호출 스레드에서 실행한다는 이야기이다.

이에 대한 예외사항으로는 메인큐가 있다. 메인큐에 작업을 맡기면 무조건 메인스레드에서 실행되기 때문에, 메인 스레드가 아닌 어떤 다른 스레드에서 sync 작업을 메인큐에 맡기면 호출 스레드에서 실행하지 않는다.

그러니 이제 디스패치큐에서 메인큐를 제외하고는 sync를 사용하면 호출한 스레드와 동일한 스레드에서 실행된다는 것을 알았으니 같은 스레드에서 실행되도 놀라지 말자.

sync와 교착 상태 (deadlock)

흥미롭게도 sync에 대해 더 알아두면 좋을 내용이 더 남아있다. sync는 특정 조건에서 교착 상태라는 문제를 발생시킨다. 이에 대해 자세히 알아보자.

코드는 메인스레드에서 실행 중이고, 메인큐에 sync로 작업을 맡기고 있는 상황이라고 가정해보자. 메인큐에 sync 작업이 들어간다. sync 방식의 작업이기 때문에 호출 스레드인 메인 스레드는 작업 완료까지 실행을 중단한다. 위에서 메인큐에 담긴 작업은 모두 메인스레드에서 실행시킨다고 얘기했다. 그렇기 때문에 메인큐의 sync 작업은 메인스레드에서 실행시키려할 것이다. 그런데 메인스레드는 실행이 중단된 상태다. 이렇게 메인스레드는 sync 작업이 완료될 때까지 기다리고 있고, 메인큐의 sync 작업은 메인 스레드를 기다리고 있는 상황이 된다. 이렇게 두가지 이상의 요소가 원형으로 무한정 기다리게 되는 상황을 교착 상태라고 한다.

이러한 문제점으로 인해 DispatchQueue.main.sync { ... } 를 실행하면 바로 크래시가 일어나는 것을 볼 수 있다. 이 문제 자체가 크래시를 발생시키는 것은 아니고 GCD가 이러한 문제를 감지해서 크래시를 발생시킨다고 한다. 또한 메인큐에서 sync 작업을 실행한다고 해서 무조건적으로 교착 상태가 발생해 크래시가 일어나는 것은 아니다. 작업을 호출한 스레드와 작업을 실행할 스레드가 동일하지만 않으면 교착 상태는 발생하지 않기 때문에, 호출 스레드가 메인 스레드가 아니면 메인큐에 sync 작업을 넣어도 크래시가 일어나지 않는다. 따라서 디스패치큐 Documentation의 Important 섹션의 내용에 나와있듯이 메인큐에서 sync 방식으로 작업을 실행하면 교착 상태가 발생한다는 내용은 메인 스레드에서 호출했을 경우에만 해당한다는 것을 알 수 있다.

이 문제는 메인큐가 아닌 직렬타입의 커스텀큐에서도 재현할 수 있다. 이 문제는 디스패치큐를 호출하는 스레드가 직접 작업을 실행하는 스레드이면서 sync 작업일 때 발생한다.

커스텀큐의 경우도 코드 레벨에서 확인해보자.

let privateQueue = DispatchQueue(label: "private")

privateQueue.async {
	// Task 1
	privateQueue.sync {
    	// Task 2
    	print("hello")
    }
} 

위 코드를 보면 우선 커스텀큐(privateQueue)를 생성한다. 그리고 async 방식으로 작업을 실행한다. async 작업의 실행 내용을 보면 동일한 커스텀큐에 sync 방식으로 작업을 전달한다. 다음으로 코드 너머의 동작을 함께 들여다보자.

커스텀큐에 대한 설명에서 커스텀큐에 담긴 작업은 사용자의 요청에 의해 GCD가 생성한 스레드에서 실행된다고 얘기했었다. 이해를 돕기 위해 위 코드에서 GCD가 생성한 스레드를 스레드 C라고 명명해보자. Task1이 커스텀큐에서 나와 스레드 C에 맡겨져 실행된다. 실행하면서 Task2 작업이 동일한 커스텀큐에 맡겨진다. Task2는 Task1이 완료될 때까지 작업 순서를 기다리는데 Task1은 Task2를 완료해야만 완료가 된다. 이렇게 커스텀큐도 교착 상태에 진입하게 된다. 정리하면 직렬큐에 맡긴 작업은 순차적으로 실행되기 때문에 Task2가 실행되기 위해서는 먼저 들어간 Task1이 완료되기까지 기다려야한다. 그러나 Task1은 내부에서 Task2를 실행하는데 Task2는 Task1이 현재 실행 중인 스레드 사용을 기다린다.

그렇다면 병렬큐에서는 어떨까? 병렬큐는 직렬큐와 다르게 큐에 여러개의 먼저 맡긴 작업이 완료 되지 않아도 다음 작업을 실행할 수 있는 성질이 있다. 따라서 병렬큐에 맡긴 작업은 이전 작업의 실행에 의존하지 않기 때문에 교착 상태가 발생하지 않는다. 실제로 병렬큐를 사용하는 글로벌큐에서 위와 같은 맥락의 코드를 실행해보면 교착상태가 발생하지 않는 것을 확인할 수 있다. print문에서 같은 스레드 넘버가 나타나는 sync 최적화가 일어나기 때문이다.

let concurrentQueue = DispatchQueue.global()

concurrentQueue.async {
    print("\(Thread.current)")
    concurrentQueue.sync {
        print("\(Thread.current)")
    }
}

GCD가 한번에 관리하는 백그라운드 스레드의 갯수는 최대 몇개일까?

글로벌큐에 작업을 맡기면 GCD가 스레드를 할당해준다. 이때 스레드는 기존에 스레드풀에 존재하는 스레드이거나 새로 GCD 생성한 스레드이다. 그런데 몇개까지 GCD가 스레드를 생성하고 관리하는지 궁금해졌다. 그래서 실험을 해보았다. 1부터 100까지 순회하는 for 루프를 만들고 그 안에서는 글로벌큐에 async 방식으로 작업을 실행하도록 한다. 그 작업 내에서는 현재 스레드를 10분 동안 sleep 시키는 것이다. 이렇게 스레드를 잠들게 하면 GCD에 의해 재사용되거나 해제되지 않을 것이라 생각했다.

우선 위 내용을 코드 옮기면 다음과 같다:

for i in 1...100 {
	DispatchQueue.global().async {
	    print("(\(i)) thread: \(Thread.current)")
    	Thread.sleep(forTimeInterval: 600)
	}
}

결과부터 얘기하자면 3번부터 66번 스레드가 나타났다. 글로벌큐에 많은 작업을 적재했을 때 GCD는 총 64개의 스레드를 생성할 수 있었다. 고로 GCD의 스레드풀의 크기는 64라고 할 수 있다. 그럼 나머지 1번과 2번 스레드는 무엇일까? 우선 앱의 1번 스레드는 항상 메인 스레드다. 그리고 2번 스레드는 stackoverflow에서 찾은 정보에 의하면 non-GCD 스레드라고 한다. 이 2번 스레드에 대해서는 정보가 많이 없다.

Thread.sleep()을 이용하지 않으면 순회 크기에 따라 스레드 넘버 더 증가하는 것을 볼 수 있는데, 이는 스레드가 소멸되고 난 후에 생성되면서 숫자가 증가하는 것이지 스레드 갯수 자체가 늘어나는 것은 아닌 것으로 보인다.


오늘로써 GCD에 대해 한발짝 더 다가선 느낌이다. GCD에 대한 내용은 당장 검색만 해도 정말 많다. 정보가 많은 만큼 양질의 정보는 점점 찾기 어려워지는 시대다. 이 글이 누군가에게는 양질의 정보가 되길 바라면서 글을 마친다.

References

  1. Developer Documentation - DispatchQueue
  2. 여기어때 기술블로그
  3. (Stack Overflow) Why calling dispatch_sync in current queue not cause deadlock
profile
iOS, 알고리즘, 컴퓨터공학에 관련 포스트를 정리해봅니다

0개의 댓글