Velox C++实践
Velox 是 Meta 开源的高性能 C++ 数据执行引擎,服务于 Presto、Spark Native Execution 等场景。
代码库约 3800 个源文件,要求 C++20、GCC 11+ 或 Clang 15+。
本文从设计思路和代码技巧两个维度,梳理 Velox 用到的重要 C++ 特性。
一、模板元编程
1.1 SFINAE 与 void_t 类型探测
是什么:SFINAE(Substitution Failure Is Not An Error)是 C++ 模板的一个规则:
当编译器为某个模板做类型替换失败时,不报错,而是直接忽略这个候选。
利用这一规则,可以在编译期”探测”一个类型是否具有某种能力(成员、方法、关联类型),
从而在不修改该类型的前提下,对不同类型走不同的代码路径。std::void_t(C++17)是一个辅助工具:它接受任意数量的类型参数,始终返回 void,
只要括号里的表达式合法,特化就被选中;否则退回 fallback 版本。
解决什么问题:虚函数继承要求被探测的类型必须实现接口,且有运行时开销。
SFINAE 无需修改类型,探测在编译期完成,运行时零开销。
适合”可选接口”场景:有则用,无则走默认路径。
Velox 例子:velox/core/Metaprogramming.h
1 | // 探测 T 是否为 map-like 类型(有 key_type、mapped_type、operator[]) |
括号里三个表达式只要有一个不合法(比如 T 没有 key_type),void_t 整体替换失败,
编译器选择 false_type 版本,不报错。这是 Velox 类型系统用来区分容器类型与标量类型的基础。
1.2 DECLARE_METHOD_RESOLVER + has_method — 编译期方法探测
是什么:在 SFINAE 基础上进一步封装:定义一个”探测器”结构体,
再用 has_method<C, Resolver, RetType, Args...> 判断类型 C 是否拥有指定签名的方法。
解决什么问题:Velox 的 UDF(用户自定义函数)允许用户选择性地实现 callNullable()、initialize()、destroy() 等可选方法。
如果用虚函数,所有 UDF 都要继承并实现一套接口,代价高。
用 has_method,框架在编译期探测 UDF 类是否有这些方法,有就调用,没有就跳过,
生成的代码与手写 if-else 等价。
Velox 例子:velox/core/Metaprogramming.h:146
1 | // 宏:生成一个"方法探测器"结构体 |
UDF 适配层用这个机制在编译期选择调用路径,零运行时分支。
1.3 if constexpr — 编译期分支
是什么:C++17 引入的 if constexpr,让 if 的条件在编译期求值。
被丢弃的分支不会被实例化,也不要求语法正确(只要能解析)。
这和普通 if 的区别在于:普通 if 两个分支都要能通过类型检查,if constexpr 只检查被选中的分支。
解决什么问题:在模板函数里,不同类型需要走完全不同的代码路径,
用普通 if 会因为某个分支对当前类型不合法而编译报错。if constexpr 让模板函数同时处理多种类型,不需要写多个特化版本,代码更集中。
Velox 例子:velox/core/SimpleFunctionMetadata.h:107
1 | // 验证 Variadic 参数只能出现在参数列表末尾 |
另一个例子,velox/vector/DictionaryVector.h:
1 | bool containsNullAt(vector_size_t idx) const override { |
单个函数处理 int64_t 和 ComplexType 两种完全不同的逻辑,编译器为每种类型生成最优代码。
1.4 overloaded — std::variant 多类型访问
是什么:std::variant 是 C++17 的类型安全联合体,用 std::visit 访问。overloaded 是一个常见惯用法:将多个 lambda 合并成一个重载集,每个 lambda 处理一种类型。
它利用了 C++17 的 pack expansion using Ts::operator()... 和 CTAD(类模板参数推导)。
解决什么问题:对 variant 做 std::visit 时,通常需要写一个 visitor 类,
为每种类型写一个 operator()。overloaded 让你直接用 lambda 列表完成,
代码更紧凑,且类型不匹配时编译报错,比 if-else + std::get<> 安全。
Velox 例子:velox/core/Metaprogramming.h:67
1 | // 定义 |
1.5 非类型模板参数 (NTTP) 与”具名模板参数”技巧
是什么:模板参数不一定是类型,也可以是值——整数、枚举、bool、指针等,
称为非类型模板参数(Non-Type Template Parameter, NTTP)。
形式如 template <int32_t POSITION>、template <bool mayHaveNulls>、template <TypeKind Kind>。
这些值在编译期就确定,编译器为每个不同的值生成一份独立的代码。
给 NTTP 起一个有意义的名字(而不是 template <int N> 里的 N),
是一种重要的可读性技巧:template <bool mayHaveNulls> 一眼就能看出这个开关控制什么,
比注释更可靠。
解决什么问题:热路径上常有”配置开关”——是否处理 null、是否是 join 模式、
是否用行号索引。如果用运行时 if (mayHaveNulls),每行数据都要判断一次,
且分支预测有成本。
把开关提升为 NTTP,编译器为”有 null”和”无 null”各生成一个特化版本,
每个版本内部的 if constexpr (mayHaveNulls) 在编译期就被消解,
运行时是两段没有分支的直线代码。这是”用代码膨胀换取分支消除”的经典权衡。
Velox 例子:velox/exec/RowContainer.h:1025,velox/exec/HashTable.h:1060
1 | // 具名 bool NTTP:useRowNumbers 和 Kind 都是编译期常量 |
template <int32_t POSITION> 的递归用法见 1.3 节的 isValidArg——POSITION 是编译期整数,每次递归 isValidArg<POSITION + 1>() 实例化一个新的特化,
直到 POSITION >= num_args - 1 终止。整个验证在编译期完成,运行时零代码。
1.6 全特化 vs 偏特化 — 类模板的两种特化
是什么:模板特化(Specialization)是为特定模板参数提供专门实现。分两种:
- 全特化(Full / Explicit Specialization):所有模板参数都被指定具体值,
写法template <> struct Foo<int> {...}。它不再是模板,是一个具体类型。 - 偏特化(Partial Specialization):只指定部分参数,或对参数施加结构约束
(如”任意shared_ptr<T>“),写法template <typename T> struct Foo<shared_ptr<T>> {...},
特化后仍然是模板(还剩T没定)。
解决什么问题:主模板提供”通用但可能无意义”的默认行为,
特化为特定类型提供正确/高效的实现。
全特化处理”某个确切类型”,偏特化处理”某一类符合某种结构的类型”。
Velox 例子:velox/type/CppToType.h:24
CppToType<T> 把 C++ 类型反向映射到 Velox 的 TypeKind,是 UDF 模板系统的基石:
1 | // 主模板:空的,故意不可用——传入未知类型会编译报错(而非默默出错) |
TypeTraits<TypeKind::BIGINT>(见 2.1 节)则是
对 NTTP 做全特化:主模板 template <TypeKind KIND> struct TypeTraits {} 是空的,
每个 TypeKind 枚举值都有一个 template <> struct TypeTraits<TypeKind::XXX> 全特化。
另一个偏特化例子——用 enable_if 对一类类型做特化:velox/type/Type.h:701
1 | // 主模板:默认所有类型都不支持自定义比较 |
1.7 函数模板不能偏特化 — 一个关键限制及 Velox 的应对
是什么:这是 C++ 一个容易踩坑的规则——
类模板可以偏特化,但函数模板不能偏特化。
你可以全特化一个函数模板(template <> void f<int>()),
但不能写 template <typename T> void f<std::vector<T>>()(偏特化),这是语法错误。
为什么有这个限制:函数有重载机制,而类没有。
C++ 标准委员会认为函数偏特化会与重载决议规则纠缠不清
(重载 + 偏特化的优先级难以定义清楚),因此干脆禁止函数偏特化,
让你用重载来达到同样目的。
应对方式(Velox 中都有体现):
方式一:函数重载 —— 最直接,用参数类型区分而非特化
1 | // 不要试图偏特化,直接重载 |
方式二:委托给类模板的静态方法 —— 类能偏特化,把逻辑搬进类
1 | // 想"偏特化"一个函数?把它包成类模板的 static 方法 |
CppToType(1.6 节)正是这个模式:与其偏特化函数,不如偏特化 struct CppToType<shared_ptr<T>>。
方式三:if constexpr 内部分发 —— 一个函数模板内用编译期分支代替多个特化
1 | // velox/type/Type.h:2607 —— 一个 valueToString 处理所有类型,不写任何特化 |
方式四:宏生成的运行时→编译期 dispatch —— 把运行时 TypeKind 转成编译期模板实参
这是 Velox 最核心的桥梁。一个函数模板按 TypeKind 参数化,但 TypeKind 是运行时值,
无法直接当模板实参。VELOX_DYNAMIC_TYPE_DISPATCH 宏用一个 switch 把运行时值
“翻译”成编译期模板实参:velox/type/Type.h:2197
1 | // 宏展开成一个 switch,每个 case 实例化一份特定 Kind 的模板函数 |
这个宏是连接”运行时动态类型 SQL”和”编译期零开销模板代码”的关键:
SQL 引擎在运行时才知道列类型,宏负责把这个运行时信息一次性转换为编译期的模板实例化,
之后所有操作都是类型特化的高效代码。
二、类型系统
2.1 TypeKind 枚举 + TypeTraits 特化
是什么:C++ 模板特化(Template Specialization)允许为特定类型参数提供完全不同的实现。
与枚举结合,可以把运行时的类型标签(TypeKind::BIGINT)映射到编译期的 C++ 类型(int64_t),
形成一个”双轨并行”的类型系统:枚举用于运行时 switch,模板特化用于编译期泛型代码。
解决什么问题:查询引擎需要在运行时处理未知类型,也需要在编译期为每种类型生成高效代码。
如果只有运行时类型,就全是虚函数和类型转换,性能差。
如果只有编译期类型,就无法处理用户的动态 SQL。
TypeKind + TypeTraits 的组合让两者共存:外层按 TypeKind 分发,内层是类型安全的模板代码。
Velox 例子:velox/core/Type.h
1 | // 每种 TypeKind 都有一个特化,描述它的 C++ 原生类型 |
运行时拿到 TypeKind,用 VELOX_DYNAMIC_TYPE_DISPATCH(func, kind, args...) 宏展开成
一个 switch,每个 case 实例化对应的模板函数,从而把运行时类型转换为编译期类型。
2.2 DECLARE_CONDITIONAL_TYPE_NAME — 可选类型别名
是什么:SFINAE 的一个具体应用模式:检查类型 T 是否有内嵌类型 TypeName,
有则用它,没有则退回默认的 OtherTypeName。
这是实现”可选接口”的编译期版本。
解决什么问题:UDF 可以声明 return_type 来覆盖框架推断的返回类型,也可以不声明。
框架需要在编译期判断用哪个,且不能让 UDF 必须继承某个基类来声明这个类型。
Velox 例子:velox/core/Metaprogramming.h:158
1 |
|
三、内存管理
3.1 MemoryPool — 层级内存池
是什么:内存池(Memory Pool)是一种预先向 OS 申请一大块内存,
然后在内部切割分配的技术。与每次 malloc/free 打交道相比,
内存池的分配/释放开销接近零,且可以批量归还,还能精确追踪使用量。
解决什么问题:查询引擎的每个算子(HashAgg、Sort、Join)在执行期间会大量小规模分配内存。
如果每次都走系统 malloc,锁竞争和碎片化会严重影响性能。
此外,多租户场景需要按查询追踪和限制内存用量。
MemoryPool 同时解决了性能和可观测性两个问题。
Velox 例子:velox/common/memory/MemoryPool.h
Velox 的 MemoryPool 是树形结构:根节点 → 查询节点 → 算子叶子节点。
每个叶子节点独立分配,父节点汇总统计,支持内存仲裁(当内存紧张时,
仲裁器可以强制某个算子 spill 到磁盘)。
1 | // 根池:全局唯一,向 OS 申请内存 |
3.2 raw_vector<T> — 带池分配且 SIMD 友好的向量
是什么:raw_vector<T> 是 Velox 自己实现的向量容器,接口类似 std::vector,
但有两处关键不同:从 MemoryPool 分配而不是 malloc;
在数据区首尾各填充一个 SIMD 宽度的 padding,允许以全 SIMD 宽度安全读写边界元素。
同时它要求 T 必须是 trivially copyable + trivially destructible(用 static_assert 强制),
因此不调用构造/析构,resize 直接 memset。
解决什么问题:std::vector 的两个问题:一是用 malloc 而非内存池,
二是边界元素无法用 SIMD 指令安全读(可能越界)。
哈希表、Join 等热路径需要既能用池分配,又能在末尾做 SIMD 操作而不越界。
Velox 例子:velox/common/memory/RawVector.h:34
1 | template <typename T> |
3.3 STL Allocator 适配 — 标准容器接入内存池
是什么:C++ 标准库容器(std::vector、std::unordered_map 等)
都接受一个 Allocator 模板参数来定制内存分配行为。
通过提供符合 std::allocator_traits 协议的自定义 Allocator,
可以让标准容器从任意内存源(内存池、mmap、GPU 内存)分配。
解决什么问题:聚合、字典等数据结构既需要与标准库容器互操作,
又需要受内存池管理。如果强制用 raw_vector,就无法用 std::map 等复杂容器。
通过 Allocator 模板参数,同一套数据结构既能在测试中用 std::allocator,
又能在生产中接入 MemoryPool。
Velox 例子:velox/common/hyperloglog/DenseHll.h
1 | template <typename TAllocator> |
四、错误处理
4.1 分层异常体系 + [[noreturn]] 出错路径优化
是什么:C++ 的 [[noreturn]] 属性告诉编译器:这个函数永远不会正常返回(总是抛异常或终止)。
编译器利用这个信息,可以在调用点省略”函数返回后”的代码路径,
使得包含错误检查的小函数仍然可以被内联,而不因为 throw 路径膨胀代码体积。
解决什么问题:错误检查宏如果直接在 macro 里写 throw,会使展开点的函数体积膨胀,
导致原本应该内联的热路径函数无法内联。
把 throw 提取到 [[noreturn]] 的 out-of-line 函数,编译器在调用点只生成一条跳转指令,
热路径体积不变,仍能内联。
Velox 例子:velox/common/base/Exceptions.h:50
1 | // 抛异常逻辑完全提取到 out-of-line 函数 |
4.2 两类错误宏 — 系统错误 vs 用户错误
是什么:Velox 把异常分为两类:VeloxRuntimeError(内部 bug,不可重试)
和 VeloxUserError(SQL 语义错误,不应打 ERROR 日志,可向用户返回友好消息)。
对应两套宏,接口完全相同,只是异常类型不同。
解决什么问题:数据库引擎里,”除以零”是用户的 SQL 写错了,
不应该在服务器日志里打 ERROR;而”哈希表内部状态不一致”是 bug,必须打 ERROR 并报警。
如果混用,要么日志噪音太大,要么真正的 bug 被淹没。
Velox 例子:velox/common/base/Exceptions.h:399
1 | // 系统错误(内部 bug)— 打 ERROR 日志,抛 VeloxRuntimeError |
4.3 VELOX_DCHECK — 仅在 Debug 模式有效的检查
是什么:条件编译的防御性检查:Debug 构建展开为真正的 CHECK,
Release 构建(NDEBUG 定义时)展开为 VELOX_CHECK(true),即什么都不做。
解决什么问题:热路径上的防御性检查(比如每行数据的边界检查)
在生产环境开销不可接受,但在开发和测试阶段非常有价值。
DCHECK 让这类检查在 Debug 下充分发挥作用,Release 下自动消失,不需要手动注释掉。
Velox 例子:
1 | // 每次访问向量元素时检查下标(每行都会执行,生产环境必须零开销) |
五、SIMD 与向量化
5.1 xsimd — 跨平台 SIMD 抽象
是什么:SIMD(Single Instruction Multiple Data)是一类 CPU 指令,
一条指令同时操作多个数据元素(比如 AVX2 一次处理 256 位,即 32 个 int8)。
直接写 _mm256_* 这样的 intrinsic 代码只能在 x86 运行,换 ARM 就要重写。
xsimd 是一个 header-only 库,提供跨平台的 SIMD 类型和操作,
编译时根据目标架构选择对应的指令集。
解决什么问题:哈希表查找、排序、过滤等热路径的瓶颈往往是”逐元素比较”。
SIMD 能一次比较 16 或 32 个元素,使吞吐量提升数倍。
用 xsimd 抽象层,同一份代码在 x86(SSE2/AVX2)和 ARM(NEON)上都能跑,
不需要维护两套平台代码。
Velox 例子:velox/exec/HashTable.h:131
哈希表每个 bucket 有一个 1 字节的 “tag”,存储哈希值的低 8 位。
查找时用 SIMD 一次比较 16 个 tag,只有 tag 匹配的 bucket 才做完整比较:
1 | // 根据编译目标自动选择 SIMD 类型 |
传统逐个比较需要 16 次迭代;SIMD 只需 1 次,且编译器能自动展开循环。
5.2 SelectivityVector — 位集合批处理
是什么:SelectivityVector 是一个位图(bitset),记录当前批次中哪些行被”选中”。
算子之间传递这个位图而不是重排物理数据,过滤掉的行只是位被清零,
数据仍在原位,不需要 memcpy。
位图操作(AND、OR、NOT)可以用 SIMD 一次处理 64 行。
解决什么问题:朴素实现里,每个 filter 会把通过的行拷贝到新数组,
多个 filter 叠加就有多次拷贝。位图方案让多个 filter 的结果只需做位运算合并,
数据零拷贝,且批量迭代时 CPU 分支预测压力小。
Velox 例子:velox/vector/SelectivityVector.h:39
1 | class SelectivityVector { |
5.3 AVX2 Intrinsics — 解码热路径
是什么:在特别关键的热路径上,xsimd 的抽象层可能引入一点点开销,
此时直接用编译器 intrinsic(如 _mm256_*)写 AVX2 代码。
这是最低层的 SIMD 编程,完全控制生成的指令。
解决什么问题:Parquet/ORC 文件大量使用 bit-pack 压缩:
将多个小整数打包到连续位中,读取时需要解包。
这个解包操作是 IO 的最后一道开销,如果用标量代码逐个解,会浪费大量 CPU。
AVX2 一条指令可以解包 16 个值,是标量代码的 8-16 倍吞吐量。
Velox 例子:velox/dwio/common/BitPackDecoder.h
1 | // 将 16 个 uint8 零扩展为 16 个 int16,存入 256 位寄存器 |
六、性能工程
6.1 FOLLY_ALWAYS_INLINE 与 FOLLY_LIKELY/UNLIKELY
是什么:
FOLLY_ALWAYS_INLINE:对应__attribute__((always_inline)),强制编译器内联函数,
无论函数体多大,无论编译器的内联启发式认为是否合适。FOLLY_LIKELY/UNLIKELY:对应__builtin_expect,向编译器提示分支概率,
让编译器把”大概率”分支的代码排在前面,减少 CPU 分支预测错误(misprediction)。
解决什么问题:编译器的内联决策基于代码大小启发式,
有时会拒绝内联本应内联的关键函数(比如一个简单的 compare 函数因为调用了另一个函数而被拒绝)。
分支预测错误每次有 10-20 个时钟周期的惩罚;在”检查是否为 null”这种每行都执行的代码里,
正确的分支提示能带来明显提升。
Velox 例子:velox/exec/PrefixSort.h:130,velox/exec/TableWriter.h:68
1 | // 排序比较函数:必须内联,否则每行排序都有函数调用开销 |
6.2 移动语义与完美转发
是什么:C++11 的移动语义(std::move)和完美转发(std::forward)
允许在不拷贝数据的前提下转移对象所有权。
移动操作通常只需要交换几个指针,是 O(1) 操作,而拷贝是 O(n)。
完美转发(配合 && 引用折叠)让包装函数可以把参数”原样”传递给内层函数,
不引入额外拷贝。
解决什么问题:查询引擎里大量传递 std::vector<RowVectorPtr>、std::shared_ptr<PlanNode> 等”重”对象。
如果按值传递且不移动,每次调用都要拷贝整个 vector(可能含数百 MB 数据)。
移动语义让所有权转移从 O(n) 降到 O(1)。
Velox 例子:velox/core/PlanNode.h,velox/core/Expressions.h:211
1 | // Builder 模式:接受右值,所有权转移,不拷贝 |
6.3 constexpr 与编译期常量
是什么:constexpr 变量和函数在编译期求值,结果直接嵌入可执行文件。constexpr 常量放在 .rodata 段,CPU 第一次访问后会缓存在指令缓存中,
后续访问几乎零延迟。
解决什么问题:热路径上如果有”计算一个只依赖于类型或配置的常量”的操作,
每次调用都重算是浪费。编译期常量消除这类开销,同时让代码保持可读。
Velox 例子:velox/exec/HashTable.h:140
1 | // 哈希表参数:编译期固定,编译器可以用于优化(比如 resize 的乘法可以用 shift 替代) |
七、Folly 库深度集成
7.1 folly::Synchronized<T> — 数据与锁绑定
是什么:folly::Synchronized<T> 将一个值和一把锁打包在一起,
强制所有访问都必须先获取锁。它提供 rlock()(读锁)和 wlock()(写锁),
返回 RAII 守卫,守卫销毁时自动释放锁。
解决什么问题:传统的 mutex + data 模式,数据和锁是独立的成员,
调用者可能”忘记加锁”就直接访问数据,且代码审查时很难发现。Synchronized 让访问数据的唯一方式就是先拿锁,从语言层面杜绝忘加锁。
Velox 例子:velox/exec/Operator.h:358,velox/exec/OutputBufferManager.h:138
1 | // Operator 统计信息:多线程读写,Synchronized 保证安全 |
7.2 folly::F14FastMap/Set — SIMD 加速的哈希容器
是什么:F14 是 Facebook 开发的开放寻址哈希表,每个 “chunk” 存 14 个条目,
chunk 的 tag 字节用 SIMD 指令一次比较 14 个,找到匹配的 slot。
相比 std::unordered_map(链地址法,大量指针追逐),
F14 的 cache 局部性更好,find 操作快约 2-3x,内存占用也更低。
解决什么问题:std::unordered_map 每个桶是一个链表,
查找时要追踪指针,对 CPU cache 极不友好。
执行引擎里哈希表是高频操作(Join、GroupBy、分区路由),
哈希容器的性能直接影响整体吞吐。
Velox 例子:velox/exec/Spiller.h:163,velox/exec/Task.h:1458
1 | // Spiller 用 F14FastMap 追踪每个分区的 spill 状态 |
7.3 folly::Executor — 解耦执行引擎与线程调度
是什么:folly::Executor 是一个抽象接口,表示”可以提交任务的执行环境”,
子类可以是线程池、单线程队列、inline executor 等。
代码面向 Executor* 编程,不直接创建线程。
解决什么问题:执行引擎如果自己管线程,会和上层框架(Presto、Spark)的线程池冲突,
造成线程数膨胀和调度混乱。
通过接受 folly::Executor*,Velox 把线程调度的控制权完全交给调用方,
自己只负责”提交任务”。
Velox 例子:velox/exec/HashTable.h:368
1 | // 哈希表并行构建:executor 由调用方提供(可能是 Presto 的线程池) |
八、设计模式
8.1 Builder 模式 + std::optional 延迟校验
是什么:Builder 模式将一个复杂对象的构建过程与其表示分离,
允许分步骤设置参数,最后调用 build() 得到完整对象。
用 std::optional 标记每个参数是否已设置,build() 统一校验必填项,
而不是在每个 setter 里提前校验(彼时上下文不完整)。
解决什么问题:PlanNode 有十几个参数,部分必填、部分可选,且参数之间有约束。
用构造函数直接传参,参数多了难以阅读且容易搞混位置。
Builder 让每个参数有名字,且 build() 是产生合法对象的唯一入口,
不可能产生”半初始化”的 PlanNode。
Velox 例子:velox/core/PlanNode.h:348
1 | class ValuesNode::Builder { |
8.2 Visitor 模式 — Plan Tree 遍历
是什么:Visitor 模式将”对数据结构的操作”与”数据结构本身”解耦。
数据结构(PlanNode、Expr)只提供 accept(visitor) 接口,
具体操作(打印、代价估计、验证、代码生成)由不同的 Visitor 类实现。
添加新操作不需要修改节点类,符合开放/封闭原则。
解决什么问题:查询计划有十几种节点类型,对计划的操作(优化、打印、序列化)有几十种。
如果把所有操作都放在节点类里,每个节点类会变得臃肿,且每次新增操作都要改所有节点类。
Visitor 让操作独立,互不干扰。
Velox 例子:velox/core/PlanNode.h:184
1 | // 节点只声明 accept,不知道 visitor 会做什么 |
8.3 Strategy 模式 — Connector 插件体系
是什么:Strategy 模式将一类算法(数据读取、写入)抽象为接口,
不同实现(Hive、Iceberg、自定义)作为可替换的”策略”插入。
调用方面向接口编程,不感知底层实现。
解决什么问题:Velox 需要同时支持 S3、GCS、HDFS、本地文件、Iceberg 等数据源,
每种数据源的 IO 细节完全不同。
通过 Connector 接口,执行引擎与存储完全解耦,新增存储只需实现接口,
不需要修改执行引擎代码。
Velox 例子:velox/connectors/Connector.h
1 | // 抽象接口:执行引擎只认识这个 |
九、向量化执行引擎
9.1 多级编码 Vector 体系
是什么:Velox 的列向量(BaseVector)支持多种物理编码:
Flat(连续存储)、Dictionary(索引 + 去重值)、Constant(整列同值)、
Bias(整数偏移压缩)、LazyVector(延迟 IO)。
这些编码可以嵌套(Dictionary of Dictionary of Constant),
而执行引擎不需要知道底层编码,通过统一的 valueAt(idx) 接口访问。
解决什么问题:实际数据中大量重复值(城市、类别、枚举)用 Dictionary 编码可以节省内存;
常量折叠后整列相同的值用 Constant 编码,算子可以直接跳过;
IO 时用 LazyVector 延迟加载,只有真正被引用的列才读磁盘。
统一接口让算子代码不因编码类型而分叉。
Velox 例子:velox/vector/DictionaryVector.h
1 | // Dictionary 编码:indices_[i] 指向 dictionaryValues_ 中的唯一值 |
9.2 DecodedVector — 透明展平多级编码
是什么:DecodedVector 是一个”解码视图”,
将任意层数的嵌套编码(Dict of Dict of Constant of Flat)展平为一个连续的逻辑视图。
它在构造时遍历编码链,合并索引和 null bitmap,之后访问是 O(1) 的。
内存从系统分配器分配(而非内存池),因为它可以在多次调用间复用。
解决什么问题:如果每个算子都要自己处理嵌套编码,代码会极其复杂,
且很容易遗漏某种编码组合。DecodedVector 提供统一入口,算子只需要一行 DecodedVector decoded(*vec, rows),
之后就像访问 FlatVector 一样访问数据。
Velox 例子:velox/vector/DecodedVector.h
1 | // Filter 算子:不管输入是什么编码,DecodedVector 统一处理 |
9.3 LazyVector — 延迟物化与 IO
是什么:LazyVector 是一种特殊的 Vector,内部存储一个”加载器”(loader),
而不是实际数据。数据只在第一次被访问时(调用 loadedVector())才真正执行 IO。
解决什么问题:TableScan 读取一个包含 100 列的宽表,SQL 只用了 5 列。
如果 scan 阶段就把所有 100 列都读出来,磁盘 IO 和内存都是严重浪费。LazyVector 让 scan 先生产占位符,Filter/Project 先用已物化的列做谓词下推,
只有通过过滤的行的所需列才触发 IO,实现精确的行列裁剪。
Velox 例子:velox/vector/LazyVector.h
1 | // TableScan 生产 LazyVector,内含 IO 加载器 |
十、Arrow 零拷贝集成
10.1 Arrow C Data Interface
是什么:Arrow C Data Interface 是一个二进制级别的协议,
允许两个进程或两个库在同一进程内共享 Arrow 格式的列数据,
而不需要序列化/反序列化,也不需要拷贝数据。
协议通过两个 C 结构体(ArrowSchema 和 ArrowArray)定义数据布局,
以及一个 release 回调管理内存所有权。
解决什么问题:不同的数据引擎(Velox、DuckDB、DataFusion、pandas)
内部表示不同,互操作时必须序列化+拷贝,对大数据集代价极高。
Arrow C Data Interface 让它们共享同一块内存,任何一方都可以安全持有引用,
最后持有引用的一方通过 release 回调释放内存。
Velox 例子:velox/vector/arrow/Bridge.h
1 | // Velox → Arrow:转移所有权,不拷贝任何数据 |
十一、并发设计
11.1 原子变量 — 无锁计数器与状态机
是什么:std::atomic<T> 提供对 T 的原子读/写/CAS(Compare-And-Swap)操作,
不需要锁。对于简单的计数器,fetch_add 是一条 CPU 指令(LOCK XADD),
比 mutex + ++counter 轻量很多。
解决什么问题:多线程并发统计(如 IO 耗时、行数、字节数)如果用锁,
锁竞争本身会成为瓶颈。原子变量让每个线程各自更新,CPU 硬件保证原子性。
状态机的状态转移用 CAS 保证”只有一个线程成功转移”,无需锁。
Velox 例子:velox/connectors/hive/FileDataSource.h
1 | // IO 线程并发累加耗时,memory_order_relaxed 足够(只要最终一致) |
11.2 Task / Driver / Operator 无共享并发模型
是什么:Velox 采用 shared-nothing 并发模型:
- Task:一个查询分片,拥有若干 Pipeline
- Driver:对应一个执行线程,拥有一条 Operator 链,单线程顺序执行
- 同一 Task 内的多个 Driver 之间通过有界内存队列交换数据,不共享可变状态
这与 Golang 的 goroutine + channel 模型思路相同,把并发隔离为消息传递。
解决什么问题:多线程共享可变状态需要大量锁,锁的粒度设计复杂,
死锁和竞争条件难以调试。
shared-nothing 模型让每个 Driver 是单线程的,
大多数代码不需要考虑并发,只有 Driver 边界(队列)需要同步原语。
Velox 例子:执行一个有 4 个 Driver 的 HashAgg:
1 | Driver 0: Scan → Filter → Project → PartialAgg ─┐ |
Driver 0-3 完全独立,各自用各自的内存池、各自的向量。
唯一的同步点是 Exchange Queue(folly::MPMCQueue 或类似结构)。
FinalAgg 的 Driver 是单消费者,不需要在 Operator 内部加锁。
十二、CRTP — 静态多态与可复用 Builder
是什么:CRTP(Curiously Recurring Template Pattern,奇异递归模板模式)
是让派生类把自身类型作为模板参数传给基类:class Derived : public Base<Derived>。
基类通过 static_cast<Derived&>(*this) 向下转型,从而在不使用虚函数的情况下
实现”基类调用派生类方法”的效果,即静态多态。
解决什么问题:PlanNode 的 Builder 模式面临代码复用困境:HashJoinNode::Builder、ProjectNode::Builder、MergeJoinNode::Builder
都有相同的 id()、source() 等 setter,直接用继承会导致 setter 返回基类引用,
链式调用断掉(builder.id("x").source(src).leftKeys(...) 中 id() 返回 Base&,
找不到 leftKeys())。
CRTP 让基类 setter 返回 DerivedBuilder&,派生 Builder 无需重写这些 setter。
Velox 例子:velox/core/PlanNode.h:770
1 | // 基类 Builder:DerivedPlanNode 是最终节点类型,DerivedBuilder 是子类 Builder |
enable_shared_from_this 是另一处经典 CRTP:
1 | // velox/core/QueryCtx.h |
十三、Policy-based Design — 编译期行为注入
是什么:策略模式的编译期版本:将可变行为(算法参数、开关选项)
定义为一个只含 static constexpr 成员的空结构体(Policy Struct),
作为模板参数注入到实现类中。
不同的 Policy 产生不同的特化版本,编译器在实例化时直接把 Policy 的常量内联进代码,
不需要运行时 if-else,也没有虚函数调用。
解决什么问题:Presto 和 Spark 的类型转换规则不同(截断行为、Unicode 处理、遗留模式),
如果用运行时 flag 区分,每次类型转换都要多一次分支。
用 Policy 模板参数,Presto 路径和 Spark 路径在编译期就分开,
各自生成最优代码,无运行时判断开销。
Velox 例子:velox/type/Conversions.h:36
1 | // 三种 Policy:只有 constexpr 常量,无数据成员,大小为零 |
十四、StringView — 小字符串优化 (SSO) 与前缀缓存
是什么:小字符串优化(SSO,Small String Optimization)是一种将短字符串
直接存储在对象本体内(避免堆分配)的技术,std::string 通常也内置了 SSO。
Velox 的 StringView 在此基础上增加了”前缀缓存”:
对于长字符串,除了存储堆指针,还缓存前 4 个字节。
比较时先比长度,再比前缀,前缀不等则直接 short-circuit,
只有前缀相等才做完整比较,大幅减少缓存 miss。
解决什么问题:列式引擎里字符串比较是高频操作(Join、Sort、Filter)。
普通 std::string 每次比较都要追踪堆指针,对大字符串集合 cache miss 严重。StringView 的 16 字节固定大小使其可以存进向量,前缀缓存让绝大多数不等比较
只需看前 8 字节(4 字节大小 + 4 字节前缀),不需要访问堆内存。
Velox 例子:velox/type/StringView.h:72
1 | struct StringView { |
= delete 禁用右值重载是一种主动防御:
编译器会在你写 StringView sv = std::string("tmp") 时报错,
而不是让你创建一个指向已析构内存的悬空指针。
十五、folly::Expected<T, E> — 无异常错误传播
是什么:folly::Expected<T, E>(类似 Rust 的 Result<T, E>、C++23 的 std::expected)
是一个类型安全的”要么值要么错误”容器。
函数返回 Expected<T, E> 而不是抛异常,调用方通过 .value()、.error()、.hasValue() 或 folly::makeUnexpected() 处理结果。
错误在调用链上是显式的、可组合的,不需要 try-catch。
解决什么问题:在底层 IO 和压缩路径上,异常代价高(栈展开、堆分配)
且难以与 C 库或 GPU 代码集成。Expected 让错误以返回值形式传播,零开销(success 路径就是普通函数返回),
且调用者被迫处理错误(不像异常可以悄悄向上传播)。
Velox 例子:velox/common/compression/Compression.h:78,velox/type/Conversions.h:68
1 | // Codec 接口:创建可能失败,但不抛异常 |
十六、[[nodiscard]] — 强制检查返回值
是什么:C++17 属性 [[nodiscard]],可以标注在函数或整个类上。
标注后,如果调用方忽略返回值(不用变量接收),编译器发出警告。
标注在类上时,任何返回该类型的函数都自动带有 nodiscard 语义。
解决什么问题:Status 类表示操作结果,如果调用方忘记检查
(常见 bug:file.close(); // 返回的 Status 被丢弃),
错误会悄悄被忽略。[[nodiscard]] 把这类 bug 提升为编译期警告,
不需要运行时才能发现。
Velox 例子:velox/common/base/Status.h:194
1 | // 整个类标注 [[nodiscard]]:所有返回 Status 的函数都被监控 |
同理,ExceptionContextSetter 也标注了 [[nodiscard]]:
1 | class [[nodiscard]] ExceptionContextSetter { |
十七、folly::Range — 非拥有数组视图
是什么:folly::Range<T*> 是一个非拥有的连续内存视图,
存储 (begin, end) 指针对,提供类似 std::span(C++20)的接口。T 可以是 char*、const int32_t* 等任意指针类型。
它不管理内存生命期,只是一个”窗口”。
解决什么问题:传递数组时常见两种做法:
- 传
vector&:强制调用方用 vector,且可能触发拷贝 - 传
T* + size:不安全,容易越界
folly::Range 是两者的替代:零拷贝、边界安全、与 std::vector/std::array/原始数组都兼容。
Velox 用它替代裸指针对,让接口更清晰、更难出错。
Velox 例子:velox/exec/RowContainer.h:41
1 | // RowContainer 的接口:操作一批行指针,不拥有数据 |
十八、结构化绑定 (Structured Bindings) — 多返回值
是什么:C++17 的结构化绑定(auto [a, b] = expr)允许把std::pair、std::tuple、结构体、std::map::insert 返回值等
一次性解构为多个具名变量,不再需要 .first/.second 或 std::get<0>。
解决什么问题:返回多个值的函数在 C++17 前通常用 std::pair 或 out 参数,
访问时要写 .first/.second,代码难以阅读(看不出语义)。
结构化绑定让返回多值的函数变得像 Python tuple unpacking 一样直观。
Velox 例子:
1 | // FileSplitReader.cpp:解构 decimal 类型的精度和小数位 |
十九、noexcept — 移动语义与性能保证
是什么:noexcept 声明函数不会抛异常。它有两个作用:
- 性能:
std::vectorresize 时,只有移动构造函数标记了noexcept,
才会用移动而非拷贝(否则为保证异常安全必须拷贝)。 - 文档:告诉调用方和编译器这个路径不会有异常,
编译器可以省略异常处理 prologue/epilogue,生成更小的代码。
解决什么问题:Status 需要放进 std::vector,如果移动构造不是 noexcept,
vector 扩容时会拷贝所有元素(O(n) 拷贝 vs O(1) 移动)。
此外,默认构造函数(创建 OK 状态)是极高频操作,constexpr + noexcept 让它在编译期完成。
Velox 例子:velox/common/base/Status.h:197
1 | class [[nodiscard]] Status { |
二十、类型双关 (reinterpret_cast) — 位级操作
是什么:类型双关(Type Punning)是将一块内存重新解释为不同类型,
在 C++ 中通过 reinterpret_cast 实现(C++20 起推荐用 std::bit_cast,更安全)。
用途:把 float 的位模式当 uint32_t 处理,或从字节流读取结构体,
而不触发真正的类型转换(不改变位的值,只改变解释方式)。
解决什么问题:哈希函数需要把 float/double 的位模式当整数处理
(否则 NaN 有无数种位模式,+0 和 -0 位模式不同但语义相等)。
序列化需要把结构体直接按字节写出。
这些操作用 static_cast 会做数值转换,用 reinterpret_cast 则直接重解释位模式。
Velox 例子:velox/connectors/hive/HivePartitionFunction.cpp:28
1 | // double 的哈希:把 64 位浮点的位模式当 uint64_t 处理 |
二十一、constexpr 查找表 — 以空间换时间
是什么:把需要在运行时计算的映射关系预计算成 constexpr 数组,
存放在 .rodata 段(只读数据段)。
程序启动时数组已就绪,访问只需一次数组下标,
且因为数组固定在 .rodata,CPU L1 cache 能长期持有热点部分。
解决什么问题:Parquet/ORC 的 bit-pack 解码需要对每种位宽(1-32 bit)
生成 PDEP 指令的掩码。如果每次解码都现算,需要复杂的位移运算;
预计算成 256 字节表,查找只需 kPdepMask8[width],一条内存读指令。
Velox 例子:velox/dwio/common/BitPackDecoder.h:30
1 | // PDEP 掩码表:对每种位宽(0-8),预算好用于 _pdep_u64 的掩码 |
二十二、std::call_once / folly::once_flag — 线程安全单次初始化
是什么:std::call_once + std::once_flag(或 folly 的版本)
保证一段初始化代码在多线程环境中只执行一次,即使多个线程同时到达。
执行完成后其他线程直接继续,不需要每次都加锁检查。
解决什么问题:全局资源(文件系统注册、连接器注册、函数库初始化)
需要懒初始化(第一次用时才初始化,而非程序启动就初始化),
且多个线程可能同时触发初始化。
用普通 mutex + bool flag 容易写出 double-checked locking 的 bug,call_once 提供了无 bug 的标准实现。
Velox 例子:velox/common/file/FileSystems.cpp:79,velox/connectors/hive/iceberg/IcebergConnector.cpp:33
1 | // 本地文件系统:第一次调用时注册,之后跳过 |
二十三、Template Template Parameters — 类型变换框架
是什么:模板的模板参数(Template Template Parameter)允许把一个模板本身
(而非模板的某个实例)作为参数传递。
语法:template <template <typename> class Container>,
即 Container 本身是一个接受一个类型参数的模板,而非某个具体类型。
解决什么问题:tuple_xform 需要对 tuple 中的每种类型应用同一个变换模板
(比如把 tuple<int, string> 变成 tuple<Optional<int>, Optional<string>>),
但变换本身是参数化的(可以是 Optional、shared_ptr、Nullable 等)。
用 template template parameter,一套代码处理任意变换,不需要为每种变换写特化。
Velox 例子:velox/core/Metaprogramming.h:30
1 | // tuple_xform<Transformer, Tuple>:对 Tuple 中每种类型应用 Transformer |
这个模式在 UDF 签名反射中大量使用:把函数参数类型列表变换为对应的”可空”类型列表。
二十四、Hidden Friend Idiom — 限制 ADL 查找范围
是什么:Hidden Friend(隐藏友元)是把非成员函数作为友元定义在类体内部。
这样的函数只能通过 ADL(Argument Dependent Lookup,依赖参数的名字查找)找到,
不能通过普通限定名查找(velox::operator<< 这样调用会失败)。
作用:避免运算符进入全局命名空间污染,减少意外的隐式转换触发。
解决什么问题:如果 operator== 是普通非成员函数,
任何可以隐式转换为 StringView 的类型都会匹配,可能产生意外的比较。
Hidden Friend 确保 operator== 只有在参数类型恰好是 StringView 时才被 ADL 找到。
Velox 例子:velox/type/StringView.h:171,velox/common/base/Status.h:225
1 | struct StringView { |
二十五、= delete 删除危险重载 — 主动防御
是什么:在 C++11 中,= delete 可以显式删除某个函数重载(包括构造函数、
赋值运算符、普通函数),使该重载在被调用时产生编译期错误,而不是被其他重载静默匹配。
解决什么问题:StringView 不拥有数据,如果从临时 std::string 构造,string 析构后 StringView 持有悬空指针。
编译器不会自动阻止这种用法(临时对象可以绑定到 const 引用),= delete 主动封堵这个漏洞,把运行时的 use-after-free 提升为编译时错误。
Velox 例子:velox/type/StringView.h:129
1 | struct StringView { |
这种”主动删除危险重载”的技巧在 Velox 里形成了一种防御性编程文化:
让错误在编译期暴露,而不是在运行时留下隐患。
二十六、条件变量 (condition_variable) — 等待状态变化
是什么:std::condition_variable 是 C++ 的线程等待原语,
允许一个线程在某个条件不满足时挂起(释放 CPU),
由另一个线程改变条件后调用 notify_one() / notify_all() 将其唤醒。
标准用法是配合 std::unique_lock<std::mutex> 使用:
1 | mutex + condition_variable + 状态变量 = 三件套,缺一不可 |
解决什么问题:线程等待资源时有三种策略:
- 忙等(busy-wait):
while (!ready) {}— 100% CPU,极度浪费 - 定时睡眠:
sleep_for(1ms)— 要么响应慢,要么仍然空转 - 条件变量:精确地”等到条件成立再醒”,不占 CPU,响应及时
条件变量还自带一个重要细节:wait(lock, predicate) 会在 notify 后
重新检查 predicate,防止虚假唤醒(spurious wakeup)——
操作系统有时会无故唤醒等待线程,predicate 是第二道门。
1 | // 标准三件套 |
Velox 中的应用
① Semaphore — 计数信号量:velox/common/base/Semaphore.h:37
Velox 用 condition_variable 实现了一个计数信号量(C++20 才有标准的 std::counting_semaphore):
1 | class Semaphore { |
② ExecutorBarrier — 等待所有并发任务完成:velox/dwio/common/ExecutorBarrier.h:60
IO 层并行读取多个 stripe / column chunk,需要等所有子任务都完成才能继续。ExecutorBarrier 包装 folly::Executor,每提交一个任务 count_++,
完成时 count_--,都降到 0 时唤醒等待方:
1 | class ExecutorBarrier : public folly::Executor { |
③ CachedFactory — 防止重复生成:velox/common/caching/CachedFactory.h:398
多线程同时 miss 同一个缓存键时,只让第一个线程去生成,其余线程等待它完成后
直接读缓存,避免重复昂贵的生成操作:
1 | // 第一个线程:标记 key 正在生成 |
二十七、folly Future / Promise — 异步值的生产与消费
是什么:Promise / Future 是一对对象,共同表示”一个未来才会有的值”:
- Promise:生产者持有,用来”兑现”承诺(
setValue(v)或setException(e)) - Future:消费者持有,代表那个未来的值,可以链式注册回调(
.thenValue()、.thenError())
folly 的实现比标准库的 std::future/promise 更强大:
- **
folly::SemiFuture<T>**:没有绑定 executor 的 future,可以跨线程传递,
需要.via(executor)才能链接回调(防止在错误线程上执行回调) - **
folly::Future<T>**:已绑定 executor,可以直接.thenValue()链式调用 - **
folly::SharedPromise<T>**:可以有多个 Future 消费同一个值(多播),
普通 Promise 只能有一个 Future
解决什么问题:
| 方案 | 问题 |
|---|---|
线程 + std::future |
future::get() 阻塞调用线程,浪费线程资源 |
| 回调函数 | 错误传播困难,多层嵌套形成”回调地狱” |
| condition_variable | 必须持有锁等待,代码复杂,容易死锁 |
| folly Future/Promise | 非阻塞、可组合、异常自动沿链传播、executor 解耦执行位置 |
Velox 中的应用
① VeloxPromise — 带生命期追踪的 Promise:velox/common/future/VeloxPromise.h
Velox 包装了 folly::Promise,在析构时检查是否未兑现:
1 | template <class T> |
② Operator::isBlocked — 非阻塞挂起协议:velox/exec/Operator.h:284
这是 Velox 执行引擎最核心的 Future 应用。
算子(Operator)不能在自己内部阻塞线程(那会卡住整个 Driver 线程),
而是通过返回 ContinueFuture 告诉调度层”我现在无法继续,等这个 Future 完成再调我”:
1 | // 每个算子都实现这个接口 |
③ Task 状态变化通知:velox/exec/Task.h:305
1 | class Task { |
④ AsyncDataCache — SharedPromise 多播缓存未命中:velox/common/caching/AsyncDataCache.h:330
多个读线程同时请求同一个未缓存的 block,第一个发起 IO,
后续的拿到 SemiFuture,等第一个 IO 完成后一起继续:
1 | // CacheEntry:正在加载时设置 SharedPromise,所有等待者拿到对应 Future |
condition_variable vs Future/Promise 的选型:
| 场景 | 选哪个 |
|---|---|
| 等待”计数归零”(Barrier、Semaphore) | condition_variable |
| 等待”一次性状态变化”,不需要回调链 | condition_variable |
| 算子挂起后需要重新调度(不阻塞线程) | ContinueFuture |
| 异步 IO,完成后继续处理数据 | SemiFuture |
| 多个消费者等同一个值(缓存加载) | folly::SharedPromise |
| 需要组合多个异步操作(链式处理) | folly::Future + .thenValue() |
二十八、类的特殊成员函数设计 — Rule of Zero/Three/Five
是什么:每个 C++ 类有六个”特殊成员函数”,编译器在需要时会自动生成:
- 默认构造函数
T() - 析构函数
~T() - 拷贝构造
T(const T&) - 拷贝赋值
T& operator=(const T&) - 移动构造
T(T&&) - 移动赋值
T& operator=(T&&)
三条经验法则:
- Rule of Zero(零法则):如果类的成员都能自管理资源(如
std::vector、shared_ptr),
就什么都别写,让编译器全自动生成。这是首选。 - Rule of Three(三法则):如果你需要手写析构/拷贝构造/拷贝赋值之一(通常因为管理裸资源),
那三个都得写——否则另两个的默认实现会出错(浅拷贝导致 double-free)。 - Rule of Five(五法则):C++11 后,写了上述三个,通常还要补上移动构造和移动赋值,
否则移动操作会退化成拷贝,丢失性能。
关键规则:一旦你声明了析构函数(或拷贝操作),编译器就不再自动生成移动操作。
这意味着写了 ~T() 却忘了移动操作,对象就只能拷贝,不能移动——一个隐蔽的性能陷阱。
28.1 为什么禁止拷贝和移动 (= delete)
解决什么问题:有些对象代表”独一无二的执行实体”——它们持有线程、锁、
或被其他对象用裸指针/引用指向。这类对象一旦被拷贝或移动,
就会出现”两个对象管同一个资源”或”指向它的指针失效”的灾难。
显式 = delete 把这种误用变成编译错误。
Velox 例子:velox/exec/Driver.h:357
1 | // Driver 代表一个执行线程的状态机,被 Task 用 shared_ptr 持有, |
为什么 Driver 必须禁止这些:
- 被裸指针引用:每个
Operator持有Driver*,移动 Driver 会让这些指针悬空。 - 持有运行时状态:Driver 内有线程归属、阻塞状态、CPU 计时,拷贝它没有任何语义意义。
- 生命期由 shared_ptr 统一管理:Driver 只能通过
shared_from_this()传递,
值拷贝会破坏引用计数模型。
注意:只要声明了
= delete的移动操作,类就彻底不可移动。
如果只想”不可拷贝但可移动”(如unique_ptr语义),就只删拷贝、留移动。
28.2 为什么有的类不需要写析构函数
解决什么问题:很多类遵循 Rule of Zero——成员全是能自管理的类型
(std::vector、std::string、shared_ptr、std::optional、值类型),
对象销毁时编译器生成的默认析构会自动、按逆序析构每个成员,无需手写。
手写一个空的 ~T() {} 反而有害:它会抑制移动操作的自动生成,让类退化为只能拷贝。
Velox 例子:CursorParameters(velox/exec/Cursor.h:26)是一个纯数据聚合体:
1 | // 没有任何特殊成员函数声明 —— 完全的 Rule of Zero |
StringView(velox/type/StringView.h)也没有析构函数——
它是非拥有视图,16 字节全是平凡数据(int32_t + 两段 char),
默认析构什么都不做,正是我们想要的。
28.3 为什么有的类必须定义虚析构函数
是什么:当一个类作为多态基类(会通过基类指针 delete 派生对象)时,
**析构函数必须是 virtual**。否则 delete basePtr 只会调用基类析构,
派生类的析构被跳过——派生类成员泄漏,行为未定义。
判断标准:
- 有虚函数 / 会被继承 / 通过基类指针删除 → 必须
virtual ~T() - 值类型 / 不会被继承 → 不要写
virtual(虚函数表指针会让对象变大、不能放进向量)
Velox 例子:
基类要虚析构——velox/common/memory/MemoryPool.h:181:
1 | // MemoryPool 是抽象基类,MemoryPoolImpl 派生它,且通过基类 shared_ptr 删除 |
velox/buffer/Buffer.h:65 的 Buffer 同理:
1 | class Buffer { |
反例——TaskCursor(velox/exec/Cursor.h:139)作为接口基类,
虚析构是 default 但必须存在:
1 | class TaskCursor { |
二十九、shared_ptr / weak_ptr — 引用计数与打破循环
29.1 两种计数器:强引用 vs 弱引用
是什么:std::shared_ptr<T> 通过引用计数管理对象生命期。
它的”控制块”(control block)里其实有两个计数器:
| 计数器 | 含义 | 谁增减 |
|---|---|---|
| strong count(强引用数) | 有多少 shared_ptr 拥有对象 |
shared_ptr 拷贝 +1,析构 -1 |
| weak count(弱引用数) | 有多少 weak_ptr 观察对象 |
weak_ptr 拷贝 +1,析构 -1 |
生命期规则:
- strong count 归 0 → 对象本身被析构(调用
~T(),释放 T 占的内存) - strong count 和 weak count 都归 0 → 控制块也被释放
std::weak_ptr<T> 是一个不拥有对象的弱引用:它不增加 strong count,
因此不会阻止对象被销毁。它只能观察对象”是否还活着”,
通过 .lock() 尝试提升为 shared_ptr。
为什么需要 weak_ptr——打破循环引用:
如果对象 A 用 shared_ptr 持有 B,B 又用 shared_ptr 持有 A,
两者的 strong count 永远 ≥ 1,谁都无法归零,内存永久泄漏。
把其中一个方向改成 weak_ptr,循环就被打破。
29.2 .lock() 的提升技巧:原子地”续命”
是什么:weak_ptr::lock() 是核心操作:它原子地检查对象是否还活着,
如果活着就返回一个新的 shared_ptr(strong count +1),保证在你使用期间对象不被销毁;
如果对象已死(strong count 已归 0),返回空 shared_ptr。
这个”检查 + 提升”是原子的——不会出现”刚检查还活着,下一行就被别的线程销毁”的竞态。
这是异步、多线程环境下安全访问可能已销毁对象的标准手法。
1 | std::weak_ptr<T> weak = ...; |
29.3 Velox 应用:异步回调持有 weak_ptr,回调内 lock 续命
这是 Velox 处理”异步任务可能比对象活得久”的核心模式。
例子:SpillMerger 的异步流读取(velox/exec/Merge.cpp:644)
SpillMerger 把多个溢写文件流并行读入。每次读取是异步的,
通过 folly::Future::thenValue 链式调度下一次读取。
问题:如果 SpillMerger 在某次异步读取还排队时就被销毁了,回调访问 this 就是 use-after-free。
解法:每个异步回调捕获 weak_ptr<SpillMerger>(命名为 mergeHolder),
回调开始时 lock()——活着才继续,死了就放弃:
1 | class SpillMerger : public std::enable_shared_from_this<SpillMerger> { |
**为什么捕获 weak_ptr 而非 shared_ptr**:
如果异步回调捕获 shared_ptr<SpillMerger>,那么只要还有回调在排队,
strong count 就 ≥ 1,SpillMerger 永远不会被销毁——即使查询已经取消。
弱引用让 SpillMerger 的生命期由其真正的所有者决定,
异步回调只是”有机会就续读,没机会就退出”的旁观者。
**例子:Task::MemoryReclaimer 持有 weak_ptr
内存仲裁器可能在 Task 已结束后才触发回收。
Task 的 MemoryPool 可能比 Task 本身活得久(注释明确写了这一点),
所以 reclaimer 只能弱引用 Task:
1 | class Task::MemoryReclaimer : public exec::MemoryReclaimer { |
例子:Operator 用 weak_ptr
Driver 用 shared_ptr 拥有它的 Operator 链;Operator 需要反向访问 Driver,但绝不能用 shared_ptr(否则 Driver↔Operator 循环引用):
1 | class OperatorCtx { |
模式总结:Velox 中 weak_ptr 的两大用途——
- 打破所有权循环:
Operator → Driver、Task → MemoryPool → Reclaimer → Task等反向边用 weak。 - 异步回调的生命期解耦:回调捕获 weak,执行时
lock(),对象活着才干活,死了就放弃,
既不延长对象生命期,又避免 use-after-free。
三十、boost::intrusive_ptr — 侵入式引用计数
是什么:std::shared_ptr 把引用计数放在一个独立的控制块里
(对象本体之外,额外一次堆分配,计数和对象不在一起)。
侵入式引用计数(intrusive refcount)则把计数器直接做成对象的成员,boost::intrusive_ptr 通过两个全局函数 intrusive_ptr_add_ref / intrusive_ptr_release
增减这个内嵌计数。
解决什么问题:
- 省一次分配:
shared_ptr的控制块是单独new的(除非用make_shared),
侵入式计数和对象同体,无额外分配。 - 更小的指针:
intrusive_ptr只有一个裸指针大小(8 字节),shared_ptr是两个指针(16 字节:对象指针 + 控制块指针)。 - 任意指针互转:可以从裸
T*直接重建intrusive_ptr(计数在对象内),shared_ptr做不到(控制块信息丢失)。
Buffer 是 Velox 最高频分配的对象(每个向量都持有若干 Buffer),
这点开销差异在亿级向量场景下非常可观,所以选择侵入式计数。
Velox 例子:velox/buffer/Buffer.h:54
1 | // BufferPtr 就是 intrusive_ptr,不是 shared_ptr |
shared_ptr vs intrusive_ptr 选型:
| 维度 | std::shared_ptr |
boost::intrusive_ptr |
|---|---|---|
| 计数位置 | 独立控制块(对象外) | 内嵌对象成员 |
| 指针大小 | 16 字节(两指针) | 8 字节(一指针) |
| 额外分配 | 有(除非 make_shared) | 无 |
| 支持 weak_ptr | 支持 | 不支持(无 weak 计数) |
| 裸指针重建 | 不能 | 能 |
| Velox 用途 | Task/Driver/Pool 等需要 weak 的对象 | Buffer 等超高频、不需要 weak 的对象 |
这正体现了 Velox 的工程权衡:需要 weak_ptr(打破循环、异步续命)的地方用 shared_ptr;
超高频分配、不需要弱引用的 Buffer 用 intrusive_ptr 省掉每次分配和一半指针体积。
小结
按本文章节顺序汇总:
| # | 维度 | 核心技术 | 收益 |
|---|---|---|---|
| 一 | 模板元编程 | SFINAE/void_t、if constexpr、overloaded、具名 NTTP、全/偏特化、函数不可偏特化的应对 |
零运行时开销的可选接口与类型分发 |
| 二 | 类型系统 | TypeKind + TypeTraits 特化、DECLARE_CONDITIONAL_TYPE_NAME |
运行时与编译期类型双轨并行 |
| 三 | 内存管理 | MemoryPool 树、raw_vector、Scratch、STL Allocator 适配 |
可追踪、可限制、缓存友好的分配 |
| 四 | 错误处理 | 分层异常、[[noreturn]] out-of-line、DCHECK |
热路径不因错误检查膨胀 |
| 五 | SIMD 与向量化 | xsimd、AVX2 intrinsics、SelectivityVector |
批处理吞吐量提升 4-16x |
| 六 | 性能工程 | FOLLY_ALWAYS_INLINE、LIKELY、移动语义、constexpr 常量 |
每个 ns 都不浪费 |
| 七 | Folly 库 | Synchronized、F14、Executor、dynamic |
生产级并发与容器 |
| 八 | 设计模式 | Builder + optional、Visitor、Strategy、Registry | 扩展性与可维护性 |
| 九 | 向量化执行 | 多级编码 Vector、DecodedVector、LazyVector |
列式、延迟、零拷贝执行 |
| 十 | Arrow 集成 | C Data Interface、buffer 所有权转移 | 引擎间零拷贝互通 |
| 十一 | 并发设计 | 原子变量、shared-nothing Driver 模型 | 简单高效的并发设计 |
| 十二 | CRTP | 静态多态、可复用 Builder 基类 | 链式 setter 零虚函数开销 |
| 十三 | Policy-based Design | constexpr Policy struct 模板参数 |
编译期行为注入,消除运行时分支 |
| 十四 | StringView | 16 字节 SSO + 前缀缓存 | 字符串比较减少堆访问 |
| 十五 | folly::Expected<T,E> |
类型安全错误传播 | 无异常开销的显式错误处理 |
| 十六 | [[nodiscard]] |
类级别 nodiscard 属性 | 编译期强制检查返回值 |
| 十七 | folly::Range |
非拥有数组视图 | 替代裸指针对,接口安全 |
| 十八 | 结构化绑定 | auto [a, b] = ... |
多返回值语义清晰 |
| 十九 | noexcept |
移动构造/析构标注 | 允许 vector 用移动而非拷贝 |
| 二十 | 类型双关 | reinterpret_cast 位模式操作 |
哈希与序列化的底层位操作 |
| 二十一 | constexpr 查找表 |
编译期查找表放 .rodata |
以空间换时间,cache 友好 |
| 二十二 | call_once |
folly::once_flag 懒初始化 |
线程安全的单次全局初始化 |
| 二十三 | Template Template Parameters | 模板的模板参数 | 类型变换框架,UDF 签名反射 |
| 二十四 | Hidden Friend | 类体内定义友元运算符 | 限制 ADL 范围,避免全局污染 |
| 二十五 | = delete |
显式删除危险重载 | 把运行时 UAF 提升为编译错误 |
| 二十六 | 条件变量 | Semaphore、ExecutorBarrier、CachedFactory | 精确等待条件成立,不空转 |
| 二十七 | Future / Promise | ContinueFuture、isBlocked、SharedPromise |
非阻塞挂起与异步 IO 协调 |
| 二十八 | 特殊成员函数 | Rule of Zero/Three/Five、Driver 删除拷贝移动 | 正确管理资源生命期 |
| 二十九 | shared_ptr / weak_ptr | 强/弱双计数、.lock() 续命、打破循环 |
异步回调生命期解耦,防 UAF |
| 三十 | intrusive_ptr | Buffer 侵入式计数 | 省一次分配、半个指针体积 |
Velox 的核心设计哲学:一切能在编译期决定的,绝不拖到运行时;一切能用位操作或 SIMD 完成的,绝不逐个元素循环。