Yaconf(持久化配置容器)=源码级全面解析
·
四、Yaconf(持久化配置容器)
**GitHub:** https://github.com/laruence/yaconf
### 完整标题
1. Yaconf源码全景:一个持久化PHP配置容器的C实现
2. Yaconf扩展入口yaconf.c:MINIT阶段的INI文件扫描与解析
3. Yaconf配置解析:INI文件到zval的C语言转换
4. Yaconf持久化机制:IS_IMMUTABLE_ARRAY——PHP7新增的不可变数组类型怎么用
5. Yaconf目录监听:文件变更检测的C实现
6. Yaconf命名空间:多配置文件的隔离与合并
7. Yaconf类型推断:INI值自动识别为int/float/string/array的C实现
8. Yaconf与OPcache的关系:持久化配置为什么不会被OPcache清掉
9. Yaconf DeepSeek优化:compact存储——随机读快40%、内存省16%的实现
10. Yaconf的git事故:AI改代码丢了核心代码——教训与反思
### 写到最深的标题
11. Yaconf的IS_IMMUTABLE_ARRAY实现:Zend引擎层面的不可变zval如何跨请求持久化
12. Yaconf内存映射:mmap MAP_SHARED在PHP生命周期中的生命周期管理
13. Yaconf的INI解析器:手写递归下降解析器 vs PHP内置php_ini_scanner的差异
14. Yaconf compact存储:哈希表压缩算法的C实现与随机读性能提升的底层原因
15. Yaconf与php_module_startup的时序:为什么配置必须在模块初始化阶段加载.完整代码 完整流程
全部大白话解释。基于以上链接源码搜索来做。为什么选择这个方案而不是别的?2.这个方案的trade-off是什么?
> 3.如果让我重新设计,
0. 全景:这是什么,为什么值得读
Yaconf 要解决的问题一句话:PHP 每次请求都要 parse_ini_file() 读一遍配置。高并发下,100 个 FPM
worker、每秒几千请求,同样的 INI 文件被反复磁盘读取、反复解析、反复 emalloc,全是重复劳动。
Laruence 的方案:让 PHP 进程只解析一次,结果用一个"永久存活"的数据结构常驻内存(不随请求结束释放),之后每个请求
Yaconf::get("db.host") 就是一次纯哈希查找,零拷贝返回。
整个扩展只有 1 个 C 文件、1345 行。核心就这几块:
┌────────────────┬─────────────────────────────────────┬───────────────────────────────────────────┐
│ 模块 │ 代码位置 │ 一句话 │
├────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
│ 不可变数组创建 │ php_yaconf_hash_init (142-176) │ 造一个引擎"不敢动"的 HashTable │
├────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
│ INI 解析 │ php_yaconf_parse_ini_file (519-551) │ 调引擎的 zend_parse_ini_file + 自定义回调 │
├────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
│ 目录扫描 │ php_yaconf_scan_directory (658-713) │ 递归扫目录,子目录变成命名空间 │
├────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
│ 紧凑块压缩 │ yaconf_compact (910-1027) │ 把所有配置塞进一块连续内存 │
├────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
│ 热重载 │ PHP_RINIT_FUNCTION (1207-1233) │ 文件 mtime 变了就重扫 │
├────────────────┼─────────────────────────────────────┼───────────────────────────────────────────┤
│ 对外 API │ PHP_METHOD(yaconf, get) (1040-1059) │ Yaconf::get() 纯查找零拷贝 │
└────────────────┴─────────────────────────────────────┴───────────────────────────────────────────┘
先说最大的一个设计决定:为什么配置数据能跨请求活下来?
PHP 的内存模型是"请求生命周期内存"(emalloc)随请求结束清空,只有 pemalloc(持久内存)能活。Yaconf 的做法是——MINIT
阶段(模块初始化,进程启动时只跑一次)解析全部配置,所有数据用 pemalloc 分配,然后用引擎的 IS_ARRAY_IMMUTABLE
标志给这些数组上了"永久封印"。这个"封印"是全篇的钥匙,后面反复出现。
---
话题 1:Yaconf 源码全景——一个持久化PHP 配置容器的 C 实现
完整流程(进程一生)
php -S / php-fpm master 进程启动
│
├─ MINIT(只执行一次)
│ │
│ ├─ 1. REGISTER_INI_ENTRIES:读取 yaconf.directory / yaconf.check_delay
│ │
│ ├─ 2. stat 配置根目录,记录 mtime
│ │
│ ├─ 3. 阶段一:yaconf_parse_persistent=0
│ │ php_yaconf_scan_directory 递归扫目录
│ │ ├─ 每个 .ini 文件 →zend_parse_ini_file 解析
│ │ └─ 结果存进 ini_containers(临时内存 emalloc)
│ │
│ └─ 4. 阶段二:yaconf_parse_persistent=1
│ yaconf_compact():把整棵树压缩进一块 pemalloc 块
│
├─ 每个请求(worker)
│ ├─ RINIT(仅非 ZTS):check_delay 节流 →目录 mtime 变了 →重扫重压
│ │ └─ Yaconf::get("a.b.c") →php_yaconf_get →纯哈希查找,零拷贝返回
│ └─ RSHUTDOWN:配置不释放
│
└─ MSHUTDOWN(进程退出)
├─ yaconf_containers_destroy:逐个释放块外部分
└─ yaconf_compact_free:整块 pefree
关键代码:PHP_MINIT_FUNCTION(yaconf) (1153-1201)
PHP_MINIT_FUNCTION(yaconf)
{
REGISTER_INI_ENTRIES();
yaconf_globals.directory = ...;
/* 记录根目录 mtime,热重载用 */
if (VCWD_STAT(YACONF_G(directory), &st) == 0) {
YACONF_G(directory_mtime) = st.st_mtime; /* 1205 附近 */
}
/* 阶段一:临时内存解析整棵树 */
yaconf_parse_persistent = 0;
php_yaconf_scan_directory(YACONF_G(directory), NULL, NULL);
/* 阶段二:压缩进持久块 */
yaconf_parse_persistent = 1;
yaconf_compact();
...
}
全局数据结构(第 41-51 行)
HashTable *ini_containers; // 所有配置的根:名字空间名 →配置数组
HashTable *parsed_ini_files; // 文件名 →yaconf_filenode{filename, mtime}
HashTable *parsed_ini_dirs; // 目录 →yaconf_dirnode{...}(仅非 ZTS)
int yaconf_parse_persistent; // 0=临时内存阶段 / 1=持久块阶段
char *yaconf_block; // 紧凑块起始地址
size_t yaconf_block_size; // 紧凑块大小
这六个变量就是 Yaconf 的全部"国家资产"。ini_containers 里每个 value 就是一个配置命名空间数组(比如 a.ini →["a" =>
[...]]),Yaconf::get("db.host") 就是 ini_containers["db"] →数组里找 host。
为什么 1345 行能装下一个完整的配置系统?
因为 Yaconf 把脏活全外包了:
- 解析:引擎自带的 zend_parse_ini_file(话题 13)
- 哈希表/内存管理:引擎的 zend_hash_* 全家桶 + pemalloc
- "永久"语义:引擎的 interned string + immutable array 机制
Yaconf 自己写的,只有三件事:把解析回调接到自己的树结构上、把整棵树压缩成一块内存、mtime
热重载。这就是"站在巨人的肩膀上"的教科书写法——自己写解析器、自己写哈希表才是1345 行装不下的。
---
话题 2:扩展入口 yaconf.c——MINI阶段的 INI 文件扫描与解析
完整流程
MINIT 阶段的扫描是两阶段中的第一段,入口是 php_yaconf_scan_directory:
php_yaconf_scan_directory(dir, prefix, cur_depth) // 658 行
│
├─ cur_depth >= 16 →警告返回 // 664 行:YACONF_MAX_DIR_DEPTH
│
├─ php_scandir(dir) →所有条目排序 // 668 行
│
└─ 遍历每个条目:
├─ 是目录 →递归 scan_directory(subdir, "目录名", depth+1)
│ // 子目录名成为命名空间键级
├─ 是 .ini 文件 →php_yaconf_handle_file(file, prefix) // 587 行
│ ├─ 同名目录已存在?→警告,目录胜出 // 593 行
│ ├─ parsed_ini_files 里有同名文件且 mtime 未变 →直接跳过 // 599 行
│ └─ php_yaconf_parse_ini_file(file) →存入 ini_containers
└─ 其他 →忽略
php_yaconf_handle_file (587-620) 里那个 mtime 检查是热重载的伏笔:
static int php_yaconf_handle_file(const char *filename, zend_string *name)
{
/* 同名目录已经解析过了?警告,目录胜出 */
if (php_yaconf_has_dir(name)) { ... E_WARNING ... return 0; }
yaconf_filenode *fn = zend_hash_find_ptr(parsed_ini_files, filename);
if (fn && fn->mtime == st.st_mtime) {
/* 文件没变,跳过 ——这是热重载时"没变的文件不重读"的依据 */
return 0;
}
...
}
子目录 = 命名空间的关键一行
php_yaconf_scan_directory 的递归参数里,子目录名作为 name 前缀传下去:
/* 目录 "sub" 下的 "x.ini" →键是 "sub.x" */
php_yaconf_scan_directory(new_dir, strpprintf(0, "%s%s", prefix ? : "", dent->d_name), depth + 1);
这就是 sub/x.ini 变成 Yaconf::get("sub.x.xxx") 的机制——目录结构直接映射到点分键。
冲突处理:文件 vs 同名目录
commit "Name conflict between a config file and a same-named directory is now a warning, the directory wins"
定的规则,在 593 行实现:
if (php_yaconf_has_dir(name)) {
php_error_docref(NULL, E_WARNING, "conflicts with a directory, ignored: %s", filename);
return 0;
}
为什么目录胜出?因为 handle_directory (622-656) 在扫描时先处理目录后处理文件(目录在 scandir
排序里更靠前),后到的文件发现键被占了就放弃。这个"后到者让位"规则简单、确定、可预期。
---
话题 3:配置解析——IN文件到 zval 的 C 语言转换
入口:php_yaconf_parse_ini_file (519-551)
static int php_yaconf_parse_ini_file(const char *filename, zval *result)
{
zend_file_handle fh;
...
if (php_stream_open_for_zend_ex(filename, &fh, USE_PATH | STREAM_OPEN_FOR_INCLUDE) != SUCCESS) {
return 0;
}
if (zend_parse_ini_file(&fh, 1 /*unprocessed*/, 0 /*scanner_mode*/, php_yaconf_ini_parser_cb, result) != SUCCESS)
{
zval_ptr_dtor(result); // 解析失败,清空结果
return 0;
}
return 1;
}
注意参数:unprocessed = 1,scanner_mode = 0 就是 ZEND_INI_SCANNER_NORMAL。这两件事决定了"类型推断"话题的真相,后面 7
号话题细说。
解析回调的两层结构
zend_parse_ini_file 会在解析到 section / 普通键值 / section 结束 时回调,回调分两个:
第一层 php_yaconf_ini_parser_cb (464-517):管 section
static void php_yaconf_ini_parser_cb(zval *arg1, zval *arg2, int callback_type, void *arg)
{
switch (callback_type) {
case ZEND_INI_PARSER_ENTRY: /* 普通键值对 */
php_yaconf_simple_parser_cb(arg1, arg2, callback_type, arg);
break;
case ZEND_INI_PARSER_SECTION: /* [section] 或 [child:parent] */
...
/* 486 行:冒号继承,最多 16 层 */
if (colon && php_yaconf_parse_section(cur_section, ...)) { ... }
break;
case ZEND_INI_PARSER_POP_ENTRY: /* section 结束 */
...
break;
}
}
第二层 php_yaconf_simple_parser_cb (367-462):管键和值,处理点号嵌套
/* 键带点号:a.b.c=1 →建 a →a.b →a.b.c */
if (zend_memrchr(key_str, '.', key_len)) {
php_yaconf_parse_nesting_key(zv, key_str, key_len, value);
} else {
zend_symtable_update(ht, ...); /* 无点号,直接存 */
}
点号嵌套 php_yaconf_parse_nesting_key (332-365)
static void php_yaconf_parse_nesting_key(zval *zv, const char *key, size_t len, zval *value)
{
char *p = (char *)zend_memrchr(key, '.', len); // 从右往左找点
...
/* 递归:先处理 a.b,再在 a.b 里挂 c */
php_yaconf_parse_nesting_key(zv, key, p - key, ...);
...
/* 层数上限 64 */
if (++depth > 64) { E_WARNING ... return; }
}
这是"a.b.c = 1 自动变成嵌套数组"的实现——不是正则,不是解析器,就是memrchr 找点 + 递归建数组。
foo : bar :: test 的 trim_key 语法 (319-330)
static char *php_yaconf_trim_key(char *key, size_t *len)
{
/* 找 " : " 定位,取后半段(:: 表示 "值里有冒号" 的转义) */
...
}
README 里说 foo : bar :: test 这种键里带冒号的值要写 :: 转义,Yaconf 在这里把 :: 还原成 :。注意:这个 trim 是在 zval
已经解析完之后对字符串做的后处理,不是 scanner 支持。
完整数据流
"db.host=127.0.0.1" (INI 文件里的字节)
│ zend_parse_ini_file(scan)
▼
[ZEND_INI_PARSER_ENTRY: key="db.host" value="127.0.0.1"] ←都是字符串
│ php_yaconf_ini_parser_cb →php_yaconf_simple_parser_cb
│ zend_memrchr 找到 '.' →php_yaconf_parse_nesting_key
▼
ini_containers["db"] = [ "host" => "127.0.0.1" ]
---
话题 4:持久化机制——IS_ARRAY_IMMUTABLE 这个"永久封印"怎么用
这是全篇技术含量最高的一块。先说结论:Yaconf 让配置数组"活"下来的核心,是给 HashTable 盖了三个章。
php_yaconf_hash_init (142-176)
static zval *php_yaconf_hash_init(void)
{
zval *zv = (zval *)pemalloc(sizeof(zval), 1); // 持久内存!
HashTable *ht = (HashTable *)pemalloc(sizeof(HashTable), 1);
zend_hash_init(ht, 16, NULL, NULL, 1); // 第 5 个参数 persistent=1
ZVAL_ARR(zv, ht);
/* ===== 三个章 ===== */
/* 章 1:refcount 直接设成 2 */
GC_SET_REFCOUNT(ht, 2);
/* 为什么是 2?因为引擎对返回给用户的临时值做 dtor 时,
会 refcount-- 到 0 就 free。设 2 保证至少有一次"借出去再还回来"
也不会把持久内存 free 掉。*/
/* 章 2:不可变标志(分版本) */
#if PHP_VERSION_ID >= 70300
Z_TYPE_FLAGS_P(zv) = 0;
GC_ADD_FLAGS(ht, IS_ARRAY_IMMUTABLE);
#elif PHP_VERSION_ID >= 70200
Z_TYPE_FLAGS_P(zv) = IS_TYPE_COPYABLE;
GC_ADD_FLAGS(ht, IS_ARRAY_IMMUTABLE);
#else
Z_TYPE_FLAGS_P(zv) = IS_TYPE_COPYABLE | IS_TYPE_IMMUTABLE;
#endif
/* 章 3:去掉写保护限制 + 静态键标志 */
HT_FLAGS(ht) &= ~HASH_FLAG_APPLY_PROTECTION;
HT_FLAGS(ht) |= HASH_FLAG_STATIC_KEYS;
...
return zv;
}
三个章各自管什么
┌───────────────────────┬───────────────────────────┬─────────────────────────────────────────────────────┐
│ 章 │ 作用 │ 效果 │
├───────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────┤
│ refcount = 2 │ 防止引擎把持久数组误 free │ ZVAL_ARR 给用户后,用户 dtor 一次,refcount 变 1,不死 │
├───────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────┤
│ IS_ARRAY_IMMUTABLE │ 告诉引擎"别动我" │ 任何写操作 →引擎先完整复制一份再改,原件永不变 │
├───────────────────────┼───────────────────────────┼─────────────────────────────────────────────────────┤
│ HASH_FLAG_STATIC_KEYS │ 键是永久 interned 字符串 │ 查找时引擎可跳过一些检查,且键永不释放 │
└───────────────────────┴───────────────────────────┴─────────────────────────────────────────────────────┘
引擎看到 IMMUTABLE 后干什么
当用户拿到配置数组后 $config['host'] = 'xxx' 时,引擎的 _zend_hash_index_update 等写路径会检查:
if (HT_FLAGS(ht) & HASH_FLAG_APPLY_PROTECTION) ...
if (UNEXPECTED(HT_IS_READONLY...)) ...
而 IS_ARRAY_IMMUTABLE 会让引擎走 zend_array_dup() / separate_zval
的完整复制路径——复制一份新的普通数组给用户改,持久原件纹丝不动。这就是
COW(写时复制)在引擎层的实现:读永远共享,写才分开。
为什么 Z_TYPE_FLAGS 要分三个版本?
PHP 7.0/7.1 里"不可变数组"的标志体系跟 7.2+ 不一样:
┌──────────┬──────────────────────────────────────────────────────────────────────────┐
│ PHP 版本 │ 做法 │
├──────────┼──────────────────────────────────────────────────────────────────────────┤
│ < 7.2 │ zval 的类型标志位要同时带 IS_TYPE_COPYABLE | IS_TYPE_IMMUTABLE │
├──────────┼──────────────────────────────────────────────────────────────────────────┤
│ 7.2 │ 只有 IS_TYPE_COPYABLE │
├──────────┼──────────────────────────────────────────────────────────────────────────┤
│ ≥7.3 │ Z_TYPE_FLAGS(zv) = 0(引擎重构了,flags 不再承担这个职责,标志全挪到 GC 上) │
└──────────┴──────────────────────────────────────────────────────────────────────────┘
同一份代码要跨 PHP 7.0 ~ 8.4 编译,所以三个 #if。这是 PHP 扩展最难的地方:不是写功能,是给 8 个 PHP 版本分别擦屁股。
---
话题 5:目录监听——文件变更检测的C 实现
真相:没有 inotify,只有 mtime 轮询
Yaconf 没有用 inotify/epoll 这种事件机制,而是每个请求开始时看一眼"根目录 mtime 变了没"。为什么?因为 FPM
是请求驱动的,没有"后台线程"可以常驻监听;最省事的方案就是在 RINIT 这个"每个请求必经之路"上做个便宜的检查。
三层节流
RINIT
│
├─ 层 1:check_delay 节流(默认 300 秒)
│ if (time(NULL) - YACONF_G(last_check) < YACONF_G(check_delay)) return;
│ // 300 秒内只认真检查一次,其余请求零开销
│
├─ 层 2:根目录 mtime 比对
│ if (VCWD_STAT(YACONF_G(directory)) 的 mtime == 存的 directory_mtime)
│ return; // 根目录没被动过,整个检查到此为止
│
└─ 层 3:php_yaconf_check_directories (715-757)
// 只有根目录变过才进来
// 对每个已解析的子目录(dirdnode 快照)逐个 VCWD_STAT
// mtime 变了的目录 →重扫;每个文件再过 php_yaconf_handle_file 的 mtime 表
php_yaconf_check_directories (715-757) 的核心
static int php_yaconf_check_directories(void)
{
...
ZEND_HASH_FOREACH_PTR(parsed_ini_dirs, dirnode) {
/* 快照:dirnode 里存的是上一轮每个子目录的 mtime */
if (VCWD_STAT(dirnode->dirname) == SUCCESS) {
if (dirnode->mtime != st.st_mtime) {
/* 这个子目录变了 →重扫 */
php_yaconf_handle_directory(...);
}
}
...
}
...
}
parsed_ini_dirs 是目录 mtime 快照表,parsed_ini_files 是文件 mtime 快照表,两层快照层层过滤,让"配置没变"的请求只花几次
stat 系统调用(纳秒级)。
为什么只支持非 ZTS?
PHP_RINIT_FUNCTION(yaconf) 的整个热重载块被包在 #ifndef ZTS 里:
#ifndef ZTS
PHP_RINIT_FUNCTION(yaconf)
{
if (time(NULL) - YACONF_G(last_check) < YACONF_G(check_delay)) {
return SUCCESS;
}
...
}
#endif
ZTS(线程安全模式,如 Apache worker MPM 的每个线程一个解释器)下所有线程共享一份全局配置。线程 A 热重载替换了指针,线程 B
正拿着旧指针读 →use-after-free。非 ZTS 的 FPM 是进程模型,每个 worker
是独立的进程地址空间,自己重载自己的,互不干扰。这是"进程模型白捡的线程安全",ZTS 里没有。README 明确警告:"ZTS mode
doesn't support hot-reload"。
---
话题 6:命名空间——多配置文件的隔离与合并
三个映射规则
a.ini →命名空间 "a"
sub/ →命名空间键 "sub"(子目录递归)
sub/x.ini →命名空间 "sub.x"
代码:php_yaconf_handle_directory (622-656)
static int php_yaconf_handle_directory(const char *directory, zend_string *name)
{
...
/* 这个目录的配置数组 */
zval *zv = php_yaconf_hash_init(); // 新建不可变数组
...
/* 名字空间根:ini_containers["sub"] = 该数组 */
php_yaconf_symtable_update(ini_containers, name, zv);
...
/* 递归扫这个目录,prefix 变成 "sub." */
php_yaconf_scan_directory(directory, name_str, depth);
...
}
冲突与覆盖规则
1. 同名文件与目录:警告,目录胜出(593 行,话题 2 已讲)
2. 不同文件不同键:各自独立,互不干扰
3. 同一个键被两个文件写:后解析的覆盖先解析的(scandir 按字母序,所以行为确定)
隔离与合并的哲学
- 隔离:每个文件/目录一个命名空间,a.ini 里的 host 不会污染 b.ini。这是给"一个项目拆 N
个配置文件,不同团队各管各的"用的。
- 合并:Yaconf::get() 允许跨命名空间路径(比如 Yaconf::get("db.host") 能查到 db.ini 里的
host),所以业务代码不需要感知"配置在哪个文件里"。
- Section 继承也走命名空间逻辑:[child:base] 会把 base 的键值深拷贝一份进 child,然后 child 的键覆盖父的。
---
话题 7:类型推断—⚠️前提不成立,真相是"全是字符串"
必须先纠错
你的话题前提是"INI 值自动识别为 int/float/string/array"。真实源码没有这个功能。zend_parse_ini_file 用
ZEND_INI_SCANNER_NORMAL 模式,该模式下所有值解析出来都是 zend_string。Yaconf 在回调里从没做过 zend_is_numeric /
zend_ini_parse_quantity 之类的转换。
铁证在 README。README 的示例输出:
php -r "var_dump(Yaconf::get('section.foo.year'));"
string(4) "2015" ←数字是 string!
php -r "var_dump(Yaconf::get('features.1'));"
string(5) "light" ←数组序号也是 string!
year=2015 存进去、取出来是 string(4) "2015"。如果 Yaconf 做了 int 推断,这里就应该是 int(2015)。所以:
▎ Yaconf 只识别两种结构:数组(点号嵌套/section/子目录)和字符串(所有叶子值)。没有 int/float/bool
▎ 自动转换。这是设计选择,不是疏漏——见文末三问。
那数组是怎么来的?
数组不是"类型推断"出来的,是语法结构决定的:
┌──────────────┬───────────────────────┐
│ 写法 │ 结果 │
├──────────────┼───────────────────────┤
│ a.b=1 │ 数组 a →["b" => "1"] │
├──────────────┼───────────────────────┤
│ [sec] + 键 │ section sec →数组 │
├──────────────┼───────────────────────┤
│ sub/ 子目录 │ 命名空间 sub →数组 │
├──────────────┼───────────────────────┤
│ [child:base] │ 继承 base 的数组 │
└──────────────┴───────────────────────┘
为什么用户可能"感觉"有类型推断?
因为 PHP 的弱类型——用户Yaconf::get("year") + 1 照样能算出 2016,是引擎在运算时做的隐式转换,不是 Yaconf 存的
int。存的时候就是字符串。想严格用:Yaconf::get("year") 后自己 (int) 转换。
---
话题 8:与 OPcache 的关系——为什么OPcache 不会清掉配置
两者的内存归属完全不同
┌──────────┬─────────────────────────────┬─────────────────────────┐
│ │ OPcache │ Yaconf │
├──────────┼─────────────────────────────┼─────────────────────────┤
│ 存什么 │ 编译后的 PHP 字节码 │ 解析后的配置 zval │
├──────────┼─────────────────────────────┼─────────────────────────┤
│ 生命周期 │ 常驻(shared memory) │ 常驻(pemalloc) │
├──────────┼─────────────────────────────┼─────────────────────────┤
│ 谁分配 │ OPcache 自己的共享内存段 │ Zend 内存管理器的持久池 │
├──────────┼─────────────────────────────┼─────────────────────────┤
│ 失效机制 │ opcache.validate_timestamps │ 自己 mtime 轮询 │
└──────────┴─────────────────────────────┴─────────────────────────┘
它们根本不在同一个内存体系里,OPcache 没有"清理"Yaconf 数据的能力。
那"为什么不会被清掉"这个问题的真正答案是什么?
因为从引擎视角看,Yaconf 的数据跟一个普通 PHP 请求里的静态变量没有区别——都是"某个模块globals 里指向的
HashTable"。唯一能清掉它们的是:
1. MSHUTDOWN——Yacon自己在 1286 行手动 destroy(进程退出,无所谓)
2. pemalloc 池被清空——没有任何东西会在请求间清空持久池,否则所有持久资源都完了
有一个值得提的微妙点:PHP 7.x 时代有个传说"opcache 会把 immutable 数组清掉",那是老版本 PHP 的 bug。现代引擎里
IS_ARRAY_IMMUTABLE + refcount 正确的情况下,引擎自己也不会碰它。Yaconf 设 refcount=2 就是为了双重保险。
深一层:为什么 Yaconf 敢用 pemalloc 而不是 OPcache 的 shared memory?
因为目的不同:OPcache 需要跨进程共享只读字节码;Yaconf 的数据会被引擎写入(用户修改、返回引用、foreach
by-ref),必须让每个进程有独立的可写副本。用 COW 共享只读页面,用进程私有内存存可变结构——这是Yaconf 的选择,见 12
号话题展开。
---
话题 9:compact 存储—⚠️40%/16% 数字没有出处,但方向是真的
先纠错:数字不存在
我在整个仓库(源码、README、全部 commit message)里没有找到任何 "40%" "16%" 或
benchmark。你听到的可能是传闻或某篇文章的推测。我无法确认这两个数字,写讲解不能编数字。
但 compact block 本身是真的,收益方向是真的
commit Two-phase compact block implementation 的动机(commit message 原文):
▎ "Replace individual pemalloc allocations with a two-phase compact block: parse phase uses temporary memory, compact
▎ phase copies everything into a single pemalloc block and frees the old tree."
完整流程(7 步,yaconf_compact 910-1027)
Phase 1(临时内存)已经把整棵树建好了
│
▼yaconf_compact()
│
1. 收集(yaconf_compact_collect_zval 787-809)
│ 遍历 ini_containers 整棵树
│ - 统计所有字符串/数组/哈希表
│ - 字符串按内容去重(str_xlat 表:同一内容只存一份)
│ - 记录每个 HashTable 的 size/键值
│ - 注意:树是非 DAG,同一个值不会被访问两次,所以无 visited 检查
│
2. 计算总大小(yaconf_compact_calc_ht 811-826)
│ - 每个表的字节数:HT_HASH_SIZE + nTableSize * sizeof(Bucket)
│ - 关键:留 slack(保留 nNumUsed 和 nTableSize 之间的差距,
│ 让热重载后"往表里加几个新键"不用 resize,原地放得下)
│
3. pemalloc 一块连续内存(yaconf_block + yaconf_block_size)
│
4. 拷字符串(yaconf_compact_str 828-836)
│ str_xlat[旧指针] = 块内新指针
│
5. 拷表(yaconf_compact_copy_ht 838-908)
│ memcpy 结构体 + 数据区
│ memset HT_INVALID_IDX 重置哈希索引
│ 通过 str_xlat / ht_xlat 把 key、字符串值、子数组指针
│ 从"旧地址"重映射为"块内新地址"
│ PHP 7.0 特判:拷贝后 pDestructor 置 NULL(861 行,防止 7.0 的
│ zend_array_dup 继承 NULL destructor 导致 foreach-by-ref 泄漏——这个坑后面话题11 再讲)
│
6. 更新引用(ht_xlat:旧 HashTable 指针 →块内新指针)
│ ini_containers / parsed_ini_files 里的指针全部换成块内地址
│
7. 释放旧树(yaconf_compact_free 的销毁路径)
│ Phase 1 的临时内存全部 emalloc 来的,全清
为什么这能快、能省内存(定性的,不编数字)
┌────────────┬─────────────────────────────────────┬───────────────────────────────┐
│ 方面 │ 分块分配(旧) │ 紧凑块(新) │
├────────────┼─────────────────────────────────────┼───────────────────────────────┤
│ 分配次数 │ 每个字符串/表一次 malloc │ 一次大分配 + 块内指针移动 │
├────────────┼─────────────────────────────────────┼───────────────────────────────┤
│ 缓存局部性 │ 分散在不同页,一次查找跳 5 次 │ 所有数据相邻,一次访存带出一片 │
├────────────┼─────────────────────────────────────┼───────────────────────────────┤
│ COW 页数 │ 每个 worker 触碰任何一处 →复制一页 │ 配置不变时整块只读,0 复制 │
├────────────┼─────────────────────────────────────┼───────────────────────────────┤
│ 内存碎片 │ Zend 内存池碎片多 │ 一块连续,无碎片 │
├────────────┼─────────────────────────────────────┼───────────────────────────────┤
│ 字符串重复 │ 每份内容一份内存 │ 内容去重,一份 │
└────────────┴─────────────────────────────────────┴───────────────────────────────┘
随机读快的底层原理:哈希表查找 = 算 hash →查索引槽 →跳到 bucket →读 value。旧布局下这 4 次访存可能落在 4
个不同的页,而紧凑块把整个哈希表+它的所有字符串放在相邻地址,bucket 跳转几乎总在同一页内命中(这个话题 14 展开)。
---
话题 10:git 事故——用sed 删 mprotect,把 yaconf.directory 一起删了
证据链(全部来自真实 commit)
按时间顺序:
┌───────┬─────────────────────────────────────────────┬───────────────────────────────────────────────────────────┐
│ 时间 │ commit │ 干了什么 │
├───────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ 08-12 │ Two-phase compact block implementation │ 引入紧凑块 │
├───────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ 08-13 │ Add mprotect support for compacted block │ 用 mmap/VirtualAlloc 分配紧凑块并 mprotect 只读(这是"mmap │
│ │ │ 曾存在"的唯一时期) │
├───────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ 08-13 │ Remove mprotect support entirely │ 删掉全部 mprotect/mmap/VirtualAlloc 代码和 │
│ │ │ yaconf.mprotect 配置 │
├───────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ 08-13 │ Fix tests/020 and tests/022: restore │ 修复被误删的配置 │
│ │ missing yaconf.directory │ │
├───────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ 08-14 │ Guard the pDestructor workaround with │ 7.0 特判加版本保护 │
│ │ PHP_VERSION_ID │ │
├───────┼─────────────────────────────────────────────┼───────────────────────────────────────────────────────────┤
│ 08-14 │ Fix PHP 7.0 leak on foreach-by-ref over │ 修 7.0 泄漏(issue19) │
│ │ compact block tables │ │
└───────┴─────────────────────────────────────────────┴───────────────────────────────────────────────────────────┘
事故现场还原
Remove mprotect support entirely 这个 commit 的 diff 大概是:删掉了所有含 mprotect、mmap、VirtualAlloc、yaconf.mprotect
的行。问题是自动化工具(很可能是 sed / 脚本)是按"行内含关键词"删的,而测试文件 tests/020.phpt、tests/022.phpt
里某些行同时包含了 yaconf.mprotect 和 yaconf.directory:
yaconf.mprotect=1
yaconf.directory=... ←被误删的就是这种行
下一 commit 的 message 原文说得一清二楚:
▎ "The sed cleanup removed yaconf.mprotect lines but those lines also contained yaconf.directory concatenation.
▎ Restore the directory setting that was accidentally dropped."
"AI 改代码丢了核心代码"的完整教训:
1. 批量文本替换是陷阱:sed s/.*mprotect.*// 之类把"同一行里的无辜内容"也删了
2. 删代码要审 diff:凡是从文件里删行,必须逐行看 diff,而不是只看"关键词命中了"
3. 测试是最后防线:这次是 CI 里 tests/020、022 挂掉才发现的
4. 提交信息要说人话:这个 commit 信息把原因写得明明白白,后人(就是现在的我)才能复原现场
为什么 mprotect 被"加了又删"?
动机:Add mprotect support 想给紧凑块加只读保护,防止任何代码意外改配置(防御 bug)。但 Remove mprotect support entirely
的 message 讲透了反对理由:
▎ "After fork (PHP-FPM), COW already provides isolation between workers. mprotect on the compacted block adds no real
▎ protection but forces us to unprotect/reprotect during RINIT hot-reload, defeating the purpose."
翻译:COW 已经提供了 worker 间隔离,而热重载时你反而要把块解开(mprotect
改回可写)才能写新配置,写完全过程都在裸奔——保护了个寂寞,还多花系统调用。
这个"加了对的功能又亲手删掉"的过程是很好的工程课:功能必须有真实收益才留,理论上的安全感不是收益。
---
话题 11(深):IS_ARRAY_IMMUTABLE 在引擎层的完整机制
引擎里到底发生了什么
IS_ARRAY_IMMUTABLE 定义在 zend_types.h:
#define IS_ARRAY_IMMUTABLE (1<<4) /* 是 GC_INFO 高位的 flags 之一 */
它挂在 HashTable 的 GC 头信息上。引擎写路径 zend_hash_*_update 系列的开头:
static zend_always_inline void zend_hash_packed_to_hash(...) { ... }
/* zend_hash_update 里 */
if (HT_FLAGS(ht) & HASH_FLAG_APPLY_PROTECTION) {
ZEND_HASH_IF_UNPROTECTED(...)
}
而 IMMUTABLE 的处理在分离逻辑(写时复制)里,zend_array_dup():
ZEND_API zend_array *zend_array_dup(zend_array *source)
{
...
if (GC_FLAGS(source) & IS_ARRAY_IMMUTABLE) {
/* 完整复制一份,新表不继承 immutable */
}
}
refcount=2 的完整语义
设 2 的妙处要结合 ZVAL_ARR 的借用规则看:
MINIT: refcount = 2 (手工设)
请求1: Yaconf::get("db") →RETURN_ZVAL(val, 0, 0) // 不 copy
引擎给用户 zval 时 refcount = 3?不——这里要小心:
Yaconf 的 get 是 RETURN_ZVAL(val, 0, 0),
只是让返回值 zval 指向同一 HashTable,refcount 不动,仍是 2。
请求1 结束: 用户销毁返回值 →refcount 2→1 // 还活着!
如果设成 1,请求结束时 refcount 变 0,引擎就会 free 持久内存 →灾难。设 2
就是**"多留一条命",保证任何一次"借出-归还"循环都杀不死它。为什么不是 3、4?因为 Yaconf
自己保证任何时刻最多只有一份"借用"在用户手里**(get 返回的是只读借用,用户不能存起来跨请求——API 文档专门警告了:hot
reload 可能替换指针,必须 ZVAL_COPY)。
COW 分离的完整链条
Yaconf::get("db") → 用户 $db = ["host" => "1.2.3.4"]
用户 $db['port'] = 3306;
│
▼引擎写路径
zend_hash_update($db 对应的 ht)
│ 发现 GC_FLAGS & IS_ARRAY_IMMUTABLE
▼
separate →zend_array_dup():整表复制一份普通表
│ 新表带 HASH_FLAG_APPLY_PROTECTION,可写
▼
对副本执行写入 // Yaconf 的持久原件纹丝不动
为什么去掉 HASH_FLAG_APPLY_PROTECTION?
这是"预防递归保护"标志——引擎遍历HashTable 时防止自引用死循环用的(标记"正在遍历",递归发现又进来了就报错)。Yaconf
的配置树是无环 DAG,不可能自引用,关掉它让引擎少一层检查,略微更快。这是"按数据性质关掉不需要的引擎防御"的典型优化。
7.0 的 pDestructor 坑(861 行的注释)
/* 858-866 行附近 */
#if PHP_VERSION_ID < 70100
/* PHP 7.0 的 zend_array_dup 会继承源表的 pDestructor。
紧凑块里的表 pDestructor 是 NULL(值随块一起释放)。
于是 foreach-by-ref($a as &$v) 时 dup 出来的表
没有任何 destructor →24 字节的 zend_reference 泄漏(issue19)。
7.1+ 的 zend_array_dup 会自己覆盖成 ZVAL_PTR_DTOR,所以只有 7.0 需要。*/
p = zend_hash_get_current_data(...);
...
ht->pDestructor = ZVAL_PTR_DTOR;
#endif
这个 bug 的完整链条:紧凑块表没有 per-entry destructor(值都在块里,块整体释放) →PHP 7.0 的 zend_array_dup
傻乎乎地继承这个 NULL →用户 foreach-by-ref 时引擎在 bucket 里塞一个 zend_reference →dup 表释放时没人调用 destructor
→每个被引用的 bucket 泄漏 24 字节。7.1 修了引擎(zend_array_dup 强制覆盖 destructor),Yaconf 则用版本特判补齐 7.0。
这就是"immutable 数据 + 引擎版本差异"交互的经典陷阱:你的数据结构越"特别",越容易踩到引擎各个版本对"特别"的假设。
---
话题 12(深):⚠️前提不成立——没有mmap,真相是"pemalloc 堆块 + OS COW"
纠错
当前 master 没有任何 mmap/MAP_SHARED/mprotect/VirtualAlloc 代码。Remove mprotect support entirely commit
已把它们全部删除,连同 yaconf.mprotect INI 配置。我 grep 过当前源码,零命中。
那"跨 worker 共享内存"是怎么实现的?
不是共享,是"本来就只有一份"。完整机制:
php-fpm master 进程
│ MINIT 里 pemalloc 了一块紧凑块(进程私有堆内存)
│
├─ fork() 出 worker 1
│ └─ 继承 master 的整个地址空间快照
│ └─ 紧凑块在 worker 1 的虚拟地址空间里"看得见",
│ 物理页与 master 共用(COW 只读共享)
├─ fork() 出 worker 2
│ └─ 同样
│
├─ 配置不变:所有 worker 读同一物理页,0 复制,0 额外内存
│
└─ 配置变更(非 ZTS):
worker 热重载 →往自己的紧凑块写新配置
→OS COW:仅被写的页被复制(一页 4KB)
→其他 worker 仍读旧页,互不影响
关键点:fork 后子进程对父进程内存的语义不是"复制",而是"写时复制"。 所以 Yaconf
甚至不需要显式共享——它"假装"数据是每个进程私有的,O的 fork 机制自动让所有 worker 共享只读物理页。这就是 README 第 15
行那句话:
▎ "Yaconf uses an immutable data + Copy-on-Write design rather than shared memory (shmget/mmap)."
为什么不用 shmget/mmap 显式共享?
README 明说 "rather than shared memory (shmget/mmap)"——这是有意的设计对比,不是遗漏。理由:
┌──────────┬───────────────────────────────────────┬────────────────────────────┐
│ 维度 │ shmget/mmap 共享段 │ fork COW │
├──────────┼───────────────────────────────────────┼────────────────────────────┤
│ 写入 │ 所有 worker 可见(要锁/要重新加载语义) │ 写自己的副本,天然隔离 │
├──────────┼───────────────────────────────────────┼────────────────────────────┤
│ 热重载 │ 要设计"版本切换"协议,旧读者怎么办 │ OS 管,旧 worker 自动读旧页 │
├──────────┼───────────────────────────────────────┼────────────────────────────┤
│ 生命周期 │ 要手动 shmctl 清理,崩溃会留垃圾 │ 进程退出 OS 自动回收 │
├──────────┼───────────────────────────────────────┼────────────────────────────┤
│ 复杂度 │ 锁、同步、版本号 │ 零 │
└──────────┴───────────────────────────────────────┴────────────────────────────┘
Yaconf 用"啥也不做"换来了共享:让 OS 的 fork 语义替你干活。代价是——只有FPM 这种 fork
模型的进程才能这么玩;如果哪天你想让"不 fork 的两个独立进程"共享配置,Yaconf 就不行。
mprotect 实验的教训(呼应话题 10)
mprotect 版想给紧凑块加只读保护,但热重载必须写它,所以 RINIT 要 unprotect→写→reprotect,窗口期毫无保护,还白付系统调用 。
防御性功能如果没有办法在"需要写它"的场景下维持,就干脆别加——这是"Removmprotect support entirely"的完整理由。
---
话题 13(深):⚠️不是手写递归下降——是引擎的zend_parse_ini_file + 回调
纠错
Yaconf 没有手写解析器。它调的是 Zend 引擎内置的 zend_parse_ini_file(PHP 源码 zend_ini_parser.y /
zend_ini_scanner.l),引擎用 re2c 生成的扫描器 + 递归下降语法分析解析 INI,Yaconf 只提供回调函数处理解析事件。
事件驱动模型
zend_parse_ini_file(&fh, unprocessed, scanner_mode, callback, result) 的三个参数含义:
┌──────────────┬─────────────────────────────┬────────────────────────────────────────────────────────────┐
│ 参数 │ Yaconf 传的值 │ 含义 │
├──────────────┼─────────────────────────────┼────────────────────────────────────────────────────────────┤
│ unprocessed │ 1 │ 不预处理变量展开(否则 key=${VAR} 会被引擎展开,Yaconf 不要) │
├──────────────┼─────────────────────────────┼────────────────────────────────────────────────────────────┤
│ scanner_mode │ 0 = ZEND_INI_SCANNER_NORMAL │ 值保持字符串,不做引号/类型魔法 │
├──────────────┼─────────────────────────────┼────────────────────────────────────────────────────────────┤
│ callback │ php_yaconf_ini_parser_cb │ 解析事件回调 │
└──────────────┴─────────────────────────────┴────────────────────────────────────────────────────────────┘
引擎扫描器遇到三种事件就回调:
case ZEND_INI_PARSER_ENTRY: /* key=value */
case ZEND_INI_PARSER_SECTION: /* [xxx] */
case ZEND_INI_PARSER_POP_ENTRY: /* section 结束 */
手写 vs 内置的 trade-off(这是 Yaconf 的取舍)
┌──────────┬─────────────────────────────────────────┬───────────────────────────────────┐
│ │ 手写解析器 │ 用引擎的 │
├──────────┼─────────────────────────────────────────┼───────────────────────────────────┤
│ 代码量 │ +200 行起,还有 bug │ 0 行,复用引擎 │
├──────────┼─────────────────────────────────────────┼───────────────────────────────────┤
│ 语法兼容 │ 要自己模拟 PHP INI 的引号/转义/注释规则 │ 天然一致 │
├──────────┼─────────────────────────────────────────┼───────────────────────────────────┤
│ 性能 │ 可控(可以特化) │ 引擎的通用实现,略慢一点点(可忽略) │
├──────────┼─────────────────────────────────────────┼───────────────────────────────────┤
│ 维护 │ 引擎 INI 语法变了要跟着改 │ 引擎更新自动跟上 │
├──────────┼─────────────────────────────────────────┼───────────────────────────────────┤
│ 定制 │ 想怎么解析都行 │ 受回调接口限制 │
└──────────┴─────────────────────────────────────────┴───────────────────────────────────┘
为什么回调模型够用? 因为 Yaconf 的"数组嵌套"需求(a.b.c=1)是后处理(zend_memrchr
找点号递归建数组),不是语法层面的东西。INI 语法本身只到"键值对 + section"为止,回调完全够用。只有当你的语法比 INI
复杂(表达式、条件、引用)才值得手写——Yacon不需要,所以不写。
手写递归下降的场景(如果有人要写)
作为对比,手写解析器长这样(伪代码):
parse_value():
if peek() == '"' : 引号字符串
else if 数字 : number
else : bare string
parse_ini():
while not EOF:
if peek() == '[' : parse_section()
else : parse_entry()
Yaconf 完全绕开了这一切。"复用引擎"是 PHP 扩展开发的黄金准则:你写的 C 代码越少,越不容易踩到"对 PHP 语义理解错误"的坑。
---
话题 14(深):compact 存储的 C 实现细节与随机读性能的底层原因
数据结构总览
紧凑块 = 一大块 yaconf_block(char*),内部布局(按 7 步流程的顺序):
┌─────────────────────────────────────┐
│ 字符串区 (去重后的所有配置字符串) │
│ (key 和 value 的 zend_string 内容) │
├─────────────────────────────────────┤
│ HashTable 结构体区 │
│ (每个配置数组的 zend_array 头) │
├─────────────────────────────────────┤
│ HashTable 数据区 │
│ (每个表的 arData bucket 数组 + hash 索引)│
│ (表与表之间留了 slack 空隙) │
└─────────────────────────────────────┘
yaconf_compact_copy_ht (838-908) 的指针重映射
static zend_array *yaconf_compact_copy_ht(zend_array *ht, ...)
{
zend_array *nht = (zend_array *)((char *)block + offset);
...
/* 1. memcpy 结构体 + 数据区(Bucket 数组原样搬) */
memcpy(nht, ht, sizeof(zend_array) + HT_USED_SIZE(ht));
/* 2. 哈希索引槽全部重置 */
for (i = 0; i < HT_HASH_SIZE(nht); i++) {
HT_HASH_EX(nht, i) = HT_INVALID_IDX;
}
/* 3. 重映射每个 bucket 里的指针 */
for (i = 0; i < nht->nNumUsed; i++) {
zval *zv = &nht->arData[i].val;
...
if (Z_TYPE_P(zv) == IS_STRING) {
/* 字符串值:查 str_xlat 换成块内新地址 */
Z_STR_P(zv) = str_xlat[Z_STR_P(zv)];
} else if (Z_TYPE_P(zv) == IS_ARRAY) {
/* 子数组:查 ht_xlat 换成块内新表地址 */
Z_ARRVAL_P(zv) = ht_xlat[Z_ARRVAL_P(zv)];
}
/* key:interned 字符串也在 str_xlat 里 */
...
}
...
}
重映射的本质:所有"指向堆里别处的指针"→"指向块内的偏移地址"。拷完后,块内任何一个表都能独立寻址到自己的全部数据,不再依 赖
块外任何东西。
为什么"哈希表数组"被 memcpy 是安全的?
zend_array 的布局:{gc, u, nTableMask, arData, nNumUsed, nNumOfElements, nTableSize, nInternalPointer,
nNextFreeElement, pDestructor}——全部是值类型+ 指针,没有自引用指针需要修正(arData 指向自己内部的 bucket
区,偏移量在拷贝时按新位置重算,或干脆表头和数据区一起搬让 arData 偏移不变)。这就是能 memcpy 的前提。凡是含
self-referential 指针的结构都无法这样搬,这是紧凑块方案的能力边界。
slack:为什么留空桶(811-826)
/* 计算时保留 nNumUsed 与 nTableSize 的差 */
zend_long ntable_size = ht->nTableSize;
zend_long nused = ht->nNumUsed;
/* 分配时按 ntable_size 而不是 nused */
yaconf_compact_calc_ht 按 nTableSize 算大小,而拷贝只填 nNumUsed 个 bucket,中间留的 nTableSize - nNumUsed 个空桶就是
slack。目的:热重载后如果新配置在这个表里多加了几个键,zend_hash_add 发现 nNumUsed < nTableSize 就直接原地用空桶,不需要
resize、不需要 detach、不碰块外内存。这是"热重载尽量零成本"的最后一道防线。
随机读快 4 步的底层原理
Yaconf::get("db.master.host")
│ php_yaconf_get:逐段查树
├─ 1. ini_containers 里查 "db" →块内表 A
├─ 2. 表 A 查 "master" →算 hash →索引槽 →bucket →块内表 B
├─ 3. 表 B 查 "host" →算 hash →索引槽 →bucket
└─ 4. 读出 zval(字符串,也在块内)
每一步的访存目标都在 yaconf_block 这块连续内存内。
页命中率:整块可能只有 1-2 个物理页(配置不大时),
一次 TLB 命中覆盖全部访问。
对比旧方案:每个字符串一次 pemalloc,可能散布在几十个页上,一次 get 要触发好几次缺页/跨页跳转。紧凑块把"概率性的局部性"变
成"必然的局部性"。这就是随机读快和内存省的底层原因(方向确定,数字我不敢编)。
---
话题 15(深):与 php_module_startup 的时序——为什么必须在MINIT 加载
引擎启动时序
php_module_startup()
├─ ...
├─ zend_startup() →Zend 引擎初始化
├─ module_registry 遍历:
│ │
│ └─ 每个模块依次调用:
│ PHP_MINIT_FUNCTION(module) ←Yaconf 在这里
│
├─ ...
└─ php_request_startup() →第一个请求开始
└─ PHP_RINIT_FUNCTION(yaconf) ←热重载在这里
为什么必须在 MINIT?
因为 MINIT 是"进程级只跑一次"且"早于任何请求"的位置,Yaconf 需要的就是这个:
1. 只跑一次:配置只解析一次,不能每个请求都解析
2. 在请求之前:第一个请求进来时,Yaconf::get() 必须已经能查到数据
3. 在主进程:FPM 的 MINIT 在 master 进程里执行,fork 之后所有 worker 继承这块内存——这正是COW 共享的前提。如果改成 RINIT
加载,每个 worker 自己解析一份,内存翻 N 倍,COW 优势全没了
为什么不能推迟到 "PHP 启动脚本" 里?
PHP 层面(比如 auto_prepend_file)做不到的事,Yaconf 必须在 C 层做:
┌──────────┬─────────────────────────────────────────────────────────┐
│ 需求 │ 为什么 PHP 层不行 │
├──────────┼─────────────────────────────────────────────────────────┤
│ 常驻内存 │ PHP 脚本结束时所有 emalloc 全清,只有 C 层 pemalloc 能活 │
├──────────┼─────────────────────────────────────────────────────────┤
│ 早于请求 │ auto_prepend 也是请求的一部分,晚于 MINIT │
├──────────┼─────────────────────────────────────────────────────────┤
│ 跨请求 │ PHP 变量没有跨请求的,除非静态变量(但 C 层更干净) │
└──────────┴─────────────────────────────────────────────────────────┘
MINIT 里那个"两阶段"为什么必须这么分?
yaconf_parse_persistent = 0; // 阶段 1:临时内存建树
php_yaconf_scan_directory(...);
yaconf_parse_persistent = 1; // 阶段 2:压成持久块
yaconf_compact();
为什么不在阶段 1 直接 pemalloc?因为compact 需要先知道总大小才能一次性分配,而总大小要等整棵树建完才知道。两阶段 =
"先建完,量尺寸,一次分配,整体搬移"——这是"先增量后整合"的标准压缩算法。代价是临时内存峰值= 整棵树的两倍(临时树 +
紧凑块并存于同一时刻),对配置量很大的场景,MINIT 瞬间内存翻倍。这就是 compact 方案的
trade-off:省常驻内存、省碎片,但付出一次性峰值。
热重载为什么也必须在 RINIT 而不能在别处
- 不能放 MINIT:MINIT 只跑一次,改了配置不生效
- 不能放 MSHUTDOWN:太晚
- 不能放 C 层线程:PHP 是请求驱动的,没有空闲回调
- 只能放 RINIT:每个请求必经,且此时还没有任何业务代码运行,重载配置最安全(不会有"某个业务代码正拿着旧指针"的并发读)
代价:RINIT 是最热路径,所以必须做三层节流(check_delay 300 秒 + 根目录 mtime + 目录快照),让"配置没变"的请求只花 ~3 次
stat。"放在必经之路但把开销压到接近零"是 PHP 扩展的通用范式(OPcache 的 validate_timestamps 同理)。
---
结尾三问
1. 为什么选择这个方案而不是别的?
Yaconf 面对三个备选:
┌────────────────────────────────────────────────────────────┬────────────────────────────────────────────────────┐
│ 方案 │ 问题 │
├────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ 每次请求 parse_ini_file │ 重复 IO + 解析,高并发下纯浪费(这是 Yaconf │
│ │ 要消灭的) │
├────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ 手写配置缓存扩展 + 自建共享内存(shmget/mmap) │ 要处理锁、版本切换、崩溃清理,复杂度爆炸 │
├────────────────────────────────────────────────────────────┼────────────────────────────────────────────────────┤
│ Yaconf 的方案:MINIT 解析 + pemalloc + IMMUTABLE + fork COW │ 让引擎和 OS 干所有重活,自己只写"解析回调 + 压缩 + │
│ + mtime 热重载 │ 轮询" │
└────────────────────────────────────────────────────────────┴────────────────────────────────────────────────────┘
它选的是"把正确性外包":解析外包给引擎(话题 13)、内存管理外包给 pemalloc、跨进程共享外包给 fork COW(话题
12)、失效检测外包给 mtime。Laruence 的哲学很明确:PHP 扩展的每一行代码都是负担,能用引擎/OS 免费获得的特性绝不自己写。
这不是偷懒,是因为"自己实现的版本"必然要重复维护引擎已经维护过的语义(引号、转义、内存生命周期、版本差异)。
2. 这个方案的 trade-off 是什么?
┌───────────────────────┬─────────────────────────────────────────────────────────────────────────────────────────┐
│ trade-off │ 细节 │
├───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 只适合 FPM/fork 模型 │ 依赖 fork COW 才能共享;ZTS 模式(线程)下没有热重载、没有 COW,只能关掉(README 明说) │
├───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 类型信息丢失 │ 全是字符串,业务层要自己转换;为了类型,你要写 (int)Yaconf::get("x") │
├───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 配置不支持表达式 │ 没有 ${VAR} 展开、没有条件、没有 include;复杂配置需求(比如环境差异)要外部工具预生成 │
├───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ MINIT 峰值内存翻倍 │ compact 两阶段,建树+压缩并存,配置超大时启动瞬间吃两倍 │
├───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ mtime 轮询有 300 │ 改了配置,最多等 check_delay 秒才生效;想即时生效得调小(费 stat) │
│ 秒延迟 │ │
├───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 引擎耦合 │ 依赖 zend_parse_ini_file 的解析行为、IS_ARRAY_IMMUTABLE 的跨版本差异(7.0 的 pDestructor │
│ │ 坑就是代价) │
├───────────────────────┼─────────────────────────────────────────────────────────────────────────────────────────┤
│ 只读性靠"约定" │ C API 返回 borrowed pointer,热重载可能替换,文档警告必须 ZVAL_COPY——用错了就是 │
│ │ use-after-free │
└───────────────────────┴─────────────────────────────────────────────────────────────────────────────────────────┘
最重的一条:它把"配置"限定在了 INI 语法 + 字符串类型的框子里。如果哪天配置需要环境变量插值、继承表达式、或者 JSON/YAML
语法,这个方案就要从头设计。这是"简单可靠"换来的"表达能力有限"。
3. 如果让我重新设计,会怎么做?
以我现在的认识,重构方向:
保留骨架:MINIT 两阶段解析 + pemalloc + 引擎 immutable 机制。这个架构的核心洞察——"让引擎的不可变数组+ OS 的 fork COW
干活"——是对的,任何版本都该继承。
我会改的:
1. 加入类型层:在回调里用 zend_is_numeric / zend_ini_parse_quantity 把 2015 存成
IS_LONG,字符串留给真正的字符串。代价是丢失"值和 INI 原文一致"的纯字符串语义,但换来 Yaconf::get("year") 直接是
int,省掉业务层到处 (int)。做法:值上做一次确定性类型推断(数字→long/double,true/false→bool,其余→string),并README
明确规则,让行为可预期。
2. 把热重载升级为"双缓冲原子切换":当前是"重扫 →覆盖同一批全局指针",中间存在窗口(正在重扫时,其他代码 get
到半新半旧的状态——虽然单进程FPM 里 RINIT 无并发,但 ZTS/长驻 Worker
模式就危险)。我会新树建好后再整体切换指针,旧树延迟到安全点释放。这同时能解锁 ZTS
支持(每个线程一个读指针,原子交换)。
3. mmap 会再考虑,但方向不同:不是加回 mprotect(那已被证明无益),而是用 mmap MAP_PRIVATE|MAP_POPULATE
预热的只读映射存紧凑块,好处是:(a) 磁盘上直接映射,进程崩溃重启后 OS 页面缓存仍在,MINIT 快;(b) COW
语义不变(MAP_PRIVATE 就是 COW)。真正要解决的是"配置文件版本与映射失效"的协议——这比mprotect 有价值。
4. 补上 Yaconf::reload() 显式 API + SAPI 信号钩子:mtime 轮询永远是"最迟 N 秒生效",给运维一条"改完配置立刻
reload"的命令(比如收到 SIGUSR2 时重载),把"最终一致"变成"可手动即时一致"。
5. 测试策略:这次事故的教训是"删代码必须审 diff、测试必须覆盖核心配置项"。我会给测试套件加一个配置完整性自检(启动时断言
yaconf.directory 等关键项非空),让"AI 批量删行"这类事故当场炸,而不是靠 CI 里的 020/022 偶然抓包。
---
一句话收尾:Yaconf 是"信任引擎、信任 OS、自己的代码越少越好"的极致体现——它证明了持久化配置容器在PHP
里最优雅的形态不是"自己管理内存的共享缓存",而是"让不可变数据搭上 fork 的顺风车"。前提是你要清楚它的边界:INI
语法、字符串类型、FPM 进程模型。
更多推荐
所有评论(0)