iOS VideoToolbox 后台卡死与 -12903 错误深度解析

·约 5 分钟·技术音视频iOS

在移动端音视频处理与转码应用中,Apple 的 VideoToolbox 是实现高吞吐、低功耗硬件编解码的基础设施。

然而,长时间转码或流媒体推流过程中,用户锁屏、接打电话、切换应用都会触发前后台切换。在接入 FFmpeg 硬编解码架构后,开发者经常遭遇严重的线上问题:

  1. App 退到后台时,编码线程出现永久卡死(Deadlock)
  2. 重新回到前台时,编解码器抛出大量 -12903 错误(即 kVTInvalidSessionErr);
  3. 即使重置解码器,视频依然会出现大段画面丢失、马赛克甚至花屏。

本文基于生产环境的排障过程,剖析 iOS 硬件资源回收机制,给出兼具稳定性与画质连续性的工程解决方案。


一、现象还原:-12903 与致命卡死

在典型的前后台切换测试中,控制台输出了以下报错日志:

1. 解码器报错:Session 失效

[hevc @ 0x11e165c00] Failed to decode frame (invalid session, -12903)
[hevc @ 0x11e165c00] hardware accelerator failed to decode picture
Demuxer::avcodec send video packet error, but will continue, Error: Unknown error occurred

2. 编码器报错与调用卡死

[hevc_videotoolbox @ 0x11e166300] Error: cannot encode frame: -12903
avcodec_send_frame, error: Generic error in an external library
[hevc_videotoolbox @ 0x11e166300] Error flushing frames: -12903
avcodec_send_frame, error: Generic error in an external library

更严重的是,解码器遇到错误通常只是跳过数据包继续报错,而编码器在后台尝试送帧或 Flush 时会直接陷入死锁,调用线程无法返回


二、底层机理:-12903 为什么会产生?

2.1 什么是 -12903?

在 macOS / iOS 系统的 VideoToolbox/VTErrors.h 中,定义了如下核心错误枚举:

kVTInvalidSessionErr = -12903,

它表明当前 VTCompressionSessionVTDecompressionSession 会话句柄已经失效,底层驱动所绑定的硬件加速上下文已不再可用。

2.2 iOS 硬件资源回收机制

iOS 执行严格的电源管理策略。当应用退至后台时(除非拥有后台音视频采集等白名单权限),系统会自动回收硬件资源:

  1. 回收硬件加速器:GPU 与 VPU 被挂起,原有的 VideoToolbox Session 被标记为 Invalid;
  2. 禁止 GPU 提交:如果底层挂载了 Metal 或 OpenGL 做纹理处理,命令缓冲区会被直接截断:
    Insufficient Permission (to submit GPU work from background)
    (00000006:kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted)
  3. Session 不可恢复:一旦被系统废弃,Session 内部的寄存器状态、显存映射与参考帧缓冲全部清空,后续调用均返回 -12903

三、解码端攻坚:IDR 关键帧与丢失画面重构

当检测到 -12903 时,最直接的做法是销毁旧解码器并重新初始化:

int Demuxer::reset_video_decoder() {
    // 释放旧资源
    if (m_video_dec_ctx) {
        avcodec_free_context(&m_video_dec_ctx);
        m_video_dec_ctx = nullptr;
    }
    if (m_hw_device_ctx) {
        av_buffer_unref(&m_hw_device_ctx);
        m_hw_device_ctx = nullptr;
    }

    // 优先硬解,失败则降级软解
    int ret = init_hw_decoder(&m_video_dec_ctx, AVMEDIA_TYPE_VIDEO);
    m_video_software_decoding = false;
    if (ret < 0) {
        ret = init_decoder(&m_video_dec_ctx, AVMEDIA_TYPE_VIDEO);
        m_video_software_decoding = true;
    }
    return ret;
}

3.1 连续丢帧:缺少 IDR 关键帧

重置解码器后,如果直接从断点处继续喂后续的 AVPacket,控制台会出现大量报错:

retry: -1313558101
retry skip
retry skip
... (连续跳过 23 个非关键帧)

原因: H.264 / HEVC 依赖帧间预测。新解码器启动时必须接收到一个 IDR(Instantaneous Decoder Refresh)关键帧来建立参考帧队列(DPB)。如果重置后直接输入 P 帧或 B 帧,解码器会丢弃所有数据包直到遇到下一个 GOP 的关键帧。

对于 GOP 为 1~2 秒的视频,切回前台后用户会看到大段画面冻结、卡顿甚至丢失数十帧

3.2 解决方案:Demuxer 回滚 Seek 与帧时间戳去重

为了找回断点与关键帧之间的画面,需要将 Demuxer 文件指针回滚至上一个关键帧

void Demuxer::read_frame() {
    AVPacket pkt;
    while (m_state != Stopped) {
        if (!m_can_read_frames) {
            m_read_frame_mutex.wait();
        }

        // 回退到上一个关键帧
        if (m_seek_keyframe_pts != AV_NOPTS_VALUE) {
            int ret = av_seek_frame(m_fmt_ctx,
                                  m_video_info.m_stream_index,
                                  m_seek_keyframe_pts,
                                  AVSEEK_FLAG_BACKWARD);
            if (ret >= 0) {
                clear_video_pkt_list();
            }
            m_seek_keyframe_pts = AV_NOPTS_VALUE;
        }

        int ret = av_read_frame(m_fmt_ctx, &pkt);
        // ... 分发音视频 Packet
    }
}

副作用:重复帧

文件指针回退后(例如从 PTS 14341 回滚至 13881),区间 [13881, 14341] 的帧会被重复解码。

直接送进编码器会导致时间倒流。因此需要在解码分发层建立基于 PTS 单调递增的去重过滤

/// 已提交的最晚视频帧时间戳
private var lastOfferVideoPst: CMTime = .zero

public func getDecodeVideoData(_ sampleBuffer: CMSampleBuffer?) {
    guard let sampleBuffer = sampleBuffer else {
        videoDecodeFinished = true
        return
    }

    if cacheType != .all && cacheType != .video { return }

    // 只有时间戳严格大于上一帧才允许进入管线
    if sampleBuffer.presentationTimeStamp > lastOfferVideoPst {
        offer(sampleBuffer, type: .video)
        lastOfferVideoPst = sampleBuffer.presentationTimeStamp
    } else {
        CMSampleBufferInvalidate(sampleBuffer)
    }
}

通过回溯与去重,确保下游收到的帧流在时序上保持连续。


四、编码端攻坚:后台死锁排查与 Flush 陷阱

解决了解码端,真正致命的卡死问题在编码端。

4.1 认知误区:退后台前先 Flush?

常规视频编码流程中,停用编码器前需要传入空指针(avcodec_send_frame(ctx, NULL))把缓存的 B 帧 Flush 出来。不少团队会在收到 applicationDidEnterBackground 时触发 Flush。

实际情况: 通过死锁堆栈分析发现:Flush 本身就是导致卡死的元凶

原因在于多线程竞态:

  1. 主线程收到退后台通知时,编码子线程可能正持有锁执行上一帧的编码;
  2. Flush 操作抢占到锁时,iOS 已经完成了硬件会话回收;
  3. 向已失效的 Session 发送 Flush 命令,Apple 驱动会无限期等待硬件反馈,陷入死锁

4.2 解决方案:切断后台硬件调用,放弃 Flush

经过反复验证,得出关键结论:

进入后台时,不要对即将失效或已失效的硬件编码器执行任何 Flush 操作。

采用即时切断策略

  1. 立刻挂起:检测到退后台后,置位原子状态标志,拦截所有 avcodec_send_frame 调用
  2. 记录断点:保存编码器最后成功落盘的 DTS / PTS;
  3. 前台重建:切回前台后,通过 avcodec_free_context 释放旧 Session,创建全新编码器上下文;
  4. 恢复编码:解码端回溯送帧,依靠时序去重保证连续性。

五、边缘问题:像素格式与生命周期时序

在持续回归测试中,还发现了两个容易忽视的问题:

5.1 像素格式影响卡死概率

FFmpeg 接入 VideoToolbox 时有两种输入像素格式:

  • AV_PIX_FMT_VIDEOTOOLBOX:数据在 CVPixelBuffer / GPU 共享显存中,零拷贝;
  • AV_PIX_FMT_YUV420P:数据在 CPU 主存中,由驱动拷贝至硬件编码器。

关键差异

  • 使用 AV_PIX_FMT_VIDEOTOOLBOX 时,后台尝试释放并重置编码器几乎必然卡死
  • 使用 AV_PIX_FMT_YUV420P 时,后台送帧与 Flush 仍报 -12903,但不会死锁,可以安全释放。

结论:如果采用零拷贝的 AV_PIX_FMT_VIDEOTOOLBOX必须将编码器的销毁与重建推迟到回到前台后执行

5.2 生命周期时序:willResignActivedidEnterBackground

iOS 生命周期通知中:

  • willResignActiveNotification:即将失去活动状态(拉下通知栏、多任务界面、锁屏);
  • didEnterBackgroundNotification:正式进入后台。

实测发现:如果 VideoToolbox 编码器在 willResignActive 状态下初始化,随后系统进入 didEnterBackground 时,该编码器在后续的送帧或析构中极易死锁。

最佳实践

  • 将防护时机绑定到 didEnterBackgroundNotification
  • 经历过 willResignActive 并确认为退后台后,强制将编码器标记为不可用。
NotificationCenter.default.addObserver(
    self,
    selector: #selector(didBecomeActive),
    name: UIApplication.didBecomeActiveNotification,
    object: nil
)

// 防护触发点绑定到退后台通知
NotificationCenter.default.addObserver(
    self,
    selector: #selector(didEnterBackground),
    name: UIApplication.didEnterBackgroundNotification,
    object: nil
)

六、全链路恢复方案

综合以上实践,恢复架构如下:

[退到后台]

置位 PipelineState = Paused,切断所有 avcodec_send_frame 调用
(不执行 Flush,避免触发驱动死锁)

[返回前台 (didBecomeActive)]

1. 解码端:销毁旧解码器,重建 VideoToolbox 解码器

2. 关键帧寻址:Demuxer 执行 av_seek_frame(BACKWARD) 回退至最近的 IDR 帧

3. 时序去重:丢弃 PTS <= 上次分发时间戳的重复帧

4. 编码端:若报错 -12903,释放旧 context,重建编码器

管线恢复,画面连续

要点

  1. 不在后台 Flush:对失效的硬件编码器执行 Flush 会触发驱动死锁;
  2. 回溯关键帧并去重:新解码器需要 IDR 帧才能工作,回退 Demuxer 并过滤重复帧;
  3. 销毁推迟到前台:尤其是零拷贝像素格式,后台释放硬件 Session 会卡死。