RFC-020:Интеграция динамических модулей и FFI
⚠️ Отклонено: Данный документ отклонён, содержимое объединено в RFC-026: Основной механизм FFI.
См. также:
Аннотация
Данный документ на основе RFC-001, 008, 018进一步细化 и расширяет 并发模型 YaoXiang для решения практических задач: динамическая загрузка модулей, внешний 函数接口 (FFI) и более тонкая 调度优化. Основные 设计 включают:
- Динамический 模块 元数据契约:为同一语言编写的动态库提供编译时依赖描述,使主程序能静态构建 DAG,同时保持透明并发。
- FFI 调度语义:外部函数在 DAG 中默认作为
@block节点,可通过注解融入并行调度(FFI 工具链详见 RFC-021)。 - 基于调用上下文 的优化:取代静态阈值回退,编译器根据函数在 DAG 中的实际角色(消费者数量、副作用等)智能决定是否内联或作为独立节点调度。
- 控制流 与 DAG 的 合并机制:通过 Phi 节点和动态展开,将
if、loop等动态结构自然融入数据流图。 - 运行时 调度器 内存 与 性能优化:明确节点生命周期管理、区域分配、无锁队列等低成本抽象实现。
本文档旨在完善语言规范,确保 YaoXiang 的 并发模型既能应对静态全程序分析,又能灵活处理动态性和外部交互,同时保持高性能和开发体验。
Мотивация
Недостатки现有设计
RFC-001/008/018 构建了优雅的透明 并发模型,但面对现实世界需求仍存在盲区:
- Динамические 模块:当程序支持插件或动态链接库时,主程序无法在编译时获知模块内部的调用关系和依赖,导致全局 DAG 构建失败。
- FFI 调用:外部函数(如 C 库)完全是个黑盒,其内部可能包含并发、阻塞或副作用,直接将其视为普通节点会破坏 并发安全性。
- 小函数 调度开销:RFC-001 提出的"L1 自动回退"采用静态阈值(指令数 <50),这种隐式规则使开发者难以预测行为,且无法适应复杂调用上下文。
- 控制流 与 DAG 的融合:
if、loop等动态结构在 DAG 中的表示尚不明确,可能影响依赖分析的准确性。 - 运行时 开销 控制:随着 DAG 节点数增加,调度器内存管理和性能优化需明确设计,避免成为瓶颈。
Цели
- 在保持透明 并发核心哲学的前提下,为动态 模块和 FFI 提供清晰、安全、渐进式的支持。
- 将 调度优化从"隐式全局规则"转变为"基于上下文 的智能决策",提高可预测性和性能。
- 完善 DAG 对动态 控制流 的表示,确保所有程序结构都能自然融入数据流模型。
- 明确 调度器 的内存管理和性能优化策略,实现低成本抽象。
Предложение
1. Динамический 模块 元数据契约
1.1 契约内容
每个用 YaoXiang 编译的动态库(.yxo / 平台特定动态库)必须附带一份元数据描述文件(.yxmeta),包含:
- 导出 函数列表:每个函数的完整类型签名(参数、返回值、资源标记)。
- 副作用标记:编译器自动推导的
@pure/@io(开发者也可显式覆盖)。 - 资源依赖:每个参数是否为资源类型(如
File),返回值是否包含新资源。 - 调用图摘要(可选):该函数可能调用的其他导出函数 ID,用于跨模块 循环依赖检测。
- 所有权信息:参数的所有权语义(借用/移动),返回值所有权。
- 并发安全:满足
Send/Sync的自动推导结果。
元数据格式采用二进制或结构化文本(如 MessagePack),保证解析效率。
1.2 编译时 处理
主程序编译时,遇到对动态 模块函数的调用:
- 读取对应模块的
.yxmeta文件。 - 在全局 DAG 中创建占位节点,记录从元数据获得的输入输出依赖、副作用标记等。
- 占位节点像普通节点一样参与依赖分析,调度器可提前规划执行顺序。
1.3 运行时 绑定
动态 模块加载时:
- 运行时验证实际函数签名与元数据是否一致(防止版本错配)。
- 将占位节点与实际函数指针绑定。
关于子图 调度语义:如果动态 模块内部有独立的子 DAG(例如模块自身包含并发逻辑),该子图将作为一个独立的 调度单元执行。其边界由模块的导出函数定义:调用导出函数时,子图作为一个整体开始执行,直到该函数返回。子图内部的节点 调度由子图自己的调度器负责(模块内部可继续使用标准调度器),但子图与主 DAG 的交互仅限于输入输出数据流——主 DAG 中的占位节点仅关心子图的开始和结束,不介入其内部调度。这种设计保证了模块的封装性,同时使主 DAG 保持静态完整性。
1.4 安全 保证
- 如果动态 模块违反契约(如声称
@pure却修改全局状态),后果由开发者承担(类似 FFI 的 unsafe 边界)。但因为是同语言,可通过运行时检查(如内存隔离)增强安全性,但会增加开销。 - 跨模块 循环依赖:若模块 A 调用 B,B 又调用 A,且元数据中已声明调用关系,编译器可检测并报错;若未声明,运行时可能发生死锁,由调度器检测并 panic。
2. FFI 在 DAG 中的 调度语义
FFI 的完整工具链支持(动态库加载、绑定生成、类型转换、内存所有权)由 RFC-021 定义。本节仅描述 FFI 调用在 DAG 调度中的行为。
2.1 默认 调度行为
外部函数(通过 native("symbol") 声明)在 DAG 中默认作为 @block 节点:
- 不参与 DAG 并行 调度,直接在当前线程同步执行。
- 执行期间调度器不干预其内部并发。
- 返回值可用,但调用本身不产生依赖边。
2.2 可选 并发标注
开发者可通过注解使 FFI 调用融入 DAG 调度(详见 RFC-021 §2.2):
@pure:视为普通 DAG 节点,可与其他无依赖节点并行。@io:参与资源依赖分析,对同一资源的多次调用自动串行化。
2.3 对 调度器 的影响
FFI 节点在调度器中与普通节点使用相同的 TaskNode 结构,区别仅在于 effect 标记为 Block。调度器在遇到 Block 节点时跳过并行调度,直接同步执行。
3. 基于调用上下文 的优化
取代 RFC-001 中的"L1 自动回退"静态阈值,改为编译器根据每个调用点在 DAG 中的实际上下文智能决策。
3.1 优化 决策依据
编译器分析每个函数调用节点:
- 消费者数量:该节点的结果被多少个下游节点使用。若为 1,则具备内联候选资格;若大于 1,则必须保留为独立节点以便结果共享。
- 副作用:若节点有
@io副作用,则必须保留为独立节点以保证顺序。 - 计算量估算:仍可参考指令数等启发式,但不作为硬性阈值,仅用于内联收益评估。
- 资源依赖:若节点涉及资源变量(如
File),且资源变量在上下游间传递,则内联可能破坏依赖链,需谨慎。
3.2 内联 操作
若决策为内联:
- 将该节点的计算逻辑直接嵌入其唯一下游节点的代码中。
- 从 DAG 中移除该节点,其输入直接成为下游节点的输入。
- 最终代码生成时,内联的函数不会产生独立的 调度单元。
3.3 内联 限制
- 递归函数或循环体内的调用通常不内联,防止无限展开。
- 跨模块边界(动态 模块、FFI)的函数不内联。
- 开发者可通过
@noinline注解强制禁止内联,或@forceinline提示编译器尝试内联。
3.4 可观测性
编译器应生成优化报告(可通过 --emit-optimization-report 开启),包含以下信息:
- 每个内联点:列出被内联的函数名、调用位置、内联原因(例如"唯一消费者且纯函数")。
- 保留为独立节点的原因:例如"有多个消费者""含副作用""跨模块调用"等。
- 决策统计:内联总数、保留节点数,帮助开发者评估优化效果。
报告输出格式可以是文本或 JSON,便于工具解析。
4. 控制流 与 DAG 的 合并
4.1 条件分支(if)的 处理
引入 Phi 节点(借鉴 SSA 形式)表示分支汇合点:
- 编译时为每个
if表达式构建:- 两个分支子 DAG(分别对应
then和else)。 - 一个 Phi 节点,其输入包括条件变量和两个分支的输出。
- 两个分支子 DAG(分别对应
- Phi 节点的语义:当条件变量就绪后,根据条件值选择对应分支的输出作为自己的输出。
- 运行时,Phi 节点依赖条件变量;条件就绪后,它动态地将自己加入所选分支的下游列表,并等待该分支的结果。
示例 DAG:
cond
/ \
then DAG else DAG
\ /
Phi
|
后续节点4.2 循环(loop/while)的 处理
循环视为带有反馈边的子 DAG,运行时按需展开:
- 编译时识别循环体,构建循环模板,包含:
- 条件节点。
- 循环体子 DAG。
- 迭代间传递的状态变量。
- 运行时,当需要循环结果时(例如循环结束后使用累加值),调度器开始动态展开迭代:
- 首次调度条件节点,若为真则实例化第一次迭代的子 DAG,其输入包括初始状态和外部变量。
- 迭代完成后产生新状态,再次调度条件节点(依赖新状态)决定是否继续。
- 重复直至条件为假,最后一次迭代的输出即为循环结果。
复杂示例:循环条件依赖于循环体内部更新
let mut x = 0
while x < 10 {
x = compute(x) // x 在循环体内更新
}此模式中,条件节点 x < 10 依赖于每次迭代后更新的 x。DAG 表示如下:
- 循环模板包含状态变量
x,初始值为 0。 - 每次迭代:先执行条件节点(依赖当前
x),若为真则执行x = compute(x)并产生新x,然后再次进入条件节点。 - 运行时按上述流程动态展开,直到条件为假。
迭代间的依赖通过状态变量自然形成数据流,有依赖的迭代自动串行,无依赖的迭代可并行(如 map)。
4.3 无限循环 的特殊 处理
单个无限循环作为主 DAG 直接同步执行(无 调度开销);多个无限循环作为后台 DAG,由调度器时间片切片 并发执行。
5. 运行时 调度器 内存 与 性能优化
5.1 节点 生命周期 管理
- 每个节点维护一个引用计数(原子变量),表示依赖其结果的消费者数量。
- 节点执行完毕并将结果传递给所有下游后,引用计数归零,节点内存可释放。
- 结果值本身也采用引用计数(
Arc<T>),但可优化:若结果只被一个消费者使用,则直接移动所有权,避免计数开销。
5.2 区域内存分配(Arena)
对于动态生成的大量短生命周期节点(如循环迭代),使用区域分配器:
- 为一次循环展开分配一个内存区域。
- 区域内的节点连续分配,整体释放,减少碎片和释放开销。
- 区域结束时,一次性回收所有节点内存。
5.3 无锁 数据结构
- 依赖计数器:使用
AtomicUsize,通过fetch_sub原子递减。 - 就绪队列:采用 Chase-Lev 双端队列(每个线程本地队列 + 工作窃取),减少锁竞争。
- 下游列表:创建后只读,避免并发修改。
5.4 自适应 调度
- 若系统中只有一个无限循环,直接同步执行,零 调度开销。
- 根据任务粒度和系统负载,动态调节并行度(例如通过监控队列长度调整工作线程数)。
5.5 低成本 抽象原则
所有 调度开销 与任务数量成正比,每个任务的额外开销(创建、入队、依赖处理)控制在几十纳秒级。对于超细粒度任务,通过内联优化(见第3节)避免调度。
详细 设计
6.1 动态 模块 元数据格式(草案)
// 元数据文件结构(简化)
struct Metadata {
version: u32,
functions: Vec<FuncMeta>,
}
struct FuncMeta {
name: String,
signature: TypeSignature,
effects: EffectTag, // Pure | IO | Block
resource_params: Vec<usize>, // 参数索引列表,表示哪些参数是资源类型
calls: Vec<String>, // 调用的其他导出函数名(可选)
ownership: OwnershipInfo,
send_sync: SendSync, // 是否满足 Send/Sync
}6.2 调度器 核心 数据结构
struct TaskNode {
id: TaskId,
deps: Vec<TaskId>, // 上游依赖
remaining_deps: AtomicUsize,
inputs: Vec<Option<Value>>,
result: Option<Value>,
func: Executable,
downstream: Vec<TaskId>, // 下游节点(创建后只读)
effect: EffectTag,
arena_id: Option<ArenaId>, // 所属区域(可选)
}
struct Scheduler {
ready_queues: PerThreadQueue<TaskId>, // 每个线程本地队列
global_work_stealer: WorkStealer,
arenas: ArenaAllocator, // 区域分配器
}6.3 基于上下文 的 优化分析
编译器在 MIR 层进行以下步骤:
- 构建全局调用图和数据依赖图。
- 对每个函数调用节点,计算其出度(消费者数量)。
- 若出度为 1,且函数无副作用(
@pure),且非递归,则标记为"可内联候选"。 - 结合启发式(如指令数)评估内联收益,决定是否内联。
- 内联时,将调用节点的代码嵌入其下游,更新依赖关系。
6.4 控制流 节点 表示
enum NodeKind {
Normal(FuncId),
Phi { cond: TaskId, then_branch: TaskId, else_branch: TaskId },
LoopTemplate { cond: FuncId, body: FuncId, state_var: VarId },
// ...
}运行时动态展开时,LoopTemplate 会生成一系列 Normal 节点实例。
Компромиссы
Преимущества
- 动态 模块 安全集成:元数据契约使动态库能与主程序无缝共享 并发模型,同时保持静态 DAG 的完整性。
- FFI 渐进式融入:开发者可逐步为外部函数添加注解,从安全降级过渡到高效并发。
- 优化 可预测:基于上下文 的决策取代隐式阈值,行为透明,开发者可通过工具理解优化。
- 控制流 自然融入:Phi 节点和动态展开使 DAG 能表示所有程序结构,无需特殊语法。
- 性能 可扩展:区域分配、无锁队列等设计确保调度器能应对大规模并发。
Недостатки
- 元数据契约增加编译复杂度:需要为动态库生成和解析元数据,工具链需支持。
- FFI 注解依赖开发者正确性:错误标注可能导致数据竞争,需通过文档和工具提示降低风险。
- 上下文优化分析耗时:全局分析可能增加编译时间,但可通过增量编译缓解。
- 动态展开增加 运行时开销:循环展开需动态创建节点,但区域分配可缓解。
Стратегия реализации
Этапы(с рекомендуемыми приоритетами)
Рекомендация по приоритету реализации:В начале не обязательно стремиться к идеальной реализации, можно сначала использовать простое решение для запуска системы, а затем постепенно оптимизировать. Например:
- 引用计数可直接使用
Arc.- 无锁队列可使用成熟库(如 crossbeam 的 deque)。
- 区域分配可先使用简单的 bump allocator,后续再优化。
Этап 1:基础支持(v0.7)
- [ ] 实现 FFI 默认降级为
@block. - [ ] 添加
@pure、@io注解供 FFI 使用。 - [ ] 实现资源包装类型(如
File)及其基本方法。
Этап 2:动态 模块 元数据(v0.8)
- [ ] 设计元数据格式,修改编译器为动态库生成
.yxmeta. - [ ] 实现主程序编译时读取元数据并创建占位节点。
- [ ] 实现 运行时 绑定机制。
Этап 3:上下文优化(v0.9)
- [ ] 实现调用图分析,计算节点出度。
- [ ] 添加内联决策和代码生成支持。
- [ ] 实现优化报告输出(含内联点、原因等)。
Этап 4:控制流 DAG 融合(v0.10)
- [ ] 实现 Phi 节点和条件分支的编译时表示。
- [ ] 实现循环模板和动态展开 运行时。
- [ ] 完善无限循环的后台调度。
Этап 5:性能优化(v1.0)
- [ ] 实现区域分配器。
- [ ] 优化无锁队列和工作窃取。
- [ ] 基准测试和调优。
Связь с другими RFC
- RFC-001:扩展了副作用处理和并发层级,用上下文优化替代自动回退。
- RFC-008:补充了动态 模块和 FFI 的 运行时 支持,保持调度器分离设计。
- RFC-018:细化了 DAG 构建和调度器实现,增加了 Phi 节点和动态展开。
Приложение:记录设计 решений
| Решение | Принятое решение | Дата | Ответственный |
|---|---|---|---|
| 动态 模块提供元数据契约 | 采用元数据文件 + 运行时绑定 | 2026-03-14 | 晨煦 |
| FFI 默认降级为 @block | 是,开发者可逐步注解 | 2026-03-14 | 晨煦 |
| 上下文优化取代静态阈值 | 基于出度、副作用等智能决策 | 2026-03-14 | 晨煦 |
| 引入 Phi 节点处理条件分支 | 借鉴 SSA,动态选择分支 | 2026-03-14 | 晨煦 |
| 循环动态展开 | 按需实例化迭代,支持依赖串行 | 2026-03-14 | 晨煦 |
| 区域分配用于短生命周期节点 | 提升内存效率和缓存局部性 | 2026-03-14 | 晨煦 |
