MIT 6.824 第 2 节 | 线程和 RPC
这节课讲线程和 RPC,与第一个实验 MapReduce 相关。在看这节课的录像前,我已经完成了实验,所以接受起来并没有很大的困难。
线程
在面试的时候,经常会被问到进程和线程的区别(我字节一面的时候便被问到了)。从历史的角度来看,当计算机刚出现的时候,并没有线程,只有进程。进程是调度的基本单元。
但进程的问题是:太重、无法利用多核,以及不能共享内存。当然,多进程是可以利用多核的,但是毕竟太重,而且共享内存是个问题。所以线程出现了,在一个进程中存在多个线程,每一个线程是程序执行的一个执行流,相对轻量(不用额外管理页表等内核结构),而且可以共享内存。
具体的细节,在后文展开吧。
为什么用 Go 语言
课程的实验全部使用 Go 语言,这也是我为什么会选择这门课程。之前尝试挑战过 Stanford CS144 的计算机网络,基于现代 C++。对于我这个 C with STL 的选手来说,并不是非常习惯。
而 Go 语言相对简单的多,而且,是云原生的标准语言,Docker、Kubernetes、etcd、Terraform 以及 Prometheus 等都用 Go 语言开发。从我个人的职业发展角度,学习这门语言也是很有帮助的。
上面是我个人的学习缘由,从课程的角度,教授认为有以下几点:
- 对线程和 RPC 有良好的支持。一个
go关键字就可以启动一个协程(用户态线程),不必额外安装 pthread 等框架;RPC 有标准库net/rpc支持; - 垃圾回收;
- 类型安全(Python 是弱类型);
- 相对简单。
之前面试米哈游的时候,遇到的一个问题是,你认为 Python 和 Go 的区别,当时回答的比较一般,现在如果让我总结的话:
- 编译 VS 解释;
- 语言层面支持并发 VS 额外使用
asyncio等框架; - 类型安全 VS 弱类型;
- 语法简单精炼 VS 语法糖多;
- 专精于云原生 VS 第三方库丰富。
什么是线程
所谓的线程,指的是程序的一个「执行流」,它的特点有:
- 允许我们同时做很多事情,而每个线程内部顺序执行;
- 线程之间可以共享内存;
- 线程内部有自己的轻量状态信息,如 PC、栈、寄存器等。
线程的好处
线程的最大好处就是能够表达并发:
- I/O 并发,如某个线程因为等待网络包挂起时,操作系统可以调度另外一个可前进的线程;
- 多核并行,如果计算机有多个 CPU 核,每个线程可以并行执行;
- 此外,线程可以很方便地让我们创建某些后台任务,直接定期
go一个就好。
同时开多个进程也能做到,但是,太贵:页表、无法共享内存等。
线程的平替
多线程的平替也是存在的,那就是异步编程。异步编程模型在 jyy 的操作系统课中专门有一节阐述,在初次听这门课的时候,我还对并发编程并不是很理解,听的有些云里雾里。现在更清晰了一些,但也不敢保证完全理解。

所谓的协程,或者 goroutine,是上述图片中所说的第一种方案,也就是轻量化线程。毕竟,线程还有 pid、栈以及操作系统中的各种结构,和函数调用相比,昂贵的多。
能不能让线程的启动和函数调用的开销差不多?
于是,协程出现了。我们可以让程序运行时在用户态调度各个协程,而不必使用中断驱动的上下文切换。为了让长时间等待的系统调用不会阻塞协程,需要修改系统调用,将同步读变成异步,这样可以让协程在阻塞时及时放弃 CPU。
到目前为止,协程的设计只能支持并发,而无法并行。Goroutine 利用 GMP 调度算法,将协程和操作系统的线程绑定,从而实现了真正的并行。
另一方面,Javascript 采取了不同的思路。因为,作为一门零门槛的语言,让大家并发编程是不可能的。它采用的思路是事件驱动的并发,禁止任何计算结点并行,也没有任何的阻塞 I/O。
取而代之的是,事件驱动模型维护每个活动(如每个客户端请求)的状态信息,每次循环检查每个活动是否有新输入,如果有,让对应活动前进一步并更新状态。有点离散的感觉。
那么,事件驱动模型可以提供 I/O 并行,因为某个活动阻塞时,其他活动可以前进。同时,可以减少线程调度的开销。当然,不足就是,无法利用多核,而且,写起来非常痛苦。
线程的挑战
线程带来了并发/并行的好处,同时也引入了诸多困难,不过好在我们都有解决方案。
我们连 1 + 1 = 2 都不会算了!
- 竟态条件(互斥)
- 避免共享
- 加锁
- 同步
- 通道或条件变量
- 死锁
jyy 的操作系统课中,详细展开了有关互斥和同步的解决方案。同济的 OS 课程,涉及到了,但是因为课程压缩的原因,只在课设中稍微展开了一点,不是必考题,更没有在理论课出现。所以我系的学生如果纯靠课内教学,或许对操作系统 3 easy pieces 的并发部分并不是很熟悉。
所谓互斥,指的是,不同线程想访问同一块区域(临界区,critical region),怎么办?加锁!或者避免共享。
所谓同步,指的是,不同的线程之间,需要通过一些信号或手段,协同运行。如,生产者-消费者问题:只有资源存在时,消费者才能消费;当资源池没满时,生产者才能生产。可以用信号量、条件变量,或者 Go 支持通道。
最后,死锁。四个产生条件:授权访问、持有并等待、不可剥夺和循环等待。需要小心。打破四个条件中的任何一个就可以消除死锁。
Go 的解决方案是:
- 通道;
- 锁 + 条件变量。
我之前一直无法区分,锁和条件变量的区别。现在想想,二者的目标完全不同。锁是为了保护关键数据,避免数据竞争;条件变量是为了线程之间的同步。所以,用锁来保护多个线程都要参考的条件变量,非常自然啊!以及,用锁 + 计数器的方式实现信号量,也很自然啊!
最后,分享一个 Go 使用条件变量的小技巧:
1 | |
思路是,在调用工作函数之前,套一个匿名函数,在这个匿名函数中处理条件变量,而不必把条件变量传入工作函数。
RPC
思想
RPC 叫远程过程调用,思路是,让访问远程服务中的代码像是本地的过程(函数)调用一样。
有人抬杠了,我调后端的 API,不也是像函数调用一样?就像在本地运行?
await axios.get(xxx)?
确实。远程过程调用和 HTTP 的思路一样,本质都是访问另一个进程中的代码。
我觉得,RPC 确实更像是函数调用,例如,Coordinator.Example 可以调用 Coordinator 结构体中的 Example 函数,只需要传入参数以及待接收返回内容的结构体即可。
这可真的是函数调用喔!我直接写了远程的函数名,并准备了参数喔!可没有什么 GET、POST 这类 HTTP 协议中的方法名喔!
如果仔细对比的话,HTTP 协议(1.1 版本)用的是文本格式传输,而且有大量 Header 字段;而 RPC 只传必要参数,而且底层是二进制编码,所以效率高一些。但如果是 HTTP 2 呢?好像二者又无法分高下了。具体可以看这篇八股文章。
语义
RPC 在错误发生下有三个语义:
- 至少一次,通过重传做到;
- 至多一次,需要避免重复,传入 ID;
- 正好一次,上述二者的结合,比较难做到。
