进程管理

进程、线程和协程

进程，线程，协程是什么

| 进程（Process） | 线程（Thread） | 协程（Goroutine）
定义 | 资源分配和拥有的基本单位 | 程序执行的基本单位 | 用户态的轻量级线程，线程内部调度的基本单位
切换情况 | 进程 CPU 环境 (栈、寄存器、页表和文件句柄等) 的保存以及新调度的进程 CPU 环境的设置 | 保存和设置程序计数器、少量寄存器和栈的内容 | 先将寄存器上下文和栈保存，等切换回来的时候再进行恢复
切换过程 | 用户态 -> 内核态 -> 用户态 | 用户态 -> 内核态 -> 用户态 | 用户态 (没有陷入内核）
拥有资源 | CPU 资源、内存资源、文件资源和句柄等 | 程序计数器、寄存器、栈和状态字 | 拥有自己的寄存器上下文和栈
并发性 | 不同进程之间切换实现并发，各自占有 CPU 实现并行 | 一个进程内部的多个线程并发执行 | 同一时间只能执行一个协程，而其他协程处于休眠状态，适合对任务进行分时处理
系统开销 | 切换虚拟地址空间，切换内核栈和硬件上下文，CPU 高速缓存失效、页表切换，开销很大 | 切换时只需保存和设置少量寄存器内容，因此开销很小 | 直接操作栈则基本没有内核切换的开销，可以不加锁的访问全局变量，所以上下文的切换非常快
通信方面 | 进程间通信需要借助操作系统 | 线程间可以直接读写进程数据段 (如全局变量) 来进行通信 |

进程，线程，协程的切换问题

进程切换： 进程切换是由操作系统负责管理和调度的。当操作系统决定切换到另一个进程时，它会保存当前进程的上下文（包括寄存器值、程序计数器等），并加载下一个进程的上下文。这个过程涉及切换内存映射、寄存器和其他资源，因此切换进程的成本较高。进程切换需要操作系统的介入，因此开销相对较大。

线程切换： 线程切换是在同一个进程内进行的，由操作系统负责调度。线程切换也需要保存和恢复寄存器值、程序计数器等上下文信息，但由于线程共享进程的地址空间和资源，因此切换成本相对较低。线程切换仍然需要操作系统的介入，但比进程切换的开销小很多。
性能开销：切换内核栈，切换硬件上下文，保存寄存器中的内容，将之前执行流程的状态保存，CPU 高速缓存失效
页表查找是一个很慢的过程，因此通常使用 Cache 来缓存常用的地址映射，这样可以加速页表查找，这个 cache 就是 TLB. 当进程切换后页表也要进行切换，页表切换后 TLB 就失效了，cache 失效导致命中率降低，那么虚拟地址转换为物理地址就会变慢，表现出来的就是程序运行会变慢。

协程切换： 协程切换是在应用程序级别上进行的，由协程库或程序员显式管理。协程切换不需要操作系统的介入，因此切换成本非常低。协程的切换通常只涉及保存和恢复少量的上下文信息，例如栈指针和局部变量等。由于协程的切换是协作式的，需要显式地将控制权交给其他协程，因此协程切换的开销可以更好地控制。
性能开销：
把当前协程的 CPU 寄存器状态保存起来，然后将需要切换进来的协程的 CPU 寄存器状态加载的 CPU 寄存器上就 ok 了。而且完全在用户态进行，一般来说一次协程上下文切换最多就是几十 ns 这个量级。

协程切换比线程切换快主要有两点：

协程切换完全在用户空间进行；线程切换涉及特权模式切换，需要在内核空间完成；

协程切换相比线程切换做的事情更少，线程需要有内核和用户态的切换，系统调用过程。

总体来说，进程切换的开销最高，线程切换次之，而协程切换的开销最低。协程由于在应用程序级别上管理，可以更灵活地控制切换时机，以提高效率和并发性能。然而，需要注意的是，在协程中合理管理切换点，避免过于频繁的切换，以免带来额外的开销。

一个进程最多可以创建多少个线程？

32 位系统，用户态的虚拟空间只有 3G，如果创建线程时分配的栈空间是 10M，那么一个进程最多只能创建 300 个左右的线程。
64 位系统，用户态的虚拟空间大到有 128T，理论上不会受虚拟内存大小的限制，而会受系统的参数或性能限制。

线程崩溃，进程崩溃

一般来说如果线程是因为非法访问内存引起的崩溃，那么进程肯定会崩溃，为什么系统要让进程崩溃呢，这主要是因为在进程中，各个线程的地址空间是共享的，既然是共享，那么某个线程对地址的非法访问就会导致内存的不确定性，进而可能会影响到其他线程，这种操作是危险的，操作系统会认为这很可能导致一系列严重的后果，于是干脆让整个进程崩溃。

进程是如何崩溃的呢，这背后的机制到底是怎样的，答案是信号 - kill (SIGSEGV)。

为什么线程崩溃不会导致 JVM 进程崩溃

因为 JVM 自定义了自己的信号处理函数，拦截了 SIGSEGV 信号，针对这两者不让它们崩溃。

通信方式有哪些？

进程间通信方式有哪些？

管道 / 匿名管道 (Pipes)：用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。

有名管道 (Named Pipes) : 匿名管道由于没有名字，只能用于亲缘关系的进程间通信。为了克服这个缺点，提出了有名管道。有名管道严格遵循 先进先出 (First In First Out) 。有名管道以磁盘文件的方式存在，可以实现本机任意两个进程通信。

信号 (Signal)：信号是一种比较复杂的通信方式，用于通知接收进程某个事件已经发生；

消息队列 (Message Queuing)：消息队列是消息的链表，具有特定的格式，存放在内存中并由消息队列标识符标识。管道和消息队列的通信数据都是先进先出的原则。与管道（无名管道：只存在于内存中的文件；命名管道：存在于实际的磁盘介质或者文件系统）不同的是消息队列存放在内核中，只有在内核重启 (即，操作系统重启) 或者显式地删除一个消息队列时，该消息队列才会被真正的删除。消息队列可以实现消息的随机查询，消息不一定要以先进先出的次序读取，也可以按消息的类型读取。比 FIFO 更有优势。消息队列克服了信号承载信息量少，管道只能承载无格式字 节流以及缓冲区大小受限等缺点。

信号量 (Semaphores)：信号量是一个计数器，用于多进程对共享数据的访问，信号量的意图在于进程间同步。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。

共享内存 (Shared memory)：使得多个进程可以访问同一块内存空间，不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作，如互斥锁和信号量等。可以说这是最有用的进程间通信方式。

套接字 (Sockets) : 此方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元，可以看做是不同主机之间的进程进行双向通信的端点，简单的说就是通信的两方的一种约定，用套接字中的相关函数来完成通信过程。

多线程间的通信？

多线程之间由于共享进程的资源，所以在多线程间通信更多关注如何处理资源使用中的冲突问题。

互斥与同步

在单核 CPU 系统里，为了实现多个程序同时运行的假象，操作系统通常以时间片调度的方式，让每个进程执行每次执行一个时间片，时间片用完了，就切换下一个进程运行，由于这个时间片的时间很短，于是就造成了「并发」的现象。多个线程如果竞争共享资源，如果不采取有效的措施，则会造成共享数据的混乱。

互斥

上面展示的情况称为竞争条件（race condition），当多线程相互竞争操作共享变量时，由于运气不好，即在执行过程中发生了上下文切换，我们得到了错误的结果，事实上，每次运行都可能得到不同的结果，因此输出的结果存在不确定性（indeterminate）。
由于多线程执行操作共享变量的这段代码可能会导致竞争状态，因此我们将此段代码称为临界区（critical section），它是访问共享资源的代码片段，一定不能给多线程同时执行。
我们希望这段代码是互斥（mutualexclusion的，也就说保证一个线程在临界区执行时，其他线程应该被阻止进入临界区，说白了，就是这段代码执行过程中，最多只能出现一个线程。

同步

互斥解决了并发进程 / 线程对临界区的使用问题。这种基于临界区控制的交互作用是比较简单的，只要一个进程 / 线程进入了临界区，其他试图想进入临界区的进程 / 线程都会被阻塞着，直到第一个进程 / 线程离开了临界区。
我们都知道在多线程里，每个线程并不一定是顺序执行的，它们基本是以各自独立的、不可预知的速度向前推进，但有时候我们又希望多个线程能密切合作，以实现一个共同的任务。
所谓同步，就是并发进程 / 线程在一些关键点上可能需要互相等待与互通消息，这种相互制约的等待与互通信息称为进程 / 线程同步。

互斥与同步的实现和使用

锁

使用加锁操作和解锁操作可以解决并发线程 / 进程的互斥问题。
任何想进入临界区的线程，必须先执行加锁操作。若加锁操作顺利通过，则线程可进入临界区；在完成对临界资源的访问后再执行解锁操作，以释放该临界资源。
根据锁的实现不同，可以分为「忙等待锁」和「无忙等待锁」。

忙等待锁

「忙等待锁」基于原子操作指令 —— 测试和置位（Test-and-Set）指令
当获取不到锁时，线程就会一直 while 循环，不做任何事情，所以就被称为「忙等待锁」，也被称为自旋锁（spin lock）。

无等待锁

当没获取到锁的时候，就把当前线程放入到锁的等待队列，然后执行调度程序，把 CPU 让给其他线程执行。

信号量

信号量是操作系统提供的一种协调共享资源访问的方法。

通常信号量表示资源的数量，对应的变量是一个整型（sem）变量。

另外，还有两个原子操作的系统调用函数来控制信号量的，分别是：

P 操作：将 sem 减 1，相减后，如果 sem < 0，则进程 / 线程进入阻塞等待，否则继续，表明 P 操作可能会阻塞；

V 操作：将 sem 加 1，相加后，如果 sem <= 0，唤醒一个等待中的进程 / 线程，表明 V 操作不会阻塞；

P 操作是用在进入临界区之前，V 操作是用在离开临界区之后，这两个操作是必须成对出现的。

如何使用信号量实现临界区的互斥访问

为每类共享资源设置一个信号量 s，其初值为 1，表示该临界资源未被占用。
只要把进入临界区的操作置于 P (s) 和 V (s) 之间，即可实现进程 / 线程互斥:

此时，任何想进入临界区的线程，必先在互斥信号量上执行 P 操作，在完成对临界资源的访问后再执行 V 操作。由于互斥信号量的初始值为 1，故在第一个线程执行 P 操作后 s 值变为 0，表示临界资源为空闲，可分配给该线程，使之进入临界区。

若此时又有第二个线程想进入临界区，也应先执行 P 操作，结果使 s 变为负值，这就意味着临界资源已被占用，因此，第二个线程被阻塞。

并且，直到第一个线程执行 V 操作，释放临界资源而恢复 s 值为 0 后，才唤醒第二个线程，使之进入临界区，待它完成临界资源的访问后，又执行 V 操作，使 s 恢复到初始值 1。

对于两个并发线程，互斥信号量的值仅取 1、0 和 -1 三个值，分别表示：

如果互斥信号量为 1，表示没有线程进入临界区；

如果互斥信号量为 0，表示有一个线程进入临界区；

如果互斥信号量为 -1，表示一个线程进入临界区，另一个线程等待进入。

通过互斥信号量的方式，就能保证临界区任何时刻只有一个线程在执行，就达到了互斥的效果。

如何使用信号量实现事件同步

同步的方式是设置一个信号量，其初值为 0, 以「吃饭 - 做饭」同步的例子：
妈妈一开始询问儿子要不要做饭时，执行的是 P (s1) ，相当于询问儿子需不需要吃饭，由于 s1 初始值为 0，此时 s1 变成 -1，表明儿子不需要吃饭，所以妈妈线程就进入等待状态。

当儿子肚子饿时，执行了 V (s1)，使得 s1 信号量从 -1 变成 0，表明此时儿子需要吃饭了，于是就唤醒了阻塞中的妈妈线程，妈妈线程就开始做饭。

接着，儿子线程执行了 P (s2)，相当于询问妈妈饭做完了吗，由于 s2 初始值是 0，则此时 s2 变成 -1，说明妈妈还没做完饭，儿子线程就等待状态。

最后，妈妈终于做完饭了，于是执行 V (s2)，s2 信号量从 -1 变回了 0，于是就唤醒等待中的儿子线程，唤醒后，儿子线程就可以进行吃饭了。

经典同步问题

生产者 - 消费者问题

生产者 - 消费者问题描述：

生产者在生成数据后，放在一个缓冲区中；

消费者从缓冲区取出数据处理；

任何时刻，只能有一个生产者或消费者可以访问缓冲区；

我们对问题分析可以得出：

任何时刻只能有一个线程操作缓冲区，说明操作缓冲区是临界代码，需要互斥；

缓冲区空时，消费者必须等待生产者生成数据；缓冲区满时，生产者必须等待消费者取出数据。说明生产者和消费者需要同步。

那么我们需要三个信号量，分别是：

互斥信号量 mutex：用于互斥访问缓冲区，初始化值为 1；

资源信号量 fullBuffers：用于消费者询问缓冲区是否有数据，有数据则读取数据，初始化值为 0（表明缓冲区一开始为空）；

资源信号量 emptyBuffers：用于生产者询问缓冲区是否有空位，有空位则生成数据，初始化值为 n （缓冲区大小）；

哲学加就餐问题

先来看看哲学家就餐的问题描述：

5 个老大哥哲学家，闲着没事做，围绕着一张圆桌吃面；

巧就巧在，这个桌子只有 5 支叉子，每两个哲学家之间放一支叉子；

哲学家围在一起先思考，思考中途饿了就会想进餐；

奇葩的是，这些哲学家要两支叉子才愿意吃面，也就是需要拿到左右两边的叉子才进餐；

吃完后，会把两支叉子放回原处，继续思考；

用一个数组 state 来记录每一位哲学家的三个状态，分别是在进餐状态、思考状态、饥饿状态（正在试图拿叉子）。

那么，一个哲学家只有在两个邻居都没有进餐时，才可以进入进餐状态。

第 i 个哲学家的左邻右舍，则由宏 LEFT 和 RIGHT 定义：

LEFT : ( i + 5 - 1 ) % 5

RIGHT : ( i + 1 ) % 5

比如 i 为 2，则 LEFT 为 1，RIGHT 为 3。

读者 - 写者问题

读者 - 写者的问题描述：

「读 - 读」允许：同一时刻，允许多个读者同时读

「读 - 写」互斥：没有写者时读者才能读，没有读者时写者才能写

「写 - 写」互斥：没有其他写者时，写者才能写

公平策略：

优先级相同；

写者、读者互斥访问；

只能一个写者访问临界区；

可以有多个读者同时访问临界资源；

死锁

产生死锁的条件

死锁只有同时满足以下四个条件才会发生：

互斥条件；

持有并等待条件；

不可剥夺条件；

环路等待条件；

如何排查死锁

Java - jstack
C++ - Valgrind
Python - 第三方库 deadlock-detector 和 deadlocks
Go - go tool trace

如何避免死锁

使用资源有序分配法，来破环环路等待条件。

那什么是资源有序分配法呢？

线程 A 和 线程 B 获取资源的顺序要一样，当线程 A 是先尝试获取资源 A，然后尝试获取资源 B 的时候，线程 B 同样也是先尝试获取资源 A，然后尝试获取资源 B。也就是说，线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。

我们使用资源有序分配法的方式来修改前面发生死锁的代码，我们可以不改动线程 A 的代码。

我们先要清楚线程 A 获取资源的顺序，它是先获取互斥锁 A，然后获取互斥锁 B。

所以我们只需将线程 B 改成以相同顺序的获取资源，就可以打破死锁了。

乐观锁，悲观锁

悲观锁认为多线程同时修改共享资源的概率比较高，于是很容易出现冲突，所以访问共享资源前，先要上锁。

乐观锁假定冲突的概率很低，它的工作方式是：先修改完共享资源，再验证这段时间内有没有发生冲突，如果没有其他线程在修改资源，那么操作完成，如果发现有其他线程已经修改过这个资源，就放弃本次操作。
场景例子：在线文档：
实现多人同时编辑，实际上是用了乐观锁，它允许多个用户打开同一个文档进行编辑，编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突？这里举个例子，比如用户 A 先在浏览器编辑文档，之后用户 B 在浏览器也打开了相同的文档进行编辑，但是用户 B 比用户 A 提交早，这一过程用户 A 是不知道的，当 A 提交修改完的内容时，那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢？通常方案如下：

由于发生冲突的概率比较低，所以先让用户编辑文档，但是浏览器在下载文档时会记录下服务端返回的文档版本号；
当用户提交修改时，发给服务端的请求会带上原始文档版本号，服务器收到后将它与当前版本号进行比较，如果版本号不一致则提交失败，如果版本号一致则修改成功，然后服务端版本号更新到最新的版本号。
实际上，我们常见的 SVN 和 Git 也是用了乐观锁的思想，先让用户编辑代码，然后提交的时候，通过版本号来判断是否产生了冲突，发生了冲突的地方，需要我们自己修改后，再重新提交。

乐观锁虽然去除了加锁解锁的操作，但是一旦发生冲突，重试的成本非常高，所以只有在冲突概率非常低，且加锁成本非常高的场景时，才考虑使用乐观锁。