ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

彻底解决FFmpeg av_read_frame阻塞:中断回调与超时实战

彻底解决FFmpeg av_read_frame阻塞:中断回调与超时实战 一提到FFmpeg开发av_read_frame永远是最让人又爱又恨的函数之一。它看起来只是一个“读一帧”的接口实际上背后牵扯着协议、底层I/O、缓冲和线程模型。我最早做IPC摄像头拉流时就因为在 av_read_frame 上没有做任何保护程序跑一个晚上必然卡死第二天去服务器上看进程还在日志却停在一小时前。后来查代码才发现不是内存泄漏也不是解码出错就是这个函数在断网之后一直傻等socket数据。这篇文章就围绕 ffmpeg 里 av_read_frame 的阻塞问题展开讲讲我这些年排查和解决这个问题的各种手段也顺带把中断回调、超时参数、独立读线程这些方案整理成一套能直接抄作业的代码。如果你正好在写播放器、流媒体网关、监控录像程序或者在碰到“程序退出时卡住”“拉流一段时间后无响应”这类现象这篇应该能帮你省下不少排查时间。1. 阻塞问题先定位av_read_frame 到底在等什么1.1 先搞清楚这个函数的读取链路av_read_frame 并不是你想象中那种“从文件里拿一帧数据”的简单封装。它内部经历了好几层av_read_frame调用 demuxer 层的read_packet逻辑比如mov_read_packet、flv_read_packet、rtsp_read_packet。demuxer 从AVFormatContext-pb也就是 AVIOContext里拿数据。AVIOContext 最终会落到协议层普通本地文件走 file 协议最终是 read/preadRTSP、HTTP、RTMP 走网络协议最终是 socket 上的 read/recv 或者 poll 等待。所以在绝大多数场景下你看到“av_read_frame 卡住”本质上是它在等一个底层 I/O 调用返回。本地文件几乎没有体感因为磁盘读数据是毫秒级返回但网络流就完全是另一回事了socket 接收缓冲区为空时底层需要等数据到达。如果对端不推流、网络断掉但没有触发 TCP RST或者中间防火墙把连接静默丢弃这个等待就可能无限拉长。这里有个很普遍的误解av_read_frame 本身不承诺“在多少毫秒内返回”它只承诺“有数据时返回一包没数据时继续等”。所以你不能靠它的超时机制来自救它根本没有这个参数。真正需要自己做的是在它背后的 I/O 链路上做手脚。1.2 “假阻塞”与“真阻塞”的判断标准要解决问题先分清你是哪一种阻塞。假阻塞网络流正常但帧率很低或者关键帧间隔特别大av_read_frame 偶尔等一两秒钟很正常。尤其 RTSP 的 GOP 如果设置为 4 秒一个 I 帧你按帧读取时会发现经常要等好几秒才返回一包。这不是故障是流的节奏。真阻塞超过你设定的超时阈值仍然不返回或者在断网、设备断电后永远等待不报错。我建议的阈值是普通局域网 RTSP 拉流连续 5 秒读不到任何包就可以判定异常公网 HTTP-FLV 或 HLS可以放到 10 秒左右。这个数值不是拍脑袋定的它是“用户可感知卡顿”和“误判网络抖动”之间的一个折中。用热词里常说的“阻塞队列”来类比如果你的读线程是生产者解码线程是消费者读线程无限期阻塞队列会慢慢堆积最终内存爆掉而设了超时就相当于给生产者加了“熔断”机制该停就停。2. 四大阻塞根源逐个说透2.1 根源一没给底层I/O设置中断回调这基本是 90% 的坑。av_read_frame 不提供“取消”参数但 AVFormatContext 上有一个interrupt_callback字段是 FFmpeg 留给我们用来中断阻塞操作的官方接口。typedef struct AVIOInterruptCB { int (*callback)(void *opaque); void *opaque; } AVIOInterruptCB;如果你没有设置它FFmpeg 在读取循环里就不会有任何机制来询问“要不要停止”。对于 RTSP、HTTP、RTMP 这类网络源底层 socket 在无数据时很可能陷入无限期的 poll 等待。即使你把 av_read_frame 放在独立线程里也只能看着它一直挂着没有任何办法让它提前返回。还有一点特别隐蔽interrupt_callback必须在avformat_alloc_context之后、avformat_open_input之前设置。我看到很多人只在 open 之后补一行代码这有可能导致连接建立阶段根本拦不住。正确顺序应该是AVFormatContext *ctx avformat_alloc_context(); ctx-interrupt_callback.callback interrupt_cb; ctx-interrupt_callback.opaque state; // 然后再 avformat_open_input2.2 根源二探测阶段和重连逻辑的隐性阻塞另外一个被忽略的重灾区是avformat_open_input和avformat_find_stream_info。open 阶段需要做协议握手RTSP 要发 OPTIONS、DESCRIBE、SETUP、PLAYHTTP 要建立 TCP 连接、发请求、等响应。如果网络不通或者对端不响应这个阶段就可能卡住。更麻烦的是 find_stream_info它会通过不断读包来分析流信息默认的探测大小和分析时长可能很大。网络源一旦不稳定它就会在“尝试拿更多数据”和“超时”之间反复横跳表现就是打开一个 RTSP 流要等十几秒甚至几十秒。很多人的重连逻辑也写得不够严谨断流后立刻重新 openopen 本身又卡住结果整个程序陷入“重连-卡死-再重连”的循环。所以做流媒体模块时不能只保护 av_read_frame还要给 open 和 find_stream_info 阶段也统一加中断和超时。2.3 根源三自定义数据源在read_packet里自己睡死还有一种高频场景你用自定义 AVIOContext 接入私有协议、硬件数据源或者加密数据。这时候你会发现 av_read_frame 依旧可能卡住而且卡点不在 FFmpeg 内部而在你自己的read_packet回调里。FFmpeg 会很“天真”地调用你的 read 函数你的函数如果从某个 fd 直接 recv或者从共享内存里等数据又没有设置超时那么整个读取链路就断在你的代码里。这种情况下 interrupt_callback 也救不了你因为底层 sleep 在你自己的作用域里FFmpeg 根本没有机会去检查中断标志。解决办法很直接强制要求在自定义 read 函数内部使用 poll/select 包裹设定一个合理的等待上限。不要等 FFmpeg 来管你要自己管好自己。2.4 根源四多线程混用同一个上下文热词里提到“线程池的阻塞队列选择”我猜不少读者是在做多线程推流或播放。这里必须强调一个铁律av_read_frame 不是线程安全的同一个 AVFormatContext 不能被两个线程同时调用。常见错误写法是一个线程做 seek另一个线程还在读或者两个线程同时调用 av_read_frame。轻则读取的数据错乱重则卡死在协议状态的锁上表现和阻塞一模一样。排查这种问题最费劲因为它的栈不一定卡在 socket而是卡在某个互斥锁里。正确的做法是单生产者模型一个读线程独占这个 context循环调用 av_read_frame把读到的 AVPacket 放进队列其他线程只消费队列。这样即使读线程阻塞了原因也只会是网络层而不会是多线程竞争导致的死锁。队列本身建议用有界队列容量可以根据帧大小和延迟要求来定无界队列很容易把内存吃光。阻塞根源典型表现影响范围第一排查点未设置中断回调断网后永久等待程序退出卡死所有网络流interrupt_callback 是否赋值探测/重连阶段卡住打开流耗时几十秒RTSP/HTTP 慢速源probesize、超时参数、重试逻辑自定义 read 函数无超时自定义数据源卡死自定义 AVIOread_packet 内部是否 poll多线程混用同一上下文偶发死锁栈在锁上所有格式是否单线程独占读取3. 四个可落地方案与代码实现3.1 推荐组合中断回调 协议超时参数先说结论不要只靠 interrupt_callback也不要只靠超时参数两者必须组合使用。interrupt_callback 告诉 FFmpeg“我希望被中断”超时参数保证底层 socket 有最大等待时间两者互相兜底。下面这段代码是我在项目里长期使用的模板你可以直接参考。它能在断流后最多 timeout_ms 毫秒内让 av_read_frame 退出阻塞。#include libavformat/avformat.h #include stdatomic.h #include stdint.h typedef struct { atomic_int stop; atomic_int64_t last_packet_us; int64_t timeout_us; } StreamState; static int interrupt_cb(void *opaque) { StreamState *st (StreamState *)opaque; int64_t now av_gettime_relative(); if (atomic_load(st-stop)) return 1; if (st-timeout_us 0) { int64_t last atomic_load(st-last_packet_us); if (last 0 (now - last) st-timeout_us) return 1; } return 0; } int open_stream_with_timeout(const char *url, AVFormatContext **pctx, StreamState *st, int timeout_ms) { AVFormatContext *ctx avformat_alloc_context(); if (!ctx) return AVERROR(ENOMEM); atomic_store(st-stop, 0); atomic_store(st-last_packet_us, av_gettime_relative()); st-timeout_us (int64_t)timeout_ms * 1000; ctx-interrupt_callback.callback interrupt_cb; ctx-interrupt_callback.opaque st; AVDictionary *opts NULL; char timeout_str[32]; snprintf(timeout_str, sizeof(timeout_str), %lld, (long long)st-timeout_us); // 网络协议超时单位都是微秒 av_dict_set(opts, stimeout, timeout_str, 0); av_dict_set(opts, timeout, timeout_str, 0); av_dict_set(opts, rw_timeout, timeout_str, 0); // RTSP 走 TCP 更稳定也能减少一部分无谓阻塞 av_dict_set(opts, rtsp_transport, tcp, 0); int ret avformat_open_input(ctx, url, NULL, opts); av_dict_free(opts); if (ret 0) { avformat_free_context(ctx); return ret; } // open 之后保留中断回调读取阶段继续生效 *pctx ctx; return 0; }读取循环里面要记得在每成功读到一个包之后更新last_packet_usint read_loop(AVFormatContext *ctx, StreamState *st) { AVPacket *pkt av_packet_alloc(); if (!pkt) return AVERROR(ENOMEM); int ret 0; while (!atomic_load(st-stop)) { ret av_read_frame(ctx, pkt); if (ret 0) break; atomic_store(st-last_packet_us, av_gettime_relative()); // 这里把 pkt 交给业务逻辑处理 // 注意业务处理完必须 av_packet_unref(pkt) av_packet_unref(pkt); } av_packet_free(pkt); return ret; }这套组合实测下来av_read_frame在 RTSP 断流、设备断电、网线拔掉等场景下最长不会超过我们设置的 5 秒就会返回。返回的错误一般是AVERROR_EXIT表示被中断回调终止或AVERROR(ETIMEDOUT)底层超时业务层看到之后就可以进行重连或清理。注意stimeout、timeout、rw_timeout这些参数不是每个 FFmpeg 版本、每个协议都完全支持。版本之间有细微差异建议你在目标环境上跑个 10 分钟实测。这也是为什么我用三个都设置的原因——多设无害漏设可能就出问题。3.2 非阻塞模式与AVFMT_FLAG_NONBLOCK的适用边界FFmpeg 确实提供了一个非阻塞标志AVFMT_FLAG_NONBLOCK可以在打开之前设置到 AVFormatContext 的 flags 上。AVFormatContext *ctx avformat_alloc_context(); ctx-flags | AVFMT_FLAG_NONBLOCK;设置之后理论上av_read_frame在没有数据时应该返回AVERROR(EAGAIN)而不是阻塞。这样你就可以在自己的主循环里用非阻塞方式轮询读取。但这里我要泼一盆冷水实测下来这个标志对部分 demuxer 有效对另外一部分基本不生效尤其是 RTSP/RTMP 这类长连接网络协议效果时好时坏。原因在于底层 socket 仍然是阻塞模式数据没到的时候协议层可能还没有机会走到返回 EAGAIN 的逻辑。所以我的建议是不要把AVFMT_FLAG_NONBLOCK当作跨协议通用方案。它比较适合你使用自定义 AVIOContext 的场景因为你自己可以在 read 回调里实现真正的非阻塞读取。对标准 RTSP 拉流还是老老实实走“中断回调 超时参数 独立线程”的组合。3.3 工程兜底独立读线程 有界队列无论你用不用非阻塞工程上最稳的做法都是把 av_read_frame 放到独立线程里然后用队列把读线程和解码/UI 线程解耦。这个方案解决的不是“av_read_frame 会不会阻塞”的问题而是“av_read_frame 阻塞了以后会不会拖死整个程序”的问题。读线程只负责读包和入队void *reader_thread_func(void *arg) { ThreadContext *tc (ThreadContext *)arg; while (!atomic_load(tc-stop)) { AVPacket *pkt av_packet_alloc(); int ret av_read_frame(tc-fmt_ctx, pkt); if (ret 0) { av_packet_free(pkt); // 正常 EOF 或错误退出循环 break; } // 生产端入队有界队列满了会阻塞等待 queue_push(tc-queue, pkt); // 注意入队后所有权转移给消费者这里不要再 unref } queue_set_stop_flag(tc-queue); // 毒丸通知消费者结束 return NULL; }消费者线程负责从队列取包void *consumer_thread_func(void *arg) { ThreadContext *tc (ThreadContext *)arg; while (1) { AVPacket *pkt queue_pop(tc-queue); if (!pkt) break; // 队列收到毒丸且已清空 // 交给解码器或写入文件等 handle_packet(pkt); av_packet_free(pkt); } return NULL; }队列容量建议根据业务来定如果每帧平均 100KB想要容忍 2 秒的消费者停顿容量就至少是“帧率 × 2”个 packet。有界队列还能天然实现背压消费者处理不过来时读线程也会适当等待不会无限堆积内存。这里有个很容易犯的错读线程退出时队列里可能还有数据消费者线程需要先消费完再退出不能直接 pthread_join否则会把未处理的包丢掉。用“毒丸”或者显式的队列 stop 标志可以解决。3.4 自定义AVIOContext接管数据源如果你输入的数据源不是标准协议比如走摄像头私有 SDK、USB 设备、加密文件或者你希望完全掌控读取逻辑那自定义 AVIOContext 是更彻底的手段。思路很简单实现一个 read 回调FFmpeg 需要数据时会反复调用它。static int custom_read(void *opaque, uint8_t *buf, int buf_size) { MySource *src (MySource *)opaque; // 一定要在这里做超时控制不要裸调 recv/read int ret src-poll_and_read(buf, buf_size, 500); // 最多等 500ms if (ret 0) return AVERROR(EAGAIN); // 没有数据 if (ret 0) return AVERROR(errno); return ret; } int open_custom_stream(MySource *src, AVFormatContext **pctx) { unsigned char *io_buffer av_malloc(4096); if (!io_buffer) return AVERROR(ENOMEM); AVIOContext *avio avio_alloc_context( io_buffer, 4096, 0, src, custom_read, NULL, NULL); AVFormatContext *ctx avformat_alloc_context(); if (!ctx) { av_free(io_buffer); return AVERROR(ENOMEM); } ctx-pb avio; ctx-flags | AVFMT_FLAG_CUSTOM_IO; int ret avformat_open_input(ctx, custom, NULL, NULL); if (ret 0) { // 注意 avio_alloc_context 的 buffer 由 FFmpeg 释放 avformat_free_context(ctx); return ret; } *pctx ctx; return 0; }为什么说“只靠 interrupt_callback 救不了自定义 read”因为如果 custom_read 内部睡死了AVIOContext 根本没有机会去检查中断回调。所以自定义 read 函数里的超时控制是必选项不是可选项。用 poll/select 包裹或者用条件变量加超时都可以。这里还需要注意一点read 回调返回0表示 EOF返回正数表示实际读取的字节数返回负数表示错误。FFmpeg 会在需要数据时反复调用你不需要一次性填满 buf_size但一般尽量多读一点不然解析效率会很低。4. RTSP、本地文件、HTTP-FLV三种场景的处理差异4.1 RTSP拉流优先查socket与传输模式RTSP 是阻塞问题最集中的场景。拉流连接走 TCP 还是 UDP会直接影响阻塞表现。UDP 模式在丢包严重时会产生重传等待阻塞更明显TCP 模式相对可靠但要关注 TCP 连接被中间设备静默断开的情况。对 RTSP 我一般这样设置rtsp_transport设置为 tcp除非有特殊需求。打开时设置stimeout和rw_timeout单位是微秒。终端设备或平台如果支持尽量开启 TCP Keep-Alive网络中间设备断开时能更快感知。断开后重连时不要复用旧的 AVFormatContext直接avformat_close_input再重新 alloc open。还有一种情况RTSP 服务端本身不主动推流只有客户端发 PLAY 之后才开始。如果你的程序在 PLAY 之前就调 av_read_frame自然会一直等。这种需要先确认 RTSP 交互状态机是否完成和阻塞本身的处理措施不同。4.2 本地文件小心挂载文件和特殊介质本地文件一般不会长时间阻塞但有两个例外NFS、CIFS 等网络挂载目录远端服务器无响应时文件系统层面的 read 可能卡在不可中断的 D 状态这时候 interrupt_callback 也没用只能靠外部看门狗重启进程。光盘、异常 U 盘、坏道严重的硬盘读取某些块可能反复重试表现也是长时间卡住。应对思路是把文件流也放到独立读线程里并且对“连续多久没有读到包”做监控。如果超过 30 秒仍无进展就按异常处理主动清理和退出线程。这里中断回调不一定能触发因为阻塞可能发生在内核态所以线程退出要配合 pthread_cancel 或者更底层的 fd 关闭来辅助。4.3 HTTP-FLV/HLS区分连接超时与读取超时HTTP-FLV 是直播常用的协议本质是一条长连接。HLS 则是分段下载阻塞点通常发生在拉取 TS 分片的时候。对 HTTP-FLV重点是区分两个阶段的超时连接阶段TCP 建连、TLS 握手、发送 HTTP 请求这个阶段由timeout参数控制。读取阶段连接建立后接收流数据这个阶段由rw_timeout控制。如果你只设置了timeout而没设置rw_timeout那么连接建立后一旦服务端停止推流你的 av_read_frame 可能一直等下去。反之亦然连接阶段可能因为没有timeout而卡在 DNS/建连。对 HLS单纯的 av_read_frame 阻塞可能来自某个分片的 HTTP 请求。除了设置超时参数外还可以考虑用 FFmpeg 的http_persistent、reconnect等选项但库开发里要谨慎使用自动重连重连次数不限的话问题会被掩盖而不是解决。5. 实战排障记录与常见问题速查5.1 一次RTSP断流导致进程挂死的完整排查去年我帮一个团队排查过一个线上问题他们的监控程序拉取网络摄像头 RTSP 流运行一两天后大概率无响应只能重启。从日志看最后一条日志停在一个随机时间后面再没有任何输出。我们的排查过程记录一下供你参考先用命令行验证网络流本身是否正常ffmpeg -rtsp_transport tcp -stimeout 5000000 -i rtsp://xxx -t 10 -f null -命令行能正常退出说明摄像头本身没问题。用 gdb attach 到卡死的进程执行thread apply all bt查看所有线程栈。发现其中一个读线程卡在ff_network_wait_fd_timeout-poll说明确实在等 socket 数据。检查代码里 AVFormatContext 的 interrupt_callback发现为空等于没有任何“主动中断”机制。继续查重连逻辑发现断流后调用了 avformat_open_input而 open 阶段同样没有任何超时保护。也就是说即使读线程退出了重连时也可能再次卡死。修复方案就是我在 3.1 节写的那套组合设置 interrupt_callback设置 stimeout 和 rw_timeout并在重连逻辑里也加入相同的超时限制。修复之后故意拔掉摄像头网线程序在 5 秒内报错重连过程也有日志输出问题解决。这个案例给我们的教训是阻塞问题不能只看单一函数要把“打开-探测-读取-重连”整条链路都纳入超时保护范围。5.2 常见问题与解决对照表现象可能原因推荐解法av_read_frame 永久不返回程序无法退出未设 interrupt_callback且没有网络超时中断回调 stimeout/rw_timeout 组合断网后要很久才报错socket 超时太大或为 0超时设为 5~10 秒打开 RTSP 流耗时几十秒find_stream_info 探测过大或握手阶段无超时设置 probesize、max_analyze_durationopen 阶段也加超时自定义数据源无数据时卡死read 回调内部没有超时在回调里用 poll/select 控制等待时间设置 AVFMT_FLAG_NONBLOCK 后仍阻塞协议层不支持非阻塞或 socket 仍为阻塞模式改用独立线程 队列pthread_join 卡死读线程阻塞在 av_read_frame无法退出设置 stop 标记并等待超时必要时关闭底层 fd偶发死锁栈在锁上多线程同时调用 av_read_frame 或 seek 与读并发单线程独占 context读包入队程序启动时卡在打开阶段可能混入了 DBus/系统服务初始化等阻塞调用把 ffmpeg 读取和系统服务初始化分线程隔离5.3 容易被忽略的细节做 FFmpeg 开发时很多坑不是逻辑复杂而是细节没注意到。我列几个自己踩过、也看别人踩过的点。第一个中断回调里的标记变量建议用 C11 的atomic_int不要用裸的volatile。多线程下可见性有保证调试起来也少很多奇怪问题。第二个av_read_frame返回的错误码要区分开。AVERROR_EOF是正常读到文件尾AVERROR_EXIT通常表示被 interrupt callback 中断AVERROR(ETIMEDOUT)表示底层 I/O 超时。业务层要根据不同错误码做不同处理不能一视同仁。第三个自定义 AVIOContext 的 buffer 是 avio_alloc_context 分配的释放 context 时会一起释放不要手动 av_free否则会 double free。第四个有些桌面环境下Qt 主线程里调用 DBus 系统服务也可能阻塞比如系统更新管理器检测、网络状态变化通知等。如果主线程同时又在等 ffmpeg 读线程的返回哪怕读线程已经设了超时表现出来还是“界面卡死”。这种不是 av_read_frame 的问题而是两个阻塞源串在了同一个线程依赖链上。在国产系统上使用 Qt FFmpeg 时尤其要注意这点把 FFmpeg 读取、解码放到独立工作线程不要和 UI 线程的 DBus 调用混在一起。第五个Windows 下开发要注意 winsock 初始化用 msvc 编译时要链接 ws2_32.lib。命令行可以用 ffmpeg.exe 先验证源是否正常再判断是不是代码问题能少走很多弯路。第六个每次 av_read_frame 成功后packet 内部是有引用计数的处理完一定要 av_packet_unref。很多“跑一段时间卡死”其实是内存持续增长导致的系统资源耗尽表面看像阻塞实际是泄露。这类问题用 valgrind 或者 ASAN 跑一遍立刻就能发现。最后再说一点我自己的体会是av_read_frame 阻塞问题没有银弹因为 FFmpeg 为了兼容各种输入源把底层的读取行为抽象得太深了。你不可能用一个参数解决所有协议的所有情况。最可靠的策略永远是“组合拳”——中断回调打底超时参数兜底独立线程保证不拖垮整个程序再加上一套完善的重连和状态机。这几样都做到位了不管是 RTSP、HTTP-FLV 还是自定义数据源av_read_frame 都不会成为你程序的死穴。如果你现在正被这个问题折磨建议先用 gdb 看一眼线程栈确认卡点到底在协议层还是业务层然后再对号入座去改。
返回列表