四、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=1yaconf_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.3Z_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/020022 挂掉才发现的
  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 21  // 还活着!

  如果设成 1,请求结束时 refcount 变 0,引擎就会 free 持久内存 →灾难。设 2
  就是**"多留一条命",保证任何一次"借出-归还"循环都杀不死它。为什么不是 34?因为 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 进程模型。

更多推荐