Concurrency Programming

태훈김·2023년 11월 24일

쓰레드

cpu를 구매할 때 고려되는 core과 쓰레드에서의 쓰레드 의미인 것 같다.

하드웨어 시스템 수준에서 프로그램을 운영할 때 멀티 쓰레드 환경에서는 각각의 프로세스들을 여러 쓰레드에서 동시에 수행하게 만들어 준다. 시스템에서는 이런 쓰레드를 관리하는 스케줄러가 존재한다.

동시성을 지원하지 않는 경우에는 하나의 스레드 만 실행되는데 이 경우 다른 메서드나 함수 등등으로 분기되어서 작동하는 것을 모두 기다렸다가 실행한다.

동시성을 지원하는 경우에는 메인 프로세스 말고도 동작하는 추가 스레드를 생성하여 활용할 수 있는데, 애플리케이션의 메인 스레드와 독립적으로 작동한다.

따라서 멀티 스레드 환경에서는 어플리케이션의 응답성을 향상시킬 수 있다 : 사용자가 계산 과정을 기다리지 않아도 된다.

메인 쓰레드의 경우 사용자와 직접적으로 상호작용하는 UI관련 로직이나, 바인딩 기능들을 사용할 때 활용되어야 한다.

추가 쓰레드의 경우 계산 시간이 오래 걸리거나, UI에 직접적으로 적용되지 않는 기능을 사용할 때 활용되면 좋다.

비동기적으로 스레드를 사용하는 법

GCD(Grand Central Dispatch)

프로그래머가 작성하는 스레드 관리 코드를 시스템 수준까지 가져와서 적용시켜준다.

기능을 정의하고 그 기능을 적절한 Dispatch Queue에 추가하면 된다.

GCD에서는 필요한 쓰레드를 만들고, 그 쓰레드에서 실행할 기능을 스케줄링하는 작업을 처리한다 (그래서 queue인가?)

Dispatch Queues란?

사용자가 작성한 기능을 실행하는 부분에서 사용된다.

  • Serially : 한 번에 하나의 태스크만을 실행하고, 이 태스크가 완료될 때 까지 기다린 수 새 태스크를 시작한다. 순차적으로 실행
  • Concurrently: 이미 시작한 태스크가 완료될 때 까지 기다리지 않고 가능한 많은 태스크를 시작한다. ADHD있는 사람처럼,, 이거했다 저거했다 한다.

애플 문서를 살펴보았을 때 DispatchQueue 클래스는 DispatchObject 클래스를 상속받고 DispatchObject 클래스에는

  • activate() : 디스패치 오브젝트를 활성화
  • resume() : 디스패치 오브젝트에서 호출된 블록 오브젝트를 재개
  • suspend() : 디스패치 오브젝트에서 호출된 블록 오브젝트를 일시 정지

라는 함수들이 있었고

  • setTarget(queue: dispatch_queue_t?) : 현재 오브젝트의 디스패치큐를 특정하여 지정해준다.

라는 함수도 있었다.

이 클래스를 상속받는 대표적인 클래스로 DispatchQueue, DispatchGroup, DispatchSource 등이 있는데 지금은 DispatchQueue에 대해 알아보겠다.

Queue답게 지정한 기능을 FIFO로 처리한다.

Queue를 구분하는 큰 두 가지 갈래는 main과 global이 있는 것으로 보인다.

main 큐는 말 그래도 메인쓰레드를 관리하는 녀석이다. main.sync의 경우 UI등 정말 중요한 작업만 수행하기 때문에 프로그래머가 직접 접근할 경우 심각한 오류를 야기할 가능성이 있으므로 쓰지 말자! UI바인딩 등에서 비동기적 작업이 필요할 경우에는 main.async에서 작업하자!

global 큐는 시스템에서 전역적으로 사용되는 큐이다. 메인 쓰레드가 아닌 추가 쓰레드에서 작업이 필요한 경우 사용되어야 할 것 같고, QOS를 필요로 한다.

QOS란?

Quality-of-Service의 약자고 작업의 중요한 정도를 나타내는 일종의 우선순위 같은 느낌으로 이해된다.

userInteractive > userInitiated > default > utility > background > unspecified 순서로 우선순위를 가지고 custom한 qos를 만들수도 있어 보인다. 자세한 내용은 여기에서

테스트를 해 본 결과 QOS는 자원을 분배하는 우선순위의 느낌이다. 즉 똑같이 10초 걸리는 두 작업에 우선순위를 다르게 주면 QOS가 높은 작업은 한 8초만에 끝나고 낮은 작업은 12초만에 끝나는 식이다.

그렇기 때문에 UI처럼 빠르게 동작해야 하는 경우의 우선순위가 가장 높은 셈이다!

함수와 프로퍼티

각 DispatchQueue를 정의하고 난 뒤에는 sync 함수와 async 함수를 통해 태스크를 어떤 방식으로 실행할 수 있는지 지정할 수 있어 보인다.

  • sync : 맥락 상 Serially 태스크를 수행하는 것으로 추정됨
globalQueue.sync{
    sleep(1) // 스레드 멈추기
    function1() //print("1")
    function2() //print("2")
}
function3() //print("3")
결과 : 1 2 3
  • async: 마찬가지로 Concurrently 하게 태스크를 수행하는 것
globalQueue.async {
    sleep(1) // 스레드 멈추기
    function1() //print("1")
    function2() //print("2")
}
function3() //print("3")
====
결과 : 3 1 2
  • executable한 값으로 DispatchWorkItem 클래스가 있다.

아래 Operation과 비슷한 느낌으로 각 작업 단위를 캡슐화해서 작업할 수 있고 perform()함수로 동기적으로 실행하는 것도 가능하다.

let item1 : DispatchWorkItem = .init(qos: .default, block: function1)
globalQueue.async(execute: item1) // 1

이렇게 사용 가능하다.

  • asyncAfter() : dispatchworkitem을 언제 수행할 지 일정을 짜고, 바로 수행한다.
globalQueue.asyncAfter(deadline: .now() + 1, execute: function3) // 지금으로부터 1초 뒤에 수행
globalQueue.asyncAfter(wallDeadline: .now() + 1, execute: function3) // 같은 기능이지만, wallDeadline은 절대적 시스템시간을 기준으로 삼고 있기 때문에 .distantFuture같은 상대적 시간값에는 작동을 안한다.

Operation Queues

Operation 이란

수행할 작업을 캡슐화 하는데 사용하는 클래스 인스턴스이다. 직접 Operation()이런 식으로 사용하지 않는 추상 클래스이고, 하위 클래스 (NSInvocationOperation 혹은 BlockOperation)으로 정의하고 작업을 수행한다.

각 Operation 객체는 한 번 실행하고 나서 다시 실행하는데 사용할 수 없다. (단발성 개체라는 의미) 일반적으로는 OperationQueue에 추가하여 작업을 실행한다.

let item1: Operation = BlockOperation(block: function1)
item1.start()
item1.start() // 얘는 실행 안됨 신기하네,,

직접 사용하려면 start()메서드를 활용하여 수동으로 호출할 수 있지만, 준비상태가 아닌 작업을 직접 실행했을 때 예외가 발생해서 부적절할 수 있다. 웬만하면 OperationQueue로 작업하자

종속성

addDependency(Operation)으로 다른 작업과의 종속성을 추가할 수 있다. 종속성관계가 되면 모든 종속된 Operation들이 실행이 완료 될 때 까지 준비된것으로 간주되지 않는다.

let item1: Operation = BlockOperation(block: function1)
let item2: Operation = BlockOperation(block: function2)
let item3: Operation = BlockOperation(block: function3)
item1.addDependency(item3) // 종속성 추가
item1.addDependency(item2) // 종속성 추가
opQueue.addOperations([item1,
                       item2,
                      item3], waitUntilFinished: true)

결과 : 2 3 1

KVO- 프로퍼티들

Operation은 KVC(key-value coding)과 (Key-value observing)을 준수한다. 그래서 관련 프로퍼티들을 이용해서 상태를 확인할 수 있다. 위에서 볼드 처리된 완료나 준비 등의 상태를 확인해보자.

  • isCancelled : 취소되었는지?
  • isAsynchronous: 비동기 오퍼레이션인지?
  • isExecuting: 작업이 수행중인지?
  • isFinished: 작업이 끝났는지?
  • isReady: 작업이 준비 상태인지?
  • queuePriority
  • completionBlock

*주의

이러한 KVO 프로퍼티들을 활용해서 옵저버를 연결할 수는 있지만, UI업데이트를 위해 바인딩 하는 경우는 사용되면 안된다.

OperationQueue

앞서 말한 Opreation을 사용하여 작업들의 종속 관계를 관리한다거나 개별적으로 작업을 캡슐화 해서 동작하게 할 수 있다.

직접 start()오퍼레이션을 통해 실행하면 동기적으로 실행되고 OperationQueue를 사용했을 때 비동기적으로 실행할 수 있다.

Async - Await

Concurrency

Task. 비동기 작업 유닛

@frozen
struct Task<Success, Failure> where Success : Sendable, Failure : Error

이런 형태로 구현되어 있다. 여기서 Sendable 프로토콜은 한 동시성 도메인에서 다른 동시성 도메인으로 전송이 가능한 유형을 의미하는데,

  • Value type

  • Mutable storage가 없는 레퍼런스 타입

  • 함수와 클로저 (@Sendable로 표시)

    • let sendableClosure = { @Sendable (number: Int) -> String in
          if number > 12 {
              return "More than a dozen."
          } else {
              return "Less than a dozen"
          }
      } // 클로저의 예시

와 같은 것들은 Sendabled이다.

initial 함수로는 TaskPriority(옵셔널)와 async 클로저를 받는다. TaskPriority는 맥락상 DispatchObject의 QOS와 비슷한 의미인 것으로 보인다.

Task {
  function1()
}
DispatchQueue.global().async {
  function1()
}

엄밀하지는 않지만 비슷한 의미인 것 같다.

Async

Swift에서는 클로저 활용이나 completion handler를 활용하는 경우가 많이 있다. 이런 경우 비동기 호출간에 제어 흐름이 복잡해지는데 이를 해결하기 위해 나온 방식이다.

함수 자체에 async여부를 선택하여 적용할 수 있으므로 복잡한 비동기 호출을 명시적으로 확인할 수 있다.

func processImageData1(completionBlock: (_ result: Image) -> Void) {
    loadWebResource("dataprofile.txt") { dataResource in
        loadWebResource("imagedata.dat") { imageResource in
            decodeImage(dataResource, imageResource) { imageTmp in
                dewarpAndCleanupImage(imageTmp) { imageResult in
                    completionBlock(imageResult)
                }
            }
        }
    }
}
func loadWebResource(_ path: String) async throws -> Resource
func decodeImage(_ r1: Resource, _ r2: Resource) async throws -> Image
func dewarpAndCleanupImage(_ i : Image) async throws -> Image

이렇게 작업 단위의 async function으로 로직을 분리할 수 있다.

Await

모든 비동기 코드는 결국 동기적으로 진행되는 프로세스에 복귀해야한다. 그래서 async 키워드를 붙은 함수를 사용할 때는 await 키워드를 사용한다.

func processImageData() async throws -> Image {
  let dataResource  = try await loadWebResource("dataprofile.txt")
  let imageResource = try await loadWebResource("imagedata.dat")
  let imageTmp      = try await decodeImage(dataResource, imageResource)
  let imageResult   = try await dewarpAndCleanupImage(imageTmp)
  return imageResult
}
profile
궁금한걸 알아보는 사람

0개의 댓글