ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RT-Thread线程让出实验:从yield函数理解实时系统调度原理

RT-Thread线程让出实验:从yield函数理解实时系统调度原理 1. 从“阻塞”到“让出”理解线程调度的关键一步在RT-Thread这类实时操作系统的学习路上很多人会卡在一个看似简单、实则至关重要的概念上线程让出。你可能已经熟练掌握了创建线程、使用信号量或邮箱进行通信但当手册上提到rt_thread_yield()这个函数时往往会一笔带过觉得它“不就是让出CPU嘛没什么用”。我曾经也是这么想的直到在一个实际项目中因为对“让出”理解不透彻导致低优先级线程“饿死”整个系统的响应性出现严重问题才真正意识到这个实验的价值。线程让出本质上是一种主动的、协作式的调度行为。它不同于因等待信号量、消息队列而导致的“被动挂起”也不同于时间片用完后的“强制切换”。它是运行中的线程自己举手说“我现在的活儿不急可以把CPU先让给其他就绪的兄弟。” 这个动作是理解RT-Thread乃至所有抢占式内核调度器工作机理的一把钥匙。通过这个实验我们不仅能搞清楚yield怎么用更能深入理解就绪列表、线程优先级和调度器是如何协同工作的。这对于设计出高效、公平、可预测的嵌入式系统至关重要。2. 实验目标与环境搭建不仅仅是调用一个API在做任何实验前明确目标至关重要。本次“线程让出实验”的核心目标有三个层次表象层学会使用rt_thread_yield()函数并观察其直接效果。原理层理解调用yield后内核的调度器、就绪列表发生了哪些变化。应用层掌握在何种实际场景下需要主动让出CPU以及滥用yield可能带来的问题。为了清晰地观察现象我们需要搭建一个最小化的实验环境。我建议使用RT-Thread Studio或Env工具基于一个简单的BSP如STM32F103系列创建项目。关键是要确保系统时钟tick正常并且打开了调度器钩子函数和线程状态追踪功能这样我们才能像看“直播”一样观察调度过程。环境准备的核心步骤创建工程使用RT-Thread Studio选择基于芯片如STM32F103RB的工程模板。确保RT-Thread内核版本在4.0.0以上。配置系统打开rtconfig.h或通过menuconfig工具进行配置。有几项关键配置必须开启RT_USING_HOOK启用钩子函数方便我们打印调度信息。RT_THREAD_PRIORITY_MAX设置为8或以上为我们创建多个不同优先级的线程留出空间。RT_USING_CONSOLE和RT_USING_DEVICE确保串口控制台可用用于打印信息。编写钩子函数在main.c或单独的文件中定义并设置调度器钩子。这是我们的“监视器”。#include rtthread.h #ifdef RT_USING_HOOK static void hook_of_scheduler(struct rt_thread *from, struct rt_thread *to) { rt_kprintf([Hook] Thread switch: %s - %s\n, from-name, to-name); } #endif int main(void) { #ifdef RT_USING_HOOK rt_scheduler_sethook(hook_of_scheduler); #endif /* ... 其他初始化 ... */ return 0; }设计实验线程创建2-3个具有不同优先级的测试线程。这是实验的主体。注意在资源极其有限的MCU上频繁使用rt_kprintf打印日志会影响线程的真实执行时序可能干扰实验现象。在最终分析时需要区分“观察行为”和“实际行为”的差异。一个技巧是可以将关键状态记录到静态变量中等线程执行完一个阶段后再统一打印。3. 核心实验一同优先级线程间的礼让这是理解yield最经典的场景。我们创建两个优先级相同的线程A和B假设优先级都为8它们都不会主动挂起即不调用rt_sem_take,rt_mb_recv等阻塞函数而是执行一个大的循环打印任务。不进行让出的情况static void thread_a_entry(void *parameter) { while (1) { rt_kprintf(A is running.\n); // 模拟一些耗时工作注意不能用rt_thread_delay那会主动挂起 for (volatile int i 0; i 100000; i); } } static void thread_b_entry(void *parameter) { while (1) { rt_kprintf(B is running.\n); for (volatile int i 0; i 100000; i); } }在这种情况下由于A和B优先级相同且都不主动放弃CPU那么谁先运行谁就会一直运行下去。因为RT-Thread默认是时间片轮转调度但时间片只会在同优先级且都就绪的线程间起作用。然而这里有个关键点调度点。如果线程不调用任何可能引发调度的函数如yield,delay, 各种take/recv并且没有中断发生那么调度器就没有机会介入。实际上A线程启动后进入死循环系统根本没有机会切换到B线程。B线程虽然创建了但永远处于就绪态得不到执行。控制台将只会看到“A is running”在刷屏。进行让出的情况现在我们在每个线程的循环末尾加上rt_thread_yield()。static void thread_a_entry(void *parameter) { while (1) { rt_kprintf(A is running.\n); for (volatile int i 0; i 100000; i); rt_thread_yield(); // 主动让出CPU } } static void thread_b_entry(void *parameter) { while (1) { rt_kprintf(B is running.\n); for (volatile int i 0; i 100000; i); rt_thread_yield(); // 主动让出CPU } }此时现象会截然不同。当A执行完自己的任务后调用yield它会主动将自己从处理器上剥离并重新加入到同优先级就绪队列的末尾。调度器随后会从就绪队列头部取出下一个线程此时是B来运行。如此往复你将在控制台看到有规律的“A… B… A… B…”输出。这就是“礼让”的效果它创造了协作式多任务执行的假象。背后的调度器原理当调用rt_thread_yield()时内核会做以下几件事将当前线程例如A的状态从RT_THREAD_RUNNING改为RT_THREAD_READY。将线程A从运行状态移出并重新插入到其对应优先级就绪列表的尾部。注意是尾部这保证了同优先级线程的公平性。调用rt_schedule()函数触发一次调度。调度器会从就绪列表的最高优先级队列中取出头部的线程现在是B来运行。完成上下文切换CPU开始执行线程B的代码。这个实验清晰地展示了在没有更高优先级线程抢占、也没有时间片超时的情况下yield是同优先级线程间实现公平轮转的唯一手段如果不使用阻塞API。它在一些简单的协作式任务处理中非常有用。4. 核心实验二不同优先级下的让出行为现在我们来探究一个更常见但也更容易让人困惑的场景不同优先级线程间的让出。创建两个线程高优先级线程H优先级6和低优先级线程L优先级8。场景A高优先级线程不让出static void thread_h_entry(void *parameter) { while (1) { rt_kprintf(H (High) is running.\n); for (volatile int i 0; i 100000; i); // 模拟工作 // 注意这里没有 rt_thread_yield() 也没有 rt_thread_delay() } } static void thread_l_entry(void *parameter) { while (1) { rt_kprintf(L (Low) is running.\n); for (volatile int i 0; i 100000; i); rt_thread_yield(); } }在这种情况下一旦线程H开始运行由于它的优先级最高且永远不会主动放弃CPU不让出、不延迟、不阻塞那么它将独占CPU。这就是著名的“优先级反转”的一种前兆形态或者说“线程饥饿”。低优先级线程L永远没有机会执行即使它非常“礼貌”地每次循环都调用yield。因为调度器的核心原则是永远运行处于就绪状态的最高优先级线程。H不让出它就一直是最高优先级的就绪线程调度器永远不会选择L。场景B高优先级线程主动让出我们在高优先级线程H的循环中也加入rt_thread_yield()。static void thread_h_entry(void *parameter) { while (1) { rt_kprintf(H (High) is running.\n); for (volatile int i 0; i 100000; i); rt_thread_yield(); // 高优先级线程也主动让出 } } static void thread_l_entry(void *parameter) { while (1) { rt_kprintf(L (Low) is running.\n); for (volatile int i 0; i 100000; i); rt_thread_yield(); } }此时的执行顺序会怎样由于H优先级高它首先被调度执行。执行完任务后它调用yield将自己移到优先级6就绪队列的末尾。但请注意yield只会让位于同优先级或更高优先级的就绪线程。因为此时优先级6的队列里只有H自己在末尾而优先级8的队列里有L。调度器检查所有就绪队列发现最高优先级仍然是6并且该队列中还有线程H自己于是它又会把H从队列头部取出来继续运行结果就是即使H调用了yield只要没有同优先级的其他就绪线程低优先级的L依然得不到执行。控制台将只会打印“H is running”。这个结果可能出乎很多人的意料但它完美诠释了抢占式调度的核心规则优先级是硬道理。yield不是“让给任意其他线程”而是“让出并允许调度器重新选择”而调度器的选择标准永远是优先级。如何让L得到执行有两种方法让H真正挂起将H中的rt_thread_yield()改为rt_thread_delay(1)。这样H会主动挂起至少一个系统时钟周期进入挂起态。此时就绪队列中最高优先级的线程就变成了优先级8的LL得以执行。创建另一个同优先级的线程H2如果存在另一个同为优先级6的线程H2那么当H调用yield后自己排到队尾调度器就会从队头取出H2运行。这样在H和H2之间形成了轮转但L依然无法执行除非H和H2都挂起。这个实验的深刻教训是rt_thread_yield()不能用于在不同优先级线程间传递执行权。它的作用范围仅限于同一优先级层级内。如果你希望高优先级任务执行一段时间后能让低优先级任务有机会运行正确的做法是使用rt_thread_delay()或等待某个内核对象如信号量而挂起从而主动离开就绪队列。5.yield与delay及sleep的深度辨析在实际编程中新手很容易混淆rt_thread_yield()、rt_thread_delay()和rt_thread_sleep()sleep是delay的别名这三个函数。它们都涉及放弃CPU但行为有本质区别。特性rt_thread_yield()rt_thread_delay(tick)rt_thread_sleep(tick)行为本质主动让出立即就绪。将自己移到同优先级就绪队列末尾不改变线程状态仍为READY。主动挂起延时就绪。将线程状态改为挂起态并启动定时器。经过指定tick后定时器回调将其重新置为就绪态。与rt_thread_delay()完全相同是它的别名。线程状态变化RUNNING - READYRUNNING - SUSPENDED - (延时结束) - READY同delay调度影响立即触发一次调度(rt_schedule())。立即触发一次调度。线程在延时期间不参与调度。同delay主要用途同优先级线程间的协作式轮转在长时间循环中插入调度点防止独占CPU。实现周期性任务如每100ms采样一次在需要等待特定时间间隔的场景中使用。同delay语义上更强调“睡眠”。常见误用试图用它来给低优先级线程让路无效见实验二。用极短的延时如delay(1)来模拟yield虽然有时效果相似但引入了不必要的定时器开销和状态切换。同delay一个关键的实操心得在编写一个永不挂起的线程比如一个紧急事件监控循环时如果你担心它会完全霸占CPU导致同优先级任务饿死那么应该在循环中适时插入rt_thread_yield()。但如果你需要的是固定的时间间隔那么必须使用rt_thread_delay()。例如// 错误试图用yield实现10ms间隔 while (1) { do_something(); rt_thread_yield(); // 间隔时间不可控取决于其他同优先级线程的执行时间 } // 正确使用delay实现10ms间隔 while (1) { do_something(); rt_thread_delay(RT_TICK_PER_SECOND/100); // 延迟10个tick假设1 tick1ms }6. 实战场景与避坑指南何时该用何时不该用理解了原理最终要服务于实践。下面结合几个真实场景聊聊yield的用武之地和需要避开的坑。适用场景同优先级后台任务轮转系统中有多个同优先级的“后台维护”线程比如一个线程负责统计CPU使用率另一个线程负责内存池碎片整理。它们都不紧急但需要偶尔运行一下。可以让它们每个循环末尾都调用yield实现简单的公平共享。static void stats_thread_entry(void *param) { while(1) { calculate_cpu_usage(); rt_thread_yield(); // 让给另一个同优先级的维护线程 } } static void defrag_thread_entry(void *param) { while(1) { memory_pool_defrag(); rt_thread_yield(); // 再让回去 } }长耗时循环中的友好性设计某个线程需要执行一个非常耗时的计算例如图像处理算法这个计算无法被合理拆分延迟。为了不让系统看起来“卡死”即其他同优先级任务完全没响应可以在循环内部每处理一定数量的数据后插入一个yield。for (int i 0; i HUGE_DATA_SET; i) { process_data(data[i]); if (i % 100 0) { // 每处理100个数据让出一次 rt_thread_yield(); } }需要避开的坑性能敏感的代码路径rt_thread_yield()会触发一次完整的线程调度。调度过程涉及保存/恢复上下文、列表操作等是有开销的。在中断服务程序、高频调用的函数或对实时性要求极高的线程中应避免随意使用。误解为“释放CPU”的万能药如前所述它无法让低优先级线程运行。如果系统设计依赖高优先级线程yield来给低优先级线程让路那设计本身就是有问题的。应该重新审视线程的优先级划分或者让高优先级线程在无事可做时delay或挂起在某个信号量上。导致不必要的频繁调度如果多个同优先级线程都在短循环中频繁调用yield会导致调度器被频繁触发大量CPU时间浪费在上下文切换上而不是实际任务处理。这种情况下应考虑合并任务或者改用时间片机制确保RT-Thread配置中开启了RT_USING_SMP或同优先级时间片轮转。在持有锁的时候调用yield这是一个非常危险的操作。如果线程在持有互斥锁mutex或其他临界资源时调用yield主动让出CPU而下一个被调度的同优先级线程也试图获取同一个锁就会导致死锁。两个线程都认为对方会释放锁但实际上它们都在就绪态互相等待谁也无法继续执行。重要提示在RT-Thread中rt_thread_yield()的实现是安全的它会先关闭中断操作就绪列表再触发调度。这意味着在yield调用本身的极短时间内是不会被中断打断的。但这不代表调用它的整个代码段是安全的尤其是涉及共享资源时必须由开发者自己用互斥锁等机制来保护。7. 透过源码看本质yield到底做了什么对于喜欢刨根问底的朋友直接看源码是最透彻的。我们摘取RT-Thread v4.x内核中rt_thread_yield()的相关代码位于src/thread.c进行简要分析/** * This function will let current thread yield processor, and scheduler will * choose a highest thread to run. After yield processor, the current thread * is still in READY state. */ void rt_thread_yield(void) { register rt_base_t level; struct rt_thread *thread; /* 获取当前正在运行的线程控制块 */ thread rt_thread_self(); /* 关中断保护就绪列表操作 */ level rt_hw_interrupt_disable(); /* 将当前线程从就绪列表中移除 */ rt_schedule_remove_thread(thread); /* 将线程的剩余时间片设置为初始值 */ thread-remaining_tick thread-init_tick; /* 将当前线程重新插入到就绪列表的末尾 */ rt_schedule_insert_thread(thread); /* 开中断 */ rt_hw_interrupt_enable(level); /* 主动发起一次线程调度 */ rt_schedule(); } RTM_EXPORT(rt_thread_yield);从源码可以清晰看到其工作流程关中断确保对就绪列表的操作是原子的不会被中断打断。移除线程调用rt_schedule_remove_thread将当前线程从它所在优先级的就绪列表中摘除。重置时间片将线程的remaining_tick重置为初始值init_tick。这是一个关键细节意味着一次yield操作会刷新该线程的时间片配额。重新插入调用rt_schedule_insert_thread将线程插入到其优先级对应的就绪列表的末尾。这保证了同优先级线程的公平性。开中断并调度恢复中断并调用rt_schedule()触发调度器。整个过程线程的状态始终是RT_THREAD_READY没有进入RT_THREAD_SUSPEND。这就是“让出”而非“挂起”的底层实现。通过这个简单的实验我们层层深入从现象到原理从API使用到源码实现最后再到实战场景与陷阱完整地剖析了rt_thread_yield()。它绝不是API列表里一个无足轻重的函数而是你理解RT-Thread调度器行为、编写出更优多线程程序的重要工具。下次当你在设计系统纠结于线程间如何合理共享CPU时希望你能想起这个实验并做出最合适的选择。
返回列表