一、Linux多任务机制
1、多任务机制简介
多任务处理指的是用户可以在同一时间内运行多个应用程序,每个正在执行的应用程序被称为“任务”。相比于单任务的操作系统(例如早期的MS-DOS),当代绝大多数操作系统都支持多任务,功能增强了很多。
但是,CPU(每个核心)在某一时刻只能执行一个任务,因此多任务操作系统必须解决CPU核心(单任务线性)与操作系统的任务(多任务并行)之间的矛盾。常见的解决方案是将CPU的运行分解成时间片(几十毫秒到上百毫秒不等),每个任务被分配不同的时间片来独占CPU进行运算。在该任务的时间片内,CPU被该任务独占,其他任务无法占用;在该任务的时间片外,CPU被其他任务独占,该任务也无法占用该CPU。由于CPU计算速度十分快且会频繁切换任务,因此用户感觉到当前操作系统是在“并行”的。
因此,多任务操作系统需要解决各个任务间分配时间片的调度策略,对于某些重要的、耗时较长的任务需要多分配时间片,而对于不重要的、耗时较短的任务需要少分配时间片。
2.调度
进程都希望自己能够占用 CPU 进行工作,那么这涉及到进程上下文切换。
一旦操作系统把进程切换到运行状态,也就意味着该进程占用着 CPU 在执行,但是当操作系统把进程切换到其他状态时,那就不能在 CPU 中执行了,于是操作系统会选择下一个要运行的进程。
选择一个进程运行这一功能是在操作系统中完成的,通常称为调度程序(scheduler)。
那到底什么时候调度进程,或以什么原则来调度进程呢?
调度时机
在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。
比如,以下状态的变化都会触发操作系统的调度:
- 从就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
- 从运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须另外一个进程运行;
- 从运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;
因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。
另外,如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:
- 非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
- 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制。
调度原则
原则一:如果运行的程序,发生了 I/O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回。这样的过程,势必会造成 CPU 突然的空闲。所以,为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。
原则二:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低。所以,要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。
原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。
原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。
原则五:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。
针对上面的五种调度原则,总结成如下:
五种调度原则:
- CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
- 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
- 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好;
- 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
- 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。
说白了,这么多调度原则,目的就是要使得进程要「快」。
调度算法
不同的调度算法适用的场景也是不同的。
接下来,说说在单核 CPU 系统中常见的调度算法。
01 先来先服务调度算法
最简单的一个调度算法,就是非抢占式的先来先服务(First Come First Severd, FCFS)算法了。
顾名思义,先来后到,每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。
这似乎很公平,但是当一个长作业先运行了,那么后面的短作业等待的时间就会很长,不利于短作业。
FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。
02 最短作业优先调度算法
最短作业优先(Shortest Job First, SJF)调度算法同样也是顾名思义,它会优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。
这显然对长作业不利,很容易造成一种极端现象。
比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
03 高响应比优先调度算法
前面的「先来先服务调度算法」和「最短作业优先调度算法」都没有很好的权衡短作业和长作业。
那么,高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。
每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,「响应比优先级」的计算公式:
从上面的公式,可以发现:
- 如果两个进程的「等待时间」相同时,「要求的服务时间」越短,「响应比」就越高,这样短作业的进程容易被选中运行;
- 如果两个进程「要求的服务时间」相同时,「等待时间」越长,「响应比」就越高,这就兼顾到了长作业进程,因为进程的响应比可以随时间等待的增加而提高,当其等待时间足够长时,其响应比便可以升到很高,从而获得运行的机会;
04 时间片轮转调度算法
最古老、最简单、最公平且使用最广的算法就是时间片轮转(Round Robin, RR)调度算法。 。
每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。
- 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
- 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;
另外,时间片的长度就是一个很关键的点:
- 如果时间片设得太短会导致过多的进程上下文切换,降低了 CPU 效率;
- 如果设得太长又可能引起对短作业进程的响应时间变长。将
通常时间片设为 20ms~50ms
通常是一个比较合理的折中值。
05 最高优先级调度算法
前面的「时间片轮转算法」做了个假设,即让所有的进程同等重要,也不偏袒谁,大家的运行时间都一样。
但是,对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行,这称为最高优先级(Highest Priority First,HPF)调度算法。
进程的优先级可以分为,静态优先级或动态优先级:
- 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
- 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
该算法也有两种处理优先级高的方法,非抢占式和抢占式:
- 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
- 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。
但是依然有缺点,可能会导致低优先级的进程永远不会运行。
06 多级反馈队列调度算法
多级反馈队列(Multilevel Feedback Queue)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。
顾名思义:
- 「多级」表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短。
- 「反馈」表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;
来看看,它是如何工作的:
- 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短;
- 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
- 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;
可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也会更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。
看的迷迷糊糊?那我拿去银行办业务的例子,把上面的调度算法串起来,你还不懂,你锤我!
办理业务的客户相当于进程,银行窗口工作人员相当于 CPU。
现在,假设这个银行只有一个窗口(单核 CPU ),那么工作人员一次只能处理一个业务。
那么最简单的处理方式,就是先来的先处理,后面来的就乖乖排队,这就是先来先服务(FCFS)调度算法。但是万一先来的这位老哥是来贷款的,这一谈就好几个小时,一直占用着窗口,这样后面的人只能干等,或许后面的人只是想简单的取个钱,几分钟就能搞定,却因为前面老哥办长业务而要等几个小时,你说气不气人?
有客户抱怨了,那我们就要改进,我们干脆优先给那些几分钟就能搞定的人办理业务,这就是短作业优先(SJF)调度算法。听起来不错,但是依然还是有个极端情况,万一办理短业务的人非常的多,这会导致长业务的人一直得不到服务,万一这个长业务是个大客户,那不就捡了芝麻丢了西瓜
那就公平起见,现在窗口工作人员规定,每个人我只处理 10 分钟。如果 10 分钟之内处理完,就马上换下一个人。如果没处理完,依然换下一个人,但是客户自己得记住办理到哪个步骤了。这个也就是时间片轮转(RR)调度算法。但是如果时间片设置过短,那么就会造成大量的上下文切换,增大了系统开销。如果时间片过长,相当于退化成退化成 FCFS 算法了。
既然公平也可能存在问题,那银行就对客户分等级,分为普通客户、VIP 客户、SVIP 客户。只要高优先级的客户一来,就第一时间处理这个客户,这就是最高优先级(HPF)调度算法。但依然也会有极端的问题,万一当天来的全是高级客户,那普通客户不是没有被服务的机会,不把普通客户当人是吗?那我们把优先级改成动态的,如果客户办理业务时间增加,则降低其优先级,如果客户等待时间增加,则升高其优先级。
那有没有兼顾到公平和效率的方式呢?这里介绍一种算法,考虑的还算充分的,多级反馈队列(MFQ)调度算法,它是时间片轮转算法和优先级算法的综合和发展。它的工作方式:
- 银行设置了多个排队(就绪)队列,每个队列都有不同的优先级,各个队列优先级从高到低,同时每个队列执行时间片的长度也不同,优先级越高的时间片越短。
- 新客户(进程)来了,先进入第一级队列的末尾,按先来先服务原则排队等待被叫号(运行)。如果时间片用完客户的业务还没办理完成,则让客户进入到下一级队列的末尾,以此类推,直至客户业务办理完成。
- 当第一级队列没人排队时,就会叫号二级队列的客户。如果客户办理业务过程中,有新的客户加入到较高优先级的队列,那么此时办理中的客户需要停止办理,回到原队列的末尾等待再次叫号,因为要把窗口让给刚进入较高优先级队列的客户。
可以发现,对于要办理短业务的客户来说,可以很快的轮到并解决。对于要办理长业务的客户,一下子解决不了,就可以放到下一个队列,虽然等待的时间稍微变长了,但是轮到自己的办理时间也变长了,也可以接受,不会造成极端的现象,可以说是综合上面几种算法的优点。
总结:
/***************一些常见的操作系统的任务(进程)调度算法*****************/
1)先来先服务(First Come First Served,简称FCFS)调度算法:最简单的任务/进程调度算法,该调度算法每次从当前运行进程的后备作业队列中选择一个或多个任务/进程并将其调入内存,分配资源。从表面上看,该算法对所有任务/进程都是公平的,不过该算法的缺点在于若有较长的作业流程的任务/进程正在工作,短作业流程的任务/进程需要等待很长时间。显然,该算法简单但总体效率较低,而且该调度算法对长运行时间作业有利,但对短运行时间作业不利。
2)短作业优先(Shortest Job First,简称SJF)调度算法:该调度算法每次从当前运行进程的后备作业队列中选择一个或多个运行时间最短的任务/进程,将其调入内存,分配资源。由于作业在未运行时无法事先知道实际运行时间的长短,因此该算法需要作业在提交申请的同时附带该作业运行时间的估算值。显然,该调度算法对短运行时间作业有利,但对长运行时间作业不利。
3)优先级调度算法:该调度算法基于需要运行的任务/进程的紧迫程度来进行调度,每次从当前运行进程的后备作业队列中选择一个或多个优先级最高的任务/进程并将其调入内存,分配资源。根据新的更高优先级进程能否抢夺当前正在执行的进程,可将该调度算法分为非剥夺式(无法打断)/可剥夺式(可以打断)两类。
4)最高响应比(Highest Response_ratio Next,简称HRN)调度算法:该调度算法是FCFS和SJF的一种综合平衡,响应比R的计算方法为:
R=(等待时间+预估运行时间)/预估运行时间
由此我们可以看出:
1.当等待时间相同时,则预估运行时间越短,响应比越高,此时接近SJF,有利于短作业
2.当预估运行时间相同时,则响应比由其等待时间决定,等待时间越长,响应比越高,此时接近FCFS
3.对于较长运行时间的作业,作业的响应比可以随着作业的等待时间增加而逐渐提高,这样就可以一定程度克服进程调度的不公平的情况
5)时间片轮转调度算法:适用于分时系统,在这种算法中,将CPU的运行时间分解为时间片(几十毫秒到上百毫秒不等),每个进程都只能在对应的时间片内执行。时间片过后,即使该进程仍未完成也必须释放资源给下一个就绪的进程,被剥夺资源的进程重新排队等候再次运行。这种调度算法较为公平,不过若时间片切换过于频繁,则系统资源的开销会很大,因此选取合适的时间片是十分重要的。
6)多级反馈队列调度算法:该算法集合了上述所有算法的综合优点,通过动态调配进程优先级和时间片大小,可以实现兼顾多方面的系统任务/进程,同时无需事先预估任务/进程的运行时间。
/***************一些常见的操作系统的任务(进程)调度算法end**************/
3、任务
//任务、进程、线程的关系
任务是一个逻辑概念,指一个软件完成的活动,或者软件为了完成该活动/实现某个目的进行的一系列操作。通常情况下一个任务是一个程序的一次运行,一个任务可以包含一个或多个独立功能的子任务,通常情况下独立的子任务是进程或线程。
二、进程的概念
/************进程与程序的区别*******************/
进程与程序的区别有几点:
1)程序是静态的,它是保存在磁盘上的一些指令的有序集合,没有任何执行的概念;进程是动态的,它是程序执行的过程,包括创建、调度、消亡。
2)进程是一个独立的可调度的任务,是一个抽象实体,当系统在执行某个程序时,系统会分配和释放各种需要的资源。进程不仅包括程序的指令和数据,还包括程序计数器值、CPU寄存器值以及存储数据的堆栈等。
3)进程是一个程序的一次执行的过程。
4)进程是程序执行和资源管理的最小单位。
/************进程与程序的区别end****************/
什么是进程?
进程(Process):进程是指一个具有独立功能的程序在某个数据集合上的一次动态执行的过程,进程是操作系统进行资源分配和调度的基本单元,进程是程序执行和资源管理的最小单位。
Linux内核只认识进程 且内核是通过task_struct来管理进程的
首先,进程是一个动态过程而不是静态实物。其次,进程就是程序的一次运行过程,一个静态的可执行程序a.out的一次运行过程(./a.out去运行到结束)就是一个进程。内核中构建了一种数据结构task_struct,又称进程控制块PCB(process control block),用来专门管理一个进程。
进程,主要包含三个元素:
o. 一个可以执行的程序;
o. 和该进程相关联的全部数据(包括变量,内存空间,缓冲区等等);
o. 程序的执行上下文(execution context)。
*Linux下的进程结构--关于进程控制块(PCB Processing Control Block) (task_struct)
在广义上,所有的进程信息被放在一个叫做进程控制块的数据结构task_struct中,可以理解为进程属性的集合。
进程控制块:
每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体。
task_struct是Linux内核的⼀一种数据结构,它会被装载到RAM⾥里并且包含着进程的信息。
每个进程都把它的信息放在 task_struct 这个数据结构⾥里,task_struct 包含了这些内容:
1.进程标示符 (PID): 描述本进程的唯⼀一标示符,⽤用来区别其他进程。(父进程标示符是
PPID)
2.状态 :任务状态,退出代码,退出信号等。
3.优先级 :相对于其他进程的优先级。
4.程序计数器:程序中即将被执⾏行的下一条指令的地址。
5.内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针
6.上下文数据:进程执行时处理器的寄存器中的数据。
7.I/O状态信息:包括显⽰示的I/O请求,分配给进程的I/O设备和被进程使⽤用的⽂文件列表。
8.记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
保存进程信息的数据结构叫做 task_struct,并且可以在 include/linux/sched.h ⾥里找到它。
所有运⾏行在系统⾥里的进程都以 task_struct 链表的形式存在内核⾥里。
进程的信息可以通过 /proc 系统⽂文件夹查看。比如 要获取PID为400的进程信息,你需要查看 /proc/400 这个⽂文件夹。大i多数进程信息同样可以使⽤用top和ps这些⽤用户级⼯工具来获取。
因为Linux系统是一个多任务的操作系统,所以操作系统必须采取某种调度算法将处理器合理地分配给正在等待的进程。内核将所有进程存放在一个双向循环链表中,该链表的每一项都是task_struct类型的结构体,称为进程控制块。task_struct结构体内容很多,它能完整描述一个进程,如进程的状态、进程的基本信息、进程的标识符、内存相关信息、父进程信息、与该进程相关的终端信息、当前工作目录、当前打开的文件、所接收的信号等。
/***************task_struct结构体部分成员简介***********************/
//该结构体在/usr/src/linux-headers-3.2.0-29/include/linux/sched.h文件内,大约位于文件中部(1227行)
//不同虚拟机内该文件位置可能不同
1、进程状态
volatile long state;
state成员用于描述进程的状态,可能的取值如下:
TASK_RUNNING 进程正在运行或准备运行
TASK_INTERRUPTIBLE 进程处在阻塞(睡眠)状态,等待某些事件发生。若被唤醒,则转变成TASK_RUNNING状态
TAST_UNINTERRUPTIBLE 与前者类似,不过不会接收信号
__TASK_STOPPED 进程被停止
__TASK_TRACED 进程被debugger等进程监视
EXIT_ZOMBIE 进程被终止,但是其父进程还未使用wait()函数族函数回收
EXIT_DEAD 进程最终退出的状态
2、进程标识符
pid_t pid; 进程标识符
pid_t tgid; 线程组标识符(thread group id)
其中pid表示进程标识符,在默认情况下,PID的取值范围是0~32767,即系统内进程最多有32767个。tgid表示的是线程组标识符,在内核运行多进程/多线程任务时,对于一个进程内的不同线程来说,每个线程都有不同的pid,但是有统一的tgid,线程组的领头线程的pid与tgid相同。当我们使用getpid()函数获取当前运行进程的进程号时,实际getpid()函数的返回值是tgid的值而不是pid的值。
3、表示进程亲属关系
struct task_struct *real_parent;
struct task_struct *parent;
struct list_head children;
struct list_head sibling;
struct task_struct *group_leader;
其中
real_parent 指向父进程,如果创建它的父进程已经不存在,则会指向init进程(PID为1的进程)
parent 指向父进程,当进程被终止时必须向父进程发送信号。通常该值与real_parent相同
children 链表头结点,该链表内的元素都是该进程的子进程
sibling 当前进程的兄弟进程,该成员用于将当前进程信息插入到它的兄弟进程的链表内
group_leader 指向所在进程组的领头进程
4、进程调度优先级
int prio,static_prio,normal_prio;
unsigned int rt_priority;
其中
prio 保存该进程的动态优先级
static_prio 保存该进程的静态优先级,范围为MAX_RT_PRIO到MAX_PRIO-1(100~139),值越大优先级越低
normal_prio 取决于静态优先级与进程调度策略
rt_priority 保存该进程的实时优先级,范围为0到MAX_RT_PRIO-1(0~99),值越大优先级越低
5、运行时间
cputime_t utime,stime;
二者都用于记录进程运行过程所经历的CPU定时器的节拍数,其中utime表示用户态,stime表示内核态。
6、构建进程链表
struct list_head tasks;
7、文件IO相关
struct fs_struct *fs;
struct files_struct *files;
其中
fs 表示进程与文件系统的联系,包括文件的目录(当前目录/根目录)
files 表示进程打开的文件
8、中断使能
struct irqaction *irqaction;
9、死锁检测
struct mutex_waiter *blocked_on;
10、延迟计数
struct task_delay_info *delays;
11、socket控制
struct list_head *scm_work_list;
task_struct结构体还有许多成员,这里不再过多描述。有兴趣同学可以查阅内核手册了解更多
/***************task_struct结构体部分成员简介end********************/
关于进程的上下文:
用户空间的应用程序,通过系统调用,进入内核空间。这个时候用户空间的进程要传递很多变量、参数的值给内核,内核态运行的时候也要保存用户进程的一些寄存器值、变量等。所谓的“进程上下文”,可以看作是用户进程传递给内核的这些参数以及内核要保存的那一整套的变量和寄存器值和当时的环境等。
进程上下文是进程执行活动全过程的静态描述。我们把已执行过的进程指令和数据在相关寄存器与堆栈中的内容称为进程上文,把正在执行的指令和数据在寄存器与堆栈中的内容称为进程正文,把待执行的指令和数据在寄存器与堆栈中的内容称为进程下文。
硬件通过触发信号,导致内核调用中断处理程序,进入内核空间。这个过程中,硬件的一些变量和参数也要传递给内核,内核通过这些参数进行中断处理。所谓的“中断上下文”,其实也可以看作就是硬件传递过来的这些参数和内核需要保存的一些其他环境(主要是当前被打断执行的进程环境)。
关于进程上下文LINUX完全注释中的一段话:
当一个进程在执行时,CPU的所有寄存器中的值、进程的状态以及堆栈中的内容被称为该进程的上下文。当内核需要切换到另一个进程时,它需要保存当前进程的所有状态,即保存当前进程的上下文,以便在再次执行该进程时,能够必得到切换时的状态执行下去。在LINUX中,当前进程上下文均保存在进程的任务数据结构中。在发生中断时,内核就在被中断进程的上下文中,在内核态下执行中断服务例程。但同时会保留所有需要用到的资源,以便中断服务结束时能恢复被中断进程的执行。
一个进程表示的,就是一个可执行程序的一次执行过程中的一个状态 。 操作系统对进程的管理,典型的情况,是通过进程表完成的。进程表中的每一个表项,记录的是当前操作系统中一个进程的情况。对于单CPU的情况而言,每一特定时刻只有一个进程占用CPU,但是系统中可能同时存在多个活动的(等待执行或继续执行的)进程。
一个称为" 程序计数器(program counter,pc)"的寄存器,指出当前占用CPU的进程要执行的下一条指令的位置。
当分给某个进程的CPU时间已经用完,操作系统将该进程相关的寄存器的值,保存到该进程在进程表中对应的表项里面;把将要接替这个进程占用CPU的那个进程的上下文,从进程表中读出,并更新相应的寄存器(这个过程称为"上下文交换(process context switch)",程序寄存器pc指出程序当前已经执行到哪里,是进程上下文的重要内容,换出CPU的进程要保存这个寄存器的值,换入CPU的进程,也要根据进程表中保存的本进程执行上下文信息,更新这个寄存器)。
进程的上下文切换
各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。
在详细说进程上下文切换前,我们先来看看 CPU 上下文切换
大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务分别在 CPU 运行,于是就造成同时运行的错觉。
任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。
所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。
CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快的多。
再来,程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
既然知道了什么是 CPU 上下文,那理解 CPU 上下文切换就不难了。
CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。
进程的上下文切换到底是切换什么呢?
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:
大家需要注意,进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。
发生进程上下文切换有哪些场景?
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
以上,就是发生进程上下文切换的常见场景了。
**进程运行的虚拟地址空间
Linux进程地址空间和进程的内存分布_cl_linux的博客-CSDN博客_进程内存空间分布https://blog.csdn.net/cl_linux/article/details/80328608
(1)操作系统中每个进程在独立地址空间中运行,所以进程直接的运行不会相互打扰。这提供了一种进程隔离的方法,从而能够提供一种多进程同时运行的方式。
(2)如果进程运行所在的系统为4GB内存时,那么每个进程的逻辑地址空间均为4GB(32位系统)。但是在实际的情况下,一个进程所占用的内存基本不会达到4GB这么多,也就是不会用满。因此,内存的使用一般会被划分为2部分,0-1G为OS,1-4G为应用,对于每个进程来说,它们自己都会认为它们在独享着这4GB的内存,但是实际情况是这4GB的内存被合理地供多个进程一起使用(这样也是为了能够提高系统效率)。
(3)那么怎样做到了每个进程能够独享4GB空间?这就用到了虚拟地址到物理地址空间的映射的方法,也就是进程用到多少内存,就在物理地址上映射多少。所以内存进行分时复用的方式,实现让每个进程觉得自己都用到了大内存。
地址是什么?地址是内存的编号,指向内存的一块区域
虚拟地址空间:
mm_struct{
long int size
code_start
code_end
data_start
a
data_end
}
上图之中,中间的物理内存地址,两边的为虚拟内存地址。堆 栈 数据段 代码段都是在虚拟地址空间的
右边是父进程将虚拟地址通过页表查找到物理内存的地址,这时父进程在运行一个程序。此时父进程又创建了一个子进程(图最右)。子进程也通过页表找到物理内存的地址,这时子进程运行的程序在物理内存的另一个新空间运行。
这是虚拟内存地址使用的过程。它的作用就是保持进程的独立性,通过页表映射物理地址,充分的利用物理地址,增加内存访问控制。
图中的共享区是:程序运行时,编译器会自动链接libc.so/libc++.so动态链接库,程序中用到的库函数就被加载到共享库这部分内存上。共享库位于栈和堆之间。同时,该进程中的工作线程压栈是压了共享区,该共享区包含了许多线程独有的资源
用户调用pthread_create函数时, 首先要为线程分配线程栈, 而线程栈的位置就落在共享区。 调用mmap函数为线程分配栈空间。
如图:
下面说一说进程页表的知识:
页表是一种特殊的数据结构,放在系统空间的页表区,存放逻辑页与物理页帧的对应关系。 每一个进程都拥有一个自己的页表,PCB表中有指针指向页表。
通过画图来帮助理解:
基本分页存储管理方式:
用固定大小的 页(Page)来描述逻辑地址空间,用相同大小的 页框(Frame)来描述物理内存空间,由操作系统实现从逻辑页到物理页框的 页面映射,同时负责对所有页的管理和进程运行的控制。
页表实现从页号到物理块号的地址映射:
逻辑地址转换成物理地址的过程是:用页号p去检索页表,从页表中得到该页的物理块号,把它装入物理 地址寄存器 中。同时,将页内地址d直接送入物理 地址寄存器 的块内地址字段中。这样,物理 地址寄存器 中的内容就是由二者拼接成的实际访问内存的地址,从而完成了从逻辑地址到物理地址的转换。
注:
逻辑地址空间:由程序所生成的所有逻辑地址的集合。
物理地址空间:与逻辑地址相对应的内存中所有物理地址的集合,用户程序看不见真正的物理地址。
也就是说我们之前学习的时候都被欺骗啦?我们在学习c时取地址&一直获取的是其逻辑地址,并非真正的物理地址。
在古老的操作系统里面,所有进程都是共用同一物理内存空间的,这种方法会有一些问题,比如两个进程之前相互踩内存,一个进程污染(踩内存)后,无法隔离,必须整个系统复位,才能恢复干净的环境。在这种操作系统下,进程之间无法隔离。
为了解决进程之间内存隔离,提供了虚拟内存这个概念。
进程看到的是虚拟内存,这根本看不到物理内存,物理内存是OS给它分配的,它不需要感知物理内存。对于同一程序运行起来的两个进程,它们的虚拟空间布局可能完全一样,但他们真实使用的物理内存空间则不相同,通过这种方式来实现进程之间的隔离。
进程的特性与分类
进程具有并发性、动态性、交互性和独立性等主要特性
1.并发性:指的是系统内多个进程可以同时并发运行,互相之间不受干扰。
2.动态性:指的是进程都有完整的生命周期,而且在进程的生命周期内,进程的状态是在不断变化的。另外进程具有动态的地址空间(包括代码、数据和进程管理块(Process Control Block,简称PCB)等。
3.交互性:指的是进程在执行过程中可能会与其他进程发生直接或间接的通信,如进程的同步与互斥等,因此需要为进程添加相应的进程处理机制。
4.独立性:指的是进程是一个相对完整的资源分配和调度的单位,各个进程的地址空间是相互独立的,只有采取特殊的手段才能实现进程间的通信。
Linux系统内主要包含以下几种类型的进程:
1.交互式进程:这类进程用于操作系统与用户进行交互,需要用户的输入(键盘、鼠标等操作)。当接收到用户的输入后,该进程能立即响应,做出动作。常见的交互式进程有shell终端、文本编辑器(vim、emacs、gedit等)、图形化应用程序等。
2.批处理进程:这类进程无需与用户进行交互,通常在后台运行。常见的批处理进程有编译器的编译操作、数据库搜索操作等。
3.守护进程:这类进程一直在后台运行,与任何终端无关,通常情况下在系统启动时开始执行,系统关闭时才结束。许多系统进程(服务类进程)都是以守护进程的形式存在。
**Linux进程状态
进程的状态
我们知道进程有着「运行 - 暂停 - 运行」的活动规律。一般说来,一个进程并不是自始至终连续不停地运行的,它与并发执行中的其他进程的执行是相互制约的。
它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。
所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
上图中各个状态的意义:
- 运行状态(Runing):该时刻进程占用 CPU;
- 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止;
- 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
当然,进程另外两个基本状态:
- 创建状态(new):进程正在被创建时的状态;
- 结束状态(Exit):进程正在从系统中消失时的状态;
于是,一个完整的进程状态的变迁如下图:
再来详细说明一下进程的状态变迁:
- NULL -> 创建状态:一个新进程被创建时的第一个状态;
- 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的;
- 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程;
- 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理;
- 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行;
- 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件;
- 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态;
如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。
所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。
那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。
另外,挂起状态可以分为两种:
- 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
- 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;
这两种挂起状态加上前面的五种状态,就变成了七种状态变迁(留给我的颜色不多了),见如下图:
导致进程挂起的原因不只是因为进程所使用的内存空间不在物理内存,还包括如下情况:
- 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
- 用户希望挂起一个程序的执行,比如在 Linux 中用
Ctrl+Z
挂起进程;
Linux系统内的进程主要有以下几种状态
//运行状态及状态切换、
1)R--运行状态(TASK_RUNNING):
该状态下进程正在运行,或已经准备就绪等待调度
2)S--可中断阻塞状态(TASK_INTERRUPTIBLE):
该状态下进程出于阻塞(睡眠)状态,正在等待某些事件发生或等待分配某些系统资源。处在该状态下可以接收信号并被信号中断。当进程被唤醒(事件发生/获得资源/接收到某些信号/被系统显示唤醒)后,进程转换为TASK_RUNNING状态
3)D--不可中断阻塞状态(TASK_UNINTERRUPTIBLE):
该状态类似可中断阻塞状态(TASK_INTERRUPTIBLE),只不过该状态下进程不能接收或处理信号。在某些情况下(例如让进程必须等待直至事件发生/获得资源)这种状态是十分有用的。
4)T--暂停状态(TASK_STOPPED):
进程的执行过程被暂停。当进程收到某些信号(SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号)时,就会进入该状态。当进程收到SIGCONT信号后,会恢复运行,进入TASK_RUNNING状态
5)Z--僵死状态(EXIT_ZOMBIE):
进程的运行已经结束,但该进程的父进程尚未使用wait()函数族对其回收。处在该状态下的进程已经放弃了系统资源和内存空间,没有任何执行代码,也不能被调度,仅仅在进程队列内保留一个位置记载该进程的退出状态,等待父进程收集。即该进程变成了僵尸进程
6)X--消亡状态(EXIT_DEAD):
父进程对该进程调用wait()函数族,该进程彻底退出。
进程标识符(PID)与父进程标识符(PPID)
Linux内核采用进程标识符来标识每个进程(简称进程号,PID),PID存放在task_struct结构体的pid成员内。系统中可以创建的进程数目有限,默认情况下,Linux系统允许的最大用户进程数为32767,而单个进程允许的最大线程数为1024。(但受限于计算机性能,绝大多数情况无法达到最大进程数和最大线程数)
当进程运行时,内核通常个会使用一个结构体指针current来索引该进程,例如current->pid表示当前处理器正在处理的进程的PID。
在Linux系统内,除了init进程(PID为1的进程)为内核启动时就存在,其余进程都是通过一个进程来创建另一个进程,被创建的进程称为“子进程”(child process),相应的,创建子进程的进程称为“父进程”(parent process)。因此,每个进程都有其相对应的父进程的进程标识符,称为父进程标识符(简称父进程号,PPID)。init进程是系统内其他所有进程的祖先。
通常情况下,父进程需要负责子进程的资源回收工作。当一个子进程结束(调用exit()函数退出或者运行出现错误)时,子进程退出状态会上报给操作系统,操作系统再将该状态报告给该进程的父进程,由父进程负责子进程的资源回收工作。
在Linux系统内,我们可以使用getpid()函数来获取当前进程标识符,使用getppid()函数来获取当前进程的父进程标识符。
**进程的创建、执行与终止
1)进程的创建
Linux内的进程创建分为两步:
1.调用fork()函数,复制当前进程信息创建一个子进程,父进程与子进程的区别仅仅在于PID、PPID和某些特殊资源(例如计时器等)
2.调用exec函数族函数,读取可执行文件并将其载入地址空间开始运行
由于调用fork()函数时,子进程需要复制父进程的资源(包括但不限于:代码区、数据区、堆区、栈区等),这样效率会十分低下。甚至,如果子进程要运行其他的可执行程序,则“拷贝父进程的资源”这个动作会毫无意义,所有的拷贝都会前功尽弃。因此Linux内核采用了写时拷贝技术(copy on write)来提高效率。
写时拷贝技术(fork创建进程):内核只为新生成的子进程创建虚拟空间,复制父进程的虚拟空间,但是不为这些虚拟空间分配物理内存,它们共享父进程的物理空间(即让这些虚拟空间指向父进程的实际内存),当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。
fork()函数还有一个兄弟函数:vfork()。有关于vfork()函数的使用方法见下面内容。
exec函数族函数提供了在进程中启动另一个可执行程序的方法。exec函数族内的函数可以根据指定的文件名找到可执行文件(二进制文件/脚本文件),并替换原始进程内的数据区、代码区、堆区、栈区等。在执行完毕后,除了PID之外该进程的其他资源全部被替换成新的可执行程序。在fork()函数之后启动exec函数族函数可以装载其他程序运行,这样就可以让子进程运行与父进程不同的程序。
2)进程的终止
终止进程时,系统需要做许多收尾工作,例如回收占用的系统资源、清理内存等并通知父进程该进程即将被回收。
在终止进程时,系统会首先将该进程设置成僵死状态,此时进程无法运行,等待资源回收。僵死进程的存在仅仅为父进程提供信息,等待父进程在某个时间段调用wait函数族函数回收该进程使其进入退出状态。
我们可以使用exit()函数和_exit()函数终止一个进程,父进程可以使用wait函数族函数对子进程进行回收工作。
进程的诞生和消亡
1.进程的诞生
我们知道在最开始的时候先有的是进程0和进程1,进程0为内核态,进程1为内核态过度到用户态的一个重要进程,也可以说所有的进程都源于进程1。进程产生的原理就是上述提到的fork形式创建。
2.进程的消亡
进程终止有两种方式,一种是自己设定的终止方式,又称正常终止;另一种是由于一些信号或者外部缘故导致的终止,成为异常终止。
进程在运行时需要消耗系统资源(内存、IO)。
linux系统设计时规定:每一个进程退出时,操作系统会自动回收这个进程涉及到的所有的资源(譬如malloc申请的内容没有free时,当前进程结束时这个内存会被释放,譬如open打开的文件没有close的在程序终止时也会被关闭)。但是操作系统只是回收了这个进程工作时消耗的内存和IO,而并没有回收这个进程本身占用的内存(8KB,主要是task_struct和栈内存)。那么这8KB的内存操作系统该怎么办?答案是需要通过他的父进程来帮它处理这些残余的资源。
由此就会有一个僵尸进程的概念
** 僵尸进程和孤儿进程
linux系统设计时规定:每一个进程退出时,操作系统会自动回收这个进程涉及到的所有的资源(譬如malloc申请的内容没有free时,当前进程结束时这个内存会被释放,譬如open打开的文件没有close的在程序终止时也会被关闭)。但是操作系统只是回收了这个进程工作时消耗的内存和IO,而并没有回收这个进程本身占用的内存(8KB,主要是task_struct和栈内存)。那么这8KB的内存操作系统该怎么办?答案是需要通过他的父进程来帮它处理这些残余的资源。
由此就会有一个僵尸进程和孤儿进程的概念
(1)僵尸进程
子进程先于父进程结束。子进程结束后父进程此时并不一定立即就能帮子进程“收尸”,在这一段(子进程已经结束且父进程尚未帮其收尸)子进程就被成为僵尸进程。
在这种情况下,子进程结束时,操作系统会将其占用的大量资源进行回收。但是8KB的参与需要通过父进程使用wait或waitpid以显式回收子进程的剩余待回收内存资源并且获取子进程退出状态。
当父进程结束时一样会回收子进程的剩余待回收内存资源。(这样设计是为了防止父进程忘记显式调用wait/waitpid来回收子进程从而造成内存泄漏)
(2)孤儿进程
父进程先于子进程结束,子进程成为一个孤儿进程。
linux系统规定:所有的孤儿进程都自动成为一个特殊进程(进程1,也就是init进程)的子进程。或者对于ubuntu这种操作系统会提供一个统一的进程进行孤儿进程的管理。
**僵尸进程的危害
上面我们知道,僵尸进程本身占用的内存(8KB,主要是task_struct和栈内存)系统是不会主动帮我们回收的 需要依靠父进程来回收 。一个僵尸进程并不怕,可怕的是一群僵尸进程,我们知道僵尸进程会保留进程号,而我们的系统能够使用的进程号是有限的,即如果大量的僵尸进程存在,就可能因为没有可用的进程号而导致系统不能产生新的进程。
**如何避免僵尸进程的危害?
⒈父进程通过wait和waitpid等函数等待子进程结束,这会导致父进程挂起。
⒉ 如果父进程很忙,那么可以用signal函数为SIGCHLD安装handler,因为子进程结束后, 父进程会收到该信号,可以在handler中调用wait回收。(也就是我们自定义信号处理函数,自定义的信号处理函数里调用wait)
⒊ 如果父进程不关心子进程什么时候结束,那么可以用signal(SIGCHLD,SIG_IGN) 通知内核,自己对子进程的结束不感兴趣,那么子进程结束后,内核会回收, 并不再给父进程发送信号。
⒋ 让僵尸进程变成孤儿进程,就是让他的父亲先死。
用户空间与内核空间
Linux系统管理内存的方式是“虚拟内存管理技术”,给每个进程分配独立的地址空间。这段地址空间是4GB的虚拟空间,用户所看到的、使用的内存均为虚拟内存地址,无法看到实际的内存地址,用户也无法直接访问物理内存。虚拟内存管理技术隔离了用户与内存,保障了内存的安全性。
4GB的内存空间会被分隔成两部分——用户空间与内核空间。其中用户空间的地址为0~3GB(0x00000000~0xBFFFFFFF),内核空间为3GB~4GB(0xC0000000~0xFFFFFFFF)。内核空间内存放的是内核的代码与数据,用户无权访问。当用户使用系统调用函数或发生中断时,该进程就从用户空间切换到了内核空间。在内核空间内,终端往往无显示内容(或光标跳动),此时该进程无法对用户的命令做出响应,只有等待该进程从内核空间退出或强制终止该进程。
当一个任务(进程)调用系统调用而进入内核空间时,我们称为该任务(进程)处于“内核态”,相应的,未处在内核空间而处在用户空间时,我们称为该任务(进程)处于“用户态”。
例如,我们学习过的“使用文件IO函数操作文件”这个程序,当我们调用open()/close()/read()/write()/lseek()等系统调用函数时,此时该进程变成内核态,进入内核空间,系统内核会根据用户命令完成相应动作。当动作完成后,进程再切换回用户态,重新进入用户空间。
用户空间与内核空间的不同点很多,例如用户空间内打印信息使用printf()函数,而内核空间内打印信息使用printk()函数。由于内核空间内的代码十分重要且与用户空间不同,因此在编写需要在内核空间运行的程序(例如驱动程序、模块程序等)时需要格外小心。
一些进程操作命令
ps 查看系统内进程
top 动态监测系统中进程
nice 按用户指定优先级运行进程
renice 改变运行中的进程优先级
kill 杀死进程
bg 将进程放在后台运行
fg 将后台进程放到前台进行
在Linux/Unix系统中,用户总是通过终端登录系统后得到一个shell进程,进而在shell命令行上输入命令与系统进行交互。说白了,终端就是系统与用户进行“交流”的窗口,而所有在这个终端下创建的进程都“属于”这个终端。这个终端也就是这些进程的控制终端。
(控制终端是保存在PCB信息中的,而fork子进程会复制PCB中的信息。所以shell创建的所有进程的控制终端都是这个终端。)
我们在图形化界面下使用Linux,打开一个叫Terminal的窗口才能进行操作,Terminal就是终端的意思,打开一个会话窗口就是打开一个终端。
三.进程常用的函数
一、fork()函数
详细说一下fork写实拷贝技术
fork是Unix-like系统中创建进程的系统调用之一。fork系统调用会创建一个新的进程,该进程是原始进程(称为父进程)的副本。这个副本进程(称为子进程)几乎完全复制了父进程的所有内容,包括代码、数据、堆栈、文件描述符等。
fork的实现是通过写时复制(Copy-on-Write)技术来实现的。具体步骤如下:
1.当调用fork时,操作系统会创建一个新的进程,并为其分配一个唯一的进程ID。
2.父进程的地址空间被复制到子进程的地址空间中,包括代码段、数据段和堆栈段。但是,这些段在物理内存中并没有真正复制,而是通过页表的方式共享。
3.在fork之后,父进程和子进程共享相同的物理内存页。这些页被标记为“只读”,这意味着如果任何一个进程试图修改这些页的内容,操作系统会为该进程分配一个新的物理页,并将修改后的内容复制到新的物理页中。
4.当父进程或子进程中的任何一个进程试图修改共享的内存页时,操作系统会检测到这个修改,并为该进程分配一个新的物理页。这个过程称为“写时复制”。
5.通过写时复制技术,父进程和子进程可以共享相同的物理内存页,从而节省了内存空间。只有在需要修改共享的内存页时,才会进行实际的复制操作,从而提高了效率。
需要注意的是,fork之后,父进程和子进程是完全独立的进程,它们有各自的进程ID、资源和执行流。父进程和子进程之间的唯一区别是fork的返回值。在父进程中,fork返回子进程的进程ID,而在子进程中,fork返回0。这样可以通过返回值来区分父进程和子进程的执行路径。
也就是说fork之后父进程和子进程的虚拟地址空间是不一样的,但是这两个进程的虚拟地址空间映射在同一个物理地址?
是的,当调用fork创建子进程时,子进程会复制父进程的虚拟地址空间,但是这两个进程的虚拟地址空间是相互独立的。虽然它们的虚拟地址空间是一样的,但是它们映射到的物理地址是不同的。
在fork之后,父进程和子进程共享相同的物理内存页。这些页被标记为“只读”,这意味着它们的内容是相同的。当父进程或子进程中的任何一个进程试图修改共享的内存页时,操作系统会为该进程分配一个新的物理页,并将修改后的内容复制到新的物理页中。这样,父进程和子进程就可以在各自的物理内存页上进行独立的修改,而不会相互影响。
因此,虽然父进程和子进程的虚拟地址空间映射到同一个物理地址,但它们的修改操作会导致物理内存页的复制,从而使它们的修改是相互独立的。这种写时复制的机制使得父进程和子进程可以共享内存,同时又能够保持独立的修改。
在Linux系统内,创建子进程的方法是使用系统调用fork()。fork()是Linux系统内一个非常重要的系统调用,它与我们之前学过的函数有一个显著的区别:fork()调用一次却会得到两个返回值。
fork()的用法:
fork()
所需头文件:#include<sys/types.h>
#include<unistd.h>
函数原型:pid_t fork()
函数参数:无
函数返回值:
0 此进程为子进程
>0 此进程为父进程,返回值为创建出的子进程的PID
-1 出错
fork()函数用于从一个已经存在的进程内创建一个新的进程,新的进程称为“子进程”,相应地称创建子进程的进程为“父进程”。使用fork()函数得到的子进程是父进程的复制品,子进程完全复制了父进程的资源,包括:
进程上下文、代码区、数据区、堆区、栈区、缓冲区丶内存信息、打开文件的文件描述符、信号处理函数、进程优先级、进程组号、当前工作目录、根目录、资源限制和控制终端等信息,
而子进程与父进程的区别有进程号、资源使用情况和计时器等。
因为子进程完全复制了父进程的资源,包括进程上下文、代码区、数据区、堆区、栈区、内存信息、打开文件的文件描述符、信号处理函数、进程优先级、进程组号、当前工作目录、根目录、资源限制和控制终端等信息 。 如果不使用exce族函数 且子进程 在if(pid==0)判断语句中没有 使用exit 或者 原本程序中没有使用if(pid==0)判断语句 子进程会执行完原本父进程执行的程序
由于复制父进程的资源需要大量的操作,十分浪费时间与系统资源,因此Linux内核采取了写时拷贝技术(copy on write)来提高效率。
由于子进程几乎对父进程完全复制,因此父子进程会同时运行同一个程序。因此我们需要某种方式来区分父子进程。区分父子进程常见的方法为查看fork()函数的返回值或区分父子进程的PID。
示例:使用fork()函数创建子进程,父子进程分别输出不同的信息
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
pid_t pid;
pid = fork();//获得fork()的返回值,根据返回值判断父进程/子进程
if(pid==-1)//若返回值为-1,表示创建子进程失败
{
perror("cannot fork");
return -1;
}
else if(pid==0)//若返回值为0,表示该部分代码为子进程
{
printf("This is child process\n");
printf("pid is %d, My PID is %d\n",pid,getpid());
}
else//若返回值>0,则表示该部分为父进程代码,返回值是子进程的PID
{
printf("This is parent process\n");
printf("pid is %d, My PID is %d\n",pid,getpid()); //getpid()获得的是自己的进程号
}
return 0;
}
第一次使用fork()函数的同学可能会有一个疑问:fork()函数怎么会得到两个返回值,而且两个返回值都使用变量pid存储,这样不会冲突么?
在使用fork()函数创建子进程的时候,我们的头脑内始终要有一个概念:在调用fork()函数前是一个进程在执行这段代码,而调用fork()函数后就变成了两个进程在执行这段代码。两个进程所执行的代码完全相同,都会执行接下来的if-else判断语句块。
当子进程从父进程内复制后,父进程与子进程内都有一个"pid"变量:在父进程中,fork()函数会将子进程的PID返回给父进程,即父进程的pid变量内存储的是一个大于0的整数;而在子进程中,fork()函数会返回0,即子进程的pid变量内存储的是0;如果创建进程出现错误,则会返回-1,不会创建子进程。
fork()函数一般不会返回错误,若fork()函数返回错误,则可能是当前系统内进程已经达到上限,或者内存不足。
注意!!!!:父子进程的运行先后顺序是完全随机的(取决于系统的调度),也就是说在使用fork()函数的默认情况下,无法控制父进程在子进程前进行还是子进程在父进程前进行。
父进程和子进程共享同一个文件偏移量
可以做个实验
这样是为了方便父子进程可以直接追加写数据,如果不共享文件偏移量 可能要追加写数据 需要其他显式的步骤 例如lseek
注意一个代码:
int main(int argc,char *argv[])
{
printf("111\n");
fork();
printf("222\n");
fork();
printf("333\n");
}
结果:
想想为什么不是全部进程都打印111 222 333,也就是说为什么fork生成的子进程不会打印输出前面的内容?
再看下面这个代码
int main(int argc,char *argv[])
{
printf("111");
fork();
printf("222");
fork();
printf("333");
}
这个代码就会全部进程都打印111 222 333
那为什么加了\n的子进程就不会打印fork前面的printf 没加就会打印?
在第一个代码中printf("111\n")是加了换行符的在调用第一个fork()之前就已经把缓冲区中的"111\n"给输出了,当执行到了第一个fork()的时候缓冲区就已经没数据了 这时候调用fork()生成的子进程因为是复制了父进程的缓冲区 但是缓冲区此时已经没数据了 所以不会再打印"111\n"了
这是因为标准输出是行缓冲的 就是如果没有遇到\n或者缓冲区满或者进程结束执行IO操作 是不会输出到屏幕的 所以没有\n的printf的内容就保存在缓冲区中 使用fork生成子进程时 将父进程的虚拟地址空间的缓冲区的内容也复制了到了自己的虚拟地址空间中 所以最后会打印;有\n的因为直接输出了 所以在缓冲区没有了 使用fork生成子进程 复制父进程的虚拟地址空间的内容时 就没有复制到打印的那些内容 所以不会打印出来
二.vfork()函数
fork()函数还有一个兄弟函数:vfork()。
函数vfork()
所需头文件:#include<sys/types.h>
#include<unistd.h>
函数原型:
pid_t vfork()
返回值:同fork()函数
vfork()函数功能与fork()函数功能类似不过更加彻底:内核不再给子进程创建虚拟空间,直接让子进程共享父进程的虚拟空间。当父子进程中有更改相应段的行为发生时,再为子进程相应的段创建虚拟空间并分配物理空间。在vfork()函数创建子进程后父进程会阻塞,保证子进程先行运行。
vfork()函数创建的子进程会与父进程(在调用exec函数族函数或exit()函数前)共用地址空间,此时子进程如果使用变量则会直接修改父进程的变量值这是因为堆 栈 数据段 代码段都是在虚拟地址空间的 ,而vfork共用父进程的虚拟地址空间 fork为子进程申请新的虚拟地址空间。因此,vfork()函数创建的子进程可能会对父进程产生干扰。另外,如果子进程未调用exec函数族函数或exit()函数,则父子进程会出现死锁现象。
举个例子,vfork()函数创建了一个“儿子”暂时“霸占”“老爹”的房产,此时需要委屈老爹一下,让老爹歇息(阻塞)。当儿子买房了(执行exec函数族函数)或者儿子死了(执行exit()退出),就相当于分家了,此时老爹得到自己的房产。
fork()函数与vfork()函数的主要区别如下:
1. 使用fork创建进程 :写时拷贝技术:内核只为新生成的子进程创建虚拟空间,复制父进程的虚拟空间,但是不为这些虚拟空间分配物理内存,它们共享父进程的物理空间(即让这些虚拟空间指向父进程的实际内存),当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。
2. 使用vfork创建进程 :内核不再给子进程创建虚拟空间,直接让子进程共享父进程的虚拟空间。当父子进程中有更改相应段的行为发生时,再为子进程相应的段创建虚拟空间并分配物理空间。
1.vfork保证子进程先运行,在它调用exec或者exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。(如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁)
2.fork()函数创建进程父子进程谁先运行是不确定的,取决于系统调度 。fork()函数需要拷贝父进程的进程环境,而vfork()函数则不需要完全拷贝父进程的进程环境,在子进程调用exec函数族函数或者exit()函数之前,子进程与父进程共享进程环境(此时子进程相当于线程),父进程阻塞等待。
1.使用vfork生成子进程此时子进程如果使用在vfork之前定义的变量则会直接修改父进程的变量值 ,在子进程中定义变量则会由内核为其申请虚拟地址空间和物理地址空间
2.而fork函数生成的子进程,子进程对fork之前定义的变量所做的改变并不影响父进程中该变量的值
这是因为堆 栈 数据段 代码段都是在虚拟地址空间的
而vfork共用父进程的虚拟地址空间和物理地址空间 fork为子进程申请新的虚拟地址空间 共用物理地址空间
vfork()创建子进程成功后是严禁使用return的,只能调用exit()或者exec族的函数,否则后果不可预料,在main函数里return和exit()效果一样是有前提的:没有调用vfork。
使用vfork()创建子进程后,父进程会被阻塞,直至子进程调用exec或者_exit函数退出,这就是出现问题的原因。
#include<stdio.h>
#include<unistd.h>
int g_var = 0;
int main() {
int var=10;
int pid;
while((pid=vfork())==-1);
if(pid==0){//子进程
g_var = 1;
var = 5;
printf("child process:g_var=%d,var=%d\n",g_var,var);
_exit(0);//出现报错因为子进程没有使用_exit(0)导致
}else{//父进程
printf("father process:g_var=%d,var=%d\n",g_var,var);
}
return 0;
}
/***********vfork()函数end*************/
练习1:修改示例代码,首先让父进程输出一段信息,3秒后让子进程输出一段信息,再3秒后让父进程输出另一段信息(使用sleep()函数控制)
答案:
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
pid_t pid;
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)
{
sleep(3);
printf("This is child process\n");
printf("pid is %d\n, My PID is %d\n",pid,getpid());
}
else
{
printf("This is parent process\n");
printf("pid is %d\n, My PID is %d\n",pid,getpid());
sleep(6);
printf("This is parent process\n");
}
return 0;
}
例2!!!!!!!!!!
#include <unistd.h>
#include <stdio.h>
int main()
{
fork();/*****/
fork() && fork() || fork();/*****/
fork();/*****/
sleep(100);
return 0;
}
问最后共有几个进程?
答:
因为子进程没有执行exit()或者exec族函数 所以各个父子进程都会执行main这个程序!!这很重要
因为最后一个fork( ) 就是把进程翻倍 所以只要把前四个fork( )生成的进程算出来乘以2就可以了
这道题主要考了两个知识点,一是逻辑运算符运行的特点;二是对fork的理解
如果有一个这样的表达式:cond1 && cond2 || cond3 这句代码会怎样执行呢?
1、cond1为假,那就不判断cond2了,接着判断cond3
2、cond1为真,这又要分为两种情况:
a、cond2为真,这就不需要判断cond3了
b、cond2为假,那还得判断cond3
fork调用的一个奇妙之处在于它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:
1、在父进程中,fork返回新创建子进程的进程ID;
2、在子进程中,fork返回0;
3、如果出现错误,fork返回一个负值(题干中说明了不用考虑这种情况)
如图分析 主要是要注意 使用fork( ) 父进程返回值>0(即子进程的pid) 子进程返回值是0 子进程使用fork( ) 后 它其实也是它的子进程的父进程 要注意
练习3(选做):在练习2的基础上,添加“文件锁”,使得父子进程分别对文件进行写操作
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
#include<sys/stat.h>
#include<fcntl.h>
#define MAX 26
int lock_set(int fd, int type)
{
struct flock lock;
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
lock.l_type = type;
lock.l_pid = -1;
switch(type)
{
case F_RDLCK:
case F_WRLCK:
if((fcntl(fd,F_SETLKW,&lock))<0)
{
printf("lock failed:type=%d\n",lock.l_type);
return -1;
}
break;
case F_UNLCK:
if((fcntl(fd,F_SETLKW,&lock))<0)
{
printf("unlock failed\n");
return -1;
}
break;
default:
printf("input error\n");
}
return 0;
}
int main(int argc, const char *argv[])
{
int fd;
pid_t pid;
int i;
char buffer[2];//write()函数缓冲区
if((fd=(open(argv[1],O_RDWR|O_CREAT|O_TRUNC,0666)))<0)
{
perror("cannot open file");
return -1;
}
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
lock_set(fd,F_WRLCK);
printf("child process begin\n");
for(i=0;i<26;i++)
{
sprintf(buffer,"%c ",'A'+i);
write(fd,buffer,2);
printf("child process write %c\n",'A'+i);
sleep(1);//延时1秒
}
printf("child process over\n");
lock_set(fd,F_UNLCK);
}
else//父进程
{
lock_set(fd,F_WRLCK);
printf("parent process begin\n");
for(i=0;i<26;i++)
{
sprintf(buffer,"%c ",'a'+i);
write(fd,buffer,2);
printf("parent process write %c\n",'a'+i);
sleep(1);//延时1秒
}
printf("parent process over\n");
lock_set(fd,F_UNLCK);
wait(NULL);
}
close(fd);
return 0;
}
三.exec函数族
exec函数族 其实就是让某个进程 不再执行原本执行的可执行程序 而是跑去 我们 !!指定的文件路径 !! 去执行我们指定的 !!可执行程序!!
我们经常使用的ls ps 这种命令其实也是可执行程序保存在/bin下 我们平时可以直接使用是因为环境变量的原因
所以想通过exce族函数 使用ls ps这种命令就要去到这个命令的可执行程序的路径去 !
比如excel("/bin/ls","ls",NULL ); 不过 我们可以使用带p的exce族函数直接例如 excelp("ls","ls",NULL ); 通过环境变量查找 而省略绝对路径
如果我们使用fork()函数创建一个子进程,则该子进程几乎复制了父进程的全部内容,也就是说,子进程与父进程在执行同一个可执行程序。那么我们能否让子进程不执行父进程正在执行的程序呢?
exec函数族提供了让进程运行另一个程序的方法。exec函数族内的函数可以根据指定的文件名或目录名找到可执行程序,并加载新的可执行程序,替换掉旧的代码区、数据区、堆区、栈区与其他系统资源。这里的可执行程序既可以是二进制文件,也可以是脚本文件。在执行exec函数族函数后,除了该进程的进程号PID,其他内容都被替换了。
通常情况下,我们首先使用fork()函数创建一个子进程,然后调用exec函数族内函数将子进程内程序替换成其他的可执行程序,这样看起来就像父进程诞生了一个新的且完全不同于父进程的子进程。
exec函数族有6个函数,这些函数的函数名、函数功能、函数参数列表有相似之处,我们在使用的过程中一定要仔细区分这些函数的区别避免混淆。有关exec函数族的更多使用方法内容请查阅man手册。
exec函数族函数
所需头文件:#include<unistd.h>
函数原型:
1. int execl(const char *path, const char *arg,…)
2.int execlp(const char *file, const char *arg,…)
3. int execle(const char *path, const char *arg,…, char *const envp[])
4. int execv(const char *path, char *const argv[])
5. int execvp(const char *file, char *const argv[])
6. int execve(const char *path, char *const argv[], char *const envp[])
execl(完整的路径名,列表……);
execlp(文件名,列表……);
execle(完整的路径,列表……,环境变量的向量表)
execv(完整的路径名,向量表);
execvp(文件名,向量表);
execve(完整的路径,向量表,环境变量的向量表)
函数参数:
path:文件路径,使用该参数需要提供完整的文件路径
file:文件名,使用该参数无需提供完整的文件路径,终端会自动根据$PATH的值查找文件路径
arg:以逐个列举方式传递参数
argv:以指针数组方式传递参数
envp:环境变量数组
返回值:-1(通常情况下无返回值,当函数调用出错才有返回值-1)
这6个函数的函数功能类似,但是在使用语法规则上有细微区别。我们可以看出,其实exec函数族的函数都是exec+后缀来命名的,具体的区别如下:
区别1:参数传递方式(函数名含有l还是v)
exec函数族的函数传参方式有两种:逐个列举或指针数组。
若函数名内含有字母'l'(表示单词list),则表示该函数是以逐个列举的方式传参,每个成员使用逗号分隔,其类型为const char *arg,成员参数列表使用NULL结尾
若函数名内含有字母'v'(表示单词vector),则表示该函数是以指针数组的方式传参,其类型为char *const argv[],命令参数列表使用NULL结尾
区别2:查找可执行文件方式(函数名是否有p)
我们可以看到这几个函数的形参有些为path,而有些为file。其中:
若函数名内没有字母'p',则形参为path,表示我们在调用该函数时需要提供可执行程序的完整路径信息
若函数名内含有字母'p',则形参为file,表示我们在调用该函数时只需给出文件名,系统会自动按照环境变量$PATH的内容来寻找可执行程序
区别3:是否指定环境变量(函数名是否有e)
exec可以使用默认的环境变量,也可以给函数传入具体的环境变量。其中:
若函数名内没有字母'e',则使用系统当前环境变量
若函数名内含有字母'e'(表示单词environment),则可以通过形参envp[]传入当前进程使用的环境变量
exec函数族简单命名规则如下:
后缀 能力
l 接收以逗号为分隔的参数列表,列表以NULL作为结束标志
v 接收一个以NULL结尾的字符串数组的指针
p 提供文件的完整的路径信息 或 通过$PATH查找文件
e 使用系统当前环境变量 或 通过envp[]传递新的环境变量
这6个exec函数族的函数,execve()函数属于系统调用函数,其余5个函数属于库函数。
示例:
int main(int argc,char *argv[])
{
/*这是在父进程执行的程序下定义的a*/
int a=1;
pid_t pid;
pid=fork();
if(pid<0)
{
printf("创建子进程失败!\n");
exit(-1);
}
else if(pid==0)
{
printf("这是子进程!子进程Pid=%d\n\n",getpid());
if( execlp("/mnt/hgfs/winshare/test3","./test3",NULL)<0)
{
perror("execlp error!");
exit(-1);
}
/*下面的程序不执行了*/
printf("这是原本执行的程序的内容");
exit(0);
}
else if(pid>0)
{
printf("这是父进程!父进程Pid为%d\n它的子进程Pid为%d\n",getpid(),pid);
}
}
可以看出 exec族函数其实就是让子进程跑去执行我们指定的文件路径 去执行我们指定的可执行程序 在这个示例中就是 子进程跑去执行了由text3.c编译生成的 text3可执行程序 而原本的可执行程序 子进程不再执行 所以 printf("这是原本执行的程序的内容");不会打印
示例1:使用execl()函数,在子进程内运行ps -ef命令
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t pid;
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
if(execl("/bin/ps","ps","-ef",NULL)<0)//子进程执行ps -ef,注意参数的写法,且需要使用NULL结尾
{
perror("cannot exec ps");
}
}
else//父进程
{
printf("This is Parent process\n");
sleep(1);//父进程延时1s,让子进程先运行
}
return 0;
}
运行该程序会发现,子进程会运行ps -ef命令,这与我们在终端直接输入ps -ef得到的结果是相同的。
注意我们在调用exec函数族的函数时,一定要加上错误判断语句。当exec函数族函数执行失败时,返回值为-1,并且报告给内核错误码,我们可以通过perror将这个错误码的对应错误信息输出。常见的exec函数族函数执行失败的原因有:
1.找不到文件或路径
2.参数列表arg、数组argv和环境变量数组列表envp未使用NULL指定结尾
3.该文件没有可执行权限
示例2:使用execlp()函数完成示例1的代码,注意execlp()与execl()函数的参数的区别
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t pid;
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
if(execlp("ps","ps","-ef",NULL)<0)//第一个参数只需要写ps即可,系统会根据环境变量自行寻找ps程序的位置
{
perror("cannot exec ps");
}
}
else//父进程
{
printf("This is Parent process\n");
sleep(1);//父进程延时1s,让子进程先运行
}
return 0;
}
示例3:使用execvp()函数完成示例2的代码,注意execvp()与execlp()函数的参数的区别
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t pid;
char *arg[]={"ps","-ef",NULL};//设定参数向量表,注意使用NULL结尾
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
if(execvp("ps",arg)<0)//注意该函数的参数与execlp()函数的区别
{
perror("cannot exec ps");
}
}
else//父进程
{
printf("This is Parent process\n");
sleep(1);//父进程延时1s,让子进程先运行
}
return 0;
}
接下来我们看如何使用execle()或execve()传递新的环境变量
示例4:使用execle()函数将一个新的环境变量添加到子进程中,并使用env命令查看
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t pid;
char *envp[]={"PATH=/tmp","USER=liyuge",NULL};//设定新的环境变量,注意使用NULL结尾
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
if(execle("/usr/bin/env","env",NULL,envp)<0)
{
perror("cannot exec env");
}
}
else//父进程
{
printf("This is Parent process\n");
sleep(1);//父进程延时1s,让子进程先运行
}
return 0;
}
运行该程序,我们可以看到输出了两个新的环境变量信息:PATH和USER,这两个新的环境变量与旧的环境变量(父进程)是不同的,有兴趣的同学可以将父进程的环境变量也输出作比较。
示例5:使用execve()函数完成示例2的代码,注意execve()与execle()函数的参数的区别
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t pid;
char *arg[]={"env",NULL};//设定参数向量表,注意使用NULL结尾
char *envp[]={"PATH=/tmp","USER=liyuge",NULL};//设定新的环境变量,注意使用NULL结尾
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
if(execve("/usr/bin/env",arg,envp)<0)
{
perror("cannot exec env");
}
}
else//父进程
{
printf("This is Parent process\n");
sleep(1);//父进程延时1s,让子进程先运行
}
return 0;
}
练习:使用exec函数族函数,在子进程内执行自己编译的可执行程序a.out文件
答案:使用execl()函数,其余函数的用法请同学们自己思考
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t pid;
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
if(execl("/home/linux/a.out","./a.out",NULL)<0)//使用execl()函数
{
perror("cannot exec a.out");
}
}
else//父进程
{
printf("This is Parent process\n");
sleep(1);//父进程延时1s,让子进程先运行
}
return 0;
}
四、exit()函数与_exit()函数
当我们需要结束一个进程的时候,我们可以使用exit()函数或_exit()函数来终止该进程。当程序运行到exit()函数或_exit()函数时,进程会无条件停止剩下的所有操作,并进行清理工作,最终将进程停止。
当我们需要结束一个进程的时候,我们可以使用exit()函数或_exit()函数来终止该进程。当程序运行到exit()函数或_exit()函数时,进程会无条件停止剩下的所有操作,并进行清理工作,最终将进程停止。
函数exit()
所需头文件:#include<stdlib.h>
函数原型:
void exit(int status)
函数参数:
status 表示让进程结束时的状态(会由主进程的wait();负责接收这个返回值【也可以不接收】-->类似函数的返回值),默认使用0表示正常结束
返回值:无
函数_exit()
所需头文件:#include<unistd.h>
函数原型:
void _exit(int status)
函数参数:
status 同exit()函数
返回值:无
exit()函数与_exit()函数用法类似,但是这两个函数还是有很大的区别的:
_exit()函数直接使进程停止运行,当调用_exit()函数时,内核会清除该进程的内存空间,并清除其在内核中的各种数据。
exit()函数则在_exit()函数的基础上进行了升级,在退出进程之间增加了若干工序。
exit()函数在终止进程之前会检测进程打开了哪些文件,并将缓冲区内容写回文件。
因此,exit()函数与_exit()函数最主要的区别就在于是否会将缓冲区数据保留并写回。
1._exit()函数不会保留缓冲区数据,直接将缓冲区数据丢弃,直接终止进程运行
2.而exit()函数会将缓冲区内数据写回,待缓冲区清空后再终止进程运行。
c语言exit和return区别,在fork和vfork中使用
exit函数在头文件stdlib.h中。exit(0):正常运行程序并退出程序;
exit(1):非正常运行导致退出程序;
return():返回函数,若在main主函数中,则会退出函数并返回一值,可以写为return(0),或return 0。
详细说:
1. return返回函数值,是关键字;exit是一个函数。
2. return是语言级别的,它表示了调用堆栈的返回;而exit是系统调用级别的,它表示了一个进程的结束。
3. return是函数的退出(返回);exit是进程的退出。4. return是C语言提供的,exit是操作系统提供的(或者函数库中给出的)。
5. return用于结束一个函数的执行,将函数的执行信息传出个其他调用函数使用;exit函数是退出应用程序,删除进程使用的内存空间,并将应用程序的一个状态返回给OS,这个状态标识了应用程序的一些运行信息,这个信息和机器和操作系统有关,一般是 0 为正常退出,非0 为非正常退出。
6. 非主函数中调用return和exit效果很明显,但是在main函数中调用return和exit的现象就很模糊,多数情况下现象都是一致的。
下面的两段示例代码演示了exit()函数与_exit()函数的区别
示例1:使用exit()函数终止进程
#include<stdio.h>
#include<stdlib.h>
int main()
{
printf("Using exit()\n");
printf("This is the content in buffer");
exit(0);
}
示例2:使用_exit()函数终止进程
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("Using exit()\n");
printf("This is the content in buffer");
_exit(0);
}
在两个示例程序中,示例1会输出"This is the content in buffer",而示例2不会输出
在文件IO中我们知道因为标准输入输出流是行缓冲的 在遇到换行符或者是流缓冲区满或者刷新缓冲区操作时 进行IO操作 所以
1.使用了 _exit( ) 函数的程序 即第二个程序中 printf("This is the content in buffer"); 这句话因为是标准输出函数 是行缓冲 因为没有换行符 所以"This is the content in buffer"是在缓冲区中的
而 _exit( ) 函数 直接将缓冲区数据丢弃,直接终止进程运行 所以就不会打印出来这句话
2.使用了exit ( )函数 因为exit()函数会将缓冲区内数据写回,待缓冲区清空后再终止进程运行。所以直接打印了 "This is the content in buffer"
WIFEXITED 和 WEXITSTATUS 宏
1,WIFEXITED(status) 这个宏用来指出子进程是否为正常退出的,如果是,它会返回一个非零值。
(请注意,虽然名字一样,这里的参数status并不同于wait唯一的参数–指向整数的指针status,而是那个指针所指向的整数,切记不要搞混了。)
2,WEXITSTATUS(status) 当WIFEXITED返回非零值时,我们可以用这个宏来提取子进程的返回值,如果子进程调用exit(5)退出,WEXITSTATUS(status)就会返回5;如果子进程调用exit(7),WEXITSTATUS(status)就会返回7。请注意,如果进程不是正常退出的,也就是说,WIFEXITED返回0,这个值就毫无意义。
status 表示让进程结束时的状态(会由主进程的wait();负责接收这个返回值【也可以不接收】-->类似函数的返回值),默认使用0表示正常结束
五.wait()函数与waitpid()函数
基本原理
(1)父进程调用wait函数后阻塞 等待子进程结束
(2)子进程结束时,系统向其父进程发送SIGCHILD信号(子进程已经停止或者终止)
(3)父进程被SIGCHILD信号唤醒然后去回收僵尸子进程
(4)父子进程之间是异步的,SIGCHILD信号机制就是为了解决父子进程之间的异步通信问题,让父进程可以及时的去回收僵尸子进程。
(5)若父进程没有任何子进程则wait返回错误
使用wait()函数与waitpid()函数让父进程回收子进程的系统资源,两个函数的功能大致类似,
wait()相当于是waitpid()的特殊情况
函数wait()
所需头文件:#include<sys/types.h>
#include<sys/wait.h>
函数原型:
pid_t wait(int *status)
函数参数:
status 保存子进程结束时的状态(由exit();返回的值)。使用地址传递,父进程获得该变量。若无需获得状态,则参数设置为NULL
返回值:
成功:已回收的子进程的PID
失败:-1
函数waitpid()
所需头文件:#include<sys/types.h>
#include<sys/wait.h>
函数原型:
pid_t waitpid(pid_t pid, int *status, int options)
函数参数:
pid pid是一个整数,具体的数值含义为:
pid>0 回收PID等于参数pid的子进程
pid==-1 回收任何一个子进程。此时同wait()
pid==0 回收其组ID等于调用进程的组ID的任一子进程
pid<-1 回收其组ID等于pid的绝对值的任一子进程
status 同wait()
options
0:同wait(),此时父进程会阻塞等待子进程退出
WNOHANG:若指定的进程未结束,则立即返回0(不会等待子进程结束)
返回值:
>0 已经结束运行的子进程号
0 使用WNOHANG选项且子进程未退出
-1 错误
当进程结束时,该进程会向它的父进程报告。wait()函数用于使父进程阻塞,直到父进程接收到一个它的子进程已经结束的信号为止。如果该进程没有子进程或所有子进程都已结束,则wait()函数会立即返回-1。
waitpid()函数的功能与wait()函数一样,不过waitpid()函数有若干选项,所以功能也比wait()函数更加强大。实际上,wait()函数只是waitpid()函数的一个特例而已,Linux内核总是调用waitpid()函数完成相应的功能。
wait(NULL)等价于waitpid(-1,NULL,0)。
示例1:使用wait()函数,让父进程在子进程结束后再运行
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t pid;
pid = fork();
if(pid==-1)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
printf("Child process ID is %d\n",getpid());
printf("Child process will exit\n");
}
else//父进程
{
pid = wait(NULL);//等待子进程结束
printf("This is Parent process\n");
printf("Child process %d is over\n",pid);
}
return 0;
}
示例2:使用waitpid()函数,让父进程回收子进程。参数使用WNOHANG使父进程不会阻塞,若子进程暂时未退出,则父进程在1s后再次尝试回收子进程
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
int main()
{
pid_t pid;
pid = fork();
if(pid<0)
{
perror("cannot fork");
return -1;
}
else if(pid==0)//子进程
{
printf("This is Child process\n");
sleep(5);//模拟子进程运行5s
exit(0);//子进程正常退出
}
else//父进程
{
int ret;
do//循环直至子进程退出为止
{
ret = waitpid(pid,NULL,WNOHANG);//回收子进程,使用WNOHANG选项参数
if(ret==0)
{
printf("The Child process is running, can't be exited\n");
sleep(1);//1秒后再次尝试
}
}while(ret==0);
if(pid==ret)//如果检测到子进程退出
{
printf("Child process exited\n");
}
else
{
printf("Some error occured\n");
}
}
return 0;
}
练习:若将示例2的程序内的waitpid()函数去掉WNOHANG选项参数,会出现什么效果?编程验证自己的猜想。
答案:父进程会一直阻塞等待子进程结束为止。在终端上只会输出1个"Child process exited\n",而不会输出"The Child process is running, can't be exited\n"
综合练习:使用fork()函数、exec函数族函数、exit()函数、waitpid()函数完成以下功能:
该程序有3个进程,其中1个为父进程,另外2个为子进程。其中一个子进程运行"ls -l"命令,另外一个子进程延时5秒后退出。父进程首先使用阻塞的方式等待第一个子进程结束,再采用非阻塞的方式等待第二个子进程结束。待两个子进程都退出后,父进程退出。
该练习题给出两种答案,一种正确,一种错误,请观察两段代码的区别,并指出错误的代码产生错误的原因
答案1(
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
int main()
{
pid_t child1,child2,child;
child1 = fork();//?
child2 = fork();//?
if(child1<0)
{
printf("Child1 fork error\n");
exit(1);
}
else if(child1==0)
{
printf("In Child1 execute 'ls -l'\n");
if(execlp("ls","ls","-l",NULL)<0)
{
perror("child1 execlp error");
}
}
if(child2<0)
{
printf("Child2 fork error\n");
exit(1);
}
else if(child2==0)
{
printf("In Child2 sleep 5s\n");
sleep(5);
exit(0);
}
else
{
printf("In parent process:\n");
child = waitpid(child1,NULL,0);
if(child==child1)
{
printf("Get child1 exit\n");
}
else
{
printf("Error occured\n");
}
do
{
child = waitpid(child2,NULL,WNOHANG);
if(child==0)
{
printf("The child2 process hasn't exited\n");
sleep(1);
}
}while(child==0);
if(child==child2)
{
printf("Get child2 exit\n");
}
else
{
printf("Error occured\n");
}
}
exit(0);
}
答案2(正确答案):
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
int main()
{
pid_t child1,child2,child;
child1 = fork();
if(child1<0)
{
printf("Child1 fork error\n");
exit(1);
}
else if(child1==0)
{
printf("In Child1 execute 'ls -l'\n");
if(execlp("ls","ls","-l",NULL)<0)
{
perror("child1 execlp error");
}
}
else
{
child2 = fork();
if(child2<0)
{
printf("Child2 fork error\n");
exit(1);
}
else if(child2==0)
{
printf("In Child2 sleep 5s\n");
sleep(5);
exit(0);
}
else
{
printf("In parent process:\n");
child = waitpid(child1,NULL,0);
if(child==child1)
{
printf("Get child1 exit\n");
}
else
{
printf("Error occured\n");
}
do
{
child = waitpid(child2,NULL,WNOHANG);
if(child==0)
{
printf("The child2 process hasn't exited\n");
sleep(1);
}
}while(child==0);
if(child==child2)
{
printf("Get child2 exit\n");
}
else
{
printf("Error occured\n");
}
}
}
exit(0);
}
六.system () 函数和popen()函数
说在前面:在实际编程中尽量减少使用system函数。
函数说明 这两个函数都是c库函数
system()会调用fork()产生子进程,由子进程来调用/bin/sh-c string来执行参数command字符串所代表的命令(相当于exce族函数),此命令执行完后随即返回原调用的进程。在调用system()期间SIGCHLD 信号会被暂时搁置,SIGINT和SIGQUIT 信号则会被忽略。
相当于fork() 和exce族函数的结合体
system()函数
表头文件
#include<stdlib.h>
定义函数
int system(const char * command);
返回值
(1)当参数command是NULL的时候
在参数为NULL的情况下,system函数的返回值很简单明了,只有0和1。
返回1,表明系统的命令处理程序,即/bin/sh是可用的。
相反,如果命令处理程序不可用,则返回0。
(2)当参数command不是NULL的时候
当参数不为NULL的时候,情况有些小复杂,根据APUE这里可以分为以下三种情况:
1)如果fork等系统调用失败,或者waitpid函数发生除EINTR外的错误时,system返回-1
2)一切致使execl失败的情况下,system返回127
3)除此之外,system返回/bin/sh的终止状态
System与exec的区别
1、system()和exec()都可以执行进程外的命令,system是在原进程上开辟了一个新的进程,但是exec是用新进程(命令)覆盖了原有的进程
2、system()和exec()都有能产生返回值,system的返回值并不影响原有进程,但是exec的返回值影响了原进程
system()函数功能强大,很多人用却对它的原理知之甚少先看linux版system函数的源码:
#include <sys/types.h>
#include <sys/wait.h>
#include <errno.h>
#include <unistd.h>
int system(const char * cmdstring)
{
pid_t pid;
int status;
if(cmdstring == NULL){
return (1);
}
if((pid = fork())<0){
status = -1;
}else if(pid = 0){
execl("/bin/sh", "sh", "-c", cmdstring, (char *)0);
-exit(127); //子进程正常执行则不会执行此语句
}else{
while(waitpid(pid, &status, 0) < 0){
if(errno != EINTER){
status = -1;
break;
}
}
}
return status;
}
system()函数的简单使用
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main()
{
int status = 0;
status = system("ls -a");
if(-1 == status)
{
perror("system");
exit(1);
}
if(WIFEXITED(status) != 0) //正常退出
{
if(WEXITSTATUS(status) == 0) //操作正确
{
printf("run command success\n");
}
else
{
printf("run error\n");
}
}
else //异常退出
{
printf("exit error is %d\n", WEXITSTATUS(status));
}
return 0;
}
通过上面的代码我们可以看到,system在使用时的一个弊端,由于返回值太多,要安全的使用它就要进行许多步的出错处理。
所以,不太建议使用system。
使用system需要注意:
1、建议system()函数只用来执行shell命令,因为一般来讲,system()返回值不是0就说明出错了;
2、监控一下system()函数的执行完毕后的errno值,争取出错时给出更多有用信息;
建议使用popen()函数取代system()
popen()函数
类似于fork() 和exce族函数的结合体
FILE *popen(const char *command, const char *type);
int pclose(FILE *stream);
返回值: command的终止状态, 出错返回-1
popen()函数较于system()函数的优势在于使用简单,popen()函数只返回两个值:
成功返回子进程的status,使用WIFEXITED相关宏就可以取得command的返回结果;
失败返回-1,我们可以使用perro()函数或strerror()函数得到有用的错误信息。
popen先执行fork,然后调用exec以执行command并返回一个标准I/O文件指针。如果type是“r”,则文件指针链接到command的标准输出。如果type是“w”,则文件指针链接到command的标准输入。将popen和fopen进行类比,方便记忆其最后一个参数及其作用,如果type是“r”,则返回文件指针是可读的,如果type是是“w”,则是可写的。
简单使用:
#include <stdio.h>
#include <stdlib.h>
int main()
{
FILE* fp = NULL;
char buf[1024] = {0};
fp = popen("ls -a", "r");
if(NULL == fp)
{
perror("popen");
exit(1);
}
while(fgets(buf, 1024, fp) != NULL)
{
fprintf(stdout, "%s", buf);
}
pclose(fp);
return 0;
}
注意,popen绝不应该由设置用户ID或设置组ID程序调用。
当它执行命令popen等同于execl("/bin/sh", "sh", "-c", command,NULL);
它在从调用者继承的环境中执行shell,并由shell解释执行command。一个心怀不轨的用户可以
操纵这种环境,使得shell能以设置ID文件模式所授予的提升了
的权限以及非预期的方式执行命令。
popen特别适用于构造简单的过滤程序,它变换运行命令的输入或输出。
当命令希望构建自己的管道线时就是这种情形。
【IPC通信】基于管道的popen和pclose函数 - 恋恋美食的个人空间 - OSCHINA - 中文开源技术交流社区https://my.oschina.net/renhc/blog/35116
四.守护进程概述
Linux下的守护进程_snow_5288的博客-CSDN博客_linux 守护进程一、什么是守护进程1、守护进程的引入 在现实生活中, 许多大型的软件或服务器必须保证7*24小时(一周7天,一天24小时)无障碍的运行,例如淘宝网、百度搜索引擎、支付宝等等,那么像这样一种要一直运行的程序怎么实现呢?究其本质其实就是我们的守护进程。2、守护进程的定义 守护进程也称精灵进程(Daemon),是运行在后台的一种特殊进程。它独立于控制终端并且周期性地执行某种任务或等待处理某些发生的事https://blog.csdn.net/snow_5288/article/details/73321516守护进程的引入
在现实生活中, 许多大型的软件或服务器必须保证7*24小时(一周7天,一天24小时)无障碍的运行,例如淘宝网、百度搜索引擎、支付宝等等,那么像这样一种要一直运行的程序怎么实现呢?究其本质其实就是我们的守护进程。
守护进程(daemon,也被译为“精灵进程”)是一类在后台工作的特殊进程,通常情况下,守护进程用于执行特定的系统任务。守护进程的生命周期较长,一些守护进程在系统引导时启动,一直运行至系统关闭;另一些守护进程在需要工作的时候启动,完成任务后就自动结束。在操作系统内,许多的系统服务都是通过守护进程实现的。
守护进程的特点
Linux系统启动时会启动很多系统服务进程,这些系统服 务进程没有控制终端,不能直接和用户交互。其它进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但系统服务进程不受用户登录注销的影响,它们一直在运行着。这种进程有一个名称叫守护进程(Daemon)。
在Linux中,每个系统与用户进行交流的界面成为终端,每一个从此终端开始运行的进程都会依附于这个终端,这个终端被称为这些进程的控制终端,当控制终端被关闭的时候,相应的进程都会自动关闭。但是守护进程却能突破这种限制,它脱离于终端并且在后台运行,并且它脱离终端的目的是为了避免进程在运行的过程中的信息在任何终端中显示并且进程也不会被任何终端所产生的终端信息所打断。它从被执行的时候开始运转,知道整个系统关闭才退出(当然可以认为的杀死相应的守护进程)。如果想让某个进程不因为用户或中断或其他变化而影响,那么就必须把这个进程变成一个守护进程。
守护进程与终端无任何关联?
用户的登录与注销与守护进程无关系,不受其影响,守护进程自成进程组,自成会话 ,即pid = gid = sid。
由于守护进程运行在后台,因此守护进程必须脱离前台(终端)运行。通常情况下,每一个从终端启动的进程都是依附于该终端的,当该终端关闭时,依附于该终端的进程也会自动结束。而守护进程却能够(或者说,必须)突破终端的限制,不受终端的影响在后台工作。从另一个角度说,若我们希望某些任务不因用户、终端或其他因素的变化而受影响,则必须把这个进程变成守护进程。
常见的守护进程有以下几类:
1.系统守护进程:syslogd、login、crond、at等
2.网络守护进程:sendmail、httpd、xinetd等
3.独立启动的守护进程:httpd、named、xinetd等
4.被动守护进程:telnet、finger、ktalk等
/**********一些守护进程简介************/
1、syslogd:用于记录设备的运行情况,通常情况下系统内设备的运行情况都保存在/var/log目录下
2、login:改变当前登陆用户的ID并调用该用户使用的Shell终端
3、crond:Linux内用户周期性地执行某种任务或处理某些事件的守护进程,类似windows系统下的计划任务
4、at:定时任务,指定在某个时间点执行某个任务
5、httpd:Apache相关的Http服务器进程
6、xinetd:inetd进程的升级版,负责internet服务相关的守护进程,支持对TCP/UDP/RPC的服务,支持时间片访问,有效防止DoS攻击,提供功能完备的日志文件等功能。
7、telnet:internet远程登陆的标准协议与主要方式,使用telnet可以连接服务器,使得可以在本地访问/控制服务器
/**********一些守护进程简介end**********/
查看当前系统内正在工作的守护进程:命令ps -axj
五.编写守护进程
守护进程看似复杂,实际编写一个守护进程是有固定的流程的。只要遵循特定的流程编写即可。
我们在编写守护进程的时候,要特别注意守护进程是独立于终端存在的,因此需要尽可能保证该进程脱离终端控制。
编写一个守护进程的步骤如下:
1.创建子进程,父进程退出
2.在子进程内创建新会话
3.改变工作目录
4.重设文件权限掩码
5.关闭文件描述符
1、创建子进程,父进程退出
由于守护进程是脱离终端的,因此我们创建一个进程后,让它的父进程退出,子进程继续运行。这样就会在终端里造成一个“该进程已经运行完毕”的假象,让所有的守护进程的功能都在子进程内执行,而用户可以在终端输入其他命令。这样从形式上就做到了让子进程转入后台工作。
当父进程退出后,子进程变成了孤儿进程。在Linux内,如果出现了孤儿进程,则自动由1号进程(init进程)收养该进程,也就是说变成了1号进程(init进程)的子进程了。
2、在子进程内创建新会话
这个步骤是创建守护进程最重要的步骤,虽然实现特别简单,但是其背后的意义十分重大。
这里我们首先需要了解两个概念:进程组与会话期
1)进程组(Process Group)
进程组是一个或多个进程的集合。进程组号由PGID(Process Group ID)唯一识别。
进程不是孤立的,每个进程都存在父子、兄弟关系,如果某些进程关系相近或者功能相似,那么我们可以将这些进程编成一个进程组。每个进程必定属于一个进程组,也只能属于一个进程组。
那么Linux为什么要设置进程组呢?其实设置进程组是为了方便管理多个进程。例如我们完成某个任务需要100个进程,当任务结束后需要一个一个杀死每个进程,十分繁琐;如果我们将这100个进程编成一个进程组,则直接针对进程组发起“杀死进程”的操作,这样可以一次性杀死100个进程。
每个进程除了进程号PID之外,进程组号PGID也是一个必备的属性。
每个进程组都有一个组长进程,负责整个进程组的调度工作。组长进程的PID等于进程组的PGID,不过PGID不会因为组长进程的退出而改变。
2)会话期(Session)
会话期(简称会话)是一个或多个进程组的集合。通常情况下,一个会话期开始于用户登录,终止于用户退出;或者开始于终端打开,结束于终端关闭。会话期也有自己的会话期号,会话期由SID(Session ID)唯一识别。
Linux是一个多任务的操作系统,必须要支持多个用户同时登录同一个操作系统的操作。当一个用户登录一次时,操作系统就为这个用户创建一个新会话。每个会话期都有一个会话组长(Session Leader),也称为会话期首进程,即创建该会话的进程,会话期的SID就是会话组长的PID。
一个会话可以包含多个进程组,这些进程组被分为一个前台进程组和多个后台进程组。前台进程是操作系统与用户进行交互的进程组,注意前台进程组只能有一个。而后台进程组则无需与用户进行交互,一些后台工作的进程或者守护进程就被分配成为了后台进程组。
那么我们为什么要在子进程中创建一个新会话呢?
我们通过fork()函数创建了子进程,通过前面的学习我们知道,子进程复制了父进程的资源,其中就包括父进程的进程组、会话期与控制终端。虽然在第一步时我们将父进程退出,但是父进程的进程组、会话期与控制终端没有改变,还保留在子进程内,因此,这时的子进程还不是真正意义上的独立。
我们可以使用setsid()函数来创建一个新会话,并让该进程担任会话组长。
调用setsid()函数主要有三个功能:
1.让进程摆脱原会话控制
2.让进程摆脱原进程组控制
3.让进程摆脱原控制终端控制
setsid会导致:
1)调用进程成为新会话的首进程。
2)调用进程成为一个进程组的组长进程 。
3)调用进程没有控制终端。(再次fork一次,保证daemon进程,之后不会打开tty设备)
setsid()函数用法如下:
函数setsid()
所需头文件:#include<sys/types.h>
#include<unistd.h>
函数原型:pid_t setsid()
函数参数:无
函数返回值:
成功:该会话期的SID
失败:-1
3、改变工作目录
使用fork()函数创建的子进程复制了父进程的工作目录。由于在进程运行过程中,当前进程的工作目录所在的文件系统是无法卸载的,这会对后续的工作造成一定麻烦(例如需要切换到单用户模式),因此在后台工作的守护进程的工作目录必须改变成其他的不会受到干扰的工作目录。通常情况下,我们将守护进程的工作目录设定为根目录"/",除了根目录之外,某些情况下还可以放置在"/tmp"目录下。
改变工作目录的函数时chdir()。
函数chdir()
所需头文件:#include<unistd.h>
函数原型:int chdir(const char *path)
函数参数:path:需要改变工作目录的路径
函数返回值:
成功:0
失败:-1
4、重设文件权限掩码
文件权限掩码的作用是屏蔽文件权限码中对应的位,通常情况下用8进制数表示。例如,如果文件权限掩码是050,则表示屏蔽了文件组用户的可读与可写权限。
使用fork()函数创建的子进程复制了父进程的文件权限码,这样守护进程在工作时可能会受到一定影响。因此,我们重设文件权限,使得守护进程的进行不受阻碍,更加灵活。
设置文件权限掩码的函数为umask(),通常使用umask(0),即将该进程的文件权限码设为0777
因此在子进程中要把文件的权限掩码设置成为0,即在此时有最大的权限,这样可以大大增强该守护进程的灵活性。
函数umask()
所需头文件:#include<sys/types.h>
#include<sys/stat.h>
函数原型:mode_t umask(mode_t mask)
函数参数:mask:需设定的文件权限掩码。常用为0
函数返回值:无需返回值(总是成功)
5、关闭文件描述符
同文件权限码一样,使用fork()函数创建的子进程复制了父进程已打开文件的文件描述符。这些被打开的文件可能永远不会被守护进程访问,但是仍然在占用系统资源,而且还可能导致该文件无法被关闭等情况。
特别要指出,由于守护进程在后台运行,和终端无关,因此终端的标准输入流、标准输出流、标准错误流已经失去了存在的价值,因此需要关闭这3个流。(值得注意的是通过关闭这些流的文件描述符就可以关闭这些流)
通常情况下我们可以使用getdtablesize()函数获取该进程已经打开的所有文件描述符,并使用循环语句逐个关闭这些文件描述符。
函数getdtablesize()
所需头文件:#include<unistd.h>
函数原型:int getdtablesize()
函数参数:无
函数返回值:当前进程打开的文件描述符总数
知道了文件描述符总数 就可以使用close来关闭了 因为文件描述符是从0开始的
所以直接for (i=0,i<getdtablesize( ),i++ ){close(i);} j就可以关闭了
示例:创建一个守护进程,该守护进程的功能是每隔2秒向/tmp/daemon.log文件内写入一行字符串
#include<stdio.h>
#include<stdlib.h>
#include<time.h>
#include<string.h>
#include<fcntl.h>
#include<sys/types.h>
#include<unistd.h>
#include<sys/stat.h>
#include<sys/wait.h>
int main()
{
pid_t pid;
int i,fd;
char writebuf[128]={0};
time_t t;
pid = fork();
if(pid<0)
{
perror("cannot fork");
exit(1);
}
else if(pid>0)//父进程
{
exit(0);//第一步:父进程退出
}
setsid();//第二步:子进程创建新会话
chdir("/tmp");//第三步:改变工作目录为/tmp
//虽然经常设置工作目录为根目录,但由于本题需要向/tmp/daemon.log内写入数据,因此直接设置工作目录为/tmp
umask(0);//第四步:重设文件权限掩码
for(i=0;i<getdtablesize();i++)//第五步:关闭所有文件描述符,尤其是3个默认流的文件描述符
{
close(i);
}
//此时守护进程创建完毕,下面开始守护进程的工作
while(1)
{
if((fd=open("daemon.log",O_WRONLY|O_CREAT|O_TRUNC,0600))<0)
{
perror("cannot open file daemon.log");
exit(1);
}
strcpy(writebuf,"This is Daemon Process\n");
write(fd,writebuf,strlen(writebuf));
bzero(writebuf,sizeof(writebuf));
t = time(NULL);
sprintf(writebuf,"Write Time:%s\n",asctime(localtime(&t)));//使用asctime获取时间
write(fd,writebuf,strlen(writebuf));
bzero(writebuf,sizeof(writebuf));
close(fd);//写毕关闭文件
sleep(2);//延时2s
}
exit(0);
}
该程序每隔2秒向/tmp/daemon.log内写入数据。我们可以使用ps命令看到守护进程正在运行