0%

linux进程线程原语

1. 进程和线程

1.1 进程

我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个行中的程序,就被称为「进程」(Process)。

进程的基本状态

在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

  • 运行状态(Running):该时刻进程占用 CPU;
  • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
进程的三种基本状态

进程控制块PCB

PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。

  1. 进程描述信息:

    • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
    • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;
  1. 进程控制和管理信息:

    • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;

    • 进程优先级:进程抢占 CPU 时的优先级;

  1. 资源分配清单:
    • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。
  1. CPU 相关信息:
    • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行。

进程的上下文切换

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。

CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

  1. 保存处理机上下文,包括程序计数器和其他寄存器。

  2. 更新PCB信息。

  3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。

  4. 选择另一个进程执行,并更新其PCB。

  5. 更新内存管理的数据结构。

  6. 恢复处理机上下文。

进程上下文切换

触发进程切换的时机

  • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程
  • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时
  • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;

进程阻塞

正在执行的进程由于一些事情发生,如请求资源失败、等待某种操作完成、新数据尚未达到或者没有新工作做等,由系统自动执行阻塞原语,使进程状态变为阻塞状态。

因此,进程阻塞是进程自身的一种主动行为,只有处于运行中的进程才可以将自身转化为阻塞状态。当进程被阻塞,它是不占用CPU资源的。

1.2 线程

同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

多线程

线程的优点

  • 一个进程中可以同时存在多个线程;
  • 各个线程之间可以并发执行;
  • 各个线程之间可以共享地址空间和文件等资源;

线程的缺点

  • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃)

线程的上下文切换

这还得看线程是不是属于同一个进程:

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;

  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;

所以,线程的上下文切换相比进程,开销要小很多。

1.3 线程进程区别

进程是资源分配的最小单位,线程是CPU调度的最小单位;【重要的一句话】

  1. 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
  2. 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
  3. 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
  4. 线程能减少并发执行的时间和空间开销;

2. 进程间通信

img

每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。

进程间通信主要包括管道、系统IPC(包括消息队列、信号量、信号、共享内存等)、以及套接字socket。

2.1 fork + pipe 管道

1
int pipe(int fd[2])

这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。

img

这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?

我们可以使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。

img
  • 父进程关闭读取的 fd[0],只保留写入的 fd[1];
  • 子进程关闭写入的 fd[1],只保留读取的 fd[0];
img

所以说如果需要双向通信,则应该创建两个管道。

在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。

img

不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取。

2.2 消息队列

A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

消息队列是保存在内核中的消息链表。消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。

消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。

2.3 共享内存

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

img

2.4 信号量

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。

信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。

例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。

那么这时候,就可以用信号量来实现多进程同步的方式。

img

2.5 信号

上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

信号跟信号量虽然名字相似度 66.66%,但两者用途完全不一样,就好像 Java 和 JavaScript 的区别。

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

  1. 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。
  2. 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
  3. 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。

2.6 Socket

要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。

  • 针对 TCP 协议通信的 socket 编程模型
img
  • 针对 UDP 协议通信的 socket 编程模型
img

3. 孤儿和僵尸进程

当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。

3.1 孤儿进程

一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。孤儿进程并不会有什么危害。

3.2 僵尸进程

一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

任何一个子进程(init除外)在exit()之后,并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。如果子进程在exit()之后,父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。如果父进程能及时处理,可能用ps命令就来不及看到子进程的僵尸状态,但这并不等于子进程不经过僵尸状态。

原因

例如有个进程,它定期的产生一个子进程,这个子进程需要做的事情很少,做完它该做的事情之后就退出了,因此这个子进程的生命周期很短,但是,父进程只管生成新的子进程,至于子进程 退出之后的事情,则一概不闻不问,这样,系统运行上一段时间之后,系统中就会存在很多的僵死进程,倘若用ps命令查看的话,就会看到很多状态为Z的进程。

严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。【子不教父之过】

解决

  1. 进程通过 wait 和 waitpid 等函数等待子进程结束

但这会导致父进程挂起,所以这并不是一个好办法,父进程如果不能和子进程并发执行的话,那我们创建子进程的意义就没有。同时一个 wait 只能解决一个子进程,如果有多个子进程就要用到多个 wait

  1. 通过信号机制

子进程退出时,向父进程发送 SIGCHILD 信号,父进程处理 SIGCHILD 信号,在信号处理函数中调用 wait 进行处理僵尸进程。

  1. fork两次

原理是将进程成为孤儿进程,从而其的父进程变为 init 进程,通过 init 进程处理僵尸进程。

  1. 父进程一次 fork() 后产生一个子进程随后立即执行 wait(NULL) 来等待子进程结束。
  2. 然后子进程 fork() 后产生孙子进程,子进程 exit(0),顺利终止,然后父进程继续执行。
  3. 这时的孙子进程由于失去了它的父进程(即是父进程的子进程),将被转交给Init进程托管。
  4. 于是父进程与孙子进程无继承关系了,它们的父进程均为Init,Init进程在其子进程结束时会自动收尸,这样也就不会产生僵死进程了。
  1. kill 父进程

严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大量僵死进程的那个元凶枪毙掉(也就是通过 kill 发送 SIGTERM 或者 SIGKILL 信号啦)。枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被 init 进程接管,init 进程会 wait() 这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程就能瞑目而去了。

4. 头脑风暴

  • 运行可执行程序后,会产生进程。进程有 PCB(描述符,状态,CPU 寄存器,程序计数器),用来上下文切换。
  • 线程在进程里,共享代码段,数据段,打开的文件。有独立的寄存器和栈,切换效率高。
  • 进程通信,管道(fork 原理),内核消息队列,共享内存,信号,Socket。
  • 产生僵尸进程原因没 wait,解决:wait,子进程给父进程发信号,连续 fork 两次变孤儿,kill 父进程。

5. 参考资料

给作者打赏,可以加首页微信,咨询作者相关问题!