MIT 6.824学习心得(2) 浅谈多线程和RPC
上篇文章中我们简单介绍了分布式系统的设计思想以及简单性质,之后用一定篇幅简要介绍了MapReduce这个经典的分布式计算框架的大致工作原理,相信朋友们已经对此有了最基本的理解。在现实场景中,分布式系统的设计初衷是为了解决并发问题,能够承受单机系统所不能承受的流量负担,并充分利用计算机集群的硬件资源。同时,既然会利用到计算机集群,那么集群间通信也是一个不可忽略的讨论关键吧。由此,本文会着重讨论分布式系统中的并发问题以及通信问题。在介绍本文内容之前,需要提示朋友们:如果想要更好的理解本文中提到的知识点,需要有一定的操作系统和网络相关的知识,否则可能会略显吃力。
说到设计分布式系统,那么相信一定有朋友们会联想到golang这门编程语言。golang是由Google公司与2007年开始设计,2009年正式发布开源的一门主要适配后端开发的编程语言。golang语言的设计初衷是在保证高性能的同时,提高程序员的开发效率,适合构建高并发、高可用的后端系统。相比于C/C++,golang的语法更加简洁清晰,删去了部分冗余特性,同时支持垃圾回收(GC)和手动内存优化,相比C/C++又不失太多性能。最重要的是golang有相对比较完善的内置并发机制。这也是golang语言在现代应用广泛的主要原因,常应用于云原生与微服务架构的项目开发,构建高并发的web服务,DevOps工具开发,网络爬虫,区块链,构建日志系统,数据处理等多个领域。由于篇幅有限,本文不再赘述go语言的基础语法。
一.golang与并发编程
(一)并发与并行
相信学过操作系统相关知识的朋友一定对进程和线程这两个名词不会陌生。这里如果让我去介绍进程线程,我可能真的想不起来那些长篇大论晦涩难懂的八股,背那些东西也挺没意思的,除了应付考试也没有什么用。我个人对于进程和线程的理解是:无论是进程还是线程,本质上都是程序运行的载体。进程拥有独立的内存地址空间,而线程则是进程中最小的执行单元。多个线程可共享同一个进程的内存地址空间,一个进程可以包含很多个线程。
假如我们需要并发处理一个任务,那就有多进程和多线程两种处理策略。可以把多进程策略联想为让不同的公司处理这个问题,每家公司都有独立的办公区域(内存空间),彼此互不干扰。可以把多线程策略联想为让同一家公司内部的不同员工处理这个问题,它们共享办公区域(内存空间),配合效率更高,但是也需要进行协调避免冲突。
处理一个大型任务通常有两个最基本的优化思路。首先是并发I/O。我们可以通过上面提到的两种策略实现并发处理,而golang语言则内置了一种更加轻量级的用户态线程-goroutine(协程)来解决并发问题,允许不同的goroutine各自处理任务,我们后面会进行介绍。另外我们可以利用多核并行。现在的CPU大多数都拥有多个核心,我们完全可以利用多个CPU核心同时处理不同任务来提升任务的处理效率。在实际开发中,服务端开发者通常会结合使用上面这两种优化思路,在使用多进/线程提高并发量的同时,通过尽可能使用多核并行充分利用CPU中大量核心所产生的性能。
这里可能会有一些不了解操作系统基本知识的朋友会疑惑,什么是并发,什么又是并行?这确实是初学者非常容易混淆的两个概念。并行是指多个任务在多个 CPU 核心上真正同时运行,彼此互不干扰。而并发则是多个任务在单个或多个核心上“交替执行”。操作系统通过时间片轮转的方式快速在任务之间切换,虽然任意时刻每个核心只执行一个任务,但由于切换足够快,在用户看来就像是这些任务同时进行一样。并发并不意味着真正的同时运行,而是一种在资源有限条件下的高效调度策略。总结一句话,并行关注的是“同时做多件事”,而并发关注的是“如何管理好多件事”。
除此之外,还有另外一种处理并发的高效方法,即异步编程,也称事件驱动编程。它允许任务在等待某些操作完成期间,不阻塞当前线程,而是挂起当前操作、继续执行其他任务。其核心机制是单线程+事件循环。在 系统层面,异步编程往往依赖于 I/O 多路复用机制 来实现非阻塞的 I/O。而且开销会比多进/线程小,规避了创建销毁进/线程,以及上下文切换的成本。不过缺点是无法充分利用CPU的多核并行能力,比较适合I/O密集型任务。所以在选择策略时,我们应重点关注当前任务属于计算密集型任务,还是I/O密集型任务。
(二)go如何支持并发
在前文的叙述中,我们有提到,golang内置了一种更加轻量级的线程-goroutine来解决并发问题。事实上,goroutine也只是对传统操作系统线程的一种“用户态封装”,由 Go 运行时通过 GMP 模型来进行调度和管理,而并非通过操作系统内核直接调度。关于GMP模型我们会单独出文章来介绍,这是很多企业面试的常考题,我就有两三次都被问到过。与传统线程相比,goroutine 创建和切换的成本非常低,占用资源极少,初始栈空间只有几 KB(传统操作系统线程大约是MB级别),可以轻松支持成千上万个并发任务。开发者只需使用go关键字即可启动一个新的并发任务,无需手动管理线程、锁或上下文切换。总的来说,goroutine 就是 Go 并发能力的核心。除此之外,go内置了一系列非常使用的并发原语,这些原语我们会在后面穿插介绍。
处理并发问题主要有以下几大挑战。首先是如何处理共享数据。如果在一块内存区域中存在一个共享的数据对象,多个线程在同时读写共享数据,线程的执行顺序不确定,会造成数据的不一致,也就是会导致一系列并发安全相关的问题。同时,多线程也会引入资源竞争的问题。针对上述问题一个非常行之有效的方法便是引入同步机制,其中最常见的便是加互斥锁。其次,golang的一大设计哲学是“并非通过共享内存实现通信,而是通过通信实现共享内存”。由此,作为开发者,goroutine之间的交互通信需要引起格外重视。go内置了线程安全的channel数据结构来实现gouroutine之间的通信,同时内置Waitgroup原语来协调多个goroutine的执行。最后,Go语言也内置了检测锁竞争或者死锁的工具,在编译时加上-race标志,可以检测数据竞争和一些潜在的锁问题,这里需要注意-race并不是一种静态检测机制,即源码层面的检查,而是检查当前程序的运行状态。如果上面介绍的这些专有名词没有理解,没关系,下面会给出具体的实例来帮助大家理解。
(三)多线程编程实例:简单网络爬虫
相信朋友们应该对“爬虫”这个貌似挺火的词汇不陌生。网络爬虫是一种自动访问网页并提取内容的程序。该程序从网页URL开始,下载网页内容并提取网页中的链接,并不断重复以上过程,在数据的采集,分析,监控等方面有显著作用。我们往往不想重复抓取一个页面,这对于网络带宽是很大的浪费,所以我们会采用布隆过滤器进行去重操作。在本文中将介绍两种最常见的爬虫程序的实现思路。
第一种思路是串行爬虫,我们在网络路径图中通过有效执行深度优先搜索(DFS),逐步访问页面,每抓取一个URL就启动一个goroutine,维护一个map记录已经爬取过的页面实现去重,避免重复抓取。思路很简单,示例代码如下:
func Serial(url string,fetcher Fetcher,fetched map[string]bool){//去重逻辑if fetched[url]{return}fetched[url]=trueurls,err := fetcher.Fetch(url)if err != nil{return}//深度优先搜索逻辑,递归访问页面for _, u :=range urls{Serial(u,fetcher,fetched)}return
}
第二种思路是并行爬虫。并行爬虫有两种实现方式。第一种是通过共享数据对象以及加锁实现。在实现中我们使用了 sync.Mutex
来保护共享的 map[string]bool
,用于记录已经抓取过的 URL。这里可能需要解释一下为什么要在for循环中使用闭包函数并传入u,因为 range 循环中的变量 u是被复用的,而 闭包默认捕获的是变量的引用地址,而不是值本身。这意味着当 goroutine 实际启动执行时,外层循环可能已经更新了 u
的值,导致捕获到的并不是我们期望的 URL。为了解决这个问题,我们在闭包函数中将 u
显式作为参数传入,确保每个 goroutine 拿到的都是对应循环当时的 u
值,从而保证抓取逻辑的正确性。而这段代码中也使用到了WaitGroup原语,可以把它看成一个计数器:每启动一个goroutine,执行Add(1),让计数器+1。当这个goroutine完成任务时,执行Done(),让计数器-1。主协程通过调用 Wait()
进入阻塞状态,直到所有 goroutine 执行完毕、计数器归零为止。在实际爬虫程序的设计中,我们往往还需要利用协程池来控制并发goroutine数量,防止资源耗尽。
说起闭包函数,我会想到一个非常有意思的问题。如果一个闭包函数引用了其外围函数中的局部变量,而此时外围函数已经 return,那么这个变量会发生什么?起初我会担心:既然外围函数已经返回,里面定义的局部变量理应随着栈帧销毁,那闭包函数所引用的变量是否会“悬空”?是否会导致运行时错误?答案是不会,Go 的编译器在处理闭包时,会自动识别这种捕获了外部局部变量的情况,并进行“逃逸分析”。当编译器发现某个局部变量被闭包引用,并且闭包的生命周期可能超过当前函数时,它会将这个变量从栈上分配改为在堆上分配,以确保该变量的生命周期能撑到闭包函数结束。这样,无论外围函数何时 return,闭包中捕获的变量依然有效,直到最后一个引用它的函数也执行完毕,才会被垃圾回收(GC)清理掉。示例代码如下:
type fetchState struct{mu sync.Mutexfetched map[string]bool
}func ConcurrentMutex(url string,fetcher Fetcher,f *fetchState){f.mu.lock()already :=f.fetched[url]f.fetched[url]=truef.mu.Unlock()if already{return}urls,err := fetcher.Fetcher(url)if err !=nil{return}var wg sync.WaitGroupfor _,u :=range urls{wg.Add(1)//go闭包捕获引用,每个range重用了ugo func(u string){defer wg.Done()ConcurrentMutex(u,fetcher,f)}(u)}wg.Wait()return
}
第二种是通过channel实现协程通信来实现,它遵循 Go 的设计哲学“不要通过共享内存来通信,而应该通过通信来共享内存”的理念。我们无需使用锁,从而避免了显式的并发控制复杂性。主线程master 维护抓取状态,但不共享对象。master中维护了一个map,与基于 mutex
的实现不同,worker 之间并不共享这个 map
,而是由 master 单线程统一维护抓取状态,这样天然避免了并发冲突。master 与 worker 通过 channel 通信,所有的 URL 抓取任务都通过一个 chan []string
来传递,每一个 worker
负责抓取一个页面并将获取到的新 URL 列表通过 channel 发送回 master,由 master 决定是否继续抓取。在master中使用一个变量 n
来记录当前正在运行的 worker
数量,每创建一个新的 worker
,n++;
每处理完一轮从 channel 中读到的 URL 集合后,n--;
当 n=0
时,说明所有任务已完成,master 主动退出循环。在调用 master
之前,ConcurrentChannel
会先将种子 URL写入 channel,我们把这个过程称为冷启动机制。在这个过程中,因为 Go 的 channel 默认是无缓冲的,写入操作是阻塞的,因此这一步必须放在 goroutine 中,防止阻塞主协程。每个 worker 会在一个goroutine中执行,通过 channel 接收 URL,抓取内容,并将抓取到的新 URL 发回 channel。这些worker之间完全独立,并不共享任何状态或对象,主从职责清晰。由于Go 的 channel 内部实现中使用了 mutex,因此它天然就是线程安全的,可以安全地在多个 goroutine 之间传递数据,而无需加锁。示例代码如下:
func worker(url string,ch chan []string,fetcher Fetcher){//实际的抓取逻辑urls,err:=fetcher.Fetch(url)//向channel中发送信息if err!=nil{ch<-[]string{}}else{ch<-urls}
}func master(ch chan []string,fetcher Fetcher){n:=1fetched:=make(map[string]bool)//从channel中获取一个URLfor urls:=range ch{//再获取这URL列表中的URLfor_,u:=range urls{//如果这个URL未被抓取,则启动一个新的worker线程去抓这个URLif fetched[u]==false{fetched[u]=truen+=1go worker(u,ch,fetcher)}}n-=1//爬虫完成了所有工作,已抓取完每一个URLif n==0{break}}
}func ConcurrentChannel(url string,fetcher Fetcher){ch:=make(chan []string)//将URL种子写入channelgo func(){ch<-[]string{url}}()master(ch,fetcher)
}
二.服务通信-RPC
(一)既生HTTP,何生RPC?
在分布式系统中,各模块会部署在不同服务器节点上,此时不同节点之间的通信成为开发者必须考虑的问题。在网络通信实践中,相信朋友们一定对HTTP这个最常见的web应用层协议再熟悉不过了吧。作为标题党,可能会有懂行的朋友立刻指出:HTTP和RPC根本就不能这么对比,前者是协议,后者是设计思想。是这样没错,不过在本文中我还非要取这么个标题,没关系,咱们接着往下看~
HTTP全称超文本传输协议。常用于万维网服务器与本地浏览器之间的数据传输,是一个基于TCP的应用层协议。虽然HTTP是web世界的通用协议,但是在追求高性能,低延迟,强类型的分布式系统中,传统的HTTP+JSON的通信方式已经不能满足需求了。首先我要说清楚,这里说的HTTP指的是传统的HTTP/1.1+JSON/REST API模式。虽然也可以用这种方式进行服务调用,不过由于JSON是纯文本格式,体积太大,解析慢且占用带宽;且HTTP/1.1是单请求单连接的形式,无多路复用机制,大量并发请求易造成连接瓶颈和资源浪费,所以不适合服务间的高效通信。而且JSON是弱类型协议,前后端接口如果变动容易出问题,且RESTful API 只是一个风格,没有强制的规范和工具链,文档靠手写Swagger,代码全靠人维护,服务变动时容易出现客户端和服务端使用两套接口的问题。HTTP只支持客户端请求,服务端响应的单向模式,但是在分布式系统中,可能会需要客户端流,服务端流,双向流等多种通信方式。总结来讲,HTTP/1.1+JSON/REST API的设计模式只能说对浏览器友好,但是不等同于对服务友好。RPC并不是要替代HTTP,而是专为服务间高效通信而设计的一种更专业的方案。
其实RPC设计思想的起源,甚至早于HTTP协议,最早可以追溯到上世纪70年代。RPC,即远程过程调用,其实是一种通信思想,既可以基于TCP,UDP等传输层协议,也可以基于HTTP等应用层协议,有很高的可定制性,比如说我们后面要介绍到的gRPC就是以HTTP/2作为底层传输协议实现的。RPC并不是协议,但是像Thrift,gRPC这种具体实现才算得上是协议的范畴。在微服务时代,RPC 提供了更强的性能、更高的类型安全、更好的自动化与服务治理能力,是服务间调用的专业工具。
我们拿RPC的一种经典实现方式gRPC,在服务通信的场景下,与传统的HTTP/1.1进行性能对比。首先gRPC支持双向流,服务端流等通信方式,支持多路复用,即在同一个TCP连接上互不干扰地并发处理多个请求,gRPC压缩请求头的大小,提高传输效率。其次,在序列化层面,gRPC使用的Protobuf(二进制序列化协议)相比纯文本的JSON要更加轻量,具有更快的序列化和反序列化的速度,由此节省更多的网络带宽,不容忽视的的是Protobuf支持强类型结构,并自动生成多语言代码,极大提升了开发效率与可靠性。而且,gRPC支持在服务端与客户端添加拦截器来实现鉴权token校验,限流熔断,追踪埋点等服务治理的手段,提升了系统的可用性。总结一句话,对人用,选 HTTP/REST,对服务用,选 RPC/gRPC。而实际上,gRPC 与 REST 并不冲突,二者可共存。
(二)gRPC与最佳实践
gRPC是由Google公司研发的一款开源的,高性能的远程过程调用(RPC)框架。实际上,在我的理解来看gRPC既可以理解为框架,也可以理解为协议,所以大可不必为此感到困扰。它使用Protobuf作为序列化格式,同时基于HTTP/2设计,支持多种开发语言。在 gRPC 中,客户端应用程序可以直接调用另一台机器上的服务器应用程序的方法,就像调用本地对象一样。在服务器端,服务器实现此接口并运行 gRPC 服务器来处理客户端调用。在客户端,客户端有一个stub(存根)它提供与服务器相同的方法,gRPC 客户端和服务器可以在各种环境中运行并相互通信。这里我把gRPC的官方文档贴出来,朋友们可以参考学习:gRPC官方文档
与许多 RPC 系统一样,gRPC 基于定义服务的思想,指定可以远程调用的方法及其参数和返回类型。默认情况下,gRPC 使用Protobuf作为接口定义语言 (IDL),用于描述服务接口和有效消息的结构。这里我节选出我自己项目中某个微服务的一段接口定义和消息结构定义(.proto文件)作为示范:
// 用户服务接口定义
service UserService {// 用户注册:输入注册信息,返回注册结果rpc RegisterUser (RegisterRequest) returns (RegisterResponse);// 用户登录:输入用户名密码,返回登录 tokenrpc LoginUser (LoginRequest) returns (LoginResponse);
}// 消息结构定义
// 注册请求
message RegisterRequest {string username = 1;//用户名string password = 2;//密码string email = 3;//邮箱string phone = 4;//电话号码
}// 注册响应
message RegisterResponse {bool success=1;//是否成功string user_id = 2;//注册成功后分配的用户IDstring message = 3;//提示信息
}// 登录请求
message LoginRequest {string username = 1;//用户名string password = 2;//密码
}// 登录响应(返回JWT)
message LoginResponse {string token = 1;// 返回JWT token,用于鉴权string user_id = 2;//用户ID
}
一旦写好了.proto文件,gRPC原生提供Protobuf编译器,我们可以执行相对应的命令,或者编写脚本,自动生成客户端和服务端代码。一般来讲客户端用来调用这些定义好的API,而服务端则实现这些API。这些部分便是一个后端项目真正意义上的业务代码了,所以我暂时不进行展示。
在真正的项目开发过程中,开发者们总结出了一套较为系统的 gRPC 最佳实践方法。从接口的设计规范、编译脚本的自动化、到中间件的扩展能力、安全认证、负载均衡再到多语言协同开发,gRPC 已经从最初的高性能通信框架演变为一个现代微服务体系的通信骨干工具。在实际开发过程中,我们往往会引入拦截器提升服务治理能力,使用中间件进行错误处理以及重试。我们还经常使用Etcd/Consul等集群管理工具实现服务注册与自动发现,在请求量大时启用连接池充分复用节省资源等等。无论是构建小型微服务系统,还是支撑复杂的大规模分布式架构,gRPC 都是一个值得考虑的方案。它不只是“比 HTTP 快”,更是更现代、更自动化、更易维护的服务通信解决方案。
以上就是我对并发编程和RPC的粗浅理解,如有不当恳请批评指正,我们一起成长!