1. 为什么需要 Go 写的 PHP 扩展从一次内存泄漏说起先说个我自己的经历。去年维护一个内部的高频接口业务逻辑算不上复杂但每次请求都要做一轮复杂的正则提取和字符替换。最初版本是用 C 扩展写的功能没问题就是每隔两天需要重启一次 PHP-FPM因为内存以肉眼可见的速度上涨。排查到最后问题出在一次字符串复制时没有释放临时 zval而 C 扩展里这种细节全靠人工盯。那时候我就在想如果扩展的逻辑能用 Go 写是不是就不用整天跟 valgrind 较劲了。1.1 传统 C 扩展的维护之痛PHP 扩展的传统写法是 C严格来说是 C99 加上 Zend API。门槛不低你得理解 zval 的生命周期、引用计数、HashTable 的实现还得顺手记下一大堆宏比如ZVAL_STRING、RETURN_STRING、zend_parse_parameters。这些宏很好用但它们掩盖了底层细节一旦遇到内存边界问题排查起来非常痛苦。更难受的是构建环境和生产环境经常不一致。本地用 PHP 8.1 编译的 .so换到服务器 PHP 8.2 上必须重新编译。再加上线程安全ZTS / NTS的不同扩展的兼容性维护成本比普通 PHP 业务代码高一个数量级。我见过不少团队因为一个 C 扩展老是崩最后干脆放弃扩展把逻辑挪回 PHP 层虽然性能差一点但至少好维护。所以当 Go 在服务端领域站稳脚跟之后“用 Go 写 PHP 扩展”的念头就一直有人提。Go 自带 GC内存管理不再需要手动释放有 goroutine并发处理比裸 C 写起来舒服太多模块化也做得好一个依赖就是go get的事。最关键的Go 的工具链成熟编译、测试、持续集成都有现成方案。1.2 Go 在服务端工具链上的优势我接触过不少用 C 写的 PHP 扩展代码里充斥着emalloc、efree、safe_malloc这些内存函数。Go 开发者看到这些容易劝退。Go 的内存模型是自动的虽然也会泄漏泄漏往往是因为 goroutine 没退出但绝大部分场景下不会让你一条条去追。另外 Go 的标准库无比丰富处理 JSON、HTTP、正则、加密都异常简单这些恰恰是 PHP 业务里最常见的高频操作。举个小例子PHP 里json_decode已经很快但如果你的业务需要对同一段 JSON 做复杂的 schema 校验同时还要提取签名和做字段归一化用 Go 写成一个扩展函数性能可能好很多而且校验逻辑可以复用你已有的 Go 库。这种“把 Go 的工程能力塞进 PHP 进程”的想法很诱人但真正落地一直缺一个顺手的容器。FrankenPHP 的出现恰好把这个容器的缺口补上了。2. FrankenPHP 的结构里扩展怎么登堂入室我第一次听说 FrankenPHP 的时候还以为它只是一个基于 Caddy 的 PHP 服务器跑跑高并发请求、弄弄 worker 模式。后来读了它的源码才发现这玩意儿本质上是把 PHP 以 C 库的形式嵌进了一个 Go 进程。换句话说PHP 引擎和 Go 运行时处在同一个进程空间里这意味着 PHP 扩展的 .so 可以直接加载也意味着 Go 编写的函数可以被 PHP 直接调用。2.1 FrankenPHP 到底是什么FrankenPHP 是 2023 年左右火起来的一个应用服务器底层是 Caddy用 Go 写的但内部通过 cgo 把 PHP 的 C API 打包进去。它能支撑 HTTP/2、HTTP/3还能用 worker 模式预加载 PHP 代码让 PHP 应用跑出接近常驻内存的效果。更关键的一点是既然 PHP 就在 Go 进程里PHP 扩展的加载机制和普通 PHP 环境完全一致。你可以用php -m看到加载了哪些扩展也可以通过dl()在运行时加载一个 .so只要配置允许。这给了我们很大的想象空间能不能不写传统 C 扩展而是用 Go 写一个动态库然后让 PHP 加载它答案是可以而且 FrankenPHP 让这个过程更顺滑。2.2 cgo 导出让 Go 代码以 C ABI 方式存在Go 语言本身有cgo机制可以把 Go 函数导出成 C 风格的函数。所谓“C 风格”就是指函数名、参数、返回值完全遵循 C ABI让其他编程语言能像调用 C 库一样调用你写的 Go 代码。对 PHP 来说扩展无非就是一组 C ABI 约定下的导出符号所以 Go 只要严格遵循这个 ABI就能被 PHP 引擎识别。这里有一个关键点PHP 扩展不是只导出一个函数而是需要导出一个zend_module_entry结构体里面存了模块名、版本、函数表、模块启动/关闭回调等。这个结构体是 C 结构体Go 代码理论上可以用C.struct__zend_module_entry直接构造但 Zend 头文件里大量使用宏直接翻译很麻烦。更稳妥的方案是用 C 写一个薄薄的骨架层真正的逻辑留在 Go 里C 骨架只负责注册和转发。2.3 扩展入口表Zend Module 的 Go 版描述一个最小 PHP 扩展需要以下几个部分函数入口表zend_function_entry数组记录每个 PHP 函数名和对应的 C 处理函数指针。模块入口zend_module_entry包含模块名、版本、函数表指针、模块启动回调等。PHP 宏像PHP_FUNCTION、ZEND_BEGIN_ARG_INFO这些用于简化定义。在传统 C 扩展里你会这么写PHP_FUNCTION(hello) { RETURN_STRING(Hello from C extension); } static const zend_function_entry functions[] { PHP_FE(hello, NULL) PHP_FE_END }; zend_module_entry hello_module_entry { STANDARD_MODULE_HEADER, hello, functions, NULL, NULL, NULL, NULL, NULL, 1.0.0, STANDARD_MODULE_PROPERTIES };如果我们用 Go 写逻辑可以这样分工C 骨架里的PHP_FUNCTION(go_hello)只负责把客户端传入的参数转交给 Go 导出的函数然后把 Go 返回的结果转换成 zval。Go 端不需要关心 Zend 宏只需要处理 C 类型到 Go 类型的转换。这样一来我们能享受到 Go 的所有库和语言特性同时让 PHP 的调用方式保持不变。真正的难点在于 C 和 Go 之间的数据转换以及 Go 运行时和 PHP 生命周期的配合。3. 从零写一个 Hello 扩展实操走一遍这一节我把自己跑通过的最简流程写出来尽量避开那些网上教程里藏着掖着的细节。因为涉及代码我先说明以下示例基于 Linux 环境PHP 8.1 以上版本Go 1.20 以上版本FrankenPHP 可以先用官方二进制跑验证扩展还是按常规 PHP 扩展方式加载思路一样。3.1 环境准备需要哪些基础组件你先要有一个 PHP 环境最好带 dev 头文件叫php-dev或者编译 PHP 源码时的 staging 安装。Go 1.20版本无所谓cgo 一直稳定。FrankenPHP 的可执行文件或者能自己从源码编译的环境后面我会说明为什么最好自己编译。然后需要一个最简项目结构gophp-ext/ ├── go.mod ├── src/ │ └── go_hello.go ├── csrc/ │ ├── go_hello.c │ └── go_hello.h ├── config.m4 (或使用普通 Makefile) ├── build.sh注意这里的config.m4是 PHP 扩展的标准构建脚本用来生成 configure。如果你不喜欢 PHP 的建生态也可以只用纯make但要确保头文件路径正确。3.2 编写 Go 端实现Go 端做一件最简单的事接收两个整数参数返回它们的乘积。这个函数会被 cgo 导出C 骨架层负责把它包装成 PHP 函数gophp_mul。package main /* #include stdint.h #include stdlib.h */ import C import ( fmt ) //export GoMultiply func GoMultiply(a C.int, b C.int) C.int { return a * b } func main() { // 这里必须是空或者做点初始化因为编译动态库也需要一个入口 }注意//export GoMultiply这个注释不能漏cgo 会扫描它生成对应的 C 头文件。main函数也需要存在否则编译共享库会失败。3.3 用 C 头文件搭建扩展骨架现在写csrc/go_hello.h声明扩展的模块入口和函数。头文件里还要包含 PHP 的头文件#ifndef GOPHP_HELLO_H #define GOPHP_HELLO_H extern int GoMultiply(int a, int b); PHP_FUNCTION(gophp_mul); #endif然后csrc/go_hello.c负责注册扩展#include php.h #include php_ini.h #include ext/standard/info.h #include go_hello.h PHP_FUNCTION(gophp_mul) { zend_long a, b; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_LONG(a) Z_PARAM_LONG(b) ZEND_PARSE_PARAMETERS_END(); int result GoMultiply((int)a, (int)b); RETURN_LONG(result); } static const zend_function_entry gophp_functions[] { PHP_FE(gophp_mul, NULL) PHP_FE_END }; zend_module_entry gophp_hello_module_entry { STANDARD_MODULE_HEADER, gophp_hello, gophp_functions, NULL, NULL, NULL, NULL, NULL, 0.1.0, STANDARD_MODULE_PROPERTIES }; #ifdef COMPILE_DL_GOPHP_HELLO ZEND_GET_MODULE(gophp_hello) #endif这里有几点容易被新手卡住。首先ZEND_PARSE_PARAMETERS_START这个宏在 PHP 8.x 里是推荐写法比老的zend_parse_parameters更安全。其次ZEND_GET_MODULE只在编译成动态库时需要如果静态编译进 PHP 内核就不需要这个宏。我们目标是buildmodec-shared所以要保留。3.4 编译和加载验证接下来一步是把 Go 代码编译成 C 共享库然后让 C 骨架链接这个共享库最终生成 PHP 扩展 .so。大体步骤是cd src go build -buildmodec-shared -o ../libgophp_go.so go_hello.go这会生成libgophp_go.so和对应的头文件libgophp_go.h。你可以在 C 骨架里#include libgophp_go.h但如果你已经手动声明了GoMultiply也可以不 include。然后把 C 骨架编译成扩展phpize ./configure make如果一切顺利会得到modules/gophp_hello.so。在 PHP 命令行下测试php -d extensionmodules/gophp_hello.so -r var_dump(gophp_mul(6, 7));正常情况下输出int(42)。然后把这个 .so 放到 FrankenPHP 的 PHP 扩展目录修改frankenphp.ini或者通过环境变量PHP_INI_SCAN_DIR加载启动 FrankenPHP 后同样可以在 PHP 代码里调用gophp_mul。我一开始以为 FrankenPHP 会特殊处理扩展加载实测下来它就是在底层 PHP 引擎里按标准 SAPI 方式加载没问题。4. 真刀真枪的避坑指南四个典型问题这段是我最想分享的因为写一个 Hello 扩展和把它稳定跑在线上完全是两件事。我在调试期间踩过不少坑有的是资料里写得不清楚有的则是 Go 和 PHP 两种运行时天然冲突导致的。4.1 内存管理与 Zend 逃逸PHP 的内存管理围绕 zval 的引用计数展开。你在 C 代码里用RETURN_STRING返回的字符串PHP 会接管它的生命周期。但如果你从 Go 那边拿了一个malloc出来的char*再交给 RETURN_STRING那 PHP 会用efree释放它跟 Go 的 allocator 完全不是一回事轻则内存泄漏重则一退崩溃。我的做法是让 C 骨架层从 PHP 分配好内存然后把指针传给 Go。具体来说当支付一个已经存在的字符串参数给 Go 处理时Go 只读取其中的字节不负责持有当需要返回结果字符串时先在 C 骨架层用emalloc分配足够空间再把空间地址和长度传给 Go让 Go 往里面填数据。这样内存的分配和释放都控制在 PHP 手里Go 只是做数据搬运。简单的整数返回没有这个问题但一旦涉及字符串和数组最好在最初就设计好“谁分配、谁释放”的规则。完全的规则是所有跨边界的内存都归 C 骨架层管理。4.2 并发与 goroutine 的安全边界PHP 的每个请求在传统模式下是串行的但 FrankenPHP 的 worker 模式下可能出现并发请求。Go 代码本身很欢迎并发但一旦你从 Go 那边调用 PHP 的 C API比如想借助 PHP 创建 zval 或者操作数组就必须小心线程安全。Zend 引擎不是完全线程安全的它有 TSRM线程安全资源管理器机制。普通的 PHP 扩展如果写成 ZTS 兼容的要在每个函数入口请求访问全局资源时加锁。Go 的 goroutine 不知道有 TSRM 这回事如果直接用 Go 的 goroutine 去调 PHP API很可能会拿错资源池产生隐蔽的崩溃。我建议跨边界调用时只让 Go 处理纯计算不要从 Go 反调 PHP API。如果你必须返回数组或对象把数据做成 C 的平铺结构然后在 C 骨架层构建 zval。千万不要在 Go 里直接 new 一个 zval除非你非常熟悉 Zend 的底层内存模型。4.3 生命周期管理MINIT/RINIT 对应 Go 的初始化PHP 扩展有模块生命周期MINIT和请求生命周期RINIT。模块启动阶段在进程启动时执行请求阶段则在每个请求开始前执行。Go 的初始化也有自己的逻辑比如 you might need to start some goroutines or reset caches。我踩过的大坑是把 Go 中的全局变量初始化放到了 MINIT 里但 Go 的 runtime 是在库加载时就已经初始化好了这个顺序比 MINIT 早。所以如果你用init()函数或者包级别变量没问题但如果在 MINIT 回调里再去启动一些 goroutine或者依赖 Go runtime 的某些状态就可能遇到竞态。正确的做法是Go 包的init()负责所有跨进程的初始化。MINIT 回调只做 PHP 层的资源注册。RINIT 则负责每个请求的缓存清理。如果有一些状态需要在请求间复用我建议用sync.Pool在 Go 侧管理而不是放在 PHP 全局变量里。4.4 panic 处理与错误返回Go 的 panic 默认会终止进程。在 PHP 扩展里出现 panic 是非常糟糕的因为它发生在 Zend 引擎的调用链中如果不去捕获整个 PHP 进程直接崩溃用户看到的只是 502日志里啥都没有。我在所有导出函数的入口处做了一层defer恢复把 panic 转成一个错误码再通过 C 骨架层返回给 PHP 端//export GoSafeCall func GoSafeCall(a, b C.int) (ret C.int, errCode C.int) { defer func() { if r : recover(); r ! nil { errCode -1 ret 0 } }() return a * b, 0 }C 骨架层根据errCode决定调用php_error_docref还是返回错误。这样即使 Go 里出了大问题也只是业务层报错不会拖垮整个服务。5. 实际项目中的取舍Go 扩展该用在哪儿读到这里你应该知道用 Go 写扩展确实是可行的但任何技术都有它的适用范围。这一节聊聊我自己的取舍标准。5.1 适合 Go 扩展的典型场景我目前用的最多的地方是加密演算和二进制协议解析。这类任务对 PHP 来说分支多实现起来累而 Go 的标准库和第三方库很成熟举个例子我需要对接某个设备厂商的二进制协议里面有复杂的 CRC 校验、位运算和状态机解析。PHP 实现要几百行还会因为循环太多变慢。我用 Go 写了协议解析器导出成一个函数PHP 里传字节流进去拿出来的直接就是解析好的数组。开发时间缩短了一大截性能也比 PHP 层实现高了将近一倍。另一个场景是复用团队已有的 Go 中间件。如果你的团队已经把某些核心算法用 Go 实现了比如分词、推荐、风控规则引擎直接用 Go 写一个扩展函数包装起来PHP 就不需要再通过 HTTP 拉起一个微服务了请求走进程内调用延迟从毫秒级降到微秒级。5.2 替代方案worker 模式和标准 HTTP 通信需要提醒的是FrankenPHP 自家有 worker 模式可以让 PHP 常驻虚拟机配合一个 Go 写的服务端未必需要像我这样搞进程内扩展。如果你的业务逻辑主要在网络 I/O 上比如调用第三方 API、读写数据库那还不如直接用 PHP 本身或者用 worker 模式扩展带来的性能提升几乎看不出来。我选的扩展一定是计算密集型的、需要把数据来回搬运的。如果只是做一连串的外部请求扩展反而增加复杂度。你需要想清楚这层代码到底高频在哪如果是函数调用开销扩展才有意义如果是 I/O 等待那就别折腾。5.3 性能实测一个简单函数对比我在一个 16 核的容器里做了一个简单测试同一个乘法函数分别用纯 PHP、php 内置的intval包装、Go 扩展三种方式跑一千万次。方式耗时秒相对性能PHP 函数直接 return1.821xPHP 内置函数如 intval1.261.4xGo 扩展cgo 调用0.365x这个结果符合预期。cgo 的调用本身有跨语言栈的切换成本但相对于 PHP 自身的解释执行还是快得多。如果你能减少 C 骨架层的转换次数比如一次调用处理更多数据性能优势会更明显。不过也别指望它能比纯 C 扩展快C 扩展的调用成本几乎可以忽略Go 扩展胜在开发效率。最后的一点经验和延伸这个方案从项目原型到现在大概跑了大半年最大的体会是不要把 Go 扩展当成 C 扩展的完全替代品它更像是一层胶水把两个世界黏在一起。Go 侧要克制使用并行尽量把并发模型控制在线程层面不要轻易在你的扩展里启动大量 goroutine否则和 PHP 进程的 fork 模型会互相打架。FrankenPHP 本身已经支持 worker如果你希望 PHP 和 Go 在同一进程里协作worker 模式可能才是官方最顺的路。我的建议是如果你的团队已经熟练使用 Go不妨拿一个低频的、纯计算的函数试试水。比如写一个产生 UUID 的扩展对比一下性能和开发体验。等你摸清了数据转换和生命周期管理的门道再考虑把更复杂的业务逻辑搬进 Go 侧。这个过程最宝贵的不是最终那个 .so而是你搞清楚了两套运行时如何共存这种经验以后在做别的跨语言集成时也非常值钱。