Swift 采用了一种优化策略,称为 Copy-on-Write (COW),用于包装堆分配存储的值类型。该语言在赋值时不会立即执行深拷贝,而是在实例实际被修改时延迟复制。这是通过让值类型内部引用一个共享的 class 实例,同时使用 isKnownUniquelyReferenced 运行时函数来检测引用计数是否等于 1 来实现的。当发生变更且引用是唯一时,缓冲区就地修改;否则,在写入之前会创建一个拷贝,保持值语义而无需急需复制的性能损失。
我们的团队正在构建一个高性能的图像处理管道,其中我们定义了一个自定义的 Image struct,包装一个大的 CVPixelBuffer 后备存储。问题在于分析期间出现:每次过滤器应用都会创建三份中间拷贝的 4K 图像,导致每帧 300MB 的分配,并在 iPad 设备上触发内存警告。
我们考虑了解决这个瓶颈的三种不同方法。第一种方法涉及将 Image 从 struct 转换为 class。这通过使用引用语义完全消除了拷贝,但当多个处理链意外共享并同时修改相同的像素数据时,引入了严重的线程安全错误,导致难以调试的视觉伪影和竞争条件。
第二种方法保持了 struct 的标识,但使用 UnsafeMutablePointer 和 memcpy 优化实现手动深拷贝。这通过严格的值语义确保了安全性,但分析显示,它消耗的 CPU 时间比我们的目标多 800%,因为每个函数参数都触发了 12MB 的内存分配和逐位拷贝操作。
第三种方法手动实现了 Copy-on-Write 语义。我们创建了一个私有的 ImageBuffer class 来保存实际的 CVPixelBuffer,让 Image struct 持有对这个类的引用,并实现所有变更方法以在修改之前检查 isKnownUniquelyReferenced :
final class ImageBuffer { var pixels: CVPixelBuffer init(_ buffer: CVPixelBuffer) { self.pixels = buffer } } struct Image { private var buffer: ImageBuffer mutating func applyFilter(_ filter: Filter) { if !isKnownUniquelyReferenced(&buffer) { buffer = ImageBuffer(buffer.pixels.deepCopy()) } filter.process(buffer.pixels) } }
如果引用不是唯一的,我们会首先复制缓冲区。我们选择这个解决方案,因为它保持了 Swift 的值语义安全,同时在只读操作中消除了不必要的拷贝。
结果将内存压力减少了 94%,将每幅图像的帧处理时间从 120ms 改善到 18ms,使得该应用能够在老旧硬件上处理实时视频流而不出现热限制。
为什么我们不能手动检查引用计数,而要使用 isKnownUniquelyReferenced?
许多候选人建议手动跟踪引用计数或比较内存地址。然而,isKnownUniquelyReferenced 不仅仅是计数检查;它还包含编译器插入的障碍,防止优化重排内存操作。如果没有这个内在机制,编译器可能会优化掉唯一性检查,或者运行时可能由于 Objective-C 运行时交互或维持额外不可见的无拥有引用的桥接转换返回假阳性。
COW 如何与 Swift 的独占性强制执行相互作用?
候选人常常认为 COW 会自动针对所有包含类的值类型进行操作。他们忽视了 Swift 的独占性规则要求变更必须具有独占访问权限。在实现自定义 COW 时,isKnownUniquelyReferenced 检查必须在变更开始之前进行,并且缓冲区替换必须在检查时原子地发生。违反这一点而在检查期间持有多个引用可能会触发运行时独占性违规或导致唯一性检测的假阴性。
COW 在并发环境中何时无法防止复制?
在 Swift 5.5 的并发模型中,候选人假设 COW 提供线程安全的变更。然而,COW 确保安全性仅在单个线程内。当在 actor 边界之间传递值或标记为 Sendable 时,编译器可能会强制急需复制以维护隔离。此外,如果后备类包含 Objective-C 对象,isKnownUniquelyReferenced 可能由于 Objective-C 的弱引用实现而保守地返回 false,导致不必要的拷贝,从而需要重构拥有模型以进行优化。