7.6 KiB
AsyncExecutor 协作式取消设计
背景
lsp.scheduler.async_executor 当前通过 Taskflow 执行后台任务,但任务句柄只
弱引用执行状态,快速任务完成后句柄立即失效;句柄取消按任务 ID 回查,可能
误取消同 ID 的替代任务;排队任务即使已取消仍会执行。任务注册表同时承担
“当前 ID”与“全部活跃任务”两种职责,导致等待、统计和重复 ID 语义不稳定。
本设计不保留现有 API 兼容层,直接建立明确的任务生命周期、结果和协作式 取消契约。
目标
- 任务句柄在任务完成后仍可等待并读取最终结果。
- 句柄取消只影响该句柄对应的任务实例。
- 按 ID 取消只影响该 ID 当前注册的任务实例。
- 已取消的排队任务不执行任务体。
- 运行中的长任务通过
std::stop_token协作退出。 - 重复 ID 的全部活跃任务被准确等待和计数。
- 任务成功、取消和失败使用单一结果模型表达。
- 隐藏执行状态、同步原语和句柄构造细节。
- API namespace 与 Module 名保持一致。
非目标
- 不强制终止正在运行的线程。
- 不为旧的
TaskClosure、callback 或WaitForTask提供兼容重载。 - 不在本任务中启用 LSP
$/cancelRequest的请求级异步分发;本设计只保证 scheduler 及其后台索引任务具备正确取消能力。 - 不修改 Taskflow 本身。
公共 API
Module 名保持:
export module lsp.scheduler.async_executor;
公共 API 迁移到:
namespace lsp::scheduler::async_executor
任务结果统一为:
enum class TaskStatus
{
kCompleted,
kCancelled,
kFailed,
};
struct TaskResult
{
TaskStatus status;
std::optional<std::string> value;
std::exception_ptr error;
};
任务 closure 只产生业务值,最终状态由 executor 根据停止请求和异常统一 生成,避免任务体自行构造相互矛盾的状态:
using TaskClosure =
std::function<std::optional<std::string>(std::stop_token)>;
using TaskCallback = std::function<void(const TaskResult&)>;
TaskHandle 对外提供:
bool Valid() const;
bool Cancel() const;
std::optional<TaskResult> Wait() const;
std::optional<TaskResult> TryGetResult() const;
- 默认构造句柄无效,
Wait与TryGetResult返回std::nullopt。 - 有效句柄完成后仍保持有效。
Cancel仅在首次成功请求取消且任务尚未完成时返回true。Wait等待任务体和 callback 都结束,并返回最终任务结果。TryGetResult不阻塞;尚未完成时返回std::nullopt。- 构造函数为私有,仅
AsyncExecutor可以创建有效句柄。
删除 AsyncExecutor::WaitForTask。调用方必须保存 TaskHandle 来等待特定
任务;按字符串 ID 只用于取消当前实例,不承担历史结果查询。
内部状态与所有权
TaskHandle 通过 std::shared_ptr 强持有私有嵌套状态。状态至少包含:
std::stop_source:生成 stop token 并接受取消请求。TaskPhase:kPending、kRunning、kCompleted。std::optional<TaskResult>:最终结果。- mutex、condition variable 和 callback 完成标记。
- task ID 与开始时间。
状态定义不导出,外部不能构造或修改同步字段。
AsyncExecutor 使用两份索引:
current_tasks_:task_id -> state,只表示该 ID 当前实例,供Cancel(id)使用。active_tasks_:以任务实例唯一标识保存全部未完成状态,供WaitAll、GetRunningTaskCount与统计使用。
提交同 ID 新任务时,请求旧实例停止,然后替换 current_tasks_;旧实例仍
留在 active_tasks_,直到任务体和 callback 都完成。
取消与执行流程
排队取消
worker 获取任务状态后,在持锁状态下检查 stop token:
- 已请求停止:不调用 closure,直接产生
kCancelled。 - 未请求停止:状态从
kPending转为kRunning,随后调用 closure。
因此取消和开始执行之间只有一个明确的同步竞态点;取消先获得状态锁时, 任务体保证不会执行。
运行中取消
任务开始后,Cancel 调用 stop_source.request_stop()。executor 不强制停止
线程;closure 接收 std::stop_token,在文件枚举、索引和符号加载循环的
安全边界调用 stop_requested() 并尽快返回。
如果运行中收到停止请求,即使 closure 已产生普通值,executor 仍将最终
状态归类为 kCancelled,避免向 callback 报告成功。
完成与 callback
executor 捕获任务异常并生成 kFailed,保留原始 exception_ptr。任务结果
先写入状态,再调用 callback;callback 异常被捕获并记录,但不改写已经确定
的任务结果。只有 callback 返回或异常被处理后,状态才标记为完全完成、从
两个注册表注销并唤醒等待者。
callback 不得从自身调用 Wait 或 WaitAll;该约束写入接口注释和测试命名,
避免自等待死锁。
并发数与生命周期
传入并发数 0 或 std::thread::hardware_concurrency() 返回 0 时,实际 worker
数收敛到 1,再构造 tf::Executor。
AsyncExecutor 析构时调用 WaitAll。WaitAll 等待所有活跃状态完成,并再
调用 Taskflow wait_for_all(),确保 callback 中提交的后续任务也已退出。
析构期间不允许其他线程继续调用 Submit;这是对象生命周期的基本前置条件。
调用方迁移
所有 scheduler::AsyncExecutor、scheduler::TaskHandle 等引用迁移到
scheduler::async_executor::*。所有 Submit closure 增加
std::stop_token 参数,callback 改为接收 const TaskResult&。
工作区加载、系统库加载、文件索引和 workspace folder 变更任务将 stop token 传入长循环;循环在每个文件或目录边界检查停止请求。只执行一次且不可拆分的 第三方调用在调用前后检查 token,不尝试中断其内部线程。
清理
- 删除公开的
detail::ExecutionState与detail::ActiveEntry。 - 将
ExecutorMetrics作为明确公共结果类型保留在lsp::scheduler::async_executor。 - 删除未使用的
ActiveEntry::callback、ActiveEntry::start_time和kStatusLogInterval。 - 测试 Module 的入口同步放入自身命名空间,不再导出全局
Run。
测试设计
测试遵循 TDD,先在旧实现上观察失败,再实现新语义:
- 无延迟任务完成后,handle 仍有效且可读取结果。
Wait返回任务体与 callback 完成后的最终结果。- 同 ID 替换后,旧 handle 取消不影响新任务。
- 排队任务被取消后,任务体从未执行。
- 运行中任务观察 stop token 并协作退出。
- 同 ID 两个活跃实例被计数为 2,
WaitAll等待两者。 - 并发数 0 可以正常执行任务。
- task 异常生成
kFailed并保留异常。 - callback 异常不会导致等待死锁,也不会改写任务结果。
- 成功、取消、失败统计与最终状态一致。
迁移完成后运行 scheduler 测试、provider 测试和生产服务器构建;LSP 传输 冒烟测试确认后台初始化和 workspace 操作没有回归。
完成条件
- 快速完成任务的 handle 不失效。
- 任一 handle 只能取消自己的状态。
- 已取消的排队 closure 不执行。
- 长任务可以通过 stop token 提前退出。
GetRunningTaskCount和WaitAll覆盖重复 ID 的所有实例。- 不存在
WaitForTask、公开执行状态或未使用调度字段。 - 并发数始终至少为 1。
- namespace、调用方和测试全部迁移完成。
- 约定范围内构建与测试通过。