3. Metal设备与命令队列

3.1 获取 MTLDevice:你的第一行 Metal 代码

任何 Metal 应用的第一步,都是拿到一个 MTLDevice 对象。它代表 GPU 的抽象,所有命令、缓冲区、纹理都从这里诞生。

用苹果的 API,一行就够了:

guard let device = MTLCreateSystemDefaultDevice() else {
    fatalError("Metal 不可用 — 这年头不可能")
}

但这里有个坑。我曾在一个项目中,用模拟器跑这段代码,结果直接 crash。为什么?模拟器不支持 Metal。所以生产环境一定要加判断,别假设所有设备都有 GPU。

注意:MTLCreateSystemDefaultDevice() 在模拟器中返回 nil。你可以用 MTLCopyAllDevices() 枚举所有设备,或者直接在真机测试。

如果你有多 GPU 的 Mac(比如部分 Intel Mac 有集显+独显),可以用 MTLCopyAllDevices() 拿到所有设备,根据功耗或性能选择:

let devices = MTLCopyAllDevices()
for device in devices {
    print("\(device.name), lowPower: \(device.isLowPower)")
}
获取方式适用场景注意点
MTLCreateSystemDefaultDevice()绝大多数 iOS / macOS 应用模拟器返回 nil,需 fallback
MTLCopyAllDevices()多 GPU Mac、选择低功耗或高性能在 iOS 上只返回一个

3.2 MTLCommandQueue:GPU 的任务管线

有了 device,下一步就是创建 Command Queue。它的职责很简单:排队提交命令缓冲区(CommandBuffer)给 GPU 执行。

我个人习惯每个线程只创建一条 queue,避免浪费资源:

guard let commandQueue = device.makeCommandQueue() else {
    fatalError("创建 CommandQueue 失败")
}

一个 queue 可以同时提交多个 command buffer,苹果保证顺序执行(FIFO)。如果你需要并行编码多组命令,可以用 makeCommandQueue(maxCommandBufferCount:) 指定最大缓冲数。不过说实话,默认值大多数情况都够用。

小经验:我曾经在 iPad Pro 上调试,发现创建了 10 条 queue 后编码速度反而下降。后来在 WWDC 文档里看到——不要为每个小任务创建独立 queue,尽量复用。一条 queue + 多线程编码共享它,才是最佳实践。

3.3 多线程安全:谁动了我 queue?

Metal 的 Command Queue 是 线程安全 的——这是我说的第一个好消息。你可以多个线程同时调用 makeCommandBuffer(),它内部有锁。

但 CommandBuffer 和 Encoder 不是线程安全的!每个线程必须用自己的 buffer 和 encoder。怎么搞?我推荐一个「生产者-消费者」模型:

  1. 一个全局 queue(主线程创建)。
  2. N 个工作线程,每线程创建自己的 command buffer,编码自己的 blit / compute 命令。
  3. 编码完成后,通过同一 queue 提交 buffer。

看代码:

// 全局 queue,线程安全
let queue = device.makeCommandQueue()!

// 工作线程
DispatchQueue.global(qos: .userInitiated).async {
    let buffer = queue.makeCommandBuffer()!
    let encoder = buffer.makeComputeCommandEncoder()!
    // ... 编码工作 ...
    encoder.endEncoding()
    buffer.commit()
    buffer.waitUntilCompleted()
}

嗯,这里要注意——千万别把 encoder 扔到其他线程去用。我在一个老项目里犯过这个错:后台线程用同一个 encoder 同时编码,结果 Metal 直接崩溃。后来加了个 @synchronized 勉强不崩,但性能惨不忍睹。正确做法是在每个线程内部创建独立的 encoder。

黄金规则:每线程一条 CommandBuffer + 自己的 Encoder;共享同一个 CommandQueue 提交。既安全又不阻塞。

3.4 内存与生命周期管理

MTLDevice 和 MTLCommandQueue 在对象存活期间一直有效,不用关心释放。但 CommandBuffer 和 Encoder 是临时对象,提交后建议调用 waitUntilCompleted 或通过回调跟踪完成状态。

你想想看,如果你不断创建 buffer 又不等待,内存会持续增长。Metal 有自动回收机制,但最好显式管理。我的经验是:

  • 编码循环中,每次用完 encoder 立即 endEncoding()。
  • 频繁调用的路径,重用 buffer 而不是每次都新建。
  • 用 MTLCommandBufferHandler 回调检测 GPU 完成,及时释放资源。

下面这张图展示了整个交互关系:

MTLDevice 与 CommandQueue 多线程架构 MTLDevice (GPU 抽象) MTLCommandQueue (线程安全) Thread 1 CommandBuffer #1 Encoder (非线程安全) Thread 2 CommandBuffer #2 Encoder (非线程安全) Thread 3 CommandBuffer #3 Encoder (非线程安全) Queue 线程安全,可被多线程共享提交 但每个线程必须使用自己的 CommandBuffer 和 Encoder

3.5 避坑清单

  • 不要在模拟器上依赖 Metal — 真机才能测试完整 GPU 功能。
  • 不要跨线程共享 Encoder — 每个线程独立创建。
  • 不要频繁创建新 Queue — 3~5 条足矣,超过则性能下降。
  • 别忘了等待 GPU 完成 — 特别是你要复用资源时。
最后一句个人心得: 刚开始写 Metal 多线程时,我总想“一次创建,到处使用”。后来发现,按线程创建 buffer 再统一提交,虽然代码量多几行,但心智负担极低,且没有偶发崩溃。这是“慢就是快”的典型例子。