操作系统-进程详解

进程

进程的基本概念:进程是程序的一次执行过程

进程包括:进程控制块(PCB),程序段和数据段,其中PCB是进程存在的唯一标识,创建进程实际就是创建一个PCB,而销毁进程实际就是销毁PCB

PCB

PCB是一个结构体,在linux中为task_struct结构体,其中包括进程状态,进程标识信息(每个进程有一个唯一的标识符PID),优先级,定时器,寄存器,栈指针等,在linux中将进程控制块组织成一个双向循环链表(这个链表是有上限的),每一项指向一个进程控制块,创建新进程时,系统在内存中申请一个空的task_struct区,填入所需信息,并将该结构体插入到链表中

用户态和内核态

https://blog.csdn.net/qq_39823627/article/details/78736650

https://blog.csdn.net/youngyoungla/article/details/53106671

  1. 用户态:当进程在执行用户代码时所处的运行状态为用户态,用户态下进程所能访问的内存空间和对象受到限制,其所占有的处理机是可以被抢占的
  2. 内核态:当进程在执行内核代码(在内核代码地址空间处执行内核代码)时所处的运行状态为内核态,内核态下的进程可以访问所有的内存空间和对象,且所占用的处理机是不允许被抢占的。
    • 所谓内核态,就是拥有资源多的状态,也就是特权态,在当进程处于内核态,那么进程将可以访问内核数据,包括内核数据结构(例如进程表),IO,内存清零,设置时钟等

为什么要区分内核态和用户态?

​ 主要是为了安全,有些操作(CPU指令)是非常危险的,例如修改内核数据结构,内存清零等,如果所有程序都能干这些事情,那么系统可能会经常崩溃了,所以这些操作即为特权操作只能交给内核代码来完成,而用户程序不能完成,当用户需要用当相关内核操作时,可以通过操作系统提供的系统调用来进入内核态完成(个人理解就是处理器在用户态时特权级最低,此时和内核相关的操纵是不能完成的,如果要完成只能进入内核态去执行内核代码

哪些情况会发生用户态到内核态的切换?

  1. 系统调用,系统调用是操作系统提供一组接口,用户进程通过系统调用获取操作系统服务,系统调用实际是用户进程从用户栈切换到了内核栈,还是该进程在执行,只不过执行的是内核代码
  2. 异常,用户进程在用户态下执行时,突然发生某些异常,这将导致当前运行的进程切换到处理此异常的内核相关程序中(此时发生了进程切换,和系统调用有区别)
  3. 外围设备的中断,当外围设备完成用户请求后,会向CPU发出相应的中断信号,此时暂停执行当前的程序,而去执行与中断信号对应的处理程序,如果之前执行的进程是用户态,此时就发生了用户态到内核态的转换

用户态和内核态的切换是怎么实现的?

​ 1. 实现方式和用户栈和内核栈有关,就linux系统而言,当创建进程时,会为进程创建相应的堆栈,每个进程有两个栈,分为用户栈和内核栈,操作系统为其分配两个连续的物理页面(8KB),其中PCB约占1KB,而剩余的7KB即为内核堆栈,当进程运行在用户态时CPU堆栈寄存器中存放的是用户堆栈的地址,此时使用用户栈,而进程进入内核空间时,cpu堆栈指针寄存器中存放的是内核栈空间地址,使用内核栈

  1. 当进程因为系统调用陷入内核态时,进程所使用的堆栈也将从用户堆栈转换为内核堆栈,此时先把用户堆栈的地址,代码段地址,程序计数器(下一条要执行的指令)保存到内核栈中,然后设置堆栈指针寄存器中的内容和内核栈的地址,当进程从内核态返回用户态时,将之前保存的用户栈信息恢复到寄存器中即可(注意,返回到用户态后,内核态堆栈中保存的信息将会被恢复,并被清理空,即每次由用户到内核态时,内核栈都是空的
fork/vfork

https://blog.csdn.net/jason314/article/details/5640969

pid_t fork(void);
//该函数可以返回两个值,在父进程中返回子进程pid,在子进程中返回0,如果出错则返回小于0

该函数的作用是以当前进程为副本创建一个子进程,并将该进程的数据和资源拷贝给子进程,内核通过PID来标识每一个进程,最大值默认为32768,即short int类型,linux下可以查看,/proc/sys/kernel 下的pid_max

一个重点是fork会有两个返回值

其实fork创建一个进程就是创建一个PCB并插入到进程链表中,并将当前父进程中的数据拷贝到子进程中,具体的步骤如下:

  1. 创建PCB,分配新的内存块和内核数据结构给子进程(即为子进程创建内核堆栈,向PCB拷贝数据)
  2. 将父进程的部分数据拷贝到子进程
  3. 添加子进程到系统进程列表中
  4. fork函数返回,开始调度

问题:为什么fork会返回两个值?

  1. 由于在复制时复制了父进程的堆栈段,所以两个进程都停留在fork函数中,等待该函数返回,此时fork函数会返回两次,一次在子进程中返回,一次在父进程中返回

什么是写时拷贝技术(COW)(看完操作系统的内存部分后再来思考)?

  1. 当创建子进程时,fork函数其实不会为新生成的子进程创建物理内存,而是为其创建虚拟空间结构,他们此时共享父进程的物理空间,当父子进程中有相应的段(代码段,数据段,堆栈等)发生改变时,再为子进程相应的段分配物理空间,这样可以避免没有意义的复制而导致效率下降(即需要修改数据的时候才真正开辟空间去写)https://www.cnblogs.com/wuchanming/p/4495479.html
  2. 当fork子进程后,如果直接为子进程分配内存,并拷贝,则可能会导致很多效率问题,所以此时子进程只有自己独立的逻辑进程空间,在fork之后,exec之前实际上子进程和父进程是共享相同的物理空间,一般而言当子进程/父进程修改了数据,那么一般会进行堆栈和数据段的复制,但是代码段一般是共享的,但是如果执行了exec则所有的部分都会分配单独的内存。
  3. 一般而言在fork后都是子进程先执行,因为一般fork子进程都是为了进行exec,从而避免父进程先执行修改了数据导致了写时复制,但是exec后又会清空堆栈,重新加载数据和代码,避免无用的写时复制
  4. https://blog.csdn.net/qq_37174526/article/details/91161439

fork和vfork的区别:

  1. fork的子进程会拷贝父进程的代码段和数据段(虽然是写时复制)
  2. vfork的子进程和父进程共享内存,当vfork创建出子进程后,父进程会挂起,除非子进程exit或者exec函数才会唤醒父进程
  3. 由vfork创建的子进程不应该用return返回调用者,而是用exit()返回,或者_exit()返回
  4. vfork后子进程先执行,此时子进程如果修改了变量,那么父进程对应变量也会被修改
  5. 引入vfork一般是为了解决fork复制父进程数据到新内存空间的问题(当然出现写时复制后这种问题将不存在),一般而言子进程都是用来exec的所以exec前的复制将是没有必要的,这样也会降低效率,所以引入vfork,当exec时子进程分配内存拷贝数据和代码,此时父进程可以继续执行
  6. 注意,如果子进程依赖于父进程的执行,则会导致死锁

为什么vfork要用exit返回,而不能用return?

  1. vfork创建和子进程和父进程共享内存,包括堆栈,如果return那么子进程在return时会摧毁堆栈,当父进程执行时堆栈被摧毁,无法继续执行(段错误,return了两次)
  2. 但是exit表示进程退出,其不会做清理栈的操作,而是在结束进程后由系统来清理

return和exit的区别

  1. return表示函数返回,会清理局部变量(调用析构函数),主函数return会调用exit退出进程
  2. exit可以用在任何地方,表示进程退出,其不会清理局部变量

https://blog.csdn.net/biqioso/article/details/79937410

https://www.cnblogs.com/1932238825qq/p/7373443.html

https://blog.csdn.net/Dawn_sf/article/details/78709839

exec

这是一个函数族,提供了在一个进程中启动另外一个程序执行的方法,其可以根据指定的文件名或者目录名找到可执行文件,并用其来取代原调用进程的数据段、代码段、堆栈

exec包括以下6个函数

int execl(const char* path, const char* arg,...);
int execlp(const char* file, const char* arg,...);
int execle(const char* path, const char* arg, ..., const char* envp[]);
int execv(const char* path, const char* argv[]);
int execvp(const char* file, const char* argv[]);
int execve(const char* path, const char* argv[], const char* envp[]);

exec函数族第一个参数是文件名/文件路径,第二个参数可能是列举出新程序名和参数或者以字符串数组的形式包含了新程序名和参数,但是需要注意的是在新程序名和参数后是NULL结尾

可以根据函数名来区分这个6个函数

l:以列举的方式列出程序名和参数,同时最后一个参数都是NULL

V:以字符串数组的方式列出程序名和参数,最后一个参数是NULL

p:表示根据环境变量去搜索文件名

e:表示使用函数中提供的环境变量数组去搜索文件

如果没有p和e,则第一个参数文件名必须以绝对路径给出

当进程调用exec后,在exec的程序执行完后该(子)进程就结束了,不会再执行exec后面的代码了

exit函数

exit函数/_exit函数都是用来使得进程退出的函数,当参数为0表示正常退出,当参数非0表示异常退出,exit函数和_exit函数还是有区别的

  1. exit定义在stdlib文件中,而_exit函数定义在unistd.h文件中
  2. _exit函数最为简单,就是使进程停止运行,并返回给内核,由内核(系统)来关闭打开的文件,释放进程的内存
  3. exit函数则会先调用atexit()注册的函数,按照注册相反的顺序调用注册的函数,从而可以执行用户自定义的一些清理操作,之和会关闭打开流(关闭打开的文件),并清理IO缓冲区,并将输出缓冲区中的数据写入到文件中,最后调用_exit函数
  4. 注意exit和_exit仅仅是导致进程退出,清理内存的操作是由系统内核来完成的

一个进程在调用了exit后,并没有被真正的销毁,而是留下一个被称为僵尸进程的数据结构,僵尸进程放弃了所有内存空间,但是在进程列表中即task链表中该保持一个位置,记载该进程的退出状态等信息

但是fork出来的子进程的退出最好使用_exit(),而不是exit()

https://blog.csdn.net/biqioso/article/details/79937410

wait/waitpid

为什么父进程需要等待回收子进程?

  1. 避免出现僵尸进程,从而造成内存泄漏
  2. 父进程得管理子进程,例如父进程交给子进程的任务完成的如何,父进程需要及时知道,所以父进程要等待子进程wait
  3. 父进程通过进程等待的方式来回收子进程资源,获取子进程退出信息

wait和waitpid都是用来处理僵尸进程的

wait
pid_t wait(int* status);
//返回子进程的pid,status为子进程的退出状态

wait函数是父进程用来等待回收子进程的

  1. 当调用wait函数时,如果该进程没有子进程则返回-1错误
  2. 当调用wait函数时,如果该进程的子进程还没有退出,则阻塞
  3. 如果子进程退出,则返回子进程的pid,同时status为子进程的退出状态
  4. wait函数的常见使用方法是注册到SIGCHLD的信号处理函数中来回收,这样可以避免阻塞
waitpid
pid_t waitpid(pid_t pid, int* status, int options);

该函数可以指定等待哪个进程结束,也可以指定是否阻塞,但是wait只能等待任意一个子进程退出

  1. pid
    • pid<-1,等待进程组号为pid绝对值的任何子进程
    • pid=-1,等待任何子进程,类似wait
    • pid=0,等待等待进程组号和当前进程相同的子进程
    • pid>0,等待进程号为pid的子进程
  2. status,进程退出状态
  3. options,提供一些选项来控制waitpid函数的行为
    • WNOHANG,如果等待的子进程没有结束,则不会阻塞,而是立即返回0,结束了则返回进程号
    • WUNTRACED,如果子进程阻塞,则马上返回

https://blog.csdn.net/Roland_Sun/article/details/32084825

引入waitpid的原因是SIGCHLD信号是不排队的,当有多个子进程同时结束,那么会产生多个SIGCHLD信号,此时信号处理函数只会被执行一次,如果在执行信号处理函数期间,某个子进程被结束,那么该SIGCHLD信号会被阻塞并等待处理,假设此时又来了第三个此信号,那么其会被抛弃,后续的都会被抛弃,从而导致后面的子进程无法被回收,所以最好的办法是用waitpid在信号处理函数中循环处理,同时设置为非阻塞WNOHANG

https://blog.csdn.net/pzqingchong/article/details/52853064

init进程/孤儿进程/僵尸进程/守护进程
守护进程

​ 守护进程是一种运行在后台的特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些发生地事件。它不需要用户输入就能运行而且提供某种服务。Linux大多数的服务器都是通过守护进程去实现的,常见守护进程如syslogd,httpd等,守护进程一般在系统开机后运行,除非强行终止,否则直到关机都保持运行,守护进程经常以超级用户root权限来运行,因为其往往需要访问一些特殊的资源。守护进程的父进程是init进程,其真正的父进程在fork出子进程后就exit了,所以其是由init继承的孤儿进程,守护进程一般而言和终端没有交互,即没有输入和输出,守护进程一般以d结尾。

即,引入守护进程的目的就是让该进程不受终端的控制,即终端的输入输出,或者关闭与否不会影响到该进程。

守护进程的创建:setsid(),出错返回-1

进程组:一个或者多个进程的集合,进程组ID为进程组长的进程ID

会话期:一个或多个进程组的集合,每个会话有唯一一个会话首进程,会话ID为会话首进程ID

setsid的根本目的是创建一个新的会话

  1. fork创建一个子进程,父进程exit退出

    • 父进程退出,造成进程运行完毕的假象,而实际的操作是在子进程在后台完成(当创建子进程时,子进程一定不是进程组长,父进程和子进程是一个进程组,父进程已经有所属的进程组了,那么其子进程不可能是进程组)
  2. 子进程中调用setsid()创建新的会话

    • 子进程会拷贝父进程的进程组,会话期,控制终端等,此时需要setsid函数来使子进程摆脱原会话控制
    • 摆脱原进程组,并成为一个新的进程组长
    • 摆脱终端控制,如果在调用setsid前,该进程有终端控制,那么与终端的联系被解除,如果该进程是一个进程组长,则函数报错
    • 调用了setsid后的函数成为该会话的首进程,也成为该进程组的组长
  3. 再次fork()一个子进程并让父进程退出,防止该子进程(会话)再次打开一个终端,fork后父进程退出,这个子进程将不是会话的首进程

  4. 在子进程中调用chdir()函数,让根目录"/"成为子进程的工作目录(防止工作目录被卸载)

  5. 在子进程中调用umask()函数,设置进程的文件权限掩码为0

    • umask(m)是指设置文件权限掩码,即创建文件时在默认文件权限基础上屏蔽的文件权限
    • linux默认umask为022,而默认创建权限为666,目录为777,所以在创建时会屏蔽022变成644,755
  6. 在子进程中关闭不需要的文件描述符

  7. 守护进程退出处理,即对kill命令产生的signal信号进行处理,达到进程正常退出

当然了也可以用库函数daemon()函数再次创建一次守护进程

int daemon(int nochdir,int noclose);
//nochdir为0,将目录设定为根目录,noclose重定向输出

https://blog.csdn.net/woxiaohahaa/article/details/53487602

注:创建守护进程的根本是为了突破终端的限制(普通情况下终端关闭,那么在该终端下创建的进程将被关闭,这些进程受到了终端的控制),而想让进程不受控制,那么需要为进程创建新的进程组,会话,并摆脱原来的终端

http://blog.chinaunix.net/uid-24517549-id-4030070.html

守护进程和后台进程的区别?

  1. 后台进程虽然运行在后台,但是还是没有独立于终端,后台程序还是可能会在终端上输出的
  2. 后台进程还是受到终端控制的,当终端退出,那么后台进程也会退出
  3. 后台进程的进程组、会话等没有改变,其只是进行了一次fork而已

如何实现守护进程?

init进程

三个进程:idle进程(PID=0)、init进程(PID=1)、kthreadd进程(PID=2)

init进程由idle进程通过kernel_thread创建,该进程是一个用户级进程,在内核自行启动后被创建,其是所有用户进程的祖先,其启动位置为/sbin/init或者/bin/sh,如果都无法启动,将系统启动失败

僵尸进程

​ 当子进程退出时,如果父进程没有调用wait/waitpid函数来等待子进程结束,同时也没有显示忽略SIGCHLD信号,那么其将变成僵尸进程,如果此时父进程结束,那么init会自动接受这个子进程,为其收尸,但是如果父进程不退出,那么子进程将一直保持僵尸进程状态,造成内存泄漏

注意除了init进程外,每个进程exit后都会经历僵尸进程的阶段,只不过有些进程马上被父进程回收了,导致看不到

​ 子进程调用exit结束时,虽然内存空间基本都被回收,但是其并没有被真正销毁,其还有一个僵尸进程数据结构在进程列表中,其中保存着相应的退出信息。

避免僵尸进程的方法:

  1. 使用signal显示忽略SIGCHLD信号,此时该僵尸子进程将交给init进程托管回收
  2. 调用wait/waitpid函数
  3. 父进程fork一个子进程后,子进程fork一个孙进程后退出,该孙进程由init进程接管回收,但是子进程要被父进程回收
孤儿进程

当子进程的父进程先于子进程退出,那么这些子进程将成为孤儿进程,这些进程会被init进程接管,由init进程回收

进程通信(IPC)

常见的进程间的通信方式包括:管道、消息队列、共享内存、信号量、socket、信号、文件锁

管道通信

管道分为匿名管道(PIPE)和命名管道(FIFO),管道实际就是一个伪文件,让两个进程都能够访问该文件

匿名管道:

  1. 匿名管道一般用于父进程和子进程间的通信,或者同一父进程的不同子进程的通信
  2. 匿名管道的数据只能在一个方向上流动,即只允许一方写,另一方读
  3. 其本质是一个内核缓冲区,由两个文件描述符分别表示读端和写端,数据从写端流入,从读端流出
  4. 使用匿名管道时,如果缓冲区空则读阻塞,如果缓冲区满则写阻塞

创建管道的方法:

pipe函数

int pipe(int fd[2]);
//成功返回0,失败返回-1
  1. 一般是父进程创建管道,创建成功后fd[0]为读,fd[1]为写
  2. 然后fork出子进程,子进程如果读则关闭写端,子进程如果写则关闭读端,父进程对应关闭读端和写端

管道读写需要注意:

  1. 管道写端被关闭,则将管道中数据读完后,再读则返回0
  2. 管道读端被关闭,则写管道将保存,并发出SIGPIPE信号

匿名管道只能是单向的,如果要实现双向传输,需要创建两个管道

命名管道(FIFO):

FIFO是一种文件,以FIFO文件的形式存在于文件系统中,其可以用于任意两个进程之间的通信,其是以设备文件在文件系统中以文件名的形式存在,FIFO遵循先进先出,先进来的数据被先读取,当FIFO文件被创建后如果不删除,则会一直存在于文件系统中(在硬盘上)

命名管道的创建

int mkfifo(const char *pathname, mode_t mode);
//成功返回0,失败返回-1
/*
pathname 创建管道的路径名
mode 创建管道的模式 (O_RDWR O_RDONLY O_WRONLY 读写,只读 只写)
*/
int mknod(const char* path, mode_t mod, dev_t dev);
//dev通常为0

命名管道创建后,使用方法和匿名管道不同,其和文件一样每次使用前需要打开open(通过FIFO文件名),通过open返回的fd来读写文件,其实对FIFO的操作就和文件一样,需要open和close,当以读写打开FIFO时不会阻塞,当只写打开是阻塞,直到有进程以只读打开,当以只读打开时会阻塞到FIFO中有数据时。

FIFO即使关闭了,但是该文件还是存在的,除非手动删除。

匿名管道和FIFO

  1. 匿名管道只能用于父子进程、兄弟进程通信,但是FIFO可以用于任意两个进程通信
  2. FIFO实际以文件存在于文件系统,匿名管道实际是一个内核缓冲区

重定向dup函数

消息队列

消息队列即消息链表,消息队列存放在内核,每个消息队列由消息队列标识符所标识,消息队列使得一个进程可以向另外一个进程发送数据块,每个数据块都有一个类型,接收者接收的数据块可以有不同类型

  1. 消息队列是面向消息记录的,消息是具有特定格式,特定类型、优先级
  2. 消息队列独立于发送与接收进程,进程终止时,消息队列及其内容不会被删除
  3. 消息队列不一定是按照消息的先进先出次序读取,也可以按照消息的类型读取
  4. 消息队列对每个消息有一个大小限制(MSGMAX)

消息队列在内核中以链表形式存在,内核为消息队列维护一个struct ipc_perm结构体,其标识消息队列,当向消息队列发送一个消息,则将该消息构造成一个msg的结构对象,并挂在链表队列上

特点

  1. 声明周期随内核,消息队列会一直存在,除非显示调用命令、函数删除
  2. 消息队列可以双向通话
  3. 消息队列克服了管道无结构字节流的缺点,而是有格式,有结构的消息

https://blog.csdn.net/wei_cheng18/article/details/79661495

共享内存

​ 所谓共享内存即两个进程使用同一块物理内存,即两个进程不同的虚拟地址通过页表映射到同一块物理空间,它们所共同使用这块内存即为共享内存。共享内存的通信主要是一个进程可以向共享内存写入信息,而另一个进程可以通过简单读内存的形式来将数据读走,从而实现进程间的通信

进程在使用共享内存时需要搭配信号量一起使用,从而实现对共享内存的同步

共享内存是最快的IPC形式,不同于管道和消息队列等通信形式,其直接是对内存的操作来达到进程通信的目的,而不需要通过系统调用来实现

共享内存创建和删除的相关函数

  1. ftok //生成系统ipc唯一标识

  2. shmget //创建共享内存

  3. shmat //将共享内存和进程挂接

  4. shmdt //将共享内存和进程接触关联

  5. shmctl //销毁共享内存

int shmget(key_t key, size_t size, int shmflg);//创建共享内存
//key是通过ftok函数来生成
key_t key = ftok(PATHNAME,PROJ_ID);//这两个参数由用户提供
//shmflg创建时是IPC_CREAT|0666,如果想要获取已经创建的共享内存则参数为0

void* shmat(int shmid, NULL,int shmflg);//第三个参数是以可读/可读可写关联
shmdt(void* p);
shmctl(int shmid, IPC_RMID, NULL)

https://blog.csdn.net/ypt523/article/details/79958188

信号量

信号量是一个计数器,一般用来控制多个进程对共享资源的访问,信号量用于实现进程间的同步,而不是用于存储进程间通信数据。

特点:

  1. 信号量用于进程间同步与互斥,若在进程间传递数据,则需要结合共享内存
  2. 信号量基于操作系统的PV操作,程序对信号量的操作都是原子操作
  3. 每次对信号量的PV操作不仅限于对信号量加一或者减一,而且可以加减任意正整数

信号量用于控制进程对临界资源的访问,而不是进程之间的数据传递,主要是通过wait和signal两个操作,即PV操作,当wait时如果计数器不为0,则计数器减一,如果为0,则将该进程挂起;当signal操作时,如果当前有挂起的进程则唤醒,否则计数器加一

进程同步:进程间的一种制约关系,一般是指多个进程为完成某项任务而建立起来的某种顺序等制约关系

进程互斥:表示某个资源同时只能由一个进程使用,其他进程需要等待

信号

一个进程发出信号,另外一个进程捕获此信号并作出动作

常见的信号包括:

  1. SIGHUP:当控制台被关闭时,向拥有控制台sessionID的所有进程发送HUP信号
  2. SIGINT:终止进程 ctrl+c
  3. SIGQUIT:终止进程 ctrl+/
  4. SIGKILL :强制杀死进程
  5. SIGTERM :是kill默认的信号
  6. SIGTOP:暂停进程的执行
  7. SIGCONN:搭配上一个信号,暂停的进程继续运行
  8. SIGPIPE:该信号最好被忽略 signal(SIGPIPE,SIG_IGN)
  9. SIGCHLD:高性能服务器一般忽略
  10. SIGSEGV:一般在段错误时发生
  11. SIGALRM:时钟定时信号
socket

socket主要是用于不同主机上的进程之间的通信

发布了23 篇原创文章 · 获赞 4 · 访问量 2123

猜你喜欢

转载自blog.csdn.net/hdadiao/article/details/104614667