
내가 Swift에서 비동기와 동시성을 공부하면서, 가졌던 의문들과 이해 과정을 정리해봤다.
사실 결론부터 말하면, 이 둘은 직접 비교되는 개념이 아니다.
마치 사과와 빨간색을 비교하는 것과 같다.
과일과 색깔이라는 다른 범주의 개념이지만, 사과가 빨갛다고 말할 수는 있는 것처럼.
비동기(Async): 지금 이 작업이 끝날 때까지 기다리지 않고 다음 작업을 진행할 수 있는 구조
동시성(Concurrency): 여러 작업을 동시에 실행할 수 있는 구조
비동기는 흐름에 대한 것.
동시성은 실행 구조에 대한 것.
모든 동시성 작업은 비동기적이지만,
모든 비동기 작업이 반드시 동시에 실행되지는 않는다.
때문에 어떻게 사용하느냐에 따라 순차 실행과 동시 실행으로 구분될 수 있다.
func loadData() async {
let user = await fetchUser()
let posts = await fetchPosts(user : user)
}
fetchUser()가 끝난 후에야 fetchPosts()가 시작된다.
→ 비동기 함수들을 사용했지만, 실제로는 순차적으로 실행되는 것이다.
func loadData() async {
async let user = fetchUser()
async let posts = fetchPosts()
let (u, p) = await (user, posts)
}
user, posts를 동시에 요청하고, 결과를 한꺼번에 await한다.
사실 이 부분이 공부하면서 제일 궁금했던 주제다. 동시성으로 여러 작업을 동시에 처리할 수 있다면, 그 작업들의 실행 순서가 보장되지 않을 경우 어떤 문제가 생길 수 있을까?
예를 들어, A에서는 이름을 수정하고, B에서는 이름을 가져오는 작업이 있다고 했을 때 B가 먼저 실행되어 수정 전 데이터를 가져온다면?
결론: 작업 순서는 보장되지 않는다.
Swift는 어떤 작업(Task)이 먼저 실행될지 보장해주지 않는다.
특히 Task를 여러 개 사용해서 동시에 요청을 날리는 경우,
어떤 Task가 먼저 실행되고, 어떤 Task가 나중에 실행될지는 스케줄러의 결정에 따라 달라진다.
실행 순서를 명시해주는게 좋을 것이다.
가장 기본적인 방법은, Task 내부에서 await를 순서대로 호출하는 것이다.
Task {
await step1()
await step2()
}
이렇게 하면 step1()이 끝난 다음에야 step2()가 실행되므로
우리가 원하는 순서로 실행시킬 수가 있다.
이건 순서만의 문제가 아니다.
A와 B 두 Task가 동시에 같은 변수에 대해 += 1을 수행한다면
정상적으로는 +2가 되어야 하지만, 실제로는 +1만 되거나 값이 꼬일 수 있다.
이런 상황을 데이터 레이스(data race)라고 한다.
레이스 컨디션과 유사하지만,
동시에 메모리에 접근하는 작업에서 실행 타이밍에 따라 결과가 달라지는 문제를 말한다.
Swift에서는 이런 문제를 해결하기 위해 actor라는 타입을 제공한다.
actor는 class처럼 생겼지만,
여러 작업이 동시에 접근하더라도 내부 상태를 자동으로 직렬 처리해서
데이터 충돌 없이 안정적인 처리가 가능하다.
actor Counter {
var value = 0
func increase() {
value += 1
}
}
Actor isolation은 Swift가 동시성 안전성을 보장하는 핵심 메커니즘이다.
각 actor는 고유한 isolation domain을 가지며, 이는 actor 내부의 모든 프로퍼티와 메서드가 동시에 하나의 Task만 접근할 수 있음을 의미한다.
Actor isolation은 컴파일 타임에 검증되어, 데이터 레이스를 원천적으로 차단한다.
actor 내부에서 let과 var는 다르게 동작한다.
actor BankAccount {
let accountNumber = "123456" // 불변값 - 동기적 접근 가능
var balance = 1000 // 가변값 - 비동기적 접근 필요
}
// actor 외부에서 인스턴스에 접근할 때
let account = BankAccount()
print(account.accountNumber) // await 없이 접근 가능
print(await account.balance) // await 필요
let으로 선언된 상수는 변경될 가능성이 없으므로 동기적으로 접근 가능하다.
반면 var로 선언된 변수는 언제든 변경될 수 있으므로 actor의 isolation을 통해야 한다.
다른 actor의 메서드나 프로퍼티에 접근할 때는 cross-actor reference가 발생한다
actor 로그찍어주는액터 {
func log(_ message: String) {
print(message)
}
}
actor DataProcessor {
func processData() async {
let 로그액터 = 로그찍어주는액터()
// 다른 actor에서부터 호출할때는 비동기로 접근을 해야한다..! -> await 필요!
await 로그액터.log("Processing started")
}
}
이런 cross-actor 호출은 Swift 컴파일러가 자동으로 감지하고,
await를 요구함으로써 데이터 레이스를 컴파일 타임에 방지한다.
let counter = Counter()
Task {
await counter.increase()
let currentValue = await counter.value
print(currentValue)
}
Counter는 내부적으로 고유한 Serial Executor(일련번호라 생각하면좋을듯)를 가진다.
여러 Task가 동시에 increase()를 호출해도 Swift는 이 요청들을 한 줄로 직렬화시켜서 한 번에 하나씩만 실행시키는 것이다.
이렇게하면 순차적 실행이기때문에, value 값이 꼬이는 불상사는 없다.
그리고 actor 내부 속성이나 메서드에 접근할 때 await를 붙여야 하는 이유도 이 때문이다. Swift가 해당 작업을 직렬 큐에 등록하고 순차적으로 실행할 수 있도록 보장하는 구조인 것!
Actor isolation은 다음과 같은 과정으로 동작한다:
actor Counter {
private var count = 0
func increment() {
count += 1 // actor 내부에서는 동기적 접근이 가능하다. await없어도됨 !
}
func getValue() -> Int {
return count
}
}
// 사용 예시
let counter = Counter()
// 여러 곳에서 동시에 접근해도 안전
Task {
await counter.increment()
}
Task {
await counter.increment()
}
print(await counter.getValue()) // 순차적으로 실행되어 안전하다!
같은 코드를 class로 바꾼다면?
class Counter {
var value = 0
func increase() {
value += 1
}
}
겉보기에는 동일하지만, 이 경우 여러 Task가 동시에 increase()를 호출하면 데이터 충돌이나 레이스 컨디션이 발생할 수 있다.
actor를 사용하면 직접 락이나 동기화 코드를 작성하지 않아도 된다 !
꼭 그렇진 않다. actor는 내부 상태를 안전하게 보호하는 대신 모든 작업을 직렬화해서 하나씩 실행하기 때문에 성능이 저하될 수 있다. 특히 actor 내부에서 오래 걸리는 작업이 있을 경우, 다음 작업을 기다려야되니따 전체 처리 흐름이 느려질 수 있다.
여러 Task가 동시에 접근할 수 있고,
그 값이 꼬이면 안 되는 경우에만!
사례를 들자면
actor는 내부 데이터를 보호해주는 것이지, 작업 순서까지 보장하는 것은 아니다. 혼선이 안생기게 직렬화 해주는 것일 뿐이다!
Task 안에서 직접 설계해야 한다.actor를 사용해 직렬화가 필요하다.