- 上下文切换的分类:分成抢占和让权两种;
- 上下文切换的过程分成保存和恢复两步。抢占时的保存会占用堆栈,可视为线程;让权时的保存会让出堆栈,可视为协程。恢复过程按是否需要空栈分成线程恢复和协程恢复两种情况。
- 可能的实施线路:
- 在uCOS中没有中断的场景下,引入embassy,以支持协程;(线程被视为不会暂停的协程)
- 统一线程和协程的控制块结构TCB(Task Control Block);
- 只有让权情况出现,让权时栈空,可以复用栈;(解决堆栈的分配和回收问题)
- 在uCOS中没有中断的场景下,引入embassy和优先级,以支持线程和协程的优先级调度;
- 按优先级选就绪任务(可能是线程,也可能是协程);
- 在uCOS中有中断的场景下,引入embassy和优先级,以支持线程和协程的优先级调度;
- 中断就是抢占情况,需要保存堆栈,并(有可能)分配新堆栈,用于恢复下一个任务;
整体思路设计
- 短期:将线程任务创建接口与协程创建接口分离,支持Rust异步任务的创建并兼容 uC/OS 的线程创建。
- 长期:将线程任务创建接口与协程创建接口统一
- 历史讨论
结论
还是分成两个接口,一个接口是对于c/rust的普通函数的,另一个接口是对于rust的异步函数的。
然而对于c/rust普通函数,我们对它进行包装,在接口内转为一个future 对象,从而将调度都统一到执行器进行。
后续可以通过宏或者接口中直接区别rust的普通函数和异步函数从而对于rust普通函数和异步函数进行合并
任务切换设计
- 总体思路:将线程切换与协程切换统一起来,将一个线程认为是一个没有await点的协程,这样就能实现线程对栈的占有。下面进行枚举讨论:
- 线程切换到线程:与uC/OS中的线程切换完全一致,需要进行上下文切换等操作。
- 线程切换到协程:同样需要进行上下文切换,但是需要注意的是,这里一定要实现线程的上下文保护。至于协程的上下文是否需要恢复,通过判断当前协程是否持有一个栈来实现
- 协程切换到线程(await):由于此时协程是在await点主动让权,所以仅需要从线程栈中恢复执行上下文即可。
- 协程切换到协程(await):协程切换到协程由执行器实现,在这个过程中并没有栈的切换,同样的,也需要判断目标协程是否拥有一个栈,如果拥有一个栈的话就需要进行上下文的恢复。
- 协程切换到线程(非await):由于此时协程并不是在await点被抢占的,因此需要为协程保存现场。此时将从堆栈池中选取出一个空闲的堆栈以保存协程的执行现场;而在保存现场之后,同样需要从线程栈中恢复现场。
- 协程切换到协程(非await):同样的,此时需要为被打断的协程保存现场。同样需要根据目标协程是否具有栈来判断是否需要恢复协程的上下文。
- 一种是正常await出控制权的任务,那么直接不需要恢复上下文,直接executor执行(poll)它就可以了。
- 另一种是被打断的任务,那么就需要恢复它的上下文,将pc也恢复从而继续执行任务,并将栈指针设置为非法值(一般为0)
由于在我们的设计中,线程相当于是没有await点的协程,所以当线程切换到协程时只有可能是基于优先级的抢占,因此这里线程的上下文保护也可以理解为一个协程在非await点被抢占,这也是将线程和协程统一起来的核心
下面以表格形式给出各种情况的上下文切换情况:
当前执行现场 | 新执行现场 | 切换条件 | 是否需要保存上下文 | 是否需要恢复上下文 |
线程 | 线程 | 非await | 是 | 是 |
线程 | 协程(未被打断) | 非await | 是 | 否 |
线程 | 协程(被打断) | 非await | 是 | 是 |
协程 | 线程 | await | 否 | 是 |
协程 | 协程(未被打断) | await | 否 | 否 |
协程 | 协程(被打断) | await | 否 | 是 |
协程 | 线程 | 非await | 是 | 是 |
协程 | 协程(未被打断) | 非await | 是 | 否 |
协程 | 协程(被打断) | 非await | 是 | 是 |
需要注意的是,这里的切换条件只针对切换点是否是await点,因此如果当前执行现场是一个线程的话,条件一定是非await,进而线程一定是被打断状态。
从上表中,可以直接地看出,线程的切换与协程在非await点的切换完全一致,这也是我们想将线程切换与协程切换统一起来的理论基础。
核心问题1:非await点的抢占如何实现?如何检测并且切换?
我们是把线程和协程融合着实现的,并不实际区分线程和协程,而是着重在有栈还是无栈。
什么时候需要栈?在我们的强实时性的情况下:比如新的任务被创建,就很可能需要抢占原先运行的任务。
我们的切换部分计划在Waker部分实现,在上面的讨论里面列出了切换的表格。在切换时,只有中断处理程序中才会是需要栈的切换(也就是说中断才会进行当前任务在非await点的切换),这个时候会从上下文堆栈池里面获取一个空的上下文堆栈(这一片区域的释放回收完全能参照ucosii的tcb的设计,一片上下文大小的数组,元素通过指针连接做到离散的分配和回收),然后保存当前任务的上下文到这个空的堆栈。然后,通过位图法能在线性时间下快速找到最高优先级任务的指针,这个任务就分成两种情况:
而区分这两种类型的任务就可以通过在OS_TCB这个地方加入一个栈指针,它既用于保存可能存在的上下文堆栈位置,也可用于指明是否是被打断的情况,因为如果被打断,那么它就是个合理的值,而没有被打断的话,这里就会是一个非法值(一般设置为0)
核心问题2:执行器如何找到高优先级任务,如何调度执行
参考ucosii的设计,通过一个OSRdyGrp和OSRdyTbl两个数组存储位图信息,从而找到就绪的最高优先级任务的优先级,然后将优先级作为下标在tcb数组里面找到最高优先级任务对应的TCB。
而转到我们异步的executor这里,大致思路一样,但是有些细节要仔细考虑:
我们的任务是taskstorage,它的大小不像ostcb一样是固定的,导致我们想要做到线性时间找到对应任务tcb,需要再单独一个指针数组,存放TaskRef,这里其实embassy对这里也有一定的考虑,它将TaskRef作为指针,它指向的数据结构完整的是taskstorage,而这里就得说一下在embassy设计里面poll_fn的重要性,在spawn初始化过程中,F范型参数确定下来了,但是后续poll的时候是需要知道这个信息的,所以就有了poll_fn里面的第一行代码:
let this = &*(p.as_ptr() as *const TaskStorage<F>);
从而将带有future具体信息的对象“映射”出来
这个F的确定是在编译时完成的,embassy通过宏的这段操作完成:
POOL.get::<_, POOL_SIZE>()._spawn_async_fn(move || #task_inner_ident(#(#full_args,)*))
好了,那我们的确需要保留这部分的设计,去掉它宏的部分,将这部分放到OS_TASK_CREATE部分去处理。
这样以后,我们只需要存储固定大小的taskref,他们存放在数组里面,指向对应的taskheader,并且,通过poll_fn(taskref)做到执行任务。