目录
目的
这篇文章的意义,不在于解决问题本身,因为这次问题发生在一种错误的使用条件之下,但是在这个过程中发现了一些有趣的现象,有一些感悟,进行记录。
插入几句话:运行在客户端的CS架构的软件,比WBE网页应用,有一个很大的不同点:浏览器本身解决了对很多客户端的兼容性问题,尽管网页开发时仍然需要根据浏览器类型的不同来进行兼容区分处理,但是针对的浏览器类型的数量是有限的,数量非常少;但是CS架构类型的客户端软件(窗口程序)可能会运行在各种不同的操作系统上,或者操作系统相同但是系统的版本号不同,或者系统版本号相同但是硬件配置不同,因此相同的代码运行的功能,都可能会有不同的表现。软件开发的工作,不仅仅在于编码完成,而在于能够在五花八门的客户机上以良好的兼容性稳定运行,所以会有很多的时间是在处理 由于设备不同 带来的麻烦问题。
问题
我们经常会有需求使用 ffmpeg 来对 视频 文件进行解码 ,正常情况下 把非透明通道的 mp4 视频解码为 JPG 图片序列帧是非常高效的 ,但是 此处对 mp4 视频进行了 png 解码 ,导致 出现了很多问题 ,出现了缓慢、卡死,并且在不同电脑有不同表现, 过程中有一些有趣的发现。
视频属性
在解码的视频是佳能相机录制的原生高清MP4视频, 视频属性信息如下:
大小:40.0 MB (41,965,096 字节)
总帧数:326
时长:00:00:10
帧宽度:1920
帧高度:1080
数据速率:30167kbps
总比特率:30417kbps
帧速率:29.97 帧/秒
比特率:250kbps
频道:2(立体声)
音频采样频率:48.000kHz
FFMPEG 版本
使用的 ffmpeg.exe (83.5MB), 版本信息如下:
FFMPEG 版本信息:
ffmpeg version 7.1-essentials_build-www.gyan.dev Copyright (c) 2000-2024 the FFmpeg developers
built with gcc 14.2.0 (Rev1, Built by MSYS2 project)
configuration: --enable-gpl --enable-version3 --enable-static --disable-w32threads --disable-autodetect --enable-fontconfig --enable-iconv --enable-gnutls --enable-libxml2 --enable-gmp --enable-bzlib --enable-lzma --enable-zlib --enable-libsrt --enable-libssh --enable-libzmq --enable-avisynth --enable-sdl2 --enable-libwebp --enable-libx264 --enable-libx265 --enable-libxvid --enable-libaom --enable-libopenjpeg --enable-libvpx --enable-mediafoundation --enable-libass --enable-libfreetype --enable-libfribidi --enable-libharfbuzz --enable-libvidstab --enable-libvmaf --enable-libzimg --enable-amf --enable-cuda-llvm --enable-cuvid --enable-dxva2 --enable-d3d11va --enable-d3d12va --enable-ffnvcodec --enable-libvpl --enable-nvdec --enable-nvenc --enable-vaapi --enable-libgme --enable-libopenmpt --enable-libopencore-amrwb --enable-libmp3lame --enable-libtheora --enable-libvo-amrwbenc --enable-libgsm --enable-libopencore-amrnb --enable-libopus --enable-libspeex --enable-libvorbis --enable-librubberband
libavutil 59. 39.100 / 59. 39.100
libavcodec 61. 19.100 / 61. 19.100
libavformat 61. 7.100 / 61. 7.100
libavdevice 61. 3.100 / 61. 3.100
libavfilter 10. 4.100 / 10. 4.100
libswscale 8. 3.100 / 8. 3.100
libswresample 5. 3.100 / 5. 3.100
libpostproc 58. 3.100 / 58. 3.100
不同电脑配置
在2台不同的电脑上进行测试,现在把这2台电脑分别定义为 “电脑A” , "电脑B",后面将用这2个名称代指。
电脑A:
处理器 Intel(R) Core(TM) i9-14900HX 2.20 GHz
机带 RAM 32.0 GB (31.7 GB 可用)
系统类型 64 位操作系统, 基于 x64 的处理器
笔和触控 没有可用于此显示器的笔或触控输入版本 Windows 11 家庭中文版
版本号 24H2
操作系统版本 26100.3775
体验 Windows 功能体验包 1000.26100.66.0
电脑B:
处理器 Intel(R) Core(TM) i5-8350U CPU @ 1.70GHz 1.90 GHz
机带 RAM 16.0 GB (15.9 GB 可用)
系统类型 64 位操作系统, 基于 x64 的处理器
笔和触控 为 10 触摸点提供笔和触控支持版本 Windows 10 专业版
版本号 22H2
操作系统内部版本 19045.3324
体验 Windows Feature Experience Pack 1000.19041.1000.0
不同方式比对
将要执行的解码任务命令是:
-i "D:\1.mp4" -vf \"format=rgba\" -f image2 -pix_fmt rgba "D:\1\frame_%05d.png"
分别从 是否出现黑色窗口 、解码时长 、内存占用 这3个方面,来在 电脑A 测试手动模式、调试模式、和运行模式 三种, 在 电脑B 测试手动模式 和 运行模式 两种 。
详细测试数据汇总到了如下的表格图:
对于调用方式,一共验证了14种 , 分别为手动方式,和测试0~测试12。手动方式是指打开命令行窗口来执行解码命令,其他方式都是通过在C#中来通过代码的方式调用,在代码中,它们的具体写法如下:
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = AppDomain.CurrentDomain.BaseDirectory + "ffmpeg.exe";
startInfo.Arguments = "-i \"D:\\1.mp4\" -vf \"format=rgba\" -f image2 -pix_fmt rgba \"D:\\1\\frame_%05d.png\"";
//startInfo.Arguments = "-i \"D:\\1.mp4\" -f image2 -qscale:v 1 \"D:\\1\\frame_%05d.jpg\"";
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
//***********************测试 0
var process = Process.Start(startInfo.FileName, startInfo.Arguments);
Stopwatch stopwatch = new Stopwatch();
***********************测试1
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = true;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
***********************测试2
不关心输出内容 避免重定向 只关心结果文件 提高效率
//startInfo.RedirectStandardOutput = false;
不关心输出内容 避免重定向 只关心结果文件 提高效率
//startInfo.RedirectStandardError = false;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
***********************测试3
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = true;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
//process.ErrorDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
//process.OutputDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
开始异步读取。如果不启用 则以上日志始终不会被读取 当日志量达到指定量(可能4K?)后会卡死
//process.BeginOutputReadLine();
//process.BeginErrorReadLine();
***********************测试4
//startInfo.RedirectStandardOutput = false;
//startInfo.RedirectStandardError = true;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
//process.ErrorDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
//process.BeginErrorReadLine();
***********************测试5
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = false;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
//process.OutputDataReceived += (s, e) =>
//{
// if (e.Data != null) Console.WriteLine(e.Data);
//};
//process.BeginOutputReadLine();
***********************测试6
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = true;
禁用控制台渲染 减少编码开销
//startInfo.StandardOutputEncoding = Encoding.ASCII;
//startInfo.StandardErrorEncoding = Encoding.ASCII;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
//process.ErrorDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
//process.OutputDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
开始异步读取。如果不启用 则以上日志始终不会被读取 当日志量达到指定量(可能4K?)后会卡死
//process.BeginOutputReadLine();
//process.BeginErrorReadLine();
***********************测试7
//startInfo.RedirectStandardOutput = false;
//startInfo.RedirectStandardError = true;
禁用控制台渲染 减少编码开销
//startInfo.StandardErrorEncoding = Encoding.ASCII;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
//process.ErrorDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
//process.BeginErrorReadLine();
***********************测试8
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = false;
禁用控制台渲染 减少编码开销
//startInfo.StandardOutputEncoding = Encoding.ASCII;
//var process = Process.Start(startInfo);
//Stopwatch stopwatch = new Stopwatch();
//process.OutputDataReceived += (s, e) =>
//{
// if (e.Data != null) Console.WriteLine(e.Data);
//};
//process.BeginOutputReadLine();
***********************测试9
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = true;
禁用控制台渲染 减少编码开销
//startInfo.StandardOutputEncoding = Encoding.ASCII;
//startInfo.StandardErrorEncoding = Encoding.ASCII;
//var process = Process.Start(startInfo);
设置进程优先级
//process.PriorityClass = ProcessPriorityClass.High;
//Stopwatch stopwatch = new Stopwatch();
//process.ErrorDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
//process.OutputDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
开始异步读取。如果不启用 则以上日志始终不会被读取 当日志量达到指定量(可能4K?)后会卡死
//process.BeginOutputReadLine();
//process.BeginErrorReadLine();
***********************测试10
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = true;
禁用控制台渲染 减少编码开销
//startInfo.StandardOutputEncoding = Encoding.ASCII;
//startInfo.StandardErrorEncoding = Encoding.ASCII;
//var process = Process.Start(startInfo);
设置进程优先级
//process.PriorityClass = ProcessPriorityClass.High;
//Stopwatch stopwatch = new Stopwatch();
//process.ErrorDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
//process.OutputDataReceived += (s, e) =>
//{
// Console.WriteLine(e.Data);
//};
开始异步读取。如果不启用 则以上日志始终不会被读取 当日志量达到指定量(可能4K?)后会卡死
//process.BeginOutputReadLine();
//process.BeginErrorReadLine();
***********************测试11
//startInfo.RedirectStandardOutput = true;
//startInfo.RedirectStandardError = false;
禁用控制台渲染 减少编码开销
//startInfo.StandardOutputEncoding = Encoding.ASCII;
//var process = Process.Start(startInfo);
设置进程优先级
//process.PriorityClass = ProcessPriorityClass.High;
//Stopwatch stopwatch = new Stopwatch();
//process.OutputDataReceived += (s, e) =>
//{
// if (e.Data != null) Console.WriteLine(e.Data);
//};
//process.BeginOutputReadLine();
***********************测试12
//startInfo.RedirectStandardOutput = false;
//startInfo.RedirectStandardError = false;
//var process = Process.Start(startInfo);
设置进程优先级
//process.PriorityClass = ProcessPriorityClass.High;
//Stopwatch stopwatch = new Stopwatch();
//监控资源
new Action(delegate
{
while (!process.HasExited)
{
Console.WriteLine($"内存使用: {process.WorkingSet64 / 1024}KB");
Thread.Sleep(1000);
}
}).BeginInvoke(null, null);
stopwatch.Start();
var ok = process.WaitForExit(-1);
stopwatch.Stop();
Console.WriteLine("解码用时(毫秒):" + stopwatch.ElapsedMilliseconds);
Console.WriteLine("完成");
Console.ReadLine();
发现规律
根据在错误的条件下发现的一些规律,进而推导出对正确条件下的使用的一些指导意义,总结出来了以下的规律,按照规律的重要性列举如下:
1. 电脑A 是 电脑B 的性能3~4倍
2. 只要启用了正常日志输出(RedirectStandardOutput)或者是错误日志(RedirectStandardError)输出,就一定要异步读取这些消息。
FFmpeg 处理高清视频时会产生大量输出,当缓冲区填满(可能默认约 4KB?)时,进程会阻塞等待父进程读取,如果不及时处理,就会一直卡住,测试1 的卡死就是属于这种情况 ,序列帧 在 正确的时间内被处理完成了 ,但是之后却一直卡住,无法跳出 WaitForExit 。
如果关注的重点与日志内容无关,建议直接关闭 RedirectStandardOutput/RedirectStandardError
3. VS 的调试模式 比 直接运行exe 具有非常大的性能提升, 这可能是因为VS本身的调试模式进行了内存优化,比如对输出管道的优化,也有可能是VS对执行进程赋予了较高的进程优先级。
例如上面的测试数据中,当在exe直接运行模式时, 赋予执行进程高优先级的表现,与VS调试模式下水平接近, 但是, 这在高性能电脑上表现优异, 却在低性能电脑上可能会导致 ffmpeg 在启动时略有卡顿,可能会影响系统稳定,因此这里不建议提升执行进程的优先级。
例如 左下 红框选定的部分是 exe模式下将执行进程设置为高优先级,右上 红框选定的部分是 VS 调试模式下保持默认优先级,他们表现出了相近的水平,真的有很大提升。
正确的条件
以上的测试,实际上是假设在一种错误的条件下来进行测试的, 因为一般mp4视频解码为JPG图像序列就可以了, 我们一般把动图GIF文件或者是包含透明通道的MOV视频来解码为 PNG 图像序列,但是当视频本身已经包含透明通道的情况下,它的解码速度有可能是比较快的,这一部分未进行详细验证,暂不细讨论。
但是对于MP4正常解码为JPG情况,行了一组简单的测试:
-i "D:\1.mp4" -f image2 -qscale:v 1 "D:\1\frame_%05d.jpg"
对上面的解码命令,测试情况如下:
从上图看出,在 测试2 的情况下,整体的表现与第一组解码命令有天壤之别 。
因此, “守规矩” 是非常重要的,但是当不守规矩的时候,我们对在破坏中遇到的问题,也保持好奇与学习的心态。
重新封装
根据上面的经验教训,重新封装了对进程的调用,代码如下:
/// <summary>
/// 隐藏窗口
/// 禁止任何日志输出
/// </summary>
/// <param name="exeFile"></param>
/// <param name="exeArgs"></param>
internal static bool CallExe_Hide_LogNone(string exeFile, string exeArgs, int maxWaitMillsec = 120)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = exeFile;
startInfo.Arguments = exeArgs;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
startInfo.RedirectStandardOutput = false;
startInfo.RedirectStandardError = false;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
var process = Process.Start(startInfo);
if (maxWaitMillsec <= 0) maxWaitMillsec = 30000;
if (!process.WaitForExit(maxWaitMillsec * 1000))
{
stopwatch.Stop();
Loger.error("外部进程执行结束,用时:" + stopwatch.ElapsedMilliseconds + "毫秒,超时:" + exeFile + " " + exeArgs);
return false;
}
stopwatch.Stop();
if (process.ExitCode != 0)
{
Loger.error("外部进程执行发生了错误,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return false;
}
Loger.info("外部进程执行成功,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return true;
}
catch (Exception ex)
{
Loger.error("外部进程执行发生了异常:" + exeFile + " " + exeArgs + "=>" + ex);
return false;
}
}
/// <summary>
/// 隐藏窗口
/// 输出日志和错误
/// </summary>
/// <param name="exeFile"></param>
/// <param name="exeArgs"></param>
internal static bool CallExe_Hide_LogInfoError(string exeFile, string exeArgs, Action<string> dataInfoReceive, Action<string> dataErrorReceive, int maxWaitMillsec = 120)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = exeFile;
startInfo.Arguments = exeArgs;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
//禁用控制台渲染 减少编码开销
startInfo.StandardErrorEncoding = Encoding.ASCII;
startInfo.StandardOutputEncoding = Encoding.ASCII;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
var process = Process.Start(startInfo);
process.ErrorDataReceived += (s, e) =>
{
if (!string.IsNullOrWhiteSpace(e.Data))
dataErrorReceive?.Invoke(e.Data);
};
process.OutputDataReceived += (s, e) =>
{
if (!string.IsNullOrWhiteSpace(e.Data))
dataInfoReceive?.Invoke(e.Data);
};
//异步读取
process.BeginErrorReadLine();
process.BeginOutputReadLine();
if (maxWaitMillsec <= 0) maxWaitMillsec = 30000;
if (!process.WaitForExit(maxWaitMillsec * 1000))
{
stopwatch.Stop();
Loger.error("外部进程执行结束,用时:" + stopwatch.ElapsedMilliseconds + "毫秒,超时:" + exeFile + " " + exeArgs);
return false;
}
stopwatch.Stop();
if (process.ExitCode != 0)
{
Loger.error("外部进程执行发生了错误,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return false;
}
Loger.info("外部进程执行成功,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return true;
}
catch (Exception ex)
{
Loger.error("外部进程执行发生了异常:" + exeFile + " " + exeArgs + "=>" + ex);
return false;
}
}
/// <summary>
/// 隐藏窗口
/// 仅输出错误日志
/// </summary>
/// <param name="exeFile"></param>
/// <param name="exeArgs"></param>
internal static bool CallExe_Hide_LogError(string exeFile, string exeArgs, Action<string> dataErrorReceive, int maxWaitMillsec = 120)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = exeFile;
startInfo.Arguments = exeArgs;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
startInfo.RedirectStandardOutput = false;
startInfo.RedirectStandardError = true;
//禁用控制台渲染 减少编码开销
startInfo.StandardErrorEncoding = Encoding.ASCII;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
var process = Process.Start(startInfo);
process.ErrorDataReceived += (s, e) =>
{
if (!string.IsNullOrWhiteSpace(e.Data))
dataErrorReceive?.Invoke(e.Data);
};
//异步读取
process.BeginErrorReadLine();
if (maxWaitMillsec <= 0) maxWaitMillsec = 30000;
if (!process.WaitForExit(maxWaitMillsec * 1000))
{
stopwatch.Stop();
Loger.error("外部进程执行结束,用时:" + stopwatch.ElapsedMilliseconds + "毫秒,超时:" + exeFile + " " + exeArgs);
return false;
}
stopwatch.Stop();
if (process.ExitCode != 0)
{
Loger.error("外部进程执行发生了错误,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return false;
}
Loger.info("外部进程执行成功,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return true;
}
catch (Exception ex)
{
Loger.error("外部进程执行发生了异常:" + exeFile + " " + exeArgs + "=>" + ex);
return false;
}
}
/// <summary>
/// 隐藏窗口
/// 仅输出消息日志
/// </summary>
/// <param name="exeFile"></param>
/// <param name="exeArgs"></param>
internal static bool CallExe_Hide_LogInfo(string exeFile, string exeArgs, Action<string> dataInfoReceive, int maxWaitMillsec = 120)
{
try
{
ProcessStartInfo startInfo = new ProcessStartInfo();
startInfo.FileName = exeFile;
startInfo.Arguments = exeArgs;
startInfo.UseShellExecute = false;
startInfo.CreateNoWindow = true;
startInfo.WindowStyle = ProcessWindowStyle.Hidden;
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = false;
//禁用控制台渲染 减少编码开销
startInfo.StandardOutputEncoding = Encoding.ASCII;
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
var process = Process.Start(startInfo);
process.OutputDataReceived += (s, e) =>
{
if (!string.IsNullOrWhiteSpace(e.Data))
dataInfoReceive?.Invoke(e.Data);
};
//异步读取
process.BeginOutputReadLine();
if (maxWaitMillsec <= 0) maxWaitMillsec = 30000;
if (!process.WaitForExit(maxWaitMillsec * 1000))
{
stopwatch.Stop();
Loger.error("外部进程执行结束,用时:" + stopwatch.ElapsedMilliseconds + "毫秒,超时:" + exeFile + " " + exeArgs);
return false;
}
stopwatch.Stop();
if (process.ExitCode != 0)
{
Loger.error("外部进程执行发生了错误,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return false;
}
Loger.info("外部进程执行成功,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return true;
}
catch (Exception ex)
{
Loger.error("外部进程执行发生了异常:" + exeFile + " " + exeArgs + "=>" + ex);
return false;
}
}
/// <summary>
/// 显示窗口
/// 直接启动进程
/// </summary>
/// <param name="exeFile"></param>
/// <param name="exeArgs"></param>
internal static bool CallExe_Show_Directly(string exeFile, string exeArgs, int maxWaitMillsec = 120)
{
try
{
Stopwatch stopwatch = new Stopwatch();
stopwatch.Start();
var process = Process.Start(exeFile, exeArgs);
if (maxWaitMillsec <= 0) maxWaitMillsec = 30000;
if (!process.WaitForExit(maxWaitMillsec * 1000))
{
stopwatch.Stop();
Loger.error("外部进程执行结束,用时:" + stopwatch.ElapsedMilliseconds + "毫秒,超时:" + exeFile + " " + exeArgs);
return false;
}
stopwatch.Stop();
if (process.ExitCode != 0)
{
Loger.error("外部进程执行发生了错误,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return false;
}
Loger.info("外部进程执行成功,用时:" + stopwatch.ElapsedMilliseconds + "毫秒:" + exeFile + " " + exeArgs);
return true;
}
catch (Exception ex)
{
Loger.error("外部进程执行发生了异常:" + exeFile + " " + exeArgs + "=>" + ex);
return false;
}
}