1. Grand Central Dispatch

김가영·2022년 8월 3일
0

concurrency

목록 보기
1/4
post-thumbnail

concurrency는 여러 작업들이 동시에 일어나는 것을 이야기한다. asynchronous function은 concurrency를 구현하기 위한 방법 중 하나이다. 오래 걸리는 작업들을 함수에 넣어두고, 함수를 호출하면 함수의 동작은 다른 스레드에서 진행시킨다. 그리고 함수 밖에서는 함수의 동작과 상관없이 그 이후의 작업들을 계속해서 진행한다.

이전까지는 이러한 asynchronous function과 그 내부에서 이용할 스레드를 응용프로그래머가 직접 생성해서 관리해줘야했다. 당연히 귀찮고, 복잡하고 위험하다.

그래서 Apple은 GCD를 만들어줬다. → 응용 프로그래머가 원하는 task를 dispatch queue에 넣기만 하면, GCD는 thread를 생성하고, task를 thread에 적절하게 할당해주는 것까지 담당해준다.

💡 여기에서 task는 단순히 하고 싶은 작업(work)으로 생각하면 된다.

GCD와 굉장히 비슷한 operations도 존재한다. operation은 GCD에 의존성 추가 / 작동하고 있는 것 취소 등의 추가적인 기능을 가지고 있다. 해당 포스트에서는 크게 다루지는 못할 것 같다.

Dispatch Queues

GCD에 다시 한 번 간단하게 정리를 좀 해보자면,

  • 사용자(응용 프로그래머)는 원하는 작업을 dispatch queue에 넣어주고
  • 그 이후의 작업들(thread 생성과 task에 thread 할당)은 GCD가 모두 담당해준다.

어떻게 이용할 지에 대해 알아보기 전에, dispatch queue는 무엇일까? dispatch queue는 c를 기반으로 만들어진 FIFO queue이다. dispatch queue는 serial 또는 concurrent 타입을 갖게 된다.

  1. serial queue

serial queue는 한 번에 하나의 작업을 진행한다. 하나의 thread만을 가지기 때문에 queue에 삽입된 순서대로 시작하고, 끝나는 것을 보장해준다. serial queue는 개수와 상관 없이 여러개를 생성할 수 있다.

기억할 점은,

  • queue 내에서는 항상 serial하게(순서대로) task가 진행된다. 즉, 같은 queue 안에 있는 task 끼리는 차례대로 실행된다. 하지만 serial queue와 다른 serial queue들은 concurrent하게 작동한다. 즉, serial queue 하나가 실행되는 동안 다른 serial queue들도 동작하고 있을 수 있다.
  1. concurrent queue

concurrent queue는 여러개의 스레드를 가질 수 있다. 필요에 의해 스레드를 생성하고 없애기(release) 때문에 여러개의 task를 진행할 수 있고, 어느 시점에는 하나의 task만을 진행할 수도 있다. concurrent queue도 FIFO queue이기 때문에 순서대로 “시작”하지만, 동시에(concurrently) 실행된다.

main queue

앱이 시작되면, main thread와 연결된 main queue 하나가 자동으로 생성된다. main queue는 serial 타입이고, UI 작업을 진행할 수 있는 유일한 queue이다. 즉, main queue 외의 다른 queue에서는 UI 작업을 진행할 수 없기 때문에, DispatchQueue.main 변수를 통해 UI와 관련된 작업은 main queue에 넣어주어야 한다. (추후 등장할 예정)

  • serial queue이기 때문에 시간이 오래 걸리는 작업을 여기에 넣어두면 해당 작업이 끝날 때까지 UI가 멈추는 상황이 발생할 수 있어 주의해야한다.

GCD 이용하기 1) Queue 생성하기

이제 실제로 GCD를 이용하기 위해 1. Queue를 생성하고, 2. Queue에 task를 추가하는 방법에 대해 알아보자.

1. 직접 생성하기

let queue = DispatchQueue(label: label, qos: .userInitiated, attributes: .concurrent)
  • label은 queue를 구분하기 위한 identifier이다. 주로 domain을 reverse하여 이용하고, debugging에 많이 쓰인다.
  • qos는 quality of service의 약어로 queue의 우선순위를 의미한다.
  • attributes는 위에서 이야기 한 queue의 타입을 결정한다. .concurrent 또는 .serial 를 넣어주면 된다.

*DispatchQueue.init 문서를 통해 초기화에 이용할 수 있는 다른 parameter들을 찾아볼 수 있다.

여러 queue를 하나의 target queue에 연결시킬 수 있는데, 앱에서 이용될 스레드의 수를 줄이기 위해 많이 이용된다. target queue가 지정되면, 시스템은 해당 queue에는 스레드를 할당하지 않는다.

target queue는 queue의 행동에 영향을 끼치지 않는다. target queue가 serial인지, concurrent인지에 상관 없이(둘 다 써도 된다는 것) 해당 queue는 자신의 타입에 따라 serially 또는 concurrently 하게 행동한다.

*사이클을 만들지 않도록 주의하자. 즉, queue A의 target queue가 queue B 이면서 queue B의 target queue가 queue A가 되면 에러가 발생한다.

Quality of service

QoS에 대해 좀 더 알아보자. 해당 queue에 있는 task들이 얼마나 중요한지를 알려주기 위한 우선순위라고 생각하면 된다. 우선순위가 높은 queue에 있는 task들은 우선순위가 낮은 task들보다 더욱 빨리 스레드를 배정받는다. DispatchQoS.QoSClass 을 이용하면 되는데, 위에서는 .userInitiated 를 이용했다. 그 외의 case는 qos class 문서에서 확인할 수 있다.

  • 무작정 우선순위를 모두 높이면 좋을까? 우선순위는 기다리고 있는 작업들 중에서 어떤 걸 더 먼저 진행해야 할 지를 알려주는 것이지, 단순히 모든 작업들을 빠르게해주는 마법 도구는 아니다. 모든 작업이 높은 우선순위를 갖고 있는다면 앱은 임의로 작업을 진행하고, 애니메이션이나 버튼 클릭 이벤트와 같이 빠르게 응답해야하는 작업들이 비교적 천천히 응답해도 괜찮은 네트워크 요청과 같은 작업들에 의해 미뤄져서 유저가 답답해하거나/앱이 정상적으로 작동하지 않는 상황이 벌어질 수 있다.

2. system queue 이용하기

위에서는 직접 initializer를 이용하여 queue를 직접 생성해주는 방법을 알아봤다. 하지만 직접 queue를 생성해주는 대신, system에서 제공하는 global queue를 이용할 수도 있다.

global queue는 concurrent type만 존재하며 다음과 같이 이용가능하다.

let queue = DispatchQue.global(qos: .userInteractive)

qos는 위에서 언급한 Quality of Service로, 역시 우선순위를 의미한다. 위와 같이 호출하면 해당 qos에 맞는 global queue를 리턴해준다.

global queue와 구분하기 위해서 1번처럼 직접 생성해준 queue를 private queue라고 부른다.

Avoiding Excessive Thread Creation

GCD에서는, concurrent queue에 새로운 task가 추가되며 현재 스레드가 block되면 새로운 스레드를 생성해서 해당 task를 진행시킨다. 하지만 스레드가 이렇게 계속해서 생성되어서 너무 많아지면 스레싱과 같은 문제를 발생시킬 수 있다.

그렇기에 계속해서 새로운 concurrent queue를 생성하는 것보다는 위에서 언급한 global queue와 target queue 방법을 이용하면 좋다.

또한, serial queue를 이용하는 경우에도 target을 global concurrent queue로 설정하면 스레드를 줄이면서도 serial한 실행을 보장받을 수 있다.

GCD 이용하기 2) task 추가하기

위에서 queue를 만들어줬으니, 이제는 queue에 task를 추가할 차례이다. task는 메서드나 코드블럭(클로저)의 형태로 추가될 수 있다.

queue의 타입이 serial과 concurrent, 두 개가 있었듯이 task를 추가할 때에도 두개의 타입 중 하나를 선택할 수 있다.

// 클로저를 이용한 방법. concurrent/serial queue 모두 동일하다.
queue.sync {
	// synchronous task...
}

queue.async {
	// asynchronous task...
} 
  • synchronously : 해당 task가 끝날때까지 코드가 기다려준다.
  • asynchronously : 해당 task를 동작시키면서 동시에 코드도 계속 전진한다.

얼핏보면 앞에서 배운 serial/concurrent 개념과 굉장히 유사하다. 그렇기에 여기에서 serial과 concurrent, synchronous, asynchronous를 한 번 짚고 넘어가자.

synchronous vs asynchronous

synchronous와 asynchronous는 task 입장의 개념이다. task가 한 번 시작하면 끝날 때까지 다음 task로 넘어갈 수 없도록 BLOCK 시켜놓는 것이 synchronous이다. 반대로, task가 시작하자마자 해당 스레드의 제어권을 넘기는 것은 asynchronous이다. 제어권이 넘어갔기에 해당 스레드에서는 그 다음으로 스케쥴된 task를 작업할 수 있게된다.

스레드의 제어권을 빼앗긴 asynchronous task는 주로 다른 스레드에서 작업을 진행한다.

serial vs concurrent

serial과 concurrent는 queue 입장의 개념이다. serial queue는 하나의 스레드만을 항상 가지기 때문에 항상 한 번에 하나의 task만을 진행할 수 있다.

concurrent queue는 여러개의 스레드를 가질 수 있다. 필요에 의해 스레드를 생성하고 release하기 때문에 여러개의 task를 진행하기도, 하나의 task를 진행하기도 한다.

*serial queue에서도 asynchronous 작업이 진행될 수 있고, concurrent queue에서도 synchronous 작업이 진행될 수 있음을 기억하자.

Priority Inversion

문서 바로가기

위에서는 가장 기본적인 클로저를 이용하는 방법을 알아봤다. 그 외에도 () -> Void 타입의 함수, DispatchWorkItem 등 다양한 형태로 작업을 추가할 수 있다.

DispatchWorkItem를 이용하면 해당 task에 completion handler를 추가하거나, 작업을 취소시키거나, 기다리게 하는 등의 추가적인 기능을 이용할 수 있다.

또한 initializer를 통해 task(DispatchWorkItem)의 qos를 따로 지정해줄 수도 있다. 이 경우 queue의 qos보다 거기에 추가된 task의 qos가 높다면, queue의 qos가 일시적으로 task의 qos만큼 올라가는 현상이 일어난다.
dispatch queue는 FIFO queue이기 때문에 concurrent queue일지라도 이전에 추가된 task들이 모두 시작한 이후에서야 추가된(우선순위가 높은) task가 시작할 수 있기 때문이다. 이를 Priority Inversion이라고 부른다. priority가 낮은(이전에 추가된) task들이 일시적으로 priority가 높은 task들보다 일찍 시작하게 되는 것.

Additional Informations

Dispatch.main & [weak self]

DispatchQueue.global(qos: .utility).async { [weak self] in
	// ...

	DispatchQueue.main.async {
		// UI와 관련된 코드들
	}
}
  1. GCD의 async 클로저에서는 self를 strong하게 캡쳐해도 reference cycle이 생성되지 않는다. 클로저는 실행이 완료되면 자동으로 deallocate되기 때문. strong하게 캡쳐할 경우에는 클로저가 종료될때까지 view controller가 남아있게 되고, weak하게 캡쳐하는 경우에는 view controller가 nil 이 된다.
  2. UI와 관련된 코드는 항상 main 큐에서만 실행될 수 있기 때문에 위처럼 main queue로 작업을 dispatch 해줘야한다.

DispatchGroup

let group = DispatchGroup()

queue1.async(group: group) { ... } // task 1
queue1.async(group: group) { ... } // task 2
queue2.async(group: group) { ... } // task 3

group.notify(queue: DispatchQueue.main) { [weak self] in
	// group 내 작업이 모두 끝나면 main queue에서 "done"을 출력하게 한다.
	print("done")
}
  1. 여러 task들을 함께 관리하고 싶을 때 이용할 수 있다. group 내 모든 작업이 끝났을 때 실행할 completion handler를 추가하거나(위 코드), 끝날때까지 기다리게 할 수도 있다.
  2. DispatchWorkItem과 비슷해보이지만 DispatchWorkItem은 task 하나에 기능을 추가하는 거고, DispatchGroup은 여러개의 task를 관리할 수 있게 한다.
  3. 위처럼 각기 다른 queue에 추가한 task들을 하나의 그룹에 넣을 수도 있다.
  4. 예시에서는 클로저를 이용하여 task를 queue1에 두개, queue2에 하나 추가해줬다.

concurrency problems

concurrency를 이용할 때에 발생할 수 있는 문제점들이 있다.

  1. Priority Inversion

    앞에서 언급했으니 넘어가도록 하자. 이건 concurrency 자체의 문제라기 보다는 GCD가 FIFO 기반의 dispatch queue를 이용해서 생기는 문제이다.

  2. race condition

    concurrent 환경에서 자주 발생하는 문제이다. 스레드는 하나의 프로세스(앱) 내에서 code, data section 등을 공유한다. 즉, 같은 주소공간에 저장된(reference가 같은) 리소스에 접근할 수 있게되면서 race condition이 발생할 수 있다.

    예를 들면, count += 1 과 같은 간단한 코드 마저도 실제로 실행할 때에는

    1. count 변수에 저장된 값 가져오기
    2. 값에 1 더하기
    3. count 변수에 2에서 계산한 값 저장하기

    의 순서로 진행되는데,

    concurrent 환경에서는 내가 값을 가져와서 2번(값에 1더하기) 과정을 하는 동안에 다른 스레드가 count 변수에 새로운 값을 저장해버리는 상황이 발생할 수 있게 되는 것이다. 이런 경우 내가 2번을 끝내고 3번을 진행하면, 다른 스레드의 작업을 무시해버리므로 원치않는 문제가 발생하게 된다.

    sol1) serial queue 이용하기

    가장 간단한 방법은 serial queue를 이용하는 것이다. serial queue는 항상 한 순간에 하나의 task가 작동하는 것을 보장하므로, count에 접근하는 코드를 모두 serial queue에서 작동시키면 race condition을 방지할 수 있다.

    import Foundation
    
    private let serialQueue = DispatchQueue(label: "my-serial-queue")
    private var _count = 0
    private var count: Int {
        get {
            serialQueue.sync {
                _count
            }
        }
        set {
            serialQueue.sync {
                _count = newValue
            }
        }
    }

    sol2) Dispatch Barrier

    dispatch barrier를 이용할 수도 있다. task의 attribute로 barrier 옵션을 추가해주면, 해당 task가 concurrent queue에 삽입되었을 때 barrier 처럼 동작하게 된다.

    해당 queue에서 자신보다 먼저 삽입된 task들이 먼저 실행될 때까지 기다렸다가, 자기 자신을 실행시키고, 자신이 종료된 이후에야 이후에 삽입된 task들을 실행시킨다(정상적인 스케쥴링으로 돌아간다). 그러므로 concurrent queue 내에서 자기 자신이 실행될 때에 (같은 concurrent queue 내의)다른 작업들이 실행되지 않음을 보장받을 수 있다.

    이용할 때에는 DispatchWorkItem을 생성하면서 함께 정의할 수도 있고, 아래처럼 클로저를 이용할 수도 있다.

    import Foundation
    
    private let concurrentQueue = DispatchQueue(label: "my-concurrent-queue", attributes: .concurrent)
    
    private var _count = 0
    private var count: Int {
        get {
            concurrentQueue.sync {
                _count
            }
        }
        set {
            concurrentQueue.async(flags: .barrier) { [unowned self] in
                self._count = newValue
            }
        }
    }

    DispatchBarrier의 경우 concurrent queue를 이용할 수 있으므로, .barrier 로 지정한 task 외의 다른 task들이 concurrent하게 진행될 수 있어서 장점이 될 것 같다.

그 밖에도 Dispatch Semaphore 등 재미있는 게 많으니 궁금한 것은 문서를 찾아보자. Operation은 GCD와 굉장히 비슷한데 reusability, dependencies on other operations, ability to cancel running operation 등의 추가적인 기능을 가진다고 한다.

다음 포스팅은 컴바인에 대해 다뤄보려고 한다. 컴바인은 사실 concurrency를 직접적으로 다루지는 않는다. 하지만 async/await 과 함께 자주 다뤄지는 만큼 함께 이야기해보면 좋을 것 같다는 생각을 했다.

References+

https://developer.apple.com/library/archive/documentation/General/Conceptual/ConcurrencyProgrammingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008091-CH1-SW1

https://www.raywenderlich.com/books/concurrency-by-tutorials

https://www.raywenderlich.com/28540615-grand-central-dispatch-tutorial-for-swift-5-part-1-2

profile
개발블로그

0개의 댓글