[Swift] Concurrency(1)

상 원·2022년 7월 28일
0

Swift

목록 보기
30/31
post-thumbnail

Concurrency는 지난 학기에 운영체제 들으면서 배웠던 개념!
사실 교수님이 설명하실 때 Parallelism이랑 비슷한 거구나~ 하고 퀴즈치러 갔다가 틀려버렸다는 슬픈 이야기가...

이렇게 노션에다가 정리해 뒀는데 다시 보자면, Parallelism은 실제로 일을 동시에 하는 거고, Concurrency는 논리적으로 일을 동시에 하는 것처럼 보이는 거라고 이해했었음.

Swift에서 Concurrency는 뭔가 다른 게 있는지 살펴보자!


Swift는 체계적인 구조로 비동기적이고 병렬적인 코드를 쓸 수 있게 해 줌.
Asynchronous code(비동기적 코드)는 중단됐다가 나중에 다시 시작될 수 있지만, 한번에 프로그램의 한 조각만 실행될 수 있다. 프로그램을 중단했다가 다시 시작하는 행위(?)는 네트워크를 통해 데이터를 받아오거나 파일을 parsing(구문분석)하는 등 시간이 오래 걸리는 작업을 하면서 UI 업데이트같은 짧은 시간이 걸리는 작업도 할 수 있다.

Parallel 코드는 여러 조각의 코드를 동시에 실행할 수 있는 것이다.
예를 들어, 4코어 CPU를 가진 컴퓨터는 4조각의 코드를 동시에 실행할 수 있다. 각 코어가 한 조각씩 맡아서 실행하는 거.
이렇게 병렬적이고 비동기적인 코드를 사용하는 프로그램은 동시에 여러 작업을 할 수 있다. 외부의 반응을 기다리는 작업은 중단시켜서 코드를 memory-safe 하게 작성할 수 있음.

병렬적이고 비동기적인 코드에서 오는 스케쥴링 유연성은 코드를 더 복잡하게 만들기도 한다.
Swift는 compile-time checking을 가능하게 함으로써 내 의도를 표현할 수 있게 한다.(이건 뭔말이지..?)
예를 들어, actor라는 기능을 써서 mutable 상태로 안전하게 접근할 수 있다던가..뭔가 굉장히 운영체제스러운 말이네,,
느리거나 버그가 있는 코드에 concurrency를 추가하는 건 좋은 게 아님. 디버깅이 더 어려워질 수 있거든.
Swift의 language-level 서포트를 받으면 컴파일 타임에 문제점을 찾을 수가 있다.
뭔말인지 모르겠다

요 서포트가 없이도 concurrent한 코드를 쓸 수 있지만, 읽기가 더 어려움.

listPhotos(inGallery: "Summer Vacation") { photoNames in
    let sortedNames = photoNames.sorted()
    let name = sortedNames[0]
    downloadPhoto(named: name) { photo in
        show(photo)
    }
}

이 코드는 사진 이름의 리스트를 다운받고, 그 리스트의 첫번째 사진을 다운받고, 그 사진을 유저에게 보여주는 간단한 동작을 하는 코드임. 근데도 nested closure까지 쓰면서 복잡하고 읽기 어렵게 구성돼버림.

여기까지는 무슨 말인지 잘 모르겠다.. 차차 뜯어보자.

Defining and Calling Asynchronous Functions

비동기적인 함수를 정의하고 호출해보자. 예전에 리액트같은걸 찍먹했을 때는 함수 앞에 async 라는 키워드를 붙였던 것 같은데...

일단 정의부터 살펴보면,

정의

Asynchronous Function 은 실행 도중에 보류할 수 있는 특별한 함수이다.
끝까지 실행되거나, 에러를 뱉어내거나, 반환을 하지 않는 보통의 Synchronous Function 의 반대 개념이다.

비동기 함수도 세 가지 중 하나를 하긴 하지만 중간에 멈춰서서 어떤 다른 걸 기다릴 수가 있다! 비동기 함수의 body 안에 어떤 지점에서 실행을 멈추고 보류할 것인지 표시해놓음.

정의하는 방법은!
함수의 매개변수 다음에다 async 를 써주면 된다. 리턴값이 있는 함수일 경우에는 화살표 앞에 써주면 됨. throws 를 같이 써주는 경우에는 async 를 먼저 쓴다.

다음 코드는 갤러리에서 사진의 이름을 가져오는 코드임.

func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... some asynchronous networking code ...
    return result
}

이걸 호출할 때는, 함수가 리턴하기 전까지 실행이 보류된다. await 로 가능한 보류 지점을 표시해줌. throwing 함수 사용할 때 try 써주는거랑 비슷함.
비동기 함수 안에서, 다른 비동기 함수를 호출했을 때에만 실행이 보류됨. 보류는 절대 암시적이거나 선점적(preemptive)이지 않기 때문에 보류가 일어날 수 있는 지점은 모두 await 으로 표시를 해 줘야 한다.


갤러리에 있는 모든 사진 이름을 가져와서 첫 번째 사진을 보여주는 코드이다.

let photoNames = await listPhotos(inGallery: "Summer Vacation")
let sortedNames = photoNames.sorted()
let name = sortedNames[0]
let photo = await downloadPhoto(named: name)
show(photo)

listPhotos(inGallery: )downloadPhoto(named: ) 함수는 둘 다 네트워크 리퀘스트가 필요하므로 완료까지 비교적 오랜 시간이 걸릴 수 있다.
그래서 얘네들을 async 를 써서 비동기 함수로 만들어줌으로써 사진이 준비되길 기다리는 와중에 나머지 앱의 코드가 계속 실행되게 할 수 있는거임.

이 동시 특성을 이해하려면 다음 6단계를 살펴보자.

  1. 코드는 첫번째 줄부터 실행되기 시작해서 awiat 을 마주하게 된다. 얘는 listPhotos(inGallery: ) 함수를 호출하고 반환될 때까지 실행이 보류됨.
  2. 이 코드의 실행이 보류됐을 때, 다른 concurrent code가 실행된다. 예를 들어 새로운 사진을 갤러리에 업데이트하는 백그라운드 task가 계속 실행될 수 있음. 얘는 실행이 완료되거나 다음 await을 만날 때까지 실행될거임.
  3. listPhotos(inGallery: ) 가 리턴되면, 그 지점에서 코드가 이어서 실행된다. photoNames 에 반환값을 할당함.
  4. 2, 3번 줄은 await이 없는 일반 코드이기 때문에 그냥 쭉 실행됨.
  5. 4번 줄에서 또다시 awiat 을 만나고 함수가 반환될 때까지 보류된다. 이때도 다른 concurrent code가 실행될 수 있음.
  6. downloadPhoto(named: ) 가 리턴되면, photo 에 값을 할당하고 show(_: ) 를 호출할 수 있다.

보류 가능 지점이 await으로 표시되어 있는데 얘네는 이 비동기 함수가 반환될 때까지 실행을 멈출 수도 있다는 걸 알려주는 거임. yielding the thread 라고도 불리는데 얘가 돌아가고 있는 스레드를 다른 코드에게 넘겨주기 때문에!(운체 안들었으면 어쩔뻔..)

awiat 가 실행을 보류시켜야 하기 때문에 비동기 함수를 호출할 수 있는 부분이 정해져 있다.

  • 비동기 함수, 메소드, 프로퍼티의 body 안에서
  • 구조체, 클래스, 열거형 안의 @main 으로 표시된 static main() 메소드 안
  • unstructured child task. 이건 아래에서 설명함.

보류 가능 지점들 사이의 일반코드는 순차적으로 실행된다. 다른 concurrnet code의 간섭을 받지 않음!

let firstPhoto = await listPhotos(inGallery: "Summer Vacation")[0]
add(firstPhoto toGallery: "Road Trip")
// At this point, firstPhoto is temporarily in both galleries.
remove(firstPhoto fromGallery: "Summer Vacation")

이 코드는 사진을 다른 갤러리로 옮기는 코드인데, add(_:toGallery: )remove(_:fromGallery: ) 사이에 다른 코드가 실행될 수가 없음. 이때 첫 번째 사진이 갤러리 두개 다에 나타나는 문제점이 생긴다. 여기에 await 이 있으면 안 된다는 걸 좀 더 명확하게 보기 위해 리팩토링해보자.

func move(_ photoName: String, from source: String, to destination: String) {
    add(photoName, to: destination)
    remove(photoName, from: source)
}
// ...
let firstPhoto = await listPhotos(inGallery: "Summer Vacation")[0]
move(firstPhoto, from: "Summer Vacation", to: "Road Trip")

이런식으로 동기 함수 move(_:from:to: )를 정의했을 때, 여기에 보류 가능 지점을 달면 절대 안됨! 컴파일 에러가 난다요.

Asynchronous Sequences

위에서 봤던 listPhotos(inGallery: ) 함수는 비동기적으로 동작해서 배열의 모든 원소가 준비된 이후에 배열을 통째로 반환해줬음.

func listPhotos(inGallery name: String) async -> [String] {
    let result = // ... some asynchronous networking code ...
    return result
}

요 함수였다.

이걸 다른 방법으로 하려면 asynchronous sequence 를 사용해 한번에 원소 하나만 기다리는 방법을 사용할 수 있음. 요 비동기 시퀀스를 iterate 하는 코드는 다음과 같다.

import Foundation

let handle = FileHandle.standardInput
for try await line in handle.bytes.lines {
    print(line)
}

일반적인 for-in 루프를 사용하지 않고 for-await 를 사용했다. 비동기 함수를 호출할 때와 마찬가지로 await 를 쓰면 보류 가능 지점을 표시해 놓는건데, for-await-in 을 쓰면 매 반복마다 보류가 가능해지는데, 다음 원소가 사용 가능할 때까지 기다리는 거!

참고

사용자 정의 타입에 Sequence 프로토콜을 추가하면 for-in 루프를 사용할 수 있는 것처럼,
AsyncSequence 프로토콜을 추가하면 for-await-in 루프를 사용할 수 있다.

Calling Asynchronous Functions in Parallel

비동기 함수를 await으로 호출하면 한번에 코드 하나만 실행된다. 비동기 코드가 실행되는 중에는 다음 줄로 넘어가지 않고 이게 끝나길 기다림.

갤러리에서 처음 사진 세개를 가져오려면 아래 코드를 쓰면 됨.

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

let photos = [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

근데 이 코드는 진~짜 중요한 단점이 있음!
다운로드하는게 비동기적으로 일어나고 이때 다른 코드가 실행될 수 있긴 하지만, 한 번에 downloadPhoto 하나만 실행된다는 것!
그래서 사진 하나가 다 받아지고 난 다음에 다음 사진이 받아지기 시작한다. 근데 이렇게 기다렸다가 받을 필요가 전~~혀 없으므로 각자 따로 받을 수 있는 방법이 필요함!

비동기 함수를 호출해서 병렬적으로 실행시키기 위해서는 let 앞에 async 를 붙여주고, 이 상수를 사용할 때마다 await 을 써주면 됨.

async let firstPhoto = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto = downloadPhoto(named: photoNames[2])

let photos = await [firstPhoto, secondPhoto, thirdPhoto]
show(photos)

이런식으로 상수를 선언할 때 호출하는 비동기 함수 앞에 await 을 붙여주는 게 아니라, 비동기 함수를 호출해 상수에 async let 에다 할당해준 뒤 이 상수를 사용할 때 await 을 붙여서 사용하면 된다.

downloadPhoto 는 이제 앞에 호출된 비동기함수를 기다리지 않고 바로바로 실행된다. 리소스가 남아있을 경우 다같이 실행됨. 함수의 결과를 기다리는 코드가 아니기 때문에 함수 앞에 await 을 안 쓴거고 대신 photos 를 정의하는 코드 전까지 코드가 쭉 실행됨. photos 를 정의하는 곳에서는 위에서 정의한 세 상수 값이 필요하기 때문에 await 을 써서 세 개 사진이 다운로드 될때까지 실행을 보류하는 것.

다음과 같이 생각하면 두 코드의 차이점을 이해할 수 있음

  • 뒤따라오는 코드가 비동기 함수의 결과에 영향이 있다면 await 을 사용해서 비동기 함수를 호출함. 이렇게 하면 순차적으로 진행되는 작업을 만들 수 있음.
  • 비동기 함수의 결과가 필요하지 않으면 async-let 으로 호출하면 된다. 이렇게 하면 병렬적으로 실행되는 작업을 만들 수 있음.
  • awaitasync-let 둘 다 자기가 보류됐을 때 다른 코드를 실행할 수 있음.
  • 두 경우 모두 await으로 보류 가능 지점을 표시해 두고 비동기 함수가 반환될 때까지 기다릴 수 있다는 여지를 줄 수 있음.

Tasks and Task Groups

정의

task 는 프로그램의 일부로 비동기적으로 실행될 수 있는 작업의 단위임. 모든 비동기 코드는 태스크의 일부로 실행됨.

profile
ios developer

0개의 댓글