Lely CANopen `ev_loop` 详解
Lely CANopen ev_loop 详解

文章目录
- Lely CANopen `ev_loop` 详解
-
- 1. 先区分三个容易混淆的“停止”状态
- 2. `ntasks`:outstanding work 计数
- 3. `ntasks` 的线程安全与原子内存序
- 4. 线程局部 `ev_loop_thrd` 与 context 栈
- 5. `pstopped`:context 到线程中断标志的引用
- 6. `cnd`:非 Poll 线程的休眠与唤醒
- 7. Future/Promise:一次性异步结果与 event loop 的连接点
- 8. 任务投递后的 Poll/cnd 唤醒路径
- 9. `ev_poll_wait()` 的虚函数分派链
- 10. 从 `Loop::run()` 到 SocketCAN 事件的完整流程
- 11. `npoll = 1` 的典型多线程运行
- 12. 关键对象关系
- 13. 参考资料
1. 先区分三个容易混淆的“停止”状态
loop.c 中至少存在三类停止或完成状态:
| 状态 | 所属范围 | 作用 |
|---|---|---|
loop->stopped |
每个 ev_loop_t |
整个事件循环是否处于 stopped 状态;ev_loop_stop() 会影响所有正在运行该 loop 的线程。 |
ev_loop_thrd.stopped |
每个线程 | 中断该线程当前最内层的一次 run/wait 调用;返回后通常会清零,以便下次重新运行。 |
ctx->ready |
每个等待 context | 表示正在等待的 future 已经 ready,应结束本次等待。 |
不能把它们当作同一个状态:
loop->stopped
= loop 级停止
thread-local stopped
= 线程级、单次运行中断
ctx->ready
= 本次等待目标已经完成
ev_loop_ctx_wait_one() 的主循环实际同时检查这些条件:
loop 没有全局停止
并且
当前线程没有被 kill
并且
future 尚未 ready
2. ntasks:outstanding work 计数
2.1 统计边界:不等于任务链表长度
源码注释把 ntasks 描述为 pending work 计数,其语义可以理解为:
事件循环已知但尚未完成的工作数量
公开接口文档进一步定义 outstanding work 为:
待执行任务
+ 当前执行中的任务
+ ev_exec_on_task_init() 调用次数
- ev_exec_on_task_fini() 调用次数
ev_exec_on_task_init() 的典型意义是:
我现在还没有把任务放入队列,但未来会提交工作,事件循环不要因为暂时空队列而退出。
ev_exec_on_task_fini() 表示:
先前声明的这份 outstanding work 已经结束,不再需要阻止事件循环退出。
因此,ntasks 的关键价值是覆盖“任务尚未入队,但异步操作仍然存活”的窗口。
2.2 队列为空不代表异步工作已经结束
假设一个异步 CAN 读取操作已经启动,但当前 SocketCAN 没有数据:
任务队列暂时为空
SocketCAN read 正等待 I/O
未来收到 CAN 帧后才会投递完成任务
如果 event loop 只看任务队列,就可能错误判断:
queue 为空 → 没有工作 → stop
而 ntasks > 0 表示仍存在已登记的异步工作,因此 loop 应继续等待 Poll 或条件变量事件。
2.3 ntasks 的状态变化
关键代码路径是:
on_task_init:
ntasks 原子加一
on_task_fini:
ntasks 原子减一
若减前为 1,即减后为 0:
获取 loop->mtx
若任务队列为空:停止 loop
3. ntasks 的线程安全与原子内存序
3.1 跨线程并发更新是原子化的根本原因
ev_exec_on_task_init() 和 ev_exec_on_task_fini() 是 executor 的公开生命周期接口。调用它们的线程不一定是正在运行 ev_loop_wait_one() 的线程,也不保证已经持有 loop->mtx。
典型并发场景:
线程 A:启动异步读,调用 on_task_init()
线程 B:启动异步写,调用 on_task_init()
线程 C:某个异步操作结束,调用 on_task_fini()
线程 D:event loop 读取 ntasks 判断是否应停止
如果使用普通 size_t 且没有统一互斥锁,会出现 C 语言层面的数据竞争,行为未定义。
3.2 非原子操作会出现丢失更新
假设 ntasks == 5,两个线程同时执行普通的 ++:
线程 A 读取 5
线程 B 读取 5
线程 A 写入 6
线程 B 写入 6
正确结果应为 7,实际可能为 6。
对于 ntasks,计数错误会带来两类严重后果:
| 错误方向 | 后果 |
|---|---|
| 少计 | loop 可能在异步操作尚未结束时提前 stop。 |
| 多计 | loop 可能一直认为还有 outstanding work,无法自然退出。 |
3.3 未统一使用 loop->mtx 的原因
理论上可以把所有 ntasks 读写都放进 loop->mtx,但当前实现选择原子计数,原因可以从代码行为推断为:
on_task_init()是高频轻量路径,只做原子加一,不需要锁任务队列;on_task_fini()在减后仍不为 0 时,也不需要进入互斥锁;- 只有最后一个 outstanding work 消失时,才加锁检查队列并决定是否 stop;
- 避免每次工作引用变化都与任务队列竞争同一把锁。
这是典型的“原子引用计数快路径 + 最后一个引用进入慢路径”的结构。
3.4 加一 relaxed、减一 release 和归零 acquire fence
Linux/POSIX 原子实现中:
加一:relaxed
减一:release
观察到 1→0:acquire fence
这里最基本的要求是:
- 所有线程必须对同一个计数进行不可分割的 read-modify-write;
- 单个变量的修改顺序必须一致;
- 最后一个完成者在执行“归零后的停止判断”前,需要建立更强的内存顺序边界。
relaxed 加一足以完成“占一个 outstanding work 名额”;它不负责发布其他数据。
减一使用 release,并在最后一个减一线程上执行 acquire fence,是常见的最后引用释放模式:只有真正从 1 变为 0 的线程承担最终收尾工作。
需要强调:
原子操作主要解决的是
ntasks自身的并发计数问题;任务队列、waiting/polling 链表、loop->stopped等结构仍由loop->mtx保护。
3.5 不同构建配置下的计数类型
源码针对不同平台和裁剪配置提供多个实现:
| 配置 | 实现 |
|---|---|
| 禁用线程 | 普通 size_t,因为不存在并发访问。 |
| Windows MSVC | InterlockedIncrement/Decrement。 |
| 支持 C11 原子 | atomic_size_t。 |
| 明确禁用原子且特定配置 | 普通整数,依赖该裁剪配置的使用约束。 |
因此,“为什么要原子”准确地说是:
在启用多线程且平台支持原子操作的正常构建中,
ntasks必须以原子方式维护;单线程构建不需要。
4. 线程局部 ev_loop_thrd 与 context 栈
4.1 TLS 实例按线程独立
定义使用 _Thread_local:
static + _Thread_local + struct ev_loop_thrd
三个关键词分别表示:
| 关键字 | 含义 |
|---|---|
static |
变量名只在当前源文件可见。 |
_Thread_local |
每个线程拥有独立存储实例。 |
静态初始化 {0, NULL} |
每个线程第一次获得的实例初始为停止标志 0、context 指针 NULL。 |
这里的“每个线程一份”是 C TLS 的存储语义,不应理解为进程启动时预先为未来所有线程集中创建对象。对实际存在并访问该符号的线程,运行库提供该线程自己的实例,并以 { 0, NULL } 初始化。
因此,假设有三个线程调用同一个 ev_loop_t:
线程 A:自己的 ev_loop_thrd
线程 B:自己的 ev_loop_thrd
线程 C:自己的 ev_loop_thrd
它们之间不会共享 stopped 和 ctx 字段。
4.2 线程状态与 loop 状态相互独立
同一个线程可以先后运行不同的 loop,甚至在任务回调中嵌套运行另一个 loop。该线程仍只有一个 ev_loop_thrd。
这也是为什么结构体名称是 ev_loop_thrd,而不是 ev_loop_thread_of_loop。
4.3 两个字段的职责
stopped:中断当前线程正在执行的最内层 loop 调用
ctx:指向当前线程最内层的 ev_loop_ctx
ev_loop_self() 返回的实际上就是当前线程这份 TLS 状态的地址。这个地址被当成线程令牌传给 ev_loop_kill()。
4.4 ctx 构成嵌套调用栈
创建 context 时:
新 ctx.next = 当前 TLS ctx
TLS ctx = 新 ctx
销毁时:
TLS ctx = 当前 ctx.next
形成的结构是:
TLS ev_loop_thrd.ctx
↓
最内层 ctx
↓ next
外一层 ctx
↓ next
更外层 ctx
所以 ev_loop_kill() 的公开契约能够实现:
存在嵌套 event loop 时,只中断最内层 loop。
它通过 thr->ctx 直接找到栈顶 context,而不是遍历某个全局 loop 列表。
4.5 ev_loop_ctx 不是 ev_loop_t
二者是“被管理对象”和“单次运行上下文”的关系:
ev_loop_ctx.loop ──指向──> ev_loop_t
一个 ev_loop_t 可以同时被多个线程运行,因此可以同时存在多个 context;同一线程发生嵌套 wait/run 时,也可以在 TLS 栈上挂载多个 context。每个 context 只记录本次调用需要的状态,例如:
- 使用哪个
loop; - 是否等待某个
future; - 当前正在 Poll 还是条件变量等待;
- Poll 后端线程令牌;
- 所属线程的
pstopped; - future 是否 ready。
4.6 context 是按需创建的
ev_loop_ctx_wait_one() 先尝试从任务队列取得真实任务。只有没有任务可立即执行,并且需要进入 Poll、条件变量等待或注册 future 完成通知时,才调用 ev_loop_ctx_create()。因此:
调用 wait_one/run
├─ 立即取到任务:可以不创建 context
└─ 需要等待或 Poll:创建 context 并压入线程 TLS 栈
这也是 pctx 在第一次进入 ev_loop_ctx_wait_one() 时允许为 NULL 的原因。
5. pstopped:context 到线程中断标志的引用
5.1 指向当前线程 TLS 中的 stopped
context 创建时建立关系:
ctx->pstopped
↓
当前线程的 ev_loop_thrd.stopped
所以 pstopped 不是指向 loop->stopped,也不是 context 自己单独分配的停止标志。
5.2 context 保存该指针的用途
ev_loop_ctx 可能被以下执行路径访问:
- 当前运行线程的 wait 主循环;
- future 完成后执行的
ctx->task; - 其他线程调用
ev_loop_kill(); - loop stop 或新任务到来时的唤醒逻辑。
这些路径需要操作“该 context 所属线程的中断标志”。其他线程不能通过普通变量名取得目标线程的 TLS 实例,但 context 在创建时已经把目标 TLS 地址保存为 pstopped,因此可以定向访问。
5.3 线程级停止标志优于 context 私有标志
从现有设计可以看出,停止语义刻意绑定到线程,而不是单个 context 对象:
ev_loop_self()返回线程令牌;ev_loop_kill()接收线程令牌;- TLS 中维护最内层 context;
- 中断后标志会在本次 wait 返回前清零,使下一次调用能够继续运行。
如果每个 context 有独立停止字段,就需要额外处理:
- 没有 context 时发生的 kill;
- context 尚未创建但线程即将进入等待的窗口;
- 嵌套 context 的停止传播规则;
ev_loop_self()所代表的线程级语义。
当前方案用一个线程级标志统一这些状态。
5.4 pstopped 的同步保护
虽然指向的是线程局部对象,但该指针可能被其他线程使用。源码通过 loop->mtx 对相关访问进行同步:
- wait 主循环在持有
loop->mtx时读取; ev_loop_kill()持有loop->mtx时设置;- future 回调持有
loop->mtx时检查; - wait 退出时持有
loop->mtx清零。
因此,这个字段没有像 ntasks 一样采用原子类型。二者区别是:
ntasks:有无锁访问路径 → 必须原子化
pstopped 指向的值:统一在 loop->mtx 下访问 → 普通 int 即可
5.5 单次中断结束后的自动清零
线程级 stopped 的目标是中断“当前这一次”运行,而不是永久停止 loop。
若本次没有执行任务,并且检测到 thread-local stopped:
清零 stopped
返回调用者
因此下次再调用 run() 或 wait_one() 时,可以重新进入事件循环。
永久或全局停止使用的是 loop->stopped,需要通过 ev_loop_restart() 重置。
5.6 loop 级停止与线程定向中断
两个公开接口的作用范围不同:
| 接口 | 作用范围 | 内部状态 | 恢复方式 |
|---|---|---|---|
ev_loop_stop(loop) |
停止该 loop 上所有正在进行及后续的 run/wait 调用 |
设置 loop->stopped = 1,并唤醒 waiting/polling contexts |
ev_loop_restart(loop) |
ev_loop_kill(loop, thr) |
中断目标线程当前最内层的一次 run/wait 调用 | 通过栈顶 context 设置目标线程 TLS stopped,再 signal 条件变量或 kill Poll wait |
本次调用返回前自动清零 |
所以不能表述为“需要整个线程停止时直接使用 stopped”。stopped 是内部标志;对外应使用 ev_loop_kill(loop, ev_loop_self() 返回的线程令牌)。该接口不会终止操作系统线程,只会让目标线程当前最内层的 event-loop 调用返回。
6. cnd:非 Poll 线程的休眠与唤醒
6.1 每个 context 独立持有条件变量
ev_loop_ctx 中包含:
cnd_t cond
waiting 标志
在 Linux pthread 兼容实现中:
cnd_t → pthread_cond_t
cnd_wait() → pthread_cond_wait()
cnd_timedwait() → pthread_cond_timedwait()
cnd_signal() → pthread_cond_signal()
它不是 CANopen 条件,也不是 Poll 的事件对象,而是纯线程同步原语。
6.2 进入条件变量等待的条件
只有同时满足以下条件时,线程才会进入 cnd_wait():
- 当前没有真实任务可执行;
- context 已经创建;
- 当前线程不能进入
ev_poll_wait(); - 任务队列仍为空;
- loop 尚未停止、线程未被 kill、future 未 ready。
“不能进入 Poll”通常是因为:
loop 配置了 npoll 限制
并且
已有足够数量的其他线程正在 ev_poll_wait()
POSIX reactor 场景通常建议 npoll == 1。这意味着:
一个线程负责 epoll_pwait
其他 loop 线程没有任务时用条件变量睡眠
6.3 Poll 并发数量限制
技术上 npoll == 0 可以允许无限数量线程同时 poll,但在 POSIX readiness reactor 模型中,多线程同时等待同一个事件源通常会增加:
- 唤醒竞争;
- 锁竞争;
- 同一批 readiness 事件的调度复杂度;
- 不必要的上下文切换。
Lely 的 C API 文档明确建议 POSIX reactor 使用单 polling thread。
因此条件变量承担“非 polling worker 的休眠机制”。
6.4 cnd_wait() 的原子解锁与重新加锁
调用前线程持有 loop->mtx。条件变量等待会完成一个原子组合动作:
释放 loop->mtx
并进入 cond 等待
被唤醒后:
重新获得 loop->mtx
然后 cnd_wait 返回
这里“释放锁并睡眠”必须是相对于 signal 过程原子的,否则会出现经典丢失唤醒:
等待线程:检查无任务
等待线程:准备睡眠但尚未真正睡眠
投递线程:加入任务并 signal
等待线程:此后才睡眠
结果:任务已存在,但等待线程永久睡眠
条件变量和互斥锁的组合就是为解决这个窗口。
6.5 条件变量的唤醒来源
ev_loop_ctx_kill() 根据 context 当前状态选择唤醒方式:
ctx 正在 cnd 等待
→ cnd_signal(&ctx->cond)
ctx 正在 Poll 等待
→ ev_poll_kill(loop->poll, ctx->thr)
ctx 两者都不是
→ 只更新状态,后续循环自行观察
触发 ev_loop_ctx_kill() 的主要来源包括:
- 新任务从空队列变为非空;
- future 变为 ready;
ev_loop_stop();- 其他线程调用
ev_loop_kill()。
6.6 每个 context 独立条件变量的价值
它可以做到定向唤醒:
只唤醒被选中的 waiting context
而不是 broadcast 唤醒所有 worker
loop->waiting 链表按 LIFO 方式组织。ev_loop_kill_any() 取一个 waiting context,然后 signal 它自己的 cond。
这减少了惊群效应。
6.7 虚假唤醒与循环复核
条件变量允许虚假唤醒,因此正确写法必须始终在循环中重新检查状态,而不能认为 cnd_wait() 返回就必然有任务。
Lely 的结构正是:
while 条件仍允许继续等待:
检查 stop / killed / future
检查任务队列
检查 poll 资格
必要时再次 wait
所以虚假唤醒不会破坏逻辑,只会多执行一轮条件检查。
6.8 条件变量与 Poll 的职责边界
| 等待方式 | 等待对象 | 唤醒机制 |
|---|---|---|
ev_poll_wait() |
外部 I/O、timerfd、signal pipe 等 Poll 事件 | ev_poll_kill() 或底层 I/O 事件 |
cnd_wait() |
loop 内部任务或状态变化 | cnd_signal() |
可以把它们理解为:
Poll:等外部世界
cnd:等同一进程内其他线程
7. Future/Promise:一次性异步结果与 event loop 的连接点
7.1 Future 不是消息队列
Lely 的 Future/Promise 可以宽泛地看成一种“异步完成通知机制”,但更准确的定义是:
Future/Promise 是围绕一份共享状态建立的一次性异步结果同步机制。
它与消息队列的区别如下:
| 机制 | 主要语义 |
|---|---|
| 消息队列 | 发送方可以连续写入多条消息,接收方逐条取出;通常是多次生产、多次消费。 |
| Future/Promise | Promise 最多成功完成一次;Future 观察同一个 ready 状态,并在 ready 后取得一次异步操作的结果。 |
| Event-loop task queue | 保存待执行的 ev_task,解决“在哪个 executor 上执行回调”;它不等同于 Future 的结果存储区。 |
因此,不宜把 Future 理解为“发送方把请求消息放进 loop 队列,接收方执行后再沿队列返回结果”。Future 自身不负责传输请求,也不负责执行 I/O;它只保存或关联异步操作的完成状态、结果值以及等待完成通知的任务。
7.2 Promise、Future、异步操作和 Loop 的角色
| 对象 | 准确角色 |
|---|---|
| 异步操作发起方 | 调用 AsyncRead()、AsyncWrite()、异步 SDO 等接口,并取得 Future。 |
ev_promise_t |
异步操作完成方持有的写端。操作成功、失败或取消后,通过 Promise 发布结果并将共享状态置为 ready。 |
ev_future_t |
观察端。用于检查 ready、取得结果,或登记“Future ready 后应投递”的任务。 |
ev_loop_t |
任务执行器和 I/O 驱动器。它执行 executor 队列中的任务,并在队列暂时为空时进入 Poll 或条件变量等待。 |
| Poll/I/O 组件 | 驱动真正的 SocketCAN、timerfd、信号或其他异步事件;事件完成后通常投递任务,任务再推进异步操作。 |
ev_loop_t 不是固定的“发送方”或“接收方”。同一个 loop 既可能执行发起异步操作的任务,也可能执行 I/O 完成任务、Promise 完成后的通知任务以及其他应用任务。
7.3 Promise 与 Future 共享一次性结果状态
Promise 用于存入一个稍后由 Future 异步取得的值,其语义类似 single-shot event。Future 则提供对异步操作结果的访问。
典型状态变化为:
Promise 完成后,Future 可以:
- 通过
ev_future_is_ready()判断是否完成; - 在 ready 后通过
ev_future_get()取得 Promise 保存的结果; - 通过
ev_future_submit()登记一个任务,使其在 Future ready 后被投递到指定 executor。
这里的结果值与 executor task 是两个不同概念:结果保存在 Future/Promise 的共享状态中;task 只负责在完成时执行通知或后续处理。
7.4 ev_loop_wait(loop, future) 如何接入 Future
当 ev_loop_ctx_create() 收到非空 Future 时,它会:
1. 获取 Future 引用并保存到 ctx->future
2. 初始化 ctx->task,使其 executor 指向当前 loop
3. 调用 ev_future_submit(future, &ctx->task)
这一步不是把“异步请求”提交给 Future,而是把一个“Future 完成后唤醒本次 wait”的内部任务登记到 Future。
当异步操作完成并满足 Promise 后:
Promise 发布结果并置 Future ready
↓
Future 将 ctx->task 投递到 loop executor
↓
loop 从任务队列取出并执行 ctx->task
↓
ev_loop_ctx_task_func() 设置 ctx->ready = 1
↓
ev_loop_ctx_kill(ctx, 0) 唤醒 Poll 或条件变量等待
↓
ev_loop_wait() 看到 ctx->ready 后结束本次等待
ctx->task 的职责是结束等待,不是读取和返回业务结果。业务结果仍由调用方或上层包装在 Future ready 后通过 ev_future_get() 或相应的 C++ Future 接口取得。
7.5 等待 Future 时仍要持续驱动 Loop
ev_loop_wait(loop, future) 不是单纯阻塞在 Future 上。只要 Future 尚未 ready,它仍会继续推进 event loop:
Future 尚未 ready
├─ queue 有真实任务:执行一个任务
├─ 当前 context 可以 Poll:调用 ev_poll_wait() 等待 I/O
└─ 当前 context 不可 Poll:调用 cnd_wait() 等待内部唤醒
这正是异步 CANopen 操作需要的行为。例如,异步 SDO 请求的完成依赖:
SocketCAN 收到响应或定时器到期
↓
Poll 回调投递 I/O 任务
↓
loop 执行任务并推进 SDO 状态机
↓
异步操作完成方满足 Promise
↓
Future ready,wait 返回
如果线程只是同步睡眠而不再驱动同一个 loop,且没有其他线程负责执行该 loop,那么依赖 CAN I/O 或超时任务的 Future 可能无法变为 ready。
7.6 更准确的完整流程
因此,更准确的理解是:
异步操作发起方启动请求并取得 Future;
Loop 持续执行任务和驱动 I/O;
异步操作完成方通过 Promise 发布结果;
Future 把完成通知任务投递到 Loop;
Loop 执行该通知任务并结束 wait;
调用方再从 Future 取得结果。
7.7 run() 与 wait(future) 的区别
| 调用 | 内部 Future | 主要返回条件 |
|---|---|---|
ev_loop_run(loop) |
NULL |
loop stopped,或 outstanding work 归零触发 stop。 |
ev_loop_wait(loop, future) |
指定 Future | Future ready、loop stopped、线程被中断或发生错误。 |
ev_loop_wait_until(loop, future, deadline) |
指定 Future | 在上述条件外增加绝对超时。 |
ev_loop_poll(loop) |
NULL |
非阻塞处理当前可立即执行的工作。 |
Future 在 wait() 中是“本次 event-loop 运行的结束条件之一”,而不是 loop 队列中的普通业务任务。
8. 任务投递后的 Poll/cnd 唤醒路径
8.1 任务投递路径
任务被投递到 event loop 时:
获取 loop->mtx
判断投递前队列是否为空
把 task 放到 queue 尾部
若此前为空,调用 ev_loop_kill_any(loop, 1)
释放 loop->mtx
只在“空 → 非空”转换时唤醒线程,避免每次追加任务都重复发唤醒。
8.2 waiting context 的优先级
ev_loop_kill_any() 的顺序是:
先找 waiting 链表中的 context
若存在:通过 cnd_signal 唤醒
否则在允许时找 polling context
若存在:通过 ev_poll_kill 唤醒
原因很直接:
- 条件变量线程本来就在等内部任务;
- 唤醒它不需要打断正在处理外部 I/O 的 poller;
- 可让 poller 继续服务 I/O readiness。
8.3 完整时序
9. ev_poll_wait() 的虚函数分派链
9.1 抽象接口分派
ev_poll_t 不是一个固定的 Linux io_poll 类型,而是一个抽象 Poll 接口。它的虚函数表包含:
self
wait
kill
ev_poll_wait() 做的事情等价于:
调用当前 poll 对象虚函数表中的 wait 指针
因此:
ev_poll_wait
不是具体后端函数
而是统一分派入口
9.2 Linux io::Poll 后端注册
Linux io_poll 初始化时:
poll_vptr = io_poll_poll_vtbl
该虚函数表的 wait 成员指向:
io_poll_poll_wait
io_poll_get_poll() 返回的不是新对象,而是 io_poll 内部 poll_vptr 字段的地址。ev_loop 保存这个抽象接口指针。
完整对象关系:
9.3 完整调用链
ev_loop_ctx_wait_one_until()
↓
ev_poll_wait(loop->poll, msec)
↓
(*loop->poll)->wait(loop->poll, msec)
↓
io_poll_poll_wait(..., msec)
↓
epoll_pwait(epfd, events, maxevents, msec, signal_mask)
因此,在该对象组合下可以确定:
如果
loop->poll来自 Linuxio_poll_get_poll(),执行的就是io_poll_poll_wait()。
但要加两个限定:
- 它是通过函数指针间接调用,不是
loop.c对io_poll_poll_wait()的直接静态调用; - 换成 Windows Poll 或自定义
ev_poll_t实现后,会调用对应后端的wait。
9.4 与 POSIX poll(2) 的区别
名称容易产生误导:
ev_poll_wait:Lely 抽象接口
io_poll_poll_wait:Linux io2 后端函数
epoll_pwait:实际 Linux 系统调用
它没有调用传统 POSIX poll()。
9.5 msec 在两个 wait 函数中的含义
| 调用场景 | msec |
|---|---|
| 无超时 wait,队列为空 | -1,无限阻塞 |
| 无超时 wait,但已有任务 | 0,只检查一次 I/O |
| until,有未来 deadline 且队列为空 | deadline 剩余毫秒 |
| until,deadline 为 NULL | 0,完全非阻塞 |
| until,队列已非空 | 0,避免阻塞任务执行 |
注意:ev_poll_wait() 的返回值表示 Poll 调用成功或失败,并不等于“执行了几个 loop task”。Poll 后端可能在内部处理多个 I/O watch 回调,这些回调通常再把任务投递到 loop 队列;loop 下一轮才执行对应 task。
10. 从 Loop::run() 到 SocketCAN 事件的完整流程
结合前一篇 io_poll 分析,可以把完整运行链路串起来:
1. C++ 调用 loop.run()
2. loop.hpp 转调 ev_loop_run()
3. ev_loop_run() 转调 ev_loop_wait(loop, NULL)
4. ev_loop_wait() 在外层循环中重复调用 ev_loop_ctx_wait_one()
5. 每次 wait_one 最多主动执行一个真实队列任务;若当前没有任务但 ntasks > 0,loop 不会 stop
6. 若当前线程有 Poll 资格,调用 ev_poll_wait(..., -1)
7. 虚函数分派到 io_poll_poll_wait()
8. io_poll_poll_wait() 在 epoll_pwait() 阻塞
9. SocketCAN fd 可读,epoll 返回
10. io_poll 找到对应 io_poll_watch 并调用 watch 回调
11. CanChannel watch 回调向 executor 投递接收任务
12. 投递使 loop queue 从空变为非空,并中断 Poll wait 或唤醒 cnd waiter
13. ev_loop_ctx_wait_one() 重新检查 queue
14. 弹出并执行 CanChannel 接收任务
15. 读取 CAN 帧,完成异步操作并进入 CanNet/CANopen 上层
11. npoll = 1 的典型多线程运行
假设:
npoll = 1
线程 A、B 都调用 loop.run()
可能出现:
线程 A:取得 Poll 资格,进入 epoll_pwait()
线程 B:无 Poll 资格,进入 cnd_wait()
此时另一个线程 C 投递一个普通任务:
线程 C:queue 空→非空
线程 C:优先 signal 线程 B 的 cond
线程 B:醒来并执行任务
线程 A:继续等待 I/O
如果没有 waiting 线程,才会中断 Poll 线程 A:
线程 C:ev_poll_kill(A 的 poll thread token)
线程 A:epoll_pwait 被信号打断
线程 A:返回 loop,执行新任务
这种设计让:
- Poll 线程尽量持续负责外部 I/O;
- 非 Poll worker 优先处理内部任务;
- 必要时仍能中断 Poll,保证任务投递不会长期等待。
12. 关键对象关系
| 对象/字段 | 归属 | 是否共享 | 保护方式 | 主要职责 |
|---|---|---|---|---|
ev_loop_t |
每个 loop | 多线程共享 | loop->mtx + 部分原子 |
任务队列、停止状态、poller/waiter 管理 |
loop->queue |
每个 loop | 多线程共享 | loop->mtx |
待执行任务队列 |
loop->ntasks |
每个 loop | 多线程共享 | 原子操作 | outstanding work 计数 |
loop->stopped |
每个 loop | 多线程共享 | loop->mtx |
loop 全局停止状态 |
ev_loop_thrd |
每个线程 | 不在线程间共享实例 | TLS;其字段跨线程访问时受 loop->mtx 保护 |
线程级中断和嵌套 context 栈顶 |
ev_loop_ctx |
按需创建的一次 wait/run context | 可被 future/kill 路径访问 | loop->mtx + refcnt |
连接 loop、线程、Poll/cond 和可选 future;不是 loop 对象 |
ctx->pstopped |
context 指向线程 TLS | 指向目标线程实例 | loop->mtx |
检查或设置该线程的单次中断标志 |
ctx->cond |
每个 context | 目标线程等待、其他线程 signal | 条件变量 + loop->mtx |
非 Poll 线程休眠与唤醒 |
ctx->thr |
每个 context | 保存 Poll 后端线程令牌 | 后端定义 | 通过 ev_poll_kill() 中断具体 Poll wait |
ctx->future |
可选,每个 context | 与 promise 共享异步完成状态 | future mutex/atomic state + refcount | future ready 时投递 ctx->task 并结束本次 wait |
ev_promise_t |
每个异步结果生产端 | 可跨线程共享 | future mutex/atomic state + refcount | 一次性写入结果并发布 ready |
loop->poll |
每个 loop | 可共享后端 Poll | ev_poll 接口契约 |
对外部事件进行抽象等待 |
13. 参考资料
13.1 Lely 官方源码
- Lely
src/ev/loop.c - Lely
include/lely/ev/loop.h - Lely
include/lely/ev/loop.hpp - Lely
include/lely/ev/poll.h - Lely Linux
src/io2/linux/poll.c - Lely pthread 兼容实现
src/libc/threads-pthread.c - Lely executor API
include/lely/ev/exec.h - Lely standard executor
src/ev/std_exec.c - Lely Future API
include/lely/ev/future.h - Lely Future implementation
src/ev/future.c - Lely library overview
13.2 线程与语言规范
- The Open Group:
pthread_cond_wait()/pthread_cond_timedwait() - The Open Group:
pthread_cond_signal() - WG14 N1570: C11 draft
13.3 关联文章
本文的章节结构、结论先行、流程图、状态表和源码索引方式参考了已上传的《Lely canopen io_poll 机制原理与完整运行流程详解》。两篇文章可以连续阅读:
ev_loop:决定何时执行 task、何时等待、怎样协调多个线程
io_poll:决定怎样等待 Linux fd、怎样分发 epoll readiness
更多推荐


所有评论(0)