Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

macOS IOSurface: Cross-Process Image Handoffs, Ownership, and Synchronization

Design macOS IOSurface pipelines with explicit Mach or XPC handoff, pixel-plane layout, balanced lifetimes, and separate CPU/GPU synchronization.

IOSurface is Apple’s opaque framebuffer-style object for sharing image memory across process boundaries. It gives applications a way to pass large pixel buffers between components, such as a decoder, image-processing service, and renderer, without treating the pixels as an ordinary copied message. Apple describes the framework specifically as a mechanism for sharing hardware-accelerated buffer data across processes. A successful handoff still requires a contract for the pixel format, plane layout, access phase, synchronization, and lifetime; IOSurface does not make those application rules disappear. (IOSurface overview)

Define the buffer contract before allocating it

Create a surface with IOSurfaceCreate() and a property dictionary that describes the intended image. At minimum, both sides need to agree on dimensions, pixel format, and any planar layout. Do not calculate every row as width × bytesPerPixel: row stride can include padding, and formats such as YUV can have multiple planes with plane-specific dimensions and strides. Use the IOSurface accessors for allocation size, bytes per row, plane count, and per-plane addresses instead of reconstructing layout from a display label. Apple’s API exposes separate functions for the base address and plane addresses, as well as the corresponding byte-stride queries. (IOSurface functions)

Treat the surface descriptor as a versioned IPC contract, not as an incidental implementation detail. Include a frame or generation number in the surrounding message so a consumer can reject stale metadata when the producer changes its format or replaces a pool. Validate dimensions, plane count, and expected allocation bounds before a consumer interprets memory. The surface object identifies storage; the IPC message should identify what those bytes mean and which producer state they belong to.

Pass a reference, not a guessed global identity

For a Mach-based transfer, the producer can call IOSurfaceCreateMachPort(surface) and send the returned port through an IPC channel whose access policy already permits the intended receiver. The receiving process reconstructs its local IOSurfaceRef using IOSurfaceLookupFromMachPort(). Apple documents this route as a way to pass the surface to another task without making it global to the entire system. The returned port must be deallocated with mach_port_deallocate() or an equivalent ownership operation; while a Mach port created from a surface remains live, it implicitly increments the surface’s global use count. (IOSurfaceCreateMachPort, IOSurfaceLookupFromMachPort)

If the service boundary is XPC, IOSurface also provides IOSurfaceCreateXPCObject() and IOSurfaceLookupFromXPCObject(). The created XPC object holds a reference to the surface; lookup recreates a surface reference from an object produced by the matching create function. Keep the transport object and the local surface reference’s ownership separate in your cleanup path. A lookup does not make the transport’s lifetime irrelevant, and releasing a local Core Foundation reference is not the same operation as deallocating a Mach right. (IOSurfaceCreateXPCObject, IOSurfaceLookupFromXPCObject)

This is also why passing a numeric IOSurface ID is not an equivalent substitute for transferring a reference. Apple’s Core Video documentation warns that a CVPixelBufferPool must not reuse a pixel buffer while its IOSurface remains in use by another process. It says Core Video and IOSurface manage this pool-reuse lifetime when the reference is transferred with IOSurfaceCreateMachPort() and IOSurfaceLookupFromMachPort(), but not when an application passes only an IOSurface ID. An ID-based protocol therefore needs its own ownership record, such as a consumer-done acknowledgment or lease, before returning a buffer to the pool. This guarantee is about pool reuse; it does not replace synchronization when processes or the GPU concurrently access a surface that remains shared. (CVPixelBufferCreateWithIOSurface)

CPU locks do not replace a GPU dependency graph

When the CPU directly reads or writes surface bytes, use IOSurfaceLock() and IOSurfaceUnlock() around that CPU access and check the returned status. For read-only access, pass the read-only option to both the matching lock and unlock calls; Apple’s sample follows this symmetric pairing. Do not pass it on only one side. Apple’s avoidSync lock option is useful when the caller must avoid a potentially expensive paging operation such as GPU-to-system-memory readback: if the lock requires that readback, the operation fails with kIOReturnCannotLock. That is a deliberate failure to avoid the transfer, not a successful lock with stale data. (IOSurfaceLock, Apple read-only IOSurface sample, IOSurfaceLockOptions.avoidSync)

Do not use an IOSurface CPU lock as a substitute for ordering GPU command buffers. Metal has its own resource synchronization mechanisms. For dependencies that cross queues, devices, processors, or processes, Apple documents MTLSharedEvent as a shareable event with a monotonically increasing value; a producer can signal a value after its commands, and a consumer can wait for that value before using the shared resource. Keep the buffer ownership protocol and GPU event protocol aligned: the producer must not recycle a frame until the consumer’s completion value proves that its last read has finished. (Metal synchronization events, synchronizing across devices or processes)

For a Core Video path, CVPixelBufferCreateWithIOSurface() wraps the surface in a pixel buffer. For a pool-backed buffer transferred with the Mach-port create/lookup pair, Apple’s documented lifetime handling prevents pool reuse while the IOSurface is still in use by another process. If a protocol passes only IDs, track remote use at the application layer before recycling the buffer. Neither mechanism makes concurrent access safe: synchronize producer/consumer phases, and when importing an IOSurface into Metal, keep the API-specific resource and event handles alive through the encoded GPU work and release them only after their documented completion point.

A bounded producer/consumer protocol

A practical frame slot can carry:

  1. A transferred IOSurface reference.
  2. A generation number and the agreed format/plane description.
  3. A producer-ready event value.
  4. A consumer-done event value or an IPC acknowledgment.

The producer chooses a free slot, writes the frame, signals readiness, and does not reuse that slot until the consumer has acknowledged completion. The consumer checks the generation and format, waits for producer readiness, processes the frame, then signals completion. Keep a bounded number of slots and define what happens when the consumer falls behind: drop a frame, apply backpressure, or disconnect the stalled consumer. An unbounded queue of surfaces converts a slow consumer into unbounded memory retention.

Use separate slot states such as free, producing, ready, and consuming; transition them with a synchronization primitive appropriate to the IPC architecture. Do not let both processes independently decide that the same slot is free. If a consumer exits unexpectedly, have a supervisor retire its outstanding ownership records and recreate the affected slot rather than returning it to the pool based on a timeout alone. A timeout can detect a stalled peer, but it cannot prove that another process or GPU has stopped accessing memory.

Diagnose format and lifetime bugs systematically

Log surface generation, dimensions, pixel format, plane count, per-plane row bytes, and slot state, not raw image content or secrets. Validate color space and range separately from byte layout; a correctly sized buffer can still display incorrectly if the producer and consumer disagree on color interpretation. In tests, transfer frames across actual process boundaries, exercise both Mach-port and XPC paths used by the application, delay consumer completion to test pool pressure, and terminate the consumer while it owns a slot. Verify that every Mach right, XPC object, IOSurface reference, pixel-buffer wrapper, and Metal event handle has a defined release point.

IOSurface solves shared storage; it does not by itself define who may read or write a frame, when those accesses are ordered, or when the producer may reuse it. Keep those policies in the surrounding protocol, use Apple’s reference-transfer APIs for the handoff, use CPU locks only for CPU access, and use Metal synchronization for GPU dependencies.

Related:

Sources:

Comments