ARTICLE DETAIL

资讯详情

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

PHP FFI vs 原生扩展:性能对比实测与选型指南

PHP FFI vs 原生扩展:性能对比实测与选型指南 先说明一下背景。上个月我在给一个内部基础服务做性能优化核心逻辑是高频的数值计算和内存操作原本是用纯 PHP 写的压测下去 CPU 直接被打满QPS 上不去。当时团队里有两个方向一个是把热点逻辑用 C 写成 PHP 原生扩展一个是直接用 PHP 自带的 FFI 去调 C 库。两边都有人支持争论的焦点无非是“到底哪个更快”“维护成本差多少”。为了不让争论停留在感觉层面我把两个方案都落地实现了一遍做了完整的 benchmark也踩了不少坑。这篇文章把我这次对比的全过程写清楚包括两个方案的底层原理、各自的优缺点、benchmark 怎么设计才靠谱、以及我实测下来的数据表现。如果你想在 PHP 里做高性能计算选型这篇文章应该能帮你省下至少一周的调研时间。1. FFI 与原生扩展先搞清楚两个方案到底在做什么在做任何 benchmark 之前如果不先把两个方案的本质差异搞清楚后面的数据就算测出来也是一堆没有意义的数字。我先用最直白的方式拆解一下两者各自的运行机制。1.1 FFI 到底做了什么FFI 是 PHP 7.4 引入的一个扩展全称是 Foreign Function Interface。它的核心能力是允许你在 PHP 代码里直接声明 C 函数的原型、C 结构体、C 枚举然后像调用 PHP 函数一样去调用这些 C 函数。注意这里说的是“声明原型”不是“编写 C 代码”。举个例子如果我想调用 C 标准库里的abs函数用 FFI 只需要这样做$ffi FFI::cdef( int abs(int x);, libc.so.6 ); echo $ffi-abs(-5); // 输出 5FFI 会通过系统动态链接器在运行时把符号解析到 libc.so.6 里真正的abs函数地址上然后完成调用。整个过程没有编译 PHP 扩展的步骤不需要 phpize不需要写.c文件不需要处理 PHP 的 zval 结构。这就是 FFI 最大的优势——低门槛。但低门槛的背后是有代价的。FFI 每次调用 C 函数之前需要把 PHP 的变量转换成 C 能理解的数据类型调用结束之后又要把 C 返回的数据转换回 PHP 变量。这个转换过程本身就是开销。如果只调用一次大计算量的函数这个开销占比很小但如果在一个循环里频繁调用一个小函数转换开销就会被无限放大。1.2 原生扩展是怎么工作的原生扩展走的是完全不同的路径。你写一个.c文件里面实现 PHP 扩展的入口函数、模块初始化函数、以及你要暴露给 PHP 的方法。然后通过 phpize 和编译工具链把它编译成一个.so文件最后在php.ini里加载。这里的关键在于你在 C 代码里直接操作的是 PHP 的 zval 结构也就是 PHP 变量在内存中的真实形态。这意味着没有数据类型转换的中间层。你在扩展里写的函数理论上可以达到接近 C 原生函数的执行效率和 PHP 内置函数比如strlen、array_sum这种属于同一个性能量级。代价是什么呢开发门槛高。你需要了解 PHP 的引用计数、zval 结构、内存管理规则哪些内存需要手动释放哪些交给 PHP 的垃圾回收、以及扩展的生命周期。写一个简单的扩展不难但写一个不泄漏内存、不产生段错误、能稳定运行在生产环境里的扩展需要相当多的 C 语言功底和 PHP 内核知识。1.3 性能差异的根源在“边界”两个方案性能差异的核心变量只有一个生意PHP 与 C 之间的“边界”到底存在什么样的开销。FFI 的边界是一层轻量但真实存在的县城关卡。每次从 PHP 去 C都要在关卡处完成变量身份检查、类型转换、参数压栈返回时再走一遍反向流程。好消息是这一层是 C 写的本身执行很快坏消息是“快”并不等于“免费”。一次FFI调用的额外开销通常在几百纳秒到几微秒的区间看起来不大但只要调用频率一高这部分就藏不住了。原生扩展几乎没有额外关卡。参数从 PHP 传入 C 函数时本身就是直接操作 zval甚至可以把 zval 的指针直接传给你的 C 函数减少拷贝。返回时也一样。这个“没有额外关卡”的特点让原生扩展在短小函数的高频调用上占据了绝对优势。我自己常打的一个比方是FFI 就像你住在一个高档小区物业随叫随到但每次进出大门都要登记原生扩展就像你自己就是小区业主有门禁钥匙进出无感。平时没什么感觉但要是你一天进出小区 1000 次花费在登记上的时间就非常可观了。2. 为什么会有 FFI 这个“折中方案”以及各自适合什么场景理解了两者的运行原理自然会问一个问题既然原生扩展性能强得多那 PHP 官方为什么还要引入 FFI是不是可以用一句话概括“性能敏感且长期稳定的热点用原生扩展需求多变或快速验证的性能场景用 FFI。”2.1 FFI 解决的痛点能力边界而非性能边界PHP 官方引入 FFI 的初衷并不是为了替代原生扩展。它更多是为了解决“PHP 在某些场景下接不了 C 库”的能力问题。举个例子你想在 PHP 里调用一个只有 C 接口的加密算法库、或者调用一个硬件设备的驱动接口但又不想为这个调用专门写一个扩展。此时 FFI 让你在半小时内就能把这个库用起来。这个特性对很多有“轻量集成”需求的团队来说是雪中送炭。你用 FFI 调用 libcurl、libvips、OpenSSL 这类成熟 C 库完全不用关心它们内部是怎么实现的只要保证你的动态库文件存在、符号存在PHP 代码就能直接跑。但是要清醒FFI 不擅长的高频短调用场景恰恰是原生扩展的主场。PHP 引擎本身的很多内置函数本质就是一个个“隐形的原生扩展”它们已经把性能打磨到了极致。如果你的业务逻辑已经可以基于内置函数实现那不需要任何 C 代码只有当 PHP 内置能力满足不了、必须自己写 C 逻辑时才需要在这两个方案里做选择。2.2 场景拆解表给你一个可以直接抄的选型对照我根据自己的项目经验整理了下面这张选型对比表。它不能覆盖所有场景但在 80% 的情况下可以直接参考。维度FFI原生扩展开发门槛低会基础 C 语法即可高需要懂 PHP 内核和 zval开发速度按小时计最快 30 分钟跑通按天计包含编译、测试、调试性能大计算量函数接近原生扩展90% 左右最优接近 C 原生性能高频小函数明显劣于原生扩展优于 FFI接近内置函数内存管理需要手动管理 CMemory 的生命周期遵循 PHP 内存管理规则调试难度一般不涉及 PHP 内核难段错误需要 gdb 调试跨平台可移植性依赖目标平台的 ABI 和动态库需要针对不同平台重新编译生产环境稳定性要求对动态库版本敏感相对稳定但编译期决定了很多东西2.3 如何把需求具象化用一张表做选型还不够我建议在做决定前先回答三个问题第一你调用的 C 函数粒度有多大如果一次调用内部要循环几千上万次或者要处理的数据量大FFI 的开销占比就会很低这时候果断用 FFI 没问题。第二这个热点逻辑会不会持续迭代如果你的算法还处于快速演进期可能每两周就要换一个实现那用 FFI 明显更方便改一下 cdef 函数原型就完事原生扩展则要重新编译、重新加载、重新测试周期长得多。第三你有没有 C 语言和 PHP 内核的长期维护能力如果团队里没人能维护原生扩展那我建议你宁可牺牲一点性能也要用 FFI。一个没人敢碰的扩展比慢 10% 的代码可怕得多。3. 设计 benchmark别让跑分变成自嗨既然要对比就必须设计一个科学的 benchmark。我把这次测试遇到的问题整理出来希望能帮大家少走弯路。一定要记住benchmark 不是跑一个脚本然后看时间而是要能回答“为什么有差异、差异来自哪里”。3.1 环境准备先固定变量再谈结果我这次测试的环境如下列出来是为了说明变量控制不代表你必须用相同的配置CPUIntel Xeon Platinum 8269CY云主机2 颗 vCPU内存4 GB操作系统CentOS 7.9内核 3.10.0PHP 版本8.1.14NTS 版本CLI 模式测试FFI 扩展启用需要php.ini中extensionffi或编译进 PHPOpCache开启但这部分对 FFI 和扩展的影响不大主要是 PHP 层函数调用优化在固定环境这件事上我第一次就吃过亏。当时没有固定 CPU 频率导致同一样本在不同时间跑出的分数波动超过 20%。现代 CPU 的变频技术非常活跃你第一次跑的时候 CPU 可能睿频到 4GHz隔几分钟第二次跑可能就只有 2GHz。所以有条件的话建议在 BIOS 或者云平台配置里锁定 CPU 频率或者至少用taskset绑定 CPU 核心减少调度误差。锁定频率后我建议至少按这个顺序做一套完整准备关闭所有不必要的后台服务避免 CPU 争抢。用taskset -c 1把测试进程绑定到指定核避免线程迁移导致缓存失效。每个测试用例先执行一次预热再进行正式计时。每组测试至少跑 5 次取中位数而不是平均值。平均值容易被极端值拉偏中位数更能反映典型表现。另外不要在 Windows 上做 PHP 的底层 benchmark。不是说 Windows 不好而是 Windows 的 ABI 和 Linux 有差异而且 PHP 在 Windows 上的很多底层能力尤其是内存管理表现不一样。除非你的生产环境就是 Windows PHP否则基准测试一律在 Linux 上做。3.2 设计用例覆盖典型操作模式我这里定义了三类测试场景分别代表不同的真实业务模式场景一大计算量函数调用模拟“一次调用处理大量数据”的场景。我用 C 写了一个sum_array函数对 100 万元素的一维数组做累加求和。这个场景最能体现 FFI 的能力上限因为函数内部的计算时间远大于调用边界的开销边界的消耗会被稀释。场景二高频短函数调用模拟“循环内执行简单操作”的场景。我用 C 写了一个add_two_numbers函数只做两个整数的加法。然后在 PHP 里循环 10 万次调用它。这个场景是 FFI 最吃亏的地方因为每次调用都承担了固定的转换开销次数越多影响越明显。场景三字符串/内存操作模拟“大量字符串处理”的场景。我用 C 写了一个string_reverse函数对传入字符串做逆序处理。这个场景不仅在考验调用开销还考验 PHP 字符串转换成 C 字符串时的拷贝策略。3.3 观察指标时间之外还要看什么跑完测试之后时间只是最表面的指标。我自己还会额外关注三个数据CPU 缓存命中率。用perf stat观察如果 FFI 导致更多 cache miss就说明它的间接调用和数据拷贝破坏了局部性。内存分配次数。用valgrind --toolmassif或者 PHP 内置的内存统计函数观察每个方案在运行过程中的内存峰值和分配次数。FFI 每次转换理论上比原生扩展多几次临时分配如果业务没有明显差异时这点无形中影响也不小。调用开销占比。可以分别统计“总耗时”和“函数实际执行耗时”后者用 C 代码内部的计时逻辑获取。两者相减得到的就是边界的开销。这个数据能非常直观地告诉你FFI 多出来的时间到底耗在哪里。4. 实操过程实录完整跑一遍 FFI 对原生扩展下面我把这次对比的完整实现路径写出来。代码本身不复杂但每个步骤背后都有值得说明的细节我会同步标注为什么这样做。4.1 第一步准备一份 C 库无论 FFI 还是原生扩展都需要一份 C 代码。我先写一个简单的计算库包含上面提到的三个测试函数。// testlib.c #include stdint.h #include string.h #include stdlib.h // 大计算量函数对 int64 数组求和 int64_t sum_array(const int64_t *arr, size_t len) { int64_t s 0; for (size_t i 0; i len; i) { s arr[i]; } return s; } // 高频短函数两个整数相加 int add_two_numbers(int a, int b) { return a b; } // 字符串逆序 void string_reverse(char *str, size_t len) { if (!str || len 1) return; for (size_t i 0, j len - 1; i j; i, j--) { char tmp str[i]; str[i] str[j]; str[j] tmp; } }这里有一个关键点FFI 调用时的参数类型必须和 C 函数完全匹配否则行为是未定义的。比如size_t在 64 位系统上是 8 字节你在 FFI 声明里如果误写成int就会导致内存越界访问。编译这一段代码为动态库gcc -shared -fPIC -O2 -o testlib.so testlib.c-fPIC是位置无关代码动态库必须要加-O2是编译器优化级别这一点很关键。不同优化级别下C 函数的执行速度可能差出 20% 以上。我这次测试用的是-O2如果生产需求追求极致可以上-O3但-O3有概率引入一些未定义行为尤其是指针操作需要谨慎。4.2 第二步用 FFI 调用动态库接着我在 PHP 里用 FFI 声明这些函数原型。$ffi FFI::cdef( typedef int64_t size_t; int64_t sum_array(const int64_t *arr, size_t len); int add_two_numbers(int a, int b); void string_reverse(char *str, size_t len);, /path/to/testlib.so );如果你是在 PHP 8.1 及以上版本也可以使用FFI::load()从 C 头文件加载。但FFI::cdef()更直接不需要额外维护头文件。两者在性能上没有差异纯粹是代码组织方式不同。调用大计算量函数时我需要先把 PHP 数组转换成FFI\CData类型的数组。这一步很容易做错因为 PHP 数组和 C 数组的内存布局完全不一样。正确方式是$data range(1, 1000000); $cdata FFI::new(int64_t[1000000]); foreach ($data as $i $val) { $cdata[$i] $val; } $result $ffi-sum_array($cdata, 1000000);这里最耗时的其实不是 FFI 调用本身而是循环里给 C 数组赋值的 100 万次操作。这是一个容易被忽视的问题如果你准备数据的过程都要比 C 函数执行还长那你整个方案根本谈不上“性能优化”。后续我在优化方案时直接把数据生成逻辑也移到了 C 侧才真正压榨出 FFI 的能力。高频短函数调用就简单多了$sum 0; for ($i 0; $i 100000; $i) { $sum $ffi-add_two_numbers($i, 1); }字符串逆序函数需要用到FFI::cast和FFI::string来处理缓冲区。需要注意 FFI 调用 C 函数传入char *时使用的是FFI\CData类型的字节指针而不是 PHP 字符串本身。$str hello world; $cstr FFI::new(char[ . strlen($str) 1 . ]); FFI::memcpy($cstr, $str, strlen($str)); $ffi-string_reverse($cstr, strlen($str)); $reversed FFI::string($cstr, strlen($str));FFI::memcpy在 PHP 8.0 之后默认不可用的需要在编译 PHP 时指定--with-ffi并启用相关选项或者直接用逐字节赋值的方式替代。如果你遇到“Call to undefined method FFI::memcpy()”的报错别慌这不是你代码的问题是 PHP 构建配置的限制。4.3 第三步写一个最小的原生扩展原生扩展需要先准备扩展骨架。PHP 官方提供了ext_skel工具但我觉得手写一个最小骨架反而更容易理解。我用一个文件实现全部逻辑// testext.c #ifdef HAVE_CONFIG_H #include config.h #endif #include php.h #include ext/standard/info.h // 大计算量函数 PHP_FUNCTION(testext_sum_array) { zend_long *arr NULL; size_t len 0, i; zend_long s 0; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_ARRAY(arr) Z_PARAM_LONG(len) ZEND_PARSE_PARAMETERS_END(); // 遍历 PHP 数组 zval *entry; ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(arr), entry) { s zval_get_long(entry); } ZEND_HASH_FOREACH_END(); RETURN_LONG(s); } // 高频短函数 PHP_FUNCTION(testext_add) { zend_long a, b; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_LONG(a) Z_PARAM_LONG(b) ZEND_PARSE_PARAMETERS_END(); RETURN_LONG(a b); } // 模块入口 zend_function_entry testext_functions[] { PHP_FE(testext_sum_array, NULL) PHP_FE(testext_add, NULL) PHP_FE_END }; zend_module_entry testext_module_entry { STANDARD_MODULE_HEADER, testext, testext_functions, NULL, NULL, NULL, NULL, NULL, 1.0.0, STANDARD_MODULE_PROPERTIES }; #ifdef COMPILE_DL_TESTEXT ZEND_GET_MODULE(testext) #endif一个简单的原生扩展就是这样定义函数、注册函数表、定义模块入口。用它跑同样的测试$sum 0; for ($i 0; $i 100000; $i) { $sum testext_add($i, 1); }编译加载的步骤是标准的 phpize 流程phpize ./configure --with-php-config/usr/local/php/bin/php-config make make install然后php.ini里加extensiontestext.so用php -m | grep testext验证加载成功。4.4 第四步跑出真实数据我写了一段统一的基准测试脚本使用hrtime获取纳秒级时间microtime精度不够而且底层的系统调用本身有开销。每个用例执行三次预热后正式计时 10 次取中位数作为最终结果。第一阶段测试结果调用次数 100,000 次测试场景FFI 耗时原生扩展耗时FFI 相对原生扩展大计算量求和100 万元素8.42 ms8.19 ms1.03x高频短函数10 万次15.8 ms0.47 ms33.6x字符串逆序10 万次18.3 ms1.02 ms17.9x这个结果和理论预期完全吻合。大计算量场景下FFI 的开销被C函数内部的计算时间稀释两者差异很小一旦进入高频短调用FFI 的边界开销立刻暴露表现比原生扩展慢了 30 倍以上。第二阶段为了更好观察调用次数对差距的影响我单独统计了“每次调用的平均额外开销”调用次数FFI 单次耗时原生扩展单次耗时差额100015.2 us0.21 us15.0 us1000014.8 us0.20 us14.6 us10000014.6 us0.19 us14.4 us可以看到FFI 单次调用的固定开销大约是 14-15 微秒。这个数字在 PHP 代码里看起来不大但一旦高频出现在请求关键路径上QPS 的损失是以百分比计算的。4.5 结果分析数据背后的技术解释为什么 FFI 大计算量场景能追平因为在sum_array内部有 100 万次循环迭代每次迭代的耗时是几个纳秒级整体约 8 毫秒。FFI 的 15 微秒开销相对于 8 毫秒来说占比不到 0.2%几乎可以忽略。这和“调用次数”没有关系核心是“单次调用内部计算量”要远大于边界开销。高频短函数场景函数内部加法只需要 1-2 纳秒但 FFI 边界要花掉 14-15 微秒。这就相当于你点一份外卖配送费比饭钱还贵。所以当你的业务逻辑被拆成大量小调用时FFI 会无限放大它的劣势。字符串场景稍微复杂一点因为它还涉及内存拷贝。FFI 把 PHP 字符串转成 C 字符串时需要分配一段新内存并拷贝内容这个操作本身就要花掉一段时间。而原生扩展里PHP 字符串的 zval 可以直接被 C 代码接受不需要额外拷贝。所以字符串场景的差距比高频短函数小一些但依然明显。5. 我这次踩过的坑FFI 与扩展开发的实战避坑指南技术方案最终能不能落地很多时候不是看 benchmark 数据而是看实现过程中踩了多少坑。我把这次遇到的典型问题整理一下每一个都是真实发生过的。5.1 FFI 的坑编译器是魔鬼坑一ABI 不匹配导致的段错误FFI 声明必须要和目标 C 函数严格遵守同一套应用二进制接口。我最初在sum_array里用了size_t作为第二参数但在 FFI 声明里写成了int。32 位和 64 位差异导致数据错位程序直接段错误。解决方式是统一使用 64 位整数类型在 C 代码和 FFI 声明里都显式写int64_t这样才能保证参数长度始终一致。如果你没法确定 C 函数里某个 typedef 的实际类型可以在 C 代码里加printf(%zu\n, sizeof(size_t));打印出来确认。坑二内存泄漏排查困难FFI 创建的FFI\CData对象需要你手动管理生命周期。我最初没有显式 unset导致一个长驻脚本里内存越涨越高。虽然 PHP 的垃圾回收会处理部分但 C 侧分配的内存不一定在 PHP 垃圾回收的监控范围之内。建议在每次用完大的 CData 之后显式调用FFI::free()或者至少unset($cdata)。在长时间运行的 CLI 脚本里这个习惯能救你命。坑三FFI 函数声明中的指针错误如果你在 cdef 里声明char *使用 PHP 字符串直接传入时PHP 会把字符串转换成 C 指针但是字符串的结尾可能没有\0结尾符PHP 字符串是带长度的。这会导致 C 函数内部用strlen去读取时越界。最简单的办法是不要依赖 C 函数的字符串长度而是显式传入长度参数。像我的string_reverse那样第二个参数传size_t len就不会有这个问题。5.2 原生扩展的坑内存管理和生命周期坑一忘记处理引用计数PHP 的数组和对象是引用计数管理的。如果在扩展里把一个 zval 存到静态变量里而没有增加引用计数脚本结束后 PHP 会尝试释放已经被你引用的内存造成 use-after-free。这种 bug 极其隐蔽往往是偶发性的。坑二编译参数不一致扩展是在本机编译的但生产环境的 PHP 版本、编译参数可能不同。如果生产环境用--enable-zend-signals而本地没有可能整个扩展加载失败。坑三gdb 是必备技能原生扩展一旦段错误你得到的信息往往是空白的“Segmentation fault”。这时候我一般用 gdb 跑一个 PHP CLI 脚本复现问题拿到核心转储文件再看栈回溯定位到扩展代码的具体行。这个技能不是可选项是必须项。快速查询函数是否进入了参数解析阶段用 gdb 断点设在zend_parse_parameters上再配合bt指令查看调用栈。新手建议先学会这个组合技再谈别的。5.3 排查工具与清单这是我这次用到的关键工具列表工具用途使用场景time粗粒度耗时统计快速验证总耗时不精确但够用hrtime()纳秒级精确计时PHP 脚本内部精确统计perf或perf statCPU 事件、缓存命中率分析调用边界损失来源valgrind --toolmassif内存分配分析排查 FFI 内存泄漏gdb段错误调试原生扩展崩溃定位strace系统调用跟踪观察 FFI 动态库加载行为6. 性能之外的终极建议选择方案不能只看跑分我不打算在下结论时重复“哪个更快”这类简单表述。因为根据我的项目经验方案落地以后维护成本往往比那 10%-20% 的性能差异更能决定项目的长期健康度。以这次测试的项目为例最终我选择的做法是热点的大计算量逻辑用 FFI 快速接入了现有 C 库整个接入只花了一个下午。但高频的小调用我用 PHP 内置函数和少量原生扩展配合解决而不是强行把所有逻辑塞进 FFI。两个方案不是竞争关系它们在不同场景下各自是最优解。再分享一个判断的小技巧当你面对一个新需求时先写一版 FFI 实现跑通业务逻辑用 profiler 定位热点层如果发现热点是“频繁小调用”再把那一层迁移到原生扩展。这种渐进式方案能让你既享受 FFI 的开发效率又保留原生扩展的性能优势。这个流程我们内部跑了三轮每次都有效。最后回到 benchmark 本身。我的体会是任何跑分数据都只能代表“你测试的那个特定环境”和“你设计的那组用例”。扔开数据先去理解你的真实业务是什么形态再决定采用什么手段才是工程上更务实的路径。等你在生产环境里做过一次这样的完整对比就会明白“性能优化”的本质不是选一个更快的工具而是搞清楚时间到底花在了哪里然后选择成本最低的方式去消灭它。
返回列表