JosephCha의 개발일지

동시성 프로그래밍(개념, GCD, OperationQueue) 본문

iOS

동시성 프로그래밍(개념, GCD, OperationQueue)

JosephCha 2023. 8. 16. 20:08
반응형

프로세서

  • 하드웨어적인 측면에서 컴퓨터 내에서 프로그램을 수행하는 하드웨어 유닛
  • 중앙처리장치(Centeral Processing Unit-CPU)가 대표적
  • 한 컴퓨터가 여러 개의 프로세서를 갖는다면 멀티 프로세서

코어

  • 코어는 프로세서의 주요 연산회로
  • 여러 개의 코어를 가진 프로세서를 멀티 코어라고 함
  • 멀티 코어는 단일 CPU에 여러 개의 코어 또는 처리 장치를 가지고 있음

프로그램과 프로세스

  • 프로그램은 일반적으로 보조기억장치에 저장된 실행코드 즉, 생명이 없는 상태
  • 프로세스는 프로그램을 구동하여 프로그램 자체와 프로그램의 상태가 메모리상에서 실행되는 작업 단위
  • 동시에 여러 개의 프로세스(or 스레드)를 운용하는 시분할 방식을 멀티태스킹

스레드

  • 하나의 프로세스 내에서 실행되는 작업흐름의 단위
  • 보통 한 프로세스는 하나의 스레드를 가지고 있지만, 프로세스 환경에 따라 둘 이상의 스레드를 동시에 실행할 수 있음. 이러한 방식을 멀티스레딩
  • 프로그램 실행이 시작될 때부터 동작하는 스레드를 메인 스레드라 하고 그 외에 나중에 생성된 스레드를 서브 스레드 또는 세컨더리 스레드
  • Thread Safe: 멀티 스레드 상황에서 함수나 프로퍼티, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램 실행에 문제가 없음을 뜻함. 즉 하나의 함수가 한 스레드로부터 호출되어 실행 중일 때, 다른 스레드가 그 함수를 호출하여 동시에 함께 실행되더라도 각 스레드에서의 함수의 수행 결과가 올바로 나오는 것

동시성 프로그래밍(Concurrency Programming)

  • 논리적인 용어로 동시에 실행되는 것처럼 보이는 것
  • 싱글 코어나 멀티 코어에서 멀티스레드를 동작시키기 위한 방식으로 멀티 태스킹을 위해 여러 개의 스레드가 번갈아 가면서 실행되는 방식
    • 멀티 코어에서 멀티 스레드를 이용하여 이 동시성을 만족할 경우에는 실제 물리적 시간으로 동시에 실행됨
  • 동시성을 이용한 싱글 코어의 멀티 태스킹은 각 스레드들이 병렬적으로 실행되는 것처럼 보이지만 사실은 서로 번갈아 가면서 실행되고 있는 방식
  • 장점
    • 멀티 코어에서 멀티스레드를 동작할 시(병렬성 프로그래밍), 여러 작업을 병렬로 실행하여 성능 개선시킬 수 있음
    • 비동기적인 작업을 통해 UI를 블로킹하지 않고 앱의 응답성을 향상시킬 수 있음

병렬성 프로그래밍(Parallel)

  • 물리적으로 동시에 정확히 동시에 실행되는 것
  • 멀티 코어에서 멀티스레드를 동작시키는 방식으로, 한 개 이상의 스레드를 포함하는 각 코어들이 ‘실제로 동시에' 실행되는 성질
  • 보통 동시성에 비해서 병렬성은 각 코어들이 동시에 실행되므로 CPU의 유휴 시간이 줄어들어 성능이 좋음
    • 유휴 시간: 컴퓨터가 작동 가능한데도 작업을 하지 아니하는 시간. 주로 컴퓨터의 입력ㆍ출력을 위한 대기 시간
    • 그러나 CPU 수보다 처리해야 할 프로세스나 스레드 수가 많다면 CPU를 사용하기 전까지 대기가 발생

동시성, 병렬성 참고사항

  • 병렬성이 멀티 코어 + 멀티 스레드 작업이라 항상 더 좋을 것 같지만, 동시성으로 접근하는게 좋은 경우도 있음
    • ex. 네트워크 통신, 파일 저장 및 로드 등의 I/O 작업은 CPU가 거의 일을 하지 않고 요청 후 응답이 올 때까지 대기상태에 있게됨. 이때 한 개의 CPU가 I/O 요청 후 기다리는 동안 다른 작업을 처리하도록 하면 효율적
    • 물론 병렬작업으로 여러 CPU가 동시에 다수 I/O 작업을 실행하게 할 수 있으나, 물리적인 CPU의 경우 개수 제한이 있기 때문에 동시성으로 접근하는 것이 좋음
  • 동시성과 병렬성을 혼용해서 처리하는 경우도 있음
  • 동시성은 작업이 바뀔 때 문맥 교환(Context Switching)이 발생하고, 동시 작업이 너무 많다면 문맥 교환의 오버헤드로 인해 싱글 코어에서 싱글 스레드로 작업하는 것이 더 빠를 수 있음
  • 코어가 N배로 늘어나더라고 성능이 N배로 늘어나는 것은 아님(암달의 법칙) : 프로그램의 모든 부분을 Parallel 하게 작성할 수 없고 반드시 Sequential하게 동작해야 하는 부분들이 존재

비동기 프로그래밍(Async Programming)

  • 다른 작업의 완료와 관계 없이 바로 다음 코드를 실행하는 병렬처리 방식

동기 프로그래밍 (Sync Programming)

  • 앞에 작업이 완료되어야 다음 작업을 수행할 수 있는 직렬처리 방식

iOS 환경 동시성 프로그래밍 지원 종류

  • GCD(Grand Central Dispatch): 멀티 코어와 멀티 프로세싱 환경에서 최적화된 프로그래밍을 할 수 있도록 애플이 개발한 기술
  • Operation Queue : 비동기적으로 실행되어야 하는 작업을 객체 지향적인 방법으로 사용함
  • Thread : 멀티스레드 프로그래밍을 위한 애플에서 제공하는 스레드 클래스

여러 스레드를 사용할 때 발생할 수 있는 문제점

Data Race Condition

  • 데이터 레이스(데이터 경쟁 상태)는 멀티 스레드를 이용하는 환경에서, 같은 데이터를 여러 스레드에서 동시에 읽거나 쓰려고 할 때 경쟁하게 되는 현상

임계 영역 (Critical Section)

  • 여러 프로세스가 데이터를 공유하며 수행될 때, 각 프로세스에서 공유 데이터를 접근하는 프로그램 코드 블록
    • 여러 프로세스가 동일 자원을 동시에 참조하여 값(공유하는 변수명, 파일 등)이 오염될 위험 가능성이 있는 영역

해결방법 (상호배제)

상호배제: 공유자원을 모든 쓰레드/프로세스가 원하는 때 언제든지 접근할 수 있도록 허용하지 않고, 쓰레드/프로세스의 접근을 제한적으로 허용하는 방식

  1. 뮤텍스
    • 임계구역에 쓰레드/프로세스가 하나만 들어가도록 하는 기법
    • 한 프로세스에 의해 소유될 수 있는 Key를 기반으로 한 상호배제 기법
      • Key에 해당하는 어떤 객체(Object)가 있으며, 이 객체를 소유한 스레드/프로세스만이 공유자원에 접근할 수 있음
    • 다중 프로세스들의 공유 리소스에 대한 접근을 조율하기 위해 동기화(Synchronization) 또는 락(Lock)을 사용
    • 즉, 뮤텍스 객체를 두 스레드가 동시에 사용할 수 없음
  2. 동시 프로그래밍에서 공유 불가능한 자원의 동시 사용을 피하기 위해 사용하는 알고리즘
  3. 세마포어
    • 사용하고 있는 스레드/프로세스의 수를 정해진 수 만큼만 상호배제를 달성
    • 공유 자원에 접근할 수 있는 프로세스의 최대 허용치만큼 동시에 사용자가 접근할 수 있으며, 각 프로세스는 세마포어의 값을 확인하고 변경할 수 있음
    • 자원을 사용하지 않는 상태가 될 때, 대기하던 프로세스가 즉시 자원을 사용하고. 이미 다른 프로세스에 의해 사용중이라는 사실을 알게 되면, 재시도 전에 일정시간 대기
  4. 멀티 프로그래밍 환경에서 공유된 자원에 대한 접근을 제한하는 방법
  • 단점
    • 처리시간이 느려짐
    • 쓰레드/프로세스의 컨텍스트 스위칭(context switching) 이 일어나기 때문. 먼저 자원을 점유하고 있던 쓰레드/프로세스가 뮤텍스(세마포어)를 언락(포스트) 했을 때, 대기하고 있던 다른 쓰레드/프로세스가 깨어나면서 컨텍스트 스위칭이 일어남. 이러한 컨텍스트 스위칭이 잦으면 오버헤드가 발생해 성능이 떨어짐
    • 따라서 개인적으로 치명적이라 생각되는 데이터 레이스에만 상호배제 처리하고 성능항샹을 위해 데이터 레이스를 허용하는 방향이 바람직해 보임

Dead Lock

교착상태(Dead Lock)은 상호 배제에 의해 나타나는 문제점으로, 둘 이상의 프로세스들이 자원을 점유한 상태에서 서로 다른 프로세스가 점유하고 있는 자원을 요구하며 무한정 기다리는 현상

발생조건

4가지 조건이 동시에!! 성립해야 함

  1. 상호배제 (Mutual Exclusion)
    • 자원은 한 번에 한 프로세스만 사용이 가능
  2. 점유와 대기 (Hold and Wait)
    • 최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용하고 있는 자원을 추가로 점유하기 위해 대기하는 프로세스가 있어야 함
  3. 비선점 (Non Preemptive)
    • 다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼앗을 수 없음
  4. 순환 대기 (Circular wait)
    • 프로세스의 집합 {p0, p1, ... , pn}이 있을 때, p0은 p1이 점유한 자원을 대기하고 p1은 p2가 점유한 자원을 대기하고 pn은 p0이 점유한 자원을 요구해야 함

해결방법

  1. 교착 상태 예방: 교착 상태가 발생하기 전에 미리 조치를 취하는 방식으로, 교착 상태 발생 조건 중 하나를 제거함으로써 해결함 
    • 자원의 상호배제 조건 방지
      • 모든 자원을 공유 허용
    • 점유와 대기 조건 방지
      • 모든 자원에 대해 선점 허용
    • 비선점 조건 방지
      • 필요 자원을 한 번에 모두 할당하기
    • 순환 대기 조건 방지
      • 자원에게 순서 부여를 통해 프로세스 순서의 증가 방향으로만 자원 요청
    • 단점 : 자원 낭비가 심함
  2. 교착 상태 회피 : 프로세스가 자원을 요구할 때, 시스템은 자원을 할당한 후에도 안정 상태로 남아있는가를 확인하여 교착 상태를 회피하는 방법
    • 데드락이 발생할 위험이 있는 자원이 생기면 자원 할당 요청을 보류하여 시스템을 안전하게 유지함
    • 단점 : 오버헤드가 많이 발생함
    • 예시) Banker's Algorithm프로세스가 자원을 요구할 때, 시스템은 자원을 할당한 후에도 안정상태로 남아있는 지를 사전에 검사하여 교착 상태를 회피하는 기법. 안정 상태에 있으면 자원을 할당하고, 그렇지 않으면 다른 프로세스들이 자원을 해지할 때까지 대기함
    • E.J.Dijkstra가 제안한 방법으로 은행에서 모든 고객의 요구가 충족되도록 현금을 할당하는 데에서 유래한 기법. 프로세스가 자원을 요구할 때, 시스템은 자원을 할당한 후에도 안정상태로 남아있는 지를 사전에 검사하여 교착 상태를 회피하는 기법. 안정 상태에 있으면 자원을 할당하고, 그렇지 않으면 다른 프로세스들이 자원을 해지할 때까지 대기함
  3. 교착 상태 탐지 : 데드락이 발생하면 빠르게 발견하고 문제를 해결하는 것
    • 자원 할당 그래프(Resource Allocation Graph)를 통해 교착 상태 탐지 가능
    • 단점: 자원을 요청할 때마다 탐지 알고리즘을 실행하면, 오버헤드가 발생함
  4. 교착 상태 회복: 교착 상태를 일으킨 프로세스를 종료하거나 할당된 자원을 해제하면서 회복
    • 프로세스 종료 방법
      • 교착 상태의 프로세스 모두 중지
      • 교착 상태가 제거될 때까지 한 프로세스씩 중지
    • 자원 선점 방법
      • 자원을 빼앗긴 프로세스는 강제 종류 이후 재시작
      • 교착 상태에 빠진 프로세스가 필요로 하는 자원을 강제로 가져옴

GCD

  • 멀티코어와 멀티 프로세싱 환경에서 최적화된 프로그래밍을 할 수 있도록 애플이 개발한 기술
  • 기본적으로 스레드 풀의 관리를 프로그래머가 아닌 운영체제에서 관리하기 때문에 프로그래머가 태스크(작업)를 비동기적으로 쉽게 사용할 수 있음
  • 프로그래머가 실행할 태스크(작업)을 생성하고 Dispatch Queue에 추가하면 GCD는 태스크(작업)에 맞는 스레드를 자동으로 생성해서 실행하고 작업이 종료되면 해당 스레드를 제거

DispatchQueue

  • 앱의 메인 스레드 또는 백그라운드 스레드에서 연속적으로 또는 동시에 작업 실행을 관리하는 객체
  • 애플리케이션이 블록 객체의 형태로 작업을 제출할 수 있는 FIFO큐
  • DispatchQueue에 등록된 작업은 시스템에서 관리하는 스레드 풀에서 실행됨
  • 앱의 메인 스레드를 나타내는 디스패치 큐를 제외하고, 시스템은 작업을 실행하는 데 사용하는 스레드를 보장하지 않음
  • 개발자는 작업 항목을 동기적으로 또는 비동기적으로 예약함. 작업 항목을 동기화하여 예약할 때, 코드는 해당 항목이 실행을 완료할 때까지 기다립니다. 작업 항목을 비동기적으로 예약하면, 작업 항목이 다른 곳에서 실행되는 동안 코드가 계속 실행됨

Dispatch Queue 종류 (동시성)

  1. Serial Queue : 큐에 등록된 작업들을 하나의 스레드에서 수행
  2. Concurrent Queue : 큐에 등록된 작업들을 여러 스레드에서 동시에 수행

작업 등록 종류

1. Sync(동기) : 큐에 태스크를 등록한 이후에 해당 태스크가 완료될 때까지 다른 작업들의 스레드를 멈춤

 

주의사항!!
메인 큐에서 작업 항목을 동기적으로 실행하려고 하면 교착 상태가 발생함. 
메인스레드에서 동기적으로 실행할 작업을 메인스레드에 배치해서 작업을 완료해야하는데, 메인스레드가 동기때문에 멈춰버려 교착상태 일어남

2. Async(비동기) : 큐에 태스크를 등록하면 태스크의 완료여부와 상관없이 다른 태스크들을 수행하고 큐에 태스크들을 등록

DispatchSemaphore

  • iOS에서의 세마포어 방식
  • Signal() 메서드를 호출하여 세마포어 카운트를 증가시키고, wait() 또는 타임아웃을 지정하는 변형 중 하나를 호출하여 세마포어 카운트를 줄이며 임계영역에 동시 접근가능한 작업 수를 제한시킴
// 공유 자원에 접근할 수 있는 쓰레드 수 지정
let semaphore: DispatchSemaphore = .init(value: 2)

for i in 1...3 {
  semaphore.wait() // semaphore 감소
  DispatchQueue.global().async() {
    print("공유 자원 접근 시작 \\(i)")
    sleep(3)
    print("공유 자원 접근 종료 \\(i)")
    semaphore.signal() // semaphore 증가
  }
}

// 하기와 같이 출력됨
공유 자원 접근 시작 1 
공유 자원 접근 시작 2 
공유 자원 접근 종료 2 
공유 자원 접근 종료 1 
공유 자원 접근 시작 3 
공유 자원 접근 종료 3
  • 두 스레드가 특정 이벤트의 완료 상태를 동기화 하는 경우, init시 value파라미터를 0으로 지정
    • 즉 특정 작업의 완료를 기다릴 때 사용

Dispatch Group

  • 비동기 작업들을 동기 작업처럼 처리할 수 있는 역할
  • 그룹에 등록된 모든 작업 실행들이 완료되면 그룹은 완료 핸들러를 실행함
  • 세마포어는 하나의 이벤트의 완료만 기다리지만 그룹은 여러개의 작업이 완료되기를 기다릴 수 있음
let group: DispatchGroup = .init()

DispatchQueue.global().async(group: group, execute: {
    print(1)
})

DispatchQueue.global().async(group: group, execute: {
    print(2)
})

DispatchQueue.global().async(group: group, execute: {
    print(3)
})

group.notify(queue: .main) {
    print("끝")
}
  • 그룹의 모든 작업이 완료될때까지 동기적으로 기다릴 수도 있음, 기다림에 대한 timeout도 설정 가능함
let group: DispatchGroup = .init()

DispatchQueue.global().async(group: group, execute: {
    print(1)
})

DispatchQueue.global().async(group: group, execute: {
    print(2)
})

DispatchQueue.global().async(group: group, execute: {
    print(3)
})

group.wait()

print("끝")
  • wait 메소드를 메인 스레드에서 실행하면 안됨
    • wait메소드는 함수를 호출한 스레드를 블럭하는데, 모든 작업이 완료될때까지 UI가 멈춤
  • 그룹 내 태스크가 wait를 호출한 스레드에 할당되면 데드락 발생함
    • 태스크가 수행될 스레드가 wait에 의해 멈추기 때문

Operation

  • 단일 작업과 관련된 코드 및 데이터를 나타내는 추상 클래스
  • Operation 클래스는 추상 클래스이기 때문에 직접 사용하지 않고 대신 서브클래스를 사용하거나 시스템 정의 서브클래스(NSInvocationOperation 또는 BlockOperation) 중 하나를 사용하여 실제 작업을 수행함
  • 추상적임에도 불구하고, Operation의 기본 구현에는 작업의 안전한 실행을 조정하는 중요한 논리가 포함되어 있음
  • Operation에는 내부적으로 상태값들이 존재하고 상태를 읽어서 특정 처리를 할 수 있는 장점이 존재
    • pending: Queue에 Operation(task)가 추가 될 경우
    • Ready: Pending에서 모든 조건 만족 시 해당 상태로 변경
    • Executing: start() 메소드를 호출하여 작업이 시작된 경우
    • Finished: 작업이 완료된 경우 해당 상태로 변경되고 Queue에서 Operation이 제거
    • Cancelled: 해당 상태는 3개의 상태 Pending, Ready, Executing에서만 변경 가능하고, Cancelled 상태가 되었다가 곧바로 Finished 상태로 변경

 

OperationQueue

  • Operation 객체들의 우선순위와 준비상태에 따라 해당 객체들을 호출함
    • OperationQueue에 있는 모든 Operation의 queuePriority가 동일하고 isReady 속성이 true로 반환되면 추가한 순서대로 작업을 호출함. OperationQueue은 항상 다른 Operation의 우선순위보다 높은 우선순위의 Operation를 호출함
  • OperationQueue에 추가한 후에는 Operation이 완료될 때까지 대기열에 남아 있음
  • 완료되지 않은 작업으로 작업 대기열을 일시 중지하면 메모리 누수가 발생할 수 있음

Operation 사용 방법

  • Operation 클래스를 subclassing하여 사용하고, main() 메소드를 overriding 해서 사용할 것
  • main(): setup 용도의 함수이며 override해서 사용 (가장 먼저 실행)
  • start()
    • 스레드를 생성하든 비동기 함수를 호출하든 이 메소드에서 수행
    • 이 메소드 사용 시, 클라이언트가 현재 실행 중임을 알 수 있도록 KeyPath 경로에 대한 KVO 알림을 전달하여 이를 수행
    • super() 호출 금지
  • isAsynchronous
  • isExecuting
  • isFinished

Operation 단순 사용법 - BlockOperation을 이용

let blockOperation = BlockOperation {
    print("Executing!")
}

let queue = OperationQueue()
queue.addOperation(blockOperation)

추상 클래스 Operation을 구현해서 모듈화

  • Operation을 상속받아서 구현
final class ContentImportOperation: Operation {

    let itemProvider: NSItemProvider

    init(itemProvider: NSItemProvider) {
        self.itemProvider = itemProvider
        super.init()
    }

    override func main() {
        guard !isCancelled else { return }
        print("(main) Importing content...")

// .. import the content using the item provider

    }
}
  • addOperation 선언 시 자동으로 실행
    • 주의: task는 한 번만 실행될 수 있으므로 완료 or 취소 상태일때는 더이상 동일한 인스턴스를 다시 시작 불가
let queue = OperationQueue()

let fileURL = URL(fileURLWithPath: "..")
let contentImportOperation = ContentImportOperation(itemProvider: NSItemProvider(contentsOf: fileURL)!)

contentImportOperation.completionBlock = {
    print("Importing completed!")
}

queue.addOperation(contentImportOperation)

// (main) Importing content...// Importing completed!

Dependency를 이용한 작업 구현

  • B작업은 A작업이 끝난 이후에만 실행되도록 하는 방법?
    • B.addDependency(A)로 사용
let fileURL = URL(fileURLWithPath: "..")
let contentImportOperation = ContentImportOperation(itemProvider: NSItemProvider(contentsOf: fileURL)!)
contentImportOperation.completionBlock = {
    print("Importing completed!")
}

let contentUploadOperation = ContentUploadOperation(itemProvider: NSItemProvider(contentsOf: fileURL)!)
contentUploadOperation.addDependency(contentImportOperation)
contentUploadOperation.completionBlock = {
    print("Uploading completed!")
}

queue.addOperations([contentImportOperation, contentUploadOperation], waitUntilFinished: true)

// (main) Importing content...// (main) Uploading content...// Importing completed!// Uploading completed!

GCD vs Operation

  • Operation, Operation Queue는 내부적으로 GCD를 사용
  • GCD는 종속성 (A다음에 B가 꼭 실행되게끔 설정) 설정이 어려움, Operation은 쉬움
  • Operation은 GCD에 비해 단순하게 사용하지 않지만, 작업 간에 종속성 추가취소일시 중지가 가능

 

참고

https://www.boostcourse.org/mo326/lecture/16866?isDesc=false

https://mentha2.tistory.com/245

https://chelseashin.tistory.com/40

https://yechoi.tistory.com/58

https://ios-development.tistory.com/799

 

'iOS' 카테고리의 다른 글

RxSwift? 이거 하나로 종결 (전체 요약)  (2) 2023.08.09
Coordinator 패턴  (1) 2023.08.09
iOS MVVM 패턴  (0) 2023.08.09
SOLID 원칙  (0) 2023.08.09
네트워크 Endpoint  (0) 2023.08.09
Comments