GCD의 한계점들.
Thread Explosion
가장 큰 문제는 Thread Explosion입니다. GCD는 새로운 작업이 들어올 때마다 "혹시 기존 스레드가 block되어 있나?"를 확인하고, block되어 있으면 새 스레드를 생성해버려요.
그런데 제가 이거 실제 DispatchQueue 여러개 찍어내고 해보니까 62개까지 쓰레드가 생성되더라고요.
결국 GCD의 쓰레드풀은 64개인것 같습니다.
64개를 넘어가면:
- 새로운 작업들은 기존 스레드가 해제될 때까지 대기하고 스레드 재사용하며 무한정 스레드를 만드는것은 아닙니다.
Thread Explosion의 진짜 문제는 개수보다는: 각 쓰레드 마다 가지고 있는 메모리의 오버헤드, context Switching 비용인것 같습니다.
Priority Inversion의 함정
QoS를 사용할 때도 예상치 못한 문제가 발생합니다. 제가 겪었던 실제 사례를 보여드릴게요:
// UI 업데이트용 고우선순위 작업
DispatchQueue.global(qos: .userInteractive).async {
// 하지만 내부에서 낮은 우선순위 리소스에 의존한다면?
let result = SharedResource.getValue() // 💀 background queue에서 처리 중
DispatchQueue.main.async {
updateUI(with: result) // 결국 지연됨
}
}
고우선순위로 설정했지만 실제로는 낮은 우선순위 작업의 완료를 기다려야 하는 상황이 생기더라고요.
그외의 코드 문제점들:)
- 비동기 코드가 실행되면 코드가 control을 포기하기전까지 CPU core를 정상적 회수할수없다. >> thread blocking 현상 발생.
- 비동기로 작업을 수행하고 completion closure를 사용하여 해당작업이 끝날때 처리를 해주는데 completion을 사용하는 것을 잊을 경우 문제 발생위험도가 있다.(에러 처리를 위해 모든 case에서 completion handler 리턴했는가?) 컴파일러는 이것을 체크 할수 없어 빌드와 런이 제대로 되지만 실제 버그가 남아있는 경우가 있을수있다.
- reatain cycle발생 가능성. GCD내부에서 self캡쳐하는경우
- 콜백지옥으로 인한 가독성 문제..
func fetchThumbnail(for id: String, completion: @escaping (Result<UIImage, Error>) -> Void) {
let request = thumbnailURLRequest(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion (.failure (error))
} else if (response as? HTTPURLResponse) ? .statusCode != 200 {
completion(.failure(FetchError.badID))
} else {
guard let image = UIImage (data: data!) else {
completion(.failure(FetchError.badImage))
return
}
image.prepareThumbnai1(of: CGSize (width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
completion(.failure(FetchError.badImage))
return
}
completion (.success (thumbnail))
}
}
}
task.resume()}
잠깐 스포일러 하자면
func fetchThumbnail(for id: String) async throws -> UIImage
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLesponse)?.statusCode == 200 else { throw FetchError. badID }
let maybeImage = UIImage (data: data)
guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError. badImage }
return thumbnail
}
Swift 5.5 Councurrency Model
Cooperative Threading
기존 GCD는 preemptive하게 작동해요. 시스템이 강제로 스레드를 중단시키고 다른 작업으로 넘어가죠.
하지만 Swift Concurrency는 개발자가 명시적으로 await을 표시한 지점에서만 양보합니다.
이 방식의 장점은 예측 가능성이에요. 언제 컨텍스트 스위칭이 일어날지 개발자가 정확히 알 수 있거든요.
컴파일 타임 안전성
컴파일러가 비동기 코드의 완전성을 검증해주어 실수로 completion handler 호출을 빼먹는 일이 원천 차단되죠.
Race Condition
let nameList = ["서노","수낵","민킴","주용","진창","니지","7의멤버","8의멤버","9의멤버"]
func beforeasync() -> [String] {
let group = DispatchGroup()
let queue = DispatchQueue(label: "GCD", attributes: .concurrent)
var names = [String]()
for i in 0..<9 {
queue.async(group: group) {
Thread.sleep(forTimeInterval: 0.1)
let name = nameList[i]
names.append(name)
}
}
group.wait()
return name
}
GCD로 하면 과연 9개의 이름을 다 가져올까? Nono
여러 스레드가 동시에 names.append()를 호출하면서 내부 배열 구조가 깨지기 때문이에요.
func safeButComplexGCD() -> [String] {
let group = DispatchGroup()
let queue = DispatchQueue(label: "GCD", attributes: .concurrent)
let serialQueue = DispatchQueue(label: "names-append") // 직렬 큐 추가!
var names = [String]()
for i in 0..<9 {
queue.async(group: group) {
Thread.sleep(forTimeInterval: 0.1)
let name = nameList[i]
serialQueue.sync { // 🔒 동기화 필요
names.append(name)
}
}
}
group.wait()
return names
}
원하는 의도대로 하기위해서는 코드와 같이 추가 큐를 생성하여 동기화 관련 처리를 해주어야 합니다.
보면 코드도 복잡해지고 성능에 오버헤드도 발생합니다.
Swift Concurrency는?
func afterAsync() async -> [String] {
await withTaskGroup(of: String.self) { group in
for i in 0..<9 {
group.addTask {
try? await Task.sleep(nanoseconds: 100_000_000)
return nameList[i]
}
}
var names = [String]()
for await name in group {
names.append(name) // ✅ 순차적으로 안전하게 추가!
}
return names
}
}
왜 안전한가?
- for await 루프는 순차적으로 실행됨
- 한 번에 하나의 결과만 처리
- 컴파일러가 동시성 안전성을 보장
혹은 Actor를 활용할수도 있죠.
Suspension Point¡
func addAuthor1() async {
let author1 = "Author1 : \(Thread.current)"
self.dateArray.append(author1)
try? await Task.sleep(nanoseconds: 2_000_000_000) // 🔄 여기서 스레드 변경!
let author2 = "Author2 : \(Thread.current)"
self.dateArray.append(author2)
try? await doSomething() // 🤔 여기서는 왜 같은 스레드?
await MainActor.run(body: {
let author4 = "Author3: \(Thread.current)"
self.dateArray.append(author4)
})
}
실행 결과:
Author1 : <NSThread: 0x280f44200>{number = 1, name = main}
Author2 : <NSThread: 0x280f5c080>{number = 4, name = (null)} // 다른 스레드!
something1 : <NSThread: 0x280f5c080>{number = 4, name = (null)} // 같은 스레드?
Author3: <NSThread: 0x280f44200>{number = 1, name = main}
왜 Task.sleep() 후에는 스레드가 바뀔까?
2초(2,000,000,000 나노초)는 충분히 길기 때문에 시스템이 "이 스레드를 다른 작업에 쓰는 게 낫겠다"고 판단해서 양보하는 거예요.
async를 호출하게 된다면 기존 실행컨텍스트 처리하던 쓰레드는 자유로워지고 작업권을 다른데 넘길수 있다는 뜻!
doSomething()에서는 왜 같은 스레드일까?
핵심은 실제 suspension이 없다는 점이에요. doSomething()은 async 키워드가 있지만 내부에서 실제로 await을 호출하지 않거든요.
이런 경우 Swift runtime은 굳이 ? 스레드 바꿀 이유가 없다라고 판단한답니다
시스템이 스레드를 알아서 분배 처리하기에 async 호출전 스레드와 await이후의 스레드는 다를 수 있다를 기억!!
- 그러므로 스레드에 관계된 데이터를 유지해서는 안됩니다.
만약 여기서 메인쓰레드에 무조건 해야하는 경우는 mainActor사용
- await을 만나면 현재 쓰레드 제어권을 시스템에게 넘겨줌(현재 쓰레드를 block하는게 아님!!) > 2. 시스템은 해당쓰레드에서 우선순위가 높은 작업을 먼저함. > 3. 작업이 끝나면 다시 스레드 제어권을 줌
프로토콜에서도 async메소드 생성가능
이 async 메서드를 호출하려면 async 메서드 내에서 호출되거나 Task로 묶어서 호출.
핵심 원칙들
- 스레드에 의존적인 데이터 저장 금지
// ❌ 위험한 코드
ThreadLocal.set("user_id", userId)
await someAsyncWork()
let savedUserId = ThreadLocal.get("user_id") // nil일 수 있음!
// ✅ 안전한 코드
let userId = getCurrentUserId()
await someAsyncWork()
// userId 변수는 여전히 유효
2. await 후 스레드 변경 가능성 항상 염두
func dangerousCode() async {
assert(Thread.isMainThread) // ✅ 통과
await someWork()
assert(Thread.isMainThread) // ❌ 실패 가능!
}
3. 명시적 스레드 제어가 필요하면 Actor 사용
@MainActor
class UIManager {
func updateUI() {
// 항상 메인 스레드에서 실행 보장
}
}
이렇게 Swift Concurrency의 스레드 관리는 단순해 보이지만 내부적으로는 매우 정교한 최적화가 이뤄지고 있어요. 개발자는 스레드를 직접 관리하지 않고도 효율적인 동시성 코드를 작성할 수 있게 되는 거죠!
'면접준비' 카테고리의 다른 글
KeyChain & 암호화 (1) | 2023.10.08 |
---|---|
TaskGroup (0) | 2023.08.13 |
디스패치 그룹 (0) | 2023.07.02 |
GCD queue. (0) | 2023.07.02 |
Actor🕴🏻 (0) | 2023.06.29 |