1.进程是什么
(1)来源:
进程(Process)的概念首先在60年代由麻省理工学院的MULTICS系统和IBM公司的CTSS/360系统引入。
(2)意义:
程序是我们时常挂在嘴边的一个词汇,但程序本身是一个没有生命的实体,只有当处理器赋予程序生命时,它才能成为一个活动的实体,此时我们就将其称作进程。
在操作系统中,多道程序需要共享系统资源,从而导致各程序在执行过程中出现相互制约的关系。为了刻画系统内部的动态情况,描述系统中各道程序的活动规律,进程的概念便被引入,这也是它的意义所在。因此,进程是操作系统中一个最基本、最重要的概念。
(3)定义:
从理论上讲,进程是对正在运行的程序的一种抽象化的表现形式,也可以通俗的说就是一个“运行中的程序”;
从现实角度看,进程是一种数据结构,目的在于清晰地刻画动态系统的内在规律,有效管理和调度进入计算机系统主存储器运行的程序。
那么如何准确表述进程的定义呢?我觉得以下表述清晰而严谨:
进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。
这一定义清晰且严谨地表达出了进程的两大要素,一是具有一定独立功能的程序,二是该程序在某个数据集合上的一次运行活动。这两者缺一不可。
2.进程包含什么资源
上面从来源、意义和定义三方面解释了进程这货到底是啥,可是我们还会困惑:在操作系统中,一个进程具体而言包含哪些数据?
是的,这个问题我从未认真考虑过,我相信《操作系统》那门课上会有清晰准确的描述,whatever,我错过了那门课。不过现在了解也不迟。
(1)进程的组成部分
进程包含以下四个部分,进程控制块、文本区域(也称代码段)、数据区域(也称数据段)和堆栈。
进程控制块(PCB)是操作系统内核为了管理进程而专门设置的一种数据结构,用于表示进程的外部特征,记录进程的状态,描述进程的运动变化过程。对操作系统而言,PCB是用来观察和管理进程的眼睛,是系统感知进程存在的唯一标志。PCB中包含以下部分:进程标识符(操作系统用于识别进程的唯一标识)、处理机状态(主要是通用寄存器,指令寄存器,PSW和用户栈指针) 、进程调度信息(状态、优先级,被阻塞原因和其他一些乱七八糟的东西) 和进程控制信息。准确地说,PCB属于操作系统,但它与进程是一一对应的,故此处将其列出。
文本区域(代码段)主要用于存储处理器执行的程序的二进制代码。很显然,这部分内存区域的大小在程序执行之前就已经确定,并且通常是只读属性(某些架构中也可能允许运行中修改代码,那么就是可写)。代码段除了存储程序的二进制代码外,还会有一个rodata区,用于存储常量,如char *s = "helloworld",这其中的字符串常量(注意末尾的'\0')helloworld\0就存储在代码段的常量区。此外,全局或静态的const变量也会存储在常量区,如static const char *s = "helloworld",除了helloworld\0存储在rodata段外,指向它的指针s也是如此。而局部const变量则是存储在栈中。
数据区域(数据段)包括了所有静态分配的数据空间,也称为静态区。其中的变量通俗来讲就是程序中的全局变量和所有申明为static的局部变量,这些空间是进程所必需的基本要求,因此,当操作系统生成一个程序的运行映像时,这些空间也就同时分配了。值得注意的是,数据段中已初始化静态变量放在一块连续的内存中,未初始的静态变量放在另一个块连续的内存中(这一区域成为BSS段,BSS全称Block Started by Symbol)。这两块内存相互连接,但各占一边。也有说法是数据段保存已初始化的静态变量,BSS段保存未初始化的静态变量。考虑到内存中这两块实际连在一起,暂且都归到数据区域吧,并不影响理解。
堆栈包括了堆区(heap)和栈区(stack)两部分,程序猿们实际触手可及的地方,再熟悉不过了。栈区存放函数的参数值和局部变量,其操作方式类似于数据结构中的栈(后入先出)。堆区由程序员分配和释放,程序结束后有可能由操作系统收回,分配方式类似于链表(因为实际分配的内存可能并不连续,是一块一块连续的小内存利用内存地址联系在一起)。
(2)进程的内存组织
以上代码段、数据段和堆栈段都指代了一块具体内存区域,他们的组织如下图所示。其中代码段在最下方,往上走是数据段,最上方是堆栈段。可以发现,栈区和堆区之间有一个巨大“空洞”,这一空洞就代表了程序猿们可以动态分配的堆区内存。相比于VC默认的几Mbytes的栈区,堆区可达几Gbytes。
(3)不得不说的代码段
关于代码段,自己有一些困惑,此处也不避讳,mark一下后期再补上。这个困惑就是,同一程序的多个进程是否共用代码段呢?如果是的话,如何共用?
3.进程的状态
多进程的执行,实际上是将所有进程都拆成碎片,插入到CPU快速的执行过程中。进程执行的间断性,决定了进程可能具备多种状态以淡定应对支离破碎的生活。
事实上,进程的确有三种状态:就绪(Ready)、执行(Running)和阻塞(Blocked)。这三个状态应该也是老生常谈的东西了,就用借来的一张图表示一下他们之间的转换吧。
下图中:1代表就绪态的进程获得了处理器资源进入执行态,2代表执行态的进程时间片用完而回到就绪态,3代表执行态的进程等待某种条件而被挂起并进入阻塞态,4代表阻塞态的进程等待结束,再次进行就绪态,等待处理器分配资源。
4.进程间通信
不同进程之间存在着间接和直接的关系,关系包括竞争和协作。间接的竞争在于所有进程共享CPU资源,所谓的并发执行实际上是充分利用CPU的高速计算能力,大家共享CPU的资源,你来我往,见缝插针式地获取CPU资源,完成进程所需的计算;直接的竞争在于某些进程会争用同一资源,A进程正在改写某资源时B进程只能处在阻塞状态;直接的协作在于某些进程必须获得其他进程的运行结果才能继续进行下去。
因此,为了有效解决进程间直接的竞争和协作,进程间通信机制即IPC(Inter-Process Communication)就显得尤为重要。由于Linux被广泛作为商业系统后台,故本文以Linux为例说明进程间的主要通信方式。其他操作系统可能会略有差异,比如Windows提供了一些特别的进程通信方式(如COM、COM+、DCOM、OLE),此处就不多叙述。
总体来讲,进程间通信主要有以下方式:管道(匿名或命名)、信号、信号量、共享内存、消息队列、套接字。
(1)管道(Pipe/FIFO)
管道是单向的、先进先出的、无结构的、固定大小的字节流,它把一个进程的标准输出和另一个进程的标准输入连接在一起。对于亲属进程,可使用匿名管道(Pipe)来完成通信;对于非亲属进程,必须使用命名管道(FIFO)来进行通信。为了实现两个进程的双向通信,需要建立两个管道,因为管道本身是单向的。
(2)信号(Signal)
信号是软件中断,是来自于Unix系统的一种比较古老的通信机制。它用于在一个或多个进程之间传递异步信号。操作系统内核为进程生产信号,来响应不同的事件,这些事件就是信号源。
主要的信号源有:
硬件来源,如键盘操作或其他硬件故障;
软件来源,如常用系统函数kill,raise,alarm,setitimer,sigquene,abort,以及非法运算操作(如除零)。
进程在某个信号出现时可按照以下三种方式之一进行操作:
忽略此信号:大多数信号可以进行此操作,但有两种信号却决不能被忽略:SIGKILL和SIGSTOP。
捕捉信号:即对特定信号通知内核调用特定用户函数。
执行系统默认操作:每一个信号都有一个缺省动作,它是当进程没有给这个信号指定处理程序时,内核对信号的处理。对大多数信号,系统的默认操作是终止该进程。缺省动作一共有五种:异常终止(abort)、退出(exit)、忽略(ignore)、停止(stop)和继续(continue)。
(3)信号量(Semaphores)
诚实地讲,在楼主靠死记硬背来应对面试中有关进程通信的问题的阶段,信号和信号量一直是我脑海中挥之不去的两个容易混淆的概念。其实他们两者基本毫无可比性。因为信号是一种软中断,而信号量简单来讲是对临界资源的访问控制。下面详细道来。
进程之间的直接竞争和协作,也可以称为互斥和同步关系。究其根本,就是他们都与某些资源相关联,这些资源称之为临界资源。
临界资源是同一时刻只允许有限个(通常只有一个)进程可以访问(读)或修改(写)的资源,包括硬件资源(处理器、内存、存储器以及其他外围设备等)和软件资源(共享代码段,共享结构和变量等)。此外,临界区本身也是一种临界资源。
信号量一般是一个非负的整数,对应于某一种资源。最简单的信号量只有0和1两个值,也成为二维或二值信号量。具有不止一个正数值的信号量成为通用信号量。
信号量的值指的是当前可用资源的数量,若它等于0则意味着目前没有可用的资源。
可以这样理解信号量的值:当临界资源中存在可用资源时,信号量为true,其值的大小表示了有多少个可用资源;当临界资源中不存在可用资源时,信号量变为false。
当进程需要使用临界资源时,对于其信号量变量sv,有两个基本操作P和V:
P(sv):申请资源,如果sv大于0,减小sv。如果sv为0,挂起这个进程的执行;
V(sv):释放资源,如果有进程被挂起等待sv,使其恢复执行。如果没有进行被挂起等待sv,增加sv。
(4)共享内存
共享内存是最容易从字面上理解的进程间通信方式了。是的,它的原理就是为无关进程开辟一块可以共同访问的逻辑内存,进而为两个运行的进程之间传递数据提供有效手段。但由于共享内存并不提供同步的方式,比如并没有自动的方法来阻止在第一个进程完成写入共享内存之前第二个进程就开始读取共享内存。所以,我们通常需要其他机制来同步对共享内存的访问,比如传递少量消息,利用互斥量和信号量等。
(5)消息队列
消息队列就是一个消息的链表。就是把消息看作一个记录,并且这个记录具有特定的格式以及特定的优先级。对消息队列有写权限的进程可以按照一定的规则添加新消息,对消息队列有读权限的进程则可以从消息队列中读出消息。消息的发送方不必等待接收方检查它所收到的消息就可以继续工作下去,而接收方如果没有收到消息也不需等待。这种通信机制相对简单,但是应用程序使用起来就需要使用相对复杂的方式来应付了。新的消息总是放在队列的末尾,接收的时候并不总是从头来接收,可以从中间来接收。
消息队列由于实际没有使用过,所以理解起来略微生涩。我个人的通俗理解是,消息队列可以看成学生宿舍门口的告示板。
管理员大妈和管理员大叔是两个进程,他们有写权限,且有一定优先级。比如大妈每次都是一大早过来写上当天的天气信息,大叔则是中午过来写上邮件信息在上面,大妈的优先级略高。每个经过告示板的同学也可以看做一个个的进程,他们有读权限,也有一定优先级,大体上就是先到的先看,后到的后看(不然还能肿么样)。
用以上的例子还可以解释消息队列所谓的“消息的发送方不必等待接收方检查它所收到的消息就可以继续工作下去,而接收方如果没有收到消息也不需等待”。以上的告示板,大妈大叔写完之后该干啥干啥去了,大妈嗑着瓜子,大叔抽着烟。同学们没有看到告示板,生活也很依然进行着,不会挂起和阻塞着直到看到大妈大叔写告示板(那样的话大妈大叔该有多感动),而是上课的上课,打dota的打dota。当然,告示板的作用也是明显的,部分同学看到天气变化后觉得该添衣减衣就添衣减衣,还有些同学看到自己的邮件来了,就去大叔那里取邮件。
好了,如此通俗的例子,希望没有理解错消息队列。不过我真想吐槽一下,这种机制对于程序辕来说,使用起来的确需要小心翼翼,慎之又慎。总之我是没有用过。
(6)套接字(Socket)
套接字可以实现网络中不同主机之间的进程通信,同样也可以实现同一主机上不同进程之间的通信,只需要将目的IP设置为127.0.0.1(即本机IP)。套接字根据地址可以寻址到主机,根据端口号则可以寻址到主机提供特定服务的进程,因此在同一机器上完全可以实现进程通信。
若使用TCP协议进行通信,需要双方进程各自绑定一个唯一标识自己的套接口,以便建立连接;若使用UDP传输协议,只需要服务器绑定一个标识自己的套接口即可,客户端则不需要。
这里回顾一下进行socket传输的步骤吧(以常用的基于TCP协议的socket为范例):
STEP1-创建套接口 --> STEP2-绑定地址&端口 --> STEP3-TCP客户端请求建立连接 --> STEP4-TCP服务端接受连接请求 --> STEP5-通信 --> STEP6-关闭套接口
(7)进程间通信总结
以上6种进程间通信方式各有特点。管道是一种基于文件系统的通信方式,信号是软件层面对中断的模拟,信号量、消息队列、共享内存是基于系统内核的通信方式,其中信号量也可用于同一进程下多线程间的通信,套接字则是以上所有方式中唯一不局限于系统的——它可以实现不同主机之间的进程通信(是不是酷毙了)。
好了,希望以后需要用到进程间通信的你们和我,将来看到以上内容,可以选择合适的方式来完成我们的“进程间通信伟业”。
5.线程是什么
你可能会问,既然有了进程,可以实现分时操作,为何还要有线程?
上面详细介绍了进程的前世今身,可以将其简要总结为操作系统分配资源的最小单位。对于某些任务来说,需要并行操作,但不需要花费那么大的开销(这个开销指的是建立若干个具有独立资源的进程)。这个时候,就需要一些更加轻量级的单元,既能独立完成特定功能,又可以共享大量资源,一方面节约系统资源,一方面提高共享性(某些并行任务的完成本身就需要共享大量资源)。所以,一个更轻量级的进程诞生了,那就是线程。
进程是操作系统分配资源的最基本单元,线程是操作系统调度的最基本单元。
线程(Thread),又称轻量级进程,是程序中一个单一的顺序控制流程。一个进程最少包含一个线程。如果只有一个,那就是他本身。
6.线程的资源
在上面的第2部分,详细介绍了进程所含的数据,那么一个线程具有什么资源呢?
线程的创建和打开一个文件资源类似,都会有一个控制块,称为TCB(Thread Control Block),与进程的PCB相对应。
设计线程就是为了轻量级的并行调用,所以线程只包含一些运行所必须的资源,放在线程的TCB中。这些资源包括:
线程标识符(Thread ID):线程的唯一ID号;
程序计数器(Program Counter):记录线程当前指令的下一条指令;
寄存器集(Register Set):保存CPU中寄存器的值,用于恢复线程;
栈(Stack):用于保存运行产生的局部变量。
值得注意的是线程没有自己的堆,使用new、malloc所分配的动态内存并不属于线程,而是在进程的堆中开辟内存。
7.线程的状态
线程亦具有进程的三种状态,即就绪、执行和阻塞,原理同进程,故不再赘述。
8.线程间同步
上面花费了很多文字介绍进程间的通信机制,而这一小结使用的则是线程间同步,为何不叫通信?这是因为同一进程下的不同线程共用了大量资源:该进程的代码区、数据区和堆区。因此一个线程可以轻易获取到其他线程的资源(除了栈中的数据,显然这个不可能获取,也不符合程序设计的逻辑),此时需要的并不是通信,而是同步。
这个同步有两层意思,一层意思是,对于某些同一时间内只允许有限个线程访问的资源,需要进行互斥处理,但互斥只决定了访问的唯一性和排他性,没有决定顺序;另一层意思是,在互斥的基础上,需要决定谁先访问,谁后访问,即访问的顺序。这两点就构成了同步。
线程之间的同步可以利用以下几种方式:
(1)全局变量(Global Variable)
由于全局变量被统一进程的所有线程所共有,这自然成为了一种最为简便的线程同步方式。
值得注意的是,最好将全局变量设置为volatile属性,以防止编译器优化而导致逻辑错误。
(2)临界区(Critical Section)
临界区是一段同一时刻只允许单独占用的数据或代码,在任意时刻只允许一个线程对共享资源进行访问。
如果有多个线程试图同时访问临界区,那么在有一个线程进入后其他所有试图访问此临界区的线程将被挂起,并一直持续到进入临界区的线程离开。临界区在被释放后,其他线程可以继续抢占,并以此达到用原子方式操作共享资源的目的。
临界区通过对多线程的串行化来访问公共资源或一段代码,仅在能进程内使用,因此速度快,适合控制数据访问。
(3)互斥量(Mutex)
为协调对一个资源的单独访问而设计。其作用于临界区非常相似,但仍有以下区别:
① 互斥量是可以命名的,使用互斥不仅仅能够在同一进程的不同线程中实现资源的安全共享,而且可以在不同进程的线程之间实现对资源的安全共享。这些是临界区做不到的。
② 由于互斥量比临界区复杂,也就是说它可以跨越进程使用,所以创建互斥量需要的资源更多,所以如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量。
(4)信号量(Semaphores)
为控制一个具有有限用户的资源而设计,这里的有限可以是1(那么就基本退化为互斥量),也可以是N(N>1)。所以信号量可以理解为升级版互斥量(如有不同意见还请提出)。
(5)事件(Event)
用于通知线程某些操作已经完成,从而启动后继任务。事件对象也可以通过通知操作的方式来实现不同进程中的线程同步操作。
9. 写在后面的话
关于进程和线程,花了快一天的时间做了一个详细的总结。其中的精髓其实在于如何让进程和线程进行协同工作。
协同工作包含通信、互斥和同步三个层面。通信是为了传递数据,互斥是为了不起争执,同步是为了井井有条。
对进程而言,首先要建立有效的通信机制,因为进程之间本身是无法直接通信的,犹如两个人坐在两个相邻的屋子里,协同工作的前提是两人得能够互相交流。进程可以使用共享内存、管道、消息队列、信号、套接字来实现通信,在此基础上,一般使用信号量的PV操作实现互斥和同步。
对线程而言,无需特意建立通信机制,因为线程之间本身就共享大量资源,犹如两个人坐在一间屋子里,你可以使用我的剪刀,我可以使用你的胶水,来共同制作一件工艺品。这里说到的剪刀和胶水就可以理解为同来沟通的全局变量以及其他可共享的数据。此事,就可以直接考虑如何进行互斥和同步,常用的方法有临界区、互斥量、信号量(升级版互斥量)和事件。当然,不同进程的线程之间仍需要建立通信机制来交换数据,这可以借助进程层面的通信来解决。
—————————————————————————————————————————————————————————————————