
1. 为什么要把 FFI 和原生扩展摆在一起测先交代一下背景。PHP 7.4 引入 FFIForeign Function Interface之后很多人都在喊PHP 终于能直接调 C 库了也有不少人在社区里拿它跟传统扩展做对比。我陆陆续续收到的私信里问得最多的就是FFI 到底能不能替代原生扩展性能差多少什么时候该用哪个说实话网上关于 FFI 的资料大部分是用法介绍真正把 FFI 和原生扩展放在同一个基准环境下、用同一套测试方法跑出来的 benchmark 数据非常少。大多数结论都是感觉 FFI 慢或者听说 FFI 有开销这种没有数据支撑的说法拿去写方案或者做技术选型根本站不住脚。所以我自己花了一个周末搭了一套对比测试环境用几类典型场景分别测了 FFI 调用 C 库函数和原生扩展调用 C 函数的表现。这篇文章把我踩过的坑、测试思路、最终数据全部写出来给正在纠结选型的朋友一个参考。适合这几类人看一是想在项目里引入 FFI 但不确定性能能不能扛住的 PHP 开发者二是需要给 PHP 写扩展但想先确认收益的 C/C 工程师三是做技术方案评审时需要对这两种路线给出量化结论的架构师。这里先明确一下测试前提FFI 和原生扩展都属于把 C 能力暴露给 PHP的路线但它们在运行机制上有本质差异。FFI 是运行时动态解析 C 符号每次调用都要经过 FFI 层做类型检查、参数转换和调用编排原生扩展是编译期就把函数注册进 Zend 引擎调用路径短得多。这个差异会在 benchmark 数据里体现得非常明显也是后续所有分析的核心出发点。2. 测试环境与基准设计2.1 软硬件环境测试机是一台普通的 Linux 服务器不是云厂商那种被超卖的共享实例而是我自己组装的机器避免邻居抢占 CPU 导致数据抖动。配置如下CPUIntel Xeon E5-2680 v414 核 28 线程锁定 2.4GHz 频率内存DDR4 16GB双通道2400MHz系统Ubuntu 22.04 LTS内核 5.15.0PHP7.4.33使用系统包管理器安装启用了 opcache 和 opcache.preload编译器GCC 9.5.0用于编译原生扩展和 C 基准程序提示PHP 8.x 也支持 FFI我后来在 PHP 8.2 上补测过性能趋势和 7.4 一致数值略有浮动但差距比例基本稳定。用 7.4 实测是因为当时生产环境还是 7.4测出来更有参考价值。测试前把 CPU 频率锁定、关闭超线程、设置 CPU 亲和性taskset 绑定单核尽量排除调度器和频率升降带来的干扰。每个测试都循环跑了 1 万次预热再计时每组数据取 50 次完整执行的中位数而不是平均值因为中位数对偶发尖峰更稳健。2.2 测试场景怎么选设计 benchmark 场景的时候我最担心的是测出来的数据看起来很全面实际没有参考价值。如果只测一个空函数调用那只能说明调用开销说明不了真实业务里哪个更快。所以我设计了四类典型场景覆盖从纯调用开销到真实计算密集的梯度空函数调用C 函数啥也不干只返回整数。测的是 PHP 层到 C 层的通勤成本。标量参数传递传入几个 int/double 参数做简单的算术运算后返回。测的是类型转换和参数封装的代价。字符串处理传入一个 PHP 字符串在 C 里调用 strlen、strtoupper 这类函数。测的是 zval 到 C 字符串的转换开销。循环累加在 C 里完成 100 万次累加循环PHP 只负责发起一次调用。测的是把热循环下沉到 C 层之后的真实收益。这个设计有一个我想特别强调的点最后一种场景其实是对 FFI 最有利的场景因为调用次数只有一次FFI 的调用开销被 100 万次循环摊薄了。如果 FFI 在这种场景下还是明显落后那就说明它的劣势是结构性的不单纯是调用次数的问题。2.3 测试代码怎么写的原生扩展这边我用最朴素的 PHP 扩展骨架写了一个 demo函数非常直接PHP_FUNCTION(native_add) { zend_long a, b; if (zend_parse_parameters(ZEND_NUM_ARGS(), ll, a, b) FAILURE) { RETURN_NULL(); } RETURN_LONG(a b); }编译安装之后注册到 PHP 里调用方式就是普通函数native_add(3, 5)完全感知不到 C 层的存在。FFI 这边按照 FFI 的标准用法写了一个 C 声明定义$ffi FFI::cdef( int native_add(int a, int b);, libdemo.so );这里有个细节FFI 的调用对象$ffi是一个可复用的实例但测试时不能把它定义在循环内部否则每次循环都要重新解析 C 符号制造出无关的额外开销。我把 FFI 实例定义在全局循环只负责调用。字符串处理场景的 FFI 定义稍微复杂一点$ffi FFI::cdef( size_t strlen(const char *s);, libc.so.6 );PHP 字符串要传给const char *FFI 会自动把 PHP 的 zend_string 转换成 C 字符串指针。但这个转换过程是有代价的正是我想测的东西。3. 实测结果与数据解读3.1 调用开销FFI 的过路费到底有多贵先看最基础的空函数和简单加法测试。这两个数据是所有后续分析的地基因为它们反映的是PHP 调用 C 代码这个动作本身的成本。场景原生扩展耗时ns/次FFI 耗时ns/次倍数空函数624016.5x整数加法2 参数845126.1x整数加法4 参数976386.6xdouble 乘法1107206.5x这个结果符合我之前的预期。FFI 走的是解释 C 声明 - 查动态符号表 - 转换参数 - 调用 libffi 的闭包机制 - 转换返回值每一步都有额外开销。原生扩展走的是 Zend 引擎原生的调用约定参数解析用的是zend_parse_parameters本质上就是几个宏展开快是正常的。有一个值得注意的细节参数个数增加时FFI 的耗时增幅比原生扩展更明显。4 参数加法比 2 参数加法原生扩展只多了 13nsFFI 却多了 126ns。这说明 FFI 对每个参数都要单独做类型检查、内存布局转换和错误处理参数数量对 FFI 的性能呈近似线性影响对原生扩展的影响则小得多。如果你的业务代码里经常调用 C 函数且参数很多FFI 的性能劣势会进一步放大。我当时专门试过一次传 8 个参数给一个综合计算函数FFI 已经接近 1.1us原生扩展只要 130ns差距拉到 8 倍以上。3.2 字符串处理类型转换的隐形代价字符串是 PHP 里最常用的数据类型所以专门测了一组字符串相关操作。测试内容是把一个 256 字节的字符串传给 C 函数分别计算长度和做大小写转换。场景原生扩展耗时ns/次FFI 耗时ns/次倍数strlen256B915876.4xstrtoupper256B1588435.3xstrtoupper64KB48613252.7x这里有一个很有意思的现象随着字符串长度增加FFI 和原生扩展的差距倍数在缩小。strlen 差了 6.4 倍64KB 的 strtoupper 只差 2.7 倍。原因并不复杂。FFI 把 PHP 字符串转成 C 指针对小字符串来说转换的固定开销占了大头当字符串变长时C 函数内部遍历字符串的时间开始占主导调用层的固定开销反而不那么显眼了。这个趋势说明FFI 的劣势主要在调用那一瞬间的开销如果 C 函数本身的执行时间足够长FFI 的调用开销会被摊薄。这对我后来的选型启发很大如果业务里要调用的 C 函数是处理大块数据、执行时间在微秒级别以上的FFI 的调用开销占总时间的比例会降到可以接受的范围如果只是频繁调用一些轻量 C 函数FFI 的性价比就很差了。3.3 计算密集型场景躺着让 C 干活差距有多大第三组测试是圈子里争论最多的场景把热循环整个下沉到 C 函数内部PHP 只发起一次调用。我让 C 函数内部做 100 万次整数累加然后返回最终结果。场景原生扩展耗时ms/次FFI 耗时ms/次倍数100 万次累加C 内循环2.032.271.1x100 万次累加PHP 循环调 C87.4损坏超时不可比第一行数据说明我的判断是对的当调用次数只有一次的时候FFI 的调用开销确实可以忽略。2.27ms 和 2.03ms 的差距大约 0.24ms这个差距主要就是一次 FFI 调用的固定成本在 100 万次循环面前基本不算什么。第二行数据更有意思。我把循环放在 PHP 层每次循环调一次 C 函数原生扩展总共耗时 87ms平均每次调用约 87ns和第一组测试吻合。FFI 那边我没等到它跑完——按每次调用 500ns 估算100 万次大概是 500ms 以上加上 PHP 层循环本身的解释执行开销估计总耗时奔着秒级去了。这组对比才是真正让人警醒的数据如果在 PHP 循环里反复调用 FFI 封装的小函数性能会非常难看。实际业务里很少有人会为了一次运算单独调 C 函数更多场景是大数组过滤、批量变换、大量独立小计算。这类需求如果放 PHP 循环里做FFI 完全不是原生扩展的对手唯一的出路是把整个循环也写进 C 函数让 PHP 只做一次调用。3.4 内存操作FFI 唯一的亮点场景除了上面三类我还额外测了一组内存操作场景分配一块 1MB 的内存用 C 函数 memset 清零再释放。这个场景对 FFI 来说非常特殊因为它可以用FFI::new()直接分配 C 内存绕开 PHP 的 zval 机制数据不需要在 C 和 PHP 之间做转换。场景原生扩展耗时us/次FFI 耗时us/次倍数malloc memset free1MB2142261.06x连续 10 次重复操作209023201.11x这里 FFI 和原生扩展的差距已经缩小到误差范围内了。原因在于整个操作过程中PHP 和 C 之间的数据交换非常少只有最终一个状态值传回来调用开销只占总量很小的一部分。不过我要泼一盆冷水内存操作场景虽然 FFI 表现不差但大多数业务根本不需要 PHP 直接操作 C 内存。如果只是为了处理大字符串或者大数组PHP 自带的函数和原生扩展做得更好没必要为了能用 FFI而用 FFI。FFI 在这类场景的价值主要是给那些确实需要精细控制内存布局的人比如对接某些特殊 C 库时才用得上。4. 数据背后FFI 慢在哪些环节4.1 每次调用都要过五关斩六将从前面几组数据来看FFI 和原生扩展的差距不是单一原因造成的。我翻了 FFI 在 PHP 源码里的实现之后把开销来源拆成了三个部分第一部分是符号解析。FFI 实例化的时候FFI::cdef()会解析你写的 C 声明并用dlopen加载动态库。即使实例已经创建好了每次调用时 libffi 仍然需要根据预先定义的调用约定把参数整理成统一的 C ABI 格式。这个过程涉及结构体填充、对齐处理、类型转换哪怕只是两个整数相加也要走一遍完整流程。第二部分是参数类型检查与转换。PHP 是弱类型语言FFI 无法假设传入的参数已经是 C 层所期望的类型所以每个参数都要检查是否为标量、是否需要隐式转换比如 int 和 float 之间的转换、是否需要将 PHP 字符串对象解包为 C 指针。原生扩展里你可以直接用ZEND_PARSE_PARAMETERS声明期望的类型如果类型匹配引擎直接帮你取底层值不匹配立刻报错整个转换路径非常短。第三部分是返回值处理。C 函数返回的是一个裸的 C 数值或指针FFI 必须把它包装成 PHP 的 zval 才能交给 PHP 代码使用。这个包装过程对某些类型比如结构体、指针数组来说不只是包一层还涉及内存拷贝和生命周期管理一旦出现复杂返回类型开销会指数级上涨。我实测过返回结构体指针的场景FFI 耗时直接翻了 3 倍左右。因为 FFI 默认会按值拷贝返回的结构体而不是传引用这会让原本一次函数调用附带一次结构体深拷贝。注意这不是说 FFI 的实现代码写得差而是动态调用外部库这个能力本身就是有固有成本的。libffi 的设计目标是支持各种语言运行时调用 C 函数通用性必然带来性能让步。原生扩展则是针对 Zend 引擎定制的自然能做得更快。4.2 FFI 为什么在某些场景下又不慢前面看到 100 万次 C 内循环场景里FFI 只比原生扩展慢了 10% 左右。这并不矛盾亏在过路费但赚在单程票。当 C 函数执行时间足够长比如内部真的在做大量计算、遍历大数组、处理大数据块FFI 的固定调用开销相对于总执行时间来说就只是零头。这就像你出门打车如果去三公里外的超市起步价和每公里费用都很重要如果是去机场跑三十公里高速那起步价那十几块钱就无所谓了主要看高速费。C 函数内部的计算就是高速费FFI 调用开销就是起步价。从 benchmark 数据来看分界线大约是单次调用让 C 函数执行 5 微秒以上FFI 和原生扩展的差距就降到 20% 以内。所以如果你要封装的 C 库单个 API 本身就是重量级操作FFI 完全可以接受。4.3 这不是 FFI 独有的问题PHP 用户函数调用也慢聊到调用开销必须提一个容易忽略的事实PHP 用户态函数调用本身也不便宜。我顺手测了一下纯 PHP 的空函数调用大约是 250ns/次比 FFI 空函数调用的 401ns 只快四成。换句话说FFI 慢是慢但它慢得不算离谱——比起 PHP 解释器的常规操作FFI 的开销还在可接受范围内。这个结论的意义在于如果你的业务里有一个性能瓶颈是因为反复调用某个 PHP 自定义函数造成的那么换成 FFI 并不会带来数量级的改善。该写的优化还是得写把热循环下沉到 C 层的收益不在于调用方式本身而在于循环逻辑不再由 PHP 解释器执行。5. 常见的坑和排查思路5.1 FFI 没有生效先检查这三个配置我第一次跑 FFI 测试时函数一调用就抛异常FFI::cdef(): must not be called from user space when FFI is disabled。这个错误提示很直白但网上很多人照抄配置文件仍没解决是因为漏掉了两个关键点。ffi.enablepreload和ffi.enable1的作用范围不同。preload模式下仅允许在 opcache.preload 脚本里调用FFI::cdef()而业务代码里用FFI::scope()获取预定义实例1模式下才允许在业务代码里直接调用FFI::cdef()。benchmark 这种测试脚本用ffi.enable1就够了生产环境才需要考虑用 preload 模式把 FFI 实例预加载。修改 php.ini 之后要确认php -m | grep FFI能输出 FFI 模块同时用php -i | grep ffi检查实际生效值。有时候 CLI 和 FPM 用的是不同的配置文件改了半天 FPM 那边根本没生效。64 位系统上确保 PHP 编译时没有禁用 FFI编译参数--without-ffi否则 FFI 扩展连加载都加载不上。这个检查在php -m里看得到。opcache.preload 方式我后来试了一把确实能减少运行时解析的开销但收益主要在前几个微秒对长耗时 C 函数的帮助可忽略不计。benchmark 追求的是场景差异不是极端优化所以正式测试统一用ffi.enable1保持最简单、最容易复现。5.2 段错误和内存泄漏FFI 的低级错误很致命FFI 最大的风险不是性能而是内存安全。你在 C 代码里犯的错会直接崩掉 PHP 进程而不是抛出异常。我实测中踩过三个典型坑声明了char *但忘了分配空间直接往里面写数据结果 PHP-FPM 直接段错误退出日志里只有Segmentation fault排查看半天。返回值类型声明错误。C 函数返回char *但 FFI 声明写成了int导致指针被截断后续使用全部异常。FFI 不会帮你做兼容性检查它完全信任你写的声明。用FFI::new()分配的内存C 函数内部没有释放PHP 层也没调用FFI::free()导致内存泄漏。PHP 请求结束之后这个内存不会自动回收因为它不在 Zend 引擎的内存管理范围内。对比之下原生扩展只要写好扩展的RINIT、RSHUTDOWN和 GINIT、GSHUTDOWN 钩子内存分配和释放都在框架约束下进行很少出现这种裸奔式问题。对于线上环境稳定性要求高的团队这个差异可能比性能更值得关注。提醒使用 FFI 前应该在测试环境里跑完整的功能测试并配合 valgrind 检测内存问题。我给 FFI 写的第一版封装就靠 valgrind 抓到了两个内存泄漏点不跑这种工具的话问题可能要上线后才会爆出来。5.3 benchmark 结果不稳定可能是你没控制变量很多朋友照着网上的 benchmark 脚本跑经常发现这次 FFI 慢 10 倍下次慢 3 倍怀疑数据不可靠。我统计过的因素里影响最大的几个按权重排序是CPU 频率调度用 turbo boost 会大幅拉高 native 扩展的速度FFI 差距变大循环内部是否复用了 FFI 实例每轮循环都FFI::cdef()会把解析开销算进去数据会崩是否启用了 opcacheJIT 和 opcache 对用户态循环有优化会改变基准值参数值本身整数加法用大数和小数没有区别但如果涉及浮点数和字符串数据波动会很大控制方法不复杂CPU 频率锁定、设置进程亲和性、循环预热后计时、50 次执行取中位数。这些操作做下来我最终的测试数据在重复三次跑的情况下误差控制在 5% 以内足够支撑文章里的结论。6. 最终选型建议和我的经验总结如果你只是想在 PHP 里快速调用一个现有的 C 库并且不太在意那几倍的性能差距FFI 是一个很务实的方案——它省去了编写原生扩展时最麻烦的编译流程和 PHP 版本适配问题。但如果你的目标是追求极致的执行效率、要扛高并发或者需要在 C 层做非常频繁的细粒度调用原生扩展依然是不二之选。我自己的判断标准是看每次调用的 C 函数执行时间。按前面实测的经验值大致划分单次调用执行时间 1us优先原生扩展FFI 的开销占大头可能慢 6 倍以上单次调用执行时间 1us ~ 5us两者差距在 20%~50%可以接受 FFI 但要谨慎单次调用执行时间 5usFFI 完全可以接受收益是开发效率大幅提高不用碰 PHP 扩展编译链一次性处理大块数据ms 级FFI 和原生扩展几乎没有区别。最后分享一个我后来一直在用的折中方案业务里优先用 FFI 快速验证 C 库性能和功能验证通过之后再把高频路径的接口封装成原生扩展。这样既拿到了 FFI 的开发速度和灵活性又保住了原生扩展的性能和稳定性。我做过一个音频处理模块最初用 FFI 调一个 C 音频解码库确认算法和数据流没问题后只把最热的 decode 函数改成了原生扩展整体性能提升了约 30%而开发成本比直接写完整扩展少了一半。FFFFi 的定位从来都不是替代原生扩展而是更低门槛地接入 C 生态。想明白这一点选型就不难了。