Separate CGBitmapContexts drawn concurrently produce wrong pixels when another thread fills translucent shapes (FB25211753)

Hi, my first post :-) feel free to say hi!

I’ve filed this as FB25211753 and am posting in case others hit it.

When several threads each draw into their own CGBitmapContext (separate buffers, separate contexts), one context’s output is corrupted if another thread is filling translucent shapes at the same time. QA1238 says drawing to separate Quartz objects on separate threads is supported.

  • With only identical renders running concurrently, 0 of 64 differ from a serial reference.
  • With another thread filling translucent ellipses into an unrelated context, 32 of 32 differ.
  • On macOS 27.0.1, each corrupted render has about 0.3–4.6% of its bytes wrong, by up to 219 of 255 levels. On 26.5.1 it was smaller: 0.2–0.5%, up to 55.
  • It’s triggered by translucent ellipses or paths. Opaque fills and translucent rectangles don’t trigger it, and per-thread colour spaces don’t help.
  • Thread Sanitizer reports no race in the test code.

Configuration: MacBook Pro (M4 Pro), macOS 26.5.1 and 27.0.1, Xcode 26.4 / Swift 6.3.

Minimal reproduction (Core Graphics only). Run with swiftc -O repro.swift -o repro && ./repro:

// Separate CGBitmapContexts drawn on separate threads give wrong pixels while another thread fills translucent
// shapes. Seen on macOS 26.5.1 and 27.0.1 (Apple M4 Pro). Core Graphics only.
// Run: swiftc -O repro.swift -o repro && ./repro
// Minimal repro: separate CGBitmapContexts on separate threads, Core Graphics only.
import CoreGraphics
import Foundation

let sRGB = CGColorSpace(name: CGColorSpace.sRGB)!

/// A fixed drawing: black, a few large translucent ellipses and a stroked circle.
func victim() -> [UInt8] {
    let w = 600, h = 400
    let ctx = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: w * 4,
                        space: sRGB, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
    ctx.setFillColor(CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1))
    ctx.fill(CGRect(x: 0, y: 0, width: w, height: h))
    for i in 0..<6 {
        ctx.setFillColor(CGColor(srgbRed: 1, green: Double(i) / 6, blue: 0.3, alpha: 0.5))
        ctx.fillEllipse(in: CGRect(x: 40 + i * 80, y: 100, width: 120, height: 120))
    }
    ctx.setStrokeColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.3))
    ctx.strokeEllipse(in: CGRect(x: 100, y: 50, width: 400, height: 300))
    let p = ctx.data!.assumingMemoryBound(to: UInt8.self)
    return Array(UnsafeBufferPointer(start: p, count: w * h * 4)) // read the context's own memory: no image decoding
}

/// Unrelated work on another context: many tiny translucent ellipses.
func aggressor() {
    let ctx = CGContext(data: nil, width: 800, height: 500, bitsPerComponent: 8, bytesPerRow: 0,
                        space: sRGB, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
    for k in 0..<1600 {
        ctx.setFillColor(CGColor(srgbRed: 1, green: 1, blue: 1, alpha: Double(k % 7) / 7))
        ctx.fillEllipse(in: CGRect(x: k % 800, y: k % 500, width: 1, height: 1))
    }
}

let reference = victim()
for mode in ["victims only", "victims + aggressors"] {
    let lock = NSLock()
    nonisolated(unsafe) var bad = 0, compared = 0
    DispatchQueue.concurrentPerform(iterations: 64) { i in
        if mode != "victims only", i % 2 == 1 { aggressor(); return }
        let same = victim() == reference
        lock.lock(); compared += 1; if !same { bad += 1 }; lock.unlock()
    }
    print("\(mode): \(bad) of \(compared) renders differ from the serial reference")
}

Has anyone else seen this, or found a workaround other than drawing on a single thread?

Answered by DTS Engineer in 910033022

Hello, and welcome to the forums!

Thanks for the careful, minimal reproduction and for filing FB25211753. Your reading of QA1238: Quartz 2D Thread Safety matches mine. It says "Quartz is thread safe on the whole, but individual Quartz objects are not." The conflicts it describes involve contexts that share a window, a bitmap, or a PDF data consumer. The contexts in your sample share none of these.

I reproduced what you describe with your sample on a Mac with M5 Max running macOS 27.0.1 (26A434). A similar test also produced wrong pixels on iPhone 18 Pro Max with iOS 27.0 (24A427) and on iPhone 16 with iOS 26.6.2 (23G90). Here's what I saw across those runs:

  • Wrong pixels appeared only when two or more threads drew antialiased, translucent shapes whose color or alpha changed between fills. Ellipses, paths, strokes, and text all showed the problem.
  • Opaque fills, fills that kept a single translucent color, one-point rectangles, gradients, and image draws produced correct output.
  • The wrong pixels lie along the edges of the shapes and contain another thread's colors. In your sample, many of them hold the other thread's white at that thread's alpha values.
  • A separate color space for each context made no difference, matching your result.

These approaches produced correct output in my tests:

  • setShouldAntialias(_:) with false in every context that draws at the same time, at the cost of jagged edges.
  • Bitmap contexts with 16-bit or 32-bit float components for every context that draws at the same time. Antialiasing stays on, but each context uses two or four times the memory of 8-bit RGBA. For 16-bit components:
let context = CGContext(data: nil, width: width, height: height,
                        bitsPerComponent: 16, bytesPerRow: width * 8,
                        space: CGColorSpace(name: CGColorSpace.sRGB)!,
                        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
                            | CGBitmapInfo.floatComponents.rawValue
                            | CGBitmapInfo.byteOrder16Little.rawValue)

I've added these results to FB25211753.

Hello, and welcome to the forums!

Thanks for the careful, minimal reproduction and for filing FB25211753. Your reading of QA1238: Quartz 2D Thread Safety matches mine. It says "Quartz is thread safe on the whole, but individual Quartz objects are not." The conflicts it describes involve contexts that share a window, a bitmap, or a PDF data consumer. The contexts in your sample share none of these.

I reproduced what you describe with your sample on a Mac with M5 Max running macOS 27.0.1 (26A434). A similar test also produced wrong pixels on iPhone 18 Pro Max with iOS 27.0 (24A427) and on iPhone 16 with iOS 26.6.2 (23G90). Here's what I saw across those runs:

  • Wrong pixels appeared only when two or more threads drew antialiased, translucent shapes whose color or alpha changed between fills. Ellipses, paths, strokes, and text all showed the problem.
  • Opaque fills, fills that kept a single translucent color, one-point rectangles, gradients, and image draws produced correct output.
  • The wrong pixels lie along the edges of the shapes and contain another thread's colors. In your sample, many of them hold the other thread's white at that thread's alpha values.
  • A separate color space for each context made no difference, matching your result.

These approaches produced correct output in my tests:

  • setShouldAntialias(_:) with false in every context that draws at the same time, at the cost of jagged edges.
  • Bitmap contexts with 16-bit or 32-bit float components for every context that draws at the same time. Antialiasing stays on, but each context uses two or four times the memory of 8-bit RGBA. For 16-bit components:
let context = CGContext(data: nil, width: width, height: height,
                        bitsPerComponent: 16, bytesPerRow: width * 8,
                        space: CGColorSpace(name: CGColorSpace.sRGB)!,
                        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
                            | CGBitmapInfo.floatComponents.rawValue
                            | CGBitmapInfo.byteOrder16Little.rawValue)

I've added these results to FB25211753.

Thanks for such a thorough follow-up, and for the welcome!

It helps a lot to know this reproduces on M5 Max and on iOS 26 and 27, and that the wrong pixels on the edges contain the other thread's colours. That fits what I saw: the corruption got worse from 26.5.1 to 27.0.1, and per-thread colour spaces made no difference.

I'll try both workarounds in my real app (not just the repro) and post the results here. A few questions in the meantime:

  1. Scope of the workaround. Does every context that draws at the same time need to switch to 16-bit float or turn off antialiasing? Or is it enough to change only the contexts that draw translucent shapes? In my app the "aggressor" work and the "victim" work come from different subsystems, so it matters which side has to change.
  2. Narrower serialisation. You found that fills which keep a single translucent colour are fine. Would it be safe to serialise only the antialiased translucent fills where the colour or alpha changes, behind a shared lock, and leave everything else concurrent? Or could the problem reach other drawing operations too?
  3. Converting back to 8-bit. If I render into a 16-bit float context and then convert to 8-bit RGBA for output, is that conversion step safe to run concurrently too?
  4. Fix timing. I understand you can't share release plans, but should I treat this as a long-term workaround or a short-term one?

Thanks again for reproducing it so quickly and adding your results to FB25211753.

Thanks for the follow-up, and for planning to try both workarounds in your real app. I ran new tests for your questions on four devices, and the results matched on all of them:

  • Mac with macOS 27.0.1
  • iPhone 18 Pro Max with iOS 27.0
  • iPad Pro 11-inch (4th generation) with iPadOS 27.0
  • iPad Pro 13-inch (M4) with iPadOS 26.7

Which contexts need the change. Contexts that drew only opaque content didn't need the change. When two contexts drew translucent shapes at the same time, changing either one was enough. Turning off antialiasing or using float components on one side kept both sides correct. On the Mac, every context except one needed the change when three or more contexts drew different translucent content at the same time. Any two antialiased 8-bit contexts with different content can corrupt each other. That includes your aggressors: on the Mac, their own output was wrong in many runs of my version of your test. So if your aggressor work runs on more than one thread at a time, each of those contexts counts separately.

A narrower lock. A single lock shared by every context, held only around each antialiased translucent fill and stroke, produced correct output on all four devices. Opaque drawing, context creation, and reading the pixels back all ran outside the lock, and the output stayed correct. A lock taken by only one side didn't help in earlier tests. The lock covered every translucent fill, including ones that kept the same color. I tested ellipses and strokes under it, but not text. In earlier tests, text and paths showed the problem too, while gradients and image draws didn't. So text and paths belong inside the lock as well.

Converting back to 8-bit. Converting a 16-bit float result to 8-bit RGBA produced correct output every time, while 8-bit contexts drew translucent shapes on other threads. I tried two ways: drawing the float context's makeImage() result into an 8-bit RGBA context, and vImageBuffer_InitWithCGImage(_:_:_:_:_:). An 8-bit context drawing at the same time in those runs produced wrong pixels, so the test was detecting the problem.

How long to plan for. I can't comment on future releases. The behavior reproduces on releases that have already shipped: iOS 26.6.2, iPadOS 26.7, iOS 27.0, and macOS 27.0.1 in my tests, and 26.5.1 in yours. Those releases keep their current behavior, so your app needs a workaround for as long as it supports them.

I'm interested to hear how both workarounds do in your real app.

Thanks for the detailed follow-up. I tested both workarounds on a Mac16,8 (Apple M4 Pro) running macOS 27.0.1 (26A434).

Every context is checked against its own serial reference, including the aggressors. Each line is 50 rounds of 64 concurrent renders, and the two control lines in the same run show the problem is still being caught.

Renders wrong (victims, aggressors):

  • No workaround (control): 1593/1600, 141/1600
  • Lock on the victim side only (control): 1600/1600, 221/1600
  • One lock shared by all contexts, held only around translucent fills and strokes: 0/1600, 0/1600
  • Antialiasing off on the victim only: 0/1600, 0/1600
  • Antialiasing off on the aggressor only: 0/1600, 0/1600
  • Victim in 16-bit float, converted to 8-bit by drawing makeImage() into an RGBA8 context: 0/1600, 0/1600
  • Victim in 16-bit float, converted with vImageBuffer_InitWithCGImage: 0/1600, 0/1600
  • Aggressor in 16-bit float, converted with vImage: 0/1600, 0/1600

With 2, 3 and 4 victims drawing different content and no aggressors, 612, 733 and 786 of 3,200 renders were wrong without a workaround. With the shared lock or the float path on every context, none were.

These results match yours: a lock on one side doesn't help, changing eit contexts, and the aggressors' own output is also corrupted. The floatpath isn't pixel-identical to drawing directly in 8-bit. About 2.5% of bytes differ, by at most 1, which is fine for my use. I didn't test text under the lock, and I only tested on this Mac.

As for my app: it currently renders all its artwork on a single thread, rmal use. The only place it was exposed is the parallel test runner, andI've serialised the rendering tests. If I move rendering onto several threads later, I'll use the float path or turn off antialiasing on all but one context, so the drawing itself doesn't have to be serialised.

Thanks for testing both workarounds so carefully, and for posting the numbers. You checked every context against its own serial reference, aggressors included, with the controls in the same run, so the results are easy to trust. They match mine on the Mac. I added notes about your findings to our bug report tracking system.

You found that the float path differs from direct 8-bit drawing in about 2.5% of bytes, by at most 1 level. That shows anyone choosing between the two workarounds how much the output changes.

I'm glad to hear only your test runner was affected.

Please continue to test your software in release and pre-release versions of system software made available to you through your developer.apple.com account. When you do, add details about that into your bug report to keep it up to date. Please see developer.apple.com/download.