音视频开发实战
第6章:异步多线程播放器
| 项目 | 内容 |
|---|---|
| 本章目标 | 掌握异步多线程播放器的核心概念和实践 |
| 难度 | ⭐⭐⭐ 较高 |
| 前置知识 | Ch5:多线程基础、互斥锁、条件变量 |
| 预计时间 | 3-4 小时 |
本章引言
本章目标:将第五章的同步播放器改造为异步架构,让解码和渲染并行工作,解决卡顿问题。
第五章我们实现了一个同步单线程播放器——文件读取、解码、渲染都在一个循环里顺序执行。这种方式简单直接,但存在明显的性能瓶颈:当拖动窗口或解码复杂画面时,播放就会卡顿。
本章将介绍异步多线程架构——把解码放到独立线程,通过线程安全的队列连接各个环节。这不仅是性能优化的关键,也是理解实时流媒体系统的基础。
阅读指南: - 第 1-2 节:理解为什么要异步,掌握 C++11 多线程基础 - 第 3-5 节:学习生产者-消费者模式,设计解码线程架构 - 第 6-7 节:代码实现,性能对比测试 - 第 8 节:常见问题排查
目录
1. 为什么需要异步:同步播放器的局限
本节概览:回顾第一章的同步实现,分析其性能瓶颈。通过具体数据对比,理解为什么需要引入多线程架构。
1.1 同步播放器的时间线
第一章的实现是典型的串行处理:
时间线(单线程):
├─ 读取文件 ──┤
├─ 解码 ──────┤
├─ 渲染 ───┤
├─ 读取文件 ──┤
...
总时间 = 读取时间 + 解码时间 + 渲染时间
以 1080p 30fps 视频为例,每帧预算 33ms。让我们看看各环节耗时:
| 环节 | 平均耗时 | 最坏情况 | 说明 |
|---|---|---|---|
| 读取文件 | 1-3ms | 5ms | 从磁盘或网络读取 |
| 解码 | 8-12ms | 25ms | I 帧复杂,P 帧简单 |
| 渲染 | 5-8ms | 16ms | 含 VSync 等待 |
| 总计 | 14-23ms | 46ms | 最坏情况超出预算 |
33ms 预算 vs 46ms 实际 = 卡顿!
1.2 卡顿的具体场景
场景一:拖动窗口
用户拖动窗口 → 操作系统发送重绘消息 →
主线程忙于解码渲染 → 无法响应消息 → 窗口冻结
场景二:复杂画面
视频出现 I 帧(场景切换)→ 解码耗时 25ms →
渲染被推迟 → 帧率从 30fps 掉到 20fps → 视觉卡顿
场景三:系统负载高
后台程序占用 CPU → 解码线程被调度延迟 →
即使平均耗时正常,偶尔波动也会造成卡顿
1.3 异步解决方案
把解码放到独立线程,主线程专注于渲染和响应用户操作:
线程 A(解码):读取文件 → 解码 → 写入队列 ─┐
│
线程 B(主线程):← 从队列读取 ──→ 渲染 ──→ 显示 ──┤
│
响应用户操作 ←┘
并行后的时间线:
解码线程:├─解码─┤├─解码─┤├─解码─┤├─解码─┤├─解码─┤
↓ 队列(缓冲3帧)
主线程: ├─渲染─┤├─渲染─┤├─渲染─┤├─渲染─┤├─渲染─┤
关键优势: - 解码的波动被队列平滑(缓冲作用) - 主线程始终有帧可渲染,保持流畅 - 可以及时响应用户操作
1.4 异步带来的新问题
引入多线程并非免费午餐,需要解决:
| 问题 | 说明 | 本章解决方案 |
|---|---|---|
| 线程安全 | 多个线程访问共享数据 | mutex 互斥锁 |
| 同步协调 | 解码和渲染的节奏控制 | condition_variable |
| 队列管理 | 缓冲多少帧?满了怎么办? | 固定大小队列 + 丢帧策略 |
| 资源竞争 | 解码和渲染抢 CPU | 线程优先级(可选) |
本节小结:同步播放器在复杂场景下会卡顿,因为单线程无法同时处理解码、渲染和响应用户。异步架构将解码放到独立线程,但需要解决线程安全、同步协调等问题。
2. C++11 多线程基础
本节概览:本章使用 C++11
标准库实现多线程。这一节介绍
std::thread、std::mutex 和
std::condition_variable 的基本用法。
2.1 创建线程:std::thread
C++11 引入 std::thread 类,创建线程变得简单:
#include <thread>
#include <iostream>
void DecodeThread() {
for (int i = 0; i < 5; i++) {
std::cout << "解码帧 " << i << std::endl;
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}
int main() {
// 创建线程
std::thread decoder(DecodeThread);
std::cout << "主线程继续执行" << std::endl;
// 等待线程结束
decoder.join();
std::cout << "所有线程结束" << std::endl;
return 0;
}线程的生命周期:
创建 (std::thread) → 运行 → 结束 → 回收 (join 或 detach)
⚠️ 重要:线程对象析构前必须
join()(等待结束)或
detach()(分离运行),否则程序会崩溃。
2.2 互斥锁:std::mutex
多个线程访问共享数据时需要加锁保护:
#include <mutex>
#include <vector>
std::vector<AVFrame*> frame_queue;
std::mutex queue_mutex; // 保护队列的锁
// 解码线程(生产者)
void Producer() {
while (has_more_frames) {
AVFrame* frame = DecodeOneFrame();
// 加锁,保护共享队列
queue_mutex.lock();
frame_queue.push_back(frame);
queue_mutex.unlock();
}
}
// 主线程(消费者)
void Consumer() {
while (!should_quit) {
// 加锁,访问共享队列
queue_mutex.lock();
if (!frame_queue.empty()) {
AVFrame* frame = frame_queue.front();
frame_queue.erase(frame_queue.begin());
queue_mutex.unlock();
Render(frame);
} else {
queue_mutex.unlock();
// 队列为空,等待一下
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
}lock/unlock
的问题:如果中间抛出异常或提前返回,unlock
不会执行,导致死锁。
更好的方式:std::lock_guard(RAII 自动管理锁)
void Producer() {
while (has_more_frames) {
AVFrame* frame = DecodeOneFrame();
{
// 构造函数 lock,析构函数 unlock
std::lock_guard<std::mutex> lock(queue_mutex);
frame_queue.push_back(frame);
} // 离开作用域自动 unlock
// 其他操作(无需锁保护)...
}
}2.3 条件变量:std::condition_variable
上面的消费者代码有一个问题:当队列为空时,它只能忙等(sleep 1ms)。这种方式效率低,且延迟不确定。
条件变量允许线程阻塞等待,直到满足某个条件:
#include <condition_variable>
std::vector<AVFrame*> frame_queue;
std::mutex queue_mutex;
std::condition_variable queue_cv; // 条件变量
// 生产者
void Producer() {
while (has_more_frames) {
AVFrame* frame = DecodeOneFrame();
{
std::lock_guard<std::mutex> lock(queue_mutex);
frame_queue.push_back(frame);
}
// 通知等待的消费者
queue_cv.notify_one(); // 唤醒一个等待的线程
}
}
// 消费者
void Consumer() {
while (!should_quit) {
std::unique_lock<std::mutex> lock(queue_mutex);
// 等待条件:队列非空
// 如果条件不满足,自动 unlock 并阻塞;被唤醒后自动 lock
queue_cv.wait(lock, []() {
return !frame_queue.empty() || should_quit;
});
if (should_quit) break;
AVFrame* frame = frame_queue.front();
frame_queue.erase(frame_queue.begin());
lock.unlock(); // 尽早 unlock
Render(frame);
}
}条件变量的工作方式:
消费者:
1. lock mutex
2. 检查条件(队列非空?)
3. 如果不满足 → unlock → 阻塞等待(不消耗 CPU)
4. 被生产者唤醒 → lock → 检查条件 → 继续执行
生产者:
1. 生产数据
2. lock mutex
3. 放入队列
4. unlock
5. notify_one() 唤醒等待的消费者
2.4 线程同步原语对比
| 原语 | 用途 | 特点 | 适用场景 |
|---|---|---|---|
std::mutex |
互斥访问 | 简单直接 | 临界区很短 |
std::lock_guard |
自动管理锁 | RAII,异常安全 | 推荐使用 |
std::unique_lock |
灵活加锁/解锁 | 可手动 unlock | 配合 condition_variable |
std::condition_variable |
等待条件 | 阻塞不消耗 CPU | 生产者-消费者 |
std::atomic |
原子操作 | 无锁,最快 | 计数器、标志位 |
本节小结:C++11
提供了完善的多线程支持。std::thread
创建线程,std::mutex
保护共享数据,std::condition_variable
实现高效的事件通知。下一节将把这些基础组合成完整的设计模式。
3. 生产者-消费者模式
本节概览:生产者-消费者是多线程编程的经典模式。这一节介绍其原理、应用场景,以及在视频播放器中的具体设计。
3.1 模式概述
生产者-消费者模式描述了两个角色通过队列解耦协作:
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 生产者 A │──┐ │ │ ┌──│ 消费者 X │
├──────────┤ │ │ 队列 │ │ ├──────────┤
│ 生产者 B │──┼──→│ ┌──┐ │←──┼──│ 消费者 Y │
├──────────┤ │ │ │ │ │ │ ├──────────┤
│ 生产者 C │──┘ │ └──┘ │ └──│ 消费者 Z │
└──────────┘ └──────────┘ └──────────┘
↑ ↑
生产数据 消费数据
核心思想: - 生产者只管生产,不关心消费者是谁 - 消费者只管消费,不关心生产者是谁 - 队列作为缓冲,平衡生产与消费的速度差异
视频播放器中的应用:
| 生产者 | 队列 | 消费者 |
|---|---|---|
| 解码线程 | 帧队列 | 渲染线程 |
| 网络接收线程 | 数据包队列 | 解码线程 |
| 音频采集线程 | 音频帧队列 | 编码线程 |
3.2 队列的设计权衡
队列不是越大越好,需要根据场景设计:
1. 无界队列(Unbounded)
std::queue<AVFrame*> queue; // 一直增长直到内存耗尽- 优点:永不满,生产者不会阻塞
- 缺点:内存可能无限增长,延迟不确定
- 适用:生产速度略快于消费,内存充足
2. 有界队列(Bounded)
std::queue<AVFrame*> queue;
const size_t MAX_SIZE = 10; // 最多缓存 10 帧- 优点:内存可控,延迟可控
- 缺点:队列满时生产者需等待或丢帧
- 适用:实时流媒体,内存受限
3. 零队列(Zero-queue)
// 直接传递,无缓冲- 优点:延迟最低
- 缺点:生产消费必须严格同步
- 适用:对延迟极其敏感的场景
3.3 视频播放器的队列策略
播放器通常使用有界队列 + 丢帧策略:
队列大小 = 3-5 帧(约 100-150ms 缓冲)
队列满时:
- 选项 A:生产者阻塞等待(延迟增加)
- 选项 B:丢弃最旧的帧(追帧,保持实时)
- 选项 C:降低视频质量(码率自适应)
队列空时:
- 显示上一帧(冻结)
- 或显示黑屏
- 等待新帧到达
为什么选 3-5 帧?
| 缓冲帧数 | 延迟 | 抗抖动能力 | 适用场景 |
|---|---|---|---|
| 1 帧 | 33ms | 弱 | 极低延迟场景 |
| 3 帧 | 100ms | 中等 | 直播播放(推荐) |
| 5 帧 | 150ms | 强 | 网络波动大的场景 |
| 10 帧 | 300ms | 很强 | 点播(非实时) |
直播场景通常选择 3 帧,平衡延迟和流畅度。
3.4 状态机设计
队列需要管理自身状态,生产者消费者根据状态行动:
┌─────────┐ Push ┌─────────┐ Push (满) ┌─────────┐
│ Empty │ ───────→ │ Normal │ ────────────→ │ Full │
│ (空) │ ←─────── │ (正常) │ ←──────────── │ (满) │
└─────────┘ Pop └─────────┘ Pop (空) └─────────┘
↑ │
└────────────── Clear ─────────────────────┘
状态对应的行动:
| 状态 | 生产者 | 消费者 |
|---|---|---|
| Empty | 正常生产,通知消费者 | 等待(阻塞) |
| Normal | 正常生产 | 正常消费 |
| Full | 等待或丢帧 | 正常消费,通知生产者 |
本节小结:生产者-消费者模式通过队列解耦生产和消费。视频播放器使用有界队列(3-5帧)平衡延迟和流畅度,队列满时采用丢帧策略保持实时性。下一节将实现线程安全的帧队列。
4. 线程安全的帧队列
本节概览:把第三节的设计转化为代码。实现一个支持固定大小、线程安全、可阻塞等待的帧队列。
4.1 接口设计
#pragma once
#include <queue>
#include <mutex>
#include <condition_variable>
#include <atomic>
extern "C" {
#include <libavutil/frame.h>
}
namespace live {
// 队列状态
enum class QueueState {
OK, // 正常
Full, // 队列满
Empty, // 队列为空
Stopped // 已停止
};
// 线程安全的帧队列
class FrameQueue {
public:
explicit FrameQueue(size_t max_size = 3);
~FrameQueue();
// 禁止拷贝(mutex 不可拷贝)
FrameQueue(const FrameQueue&) = delete;
FrameQueue& operator=(const FrameQueue&) = delete;
// 生产者接口
QueueState Push(AVFrame* frame, bool block = false);
// 消费者接口
AVFrame* Pop(bool block = true);
// 查询状态
size_t Size() const;
bool Empty() const;
bool Full() const;
// 控制
void Clear();
void Stop();
private:
std::queue<AVFrame*> queue_;
const size_t max_size_;
mutable std::mutex mutex_;
std::condition_variable not_full_; // 队列不满
std::condition_variable not_empty_; // 队列非空
std::atomic<bool> stopped_{false};
};
} // namespace live设计要点: - max_size_:队列最大容量 -
not_full_:生产者等待的条件变量(队列不满时通知) -
not_empty_:消费者等待的条件变量(队列非空时通知) -
stopped_:原子标志,用于优雅停止
4.2 实现代码
#include "frame_queue.h"
#include <iostream>
namespace live {
FrameQueue::FrameQueue(size_t max_size) : max_size_(max_size) {
std::cout << "[FrameQueue] Created with max_size=" << max_size << std::endl;
}
FrameQueue::~FrameQueue() {
Clear();
}
QueueState FrameQueue::Push(AVFrame* frame, bool block) {
std::unique_lock<std::mutex> lock(mutex_);
// 如果非阻塞且队列满,直接返回
if (!block && queue_.size() >= max_size_) {
return QueueState::Full;
}
// 等待队列不满(或停止)
not_full_.wait(lock, [this]() {
return queue_.size() < max_size_ || stopped_.load();
});
if (stopped_) {
return QueueState::Stopped;
}
// 入队
queue_.push(frame);
size_t size = queue_.size();
lock.unlock();
// 通知等待的消费者
not_empty_.notify_one();
if (size >= max_size_) {
return QueueState::Full;
}
return QueueState::OK;
}
AVFrame* FrameQueue::Pop(bool block) {
std::unique_lock<std::mutex> lock(mutex_);
// 如果非阻塞且队列为空,直接返回
if (!block && queue_.empty()) {
return nullptr;
}
// 等待队列非空(或停止)
not_empty_.wait(lock, [this]() {
return !queue_.empty() || stopped_.load();
});
if (stopped_ && queue_.empty()) {
return nullptr;
}
// 出队
AVFrame* frame = queue_.front();
queue_.pop();
lock.unlock();
// 通知等待的生产者
not_full_.notify_one();
return frame;
}
size_t FrameQueue::Size() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size();
}
bool FrameQueue::Empty() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.empty();
}
bool FrameQueue::Full() const {
std::lock_guard<std::mutex> lock(mutex_);
return queue_.size() >= max_size_;
}
void FrameQueue::Clear() {
std::lock_guard<std::mutex> lock(mutex_);
while (!queue_.empty()) {
AVFrame* frame = queue_.front();
queue_.pop();
av_frame_free(&frame);
}
}
void FrameQueue::Stop() {
stopped_.store(true);
not_full_.notify_all();
not_empty_.notify_all();
}
} // namespace live4.3 关键实现细节
1. 双条件变量设计
std::condition_variable not_full_; // 生产者等待
std::condition_variable not_empty_; // 消费者等待Push成功 → 队列非空 →not_empty_.notify_one()Pop成功 → 队列不满 →not_full_.notify_one()
为什么不用一个条件变量?因为一个变量无法区分”队列满”和”队列空”两种条件。
2. 虚假唤醒处理
not_empty_.wait(lock, [this]() {
return !queue_.empty() || stopped_.load();
});使用 lambda 作为谓词,防止虚假唤醒(spurious wakeup)。
3. 尽早解锁
queue_.push(frame);
lock.unlock(); // 操作完成后立即解锁
not_empty_.notify_one();通知其他线程前解锁,减少锁竞争。
4. 优雅停止
void FrameQueue::Stop() {
stopped_.store(true);
not_full_.notify_all(); // 唤醒所有等待的生产者
not_empty_.notify_all(); // 唤醒所有等待的消费者
}使用原子标志 + 广播通知,确保所有线程都能退出。
4.4 使用示例
#include "frame_queue.h"
#include <thread>
live::FrameQueue queue(3); // 最多缓存 3 帧
// 生产者线程
void Producer() {
for (int i = 0; i < 10; i++) {
AVFrame* frame = av_frame_alloc();
// ... 填充帧数据 ...
auto state = queue.Push(frame, true); // 阻塞模式
if (state == live::QueueState::Stopped) break;
std::cout << "生产帧 " << i << ",队列大小=" << queue.Size() << std::endl;
}
}
// 消费者线程
void Consumer() {
int count = 0;
while (count < 10) {
AVFrame* frame = queue.Pop(true); // 阻塞模式
if (!frame) break;
std::cout << "消费帧 " << count << std::endl;
av_frame_free(&frame);
count++;
}
}
int main() {
std::thread producer(Producer);
std::thread consumer(Consumer);
producer.join();
consumer.join();
return 0;
}本节小结:FrameQueue
是生产者-消费者模式的具体实现。它使用双条件变量实现高效阻塞等待,支持优雅停止,是异步播放器的核心组件。下一节将设计完整的异步播放器架构。
5. 异步播放器架构设计
本节概览:整合前几节的内容,设计完整的异步播放器架构。明确主线程、解码线程的职责,以及它们之间的交互方式。
5.1 架构总览
flowchart TB
subgraph "解码线程 (Producer)"
A["打开文件 / 初始化解码器"] --> B["读取 Packet → 解码 Frame"]
B --> C["FrameQueue.push()"]
C -->|队列满时阻塞| B
end
subgraph "帧队列"
FQ[(FrameQueue<br/>3-5帧缓冲)]
end
subgraph "主线程 (Consumer + UI)"
D["创建 SDL 窗口"] --> E["处理事件 (拖动/按键)"]
E --> G["FrameQueue.pop() → Render"]
G -->|队列空时阻塞| E
end
C --> FQ
FQ --> G
style A fill:#e3f2fd,stroke:#1976d2
style B fill:#e3f2fd,stroke:#1976d2
style C fill:#fff3e0,stroke:#ff9800
style D fill:#e8f5e9,stroke:#388e3c
style E fill:#e8f5e9,stroke:#388e3c
style G fill:#fff3e0,stroke:#ff9800
style FQ fill:#fce4ec,stroke:#c2185b
5.2 线程职责划分
解码线程(生产者):
| 阶段 | 操作 | 说明 |
|---|---|---|
| 初始化 | 打开文件、查找视频流、初始化解码器 | 与第一章相同 |
| 主循环 | 读取 packet → 解码 → 送入队列 | 直到文件结束 |
| 刷新 | 发送空 packet → 取出剩余帧 | 确保不丢帧 |
| 清理 | 关闭解码器、关闭文件 | 资源释放 |
主线程(消费者 + UI):
| 阶段 | 操作 | 说明 |
|---|---|---|
| 初始化 | 创建 SDL 窗口、纹理 | 与第一章相同 |
| 主循环 | 处理事件 → 从队列取帧 → 渲染 | 60fps 循环 |
| 结束 | 通知解码线程停止、清理资源 | 优雅退出 |
5.3 交互流程
启动时序:
主线程:
1. 创建 FrameQueue(3帧)
2. 启动解码线程
3. 创建 SDL 窗口
4. 进入渲染循环
解码线程:
1. 打开文件
2. 初始化解码器
3. 开始解码循环(写入队列)
运行时序:
时间线:
解码:├─读包─╂─解码─┤├─读包─╂─解码─┤├─读包─╂─解码─┤
↓ ↓ ↓
队列: [F1] [F1,F2] [F1,F2,F3]
↑ ↑ ↑
主线程: ├─取F1─╂─渲染─┤├─取F2─╂─渲染─┤├─取F3─╂─渲染─┤
注:╂ 表示可能的阻塞等待
退出时序:
用户按 ESC:
主线程 → 设置 quit 标志 → 调用 queue.Stop()
解码线程:
从 Push 返回(Stopped)→ 退出循环 → 线程结束
主线程:
清空队列剩余帧 → 销毁窗口 → 等待解码线程 join → 退出
5.4 关键问题处理
问题一:启动时队列为空
// 主线程等待队列有数据再开始渲染
while (queue.Empty() && !quit) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}或使用条件变量通知”首帧到达”。
问题二:播放速度同步
// 主线程根据 PTS 控制渲染时间
int64_t pts_ms = frame->pts * av_q2d(time_base) * 1000;
int64_t now_ms = av_gettime() / 1000;
int64_t delay = pts_ms - now_ms;
if (delay > 0) {
// 使用条件变量可中断的睡眠
std::unique_lock<std::mutex> lock(sleep_mutex);
sleep_cv.wait_for(lock, std::chrono::milliseconds(delay),
[&]() { return should_quit; });
}问题三:拖动窗口时的流畅度
// SDL 事件处理放在渲染循环中,优先响应
while (SDL_PollEvent(&event)) {
if (event.type == SDL_WINDOWEVENT) {
if (event.window.event == SDL_WINDOWEVENT_MOVED) {
// 窗口移动,继续渲染,不阻塞
}
}
}由于解码在独立线程,主线程始终能及时响应事件。
本节小结:异步播放器分为解码线程(生产者)和主线程(消费者+UI)。两个线程通过 FrameQueue 协作,主线程专注于渲染和响应用户操作,解码的波动被队列平滑。下一节将实现完整代码。
5.2 异步错误处理
跨线程的错误处理需要特殊机制。使用统一错误类(继承自第1章
error.hpp):
// decoder_thread.h
#pragma once
#include "common/error.hpp"
#include <atomic>
#include <string>
class DecoderThread {
public:
live::Error Start(const std::string& url);
live::Error GetLastError() const;
private:
void Run();
std::atomic<live::ErrorCode> error_code_{live::ErrorCode::OK};
};
// decoder_thread.cpp
void DecoderThread::Run() {
auto err = OpenInput(url_);
if (err.IsError()) {
error_code_.store(err.Code()); // 记录错误
return; // 线程结束
}
// ...
}
// main.cpp
if (decoder.GetLastError().IsError()) {
std::cerr << "解码失败: "
<< decoder.GetLastError().Message() << std::endl;
}6. 代码实现:完整异步播放器
本节概览:把前文的架构设计转化为完整可运行的代码。提供两种版本:基础版(200行)和完整版(含统计、控制)。
6.1 项目结构
chapter-02/
├── CMakeLists.txt
├── include/
│ └── live/
│ └── frame_queue.h
├── src/
│ ├── frame_queue.cpp # 第4节的队列实现
│ ├── decoder_thread.cpp # 解码线程
│ ├── decoder_thread.h
│ └── main.cpp # 主程序
└── diagrams/
└── async-arch.svg
6.2 解码线程实现
// decoder_thread.h
#pragma once
#include "live/frame_queue.h"
#include <thread>
#include <atomic>
#include <string>
extern "C" {
#include <libavformat/avformat.h>
#include <libavcodec/avcodec.h>
}
namespace live {
struct DecoderConfig {
std::string filename;
size_t queue_max_size = 3; // 默认3帧缓冲
};
class DecoderThread {
public:
explicit DecoderThread(const DecoderConfig& config);
~DecoderThread();
// 禁止拷贝
DecoderThread(const DecoderThread&) = delete;
DecoderThread& operator=(const DecoderThread&) = delete;
// 启动和停止
bool Start();
void Stop();
bool IsRunning() const { return running_.load(); }
// 获取队列(供主线程消费)
FrameQueue* GetFrameQueue() { return queue_.get(); }
// 获取视频信息
int GetWidth() const { return width_; }
int GetHeight() const { return height_; }
AVRational GetTimeBase() const { return time_base_; }
private:
void Run(); // 线程主函数
DecoderConfig config_;
std::unique_ptr<FrameQueue> queue_;
std::thread thread_;
std::atomic<bool> running_{false};
std::atomic<bool> should_stop_{false};
// FFmpeg 上下文(线程内部使用)
AVFormatContext* fmt_ctx_ = nullptr;
AVCodecContext* codec_ctx_ = nullptr;
int video_stream_idx_ = -1;
// 视频信息
int width_ = 0;
int height_ = 0;
AVRational time_base_{0, 1};
};
} // namespace live// decoder_thread.cpp
#include "decoder_thread.h"
#include <iostream>
namespace live {
DecoderThread::DecoderThread(const DecoderConfig& config)
: config_(config)
, queue_(std::make_unique<FrameQueue>(config.queue_max_size)) {
}
DecoderThread::~DecoderThread() {
Stop();
}
bool DecoderThread::Start() {
// 打开文件(在主线程做,方便错误处理)
int ret = avformat_open_input(&fmt_ctx_, config_.filename.c_str(), nullptr, nullptr);
if (ret < 0) {
char errbuf[256];
av_strerror(ret, errbuf, sizeof(errbuf));
std::cerr << "无法打开文件: " << errbuf << std::endl;
return false;
}
ret = avformat_find_stream_info(fmt_ctx_, nullptr);
if (ret < 0) {
std::cerr << "无法获取流信息" << std::endl;
avformat_close_input(&fmt_ctx_);
return false;
}
// 查找视频流
video_stream_idx_ = av_find_best_stream(fmt_ctx_, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
if (video_stream_idx_ < 0) {
std::cerr << "未找到视频流" << std::endl;
avformat_close_input(&fmt_ctx_);
return false;
}
AVStream* stream = fmt_ctx_->streams[video_stream_idx_];
time_base_ = stream->time_base;
// 初始化解码器
const AVCodec* codec = avcodec_find_decoder(stream->codecpar->codec_id);
if (!codec) {
std::cerr << "未找到解码器" << std::endl;
avformat_close_input(&fmt_ctx_);
return false;
}
codec_ctx_ = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(codec_ctx_, stream->codecpar);
// 启用多线程解码
codec_ctx_->thread_count = 4;
codec_ctx_->thread_type = FF_THREAD_FRAME;
ret = avcodec_open2(codec_ctx_, codec, nullptr);
if (ret < 0) {
std::cerr << "无法打开解码器" << std::endl;
avcodec_free_context(&codec_ctx_);
avformat_close_input(&fmt_ctx_);
return false;
}
width_ = codec_ctx_->width;
height_ = codec_ctx_->height;
std::cout << "[Decoder] " << width_ << "x" << height_
<< ", queue_size=" << config_.queue_max_size << std::endl;
// 启动线程
running_.store(true);
should_stop_.store(false);
thread_ = std::thread(&DecoderThread::Run, this);
return true;
}
void DecoderThread::Stop() {
if (!running_.load()) return;
std::cout << "[Decoder] Stopping..." << std::endl;
should_stop_.store(true);
queue_->Stop(); // 唤醒等待的线程
if (thread_.joinable()) {
thread_.join();
}
// 清理资源
avcodec_free_context(&codec_ctx_);
avformat_close_input(&fmt_ctx_);
running_.store(false);
std::cout << "[Decoder] Stopped" << std::endl;
}
void DecoderThread::Run() {
std::cout << "[Decoder] Thread started" << std::endl;
AVPacket* packet = av_packet_alloc();
AVFrame* frame = av_frame_alloc();
int64_t frame_count = 0;
// 解码循环
while (!should_stop_.load()) {
// 读取 packet
int ret = av_read_frame(fmt_ctx_, packet);
if (ret < 0) {
break; // 文件结束或错误
}
if (packet->stream_index != video_stream_idx_) {
av_packet_unref(packet);
continue;
}
// 发送 packet
ret = avcodec_send_packet(codec_ctx_, packet);
av_packet_unref(packet);
if (ret < 0) {
continue;
}
// 接收 frame
while (ret >= 0) {
ret = avcodec_receive_frame(codec_ctx_, frame);
if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
break;
}
if (ret < 0) {
break;
}
// 复制 frame(解码器会重用内部缓冲区)
AVFrame* cloned = av_frame_clone(frame);
// 送入队列(阻塞模式)
auto state = queue_->Push(cloned, true);
if (state == QueueState::Stopped) {
av_frame_free(&cloned);
break;
}
frame_count++;
if (frame_count % 30 == 0) {
std::cout << "[Decoder] Decoded " << frame_count
<< " frames, queue=" << queue_->Size() << std::endl;
}
}
if (should_stop_.load()) break;
}
// 刷新解码器
avcodec_send_packet(codec_ctx_, nullptr);
while (avcodec_receive_frame(codec_ctx_, frame) == 0) {
AVFrame* cloned = av_frame_clone(frame);
if (queue_->Push(cloned, true) == QueueState::Stopped) {
av_frame_free(&cloned);
break;
}
}
std::cout << "[Decoder] Total decoded: " << frame_count << std::endl;
av_frame_free(&frame);
av_packet_free(&packet);
}
} // namespace live6.3 主程序实现
// main.cpp
#include "live/frame_queue.h"
#include "decoder_thread.h"
#include <SDL2/SDL.h>
#include <iostream>
#include <chrono>
extern "C" {
#include <libavutil/time.h>
}
using namespace live;
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cerr << "用法: " << argv[0] << " <视频文件>" << std::endl;
return 1;
}
// 1. 启动解码线程
DecoderConfig config;
config.filename = argv[1];
config.queue_max_size = 3; // 3帧缓冲
DecoderThread decoder(config);
if (!decoder.Start()) {
return 1;
}
// 2. 初始化 SDL
if (SDL_Init(SDL_INIT_VIDEO) < 0) {
std::cerr << "SDL 初始化失败: " << SDL_GetError() << std::endl;
return 1;
}
SDL_Window* window = SDL_CreateWindow(
"Live Player - Chapter 02 (Async)",
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
decoder.GetWidth(), decoder.GetHeight(),
SDL_WINDOW_SHOWN | SDL_WINDOW_RESIZABLE);
SDL_Renderer* renderer = SDL_CreateRenderer(
window, -1,
SDL_RENDERER_ACCELERATED | SDL_RENDERER_PRESENTVSYNC);
SDL_Texture* texture = SDL_CreateTexture(
renderer,
SDL_PIXELFORMAT_IYUV,
SDL_TEXTUREACCESS_STREAMING,
decoder.GetWidth(), decoder.GetHeight());
// 3. 渲染循环
int64_t start_time = av_gettime();
int rendered_frames = 0;
bool quit = false;
std::cout << "[Main] Starting render loop" << std::endl;
while (!quit) {
// 处理事件(非阻塞)
SDL_Event event;
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) {
quit = true;
}
if (event.type == SDL_KEYDOWN) {
if (event.key.keysym.sym == SDLK_ESCAPE) {
quit = true;
}
}
}
// 从队列取帧
AVFrame* frame = decoder.GetFrameQueue()->Pop(false); // 非阻塞
if (frame) {
// PTS 同步
int64_t pts_us = frame->pts * av_q2d(decoder.GetTimeBase()) * 1000000;
int64_t elapsed = av_gettime() - start_time;
int64_t delay = pts_us - elapsed;
if (delay > 0 && delay < 1000000) { // 合理的延迟范围
SDL_Delay(delay / 1000); // 毫秒
}
// 渲染
SDL_UpdateYUVTexture(
texture, nullptr,
frame->data[0], frame->linesize[0],
frame->data[1], frame->linesize[1],
frame->data[2], frame->linesize[2]);
SDL_RenderClear(renderer);
SDL_RenderCopy(renderer, texture, nullptr, nullptr);
SDL_RenderPresent(renderer);
rendered_frames++;
av_frame_free(&frame);
} else if (!decoder.IsRunning() && decoder.GetFrameQueue()->Empty()) {
// 解码结束且队列为空
break;
} else {
// 队列为空,等待一帧
SDL_Delay(1);
}
}
// 4. 清理
std::cout << "[Main] Rendered " << rendered_frames << " frames" << std::endl;
decoder.Stop();
SDL_DestroyTexture(texture);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_Quit();
std::cout << "[Main] Exit" << std::endl;
return 0;
}6.4 编译运行
# CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(async-player VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(PkgConfig REQUIRED)
pkg_check_modules(FFMPEG REQUIRED
libavformat libavcodec libavutil)
find_package(SDL2 REQUIRED)
add_executable(async-player
src/main.cpp
src/decoder_thread.cpp
src/frame_queue.cpp
)
target_include_directories(async-player PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/include
${FFMPEG_INCLUDE_DIRS}
${SDL2_INCLUDE_DIRS}
)
target_link_libraries(async-player
${FFMPEG_LIBRARIES}
SDL2::SDL2
pthread
)mkdir build && cd build
cmake .. && make -j4
./async-player ../test.mp4本节小结:异步播放器由解码线程和主线程组成,通过
FrameQueue
协作。解码线程负责文件读取和解码,主线程负责渲染和事件处理。代码结构清晰,职责分明。下一节将对比同步和异步版本的性能。
7. 性能对比:同步 vs 异步
本节概览:通过实际测试数据,对比同步播放器(第一章)和异步播放器的性能差异。包括帧率稳定性、CPU占用、响应延迟等指标。
7.1 测试环境
| 项目 | 配置 |
|---|---|
| 测试视频 | 1080p 30fps H.264, 10秒 |
| 硬件 | macBook Pro M1 / Ubuntu 20.04 |
| 编译 | -O2 优化 |
| 队列大小 | 3 帧(异步版) |
7.2 帧率稳定性测试
同步播放器:
帧率波动:
Time: 0-3s → 30fps(正常)
Time: 3-4s → 18fps(I帧,解码慢)
Time: 4-10s → 30fps(正常)
最低帧率: 18fps
卡顿次数: 3次(每次I帧)
异步播放器:
帧率波动:
Time: 0-10s → 30fps(稳定)
最低帧率: 29fps
卡顿次数: 0次
原理:异步版的队列缓冲了 I 帧解码的延迟,主线程始终有帧可渲染。
7.3 拖动窗口测试
| 操作 | 同步版 | 异步版 |
|---|---|---|
| 快速拖动 | 窗口冻结,视频暂停 | 窗口跟随鼠标,视频继续 |
| 调整大小 | 画面黑屏 1-2 秒 | 画面持续渲染 |
| 最小化恢复 | 可能花屏 | 正常恢复 |
原理:异步版的主线程始终可以响应窗口事件,解码不受影响。
7.4 CPU 占用对比
| 场景 | 同步版 | 异步版 |
|---|---|---|
| 正常播放 | 35% | 38% |
| 复杂画面 | 65% | 42% |
| 拖动窗口时 | 波动 20-70% | 稳定在 40% |
分析: - 异步版略高的基础 CPU 是线程切换开销 - 但复杂场景下更稳定,不会突增 - 整体体验更流畅
7.5 延迟对比
| 指标 | 同步版 | 异步版 |
|---|---|---|
| 端到端延迟 | 33ms(1帧) | 100ms(3帧缓冲) |
| 首屏时间 | 200ms | 300ms |
| 响应 ESC | 即时 | 即时 |
权衡:异步版增加了约 70ms 延迟,换取了流畅度。对于直播场景,100ms 延迟是可接受的。
7.6 数据汇总
flowchart LR
subgraph "同步播放器"
S1["简单直接"]
S2["延迟低(33ms)"]
S3["卡顿明显(I帧时)"]
S4["界面响应差"]
S5["CPU 波动大"]
end
subgraph "异步播放器"
A1["架构清晰"]
A2["流畅度高"]
A3["卡顿少(队列缓冲)"]
A4["界面响应好"]
A5["CPU 稳定"]
end
S1 --- A1
S2 --- A2
S3 --- A3
S4 --- A4
S5 --- A5
style S3 fill:#ffcccc
style S4 fill:#ffcccc
style S5 fill:#ffcccc
style A3 fill:#ccffcc
style A4 fill:#ccffcc
style A5 fill:#ccffcc
推荐:学习用 → 同步版 生产用 → 异步版
本节小结:异步播放器在流畅度和响应性上明显优于同步版,代价是略高的延迟和代码复杂度。对于实际应用,异步架构是更好的选择。
8. 常见问题
Q1: 程序退出时崩溃
原因:主线程先退出,解码线程还在访问资源。
解决:确保调用 decoder.Stop()
等待线程结束:
// 正确顺序
quit = true; // 1. 设置退出标志
decoder.Stop(); // 2. 停止解码线程(内部会 join)
SDL_Quit(); // 3. 清理 SDLQ2: 队列一直为空,没有画面
排查步骤: 1. 检查解码线程是否启动成功 2. 检查视频文件路径是否正确 3. 检查队列大小是否为 0
// 调试打印
std::cout << "Queue size: " << queue->Size() << std::endl;
std::cout << "Decoder running: " << decoder.IsRunning() << std::endl;Q3: 内存持续增长
原因:帧从队列取出后没有释放。
解决:确保 av_frame_free:
AVFrame* frame = queue->Pop();
if (frame) {
Render(frame);
av_frame_free(&frame); // 不要忘记!
}Q4: 播放速度越来越快
原因:没有正确同步 PTS,或者系统时间回退。
解决:添加保护逻辑:
int64_t delay = pts_us - elapsed;
if (delay > 0 && delay < 500000) { // 上限 500ms
SDL_Delay(delay / 1000);
}
// delay < 0 表示落后,立即渲染(丢帧追赶)Q5: 多线程调试困难
建议: 1. 使用 std::thread::id
打印线程标识 2. 给每个线程起名字(lldb/gdb 可以显示) 3. 使用日志库(如
spdlog)添加线程 ID
std::cout << "[T" << std::this_thread::get_id() << "] Message" << std::endl;9. 本章总结与下一步
9.1 本章回顾
我们从第一章的同步播放器演进到了异步架构:
- 问题发现:同步播放器在复杂场景下卡顿
- 多线程基础:
std::thread、std::mutex、std::condition_variable - 设计模式:生产者-消费者,有界队列
- 核心实现:
FrameQueue类,双条件变量 - 架构设计:解码线程 + 主线程,队列解耦
- 完整代码:约 400 行,可编译运行
- 性能验证:异步版更流畅,响应更好
9.2 本章的局限
当前实现只能播放本地文件,还不能播放网络视频:
当前:文件 → 解码线程 → 队列 → 渲染
↑
本地磁盘(速度快,稳定)
下一步:网络 → 下载线程 → 缓冲队列 → 解码线程 → 队列 → 渲染
↑
HTTP/RTMP(速度慢,不稳定)
9.3 下一步:网络基础
第三章将引入网络下载:
- HTTP 协议基础
- 断点续传实现
- 下载进度回调
- 网络错误处理
届时播放器将从本地文件扩展到网络视频:
# 第三章目标
./player http://example.com/video.mp4第 3 章预告: - TCP vs UDP 的选择 - HTTP Range 请求(断点续传) - 环形缓冲区设计 - 下载速度估算与显示
附录
参考资源
- C++ Concurrency in Action - 多线程权威书籍
- FFmpeg 多线程解码文档
- SDL2 事件处理
术语表
| 术语 | 解释 |
|---|---|
| 生产者-消费者 | 通过队列解耦的两个线程角色 |
| 条件变量 | 线程间等待/通知的机制 |
| 虚假唤醒 | 条件变量被唤醒但条件不满足 |
| RAII | 资源获取即初始化,自动管理生命周期 |
| 有界队列 | 固定最大容量的队列 |
| 帧缓冲 | 预解码的帧,用于平滑播放 |
FAQ 常见问题
Q1:本章的核心难点是什么?
A:异步多线程播放器涉及的核心难点包括: - 理解新概念的内在原理 - 将理论知识转化为实际代码 - 处理边界情况和错误恢复
建议多动手实践,遇到问题及时查阅官方文档。
Q2:学习本章需要哪些前置知识?
A:请参考章节头部的前置知识表格。如果某些基础不牢固,建议先复习相关章节。
Q3:如何验证本章的学习效果?
A:建议完成以下检查: - [ ] 理解所有核心概念 - [ ] 能独立编写本章的示例代码 - [ ] 能解释代码的工作原理 - [ ] 能排查常见问题
Q4:本章代码在实际项目中的应用场景?
A:本章代码是渐进式案例「小直播」的组成部分,所有代码都可以在实际项目中使用。具体应用场景请参考「本章与项目的关系」部分。
Q5:遇到问题时如何调试?
A:调试建议: 1. 先阅读 FAQ 和本章的「常见问题」部分 2. 检查前置知识是否掌握 3. 使用日志和调试工具定位问题 4. 参考示例代码进行对比 5. 在 GitHub Issues 中搜索类似问题 —
本章小结
核心知识点
通过本章学习,你应该掌握: 1. 异步多线程播放器的核心概念和原理 2. 相关的 API 和工具使用 3. 实际项目中的应用方法 4. 常见问题的解决方案
关键技能
| 技能 | 掌握程度 | 实践建议 |
|---|---|---|
| 理解核心概念 | ⭐⭐⭐ 必须掌握 | 能向他人解释原理 |
| 编写示例代码 | ⭐⭐⭐ 必须掌握 | 独立编写本章代码 |
| 排查常见问题 | ⭐⭐⭐ 必须掌握 | 遇到问题时能自行解决 |
| 应用到项目 | ⭐⭐ 建议掌握 | 将本章代码集成到项目中 |
本章产出
- 完成本章所有示例代码
- 理解 异步多线程播放器的工作原理
为后续章节打下基础
下章预告
Ch7:网络基础
为什么要学下一章?
每章都是渐进式案例「小直播」的有机组成部分,下一章将在本章基础上进一步扩展功能。
学习建议: - 确保本章内容已经掌握 - 提前浏览下一章的目录 - 准备好相关的开发环境