进程和线程
本章包括:
过程和线程基础
在我们开始讨论线程,进程,时间片和所有其他精彩的“调度概念”之前, 让我们建立一个类比。
我想先做的是说明线程和进程如何工作。我能想到的最好的方法(缺乏对实时系统的设计的挖掘)是想象我们的线程和进程在某种情况下。
一个过程作为一个房子
让我们使用一个常规的日常对象(一个房子)来对流程和线程进行类比。
房子真的是一个集装箱,具有某些属性(例如,楼面面积的数量,卧室数量,等等)。
如果你这样看待,房子真的不会主动做 任何事情 - 它是一个被动的对象。这实际上是一个过程。我们将很快探讨这一点。
占用者作为线程
居住在房子里的人是活跃的对象 - 他们是使用各种房间,看电视,烹饪,洗澡等等。我们很快就会看到线程的行为。
单线程
如果你曾经住在你自己的,那么你知道这是什么样-你知道,你可以做任何事,你在家里想要的任何时间,因为有没有人在房子里其他人。如果你想打开立体声,使用洗手间,吃晚饭 - 无论 - 你只是去做,做它。
多线程
当你添加另一个人到房子里时,事情发生了巨大的变化。让我们说你结婚了,所以现在你有一个配偶住在那里了。你不能只是在任何给定点进入洗手间; 你需要先检查,以确保你的配偶不在那里!
如果你有两个负责任的成年人住在一个房子,一般来说你可以相当松懈“安全” - 你知道,另一个成年人会尊重你的空间,不会尝试设置厨房火灾(故意!),等等上。
现在,把几个孩子在混合中,突然事情变得更有趣。
回到进程和线程
就像房子占据房地产一样,一个过程占据了记忆。正如一个房子的居民可以自由进入他们想要的任何房间,一个进程的线程都有共同的访问该记忆。如果一个线程分配一些东西(妈妈出去买游戏),所有其他线程都会立即访问它(因为它存在于公共地址空间 - 它在房子中)。同样,如果进程分配内存,这个新内存也可用于所有线程。这里的技巧是识别内存是否应该可用于进程中的所有线程。如果是,那么您需要让所有线程同步它们对它的访问。如果不是,那么我们假设它特定于一个特定的线程。在这种情况下,
正如我们从日常生活中所知道的,事情并不那么简单。现在我们已经看到了基本特征(总结:一切都是共享的),让我们看看事情变得更有趣的地方,为什么。
下图显示了我们将代表线程和进程的方式。该过程是圆,表示“容器”概念(地址空间),并且三个直角线是线程。在整本书中,你会看到这样的图表。
一个进程作为线程的容器。
互斥
如果你想洗澡,有人已经在使用浴室,你必须等待。线程如何处理这个?
它是与称为互斥的东西。这意味着你的想法 - 几个线程是相互排斥的,当它涉及到一个特定的资源。
如果你洗澡,你想有独家访问 浴室。要做到这一点,你通常会进入浴室,从内部锁门。任何人试图使用浴室将被锁停止。当你完成后,你会解锁门,允许别人访问。
这只是一个线程。一个线程使用一个称为对象互斥体(的缩写MUT UAL EX包涵体)。这个对象就像一个门上的锁 - 一旦一个线程有互斥体锁定,没有其他线程可以获得互斥体,直到拥有线程释放(解锁)它。就像门锁,等待获得互斥量的线程将被禁止。
互斥和门锁发生的另一个有趣的并行是互斥是真正的“咨询”锁。如果一个线程不服从约定使用互斥的,则保护是无用的。在我们的房子比喻中,这将像一个人通过一个墙壁闯入洗手间,忽视了门和锁的约定。
优先级
如果浴室当前被锁定,并且有很多人正在等待使用它怎么办?显然,所有的人都坐在外面,等待谁在浴室里出去。真正的问题是,“当门解锁时会发生什么?谁下一步去?“
你会认为这是“公平”,允许任何人等待最长的下一个。或者可能是“公平”,让谁是最古老的人接下来。或最高。或者最重要的。有许多方法来确定什么是“公平”。
我们通过两个因素解决这个问题:优先级和等待时间长度。
假设两个人同时在(锁定的)浴室门上显示。其中一个有一个紧迫的期限(他们已经迟到了一次会议),而另一个没有。是否允许有紧急截止日期的人下一步是不是有意义?好吧,当然会的。唯一的问题是你如何决定谁更重要。 这可以通过分配一个优先级(让我们使用像Neutrino一样的数字 - 一个是最低的可用优先级,255是这个版本的最高)。房子中具有紧迫期限的人将被给予较高优先级,而那些不具有较高优先级的人将被给予较低优先级。
同样的事情线程。线程从其父线程继承其调度算法,但可以调用 pthread_setschedparam() 来更改其调度策略和优先级(如果它有权限这样做)。
如果多个线程正在等待,并且互斥体解锁,我们将给互斥体到具有最高优先级的等待线程。然而,假设两个人都有相同的优先级。现在你做什么?那么,在这种情况下,允许下一个等待时间最长的人是“公平”的。这不仅是“公平”,而且也是Neutrino内核的作用。在一个线程等待的情况下,我们主要按优先级,其次 按等待时间长度。
互斥体当然不是我们将遇到的唯一同步对象。让我们来看看其他一些。
信号量
让我们从浴室走进厨房,因为这是一个社会上可以接受的地方,同时有多个人。在厨房里,你可能不想让大家都在一起。事实上,你可能想限制你可以在厨房里的人数(太多的厨师,和所有)。
假设你不想同时有两个以上的人。你能用mutex做它吗?不是我们定义它。为什么不?这实际上是我们类比的一个非常有趣的问题。让我们分成几个步骤。
计数为1的信号量
浴室可以有两种情况之一,两种状态彼此相伴:
- 门是解锁的,没有人在房间里
- 门被锁住,一个人在房间里
没有其他组合是可能的 - 门不能锁在房间里的任何人(我们将如何解锁它?),门不能解锁与在房间里的人(他们如何确保他们的隐私?)。这是一个计数为一的信号量的例子 - 在那个房间里最多只能有一个人,或者一个线程使用信号量。
这里的关键(原谅双关语)是我们描绘锁的方式。在你典型的浴室锁,你可以锁定和解锁它只从内部 - 没有外部访问的钥匙。实际上,这意味着互斥体的所有权是一个原子操作-有没有机会,当你在获得互斥体的过程是一些其他的线程会得到它,结果,你都 拥有互斥。在我们的房子的类比中,这不太明显,因为人类只是比那些比零和零更聪明。
我们需要的厨房是一种不同类型的锁。
计数大于1的信号量
假设我们在厨房里安装了传统的基于键的锁。这种锁的工作方式是,如果你有一个钥匙,你可以解锁门,进去。任何人谁使用这种锁同意,当他们进来时,他们将立即从内部锁门,使外面的任何人总是需要一个密钥。
好了,现在变成一个简单的事情来控制我们想要在厨房里有多少人 - 挂在门外的两把钥匙!厨房总是锁着的。当有人想进入厨房时,他们看到门外是否有钥匙。如果是这样,他们带他们,解开厨房门,进去,并使用钥匙锁门。
由于进入厨房的人在厨房里必须有钥匙,所以我们直接控制在任何给定点进入厨房的人数,方法是限制外面的挂钩上可用的钥匙数量门。
使用线程,这是通过信号量完成的。一个“纯”信号量就像一个互斥体一样工作 - 你或者拥有互斥体,在这种情况下你可以访问资源,或者你没有,在这种情况下你没有访问权限。我们刚刚描述的与厨房的信号量是一个计数信号量- 它跟踪计数(通过线程可用的密钥数量)。
信号量作为互斥体
我们刚刚问了一个问题:“你 能用互斥体做到这一点吗?”关于用计数器实现锁,答案是否定的。另一方面怎么样?我们可以使用信号量作为互斥吗?
是。事实上,在一些操作系统中,这正是他们做的 - 他们没有互斥体,只有信号量!那么为什么要麻烦互斥体呢?
要回答这个问题,看看你的洗手间。你的房子的建设者如何实现“互斥”?我怀疑你没有挂在墙上的钥匙!
互斥体是一种“特殊目的”信号量。如果你想要一个线程运行在特定的代码段,互斥体是迄今为止最有效的实现。
稍后,我们将讨论其他同步方案 - 事件称为condvars,barrier和sleepons。
![]() | 所以没有混乱,意识到互斥体有其他属性,如优先级继承,将其与信号量区分开。 |
内核的角色
这个房子的类比对于跨越同步的概念是很好的,但它落在一个主要领域。在我们家,我们有多个线程运行的同时。然而,在真实的实时系统中,通常只有一个CPU,因此只有一个“事物”可以同时运行。
单CPU
让我们看看现实世界中发生了什么,具体来说,我们在系统中有一个CPU 的 “经济”情况。在这种情况下,由于只存在一个CPU,只有一个线程可以在任何给定的时间点运行。内核决定(使用一些规则,我们将很快看到)哪个线程运行,并运行它。
多CPU(SMP)
如果你买有多个相同的CPU上的所有共享内存和设备的系统,你有一个SMP系统(SMP代表小号 ymmetrical 中号 ULTI P rocessor,与“对称”的一部分,表明系统中的所有CPU是相同的) 。在这种情况下,可以并发(同时)运行的线程数受CPU数量的限制。(实际上,单处理器盒也是如此!)由于每个处理器一次只能执行一个线程,因此使用多个处理器,可以同时执行多个线程。
让我们忽略现在的CPU数量 - 一个有用的抽象是设计系统,好像多个线程真的同时运行,即使不是这样。稍后,在“ 使用SMP时需要注意的事项 ” 部分中,我们将看到SMP的一些非直观的影响。
内核作为仲裁器
那么谁决定哪个线程将在任何给定的时刻运行?这是内核的工作。
内核确定哪个线程应该在特定时刻使用CPU,并将上下文切换到该线程。让我们来看看内核对CPU的作用。
CPU具有多个寄存器(精确数量取决于处理器系列,例如x86对MIPS,以及特定家庭成员,例如80486对比Pentium)。当线程运行时,信息被存储在那些寄存器(例如,当前程序位置)中。
当内核决定另一个线程应该运行时,它需要:
- 保存当前运行的线程的寄存器和其他上下文信息
- 将新线程的寄存器和上下文加载到CPU中
但是内核如何决定另一个线程应该运行?它查看特定线程是否能够在此时使用CPU。例如,当我们讨论互斥体时,我们引入了一个阻塞状态(当一个线程拥有互斥体时,另一个线程想要获取互斥体;第二个线程将被阻塞)。
因此,从内核的角度来看,我们有一个线程可以消耗CPU,一个不能,因为它被阻塞,等待互斥。在这种情况下,内核允许可以运行的线程使用CPU,并将其他线程放入内部列表(以便内核可以跟踪其对互斥体的请求)。
显然,这不是一个非常有趣的情况。假设有多个线程可以使用CPU。记住,我们基于等待的优先级和长度来委托对互斥体的访问?内核使用类似的方案来确定接下来要运行的线程。有两个因素:优先级和调度算法,按该顺序评估。
优先级
考虑两个能够使用CPU的线程。如果这些线程有不同的优先级,那么答案是非常简单的 - 内核给CPU提供最高优先级的线程。Neutrino的优先级从一个(最低的可用)和更高,我们提到当我们谈论获得互斥。请注意,优先级0是为空闲线程保留的 - 您不能使用它。(如果你想知道你的系统的最小和最大值,使用函数sched_get_priority_min() 和 sched_get_priority_max() - 他们的原型在<sched.h>。在这本书中,我们假设一个作为最低的可用,255为最高)。
如果具有较高优先级的另一个线程突然变得能够使用CPU,则内核将立即上下文切换到较高优先级线程。我们称之为抢占 - 更高优先级的线程抢占低优先级线程。当高优先级的线程完成,内核上下文切换回之前处于运转状态低优先级的线程,我们称这种 恢复 -内核恢复运行前一个线程。
现在,假设两个线程能够使用CPU并且具有完全相同的优先级。
调度算法
让我们假设其中一个线程当前正在使用CPU。我们将检查内核在这种情况下用于决定何时进行上下文切换的规则。(当然,这整个的讨论确实只适用于线程在同一优先级-的瞬间,一个高优先级的线程就可以使用它得到它的CPU,这是一个实时操作系统具有优先级的整点)。
Neutrino内核理解的两种主要的调度算法(策略)是循环(或只是“RR”)和FIFO(先入先出)。(还有零星的调度,但它超出了本书的范围;参见 “ 零星调度 ” 在系统架构指南的QNX Neutrino Microkernel一章 )。
FIFO
在FIFO调度算法中,允许线程只要需要就消耗CPU。这意味着,如果该线程是做一个非常长的数学计算,和一个更高的优先级的任何其他线程准备,该线程可能运行永远。那么优先级相同的线程呢?他们也被锁定。(在这一点上应当明显的是,较低优先级的线程也被锁定。)
如果运行的线程退出或自愿放弃CPU,则 内核会查找具有相同优先级的其他线程,这些线程能够使用CPU。如果没有这样的线程,则内核寻找能够使用CPU的低优先级线程。注意,术语“自愿放弃CPU”可以表示两种情况之一。如果线程进入休眠或信号量上的块等等,则是,则较低优先级的线程可以运行(如上所述)。但是还有一个“特殊”调用, sched_yield() (基于内核调用 SchedYield()),它只将 CPU放弃给具有相同优先级的另一个线程 - 如果较高优先级已准备好运行,则较低优先级线程将永远不会被给予运行机会。如果一个线程事实上调用了sched_yield(),并且没有其他同一优先级的线程准备好运行,原线程继续运行。有效地,sched_yield()用于给另一个相同优先级的线程在CPU处的裂缝。
在下面的图中,我们看到三个线程在两个不同的进程中操作:
三个线程在两个不同的进程。
如果我们假设线程“A”和“B”是READY,并且线程“C”被阻塞(可能等待互斥),并且当前正在执行线程 “D”(未示出)部分的Neutrino内核维护的READY队列看起来像:
两个线程上的READY队列,一个阻塞,一个运行。
这显示了内核的内部READY队列,内核使用该队列来决定下一个计划。注意,线程“C”不在READY队列上,因为它被阻塞,并且线程“D”不在READY队列上,因为它正在运行。
循环
RR调度算法与FIFO相同,除了如果有相同优先级的另一个线程,线程将不会永远运行。它只运行一个系统定义的时间片,其值可以通过使用函数 sched_rr_get_interval()来确定。时间片通常是4 ms,但实际上是ticksize的 4倍 ,您可以使用ClockPeriod()查询或设置 。
发生什么是内核启动RR线程,并注意时间。如果RR线程运行了一段时间,分配给它的时间将会up(时间片将已经过期)。内核会查看是否有另一个线程处于同一优先级。如果有,内核就运行它。如果没有,那么内核将继续运行RR线程(即,内核授予线程另一个时间片)。
规则
让我们按照重要性的顺序总结(对于单个CPU)的调度规则:
- 一次只能运行一个线程。
- 将运行最高优先级的就绪线程。
- 线程将运行,直到阻塞或退出。
- RR线程将运行其时间片,然后内核将重新计划它(如果需要)。
以下流程图显示了内核所做的决策:
计划路线图。
对于多CPU系统,规则是相同的,除了多个CPU可以并发运行多个线程。线程运行的顺序(即哪些线程在多个CPU上运行)以与单个CPU完全相同的方式确定 - 最高优先级的READY线程将在CPU上运行。对于较低优先级或较长等待线程,内核具有一定的灵活性,以便何时调度它们以避免使用高速缓存的低效率。有关SMP的详细信息,请参阅“ 多核处理用户指南”。
内核状态
我们一直在谈论“运行”, “准备好”和 “阻塞”松散 - 让我们现在形式化这些线程状态。
运行
Neutrino的RUNNING状态意味着线程现在正在积极地消耗CPU。在SMP系统上,将有多个线程运行; 在单处理器系统上,会有一个线程运行。
准备
在就绪状态意味着该线程可以运行现在-但它不是,因为另一个线程(在相同或更高的优先级),正在运行。如果两个线程能够使用CPU,一个线程优先级为10,一个线程优先级为7,则优先级为10的线程将为RUNNING ,优先级为7的线程将为READY。
阻塞状态
我们称之为阻塞状态?问题是,不只是一个被阻塞的状态。在Neutrino下,实际上有十几个阻塞状态。
为什么这么多?因为内核记录了为什么线程被阻塞。
我们看到两个阻塞状态已经 - 当线程被阻塞等待互斥量时,线程处于MUTEX状态。当线程被阻塞等待信号量时,它处于SEM状态。这些状态仅仅指示线程被阻塞在哪个队列(和哪个资源)。
如果一个线程被阻塞在mutex上(在MUTEX 被阻塞的状态),它们不会被内核注意,直到拥有该mutex的线程释放它。此时,一个被阻塞的线程被准备就绪,并且内核进行重新安排决定(如果需要)。
为什么“如果需要?” 刚刚释放互斥体的线程仍然可以有其他事情要做,并具有比等待线程更高的优先级。在这种情况下,我们转到第二条规则,其中规定“最高优先级的就绪线程将运行”,这意味着调度顺序没有改变 - 更高优先级的线程继续运行。
内核状态,完整的列表
这里是内核阻塞状态的完整列表,简要解释每个状态。顺便说一下,这个列表在<sys / neutrino.h>中可以找到- 你会注意到所有的状态都以STATE_ 为前缀(例如,这个表中的“READY”在头文件中被列为STATE_READY):
如果状态是: | 线程是: |
---|---|
CONDVAR | 等待条件变量发出信号。 |
死 | 死。内核正在等待释放线程的资源。 |
INTR | 等待中断。 |
加入 | 等待另一个线程的完成。 |
MUTEX | 正在等待获取互斥体。 |
NANOSLEEP | 睡一段时间。 |
NET_REPLY | 等待答复将通过网络传送。 |
NET_SEND | 等待要通过网络传递的脉冲或消息。 |
准备 | 不是在CPU上运行,而是准备运行(一个或多个更高或相等优先级的线程正在运行)。 |
接收 | 等待客户端发送消息。 |
回复 | 正在等待服务器回复邮件。 |
运行 | 主动在CPU上运行。 |
SEM | 等待获取信号量。 |
发送 | 正在等待服务器接收消息。 |
SIGSUSPEND | 等待信号。 |
SIGWAITINFO | 等待信号。 |
堆叠 | 等待更多的堆栈分配。 |
停止 | 已暂停(SIGSTOP信号)。 |
WAITCTX | 等待寄存器上下文(通常为浮点)变为可用(仅在SMP系统上)。 |
WAITPAGE | 正在等待进程管理器解决页面上的故障。 |
等待 | 正在等待创建线程。 |
要记住的重要一点是,当一个线程被阻塞,无论哪个国家它受阻于,它消耗没有 CPU。相反,线程占用CPU的唯一状态是RUNNING状态。
我们将 在“ 消息传递”一章中看到SEND,RECEIVE和REPLY阻止状态。该了nanosleep状态用于像功能的 睡眠() ,我们将看看在本章的时钟,定时器和获得一个踢每隔一段时间。该INTR状态用于与 InterruptWait() ,我们将在该看看中断的篇章。大多数其他状态在本章中讨论。
线程和进程
让我们回到我们讨论的线程和进程,这一次从一个真实的活系统的角度。然后,我们将看看用于处理线程和进程的函数调用。
我们知道一个进程可以有一个或多个线程。(一个拥有零个线程的进程将无法做任何事情 - 没有人在家,所以可以说,实际执行任何有用的工作。)Neutrino系统可以有一个或多个进程。(同样的讨论适用 - 具有零过程的Neutrino系统不会做任何事情。)
那么这些进程和线程做什么?最终,它们形成一个系统 - 执行某个目标的线程和进程的集合。
在最高级别,该系统由若干进程组成。每个进程负责提供某种性质的服务 - 无论是文件系统,显示驱动程序,数据采集模块,控制模块等。
在每个进程中,可能有多个线程。线程数不同。一个仅使用一个线程的设计者可以实现与使用五个线程的另一个设计者相同的功能。一些问题本身是多线程的,并且实际上相对来说很容易解决,而其他进程适合于单线程,并且很难做出多线程。
使用线程设计的主题很容易占据另一本书 - 我们只是坚持这里的基础。
为什么要进程?
那么,为什么不是只有一个进程与zillion线程?虽然一些操作系统强迫你以这种方式编写代码,但是将事物分解为多个进程的优点有很多:
- 解耦和模块化
- 可维护性
- 可靠性
将问题分解为几个独立问题的能力是一个强有力的概念。它也在Neutrino的心脏。Neutrino系统由许多独立的模块组成,每个模块都有一定的责任。这些独立模块是不同的过程。QSS的人使用这个技巧来隔离开发模块,而模块之间没有依赖。模块相互之间唯一的“依赖”是通过少量明确定义的接口。
这自然导致增强的可维护性,由于缺乏相互依赖性。由于每个模块都有自己的特定定义,因此很容易修复一个模块 - 特别是因为它不与任何其他模块绑定。
可靠性,但也许是最重要的一点。一个过程,就像一个房子,有一些明确界定的“边界”。 一个人在房子里有一个很好的主意,当他们在房子里,当他们不是。线程有一个非常好的主意 - 如果它在进程内访问内存,它可以生存。如果它超出了进程地址空间的界限,它会被杀死。这意味着在不同进程中运行的两个线程被有效地彼此隔离。
内存保护。
该进程的地址空间是维护和中微子的进程管理器模块执行。当进程启动时,进程管理器为其分配一些内存并启动线程运行。内存被标记为由该进程拥有。
这意味着如果在该进程中有多个线程,并且内核需要在它们之间进行上下文切换,那么这是一个非常有效的操作 - 我们不必更改地址空间,只是哪个线程正在运行。然而,如果我们必须改变到另一个进程中的另一个线程,那么进程管理器就会涉及并引起地址空间切换。不要担心 - 虽然这额外的步骤有一点开销,在Neutrino下,这仍然是非常快。
启动进程
现在让我们将注意力转向可用于处理线程和进程的函数调用。任何线程都可以启动一个进程; 所施加的唯一限制是源于基本安全(文件访问,特权限制等)的限制。在所有的可能性,你已经开始其他过程; 无论是从系统启动脚本,shell,或通过让程序以您的名义启动另一个程序。
从命令行启动进程
例如,从shell可以键入:
$ program1
这指示shell启动的程序调用程序1 ,并等待它完成。或者,您可以键入:
$ program2&
这指示shell启动Program2中 ,而不等待它完成。我们说Program2中运行“在后台”。
如果要在启动程序之前调整程序的优先级,可以使用 nice 命令,就像在UNIX中一样:
$ nice program3
这将指示shell以 降低的优先级启动program3。
还是吗?
如果你看看真正发生了什么,我们告诉shell运行一个名为nice的程序在正常的优先级。在漂亮的指令调整了自己的优先级要低一些(这是名“好”来自),然后就跑到program3在较低的优先级。
从程序中启动进程
你通常不关心shell创建进程的事实 - 这是关于shell的一个基本假设。在某些应用程序设计中,您肯定会依赖于shell脚本(文件中的批处理命令)为您完成工作,但在其他情况下,您需要自己创建这些进程。
例如,在大型多进程系统中,您可能希望有一个主程序基于某种配置文件启动应用程序的所有其他进程。另一个示例将包括当检测到某些操作条件(事件)时启动过程。
让我们来看看Neutrino为启动其他进程(或转换到不同的程序)提供的功能:
您使用的功能取决于两个要求:可移植性和功能。像往常一样,两者之间有一个权衡。
在创建新进程的所有调用中发生的常见事情如下。原始进程中的线程调用上述函数之一。最终,函数使进程管理器为新进程创建一个地址空间。然后,内核在新进程中启动一个线程。这个线程执行几个指令,并调用main()。(在fork()和vfork()的情况下,当然,新线程通过从fork()或vfork()返回在新进程中开始执行;我们将看到如何处理这一点。)
使用system()调用启动进程
该 系统() 函数是最简单的; 它需要一个命令行,与您在shell提示符下键入它一样,并执行它。
事实上,system()实际上启动一个shell来处理你想要执行的命令。
我用来编写这本书的编辑器使用了system() 调用。当我编辑时,我可能需要“shell out”, 检查一些样品,然后回到编辑器,所有没有失去我的地方。在这个编辑器中,我可以发出命令:!pwd例如,显示当前工作目录。编辑器运行以下代码:!pwd命令:
系统(“pwd”);
是system()适合太阳下的一切吗?当然不是,但它对于很多你的过程创建需求是有用的。
使用exec()和spawn()调用启动一个进程
让我们来看一些其他的过程创建函数。
下一个进程创建函数我们应该看看 exec() 和 spawn() 家族。在我们进入细节之前,让我们看看这两组功能之间的区别。
在执行exec()系列改造当前的进程到另一个。我的意思是,当一个进程发出exec() 函数调用时,该进程停止运行当前程序,并开始运行另一个程序。进程ID不更改 - 该进程更改为另一个程序。在进程中的所有线程发生了什么?我们将回到那,当我们看看fork()。
该菌种()的家庭,在另一方面,没有做到这一点。调用spawn()系列的成员会创建另一个 进程(具有新的进程ID),该进程对应于函数参数中指定的程序。
让我们来看看spawn()和 exec()函数的不同变体。在下面的表中,您将看到哪些是POSIX,哪些不是。当然,为了最大的可移植性,你只需要使用POSIX函数。
产卵 | POSIX? | 执行 | POSIX? |
---|---|---|---|
spawn() | 没有 | ||
spawnl() | 没有 | execl() | 是 |
spawnle() | 没有 | execle() | 是 |
spawnlp() | 没有 | execlp() | 是 |
spawnlpe() | 没有 | execlpe() | 没有 |
spawnp() | 没有 | ||
spawnv() | 没有 | execv() | 是 |
spawnve() | 没有 | execve() | 是 |
spawnvp() | 没有 | execvp() | 是 |
spawnvpe() | 没有 | execvpe() | 没有 |
而这些变体看起来似乎是压倒性的,存在 是其后缀的图案:
后缀: | 手段: |
---|---|
l(小写“L”) | 参数列表通过调用本身中给出的参数列表指定,由NULL参数终止。 |
e | 指定了环境。 |
p | 如果未指定程序的完整路径名,则使用PATH环境变量。 |
v | 参数列表通过指向参数向量的指针来指定。 |
参数列表是传递给程序的命令行参数列表。
另外,请注意,在C库中, spawnlp(),spawnvp()和spawnlpe() 都调用spawnvpe(),它依次调用spawnp()。函数spawnle(),spawnv()和spawnl() 都调用spawnve(),然后调用 spawn()。最后,spawnp()调用 spawn()。所以,所有产生功能的根是spawn() 调用。
现在让我们详细了解各种spawn() 和exec()变体,以便您可以感觉到使用的各种后缀。然后,我们会看到spawn()调用本身。
/* To run ls and keep going: */ spawnl (P_WAIT, "/bin/ls", "/bin/ls", "-t", "-r", "-l", NULL); /* To transform into ls: */ execl ("/bin/ls", "/bin/ls", "-t", "-r", "-l", NULL);
or, using the v suffix variant:
char *argv [] = { "/bin/ls", "-t", "-r", "-l", NULL }; /* To run ls and keep going: */ spawnv (P_WAIT, "/bin/ls", argv); /* To transform into ls: */ execv ("/bin/ls", argv);
为什么选择?它是作为方便提供。您可能已经在程序中构建了一个解析器,并且传递字符串数组会很方便。在这种情况下,我建议使用“ v ”后缀变体。或者,你可能正在编写一个对程序的调用,你知道什么是参数。在这种情况下,当你知道参数 是什么时,为什么还要设置一个字符串数组?只是将它们传递给“ l ”后缀变体。
请注意,我们通过程序(实际路径/斌/ LS)和节目的名字再次作为第一个参数。我们再次传递名称,以支持根据它们的调用方式而表现不同的程序。
例如,GNU压缩和解压缩实用程序(gzip 和 gunzip)实际上是指向同一个可执行文件的链接。当可执行文件启动时,它查看argv [0](传递给 main()),并决定是应该压缩还是解压缩。
在“ ê ”后缀的版本传递一个 环境给程序。一个环境就是这样 - 一种 程序操作的“上下文”。例如,您可能有一个拼写检查器,它有一个字典字典。而不是每次在命令行上指定字典的位置,您可以在环境中提供它:
$ export DICTIONARY = / home / rk / .dict $ spellcheck document.1
该出口的命令告诉shell来创建一个新的环境变量(在这种情况下,字典),并为其分配一个值(/home/rk/.dict)。
如果你想使用不同的字典,你必须在运行程序之前改变环境。这很容易从壳:
$ export DICTIONARY = / home / rk / .altdict $ spellcheck document.1
但是你怎么能从你自己的程序这样做?要使用spawn()和 exec()的“ e ”版本,您需要指定一个代表环境的字符串数组:
char *env [] = { "DICTIONARY=/home/rk/.altdict", NULL }; // To start the spell-checker: spawnle (P_WAIT, "/usr/bin/spellcheck", "/usr/bin/spellcheck", "document.1", NULL, env); // To transform into the spell-checker: execle ("/usr/bin/spellcheck", "/usr/bin/spellcheck", "document.1", NULL, env);
在“ p ”后缀的版本将搜索目录在 PATH环境变量来查找可执行文件。您可能已经注意到所有示例都有一个硬编码的可执行文件位置 - / bin / ls和/ usr / bin / spellcheck。其他可执行文件呢?除非你想首先找到特定程序的确切路径,最好让用户告诉你的程序所有的地方搜索可执行文件。标准的PATH环境变量就是这样。这里是一个从最小系统:
PATH = / proc / boot:/ bin
这告诉shell当我输入一个命令,它应该首先查找目录/ proc / boot,如果它找不到命令,那么它应该在binaries目录/ bin部分。 PATH是以冒号分隔的列表,用于查找命令。您可以根据需要向PATH中添加任意数量的元素,但请记住,将对可执行文件搜索所有路径名组件(按顺序)。
如果你不知道可执行文件的路径,那么你可以使用 “ p ”变体。例如:
//使用显式路径: execl(“/ bin / ls”,“/ bin / ls”,“-l”,“-t”,“-r”,NULL); //在PATH中搜索可执行文件: execlp(“ls”,“ls”,“-l”,“-t”,“-r”,NULL);
如果 execl()在/ bin中 找不到ls,它返回一个错误。在 execlp() 函数将搜索中指定的所有目录路径的LS,并会返回一个错误,只有当它找不到LS 在任何这些目录。这也是伟大的多平台支持 - 您的程序不必编码知道不同的CPU名称,它只是找到可执行文件。
如果你做这样的事情怎么办?
execlp(“/ bin / ls”,“ls”,“-l”,“-t”,“-r”,NULL);
它搜索环境吗?不,你告诉execlp()使用显式路径名,它覆盖正常的PATH搜索规则。如果在/ bin中找不到ls,那么不会进行其他尝试(这与execl()在这种情况下的工作方式相同)。
将显式路径与简单命令名混合(例如,路径参数/ bin / ls和命令名参数ls,而不是/ bin / ls)是否危险?这通常是相当安全的,因为:
- 大量的节目忽略的argv [0]反正
- 那些通常调用 basename()的函数,它剥离argv [0]的目录部分并返回刚才的名字。
指定第一个参数的完整路径名的唯一原因是程序可以打印出包括第一个参数的诊断信息,它可以立即告诉您程序被调用的位置。当在PATH的多个位置找到程序时,这可能很重要。
该菌种()函数都有一个额外的参数; 在所有上面的例子中,我总是指定P_WAIT。有四个标志可以传递给spawn()来改变它的行为:
-
P_WAIT
- 调用进程(您的程序)被阻塞,直到新创建的程序运行到完成并退出。 P_NOWAIT
- 在新创建的程序运行时,调用程序 不会 阻塞。这允许你在后台启动一个程序,并继续运行,而其他程序做它的事情。 P_NOWAITO
- 与 P_NOWAIT相同 ,只是 SPAWN_NOZOMBIE 标志被设置,这意味着你不必担心执行 waitpid() 来清除进程的退出代码。 P_OVERLAY
-
此标志将
spawn()
调用转换为相应的
exec()
调用!您的程序转换为指定的程序,而进程ID没有更改。
使用exec()调用通常更清楚,如果这是你的意思 - 它保存软件的维护者不必在C库参考中查找P_OVERLAY!
如上所述,所有的 spawn()函数最终调用普通的 spawn() 函数。这里是spawn()函数的原型:
#include <spawn.h> pid_t spawn(const char * path, int fd_count, const int fd_map [], const struct inheritance * inherit, char * const argv [], char * const envp []);
我们可以立即免除路径,argv和 envp参数 - 我们已经看到了上面代表可执行文件(路径成员),参数向量(argv)和环境(envp)的位置。
该fd_count和fd_map参数一起去。如果为fd_count指定零,则fd_map将被忽略,这意味着所有文件描述符(除了由fcntl()的 FD_CLOEXEC标志修改的那些 )将在新创建的进程中继承。如果fd_count为非零,则它指示包含在fd_map中的文件描述符的数目 ; 只有指定的那些将被继承。
在继承参数是一个指向包含一组标志,信号口罩,等的结构。有关更多详细信息,请参阅Neutrino Library Reference。
使用fork()调用启动一个进程
假设您想创建一个与当前运行的进程相同的新进程,并让它同时运行。您可以使用spawn()(和P_NOWAIT 参数)来处理这个问题,为新创建的进程提供有关进程的确切状态的足够信息,以便设置自己。然而,这可能是非常复杂的; 描述过程的“当前状态”可能涉及大量数据。
有一个更简单的方法 - fork() 函数,它复制当前进程。所有的代码是相同的,并且数据与创建(或父)进程的数据相同。
当然,不可能创建一个 在每个方面都与父进程相同的进程。为什么?这两个进程之间最明显的区别是进程ID - 我们不能使用相同的进程ID创建两个进程。如果你看看Neutrino Library Reference中的fork()的文档,你会看到两个进程之间存在差异的列表。您应该阅读此列表,以确保您知道这些差异,如果您计划使用fork()。
如果两边的fork()看起来一样,你怎么告诉他们分开?当你调用fork(),你创建另一个进程在同一位置执行相同的代码(即,两个都将从fork()调用返回)作为父进程。让我们看看一些示例代码:
int main(int argc,char ** argv) { int retval; printf(“这绝对是父进程\ n”); fflush(stdout); retval = fork(); printf(“哪个过程打印了这个?\ n”); return(EXIT_SUCCESS); }}
后fork()的调用,这两个 过程都将执行第二个printf()的调用!如果你运行这个程序,它打印如下:
这绝对是父进程 哪个进程打印的? 哪个过程打印了?
两个进程都打印第二行。
告诉两个进程分开的唯一方法是 在retval中的fork()返回值。在新创建的子进程中,retval为零; 在父进程中,retval是子进程的进程ID。
清除泥?这里是另一个代码片段来澄清:
printf(“父代是pid%d \ n”,getpid()); fflush(stdout); if(child_pid = fork()){ printf(“This is the parent,child pid is%d \ n”, child_pid); } else { printf(“This is the child,pid is%d \ n”, getpid()); }}
这个程序将打印如下:
父是pid 4496 这是父,子pid是8197 这是孩子,pid是8197
你可以通过查看fork()的返回值来判断fork()之后是哪个进程(父进程或子进程) 。
使用vfork()调用启动进程
该 了vfork() 函数可以是资源少很多比平原密集fork()的,因为它共享父进程的地址空间。
该了vfork()函数创建一个孩子,但后来暂停父线程,直到孩子调用的exec()或退出(通过出口()和朋友)。此外,vfork()将在物理内存模型系统上工作,而fork()不能 - fork()需要创建相同的地址空间,这在物理内存模型中是不可能的。
过程创建和线程
假设你有一个进程,你还没有创建任何线程(即,你正在运行一个线程,一个名为main())。当你调用fork(),另一个进程创建,也有一个线程。这是简单的情况。
现在假设在你的过程中,你调用 pthread_create() 来创建另一个线程。当你调用fork(),它现在将返回ENOSYS (意味着该功能不被支持)!为什么?
好吧,相信或不,这是 POSIX兼容 - POSIX说,fork() 可以返回ENOSYS。实际发生的是这样的:Neutrino C库不是建立来处理线程的进程的分叉。当你调用在pthread_create()中,在pthread_create()函数设置一个标志,有力地说:“不要让这个过程fork()的,因为我不准备处理吧。” 然后,在库fork()的函数,这个标志被检查,如果设置,使得fork()返回ENOSYS。
这是故意做的原因与线程和互斥。如果此限制不到位(并且可能在将来的版本中解除),则新创建的进程将具有与原始进程相同数量的线程。这是你期望的。然而,发生并发症是因为一些原始线程可能拥有互斥体。由于新创建的进程具有原始进程的数据空间的相同内容,因此库将必须跟踪哪些互斥体由原始进程中的哪些线程拥有,然后在新进程中复制该所有权。这不是不可能的 - 有一个名为pthread_atfork()的函数 允许进程处理这个; 然而,调用pthread_atfork()的功能 isn'
那么你应该使用什么?
显然,如果你移植现有的代码,你会想使用现有的代码使用。对于新的代码,你应该避免fork()如果可能的话。这里是为什么:
- fork()不能与多个线程一起工作,如上所述。
- 当fork()使用多个线程,你需要注册一个pthread_atfork()处理程序,并锁定每个单一的互斥体,你叉,复杂的设计。
- 的孩子fork()的复制所有打开的文件描述符。我们将在后面的资源管理器一章中看到,这将导致大量的工作 - 如果子进程立即执行一个exec() 并关闭所有的文件描述符,大多数工作是不必要的。
vfork()和spawn()之间的选择归结于可移植性,以及你想让孩子和父母做什么。该了vfork()函数将暂停,直到孩子调用的exec()或退出,而产卵()系列函数可以让两个同时运行。所述的vfork()函数,但是,是操作系统之间微妙的不同。
启动线程
现在我们已经了解了如何启动另一个进程,让我们看看如何启动另一个线程。
任何线程可以在同一进程中创建另一个线程; 没有限制(内存空间不足,当然!)。最常用的方法是通过POSIXpthread_create() 调用:
#include <pthread.h> int pthread_create(pthread_t * thread, const pthread_attr_t * attr, void *(* start_routine)(void *), void * arg);
将在pthread_create()函数有四个参数:
-
线
- 指向存储线程ID 的 pthread_t 的指针 attr
- 一个 属性结构 start_routine
- 线程开始的例程 arg
- 一个参数传递给线程的 start_routine
请注意,线程指针和属性结构(attr)是可选的 - 您可以将它们作为NULL传递。
的螺纹参数可被用来存储新创建的线程的线程ID。你会注意到,在下面的例子中,我们将传递一个NULL,这意味着我们不关心新创建的线程的ID。如果我们小心,我们可以这样做:
pthread_t tid; pthread_create(&tid,... printf(“Newly created thread id is%d \ n”,tid);
这种使用实际上是很典型的,因为你经常想知道哪个线程ID正在运行哪段代码。
![]() | 一个小小的点。可能在线程ID(tid参数)填充之前,新创建的线程可能正在运行。这意味着你应该小心使用tid作为全局变量。上面所示的用法是可以的,因为pthread_create()调用已经返回,这意味着tid值被正确填充。 |
新线程开始在start_routine()处执行,并带有参数arg。
线程属性结构
当你启动一个新的线程时,它可以假设一些定义明确的默认值,或者你可以明确的指定它的特性。
在我们讨论线程属性函数之前,让我们来看看 pthread_attr_t数据类型:
typedef struct { int __flags ; size_t __stacksize ; void * __stackaddr ; void(* __exitfunc)(void * status); int __policy ; struct sched_param __param ; unsigned __guardsize ; } pthread_attr_t;
基本上,字段使用如下:
-
__flags
- 非数值(布尔)特性(例如,线程是应该运行 “分离” 还是 “可连接” )。 __stacksize , __stackaddr 和 __guardsize
- 堆叠规格。 __exitfunc
- 在线程退出时执行的功能。 __policy 和 __param
- 计划参数。
以下功能可用:
-
属性管理
-
pthread_attr_destroy()
pthread_attr_init()
标志(布尔特性)
-
pthread_attr_getdetachstate()
pthread_attr_setdetachstate()
pthread_attr_getinheritsched()
pthread_attr_setinheritsched()
pthread_attr_getscope()
pthread_attr_setscope()
堆栈相关
-
pthread_attr_getguardsize()
pthread_attr_setguardsize()
pthread_attr_getstackaddr()
pthread_attr_setstackaddr()
pthread_attr_getstacksize()
pthread_attr_setstacksize()
pthread_attr_getstacklazy()
pthread_attr_setstacklazy()
计划相关
-
pthread_attr_getschedparam()
pthread_attr_setschedparam()
pthread_attr_getschedpolicy()
pthread_attr_setschedpolicy()
这看起来像一个非常大的列表(20个函数),但实际上我们只需要担心一半,因为它们是配对的:“get”和“set”(除了pthread_attr_init()和pthread_attr_destroy())。
在我们检查属性函数之前,有一件事要注意。在使用之前,必须调用pthread_attr_init()来初始化属性结构,使用适当的pthread_attr_set *()函数进行设置, 然后调用pthread_create()创建线程。在创建线程后更改属性结构没有任何效果。
线程属性管理
在使用之前,必须调用函数 pthread_attr_init()来初始化属性结构:
... pthread_attr_t attr; ... pthread_attr_init(&attr);
你可以调用 pthread_attr_destroy() 来“初始化” 线程属性结构,但几乎没有人做过(除非你有POSIX兼容的代码)。
在下面的说明中,我使用“ (默认) ”标记了默认值。
在“标志”线程属性
三个函数 pthread_attr_setdetachstate(), pthread_attr_setinheritsched()和 pthread_attr_setscope() 确定线程是创建“可连接”还是“分离” ,线程是否继承创建线程的调度属性或使用由pthread_attr_setschedparam指定的调度 属性) 和 pthread_attr_setschedpolicy(),最后是线程是否有“system”或“process”的范围。
要创建一个“可连接”线程(意味着另一个线程可以通过pthread_join()同步到它的终止 ),你可以使用:
(默认) pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_JOINABLE);
要创建一个不能被加入(称为“分离的”线程),你可以使用:
pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED);
如果你想让线程继承创建线程的调度属性(也就是说,具有相同的调度算法和相同的优先级),你可以使用:
(默认) pthread_attr_setinheritsched(&attr,PTHREAD_INHERIT_SCHED);
要创建一个使用属性结构本身中指定的调度属性(您将使用 pthread_attr_setschedparam() 和pthread_attr_setschedpolicy()设置),您将使用:
pthread_attr_setinheritsched(&attr,PTHREAD_EXPLICIT_SCHED);
最后,你永远不会调用 pthread_attr_setscope()。为什么?因为Neutrino只支持“系统”范围,并且它是默认的,当你初始化属性。(“系统”范围意味着系统中的所有线程都会相互竞争CPU;另一个值“进程”意味着线程在进程内彼此竞争CPU,并且内核调度进程)。
如果你坚持调用它,你可以调用它只有如下:
(默认) pthread_attr_setscope(&attr,PTHREAD_SCOPE_SYSTEM);
在“栈”线程属性
线程属性堆栈参数的原型如下:
int pthread_attr_setguardsize(pthread_attr_t * attr,size_t gsize); int pthread_attr_setstackaddr(pthread_attr_t * attr,void * addr); int pthread_attr_setstacksize(pthread_attr_t * attr,size_t ssize); int pthread_attr_setstacklazy(pthread_attr_t * attr,int lazystack);
这些函数都将属性结构作为它们的第一个参数; 其第二参数选自以下:
-
gsize
- “防护” 区域的大小。 地址
- 堆栈的地址,如果你提供一个。 ssize
- 堆栈的大小。 lazystack
- 指示是否应根据需要或从物理内存预先分配堆栈。
保护区域是紧接在线程不能写入的堆栈之后的存储器区域。如果它是(意味着堆栈即将溢出),线程将得到一个SIGSEGV命中。如果guard是0,这意味着没有保护区。这也意味着没有堆栈溢出检查。如果guardsize是非零的,那么它至少设置为系统级的默认guardsize( 通过使用常量_SC_PAGESIZE调用sysconf()可以获得 该值)。请注意,guardsize将至少与“页面”一样大(例如,x86处理器上的4 KB)。另外,请注意,保护页不占用任何物理内存 -
该地址是堆栈的地址,如果你要提供它。你可以将其设置为NULL,意味着系统将分配(并将释放!)线程的堆栈。指定堆栈的优点是可以进行事后堆栈深度分析。这是通过分配一个堆栈区域,填充一个“签名” (例如,字符串“堆栈”重复一遍又一遍),并让线程运行。当线程完成后,你可以查看堆栈区域,看看线程在你的签名上写了多少,给出了在这个特定运行期间使用的堆栈的最大深度。
该ssize参数指定堆有多大。如果在addr中提供堆栈,则ssize应该是该数据区的大小。如果你不在addr中提供堆栈(意思是你传递一个NULL),那么ssize参数告诉系统它应该为你分配多大的堆栈。如果为ssize指定0,系统将为您选择默认堆栈大小。显然,这是糟糕的做法,指定一个0为ssize 和 指定一个堆栈使用addr - 实际上你说“这里是一个指向一个对象的对象,该对象是一些默认大小。
最后,lazystack参数指示物理内存是否应根据需要分配(使用值PTHREAD_STACK_LAZY)或全部前面(使用值PTHREAD_STACK_NOTLAZY)。分配堆栈“按需”(根据需要)的优点是线程将不会消耗比它绝对必需的更多的物理内存。缺点(因此“所有前端”方法的优点)是在低内存环境中,线程在操作期间不会神秘地死掉一些时间,当它需要堆栈的额外位时,并且没有任何内存剩余。如果你使用PTHREAD_STACK_NOTLAZY,
在“调度”线程属性
最后,如果你指定PTHREAD_EXPLICIT_SCHED为 pthread_attr_setinheritsched() ,那么你就需要一种方法来同时指定调度算法和线程你要创建的优先级。
这是通过两个函数完成的:
int pthread_attr_setschedparam(pthread_attr_t * attr, const struct sched_param * param); int pthread_attr_setschedpolicy(pthread_attr_t * attr, int policy);
该政策很简单-它是一个SCHED_FIFO,SCHED_RR或SCHED_OTHER。
![]() | SCHED_OTHER当前映射到SCHED_RR。 |
该参数是一个包含相关这里的一个成员的结构:sched_priority。通过直接分配将该值设置为所需的优先级。
![]() | 要注意的常见错误是指定PTHREAD_EXPLICIT_SCHED,然后仅设置调度策略。问题是,在初始化的属性结构中,param.sched_priority的 值为0。这与IDLE进程的优先级相同,这意味着您新创建的线程将与IDLE进程竞争CPU。 在那里,做到了,得到了T恤。:-) 足够的人被咬了这一点QSS已经为仅空闲线程保留优先级零。你根本不能运行优先级为零的线程。 |
几个例子
让我们来看看一些例子。我们假设已经包含了正确的包含文件(<pthread.h>和<sched.h>),并且要创建的线程被称为new_thread(),并且被正确地原型化和定义。
创建线程的最常见的方法是简单地让值为default:
pthread_create(NULL,NULL,new_thread,NULL);
在上面的例子中,我们使用默认值创建了一个新的线程,并传递一个 NULL作为它的唯一参数(这是 上面 的pthread_create()调用的第三个NULL)。
一般来说,你可以传递任何你想要的(通过arg字段)到你的新线程。这里我们传递数字123:
pthread_create(NULL,NULL,new_thread,(void *)123);
一个更复杂的例子是创建一个具有优先级15的循环调度的非可连接线程:
pthread_attr_t attr; //初始化属性结构 pthread_attr_init(&attr); //将分离状态设置为“detached” pthread_attr_setdetachstate(&attr,PTHREAD_CREATE_DETACHED); //覆盖默认的INHERIT_SCHED pthread_attr_setinheritsched(&attr,PTHREAD_EXPLICIT_SCHED); pthread_attr_setschedpolicy(&attr,SCHED_RR); attr.param.sched_priority = 15; // finally,create the thread pthread_create(NULL,&attr,new_thread,NULL);
要查看多线程程序“看起来像什么”,您可以 从shell 运行 pidin命令。说我们的程序叫做spud。如果我们运行pidin前一次开钻创建一个线程,一旦后开钻 创造了两个以上的线程(三共),这里的输出将是什么样子(我已经缩短了pidin 输出,只显示开钻):
#pidin pid tid name prio STATE Blocked 12301 1 spud 10r READY #pidin pid tid name prio STATE Blocked 12301 1 spud 10r READY 12301 2 spud 10r READY 12301 3 spud 10r READY
可以看到,进程spud(进程ID 12301)有三个线程(在“ tid ”列下)。三个线程以循环调度算法(由10之后的“r”指示)以优先级10运行。所有三个线程都是READY的,这意味着它们能够使用CPU,但是目前不在CPU上运行(另一个更高优先级的线程,当前正在运行)。
现在,我们知道所有关于创建线程,让我们来看看我们如何和在哪里使用它们。
哪里一个线程是个好主意
有两类问题,其中线程的应用是一个好主意。
![]() | 线程就像C ++中的重载操作符 - 它似乎是一个好主意(在当时)重载每一个操作符与一些有趣的使用,但它使代码很难理解。与线程类似,您可以创建成堆的线程,但额外的复杂性将使您的代码难以理解,因此很难维护。另一方面,合理使用线程将导致代码在功能上非常干净。 |
线程是伟大的,你可以并行化操作 - 大量的数学问题(图形,数字信号处理等)。线程也是伟大的,你希望程序在共享数据时执行几个独立的功能,例如同时为多个客户端提供服务的Web服务器。我们将检查这两个类。
数学运算中的线程
假设我们有一个执行光线跟踪的图形程序。屏幕上的每个光栅线都取决于主数据库(描述生成的实际图像)。这里的关键是: 每个光栅线都是独立的。这会立即导致问题突出作为一个螺纹程序。
这里是单线程版本:
int main(int argc,char ** argv) { int x1; ... //执行初始化 (x1 = 0; x1 <num_x_lines; x1 ++){ do_one_line(x1); } ... //显示结果 }
在这里我们看到,程序将遍历所有要计算的栅格线迭代x1。
在SMP系统上,此程序将仅使用一个CPU。为什么?因为我们没有告诉操作系统并行执行任何操作。操作系统不够聪明,看不到程序,说: “嘿,坚持第二!我们有4个CPU,看起来在这里有独立的执行流。我会在所有4个CPU上运行它!
所以,由系统设计师(你)告诉Neutrino哪些部分可以并行运行。最简单的方法是:
int main(int argc,char ** argv) { int x1; ... //执行初始化 (x1 = 0; x1 <num_x_lines; x1 ++){ pthread_create(NULL,NULL,do_one_line,(void *)x1); } ... //显示结果 }
这种简单的方法有很多问题。首先(这是最小的),do_one_line() 函数必须修改为接受void *, 而不是int作为其参数。这很容易补救与类型转换。
第二个问题是有点棘手。让我们说,你计算的图片的屏幕分辨率是1280由1024.我们将创建1280个线程!这不是一个问题为Neutrino - Neutrino “限制” 你到每个过程32767个线程!但是,每个线程必须有唯一的堆栈。如果你的堆栈是合理的大小(比方说8 KB),你将使用1280×8 KB(10兆字节!)的堆栈。为什么?您的SMP系统中只有4个处理器。这意味着1280个线程中只有4个线程会一次运行 - 其他1276个线程正在等待CPU。(实际上,堆栈将“故障进入”,意味着它的空间将只根据需要分配,然而,这是一个浪费 - 还有其他开销)。
一个更好的解决方案是将问题分成4个部分(每个CPU一个),并为每个部分启动一个线程:
int num_lines_per_cpu; int num_cpus; int main(int argc,char ** argv) { int cpu; ... //执行初始化 //获取CPU数 num_cpus = _syspage_ptr - > num_cpu; num_lines_per_cpu = num_x_lines / num_cpus; for(cpu = 0; cpu <num_cpus; cpu ++){ pthread_create(NULL,NULL, do_one_batch,(void *)cpu); } ... //显示结果 } void * do_one_batch(void * c) { int cpu =(int)c; int x1; for(x1 = 0; x1 <num_lines_per_cpu; x1 ++){ do_line_line(x1 + cpu * num_lines_per_cpu);
这里我们只启动num_cpus个线程。每个线程将在一个CPU上运行。因为我们只有少量的线程,我们不会浪费内存与不必要的堆栈。请注意,我们通过取消引用“系统页面” 全局变量_syspage_ptr来获取CPU数。(有关系统页面中的内容的更多信息,请参阅QSS的 Building Embedded Systems书或<sys / syspage.h> 包含文件)。
SMP或单处理器的编码
关于这个代码的最好的部分是它将在单处理器系统上运行良好 - 你将只创建一个线程,并让它做所有的工作。额外的开销(一个堆栈)是非常值得的软件“只是工作更快”在SMP盒的灵活性。
同步到线程的终止
我提到,最初显示的简单代码示例有许多问题。另一个问题是,main()启动一堆线程,然后显示结果。该函数如何知道什么时候可以安全地显示结果?
要使main()函数轮询完成,将会破坏实时操作系统的目的:
int main(int argc,char ** argv) { ... //以前开始的线程 while(num_lines_completed <num_x_lines){ sleep(1); } }
甚至不要考虑写这样的代码!
这个问题有两个优雅的解决方案: pthread_join() 和 pthread_barrier_wait()。
加盟
最简单的同步方法是在线程终止时连接线程。加入真的意味着等待终止。
加入是由一个线程完成的,等待另一个线程的终止。等待线程调用 pthread_join():
#include <pthread.h> int pthread_join(pthread_t thread,void ** value_ptr);
要使用pthread_join(),你需要传递你想要加入的线程的线程ID,以及一个可选的value_ptr,它可以用来存储来自加入线程的终止返回值。(如果你对这个值不感兴趣,你可以传入一个NULL - 在这种情况下我们不是。)
线程ID来自哪里?我们在pthread_create()中忽略它- 我们 为第一个参数传递了一个NULL。让我们现在更正我们的代码:
int num_lines_per_cpu,num_cpus; int main(int argc,char ** argv) { int cpu; pthread_t * thread_ids; ... //执行初始化 thread_ids = malloc(sizeof(pthread_t)* num_cpus); num_lines_per_cpu = num_x_lines / num_cpus; for(cpu = 0; cpu <num_cpus; cpu ++){ pthread_create(&thread_ids [cpu],NULL, do_one_batch,(void *)cpu); } //同步到所有线程 的终止(cpu = 0; cpu <num_cpus; cpu ++){ pthread_join(thread_ids [cpu],NULL); } ... //显示结果 }
你会注意到,这次我们将第一个参数传递给pthread_create() 作为指向pthread_t的指针。这是新创建的线程的线程ID存储在哪里。在第一个for循环完成后,我们有num_cpus 个线程运行,加上运行main()的线程。我们不太关心main()线程消耗我们所有的CPU; 它会花时间等待。
等待是通过对我们的每个线程依次执行pthread_join()来完成的。首先,我们等待thread_ids [0]完成。当它完成时,pthread_join()将解除阻塞。for循环的下一次迭代将使我们等待 thread_ids [1]完成,等等,对于所有num_cpus 线程。
在这一点上出现的一个常见问题是,“如果线程以相反的顺序完成了什么?” 换句话说,如果有4个CPU,并且由于某种原因,在最后一个CPU(CPU 3)上运行的线程,首先完成,然后在CPU 2上运行的线程完成下一步,依此类推?好吧,这个计划的美丽是没有什么不好的发生。
将发生的第一件事是,pthread_join() 将在thread_ids [0]上阻塞。同时,thread_ids [3]完成。这对main()线程绝对没有影响,这仍然等待第一个线程完成。然后thread_ids [2]完成。仍然没有影响。等等,直到最后 thread_ids [0]完成,在这一点,pthread_join()解除阻塞,我们立即进行到for循环的下一次迭代。for循环的第二次迭代在thread_ids [1]上执行pthread_join(),它不会阻塞 - 它立即返回。为什么?因为由所确定的螺纹thread_ids [1]是已完成。因此,我们的for循环将“鞭打”通过其他线程,然后退出。在这一点上,我们知道我们已经同步所有的计算线程,所以我们现在可以显示结果。
使用屏障
当我们谈到main()函数与完成工作线程的同步(在上面的“同步到线程的终止”)时,我们提到了两个方法:pthread_join(),我们看过,和屏障。
回到我们的房子比喻,假设家人想去某个地方旅行。司机进入小型货车,起动发动机。并等待。司机等待,直到所有的家庭成员登机,只有那时面包车离开去旅行 - 我们不能离开任何人!
这正是发生在图形示例。主线程需要等待直到所有的工作线程都完成,然后才能下一部分程序开始。
注意一个重要的区别,但是。使用pthread_join(),我们正在等待线程的终止。这意味着线程不再与我们在一起; 他们已经退出。
使用障碍,我们正在等待一定数量的线程在障碍处会合。然后,当需要的号码存在时,我们解除所有的号码。(注意线程继续运行。)
您首先使用pthread_barrier_init()创建一个屏障 :
#include <pthread.h> int pthread_barrier_init(pthread_barrier_t * barrier, const pthread_barrierattr_t * attr, unsigned int count);
这将在传递的地址(指向屏障对象的指针在屏障中)创建一个屏障对象,其中的属性由attr指定(我们将使用NULL来获取默认值)。必须调用pthread_barrier_wait()的线程数 在count中传递。
一旦创建了障碍,我们就需要每个线程调用pthread_barrier_wait() 来指示它已经完成:
#include <pthread.h> int pthread_barrier_wait(pthread_barrier_t * barrier);
当线程调用pthread_barrier_wait()时,它将阻塞,直到在pthread_barrier_init()中初始指定的线程数调用pthread_barrier_wait()(也被阻塞)。当正确的线程数调用pthread_barrier_wait()时,所有这些线程将 “同时”解除阻塞。
这里有一个例子:
/* * barrier1.c */ #include <stdio.h> #include <time.h> #include <pthread.h> #include <sys/neutrino.h> pthread_barrier_t barrier; // the barrier synchronization object void * thread1 (void *not_used) { time_t now; char buf [27]; time (&now); printf ("thread1 starting at %s", ctime_r (&now, buf)); // do the computation // let's just do a sleep here... sleep (20); pthread_barrier_wait (&barrier); // after this point, all three threads have completed. time (&now); printf ("barrier in thread1() done at %s", ctime_r (&now, buf)); } void * thread2 (void *not_used) { time_t now; char buf [27]; time (&now); printf ("thread2 starting at %s", ctime_r (&now, buf)); // do the computation // let's just do a sleep here... sleep (40); pthread_barrier_wait (&barrier); // after this point, all three threads have completed. time (&now); printf ("barrier in thread2() done at %s", ctime_r (&now, buf)); } main () // ignore arguments { time_t now; char buf [27]; // create a barrier object with a count of 3 pthread_barrier_init (&barrier, NULL, 3); // start up two threads, thread1 and thread2 pthread_create (NULL, NULL, thread1, NULL); pthread_create (NULL, NULL, thread2, NULL); // at this point, thread1 and thread2 are running // now wait for completion time (&now); printf ("main () waiting for barrier at %s", ctime_r (&now, buf)); pthread_barrier_wait (&barrier); // after this point, all three threads have completed. time (&now); printf ("barrier in main () done at %s", ctime_r (&now, buf)); }
主线程创建了障碍对象,并且在它“穿越”之前应该同步多少线程(包括自身!)的初始化 计数。在我们的示例中,这是一个计数3 - 一个为主()线程,一个用于thread1(),一个用于thread2()。然后 ,像之前一样启动图形计算线程(我们这里的thread1()和thread2())。为了说明,而不是显示图形计算的源,我们只是停留在睡眠(20); 和睡眠(40); 导致延迟,好像计算正在发生。为了同步,主线程简单地阻挡在屏障上,
如前所述,使用 pthread_join(),工作线程完成并停止,以使主线程与它们同步。但有了屏障,线程仍然活着。事实上,他们刚刚从pthread_barrier_wait()解除阻塞,当所有已经完成。这里介绍的皱纹是你应该准备做这些线程的事情!在我们的图形示例中,没有任何东西可以做(像我们写的)。在现实生活中,您可能希望开始下一帧计算。
单个CPU上的多个线程
假设我们稍微修改示例,以便我们可以说明为什么有时也是一个好主意,即使在单CPU系统上有多个线程。
在该修改的示例中,网络上的一个节点负责计算光栅线(与上面的图形示例相同)。然而,当计算线时,其数据应当通过网络发送到另一个节点,该节点将执行显示功能。这里是我们修改的main()(从原来的例子,没有线程):
int main(int argc,char ** argv) { int x1; ... //执行初始化 (x1 = 0; x1 <num_x_lines; x1 ++){ do_one_line(x1); //“C”在我们的图中,下面的 tx_one_line_wait_ack(x1); //下图中的“X”和“W” } }
你会注意到,我们已经删除了显示部分,而是添加了一个tx_one_line_wait_ack()函数。让我们进一步假设我们正在处理一个相当慢的网络,但是CPU不真正涉及传输方面 - 它将数据激发到某些硬件,然后担心发送它。该tx_one_line_wait_ack()使用位CPU的数据到硬件,但使用没有CPU,而它的等待远端的确认。
下面是一个显示CPU使用情况的图表(我们使用“C”表示图形计算部分,“X” 表示发送部分,“W”表示等待来自远端的确认):
序列化,单CPU。
等一下!我们正在浪费宝贵的时间等待硬件做它的事情!
如果我们做这个多线程,我们应该能够更好地利用我们的CPU,对吧?
多线程,单CPU。
这是更好的,因为现在,即使第二个线程花了一点时间等待,我们减少了计算所需的总时间。
如果我们的时间是T 计算, T tx 传输,T 等待让硬件做它的事情,在第一种情况下,我们的总运行时间将是:
(T 计算 + T tx + T wait )× num_x_lines
而使用两个线程
(T compute + T tx )× num_x_lines + T wait
其更短
T wait ×( num_x_lines - 1)
假设当然,这的牛逼等待 ≤ Ť 计算。
![]() | 注意,我们最终将受制于:
T compute + T tx × num_x_lines 因为我们必须至少进行一次完整的计算,我们必须将数据传输出硬件 - 而我们可以使用多线程来覆盖计算周期,我们只有一个硬件资源用于传输。 |
现在,如果我们创建了一个四线程版本,并在一个具有4个CPU的SMP系统上运行它,我们最终会得到这样的东西:
四线程,四个CPU。
请注意,四个CPU中的每一个如何未充分利用(如“利用率” 图中的空矩形所示)。上图中有两个有趣的区域。当四个线程启动时,它们各自计算。不幸的是,当线程完成每个计算时,它们正在争夺发送硬件(图中的“X”部分是偏移的 - 一次只有一个传输正在进行中)。这给我们一个小的异常在启动部分。一旦线程经过这个阶段,它们自然与发送硬件同步,因为发送时间远小于计算周期的1/4。在开始忽略小异常,该系统的特征在于公式:
(T 计算 + T tx + T wait )× num_x_lines / num_cpus
这个公式表明,在四个CPU上使用四个线程将比我们开始使用的单线程模型快大约4倍。
通过结合我们从简单的多线程单处理器版本中学到的知识,我们理想地希望拥有比CPU多的线程,以便额外的线程可以 从发送确认等待(和发送时隙)“吸收”空闲CPU时间争用等待)。在这种情况下,我们会有这样的:
八线程,四个CPU。
此图假设有几件事:
- 线程5,6,7和8被绑定到处理器1,2,3和4(为了简化)
- 一旦发送开始,它以比计算更高的优先级这样做
- 发送是不可中断的操作
从图中注意到,即使我们现在的线程数是CPU的两倍,我们仍然会遇到CPU使用不足的地方。在图中,有三个这样的地方,其中CPU “停滞” ; 这些都由各个CPU利用率条形图中的数字表示:
- 线程1正在等待确认(“W”状态),而线程5已完成一个计算并等待发送器。
- 线程2和线程6都在等待确认。
- 线程3正在等待确认,而线程7已完成计算并等待发送器。
这个例子也是一个重要的教训 - 你不能只是继续添加CPUs希望,事情会不断变得更快。有限制因素。在一些情况下,这些限制因素简单地由多CPU主板的设计来控制 - 当许多CPU尝试访问相同的存储器区域时,发生多少存储器和设备争用。在我们的例子中,请注意,“TX Slot Utilization”条形图开始变满了。如果我们添加了足够的CPU,他们最终会遇到问题,因为他们的线程将被阻塞,等待传输。
在任何情况下,通过使用“soaker”线程来“吸收”备用CPU,我们现在具有更好的CPU利用率。这种利用方法:
(T 计算 + T tx )× num_x_lines / num_cpus
在计算本身,我们只受限于我们拥有的CPU数量; 我们不会让任何处理器等待确认。(很明显,这是理想的情况,正如你在图中看到的,有几次我们周期性地闲置一个CPU。另外,如上所述,
T compute + T tx × num_x_lines
是我们对于我们可以走多快的极限。
使用SMP时需要注意的事项
虽然一般来说,你可以简单地“忽略”无论你是运行在SMP架构还是单个处理器,有一些东西会 咬你。不幸的是,他们可能是这样的低概率事件,他们不会出现在开发过程中,而是在测试,演示或最糟糕的:在现场。花点时间现在编程防御方案将在路上缓解问题。
这里是你将要在SMP系统上运行的事情的种类:
- 线程真的可以并且做并行运行 - 依赖于FIFO调度或优先级同步的事情是一个没有。
- 线程和中断服务程序(ISR),也不要同时运行-这不仅意味着你将不得不保护线程从ISR,但你也必须保护从线程ISR。更多详细信息,请参见中断章节。
- 一些操作,你希望原子不是,这取决于操作和处理器。此列表中的值得注意的操作是读取 - 修改 - 写入循环(例如,++,-, | =,&=等)。请参阅包含文件 <atomic.h>以进行替换。(请注意,这不是纯粹的SMP问题;大多数RISC处理器不一定以原子方式执行上述代码。)
在独立情况下的线程
如上面在“其中线程是好主意” 部分中所讨论的,线程还可以在其中利用共享数据结构发生多个独立处理算法的情况下使用。严格来说,你可以有多个进程 (每个进程有一个线程)显式地共享数据,在某些情况下,在一个进程中拥有多个线程是更方便的。让我们来看看为什么,在这种情况下你会使用线程。
对于我们的例子,我们将演进一个标准的输入/过程/输出模型。在最一般的意义上,模型的一部分负责从某处获取输入,另一部分负责处理输入以产生某种形式的输出(或控制),第三部分负责将输出馈送到某处。
多个进程
让我们首先从多进程,每进程一个线程的角度了解情况。在这种情况下,我们有三个过程,实际上是一个输入过程,一个“处理” 过程和一个输出过程:
系统1:多操作,多进程。
这是最高度抽象的形式,也是最“松散耦合”的 。“输入”过程与“处理”或“输出”过程没有真正的“绑定” - 它只是负责收集输入,它到下一阶段(“处理”阶段)。我们可以说“处理”和“输出” 过程的同样的东西- 他们也没有相互的真正的约束。在该示例中,还假设通信路径(即,输入到处理和处理到输出数据流)通过某些连接的协议(例如,管道,POSIX消息队列,
具有共享内存的多进程
根据数据流的量,我们可能希望优化通信路径。这样做的最简单的方法是使三个过程之间的耦合更紧密。代替使用通用连接协议,我们现在选择一个共享内存方案(在图中,粗线表示数据流;细线,控制流):
系统2:多个操作,进程之间的共享内存。
在这个方案中,我们已经收紧了耦合,导致更快更有效的数据流。我们仍然可以使用“通用”连接协议来传输 “控制”信息 - 我们不希望控制信息消耗大量带宽。
多线程
最紧耦合的系统由以下方案表示:
系统3:多操作,多线程。
这里我们看到一个有三个线程的进程。这三个线程隐式共享数据区。此外,控制信息可以如在前面的示例中实现,或者也可以通过一些线程同步原语来实现(我们已经看到互斥体,障碍和信号量;我们将在短时间内看到其他的)。
比较
现在,让我们比较使用各种类别的三个方法,我们还将描述一些权衡。
使用系统1,我们看到最松耦合。这具有的优点是,三个进程中的每一个可以容易地(即,通过命令行,而不是重新编译/重新设计)替换为不同的模块。这自然是因为“模块化单元”是整个模块本身。系统1也是唯一可以分布在Neutrino网络中的多个节点中的系统。由于通信路径是通过某些连接协议抽象的,因此很容易看出三个进程可以在网络中的任何机器上执行。这可能是您设计的一个非常强大的可扩展性因素 - 您可能需要您的系统扩展到数百台机器分布在地理上(或以其他方式,
然而,一旦我们提交到共享内存区域,我们就失去了通过网络分发的能力。Neutrino不支持网络分布式共享内存对象。所以在系统2中,我们有效地限制了在同一个盒子上运行所有三个进程。我们没有失去轻松删除或更改组件的能力,因为我们仍然有单独的进程,可以从命令行控制。但是我们添加了所有可移除组件需要符合共享内存模型的约束。
在系统3中,我们失去了所有上述能力。我们绝对不能在多个节点上从一个进程运行不同的线程(我们可以在SMP系统中的不同处理器上运行它们)。而我们已经失去了可配置性方面 - 现在我们需要一个明确的机制来定义我们要使用的“输入”, “处理” 或“输出”算法(我们可以使用共享对象。)
那么为什么我会设计我的系统有多个线程像系统3?为什么不去采用最灵活的系统1?
好吧,即使系统3是最不灵活的,它是最有可能会是最快的。对于不同进程中的线程,没有线程到线程上下文切换,我不必显式地设置内存共享,我不必使用抽象的 同步方法,如管道,POSIX消息队列或消息传递传递数据或控制信息 - 我可以使用基本的内核级线程同步原语。另一个优点是,当一个进程(具有三个线程)描述的系统开始时,我知道我需要的一切已经从存储介质加载(即,我不会在后面发现“糟糕,处理驱动程序从磁盘丢失!“)。
总结:知道什么是权衡,并使用什么对你的设计有效。
更多同步
我们已经看到:
现在让我们来讨论同步:
读/写锁
读取器和写入器锁用于其名称所暗示的意义:多个读取器可以使用资源,没有写入器,或者一个写入器可以使用没有其他写入器或读取器的资源。
这种情况经常发生,足以保证专用于该目的的特殊类型的同步原语。
通常你会有一个数据结构由一个线程共享。显然,一次只能有一个线程写入数据结构。如果多个线程正在写入,则线程可能会覆盖彼此的数据。为了防止这种情况发生,写线程将以排他的方式获得“rwlock” (读取器/写入器锁),这意味着它和它只能访问数据结构。注意,访问的排他性严格由自愿手段控制。这取决于你,系统设计者,确保所有触摸数据区域的线程通过使用rwlocks同步。
相反的情况发生在读者。由于读取数据区是一种非破坏性操作,所以任何数量的线程都可以读取数据(即使它是另一个线程正在读取的同一数据块)。这里隐含的一点是,当任何线程或线程正在从它读取数据时,没有线程可以写入数据区。否则,读取线程可能通过读取数据的一部分,被写线程抢占,然后当读取线程恢复时,继续读取数据,但是从更新的数据“更新”而被混淆。然后将导致数据不一致。
让我们看看你将使用rwlocks的调用。
前两个调用用于初始化rwlock的库的内部存储区:
int pthread_rwlock_init(pthread_rwlock_t * lock, const pthread_rwlockattr_t * attr); int pthread_rwlock_destroy(pthread_rwlock_t * lock);
该 调用pthread_rwlock_init() 函数将锁定 参数(类型pthread_rwlock_t),并对其进行初始化基于指定的属性ATTR。我们只是要使用的属性NULL在我们的例子中,这意味着,“使用默认值”。 有关属性的详细信息,请参阅该库参考页 pthread_rwlockattr_init()会, pthread_rwlockattr_destroy() ,pthread_rwlockattr_getpshared()会和 pthread_rwlockattr_setpshared()。
当使用rwlock时,通常调用 pthread_rwlock_destroy() 来销毁锁,这会使它失效。你永远不要使用被销毁或尚未初始化的锁。
接下来,我们需要获取适当类型的锁。如上所述,基本上存在两种锁定模式:读取器将想要“非排他性”访问,并且写入器将想要“独占”访问。为了保持名称简单,函数以锁的用户命名:
int pthread_rwlock_rdlock(pthread_rwlock_t * lock); int pthread_rwlock_tryrdlock(pthread_rwlock_t * lock); int pthread_rwlock_wrlock(pthread_rwlock_t * lock); int pthread_rwlock_trywrlock(pthread_rwlock_t * lock);
有四个功能,而不是你可能预期的两个。在“预期”功能 pthread_rwlock_rdlock() 和 pthread_rwlock_wrlock() ,分别由读者和作者,使用。这些是阻塞调用 - 如果锁对选定的操作不可用,线程将阻塞。当锁在适当的模式下可用时,线程将解除阻塞。因为线程从调用中解除阻塞,所以现在可以假定它可以安全地访问受锁保护的资源。
然而,有时候,一个线程不会想阻止,而是会想看看它是否能获得锁。这就是“尝试”版本是为。重要的是注意,“try”版本将获得锁,如果他们可以,但如果他们不能,那么他们不会阻止,而是只返回一个错误指示。他们得到锁的原因,如果他们可以是简单的。假设线程想要获取读取的锁,但是不想等待,以防它不可用。线程调用pthread_rwlock_tryrdlock(),并被告知它可以有锁。如果pthread_rwlock_tryrdlock() 没有分配锁,那么坏事情可能发生 - 另一个线程可以抢占被告知要继续进行的线程,并且第二线程可以以不兼容的方式锁定资源。因为第一个线程实际上没有给锁,当第一个线程实际获取锁(因为它被告知它可以),它将使用pthread_rwlock_rdlock(),现在它会阻塞,因为资源不再在该模式下可用。所以,如果我们没有锁定,如果我们可以,调用“尝试” 版本的线程仍然可能仍然可能阻塞!当第一个线程实际获取锁(因为它被告知可能),它将使用pthread_rwlock_rdlock(),现在它会阻塞,因为资源在该模式下不再可用。所以,如果我们没有锁定,如果我们可以,调用“尝试”版本的线程仍然可能仍然可能阻塞!当第一个线程实际获取锁(因为它被告知可能),它将使用pthread_rwlock_rdlock(),现在它会阻塞,因为资源在该模式下不再可用。所以,如果我们没有锁定,如果我们可以,调用“尝试”版本的线程仍然可能仍然可能阻塞!
最后,不管锁的使用方式,我们需要一些释放锁的方式:
int pthread_rwlock_unlock(pthread_rwlock_t * lock);
一旦线程完成了对资源所做的任何操作,它将通过调用pthread_rwlock_unlock()释放锁 。如果锁现在在对应于另一等待线程所请求的模式的模式中可用,则该线程将被准备就绪。
请注意,我们不能仅使用互斥体实现此形式的同步。互斥体作为单线程代理,这对于书写情况(您希望只有一个线程一次使用资源)是正常的,但在读取情况下将是平坦的,因为只允许一个读取器。也不能使用信号量,因为没有办法区分两种访问模式 - 一个信号量将允许多个读者,但是如果一个作者要获取信号量,就信号量而言,这没有什么不同从一个读者获得它,现在你会有多个读者和一个或多个 作家的丑陋的情况!
睡眠锁
在多线程程序中发生的另一个常见情况是线程需要等待直到“发生了什么事”。 这个“东西”可以是任何东西!可能的事实是,数据现在可以从设备获得,或者传送带现在已经移动到适当位置,或者数据已经被提交到盘或任何其他。在这里抛出的另一个扭曲是几个线程可能需要等待给定的事件。
要完成这个,我们将使用一个条件变量(我们将看到下面)或更简单的“睡眠”锁。
要使用睡眠锁,您实际上需要执行几个操作。让我们先看看调用,然后看看如何使用锁。
int pthread_sleepon_lock(void); int pthread_sleepon_unlock(void); int pthread_sleepon_broadcast(void * addr); int pthread_sleepon_signal(void * addr); int pthread_sleepon_wait(void * addr);
![]() | 不要被前缀pthread_欺骗,认为这些是POSIX函数 - 他们不是。 |
如上所述,线程需要等待某事发生。上面函数列表中最明显的选择是 pthread_sleepon_wait()。但首先,线程需要检查它是否真的没有等待。让我们设置一个例子。一个线程是从一些硬件获取数据的生产者线程。另一个线程是一个消费者线程,它对刚刚到达的数据进行某种形式的处理。让我们先看看消费者:
volatile int data_ready = 0; consumer() { while(1){ while(!data_ready){ // WAIT } // process data } }
消费者坐在其主要处理循环中(while(1)); 它将永远做它的工作。它做的第一件事是查看data_ready标志。如果此标志为0,则表示没有数据就绪。因此,消费者应该等待。不知何故,生产者将唤醒它,消费者应该重新检查其data_ready标志。让我们说,这正是发生了什么,消费者看着标志,并决定它是1,意味着数据现在可用。消费者离开并处理数据,然后去看是否有更多的工作要做,等等。
我们将在这里遇到一个问题。消费者如何以与生产者同步的方式重置data_ready标志?显然,我们需要某种形式的对标志的独占访问,以便只有一个线程在给定的时间修改它。在这种情况下使用的方法是使用互斥体构建的,但它是一个隐藏在sleepon库实现中的互斥体,因此我们只能通过两个函数访问它: pthread_sleepon_lock() 和pthread_sleepon_unlock()。让我们修改我们的消费者:
consumer() { while(1){ pthread_sleepon_lock(); while(!data_ready){ // WAIT } // process data data_ready = 0; pthread_sleepon_unlock(); } }
现在我们添加了锁,并解开消费者的操作。这意味着消费者现在可以可靠地测试data_ready 标志,没有竞争条件,并且还可靠地设置标志。
好吧,太好了。现在关于“等待”调用呢?正如我们之前提到的,它实际上是pthread_sleepon_wait()调用。这里是第二个while循环:
while(!data_ready){ pthread_sleepon_wait(&data_ready); }}
该pthread_sleepon_wait()实际上做了三件不同的步骤!
- 解锁sleepon库互斥。
- 执行等待操作。
- 重新锁定sleepon库mutex。
它必须解锁和锁定sleepon库的mutex的原因很简单 - 因为互斥的整个想法是确保互斥到data_ready 变量,这意味着我们要锁定生产者触摸 data_ready变量,而我们'重新测试它。但是,如果我们不做操作的解锁部分,生产者将永远不能设置告诉我们数据确实可用!重新锁定操作完全是为了方便; 这种方式,pthread_sleepon_wait()的用户 不必担心锁的状态,当它醒来。
让我们切换到生产者端,看看它如何使用sleepon库。以下是完整实施:
生成器() { while(1){ //等待从硬件 中断这里... pthread_sleepon_lock(); data_ready = 1; pthread_sleepon_signal(&data_ready); pthread_sleepon_unlock(); } }
正如你所看到的,生产者锁定互斥体,以便它可以独占访问data_ready变量,以设置它。
让我们详细研究会发生什么。我们已经将消费者和生产者状态标识为:
State | Meaning |
---|---|
CONDVAR | Waiting for the underlying condition variable associated with the sleepon |
MUTEX | Waiting for a mutex |
READY | Capable of using, or already using, the CPU |
INTERRUPT | Waiting for an interrupt from the hardware |
Action | Mutex owner | Consumer state | Producer state |
---|---|---|---|
Consumer locks mutex | Consumer | READY | INTERRUPT |
Consumer examines data_ready | Consumer | READY | INTERRUPT |
Consumer calls pthread_sleepon_wait() | Consumer | READY | INTERRUPT |
pthread_sleepon_wait() unlocks mutex | Free | READY | INTERRUPT |
pthread_sleepon_wait() blocks | Free | CONDVAR | INTERRUPT |
Time passes | Free | CONDVAR | INTERRUPT |
Hardware generates data | Free | CONDVAR | READY |
Producer locks mutex | Producer | CONDVAR | READY |
Producer sets data_ready | Producer | CONDVAR | READY |
Producer calls pthread_sleepon_signal() | Producer | CONDVAR | READY |
Consumer wakes up, pthread_sleepon_wait() tries to lock mutex | Producer | MUTEX | READY |
Producer releases mutex | Free | MUTEX | READY |
Consumer gets mutex | Consumer | READY | READY |
Consumer processes data | Consumer | READY | READY |
Producer waits for more data | Consumer | READY | INTERRUPT |
Time passes (consumer processing) | Consumer | READY | INTERRUPT |
Consumer finishes processing, unlocks mutex | Free | READY | INTERRUPT |
Consumer loops back to top, locks mutex | Consumer | READY | INTERRUPT |
State | Meaning |
---|---|
CONDVAR | Waiting for the underlying condition variable associated with the sleepon |
MUTEX | Waiting for a mutex |
READY | Capable of using, or already using, the CPU |
INTERRUPT | Waiting for an interrupt from the hardware |
Action | Mutex owner | Consumer state | Producer state |
---|---|---|---|
Consumer locks mutex | Consumer | READY | INTERRUPT |
Consumer examines data_ready | Consumer | READY | INTERRUPT |
Consumer calls pthread_sleepon_wait() | Consumer | READY | INTERRUPT |
pthread_sleepon_wait() unlocks mutex | Free | READY | INTERRUPT |
pthread_sleepon_wait() blocks | Free | CONDVAR | INTERRUPT |
Time passes | Free | CONDVAR | INTERRUPT |
Hardware generates data | Free | CONDVAR | READY |
Producer locks mutex | Producer | CONDVAR | READY |
Producer sets data_ready | Producer | CONDVAR | READY |
Producer calls pthread_sleepon_signal() | Producer | CONDVAR | READY |
Consumer wakes up, pthread_sleepon_wait() tries to lock mutex | Producer | MUTEX | READY |
Producer releases mutex | Free | MUTEX | READY |
Consumer gets mutex | Consumer | READY | READY |
Consumer processes data | Consumer | READY | READY |
Producer waits for more data | Consumer | READY | INTERRUPT |
Time passes (consumer processing) | Consumer | READY | INTERRUPT |
Consumer finishes processing, unlocks mutex | Free | READY | INTERRUPT |
Consumer loops back to top, locks mutex | Consumer | READY | INTERRUPT |
表中的最后一个条目是第一个条目的重复 - 我们已经完成了一个完整的周期。
data_ready变量的用途是什么?它实际上有两个目的:
- 它是消费者和制造商之间的状态标志,指示系统的状态。如果设置为1,则表示数据可用于处理; 如果它设置为0,这意味着没有数据可用,并且消费者应该阻止。
- 它作为“其中sleepon同步发生的地方。” 更正式地,该地址的DATA_READY用作唯一标识符,其用作sleepon锁会合对象。我们可以很容易地使用“ (void *)12345 ”而不是“ &data_ready ” - 只要标识符是唯一的,并且使用一致,sleepon库真的不在乎。实际上,在进程中使用变量的地址是生成进程唯一编号的有保证的方法 - 毕竟,进程中没有两个变量具有相同的地址!
我们将讨论“ pthread_sleepon_signal() 和pthread_sleepon_broadcast() ”之间的区别到下一个条件变量的讨论。
条件变量
条件变量(或“condvars”)与我们刚刚看到的sleepon锁非常相似。事实上,sleepon锁都是建立在条件变量的顶部,这就是为什么我们有状态CONDVAR在用于sleepon示例的说明表。它需要 重复pthread_cond_wait() 函数释放互斥体,等待,然后重新获取互斥体,就像 pthread_sleepon_wait() 函数一样。
让我们跳过这些初步,并从sleepon部分重做生产者和消费者的例子,改为使用condvars。然后我们将讨论呼叫。
/ * * cp1.c * / #include <stdio.h> #include <pthread.h> int data_ready = 0; pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t condvar = PTHREAD_COND_INITIALIZER; void * consumer(void * notused) { printf(“In consumer thread ... \ n”); while(1){ pthread_mutex_lock(&mutex); while(!data_ready){ pthread_cond_wait(&condvar,&mutex); } // process data printf(“consumer:got data from producer \ n”); data_ready = 0; pthread_cond_signal(&condvar); pthread_mutex_unlock(&mutex); } } void * producer(void * notused) { printf(“In producer thread ... \ n”); while(1){ //从硬件获取数据 //我们将使用sleep(1) sleep(1)模拟这个数据。 printf(“producer:got data from h / w \ n”); pthread_mutex_lock(&mutex); while(data_ready){ pthread_cond_wait(&condvar,&mutex); } data_ready = 1; pthread_cond_signal(&condvar); pthread_mutex_unlock(&mutex); } } main() { printf(“Starting consumer / producer example ... \ n”); //创建生产者和消费者线程 pthread_create(NULL,NULL,producer,NULL); pthread_create(NULL,NULL,consumer,NULL); //让线程运行一下 (20); }}
与我们刚才看到的sleepon示例非常相似,有一些变体(我们还添加了一些printf()函数和一个main(),以便程序运行!马上,我们看到的第一件事是一种新的数据类型: pthread_cond_t。这只是条件变量的声明; 我们称之为我们的 condvar。
接下来我们注意到消费者的结构与以前的sleepon例子中的消费者的结构相同。我们 用标准互斥体版本(pthread_mutex_lock() 和 pthread_mutex_unlock())替换了pthread_sleepon_lock()和pthread_sleepon_unlock()。所述pthread_sleepon_wait()用置换调用pthread_cond_wait() 。主要的区别是,sleepon库有一个mutex埋在它内部,而当我们使用condvars,我们明确地传递mutex。我们通过这种方式获得更多的灵活性。
最后,我们注意到,我们有 pthread_cond_signal(), 而不是pthread_sleepon_signal() (再次明确传递互斥体)。
信号与广播
在sleepon部分,我们承诺讨论pthread_sleepon_signal() 和 pthread_sleepon_broadcast() 函数之间的 区别。同样,我们将讨论两个condvar函数pthread_cond_signal()和pthread_cond_broadcast()之间的区别。
简短的故事是这样的:“信号”版本将只唤醒一个线程。因此,如果在“等待”函数中阻塞了多个线程,并且线程执行了“信号”,则只有一个线程会被唤醒。哪一个?最高优先级。如果存在两个或更多个处于相同优先级,则唤醒的顺序是不确定的。使用“广播”版本,所有被阻止的线程将被唤醒。
唤醒所有线程可能是浪费。另一方面,唤醒只有一个(有效随机)线程可能看起来很草率。
因此,我们应该看看在哪里使用一个在另一个有意义。显然,如果你只有一个线程等待,就像我们在任何一个版本的消费程序中一样,一个“信号”将会很好 - 一个线程将唤醒,猜测什么,它将是当前唯一的线程。
在多线程的情况下,我们必须问:“这些线程为什么会等待?” 通常有两个可能的答案:
- 所有线程被认为是等效的,并且正在有效地形成可用于处理某种形式的请求的可用线程的“池”。
要么:
- 线程都是唯一的,并且每个线程都等待发生非常特定的条件。
在第一种情况下,我们可以想象所有的线程都有可能看起来像下面的代码:
/ * * cv1.c * / #include <stdio.h> #include <pthread.h> pthread_mutex_t mutex_data = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cv_data = PTHREAD_COND_INITIALIZER; int数据; thread1() { for(;;){ pthread_mutex_lock(&mutex_data); while(data == 0){ pthread_cond_wait(&cv_data,&mutex_data); } // do something pthread_mutex_unlock(&mutex_data); } } // thread2,thread3等具有相同的代码。
在这种情况下,哪个线程获取数据真的没有关系,只要其中一个获得它并且做一些事情。
但是,如果你有这样的东西,事情有点不同:
/ * * cv2.c * / #include <stdio.h> #include <pthread.h> pthread_mutex_t mutex_xy = PTHREAD_MUTEX_INITIALIZER; pthread_cond_t cv_xy = PTHREAD_COND_INITIALIZER; int x,y; int isprime(int); thread1() { for(;;){ pthread_mutex_lock(&mutex_xy); while((x> 7)&&(y!= 15)){ pthread_cond_wait(&cv_xy,&mutex_xy); } // do something pthread_mutex_unlock(&mutex_xy); } } thread2() { for(;;){ pthread_mutex_lock(&mutex_xy); 而(! isprime(x)){ pthread_cond_wait(&cv_xy,&mutex_xy); } // do something pthread_mutex_unlock(&mutex_xy); } } thread3() { for(;;){ pthread_mutex_lock(&mutex_xy); while(x!= y){ pthread_cond_wait(&cv_xy,&mutex_xy); } // do something pthread_mutex_unlock(&mutex_xy); } } } } thread3(){ for(;;){ pthread_mutex_lock(&mutex_xy); while(x!= y){ pthread_cond_wait(&cv_xy,&mutex_xy); } // do something pthread_mutex_unlock(&mutex_xy); } } } } thread3(){ for(;;){ pthread_mutex_lock(&mutex_xy); while(x!= y){ pthread_cond_wait(&cv_xy,&mutex_xy); } // do something pthread_mutex_unlock(&mutex_xy); } }
在这些情况下,唤醒一个线程是不会削减它!我们必须唤醒所有三个线程,并让每个线程检查其谓词是否已满足。
这很好地反映了我们上面的(问题第二种情况“为什么这些线程等待?” )。由于线程都在等待上不同的条件(线程1() 正在等待点¯x为小于或等于7或Ý 为15,线程2()正在等待点¯x 是一个素数,和thread3()正在等待对于x等于y),我们别无选择,只能唤醒他们。
睡眠与condvars
sleepons相对于condvars有一个主要优势。假设您要同步许多对象。对于condvars,你通常每个对象关联一个condvar。因此,如果你有M个对象,你很可能有M个 condvars。使用sleepons,底层的condvars(在其上面实现sleepons)被动态地分配为线程等待特定对象。因此,使用具有M个对象和N个 线程的sleepons 被阻塞,你将有(最多)N个 condvars(而不是M)。
然而,condvars比sleepons更灵活,因为:
- Sleepons建立在condvars之上。
- 睡眠有互斥体埋在图书馆; condvars允许你明确指定它。
第一点可能只是被视为是争论性的。:-) 第二点,但是,是重要的。当互斥体被埋在库中时,这意味着每个进程只能 有一个,而不管该进程中的线程数,或不同“集合”的数据变量的数量。这可能是一个非常有限的因素,特别是当你认为你必须使用一个和唯一的互斥访问任何和所有的数据变量,任何进程中的线程需要触摸!
一个更好的设计是使用多个互斥体,一个用于每个数据集,并根据需要将它们与条件变量显式组合。这种方法的真正的力量和危险是,绝对没有 编译时间或运行时检查,以确保你:
- 在操作变量之前锁定了互斥体
- 正在为特定变量使用正确的互斥体
- 正在使用具有适当互斥体和变量的正确condvar
解决这些问题最简单的方法是进行良好的设计和设计审查,也可以从面向对象编程中借用技术(例如将互斥体包含在数据结构中,具有访问数据结构的例程等)。当然,一个或两个应用程序的多少不仅取决于您的个人风格,而且还取决于性能要求。
使用condvars时要记住的要点是:
- 互斥体将用于测试和访问变量。
- 该交汇点将用作交会点。
这里是图片:
一对一互斥和condvar关联。
一个有趣的注释。既然是不检查,你可以做这样的事情联想一组与互斥变量“ABC”和另一组变量与互斥“DEF”,而关联都 套有condvar变量“ABCDEF”
多对一互斥和condvar关联。
这实际上是非常有用的。由于互斥量总是用于“访问和测试”,这意味着每当我想查看特定变量时,我必须选择正确的互斥体。公平 - 如果我检查变量“C”,我显然需要锁定互斥量“MutexABC” 。如果我改变变量“E”怎么办?好吧,在我改变它之前,我不得不获取互斥体“MutexDEF”。 然后我改变它,并命中condvar “CondvarABCDEF”告诉别人的改变。不久之后,我会释放互斥体。
现在,考虑会发生什么。突然,我有一堆线程已经在等待“CondvarABCDEF” ,现在醒来(从他们的pthread_cond_wait())。等待函数立即尝试重新获取互斥体。这里的关键点是有两个互斥体要获取。这意味着在SMP系统上,可以运行两个并发的线程流,每个线程都使用独立的互斥体来检查它认为是独立变量的情况。酷,嗯?
额外的Neutrino服务
Neutrino让你做其他优雅的东西。POSIX说,互斥必须在同一进程的线程之间操作,并让一个符合的实现扩展。Neutrino通过允许互斥体在不同进程中的线程之间操作来扩展它。要理解为什么这样做,回忆一下,被认为是“操作系统”的两个部分- 内核,处理调度,以及进程管理器,它担心内存保护和 “进程”(除其他外) 。互斥体实际上只是在线程之间使用的同步对象。由于内核只关心线程,它真的不在乎线程在不同的进程中操作 - 这是进程管理器的问题。
因此,如果你在两个进程之间设置了一个共享内存区域,并且在该共享内存中初始化了一个互斥锁,那么没有什么能阻止你通过互斥锁同步这两个(或更多)进程中的多个线程。同样的pthread_mutex_lock()和pthread_mutex_unlock() 函数仍然可以工作。
线程池
Neutrino添加的另一件事是线程池的概念。你会经常注意到你的程序中你想要能够运行一定数量的线程,但你也希望能够在一定限制内控制这些线程的行为。例如,在服务器中,您可以决定最初只阻塞一个线程,等待来自客户端的消息。当该线程获得消息并且不在处理请求时,您可以决定创建另一个线程是个好主意,以便在另一个请求到达时阻塞等待。然后,第二个线程可用于处理该请求。等等。过了一会儿,当请求得到服务时,你现在有大量的线程在等待进一步的请求。为了节约资源,
这实际上是一个常见的操作,Neutrino提供了一个库来帮助这个。我们将在“ 资源管理器” 一章中再次看到线程池函数 。
对于接下来的讨论,重要的是要意识到线程(在线程池中使用的线程)执行的真正两个不同的操作:
- 阻塞(等待操作)
- 处理操作
阻塞操作通常不消耗CPU。在典型的服务器中,这是线程正在等待消息到达的地方。与处理操作的对比,其中线程可能或可能不在消耗CPU(取决于过程是如何结构化的)。在稍后将讨论的线程池函数中,您将看到我们能够控制阻塞操作中的线程数以及处理操作中的线程数。
Neutrino提供了以下函数来处理线程池:
#include <sys / dispatch.h> thread_pool_t * thread_pool_create(thread_pool_attr_t * attr, unsigned flags); int thread_pool_destroy(thread_pool_t * pool); int thread_pool_start(void * pool); int thread_pool_limits(thread_pool_t * pool, int lowater, int hiwater, int maximum, int increment, unsigned flags);
从提供的函数中可以看出,您首先使用thread_pool_create()创建线程池定义 ,然后通过thread_pool_start()启动线程池 。当你完成线程池,你可以使用 thread_pool_destroy() 来清理自己。注意,你可能永远不会调用thread_pool_destroy(),因为在程序是运行“forever” 的 服务器的情况下。thread_pool_limits() 函数用于指定线程池行为和调整线程池的属性,以及 thread_pool_control ) 函数是thread_pool_limits() 函数的一个方便的包装器 。
所以,第一个函数看是thread_pool_create()。它需要两个参数,attr和flags。该ATTR是一个属性结构,它定义线程池的操作特性(从<SYS / dispatch.h> ):
typedef struct _thread_pool_attr { //线程池函数和句柄 THREAD_POOL_HANDLE_T * 句柄 ; THREAD_POOL_PARAM_T *(* block_func)(THREAD_POOL_PARAM_T * ctp); void (* unblock_func)(THREAD_POOL_PARAM_T * ctp); int (* handler_func)(THREAD_POOL_PARAM_T * ctp); THREAD_POOL_PARAM_T *(* context_alloc)(THREAD_POOL_HANDLE_T * handle); void (* context_free)(THREAD_POOL_PARAM_T * ctp); //线程池参数 pthread_attr_t * attr ; 无符号短 lo_water ; 无符号短 增量 ; unsigned short hi_water ; 无符号短 最大值 ; } thread_pool_attr_t;
我已经把thread_pool_attr_t类型分成两个部分,一个包含线程池中的线程的函数和句柄,另一个包含线程池的操作参数。
控制线程数
让我们先看看“线程池参数”,看看你如何控制将在这个线程池中操作的线程的数量和属性。请记住,我们将讨论“阻塞操作” 和“处理操作”(当我们查看调用函数时,我们将看到这些函数是如何相互关联的)。
下图说明了lo_water,hi_water和maximum参数的关系:
使用线程池时的线程流。
(请注意,“CA”是context_alloc()函数,“CF” 是context_free()函数,“阻塞操作”是 block_func()函数,和“处理动作”是 handler_func() )。
-
attr
- 这是在线程创建期间使用的属性结构。我们已经讨论了上面的这个结构(在 “线程属性结构” )。你会记得,这是控制关于新创建的线程的事情的结构,如优先级,堆栈大小等。 lo_water
- 在阻塞操作中应始终至少有 lo_water 线程。在典型的服务器中,这将是等待接收消息的线程数。如果有少于 lo_water 线程处于阻塞操作(因为,例如,我们刚刚收到一条消息并且已经开始对该消息的处理操作),则根据 increment 参数创建更多的线程。这在图中由标记为“创建线程”的第一步表示 。 增量
- 指示如果阻塞操作线程的计数在 lo_water 下下降,则应同时创建多少线程。在决定如何为此选择值时,您最有可能从 1 开始。这意味着,如果阻塞操作中的线程数落在 lo_water 下,则线程池会再创建一个线程。要微调您为 增量 选择的数字,您可以观察进程的行为,并确定此数字是否需要为除了一个之外的任何数字。例如,如果您注意到您的进程获得 请求的 “突发” ,那么您可能会决定, hi_water
- 表示阻塞操作中应该使用的线程数量的上限。当线程完成它们的处理操作时,它们通常将返回到阻塞操作。然而,线程池库保持当前在阻塞操作中有多少线程,并且如果该数量超过 hi_water ,则线程池库将杀死引起溢出的线程(即,刚刚完成的线程并且即将回到阻塞操作)。这是图所示为在 “分裂” 出的 “加工工序” 块, 其中一条路径到达“阻塞操作” ,另一条路径到达 “CF” 以销毁线程。因此, lo_water 和 hi_water的 组合允许您指定一个范围,指示阻塞操作中应有多少线程。 最大值
- 表示由于线程池库而将同时运行的线程的绝对最大数。例如,如果由于 lo_water 标记的下溢而创建线程,则 最大 参数将限制线程的总数。
控制线程的另一个关键参数是传递给thread_pool_create()函数的flags参数。它可以具有以下值之一:
-
POOL_FLAG_EXIT_SELF
- 该 thread_pool_start() 函数将不会返回,也不会调用线程并入线程池。 POOL_FLAG_USE_SELF
- 该 thread_pool_start() 函数不会返回,但调用线程将被纳入线程池。 0
- 该 thread_pool_start() 函数将返回,按需要创建新的线程。
以上描述可能看起来有点干。让我们看一个例子。
您可以 在示例程序附录中找到tp1.c的完整版本 。这里,我们只关注lo_water,hi_water, increment和线程池控制结构的最大成员:
/ * * tp1.c的一部分 * / #include <sys / dispatch.h> int main() { thread_pool_attr_t tp_attr; void * tpp; ... tp_attr.lo_water = 3; tp_attr.increment = 2; tp_attr.hi_water = 7; tp_attr.maximum = 10; ... tpp = thread_pool_create(&tp_attr,POOL_FLAG_USE_SELF); if(tpp == NULL){ fprintf(stderr, “%s:can not thread_pool_create,errno%s \ n”, progname,strerror(errno)); exit(EXIT_FAILURE); } thread_pool_start(tpp); ... ...
设置成员后,我们调用thread_pool_create()创建一个线程池。这将返回一个指向一个线程池控制结构(TPP),我们核对NULL(这显示一个错误)。最后,我们呼吁thread_pool_start()与TPP 线程池的控制结构。
我已经指定POOL_FLAG_USE_SELF,这意味着调用thread_pool_start()的线程将被视为线程池的可用线程。所以,在这一点上,线程池库中只有一个线程。因为我们有一个lo_water值为3,库立即创建增量线程数(在这种情况下为2)。此时,库中有3个线程,并且所有3个线程都处于阻塞操作中。该lo_water满足条件,因为有至少在阻塞操作线程该号码; 该hi_water满足条件,因为有小于该数字的阻塞操作线程; 最后,
现在,阻塞操作中的一个线程解除阻塞(例如,在服务器应用程序中,接收到消息)。这意味着现在三个线程中的一个不再在阻塞操作中(相反,该线程现在处于处理操作中)。由于阻塞线程的计数小于lo_water,它 会跳过lo_water触发器并使库创建increment (2)线程。所以现在总共有5个线程(在阻塞操作中为4个,在处理操作中为1个)。
更多线程已解除封锁。让我们假设处理操作中的线程都没有完成它们的任何请求。这里有一个表格说明了这一点,从初始状态开始(我们使用“Proc Op” 进行处理操作,而“Blk Op”用于阻塞操作,如我们在上一个图中所做的那样:“Thread flow when using thread pools 。“):
事件 | Proc Op | Blk Op | 总 |
---|---|---|---|
初始 | 0 | 1 | 1 |
lo_water旅行 | 0 | 3 | 3 |
解除封锁 | 1 | 2 | 3 |
lo_water旅行 | 1 | 4 | 5 |
解除封锁 | 2 | 3 | 5 |
解除封锁 | 3 | 2 | 5 |
lo_water旅行 | 3 | 4 | 7 |
解除封锁 | 4 | 3 | 7 |
解除封锁 | 5 | 2 | 7 |
lo_water旅行 | 5 | 4 | 9 |
解除封锁 | 6 | 3 | 9 |
解除封锁 | 7 | 2 | 9 |
lo_water旅行 | 7 | 3 | 10 |
解除封锁 | 8 | 2 | 10 |
解除封锁 | 9 | 1 | 10 |
解除封锁 | 10 | 0 | 10 |
如你所见,库总是检查lo_water变量并一次创建 增量线程,直到达到最大变量的限制(如“总计” 列达到10 时所做的那样- 即使已经创建了线程,计数已经下溢lo_water)。
这意味着在这一点上,在阻塞操作中不再有线程等待。让我们假设线程现在完成他们的请求(从处理操作); 观察hi_water触发器发生的情况:
事件 | Proc Op | Blk Op | 总 |
---|---|---|---|
完成 | 9 | 1 | 10 |
完成 | 8 | 2 | 10 |
完成 | 7 | 3 | 10 |
完成 | 6 | 4 | 10 |
完成 | 5 | 5 | 10 |
完成 | 4 | 6 | 10 |
完成 | 3 | 7 | 10 |
完成 | 2 | 8 | 10 |
hi_water旅行 | 2 | 7 | 9 |
完成 | 1 | 8 | 9 |
hi_water旅行 | 1 | 7 | 8 |
完成 | 0 | 8 | 8 |
hi_water旅行 | 0 | 7 | 7 |
注意在线程的处理完成之前没有什么真正发生,直到我们跳过hi_water 触发器。实现是,一旦线程完成,它查看接收阻塞线程的数量,并决定杀死自己,如果有太多(即,超过hi_water)等待在那一点。关于结构中的lo_water和hi_water限制的好处在于,您可以有效地拥有一个“操作范围” ,其中有足够数量的线程可用,并且您不会不必要地创建和销毁线程。在我们的情况下,在上面表格执行的操作之后,
线程池函数
现在我们对如何控制线程数有一个很好的感觉,让我们把注意力转向线程池属性结构的其他成员(从上面):
//线程池函数和句柄 THREAD_POOL_HANDLE_T * 句柄 ; THREAD_POOL_PARAM_T *(* block_func)(THREAD_POOL_PARAM_T * ctp); void (* unblock_func)(THREAD_POOL_PARAM_T * ctp); int (* handler_func)(THREAD_POOL_PARAM_T * ctp); THREAD_POOL_PARAM_T *(* context_alloc)(THREAD_POOL_HANDLE_T * handle); void (* context_free)(THREAD_POOL_PARAM_T * ctp);
从图“使用线程池时的线程流”中可以看出, context_alloc()函数被创建的每个新线程调用。(类似地,为每个被销毁的线程调用context_free()函数。)
结构(上面)的句柄成员被 作为其唯一的参数传递给context_alloc()函数。所述context_alloc()函数负责执行任何每线程 需要设置和用于返回上下文指针(称为CTP在参数列表)。注意上下文指针的内容完全取决于你 - 库不关心你放入上下文指针。
现在上下文已经被context_alloc()创建,block_func() 函数被调用来执行阻塞操作。注意,block_func()函数被传递的结果context_alloc() 函数。一旦block_func()函数解除阻塞,它返回一个上下文指针,它由库传递给handler_func()。所述handler_func()是负责执行的“工作” -例如,在一个典型的服务器,这是在被处理来自客户端的消息。该handler_func()必须现在返回一个零-非零值保留通过QSS未来扩展。此时unblock_func()也被保留; 只剩下它为NULL。也许这个伪代码示例将清除的东西(它基于相同的流,如“使用线程池时的线程流”,上面):
FOREVER DO IF(#threads <lo_water)THEN IF(#threads_total <maximum)THEN create new thread context =(* context_alloc)(handle); ENDIF ENDIF retval =(* block_func)(context); (* handler_func)(retval); IF(#threads> hi_water)THEN (* context_free)(context) 杀死线程 ENDIF DONE
注意上面大大简化了; 它的唯一目的是向您展示ctp和handle参数的数据流,并给出一些用于控制线程数的算法。
计划和现实世界
到目前为止,我们已经谈到了调度算法和线程状态,但我们还没有说明为什么和什么时候重新安排时间。有一个常见的误解,重新安排只是“发生”, 没有任何真正的原因。实际上,这是一个有用的抽象设计!但重要的是要了解导致重新安排的条件。回想一下图表“计划路线图”(在“内核的角色”部分)。
重新计划只是因为:
- 硬件中断
- 内核调用
- 故障
重新安排 - 硬件中断
由于硬件中断而发生的重新调度发生在两种情况下:
- 计时器
- 其他硬件
实时时钟为内核生成周期性中断,导致基于时间的重新调度。
例如,如果您发出睡眠(10); 调用,将发生多个实时时钟中断; 内核在每次中断时递增时钟时钟。当日时钟指示已经过去10秒钟时,内核将您的线程重新调度为READY。(这在时钟,定时器和每一个常见章节中有更详细的讨论。)
其他线程可能会等待来自外设的硬件中断,例如串行端口,硬盘或音频卡。在这种情况下,它们在内核中被阻塞等待一个硬件中断; 线程将仅在生成“事件”之后由内核重新调度。
重新安排 - 内核调用
如果重新调度由发出内核调用的线程引起,则重新调度立即完成,并且可以被认为与定时器和其他中断异步。
例如,上面我们调用sleep(10); 。这个C库函数最终被转换为内核调用。在这一点上,内核做了一个重新安排的决定,把你的线程从READY队列中取出那个优先级,然后调度另一个准备就绪的线程。
有许多内核调用导致进程被重新安排。它们中的大多数是相当明显的。这里有几个:
- 定时器功能(例如, sleep())
- 消息传递函数(例如, MsgSendv())
- 线程原语(例如, pthread_cancel(), pthread_join())
重新计划 - 异常
重新计划的最后原因,一个CPU故障,是一个例外,在硬件中断和内核调用之间。它与内核异步运行(如中断),但与引起它的用户代码(例如内核调用 - 例如,除以零异常)同步运行。与上述相同的讨论(对于硬件中断和内核调用)适用于故障。
概要
Neutrino提供了一组丰富的调度选项,线程是主调度元素。进程被定义为资源所有权的单位(例如,存储器区域)并且包含一个或多个线程。
线程可以使用以下任何同步方法:
- 互斥体 - 只允许一个线程在给定的时间点拥有互斥体。
- 信号量 - 允许固定数量的线程“拥有”信号量。
- sleepons - 允许多个线程阻塞在多个对象上,同时动态地将底层condvars分配给阻塞的线程。
- condvars - 类似于sleepons,除了condvars的分配由程序员控制。
- join - 允许线程同步到另一个线程的终止。
- barrier - 允许线程等待,直到多个线程到达同步点。
注意,互斥体,信号量和条件变量可以在同一进程或不同进程的线程之间使用,但是sleepons只能在同一进程的线程之间使用(因为库在进程的地址空间中有一个互斥体 “隐藏”), 。
除了同步之外,线程可以被调度(使用优先级和调度算法),并且它们将在单处理器盒或SMP盒上自动运行。
每当我们谈论创建一个“进程”(主要是作为从单线程实现移植代码的手段),我们真正创建一个地址空间与一个线程运行在它 - 该线程开始在main()或fork( ) 或vfork(),取决于调用的函数。