본문 바로가기
개린이 이야기

Concurrency에 관하여

by iOS 개린이 2024. 7. 28.

코어

  • 코어는 CPU의 핵심으로 CPU에서 실제로 일을 처리하는 역할을 수행.
  • 한 번에 한 가지 일만 처리할 수 있음
  • 멀티 코어는 여러 일을 동시에 처리할 수 있음

 

그렇다면 싱글 코어에서 여러 작업을 수행못함?

  • 동시성이라는 개념을 통해 가능하다.
  • 동시성이란 시분할로 여러 작업을 반복적으로 옮겨가며 처리함으로써 동시에 작업이 수행되는 것처럼 보이는 것
  • 따라서 싱글 코어에서도 동시성을 이용하여 여러 작업을 동시에 실행할 수 있음

 

그럼 코어의 갯수가 많으면 많을수록 성능이 훨씬 좋겠네?

  • 거의 그렇지만, 백퍼센트는 아님
  • 멀티 코어에서는 어떤 코어가 어떤 일을 할 지 작업을 나눠야 하는데, 이 과정에서 딜레이가 발생하여 오히려 싱글 코어보다 작업 속도가 줄어들 수 있음
  • 또한 소프트웨어가 싱글 코어에 최적화되었다면, 멀티 코어라도 성능이 좋다고 단정지을 수 없음

 

스레드

  • 프로세스 내에서 실행되는 작업의 단위
  • 멀티 스레드는 프로세스 내의 스레드가 여러 개라는 것을 의미
  • 각 스레드는 작업을 수행함으로 여러 개의 스레드에서 분산적으로 작업을 처리하는 것이 효율적임
  • 동시성 프로그래밍 또한 멀티 스레딩을 이용한 프로그래밍임

 

병렬 프로그래밍

  • 동시성은 여러 개의 작업을 시분할로 번갈아가며 처리함으로써 동시에 이루어지는 것처럼 보이는 것이라고 했음
  • 그렇다면 병렬은 여러 개의 작업을 진짜 동시에 처리하는 것임
  • 실제로 동시에 처리하는 것이기 때문에 병렬 프로그래밍은 물리적으로 멀티 코어일 경우에만 가능하다.

 

동시성 프로그래밍

  • 여러 작업을 동시에 보이는 것처럼 처리하는 것
  • 병렬 프로그래밍과 달리 싱글 코어에서도 가능함
  • 여러 작업을 번갈아가며 처리하는 것을 컨텍스트 스위칭이라고 함

 

동시성과 병렬성

  • 병렬 프로그래밍은 다중 코어를, 동시성 프로그래밍은 다중 스레드를 활용하는 것임
  • 소프트웨어 개발에 있어서 어떤 작업을 어떤 코어에서, 어떤 스레드에서 처리해야 할지 나누는 것은 운영체제가 담당하기 때문에 개발자는 개념만 이해하고 있어도 좋음

 

동시성이 왜 필요한지?

  • 여러 작업을 여러 스레드로 나누어 분담 처리함으로써 효율적으로 작업을 처리하기 위해 -> 사용자 경험 향상
  • 예를 들어, 게임을 다운로드 받으면서, 따로 다른 액션을 처리할 수 있게 하는 것

 

Swift에서는 동시성 프로그래밍을 어떻게 지원하는지?

  • GCD, Operation, async/await 이 있음
    • GCD는 가장 먼저 나온 API
    • Operation은 GCD를 기반으로 만들어진 세부적인 기능이 추가된 API
    • async/await은 가장 최근에 나온 API

 

GCD (Grand Central Dispatch)

  • 동시성 작업을 위해서는 여러 개의 스레드를 사용하여, 여러 작업을 분배해야 한다고 했음
  • 하지만, 그걸 일일히 분배해주고 있는 것은 굉장히 귀찮고 까다로움
  • iOS에서는 작업 대기열에 수행해야 하는 작업들을 넣어주고, 시스템이 알아서 스레드를 통해 작업을 수행.
  • 그 작업 대기열이 바로 GCD가 제공하는 DispatchQueue임

 

DispatchQueue

  • Dispatch: 보내다 (파견하다)
  • Queue: 대기열
  • 즉, DispatchQueue의 정의는 "대기열에 보내다"
  • 원하는 작업을 DispatchQueue에 넣어주면, 알아서 필요한 스레드를 생성하여 작업을 분산 처리함
  • 기본적으로 FIFO 동작으로 작업을 처리함

 

DispatchQueue 설정

DispatchQueue에 작업을 넘길 때, 2가지 설정이 필요함

  • 단일 스레드를 사용할 것인가 or 다중 스레드를 사용할 것인가? (Serial or Concurrent)
  • 동기로 작업을 처리할 것인가 or 비동기로 작업을 처리할 것인가? (sync or async)

 

DispatchQueue와 GCD의 관계

  • GCD는 DispatchQueue보다 더 상위 개념으로 GCD는 Dispatch라는 프레임워크와 동치가 될 수 있음
  • Dispatch 프레임워크에는 DispatchQueue, DispatchWorkItem, DispatchGroup, DispatchQoS, DispatchSource, DispatchSemaphore 등 동시성 프로그래밍 작업 처리를 위한 다양한 타입들이 구현되어 있음
  • DispatchQueue는 Dispatch 프레임워크에 정의된 하나의 타입일뿐.

 

Serial or Concurrent

  • DispatchQueue는 크게 Serial Queue와 Concurrent Queue로 나눌 수 있음
    • Serial Queue는 단일 스레드에서만 작업을 처리함
    • Concurrent Queue는 다중 스레드에 작업을 처리함
  • DispatchQueue는 기본적으로 Serial이 디폴트
    • DispatchQueue를 초기화할 때, attributes를 따로 .concurrent로 설정하면 Concurrent Queue로 작동
// Serial Queue
DispatchQueue(label: "Serial")
DispatchQueue.main  // main은 전역적으로 사용되는 Serial DispatchQueue

// Concurrent Queue
DispatchQueue(label: "Concurrent", attributes: .concurrent)
DispatchQueue.global()

 

 

헷갈리는 부분 정리

  • DispatchQueue()로 새로운 커스텀 큐를 만드는 것은 기본적으로 Serial 큐로 작동함
  • 하지만 DispatchQueue.global()로 큐를 만드는 것은 이미 만들어진 큐를 사용하는 것으로 기본적으로 Concurrent 큐로 작동함

 

main / global

  • DispatchQueue.main과 DispatchQueue.global()은 이미 만들어져있는 큐로 각각 Serial, Concurrent 큐임
    • main은 일반 Serial 큐와 달리 메모리에 늘 올라와 있으며, 전역적으로 사용가능한 큐
    • global()은 새로운 스레드를 생성하여 작업을 사용하게 되는 큐
  • main에 작업을 추가하면 Serial 큐인 메인 스레드에서 작업을 처리하게 된다
    • 메인 스레드에서 작업을 추가하게 되면, 순차적으로 처리되기 때문에 여러 작업을 동시에 할 수 없음
  • global()에 작업을 추가하면 스레드를 생성하여 작업을 처리하게 된다.
    • Concurrent Queue이기 때문에 여러 작업을 동시에 처리할 수 있음
    • 메인 스레드와 달리 작업을 처리하기 위해 생성된 스레드임. 따라서 메모리에 올라왔다가, 작업을 끝내면 메모리에서 해제된다.

 

메인 스레드가 뭔데?

  • 앱의 기본이 되는 스레드. 앱의 생명주기와 같은 생명주기를 갖는, 앱이 실행되는 동안 메모리에 지속적으로 올라와 있는 스레드임
  • 메인 스레드는 UI작업을 담당하기 때문에 다른 작업을 처리하는 동안 사용자는 멈춰있는 UI를 보게 된다.
  • 따라서 여러 작업을 메인 스레드가 아닌 다른 여러 스레드로 나눠서 처리할 수 있도록 해야 함
  • 우리가 동시성 프로그래밍을 위해 global()로 스레드를 생성하는 것은 모두 메인 스레드에서 파생되어 있는 것임

 

메인 스레드의 특징

  • 전역적으로 존재
  • global 스레드들과 다르게 런 루프가 자동으로 설정되고 실행됨. 메인 스레드에서 실행되는 런 루프를 Main run roop라고 함
  • UI작업은 메인 스레드에서만 가능함
    • 따라서 여러 작업들을 메인 스레드에서 처리하는 것은 앱의 화면을 멈추게하는 것임. -> 사용자 경험 저하

 

 

동기 or 비동기

// 동기, sync
DispatchQueue.main.sync {}
DispatchQueue.global().sync {}

// 비동기, async
DispatchQueue.main.async {}
DispatchQueue.global().async {}

 

  • 동기는 작업의 요청과 응답이 동시에 이루어지는 것 -> 다음 요청은 이전 요청의 응답이 오기전까지 실행될 수 없음
  • 비동기는 작업의 요청과 응답이 동시에 이루어지지 않음 -> 다음 요청은 이전 요청의 응답이 오지 않아도 실행될 수 있음

 

 

main.async

DispatchQueue.main.async { 
    for _ in 1...5 { 
        print("11111") 
        sleep(1) 
    } 
}

DispatchQueue.main.async { 
    for _ in 1...5 { 
        print("22222") 
        sleep(2) 
    } 
}


/*
11111 
11111 
11111 
11111 
11111
22222 
22222 
22222 
22222 
22222
*/

 

  • 메인 스레드에서 비동기로 작업을 처리하는 코드
  • 비동기 작업이라면, 굳이 다른 작업을 기다리지 않고 처리되어야 하지 않나? 하는 의문을 가질 수 있음
  • 비동기 코드는 시스템에 의해 잠시 저장됨. 이후, 시스템에 의해 적절하게 비동기적으로 처리되는 것임
  • 코드를 보면, main에서 비동기 작업을 호출하고 있음.
  • 근데 메인 큐는 Serial 큐이기 때문에 동기, 비동기와 관계없이 순차적으로 들어온 순서대로 작업을 처리함
  • 따라서 둘 다 비동기일 경우, 모두 메인 스레드에서 큐에 들어온 순서대로 작업이 처리된다.

 

sync / async

DispatchQueue.main.async {
    for _ in 1...5 {
        print("11111")
        sleep(1)
    }
}

for _ in 1...5 {
    print("22222")
    sleep(2)
}

 

  • 위 코드도 동일하게 메인스레드에서 호출되기 때문에 순차적으로 큐에 들어온대로 출력될 것이라고 예상할 수 있음
  • 하지만 이 코드의 작업 순서는 매번 달라짐. (대개 "22222"가 먼저 출력된다.)
  • 이유는 비동기 코드는 시스템에 의해 잠시 저장된다고 했음. 
  • 따라서 메인스레드는 비동기 코드를 시스템에 맡겨두고, 다음 작업들을 처리하기 때문에 비동기 작업이 미뤄질 수 있는 것임

 

global().async

DispatchQueue.global().async {
    for _ in 1...5 {
        print("11111")
        sleep(1)
    }
}

DispatchQueue.global().async {
    for _ in 1...5 {
        print("22222")
        sleep(2)
    }
}

DispatchQueue.main.async {
    for _ in 1...5 {
        print("33333")
        sleep(1)
    }
}

 

  • 위 코드의 결과를 예상해보셈.
  • 정답은 예측할 수 없다는 것임
  • global()은  새로운 스레드를 생성하고, 각 작업은 모두 비동기적으로 호출된다. 이 때, 순서는 시스템에 의해 결정되기 때문에 우리는 결과를 정확하게 예측할 수 없다.

 

main.sync

DispatchQueue.main.sync { /* error: error: Execution was interrupted, reason: EXC_BREAKPOINT (code=1, subcode=0x18011922c).
    The process has been left at the point where it was interrupted, use "thread return -x" to return to the state before expression evaluation. */

 

  • main.sync를 하면 다음과 같은 에러가 등장하는데, 이는 deadlock이 발생하기 때문.
  • 우리 일반 코드들은 모두 메인 스레드에서 돌아가고 있음. 즉, 메인 스레드는 계속해서 실행되고 있다.
  • sync의 동작 방식을 보면, 해당 요청이 완료될 때까지 다음 코드 블럭으로 이동하지 않음 -> Block - wait 상태를 만든다.
  • 여기서 DispatchQueue.main.sync를 한다면?
    • 메인 스레드가 호출되고 있는 상황에서 sync로 인해 블락됨
    • 메인 큐는 해당 sync 작업을 메인스레드에게 요청함
    • 근데 메인 스레드는 블락 상태이기 때문에 해당 요청을 수행할 수가 없음
    • 메인 큐 또한 Serial이기 때문에 앞의 작업이 완료되기 전엔 다음 작업을 수행할 수 없음
    • 따라서 deadlock이 발생함

 

다른 SerialQueue에서는?

  • 다른 SerialQueue를 생성해서 sync 작업을 하는 것은 문제 없음

 

무조건 main.sync는 안되나? 그럼 코드가 왜 필요함?

  • 무조건은 아님. 이게 필요한 상황들이 있음
  • 예를 들어, 백그라운드 스레드에서 작업을 처리하고, 그 결과를 UI에 특별한 순서에 맞게 처리해야 할 경우 main.sync를 사용할 수 있음

 

그 외의 Dispath 기능들

DispatchWorkItem

let one = DispatchWorkItem {
    for _ in 1...5 {
        print("11111")
    }
}

let two = DispatchWorkItem {
    for _ in 1...5 {
        print("22222")
    }
}

let three = DispatchWorkItem {
    for _ in 1...5 {
        print("33333")
    }
}


DispatchQueue.global().async(execute: one)
DispatchQueue.global().async(execute: two)
DispatchQueue.global().async(execute: three)

 

  • 클로저와 비슷하게 코드 블록을 캡슐화해주는 타입

 

asyncAfter

  • 비동기 코드를 원하는 시간에 호출하도록 하는 메서드
DispatchQueue.global().asyncAfter(deadline: .now() + 10, execute: two)

 

  • 위 코드는 지금으로부터 10초 후 two를 비동기적으로 호출하겠다는 메서드

 

asyncAwait

  • 해당 비동기 코드 호출 이후 다음 코드를 호출하는 메서드
DispatchQueue.global().asyncAndWait(execute: one)
DispatchQueue.global().async(execute: two)

 

  • 원래 두 작업 모두 async였다면, 섞여서 실행이 되는 것이 맞음
  • 하지만 asyncAndWait을 사용하니, one 작업이 끝난 후, two 작업이 실행된다.

 

DispatchQueue 초기화

https://developer.apple.com/documentation/dispatch/dispatchqueue/2300059-init

 

 

label

  • DispatchQueue의 식별자와 같음
  • 자세하게는 디버깅 환경에서 추적하기 위해 작성하는 식별자임

 

qos

  • Quality of Service의 약자
  • 수행할 작업들의 우선 순위를 지정하는 타입

 

attributes

  • DispatchQueue의 속성을 정해주는 값
  • 이 설정을 하지 않았을 경우, 기본 값은 Serial임
  • .concurrent로 설정하면 큐가 작업을 Concurrency하게 처리함
  • .initiallyInactive로 설정하면 큐가 생성되어도, 작업을 즉시 실행하지 않고, 비활성 상태로 만든다.
    • 이후 activate() 메서드를 호출함으로써 원하는 시점에 작업을 활성화시킨다.
let queue = DispatchQueue(label: "dd", qos: .background, attributes: .initiallyInactive)

queue.async(execute: one)
queue.activate()

 

 

autoreleaseFrequency

  • DispatchQueue에서 객체를 자동으로 해제하는 빈도를 제어하는 열거형
  • DispatchQueue는 특정 코드 블록을 수행함.
  • 따라서 autoreleaseFrequency는 블록이 끝난 후 객체를 언제 해제할지를 결정함
    • inherit: 기본 옵션으로 상위 큐의 설정을 그대로 따름
    • workitem: 각 작업 블록이 끝날 때마다 객체를 자동으로 해제
    • never: 설정하지 않음

 

target

  • 생성된 DispatchQueue가 실행할 블록들을 어떤 큐에서 실행할지 지정
  • 생성된 DispatchQueue가 특정 타겟 큐에 의존하여 작업을 수행하거나, 기본 큐를 사용할 수 있도록 함
  • 간단하게, 새로운 DispatchQueue가 작업을 실행할 때, 참조할 target 큐를 설정하는 것임

 

QoS (Quality of Service)

  • QoS를 작업 순서를 지정하는 의미로 생각하는 경우가 있는데, 이는 오해가 조금 있음.
  • 여기서 우선 순위란 "어떤 작업에 더 많은 에너지를 쏟을까?" 에 가까움
  • 여기서 더 많은 에너지를 쏟는다는 것은 더 많은 스레드를 할당한다는 것을 의미함
  • 따라서 일이 처리되는 순서를 결정하는 요소는 아니지만, 어느정도 영향을 미칠 수 있다.

GCD의 스레드 관리는 시스템이 알아서 처리해준다. 따라서 QoS로 우선 순위를 설정해도 자세한건 시스템이 알아서 제어하게 된다. 그리고 시스템은 QoS 정보를 통해 스케줄링, CPU 및 I/O 처리량, 타이머 대기 시간 등의 우선 순위를 조정한다.

우선 순위가 높을수록 더 많은 전력을 소모하고, 각 작업에 대해 QoS를 적절하게 설정해준다면, 좀 더 효율적인 에너지를 사용하는 프로그래밍이 가능하다.

 

QoS 우선 순위 높은 순으로 나열

  1. User - interactive
  2. User - initiated
  3. Default (기본 값)
  4. Utility
  5. Background
  6. Unspecified

 

async의 파라미터

https://developer.apple.com/documentation/dispatch/dispatchqueue/2016098-async

  • 블록을 비동기적으로 실행하도록 예약하고, 선택적으로 이를 DispatchGroup과 연관시킵니다.

 

DispatchWorkItemFlags

  • 작업 항목(DispatchWorkItem)의 동작 방식을 제어하는 데 사용되는 플래그들의 집합
  • 플래그들은 작업 항목이 실행될 때, 어떤 특별한 동작을 할 지 결정하는 역할
  • 종류 
    • assignCurrentContext: 현재 실행 중인 컨텍스트의 속성을 작업 항목에 적용. (현재 설정된 컨텍스트(QoS)에 맞게 작업을 수행)
    • barrier: 작업 항목이 병렬 큐에 제출될 때 배리어 블록으로 작동. 배리어 블록은 병렬 큐에서 실행되는 다른 작업 항목들과의 동시 실행을 방지하여, 배리어 블록 앞의 모든 작업이 완료된 후에야 실행됨
    • detached: 작업 항목의 속성을 현재 실행 컨텍스트에서 분리. 작업 항목이 독립적으로 실행되도록 하여, 다른 작업의 실행 컨텍스트에 영향을 받지 않도록 함
    • enfoceQoS: 작업 항목과 연관된 QoS 클래스를 우선시 함. 작업이 지정된 QoS로 실행되도록 보장
    • ingeitQoS: 현재 실행 컨텍스트와 연관된 QoS 클래스를 우선시 함. 작업 항목이 제출된 켄턱스트의 QoS를 상속받도록 함
    • noQoS: QoS 클래스를 지정하지 않고, 작업 항목을 실행함. 특정 우선 순위 없이 실행되도록 한다.

 

DispatchGroup

  • 단일 단위로 모니터링할 수 있는 작업 그룹
  • 작업을 집합적으로 처리하고, 그룹에서 동기화 동작을 제어할 수 있음
  • 여러 작업 항목을 그룹에 첨부하고, 이를 동일한 큐나 다른 큐에서 비동기적으로 실행하도록 예약할 수 있음
  • 모든 작업 항목의 실행이 완료되면, 그룹은 completionHandler를 호출합니다.
  • 또한 그룹의 모든 작업이 완료될 때까지 동기적으로 대기할 수도 있음

 

여러 작업을 그룹에 포함시켜 그룹 단위로 작업 상태를 조절해주는 기능임. 이를 사용하면 async 작업들을 묶어서 그룹의 작업이 끝나는 시점을 추적할 수 있음. 이 때, async 작업들이 같은 큐, 스레드에 있지 않더라도 그룹화하는 것이 가능하다. (사진 참고)

https://sujinnaljin.medium.com/ios-%EC%B0%A8%EA%B7%BC%EC%B0%A8%EA%B7%BC-%EC%8B%9C%EC%9E%91%ED%95%98%EB%8A%94-gcd-7-4d9dbe901835

 

DispatchGroup은 async에서 주로 사용된다. 왜냐하면 sync로 동작하는 작업들은 끝나는 시점을 알 수 있는데, async 작업들은 끝나는 시점을 예측할 수 없기 때문임. 따라서 async로 처리되는 작업들의 종료 시점을 추적할 때, DispatchGroup이 유용하게 사용될 수 있다. 

 

DispatchQueue.global().sync

 

참고로 위와 같은 sync 메서드에는 DispatchGroup 파라미터가 존재하지 않음.

 

 

 

DispatchGroup의 사용 방법

  • DispatchGroup은 특별한 초기화 구문이 없음. 바로 인스턴스를 만들어서 사용하며, 그룹화 할 작업들은 같은 인스턴스로 지정해주면 됨.
  • async 에서 봤던 방법으로 각 비동기 작업에 DispatchGroup을 할당해주는 방법이 있고,
  • enter(), leave() 등의 DispatchGroup 메서드를 사용하여 작업을 관리하는 방법도 있음
let group = DispatchGroup()

// 그룹에 블록이 들어갔음을 알림
group.enter()

DispatchQueue.global().async {
    // 비동기 작업 수행
    print("비동기 작업 실행 중 1")
}

// 모든 작업이 완료될 때까지 기다림
group.notify(queue: .main) {
    print("모든 작업 완료")
}

DispatchQueue.global().async {
    // 비동기 작업 수행
    print("비동기 작업 실행 중 2")

    // 작업 완료 후 그룹에서 나갔음을 알림
    group.leave()
}

// 출력
// "비동기 작업 실행 중 1"
// "비동기 작업 실행 중 2"
// "모든 작업 완료"

 

 

enter()

  • DispatchGroup에 블록이 추가되었음을 명시적으로 알리는 역할을 수행.
  • 비동기 작업을 시작하기 전에 그룹에 블록이 들어갔음을 알리기 위해 enter()를 호출함
  • 작업이 완료되면, leave()를 호출하여 그룹에서 블록이 나갔음을 알린다.

 

leave()

  • DispatchGroup 내의 블록이 실행 완료되었음을 명시적으로 나타냅니다.
  • enter() 메서드와 함께 짝을 이루며 사용되기 때문에, enter()의 호출마다 대응하는 leave() 호출이 필요하다.

 

notify()

https://developer.apple.com/documentation/dispatch/dispatchgroup/2016066-notify

  • 현재 그룹의 모든 작업이 완료되었을 때, 파라미터로 지정된 속성에 맞게 작업을 큐에 제출합니다.
  • 위 코드에서 사용한 바로는, 그룹 내의 모든 작업이 완료되었을 때 메인 큐로 print("모든 작업 완료")라는 작업을 제출한다는 의미.
  • 해당 작업이 호출되면, 그룹 내의 작업은 비어있게 된다.

 

 

wait()

  • 제출된 작업이 완료될 때까지 동기적으로 대기시킴
  • 즉, 그룹 내의 작업이 완료되기 전까지, 현재 wait() 메서드가 할당된 스레드를 차단시킨다.

 

wait()을 사용하지 않았을 경우

let group = DispatchGroup()

group.enter()

DispatchQueue.global().async {
    print("비동기 작업 실행 중 1")
}

DispatchQueue.global().async {
    print("비동기 작업 실행 중 2")
    group.leave()
}

print("그룹 내의 작업이 완료되면 호출됩니다.")


// 출력
// "그룹 내의 작업이 완료되면 호출됩니다."
// "비동기 작업 실행 중 1"
// "비동기 작업 실행 중 2"

 

  • 위 작업들은 비동기 작업이기 때문에 출력 작업이 먼저 이루어질 수 있음.

 

wait()을 사용했을 경우

let group = DispatchGroup()

group.enter()

DispatchQueue.global().async {
    print("비동기 작업 실행 중 1")
}

DispatchQueue.global().async {
    print("비동기 작업 실행 중 2")
    group.leave()
}

group.wait()
print("그룹 내의 작업이 완료되면 호출됩니다.")


// 출력
// "비동기 작업 실행 중 1"
// "비동기 작업 실행 중 2"
// "그룹 내의 작업이 완료되면 호출됩니다."

 

  • 실행되는 스레드를 차단하기 때문에 그룹 내의 모든 작업이 완료된 후, 출력이 이루어짐
  • 만약 group.leave()를 호출하지 않음으로써 그룹 내의 작업 완료가 명시되지 않을 경우, 출력은 끝까지 이루어지지 않음

 

만약 그룹 내의 작업이 너무 늦어질 경우?

group.wait(timeout: .now() + 10)
  • 위처럼 timeout을 통해 제한된 시간 내에 작업이 완료되지 않으면 차단을 해제함
  • 이 때, 시간 내에 완료되지 않은 작업을 멈추는 것이 아니라 다른 스레드에서 진행
  • 정의를 보면 알겠지만, 만약 시간 제한 내에 완료되지 않으면 DispatchTimeoutResult 타입을 내보내게 됨
  • 이를 통해 해당 작업이 완료되었는지, 안되었는지 여부에 따라 처리도 가능함
let result = group.wait(timeout: .now() + 10)

switch result {
case .success:
case .timedOut:
}

 

 

주의할 점 1. 메인 스레드 피하기

  • wait() 메서드를 호출할 때, 호출된 스레드를 정지하는 것을 주의해야 한다.
  • 만약 이 메서드가 메인 스레드에서 호출되면 앱의 UI가 멈추게 된다. 
  • 따라서 wait() 메서드는 메인 큐가 아닌 다른 큐에서 호출하도록 해야 한다. (위 사용 예시 코드처럼 사용하면 큰일날 수 있음!)

 

주의할 점 2. 같은 큐 피하기

  • DispatchGroup의 작업과 wait()의 호출이 같은 큐에서 이루어지는 것은 데드락을 발생시킬 수 있음
  • 데드락은 두 개 이상의 작업이 서로를 기다리며 영원히 진행되지 않는 상황을 말함
  • DispatchGroup의 작업이 수행되기 전까지 wait은 차단하게 된다. 따라서 작업은 아예 시작되지 않고, 기다리게 되기 때문에 데드락이 발생함.

 

Race Condition

동시성 처리에서는 여러 발생할 수 있는 문제점들이 있음. 그 중 하나가 race condition임

  • 여러 스레드 또는 프로세스가 동시에 같은 자원에 접근하여, 자원의 상태가 예기치 않게 변할 수 있는 상황
  • 이로 인해 비정상적인 동작이나 예상치 못한 결과를 초래할 수 있음

예시로 은행 계좌가 있음

  • 계좌에는 100원이 있음
  • A와 B라는 두 사람이 같은 은행 계좌에 접근하여 돈을 인출하려고 함
  • A와 B가 동시에 5원을 인출하는 작업을 시작
  • A는 잔액을 확인하고, 100원이 있는 것을 확인
  • 이와 동시에 B 또한 100원이 있는 것을 확인
  • A가 5원을 인출하여 잔액은 5원으로 남게 됨
  • 이와 동시에 B 또한 5원을 인출하여 잔액은 5원으로 남게 됨
  • 남은 잔액은 0원이어야 하지만, 두 인출 작업이 동시에 이루어져 최종적으로 잔액이 5원이 되는 불상사가 발생함

 

Race Condition 예시 1

var cards = [1, 2, 3, 4, 5, 6, 7, 8, 9]

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

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

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

print("최종 카드: \(cards)")
  • 1부터 9까지의 카드가 존재하고, 각 인원들은 카드 리스트를 앞에서부터 3개씩 뽑아감
  • 예상되는 최종적으로 남은 카드는 []여야 함.
  • 하지만 코드 실행 결과는 엉망임
유리: 1 카드를 뽑았습니다!
짱구: 1 카드를 뽑았습니다!
철수: 1 카드를 뽑았습니다!
짱구: 3 카드를 뽑았습니다!
짱구: 5 카드를 뽑았습니다!
유리: 3 카드를 뽑았습니다!
유리: 7 카드를 뽑았습니다!
철수: 8 카드를 뽑았습니다!
철수: 9 카드를 뽑았습니다!
최종 카드: [9]
  • 해당 결과는 일관되지 않고, 계속해서 변경된다.
  • 이렇게 하나의 배열에 여러 스레드가 동시에 접근하기 때문에 예상치 못한 결과를 낳게 되는 것임

 

예시 2

var sharedCount = 0

let group = DispatchGroup()

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        sharedCount += 1
    }
}

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        sharedCount += 1
    }
}

DispatchQueue.global().async(group: group) {
    for _ in 1...1000 {
        sharedCount += 1
    }
}

group.notify(queue: .main) {
    print(sharedCount)
}

 

  • 각 비동기 작업은 sharedCount를 1000번 반복하여 더해줌
  • 그렇다면 3개의 작업이 마무리되었을 경우, sharedCount의 예상 결과 값은 3000이 나와야 함
  • 하지만 실행해보면, 결과는 계속 달라짐 e.g. 2888, 2999, 2988 ...

 

Thread Safe

  • Thread-Safe하다는 것은 여러 스레드가 동시에 같은 자원에 접근할 때, 자원의 상태가 일관되도록 작동하기 때문에 스레드로부터 안전하다는 것임
  • 따라서 위와 같은 Race Condition의 발생 이유는 Swift의 배열이나 정수 타입이 Thread-Safe 하지 않기 때문임 (이외에도 Swift의 대부분의 타입들은 모두 Non-Thread-Safe 함)
  • 이런 동시성과 관련된 문제를 해결할 수 있는 방법에는 NSLock, 세마포어, 뮤텍스 등 여러 가지가 있음 

 

Dispatch Semaphore

  • 여러 실행 컨텍스트에 걸쳐 리소스에 대한 접근을 제어하는 카운팅 세마포어를 사용하는 객체
  • 호출 스레드가 차단될 필요가 있을 때만 커널에 호출을 함.
  • 호출 세마포어가 차단될 필요가 없으면 커널 호출이 이루어지지 않음
  • 세마포어 카운트를 증가시키려면 signal() 메서드를 호출하고, 감소시키려면 wait() 메서드를 호출

 

DispatchSemaphore(value: 3)
  • 세마포어는 공유 자원에 접근할 수 있는 스레드의 수를 제한하는 역할을 한다. 
  • 위 코드에서 value를 3으로 지정했다면, 접근할 수 있는 스레드의 수는 3개인 것임

 

Dispatch Semaphore 카운팅

let semaphore = DispatchSemaphore(value: 3)

DispatchQueue.global().async(group: group) {
    semaphore.wait()  // 카운트 감소, 접근 가능한 스레드의 수 3 - 1 = 2
    
    for _ in 1...1000 {
        sharedCount += 1
    }
    
    semaphore.signal()  // 카운트 증가, 접근 가능한 스레드의 수 2 + 1 = 3
}

 

 

  • 주석을 보면 알 수 있듯이,
    • 하나의 스레드가 접근하면 wait()을 통해 카운트를 감소시켜주고
    • 작업이 끝나면 signal()을 통해 카운트를 다시 증가시켜준다.
    • 반드시 wait()과 signal()의 짝을 맞춰주어야 한다.

 

DispatchSemaphore를 사용한 문제 해결

let group = DispatchGroup()
let semaphore = DispatchSemaphore(value: 1)

var sharedCount = 0

DispatchQueue.global().async(group: group) {
    semaphore.wait()
    for _ in 1...1000 {
        sharedCount += 1
    }
    semaphore.signal()
}

DispatchQueue.global().async(group: group) {
    semaphore.wait()
    for _ in 1...1000 {
        sharedCount += 1
    }
    semaphore.signal()
}

DispatchQueue.global().async(group: group) {
    semaphore.wait()
    for _ in 1...1000 {
        sharedCount += 1
    }
    semaphore.signal()
}

group.notify(queue: .main) {
    print(sharedCount)
}
  • 위 코드의 우리가 원하는 결과인 3000이 출력됨

 

DispatchSemaphore를 활용한 동기화

  • semaphore는 동기화 작업도 가능함
  • 즉, A작업이 완료되기 전까지 B작업이 수행되지 않도록 할 수 있음
//DispatchSemaphore 초기값 0으로 설정
let semaphore = DispatchSemaphore(value: 0)

let work1 = DispatchWorkItem {
    print("작업_1 진행중")
    print("작업_1 진행완료")
    semaphore.signal()
}

let work2 = DispatchWorkItem {
    semaphore.wait()
    print("작업_2 진행중")
    print("작업_2 진행완료")
}

DispatchQueue.global().async(execute: work1)
DispatchQueue.global().async(execute: work2)

/* 출력
작업_1 진행중
작업_1 진행완료
작업_2 진행중
작업_2 진행완료
/*

 

  • 위 작업은 work1이 수행된 이후, work2가 수행된다.
  • 이유는 세마포어를 0으로 설정함으로써, wait() 메서드를 호출하게 되면 즉시 스레드가 차단됨
  • 이후, work1의 signal() 메서드가 호출되면, 카운트가 증가하면서 wait() 메서드가 해제되는 원리임

 

이 외의 해결책들

  • NSLock
  • SerialQueue와 sync
  • Actor

개인적으로 Actor 자체가 독립적인 작업을 허용해주는 객체이기 때문에 직관적인 Actor를 사용하는 것이 좋아보임

 

 

Reference

'개린이 이야기' 카테고리의 다른 글

면접 후기  (1) 2024.07.19
캐시정책에 관하여  (0) 2024.01.24
Downsampling(다운샘플링)에 관하여  (0) 2024.01.20
이미지 캐싱에 관하여  (0) 2024.01.18
MVVM(Model-View-ViewModel)에 관하여  (0) 2023.07.12