健壮的 C++:P 和 V 有害
斩断线程安全的戈尔迪之结
引言
竞态条件是许多系统中不应出现的复杂性来源。开发人员必须不断预见它们并加以防范——当它们潜入已发布的软件时,要努力重现它们并追捕它们。锁、信号量及其变体的涌现的泥潭是纯粹的人为复杂性,与软件试图实现的规范无关。在许多情况下,这种复杂性中的大部分实际上是可以避免的。
本文描述的技术适用于设计新系统,但即使是遗留系统也可以相对轻松地采用它们。尽管如此,您可能会发现接下来的内容相当标新立异,所以请准备好跳出框框思考。但本文概述的方法已成功应用于大型服务器,包括处理 AT&T 无线网络呼叫的核心网络服务器。这些服务器运行在专有操作系统上,但本文介绍了如何在 Windows 和 Linux 等商用操作系统上实现相同的概念。如果这些概念适合您的系统,您将通过避免容易发生且难以查明的错误来提高其质量和您的生产力。
本文中的许多概念都在我的书《健壮通信软件》中进行了介绍,其中包含 Dennis DeBruler 的这句发人深省的话
作为多个程序员正在开发的 C++ 代码的系统集成商,我似乎发现我的工作是寻找关键区域 bug。大多数关键区域 bug 只能通过对系统进行负载测试来发现。由于需要进行负载测试,我们面临着在开发周期后期发现和修复最困难的 bug 的额外挑战。此外,测试变成了像剥洋葱一样,当你发现一个又一个疏于防范的关键区域时。每个都更小、更难找到。每个都更接近截止日期——或者更远地落后于截止日期。因此,我们拥有这样的乐趣:bug 越严重,管理层就越会问:“你找到 bug 了吗?”那些真正微小的关键区域 bug 会逃到生产环境中,并成为“大约每隔一个月在百个客户站点中的一个站点出现一次”的 bug 的原因。这些 bug 在实验室里很难重现,更不用说找到了。
除非处理这些挑战让您感到愉快,否则请继续阅读。
背景
假定读者熟悉线程安全问题以及如何通过锁和信号量等关键区域保护机制来实现线程安全。
本文的标题有点俏皮。已故的艾兹格·迪杰斯特拉的两项重要贡献是《Go To 语句被视为有害》,其论点如今几乎毫无疑问地被接受,以及《关于进程描述的顺序性》,该文引入了信号量操作P (wait
) 和V (signal
) 来保护关键区域。
迪杰斯特拉认为 goto
应该从高级编程语言中删除,但允许在机器码中使用。所以尽管 goto
是必不可少的,但只有编译器和使用汇编编程的人应该使用它。本文对信号量提出了类似的论点:尽管信号量是必不可少的,但在应用程序代码中的使用应受到限制,通常将其限制在基础 Thread
类的内部工作。正如应用程序软件中需要 goto
表明编程语言存在缺陷一样,整个应用程序软件中需要信号量表明线程/调度框架存在缺陷。
我们是如何陷入困境的…
令人遗憾的是,抢占式调度和优先级调度已成为当今几乎所有操作系统中的事实标准。抢占式调度起源于分时系统,以便一个 CPU 可以服务多个用户。一些用户被认为比其他用户更重要,因此他们获得了更高的优先级。后来,优先级在硬实时系统中被证明很有用,在硬实时系统中,未及时完成某项工作可能会导致严重后果。
但在许多系统中,抢占式调度和优先级调度是不合适的。您的系统是一个统一的整体,而不是用户之间争夺时间的离散软件,那么为什么您的线程会随机地被调度进出呢?而且,您认为您的系统中不包含喜欢被更高优先级工作饿死的软件。这样的软件可能不是很重要,那么它为什么会存在呢?
这两种调度方式——抢占式调度和优先级调度——都迫使应用程序在细粒度级别上保护关键区域。在相同的优先级下,上下文切换会随意发生,并且一旦有更高优先级的线程准备好运行,也会发生上下文切换。
当调度不相关的进程时效果很好,但当调度同一进程内为共同目标工作的线程时,抢占式调度和优先级调度往往是不合适的,这可能并不令人惊讶。
…以及如何摆脱困境
要减少关键区域的数量,您需要自己控制上下文切换。1在许多情况下,线程在准备运行时只有有限的工作要做,所以让它完成工作。也就是说,让它不可抢占地运行。当它完成工作后,它会休眠,然后下一个线程运行。或者,如果它有很多事情要做,它会做其中一些,然后让出,返回到就绪队列的末尾,让其他线程有机会运行。这被称为协作式调度,它通过在开始下一个逻辑工作单元之前完成当前的逻辑工作单元,从而显著减少了关键部分的数量。如果每个应用程序线程在休眠、让出或处理下一个工作项之前都返回其工作循环,那么它们之间就不会有竞态条件。
当然,如果事情这么简单,每个人都会这么做。但这需要一个合适的基类 Thread
。而且它确实会引起一些问题。所以是时候深入研究一些代码了。
Using the Code
本文中的代码摘自健壮服务核心(RSC)。如果您是第一次阅读关于 RSC 某个方面的文章,请花几分钟时间阅读这篇前言。
演练
现在我们将看看 RSC 的Thread
类如何控制调度,这是其两个主要职责之一。另一个是恢复那些否则会导致程序中止的异常,如健壮 C++:安全网中所述。
创建线程
如何创建线程在此处进行描述。每个 Thread
都有一个 SysThread
成员,它封装了一个原生线程,作为 RSC 的操作系统抽象层的一部分。Thread
子类添加了自己的数据,因此在 RSC 中不需要 thread_local
。
进入线程
两个设计原则消除了应用程序软件中的竞态条件,从而消除了对信号量的需求。
- 所有应用程序线程都以不可抢占的方式运行(也称为锁定运行)。在线程开始运行之前,它必须成为活动线程。这允许它运行直到完成一个或多个逻辑工作单元。然后,下一个线程获得其机会,依此类推。活动线程由Thread.cpp中类型为
std::atomic<Thread*>
的变量ActiveThread_
跟踪。 - 所有应用程序线程都以相同的优先级运行。严格来说,当每个线程都必须成为活动线程才能运行时,这并不是必需的。但由于这是设计的结果,我们将诚实地说明发生了什么,并为每个不可抢占的应用程序线程设置相同的优先级。
每个线程在创建和进入后,都必须先成为活动线程,然后才能继续执行。
main_t Thread::EnterThread(void* arg)
{
// Our argument (self) is a pointer to a Thread.
//
auto self = static_cast<Thread*>(arg);
// Indicate that we're ready to run. This blocks until we're signaled
// to proceed. At that point, record that we're resuming execution,
// register to catch signals, and invoke our entry function. (The three
// lines of code before the invocation of Start actually appear in Start
// but are included here because Start contains a lot of code that isn't
// relevant to this article.)
//
self->Ready();
self->Resume();
RegisterForSignals();
return self->Start();
}
void Thread::Ready()
{
// If no thread is currently active, wake InitThread to schedule this
// thread in. Regardless, the thread waits to be signaled before it runs.
//
priv_->readyTime_ = SteadyTime::Now();
priv_->waiting_ = true;
if(ActiveThread() == nullptr)
{
Singleton<InitThread>::Instance()->Interrupt(InitThread::Schedule);
}
systhrd_->Wait();
priv_->waiting_ = false;
priv_->currStart_ = SteadyTime::Now();
priv_->locked_ = (priv_->unpreempts_ > 0);
}
void Thread::Resume()
{
// Set the time before which the thread must schedule itself out.
//
auto time = InitialTime() << ThreadAdmin::WarpFactor();
priv_->currEnd_ = priv_->currStart_ + time;
}
请注意,Resume
会记录一个时间,在此之前线程必须让出或以其他方式使自身被调度出去。如果线程的函数调用正在被记录,则会应用一个“翘曲因子”,这将导致它运行得慢得多。稍后我们将回到这一点。
线程让出
线程调用 Pause
,在完成一个或多个逻辑工作单元后将自己调度出去。
DelayRc Thread::Pause(msecs_t time)
{
auto drc = DelayCompleted;
auto thr = RunningThread();
EnterBlockingOperation(BlockedOnClock);
{
if(time != TIMEOUT_IMMED) drc = thr->systhrd_->Delay(time);
}
ExitBlockingOperation();
return drc;
}
如果 time
是 TIMEOUT_NEVER
,则线程休眠,直到另一个线程调用 Thread::Interrupt
将其唤醒。如果 time
是有限的,则线程(除非被中断)休眠,直到指定的超时发生,之后它必须再次成为活动线程才能运行。
Pause
调用 EnterBlockingOperation
和 ExitBlockingOperation
,因为线程在停止运行时必须允许另一个线程成为活动线程,并在恢复执行之前再次成为活动线程。BlockedOnClock
在以下 enum
中定义。
enum BlockingReason
{
NotBlocked, // running or ready to run
BlockedOnClock, // SysThread::Delay (non-zero time)
BlockedOnNetwork, // SysUdpSocket::Recvfrom or SysTcpSocket::Poll
BlockedOnStream, // reading from console or file
BlockedOnDatabase, // in-memory database
BlockingReason_N // number of reasons
};
线程阻塞
要执行阻塞操作,线程必须将其包装在对 EnterBlockingOperation
和 ExitBlockingOperation
的调用中,这与 Pause
中的操作相同。这是 TcpIoThread
的一个片段,它服务于 TCP 套接字。
EnterBlockingOperation(BlockedOnNetwork);
{
ready = SysTcpSocket::Poll(sockets, size, msecs_t(2000));
}
ExitBlockingOperation();
两个调用之间的代码按编码约定放在一个内部块中。让我们看看会发生什么。
void Thread::EnterBlockingOperation(BlockingReason why)
{
auto thr = RunningThread();
thr->blocked_ = why;
thr->Suspend();
}
void Thread::Suspend()
{
priv_->currEnd_ = SteadyTime::Now();
Schedule();
}
void Thread::Schedule()
{
auto active = this;
if(!ActiveThread_.compare_exchange_strong(active, nullptr)
{
// This occurs when a preemptable thread suspends or invokes
// MakeUnpreemptable. The active thread is an unpreemptable
// thread, so don't try to schedule another one.
//
return;
}
// No unpreemptable thread is running. Wake InitThread to schedule
// the next thread.
//
Singleton<InitThread>::Instance()->Interrupt(InitThread::Schedule);
}
此时,当线程执行阻塞操作时,它不再是活动线程。操作完成后,线程必须再次成为活动线程才能恢复,这与 EnterThread
中的情况相同。
void Thread::ExitBlockingOperation()
{
auto thr = RunningThread();
thr->priv_->currStart_ = SteadyTime::Now();
thr->blocked_ = NotBlocked;
thr->Ready();
thr->Resume();
}
线程有一个大任务要做
也许您注意到 Schedule
提到了一个可抢占线程。这是怎么回事,因为它重新打开了竞态条件的大门?!尽管 RSC 在创建每个线程时使其不可抢占,但当线程有一个非常耗时的任务要做时(例如读取或写入大文件),它必须可抢占地运行。“耗时”的定义取决于您系统的延迟要求。换句话说,如果某项工作可能导致其他线程无法运行超过您规定的不可接受的时间长度,那么它就是耗时的。面对这种情况,线程会使自己可抢占。
void Thread::MakePreemptable()
{
auto thr = RunningThread(std::nothrow);
// If the thread is already preemptable, nothing needs to be done.
// If it just became preemptable, schedule it out.
//
if(thr->priv_->unpreempts_ == 0) return;
if(--thr->priv_->unpreempts_ == 0) Pause();
}
当线程变得可抢占时,会调用 Pause
来调度它出去。(Pause
的默认 time
参数是 TIMEOUT_IMMED
,在这种情况下,线程只是转到就绪队列的末尾。)
在完成其大任务后,线程应继续以不可抢占的方式运行。
void Thread::MakeUnpreemptable()
{
auto thr = RunningThread(std::nothrow);
// Increment the unpreemptable count. If the thread has just become
// unpreemptable, schedule it out before starting to run it locked.
//
if(thr->priv_->unpreempts_ >= MaxUnpreemptCount)
{
Debug::SwLog(Thread_MakeUnpreemptable, "overflow", thr->Tid());
return;
}
if(++thr->priv_->unpreempts_ == 1) Pause();
}
请注意,可以嵌套调用 MakeUnpreemptable
和 MakePreemptable
,因此当其堆栈上的所有函数都同意可以抢占时,线程才会变得可抢占。
大多数线程使用 FunctionGuard
类,而不是直接调用这些函数。例如,FileThread
负责接收一个 ostringstream
并将其写入文件。在打开文件之前,它会这样做:
FunctionGuard guard(Guard_MakePreemptable);
当 guard
被构造时,它调用 MakePreemptable
。它是一个堆栈变量,当它超出范围时,其析构函数调用 MakeUnpreemptable
。这确保了即使在执行大任务时抛出异常,MakeUnpreemptable
调用也会始终发生。
线程不想运行太久
回想一下,Resume
记录了一个时间,在此之前线程应该让出。如果一个线程有很多小任务要做,它可能会想在截止日期前尽可能多地处理它们。因此,如下所示:
void Thread::PauseOver(word limit)
{
if(RtcPercentUsed() >= limit) Pause();
}
word Thread::RtcPercentUsed()
{
// This returns 0 unless the thread is running locked.
//
auto thr = RunningThread();
if(!thr->IsLocked()) return 0;
nsecs_t used = SteadyTime::Now() - thr->priv_->currStart_;
nsecs_t full = thr->priv_->currEnd_ - thr->priv_->currStart_;
if(used < full) return ((100 * used) / full);
return 100;
}
线程需要更多时间
当发生异常时,RSC 会捕获线程的堆栈。这是一个耗时的操作,因此 Exception
的构造函数会给线程更多时间。
void Thread::ExtendTime(const msecs_t& time)
{
auto thr = RunningThread(std::nothrow);
if(thr == nullptr) return;
thr->priv_->currEnd_ += time;
}
线程运行太久
协作式调度的危险在于缺乏协作!线程可能运行的时间过长,或者甚至进入无限循环。在测试期间这不太可能出现问题,但在实时服务器之类的东西中,这是一个严重的问题。因此,必须有一种方法来终止一个在截止日期前不让出的线程。
RSC 的 InitThread
负责终止运行时间过长的线程。它在进入 main
后不久创建,并负责初始化系统。
系统启动并运行后,InitThread
确保每个不可抢占的线程在到达截止日期之前让出。为此,InitThread
以可抢占的方式运行,而无需成为活动线程。默认情况下,强加给不可抢占线程的截止日期是 10 毫秒,这远远超过了大多数操作系统执行上下文切换的狂热速度。这意味着 InitThread
将很快被调度进来,并能够终止运行时间过长的线程。
当 InitThread
检测到线程已锁定运行时间过长时,它会调用 Thread::RtcTimeout
向该线程发送 SIGYIELD
信号。2这会在目标线程上引发一个异常。RSC 不会强制线程退出,而是允许它清理正在执行的工作。然后 RSC 会重新调用线程的入口函数,以便它能继续服务请求。
Ready
和 Schedule
函数都中断了 InitThread
来调度下一个线程。调度下一个线程后,InitThread
会休眠,直到线程应该自行调度出去的时间。如果线程进行协作,InitThread
会再次被中断来调度下一个线程。但如果线程运行时间过长,InitThread
会超时并向其发送 SIGYIELD
信号。
void InitThread::HandleTimeout()
{
// If there is no locked (i.e. unpreemptable) thread, schedule one.
// If the locked thread is still waiting to proceed, signal it.
// Both of these are unusual situations that occur because of race
// conditions.
//
auto thr = LockedThread();
if(thr == nullptr)
{
Thread::SwitchContext();
return;
}
else
{
if(thr->priv_->waiting_)
{
thr->Proceed();
return;
}
}
// If the locked thread has run too long, signal it unless breakpoint
// debugging is enabled.
//
if((thr->TimeLeft() == ZERO_SECS) && !ThreadAdmin::BreakEnabled())
{
thr->RtcTimeout();
}
}
调度线程
当 InitThread
被中断来调度下一个线程时,它会调用以下函数。
Thread* Thread::SwitchContext()
{
auto curr = ActiveThread();
if((curr != nullptr) && curr->IsLocked())
{
// This is similar to code in InitThread, where the scheduled thread
// occasionally misses its Proceed() and needs to be resignaled.
//
if(curr->priv_->waiting_)
{
curr->Proceed();
}
return curr;
}
// Select the next thread to run. If one is found, preempt any running
// thread (which cannot be locked) and signal the next one to resume.
//
auto next = Singleton<ThreadRegistry>::Instance()->Select();
if(next != nullptr)
{
if(next == curr)
{
return curr;
}
if(!ActiveThread_.compare_exchange_strong(curr, next))
{
// CURR is no longer the active thread, even though it was when
// this function was entered.
//
return curr;
}
if(curr != nullptr) curr->Preempt();
next->Proceed();
return next;
}
return curr;
}
void Thread::Proceed()
{
// Ensure that the thread's priority is such that the platform will
// schedule it in, and signal it to resume.
//
systhrd_->SetPriority(SysThread::DefaultPriority);
if(priv_->waiting_) systhrd_->Proceed();
}
当前选择的下一个运行的线程采用简单的轮询策略。
Thread* ThreadRegistry::Select()
{
// Cycle through all threads, beginning with the one identified by
// NextSysThreadId_, to find the next one that can be scheduled.
//
auto t = threads_.find(NextSysThreadId_);
if(t == threads_.end()) t = threads_.begin();
Thread* next = nullptr;
for(NO_OP; t != threads_.end(); ++t)
{
auto thread = t->second.thread_;
if(thread == nullptr) continue;
if(thread->CanBeScheduled())
{
next = thread;
break;
}
}
if(next == nullptr)
{
for(t = threads_.begin(); t != threads_.end(); ++t)
{
auto thread = t->second.thread_;
if(thread == nullptr) continue;
if(thread->CanBeScheduled())
{
next = thread;
break;
}
}
}
// If a thread was found, start the next search with the thread
// that follows it.
//
if(next != nullptr)
{
auto entry = threads_.find(next->NativeThreadId());
while(true)
{
++entry;
if(entry == threads_.end())
{
NextSysThreadId_ = 0;
break;
}
auto thread = entry->second.thread_;
if(thread == nullptr) continue;
NextSysThreadId_ = thread->NativeThreadId();
break;
}
}
return next;
}
抢占线程
默认情况下,应用程序线程以不可抢占的方式运行。但是,如果它在被信号指示运行时间过长之前无法完成一个逻辑工作单元,那么它必须可抢占地运行(可抢占)。在此期间,不可抢占的线程轮流运行。然而,平台的调度器仍然会给可抢占的线程时间。这会窃取不可抢占线程的时间,可能导致它错过截止日期。因此,当选择了一个不可抢占的线程,并且一个可抢占的线程正在运行时,我们会降低可抢占线程的优先级,这样它就不会与不可抢占的线程争夺。
void Thread::Preempt()
{
// Set the thread's ready time so that it will later be reselected,
// and lower its priority so that the platform won't schedule it in.
//
priv_->readyTime_ = SteadyTime::Now();
systhrd_->SetPriority(SysThread::LowPriority);
}
当 RSC 被移植到 Linux(使用 WSL2 和 Ubuntu)时,优先级调度不可用。在这种情况下,如果一个可抢占的线程也在运行,一个不可抢占的线程会获得更长的时段。
线程有一个硬截止日期
如前所述,“硬”实时系统是指未及时完成某些工作可能导致严重后果的系统。这些系统使用优先级调度来确保这些工作在截止日期前完成。这是否意味着它们不能使用本文所述的内容?
它们可以,但不是完全可以。任何面临硬截止日期的软件都应该以更高的优先级运行。此类软件通常会短暂运行,定期运行,以与外部硬件接口。例如,它可能读取传感器或控制伺服电机。它也可能用其计算结果或新设置更新内存。如果这导致关键区域问题,那么<atomic>
中的某些内容可能会解决它。
剩下的是不面临硬截止日期的软件。它可能不多,也可能占系统的绝大部分。这种软件是协作式调度的良好候选者。即使如此,其中一些软件在执行大任务时仍会可抢占地运行,正如前面所讨论的。
关注点
平衡工作负载
如果一个线程只能运行一段时间然后让出,然后它转到就绪队列的末尾,如果它成为瓶颈该怎么办?
一种解决方案是创建该线程的多个实例:线程池。当一个线程经常阻塞时,这也是一个常见的解决方案。提供该线程的多个实例可以减少其客户端的周转时间。
根据系统所做的工作,线程池可能会变得复杂。每个池的大小必须经过精心设计以保持系统平衡。如果用户行为因站点而异,情况就会变得难以管理。
也许最灵活的解决方案是比例调度。在这里,每个线程属于一个派系,派系是基于其线程执行的工作类型定义的。每个派系都保证获得最低百分比的 CPU 时间。当系统负载很重时,一些派系可能比其他派系获得更多的时间,但每个派系至少会获得一些时间。例如,服务器应该花费大部分时间来处理客户端请求。但是,它还应该确保为记录问题、响应管理请求以及审计对象池保留时间。这些事情没有一个比其他事情绝对更重要,以至于应该以更高的优先级运行。然而,确实发生过服务器为客户端请求分配更高优先级的情况。因此,当它们负载很重时,它们未能响应管理命令,从而被错误地认为它们挂起而被重新启动。
在比例调度下,如果线程经常阻塞,仍然需要线程池。但不需要该线程的多个实例才能为其工作分配足够的 CPU 时间。
RSC 将通过更改 ThreadRegistry::Select
来支持比例调度。如果可用优先级调度,则总体方案将继续是:
- 最高优先级:看门狗线程,例如
InitThread
(优先级调度) - 较高优先级:硬实时线程(优先级调度)
- 标准优先级:不可抢占线程(协作/比例调度)
- 较低优先级:可抢占线程(选择可抢占线程时,它以标准优先级运行)
如果优先级调度不可用,则看门狗线程以标准优先级运行,与应用程序线程相同。不能有“更高优先级”的硬实时线程,并且如果一个可抢占线程也在运行,则不可抢占的线程会获得额外的时间来完成其工作。
如本文开头所述,如果每个线程在下一个线程运行之前都返回其工作循环,那么就不会有竞态条件。这对所有以不可抢占方式运行的线程都适用,因此可能引起问题的是其他线程。幸运的是,它们与标准优先级线程的交互通常相当有限,因此与随意抢占式调度相比,净效应是大大减少了关键区域的数量。这可以通过 RSC 的 >mutexes
CLI 命令看到,该命令列出了系统中所有互斥量。RSC 的 Mutex
类记录了它引起阻塞的频率,并且大多数互斥量很少这样做。它们仅在可抢占线程执行了与另一个线程冲突的操作时才需要。
对称多处理
对称多处理(SMP)是指多个 CPU 共享公共内存的平台。这允许可执行文件的多个实例并行运行,通常可以提高吞吐量。
对于需要提高容量的遗留系统来说,SMP 是一种有吸引力的解决方案。但是,它可能会产生额外的竞态条件,必须找到并加以保护。这是因为,在单 CPU 上运行时,一个线程通常只与不同的线程交互,这可以有一个合理的关注点分离,从而减少竞态条件的数量。但是,当线程在 SMP 平台上运行时,它可能还必须与其他实例进行交互,而这些实例正在其他核心上运行,这可能会引入以前未见的竞态条件。
在设计新系统时,使用 SMP 平台以常规方式被视为一种弊端,因为它重新引入了对广泛的线程安全滥用的需求,这不足为奇。SMP 系统的吞吐量也可能很快达到一个渐近线,因为信号量争用或缓存冲突——或者甚至由于死锁而直线下降。
我的看法是,只有三个好的数字:0、1 和 ∞。所以如果我需要多个核心,2n 总是足够的吗?通用解决方案是设计一个以分布式(网络化)方式运行的软件。这增加了自身的复杂性,但它是真正可扩展的解决方案,如果您需要任意数量的计算能力,就无法避免。生成的软件也可以在 SMP 平台上运行,通过划分共享内存,使每个核心都有自己的沙箱,任何共享内存可能仅用于高效的处理器间消息传递。而那种效率最好留到后续的软件版本中,以在客户抱怨软件速度变慢后恢复性能——当然,这是由于适应他们提出的功能请求的结果。
致谢
我要感谢honey the codewitch,她的帖子如果你讨厌编写和尝试测试多线程代码,请举手促使我写了这篇文章。其中一小部分几乎逐字地摘自我们讨论期间我写的一些内容。
注释
1 本文中描述的技术是通过在标准抢占/优先级调度器上进行更合适的时间点上下文切换来实现的,而不是替换它。
2 有关信号、异常以及 RSC 如何处理它们的讨论,请参阅健壮 C++:安全网。SIGYIELD
如“发送信号给另一个线程”中所述,被传递给目标线程。
历史
- 2019 年 12 月 15 日:更新以反映文章最初发布以来软件的变化
- 2019 年 9 月 25 日:添加更多关于硬实时系统、遗留系统和比例调度
- 2019 年 9 月 23 日:初始版本