Lec2: Threads + RPC
为什么使用Go语言?
- 根本原因就在于它拥有强大的并发特性,并且对RPC(Remote Procedure Call)的支持,这两点也是分布式编程当中重要的两个特性。
- Go拥有GC(Garbage Collection),这使得内存管理变得简单,并且可以自动的管理内存,而不用手动的去释放内存。因为对于这种多线程的编程,手动的去释放内存是一件非常tricky的事情。
- 它是类型安全的,这使得代码更加健壮,并且可以避免很多bug。
- 它是compiled language,这使得程序的运行速度更快,不需要在运行时出现更大的开销。
Goroutines
当我们执行go run
的时候,Go会创建一个执行进程,即主进程,它拥有创建新进程的原语(原话是it has primitives to create new threads)。而且每个进程都有独立的PC、Stack、Register
原语
- start/go: 启动 / 运行 一个线程,并返回一个goroutine对象。
- exit: 线程退出,一般从某个函数退出/结束执行后,会自动隐式退出。
- stop: 停止一个线程,比如向一个没有读者的channel写数据,那么channel阻塞,go可能会运行时暂时停止这个线程。
- resume: 恢复原本停止的线程重新执行,需要恢复程序计数器(program counter)、栈指针(stack pointer)、寄存器(register)状态,让处理器继续运行该线程。
Go应对多线程的机制
首先,我们先了解一下多线程会遇到的挑战。首当其冲的就是race condition(资源的竞态),也就是两个线程在同一时间对同一资源进行操作;二是coordination(同步协调),一个线程依赖另一个线程会造成阻塞;三是deadlock(死锁),两个线程互相等待对方释放资源,导致无限等待。
- Channels: 通道,适用于非共享资源(no-sharing)的同步,如果线程间不需要共享资源(如内存、变量等)。
- Locks + Condition Variables: 锁和条件变量,适用于共享资源的情况,这个在本门课程当中讲的会比较少。提到这个大家要有印象啊,Semaphore就是用锁和条件变量(Mutex + CV)实现的。
栈和堆
一个Goroutine对应一个调用栈,栈的内存由编译器自动进行分配和释放,并与函数拥有着相同的生命周期(随创建而被分配,随退出而销毁)。
Go编译器会尽可能将变量分配到到栈(Stack)上,而不是堆(Heap)中。但是当局部变量占用内存特别大或者编译器无法证明函数返回的变量是否被引用时,编译器就会在堆上分配内存。
为什么栈的优先级更高呢?首先栈是每个Goroutine独有的,不需要加锁,因此有着并发上的效率优势;其次栈的分配与释放效率很高,只需要两个CPU指令操作寄存器就可以完成,比如RISC-V中我们就加减SP就能完成栈的分配和释放;最后还有缓存性能的考量,因为栈的内存空间其实是更为连续的,因此缓存命中率更高。
逃逸分析
逃逸(escape)分析是Go编译器的一个优化技术,它通过分析代码来判断一个变量是否会逃逸到堆上,否则在栈上分配内存。Go优先会将内存分配到栈上,逃逸分析就是专门来判断或确认Go的内存分配区域。
- Go的逃逸分析在编译期完成,编译期无法确定参数类型,会逃逸到堆上。
- 如果变量在函数外部存在引用,则会逃逸到堆上。
- 如果变量占用内存较大,则会逃逸到堆上。
- 变量大小不确定,则优先放到栈上。
- 如果变量在函数外没有引用,则优先放到栈上。
如果结构体较大,传递结构体指针更合适,因为指针类型相比值类型能节省大量的内存空间
如果结构体较小,传递结构体更适合,因为在栈上分配内存,可以有效减少GC压力
Channel
channel是Go中用于协调并发的主要机制。它是一个先进先出(FIFO)的队列,可以用于不同goroutine之间的通信。
channel的声明与创建
channel的创建分为两步,第一步是声明channel,第二步是初始化channel。
声明时不对channel进行初始化,只是声明channel的类型,直到初始化时才会分配内存。
创建channel的语法 make(chan 元素类型, [缓冲大小])
。
- 缓冲区如果不指定,则默认为0,则其为无缓冲区channel。它的特点是同步的、阻塞的,只有发送方和接收方都准备好了,才可以进行通信。当发送方发送数据的时候,接收方必须在另一头进行等待,直到数据完全发出且被接收,否则这个数据会被丢弃。
- 缓冲区大小如果指定,则为有缓冲区channel。它的特点是异步的,可以缓存一定数量的数据。当发送方发送数据的时候,数据会先被缓存到缓冲区,直到接收方准备好接收,才会从缓冲区中取出数据进行处理。
channel的操作
- send: 向channel中发送数据,语法
ch <- 数据
。 - recv: 从channel中接收数据,语法
数据 = <- ch
。其中我们也可以不指定将接收到的数据赋值给变量,直接丢弃,<- ch
。 - close: 关闭channel,语法
close(ch)
。虽然出于Go的GC机制,可以不显式进行close操作。但如果你的管道不往里存值或者取值的时候一定记得关闭管道,这样可以使得接收方不再等待对方的消息。但是关闭已经关闭的channel会引发panic。
单向channel
我们在使用管道时,可能在某个函数中,我们只需要发送数据,或者只需要接收数据,这时候我们可以使用单向channel。
在函数传参及任何赋值操作中将双向通道转换为单向通道是可以的,但反过来是不可以的。
chan<- int
是一个只能发送的通道,可以发送但是不能接收。<-chan int
是一个只能接收的通道,可以接收但是不能发送。
RPC
远程过程调用(Remote Procedure Call,RPC)是分布式系统中常用的技术。它允许在不同的机器上运行的进程之间进行通信,而不需要了解底层网络协议。
我们在使用 RPC 时,就像在调用一个黑盒的本地程序,只需要知道它的接口,不需要知道它的实现,这个远程的着重点就在于这个不可感知性。也就是说,RPC 实际上是在本地机器上,通过调用远程机器上的某个函数来实现的。而这个过程不仅隐藏了远程机器的存在性,还隐藏了其中网络传输的过程。
那么从实现上来说就有几大难点。首当其冲的便是网络传输,涉及多台机器的传输,网络肯定是无法避免的。其次是数据的一致性,因为 RPC 调用的结果可能是异步的,因此需要考虑数据的一致性。最后便是无感知性,我们需要让本地机器像调用本地函数一样,调用远程函数。