본문 바로가기
C.W.K.
Lesson 07 of 07 · published

막히는 일은 협력 스레드 풀에 두지 마

~16 min · concurrency, thread-pool, blocking-io, performance, testing

Level 0번들 열어본 사람
0 XP0/84 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"정확하긴 했어. 근데 크기와 상관없이 초당 16MiB쯤밖에 안 나왔지. 응답이 전부 몇 KB짜리 JSON이던 동안엔 아무도 몰랐고."

일시 중단을 기대하는 작은 풀

Swift 동시성은 태스크를 대략 CPU 코어 수만 한 협력 스레드 풀에서 돌려. "협력"이 곧 계약이야. 태스크는 기다릴 때 await에서 멈추고 스레드를 돌려줘야 해. 반대로 막아버리는 코드(큰 디렉터리 트리를 동기로 훑기, Thread.sleep, 세마포어 대기, 동기 네트워크 호출)는 풀 스레드를 인질로 잡아. 몇 개만 막혀도 앱 곳곳의 상관없는 태스크들까지 멈춰버리고, 로그엔 이유가 하나도 안 남아.

@concurrent나 Task.detached를 써도 달라지는 건 없어. 둘은 어느 실행자가 코드를 돌릴지 고를 뿐이고, 그 실행자도 결국 같은 풀이야. 막히는 일엔 막혀도 되는 스레드가 필요해. 디스패치 큐나 전용 스레드에 두고, continuation으로 다시 이어줘.

문이 두 개인 서버

Mac에서 도는 엔진들이 쓰는 가족 Swift HTTP 리스너는 일부러 핸들러 문을 두 개 열어둬. async 핸들러는 detached 태스크에서 돌아. 기다리는 일에 알맞지. blockingHandler는 동시 디스패치 큐에서 돌아. 동기 파일 시스템 호출로 데이터 루트를 훑는 동기화 엔진에 맞아. 이건 취향 문제가 아니야. 스캔을 async 문 뒤에 두면 스캔 내내 협력 풀 스레드 하나를 차지하고 앉아 있게 돼.

원소마다 드는 비용은 무시 못 해

킷의 보안 강화 HTTP 전송은 응답 본문을 이렇게 읽었어. for try await byte in session.bytes(for: request)로 돌면서 크기 한도를 지키고 UInt8을 하나씩 붙이는 식이었지. 결과는 정확했어. 근데 로컬 파일로 재보니 크기와 상관없이 초당 16MiB쯤이었어. 원소마다 async next() 비용이 붙으니까. data(for:)는 같은 바이트를 초당 1,000MiB 넘게 옮겼지만 받는 도중 한도 검사를 포기해야 해. 고친 방법은 URLSessionDataDelegate야. didReceive data가 덩어리째 들어오니까 덩어리마다 한 번 붙이고, 누적 크기가 한도를 넘는 순간 태스크를 취소해. 루프백 소켓으로 실제 전송을 걸어 재보니 32MiB가 초당 1,237MiB쯤으로 오갔어. 느린 루프를 2주 동안 아무도 몰랐던 건, 앞선 사용처가 전부 작은 JSON만 받았기 때문이야. 이미지를 받은 첫 사용처가 찾아냈지.

메인 액터 테스트 본문이 자기 콜백을 막아

@MainActor를 붙인 Swift Testing 테스트가 메인 큐로 오는 콜백을 확인하려다 매번 타임아웃이 났어. 테스트 본문 자체가 메인 큐 작업이라서 콜백이 그 뒤에 줄 서 있었어. 테스트 안에서 런 루프를 돌려도 디스패치 큐는 비워지지 않았어. 고친 건 제품 코드가 아니라 테스트 구조였어. 테스트 본문은 격리 없이 두고, 준비하거나 값을 읽을 때만 메인 액터로 넘어가. 확인과 확인 사이엔 Task.sleep으로 메인 큐에 틈을 줘.

Code

막히는 일엔 막혀도 되는 스레드를 주고, 호출자는 멈춰·swift
import Foundation

enum Scanner {
    private static let queue = DispatchQueue(label: "com.example.spark.scan", qos: .utility, attributes: .concurrent)

    /// Synchronous, blocking file-system walk — never run it on the cooperative pool.
    nonisolated static func countFilesBlocking(at root: URL) -> Int {
        let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil)
        var count = 0
        while walker?.nextObject() != nil { count += 1 }
        return count
    }

    /// Async face: suspends the caller, does the blocking work on a dispatch queue.
    static func countFiles(at root: URL) async -> Int {
        await withCheckedContinuation { continuation in
            queue.async {
                continuation.resume(returning: countFilesBlocking(at: root))
            }
        }
    }
}
기다리는 콜백을 위해 메인 큐를 비워두는 테스트·swift
import Testing
import Foundation

@MainActor
final class FolderWatcher {
    private(set) var events = 0
    func arm() {
        // Stands in for a framework that delivers its callback on the main queue.
        DispatchQueue.main.async { MainActor.assumeIsolated { self.events += 1 } }
    }
}

// Nonisolated test body: the main queue stays free to run the callback.
@Test func watcherDeliversOnMain() async throws {
    let watcher = await FolderWatcher()
    await watcher.arm()
    for _ in 0..<50 {
        if await watcher.events > 0 { break }
        try await Task.sleep(for: .milliseconds(20))
    }
    #expect(await watcher.events == 1)
}

External links

Exercise

Spark의 엔진 클라이언트에 한도 64MiB짜리 본문 읽기를 넣어. 먼저 bytes(for:)로 한 바이트씩 읽게 만들고, 로컬 서버(python3 -m http.server로 생성한 파일을 서빙)에서 32MiB 받는 시간을 재. 이어서 덩어리마다 붙이고 한도를 넘으면 취소하는 URLSessionDataDelegate로 바꿔서 다시 재. 두 숫자를 적고, Content-Length 없는 응답에서도 한도가 지켜지는지 확인하는 테스트를 추가해.
Hint
python3 -m http.server는 늘 Content-Length를 보내서 시간 재는 덴 괜찮아. 한도 테스트용으론 Content-Length를 빼고 본문 뒤에 연결을 닫는 작은 핸들러를 따로 짜. URLSession은 선언된 길이에서 전달을 멈추니까, 길이를 실제보다 작게 선언하면 한도 검사까지 가지도 않아. 누적 합계가 필요한 건 길이 선언이 아예 없는 경우야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.