Skip to content
macOSDeep Dive Published Updated 9 min readViews unavailable

Dispatch I/O on macOS: Channel Ownership, Partial Reads, and Backpressure

Use DispatchIO for bounded asynchronous file and stream I/O with explicit descriptor ownership, partial-result handling, watermarks, errors, and teardown.

DispatchIO schedules asynchronous reads and writes for a file descriptor using stream-based or random-access semantics. It can help keep blocking I/O away from a latency-sensitive thread, but it does not define your message framing, make a write transactional, or automatically bound every buffer your application allocates.

The key design questions are ownership of the underlying descriptor, interpretation of partial callbacks, read and write limits, and what happens when the consumer is slower than the producer. Treat the channel as an I/O coordinator with an explicit lifecycle, not as a replacement for the application’s protocol or storage semantics.

Choose the right channel semantics

A stream channel processes data in sequence and ignores the offset argument for reads and writes. This fits pipes and stream-like descriptors where data arrives as a sequence. A random-access channel uses offsets relative to the initial file position and is appropriate when operations address independent ranges of a file. Choosing the wrong type can make a correct-looking offset parameter have no effect.

When creating a channel around an existing descriptor, decide whether the channel or another component is responsible for closing it. Once the channel owns its I/O lifecycle, do not continue performing unrelated reads or closes on the same raw descriptor. Duplicate the descriptor first if two owners genuinely need independent lifetimes. A channel created from a path opens that path lazily on the first I/O operation; the path constructor’s cleanup handler reports when the channel relinquishes the descriptor.

import Dispatch
import Foundation

func startBoundedRead(from descriptor: Int32) -> DispatchIO {
    let queue = DispatchQueue(label: "com.example.reader.io")
    let channel = DispatchIO(
        type: .stream,
        fileDescriptor: descriptor,
        queue: queue
    ) { error in
        if error != 0 {
            recordChannelCleanupError(error)
        }
    }

    channel.setLimit(highWater: 64 * 1024)
    channel.read(offset: 0, length: 64 * 1024, queue: queue) {
        done, data, error in
        if let data, !data.isEmpty {
            consume(data)
        }
        if error != 0 {
            if done {
                failCurrentRead(error)
            } else {
                recordReadError(error)
            }
            return
        }
        if done {
            finishCurrentRead()
        }
    }
    return channel
}

func consume(_ data: DispatchData) {}
func finishCurrentRead() {}
func failCurrentRead(_ error: Int32) {}
func recordReadError(_ error: Int32) {}
func recordChannelCleanupError(_ error: Int32) {}

This function transfers responsibility for the descriptor to the channel; the caller must not close it a second time. A production owner should retain the returned channel until its work has either completed or been cancelled, then close it and release it. The placeholder consumer is intentionally synchronous; if processing schedules further work, that downstream queue also needs a size limit and an overload policy.

A read callback can run more than once

The read handler receives a done flag, data for the most recent chunk, and an errno-style error code. A single request may invoke the handler multiple times. Do not append a presumed complete record after the first callback or treat done == false as EOF. Accumulate bytes only under a known maximum, parse the application’s framing protocol, and preserve incomplete trailing bytes for the next chunk.

For stream channels, the supplied offset is ignored. For random-access channels, it identifies the offset relative to the descriptor’s initial file position. Use a bounded requested length for large files unless reading to EOF is actually required. A request for SIZE_MAX can continue until EOF, but collecting the entire result in one in-memory Data value can defeat the point of asynchronous I/O and exhaust memory.

An empty data value with done == true and zero error means the channel reached EOF. error and done answer different questions: the error is an errno-style I/O result, while done identifies the terminal callback. Apple documents that an unrecoverable descriptor error completes the request with done == true; preserve or discard any accompanying partial bytes according to the protocol, then finish the operation as failed. Never treat a nonzero error as EOF. Keep clean EOF distinct from a truncated application record: EOF is an I/O state, while whether the received bytes form a valid complete message belongs to the parser.

Writes need their own completion model

A write handler can be invoked multiple times while the system processes the supplied DispatchData. Track the final done callback and any error before declaring the operation complete. If the caller’s data is formed from mutable memory, ensure its lifetime and immutability match the DispatchData contract for the full asynchronous operation.

For a regular file, successful completion means the requested data was handed through the file-descriptor write path; it does not mean the bytes have been committed to durable storage or that a larger application-level file replacement is atomic. Use a temporary file, flush and close it according to your durability policy, and replace the final path only after validation if the feature needs all-or-nothing publication.

For a socket or pipe, partial progress and peer closure are normal operational outcomes. Define a protocol-level maximum message size and framing. A length prefix must be validated before allocating. A newline protocol must handle split lines and multiple lines in one callback. DispatchIO transports bytes; it does not preserve application message boundaries.

Watermarks, intervals, and backpressure

High-water and low-water limits influence how much data is processed before an I/O handler is enqueued. An interval can control when handlers are invoked while data is flowing. Tune these settings against the application’s latency and throughput goals; extremely small chunks increase callback overhead, while large chunks can delay incremental consumers and raise peak memory.

Watermarks are not a complete backpressure system. If the handler puts each chunk into an unbounded queue for a parser or writer, memory can still grow without bound. Keep a bounded downstream queue and decide whether to pause reads, drop replaceable data, or fail the operation when capacity is exhausted. For an archival copy, dropping bytes is generally invalid; for a latest-value telemetry stream, coalescing may be correct if the protocol allows it.

Avoid doing expensive parsing, compression, UI work, or synchronous logging in the I/O handler. Copy only what must outlive the callback, then transfer a bounded work item to the next stage. If order matters, serialize the consumer or attach sequence numbers and reorder deliberately.

Close, cancellation, and cleanup

Closing a channel prevents new read and write operations. Decide what to do with work already submitted: allow it to drain when completing a normal file operation, or use the documented stop behavior when the user cancels and pending work should be terminated. Do not declare cancellation complete until the operation’s handlers and cleanup policy have reached a terminal state.

The cleanup handler receives an error value when the channel relinquishes control of its file descriptor. Retain the state it needs until the handler runs. Make cleanup idempotent because UI closure, task cancellation, and I/O failure can race. Do not call close() on the raw descriptor again after the channel has assumed ownership.

Use barrier(execute:) when subsequent work must be ordered after prior channel operations. A barrier orders operations on the channel; it is not a filesystem transaction, a cross-process lock, or a guarantee that bytes survive sudden power loss. For durable records, define the required synchronization and recovery semantics at the file format layer.

Failure cases and acceptance criteria

Test EOF before a full frame, a frame split across callbacks, multiple frames in one chunk, empty files, descriptor closure by another owner, EINTR or I/O errors where reproducible, disk full, pipe peer closure, cancellation during read or write, and a slow consumer. Assert a maximum buffered byte count and verify that the cleanup callback runs exactly once for each created channel.

Measure bytes per handler, callback rate, queue depth, time to first record, throughput, memory high-water mark, and cancellation latency. Use realistic files and pipe producers; a fast local SSD benchmark does not model a slow removable volume or a consumer that blocks on network I/O. Test the end-to-end pipeline, including decoding and final publication.

DispatchIO supplies asynchronous descriptor operations with useful channel controls. Correctness still depends on explicit descriptor ownership, bounded buffers, framing, complete handling of done and errors, and an honest completion policy for writes.

Coordinate descriptors with other subsystems

Do not let a FileHandle, a Process termination handler, and a DispatchIO channel all believe they own the same descriptor. Pick one owner and expose a higher-level operation to the other components. When adapting a subprocess pipe, start reading promptly so the child cannot block forever on a full pipe buffer, and also drain stderr independently if the child can write there. A process that appears hung may simply be waiting for the parent to consume output.

If a user can rename, replace, or coordinate access to the file during a long operation, resolve the product’s file-coordination policy before creating the channel. A path identifies a location, not an immutable file object. Keep any coordinated-access lifetime around the actual read or write and avoid assuming that a second lookup later refers to the same contents.

The channel’s event queue and the application’s parser queue should have intentional QoS and capacity. Giving every stage a separate concurrent queue can increase reordering and make shutdown harder. Prefer a serial consumer for ordered streams, or explicitly partition work by independent file offsets. A barrier orders operations on the channel, but any application tasks launched from a handler need their own join or cancellation mechanism before the owner is destroyed.

Establish an overload policy

When the downstream stage cannot keep up, choose whether to pause scheduling more reads, temporarily lower concurrency, or terminate with a clear resource-limit error. A bounded file copy should preserve every byte; a live diagnostics feed may discard obsolete samples if its contract allows it. Make that distinction part of the API rather than hiding it in an arbitrary buffer cap.

Track the largest outstanding DispatchData, the number of queued parser jobs, and bytes written. On cancellation, stop accepting new application work before closing the channel. This prevents a race where cleanup begins but a handler enqueues another task that retains a file or view model after the user has left the operation.

Related:

Sources:

Comments