Envoy AsyncFileManager 异步文件 I/O 框架解析:线程池调度、取消语义与回调线程模型
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
导读
本文基于 Envoy 仓库中 source/extensions/common/async_files/README.md 展开,系统讲解 Envoy 提供的通用异步文件操作框架AsyncFileManager与AsyncFileHandle:它通过线程池把阻塞式文件 I/O(打开、读写、截断、链接、删除等)从事件循环线程中剥离出去,并以“入队 + 回调 + 可取消”的模型把结果安全地送回调用方线程。读完本文,你将掌握该框架的两个核心抽象、完整的状态机与取消语义、每类动作的底层 POSIX 实现,以及如何通过AsyncFileManagerConfig配置线程池并在 Envoy 中以单例方式复用。
一、为什么 Envoy 需要一套异步文件框架
Envoy 是事件驱动的代理,核心循环基于非阻塞 I/O 与 dispatcher(事件调度器)工作。文件系统操作(open、read、write、stat、unlink、mkstemp等)通常是阻塞式系统调用,如果直接在 dispatcher 线程上执行,会卡住整个事件循环,进而阻塞同一线程上的所有连接与过滤器处理。为了在不阻塞事件循环的前提下完成文件操作,Envoy 在 source/extensions/common/async_files 目录下提供了一套通用的异步文件框架,其设计目标是:
- 将文件操作投递到专用线程池执行,dispatcher 线程不被阻塞;
- 操作完成后的回调仍回到发起请求的 dispatcher 线程执行,保持线程亲和;
- 提供与事件循环语义一致的取消机制,避免“取消与回调并发发生”的竞态。
从 BUILD 可以看到该框架被拆分为三层库:async_files_base(抽象接口与基类)、async_files_thread_pool(线程池实现)、async_files(单例工厂),上层扩展通过依赖这些库获得能力。
二、核心抽象一:AsyncFileManager(线程池的封装)
2.1 职责与生命周期
按 async_file_manager.h 的定义,AsyncFileManager应当是单例或类似单例的长生命周期对象,它代表一个专门执行文件操作的线程池。当前仓库中的具体实现是AsyncFileManagerThreadPool(见 async_file_manager_thread_pool.h 与 async_file_manager_thread_pool.cc)。
AsyncFileManager面向“文件级”操作提供四个入口:
| 方法 | 作用 | 成功回调参数 |
|---|---|---|
createAnonymousFile(dispatcher, path, on_complete) | 在指定目录创建并打开一个匿名临时文件 | absl::StatusOr<AsyncFileHandle> |
openExistingFile(dispatcher, filename, mode, on_complete) | 以指定模式打开已存在的文件 | absl::StatusOr<AsyncFileHandle> |
stat(dispatcher, filename, on_complete) | 按文件名获取struct stat元数据 | absl::StatusOr<struct stat> |
unlink(dispatcher, filename, on_complete) | 删除指定文件 | absl::Status |
所有方法统一返回一个CancelFunction(取消函数),其语义在 async_file_action.h 中有精确定义,详见下文“取消语义”一节。
2.2 打开文件的三态模式
openExistingFile的mode参数使用AsyncFileManager::Mode枚举,三态与 POSIX 打开标志一一对应(实现见 async_file_manager_thread_pool.cc):
ReadOnly→O_RDONLYWriteOnly→O_WRONLYReadWrite→O_RDWR
2.3 createAnonymousFile 的路径语义与两段式实现
createAnonymousFile的path参数不是文件名,而是一个目录路径(通常为/tmp)。文档明确指出:匿名文件虽然没有文件名、也没有链接,但该路径决定文件写入的物理硬件——如果之后要link()这个文件,跨设备链接代价高昂;也可能出于性能考虑希望把临时文件放在虚拟文件系统或可卸载的临时 SSD 上(async_file_manager.h)。
底层实现(async_file_manager_thread_pool.cc)采用两段式策略:
- 首选
O_TMPFILE:以O_TMPFILE | O_RDWR标志打开(权限S_IRUSR | S_IWUSR)。首次调用通过std::call_once探测文件系统是否支持,探测成功后用一个supports_o_tmpfile_标志记录,之后所有线程直接复用该结论; - 回退
mkstemp:当目标文件系统不支持O_TMPFILE时,回退到在path下创建名为buffer.XXXXXX的临时文件(mkstemp),随后立刻unlink使其匿名。若unlink失败(某些文件系统不允许删除打开中的文件),实现会主动close并再次unlink,然后返回absl::UnimplementedError,避免匿名临时文件堆积占满磁盘。
此外,如果路径过长(path + "/buffer.XXXXXX"超过 4096 字节),会直接返回InvalidArgumentError。
2.4 队列、状态机与线程池调度
线程池实现的核心是AsyncFileManager::QueuedAction(async_file_manager.h)以及一个五态原子状态机:
Queued → Executing → InCallback → Done ↘ ↘ Cancelled Cancelled(触发清理动作)每个待执行动作由std::shared_ptr<std::atomic<State>>标记状态,因此可以在执行线程与调用方线程之间安全地做 CAS 切换。调度逻辑在executeAction(async_file_manager_thread_pool.cc)中:
- worker 线程从互斥队列取出动作,
Queued → Executing的 CAS 失败说明已被取消,此时若该动作executesEvenIfCancelled()(如 close)仍需执行; - 执行完毕后
Executing → InCallback,再通过dispatcher->post()把回调投递回调用方线程;投递后的 lambda 中做InCallback → Done的 CAS,成功则调用真正的回调,失败则说明已取消,进入取消清理流程。
waitForIdle()(async_file_manager_thread_pool.cc)用于测试:阻塞直到active_workers_ == 0 && queue_.empty() && cleanup_queue_.empty(),即所有动作执行完毕且回调已投递(不保证回调已执行)。describe()返回线程池大小等描述信息,主要用于测试与调试。
2.5 析构与线程回收
AsyncFileManagerThreadPool的析构(async_file_manager_thread_pool.cc)先置terminate_ = true,然后逐个join线程;worker 在观察到terminate_且主队列、清理队列均为空时退出。这意味着析构会被阻塞,直到所有已入队的文件操作完成,确保不会在动作执行中销毁底层资源。
三、核心抽象二:AsyncFileHandle(单文件上下文)
3.1 与文件的绑定关系
AsyncFileHandle实际上就是std::shared_ptr<AsyncFileContext>(async_file_handle.h)。一个AsyncFileHandle代表一个执行异步文件操作的上下文,同一时刻至多关联一个文件。它由AsyncFileManager通过createAnonymousFile/openExistingFile创建。
3.2 可入队的动作清单
async_file_handle.h 定义了在已打开文件上可入队的全部动作:
| 动作 | 签名要点 | 成功回调 | 底层实现 |
|---|---|---|---|
stat | fstat当前 fd | absl::StatusOr<struct stat> | posix().fstat |
read | offset+length | absl::StatusOr<Buffer::InstancePtr> | posix().pread |
write | Buffer::Instance& contents+offset | absl::StatusOr<size_t>(实际写入字节数) | posix().pwrite循环写满所有 slice |
createHardLink | 目标filename | absl::Status | /proc/self/fd/N+linkat(AT_SYMLINK_FOLLOW) |
duplicate | 无 | absl::StatusOr<AsyncFileHandle> | posix().duplicate |
truncate | 目标length | absl::Status | posix().ftruncate |
close | 无 | absl::Status | posix().close(拷贝 fd 后执行) |
3.3 read 与 write:位置显式的读写模型
read(async_file_context_thread_pool.cc)按offset与length执行pread,结果封装为Buffer::InstancePtr。其细节:先reserveSingleSlice(length_)预留整段内存,若实际读取字节数不足请求量,则按实际字节数构造新的Buffer::OwnedImpl;回调中缓冲区的大小即可告知实际读取了多少。
write(async_file_context_thread_pool.cc)在构造动作的瞬间通过contents_.move(contents)按移动语义立即消费传入的Buffer::Instance,因此调用方调用后即可安全丢弃该缓冲区,不应假定其中数据仍然有效。执行时对每个 raw slice 循环pwrite直到全部写完(处理部分写),返回累计写入字节数。
所有读写都显式指定offset(pread/pwrite),不依赖文件指针共享状态,因此duplicate出来的句柄与原始句柄共享定位与权限也不会产生歧义——这正是文档所说“Since AsyncFileContext functions are all position-explicit, this should not matter”的缘由。
3.4 close 的调用约束与特殊语义
async_file_handle.h 对close给出三条硬性约束:
close之后不能再使用该AsyncFileContext;- 不调用
close就销毁AsyncFileHandle是错误的; - 对同一句柄调用两次
close是错误的。
由于AsyncFileHandle是shared_ptr,在句柄持有者的析构函数里调用close也是允许的——close 动作会被入队,从而让句柄存活到关闭操作完成之后。实现上(async_file_context_thread_pool.cc)ActionCloseFile在构造时拷贝一份 fd,因为close会把上下文的 fd 置为 -1,拷贝可避免关闭在途时其他线程误用句柄;同时executesEvenIfCancelled()返回 true,意味着即使动作被取消,close 也一定会完成。close入口本身在入队后立刻将fileDescriptor()置为 -1,后续任何操作经checkFileAndEnqueue(async_file_context_thread_pool.cc)检查到 fd 为 -1 都会返回FailedPreconditionError("file was already closed"),而不再入队。
3.5 createHardLink 与取消回滚
createHardLink用于把匿名文件“转正”为具名文件:通过/proc/self/fd/<fd>配合linkat(AT_SYMLINK_FOLLOW)建立硬链接。它是有副作用的动作,因此重写了onCancelledBeforeCallback(async_file_context_thread_pool.cc):如果链接已创建但回调尚未执行(即被取消),则主动unlink刚创建的链接进行回滚。
四、取消语义:一个精心设计的无竞态保证
4.1 CancelFunction 的四档行为
按 async_file_action.h,每个动作函数返回的取消函数针对动作所处阶段有四种行为:
- 动作已完成:取消函数什么都不做;
- 回调正在执行:取消函数阻塞等待,直到回调执行完成;
- 动作正在执行中:取消会移除任何消耗资源的返回值(如刚打开的文件句柄),并阻止回调被调用——例如 open 正在执行时取消,会顺带把文件关掉;
- 动作仍在队列中:取消直接阻止其执行。
4.2 线程约束与保证
文档明确了两个关键约定:
- 取消函数只能从当初传入请求的 dispatcher 所在线程调用,以此保证“取消”与“回调”不可能并发发生(README 的 cancellation 一节)。在 async_file_manager_thread_pool.cc 中,取消闭包以
ASSERT(dispatcher == nullptr || dispatcher->isThreadSafe())强约束调用线程。 - 回调只在 dispatcher 线程上执行,前提是动作未被取消(README 的 callbacks 一节)。实现保证:若取消发生在回调执行之前且来自 dispatcher 线程,则回调保证不会被执行,不存在竞态。
4.3 取消清理通道
取消发生时并非简单丢弃动作,而是区分两类:
- 无副作用动作(如 write 被取消无需撤销写入):取消后无需清理;
- 有副作用动作(打开的文件、刚建的硬链接、重复的句柄):通过
postCancelledActionForCleanup把动作放进专门的cleanup_queue_,由线程池 worker 调用其onCancelledBeforeCallback()完成回滚(async_file_manager_thread_pool.cc、worker 循环)。
ActionWithFileResult::onCancelledBeforeCallback(async_file_manager_thread_pool.cc)就是典型例子:如果 open 成功但回调未执行,就对新句柄发起一次带空回调的close。
五、回调执行的约束
async_file_action.h 还针对回调在 dispatcher 线程执行的现实给出三条编程建议:
- 回调中不应访问可能已出作用域的变量(动作在入队时按值捕获所需数据);
- 可能被其他线程修改的共享变量需要加锁保护;
- 回调内不能阻塞或做重活——耗时的处理应把结果再转交其他线程,避免拖住事件循环。
六、单例工厂与配置
6.1 工厂的单例保证
AsyncFileManagerFactory(async_file_manager_factory.h)是注册在 Envoy Singleton 管理器上的单例工厂(SINGLETON_MANAGER_REGISTRATION,见 async_file_manager_factory.cc)。其核心保证:同一个config.id()在同一 Envoy 实例中只对应一个AsyncFileManager。工厂内部以flat_hash_map<string, ManagerAndConfig>缓存;若同一 id 再次以不同配置请求,会抛出EnvoyException("AsyncFileManager mismatched config")。
工厂返回的shared_ptr必须由调用方在 manager 生命周期内持有——单例管理器本身不持有对工厂的引用,工厂只在仍有活跃引用时存在(async_file_manager_factory.h)。
6.2 配置 Proto
配置定义在 api/envoy/extensions/common/async_files/v3/async_file_manager.proto:
id(string):manager 的可选标识,空字符串是合法的默认 id;同一实例中复用同一 id 但配置不同属于错误;thread_pool.thread_count(uint32):线程池线程数,lte: 1024校验上限;未设置或为 0 时默认取std::thread::hardware_concurrency()(即硬件支持的并发线程数,见 async_file_manager_thread_pool.cc);manager_type为oneof且标记validate.required,目前唯一实现是thread_pool;未设置会在工厂处抛出EnvoyException("unrecognized AsyncFileManagerConfig::ManagerType")。
创建 manager 时还会检查posix.supportsAllPosixFileOperations(),不支持全部 POSIX 文件操作的环境(如部分 Windows 平台)会直接抛出EnvoyException("AsyncFileManagerThreadPool not supported")。
6.3 配置示例
extensions: common: async_files: v3: AsyncFileManagerConfig: # 空 id 表示默认 manager,同一 id 不同配置会报错 id: "cache_writer" thread_pool: thread_count: 4 # 不设或 0 时回退到硬件并发数;上限 1024七、错误处理:errno 到 absl::Status 的映射
文件操作失败时,框架通过 status_after_file_error.cc 把 POSIXerrno映射为语义明确的absl::Status,供调用方精准区分失败类别:
| absl 错误类别 | 覆盖的 errno |
|---|---|
PermissionDeniedError | EACCES、EPERM、EROFS |
FailedPreconditionError | EBADF、EBUSY、EISDIR、ELOOP、ENOTDIR、ETXTBSY、EWOULDBLOCK |
ResourceExhaustedError | EDQUOT、EMFILE、ENFILE、ENOMEM、ENOSPC |
AlreadyExistsError | EEXIST |
InvalidArgumentError | EFAULT、EINVAL、ENAMETOOLONG |
OutOfRangeError | EFBIG、EOVERFLOW |
UnavailableError | EINTR |
NotFoundError | ENODEV、ENOENT、ENXIO |
UnimplementedError | EOPNOTSUPP |
OkStatus | 0 |
UnknownError | 未识别的错误码(同时触发ENVOY_BUG) |
例如读取不存在的文件会得到NotFoundError,磁盘满则会得到ResourceExhaustedError,便于上层据此决定重试、降级或记录不同告警。
八、典型使用流程与最佳实践
综合文档与源码,一个完整的“异步写临时文件后转正”流程如下:
- 通过工厂按
AsyncFileManagerConfig获取(或创建)单例 manager; - 调用
createAnonymousFile(dispatcher, "/tmp", on_complete)获得匿名句柄——注意保存返回的取消函数; - 在
on_complete中拿到AsyncFileHandle后,依次入队write(dispatcher, contents, offset)(内容在入队时被移动消费)、按需truncate; - 内容写完后调用
createHardLink(dispatcher, final_path, ...)把匿名文件转正为具名文件,或直接close; - 每个动作都在回调里确认
absl::Status,结合第七节的错误类别做分支处理; - 不再需要句柄时调用
close——不要直接丢弃AsyncFileHandle,也不要对已关闭句柄再次入队。
实践要点可归纳为:
- 取消函数只在 dispatcher 线程调用;回调也只会回到 dispatcher 线程,回调内不做重活;
- 动作一旦入队即视为“投递”,不要在入队后继续使用被移动走的
Buffer; - 对同一句柄的读写操作不要并发堆叠(文档明确“There must not already be an action queued for this handle”);需要并发时用
duplicate拆分句柄; AsyncFileManager必须是长生命周期对象(单例),线程池与队列状态与进程共存。
九、小结
AsyncFileManager/AsyncFileHandle是 Envoy 将“阻塞式文件操作”安全融入“事件驱动模型”的通用基础设施:AsyncFileManager以单例线程池承载文件级操作,AsyncFileHandle提供位置显式的单文件读写上下文,五态原子状态机配合“队列 + 清理队列”双队列实现了无竞态的取消语义,回调统一回归 dispatcher 线程并辅以errno → absl::Status的语义化映射。理解这套框架,既有助于阅读 Envoy 中依赖文件 I/O 的扩展(如各类缓存、日志落盘类过滤器的实现),也为在 Envoy 中自研需要异步操作文件系统的扩展提供了可直接复用的接口范式。
【免费下载链接】envoyCloud-native high-performance edge/middle/service proxy项目地址: https://gitcode.com/GitHub_Trending/en/envoy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考