目标
- 了解Linux系统进程的基本概念
- 了解Linux系统进程的基本状态
- 了解Linux系统进程的内存布局
- 掌握Linux系统进程的创建
- 掌握Linux系统进程体的替换
- 掌握Linux系统进程间的通信方式
4.1 Linux下C程序的内存映像
4.1.1. 计算机程序的本质
(1)计算机程序的目的是为了去运行,程序运行是为了得到一定的结果,也就是说程序运行的主要目标是生成目标数据。我们如何得到目标数据?必须2个因素:原材料+加工算法。原材料就是程序的输入数据,加工算法就是代码。计算机程序的本质都是在做计算。计算就是计算数据,计算机程序=代码+数据,代码就是加工数据的动作,数据就是被代码加工的数字。程序运行得到结果,等价于代码+数据+运行=结果。
(2)整个程序分成多个源文件,一个文件分成多个函数,一个函数分成多个语句,这就是整个程序的组织形式。程序运行的目的就是得到结果或过程。用函数类比:函数形参和局部变量就是被加工的数据,函数体就是代码,函数体的执行过程就是运行,函数的返回值就是结果。有的函数重视结果、有的函数重视运行过程、有的函数既重视结果又重视运行过程。
(3)计算机程序运行的过程,其实就是很多个函数相继运行的过程。程序是由很多个函数组成的,程序的本质就是函数,函数的本质就是加工数据的动作。
4.1.2. 内存管理
(1)程序执行需要内存支持;程序是被放在内存中运行的;程序运行时需要内存存储一些临时变量。
(2)内存本身在物理上是一个硬件器件,由硬件系统提供;内存由操作系统统一管理,操作系统提供了多种机制来让应用程序使用内存,程序员编程时根据自己的实际情况选择某种机制来获取内存(在操作系统处登记该块内存的使用权限)、使用内存、释放内存(向操作系统归还这块内存的使用权限)。
(3)在C语言程序中能够获取内存的机制为栈(stack)、堆(heap)、数据区(.data)。
4.1.3. 程序存储空间布局
(1)编译器在编译程序时将程序中所有元素分成一些组成部分,各部分构成一个段,则段是可执行程序的组成部分。程序在内存中大致分为5个部分。
(2)正文段- CPU执行的机器指令部分。通常,正文段是可共享的,所以即使是频繁执行的程序,在存储器中也只需要一个副本。此外,程序段通常是只读的,以防止程序意外修改其指令。
(3)已初始化数据段-通常将此段称为数据段,它包含了程序需明确赋初值的变量。
(4)未初始化数据段-通常将此段称为BSS段,这一名称来源于早期汇编程序一个操作符,意思是“由符号开始的块”,在程序开始执行之前,内核将此段中的数据初始化为0或空指针。
(5)栈区-自动变量以及每次调用函数时所需保存的信息都存放在此段中。每次调用函数时,其返回地址以及调用者的环境信息都存放在栈中。
(6)堆区-通常在堆中进行动态分配。由于惯例,堆位于未初始化数据段和栈之间。

4.1.4. 虚拟地址空间的管理
(1)程序一旦被执行就成为一个进程,内核就会为每个运行的进程提供了大小相同的虚拟地址空间,这使得多个进程可以同时运行而又不会互相干扰。具体来说一个进程对某个地址的访问,绝不会干扰其他进程对同一地址的访问。
(2) 对于32位的系统来讲,每个进程都拥有4GB(32位)大小的虚拟地址空间,每个进程都拥有私有的前3G空间,即“用户空间”;而后1G空间被每个进程所共享,即“内核空间”。
(3) 进程访问内核空间的唯一途径为系统调用。在每个进程眼中,它们各自拥有4GB大小的地址空间;而在CPU眼中,任意时刻,一个CPU上只存在一个虚拟地址空间。虚拟地址空间随着进程间的切换而变化。

(4) 内存映射:把文件从磁盘映射到进程用户空间的一个虚拟内存区域中,对文件的访问转化为对虚拟内存区的访问。当从这段虚拟内存中读数据时,就相当于读磁盘文件中的数据,将数据写入这段虚拟内存时,则相当于将数据直接写入磁盘文件。这样就可以在不使用基本I/O操作函数read和write的情况下执行I/O操作。
(5) 用户态的程序经过编译执行形成进程,进程虽然可以任意访问整个用户空间的内存,但这毕竟属于虚拟地址空间,因此进程最终必须访问到物理内存。将虚拟内存和物理内存连接起来的就是分页机制,它在虚拟地址和物理地址之间建立了一种映射关系。操作系统实现虚拟地址到物理地址空间的映射(即MMU)。其意义在于:①进程隔离(各个进程之间彼此互不干扰,保证安全性);②提供多进程同时运行(所有应用程序都从虚拟地址0开始运行,链接地址和运行地址都设置为虚拟地址0,然后操作系统的MMU模块会将虚拟地址0转换成物理地址,即保证了程序的运行地址和链接地址相同)。进程访问的是虚拟地址,虚拟地址通过页表的转换最终形成物理地址。当一个进程运行时,CPU访问的地址是用户空间的虚拟地址。
(6)从磁盘加载到Linux系统内存中并被执行,一个程序大致经过7个阶段。

4.1.5. 程序的开始和结束
(1)编译链接时叠加的引导代码:我们执行的程序是将C语言文本程序经过编译链接后生成的一个二进制代码文件,编译链接时需要叠加引导代码。操作系统下的应用程序需要先执行一段引导代码才能去执行main函数,我们编写应用程序时无需考虑引导代码问题;程序在编译连接时使用链接器,交叉编译工具链在编译链接时由链接器将编译器中事先准备好的引导代码给链接进去和我们的应用程序一起构成最终的可执行程序。
(2)程序运行时的加载器:程序在运行时使用加载器,加载器是操作系统中的程序,当我们去执行某个程序时(./a.out或代码中使用exec族函数),加载器负责将该程序加载到内存中去执行。譬如说在shell命令行下运行./a.out(argc=1,argv[0]=”./a.out”),当前shell进程接收到”./a.out”参数后将该参数传递给加载器,加载器在加载运行a.out程序时会将参数传递给该程序的引导代码,引导代码在合适的时候会将该参数传递给该程序的main函数。
(3)程序结束分为正常终止和非正常终止:
正常终止是执行return或者执行exit()函数或者执行_exit()函数
return是函数执行完后的返回。renturn执行完后把控制权交给调用函数。
exit和_exit类似,是一个函数,有参数。exit执行完后把控制权交给系统;
_exit()函数,直接使进程停止运行,清除其使用的内存空间,并销毁其在内核中的各种数据结构。而exit()函数则在其基础上做了一些包装。
非正常终止是自己或它人发信号终止进程。
调用abort函数
进程收到某个信号,而该信号使程序终止。
4.1.6. 程序运行环境
(1)环境变量:Linux是一个多用户的操作系统。多用户意味着每个用户登录系统后,都有自己专用的运行环境。而这个环境是由一组变量所定义,这组变量被称为环境变量。用户可以对自己的环境变量进行修改以达到对环境的要求。
(2)环境变量的分类与设置
- 对所有用户生效的永久性变量 这类变量对系统内的所有用户都生效,所有用户都可以使用这类变量。作用范围是整个系统。这类变量在/etc/profile文件中设置,此文件只
在root下才能修改。

添加完成后新的环境变量不会立即生效,立即生效需要运行 source /etc/profile ,否则只能在下次重进此用户时才能生效。
- 对单一用户生效的永久性变量 在用户目录下的.bashrc或.bash_profile 文件中添加变量,这两个文件是隐藏文件,可使用ll -a查看。这两个文件的区别为:.bash_profile文件只会在用户登录的时候读取一次,而.bashrc在每次打开终端进行一次新的会话时都会读取。
- 临时有效的环境变量(只针对当前shell有效) 此类环境变量只对当前的shell有效。当我们退出登录或者关闭终端再重新打开时,这个环境变量就会消失。是临时的。设置方法:命令行下直接使用[export 变量名=变量值] 定义变量。
- echo 用于打印显示环境变量,如:echo $NAME;export 用于设置新的环境变量,如:export NAME='rethink';env 显示当前用户的变量;set 显示当前shell变量,shell变量包含用户变量;unset 删除一个环境变量,如:unset NAME。
- 进程环境表介绍:每个进程中都有一份由所有环境变量构成的表,在当前进程中我们可以直接使用这些环境变量;进程环境表即一个字符串数组,通过environ二重指针变量指向它,在程序中我们可通过environ全局变量使用该进程中所有的环境变量;一旦程序中用到了环境变量那么程序就和操作系统环境有关了,getenv函数可获取指定环境变量。
4.2 Linux系统进程概述
4.2.1. 什么是进程
(1)进程由程序产生,是正在运行的程序,或者说是已启动的可执行程序的运行实例。那么既然是正在运行的程序,它就有开始和结束,并且是一个动态的,因此进程具有自己的生命周期和各种不同的状态。
(2)程序与进程的区别
● 程序:二进制的文件,静态的。
● 进程:程序的运行过程,动态的,有生命周期及运行状态。
4.2.2. 进程的特点
进程具有三个显著的特点:独立性、动态性、并发性。
● 独立性,体现在拥有独立地址空间
进程是系统中独立存在的实体,它可以拥有自己的独立资源,每一个进程都有自己的私有地址空间;在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。
● 动态性,体现在是一个动态过程
进程与程序的区别在于: 进程是动态的,程序是静态的;进程的生命周期是相对短暂的,而程序是永久的;一个进程只能对应一个程序,一个程序可以对应多个进程。
● 并发性,体现在宏观上的并行运行
多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响。
4.2.3. 进程控制块
(1)Linux操作系统会为每个进程分配一段虚拟地址空间,并且在内核区中都有一个进程控制块PCB(process-control-block)来维护进程相关的信息,Linux内核的进程控制块是task_struct结构体,用于描述进程情况以及控制进程运行所需的全部信息。
(2)进程控制块包括:进程描述信息、进程控制信息、资源信息和现场保护信息(cpu进行进程切换时)等。
进程描述信息:
进程标识符用于唯一的标识一个进程(pid,ppid)。
进程控制信息:
● 进程当前状态
● 进程优先级
● 程序开始地址
● 各种计时信息
● 通信信息
资源信息:
● 占用内存大小及管理用数据结构指针
● 交换区相关信息
● I/O设备号、缓冲、设备相关的数结构
● 文件系统相关指针
现场保护信息(cpu进行进程切换时):
● 寄存器
● PC
● 程序状态字PSW
● 栈指针
4.2.4. 进程ID
(1)每一个进程都有一个编号称为进程ID,进程ID的作用是唯一标识某个进程。
(2)每个进程都会分配到一个独一无二的数字编号,我们称之为“进程标识”(process identifier),或者就直接叫它PID。
(3)是一个正整数,取值范围从2到32768
可以通过:cat /proc/sys/kernel/pid_max 查看系统支持多少进程
(4)当一个进程被启动时,它会顺序挑选下一个未使用的编号数字做为自己的PID
(5)数字1一般为特殊进程init保留的
init进程实际上是用户进程,它是一个程序,在/sbin/init中,linux启动的第一个进程。实际上linux中还存在0号进程(内核进程),它是一个空闲进程,它进行空闲资源的统计及交换空间的换入换出,1(init)进程是由0号进程创建的。
(6)可以用ps a命令打印输出现行终端机下的所有程序 (ps –ef); 用命令ps aux可以打印输出操作系统所有的进程。那么如何获取当前进程的ID呢?系统提供了一系列的API,用getpid可以获取当前进程的ID,用getppid可以获取当前进程的父进程的ID。
4.2.5. 进程创建与终止
(1)若操作系统需要一个新进程来运行一个程序,则操作系统会利用一个现有的进程来复制生成一个新进程。操作系统提供了一个系统调用函数fork来创建(复制)一个子进程,并且操作系统在内核区中创建一个进程控制块(PCB)来维护进程相关的信息。老进程叫父进程,复制生成的新进程叫子进程。
(2)进程终止就是程序运行结束,有5中终止方式,分为正常退出和异常退出。
正常退出:
● 从main函数返回:从return返回,执行完毕退出;
● 调用_exit:系统调用;
● 调用exit:C函数库,实际上也是调用系统调用_exit完成的,在任何一个函数调用exit函数都可使得进程撤销。
说明:exit()函数与_exit()函数最大的区别就在于exit()函数在调用_exit系统调用之前要检查文件的打开情况,把文件缓冲区中的内容写回文件,就是"清理I/O缓冲"。
异常退出:
● 调用abort:调用abort()函数使得进程终止,实际上该函数是产生一个SIGABRT信号;
● 由信号终止:发送一些信号如SINGINT等信号。
4.2.6. 进程状态变迁
(1)进程有三种基本状态:
● 就绪(Ready)态
当进程已分配到除CPU以外的所有必要的资源,只要获得处理机便可立即执行,这时的进程状态称为就绪态。
● 运行(Running)态
当进程已获得处理机,其程序正在处理机上执行,此时的进程状态称为运行态。
● 阻塞(Blocked)态
正在执行的进程,由于等待某个事件发生而无法执行时,便放弃处理机而处于阻塞态。引起进程阻塞的事件可有多种,例如,等待I/O完成、申请缓冲区不能满足、等待信号等。
(2)进程有多种状态模型,其中比较典型的是五状态模型,亦即新建态、就绪态、运行态、阻塞态以及退出态。
所谓新建态就是刚刚创建的进程,操作系统还没有把它加入到可执行进程组中,通常是进程块已经创建但还没有加载到内存中的新进程。
所谓退出态就是操作系统从可执行进程组中释放出的进程,或者因为它自身停止了,或者是因为某种原因被取消。
一个进程在运行期间,不断地从一种状态转换到另一种状态,它可以多次处于就绪状态和运行状态,也可以多次处于阻塞状态。其进程状态转换关系如图所示。

① 就绪→运行
处于就绪状态的进程,当进程调度程序为之分配了处理机后,该进程便由就绪状态转变成运行状态。
② 运行→就绪
处于运行状态的进程在其执行过程中,因分配给它的一个时间片已用完或更高优先级的进程抢占而不得不让出处理机,于是进程从运行状态转变成就绪状态。
③ 运行→阻塞
正在运行的进程因等待某种事件发生而无法继续执行时,便从运行状态变成阻塞状态。
④ 阻塞→就绪
处于阻塞状态的进程,若其等待的事件已经发生,于是进程由阻塞状态转变为就绪状态。
⑤ 运行→终止
程序执行完毕,撤销而终止。
4.2.7. 进程生命周期
(1)进程从产生到消亡就称为进程的生命周期。
(2)任何进程都可以创建子进程,父进程复制自己的地址空间(fork)创建一个新的(子)进程结构。每个新进程分配一个唯一的进程ID(PID),PID和父进程ID(PPID)是子进程环境的元素,所有进程都是第一个系统进程的后代(对于我们使用的系统就是1号进程init)。
(3)子进程,继承父进程的安全性身份、过去和当前的文件描述符、端口和资源特权、环境变量,以及程序代码。随后,子进程exec自己的程序代码。通常,父进程在子进程运行期间处于睡眠(sleeping)状态。当子进程完成时发出(exit)信号请求,在退出时,子进程会关闭或丢弃其资源环境。父进程在子进程退出时收到信号而被唤醒,清理剩余的结构,然后继续执行其自己的程序代码。

4.2.8. 进程关系
(1)无关系:两个进程完全独立,操作系统中的大部分进程彼此之间都是没有关系的,即进程里面的代码不能随便访问别的进程。
(2)父子进程关系:两个进程存在父子关系,此时两个进程间联系紧密,子进程继承了父进程fork之前的所有操作。
(3)进程组(group):由若干进程构成1个进程组,操作系统为了方便管理进程,将关系紧密的多个进程构成1个进程组。利用组关系区分不同进程对同一文件的操作权限。
(4)会话(session):会话就是进程组的组,由多个进程组构成的组,同样是为了方便管理进程。
4.2.9. 多进程调度
(1)在有OS的计算机上,应用程序必须在OS的支持下才能运行,换句话说,必须在OS的控制之下才能运行。即所谓的进程管理。
(2)进程控制做些啥事情
(a)分配内存空间,然后将程序调入内存并启动运行,运行起来后就变成了一个进程。
(b)OS调度器对其进行调度。
在OS上,众多进程是交替运行的,每个进程只运行一个ms级的时间片,在这个时间片内可以占有CPU以运行自己。
在OS上往往会运行很多的应用程序(进程),大家都要占用CPU运行,但是CPU只有一个,大家必须轮流运行,每个人轮流占用CPU运行一段时间,这个时间被称为时间片(ms级),大家轮流交替运行,由于时间片很短,宏观上给人的错觉就是,所有的程序都在独占CPU运行,但是事实上每个具体的时刻cpu只运行一个程序(进程)。
当前进程运行完毕后,下一次轮到谁运行呢,这个时候就需要一个仲裁者,它来决定下一次谁运行,这个仲裁者就是OS提供的调度器,负责按照某个规则调度进程的运行,这个调度规则就是调度的优先级算法。
调度算法有一个核心的地方,就是一定要体现对所有的进程的公平公正,不能让有些进程被宠着一直运行,而另一些进程被打入冷宫,长时间不运行。
(c)OS进行进程控制时,会负责记录进程从生到死所涉及的各个方面的管理信息,这些控制(管理)信息都记录在了PCB中。
(d)意外事件处理。比如当前运行的进程需要键盘输入的数据,但是一直等不到键盘输入的数据,那么调度器会将CPU占有权收回,让出CPU运行另一个进程。
(e)当进程运行结束之后,回收内存空间、记录有各方面进程信息的PCB,因为PCB块记录的信息是存在内存中的,显然占据着内存空间,进程死了,肯定就得把占据的这个内存空间收回。
区别并发与并行:
1)并发
多个进程相互交替运行,表面上看所有进程似乎都是同时在运行,但是实际上在每个时间片里,CPU只运行一个进程。在早期单核CPU时代,所有的进程都是并发运行的,因为CPU只有一个,所以CPU在每个时间片内也只能运行一个进程。
2)并行
并行的意思就是,所有进程同时被CPU执行,在每个时刻CPU同时运行多个进程。
当然对于单核CPU来说,是不可能做到并行的,只能是并发,不过对于现在多核cpu来说,可以做到并行,不同的CPU核可以同时并行的运行不同的进程。
对于现在多核CPU来说,并发与并行是同时存在的。
(3)进程调度由OS中的调度器实现。所有进程运行时,都包含就绪态、执行态、阻塞态、终止态这些状态,每个进程参与调度时,都会在这些状态之间来回切换。只有当进程进入执行态时,进程才会获得CPU的占有权。进程运行的时间片到后就会调度其它进程运行,每个时间片平均在10ms~20ms左右。
(3)操作系统被设计为同时运行多个进程;多个进程同时运行造成宏观上的并行和微观上的串行;实际上现代操作系统最小的调度单元是线程而不是进程。
引申:什么是线程?
线程,也被称作轻量级进程,线程是进程的执行单元,一个进程可以有多个线程,线程不拥有资源,它与父进程的其它线程共享该进程所拥有的资源。线程的执行是抢占式的。
譬如,家庭装修:张三(砌墙的),李四(贴瓷砖),王五 (装水电)
4.3. 进程创建和父子进程共享内存
4. 3.1. fork创建子进程
(1)我们知道,操作系统运行某个程序需要付出代价,也就是操作系统需要创建新进程,然后把该程序加载到进程里去运行,因此每次程序的运行都需要一个进程。操作系统提供了一个系统调用函数fork来创建一个子进程,并且操作系统通过进程控制块PCB来维护这个进程。Linux内核的进程控制块是task_struct结构体。用于描述进程情况、控制进程运行所需的信息。
(2) fork()函数语法要点
|
头文件 |
#include <sys/types.h> #include <unistd.h> |
|
函数功能 |
创建一个子进程 |
|
函数原型 |
pid_t fork(void); //一次调用两次返回值,是在各自的地址空间返回,意味着现在有两个基本一样的进程在执行 |
|
函数传参 |
无 |
|
返回值 |
如果成功创建一个子进程,对于父进程来说返回子进程ID 如果成功创建一个子进程,对于子进程来说返回值为0 如果为-1表示创建失败 |
(3) fork函数的内部原理是进程的分裂生长模式,即一个进程创建一个子进程,该子进程还可以创建自己的子进程。老进程叫父进程,复制生成的新进程叫子进程。若操作系统利用一个现有的进程来复制生成一个新进程,系统内核为这个新进程分配进程空间,并将父进程的进程空间中的内容复制到子进程的进程空间中,包括父进程的数据段和堆栈段,并且和父进程共享代码段。这时候,系统中又多了一个进程,这个进程和父进程一模一样,两个进程都接受系统的调度。fork系统调用之后,父子进程将交替执行,执行顺序不定。
(4) fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次。在fork函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork函数返回0,在父进程中,fork返回新创建子进程的进程ID。
(5)如果父进程先退出,子进程还没退出那么子进程就成了孤儿进程,子进程的父进程将变为init进程(托孤给了init进程)。(注:任何一个进程都必须有父进程)
(6)如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程(僵尸进程:只保留一些退出信息供父进程查询)。
(7)我们可以通过fork返回的值来判断当前进程是子进程还是父进程。因此,典型的使用fork的方法就是:调用fork()函数后,用if判断返回值,返回值大于0时即父进程执行入口,返回值等于0时即子进程执行入口。fork的返回值在子进程中等于0,在父进程中等于本次fork创建的子进程的进程ID。
4.3.2. 父子进程对文件的操作
(1)子进程继承父进程中打开的文件:父进程先open打开一个文件得到fd,然后再fork创建子进程,之后在父子进程中各自write向fd中写入内容;测试结论是接续写,实际上本质原因是父子进程之间的fd对应的文件指针是彼此关联。
(2)父子进程各自独立打开相同文件实现共享:父进程open打开test.txt然后写入,子进程打开test.txt然后写入,测试结论是分别写,原因是父子进程分离后才各自打开的test.txt,这时候这两个进程的PCB已经独立了,文件表也独立了,则两次读写是完全独立的。
(3)open时使用O_APPEND标志;实际测试结果标明O_APPEND标志可以把父子进程各自独立打开的fd的文件指针给关联起来,实现接续写。
(4)父子进程间终究多了一些牵绊;父进程在没有fork之前自己做的事情对子进程有很大影响,但是父进程fork之后在自己的if里做的事情就对子进程没有影响了,本质原因就是因为fork内部实际上已经复制父进程的PCB生成了一个新的子进程,并且fork返回时子进程已经完全和父进程脱离并且独立被OS调度执行;子进程最终目的是要独立去运行另外的程序。
4.4. 进程资源回收及wait族函数
4.4.1. 进程的诞生和消亡
(1)进程的诞生:进程0(由操作系统内核在启动过程中构建而来);进程1(在内核态由进程0通过内核中的fork相关函数复制而来);fork和vfork用于在用户态下创建进程。程序和进程的区别;程序即静态的在硬盘中存储的可执行程序;进程即动态的正在内存中运行的程序。
(2)进程的消亡分为正常终止和异常终止,进程在运行时需要消耗系统资源(内存–进程向操作系统请求的内存资源、IO–进程跟别的外部设备进行IO通信),进程终止时理应完全释放该类资源,如果进程消亡后仍然没有释放相应资源则这些资源就丢失了。
(3)linux系统设计时规定每个进程退出时操作系统会自动回收该进程工作时所消耗的资源(譬如malloc申请内存没有free则当前进程结束时该内存会被释放;譬如open打开的文件没有close则在当前进程结束时会被关闭),操作系统仅回收了该进程工作时消耗的内存和IO,但并没有回收该进程本身占用的内存(8KB=task_struct进程结构体+栈内存);因为进程本身的8KB内存操作系统不能回收需要别人来辅助回收,则每个进程都需要该进程的父进程帮助它收尸。
(4)僵尸进程:子进程先于父进程结束,子进程除task_struct和栈外其余内存空间皆已被操作系统清理,子进程结束后父进程此时并没有立即帮子进程回收资源,在子进程已经结束且父进程尚未帮其收尸的时期内,子进程就成为了僵尸进程。
(5)父进程可以使用wait或waitpid以显式回收子进程的剩余待回收内存资源并且获取子进程退出状态;父进程也可以不使用wait或者waitpid回收子进程,此时父进程结束时会自动回收子进程的剩余待回收内存资源(为防止父进程忘记显式调用wait/waitpid来回收子进程从而造成内存泄漏)。
(6)孤儿进程;父进程先于子进程结束,子进程成为一个孤儿进程;linux系统规定所有的孤儿进程都自动成为某个特殊进程(进程1即init进程)的子进程。
4.4.2. 父进程wait回收子进程
(1)wait的工作原理:子进程结束时,操作系统会自动向其父进程发送SIGCHILD信号;父进程调用wait函数后阻塞等待接收SIGCHILD信号;父进程被SIGCHILD信号唤醒然后去回收僵尸子进程;父子进程之间是异步的,SIGCHILD信号机制就是为了解决父子进程之间的异步通信问题,让父进程可以及时的去回收僵尸子进程;若父进程没有任何子进程则父进程调用wait会返回错误。
(2) wait ()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/wait.h> |
|
函数功能 |
回收子进程资源,回收同时还可以得知被回收子进程的pid和退出状态。 |
|
函数原型 |
pid_t wait(int *status); |
|
函数传参 |
status用来返回子进程结束时的状态; 父进程通过wait得到status后,就可以知道子进程的一些结束状态信息。 |
|
返回值 |
pid_t 本次wait回收的子进程的PID。 当前进程可能有多个子进程,wait函数阻塞直到其中一个子进程结束wait就会返回,wait的返回值可以用来判断到底是哪一个子进程本次被回收了。 |
(3)wait实战编程:wait的参数status用来返回子进程结束时的状态,父进程通过wait得到status后即可知道子进程的某些结束状态信息;wait的返回值pid_t即本次wait回收的子进程的PID,当前进程可能有多个子进程,wait函数阻塞直到其中某个子进程结束wait就会返回,wait的返回值可判断具体哪个子进程本次被回收了;wait主要是用来回收子进程资源,回收同时还可以得知被回收子进程的pid和退出状态。
(4)WIFEXITED/WIFSIGNALED/WEXITSTATUS这几个宏用来获取子进程的退出状态:WIFEXITED宏用来判断子进程是否正常终止(return/exit/_exit退出);WIFSIGNALED宏用来判断子进程是否非正常终止(被信号终止);WEXITSTATUS宏用来得到正常终止情况下的进程返回值的。
4.4.3. 父进程waitpid回收子进程
(1)waitpid和wait的基本功能一样,都是父进程用来回收子进程资源的;waitpid可以回收指定PID的子进程;waitpid可分为阻塞式或非阻塞式两种工作模式。
waitpid ()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/wait.h> |
|
函数功能 |
作用同wait,但可指定pid进程清理,可以不阻塞 |
|
函数原型 |
pid_t waitpid(pid_t pid, int *status, in options); |
|
函数传参 |
Pid > 0 回收指定ID的子进程 。 -1 回收任意子进程(相当于wait)。 0 回收和当前调用waitpid一个组的所有子进程。 |
|
Status 如果不是空,会把状态信息写到它指向的位置 | |
|
options 设置为阻塞或者不阻塞状态。 WNOHANG:不阻塞 0:阻塞 | |
|
返回值 |
成功时返回清理掉的子进程ID; 失败返回-1; 当第三个参数被设置为WNOHANG,且子进程还在运行时,返回0 |
代码示例:
ret = waitpid(-1, &status, 0);
-1表示不等待某个特定PID的子进程而是回收任意一个子进程,0表示用默认的方式(阻塞式)来进行等待,返回值ret是本次回收的子进程的PID。
ret = waitpid(pid, &status, 0);
等待回收PID为pid的这个子进程,如果当前进程并没有一个ID号为pid的子进程,则返回值为-1;如果成功回收了pid这个子进程,则返回值为回收的进程的PID。
ret = waitpid(pid, &status, WNOHANG);
这种表示父进程要非阻塞式的回收子进程。此时如果父进程执行waitpid时子进程已经先结束等待回收,则waitpid直接回收成功,返回值是回收的子进程的PID;如果父进程waitpid时子进程尚未结束则父进程立刻返回(非阻塞),但是返回值为0(表示回收不成功)。
4.5. 进程体替换及exec函数族
4.5.1. 为什么需要exec函数
(1)fork创建子进程是为了执行新程序,fork创建子进程后,子进程和父进程同时被OS调度执
行(子进程是父进程的副本,它将获得父进程数据空间、堆、栈等资源的副本,子进程有了独立的地址空间),则子进程即可单独的执行某个程序,该程序宏观上将会和父进程程序同时运行。
(2)在fork后的子进程中使用exec函数族,可以装入和运行其它程序(子进程替换原有进程,和父进程做不同的事)。exec函数族可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段。在执行完后,原调用进程的内容除了进程号外,其它全部被新程序的内容替换了,exec族函数可直接把某个编译好的可执行程序直接加载运行。
(3)我们有了exec族函数后形成了典型的多进程项目架构,即子进程需要运行的程序被单独编写并单独编译链接成某个可执行程序a.out,主程序为父进程,fork创建子进程后在子进程中通过exec来执行a.out,达到父子进程同时(宏观上)运行不同程序的效果。
4.5.2. exec函数族介绍
在linux中并没有exec函数,而是有6个以exec开头的函数族,下表列举了exec函数族的6个成员函数的语法。

返回值:成功则无返回值, 失败返回-1, 失败原因设置在errno中
(1)这些函数原型看起很容易混淆,但只要掌握了规律就很好记。一般规律:
l:list,命令行参数列表,必须以NULL结尾;
p:path,搜索 file 路径时使用 PATH 环境变量;
v:vector,使用命令行参数数组;
e:environment,使用环境变量数组,不使用进程原有的环境变量;
exec函数族关系
前4位统一为:exec
第5位
l:参数传递为逐个列举方式 execl、execle、execlp
v:参数传递为构造指针数组方式 execv、execve、execvp
第6位
e:可传递新进程环境变量 execle、execve
p:可执行文件查找方式为文件名 execlp、execvp
事实上,这6个函数中真正的系统调用只有execve,其他5个都是库函数,它们最终都会调用execve这个系统调用,调用关系如下图所示:

(3)main函数的原型可以是int_main(int_argc,char_**argv,char_**env),第3个参数是环境变量字符串数组;若用户在执行该程序时没有传递第3个参数,则程序会自动从父进程继承环境变量(默认来源于OS中的环境变量);若我们exec的时候使用execle/execvpe去传递envp数组,则程序中的实际环境变量就是我们传递的这份,即取代了默认的从父进程继承来的那份环境变量。
4.5.3. system函数
Linux系统中可以使用函数system()调用Shell命令,其函数原型如下所示:

参数command是需要执行的Shell命令。
函数system是一个库函数,其中封装了函数fork()、exec()和waitpid(),实际上system()函数执行了三步操作:
- fork一个子进程;
- 在子进程中调用exec函数去执行command;
- 在父进程中调用wait去等待子进程结束。
函数system()的返回值情况比较复杂,其返回值也要根据这三个函数来加以区分。
- 如果函数fork()或waitpid()执行失败,函数system()返回-1。
- 如果函数exec()执行失败,函数system()的返回值与Shell调用了exit()的返回值一样,表示指定文件不可执行。
- 如果三个函数都执行成功,函数system()返回执行程序的终止状态,其值和命令“echo $?”的值是一样的。
函数sysetem()的执行效率比较低:在函数system()中要两次调用函数fork()和exec(),第一次用于加载Shell程序,第二次是加载需要执行的程序(这个程序由Shell负责加载)。但是对比直接使用函数fork()+exec()的方法,函数system()虽然在效率上有些低,却有以下优点:
- 函数system()添加了出错处理操作;
- 函数system()添加了信号处理操作;
- 函数system()调用了wait()函数,保证不会出现僵尸进程。
system()函数用起来很容易出错,返回值太多,而且返回值很容易跟command的返回值混淆。但system()函数运行完调用的shell命令后还会继续执行源代码。
4.6. 进程间通信
4.6.1. linux进程间通信概述
4.6.1.1. 进程间通信的本质
(1)什么是进程间通信
所谓进程间通信((IPC,Inter Process Communication)指的是两个任意进程之间的信息交互和状态传递。
(2)为什么要通信(进程间通信的目的)
首先是因为软件中有这个需求,比如有些任务是由多个进程一起协同来完成的,或者一个进程对另一个进程有服务请求,或者有消息要向另一方提供。
其次是因为进程间有隔离,每个进程都有自己独立的用户空间,互相看不到对方,所以才需要通信。具体来讲主要有以下几种应用场景:
- 数据传输:一个进程需要将它的数据发送给另一个进程。
- 资源共享:多个进程之间共享同样的资源。
- 通知事件:一个进程需要向另一个或一组进程发送消息,通知它(它们)发生了某种事件,比如进程终止时需要通知其父进程。
- 进程控制:有些进程希望完全控制另一个进程的执行(如Debug进程),此时控制进程希望能够拦截另一个进程的所有陷入和异常,并能够及时知道它的状态改变。
- 为什么能通信?
因为内核空间是共享的,虽然N个进程都有N个用户空间,但是内核空间只有一个,虽然用户空间之间是完全隔离的,但是用户空间与内核空间并不是完全隔离的,他们之间有系统调用这个通道可以沟通。所以进程之间可以通过一些特殊的系统调用和内核沟通从而达到和其它进程通信的目的。

各个进程之间若想实现通信,一定要借助第三方资源,这些进程就可以通过向这个第三方资源写入或是读取数据,进而实现进程之间的通信,这个第三方资源实际上就是操作系统提供的一段内存区域。

因此,进程间通信的本质就是,让不同的进程看到同一份资源(内存,文件内核缓冲等)。由于这份资源可以由操作系统中的不同模块提供,因此出现了不同的进程间通信方式。
4.6.1.2. 进程间通信的框架
(1)进程间通信机制的结构
进程间通信机制都要有两部分组成,一是存在于内核空间的通信中枢,二是存在于用户空间的通信接口,这两者的关系就好比是邮局与信纸的关系、基站与手机的关系。通信中枢提供通信机制,通信接口提供使用方法。我们使用通信接口来让通信中枢帮我们建立通信信道或者传递通信信息。

- 进程间通信机制的类型
进程间通信机制的类型有三种:
共享内存式、消息传递式、进程间同步。
- 共享内存式进程间通信,通信中枢建立好通信信道之后,就不再管了,通信双方之后的通信不需要通信中枢的协助。(由于通信信息的传递不需要通信中枢的协助,所以通信双方还需要进程间同步,来保证数据读写的一致性,以避免踩踏数据或者读到垃圾数据。)
- 消息传递式进程间通信,通信中枢建立好通信信道之后,每次通信还都需要通信中枢的协助。(由于通信信息是通过通信中枢传递的,所以不需要进程间同步。)
- 进程间同步是为了同步两个进程对共享内存的读写,进程间同步也算是在两个进程间传递了信息。

4.6.2. 匿名管道
4.6.2.1. 什么是匿名管道
(1)管道(Pipe)是单向的、先进先出的、无结构的、固定大小的字节流,它把一个进程的标准输出和另一个进程的标准输入连接在一起。写进程在管道的尾端写入数据,读进程在管道的首端读出数据。数据读出后将从管道中移走,其它读进程都不能再读到这些数据。管道提供了简单的流控制机制。进程试图读空管道时,在有数据写入管道前,进程将一直阻塞。同样,管道已经满时,进程再试图写管道,在其它进程从管道中移走数据之前,写进程将一直阻塞。
(2)管道有时也被称为匿名管道,顾名思义就是没有名字的管道。管道使用的文件描述符没有路径名,也就是不存在实际意义上的文件。它们只是内存中跟某个索引节点相关联的两个文件描述符。
(3)管道,也指一种特殊的文件,叫管道文件。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
(4)匿名管道,在bash中,用符号“|”来表示。在同一个终端上通讯

匿名管道作用:将上一个命令执行的结果(标准输出)作为下一条命令的标准输入。

4.6.2.2. 匿名管道的创建与实现
(1)匿名管道由pipe函数创建,调用pipe函数时在内核中开辟一块缓冲区(称为管道)用于通信,它有一个读端一个写端。管道利用fork机制建立,从而让两个进程可以连接到同一个PIPE上。
pipe函数
|
头文件 |
#include <unistd.h> |
|
函数原型 |
int pipe(int pipefd[2]); //功能:创建匿名管道 |
|
函数传参 |
参数:pipefd : 为 int 型数组的首地址,其存放了管道的文件描述符 pipefd[0]、pipefd[1]。 当一个管道建立时,它会创建两个文件描述符 fd[0] 和 fd[1]。其中 fd[0] 固定用于读管道,而 fd[1] 固定用于写管道。一般文件 I/O的函数都可以用来操作管道(lseek() 除外)。 |
|
返回值 |
成功:返回 0 失败:返回 -1 |
(2)在创建匿名管道实现父子进程间通信的过程中,需要pipe函数和fork函数搭配使用,具体步骤如下:
1、父进程调用pipe函数创建管道。

2、父进程创建子进程。

3、父进程关闭写端,子进程关闭读端。

注意:
★ 管道只能够进行单向通信,因此当父进程创建完子进程后,需要确认父子进程谁读谁写,然后关闭相应的读写端。
★ 从管道写端写入的数据会被内核缓冲,直到从管道的读端被读取。
(3)匿名管道通信常用函数
一般文件的I/O函数都可以用于管道,常用管道通信函数:
pipe(创建管道)/write(写管道)/read(读管道)/close(关闭管道)。
(4)匿名管道通信的缺点
匿名管道通信有两个明显的不足:一是半双工,只能单向传输;二是只能在父子进程间使用(因为管道是基于fork机制建立的)。
4.6.3. 命名管道
4.6.3.1. 什么是命名管道
(1)所谓命名管道就是有名字的管道。命名管道克服了管道没有名字的限制,因此,除具有管道所具有的功能外,它还允许无亲缘关系进程间的通信。
(2)命名管道又称为FIFO (First in, First out)。FIFO不同于管道之处在于它提供一个路径名与之关联,以FIFO的文件形式存储于文件系统中。命名管道是一个设备文件。
4.6.3.2. 命名管道实现进程间通信
(1)FIFO作为一种特殊的文件类型,它在文件系统中有对应的路径。当一个进程以读(r)的方式打开该文件,而另一个进程以写(w)的方式打开该文件,那么内核就会在这两个进程之间建立管道,所以FIFO实际上也由内核管理,不与硬盘打交道。之所以叫FIFO,是因为管道本质上是一个先进先出的队列数据结构,最早放入的数据被最先读出来,从而保证信息交流的顺序。FIFO只是借用了文件系统来为管道命名。写模式的进程向FIFO文件中写入,而读模式的进程从FIFO文件中读出。当删除FIFO文件时,管道连接也随之消失。FIFO的好处在于我们可以通过文件的路径来识别管道,从而让没有亲缘关系的进程之间建立连接。
FIFO的通信方式类似于在进程中使用文件来传输数据,只不过FIFO类型文件同时具有管道的特性。在数据读出时,FIFO管道中同时清除数据,并且“先进先出”。
(2)命名管道的创建与读写
Linux下有两种方式创建命名管道。一是在Shell下交互地建立一个命名管道,二是在程序中使用系统函数建立命名管道。Shell方式下可使用mknod或mkfifo命令。

创建命名管道的系统函数有两个:mknod和mkfifo。两个函数均定义在头文件sys/stat.h中,其中mkfifo函数原型如下:
mkfifo函数
|
头文件 |
#include <sys/types.h> #include <sys/stat.h> |
|
函数原型 |
int mkfifo(const char *pathname, mode_t mode); //功能:命名管道的创建 |
|
函数传参 |
pathname : 普通的路径名,也就是创建后 FIFO 的名字。 mode : 文件的权限,与打开普通文件的 open() 函数中的 mode 参数相同。 |
|
返回值 |
成功:返回 0 失败:返回 -1 |
命名管道创建后就可以使用了,命名管道和匿名管道的使用方法是相同的。只是使用命名管道时,必须用open()将其打开。因为命名管道是一个存在于硬盘上的文件,而匿名管道是存在于内存中的特殊文件。
需要注意的是,调用open()打开命名管道的进程可能会被阻塞。但如果同时用读写方式(O_RDWR)打开,则一定不会导致阻塞;如果以只读方式(O_RDONLY)打开,则调用open()函数的进程将会被阻塞直到有写方打开管道;同样以写方式(O_WRONLY)打开也会阻塞直到有读方式打开管道。
一般文件的I/O函数都可以用于FIFO,如close、read、write等

4.6.3.3. 匿名管道与命名管道的区别
(1)管道是特殊类型的文件,在满足先入先出的原则条件下可以进行读写,但不能进行定位读写。
(2)匿名管道是单向的,只能在有亲缘关系的进程间通信;命名管道以磁盘文件的方式存在,可以实现本机任意两个进程间通信。
(3)匿名管道由pipe函数创建并打开;命名管道由mkfifo函数创建,由open函数打开。
(4)匿名管道阻塞问题:匿名管道无需显示打开,创建时直接返回文件描述符,在读写时需要确定对方的存在,否则将退出。如果当前进程向匿名管道的一端写数据,必须确定另一端有某一进程。如果写入匿名管道的数据超过其最大值,写操作将阻塞,如果管道中没有数据,读操作将阻塞,如果管道发现另一端断开,将自动退出。
(5)命名管道阻塞问题:命名管道在打开时需要确定对方的存在,否则将阻塞。即以读方式打开某管道,在此之前必须一个进程以写方式打开管道,否则阻塞。此外,可以以读写(O_RDWR)模式打开命名管道,即当前进程读,当前进程写,不会阻塞。
4.6.4. 共享内存
4.6.4.1. 什么是共享内存
共享内存就是两个不同进程A、B共享内存的意思,是指同一块物理内存被映射到进程A、B各自进程的虚拟地址空间。进程A可以即时看到进程B对共享内存中数据的更新,反之亦然。由于多个进程共享同一块内存区域,必然需要某种同步机制。
4.6.4.2. 共享内存的使用
(1)获取共享内存
要使用共享内存,首先需要使用 shmget() 函数获取共享内存,shmget() 函数的原型如下:
int shmget(key_t key, size_t size, int shmflg);
- 参数 key 一般由 ftok() 函数生成,用于标识系统的唯一IPC资源。
- 参数 size 指定创建的共享内存大小。
- 参数 shmflg 指定 shmget() 函数的动作,比如传入 IPC_CREAT 表示要创建新的共享内存。
函数调用成功时返回一个新建或已经存在的的共享内存标识符,取决于shmflg的参数。失败返回-1,并设置错误码。
(2)关联共享内存
shmget() 函数返回的是一个标识符,而不是可用的内存地址,所以还需要调用 shmat() 函数把共享内存关联到某个虚拟内存地址上。shmat() 函数的原型如下:
void *shmat(int shmid, const void *shmaddr, int shmflg);
- 参数 shmid 是 shmget() 函数返回的标识符。
- 参数 shmaddr 是要关联的虚拟内存地址,如果传入0,表示由系统自动选择合适的虚拟内存地址。
- 参数 shmflg 若指定了 SHM_RDONLY 位,则以只读方式连接此段,否则以读写方式连接此段。
函数调用成功返回一个可用的指针(虚拟内存地址),出错返回-1。
(3)取消关联共享内存
当一个进程不需要共享内存的时候,就需要取消共享内存与虚拟内存地址的关联。取消关联共享内存通过 shmdt() 函数实现,原型如下:
int shmdt(const void *shmaddr);
- 参数 shmaddr 是要取消关联的虚拟内存地址,也就是 shmat() 函数返回的值。
函数调用成功返回0,出错返回-1。
(4)释放共享内存
当我们的进程运行完毕后,申请的共享内存依旧存在,并没有被操作系统释放。若是要将创建的共享内存释放,有两个方法,一就是使用命令释放共享内存,二就是在进程通信完毕后调用释放共享内存的函数进行释放,释放共享内存通过shmctl()函数实现,原型如下:
int shmctl(int shmid, int cmd, struct shmid_ds *buf);
- 参数shmid,表示所控制共享内存的用户级标识符。
- 参数cmd,表示具体的控制动作。常用的选项有以下三个:

3. 参数buf,用于获取或设置所控制共享内存的数据结构。
函数调用成功,返回0,调用失败,返回-1。
4.6.4.3. 共享内存的基本原理
共享内存让不同进程看到同一份资源的方式就是,在物理内存当中申请一块内存空间,然后将这块内存空间分别与各个进程各自的页表之间建立映射,再在虚拟地址空间当中开辟空间并将虚拟地址填充到各自页表的对应位置,使得虚拟地址和物理地址之间建立起对应关系,至此这些进程便看到了同一份物理内存,这块物理内存就叫做共享内存。

注意:
这里所说的开辟物理空间、建立映射等操作都是调用系统接口完成的,也就是说这些动作都由操作系统来完成。
4.6.4.4. 共享内存的特点
采用共享内存通信的一个显而易见的好处是效率高,因为进程可以直接读写内存,而不需要任何数据的拷贝。对于像管道和消息队列等通信方式,则需要在内核和用户空间进行四次的数据拷贝,而共享内存则只拷贝两次数据:一次从输入文件到共享内存区,另一次从共享内存区到输出文件。实际上,进程之间在共享内存时,并不总是读写少量数据后就解除映射,有新的通信时,再重新建立共享内存区域。而是保持共享区域,直到通信完毕为止,这样,数据内容一直保存在共享内存中,并没有写回文件。共享内存中的内容往往是在解除映射时才写回文件的。因此,采用共享内存的通信方式效率是非常高的。
- 有两个进程,分别为 进程A 和 进程B,进程A 创建一块共享内存,然后写入数据,进程B 获取这块共享内存并且读取其内容。
- 测试时先运行进程A,然后再运行进程B,可以看到进程B会打印出 “Hello World”,说明共享内存已经创建成功并且读取。
4.6.5. 共享内存映射
4.6.5.1. 什么是内存映射
(1)内存映射是在进程的虚拟地址空间中创建一个映射。
根据映射来源分为文件映射和匿名映射:
- 文件映射(文件支持的内存映射):把文件的一个区间映射到进程的虚拟地址空间,数据源是存储设备上的文件。
- 匿名映射(没有文件支持的内存映射):把物理内存映射到进程的虚拟地址空间,没有数据源。
根据映射方式分为共享映射和私有映射:
- 共享映射:修改数据时映射相同区域的其他进程可以看见,如果是文件支持的映射,修改会传递到底层文件。
- 私有映射:第一次修改数据时会从数据源复制一个副本,然后修改副本,其他进程看不见,不影响数据源。
(2)两个进程可以使用共享的文件映射实现共享内存。匿名映射通常是私有映射,共享的匿名映射只可能出现在父进程和子进程之间。
- 对于磁盘文件和进程: 将一个文件或其它对象映射到进程地址空间,实现文件在磁盘的存储地址和进程地址空间中一段虚拟地址的映射关系。有了这样的映射,进程利用指针直接读写虚拟地址就可以完成对文件的读写操作。这样可以避免进行read/write函数操作。
- 对于用户进程和内核进程:将用户进程的一段内存区域映射到内核进程,映射成功后,用户进程对这段内存区域的修改直接反映到内核空间,同样,内核进程对这段内存区域的修改也直接反映到用户空间。
(5)内存映射的优点:
- 减少了拷贝次数,节省I/O操作的开支
- 用户空间和内核空间可以直接高效交互
- 进程可以直接操作磁盘文件,用内存读写代替 I/O读写
4.6.5.2. 内存映射的使用
(1)创建内存映射
要使用内存映射,首先需要使用 mmap()用来创建内存映射,mmap()函数的原型如下:
void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset);
- start:用户进程中要映射的某段内存区域的起始地址,通常为NULL(由内核来指定)
- length:要映射的内存区域的大小
- prot:期望的内存保护标志

4. flags:指定映射对象的类型

5. fd:要映射的文件描述符
6. offset:要映射的用户空间的内存区域在内核空间中已经分配好了的内存区域中的偏移
返回:若成功,返回指向内存映射区域的指针,若出错,返回MAP_FAILED(-1)。
- 删除内存映射
在使用完内存映射时,需要使用 munmap()函数来删除内存映射,munmap()函数原型如下:
int munmap(void *start, size_t length);
- start:指向内存映射区的指针
- length:内存映射区域的大小
返回:若成功,返回0,若出错,返回-1。
4.6.5.3. 内存映射的基本原理
(1)创建内存映射的时候,在进程的用户虚拟地址空间中分配一个虚拟内存区域。
(2)Linux 内核采用延迟分配物理内存的策略,在进程第一次访问虚拟页的时候,产生缺页异常。如果是文件映射,那么分配物理页,把文件指定区间的数据读到物理页中,然后在页表中把虚拟页映射到物理页;如果是匿名映射,那么分配物理页,然后在页表中把虚拟页映射到物理页。
4.6.5.4. 内存映射和共享内存的区别
- 内存映射与文件关联,共享内存不需要与文件关联,把共享内存理解为内存上的一个匿名片段。
- 内存映射可以通过fork继承给子进程,共享内存不可以。
- 文件打开的函数不同,内存映射文件由open函数打开,共享内存区对象由shm_open函数打开。但是它们被打开后返回的文件描述符都是由mmap函数映射到进程的地址空间。
4.6.6. 消息队列
4.6.6.1. 什么是消息队列
(1)消息队列是消息的链接表,包括Posix消息队列system V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。
(2)每个消息队列都有一个队列头,用结构struct msg_queue来描述。队列头中包含了该消息队列的大量信息,包括消息队列键值、用户ID、组ID、消息队列中消息数目等等,甚至记录了最近对消息队列读写进程的ID。读者可以访问这些信息,也可以设置其中的某些信息。
4.6.6.2. 消息队列的基本原理
消息队列实际上就是在系统当中创建了一个队列,队列当中的每个成员都是一个数据块,这些数据块都由类型和信息两部分构成,两个互相通信的进程通过某种方式看到同一个消息队列,这两个进程向对方发数据时,都在消息队列的队尾添加数据块,这两个进程获取数据块时,都在消息队列的队头取数据块。

其中消息队列当中的某一个数据块是由谁发送给谁的,取决于数据块的类型。
说明:
★ 消息队列提供了一个从一个进程向另一个进程发送数据块的方法。
★ 每个数据块都被认为是有一个类型的,接收者进程接收的数据块可以有不同的类型值。
4.6.6.3. 消息队列的特点
(1)消息队列是消息的链表,存放在内存中,由内核维护。只有内核重启或人工删除消息队列时,该消息队列才会被删除。若不人工删除消息队列,消息队列会一直存在于系统中。
(2)消息队列可以实现消息的随机查询。消息不一定要以先进先出的次序读取,编程时可以按消息的类型读取,克服了管道只能承载无格式字节流的缺点。
(3)消息队列可以双向通信,允许一个或多个进程向它写入或者读取消息。
(4)与无名管道、命名管道一样,从消息队列中读出消息,消息队列中对应的数据都会被删除。
(5)每个消息队列都有消息队列标识符,消息队列的标识符在整个系统中是唯一的。
4.6.6.4. 消息队列的实现步骤
(1)创建消息队列需要用msgget函数,消息队列创建成功时,msgget函数返回一个有效的消息队列标识符;
(2)向消息队列发送数据需要用msgsnd函数,调用成功,返回0,调用失败,返回-1;
(3)从消息队列获取数据需要用msgrcv函数,调用成功,返回实际获取到的字节数,调用失败,返回-1;
(4)释放消息队列我们需要用msgctl函数。
4.6.6.5. 消息队列常用函数
消息队列的实现包括创建或打开消息队列、添加消息、读取消息和控制消息队列这四种操作
涉及到的相关函数如下:
(1) msgget ()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> |
|
函数功能 |
创建和访问一个消息队列 |
|
函数原型 |
int msgget(key_t key, int msgflag); |
|
函数传参 |
key:某个消息队列的名字,用ftok()产生 key_t ftok(const char *pathname, int proj_id); 调用成功返回一个key值,用于创建消息队列,如果失败,返回-1 msgflag:有两个选项IPC_CREAT和IPC_EXCL,单独使用IPC_CREAT,如果消息队列不存在则创建之,如果存在则打开返回;单独使用IPC_EXCL是没有意义的;两个同时使用,如果消息队列不存在则创建之,如果存在则出错返回。 |
|
返回值 |
成功:返回一个非负整数,即消息队列的标识码, 失败:返回-1 |
(2) msgctl()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> |
|
函数功能 |
消息队列的控制函数 |
|
函数原型 |
int msgctl(int msqid, int cmd, struct msqid_ds *buf); |
|
函数传参 |
msqid:由msgget函数返回的消息队列标识码 cmd:有三个可选的值 IPC_STAT 把msqid_ds结构中的数据设置为消息队列的当前关联值 IPC_SET 在进程有足够权限的前提下,把消息队列的当前关联值设置为msqid_ds数据结构中给出的值 IPC_RMID 删除消息队列 |
|
返回值 |
成功:返回0 失败:返回-1 |
(3) msgsnd()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> |
|
函数功能 |
把一条消息添加到消息队列中 |
|
函数原型 |
int msgsnd(int msqid, const void *msgp, size_t msgsz, int msgflg); |
|
函数传参 |
msgid:由msgget函数返回的消息队列标识码 msgp:指针指向准备发送的消息 msgze:msgp指向的消息的长度(不包括消息类型的long int长整型) msgflg:默认为0 |
|
返回值 |
成功:返回0 失败:返回-1 |
消息结构一方面必须小于系统规定的上限,另一方面必须以一个long int长整型开始,接受者以此来确定消息的类型
struct msgbuf
{
long mtye;
char mtext[1];
};
(4) msgrcv ()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> |
|
函数功能 |
是从一个消息队列接受消息 |
|
函数原型 |
ssize_t msgrcv(int msqid, void *msgp, size_t msgsz, long msgtyp, int msgflg); |
|
函数传参 |
msgid:由msgget函数返回的消息队列标识码 msgp:指针指向准备发送的消息 msgze:msgp指向的消息的长度(不包括消息类型的long int长整型) msgflg:默认为0 |
|
返回值 |
成功:返回实际放到接收缓冲区里去的字符个数 失败:返回-1 |
4.6.7. 信号量
4.6.7.1. 什么是信号量
(1)信号量的本质是一种数据操作锁,它本身不具有数据交换的功能,而是通过控制其他的通信资源(文件,外部设备)来实现进程间通信,它本身只是一种外部资源的标识。信号量在此过程中负责数据操作的互斥、同步等功能。信号量主要作为进程间以及同进程不同线程之间的同步手段。
(2)当请求一个使用信号量来表示的资源时,进程需要先读取信号量的值来判断资源是否可用。大于0,资源可以请求,等于0,无资源可用,进程会进入睡眠状态(进程挂起等待)直至资源可用。当进程不再使用一个信号量控制的共享资源时,信号量的值+1(信号量的值大于0),对信号量的值进行的增减操作均为原子操作,这是由于信号量主要的作用是维护资源的互斥或多进程的同步访问。 而在信号量的创建及初始化上,不能保证操作均为原子性。
4.6.7.2. 为什么使用信号量
(1)在多任务操作系统环境下,多个进程会同时运行,并且一些进程之间可能存在一定的关联。多个进程可能为了完成同一个任务会相互协作,这样形成进程之间的同步关系。而且在不同进程之间,为了争夺有限的系统资源(硬件或软件资源)会进入竞争状态,这就是进程之间的互斥关系。
(2)进程之间的互斥与同步关系存在的根源在于临界资源。临界资源是在同一个时刻只允许有限个(通常只有一个)进程可以访问(读)或修改(写)的资源,通常包括硬件资源(处理器、内存、存储器以及其他外围设备等)和软件资源(共享代码段,共享结构和变量等)。访问临界资源的代码叫做临界区,临界区本身也会成为临界资源。
4.6.7.3. 信号量的工作原理
(1)信号量是用来解决进程之间的同步与互斥问题的一种进程之间通信机制,包括一个称为信号量的变量和在该信号量下等待资源的进程等待队列,以及对信号量进行的两个原子操作(PV 操作)。其中信号量对应于某一种资源,取一个非负的整型值。信号量值指的是当前可用的该资源的数量,若它等于0 则意味着目前没有可用的资源。
(2) PV 原子操作的具体定义如下:
P 操作:如果有可用的资源(信号量值>0),则占用一个资源(给信号量值减去一,进入临界区代码);如果没有可用的资源(信号量值等于0),则被阻塞到,直到系统将资源分配给该进程(进入等待队列,一直等到资源轮到该进程)。
V 操作:如果在该信号量的等待队列中有进程在等待资源,则唤醒一个阻塞进程。如果没有进程等待它,则释放一个资源(给信号量值加一)。
4.6.7.4. 信号量常用函数
Linux提供了一组精心设计的信号量接口来对信号量进行操作。需要注意的是:它们不只是针对二进制信号量。
(1) semget ()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> |
|
函数功能 |
创建或获取一个信号量 |
|
函数原型 |
int semget(key_t key, int nsems, int semflg); |
|
函数传参 |
key:信号量的键值,多个进程可以通过它访问同一个信号量 nsems: 需要创建的信号量数目,通常为1 semflg 同 open() 函数的权限位 |
|
返回值 |
成功:返回信号量标识符,在信号量的其他函数中都会使用该值; 失败:返回-1 |
(2) semctl()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/ipc.h> #include <sys/sem.h> |
|
函数功能 |
信号量的控制函数 |
|
函数原型 |
int semctl(int semid, int semnum, int cmd, union semun arg); |
|
函数传参 |
semid: semget()函数返回的信号量标识符 semnum: 信号量编号,使用信号量集时会用到。通常取为0,即使用单个信号量 cmd: 指定对信号量的各种操作,当使用单个信号量时,常见的操作见下表 arg:是union semun结构,可选参数,取决了第三个参数cmd。 |
|
返回值 |
成功:根据cmd值的不同返回不同的值, IPC_STAT,IPC_SETVAL,IPC_RMID返回0, IPC_GETVAL返回信号量当前值; 失败:返回-1 |
(3) semop()函数语法要点
|
头文件 |
#include <sys/types.h> #include <sys/ipc.h> #include <sys/msg.h> |
|
函数功能 |
操作信号量,P,V 操作 |
|
函数原型 |
int semop(int semid, struct sembuf *sops, size_t nsops); |
|
函数传参 |
semid: semget()函数返回的信号量标识符 sops: 指向信号量操作数组 nsops:操作数组sops中的操作个数,通常取1 |
|
返回值 |
成功:0 失败:返回-1 |
说明:
// sembuf结构体的定义
struct sembuf {
short sem_num; //信号量编号,使用单个信号量时,通常取0
short sem_op; //信号量操作,取值-1表示P操作,取值+1表示V操作
short sem_flg; //通常设置为SEM_UNDO,表示在进程没释放信号量而
//退出时,系统自动释放该进程中未释放的信号量
}
4.6.7.5. 信号量编程
(1)创建信号量或获得在系统已存在的信号量,此时需要调用semget()函数。不同进程通过使用同一个信号量键值来获得同一个信号量。
(2)初始化信号量,此时使用semctl()函数的SETVAL操作(SETVAL设置信号量集中的一个单独的信号量的值)。当使用二维信号量时,通常将信号量初始化为1。
(3)进行信号量的PV操作,此时调用semop()函数。这一步是实现进程之间的同步和互斥的核心工作部分。
(4)如果不需要信号量,则从系统中删除它,此时使用semclt()函数的IPC_RMID操作(IPC_RMID将信号量集从内存
4.6.8. linux中的信号
4.6.8.1. 什么是信号
(1)信号是由用户、系统或进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常。
(2)信号是通信内容受限(int型数字)的一种异步通信机制;信号是当前进程对外通信的一种手段;信号的目的是用来通信的;信号是异步的(类似硬件中断);信号本质上是操作系统事先定义好的int型数字编号。
4.6.8.2. 信号由谁发出
一般有以下几种情况:
①用户在终端按下按键(譬如用户按下Ctrl+c按键终止程序运行);
②硬件异常后由操作系统内核发出信号;
③用户使用kill命令向其它进程发出信号;
④某种软件条件满足后也会发出信号,如alarm闹钟时间到会产生SIGALARM信号,向一个读端已经关闭的管道write时会产生SIGPIPE信号。
信号发送可以发送给进程或者线程。
4.6.8.3. 信号由谁处理、如何处理信号
(1)虽然信号可以发送给进程或者线程,但是信号的处理是在线程中进行的,因为线程是代码执行的单元。线程首先处理自己队列里的信号,自己的处理完了再去处理进程队列里的信号。被阻塞的信号暂时不处理,还放回原队列中去。
(2)信号处理一般有三种方式:
① 忽略信号(收到信号直接丢弃);
② 捕获信号(信号绑定了某个函数,当信号发生时,执行相应的处理函数);
③ 默认处理(当前进程收到该信号后执行信号默认操作,一般是忽略或终止进程)。
默认处理有五种情况,不同的信号,其默认处理方式不同。这五种情况分别是:
- ignore(忽略)
- term(终结进程也就是杀死进程)
- core(coredump内存转储并杀死进程)
- stop(暂停进程)
- cont(continue恢复执行进程)
4.6.8.4. 常见信号介绍:
系统都有哪些信号呢,这些信号有什么不同呢?
- SIGINT(2-Ctrl+C时OS送给前台进程组中每个进程);
- SIGABRT(6-调用abort函数,进程异常终止);
- SIGPOLL/SIGIO(8-指示一个异步IO事件);
- SIGKILL(9-杀死进程的终极办法,该信号无法被忽略);
- SIGSEGV(11-无效存储访问时OS发出该信号,提示段错误信息)。
- SIGPIPE(13-涉及管道和socket编程);
- SIGALARM(14-涉及alarm函数的实现);
- SIGTERM(15-kill命令发送的OS默认终止信号);
- SIGCHLD(17-子进程终止或停止时OS向其父进程发此信号)。
另外SIGUSR1(10)和SIGUSR2(12)是用户自定义信号,作用和意义由应用自己定义。信号定义在/usr/include/signal.h和/usr/include/bits/signum.h这两个头文件中。
4.6.8.5. 常用信号函数
我们分为信号发送、信号捕获和信号处理三类:
(1)信号发送:kill()和raise()
① kill()函数同kill系统命令一样,可以发送信号给进程或进程组,它不仅可以中止进程(实际上发出SIGKILL信号),也可以向进程发送其他信号。
kill ()函数语法要点
|
头文件 |
#include <signal.h> #include <sys/types.h> |
|
函数功能 |
发送信号给进程或进程组 |
|
函数原型 |
int kill(pid_t pid,int sig); |
|
函数传参 |
pid: 正数:要发送信号的进程号 0:信号被发送到所有的当前进程在同一进程组的进程 -1:信号发给所有的进程表中的进程 <-1:信号发给进程组pid(绝对值)的每一个进程 sig:信号 |
|
返回值 |
成功:返回0 失败:返回-1 |
② raise()函数用来向正在执行的程序发送一个信号,允许进程向自身发送信号。
raise()函数语法要点
|
头文件 |
#include <signal.h> |
|
函数功能 |
向正在执行的程序发送一个信号,允许进程向自身发送信号。 |
|
函数原型 |
int raise(int sig); |
|
函数传参 |
sig:用于指定要发送的信号 |
|
返回值 |
成功:返回0 失败:返回-1 |
(2)信号捕捉:alarm()、pause()
① alarm()也称为闹钟函数,它可以在进程中设置一个定时器,当定时器指定的时间到时,它就向进程发送SIGALARM信号 。
alarm()函数语法要点
|
头文件 |
#include <unistd.h> |
|
函数功能 |
用来设置定时器,定时器超时将产生SIGALRM信号给调用进程 |
|
函数原型 |
unsigned int alarm(unsigned int seconds); //每个进程只能有一个闹钟时钟。 |
|
函数传参 |
seconds:设定的秒数,经过seconds后,内核将给调用该函数的进程发送SIGALRM信号。 如果seconds为0,则不再发送SIGALRM信号,最新一次调用alarm函数将取消 之前一次的设定。 |
|
返回值 |
成功:返回值是以前设置的闹钟时间的余留秒数. 失败:返回-1 |
② pause()函数是用于将调用进程挂起直至捕捉到信号为止。这个函数很常用,通常可以用于判断信号是否已到。
pause ()函数语法要点
|
头文件 |
#include <unistd.h> |
|
函数功能 |
用于将调用进程挂起直至捕捉到信号为止 |
|
函数原型 |
int pause(void); |
|
函数传参 |
无参 |
|
返回值 |
只有执行了一个信号处理程序并从其返回时,pause才返回。在这种情况下,pause返回-1,并将errno设置为EINTR。 |
(3)信号处理:signal()、sigaction()
① signal函数绑定某个捕获函数后,当信号发生后会自动执行绑定的捕获函数,并且把信号编号作为传参传给捕获函数;signal的返回值在出错时为SIG_ERR,绑定成功时返回旧的捕获函数;signal函数的优点-简单好用,捕获信号常用;缺点-无法简单直接得知之前设置的对信号的处理方法。
signal ()函数语法要点
|
头文件 |
#include <signal.h> |
|
函数功能 |
用来设置进程在接收到信号时的动作 |
|
函数原型 |
typedef void (*sighandler_t)(int); //信号处理函数的指针 sighandler_t signal(int signum, sighandler_t handler); |
|
函数传参 |
Signnum:指定的信号编号 Hander:处理方式 1、SIG_IGN:忽略这个信号 2、SIG_DFL:按照默认动作执行这个信号 3、如果是一个函数,则是捕捉这个信号,收到这个信号的时候去执行这个函数。 |
|
返回值 |
成功:返回信号处理程序的先前值 失败:返回SIG_ERR |
② sigaction函数比signal更具有可移植性,其使用方法的关键是两个sigaction指针;sigaction可以一次得到设置新捕获函数和获取旧的捕获函数(其实还可以单独设置新的捕获或者单独只获取旧的捕获函数),而signal函数必须在设置新的捕获函数的同时才获取旧的捕获函数。
sigaction()函数语法要点
|
头文件 |
#include <signal.h> |
|
函数功能 |
根据参数signum指定的信号编号来设置该信号的处理函数 |
|
函数原型 |
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact); |
|
函数传参 |
signnum:指定的信号编号,可以是除了SIGKILLh和SIGSTOP以外的任何信号 如果参数act 不是空指针,则为signum设置新的信号处理函数; 如果oldact不是空指针,则旧的信号处理函数将被存储在oldact中 |
|
返回值 |
成功:返回0 失败:返回-1 |
4.6.9. 套接字(Socket)
(1)套接字(Socket)是由Berkeley在BSD系统中引入的一种基于连接的IPC,是对网络接口(硬件)和网络协议(软件)的抽象。它既解决了无名管道只能在相关进程间单向通信的问题,又解决了网络上不同主机之间无法通信的问题。
(2)套接字有三个属性:域(domain)、类型(type)和协议(protocol),对应于不同的域,套接字还有一个地址(address)来作为它的名字。
(3) 域(domain)指定了套接字通信所用到的协议族,最常用的域是AF_INET,代表网络套接字,底层协议是IP协议。对于网络套接字,由于服务器端有可能会提供多种服务,客户端需要使用IP端口号来指定特定的服务。AF_UNIX代表本地套接字,使用Unix/Linux文件系统实现。
(4)IP协议提供了两种通信手段:流(streams)和数据报(datagrams),对应的套接字类型(type)分别为流式套接字和数据报套接字。流式套接字(SOCK_STREAM)用于提供面向连接、可靠的数据传输服务。该服务保证数据能够实现无差错、无重复发送,并按顺序接收。流式套接字使用TCP协议。数据报套接字(SOCK_DGRAM)提供了一种无连接的服务。该服务并不能保证数据传输的可靠性,数据有可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP协议。
(5)一种类型的套接字可能可以使用多于一种的协议来实现,套接字的协议(protocol)属性用于指定一种特定的协议。
4.7. 本章总结:
1. Linux系统进程的基本概念(了解)
进程是计算机中正在运行的程序的一个实例,进程的两个基本元素是程序代码和与代码相关联的数据集。
2. Linux系统进程的基本状态和进程关系(了解)
进程五状态模型的5个状态分别是:新建态、就绪态、运行态、阻塞态以及退出态。
进程关系:无关系、父子进程关系、进程组、会话(session)。
3. Linux系统进程的内存布局(了解)
进程在系统中的内存分配大致分为5段:正文段、已初始化的全局变量段、未初始化的全局变量段、堆区、栈区。
进程启动初始化大致分为7个步骤,依次是:计算地址空间、分配地址空间、载入地址空间、BSS段初始化零、创建堆栈段、设置环境变量、从函数main()开始执行程序。
4. Linux系统进程的创建(掌握)
进程是Linux系统中最基本的执行单位,其中调用函数fork()可以创建一个子进程,子进程拷贝父进程的内容,包括程序执行到的位置。
5. Linux系统进程的资源回收(掌握)
进程结束分为正常终止和异常终止。正常终止方式:return()/exit()/_exit();异常终止方式:自己或它人发信号终止进程。当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号,父进程可以调用函数wait()和waitpid()来回收子进程资源。
6. Linux系统进程体的替换(掌握)
进程调用函数exec()时,该进程执行的程序完全替换为新程序,调用函数exec()并不创建新进程,所以调用前后的进程ID并未改变。
7. Linux系统进程间的通信方式(了解)
进程间通信((IPC,InterProcess Communication)指的是两个任意进程之间传播或交换信息,以帮助两个彼此独立但相关联的进程调度工作。常见的进程间通信方式有:匿名管道、命名管道、信号、消息队列、信号量、共享内存、内存映射、套接字等。
(1)匿名管道的应用一般体现在父子进程或者兄弟进程间的通信上,创建管道的函数是pipe()。
(2)命名管道,又称FIFO(First Input First Output)是一种文件类型,通过FIFO,不相关的进程也能交换数据,其中创建FIFO的函数是mkfifo()。FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。FIFO的通信方式类似于在进程中使用文件来传输数据,只不过FIFO类型文件同时具有管道的特性。在数据读出时,FIFO管道中同时清除数据,并且“先进先出”。
(3) 信号是通信内容受限的一种异步通信机制;信号是当前进程对外通信的一种手段;信号本质上是操作系统事先定义好的int型数字编号。常见信号:SIGINT(2-Ctrl+C时OS送给前台进程组中每个进程);SIGKILL(9-杀死进程的终极办法,该信号无法被忽略);SIGTERM(15-kill命令发送的OS默认终止信号);
(4)消息队列是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级。消息队列独立于发送与接收进程。进程终止时,消息队列及其内容并不会被删除。消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取。
(5) 信号量实质就是个计数器,本质即一个可以用来计数的变量,可理解为一个int类型的变量;
信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据,若要在进程间传递数据需要结合共享内存;程序对信号量的操作都是原子操作;
(6)共享内存允许两个或多个进程共享物理内存的同一块区域。其特点:
①共享内存是最快的一种 IPC,因为进程是直接对内存进行存取。
②因为多个进程可以同时操作,所以需要进行同步。
③信号量+共享内存通常结合在一起使用,信号量用来同步对共享内存的访问。



188

被折叠的 条评论
为什么被折叠?



