这节课讲线程和 RPC,与第一个实验 MapReduce 相关。在看这节课的录像前,我已经完成了实验,所以接受起来并没有很大的困难。

线程

在面试的时候,经常会被问到进程和线程的区别(我字节一面的时候便被问到了)。从历史的角度来看,当计算机刚出现的时候,并没有线程,只有进程。进程是调度的基本单元。

但进程的问题是:太重、无法利用多核,以及不能共享内存。当然,多进程是可以利用多核的,但是毕竟太重,而且共享内存是个问题。所以线程出现了,在一个进程中存在多个线程,每一个线程是程序执行的一个执行流,相对轻量(不用额外管理页表等内核结构),而且可以共享内存。

具体的细节,在后文展开吧。

为什么用 Go 语言

课程的实验全部使用 Go 语言,这也是我为什么会选择这门课程。之前尝试挑战过 Stanford CS144 的计算机网络,基于现代 C++。对于我这个 C with STL 的选手来说,并不是非常习惯。

而 Go 语言相对简单的多,而且,是云原生的标准语言,DockerKubernetesetcdTerraform 以及 Prometheus 等都用 Go 语言开发。从我个人的职业发展角度,学习这门语言也是很有帮助的。


上面是我个人的学习缘由,从课程的角度,教授认为有以下几点:

  • 对线程和 RPC 有良好的支持。一个 go 关键字就可以启动一个协程(用户态线程),不必额外安装 pthread 等框架;RPC 有标准库 net/rpc 支持;
  • 垃圾回收;
  • 类型安全(Python 是弱类型);
  • 相对简单。

之前面试米哈游的时候,遇到的一个问题是,你认为 Python 和 Go 的区别,当时回答的比较一般,现在如果让我总结的话:

  1. 编译 VS 解释;
  2. 语言层面支持并发 VS 额外使用 asyncio 等框架;
  3. 类型安全 VS 弱类型;
  4. 语法简单精炼 VS 语法糖多;
  5. 专精于云原生 VS 第三方库丰富。

什么是线程

所谓的线程,指的是程序的一个「执行流」,它的特点有:

  • 允许我们同时做很多事情,而每个线程内部顺序执行;
  • 线程之间可以共享内存;
  • 线程内部有自己的轻量状态信息,如 PC、栈、寄存器等。

线程的好处

线程的最大好处就是能够表达并发

  • I/O 并发,如某个线程因为等待网络包挂起时,操作系统可以调度另外一个可前进的线程;
  • 多核并行,如果计算机有多个 CPU 核,每个线程可以并行执行;
  • 此外,线程可以很方便地让我们创建某些后台任务,直接定期 go 一个就好。

同时开多个进程也能做到,但是,太贵:页表、无法共享内存等。

线程的平替

多线程的平替也是存在的,那就是异步编程。异步编程模型在 jyy 的操作系统课中专门有一节阐述,在初次听这门课的时候,我还对并发编程并不是很理解,听的有些云里雾里。现在更清晰了一些,但也不敢保证完全理解。

异步编程模型,引自 jyywiki.cn

所谓的协程,或者 goroutine,是上述图片中所说的第一种方案,也就是轻量化线程。毕竟,线程还有 pid、栈以及操作系统中的各种结构,和函数调用相比,昂贵的多。

能不能让线程的启动和函数调用的开销差不多?

于是,协程出现了。我们可以让程序运行时在用户态调度各个协程,而不必使用中断驱动的上下文切换。为了让长时间等待的系统调用不会阻塞协程,需要修改系统调用,将同步读变成异步,这样可以让协程在阻塞时及时放弃 CPU。

到目前为止,协程的设计只能支持并发,而无法并行。Goroutine 利用 GMP 调度算法,将协程和操作系统的线程绑定,从而实现了真正的并行。


另一方面,Javascript 采取了不同的思路。因为,作为一门零门槛的语言,让大家并发编程是不可能的。它采用的思路是事件驱动的并发,禁止任何计算结点并行,也没有任何的阻塞 I/O。

取而代之的是,事件驱动模型维护每个活动(如每个客户端请求)的状态信息,每次循环检查每个活动是否有新输入,如果有,让对应活动前进一步并更新状态。有点离散的感觉。

那么,事件驱动模型可以提供 I/O 并行,因为某个活动阻塞时,其他活动可以前进。同时,可以减少线程调度的开销。当然,不足就是,无法利用多核,而且,写起来非常痛苦。

线程的挑战

线程带来了并发/并行的好处,同时也引入了诸多困难,不过好在我们都有解决方案。

我们连 1 + 1 = 2 都不会算了!

  • 竟态条件(互斥)
    • 避免共享
    • 加锁
  • 同步
    • 通道或条件变量
  • 死锁

jyy 的操作系统课中,详细展开了有关互斥和同步的解决方案。同济的 OS 课程,涉及到了,但是因为课程压缩的原因,只在课设中稍微展开了一点,不是必考题,更没有在理论课出现。所以我系的学生如果纯靠课内教学,或许对操作系统 3 easy pieces 的并发部分并不是很熟悉。

所谓互斥,指的是,不同线程想访问同一块区域(临界区,critical region),怎么办?加锁!或者避免共享。

所谓同步,指的是,不同的线程之间,需要通过一些信号或手段,协同运行。如,生产者-消费者问题:只有资源存在时,消费者才能消费;当资源池没满时,生产者才能生产。可以用信号量、条件变量,或者 Go 支持通道。

最后,死锁。四个产生条件:授权访问、持有并等待、不可剥夺和循环等待。需要小心。打破四个条件中的任何一个就可以消除死锁。

Go 的解决方案是:

  • 通道;
  • 锁 + 条件变量。

我之前一直无法区分,锁和条件变量的区别。现在想想,二者的目标完全不同。锁是为了保护关键数据,避免数据竞争;条件变量是为了线程之间的同步。所以,用锁来保护多个线程都要参考的条件变量,非常自然啊!以及,用锁 + 计数器的方式实现信号量,也很自然啊!

最后,分享一个 Go 使用条件变量的小技巧:

1
2
3
4
5
6
7
8
9
10
11
func main {
var done wg.SyncGroup

for _, u := range urls {
done.Add(1)
go func(u string) {
defer done.Done()
WorkerFunction()
}
}
}

思路是,在调用工作函数之前,套一个匿名函数,在这个匿名函数中处理条件变量,而不必把条件变量传入工作函数。

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;
  • 正好一次,上述二者的结合,比较难做到。