티스토리 뷰

GCD를 공부하다가 DispatchQueue에서 직렬성(Serial)과 동시성(Concurrent), 동기(Sync)와 비동기(Async)의 개념이 너무 햇갈려서 별도로 정리해보기로 했다.

 

지금까지는 Serial/Concurrent와 Sync/Async의 차이를 작업 A와 B가 있을 때, 작업 B를 작업 A가 끝나기를 기다렸다가 실행하느냐, 아니면 기다리지 않고 바로 실행하느냐의 차이 정도로 이해하고 있었다. 그러다 보니 '왜 SynchronousQueue라고 하지 않고 SerialQueue라고 하는거지?'라는 궁금증이 생겼다. 같은 의미인데 굳이 Sync대신 Serial을 사용한 이유가 있을 것 같아서 찾아보았지만, 아무리 구글링을 해도 답을 찾을 수 없었다.

 

이 궁금증에 대한 해답은 iOS 단톡방에서 얻을 수 있었다. 단톡방의 많은 분들이 이 고민을 풀어줄 많은 가르침을 주셨다.(감사합니다 ㅠㅠ). 이런 혼란이 생긴건 Serial/Concurrent와 Sync/Async가 근본적으로 같다는 잘못된 생각 때문이었다. 이 둘은 완전히 다른 개념인데, 같다는 생각을 깔고서 접근하니 당연히 이해가 안될 수 밖에. 그래서 잘못 알고있던 내용을 바로잡기 위해 밑바닥부터 차근차근 의미를 정리해 나가려고 한다. 필자와 같은 의문을 가져본 적이 있거나 아직 Seirla/Concurrent, Sync/Async를 명확하게 이해하지 못한 사람들에게 이 글이 도움이 되길 바란다.

 


왜 이 단어를 사용하게 되었는가

먼저, 처음부터 가졌던 궁금증부터 해결해보고자 한다. 왜 작업 Queue에는 Sync 대신 Serial이란 단어를 사용하게 되었는가. 많은 경우에 어떤 대상의 이름은 그것을 요약해서 설명할 수 있는 핵심적인 의미를 담고 있기 마련이므로 Serial/Concurrent, Synchronous/Asynchronous라는 이름의 사전적인 정의를 살펴보았다.

Serial과 Concurrent

먼저, Serial은 '순차적인'이라는 뜻을 가지고 있다. 이것은 '차례를 지킨다'와 같은 의미이다. 

Serial 뜻(출처 : 네이버 사전)

가령, 사람들이 마트에서 계산을 하기 위해 줄을 서고 있다고 하자. 이 계산 대기줄은 한 번에 한 명씩 '순차적으로' 차례대로 계산을 할 수 있다. 자기 차례가 되어야만 계산을 할 수 있고, 앞사람이 계산을 끝내지 않았다면 뒷사람은 계산할 수 없다(새치기는 당연히 안됨). 이처럼, serial은 어떤 일을 순서대로 하나씩 처리하는 경우에 사용될 수 있다.

 

한편, Concurrent는 '동시에 발생하는'이라는 뜻을 가지고 있다. 

 

Concurrent 뜻(출처 : 네이버 사전)

위에서 예시로 든 계산 대기줄을 다시 생각해 보자. Serial한 계산 대기줄은 '순차적으로' 계산하기 때문에 비어 있는 계산대가 있더라도 한 번에 한 사람씩 계산을 할 수 있다. 하지만, Concurrent한 대기줄은 '동시에' 계산을 할 수 있기 때문에 앞사람이 계산을 끝내지 않았더라도 뒷사람이 계산을 할 수 있다. 이처럼, concurrent는 여러 가지 일을 동시에 처리하는 경우에 사용될 수 있다.

Synchronous와 Asynchronous

이번에는 Synchronous와 Asynchronous의 뜻을 사전에서 찾아보았다. 단어에서도 알 수 있듯이 이 두 단어는 서로 상반된 의미를 갖고 있다. (Asynchronous는 Synchronous의 반대되는 의미를 갖는다.)

Synchronous(왼쪽)와 Asynchronous(오른쪽)의 뜻(출처 : 네이버 사전)

Synchronous와 Asynchronous는 '둘 이상의 어떤 일이 동시에 발생/존재하는지' 여부에 차이가 있다. 위에서 예시로 든 계산하는 상황을 다시 생각해 보자. 줄을 서 있던 사람이 차례가 되어 계산대에 섰다. 계산대에서 구매할 물건의 결제를 요청하고, 결제 승인이 나면 영수증이 출력되면서 계산이 끝난다. 이 때, 계산원이 결제 요청과 그 응답을 기다리는 동안 다른 일을 하지 못하면 Synchronous하다고 말할 수 있다. 반면에, 카드를 꼽아서 결제를 요청하고 그 사이에 물건을 담을 봉투를 준다던지, 포인트 적립을 위해 장부에 기록을 한다던지 하는 결제 이외에 다른 일을 할 수 있다면 Asynchronous하다고 말할 수 있다. 즉, 어떤 일을 시작하고(요청) 끝나는(응답) 것이 동시에 발생하면 Sync, 요청을 해 두고 다른 일을 하다가 나중에 응답을 받는다면 Async하다고 말할 수 있다.

 


 

Serial과 Sync를 동작의 결과만 놓고 보면 같은것 처럼 느껴질 수도 있다. 처음에 내가 잘못 이해한 이유도 여기에 있는데, Sync에서 요청에 대한 응답을 받는 것을 작업의 끝이라고 본다면 Serial과 Sync는 작업이 끝나기를 기다렸다가 다음 작업을 수행하는 것이라고 생각할 수 있기 때문이다. 이런 생각이 '같은 동작인데 왜 다르게 부르지?'라는 의문을 품게 만들었다.

 

다시 개념을 혼동하지 않도록 Serial/Concurrent와 Sync/Async를 동작하는 모습을 기준으로 정리해 보았다.

 

 

Serial은 요청된 하나 이상의 작업들을 순차적으로 실행하기 위해 선행 작업이 종료되기를 기다린다.

Concurrent는 요청된 하나 이상의 작업들을 실행할 때 선행 작업의 종료를 기다리지 않고 다음 작업을 실행한다.

Synchronous는 작업을 요청하고 그에 대한 응답(결과)이 올 때 까지 다음 요청을 대기한다.

Asynchronous는 요청한 작업에 대한 응답(결과)이 오는 것을 기다리지 않고 다음 작업을 요청한다.

 

 

정리해 보면, Seiral/Concurrent는 요청된 여러 개의 작업들을 순차적으로 또는 동시에 처리하는 경우를 의미하고, Sync/Async는 어떤 작업에 대한 요청과 응답이 한 번에 또는 따로 발생하는 경우에 사용된다. Serial/Concurrent는 다수의 대상에 대한 '순서'가 주된 개념이고, Sync/Async는 단일 작업에 대한 요청과 응답(결과)의 발생 시점이 주된 개념이다.

 


DispatchQueue에서 의미를 생각해보기

지금까지 단어의 의미를 알아보면서, 이제는 적어도 '왜 SerialQueue 대신 SynchronousQueue라고 하지 않았을까?'같은 의문은 품지 않게 되었으리라 생각한다. 이번에는 SerialQueue와 ConcurrentQueue에 각각 Sync 또는 Async하게 작업을 실행하도록 요청하는 상황을 살펴보도록 하자.

SerialQueue와 ConcurrentQueue

위에서 Seiral/Concurrent는 작업의 '순서'가 중요하다고 했는데, 순서가 있다는 것은 그 개수가 여러개라는 뜻이다. 우리가 실행할 작업을 대기열(Queue)에 넣게 되면, 그 Queue는 들어온 다수의 작업들을 Thread에 전달할 때 Serial 또는 Concurrent하게 전달할 수 있다. 그래서 SerialQueue와 ConcurrentQueue가 존재하게 된다.

 

아래 그림은 Serial과 Concurrent를 설명할 때 단골로 등장한다. 필자는 이 그림을 처음 볼 때는 크게 와닿지 않았는데, 이제 '순차적인' 의미를 생각하면서 보니 이보다 더 잘 나타낸 그림이 없는 것 같다.

다수의 작업이 '순서대로' 실행되는 Serial 동작과(좌), 동시에 실행되는 Concurrent의 동작(우)

실제로 SerialQueue에 log를 출력하는 작업을 전달하는 코드를 작성해 보았다. 시간이 걸리는 작업은 taskSize만큼 반복문을 돌려서 표현했다.

SerialQueue는 작업이 전달된 순서대로 log가 출력되는 것을 확인할 수 있었다.

ConcurrentQueue의 경우, Queue의 FIFO 특성에 의해 작업들의 실행은 순서대로 이루어지지만 선행 작업이 끝나길 기다리지 않기 때문에 종료되는 시점은 순서가 지켜지지 않는다. 그래서 출력 log가 작업을 전달한 순서와 다른 것을 확인할 수 있었다.

Synchronous와 Asynchronous

위에서 Sync와 Async는 하나의 작업에 대한 요청과 응답이 동시에 발생하는지의 여부에 따라 구분한다고 했다. 우리가 Queue에 실행시킬 작업을 전달하면 DispatchQueue는 사용 가능한 thread에 작업을 할당하여 실행시킨다. 이 때, 전달하는 작업이 thread에서 실제로 실행되면 그 작업의 종료를 기다릴 것인지, 아니면 기다리지 않고 다음 코드를 실행할 것인지 결정할 수 있다. 작업의 종료를 기다린다면, 즉, 작업의 실행과 종료가 한 번의 코드 실행 안에서 동시에 발생하도록 한다면, Queue에 작업을 전달하는 코드가 실행된 후 해당 작업이 종료될 때 까지 다음 코드를 실행할 수 없다. Async는 반대로 작업의 실행과 종료가 한 번의 코드 실행 안에서 동시에 발생하지 않아도 되기 때문에, 작업을 전달하여 일단 실행시키고 다음 코드로 넘어간다. 그리고 작업이 종료되었을 때 응답 코드를 실행하게 된다.

 

아래 그림은 작업의 실행 방식을 Sync/Async로 설정했을 때 차이를 보여주고 있다. 실제로는 Queue에서 Thread로 작업이 전달되어 실행되는 시점이 Request가 될 것이지만, 대부분의 경우 Queue에 작업이 전달되는 시점에서 아주 짧은 시간 내에 이루어지기 때문에 DispatchQueue에 작업을 전달하는 코드가 실행되는 시점을 Request로 봐도 무방하다. Response는 작업이 종료되었음을 나타내고 있다. Code Execution Sequence는 프로그램이 동작할 떄 코드의 실행 흐름을 나타낸다. Sync는 Request와 Response가 모두 발생해야 다시 코드 실행으로 돌아가고 있다. 반면에, Async는 Request가 발생하면 곧바로 다시 코드가 실행된다. 그리고 이후 해당 작업이 완료되는 시점에서 응답이 발생하고 있다.

Synchronous와 Asynchronous 방식의 비교


Serial/Concurrent Queue에서 Sync/Async 방식의 작업 실행 비교

이 글이 완성되어갈 무렵, 갑작스러운 호기심에 이끌려 몇 가지 실험을 해보았다. SerialQueue와 ConcurrentQueue에 각각 Sync와 Async 방식으로 실행되는 작업을 전달할 수 있으니, 가능한 경우는 총 네 가지이다. 각각 어떤 결과가 나올지 잠깐 생각해봤는데 확신이 서질 않아서 네 가지 경우를 모두 실험해 보았다. 역시 머리로는 이해하는 것 같아도 눈으로 직접 보고 겪기 전까지는 모르는 법이다.

1. Serial - Sync

SerialQueue에 동기(Sync)적으로 실행되는 작업을 전달하는 경우이다. 일단 SerialQueue이므로 Queue에 전달한 '1, 2, 3'을 출력하는 작업들은 순서대로 실행될 것이다. 그리고 Queue에 전달되지 않는 'A, B, C'를 출력하는 작업들은 당연히 순서대로 실행될 것이므로 '1, 2, 3'과 'A, B, C'의 출력 순서는 보장되어 있다. SerialQueue에 전달하는 작업은 동기(Sync) 방식으로 실행될 것이므로 반드시 '1'이 출력된 뒤에서야 'A'를 출력할 수 있다 이것은 '2, 3'과 'B, C'에 대해서도 마찬가지이다. 따라서, '1 A 2 B 3 C'의 순서로 출력되는 것을 확인할 수 있다.

 

let serialQueue = DispatchQueue(label: "cskime.serialQueue")

serialQueue.sync { print("1", termimator: " ") }
print("A", terminator: " ")
serialQueue.sync { print("2", termimator: " ") }
print("B", terminator: " ")
serialQueue.sync { print("3", termimator: " ") }
print("C", terminator: " ")

// Prints
// 1 A 2 B 3 C

2. Serial - Async

SerialQueue에 비동기(Async)적으로 실행되는 작업을 전달하는 경우이다. 1번의 경우에서 Sync가 Async로 바뀐 것이므로, '1, 2, 3'의 순서와 'A, B, C'의 순서는 동일할 것이다. Queue에 전달되는 작업은 비동기(Async)적으로 실행될 것이므로, '1'을 출력하는 작업이 시간이 많이 소요되어 'A'를 출력하는 작업이 '1'이 출력되기 전에 시작될 수 있다. 따라서, 1번과 다르게 '1'을 출력하는 코드가 먼저 실행되지만 'A, B'가 먼저 출력된 다음에서야 '1'이 출력되는 것을 확인할 수 있다. 이것은 상황에 따라 연산 속도가 달라질 수 있는 문제이므로 매번 결과가 달라질 수 있다.

 

let serialQueue = DispatchQueue(labeel: "cskim.serialQueue")

serialQueue.async { print("1", terminator: " ") }
print("A", terminator: " ")
serialQueue.async { print("2", terminator: " ") }
print("B", terminator: " ")
serialQueue.async { print("3", terminator: " ") }
print("C", terminator: " ")

// Prints
// A B 1 C 2 3

3. Concurrent - Sync

이번에는 ConcurrentQueue에 동기(Sync)적으로 실행되는 작업을 전달하는 경우이다. 1번과 2번 경우와 달리 ConcurrentQueue에 전달하므로 '1, 2, 3'의 출력 순서가 달라질 수 있을 것 같지만, 이 경우에는 1번과 출력 결과가 완전히 동일하다. 그 이유는 작업들이 동기(Sync)적으로 실행되기 때문이다. ConcurrentQueue에서는 작업들이 선행 작업의 종료를 기다리지 않고 먼저 실행될 수 있어서 작업 소요 시간에 따라 작업의 종료 순서가 달라질 수 있지만, 이것은 Queue에 작업이 전달되었을 때의 이야기다. 3번의 경우에는 '1'을 출력하는 작업이 전달되면 실제로 '1'이 출력 되어야 다음 코드를 실행할 수 있기 때문에, 다음줄에 있는 '2'를 출력하는 작업은 Queue에 전달되지 않는다. 따라서, 모든 작업이 순서대로 실행되는 것과 같아져서 1번과 같은 결과가 출력되었다.

 

let concurrentQueue = DispatchQueue(
  label: "cskim.concurrentQueue", attributes: [.concurrent]
)

concurrentQueue.sync { print("1", terminator: " ") }
print("A", terminator: " ")
concurrentQueue.sync { print("2", terminator: " ") }
print("B", terminator: " ")
concurrentQueue.sync { print("3", terminator: " ") }
print("C", terminator: " ")

// Prints
// 1 A 2 B 3 C

4. Concurrent - Async

ConcurrentQueue에 비동기(Async)적으로 실행되는 작업을 전달하는 경우이다. 이번에는 3번과 달리 ConcurrentQueue에 전달되는 작업을 비동기(Async)적으로 실행한다. 비동기적으로 실행하므로 '1, 2, 3'의 출력 결과를 기다리지 않고 다음 코드들을 실행한다. 그래서 'A, B, C'를 출력하는 작업은 Queue에 전달하지 않았기 때문에 출력 순서가 그대로 보장되지만, '1, 2, 3'의 출력 순서는 완전히 달라질 수 있다. 작업을 비동기적으로 실행하기 떄문에 '1, 2, 3'과 'A, B, C' 간의 출력 순서 또한 뒤죽박죽으로 나올 수 있게 된다. 2번과 마찬가지로 상황에 따라 매번 결과가 달라질 수 있다.

 

let concurrentQueue = DispatchQueue(
  label: "cskim.concurrentQueue", attributes: [.concurrent]
)

concurrentQueue.async { print("1", terminator: " ") }
print("A", terminator: " ")
concurrentQueue.async { print("2", terminator: " ") }
print("B", terminator: " ")
concurrentQueue.async { print("3", terminator: " ") }
print("C", terminator: " ")

// Prints
// A B C 1 3 2

 


 

이렇게 해서 Serial, Concurrent, Synchronous, Asynchronous의 단어 뜻부터 시작해서 네 가지를 모두 비교해 보고 몇 가지 실험까지 진행해 보았다. 사실 이 글을 완성하는 순간까지도 명확하게 정의가 내려지지는 않는다. 앞으로 관련 내용이 나올 때 마다 반복해서 머리속에 집어넣어야 할 것 같다.

 

마지막으로 정리하자면,

 

  1. Serial과 Concurrent는 Queue에 전달된 여러 개의 작업에 대한 실행 순서를 결정한다.
  2. Synchronous와 Asynchronous는 Queue에 전달하는 작업 하나에 대한 실행 방식을 결정한다. 여기서 실행 방식이란 작업의 요청과 응답이 하나의 코드 실행에서 동시에 발생할지, 아니면 응답을 기다리지 않고 다음 코드를 실행시킬 지를 의미한다. 전자의 경우, 오래 걸리는 작업이라면 프로그램이 멈추는 현상을 경험할 것이다.
  3. Serial/Concurrent가 Sync/Async보다 다루는 작업의 범위가 넓다. 전자는 여러 개의 작업에 대한 실행 순서를, 후자는 작업 하나에 대한 실행 방식을 나타낸다.

 

'iOS' 카테고리의 다른 글

Frame과 Bounds의 차이  (0) 2020.08.07
GCD: Grand Central Dispatch  (0) 2020.07.27
Serial/Concurrent와 Sync/Async 이해하기(feat. DispatchQueue)  (0) 2020.07.26
Application Life Cycle  (0) 2020.07.15
ViewController Life Cycle  (0) 2020.07.13
PhotoKit 사용기  (0) 2020.07.12
댓글
댓글쓰기 폼