ARTICLE DETAIL

资讯详情

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

深入理解RTOS核心:手把手设计TCB任务控制块与调度机制

深入理解RTOS核心:手把手设计TCB任务控制块与调度机制 作为一个在嵌入式开发里摸爬滚打了快十年的老工程师我见过太多人卡在“会点灯、懂中断、能看寄存器”这个阶段然后对着RTOS的源码发呆。明明每个API都能调用任务也能跑起来但一旦出问题调度器为什么卡死、任务为什么莫名消失、栈为什么爆了脑子里完全一片空白。这其实是没搞懂RTOS真正的根基——TCBTask Control Block任务控制块。如果你想把“点灯大师”的光环再往上提一档想亲手把一个简化版操作系统从零撸起来那这篇关于TCB的内容就是你绕不过去的坎。TCB本质上是内核为每个任务建立的“档案袋”里面塞满了这个任务的所有身家性命栈指针、优先级、状态、事件等待、时间片计数甚至还有追查Bug用的错误码。调度器不关心你的任务函数里写了什么、循环里点的是红灯还是绿灯它只认TCB。谁该上CPU谁该去睡觉谁被哪个信号量挂住了全靠翻阅这份档案来决定。所以理解TCB就等于拿到了打开整个RTOS调度机制大门的钥匙。这篇文章我会用纯手工的方式带你从零设计一个够用的TCB结构体再把任务栈、就绪队列、调度器主循环这几件事串起来最后对比一下FreeRTOS和uC/OS的TCB实现差异把为什么业界大佬要这么设计的原因也一并翻出来。1. TCB引子为什么任务调度必须要有“档案袋”1.1 没有TCB多任务并发只是一句空话想象一下你在管理一个几百号人的研发团队每个工程师负责一个模块。突然领导说咱们以后搞“多项目并行”每个人都有机会上项目但CPU只有一个其实就是开会的时间片只有一个会议室。如果没有花名册、没有项目登记表、没有每个人的进度记录你根本不知道谁做到哪了、谁的任务卡在哪个环节、下一个该叫谁进会议室汇报。这项目分分钟就乱成一锅粥。RTOS里的“多任务并发”也是这个道理。Cortex-M这种单核CPU在任意时刻只能执行一条指令流所谓并发其实是调度器让任务A跑几个时钟周期然后强行切换成任务B再跑几个时钟周期。可问题来了任务A被换下去的时候它的局部变量还没算完呢它的函数调用栈还堆着一半的调用链它的CPU寄存器里还存着关键中间值。如果调度器就这么不管不顾地把CPU交给任务B等再换回A的时候A肯定彻底懵了——“我刚才的变量哪去了我的栈顶在哪我还运行到哪条指令了”所以调度器在切换任务之前必须做两件事第一把这个任务当前的“CPU现场”完整保存下来第二把将来恢复运行所需的全部信息登记在一个结构体里。这个结构体就是你亲手做的第一个TCB。它存在的意义就是让内核能够回答三个问题这个任务现在跑到哪了它还剩下多少时间片它手里握着哪些资源。不夸张地说TCB是操作系统能够实现“记忆”的最小单元。没有它任务切换就是一次性的赌博连最简单的双任务点灯都会频繁崩溃。1.2 手搓操作系统系列的前后呼应如果有人是从这个系列的第一篇开始跟着做的那你手头应该已经有了一套能跑的最小启动代码、一个能工作的SysTick中断以及一个可以在两个任务之间做粗糙切换的早期版本。那个阶段的切换代码通常写得很“暴力”直接swap栈指针然后在函数里手动push/pop一堆寄存器。它勉强能跑但非常脆弱——你没法知道当前系统里有哪几个任务、每个任务处于什么状态、哪个任务的优先级更高。从这一篇开始我们要把这些“一次性”的切换逻辑抽象成一个真正的内核模型。TCB就是把散落的寄存器保存代码、任务入口地址、栈空间管理统统收纳起来的那根定海神针。后续你再学消息队列、信号量、互斥锁你会发现所有同步机制的第一行代码几乎都是在操作当前任务的TCB状态。基础打牢了后面全是坦途。2. TCB的字段设计一个任务到底需要登记哪些信息2.1 裸机RTOS的TCB结构体最小集先别急着往结构体里堆字段。工程上最重要的能力是“够用就好”然后循序渐进地加。在我打磨过的几个教学用内核里一个能跑通优先级抢占时间片轮转的TCB最少需要下面这些成员typedef struct tcb { uint32_t *sp; // 任务栈指针内核栈/上下文保存位置 uint8_t priority; // 任务优先级数值越小优先级越高或反之看设计 uint8_t state; // 任务状态就绪/运行/阻塞/挂起/延时 uint32_t slice_remaining; // 时间片剩余计数 uint32_t slice_total; // 时间片总长度 uint32_t delay_ticks; // 任务还在睡多少tick用于延时阻塞 void *wait_obj; // 在等什么对象信号量/队列/互斥量 uint32_t wait_event; // 等待的事件掩码 uint32_t error_code; // 最后一次错误记录 char name[8]; // 任务名字调试时非常救命 struct tcb *next; // 链表指针用于挂到就绪队列/阻塞队列 } TCB_t;别看就这么十几行这里面每一个字段都有讲究。sp是内核切换上下文时的生命线它指向的任务栈里保存着上一次被换出时的完整寄存器快照。state字段决定了调度器是否应该把这个任务纳入候选执行名单。slice_remaining是时间片轮转的计数器每次SysTick中断进来就减一减到零就得把CPU让给同级任务。wait_obj和wait_event是以后做IPC的基础现在可以先留空。next指针是所有队列操作的核心有了一根指针TCB才能从一个链表跳到另一个链表从就绪态变成阻塞态时本质就是把TCB从一个队列搬运到另一个队列。2.2 为什么优先级字段的数值方向要统一优先级这个东西新手最容易踩坑。有的RTOS数值越大优先级越高有的RTOS恰恰相反数值越小越优先。在你自己做内核时务必在头文件顶部用宏注释写清楚#define OS_PRIO_HIGHEST 0 // 0号优先级最高 #define OS_PRIO_LOWEST 31 // 31号优先级最低32个优先级够教学用为什么推荐“数值越小优先级越高”因为查找最高优先级就绪任务时可以配合位图或者CLZ指令Count Leading Zeros做极速查找。比如你用一个32位变量ready_bitmap来表示优先级0到31是否有任务就绪那么最高优先级任务就是31 - __CLZ(ready_bitmap)一条指令就找到了。不用遍历链表时间复杂度O(1)。这是Cortex-M内核做RTOS的经典套路uC/OS-III和FreeRTOS用的都是这个思路的变体。一旦定了这个约定所有比较操作都要保持一致。创建任务时要对优先级做合法性检查超过最大值就直接返回错误码。修改优先级时记得动态更新就绪位图。这些小细节在当时看起来很繁琐但整个内核几百行代码的稳定性全靠这些约定在撑。2.3 状态机初探TCB状态字段与队列归属TCB的state字段通常会定义成一组宏#define TASK_STATE_READY 0x01 #define TASK_STATE_RUNNING 0x02 #define TASK_STATE_BLOCKED 0x04 #define TASK_STATE_SUSPENDED 0x08 #define TASK_STATE_DELAYED 0x10任务状态切换的规律是整个调度器的行为骨架。一个任务创建后初始化状态为READY被调度器选中后就变成RUNNING。如果它调用了delay(n)状态变成DELAYEDTCB会从就绪队列摘下挂到延时队列延时到期后又被重新挂回就绪队列。如果它去获取一个暂时不可用的信号量状态变成BLOCKEDTCB被挂到该信号量的等待链表上。当别人释放信号量时才会把它唤醒重新变回READY。状态字段和队列归属是“一体两面”的关系。你始终要问自己这个TCB此刻在不在就绪链表里在不在延时链表里在不在某个信号量的等待链表里如果状态字段说它READY它却不在任何就绪链表中那系统离死机就不远了。我调试这类问题的时候最喜欢干的一件事就是周期性地打印所有TCB的状态和所属链表地址一眼就能看出谁“脱管”了。3. 任务栈与TCB的绑定给任务一个可以折腾的空间3.1 栈空间分配策略每个任务都必须有自己的栈空间否则函数调用和局部变量会互相踩踏。我们做教学内核时可以在一个内存池里划分出大小不等的区域也可以用静态数组为每个任务固定分配一块空间。静态数组最直观static uint32_t task1_stack[128]; static uint32_t task2_stack[128];这是孩子都能写明白的方案。但工程上更有价值的是统一管理把栈空间从一个大数组中切片分发。假设系统内存总共8KB你划分4个任务每个任务2KB这种静态切分在编译期就能确定运行时零开销、无碎片。缺点是灵活性差如果某个任务突然需要更多栈你只能干瞪眼。动态分配malloc灵活但嵌入式环境里的内存碎片和不确定分配耗时是噩梦。教学内核我推荐先使用静态数组代码简单可预测性强所有的内存问题一眼就能看穿。等你把调度机制吃透了再考虑实现一个轻量级的内存块分配器也不迟。3.2 初始栈帧设计与伪造现场任务第一次“被调度”之前CPU从来没有运行过它的代码。但调度器切换任务时它不管你是第一次运行还是第一百次运行统一都会从任务栈里“恢复”一套寄存器现场。所以任务创建函数里要把任务函数的入口地址和一堆初始化寄存器值像伪造犯罪现场一样预先填进任务栈里。这个伪造的现场核心是三条PC寄存器程序计数器填任务函数的入口地址恢复上下文后CPU就知道该往哪跳。xPSR寄存器需要把bit24置1表示要切换到ARM Thumb指令模式Cortex-M必须置位这个位否则进HardFault。LR寄存器一般填一个任务退出函数的地址。正常任务不应该返回但如果真的返回了比如任务函数末尾写了个return内核得有一个兜底函数来处理这种“任务跑丢了”的情况。以下是一个简化的初始化栈帧代码片段基于Cortex-M的寄存器分布void TCB_InitStack(TCB_t *tcb, void (*task_entry)(void *param), void *param, uint32_t *stack_top) { // stack_top 应该指向栈的尾端高地址 uint32_t *sp stack_top; // 为任务入口创建一份完整的异常返回帧 *--sp (uint32_t)0x01000000; // xPSR: Thumb位 *--sp (uint32_t)task_entry; // PC: 入口地址 *--sp (uint32_t)Task_Exit; // LR: 退出兜底 *--sp (uint32_t)0x0000000C; // R12 *--sp (uint32_t)0x00000003; // R3 *--sp (uint32_t)0x00000002; // R2 *--sp (uint32_t)0x00000001; // R1 *--sp (uint32_t)param; // R0: 参数 // 剩余通用寄存器 R4-R11 的初始值 for (int i 0; i 8; i) { *--sp 0; } tcb-sp sp; }这套栈帧写完之后tcb-sp就指向伪造现场的栈顶。调度器第一次切入该任务时只需做一次标准的上下文恢复就能把任务函数“骗”得以为自己是刚开机的main函数。3.3 栈溢出探测给任务多上一道保险栈空间是有限的任务一个不小心递归两三下就可能撞穿栈底。而栈一旦溢出最先被破坏的往往是相邻内存里的另一个TCB或者另一个任务的栈。这种Bug极其阴险经常表现为“任务A突然变疯任务B在打印错误数据”。工程里最土但最有效的方法就是在每个任务栈底部埋一条“水印”。任务创建时把整个栈区域都填充成0xCCCCCCCC。调度器每次切换任务时扫描栈底头几个字节的“水印”有没有被改写。如果被改写了说明栈已经溢出了触发断言。这个方案只增加了几行代码和微秒级别的扫描耗时却能救你于水火。更精细的做法是统计任务栈的最大使用深度Stack High-Water Mark。在RTOS里调度器每次切出任务时顺着当前SP往下数看看有多少个0xCCCCCCCC没被破坏就能估算出“历史上最多用了多少栈”。FreeRTOS提供了uxTaskGetStackHighWaterMark()就是这个原理。我们自己手搓的内核完全可以照抄这个思路在调试函数里打印每个任务的栈剩余深度。4. 就绪队列与TCB的舞蹈调度器怎么找到“下一个幸运儿”4.1 就绪链表的双向与单向抉择调度器最核心的数据结构就是就绪队列。最简单的是单链表数组每个优先级一个链表头相同优先级的任务串在一根链上。教学版可以先使用双向链表因为删除操作时需要找到前驱节点而单链表删除往往要遍历整根链时间复杂度不理想。但如果你学了FreeRTOS会发现它用的是内核自带的一个精巧链表实现——每个TCB节点里直接内嵌list item结构而不是用“TCB包含指针”的简单方式。这样做的好处是同一个TCB可以同时挂在多个链表上而不冲突比如它在就绪链表里同时也在某个等待事件链表中。我们手搓内核不必一开始就上这么复杂的结构但至少要做到TCB内含两个指针prev/next能实现O(1)插入和删除。4.2 以时间片轮转调度为例的TCB生命周期假设系统里有两个相同优先级的任务TaskA和TaskB都用时间片轮转。初始状态TCB_A和TCB_B都在优先级1的就绪链表上TCB_A在表头。调度器选任务时取就绪链表第一个节点把TCB_A的state改成RUNNING加载它的sp恢复上下文任务A开跑。SysTick每来一次中断中断里除了保存现场还会把TCB_A的slice_remaining减一。当它减到0时调度器会做三件事将TCB_A的slice_remaining重置为slice_total。把TCB_A的state改回READY。如果就绪链表上还有同级其他任务就把TCB_A移到链表尾部让调度器下次选择TCB_B。这个“把当前任务挪到队尾”的动作就是时间片轮转的全部奥秘。它保证了同优先级的所有任务都能轮流获得CPU谁也别想饿着谁。4.3 优先级抢占时TCB如何被“挤下去”如果TaskA正在运行优先级为2然后TaskB优先级为1更高从延时中被唤醒调度器在SysTick中断或任务退出时发现就绪位图里最高优先级已经是优先级1了那么很高优先级TaskB将被置为RUNNING当前运行的TaskA还没用完的时间片会被“冻结”state被改回READY并被挂回优先级2的链表。这就是优先级抢占调度。抢占的关键在TCB的state和priority字段协同一个任务即便正在运行它的TCB状态也可能是READY而不是RUNNING在某些设计里RUNNING只可能有唯一一个任务。这份档案的每一次修改都代表着一次CPU所有权的转移。你在调试调度器时其实就是在追踪谁在什么时机动了哪个TCB的state字段。5. 手把手构建TCB的创建函数与任务切换5.1 任务创建函数的设计思路任务创建函数本质上做四件事分配TCB节点、分配任务栈、初始化栈帧、把TCB挂入就绪队列。我用一个简化版的AppTaskCreate做演示它接收的参数包括任务名、入口函数、参数、栈大小、优先级int AppTaskCreate( const char *name, void (*task_entry)(void *param), void *param, uint32_t *stack_buffer, uint32_t stack_size, uint8_t priority ) { if (priority OS_PRIO_LOWEST || stack_size 64) { return -1; } TCB_t *tcb TCB_PoolAlloc(); // 从静态TCB池中取一个空闲节点 if (tcb NULL) { return -2; } memset(tcb, 0, sizeof(TCB_t)); snprintf(tcb-name, sizeof(tcb-name), %s, name); tcb-priority priority; tcb-state TASK_STATE_READY; tcb-slice_total DEFAULT_SLICE_TICKS; tcb-slice_remaining tcb-slice_total; tcb-delay_ticks 0; tcb-wait_obj NULL; tcb-wait_event 0; tcb-error_code 0; // 计算栈顶并做好栈水印初始化栈帧 uint32_t *stack_top stack_buffer stack_size - 1; TCB_InitStack(tcb, task_entry, param, stack_top); // 挂入就绪队列 ReadyQueue_Insert(tcb); return 0; }代码很短但里面藏着几个容易忽略的细节。TCB_PoolAlloc是从一块预先分配好的TCB数组中取空闲项数组大小是编译期宏定义。如果分配失败函数立刻返回错误码绝不带病运行。栈水印的填充放在TCB_InitStack之前或者之后都行但一定要确保任务创建期间没有中断会提前调度到该任务。5.2 上下文切换中TCB的保存与恢复Sp、寄存器这一层的切换细节在系列前几篇应该讲过了。这里只强调TCB在切换过程中扮演的角色。以PendSV为例当内核决定要切换任务时典型流程是将当前任务的sp保存到current_tcb-sp。这一步是“把现场存进档案”。从就绪队列中选出下一个任务令next_tcb指向它。更新当前运行的TCB指针current_tcb next_tcb。把next_tcb-sp加载到CPU的sp寄存器。从栈中弹出所有寄存器包括PC跳转执行。这里的第1步和第4步就是TCB与CPU硬件寄存器之间的唯一“对话”。你可以在切换函数里加日志把每次切换前后的当前任务TCB地址打印出来然后和任务实际执行顺序对照。如果发现打印的地址和实际执行的入口函数对不上那一定是栈帧伪造或者TCB链表链接出了问题。5.3 延时任务如何借助TCB实现“睡觉”一个任务调用OSDelay(1000)意思是“我睡1000个tick别安排我上CPU”。实现办法是在SysTick中断里把当前任务的state改为TASK_STATE_DELAYED将delay_ticks设为1000然后把TCB从就绪队列摘除挂入一个按delay_ticks升序排列的延时链表。每次SysTick中断遍历延时链表把所有delay_ticks大于0的节点减一减到0的任务就重新回到就绪队列。如果你的延时链表按剩余tick数排序那么每次SysTick只需要检查链表头部的节点即可因为有最小剩余时间的节点如果没到期后面的节点也必然没到期。这是典型的“按到期时间排序”的定时器算法。TCB在这里就是一个可排序的节点排序键就是delay_ticks。6. 放大镜看工业级RTOSFreeRTOS和uC/OS的TCB设计对比6.1 FreeRTOS的TCB里多藏了哪些宝贝FreeRTOS的TCB定义在tasks.c文件里结构体名叫tskTaskControlBlock。它比我前面给的最小集多了很多字段挑几个有代表性的说说uxPriority和uxBasePriority。这两个字段是为了支持优先级继承机制。当一个低优先级任务持有互斥锁而高优先级任务正在等锁时内核会临时把低优先级任务的uxPriority提升到高优先级任务的级别避免高优先级任务被一堆中优先级任务饿死。等到释放锁再把优先级降回uxBasePriority。xStateListItem和xEventListItem。这是两个内嵌链表节点。FreeRTOS的双节点设计允许TCB同时在就绪链表和事件链表上支撑了复杂的等待/唤醒场景。pxStack和uxStackHighWaterMark。这是栈空间的管理和监测字段。前者保存栈底指针后者记录历史最低剩余栈深度用于调试。xTaskRunTime。任务运行累计时间。配合调度器的统计功能你可以知道每个任务吃掉了多少CPU时间这对发现性能瓶颈极其重要。这些字段都是为实际工程问题服务的优先级反转要解决、任务运行状况要监控、栈安全要保障。我们自己手写一个最小内核时可以不知道这些但绝不能不知道它们存在的理由。6.2 uC/OS的OS_TCB表格化与事件标志uC/OS的TCB用了一个宏展开列表的技巧来定义所有字段OS_TCB_EXT系列宏它的核心字段同样包括优先级、状态、堆栈指针、消息指针等。uC/OS尤其擅长的是把任务挂起和事件标志组整合到TCB里一个TCB可以通过OSFlagPend同时等待多个事件标志这些等待信息就存放在TCB内的OS_PEND_DATA数组中。和FreeRTOS相比uC/OS更“结构化”每个字段都有明确的注释和归属适合作为教学和学习的范本。如果你已经跟着这个系列把简单内核写通了强烈建议再去读一遍uC/OS的os_tcb.c。你会发现你手搓过的那些功能在uC/OS里都以更规范、更完善的方式存在着。读到“原来这里的TCB字段是用来做这个的”的时候就是你对操作系统理解真正深化的时候。6.3 从对比中学到的内核设计经验看完成熟的RTOS实现你会意识到TCB不是一个死的数据结构而是围绕着任务管理的所有需求逐步演化出来的“瑞士军刀”。我建议你在自己手搓内核的过程中坚持一条原则不要一次性把字段加满让需求驱动设计。先跑通任务切换再遇到需要统计运行时间的需求就加run_time字段再遇到互斥锁优先级反转的需求就加base_priority字段。这样写出来的内核虽然不够酷炫但每个字段你都能讲清楚它为什么存在。另外工程上极其讲究“内核数据结构的内存排布”。Cortex-M是32位架构指针和整数最好都按4字节对齐。如果结构体里塞进一个uint8_t再塞一个uint32_t编译器会为了对齐自动填充空洞。你可以使用__attribute__((packed))强行紧凑但代价是访问效率降低。除非你的内存紧张到以字节计否则老老实实接受编译器对齐或者在手动设计时把同类型的成员排在一起。7. 调试TCB相关问题的独家技巧与常见坑7.1 经典故障任务列表里找不到“失踪人口”症状创建了三个任务但跑起来永远只有两个任务在交替运行第三个任务像空气一样消失了。排查思路先检查任务创建函数返回值。如果返回了错误码说明TCB池满了或栈参数非法。再打印创建后TCB的state确认它处于READY状态。然后查看就绪链表头确认TCB真的被插入进去了。最后检查优先级数值如果创建的任务优先级比当前运行任务低而且当前任务从不阻塞、不释放CPU那低优先级任务确实永远不会运行这是正常的。我遇过最离谱的一次是因为我在任务函数末尾没有写while(1)死循环任务跑完return了被兜底函数挂起看起来就像“任务运行一次后就消失了”。把Task_Exit兜底函数里做个while(1)挂起动作就是为了让这种错误不至于变成HardFault。7.2 经典故障任务栈水印被冲破系统间歇性发疯如果你加了栈水印检测开机会立刻黄牌警告如果没加系统可能会在触发某条深层调用路径时随机崩溃。排查方法比较暴力但有效在任务切换函数里把任务栈底地址打印出来一旦发现相邻任务的栈数据被改写就能立刻锁定是哪个任务溢出了。然后把这个任务的栈空间翻倍或者检查它的递归逻辑是不是失控了。很多初学者不知道Cortex-M的中断嵌套也会吃栈。任务栈不仅要装任务自身的局部变量和函数调用链还要装突发中断的现场。如果你把任务栈大小卡得很极限正常路径没事但某个中断一进来栈直接爆穿。所以我的经验是任务栈至少要在预估需求上再留30%-50%的余量别拿省内存的心态去赌任务栈的极限。7.3 经典故障TCB链表被踩踏状态和数据全部错乱如果某个任务的代码里有一个野指针越界写到了别的内存区域恰好把TCB结构体给改了那么调度器基于这份“伪造档案”来调度系统就会上演各种灵异事件。排查手段通常是给每个TCB结构体起始位置放一个魔数Magic Number例如0xA5A5A5A5。调度器在每次切换时检查魔数是否完好。如果魔数被破坏通过编译器或调试器追查这个内存地址的写入来源。Cortex-M的硬件断点也可以配一个“数据观察点”指定某地址写入就触发断点非常高效。这个“魔数检测”方案我在多个项目里都是救命稻草。它虽然不能自动修复问题但能在第一时间把Bug从“玄学崩溃”变成“抓个正着”。7.4 任务响应延迟的定位技巧有时候系统不崩溃但某个任务的响应延迟忽高忽低影响实时性。排查方法之一是看当前任务的时间片是否被抢占得过于频繁另一个是看是否有高优先级任务在忙等待。你可以利用TCB里的run_time累计字段打印每个任务的CPU占用率。如果发现某个高优先级任务的运行时间占比异常高而且它里面还是空的死循环那不用犹豫给它加上合理的延时或者信号量等待让出CPU。8. 从TCB起步走向真正的内核工程师写到这我特别想跟读者说一句TCB这个结构体不大却是整个操作系统最核心的“世界观”。你搞懂了它任务调度就不再是黑魔法而是一个又一个TCB在链表之间游走的确定性过程。下一步你可以尝试在现有内核里加入互斥锁和优先级继承你会发现那不过是在TCB里加一个字段、在等待队列里多走一圈而已。我自己当年手搓操作系统第一次在串口里看到“TaskA Running TaskB Running”的交替输出时成就感比后来在真实产品上跑通FreeRTOS还要大。那是因为我清楚地知道每一行调度代码、每一个TCB字段都是我亲手放进去的我没有在任何一个细节上“蒙混过关”。如果你已经跟着系列读到这里别停下。可以试着做一个小实验把当前内核的TCB池数量改成1然后创建两个任务。你会立刻体会到缺乏TCB管理的系统有多混乱也会更珍惜手里这份小小的“档案袋”。从点灯大师到内核爱好者往往就差这么一次亲手拆开TCB的勇气。动手写代码吧纸上谈兵的博主写不出操作系统同样只看不练的读者也永远学不会它。等你的双任务点灯跑起来别忘了回来看看下一个更难也更精彩的挑战应该是如何让两个任务通过队列安全地“对话”。
返回列表