QNX----第2章 QNX Neutrino 线程 进程与调度策略(1部分)

QNX Neutrino RTOS的实现

从历史上看,QNX的软件系统的"应用压力"是由内存有限的嵌入式系统从内存有限的嵌入式系统中得到的,一直到高端的SMP(对称多处理器)计算机,有千兆字节的物理内存。因此,QNX中微子的设计目标同时适用于这两种看似唯一的功能集。追求这些目标的目的是扩展系统的范围,远远超出其他操作系统实现所能解决的范围。

POSIX实时和线程扩展

由于QNX Neutrino RTOS直接在微内核中实现了大部分的实时和线程服务,所以即使没有额外的OS模块,这些服务也是可用的。此外,POSIX定义的一些概要文件表明,这些服务在不需要进程模型的情况下存在。为了适应这一点,操作系统提供了对线程的直接支持,但是依赖于它的进程管理器部分将此功能扩展到包含多个线程的进程。注意,许多实时管理系统和内核只提供一个非内存保护的线程模型,根本没有进程模型和/或受保护的内存模型。没有进程模型,就无法实现完全POSIX遵从性。

系统服务

微内核有内核调用来支持以下内容:

•线程

•消息传递

•信号

•时钟

•定时器

•中断处理程序

•信号量

•互斥锁(互斥锁)

•条件变量(condvars)

•隔离

整个操作系统是建立在这些调用之上的。操作系统是完全可抢占的,即使在进程之间传递消息;它恢复消息传递,然后在抢占之前停止。

微内核的最小复杂度有助于在经过内核的最长不可抢占的代码路径上设置上限,而较小的代码大小使解决复杂的多处理器问题成为一个易于处理的问题。选择服务作为包含在微内核中的基础,因为服务的执行路径很短。需要大量工作的操作(例如,进程加载)被分配给外部进程/线程,与线程中为服务请求所做的工作相比,进入该线程上下文的工作是无关紧要的。

严格地应用这个规则来划分内核和外部进程之间的功能,这破坏了一个神话,即一个微内核操作系统必须比一个单一的内核操作系统带来更高的运行时开销。考虑到上下文切换(隐含在消息传递中)的工作,以及从简化的内核中获得的快速上下文切换的时间,所花的时间运行的上下文切换就变成了为服务的工作所做的工作所做的工作的“丢失”的过程中传递的信息传递的过程中所传递的信息传递的过程中所传递的信息。  

下图显示了non-SMP内核抢占细节(x86)实现。

图6:QNX Neutrino抢占的详细信息

中断是被禁用的,或者抢占是被延迟的,只有非常短的间隔(通常是几百纳秒的顺序)

 

线程和进程

在构建应用程序时(实时、嵌入式、图形化或其他),开发人员可能希望应用程序中的几种算法并发执行。这种并发性是通过使用POSIX线程模型实现的,该模型将进程定义为包含一个或多个执行线程。

线程可以被认为是最小的“执行单元”,即微内核中的调度和执行单元。另一方面,进程可以被认为是线程的“容器”,定义线程执行的“地址空间”。进程将始终包含至少一个线程。

根据应用程序的性质,线程可能独立执行,而不需要在算法之间通信(不太可能),或者它们可能需要紧密耦合,具有高带宽通信和紧密同步。为了帮助这种通信和同步,QNX中微子RTOS提供了丰富的IPC和同步服务。

下面的pthread_* (POSIX线程)库调用不包含任何微内核线程调用:

• pthread_attr_destroy()

• pthread_attr_getdetachstate()

• pthread_attr_getinheritsched()

• pthread_attr_getschedparam()

• pthread_attr_getschedpolicy()

• pthread_attr_getscope()

• pthread_attr_getstackaddr()

• pthread_attr_getstacksize()

• pthread_attr_init()

• pthread_attr_setdetachstate()

• pthread_attr_setinheritsched()

• pthread_attr_setschedparam()

• pthread_attr_setschedpolicy()

• pthread_attr_setscope()

• pthread_attr_setstackaddr()

• pthread_attr_setstacksize()

• pthread_cleanup_pop()

• pthread_cleanup_push()

• pthread_equal()

• pthread_getspecific()

• pthread_setspecific()

• pthread_key_create()

• pthread_key_delete()

• pthread_self()

下表列出了POSIX线程调用,它有一个相应的微内核线程调用,允许您选择其中一个接口:

可以将操作系统配置为提供线程和进程的混合(由POSIX定义)。每个进程都受到mmu的保护,每个进程可能包含一个或多个共享进程地址空间的线程。

线程属性

尽管进程中的线程共享进程地址空间中的所有内容,但每个线程仍然拥有一些“私有”数据。在某些情况下,这些私有数据在内核中受到保护(例如tid或线程ID),而其他私有数据则不受保护在进程的地址空间中(例如,每个线程都有自己的堆栈)。一些更值得注意的线程私有资源有:

tid: 每个线程由整数线程ID标识,从1开始。tid是线程进程中唯一的。

Priority: 每个线程都有一个优先级,帮助确定它什么时候运行。线程从父线程继承其初始优先级,但是优先级可以改变,这取决于调度策略、线程进行的显式更改或发送给线程的消息。

Name: 从QNX中微子核心OS 6.3.2开始,可以为指定一个名称线程;请参阅QNX中微子C库引用中的pthread_getname_np()和pthread_setname_np()条目。像dumper和pidin这样的实用程序支持线程名。线程名称是一个QNX中微子扩展。

Register set: 每个线程都有自己的指令指针(IP)、堆栈指针(SP)和其他处理器寄存器上下文。

Stack: 每个线程在它自己的堆栈上执行,存储在地址空间中它的过程。

Signal mask: 每个线程都有自己的信号掩码。

Thread local storage: 线程有一个系统定义的数据区域,称为“线程本地存储”(TLS)。TLS用于存储“每个线程”的信息(例如tid、pid、堆栈基数、errno和线程特定的键/数据绑定)。用户应用程序不需要直接访问TLS。线程可以拥有与特定于线程的数据键相关联的用户定义数据。

Cancellation handlers: 当线程终止时执行的回调函数。在pthread库中实现并存储在TLS中的特定于线程的数据提供了一种将进程全局整数键与每个线程的唯一数据值关联起来的机制。使用线程特定的数据,首先创建一个新键,然后将唯一的数据值绑定到键(每个线程)。例如,数据值可以是整数或指向动态分配的数据结构的指针。随后,键可以返回每个线程的绑定数据值。线程特定数据的典型应用程序是用于线程安全的函数,该函数需要为每个调用线程维护上下文。

图7:稀疏矩阵(tid,key)到值的映射

可以使用以下函数来创建和操作这些数据:

线程的生命周期

进程中线程的数量可以有很大的变化,线程是动态创建和销毁的。线程创建(pthread_create())涉及到在进程的地址空间(例如线程堆栈)中分配和初始化必要的资源,并在地址空间中的某个函数上启动线程的执行。

线程终止(pthread_exit(), pthread_cancel())涉及到停止线程并回收线程的资源。当一个线程执行时,它的状态通常可以被描述为“就绪”或“阻塞”。“更具体地说,它可以是以下几项之一:

图8:可能的线程状态。注意,除了上面显示的转换之外,线程还可以从任何状态(死状态除外)移动到就绪状态

CONDVAR: 线程被阻塞在一个条件变量上(例如,它被称为pthread_cond_wait())。

DEAD: 线程已经终止,正在等待另一个线程的连接。

INTERRUPT: 线程被阻塞,等待中断。,它叫做InterruptWait())。

JOIN: 线程被阻塞,等待加入另一个线程(例如,它调用pthread_join())。

MUTEX: 线程被阻塞在互斥锁上(例如,它调用pthread_mutex_lock())。

NANOSLEEP: 线程睡眠的时间很短(例如,它被称为nanosleep())。

NET_REPL Y: 线程正在等待通过网络(即,它叫MsgReply *())。

NET_SEND: 线程正在等待通过网络传递脉冲或信号(即它称为MsgSendPulse()、MsgDeliverEvent()或SignalKill())。

READY: 线程正在等待执行,而处理器正在执行另一个线程优先级相等或更高的线程。

RECEIVE: 线程被阻塞在消息receive(例如,它称为MsgReceive())上。

REPL Y: 线程在消息应答(即,它调用MsgSend()服务器收到消息)。

RUNNING: 线程是由处理器执行的。内核使用一个数组(系统中每个处理器有一个条目)来跟踪正在运行的线程。

SEM: 线程正在等待一个信号量被发布(例如。,它叫SyncSemWait())。

SEND: 线程在消息发送时被阻塞(例如,它调用MsgSend(),但是服务器还没有收到消息)。

SIGSUSPEND: 线程被阻塞,等待一个信号(即,它叫做sigsuspend())。

SIGWAITINFO: 线程被阻塞,等待一个信号(即,它叫做sigwaitinfo())。

STACK: 线程正在等待为线程的堆栈分配虚拟地址空间(父线程将调用ThreadCreate())。

STOPPED:线程被阻塞,等待SIGCONT信号。

WAITCTX: 线程正在等待一个非整数(例如浮点)上下文可用。

WAITPAGE: 线程正在等待为虚拟地址分配物理内存。

WAITTHREAD: 线程正在等待子线程完成自己的创建(即。,它叫ThreadCreate())。

线程调度

内核的部分工作是确定哪个线程在什么时候运行。

首先,让看看内核什么时候做出调度决策。

当作为内核调用、异常或硬件中断的结果输入微内核时,正在运行的线程的执行将暂时暂停。当任何线程的执行状态发生变化时,就会做出调度决策——线程可能驻留在哪个进程中并不重要。所有进程的线程都是全局调度的。

正常情况下,挂起的线程将继续执行,但是当运行的线程:

•被阻塞

•被抢占

•被调度

什么时候线程被阻塞?

当运行的线程必须等待某个事件发生时(响应IPC请求,等待互斥锁等),线程就会被阻塞。阻塞的线程将从正在运行的数组中删除,然后运行最高优先级的就绪线程。当阻塞的线程随后被解除阻塞时,它被放在优先级级别的就绪队列的末尾。

线程何时被抢占?

当一个高优先级的线程被放在就绪队列上时,正在运行的线程将被抢占(当它的块条件被解析时,它将成为就绪状态)。被抢占的线程被放在该优先级的就绪队列的开头,高优先级的线程运行。

什么时候会有线程被释放?

运行的线程自动生成处理器(sched_yield()),并被放在优先级就绪队列的末尾。然后,最优先级的线程运行(这可能仍然是刚刚屈服的线程)。

调度优先级

   每个线程都有一个优先级。线程调度程序通过查看分配给每个已就绪的线程的优先级(即能够使用CPU)。选择优先级最高的线程运行。

下图显示了已经就绪的5个线程(B-F)的就绪队列。

线程A当前正在运行。所有其他线程(G-Z)都被阻塞。线程A、B和QNX中微子微核C的优先级最高,因此它们将基于运行线程的调度策略共享处理器。

图9:就绪队列

操作系统总共支持256个调度优先级级别。非特权线程可以将其优先级设置为1到63(最高优先级),而不依赖于调度策略。(即只有根线程。,那些有效uid为0的)或那些启用了PROCMGR_AID_PRIORITY能力的可以将优先级设置在63以上。特殊空闲线程(在进程管理器中)的优先级为0,并且总是准备好运行。默认情况下,线程继承父线程的优先级。

可以使用procnto -P选项更改非特权进程的允许优先级范围。

procnto -P priority

在QNX中微子6.6或更高版本中,如果默认情况下希望超出范围的优先级请求饱和于最大允许值,而不是导致错误,则可以在此选项中附加s或s。当设置优先级时,可以将其封装在(非posix)宏中,以指定如何处理超出范围的优先级请求:

• SCHED_PRIO_LIMIT_ERROR(priority) — indicate an error

• SCHED_PRIO_LIMIT_SA TURA TE(priority) — saturate at the maximum allowed

Priority

注意,为了防止优先级反转,内核可能会临时提升线程的优先级。有关更多信息,请参阅本章后面的“优先级继承和互斥”,以及进程间通信(IPC)一章的“优先级继承和消息”。内核线程的初始优先级为255,但是它们所做的第一件事就是在MsgReceive()中阻塞,因此在此之后,它们将在发送消息给它们的线程的优先级上操作。

就绪队列上的线程按优先级排序。就绪队列实际上实现为256个独立队列,每个优先级一个。选择运行最高优先级队列中的第一个线程。

大多数情况下,线程在其优先级队列中以FIFO顺序排队,但也有一些例外:

•从接收阻塞状态发出消息的服务器线程被插入到优先级队列的最前面——也就是说,顺序是LIFO,而不是FIFO。

•如果一个线程使用MsgSend*()的“nc”(非取消点)变量发送消息,那么当服务器响应时,该线程被放置在就绪队列的最前面,而不是最后。如果调度策略是循环的,则线程的时间片没有得到补充;例如,如果线程在发送之前已经使用了一半的时间片,那么在有资格进行抢占之前,它仍然只剩下半个时间片。

调度策略

为了满足各种应用的需求,QNX中微子RTOS提供了以下调度算法:

•FIFO调度机制

•循环调度

•分散的调度

系统中的每个线程都可以使用任何方法运行。这些方法对每个线程都有效,而不是对节点上的所有线程和进程都有效。

FIFO和循环调度策略仅在两个或多个具有相同优先级的线程准备就绪(即,线程直接相互竞争)。然而,适用的方法为线程的执行使用“预算”。在所有情况下,如果高优先级线程准备就绪,它会立即抢占所有低优先级线程。

在下面的图中,有三个优先级相同的线程。如果线程A阻塞,线程B将运行。

图10:线程A块;线程B。

虽然线程从它的父进程继承它的调度策略,但是线程可以请求修改内核应用的算法。

FIFO调度机制

在FIFO调度中,选择运行的线程继续执行,直到:

•主动放弃控制(例如,它会阻塞)

•被高优先级线程抢占

图11:FIFO调度机制

循环调度

在循环调度中,选择运行的线程继续执行,直到:

•自愿放弃控制权

•被高优先级线程抢占

•使用它的时间片

如下图所示,线程A一直运行,直到耗尽其时间片;下一个准备好的线程(线程B)现在运行:

图12:循环调度

时间片是分配给每个进程的时间单位。一旦它消耗了它的时间片,一个线程就会被抢占,下一个在相同优先级的准备好的线程就会被控制。一个时间片4×时钟周期。

分散的调度

分散的调度策略通常用于在给定的时间段内提供线程执行时间的上限限制。当在服务于周期性和非周期性事件的系统上执行速率单调分析(RMA)时,这种行为是必不可少的。从本质上讲,这种算法允许线程服务于非周期事件,而不会危及系统中其他线程或进程的严格期限。

在FIFO调度中,使用分散调度的线程继续执行,直到阻塞或被高优先级线程抢占为止。在自适应调度中,使用分散调度的线程会降低优先级,但是在分散调度中,可以更精确地控制线程的行为。

在零星调度下,线程的优先级可以在前台优先级或普通优先级与后台优先级或低优先级之间动态振荡。使用以下参数,可以控制这种分散调度的条件:

最初的预算(C):线程在被降为低优先级(L)之前,被允许以其正常优先级(N)执行的时间量。

低优先级(L):线程将下降到的优先级级别。线程在后台以低优先级(L)执行,在前台以正常优先级(N)运行。

补给时间(T):线程被允许使用其执行预算的一段时间。在计划补货操作时,POSIX实现也使用这个值作为线程准备就绪时的偏移量。

   待定补给的最大数量:这个值限制了可以进行的补给操作的数量,从而限制了分散调度策略所消耗的系统开销。

正如下图所示,分散的调度策略建立一个线程的初始执行预算(C),这是被线程运行和定期补充(T)。当一个线程块,执行预算的数量的消耗(R)安排在一些以后补充(如40毫秒)线程开始后准备好运行。

图13:线程的预算是定时补充

在正常优先级为N的情况下,线程将按照其初始执行预算c定义的时间执行。一旦这个时间耗尽,线程的优先级将下降到低优先级L,直到执行补给操作。

例如,假设一个系统中线程从未阻塞或从未被抢占:

图14:线程的优先级下降,直到其预算得到补充

在这里,线程将下降到低优先级(后台)级别,根据系统中其他线程的优先级,它可能有机会运行,也可能没有机会运行。

一旦进行了补充,线程的优先级就会提高到原来的级别。这保证了在一个正确配置的系统中,线程将有机会在每个周期T中运行最长的执行时间C.这保证了优先级为N的线程只消耗C/T的系统资源。

当一个线程多次阻塞时,可能会启动几个补给操作,并在不同的时间进行补给操作。这可能意味着线程的执行预算将在一段时间内达到C;然而,在此期间,执行预算可能不是连续的。

图15:线程在高优先级和低优先级之间摇摆。

在上面的图表中,线程的预算(C)为10 msec,在每个40 msec补给周期(T)内消耗。

1 线程的初始运行在3 msec之后被阻塞,所以3 msec的补货操作计划从40 msec开始,即,当其第一次补充期间已经过去时。

2 线程有机会在6 msec再次运行,这标志着另一个补给周期(T)的开始。线程的预算中还有7 msec。

3 线程在没有阻塞的情况下运行了7 msec,从而耗尽了它的预算,然后降到低优先级L,在那里它可能执行,也可能无法执行。计划在46 msec(40 + 6),即,当T过去时。

4 .线程3 msec的预算在40 msec被补充(见第1步),因此被提升到正常优先级。

5.线程消耗其预算的3 msec,然后被降回低优先级。

6.线程的预算中有7 msec在46 msec被补充(见第3步),并且被恢复到正常的优先级。

等等。线程将继续在两个优先级之间振荡,以可控的、可预测的方式为系统中的非周期事件提供服务。

操作优先级和调度策略

线程的优先级在执行过程中可能会发生变化,要么是线程本身直接操作,要么是内核在接收到高优先级线程的消息时调整线程的优先级。

除了优先级之外,您还可以选择内核将用于线程的调度算法。库提供了许多不同的方法来获取和设置调度参数,但是最好的选择是pthread_getschedparam()、thread_setschedparam()和pthread_setschedprio()。

IPC的问题

由于进程中的所有线程都可以不受阻碍地访问共享数据空间,这种执行模型难道不能“简单地”解决所有IPC问题吗?难道我们不能通过共享内存来通信数据,而不使用其他执行模型和IPC机制吗?

要是这么简单就好了!

一个问题是,单个线程对公共数据的访问必须同步。让一个线程读取不一致的数据,因为另一个线程是修改数据的一部分,这会导致灾难。例如,如果一个线程正在更新链表,那么在第一个线程完成之前,不允许其他线程遍历或修改链表。必须“串行”执行的代码段(例如:以这种方式被称为“临界段”。除非有同步机制确保串行访问,否则程序将不可修复地损坏链接(间歇性的,取决于“冲突”发生的频率)。

互斥、信号量是可以用来解决这个问题的同步工具的例子。本节稍后将介绍这些工具。

尽管可以使用同步服务来允许线程进行合作,但是共享内存本身不能解决许多IPC问题。例如,尽管线程可以通过公共数据空间进行通信,但只有当所有通信的线程都在一个进程中时才会工作。如果我们的应用程序需要与数据库服务器通信查询怎么办?我们需要将查询的详细信息传递给数据库服务器,但是我们需要与之通信的线程位于一个数据库服务器进程中,并且该服务器的地址空间不能被我们寻址。

操作系统负责网络分布式IPC问题,因为一个接口消息传递在本地和网络远程情况下都可以操作,并且可以用来访问所有操作系统服务。由于消息可以精确地大小,而且由于大多数消息都非常小(例如,写请求上的错误状态,或者很小的读请求上的错误状态),在网络中移动的数据通过消息传递的情况要比使用网络分布式共享内存的情况少得多,后者一般会复制4K页面。

线程复杂度问题

尽管线程对于某些系统设计非常适合,但重要的是,注意潘多拉的复杂问题,它们使用的是未发的。在某些方面,具有讽刺意味是,虽然保护mmu的多任务处理已经变得常见,但是计算方式使得在未受保护的地址空间中使用多个线程变得流行。这不仅使调试变得困难,而且妨碍了可靠代码的生成。

线程最初作为一种“轻量级”并发机制引入UNIX系统,以解决“重量级”进程之间的慢上下文切换问题。尽管这是一个值得实现的目标,但一个明显的问题出现了:为什么进程到进程的上下文切换一开始就很慢?

在体系结构上,操作系统首先解决上下文切换的性能问题。实际上,线程和进程提供了几乎相同的上下文切换性能数字。QNX中微子RTOS的进程切换时间比UNIX线程切换时间快。因此,不需要使用QNX中微子线程来解决IPC性能问题;相反,它们是在应用程序和服务器进程中实现更大并发性的工具。

在不使用线程的情况下,快速的进程到进程上下文切换使得将应用程序组织为一个共享显式分配的共享内存区域的协作进程团队是合理的。因此,应用程序只在那些bug对共享内存区域内容的影响的范围内,才会暴露在协作进程中的bug面前。进程的私有内存仍然受到其他进程的保护。在纯线程模型中,所有线程(包括它们的堆栈)的私有数据都是可公开访问的,在进程中的任何线程中都容易出现指针错误。

不过,线程还可以提供纯进程模型无法解决的并发优势。例如,一个代表多个客户机执行请求的文件系统服务器进程(每个请求都需要花费大量的时间来完成)肯定会受益于多线程的执行。如果一个客户机进程从磁盘请求一个块,而另一个客户机请求一个已经在缓存中的块,那么文件系统进程可以利用一个线程池并发地服务客户机进程,而不是保持“忙碌”,直到读取第一个请求的磁盘块。

当请求到达时,每个线程都能够直接从缓冲区缓存响应,或者阻塞并等待磁盘I/O,而不会增加其他客户机进程看到的响应延迟。文件系统服务器可以“预创建”一组线程,以便在客户机请求到达时依次响应。虽然这会使文件系统管理器的体系结构变得复杂,但是并发性的好处是显著的

猜你喜欢

转载自blog.csdn.net/u011996698/article/details/82825229