进程的概念
我们编写的代码只是⼀个存储在硬盘的静态⽂件,需要经过编译器的预编译、编译、汇编以及链接形成一份可执行文件,再将它加载到内存中,让CPU逐条执行并做出相应的动作,才形成一个动态的进程。
一份可执行文件中,除了包含有数据段、代码段的相关数据,还有ELF头以及其它的辅助信息。ELF文件里大部分的数据是与程序本身的逻辑没有关系的,只是程序被加载到内存中运行时,系统会对这些辅助信息进行处理。而执行时真正被复制到内存中的只有 .data、.rodata、.text、.init段的内容,.bss段放的是未初始化的的静态数据,它们是不需要被复制的,因为未初始化的静态数据是没有值的,是在加载进来时由系统将其初始化为0。
当可执行文件被执行时,内核会产生一个名为task_struct{}的结构体来表示该进程。进程是一个“活动的实体”,从一开始产生就需要各种各样的资源以便于生存,比如内存资源、CPU资源、文件、信号等等,所有这些东西都是动态变化的,都被记录在该结构体的,所以该结构体也被称为进程控制块。
进程的组织方式
进程有诞生的一天自然就有死亡的一天,而且每一个进程都是有父母的(除了init),这个父母被称为父进程。而init进程就是所有进程的祖先,它是由系统内核的启动镜像文件产生的;
每一个进程都拥有自己的PID(身份证),PID是用来区分各个进程的基本依据,可以使用ps命令来查看进程的PID。
进程的状态
既然进程是一个活动的实体,那它就会有很多种运行状态;在⼀个进程的活动期间⾄少具备三种基本状态,即运⾏状态、就绪状态、阻塞状态。
进程的整个生命周期:
1、一个进程的诞生是由父进程调用fork()来实现的。
2、进程刚被创建时,处于TASK_RUNNING状态,处于该状态的进程可能是正在进程等待队列中排队(就绪态),也可能是正占用CPU在运行(执行态);
3、刚被创建出来的进程都处于就绪态,等待系统调度,内核中的sched()称为调度器,它会根据各种参数来选择一个正在排队的进程去占用CPU一段时间,时间用完后如果进程还没结束又会被放回等待队列中重新排队;在它处于执行态,运行期间如果被优先级更高的进程抢占了,该进程也会被迫重新排队。
4、进程处于执行态时,也可能会因为需要的某些资源没有得到而被置为睡眠态/挂起态。比如进程要读取一个管道文件数据但管道文件中没有数据时、或进程调用sleep()来强制挂起自己时,进程的状态变成TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE(深度睡眠);(这两个睡眠态的区别是后者与某些硬件设备相关,在睡眠期间不能响应信号;而前者是可以响应信号的。)当进程所等待的资源得到时,又会被系统置为就绪态重新排队。
5、当进程收到SIGSTOP或SIGTSTP信号时,状态就会被置为TASK_STOPPED,又称为暂停态,该状态下的就不再参与调度,但系统资源不释放,直到收到SIGCONT信号后被重新置为就绪态。
当进程被追踪时(比如被调试器调试时),收到任何信号状态都会被置为TASK_TRACED,该状态与暂停态一样,直到收到SIGCONT才会重新参与系统进程调度。
6、进程跟人一样,是会死的,而且分为正常死亡和非正常死亡。在程序中调用return、exit等函数属于正常死亡,如果被信号杀死就属于非正常死亡。不管怎么死的,内核都会调用do_exit()来使进程的状态变成所谓的僵尸态EXIT_ZOMBIE。这里的僵尸态指的是进程的PCB,因为进程死亡后其它的资源都被释放了,唯独留下PCB还在。
为什么进程死亡后还有把尸体留下来?
因为进程在退出时,将其退出信息都封装在它的尸体(PCB)里,父进程可以从子进程的尸体中获取子进程的一些信息,比如退出值、被什么信号杀死的、或者父子进程曾经约定过去做什么事,如果子进程完成了退出值为1,还没完成为0等等。
7、父进程调用wait()/waitpid()来查看子进程的死亡信息,顺便将子进程的状态设置为EXIT_DEAD死亡态;只有变为死亡态,子进程的PCB才会被系统回收。如果父进程比子进程先死,那子进程就会被祖先进程init收养,由祖先进程将子进程置为死亡态。
PCB
在操作系统中,是⽤进程控制块(process control block , PCB)数据结构来描述进程的。
PCB 是进程存在的唯⼀标识,这意味着⼀个进程的存在,必然会有⼀个 PCB,如果进程消失了,那么PCB 也会随之消失。
PCB 具体包含什么信息呢?
进程描述信息:
- 进程标识符:标识各个进程,每个进程都有⼀个并且唯⼀的标识符;
- ⽤户标识符:进程归属的⽤户,⽤户标识符主要为共享和保护服务;
进程控制和管理信息:
- 进程当前状态;
- 进程优先级:进程抢占 CPU 时的优先级;
资源分配清单:
- 有关内存地址空间或虚拟地址空间的信息,所打开⽂件的列表和所使⽤的 I/O 设备信息。
CPU 相关信息:
- CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程
重新执⾏时,能从断点处继续执⾏。
每个 PCB 是如何组织的呢?
通常是通过链表的⽅式进⾏组织,把具有相同状态的进程链在⼀起,组成各种队列。⽐如:
- 将所有处于就绪状态的进程链在⼀起,称为就绪队列;
- 把所有因等待某事件⽽处于等待状态的进程链在⼀起就组成各种阻塞队列;
- 对于运⾏队列在单核 CPU 系统中则只有⼀个运⾏指针了,因为单核 CPU 在某个时间,只能运⾏⼀个程序。
相关API:
当一个进程调用fork()成功后,将分别返回到两个进程中,当它们的返回值是不一样的,调用fork()的函数是父进程,调用该函数后函数的返回值是新产生的子进程的PID;而新产生的子进程的函数返回值是0。
父子进程基本是一模一样的,但也存在不一样的地方:
相同的部分:
-
实际UID、GID,以及有效UID、GID;
-
所有环境变量;
-
进程组ID和会话ID;
-
当前工作路径。除非用chdir()修改;
-
打开的文件;(注意,由于这时父子进程共用同一个文件描述符,父进程操作文件也会导致子进程中的文件指针的改变)
-
信号响应函数;
-
整个内存空间,包括栈、堆、数据段、代码段、标准I/O的缓冲区等。
不同部分:
- 进程号PID;
- 记录锁;
- 挂起的信号;
注意点:
1、子进程会从fork()返回值后的下一条逻辑语句开始运行;
2、父子进程是平等的,它们的执行次序的随机的,除非使用特殊机制来同步它们;
3、父子进程是相互独立的。子进程完整复制了父进程的内存空间,它们之间是相互独立、互不干扰的。
并不是只有fork函数才能创建进程,还有其它函数也可以;比如vfork函数也可以创建进程,并且创建出来的子进程是和父进程共享内存空间的,并且父进程要等子进程执行完后才能继续执行。
因为子进程是复制父进程的内存空间,所以父子进程所执行的工作是一样的;如果子进程只能怎么做那就完全没有意义的。我们可以使用exec函数簇来使得子进程去执行其它的动作。
exec函数簇指一系列功能相似的函数,它们的作用是让进程去加载一份新的可执行文件或脚本,覆盖原来的代码,重新运行。
注意:
1、该函数加载的是可执行文件(elf)或脚本。
2、被加载的文件的参数列表必须以自身名字为开始,以NULL为结尾。
3、成功执行后,进程原来的代码被新的程序代码覆盖,是不可返回到原来的代码的。
4、如果在调用fork之前打开了文件,子进程会继承文件描述符,调用exec函数簇覆盖程序后,新的程序依然会继承该文件描述符。
注册函数,让进程在退出时调用已经注册了的函数。
atexit(void (*func)(void));
exit()除了停止进程的运行外,它还会关闭所有已打开的文件。如果父进程调用wait()处于睡眠态,那么子进程执行exit()会重新启动父进程运行。另外,exit()还将完成一些系统内部的清除工作,例如缓冲区的清除工作等。
可以使用wait()和处理子进程退出状态值的宏来获取子进程的死亡信息。
进程间的通信
每个进程的⽤户地址空间都是独⽴的,⼀般⽽⾔是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
进程间的通信(IPC)方式主要有:
- 管道:有名管道(FIFO)和无名管道(PIPE)
- 信号:signal
- 共享内存:SHM
- 消息队列:MSG
- 信号量:SEM
- 套接字:Socket
管道
无名管道
无名管道是最简单的,常用于一对一的亲缘进程间的通信方式;
所谓的管道,就是内核⾥⾯的⼀串缓存。从管道的⼀段写⼊的数据,实际上是缓存在内核中的,另⼀端读取,也就是从内核中读取这段数据。另外,管道传输的数据是⽆格式的流且⼤⼩受限。
无名管道PIPE的特征:
- 没有名字,无法使用open();
- 只能用于亲缘进程间通信;
- 半双工工作方式:读写端分开;
- 写入操作不具备原子性,只能用于一对一的简单通信;
- 不能使用lseek()来定位。
PIPE是一种特殊文件,虽然是文件却没有文件名,只能由一个进程创建,然后通过继承的方式将它的文件描述符传递给子进程。而且它有两个文件描述符,一个只能用来读,一个只能用来写;
父进程必须先创建PIPE,然后再创建子进程,这样子进程才能继承父进程已经产生的PIPE文件描述符。这样父子进程都可以对同一个管道进行读写操作,但是这样会很容易找出数据混乱;一般的做法是创建两个无名管道,父进程向管道1写数据,从管道2读数据;子进程向管道1读数据,向管道2写数据;这样就能实现两个进程间的通信。
有名管道
有名管道,它可以在不相关的进程间也能相互通信。因为有名管道,提前创建了⼀个类型为管
道的设备⽂件,在进程⾥只要使⽤这个设备⽂件,就可以相互通信。
有名管道(FIFO)的特征:
- 有名字,存储于普通文件系统之中。
- 任何具有相应权限的进程都可以使用open()来获取FIFO的文件描述符。
- 跟普通文件一样:使用统一的read()/write()来读写。
- 跟普通文件不同:不能使用lseek()来定位。
- 具有写入原子性,支持多写者同时进行写操作而数据不会相互践踏。
- 有名管道文件不能保存在共享路径下。
读者/写者:持有文件可读/可写权限的描述符的进程。
有名管道最典型的用处———系统的日志功能
系统的日志信息被统一安排在/var/log下,这些日志文件都是一些普通的文本文件,它可以被一个或多个进程重复打开,如果多个进程同时写文件,必然会造成数据混乱,因为它们没办法相互协调好。
如何使毫不相干的不同进程的日志信息都能够完整地输送到日志文件中而不会出现混乱?
最简单高效地方案是:使用FIFO来接收各个不相干进程地日志信息,然后让一个进程专门将FIFO中地数据写到相应地日志文件中。
总结:不管是无名管道还是有名管道,进程写⼊的数据都是缓存在内核中,另⼀个进程读取数据时候⾃然也是从
内核中获取,同时通信数据都遵循先进先出原则,不⽀持 lseek 之类的⽂件定位操作。
信号
信号是唯一一种异步通信方式;一般情况下,进程什么时候会收到信号、收到什么信号都是无法事先预知的(除了几个特殊的信号).
kill -l //可以查看系统中的所有信号
1-31的信号都要一个特殊的名字,对应一个特殊事件。它们也被称为非实时信号或不可靠信号。
1、非可靠信号不排队,信号的响应会相互嵌套;
2、如果目标进程没有及时响应非可靠信号,那么随后到达的信号将会被丢去;
3、每一个非可靠信号都会对应一个系统事件,当事件发生时,就会产生对应的信号;
4、如果进程挂起信号中含有可靠信号和非可靠信号,那么进程优先响应可靠信号并会从大到小响应,而非可靠信号没有固定的次序。
注意:9号和19号信号是两个特殊的信号,它们不能被忽略、阻塞或捕捉,只能按默认动作来响应
34-64的信号属于实时信号或可靠信号,是专门留给用户使用的。
1、可靠信号的响应次序按接收顺序排队,不嵌套;
2、如果同时接收到多个可靠信号,按照从大到小次序响应;
3、相同的可靠信号被多次发送,也不会被丢弃,而是会依次逐个响应;
4、可靠信号没有特殊的系统事件与之对应,默认动作是结束进程。
当进程接收到一个信号时:
1、会先判断该信号是否被阻塞,如果阻塞了,该信号会被挂起,知道阻塞解除;
2、判断该信号是否被捕获,如果捕获了,判断捕获类型:
①如果设置为响应自定义动作,则执行自定义信号
②如果设置为忽略,则直接丢弃该信号
③如果设置为默认,执行默认动作
相关API:
注意:普通用户的进程只能向具有与其相同的用户标识符的进程发送信号。也就是说,一个用户
的进程不能向另一个用户的进程发送信号。只有 root 用户的进程能够给任何线程发送信号。
函数signal()一般是和kill()配套使用的,目标进程必须先使用signal()来为某个信号设置一个响应函数或设置忽略某个信号,才能改变信号的默认动作,这个过程称为信号的捕捉。
自己给自己发送一个信号,raise()是kill()的一个特例。
注意:pause()是在响应函数返回之后再返回的。如果想要在一个进程中持续地接收信号,可以写一个死循环来不断pause().
如果一个进程临时不想响应某个或某些信号,可以通过设置阻塞掩码来达到目的。使用信号集来实现多个信号同时设置。
system-V IPC
消息队列、共享内存、信号量统称为system-V IPC,一般称它们为IPC对象。
这些对象地接口比较类似,都使用一种名为key的键值来唯一标识,而且它们被创建之后,不会因为进程的退出而消失,而是会持续存在,除非调用特殊的函数或命令删除。
进程每次打开一个IPC对象,就会获得一个象征这个对象的ID,进而使用这个ID来操作该对象。IPC对象的key是唯一的,但ID是可变的。key类似于文件的路径名,ID类似于文件的描述符。
系统中多个进程如果向操作同一个对象,就必须使用相同的key值。
每个IPC对象在系统内核中都有一个唯一的标识符。通过标识符内核可以正确的引用指定的IPC对象。
标识符的唯一性只在每一类的IPC对象内成立。比如说,一个消息队列和一个信号量的标识符可能是相同的,但绝对不会出现两个相同标识符在消息队列中。
1、调用该函数时,如果两个参数一样,那得到的key值也一样;
2、第一个参数一般选用进程所在的目录,因为在一个项目中需要通信的几个进程通常会出现在同一个目录;
3、如果同一个目录中的进程需要超过一个IPC对象,可以通过第2个参数来标识;
4、系统只有一套key标识,不同类型的IPC对象也不能重复。
-
查看消息队列:ipcs -q
-
查看内存共享:ipcs -m
-
查看信号量:ipcs -s
-
查看所有的IPC对象:ipcs -a
-
删除指定的消息队列:ipcrm -q MSG_ID或ipcrm -Q msg_key
-
删除指定的共享内存:ipcrm -m SHM_ID或ipcrm -M shm_key
-
删除指定的信号量:ipcrm -s SEM_ID或ipcrm -S sem_key
消息队列MSG
使用管道无法获取指定的数据,只能按照次序逐个读取;在面对多个进程相互通信时,无法使用一条管道来完成通信。
消息队列提供一种带有数据标识的特殊管道,使得每一段被写入的数据都变成带有标识的信息,只要指定标识就能获取对应的信息而不会受其它信息的干扰。
消息队列类似了多条并存的管道,可以选择其它一条获取特定的信息。
消息队列是保存在内核中的消息链表,在发送数据时,会分成⼀个⼀个独⽴的数据单元,也就是消息体(数据块),消息体是⽤户⾃定义的数据类型,消息的发送⽅和接收⽅要约定好消息体的数据类型,所以每个消息体都是固定⼤⼩的存储块,不像管道是⽆格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。消息队列⽣命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会⼀直存在。
使用方法:
1、发送者:
①获取消息队列的key与ID
②将数据放入一个带有特定标识的结构体,发送给消息队列
2、接收者:
①获取消息队列的key与ID
②将指定标识的信息读取出来
当发送者和接收者不再使用消息队列,及时删除。
在使用过程中,PATH相当于消息队列的路径,key值相当于消息队列的文件名,id相当于文件描述符;需要使用ftok函数把路径生成文件,再使用msgget函数打开文件获取文件描述符。
注意:
1、如果key指定为IPC_PRIVATE,则自动产生一个随机未用的新键值来对应新的MSG,一般用于线程间通信
2、参数2是一个位屏蔽字,可以用按位或来叠加参数
3、消息队列的权限只有读和写,执行权限是无效的,所有0777和0666是一样的
4、不同的进程想要使用同一个消息队列就需要使用相同的消息队列ID;相同的key值才能获取相同的消息队列ID。
注意:
1、发送消息时,消息必须被组织成以下格式:
struct msgbuf
{
long mtype;//消息的标识
char mtext[1];//消息的正文
}
该结构体是需要自定义的,必须要有标识段而且必须是第一个,正文段可以不止一个。
2、消息的标识可以是任意长整型数值,但不能是0L;
3、参数msgsz指正文部分的大小,不包含标识的大小。
共享内存SHM
消息队列使用简单,但它和管道一样,都需要“代理人”的进程通信机制:内核充当了这个代理人,内核为使用者分配内存、检查边界、设置阻塞以及各种权限监控,使得我们使用起来非常省心省力。但这也导致了它们的通信效率不高,在通信过程中,存在⽤户态与内核态之间的数据拷⻉开销,因为进程写⼊数据到内核中的消息队列或者管道时,会发⽣从⽤户态拷⻉数据到内核态的过程,同理另⼀进程读取内核中的消息数据时,会发⽣从内核态拷⻉数据到⽤户态的过程。因此它们不适合用来传输海量数据。
现代操作系统,对于内存管理,采⽤的是虚拟内存技术,也就是每个进程都有⾃⼰独⽴的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是⼀样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出⼀块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写⼊的东⻄,另外⼀个进程⻢上就能看到了,都不需要拷⻉来拷⻉去,传来传去,⼤⼤提⾼了进程间通信的速度。
一般使用步骤:
1、获取共享内存对象的key与ID(指定共享内存大小)
2、将共享内存映射至本进程虚拟内存空间的某个区域。
3、当不再使用时,解除映射关系
4、当没有进程需要这块共享内存时,删除它。
为什么共享内存是最高效的:
1、裸露内存空间,提供内存的首地址,可以通过地址偏移操控整块内存空间。
2、没有复杂的通信机制,也没有规定传输的数据的类型。
相关API:
注意:
1、如果key指定为IPC_PRIVATE,则自动产生一个随机未用的新键值来对应新的SHM
2、大页面指内核为了提高程序性能,对内存进行分页管理,采用比默认4KB更大的分页,以减少缺页中断。
Linux内核支持以2MB作为物理页面分页的基本单位。
注意:
1、共享内存只能以只读或可读可写方式映射,无法以只写方式映射。
2、参数2一般为NULL,如果不为NULL,最好在参数3加上SHM_RND,不然就得自己选择好shmaddr为页对齐地址
3、解除映射后,进程不能再允许访问SHM。
注意:
IPC_RMID只是为删除做好准备,并不会立即删除。
信号量SEM
⽤了共享内存通信⽅式,带来新的问题,那就是如果多个进程同时修改同⼀个共享内存,很有可能就冲突
了。例如两个进程都同时写⼀个地址,那先写的那个进程会发现内容被别⼈覆盖了。
为了防⽌多进程竞争共享资源,⽽造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只
能被⼀个进程访问。正好,信号量就实现了这⼀保护机制。
信号量SEM中文也翻译为信号灯,sem不是用来传输数据的,而是用来协调各个进程或线程工作的。
Linux中用到的信号量有3种:system-V 信号量、POSIX 有名信号量和POSIX无名信号量。它们虽然有很多显著不同的地方,但基本功能都是用来表征一种资源的数量,当多个进程或线程争夺这些资源时,信号量用来保证它们合理地、秩序地使用这些资源。
基本概念:
1、多个进程或线程有可能同时访问的资源(变量、链表、文件等)称为共享资源,也称为临界资源。
2、访问这些资源的代码称为临界代码,这些代码区域称为临界区域。
3、程序进入临界区之前必须对资源进行申请,这个动作称为P操作;
4、程序离开临界区之后必须释放相应的资源,这个动作称为V操作。
system-V的信号量并不是一个单个的值,而是一组信号量元素构成的,当我们需要多个资源时,可以同时向多个信号量元素申请。
信号量的P、V操作最核心的特征是:它们是原子性的。
相关API
注意:
创建信号量时,还受到以下系统信息的影响。
1、SEMMNI:系统中的信号量的总数最大值。
2、SEMMSL:每个信号量中信号量元素的个数最大值。
3、SEMMNS:系统中所有信号量中的信号量元素的总数最大值
在Linux中,以上信息在/proc/sys/kernel/sem中可查看。
注意:信号量元素的序号从0开始,实际上就是数组下标。
根据sem_op的数值,信号量操作分成3种情况:
- 当sem_op大于0时,进行V操作,即信号量元素的值(semval)将会被加上sem_op的值。如果SEM_UNDO被设置,那么该V操作将会被系统记录。V操作永远不会导致进程阻塞。
- 当sem_op等于0:进行等零操作,如果此时semval恰好为0,则semop()立即成功返回,否则如果IPC_NOWAIT被设置,则立即出错返回并将errno设置为EAGAIN,否则进程将进入睡眠,直到以下情况发生:
+ semval变为0;- 信号量被删除(将导致semop()出错退出,错误码为EINTR)。
- 收到信号(将导致semop()出错退出,错误码为EINTR)。
- 当sem_op小于0:进行P操作,即信号量元素的值将会被减去sem_op的绝对值。如果semval大于或等于sem_op的绝对值,则semop()立即成功返回,semval的值将减去sem_op的绝对值,并且如果SEM_UNDO被设置了,那么该P操作将会被系统记录。 如果semval小于sem_op的绝对值并且设置了IPC_NOWAIT,那么semop()将出错返回且将错误码置为EAGAIN,否则使进程进入睡眠,直到以下情况发生:
- semval的值大于或等于sem_op的绝对值。
- 信号量被删除(将导致semop()出错退出,错误码为EINTR)。
- 收到信号(将导致semop()出错退出,错误码为EINTR)。
除(将导致semop()出错退出,错误码为EINTR)。
+ 收到信号(将导致semop()出错退出,错误码为EINTR)。
- 当sem_op小于0:进行P操作,即信号量元素的值将会被减去sem_op的绝对值。如果semval大于或等于sem_op的绝对值,则semop()立即成功返回,semval的值将减去sem_op的绝对值,并且如果SEM_UNDO被设置了,那么该P操作将会被系统记录。 如果semval小于sem_op的绝对值并且设置了IPC_NOWAIT,那么semop()将出错返回且将错误码置为EAGAIN,否则使进程进入睡眠,直到以下情况发生:
- semval的值大于或等于sem_op的绝对值。
- 信号量被删除(将导致semop()出错退出,错误码为EINTR)。
- 收到信号(将导致semop()出错退出,错误码为EINTR)。