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까지 쓰면서 복잡하고 읽기 어렵게 구성돼버림.
여기까지는 무슨 말인지 잘 모르겠다.. 차차 뜯어보자.
비동기적인 함수를 정의하고 호출해보자. 예전에 리액트같은걸 찍먹했을 때는 함수 앞에 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단계를 살펴보자.
awiat
을 마주하게 된다. 얘는 listPhotos(inGallery: )
함수를 호출하고 반환될 때까지 실행이 보류됨.await
을 만날 때까지 실행될거임.listPhotos(inGallery: )
가 리턴되면, 그 지점에서 코드가 이어서 실행된다. photoNames
에 반환값을 할당함.awiat
을 만나고 함수가 반환될 때까지 보류된다. 이때도 다른 concurrent code가 실행될 수 있음.downloadPhoto(named: )
가 리턴되면, photo
에 값을 할당하고 show(_: )
를 호출할 수 있다.보류 가능 지점이 await으로 표시되어 있는데 얘네는 이 비동기 함수가 반환될 때까지 실행을 멈출 수도 있다는 걸 알려주는 거임. yielding the thread 라고도 불리는데 얘가 돌아가고 있는 스레드를 다른 코드에게 넘겨주기 때문에!(운체 안들었으면 어쩔뻔..)
awiat
가 실행을 보류시켜야 하기 때문에 비동기 함수를 호출할 수 있는 부분이 정해져 있다.
@main
으로 표시된 static main()
메소드 안보류 가능 지점들 사이의 일반코드는 순차적으로 실행된다. 다른 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: )
를 정의했을 때, 여기에 보류 가능 지점을 달면 절대 안됨! 컴파일 에러가 난다요.
위에서 봤던 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 루프를 사용할 수 있다.
비동기 함수를 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
으로 호출하면 된다. 이렇게 하면 병렬적으로 실행되는 작업을 만들 수 있음.await
과 async-let
둘 다 자기가 보류됐을 때 다른 코드를 실행할 수 있음.정의
task 는 프로그램의 일부로 비동기적으로 실행될 수 있는 작업의 단위임. 모든 비동기 코드는 태스크의 일부로 실행됨.