四、进程
26. 程序和进程对比
-
程序,是指编译好的二进制文件,在磁盘上,不占用系统资源 (CPU、内存、打开的文件、设备、锁…)
- 程序 → 剧本 (纸)
-
进程是一个可执行程序的实例(可执行文件被运行),占用系统资源,在内存中执行 (程序运行起来,产生一个进程)
- 进程 → 戏 (舞台、演员、灯光、道具…)
- 进程是一个动态过程,而非静态文件,它是程序的一次运行过程
- 每一个进程都有一个进程号(PID,正数),用于唯一标识系统中的某一个进程
- 通过系统调用 getpid() 来获取本进程的进程号,使用 getppid() 系统调用获取父进程的进程号
同一个剧本(程序)可以在多个舞台(进程)同时上演。同样,同一个程序也可以加载为不同的进程 (彼此之间互不影响)。如:同时开两个终端,各自都有一个 bash 但彼此 ID 不同
27. 进程的内存布局
- Linux/x86-32 体系中进程内存布局
- 文本段
- 也称代码段,这是 CPU 执行的机器语言指令部分,文本段具有只读属性,以防程序由于意外而修改其指令,文本段是可以共享的,即使在多个进程间也可同时运行同一段程序
- 初始化数据段
- 包含了显式初始化的全局变量和静态变量
- 未初始化数据段(bss)
- 包含了未进行显式初始化的全局变量和静态变量
- 栈
- 函数内的局部变量以及每次函数调用时所需保存的信息都放在此段中,每次调用函数时,函数传递的实参以及函数返回值等也都存放在栈中
- 栈是一个动态增长和收缩的段,由栈帧组成,系统会为每个当前调用的函数分配一个栈帧,栈帧中存储了函数的局部变量、实参和返回值
- 堆
- 可在运行时动态进行内存分配的一块区域,如:使用 malloc() 分配的内存空间,就是从系统堆内存中申请分配的
- 可在运行时动态进行内存分配的一块区域,如:使用 malloc() 分配的内存空间,就是从系统堆内存中申请分配的
- 文本段
28. 进程的虚拟地址空间
- Linux 系统中采用了虚拟内存管理技术,应用程序运行在一个虚拟地址空间中
- 虚拟地址会通过硬件 MMU(内存管理单元)映射到实际的物理地址空间中,建立虚拟地址到物理地址的映射关系后,对虚拟地址的读写操作实际上就是对物理地址的读写操作,MMU 会将虚拟地址 “翻译” 为对应的物理地址
- 为什么需要引入虚拟地址?
- 计算机物理内存的大小是固定的,如果操作系统没有虚拟地址机制,所有应用程序访问的内存地址就是实际的物理地址,要将所有应用程序加载到内存中,但是实际的物理内存只有 4G,所以就会出现一些问题
- 内存使用效率低
- 进程地址空间不隔离
- 无法确定程序的链接地址
- 针对上述问题引入了虚拟地址机制。程序访问存储器所使用的逻辑地址就是虚拟地址,通过逻辑地址映射到真正的物理内存上,所有应用程序运行在自己的虚拟地址空间中,使得进程的虚拟地址空间和物理地址空间隔离开来,这样做带来了很多的优点
- 进程与进程、进程与内核相互隔离,提高了系统的安全性与稳定性
- 便于实现内存保护机制
- 编译应用程序时,无需关心链接地址
- 计算机物理内存的大小是固定的,如果操作系统没有虚拟地址机制,所有应用程序访问的内存地址就是实际的物理地址,要将所有应用程序加载到内存中,但是实际的物理内存只有 4G,所以就会出现一些问题
29. fork() 创建子进程
- 一个现有的进程可以调用系统调用 fork() 函数创建一个新的进程
- 调用 fork() 函数的进程称为父进程
- 由 fork() 函数创建出来的进程被称为子进程
- 创建子进程的作用
- 创建多个进程是任务分解时行之有效的方法
- 提高系统的并发性(即同时能够处理更多的任务或请求,多个进程在宏观上实现同时运行)
- 如何区分父、子进程?
- fork() 成功调用后将存在两个进程,一个是原进程(父进程),另一个则是创建出来的子进程,并且每个进程都会从 fork() 函数的返回处继续执行,会导致调用 fork() 返回两次值,子进程返回一个值,父进程返回一个值
- fork() 调用成功后,将会在父进程中返回子进程的 PID,而在子进程中返回值是 0
- 如果调用失败,父进程返回值 -1,不创建子进程,并设置 errno
- 调用 fork() 后,父、子进程中一般只有一个通过 exit() 退出进程,而另一个则应使用 _exit() 退出
- fork() 成功调用后将存在两个进程,一个是原进程(父进程),另一个则是创建出来的子进程,并且每个进程都会从 fork() 函数的返回处继续执行,会导致调用 fork() 返回两次值,子进程返回一个值,父进程返回一个值
- fork() 调用成功后,子进程和父进程会继续执行 fork() 调用之后的指令
- 子进程、父进程各自在自己的进程空间中运行,事实上,子进程是父进程的一个副本
- 如:子进程拷贝了父进程的数据段、堆、栈以及继承了父进程打开的文件描述符,父进程与子进程并不共享这些存储空间,这是子进程对父进程相应部分存储空间的完全复制
- 两个进程执行相同的代码段,因为代码段是只读的,也就是说父、子进程共享代码段,在内存中只存在一份代码段数据
- fork() 之后的竞争条件
- 调用 fork 之后,无法确定父、子两个进程谁将率先访问 CPU,也就是说无法确认谁先被系统调用运行(在多核处理器中,它们可能会同时各自访问一个 CPU),这将导致谁先运行、谁后运行这个顺序是不确定的
- 可以采用信号来实现,如果要让子进程先运行,则可使父进程被阻塞,等到子进程来唤醒它
30. 什么是系统调度
- Linux 是一个多任务、多进程、多线程的操作系统,系统启动后会运行成百上千个不同的进程,对于单核 CPU 计算机来说,在某一个时间它只能运行某一个进程的代码指令,那其它进程怎么办呢(多核处理器也是如此,同一时间每个核它只能运行某一个进程的代码)?
- 这里就出现了调度的问题:每一个进程(或线程)执行一段固定的时间,时间到了之后切换执行下一个进程(或线程),依次轮流执行,这就称为调度
- 系统调度的基本单元是线程
31. wait() 和 waitpid() 系统调用
-
系统调用 wait() 可以等待进程的任一子进程终止,同时获取子进程的终止状态信息
- 调用 wait() 函数,如果其所有子进程都还在运行,则 wait() 会一直阻塞等待,直到某一个子进程终止
- 调用 wait() 函数,但是该进程并没有子进程,也就意味着该进程并没有需要等待的子进程,那么 wait() 将返回错误,也就是返回 -1、并且会将 errno 设置为 ECHILD
- 如果进程调用 wait() 之前,它的子进程当中已经有一个或多个子进程已经终止了,那么调用 wait() 也不会阻塞,而是立刻回收子进程资源,然后返回到正常的程序流程中,一次 wait() 调用只能处理一次
-
使用 wait() 系统调用存在着一些限制,系统调用 waitpid() 函数可以突破下述限制
- 如果父进程创建了多个子进程,使用 wait() 将无法等待某个特定的子进程的完成,只能按照顺序等待下一个子进程的终止,一个一个来、谁先终止就先处理谁
- 如果子进程没有终止而是正在运行,那么 wait() 总是保持阻塞,有时希望执行非阻塞等待,是否有子进程终止,通过判断即可得知
- 使用 wait() 只能发现那些被终止的子进程,对于子进程因某个信号(如 SIGSTOP 信号)而停止,或是已停止的子进程收到 SIGCONT 信号后恢复执行的情况就无能为力了
#include <sys/types.h> #include <sys/wait.h> // status:用于存放子进程终止时的状态信息,可以为 NULL,表示不接收子进程终止时的状态信息 // 返回值:若成功则返回终止的子进程对应的进程号;失败则返回-1 pid_t wait(int *status); // pid:用于表示需要等待的某个具体子进程 /* pid > 0,表示等待进程号为 pid 的子进程 pid = 0,则等待与调用进程(父进程)同一个进程组的所有子进程 pid < -1,则会等待进程组标识符与 pid 绝对值相等的所有子进程 pid = -1,则等待任意子进程,此时 wait(&status) 与 waitpid(-1, &status, 0) 等价 */ // status:与 wait() 函数的 status 参数意义相同 pid_t waitpid(pid_t pid, int *status, int options);
32. 僵尸进程与孤儿进程
-
孤儿进程:父进程先于子进程结束,此时子进程变成了一个 “孤儿”
- Linux 中所有孤儿进程都自动成为 init 进程(进程号为 1)的子进程,换言之:某一子进程的父进程结束后,该子进程调用 getppid() 将返回 1,init 进程变成了孤儿进程的 “养父”,这是判定某一子进程的 “生父” 是否还 “在世” 的方法之一
如何避免孤儿进程?
- 父进程在创建子进程后,可以选择等待子进程执行完毕,或者让子进程成为守护进程(daemon),使其脱离父进程的控制
- 可以使用进程组和会话管理来确保进程的正常结束,避免孤儿进程的产生
-
僵尸进程:进程结束之后,通常需要其父进程为其 “收尸”,回收子进程占用的一些内存资源,父进程通过调用 wait()(或其变体 waitpid()、waitid() 等)函数回收子进程资源,归还给系统,如果子进程先于父进程结束,此时父进程还没来得及给子进程 “收尸”,那么此时子进程就变成了一个僵尸进程
- 当父进程调用 wait() 为子进程 “收尸” 后,僵尸进程就会被内核彻底删除。另外一种情况,如果父进程并没有调用 wait() 函数然后就退出了,那么此时 init 进程将会接管它的子进程并自动调用 wait(),从系统中移除僵尸进程
- 僵尸进程无法通过信号将其杀死,只能杀死僵尸进程的父进程(或等待其父进程终止),这样 init 进程将会接管这些僵尸进程,从而将它们从系统中清理掉
如何避免僵尸进程?
- 父进程应该在 fork 子进程后调用 wait() 或 waitpid() 来回收子进程资源
- 父进程可以设置 SIGCHLD 信号处理函数,在子进程终止时收到通知并进行处理
33. 进程的 6 种不同状态
- 1. 就绪态
- 指该进程满足被 CPU 调度的所有条件但此时并没有被调度执行,只要得到 CPU 就能够直接运行;意味着该进程已准备好被 CPU 执行,当一个进程的时间片到达,操作系统会从就绪态链表中调度一个进程
- 2. 运行态
- 指该进程当前正在被 CPU 调度运行,处于就绪态的进程得到 CPU 调度就会进入运行态
- 3. 僵尸态
- 僵尸态进程其实指的就是僵尸进程,指该进程已经结束,但其父进程还未给它 “收尸”
- 4. 可中断睡眠状态(浅度睡眠)
- 可中断睡眠也称为浅度睡眠,表示睡的不够 “死”,还可以被唤醒,一般来说可以通过信号来唤醒
- 5. 不可中断睡眠状态(深度睡眠)
- 不可中断睡眠称为深度睡眠,深度睡眠无法被信号唤醒,只能等待相应的条件成立才能结束睡眠状态。把浅度睡眠和深度睡眠统称为等待态(或者叫阻塞态),表示进程处于一种等待状态,等待某种条件成立之后便会进入到就绪态,处于等待态的进程无法参与进程系统调度
- 6. 暂停态
- 暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP 信号;处于暂停态的进程是可以恢复进入到就绪态的,如收到 SIGCONT 信号
- 暂停并不是进程的终止,表示进程暂停运行,一般可通过信号将进程暂停,譬如 SIGSTOP 信号;处于暂停态的进程是可以恢复进入到就绪态的,如收到 SIGCONT 信号
34. 进程组和会话
- 进程组:每个进程除了有一个进程 ID、父进程 ID 外,还有一个进程组 ID,用于标识该进程属于哪一个进程组,进程组是一个或多个进程的集合,这些进程并不是孤立的,它们彼此之间或者存在父子、兄弟关系,或者在功能上有联系
- 每个进程必定属于某一个进程组,且只能属于一个进程组
- 每一个进程组有一个组长进程,组长进程的 ID 就等于进程组 ID,组长进程不能再创建新的进程组
- 只要进程组中还存在一个进程,则该进程组就存在,这与其组长进程是否终止无关
- 默认情况下,新创建的进程会继承父进程的进程组 ID
- 会话:会话是一个或多个进程组的集合
- 一个会话可包含一个或多个进程组,但只能有一个前台进程组,其它的是后台进程组,每个会话都有一个会话首领(leader),即创建会话的进程
35. 守护进程
- 守护进程(Daemon)也称为精灵进程,是运行在后台的一种特殊进程,它独立于控制终端并且周期性地执行某种任务或等待处理某些事情的发生,系统中守护进程一般都是服务器进程,主要表现为以下两个特点
- 长期运行
- 守护进程是一种生存期很长的一种进程,它们一般在系统启动时开始运行,除非强行终止,否则直到系统关机都会保持运行
- 与守护进程相比,普通进程都是在用户登录或运行程序时创建,在运行结束或用户注销时终止,但守护进程不受用户登录注销的影响,它们将会一直运行着、直到系统关机
- 与控制终端脱离
- 在 Linux 中,系统与用户交互的界面称为终端,每一个从终端开始运行的进程都会依附于这个终端,也就是会话的控制终端,当控制终端被关闭的时候,该会话就会退出,由控制终端运行的所有进程都会被终止,这使得普通进程都是和运行该进程的终端相绑定的
- 但守护进程能突破这种限制,它脱离终端并且在后台运行,脱离终端的目的是为了避免进程在运行过程中的信息在终端显示,并且进程也不会被任何终端产生的信息所打断
- 长期运行
五、进程间通信
36. 什么是进程间通信
-
Linux 环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核
- 在内核中开辟一块缓冲区,进程 1 把数据从用户空间拷到内核缓冲区,进程 2 再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)
- 在内核中开辟一块缓冲区,进程 1 把数据从用户空间拷到内核缓冲区,进程 2 再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)
-
经典的 IPC
- 管道(使用最简单,用于有血缘关系进程间)
- FIFO(也称为命名管道,可用于无血缘关系进程间)
- 信号(开销最小)
- 共享存储映射(用于非血缘关系进程间)
- socket 本地套接字(最稳定)
-
进程间通信的机制有哪些?
- UNIX IPC:管道、FIFO、信号
- POSIX IPC:信号量、消息队列、共享内存
- Socket IPC:基于 Socket 进程间通信
37. 进程间通信的常见方式
37.1 管道和 FIFO
- 管道是 UNIX 系统上最古老的 IPC 方法,把一个进程连接到另一个进程的数据流称为管道,管道被抽象成一个文件
- 管道包括三种
- 普通管道 pipe
- 数据只能单向传输(单工)
- 只能在父、子或者兄弟进程间使用
- 流管道 s_pipe
- 数据可以双向传输(半双工,发送和接收不能同时进行)
- 只能在父子或兄弟进程间使用
- 命名管道 name_pipe(FIFO)
- 数据可以双向传输(半双工,发送和接收不能同时进行)
- 允许在不相关(不是父子或兄弟关系)的进程间进行通讯
- 普通管道 pipe
37.2 信号
- 信号用于通知接收信号的进程有某种事件发生,所以可用于进程间通信,除了用于进程间通信之外,进程还可以发送信号给进程本身
- 信号是一种异步通信机制,用于通知进程发生了某些事件。例如,当用户按下 Ctrl+C 时,终端会向进程发送一个中断信号(SIGINT)
37.3 消息队列
- 消息队列是消息的链表,存放在内核中并由消息队列标识符标识
- 消息队列克服了信号传递信息少、管道只能承载无格式字节流以及缓冲区大小受限等缺陷
- 消息队列可以实现进程之间的异步通信,并且可以在进程之间传递任意类型的数据
- 消息队列是 UNIX 下不同进程之间实现共享资源的一种机制,UNIX 允许不同进程将格式化的数据流以消息队列形式发送给任意进程,有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息
37.4 信号量
- 信号量是一种用于进程同步的计数器,它们通常用于控制多个进程对共享资源的访问,以避免竞争条件和死锁
- 它主要用于控制多个进程间或一个进程内的多个线程间对共享资源的访问,相当于内存中的标志,进程可以根据它判定是否能够访问某些共享资源,同时,进程也可以修改该标志,除了用于共享资源的访问控制外,还可用于进程同步
- 它常作为一种锁机制,防止某进程在访问资源时其它进程也访问该资源,主要作为进程间及同一个进程内不同线程间的同步手段。Linux 提供了一组精心设计的信号量接口来对信号量进行操作,它们声明在头文件 sys/sem.h 中
37.5 共享内存
- 共享内存就是映射一段能被其它进程所访问的内存,这段共享内存由一个进程创建,但其它的多个进程都可以访问,使得多个进程可以访问同一块内存空间,这样它们就可以直接交换数据,而无需通过内核
- 共享内存是最快的 IPC 方式,它是针对其它进程间通信方式运行效率低而专门设计的,它往往结合其它通信机制如信号量来使用,以实现进程间的同步和通信
37.6 套接字(Socket)
- Socket 是一种 IPC 方法,是基于网络的 IPC 方法,允许位于同一主机(计算机)或使用网络连接起来的不同主机上的应用程序之间交换数据,也就是网络通信