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?
1
0
391