1. 进程和线程
1.1 进程
当我们运行可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个行中的程序,就被称为进程。
进程的基本状态
在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。
- 运行状态(Running):该时刻进程占用 CPU;
- 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
- 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;

进程控制块PCB
进程标识符,用户标识符。进程当前状态,进程优先级。
虚拟内存空间、文件描述符、信号资源。CPU 中各个寄存器的值。
进程的上下文切换
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
保存处理机上下文,包括程序计数器和其他寄存器。
更新PCB信息。
把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
选择另一个进程执行,并更新其PCB。
更新内存管理的数据结构。
恢复处理机上下文。

触发进程切换的时机
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
进程阻塞
正在执行的进程由于一些事情发生,如请求资源失败、等待某种操作完成、新数据尚未达到或者没有新工作做等,由系统自动执行阻塞原语,使进程状态变为阻塞状态。
因此,进程阻塞是进程自身的一种主动行为,只有处于运行中的进程才可以将自身转化为阻塞状态。当进程被阻塞,它是不占用CPU资源的。
1.2 线程
同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

线程的优点
- 一个进程中可以同时存在多个线程;
- 各个线程之间可以并发执行;
- 各个线程之间可以共享地址空间和文件等资源;
线程的缺点
- 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程奔溃不会造成进程崩溃)
线程的上下文切换
当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;
所以,线程的上下文切换相比进程,开销要小很多。
1.3 线程进程区别
进程是资源分配的最小单位,线程是CPU调度的最小单位;
- 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
- 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
1.4 有了进程为什么还需要线程
进程切换是一个开销很大的操作,线程切换的成本较低。
线程更轻量,一个进程可以创建多个线程。
多个线程可以并发处理不同的任务,更有效地利用了多处理器和多核计算机。而进程只能在一个时间干一件事,如果在执行过程中
遇到阻塞问题比如 IO 阻塞就会挂起直到结果返回。
同一进程内的线程共享内存和文件,因此它们之间相互通信无须调用内核。
1.5 进程、线程、协程的概念
- 进程(Process):进程是操作系统中的一个执行实例,它拥有独立的内存空间和资源。每个进程都是独立运行的,拥有自己的地址空间、文件句柄、环境变量等。进程间通信需要通过特定的机制,如管道、消息队列、共享内存等。
- 线程(Thread):线程是进程的一部分,是在同一进程内并发执行的执行单元。不同线程共享同一进程的内存空间和资源,包括全局变量、堆、文件描述符等。线程可以更轻量级地创建、切换和销毁,相对于进程而言,线程间的切换开销较小。线程之间可以通过共享内存等机制进行通信。
- 协程(Coroutine):协程是一种用户级的轻量级线程。协程由用户控制,而不是由操作系统内核控制。在协程中,执行流可以在不同协程之间进行切换,切换不需要内核介入。协程可以在一个线程内实现并发,但无法利用多核心处理器。协程通常用于实现高效的异步编程和协作任务。
2. 进程间通信

每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
进程间通信主要包括管道、系统IPC(包括消息队列、信号量、信号、共享内存等)、以及套接字socket。
2.1 fork + pipe 管道
1 | int pipe(int fd[2]) |
这里表示创建一个匿名管道,并返回了两个描述符,一个是管道的读取端描述符 fd[0]
,另一个是管道的写入端描述符 fd[1]
。注意,这个匿名管道是特殊的文件,只存在于内存,不存于文件系统中。
这两个描述符都是在一个进程里面,并没有起到进程间通信的作用,怎么样才能使得管道是跨过两个进程的呢?
我们可以使用 fork
创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0]
与 fd[1]
」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了。
- 父进程关闭读取的 fd[0],只保留写入的 fd[1];
- 子进程关闭写入的 fd[1],只保留读取的 fd[0];
所以说如果需要双向通信,则应该创建两个管道。
在 shell 里面执行 A | B
命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell。

不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取。
2.2 消息队列
A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。
消息队列是保存在内核中的消息链表。消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。
消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程。
2.3 共享内存
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

2.4 信号量
信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。
信号初始化为 1,就代表着是互斥信号量,它可以保证共享内存在任何时刻只有一个进程在访问,这就很好的保护了共享内存。
例如,进程 A 是负责生产数据,而进程 B 是负责读取数据,这两个进程是相互合作、相互依赖的,进程 A 必须先生产了数据,进程 B 才能读取到数据,所以执行是有前后顺序的。
那么这时候,就可以用信号量来实现多进程同步的方式。

2.5 信号
信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。
- 执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。
- 捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
- 忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程。
2.6 Socket
要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。
- 针对 TCP 协议通信的 socket 编程模型

- 针对 UDP 协议通信的 socket 编程模型

3. 孤儿和僵尸进程
当一个进程完成它的工作终止之后,它的父进程需要调用wait()或者waitpid()系统调用取得子进程的终止状态。
3.1 孤儿进程
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
孤儿进程是没有父进程的进程,孤儿进程这个重任就落到了init进程身上,init进程就好像是一个民政局,专门负责处理孤儿进程的善后工作。孤儿进程并不会有什么危害。
3.2 僵尸进程
一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。
任何进程退出都会留下一个称为僵尸进程(Zombie)的数据结构,等待父进程处理。父进程没有来得及处理,这时用ps命令就能看到子进程的状态是“Z”。
原因
父进程只管生成新的子进程,至于子进程 退出之后的事情,则一概不闻不问,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。【子不教父之过】
解决
- 进程通过 wait 和 waitpid 等函数等待子进程结束
但这会导致父进程挂起,所以这并不是一个好办法,父进程如果不能和子进程并发执行的话,那我们创建子进程的意义就没有。同时一个 wait 只能解决一个子进程,如果有多个子进程就要用到多个 wait
- 通过信号机制
子进程退出时,向父进程发送 SIGCHILD 信号,父进程处理 SIGCHILD 信号,在信号处理函数中调用 wait 进行处理僵尸进程。
- fork两次
原理是将进程成为孤儿进程,从而其的父进程变为 init 进程,通过 init 进程处理僵尸进程。
父进程一次 fork() 后产生一个子进程随后立即执行 wait(NULL) 来等待子进程结束。
然后子进程 fork() 后产生孙子进程,子进程 exit(0),顺利终止,然后父进程继续执行。
这时的孙子进程由于失去了它的父进程(即是父进程的子进程),将被转交给Init进程托管。
于是父进程与孙子进程无继承关系了,它们的父进程均为Init,Init进程在其子进程结束时会自动收尸,这样也就不会产生僵死进程了。
kill 父进程
严格地来说,僵死进程并不是问题的根源,罪魁祸首是产生出大量僵死进程的那个父进程。因此,当我们寻求如何消灭系统中大量的僵死进程时,答案就是把产生大量僵死进程的那个元凶枪毙掉(也就是通过 kill 发送 SIGTERM 或者 SIGKILL 信号啦)。
枪毙了元凶进程之后,它产生的僵死进程就变成了孤儿进 程,这些孤儿进程会被 init 进程接管,init 进程会 wait() 这些孤儿进程,释放它们占用的系统进程表中的资源,这样,这些已经僵死的孤儿进程就能瞑目而去了。
4. 头脑风暴
- 运行可执行程序后,会产生进程。进程有 PCB(描述符,状态,CPU 寄存器,程序计数器),用来上下文切换。
- 线程在进程里,共享代码段,数据段,打开的文件。有独立的寄存器和栈,切换效率高。
- 进程通信,管道(fork 原理),内核消息队列,共享内存,信号,Socket。
- 产生僵尸进程原因没 wait,解决:wait,子进程给父进程发信号,连续 fork 两次变孤儿,kill 父进程。