Lely CANopen 事件调度机制详解:从 ev_task、Executor、std_exec 到 ev_loop、Future 与 Poll
@[toc]
0. 阅读前先记住六个结论
ev_task只是“可执行工作”的载体,不负责排队、线程切换或等待。ev_exec_t是抽象 Executor 接口,本质是 C 语言虚函数表。ev_std_exec不是另一个事件循环,而是一个适配层:只要求后端实现post/abort/on_task_init/on_task_fini,它补齐dispatch/defer/run。ev_loop才拥有真正的全局任务队列、等待线程、Poll 线程和 outstanding work 状态。ev_exec_run()的关键价值不是简单调用回调,而是建立“当前线程正在这个 Executor 内执行任务”的 TLS 上下文。- Future、I/O、定时器最终都不会直接“执行用户逻辑”;它们把
ev_task投递给 Executor,由ev_loop取出并运行。
可以先把整个系统压缩成下面这条链:
1 | 异步事件 / Future ready / 用户主动 post |
1. 源码范围与文件职责
| 文件 | 主要职责 | 阅读优先级 |
|---|---|---|
include/lely/ev/ev.h |
前置声明 ev_task,定义抽象类型 ev_exec_t |
最高 |
include/lely/ev/task.h |
定义 C 任务对象 ev_task 与任务队列辅助函数 |
最高 |
include/lely/ev/task.hpp |
C++ Task、临时 TaskWrapper |
中 |
src/ev/task.c |
structof() 反查、批量 post/abort |
高 |
include/lely/ev/exec.h |
Executor 虚函数表及 C API | 最高 |
src/ev/exec.c |
为头文件 inline API 提供外部定义 | 中 |
include/lely/ev/exec.hpp |
C++ Executor 包装 |
中 |
include/lely/ev/std_exec.h |
标准 Executor 适配器及精简后端接口 | 最高 |
src/ev/std_exec.c |
dispatch/post/defer/run/abort 的核心语义 |
最高 |
include/lely/ev/loop.h |
Event loop 的公开行为与 outstanding work 定义 | 高 |
src/ev/loop.c |
队列、线程、Poll、条件变量、Future wait 的落地实现 | 最高 |
include/lely/ev/future.h |
非阻塞 Future/Promise 契约 | 高 |
src/ev/future.c |
Future 状态机、等待任务队列、计数闭环 | 高 |
2. 整体分层:谁定义语义,谁保存状态,谁真正执行
Lely 的事件库不是把所有功能堆在 ev_loop 中,而是分成四层:
1 | flowchart TB |
2.1 四层职责不能混淆
| 层 | 解决的问题 | 不负责什么 |
|---|---|---|
ev_task |
“要执行哪个函数,属于哪个 Executor” | 不保存线程,不主动入队 |
ev_exec_t |
“用统一 API 提交、运行、取消任务” | 不规定具体队列结构 |
ev_std_exec |
“补齐 dispatch/defer/run 的标准行为” | 不拥有全局事件循环队列 |
ev_loop |
“任务排队、唤醒、Poll、停止、线程协调” | 不理解 CANopen 协议业务语义 |
因此,下面两句话都不准确:
1 | “Future 自己执行回调” |
更准确的描述是:
1 | Future 或 Poll 产生完成事件 |
3. ev_exec_t:用 C 指针模拟对象和虚函数
3.1 看似简单的 typedef,实际有两层指针语义
ev.h 中的核心定义是:
1 | typedef const struct ev_exec_vtbl *const ev_exec_t; |
这表示:
1 | ev_exec_t |
公开 API 使用的通常是:
1 | ev_exec_t *exec; |
所以 exec 并不是“直接指向 Executor 结构体”,而是:
1 | 指向 concrete object 内部 vptr 字段的地址 |
调用方式:
1 | (*exec)->post(exec, task); |
可以把它类比为:
1 | C++ this 指针 + 虚函数表 |
3.2 对象布局
以 ev_std_exec 为例:
1 | struct ev_std_exec { |
调用方拿到的是:
1 | &std_exec->exec_vptr |
内存关系如下:
1 | ev_exec_t *exec |
std_exec.c 通过:
1 | structof(exec, struct ev_std_exec, exec_vptr) |
从 exec_vptr 字段地址反推出完整 ev_std_exec 对象地址。
3.3 为什么不是普通函数指针
单个函数指针只能表示“如何执行”,而 Executor 需要一组统一操作:
1 | struct ev_exec_vtbl { |
这允许同一套上层代码连接不同 Executor:
- polling event loop;
- fiber executor;
- 自定义线程池;
- 测试用同步 Executor;
- 其他平台后端。
3.4 exec.c 为什么只有几行
exec.h 中的 API 采用 static inline/inline 转调虚函数表。src/ev/exec.c 先定义:
1 |
再包含 exec.h,主要作用是为 C inline 函数提供可链接的外部定义。
因此:
exec.c几乎没有业务逻辑,真正的 Executor 行为在虚函数表对应的具体实现中。
4. ev_task:侵入式、零额外队列节点的任务对象
4.1 数据结构
1 | struct ev_task { |
| 字段 | 含义 |
|---|---|
exec |
该任务所属或将要提交到的 Executor |
func |
执行入口,签名为 void func(struct ev_task *) |
_node |
侵入式单链表节点,可直接挂到队列 |
_data |
内部处理时可保存附加信息,不应随意假设用途 |
4.2 为什么使用侵入式链表
普通任务队列通常需要单独分配节点:
1 | queue node → task object |
Lely 直接把 _node 嵌入任务:
1 | queue 直接保存 task._node |
优点:
- 投递时不必额外 malloc;
- 队列操作开销固定;
- 适合实时性和嵌入式场景;
- Future 队列、event loop 队列、defer 临时队列可以复用同一节点。
代价也很明确:
同一个
ev_task在同一时刻只能安全地位于一个链表中。
不要在任务仍在 Future 队列或 event loop 队列时再次提交同一个对象,否则 _node 链接关系会被破坏。
4.3 通过嵌入实现“继承”
典型 C 用法:
1 | struct read_task { |
这是 Lely 中非常常见的对象模型:
1 | 基类对象嵌入派生结构 |
4.4 生命周期责任
ev_task 本身不带引用计数,也不会被 Executor 自动复制。
必须保证:
1 | 从提交任务开始 |
栈上任务只有在调用方能保证任务在离开作用域前完成时才安全。异步提交通常应使用:
- 长生命周期对象内嵌任务;
- 堆对象;
- Promise/Future 共享对象内嵌任务;
- 驱动对象或 I/O 请求对象内嵌任务。
5. C++ Task 与 TaskWrapper
5.1 Task
task.hpp 的 Task 继承 ev_task,内部持有:
1 | std::function<void()> func_; |
构造函数把 C 回调入口设置为一个无捕获 lambda:
1 | ev_task 回调 |
Task 不自删除,因此其生命周期仍由调用方负责。
5.2 TaskWrapper
make_task_wrapper() 使用 new 创建临时任务。任务运行时:
1 | 调用 invoker_() |
这使 C++ API 可以直接写:
1 | exec.post([] { |
但要记住:
临时 wrapper 是在“任务实际运行”时自删除;若在运行前被取消,必须确认当前 Lely 版本对该匿名任务的所有权约定,不能把“abort”自动等同于“delete”。
5.3 一个版本相关的源码阅读提示
在官方 v2.3.5 与当前 master 的 exec.hpp 中,Callable 版本的 Executor::dispatch() 声明返回 bool,函数体调用 dispatch(*task) 后未显式 return。
这不影响理解 Executor 主机制,但如果本地代码使用了这个重载的返回值,应检查本地版本、编译告警和上游修复情况。直接提交已有 ev_task 的重载没有这一问题。
6. Executor 七个操作的准确语义
6.1 总表
| API | 是否允许同步完成 | 当前任务结束前是否允许开始 | 主要用途 |
|---|---|---|---|
on_task_init |
不适用 | 不适用 | 声明未来还有工作,防止 loop 提前退出 |
on_task_fini |
不适用 | 不适用 | 释放先前声明的工作引用 |
dispatch |
允许 | 允许,常在当前 Executor 上内联执行 | “能立即执行就立即执行” |
post |
调用本身不等待完成 | 允许其他 worker 并发执行 | 普通异步投递 |
defer |
调用本身不等待完成 | 若在当前任务内调用,禁止在当前任务结束前执行 | continuation/避免重入 |
abort |
不执行任务 | 不适用 | 移除尚未开始的任务 |
run |
当前调用中执行 | 立即 | 建立 Executor 执行上下文并调用任务 |
6.2 dispatch
抽象契约是:
1 | 如果当前就在该 Executor 的执行上下文中 |
标准 Executor 的返回值:
1 | 1:本次调用中任务已经完成 |
6.3 post
post 保证调用者不等待任务完成,但不保证任务一定等到函数返回之后才开始。
在 Lely ev_loop 后端中,post() 自己只是加锁入队;但如果另一个 worker 正在运行同一 loop,该 worker 可能在投递线程返回前就取走并执行任务。
6.4 defer
defer 的核心语义不是“低优先级”,而是:
1 | 如果当前正在这个 Executor 的某个任务中 |
它主要解决回调重入和 continuation 顺序问题。
6.5 run
run 不是通常意义上的“启动事件循环”。它的对象是单个任务:
1 | ev_exec_run(exec, task); |
其职责是:
- 标记当前线程正在
exec上执行; - 为本次任务建立 defer 队列;
- 调用
task->func(task); - 任务结束后,把 defer 队列转交后端;
- 恢复上一层嵌套执行上下文。
这是理解 std_exec.c 的核心。
7. ev_std_exec:为什么要在 Executor 与 event loop 之间再加一层
7.1 精简后端接口
ev_std_exec_impl_vtbl 只要求后端提供四个操作:
1 | struct ev_std_exec_impl_vtbl { |
而对外的完整 Executor 有七个操作。
因此:
1 | ev_std_exec |
这样 ev_loop 不必重复实现复杂的:
- 当前线程是否在该 Executor 上执行;
- dispatch 是否可以内联;
- defer 应该挂在哪一层;
- 嵌套 run 的 defer 队列如何恢复。
7.2 两套虚函数表
1 | flowchart LR |
调用 ev_exec_post() 时的实际链路:
1 | ev_exec_post(exec, task) |
8. std_exec.c 最关键的数据结构:线程局部执行上下文
8.1 TLS 根节点
1 | static _Thread_local struct ev_exec_node *ev_exec_list; |
每个线程拥有独立的 ev_exec_list。
它回答一个问题:
1 | 当前线程此刻正在执行哪些 Executor 的任务? |
8.2 两级结构
1 | struct ev_task_node { |
关系如下:
1 | 线程 TLS ev_exec_list |
8.3 为什么一个 Executor 还需要多层 queue
同一线程可能出现嵌套 ev_exec_run():
1 | run(task A) |
任务 C 应属于 B 这一层的 continuation,而不是直接混到 A 的 defer 队列中。
所以同一个 ev_exec_node 下还有 ev_task_node 栈。
8.4 不同 Executor 的嵌套
1 | Executor A 正在运行 task A |
线程 TLS 中会同时出现 A、B 两个 ev_exec_node。ev_exec_find() 按 Executor 地址查找对应节点。
9. ev_std_exec_run():完整执行边界
其核心过程可简化为:
1 | void run(exec, task) |
9.1 为什么 defer 入队时要 on_task_init()
defer 任务暂时只存在于线程栈上的局部队列中,还没有进入 ev_loop->queue。
若不登记 outstanding work:
1 | 其他 loop 线程看到全局队列为空 |
因此 ev_std_exec_defer() 在加入局部队列前执行:
1 | ev_std_exec_on_task_init(exec); |
9.2 为什么 flush 时必须先 post,再 fini
源码顺序是:
1 | impl->post(impl, task); |
不能随意交换。
正确的状态迁移:
1 | 任务在 defer 临时队列 |
若先 fini:
1 | ntasks 可能先变 0 |
这会形成提前停止窗口。
同样的“先 post,后 fini”原则也出现在:
ev_task_queue_post();- Future ready 后转移等待任务;
- Future cancel 后提交任务。
10. dispatch、post、defer 在 std_exec 中怎样实现
10.1 dispatch
1 | 查找当前线程 TLS 中是否存在该 exec |
关键细节:
dispatch()的内联分支直接调用task->func(),不会再创建新的ev_exec_run()defer 边界。
因此,内联 dispatch 的任务与当前外层任务共享当前最内层 defer queue。
例如:
1 | outer task |
D 不是在 inner task 返回后立即 flush,而是在当前 ev_exec_run() 边界结束后 flush。
10.2 post
1 | 如果 task->exec 为空,绑定当前 exec |
对 ev_loop 后端而言就是进入全局 FIFO 单链表。
10.3 defer
1 | 查找当前线程是否正在该 exec 上运行 |
10.4 abort
标准 Executor 先检查当前线程该 Executor 的局部 defer queue:
1 | 找到待取消任务 |
若未找到,或要求取消全部任务,则再调用后端 impl->abort()。
这体现了一个重要事实:
1 | 尚未开始的任务可能位于两处: |
11. 一个最直观的执行顺序示例
假设单线程 event loop 正在运行 outer:
1 | void outer() { |
执行过程:
1 | 1. outer 开始 |
单线程且没有其他已有任务时,顺序通常为:
1 | outer → A → B → C |
多线程时,B 进入全局队列后可能被其他 worker 并发执行,甚至在 outer 返回前完成;C 仍不会在 outer 的当前 run 边界结束前被提交。
12. ev_loop 怎样接入 ev_std_exec
12.1 event loop 内嵌标准 Executor
struct ev_loop 中包含:
1 | const struct ev_std_exec_impl_vtbl *impl_vptr; |
初始化时:
1 | loop->impl_vptr = &ev_loop_std_exec_impl_vtbl |
所以:
1 | ev_loop_get_exec(loop) |
返回的是 event loop 内嵌 Executor 的接口地址,而不是新建对象。
12.2 event loop 只实现四个后端操作
1 | static const struct ev_std_exec_impl_vtbl |
其余 dispatch/defer/run 都由 ev_std_exec 提供。
13. event loop 后端四个操作
13.1 on_task_init:增加额外 work 引用
多线程正常构建中:
1 | atomic_fetch_add_explicit(&loop->ntasks, 1, |
其语义不是“队列中新增一个任务”,而是:
1 | 还有一份尚未进入 loop queue 的异步工作 |
13.2 on_task_fini:释放 work 引用
当最后一个引用从 1 变 0:
- 建立 acquire 屏障;
- 获取
loop->mtx; - 再检查真实任务队列;
- 队列为空时调用
ev_loop_do_stop()。
这里采用典型的:
1 | 原子快路径 |
13.3 post:加入全局队列并唤醒一个线程
1 | lock loop->mtx |
只在空队列首次获得任务时唤醒,可减少无效 signal/kill。
13.4 abort
后端 abort 从 loop->queue 移除指定任务或全部真实任务。
若启用了 poll_task,这个伪任务会被保留或重新放回队列,因为它是 event loop 的调度哨兵,不是普通用户任务。
14. poll_task:不是用户任务,而是避免 Poll 饥饿的调度哨兵
当 poll_task 启用时,event loop 队列中会长期存在一个特殊 loop->task。
其目的:
1 | 即使 CPU 任务持续不断 |
关键规则:
ev_loop_empty()认为“队列中仅有 poll_task”仍然等价于没有真实任务;- wait 循环弹出它后不调用
task->func(); - 本轮结束时重新把它放回队列;
- abort-all 时必须保留它。
因此,不能把:
1 | sllist_empty(loop->queue) |
简单等同于:
1 | 没有业务工作 |
源码专门通过 ev_loop_empty() 统一判断。
15. ev_loop_wait_one():任务真正开始运行的桥
核心路径:
1 | 持有 loop->mtx |
为什么执行任务前必须释放 loop->mtx:
- 用户回调可能再次 post/defer/abort;
- 回调可能启动新的异步操作;
- 回调可能调用 Future、I/O、CANopen API;
- 长时间持锁会阻塞其他 worker 和 I/O 完成线程;
- 若回调内部再次访问 loop,会产生死锁风险。
16. 队列为空后:Poll 还是条件变量
当没有真实任务可执行,但仍有 outstanding work 时:
1 | 当前线程有 Poll 资格 |
16.1 Poll 线程
负责等待外部事件,例如:
- SocketCAN fd 可读/可写;
- timerfd;
- signal fd/pipe;
- 平台 I/O completion。
Poll 返回后,后端回调通常继续向 Executor post() 任务。
16.2 非 Poll worker
在 npoll 已达到上限时,其他 worker 使用条件变量等待内部事件:
- 新任务入队;
- loop stop;
ev_loop_kill();- Future ready。
16.3 为什么优先唤醒 waiting worker
ev_loop_kill_any() 先查 waiting 链表,再查 polling 链表。
这样可以:
- 让条件变量 worker 处理新入队任务;
- 尽量保持 Poll 线程继续等待外部 I/O;
- 减少不必要地打断
epoll_pwait()。
17. outstanding work:不能只看任务队列长度
公开文档将 outstanding work 定义为:
1 | 待执行任务 |
在 loop.c 的停止判断中,最关键的两个观测量是:
1 | ev_loop_empty(loop) |
其中 ntasks 主要承担“尚未进入全局队列的工作引用”,例如:
- Future 等待队列中的任务;
- 当前任务的 defer 临时队列;
- 正在等待 I/O 完成、未来才会提交回调的异步操作;
- 其他显式 work guard。
17.1 为什么计数必须严格成对
少调用一次 fini:
1 | loop 永远认为还有工作 |
多调用一次 fini:
1 | 计数下溢或提前归零 |
17.2 计数迁移原则
任务在多个“未执行位置”间迁移时,应保持连续保活:
1 | 临时队列 / Future queue |
这个顺序是整个系统正确性的关键不变量之一。
18. task.c 两个批处理函数为什么重要
18.1 ev_task_queue_post()
1 | 逐个从临时 queue 取任务 |
用途:
1 | 把“由 work 引用保护的等待队列” |
18.2 ev_task_queue_abort()
1 | 逐个丢弃任务 |
用途:
1 | 任务不再执行 |
18.3 为什么 post 在 fini 前
再次强调:
1 | ev_exec_post(exec, task); |
这是为了让任务在任何时刻至少满足一个条件:
1 | 要么由额外 work 引用保活 |
19. Future 怎样连接 Executor
Lely Future 是非阻塞 Future:
1 | Future::get() 不负责等待 |
19.1 ev_future_submit()
核心流程:
1 | 取得 task->exec |
为什么注册 Future 等待时就要 on_task_init():
1 | 任务此时不在 event loop queue |
19.2 Promise 完成
ev_promise_set_release():
- 写入结果指针;
- 锁定 Future;
- 把等待任务整体转移到局部队列;
- release-store 状态为 READY;
- 解锁;
- 调用
ev_task_queue_post()。
因此每个等待任务完成如下迁移:
1 | Future queue |
19.3 cancel 与 abort
| 操作 | 从 Future queue 移除 | 是否继续执行任务 | work 引用 |
|---|---|---|---|
ev_future_cancel() |
是 | 是,立即 post | 释放 |
ev_future_abort() |
是 | 否 | 释放 |
所以:
1 | cancel ≠ 保证回调不执行 |
20. ev_loop_wait(loop, future) 并不是睡眠等待 Future
当 loop 等待一个 Future 时,会创建 ev_loop_ctx,其中内嵌:
1 | struct ev_task task; |
该任务的 Executor 是:
1 | ev_loop_get_exec(loop) |
然后:
1 | ev_future_submit(future, &ctx->task); |
Future ready 后:
1 | Promise 完成 |
因此 ev_loop_wait() 的真实含义是:
1 | 持续驱动同一个 event loop |
它在等待期间仍会:
- 运行其他任务;
- 处理 SocketCAN;
- 推进 SDO/NMT 状态机;
- 处理定时器;
- 执行超时回调。
21. 从异步 SDO 到 Future ready 的完整链路
1 | sequenceDiagram |
Mermaid 兼容性说明:参与者的内部标识使用
EvLoop,显示名称仍通过as ev_loop保持不变。不要将Loop/loop用作 sequence diagram 的参与者标识,因为loop ... end是 Mermaid 的循环控制块语法,部分解析器会按关键字处理并产生解析错误。
这条链路解释了一个常见问题:
如果没有其他线程驱动 loop,当前线程也没有调用
run()/wait(),SDO Future 不会凭空完成,因为 CAN I/O 和超时任务无人执行。
22. 从 Executor::post() 到任务执行的完整调用链
22.1 C++ 入口
1 | auto exec = loop.get_executor(); |
22.2 调用链
1 | Executor::post(callable) |
23. 从 defer() 到任务执行的完整调用链
当正在 loop task 内调用 defer():
1 | ev_exec_defer() |
当不在该 Executor 的执行上下文中调用:
1 | defer() → post() |
因此,不要把 defer() 理解为固定延迟或定时器 API。
24. 从 dispatch() 到任务执行的完整调用链
24.1 当前不在该 Executor 上
1 | dispatch(task) |
24.2 当前正在该 Executor 上
1 | dispatch(task) |
这会产生同步重入。若不希望当前调用栈中立即进入下一个回调,应使用 defer() 或 post()。
25. 多线程模型下的准确理解
假设:
1 | npoll = 1 |
可能状态:
1 | 线程 A:进入 ev_poll_wait() |
线程 C post(task):
1 | loop queue 空 → 非空 |
若没有 waiting worker:
1 | post(task) |
25.1 多线程中的 post 可并发执行
即使 post() 本身不内联调用任务,另一个 worker 也可以立即取走任务。
因此下面写法存在竞态:
1 | exec.post([&] { use(value); }); |
需要通过锁、原子变量、消息所有权或把数据封装进任务对象来保证同步。
25.2 defer 只约束当前执行边界
defer() 保证:
1 | 不会在当前 task 返回前,通过当前 defer 路径开始执行 |
但它被 flush 到全局队列后,仍可能由任意 worker 执行。
它不是线程亲和性机制。
26. C 语言最小任务示例
1 |
|
注意:
struct app_task必须保持有效直到任务完成;- 未完成前不能再次提交同一个
_node; - 若任务可能被 abort,调用方仍需负责对象生命周期;
- 回调内可通过
task->exec获取所属 Executor。
27. C++ 最小示例
1 |
|
若任务需要稳定对象生命周期,优先显式定义 lely::ev::Task 或把任务嵌入业务对象,而不是无条件依赖匿名临时 wrapper。
28. dispatch/post/defer 选择表
| 需求 | 建议接口 | 原因 |
|---|---|---|
| 当前已在 Executor 上,允许立即同步调用 | dispatch |
减少入队和上下文切换 |
| 必须异步排队 | post |
不在当前调用栈内直接执行 |
| 必须等当前任务完整返回后再提交 continuation | defer |
避免当前 task 内重入 |
| Future ready 后运行回调 | ev_future_submit |
自动处理等待队列与 work 引用 |
| 只想直接调用函数,不需要 Executor 语义 | 直接函数调用 | 不应滥用 ev_exec_run |
28.1 常见误用
误用一:把 dispatch 当作普通异步 post
当前已在 Executor 上时,它会同步重入。
误用二:把 defer 当作毫秒级延迟
它不使用时钟,不提供 deadline。
误用三:手工调用 on_task_fini,但没有对应 init
会破坏 outstanding work 计数。
误用四:Future 等待任务使用空 task->exec
ev_future_submit() 要求任务已绑定 Executor。
误用五:重复提交同一个 task
侵入式 _node 不能同时挂在多个队列。
29. 三类“队列”必须分清
| 队列 | 所属对象 | 何时进入 | 如何离开 |
|---|---|---|---|
| event loop 全局 queue | ev_loop |
post() |
loop worker 弹出并 run() |
| defer 临时 queue | TLS ev_task_node |
当前任务内 defer() |
当前 ev_exec_run() 返回时 flush |
| Future 等待 queue | ev_future |
ev_future_submit() 且尚未 ready |
Promise ready、cancel 或 abort |
一个任务在不同队列间迁移时仍使用同一个 _node,所以迁移必须先从旧队列移除,再加入新队列。
30. 三类状态也必须分清
| 状态 | 范围 | 含义 |
|---|---|---|
loop->stopped |
整个 event loop | 后续 run/wait 立即停止,需 restart |
ev_loop_thrd.stopped |
当前 OS 线程 | 中断该线程最内层一次 loop 调用 |
ctx->ready |
一次 Future wait context | 等待目标已 ready |
Executor 的 defer TLS 又是另一套状态:
1 | ev_exec_list |
它与 ev_loop_thrd 的 context 栈职责不同:
| TLS 状态 | 管什么 |
|---|---|
ev_exec_list |
Executor 执行上下文、dispatch/defer/run |
ev_loop_thrd |
loop wait/run 嵌套、中断与 Future wait context |
31. 一张对象关系总图
1 | flowchart TB |
32. 一张完整调用链总图
1 | flowchart TD |
33. 调试时建议重点观察的变量
| 变量 | 观察目的 |
|---|---|
task->exec |
任务是否绑定到预期 Executor |
task->_node.next |
是否可能重复入队或链表损坏 |
loop->queue |
当前真实待执行任务 |
loop->ntasks |
是否存在未进入 queue 的保活工作 |
loop->stopped |
run 不执行是否因为 loop 已停止 |
loop->npolling |
Poll 线程数量是否达到上限 |
ev_exec_list |
当前线程是否处于该 Executor 上 |
exec_node->queue |
当前最内层 defer queue |
future->queue |
Future 是否仍持有等待任务 |
future->state |
WAITING/SETTING/READY |
ctx->ready |
ev_loop_wait(future) 是否收到 ready 任务 |
33.1 任务“提交了但不执行”排查顺序
task->exec是否为 NULL;- task 是否真的进入
loop->queue; - loop 是否已经
stopped; - 是否有线程正在调用
run/wait; - 是否误把任务留在 Future queue;
on_task_init/fini是否失衡;- 是否重复使用同一
_node; - worker 是否全部阻塞在非预期锁中;
- Poll/condition variable 是否被正确唤醒;
- 回调是否被
abort移除。
34. 与 CANopen 业务层的对应关系
Lely CANopen 上层异步接口通常可以映射为:
| CANopen 行为 | Event/Future 层动作 |
|---|---|
| 发起 SDO 请求 | 创建请求对象、Promise/Future、注册 I/O/超时 |
| 收到 SDO 响应 | Poll 回调 post 接收任务 |
| 推进协议状态机 | loop 执行 task |
| SDO 成功或失败 | Promise set,Future READY |
AsyncWrite() 返回 |
返回 Future 句柄,不阻塞 |
Wait(future) |
coroutine 或 loop 持续驱动,直到 ready |
| NMT/Heartbeat 定时事件 | timer readiness → post task → 状态机回调 |
| PDO 接收 | CAN fd ready → receive task → OD/driver callback |
CANopen 协议本身是“被动、异步”的,线程与 I/O 调度由 liblely-ev/liblely-io2 提供。理解 Executor 后,再读 SDO、NMT、PDO 代码会清晰很多。
35. 推荐源码阅读顺序
不要直接从 1000 多行的 loop.c 从头看到尾。建议按下面顺序:
ev.h:看懂ev_exec_t;task.h:看懂任务对象和侵入式节点;exec.h:掌握七个操作契约;std_exec.h:理解“完整接口/精简后端”两层虚表;std_exec.c:重点读run/dispatch/defer与 TLS;task.c:理解 post-then-fini 迁移原则;loop.c:先看后端四函数,再看 wait_one;future.c:重点读 submit/set_release/cancel/abort;poll.h与 Linux poll 后端;- 最后再追到 SocketCAN、Timer、SDO/NMT。
建议第一次阅读时只围绕三条调用链做笔记:
1 | post → queue → run |
36. 最终心智模型
可以把 Lely 事件机制想成一个物流系统:
1 | ev_task |
最重要的不变量是:
1 | 一份尚未完成的异步工作,必须满足至少一个条件: |
而任务从 Future/defer 暂存区迁移到 Executor 队列时必须遵守:
1 | 先 post |
一旦掌握这两个原则,Lely 中大部分“为什么 loop 没退出”“为什么任务没执行”“为什么要有 ntasks”“为什么还要 std_exec”的问题就能统一解释。
37. 官方源码索引
37.1 稳定版本 v2.3.5
ev.h
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/ev.htask.h
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/task.htask.hpp
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/task.hpptask.c
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/src/ev/task.cexec.h
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/exec.hexec.hpp
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/exec.hppexec.c
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/src/ev/exec.cstd_exec.h
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/std_exec.hstd_exec.c
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/src/ev/std_exec.cloop.h
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/loop.hloop.c
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/src/ev/loop.cfuture.h
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/future.hfuture.c
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/src/ev/future.cfuture.hpp
https://gitlab.com/lely_industries/lely-core/-/blob/v2.3.5/include/lely/ev/future.hpp
37.2 官方文档
- Library overview
https://opensource.lely.com/canopen/docs/overview/ - Build configuration
https://opensource.lely.com/canopen/docs/configuration/ - C++ tutorial
https://opensource.lely.com/canopen/docs/cpp-tutorial/ - Lely CANopen homepage
https://opensource.lely.com/canopen/ - Mermaid sequence diagram syntax
https://mermaid.js.org/syntax/sequenceDiagram.html
38. 建议配套实验
为了把源码机制真正学会,建议依次做四个小实验:
实验一:验证 dispatch/post/defer 顺序
在一个 loop task 内分别调用三种接口,打印:
1 | 线程 ID |
先单线程,再双线程运行 loop。
实验二:故意制造 work 引用不平衡
仅在测试程序中:
1 | 调用 on_task_init 但不 fini |
观察 run() 是否无法自然退出。然后补上 fini 验证恢复。
实验三:Future ready 前后提交任务
分别在:
1 | Promise set 前 submit |
观察两条路径都能进入 Executor,但前者经过 Future queue,后者直接 post。
实验四:npoll = 1 双 worker
记录哪个线程进入 Poll,哪个线程进入条件变量,以及普通任务入队后优先唤醒哪个线程。
完成这四个实验后,再追踪 SocketCAN 接收、SDO Future 和定时器,理解会稳定得多。








