QNX系列:一、进程和线程

进程和线程

本章内容包括:

流程和线程基础

在我们开始讨论线程,流程,时间片以及所有其他奇妙的“计划概念”之前,让我们建立一个类比。

我首先要做的是说明线程和进程如何工作。我能想到的最好的方法(不涉及实时系统的设计)是在某种情况下想象我们的线程和过程。

一个房子的过程

让我们以一个日常的日常对象(一所房屋)为基础来进行流程和线程的类比。

房屋实际上是具有某些属性(例如,地面空间量,卧室数量等)的容器。

如果您以这种方式看待,房子实际上并不会主动 任何事情-它是一个被动的对象。这实际上是一个过程。我们将很快对此进行探讨。

占用者为线程

居住在房子里的人是活动的对象,他们是使用各种房间,看电视,做饭,洗澡的人。我们很快就会看到线程的行为。

单螺纹

如果你曾经住在你自己的,那么你知道这是什么样的-你 知道,你可以做任何你想在家里 的任何时间,因为在家里有没有其他人。如果您想打开立体声音响,请使用洗手间,吃晚饭-随便什么,您都可以继续。

多线程

当您将另一个人添加到房屋中时,情况会发生巨大变化。假设您已结婚,那么现在您也有一个配偶住在那儿。您不能在任何给定时间步入洗手间。您需要先检查以确保您的配偶不在其中!

如果您有两个负责任的成年人居住在房屋中,那么通常您可以对“安全性”放松一些-您知道另一个成年人会尊重您的空间,不会试图将厨房放火(故意!),等等。上。

现在,把几个孩子混在一起,突然之间事情变得更加有趣了。

返回流程和线程

就像一间房屋占用不动产一样,过程也要占用内存。正如房屋的占用者可以随意进入他们想要的任何房间一样,进程的线程都可以对该内存进行通用访问。如果某个线程分配了某些东西(妈妈出去玩游戏),那么所有其他线程都可以立即访问它(因为它存在于公共地址空间中-它在房子里)。同样,如果进程分配了内存,则新的内存也可用于所有线程。这里的窍门是识别内存是否 应该对进程中的所有线程都可用。如果是,那么您将需要让所有线程同步它们对其的访问。如果不是,那么我们将假定它特定于特定线程。在这种情况下, 线程可以访问它,我们可以假设不需要同步-线程不会使自己跳闸!

从日常生活中我们知道,事情并不是那么简单。现在,我们已经了解了基本特征(摘要:所有内容都是共享的),让我们看一下事情变得更有趣的地方以及原因。

下图显示了我们表示线程和进程的方式。进程是一个圆圈,代表“容器”概念(地址空间),三根扁线是线程。在整本书中,您将看到类似这样的图。


20200724164800


作为线程容器的进程。

互斥

如果您想洗个澡,并且已经有人在洗手间,您将不得不等待。线程如何处理呢?

这是通过相互排斥来完成的。这几乎意味着您的想法-涉及特定资源时,许多线程是互斥的。

如果要洗个澡,想独享 浴室。为此,您通常会进入浴室并从内部锁定门。任何试图使用浴室的人都会被锁锁住。完成后,您将打开门,允许其他人进入。

这就是线程的作用。线程使用一个称为互斥体的对象(互斥体MUT ual EX clusion)的缩写)。此对象就像门上的锁—一旦线程将互斥锁锁定,其他线程就无法获取该互斥锁,直到拥有线程释放(解锁)它为止。就像门锁一样,等待获取互斥量的线程将被禁止。

互斥锁和门锁发生的另一个有趣的相似之处是,互斥锁实际上是“建议”锁。如果线程不遵守使用互斥锁的约定,则保护是无用的。在我们的房屋类比中,这就像有人通过一堵墙闯入洗手间而忽略了门和锁的惯例。

这个很好理解

优先事项

如果浴室当前处于锁定状态,并且有许多人在等待使用该怎么办?显然,所有的人都坐在外面,等着谁在浴室里出来。真正的问题是,“当门解锁时会发生什么?下一步谁去?”

您会认为,允许等待时间最长的下一个人是“公平的”。或者让最年长的人去下一步可能是“公平的”。或最高。或最重要的。有许多方法可以确定什么是“公平的”。

我们通过两个因素来解决线程问题:优先级和等待时间。

假设两个人同时出现在(上锁的)浴室门上。其中一个有一个紧迫的截止日期(他们已经开会迟到了),而另一个没有。允许紧迫的最后期限的人再去是没有道理的吗?好吧,当然可以。唯一的问题是您如何确定谁更“重要”。这可以通过分配优先级来完成(让我们像Neutrino一样使用数字-此版本中,最低优先级是一个,最高优先级是255)。截止时间紧迫的房子里的人被赋予较高的优先权,那些没有截止时间的 人被赋予较低的优先权

与线程相同。线程从其父线程继承其调度算法,但可以调用pthread_setschedparam()更改其调度策略和优先级(如果有权这样做)。

如果有多个线程正在等待,并且互斥锁被解锁,则我们会将互斥锁赋予优先级最高的等待线程。但是,假设两个人的优先级相同。现在你怎么办?好吧,在这种情况下,允许等待时间最长的人继续前进是“公平的”。这不仅是“公平”的,而且也是Neutrino内核所做的。在一堆等待线程的情况下,我们去主要的优先级, 其次是通过等待的长度。

互斥锁当然不是我们将遇到的唯一同步对象。让我们看看其他一些。

信号量

让我们从浴室转移到厨房,因为这是一个社会上可以接受的位置,可以同时容纳多个人。在厨房里,您可能不想一次让所有人都在那里。实际上,您可能想限制厨房里的人数(厨师太多,等等)。

假设您永远不想同时有两个以上的人。你可以用互斥锁吗?并非如我们所定义。为什么不?对于我们的类比,这实际上是一个非常有趣的问题。让我们将其分解为几个步骤。

计数为1的信号量

洗手间可以处于以下两种情况之一,两种状态相互关联:

  • 门是开锁的,房间里没人
  • 门锁着,一个人在房间里

没有其他组合是不可能的-不能在房间里没人的情况下将门锁上(我们将如何解锁?),也不能和房间里的某人一起将门解锁(他们将如何确保他们的隐私?)。这是一个信号量为1的示例-该房间中最多只能有一个人,或者使用该信号量只有一个线程。

这里的钥匙(对双关语)是我们表征锁的方式。在典型的浴室锁中,您只能从内部对其进行锁定和解锁-没有可从外部访问的钥匙。实际上,这意味着互斥锁的所有权是一个原子操作– 在您获取互斥锁的过程中,没有任何其他线程可以获取互斥锁的可能性,结果是您俩都拥有该互斥锁。在我们的房子里,这比喻不那么明显,因为人类比一和零聪明得多。

厨房所需的是另一种类型的锁。

计数大于1的信号量

假设我们在厨房中安装了传统的基于钥匙的锁。该锁的工作方式是,如果您有钥匙,则可以解锁门并进去。使用此锁的任何人都同意,当他们进入室内时,他们会立即从内部锁定门,以便外面的任何人都可以。总是需要一个钥匙。

好了,现在控制我们想要在厨房里有多少人成为一件简单的事情—将两把钥匙挂在门外!厨房总是上锁的。当某人想进入厨房时,他们会看到门外是否挂有钥匙。如果是这样,他们会随身携带,解锁厨房门,走进去,然后使用钥匙锁定门。

由于进入厨房的人在厨房时必须随身带钥匙,因此我们通过限制门外钩上可用的钥匙数来直接控制在任何给定位置允许进入厨房的人数。门。

对于线程,这是通过信号量实现的。“普通”信号灯的工作原理与互斥锁一样,即您拥有互斥锁(在这种情况下您可以访问该资源),或者您没有该互斥锁(在这种情况下您没有权限)。我们刚才在厨房中描述的信号量是一个计数信号量 -它跟踪计数(通过线程可用的键数)。

信号量作为互斥量

我们只是问了一个问题:“您可以用互斥锁吗?” 关于实现带计数的锁,答案是否定的。反过来呢?我们可以使用信号量作为互斥量吗?

是。实际上,在某些操作系统中,这正是它们的作用-它们没有互斥锁,只有信号量!那么,为什么还要烦恼互斥体呢?

要回答该问题,请查看您的洗手间。您房屋的建造者如何实施“互斥体”?我怀疑您墙上没有钥匙!

互斥体是一种“特殊目的”信号灯。如果希望一个线程在代码的特定部分中运行,则互斥锁是迄今为止最有效的实现。

稍后,我们将介绍其他同步方案-称为condvars,barrier和sleepon的事物。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gxpZjsOh-1595821740528)(…/…/…/…/…/…/…/…/pointing.gif)]这样就不会引起混淆,请意识到互斥体具有其他属性,例如优先级继承,可以将其与信号量区分开。

内核的作用

房屋类比非常适合用于理解同步的概念,但是它属于一个主要领域。在我们的房子里,我们有许多线程同时运行。但是,在实际的实时系统中,通常只有一个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)。当线程正在运行时,信息存储在那些寄存器中(例如,当前程序位置)。

当内核决定另一个线程应该运行时,它需要:

  1. 保存当前正在运行的线程的寄存器和其他上下文信息
  2. 将新线程的寄存器和上下文加载到CPU中

但是内核如何决定另一个线程应该运行?此时将检查特定线程是否能够使用CPU。例如,当我们谈论互斥锁时,我们引入了阻塞状态(当一个线程拥有该互斥锁,而另一个线程也想要获取该互斥锁时,就会发生这种状态;第二个线程将被阻塞)。

因此,从内核的角度来看,我们有一个线程可以消耗CPU,而另一个线程 不能,因为它被阻塞了,等待互斥。在这种情况下,内核让可以运行的线程消耗CPU,并将另一个线程放入内部列表中(以便内核可以跟踪其对互斥锁的请求)。

显然,这不是一个非常有趣的情况。假设许多线程可以使用CPU。还记得我们根据优先级和等待时间委派访问互斥锁吗?内核使用类似的方案来确定下一个要运行的线程。有两个因素:优先级和调度算法,按该顺序进行评估。

优先次序

考虑两个可以使用CPU的线程。如果这些线程具有不同的优先级,那么答案确实非常简单-内核将CPU分配给最高优先级的线程。正如我们在谈到获取互斥锁时提到的那样,Neutrino的优先级从一个(可用的最低)开始。请注意,优先级0为空闲线程保留-您不能使用它。(如果您想了解系统的最小值和最大值,请使用函数sched_get_priority_min()sched_get_priority_max()) —它们是在中原型化的<sched.h>。在本书中,我们将其中一个作为最低可用值,将255作为最大可用值。最高。)

如果另一个具有更高优先级的线程突然能够使用CPU,则内核将立即上下文切换到更高优先级的线程。我们称这种抢占 -优先级较高的线程优先于优先级较低的线程。当较高优先级的线程完成并且内核上下文切换回之前运行的较低优先级的线程时,我们称之为恢复(resume) —内核恢复运行先前的线程。

这里讲的是线程的优先级

现在,假设两个线程能够使用CPU并具有完全相同的优先级。

调度算法(Scheduling algorithms)

假设其中一个线程当前正在使用CPU。在这种情况下,我们将检查内核用来决定何时进行上下文切换的规则。(当然,这整个的讨论确实只适用于在同一优先级的线程-的瞬间,较高优先级的线程就可以使用它得到它的CPU;这是在一个实时操作系统具有优先级的整点)

对于优先级相同的不用现成才需要调度?

Neutrino内核可理解的两种主要调度算法(策略)是Round Robin(或简称为“ RR”)和FIFO(先进先出)。(虽然还有零星的调度,但这超出了本书的范围;请参见《系统架构指南》的“ QNX Neutrino微内核”一章中的“ 零星调度 ”。)

FIFO

在FIFO调度算法中,允许线程消耗CPU所需的时间。这意味着,如果该线程正在执行很长的数学计算,并且没有其他优先级更高的线程就绪,则该线程可能会永远运行。相同优先级的线程呢?他们也被锁定。(在这一点上很明显,较低优先级的线程也被锁定。)

如果正在运行的线程退出或自愿放弃了CPU, 内核会以能够使用CPU的相同优先级查找其他线程。如果没有这样的线程,内核会寻找能够使用CPU的低优先级线程。请注意,术语“自愿放弃CPU”可能是两件事之一。如果线程进入睡眠状态,或阻塞了信号灯等,则可以,较低优先级的线程可以运行(如上所述)。但是还有一个“特殊的”调用sched_yield()(基于内核调用SchedYield()),它放弃CPU到另一个具有相同优先级的线程-如果可以运行较高优先级的线程,则永远不会给较低优先级的线程运行的机会。如果某个线程实际上确实调用了sched_yield(),并且没有其他优先级相同的线程可以运行,则原始线程将继续运行。有效地,使用sched_yield()可以使另一个优先级相同的线程在CPU上破裂。

在下图中,我们看到三个线程在两个不同的进程中运行:


20200724164831


在两个不同进程中的三个线程。

如果我们假设线程“ A”和“ B”处于就绪状态,并且线程“ C”被阻塞(可能正在等待互斥锁),并且线程“ D”(未显示)当前正在执行,那么这就是Neutrino内核维护的READY队列的一部分如下所示:


20200724164838


READY队列中有两个线程,一个被阻塞,一个正在运行。

这显示了内核的内部READY队列,内核用来决定下一步调度谁。请注意,线程“ C”不在READY队列中,因为它被阻塞了;线程“ D”也不在READY队列中,因为它正在运行。

RR Round Robin

RR调度算法与FIFO相同,不同之处在于,如果存在另一个具有相同优先级的线程,则该线程不会永远运行。它仅针对系统定义的时间片运行,您可以使用sched_rr_get_interval()函数确定其值。时间片通常为4毫秒,但实际上是ticksize的4倍,您可以使用ClockPeriod()查询或设置它。

发生的情况是内核启动了RR线程并记录了时间。如果RR线程运行了一段时间,则分配给它的时间将结束(时间片将过期)。内核查看是否有另一个具有相同优先级的线程就绪。如果存在,则内核将运行它。如果不是,则内核将继续运行RR线程(即,内核授予该线程另一个时间片)。

规则

让我们按重要性顺序总结调度规则(对于单个CPU):

  • 一次只能运行一个线程。
  • 优先级最高的就绪线程将运行。
  • 线程将一直运行直到阻塞或退出。
  • RR线程将为其时间片运行,然后内核将对其进行重新调度(如果需要)。

以下流程图显示了内核做出的决定:


20200724155934


计划路线图。

对于多CPU系统,规则相同,只是多个CPU可以同时运行多个线程。线程的运行顺序(即哪些线程可以在多个CPU上运行)的确定方式与单个CPU完全相同-优先级最高的READY线程将在CPU上运行。对于优先级较低或等待时间较长的线程,内核在何时安排它们方面具有一定的灵活性,以避免高速缓存的使用效率低下。有关SMP的更多信息,请参见《多核处理用户指南》

内核状态

我们一直在松散地谈论“运行”,“就绪”和“阻塞” —现在让我们正式化这些线程状态。

正在运行

Neutrino的RUNNING状态只是意味着该线程现在正在积极消耗CPU。在SMP系统上,将有多个线程在运行。在单处理器系统上,将运行一个线程。

准备

READY状态表示此线程 可以立即运行,但不能运行,因为另一个线程(优先级相同或更高)正在运行。如果两个线程能够使用CPU,则一个优先级为10的线程和一个优先级为7的线程,优先级10的线程将为RUNNING,优先级7的线程将为READY。

封锁状态

我们称之为封锁状态?问题是,不仅存在 一种阻塞状态。在Neutrino领导下,实际上有十几个封锁国家。

为什么那么多?因为内核跟踪线程被阻止的原因

我们已经看到了两个阻塞状态-当一个线程被阻塞等待互斥时,该线程处于MUTEX状态。当线程被阻塞等待信号量时,它处于SEM状态。这些状态仅指示线程被阻塞在哪个队列(和哪个资源)上。

如果多个线程在互斥锁上处于阻塞状态(处于MUTEX阻塞状态),则直到拥有该互斥锁的线程将其释放之前,它们不会从内核引起注意 。到那时,已阻塞的线程之一已准备就绪,内核做出了重新调度的决定(如果需要)。

为什么是“如果需要”?刚释放互斥锁的线程可能还有其他事情要做,并且比等待线程的优先级更高。在这种情况下,我们转到第二条规则,该规则指出“将运行最高优先级的就绪线程”,这意味着调度顺序未更改-更高优先级的线程继续运行。

内核状态,完整列表

这是内核阻塞状态的完整列表,并简要说明了每个状态。顺便说一下,此列表可用在<sys/neutrino.h>—您会注意到,所有状态都以STATE_开头(例如,此表中的“ READY”在头文件中列为STATE_READY):

如果状态为:线程是:
CONDVAR等待条件变量发出信号。
死。内核正在等待释放线程的资源。
INTR等待中断。
加入等待另一个线程的完成。
MUTEX等待获取互斥量。
纳米睡眠睡了一段时间。
NET_REPLY等待通过网络传递答复。
NET_SEND等待脉冲或消息通过网络传递。
准备未在CPU上运行,但已准备好运行(一个或多个更高或相等优先级的线程正在运行)。
接收等待客户端发送消息。
回复等待服务器回复消息。
正在运行主动在CPU上运行。
扫描电镜等待获取信号量。
发送等待服务器接收消息。
SIGSUSPEND等待信号。
SIGWAITINFO等待信号。
堆栈等待分配更多堆栈。
已停止暂停(SIGSTOP信号)。
WAITCTX等待寄存器上下文(通常是浮点数)变得可用(仅在SMP系统上)。
等待页等待流程管理器解决页面上的故障。
等待阅读等待创建线程。
If the state is:The thread is:
CONDVARWaiting for a condition variable to be signaled.
DEADDead. Kernel is waiting to release the thread’s resources.
INTRWaiting for an interrupt.
JOINWaiting for the completion of another thread.
MUTEXWaiting to acquire a mutex.
NANOSLEEPSleeping for a period of time.
NET_REPLYWaiting for a reply to be delivered across the network.
NET_SENDWaiting for a pulse or message to be delivered across the network.
READYNot running on a CPU, but is ready to run (one or more higher or equal priority threads are running).
RECEIVEWaiting for a client to send a message.
REPLYWaiting for a server to reply to a message.
RUNNINGActively running on a CPU.
SEMWaiting to acquire a semaphore.
SENDWaiting for a server to receive a message.
SIGSUSPENDWaiting for a signal.
SIGWAITINFOWaiting for a signal.
STACKWaiting for more stack to be allocated.
STOPPEDSuspended (SIGSTOP signal).
WAITCTXWaiting for a register context (usually floating point) to become available (only on SMP systems).
WAITPAGEWaiting for process manager to resolve a fault on a page.
WAITTHREADWaiting for a thread to be created.

要记住的重要一点是,当线程被阻塞时,无论处于阻塞状态是什么,它都不会占用CPU。相反,线程消耗CPU的唯一状态是RUNNING状态。

我们将在“ 消息传递”一章中看到SEND,RECEIVE和REPLY阻止状态。NANOSLEEP状态与sleep()之类的功能一起使用,我们将在“ 时钟,计时器和时常得到踢 ”一章中进行介绍。INTR状态与InterruptWait()一起使用,我们将在“ 中断”一章中进行介绍。本章讨论了其他大多数状态。

线程和进程

让我们从真实的实时系统的角度回到对线程和进程的讨论。然后,我们来看看用于处理线程和进程的函数调用。

我们知道一个进程可以有一个或多个线程。(具有零线程的进程将无法执行任何操作-可以这么说,实际上没有人在家中执行任何有用的工作。)Neutrino系统可以具有一个或多个进程。(同样的讨论也适用-具有零过程的Neutrino系统什么也做不了。)

那么这些进程和线程是做什么的呢?最终,它们形成一个系统 -执行某些目标的线程和进程的集合。

在最高级别上,系统由许多过程组成。每个进程负责提供某种性质的服务-无论是文件系统,显示驱动程序,数据采集模块,控制模块还是其他。

在每个进程中,可能有多个线程。线程数有所不同。仅使用一个线程的设计者可以完成与使用五个线程的另一设计者相同的功能。有些问题使自己成为多线程的,并且实际上相对容易解决,而其他进程则使自己成为单线程的,并且很难制作多线程。

使用线程进行设计的主题很容易占据另一本书-我们在这里只坚持基础知识。

Why processes?

那么,为什么不仅仅拥有一个拥有无数线程的进程呢?尽管某些操作系统迫使您采用这种方式进行编码,但是将事物分解为多个进程的好处很多:

  • 解耦和模块化
  • 可维护性
  • 可靠性

将问题“分解”为几个独立问题的能力是一个强大的概念。它也是Neutrino的核心。中微子系统由许多独立的模块组成,每个模块都有一定的责任。这些独立的模块是不同的过程。QSS的人员使用此技巧来孤立地开发模块,而无需模块相互依赖。模块之间相互之间唯一的“依赖”是通过少量定义明确的接口。

由于缺乏相互依赖性,因此自然可以提高可维护性。由于每个模块都有其自己的特定定义,因此修复一个模块相当容易,尤其是因为它不与任何其他模块绑定。

但是,可靠性也许是最重要的一点。就像房屋一样,一个过程具有一些定义明确的“边界”。一个房子里的人在房子里的时候和不在的时候都有一个不错的主意。一个线程拥有非常不错的主意-如果它的过程中访问内存,它可以活。如果它超出了进程地址空间的范围,它将被杀死。这意味着在不同进程中运行的两个线程实际上是相互隔离的。


20200724160002


内存保护。

进程的地址空间是维护和中微子的进程管理器模块执行。启动进程后,进程管理器会为其分配一些内存,并开始运行线程。内存被标记为由该进程拥有。

这意味着,如果该进程中有多个线程,并且内核需要在它们之间进行上下文切换,那么这是一个非常高效的操作-我们不必更改地址空间,只需运行哪个线程即可。但是,如果必须在另一个进程中切换到另一个线程,则进程管理器会介入并导致地址空间切换。不用担心-尽管在此附加步骤中还有更多的开销,但是在Neutrino下,这仍然非常快。

Starting a process

现在,我们将注意力转移到可用于处理线程和进程的函数调用上。任何线程都可以启动进程。唯一施加的限制是源自基本安全性的限制(文件访问,特权限制等)。您很有可能已经开始了其他过程。从系统启动脚本,外壳程序,或者让一个程序代表您启动另一个程序。

从命令行启动进程

例如,在外壳中,您可以输入:

$ program1

这指示外壳程序启动一个名为的程序,program1并等待其完成。或者,您可以输入:

$ program2 &

这指示外壳启动program2 而无需等待外壳完成。我们说这program2是“在后台”运行的。

如果要在启动程序之前调整程序的优先级,则可以使用nice 命令,就像在UNIX中一样:

$ nice program3

这指示外壳程序以program3降低的优先级启动。

还是呢?

如果您查看实际发生的情况,我们告诉Shell运行一个nice以常规优先级调用的程序 。该nice 命令调整了自己的优先级要低(这是命名为“很好”来自),然后program3在较低的优先级。

从程序内部启动过程

您通常不需要关心shell创建进程的事实-这是有关shell的基本假设。在某些应用程序设计中,您肯定会依靠Shell脚本(文件中的命令批次)来为您完成工作,但是在其他情况下,您将需要自己创建进程。

例如,在大型的多进程系统中,您可能希望让一个主程序基于某种配置文件为您的应用程序启动所有其他进程。另一个示例包括检测到某些操作条件(事件)时启动过程。

让我们看一下Neutrino提供的用于启动其他进程(或转换为其他程序)的功能:

您使用哪种功能取决于两个要求:可移植性和功能。像往常一样,两者之间需要权衡。

以下是在创建新流程的所有调用中发生的常见事件。原始进程中的线程调用上述函数之一。最终,该功能使流程管理器为新流程创建地址空间。然后,内核在新进程中启动线程。该线程执行一些指令,并调用main()。(当然,在*fork()vfork()的情况下,新线程通过从fork()vfork()*返回来开始在新进程中执行;我们将很快看到如何处理它。)

通过*system()*调用启动进程

系统()函数是最简单的; 它需要一个命令行,就像您在shell提示符下键入它一样,然后执行它。

实际上,*system()*实际上启动了一个外壳程序来处理您要执行的命令。

我用来编写本书的编辑器利用了*system()*调用。在编辑时,可能需要“掏空”,签出一些示例,然后再返回编辑器,而这一切都不会丢失我的位置。在此编辑器中,我可以发出:!pwd例如显示当前工作目录的命令 。编辑器为:!pwd命令运行以下代码:

system ("pwd");

是*系统()*适用于一切在阳光下?当然不是,但这对于您的许多过程创建需求很有用。

使用*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()*调用本身。

“ l”后缀

例如,如果我想ls 使用参数**-t****-r**和调用命令**-l**(意思是“按时间排序输出,以相反的顺序,并显示输出的长版本”),则可以将其指定为:

/* 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);

或者,使用v后缀变体:

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后缀“ ”即可。

请注意,我们再次传递了程序的实际路径名(/bin/ls程序名作为第一个参数。我们再次传递了该名称,以支持基于调用方式而表现不同的程序。

例如,GNU压缩和解压缩实用程序(gzipgunzip)实际上是指向同一可执行文件的链接。可执行文件启动时,它会查看argv [0](传递给main())并决定是压缩还是解压缩。

“ e”后缀

e后缀“ ”版本将环境传递给程序。环境就是这样-一种程序在其中运行的“上下文”。例如,您可能有一个带有单词词典的拼写检查器。您可以在环境中提供字典,而不必每次在命令行上都指定字典的位置:

$ export DICTIONARY=/home/rk/.dict

$ spellcheck document.1

export命令告诉Shell创建一个新的环境变量(在本例中为DICTIONARY),并为其分配一个值(/home/rk/.dict)。

如果您想使用其他词典,则必须在运行程序之前更改环境。从外壳很容易做到:

$ export DICTIONARY=/home/rk/.altdic
$ 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”后缀

p后缀“ ”的版本将在PATH环境变量中搜索目录以找到可执行文件。您可能已经注意到,所有示例都为可执行文件(/bin/ls和)设置了硬编码位置/usr/bin/spellcheck。那其他可执行文件呢?除非您想首先找到该特定程序的确切路径,否则最好让用户告诉您的程序所有可搜索可执行文件的位置。标准的PATH环境变量就是这样做的。这是最小系统中的一个:

PATH=/proc/boot:/bin

这告诉外壳程序,当我键入命令时,应首先查看目录/proc/boot,如果在该目录中找不到命令,则应查看二进制文件目录/bin部分。 PATH是用冒号分隔的查找命令的列表。您可以根据需要向PATH添加任意数量的元素,但请记住,将(按顺序)在所有路径名组件中搜索可执行文件。

如果您不知道可执行文件的路径,则可以使用“ p”变体。例如:

// Using an explicit path:
execl ("/bin/ls", "/bin/ls", "-l", "-t", "-r", NULL);

// Search your PATH for the executable:
execlp ("ls", "ls", "-l", "-t", "-r", NULL);

如果execl()ls在中找不到/bin,则返回错误。在execlp()函数将搜索指定的所有目录路径ls,并且将只返回,如果它不能找到一个错误ls在任何这些目录。这对于跨平台支持也非常有用-不必对您的程序进行编码即可知道不同的CPU名称,而只需找到可执行文件即可。

如果您做这样的事情怎么办?

execlp ("/bin/ls", "ls", "-l", "-t", "-r", NULL);

它会搜索环境吗?否。您告诉execlp()使用显式路径名,该路径名将覆盖常规PATH搜索规则。如果没有找到ls/bin话,不会以其他方式作出的(这是相同的方式*EXECL()*工作在这种情况下)。

将显式路径与简单的命令名称混合使用是否很危险(例如,path参数/bin/ls和command name参数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()函数。这是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 []);

我们可以立即省去pathargvenvp参数-我们已经在上面看到了那些参数,它们表示可执行文件(路径成员),参数向量(argv)和环境(envp)的位置。

fd_countfd_map参数一起去。如果您指定零fd_count,然后fd_map被忽略,这意味着所有的文件描述符(除了那些由改性的fcntl()的FD_CLOEXEC标志)将在新创建的进程继承。如果fd_count不为零,则表明fd_map中包含的文件描述符的数量;只有指定的将被继承。

继承参数是一个指向包含一组标志,信号口罩,等上的结构。有关更多详细信息,请查阅Neutrino 库参考

使用*fork()*调用启动进程

假设您要创建一个与当前正在运行的进程相同的新进程,并使其同时运行。您可以使用spawn()(和P_NOWAIT参数)来解决这个问题,为新创建的进程提供有关进程确切状态的足够信息,以便它可以自行设置。但是,这可能非常复杂。描述过程的“当前状态”可能涉及大量数据。

有一种更简单的方法-fork() 函数,它复制当前进程。所有代码都是相同的,并且数据与创建(或)流程的数据相同。

当然,不可能创建一个父流程在各个方面都相同的流程。为什么?这两个流程之间最明显的区别就是流程ID-我们无法创建具有相同流程ID的两个流程。如果查看Neutrino库参考中的*fork()文档,您会发现两个进程之间存在差异列表。如果您打算使用fork(),*则应阅读此列表,以确保您知道这些区别。

如果fork()的两面看起来都一样,您如何区分它们?当您调用fork()时,您将创建另一个与父进程在同一位置执行相同代码的进程(即,两个都将从*fork()*调用返回)。让我们看一些示例代码:

int main (int argc, char **argv)
{
    int retval;

    printf ("This is most definitely the parent process\n");
    fflush (stdout);
    retval = fork ();
    printf ("Which process printed this?\n");

    return (EXIT_SUCCESS);
}

在*fork()调用之后,两个进程都将执行第二个printf()*调用!如果您运行此程序,它将显示以下内容:

This is most definitely the parent process
Which process printed this?
Which process printed this?

这两个过程都将打印第二行。

区别两个进程的唯一方法是retval中fork() 返回值。在新创建的进程中,retval为零;在父进程中,retval是子进程的ID。

像泥一样清澈?这是另一个要澄清的代码段:

printf ("The parent is 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 ());
}

该程序将打印如下内容:

The parent is pid 4496
This is the parent, child pid is 8197
This is the child, pid is 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()*的功能 在撰写本文时,Neutrino C库中的所有互斥体都没有使用它。

那你应该用什么呢?

显然,如果要移植现有代码,则需要使用现有代码使用的任何内容。对于新代码,应尽可能避免fork()。原因如下:

  • 如上所述,*fork()*不适用于多个线程。
  • fork()的做多线程工作,你需要注册一个pthread_atfork()处理程序,并锁定每一个互斥体之前,你叉,设计复杂化。
  • fork()的子级复制所有打开的文件描述符。正如我们将在后面的“ 资源管理器”一章中看到的那样,这将导致很多工作-如果子进程立即执行*exec()*并关闭所有文件描述符,则其中的大部分工作都是不必要的。

*vfork()spawn()系列之间的选择归结为可移植性,以及您希望孩子和父母做什么。该了vfork()函数将暂停,直到孩子调用的exec()或退出,而产卵()系列函数可以允许两个同时运行。所述的vfork()*函数,但是,是操作系统之间微妙的不同。

启动线程

现在我们已经了解了如何启动另一个进程,让我们看看如何启动另一个线程。

任何线程都可以在同一进程中创建另一个线程。没有任何限制(当然,内存空间不足!)。最常见的方法是通过POSIX pthread_create()调用:

#include <pthread.h>

int
pthread_create (pthread_t *thread,
                const pthread_attr_t *attr,
                void *(*start_routine) (void *),
                void *arg);

将*在pthread_create()*函数有四个参数:

  • 线

    指向pthread_t存储线程ID 的指针

  • 属性

    一个属性结构

  • start_routine

    线程开始的例程

  • 精氨酸

    传递给线程的start_routine的参数

请注意,线程指针和属性结构(attr)是可选的-您可以将它们作为NULL传递。

所述螺纹参数可以被用来存储新创建的线程的线程ID。您会注意到,在下面的示例中,我们将传递一个NULL,这意味着我们不在乎新创建的线程的ID。如果我们确实在意,我们可以做这样的事情:

pthread_t tid;

pthread_create (&tid, …
printf ("Newly created thread id is %d\n", tid);

这种用法实际上是很典型的,因为您经常想知道哪个线程ID正在运行哪个代码段。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SxRvvKiF-1595821740536)(…/…/…/…/…/…/…/…/pointing.gif)]一个小妙点。新创建的线程可能在填充线程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*

    调度参数。

提供以下功能:

这看起来像一个很大的列表(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(),最后是线程的作用域是“系统”还是“进程”。

要创建一个“可连接”线程(意味着另一个线程可以通过pthread_join()与其终止同步),您可以使用:

(default)
pthread_attr_setdetachstate (&attr, PTHREAD_CREATE_JOINABLE);

要创建一个不能加入的线程(称为“分离”线程),可以使用:

pthread_attr_setdetachstate (&attr, PTHREAD_CREATE_DETACHED);

如果您希望线程继承创建线程的调度属性(即具有相同的调度算法和相同的优先级),则可以使用:

(default)
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,并且内核调度进程。)

如果确实要调用它,则只能按以下方式调用它:

(default)
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);

这些函数都将属性结构作为其第一个参数。它们的第二个参数选自以下内容:

  • 尺寸

    “守卫”区域的大小。

  • 地址

    堆栈的地址(如果要提供)。

  • 大小

    堆栈的大小。

  • 懒栈

    指示是应按需分配堆栈还是应从物理内存预先分配堆栈。

保护区域是紧接线程无法写入的堆栈之后的存储区域。如果确实如此(意味着堆栈即将溢出),则该线程将被SIGSEGV击中。如果guardsize为0,则表示没有保护区域。这也意味着没有堆栈溢出检查。如果guardsize非零,则将其至少设置为系统范围的默认guardsize(您可以通过使用常量_SC_PAGESIZE 调用sysconf()来获得该默认值)。请注意,guardsize至少与“页面”一样大(例如,在x86处理器上为4 KB)。另外,请注意,保护页面不会占用任何物理 内存,它是作为虚拟地址(MMU)“技巧”完成的。

如果提供的话,addr是堆栈的地址。您可以将其设置为NULL,这意味着系统将为线程分配(并释放!)堆栈。指定堆栈的优点是您可以进行事后堆栈深度分析。这是通过分配堆栈区域,用“签名”(例如,反复重复的字符串“ STACK”)填充并让线程运行来实现的。线程完成后,您将查看堆栈区域,并查看线程在签名上涂抹了多远,从而为您提供了在此特定运行期间使用的最大堆栈深度。

ssize参数指定大堆栈。如果在addr中提供堆栈,则ssize应该是该数据区域的大小。如果您没有在addr中提供堆栈(意味着您传递了NULL),那么ssize参数会告诉系统应为您分配多少堆栈。如果将ssize指定为0 ,则系统将为您选择默认的堆栈大小。显然,将ssize 指定为0 使用addr指定堆栈是一种不好的做法—实际上,您说的是“这里是一个指向对象的指针,并且该对象为某些默认大小。” 问题在于对象大小和传递的值之间没有绑定。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KSrIRIWF-1595821740537)(…/…/…/…/…/…/…/…/pointing.gif)]如果通过addr提供堆栈,则该线程不存在自动堆栈溢出保护(即,没有保护区)。但是,您当然可以使用mmap()mprotect()自行设置

最后,lazystack参数指示应按要求分配物理内存(使用值PTHREAD_STACK_LAZY)还是全部分配(使用值PTHREAD_STACK_NOTLAZY)。“按需”分配堆栈(按需)的好处是,该线程不会消耗超出其绝对必需数量的物理内存。缺点(因此是“全过程”方法的优点)是,在内存不足的环境中,线程在操作期间需要额外的堆栈空间时不会神秘地死掉,并且没有任何线程内存不足。如果您使用的是PTHREAD_STACK_NOTLAZY,则您很可能希望设置堆栈的实际大小而不是接受默认值,因为默认值非常大。

“计划”线程属性

最后,如果确实为pthread_attr_setinheritsched()指定了PTHREAD_EXPLICIT_SCHED ,那么您将需要一种方法来指定调度算法和要创建的线程的优先级。

这是通过两个功能完成的:

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之一。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-z5dA0uWx-1595821740538)(…/…/…/…/…/…/…/…/pointing.gif)]SCHED_OTHER当前已映射到SCHED_RR。

PARAM是包含在这里相关的一个成员的结构:sched_priority。通过直接分配将该值设置为所需的优先级。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-u18g7LqD-1595821740538)(…/…/…/…/…/…/…/…/pointing.gif)]需要注意的一个常见错误是指定PTHREAD_EXPLICIT_SCHED,然后仅设置调度策略。问题在于,在初始化的属性结构中, param.sched_priority的值为0。这与IDLE进程具有相同的优先级,这意味着您新创建的线程将与IDLE进程竞争CPU。到那儿去,做了那件T恤。:-) 对此,已经有足够的人咬牙切齿,QSS已将仅为空闲线程保留的优先级设为零。您根本无法以零优先级运行线程。

一些例子

让我们看一些例子。我们假设已经包含了正确的包含文件(<pthread.h><sched.h>),并且要创建的线程称为*new_thread(),*并且已正确原型化并定义了该线程。

创建线程的最常见方法是简单地将值设为默认值:

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;

// initialize the attribute structure
pthread_attr_init (&attr);

// set the detach state to "detached"
pthread_attr_setdetachstate (&attr, PTHREAD_CREATE_DETACHED);

// override the default of 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);

要查看多线程程序的外观,可以pidin从shell 运行命令。说我们的程序被调用了spud。如果我们pidinspud创建线程之前运行一次,在spud 创建两个线程之后(总共三个)运行一次,则输出结果如下所示(我将pidin输出缩短为仅显示spud):

# 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上运行(另一个优先级更高的线程,当前正在运行)。

既然我们已经知道创建线程的全部知识,那么让我们看一下如何以及在何处使用它们。

线程是个好主意

有两类问题,线程的应用是一个好主意。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lXAP4xcX-1595821740539)(…/…/…/…/…/…/…/…/pointing.gif)]线程就像C ++中的重载运算符—(在当时)用一些有趣的用法重载每个单个运算符似乎是一个好主意,但这会使代码难以理解。与线程类似,您可以创建大量线程,但是额外的复杂性将使您的代码难以理解,因此难以维护。另一方面,明智地使用线程将导致代码在功能上非常干净。

线程很棒,您可以在其中并行化操作 -想到了许多数学问题(图形,数字信号处理等)。如果您希望程序在共享数据时执行多个独立功能,例如在同时为多个客户端提供服务的Web服务器上,线程也非常有用。我们将研究这两个类。

数学运算中的线程

假设我们有一个执行光线跟踪的图形程序。屏幕上的每条光栅线均取决于主数据库(该数据库描述了所生成的实际图片)。这里的关键是:每个栅格线都独立于其他栅格线。这立即导致该问题作为线程程序脱颖而出。

这是单线程版本:

int
main (int argc, char **argv)
{
    int x1;

    …    // perform initializations

    for (x1 = 0; x1 < num_x_lines; x1++) {
        do_one_line (x1);
    }

    …    // display results
}

在这里,我们看到该程序将对要计算的所有栅格线进行x1迭代。

在SMP系统上,该程序将仅使用一个CPU。为什么?因为我们还没有告诉操作系统并行执行任何操作。操作系统不够智能,无法查看程序并说:“嘿,等一下!我们有4个CPU,看起来这里有独立的执行流程。我将在所有4个CPU上运行它!”

因此,由系统设计者(您)告诉Neutrino哪些零件可以并行运行。最简单的方法是:

int
main (int argc, char **argv)
{
    int x1;

    …    // perform initializations

    for (x1 = 0; x1 < num_x_lines; x1++) {
        pthread_create (NULL, NULL, do_one_line, (void *) x1);
    }

    …    // display results
}

这种简单的方法存在许多问题。首先(这是最次要的),必须修改*do_one_line()*函数以使用a void *而不是an int作为其参数。使用类型转换可以轻松地对此进行补救。

第二个问题有点棘手。假设您要计算图片的屏幕分辨率为1280 x1024。我们将创建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;

    …    // perform initializations

    // get the number of CPUs
    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);
    }

    …    // display results
}

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上运行。而且由于我们只有少数几个线程,所以我们不会在内存中浪费不必要的堆栈。注意,我们如何通过取消引用“ System Page”全局变量*_syspage_ptr来*获得CPU的数量。(有关系统页面中内容的更多信息,请查阅QSS的《 Building Embedded Systems》书籍或 <sys/syspage.h>包含文件)。

SMP或单处理器的编码

关于此代码的最好之处在于,它将在单处理器系统上正常工作-您将只创建一个线程,并使其完成所有工作。额外的开销(一个堆栈)非常值得让软件在SMP机器上“工作得更快”的灵活性。

与线程终止同步

我提到最初显示的简单代码示例存在许多问题。另一个问题是main() 启动了一堆线程,然后显示结果。函数如何知道何时安全显示结果?

具有*main()*函数轮询以完成操作会破坏实时操作系统的目的:

int
main (int argc, char **argv)
{
    …

    // start threads as before

    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;

    …    // perform initializations
    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);
    }

    // synchronize to termination of all threads
    for (cpu = 0; cpu < num_cpus; cpu++) {
        pthread_join (thread_ids [cpu], NULL);
    }

    …    // display results
}

您会注意到,这次我们将第一个参数传递给pthread_create()作为指向a的指针pthread_t。这是新创建线程的线程ID的存储位置。第一个for循环完成后,我们将运行num_cpus个线程,以及正在运行*main()的线程 。我们不太担心main()*线程消耗了我们所有的CPU;它会花时间等待。

通过依次对我们的每个线程执行pthread_join()来完成等待。首先,我们等待thread_ids [0] 完成。完成后,pthread_join()将解除阻止。for循环的下一次迭代将导致我们等待 所有num_cpus线程的thread_ids [1]完成,依此类推

此时出现的一个常见问题是:“如果线程以相反的顺序完成该怎么办?” 换句话说,如果有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);

这将在传递的地址处创建一个屏障对象(该屏障对象的指针位于barrier中),其属性由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-一个用于main()线程,一个用于thread1(),另一个用于thread2()。然后,像以前一样启动图形计算线程(在本例中为thread1()thread2())。为了说明起见,我们没有显示图形计算的源,而是停留在sleep (20);和中。sleep (40);造成延迟,就好像正在进行计算一样。为了进行同步,主线程只是简单地将自己阻塞在屏障上,因为知道屏障也只有在工作线程也加入屏障之后才会解除阻塞。

如前所述,使用pthread_join(),工作线程完成并且死掉,以便主线程与其同步。但是有了障碍,线程仍然可以正常运行。实际上,它们都在完成所有操作后才从pthread_barrier_wait()中解除阻塞。这里介绍的皱纹是,您应该准备好使用这些线程!在我们的图形示例中,他们无事可做(如我们所写)。在现实生活中,您可能希望开始进行下一帧计算。

单个CPU上有多个线程

假设我们稍微修改一下示例,以便可以说明为什么即使在单CPU系统上也具有多个线程有时也是一个好主意。

在此修改示例中,网络上的一个节点负责计算栅格线(与上面的图形示例相同)。但是,计算一条线时,应将其数据通过网络发送到另一个节点,该节点将执行显示功能。这是我们修改后的main()(来自原始示例,没有线程):

int
main (int argc, char **argv)
{
    int x1;

    …    // perform initializations

    for (x1 = 0; x1 < num_x_lines; x1++) {
        do_one_line (x1);           // "C" in our diagram, below
        tx_one_line_wait_ack (x1);  // "X" and "W" in diagram below
    }
}

您会注意到,我们已经删除了显示部分,而是添加了一个 tx_one_line_wait_ack()函数。进一步假设我们正在处理一个相当慢的网络,但是CPU并没有真正参与传输方面–它将数据发射到某些硬件上,然后担心传输这些数据。该tx_one_line_wait_ack()使用位CPU的数据到硬件,但随后使用没有CPU,而它的等待远端的确认。

这是一个显示CPU使用情况的图表(我们将“ C”用于图形计算部分,将“ X”用于传输部分,将“ W”用于等待远端的确认):


20200724160129


序列化的单CPU。

等一下!我们浪费了宝贵的时间,等待硬件完成任务!

如果我们使用多线程,应该可以更好地利用我们的CPU,对吗?


20200724160142


多线程单CPU。

这样好得多,因为现在,即使第二个线程花了一些时间等待,我们也减少了计算所需的总时间。

如果我们的时间是T **计算来计算,T ** TX到发射,和T **等待让硬件做它的事,在第一种情况下我们总的运行时间是:

(Tcompute + Ttx + Twait)× num_x_lines

而使用两个线程

(Tcompute + Ttx)× num_x_lines + Twait

短一点

特威特×(num_x_lines -1)

假设T *** wait≤T *** compute


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9vSf1NGx-1595821740541)(…/…/…/…/…/…/…/…/pointing.gif)]请注意,我们最终将受到以下约束:Tcompute + Ttx× *num_x_lines,*因为我们必须至少进行一次完整的计算,并且必须将数据传输到硬件之外,同时我们可以使用多线程覆盖计算周期,我们只有一个硬件资源可用于传输。

现在,如果我们创建了一个四线程版本,并在具有4个CPU的SMP系统上运行它,那么最终将得到如下所示的内容:


20200724160247


四个线程,四个CPU。

请注意,四个CPU的每个利用率均未得到充分利用(如“利用率”图中的空白矩形所示)。上图中有两个有趣的区域。当四个线程启动时,它们各自进行计算。不幸的是,当线程在每次计算完成时,它们都在争夺传输硬件(图中的“ X”部分已偏移-一次只能进行一次传输)。这在启动部分给了我们一个小异常。一旦线程经过此阶段,由于传输时间比计算周期的1/4小得多,因此它们自然会与传输硬件同步。首先,忽略小异常,该系统的特征在于以下公式:

(Tcompute + Ttx + Twait)× num_x_lines / num_cpus

该公式指出,在四个CPU上使用四个线程将比我们刚开始使用的单线程模型快大约4倍。

通过结合从简单拥有多线程单处理器版本中学到的知识,我们理想地希望拥有比CPU多的线程,以便多余的线程可以“吸收”发送确认等待(和发送插槽)中的空闲CPU时间。竞争等待)。在这种情况下,我们将有以下内容:


20200724160258


八个线程,四个CPU。

此图假设以下几点:

  • 线程5、6、7和8绑定到处理器1、2、3和4(为简化起见)
  • 传输开始后,它的优先级高于计算
  • 传输是不间断的操作

从图中注意到,即使我们现在的线程数是CPU的两倍,我们仍然会遇到CPU使用率不足的地方。在该图中,CPU被“固定”在三个地方。这些在各个CPU利用率条形图中用数字表示:

  1. 线程1正在等待确认(“ W”状态),而线程5已完成计算并正在等待发送器。
  2. 线程2和线程6都在等待确认。
  3. 线程3在等待确认,而线程7已完成计算并在等待发送器。

此示例也是一个重要的教训—您不能只是继续增加CPU,希望事情会越来越快。有限制因素。在某些情况下,这些限制因素仅受多CPU主板的设计支配-当许多CPU尝试访问相同的内存区域时,会发生多少内存和设备争用。在我们的情况下,请注意“ TX插槽利用率”条形图开始变满。如果我们添加足够的CPU,它们最终将遇到问题,因为它们的线程将停滞,等待传输。

无论如何,通过使用“浸泡器”线程“吸收”备用CPU,我们现在具有更好的CPU利用率。该利用率接近:

(Tcompute + Ttx)× num_x_lines / num_cpus

在计算本身中,我们仅受拥有的CPU数量的限制;我们不会让任何处理器等待确认。(显然,这是理想的情况。如您在图中所看到的,有几次我们会定期使一个CPU处于空闲状态。此外,如上所述,

Tcompute + Ttx × num_x_lines

是我们能走多快的极限。)

使用SMP时需要注意的事项

通常,您可以简单地“忽略”您是在SMP架构上运行还是在单个处理器上运行,但是有些事情困扰您。不幸的是,它们可能是概率很低的事件,因此它们不会在开发过程中出现,而是在测试,演示或最坏的情况下出现:在现场。现在花点时间进行防御性编程可以节省将来的问题。

这是您将在SMP系统上遇到的各种问题:

  • 线程确实可以并且确实可以并发运行-依靠FIFO调度或优先级进行同步是不行的。
  • 线程和中断服务程序(ISR),也不要同时运行-这不仅意味着你将不得不保护从ISR线程,但你也必须保护从线程ISR。有关更多详细信息,请参见“ 中断”一章。
  • 您期望不是原子操作的某些操作取决于操作和处理器。在此列表中值得注意的操作的事情,做一个读-修改-写周期(如++--|=&=等)。请参阅包含文件 <atomic.h>以进行替换。(请注意,这并不是纯粹的SMP问题;大多数RISC处理器不一定都以原子方式执行上述代码。)

独立情况下的线程

如上面“线程是个好主意”部分中所讨论的,线程也可以在共享数据结构中出现许多独立处理算法的地方找到用处。严格来讲,您可以有多个进程(每个进程一个线程)显式共享数据,但在某些情况下,在一个进程中拥有多个线程要方便得多。让我们看看在这种情况下为什么以及在哪里使用线程。

对于我们的示例,我们将发展一个标准的输入/过程/输出模型。从最一般的意义上讲,模型的一部分负责从某处获取输入,另一部分负责处理输入以产生某种形式的输出(或控制),第三部分负责将输出馈送到某处。

多个过程

让我们首先从多进程,每进程一个线程的角度了解情况。在这种情况下,我们将有三个过程,从字面上看是输入过程,“处理”过程和输出过程:


20200724160313


系统1:多个操作,多个流程。

这是最抽象的形式,也是最“松散的耦合”。“输入”过程与“处理”过程或“输出”过程都没有真正的“绑定”,它只是负责收集输入并以某种方式将其提供给下一个阶段(“处理”阶段)。我们可以说与“处理”和“输出”过程相同-它们之间也没有真正的约束力。在此示例中,我们还假设通信路径(即,输入到处理和处理到输出数据流)是通过某些连接协议(例如管道,POSIX消息队列,本机Neutrino消息传递)完成的,随你)。

具有共享内存的多个进程

根据数据流的数量,我们可能需要优化通信路径。最简单的方法是使三个过程之间的耦合更紧密。现在,我们不再使用通用连接协议,而是选择共享内存方案(在图中,粗线表示数据流;细线表示控制流):


20200724160320


系统2:多种操作,进程之间共享内存。

在此方案中,我们加强了耦合,从而实现了更快,更有效的数据流。我们可能仍然使用“通用”连接协议来传递“控制”信息,但我们并不期望控制信息会消耗大量带宽。

多线程

最紧密耦合的系统由以下方案表示:


20200724160329


系统3:多个操作,多个线程。

在这里,我们看到一个具有三个线程的进程。这三个线程隐式共享数据区域。同样,控制信息可以像前面的示例中那样实现,或者也可以通过一些线程同步原语来实现(我们已经看到了互斥体,屏障和信号量;我们很快就会看到其他信息)。 )。

比较

现在,让我们使用不同的类别比较这三种方法,并且还将描述一些折衷方案。

对于系统1,我们看到了最松动的耦合。这样做的好处是,可以轻松地(即,通过命令行,而不是重新编译/重新设计)用不同的模块替换这三个进程中的每个进程。这自然而然,因为“模块化单位”是整个模块本身。系统1也是唯一可以在Neutrino网络中的多个节点之间分发的系统。由于通信路径是通过某种连接协议抽象的,因此很容易看出这三个过程可以在网络中的任何计算机上执行。这可能是非常强大的可伸缩性 设计的重要因素–您可能需要将系统扩展到具有地理分布的数百台计算机(或以其他方式(例如,外围硬件功能))并相互通信。

但是,一旦我们承诺共享内存区域,我们就会失去通过网络进行分发的能力。Neutrino不支持网络分布的共享内存对象。因此,在系统2中,我们有效地限制了自己在同一个盒子上运行所有三个进程。我们并没有失去轻松删除或更改组件的能力,因为我们仍然可以通过命令行控制单独的进程。但是我们增加了约束,即所有可移动组件都必须符合共享内存模型。

在系统3中,我们已经失去了所有上述能力。我们绝对不能在多个节点上的一个进程中运行不同的线程(不过,我们可以在SMP系统中的不同处理器上运行它们)。而且我们已经失去了可配置性方面-现在我们需要一种明确的机制来定义我们要使用的“输入”,“处理”或“输出”算法(我们可以使用共享库(也称为DLL)来解决)

那么,为什么要将我的系统设计为具有多个线程,如系统3?为什么不选择最大灵活性的系统1?

好吧,即使系统3是最不灵活的,它 很可能是最快的。没有针对不同进程中的线程的线程到线程上下文切换,我不必显式设置内存共享,也不必使用抽象的同步方法,例如管道,POSIX消息队列或消息传递到提供数据或控制信息-我可以使用基本的内核级线程同步原语。另一个优点是,当一个进程(具有三个线程)描述的系统启动时,我知道我需要的所有东西都已经从存储介质中加载了(即,以后我不会发现“糟糕,磁盘上缺少处理驱动程序!”)。最后,系统3也很有可能是最小的,因为我们将没有“过程”信息(例如文件描述符)的三个单独副本。

总结:知道什么是折衷方案,并使用对您的设计有效的方法。

有关同步的更多信息

我们已经看过:

现在,我们通过讨论以下内容来结束对同步的讨论:

读/写锁

读取器和写入器锁的使用恰恰是其名称的含义:多个读取器可以使用没有写入器的资源,或者一个写入器可以使用没有其他写入器或读取器的资源。

这种情况经常发生,足以保证专门用于此目的的特殊类型的同步原语。

通常,您将拥有由一堆线程共享的数据结构。显然,一次只能有一个线程写入数据结构。如果要写入多个线程,则这些线程可能会覆盖彼此的数据。为了防止这种情况的发生,写入线程将以独占方式获取“ 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(),现在它将阻塞,因为资源不再在该模式下可用。因此,如果我们不能锁定它,那么调用“ try”版本的线程仍然可能会阻塞!

最后,无论使用哪种锁,我们都需要某种释放锁的方法:

int
pthread_rwlock_unlock (pthread_rwlock_t *lock);

一旦线程完成了它想对资源执行的任何操作,它将通过调用pthread_rwlock_unlock()释放锁。如果现在该锁在与另一个等待线程请求的模式相对应的模式下可用,则该线程将变为READY。

请注意,我们不能仅使用互斥锁来实现这种形式的同步。互斥体充当单线程代理,这在编写情况下是可以的(在这种情况下,您一次只希望一个线程在使用该资源),但是在阅读情况下却是平淡的,因为只允许一个阅读器。信号量也不能使用,因为没有办法区分两种访问方式–信号量将允许多个读者,但是如果作家要获取信号量,就信号量而言,这没有什么不同从获得它的读者那里,现在您将面临多个读者和一个或多个作家的丑陋局面!

Sleepon锁

多线程程序中发生的另一种常见情况是需要线程等待“事情发生”。这个“东西”可以是任何东西!可能是这样的事实:现在可以从设备获得数据,或者传送带现在已经移至正确的位置,或者数据已经提交到磁盘,或者其他任何情况。这里要提出的另一种说法是,多个线程可能需要等待给定的事件。

为了实现这一点,我们将使用条件变量 (我们将在下面看到)或简单得多的“ sleepon”锁。

要使用sleepon锁,您实际上需要执行几个操作。让我们先看一下调用,然后看一下如何使用锁。

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);

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XqRyVp10-1595821740546)(…/…/…/…/…/…/…/…/pointing.gif)]不要被前缀*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标志,并且还可以可靠地设置该标志。

好,太棒了。现在,“ WAIT”呼叫呢?正如我们之前所建议的,实际上是pthread_sleepon_wait() 调用。这是第二个while循环:

        while (!data_ready) {
            pthread_sleepon_wait (&data_ready);
        }

该*pthread_sleepon_wait()*实际上做了三个不同的步骤!

  1. 解锁sleepon库互斥体。
  2. 执行等待操作。
  3. 重新锁定sleepon库互斥锁。

它必须解锁和锁定sleepon库的互斥锁的原因很简单-因为互斥锁的整个思想是确保互斥data_ready变量,所以这意味着我们希望锁定生产者,使其在我们使用时不接触data_ready变量。重新测试。但是,如果我们不执行操作的解锁部分,那么生产者将永远无法设置它来告诉我们数据确实可用!重新锁定操作纯粹是为了方便起见;这样,*pthread_sleepon_wait()*的用户不必在唤醒时担心锁的状态。

让我们切换到生产者端,看看它如何使用sleepon库。这是完整的实现:

producer ()
{
    while (1) {
        // wait for interrupt from hardware here...
        pthread_sleepon_lock ();
        data_ready = 1;
        pthread_sleepon_signal (&data_ready);
        pthread_sleepon_unlock ();
    }
}

如您所见,生产者也锁定互斥锁,以便它可以独占访问data_ready变量以进行设置。


[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-doseY4fm-1595821740547)(…/…/…/…/…/…/…/…/pointing.gif)]这是不是写的行为1,以DATA_READY能唤醒客户端!这是对pthread_sleepon_signal()的调用。

让我们详细研究会发生什么。我们将消费者和生产者州确定为:

含义
CONDVAR等待与sleepon相关的基础条件变量
MUTEX等待互斥
准备能够使用或已经使用CPU
打断等待来自硬件的中断
行动互斥体所有者消费者状态生产者状态
消费者锁互斥消费者准备打断
消费者检查data_ready消费者准备打断
消费者调用pthread_sleepon_wait()消费者准备打断
*pthread_sleepon_wait()*解锁互斥锁自由准备打断
*pthread_sleepon_wait()*块自由CONDVAR打断
时间流逝自由CONDVAR打断
硬件生成数据自由CONDVAR准备
生产者锁定互斥锁制片人CONDVAR准备
生产者设置data_ready制片人CONDVAR准备
生产者调用pthread_sleepon_signal()制片人CONDVAR准备
消费者醒来,*pthread_sleepon_wait()*尝试锁定互斥锁制片人MUTEX准备
生产者发布互斥体自由MUTEX准备
消费者互斥消费者准备准备
消费者处理数据消费者准备准备
生产者等待更多数据消费者准备打断
时间流逝(消费者处理)消费者准备打断
消费者完成处理,解锁互斥锁自由准备打断
消费者循环回到顶部,锁定互斥锁消费者准备打断

表中的最后一个条目是第一个条目的重复-我们已经完成了一个完整的周期。

data_ready变量的用途是什么?它实际上有两个目的:

  • 消费者和生产者之间的状态标志指示系统的状态。如果将其设置为1,则表示数据可供处理;如果将其设置为0,则表示没有数据可用,使用者应阻止。
  • 它充当“睡眠同步发生的地方”。更正式地,地址DATA_READY被用作唯一的标识符,用作用于sleepon锁会合对象。我们很容易就可以使用“ (void *) 12345”代替“ &data_ready”,只要标识符是唯一的并且始终如一地使用,sleepon库就真的不在乎了。实际上,在进程中使用变量的地址是生成进程唯一编号的保证方法—毕竟,进程中没有两个变量将具有相同的地址!

接下来,我们将“关于pthread_sleepon_signal()pthread_sleepon_broadcast()有什么区别”的讨论推迟到条件变量的讨论。

条件变量

条件变量(或“ condvars”)与我们上面刚刚看到的sleepon锁非常相似。实际上,sleepon锁是在condvars之上构建的,这就是为什么我们在sleepon示例的说明表中拥有CONDVAR状态的原因。值得重复的是,pthread_cond_wait()函数释放互斥锁,等待,然后重新获取该互斥锁,就像pthread_sleepon_wait()函数一样。

让我们跳过预备知识,并使用condvars重做sleepon部分中的生产者和消费者的示例。然后,我们将讨论通话。

/*
 * 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) {
        // get data from hardware
        // we'll simulate this with a 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");

    // create the producer and consumer threads
    pthread_create (NULL, NULL, producer, NULL);
    pthread_create (NULL, NULL, consumer, NULL);

    // let the threads run for a bit
    sleep (20);
}

与我们刚才看到的sleepon示例几乎相同,只是有所不同(我们还添加了一些printf()函数和main()以便程序可以运行!)。马上,我们看到的第一件事就是一个新的数据类型:pthread_cond_t。这只是条件变量的声明;我们称其为condvar

我们注意到的下一件事情是,消费者的结构与前面的sleepon示例中的消费者的结构相同。我们已将pthread_sleepon_lock()pthread_sleepon_unlock()替换为标准互斥体版本(pthread_mutex_lock()pthread_mutex_unlock())。将pthread_sleepon_wait()替换为pthread_cond_wait()。主要区别在于sleepon库中有一个深埋的互斥锁,而当使用condvars时,我们显式传递了互斥锁。这样,我们可以获得更多的灵活性。

最后,我们注意到我们有了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 data;

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, etc have the identical code.

在这种情况下,实际上哪个线程获取数据都没有关系,只要其中一个线程可以获取数据并对其执行某些操作即可。

但是,如果您有类似这样的内容,则情况会有所不同:

/*
 * 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);
        while (!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);
    }
}

在这些情况下,唤醒一个线程不会削减它!我们必须 唤醒所有三个线程,并让它们中的每一个进行检查,以查看其谓词是否得到满足。

这很好地反映了我们上面问题中的第二种情况(“为什么这些线程在等待?”)。由于线程都在不同的条件下等待(thread1()正在等待x小于或等于7或y为15,thread2() 正在等待x为素数,而thread3()正在等待)如果x等于y),我们别无选择,只能唤醒它们。

Sleepons与Condvars

相较于condvar,sleepon具有一个主要优势。假设您要同步许多对象。使用condvar,通常每个对象关联一个condvar。因此,如果您有M个对象,则很可能会有M个 condvar。使用sleepon,在线程等待特定对象时,动态分配基础condvar(在其上实现sleepon)。因此,使用具有M个对象和N个线程被阻止的sleepon ,您将(最多)拥有N个 condvar(而不是M)。

但是,condvars比sleepon更灵活,因为:

  1. 无论如何,sleepon是在condvars之上构建的。
  2. Sleepons将互斥量埋在库中;condvars允许您明确指定它。

第一点可能只是被认为是有争议的。:-)但是,第二点很重要。当互斥锁埋在库中时,这意味着每个进程只能有一个 -与该进程中的线程数或数据变量的不同“集合”的数目无关。这可能是一个非常有限的因素,尤其是当您考虑必须使用一个唯一的互斥体来访问进程中任何线程需要接触的所有数据变量时,尤其如此!

更好的设计是使用多个互斥锁,每个数据集使用一个互斥锁,并根据需要将它们与条件变量明确组合。这种方法的真正威力和危险在于,绝对没有编译时或运行时检查来确保您:

  • 在操作变量之前已锁定互斥锁
  • 对特定变量使用正确的互斥锁
  • 使用正确的condvar和适当的互斥量和变量

解决这些问题的最简单方法是进行良好的设计和设计审查,并从面向对象的编程中借鉴技术(例如,将互斥量包含在数据结构中,并使用例程来访问数据结构等)。当然,您采用的一种或两种用量不仅取决于您的个人风格,还取决于性能要求。

使用condvars时要记住的关键点是:

  1. 互斥锁将用于测试和访问变量。
  2. condvar将用作集合点。

这是一张照片:


20200724160422


一对一互斥和condvar关联。

一个有趣的笔记。既然没有检查,你可以做这样的事情的变量关联一组与互斥体“ABC”和另一组与互斥变量“DEF”,而关联的两个组与condvar变量“ABCDEF”


20200724160429


多对一互斥和condvar关联。

这实际上非常有用。由于互斥锁始终用于“访问和测试”,因此这意味着无论何时要查看特定变量,都必须选择正确的互斥锁。足够公平-如果我正在检查变量“ C”,则显然需要锁定互斥锁“ MutexABC”。如果我更改了变量“ E”怎么办?好吧,在更改它之前,我必须获取互斥锁“ MutexDEF”。然后,我对其进行了更改,然后点击condvar“ CondvarABCDEF”以将更改告知他人。此后不久,我将释放互斥量。

现在,考虑发生了什么。突然,我有一堆线程正在等待“ CondvarABCDEF”,这些线程现在已经从它们的pthread_cond_wait()中唤醒了 。等待功能立即尝试重新获取互斥量。这里的关键点是要获取两个互斥量。这意味着在SMP系统上,可以运行两个并发的线程,每个线程都使用独立的互斥体检查它认为是独立变量的情况。酷吧?

其他中微子服务

Neutrino让您做其他优雅的事情。POSIX表示,互斥锁必须在同一进程中的线程之间进行操作,并允许一致的实现对其进行扩展。Neutrino通过允许互斥锁在不同进程中的线程之间进行操作来扩展此功能。要理解为什么它起作用,请回想一下,所谓的“操作系统”确实有两个部分:处理调度的内核和担心内存保护和“进程”的进程管理器(除其他外) 。互斥锁实际上只是线程之间使用的同步对象。由于内核仅担心线程,因此它实际上不在乎线程是否在不同的进程中运行-这是进程管理器的问题。

因此,如果您在两个进程之间设置了一个共享内存区域,并且已在该共享内存中初始化了一个互斥锁,则不会阻止您通过该互斥锁同步这两个(或更多!)进程中的多个线程。相同的*pthread_mutex_lock()pthread_mutex_unlock()*函数仍将起作用。

线程池

Neutrino添加的另一件事是线程池的概念。您通常会在程序中注意到希望能够运行一定数量的线程,但同时也希望能够在特定限制内控制那些线程的行为。例如,在服务器中,您可能决定最初只应阻塞一个线程,等待来自客户端的消息。当该线程收到消息并停止为请求提供服务时,您可能会决定创建另一个线程是个好主意,这样就可以阻止它等待另一个请求到来。然后,该第二个线程将可用于处理该请求。等等。一段时间后,在为请求提供服务后,您现在将有大量线程在等待更多的请求。为了节省资源,

实际上,这是常见的操作,Neutrono提供了一个库来帮助您完成此操作。我们将在“ 资源管理器”一章中再次看到线程池功能。

对于随后的讨论很重要,要认识到线程(在线程池中使用)实际上执行两个不同的操作:

  • 阻塞(等待操作)
  • 处理操作

阻塞操作通常不会消耗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);

int
thread_pool_control (thread_pool_t *pool,
                     thread_pool_attr_t *attr,
                     uint16_t lower,
                     uint16_t upper,
                     unsigned flags);

从提供的功能中可以看到,首先使用thread_pool_create()创建线程池定义,然后通过thread_pool_start()启动线程池。处理完线程池后,可以使用thread_pool_destroy()自行清理。请注意,您可能永远都不会调用thread_pool_destroy(),就像程序是“永远”运行的服务器一样。所述 thread_pool_limits()函数是用来指定线程池的行为和调整线程池的属性,并且thread_pool_control()函数是用于一个便利的包装thread_pool_limits()函数。

因此,要查看的第一个函数是thread_pool_create()。它带有两个参数attrflags。的 ATTR是一个属性结构,用于定义(从线程池的操作特性<sys/dispatch.h>):

typedef struct _thread_pool_attr {
    // thread pool functions and handle
    THREAD_POOL_HANDLE_T    *handle;

    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);

    // thread pool parameters
    pthread_attr_t          *attr;
    unsigned short          lo_water;
    unsigned short          increment;
    unsigned short          hi_water;
    unsigned short          maximum;
} thread_pool_attr_t;

我将thread_pool_attr_t类型分为两部分,一部分包含线程池中线程的功能和句柄,另一部分包含线程池的操作参数。

控制线程数

首先,让我们看一下“线程池参数”,以了解如何控制将在此线程池中运行的线程的数量和属性。请记住,我们将讨论“阻塞操作”和“处理操作”(当我们查看标注函数时,我们将看到它们之间的关系)。

下图说明了lo_waterhi_watermaximum 参数的关系:


20200724160504


使用线程池时的线程流。

(请注意,“ CA”是context_alloc()函数,“ CF”是context_free()函数,“阻塞操作”是block_func()函数,“处理操作”是handler_func()。)

  • 属性

    这是在线程创建过程中使用的属性结构。上面我们已经讨论了这种结构(在“线程属性结构”中)。您会记得,这是控制有关新创建线程的事物的结构,例如优先级,堆栈大小等。

  • lo_water

    阻塞操作中至少应始终有lo_water线程。例如,在典型的服务器中,这将是等待接收消息的线程数。如果阻塞操作中的lo_water线程少于(例如,因为我们刚收到一条消息,并且已经对该消息进行了处理操作),则根据递增参数创建更多的线程。在图中,第一步用“创建线程”表示。

  • 增量

    指示如果阻塞操作线程数下降到lo_water以下,则一次应创建多少个线程。在确定如何为此选择一个值时,您最有可能从开始1。这意味着,如果阻塞操作中的线程数下降到lo_water以下,则线程池将再创建一个线程。要微调为增量选择的数字 ,您可以观察流程的行为并确定该数字是否需要为除1以外的其他任何数字。例如,如果您发现您的过程收到了请求的“突发”事件,那么您可以决定一旦降至lo_water以下 阻塞操作线程,您可能会遇到这种“突发”请求,因此您可能决定一次请求创建多个线程。

  • hi_water

    指示应在阻塞操作中使用的线程数上限。随着线程完成其处理操作,它们通常将返回到阻塞操作。但是,线程池库会统计当前正在阻塞操作中的线程数,如果该数量超过hi_water,则线程池库将杀死导致溢出的线程(即,刚刚结束并被占用的线程)。即将返回到阻止操作)。在图中将其显示为“处理操作”块中的“拆分”,一条路径转到“阻塞操作”,另一路径转到“ CF”以破坏线程。lo_waterhi_water的组合因此,允许您指定一个范围,该范围指示阻塞操作中应有多少个线程。

  • 最大值

    指示由于线程池库而将同时运行的绝对最大线程数。例如,如果由于lo_water 标记下溢而创建线程,则maximum参数将限制线程总数。

控制线程的另一个关键参数是传递给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_waterhi_water增量和线程池控制结构的最大成员:

/*
 * part of 	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't thread_pool_create, errno %s\n",
                 progname, strerror (errno));
        exit (EXIT_FAILURE);
    }

    thread_pool_start (tpp);
    …

设置成员后,我们调用thread_pool_create()创建一个线程池。这将返回一个指向线程池控制结构(tpp)的指针,我们将其检查为NULL(这将指示错误)。最后,我们使用tpp线程池控制结构调用thread_pool_start()

我指定了POOL_FLAG_USE_SELF,这意味着调用thread_pool_start()的线程将被视为线程池的可用线程。因此,在这一点上,线程池库中只有一个线程。由于我们的lo_water 值为3,因此该库会立即创建线程的增量数(在这种情况下为2)。此时,库中有3个线程,所有3个线程都处于阻塞操作中。所述lo_water条件满足时,因为至少有,在阻塞操作线程的数目; 所述hi_water条件被满足,因为有小于该数目在阻塞操作的线程; 最后, 最大 条件也得到满足,因为线程池库中的线程数不超过该数量。

现在,阻止操作中的线程之一解除阻止(例如,在服务器应用程序中,收到消息)。这意味着现在三个线程之一不再处于阻塞操作中(相反,该线程现在处于处理操作中)。由于阻塞线程的数量少于 lo_water,因此它将触发lo_water触发器,并使库创建增量(2)线程。因此,现在总共有5个线程(阻塞操作中有4个线程,处理操作中有1个线程)。

更多线程解除阻塞。假设处理操作中的任何线程都没有完成它们的任何请求。下表说明了这一点,从初始状态开始(如上图“使用线程池时的线程流”所示,我们将“ Proc Op”用于处理操作,将“ Blk Op”用于阻塞操作)。 。”):

事件程序操作Blk Op
初始01个1个
lo_water旅行033
解除封锁1个23
lo_water旅行1个45
解除封锁235
解除封锁325
lo_water旅行347
解除封锁437
解除封锁527
lo_water旅行549
解除封锁639
解除封锁729
lo_water旅行7310
解除封锁8210
解除封锁91个10
解除封锁10010

如您所见,该库始终检查lo_water 变量并一次创建增量线程,直到 达到最大变量的限制为止(就像“ Total”列达到10时所做的那样—即使创建了更多线程,即使计数下溢了lo_water)。

这意味着在这一点上,在阻塞操作中没有更多的线程在等待。假设线程现在正在完成他们的请求(来自处理操作);观察hi_water触发器会发生什么:

事件程序操作Blk Op
完成时间91个10
完成时间8210
完成时间7310
完成时间6410
完成时间5510
完成时间4610
完成时间3710
完成时间2810
水上旅行279
完成时间1个89
水上旅行1个78
完成时间088
水上旅行077

注意,在我们完成对hi_water 触发器的跳转**之前,在线程处理完成期间实际上什么都没有发生。其实现是,线程完成后,它将查看接收到的阻塞线程的数量,并决定是否在那一点等待了太多(即,超过hi_water)杀死自己。关于lo_waterhi_water好处 结构上的限制是,您可以有效地拥有一个“操作范围”,其中有足够数量的线程可用,并且您不必不必要地创建和销毁线程。在我们的情况下,在完成上表中的操作之后,我们现在有了一个系统,可以同时处理多达4个请求,而无需创建更多线程(7-4 = 3,这是lo_water行程)。

线程池功能

现在我们对如何控制线程数有了一个很好的了解,让我们将注意力转移到线程池属性结构的其他成员上(从上面):

    // thread pool functions and handle
    THREAD_POOL_HANDLE_T    *handle;

    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)
        kill thread
    ENDIF
DONE

注意,以上内容已大大简化;它的唯一目的是向您显示ctp处理参数的数据流,并对控制线程数的算法有所了解。

计划与现实世界

到目前为止,我们已经讨论了调度算法和线程状态,但是对于为什么以及何时重新调度事情,我们还没有说太多。有一个普遍的误解,认为重新安排只是“发生”,没有任何真正的原因。实际上,这是设计过程中的有用抽象!但是了解导致重新计划的条件很重要。调用图“计划路线图”(在“内核的角色”部分中)。

重新安排时间由于以下原因而发生:

  • 硬件中断
  • 内核调用
  • 过错

重新安排—硬件中断

由于硬件中断而发生的重新调度在两种情况下发生:

  • 计时器
  • 其他硬件

实时时钟为内核生成定期中断,从而导致基于时间的重新调度。

例如,如果您发出sleep (10);呼叫,则将发生许多实时时钟中断。内核在每个中断处增加时钟时间。当一天中的时钟指示已过去10秒时,内核会将您的线程重新安排为READY。(这在“时钟,计时器和时常得到踢动”一章中进行了详细讨论。)

其他线程可能会等待外围设备的硬件中断,例如串行端口,硬盘或声卡。在这种情况下,它们被阻止在内核中等待硬件中断。只有在生成“事件”之后,内核才会重新计划线程。

重新计划—内核调用

如果重新安排是由发出内核调用的线程引起的,则立即进行重新安排,可以将其视为与计时器和其他中断异步。

例如,上面我们叫sleep(10);。最终,此C库函数被转换为内核调用。那时,内核做出了重新调度的决定,将您的线程从该优先级的READY队列中删除,然后调度另一个线程READY。

有许多内核调用会导致进程重新调度。其中大多数是相当明显的。这里有一些:

重新安排-例外

重新调度的最终原因(CPU故障)是异常,介于硬件中断和内核调用之间。它与内核异步运行(如中断),但与导致它的用户代码同步运行(如内核调用-例如,零除异常)。与上面相同的讨论(针对硬件中断和内核调用)适用于故障。

摘要

Neutrino提供了一组丰富的带有线程的调度选项,线程是主要的调度元素。进程定义为资源所有权的单位(例如,内存区域),并包含一个或多个线程。

线程可以使用以下任何同步方法:

  • 互斥锁—在给定的时间点仅允许一个线程拥有该互斥锁。
  • 信号灯-允许固定数量的线程“拥有”该信号灯。
  • sleepons —允许多个线程阻塞多个对象,同时将基础condvar动态分配给被阻塞的线程。
  • condvars —与sleepons相似,但condvars的分配由程序员控制。
  • join —允许一个线程与另一个线程的终止同步。
  • barriers-允许线程等待,直到多个线程到达同步点。

请注意,互斥量,信号量和条件变量可以在相同或不同进程中的线程之间使用,但是sleepon只能在同一进程中的线程之间使用(因为库在进程的地址空间中具有“隐藏”的互斥体) 。

除了同步之外,还可以对线程进行调度(使用优先级和调度算法),它们将自动在单处理器盒或SMP盒上运行。

每当我们谈论创建“进程”(主要是从单线程实现中移植代码的一种手段)时,我们实际上是在创建一个运行着一个线程的地址空间,该线程始于 *main()fork( )vfork()*取决于所调用的函数。


  • 5
    点赞
  • 37
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

擦擦擦大侠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值