GGURUPiOS

동시성 프로그래밍 - Concurrency (2) Structured Concurrency (async-let, Task) 본문

Swift/동시성 프로그래밍

동시성 프로그래밍 - Concurrency (2) Structured Concurrency (async-let, Task)

꾸럽 2023. 4. 24. 15:34

Strucuted Concurrency

배경

스위프트에서 structured concurrency 는 structured programming 에 기반을 둔 개념임

간단히 말해서 구조화된 프로그래밍은 위에서 아래로 읽히기 때문에 제어 흐름과 변수의 수명을 이해하기 쉽게 함

그러나 오늘날의 프로그램은 비동기, 동기를 같이 사용하기 때문에 구조화된 프로그래밍을 사용하기 어려웠다. ( 예를들어, 네트워킹이 필요한 작업에서 콜백함수를 호출 한다던가 하는 )

그러나 async/await 는 구조화된 프로그래밍 기반으로 만들어 졌기 때문에, async/await 로 구조화된 프로그래밍이 가능해 짐

그러나, 만약 위와 같은 코드에서 수천 개의 이미지에 대해 썸네일을 생성한다고 했을 때

→ 각 섬네일을 한 번에 하나씩 처리하는 것은 이상적이지 않음

몇몇의 concurrency를 추가해서, 동시에 병렬로 다운 받을 수 있음

Task 는 비동기 기능과 함께 동작하는 새로운 기능임

Task 는 비동기 코드를 실행하기 위한 새로운 실행 컨텍스트를 제공함

각 Task 는 다른 실행 컨텍스트와 관련하여 동시에 실행되며, 안전하고 효율적으로 병렬 실행되도록 자동 예약 됨

비동기 함수를 호출하는 것은 새로운 task를 생성하지 않음

구조화된 동시성은 유연성과 단순함 사이의 균형에 관한 것이기 때문에 몇 가지 종류의 Task 들이 있음

→ 한마디로 요약하면, 구조화된 프로그래밍 개념처럼 async/await 를 활용 해 코드들을 읽히기 쉽게 만들어 (top to bottom) 주고 + 새로운 기능 (Task 등) 로 병렬 접근을 가능하게 하여 효율적으로 관리하겠다. 정도 인 것 같음


Async-let tasks

선언

만약 아래의 코드를 가정해보자 ( 일반적인 let 바인딩 )

만약 위와 같은 코드에서 다운로드가 진행되는 와중에, 다른 작업을 하고싶다면 let 키워드 앞에 async 를 적어주면 됨

이렇게 하면 concurrent 바인딩으로 전환 됨

 

동작 방식

async-let 의 동작방식에 대해 위의 예제를 통해 알아보자

  1. 데이터를 가져오는 부분을 평가하기 위해 새 child task 를 생성함 Child task ? → Task 내부에 생성되는 작은 작업
  2. 동시에 시작되는 작업
    1. Child Task: 첫번째 화살표에서 데이터 다운로드가 즉시 시작 됨
    2. Parent Task: 두번째 화살표에서 변수 결과를 Assign placeholder 값으로 즉시 바인딩 함
  3. Parent Task 는 그 외의 구문 실행 중 결과의 실제 값이 필요한 경우 Parent Task 는 Child Task 작업을 기다림
    1. 위의 예시는 URL세션에서 오류가 발생가능 → try 키워드 작성

동작 방식을 바탕으로 아래코드를 바꿔보자

위에서는 순차적인 바인딩으로 다운로드 받는 두 부분이 순차적으로 진행 됨

두 다운로드를 동시에 수행하려면 let 옆에 async 키워드를 적으면 됨

그리고 다운로드는 Child Task 에서 수행되므로 try await 키워드는 쓰지 않음

바인딩 된 변수를 사용할 때만 Parent Task 에 의해 관찰됨

→ 메타데이터와 데이터를 읽기 전에 ( 변수를 사용하기 전에 ) try await 키워드를 사용

동시(Concurrent) 바인딩 변수를 사용할 경우 메서드 호출이나 다른 변경이 필요하지 않음

변수는 순차 바인딩에서 수행한 것과 동일한 타입을을 가지고 있음

지금까지 봐온 Child Task는 사실 Task Tree 라고 부르는 계층구조의 일 부분임

Task Tree는 cancellation, priority, and task-local variables 같은 Task 속성에 영향을 줌

한 비동기 함수에서 다른 비동기 함수로 호출할 때마다 동일한 Task가 호출을 실행하는 데 사용됨.

따라서 fetchOneThumbnail 함수는 해당 Task의 모든 속성을 상속함

async-let 과 같은 구조화된 새 Task를 생성하면 현재 함수가 실행 중인 Task의 자식이 됨

Task는 특정 함수의 하위 항목이 아니지만 수명주기는 해당 함수에 포함될 수 있음

Task Tree는 각 부모와 자식 Task 간의 link(링크)로 구성됨

링크는 모든 Child Task 가 완료된 경우에만 Parent Task를 완료할 수 있다는 규칙이 있음

만약 첫번째 Child Task 가 에러가 난다면 ?

Swift는 자동적으로 취소된 것으로 표시한 다음 완료될 때까지 기다렸다가 기능을 종료함

Task를 취소된 것으로 표시해도 Task 가 중지되지는 않음

단순히 결과가 더 이상 필요하지 않음을 Task 에 알림

→ 즉, 에러가 난 Task가 있어도, 다른 Task 는 완료 될 때까지 실행은 하지만 결과가 필요 없다고 알림

최종적으로 fetchOneThumbnail 함수는 직-간접적으로 생성된 모든 구조화된 Task 가 완료되면 오류를 던짐으로써 최종적으로 종료됨

→ 이것은 구조화된 동시성 (Structured Concurrency)의 기본 규칙임

ARC가 메모리의 수명을 관리하여 실수로 Task가 유실되는 것을 방지함

따라서 복잡한 연산이 포함된 경우 cancellation을 염두에 두고 API를 구현해야 함

사용자는 취소할 수 있는 Task 에서 코드를 호출할 수 있으며 가능한 한 빨리 연산이 중지될 것으로 예상 함

 

Cancellation

만약 Task 가 cancel 된 상태인걸 확인하려면

  • checkCancellation() 메서드
  • isCancelled 프로퍼티

를 활용해서 확인할 수 있음

이렇게 API가 부분적인 결과를 반환할 수 있음을 명시해야 Task Cancel로 인한 치명적인 오류를 방지할 수 있음


Group tasks

Group Task는 async-let 보다 유연함

async-let 은 고정된 Task 의 양에 유리함

fetchOneThumbnail 은 Task 가 2개로 고정적임

fetchThumbnails함수에서 loop가 시작되려면 두 child task를 완료 해야함

만약, loop가 모든 썸네일을 동시에 가져오기 위한 Task를 시작하기를 원한다면?

→ Id수에 따라 달라지기 때문의 Task의 양을 정적으로 알 수 없음

이럴 때 Task Group을 활용할 수 있음

wtihThrowingTaskGroup 메서드를 호출하여 Task Group을 선언할 수 있음

선언

오류가 발생가능한 Child Task를 만들 수 있는 범위가 지정된 그룹 개체를 제공함

그룹에 추가된 Task는 그룹이 정의된 블록의 범위보다 오래 지속될 수 없음

  1. withThrowingTaskGroup 메서드로 Task Group 생성
  2. for-loop 를 블럭 내부에 배치해서 동적인 Task 생성
  3. 비동기 메서드를 호출 (fetchOneThumbnail) 을 호출하여 하위 Task 생성 ( 생성과 동시에 임의의 순서로 실행 )
  4. 그룹 개체가 범위를 벗어나면 해당 개체 내의 모든 Task가 완료되는 것을 기다림

위의 그림처럼 Task Tree가 완성 됨

그러나 위의 코드에는 Data races 문제가 있어서 컴파일이 안됨

Data race ? → 멀티 스레드 환경에서 하나의 메모리에 멀티 스레드가 접근하는 경우

위에서는 두개의 Task 에서 한 개의 Dictionary에 접근하기 때문에 컴파일 에러 발생

스위프트에서는 이러한 현상을 막기 위해 컴파일러 에서 에러를 뱉어냄

Sendable

새 Task 를 생성할 때마다 Task 가 수행하는 작업은 @Sendable(센더블) 이라는 새로운 클로저 타입내에 있음

센더블 클로저의 본문은 Task가 시작 된 후 변수가 수정될 수 있기 때문에 해당 컨텍스트에서 변수를 캡쳐하는 것이 제한됨

즉 Task 에서 캡처한 값은 공유하기에 안전해야 함

예를들어, Int 및 String 같은 값 타입이거나 멀티 스레드 및 자체 동기화를 구현하는 클래스 (actor 등) 에서 액세스 하도록 설계된 개체이기 때문

위 예에서는 data races 를 방지하기 위해 각 Child Task 에서 값을 반환하도록 할 수 있음

  1. 각 Child Task 에게 (String, UIImage)를 반환하도록 함
  2. 각 Child Task 에서 Dictionary 에 접근하는 대신, 부모 Task에서 접근 하도록 함
  3. 각 Child Task는 부모가 처리할 키 값 튜플을 반환 하도록 하고, Parent Task 는 새 async loop 를 활용해서 각 Child Task 의 결과를 반복 함

Unstructured tasks

  • 몇몇 task 는 일반 코드에서 실행하고자 할 때가 있다 ( 부모 Task 가 없을 수 있다 )
  • 몇몇 task 에 대해 원하는 수명이 단일 컨텍스트 또는 단일 함수의 한계에 맞지 않을 수 있다.

예를들어 delegate 객체를 사용하는 AppKit 이나 UIKit 에서 많이 발생하는 문제인데, 스위프트는 mainactor에 속하는 UI 클래스들을 선언해 보완함

위에서 delegate의 메서드는 비동기가 아니므로 await 할 수 없음

또한, 이 작업이 mainActor에서 UI우선순위를 가지고 실행되기를 원하며, Task 의 수명을 이 메소드 한정으로 묶고싶지 않음

이 때, unstructured task 를 사용해서 구성할 수 있음

구현

위의 예시 처럼 Task {} 로 묶고, 그안에 비동기 코드를 작성하면 됨

이러한 방식의 task 작성은 시작된 컨텍스트가 있는 경우에도 actor를 상속 받으며, group task 나 async task 처럼 원래 task 의 우선 순위 및 기타 특성들을 상속 받음

차이점

unstructured task 는 스코프가 한정적이지않다.

life cycle은 task가 시작된 스코프에 묶이지않음

그것이 속한 스코프가 비동기일 필요도 없음

어디서나 스코프에 묶이지 않은 task를 만들 수 있음

그러나 structured concurrency 가 자동으로 Cancellation 과 에러를 관리를 도와준 반면, unstructured concurrency는 수동으로 관리 해줘야 함

순서대로 예시를 통해 알아보자

만약 컬렉션뷰 셀이 보여질 때, 섬네일을 가져오는 Task를 구성하고,

해당 셀의 섬네일을 가져오기 전에 스크롤해서 그 섬네일이 사라지면 cancel 하는 구조를 짠다고 가정

(보여지면 섬네일 가져오는 Task 시작 → 그 전에 사라지면 Task 취소)

  1. Task 를 구성 후 Task를 딕셔너리에 저장 (셀 인덱스를 키값으로 저장해서 나중에 해당 셀의 cancel 을 가능하게 함 )
  2. defer문 으로 Task 가 완료된 경우 딕셔너리에서 제거 (완료된 task 에 대해 취소하지 않도록 함)
  3. delegate class 가 mainActor에 연결되어 있고, 새로운 task는 mainActor를 상속 받으므로 data races 상황에 놓일 일이 없다 (concurrent 환경이 아니기 때문에)

나중에 델리게이트에서 디스플레이에서 셀이 제거 되었다는 메세지를 받으면 task 를 취소할 수 있음

  1. 델리게이트 메서드 에서, 셀이 사라진 경우 task 를 종료하는 cancel 메서드 호출

Detached tasks

Detached tasks (분리된 태스크?) 는 아래와 같을 때 사용하면 좋음

  • 원래 컨텍스트에서 어떤 것도 상속 받지 않기를 원할 때
  • 최대한의 유연성을 원할 때

분리된 태스크는 여전히 unstructured task 임

→ life cycle 은 원래 스코프에 구속되지 않음

차이점

  • unstructured task 는 우선순위 등을 상속 받지만 detached task는 원래 스코프에서 다른 항목을 상속받지 않음
  • → 동일한 actor 로 제한되지 않으며, 시작된 위치와 동일한 우선순위로 실행될 필요가 없음

일반적으로 분리된 태스크는 우선순위 같은 것들을 기본 값을 가진 채로 시작되지만 옵셔널 파라미터등으로 제어할 수 있음


정리

위에서 네개의 task 종류에 대해 알아보았다.

각각 차이점을 표로 나타내면 아래와 같음

즉 Task는 병렬 처리에 대한 이점을 제공하는 것 같다

Task Tree 로 Task 들을 상황에 맞춰 구조화해서 잘 사용할 수 만 있다면 성능향상과 코드의 가독성을 높일 수 있을 듯

본문에서는 안다뤘지만 WWDC21 아래영상 마지막 부분에 Detached Task, Group Task 을 트리화 해서 쓰는 예제가 나옴

참고하면 좋을 듯 함


actor와 async sequence 가 남았네요.

async sequence 는 group task 에서도 약간 나오는데 (for- await loop) 일부분일 뿐이라더군요.

영어공부좀 해둘 걸……시간을 너무 많이 잡아먹는다 ㅠ

위의 내용은 WWDC21 Explore strucured concurrency 영상을 참고함