Skip to content
C.W.K.
Stream
Lesson 07 of 07 · published

Blocking Work Does Not Belong on the Cooperative Pool

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

Level 0Bundle Opener
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"Correct, and about 16 MiB per second at any size. Nobody noticed while every body was a few kilobytes of JSON."

A Small Pool That Expects You to Suspend

Swift concurrency runs tasks on a cooperative thread pool roughly the size of your CPU core count. "Cooperative" is the contract: a task is expected to suspend at await while it waits, giving its thread back. Code that blocks instead — a synchronous walk over a large directory tree, Thread.sleep, a semaphore wait, a synchronous network call — holds a pool thread hostage. Block a handful of them and unrelated tasks across the whole app stop making progress, with nothing in the logs to say why.

@concurrent and Task.detached do not change this. They choose which executor runs your code, and that executor is still the pool. Blocking work needs a thread that is allowed to block: a dispatch queue, or a dedicated thread, bridged back with a continuation.

The Server With Two Doors

The family's Swift HTTP listener, used by engines that run on the Mac, offers two handler doors on purpose. The async handler runs on a detached task — right for work that awaits. The blockingHandler runs on a concurrent dispatch queue — right for a sync engine that scans its data root with synchronous file-system calls. The choice is not style: put the scan behind the async door and it sits on a cooperative-pool thread for its whole duration.

Per-Element Overhead Is Real

The kit's hardened HTTP transport read response bodies like this: for try await byte in session.bytes(for: request), appending one UInt8 at a time while enforcing a size budget. Correct — and measured against a local file at about 16 MiB/s regardless of size, because every element costs an async next(). data(for:) moved the same bytes at over 1,000 MiB/s but gives up the mid-stream budget. The fix was a URLSessionDataDelegate: didReceive data arrives in chunks, appends once per chunk, and cancels the task the moment the running total would exceed the budget. Through the real transport over a loopback socket, 32 MiB then moved at about 1,237 MiB/s. Nobody noticed the slow loop for two weeks because every earlier consumer fetched small JSON; the first consumer to pull images found it.

A Main-Actor Test Body Blocks Its Own Callback

A Swift Testing test marked @MainActor verified a callback delivered on the main queue and timed out every time. The test body itself was the main-queue job, so the callback sat behind it, and spinning the run loop inside the test did not drain the dispatch queue. The fix was the test's shape, not the production code: make the test nonisolated, hop to the main actor only to arm and to read, and Task.sleep between polls so the main queue stays free.

Code

Give blocking work a thread that may block, and suspend the caller·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))
            }
        }
    }
}
A test that leaves the main queue free for the callback it waits on·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

Give Spark's engine client a body reader with a 64 MiB budget. Implement it first with bytes(for:) one byte at a time and time a 32 MiB download from a local server (python3 -m http.server over a generated file). Then implement it with a URLSessionDataDelegate that appends per chunk and cancels past the budget, and time it again. Record both numbers, and add a test proving the budget still holds for a response with no Content-Length.
Hint
python3 -m http.server always sends Content-Length, which is fine for timing. For the budget test, write a tiny handler that omits Content-Length and closes the connection after the body: URLSession stops delivering at a declared length, so an understated length never exercises the budget. The case that needs a running total is the one with no declared length.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.