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?
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(_:)withfalsein 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.