我们今天开始讲Go的第三课concurrency
也就是并发
我们知道Go的并发就是两个关键字
一个是go关键字
一个是channel关键字
内置类型的channel和go关键字
go的话就是启动一个goroutine
这两个关键字实际上就是
让Go语言本身在处理并发上获得了非常多的一些优势
大家如果写过其他语言的一些网络编程的话
它写起来是一种怎么讲
就是属于一种callback回调的形式
如果大家写过c的话
实际上是非常不利于我们梳理整个网络逻辑的一个编码方式
不是非常利于
为什么
到处都是回调代码跳来跳去
如果大家看过Nginx代码或者Redis代码
或者是像cache的一些proxy
基本都是类似的
就是大量回调
对吧
但是如果Go的话
如果网络和go关键字本身结合得非常好
那么它其实是一种让你串行的顺序的编码方式
去处理网络(代码)的一个编写
非常简单
同样的我们可以启动大量的goroutine来处理一些并发的事情
可以同时去让不同的goroutine做不同的事情
非常的方便
这两个关键字看起来是Go的语言设计者
让我们整个使用变得非常简单
但是它里面还是有很多细节点需要注意
需要去关注的
我们就要看一下在go关键字或者是channel上我们有哪些注意的
当然除了这两种机制
其实原生标准库还提供了类似像sync包
这种模式让你去同步去操作资源
这也是非常常用的方式
但是底层一些原理的知识还是会涉及的面是非常广的
我们先看一下goroutine它到底是怎么去工作的
首先我们了解一下进程和线程
操作系统会为应用程序创建一个进程
作为一个应用程序
它像一个为所有资源而运行的容器
这些资源就包含像内存的地址空间 文件句柄 设备 线程
它相当于
进程就像一个容器一样把这些东西包起来
你可以去作为一个应用程序去运行
那么线程实际上也是操作系统调度的一种执行路径
用于在处理器执行
我们在函数中编写的代码
一个进程通常是从一个线程开始
我们叫主线程
当该线程终止的时候进程也会停止
这是因为主线程是应用程序的原点
然后主线程依次可以启动更多的线程
而这些线程可以启动更多的线程
无论线程属于哪个进程
操作系统都会安排线程在可用的处理器上去运行
每个操作系统的都有自己的算法去做一些这样的决定
就是哪个处理器当前调度哪个线程来执行
Go语言的话
它语言层面是支持go关键字的
我们可以快速的让一个函数创建goroutine
然后让它快速去执行
比方说我们的main函数
它其实就是作为一个goroutine来执行的
操作系统和线程还有goroutine它们是什么关系
我们理解为线程被操作系统调度
那么goroutine实际上是被Go的运行时runtime来调度的
那么最终这些goroutine
它都可以映射到
可以理解为映射到某一个线程上
执行单元对于操作系统来说
执行单元它不认识goroutine
它只认识操作系统的线程
也就是goroutine它最终是要跑到某一个线程去执行的
那go runtime 实际上就是负责把这些goroutine
调度到某一个Go runtime的一个逻辑处理器
我们缩写为P
在P上像一个队列
把这些任务挂到队列上
然后相当于最终是在各个线程去队列里面捞到一个任务去执行
通过Go runtime的特性使得我们可以调度数以10万计的goroutine
以竞争的效率和性能去并发运行
我们在整个Go训练营的最后一个课程就是Go runtime
我们会详细展开goroutine它是如何被go runtime调度的
这个也是非常关键
我们再看一下这段非常经典的话
“并发不是并行”
这段话其实也是Rob Pike之前在一个blog上去分享的
那么我们先了解一下它两个术语
并行是指两个或多个线程
同时在不同的处理器上执行代码
这才是真正意义上的并行
因为你有两个处理器才能同时跑不同的函数 对吧
它们才是真正的去并行去执行的
我们如果将运行时(Go的runtime)配置为使用多个逻辑处理器
因为我们刚才讲了Go runtime是有逻辑处理器概念的
我们假设现在是有多个逻辑处理器
调度程序将这些逻辑处理器之间来分配goroutine
就是goroutine相当于一个逻辑处理器负责三五个
另外一个逻辑处理器负责三五个
对吧
这将导致goroutine是有机会在不同的操作系统的线程上去运行的
因为逻辑处理器可能可以和某一个线程
就是操作系统的线程进行绑定
那么现成可能会运行在不同的物理处理器
物理处理器就是CPU
运行在不同的物理处理器上去执行
所以说要获得真正的并行的特性
你需要在多个物理处理器的计算机上去运行
就说你的逻辑处理器两个
要分别挂在两个核心上去跑
这样的话才是叫真正的并行
否则的话你的goroutine将针对单个物理处理器
就是你只有一核一个cpu去并发运行
它只能是并发
它只能去并发运行
但它不是实际意义上的并行
也就是说Go运行的时候
它都是使用多个逻辑处理器
也就是我们runtime不是有个函数叫runtime.GOMAXPROCS
那个函数去执行的
这个函数决定了你逻辑处理器的个数
我们就先简单了解一下
快速知道一下
我们现在唯一需要知道
goroutine它是一个非常轻量的可以大量创建的
然后它可以让我们的函数以并发的特性去执行
当然如果它能不能并行取决于我们到底设置了多少个逻辑处理器
和这台机器上具体有多少个物理CPU
这个才是它决定是否能并行的非常关键的一个特点
我们既然知道了Go有非常好用的
一个能够让函数并发的这么一个特性
我们再看一下
其实就这么一个简单的关键字
让Go语言与和其他语言拉开了一个身段
它写并发代码的时候非常得简单
因为我们知道创建一个线程它的成本和门槛都会高很多
我们在runtime一节也会展开讲
因为一个线程的话
它不能快速的创建和销毁
它的创建成本也是非常高
而且受限于我们的内核
我们的线程数量也是有限制的它不能大量的创建
因为如果大量创建线程的话
我们操作系统的要在各个线程之间去进行切换
是有一定的上下文开销
这个都是需要成本如果你创建非常多的话
大量的线程如果被不断的唤醒和睡眠的话
是有很高成本的
但是goroutine的话
它非常轻量
去让函数并发非常便利
但是它背后其实还是有很多坑要注意
非常简单的例子
启动一个HTTP server
相信大家刚开始写Go代码的时候
应该都会遇到这个问题
我就举我们一般都会怎么去启动 Go HTTP server这个代码
第一种情况我们看一下这个函数
非常简单
main函数里面有一个http.HandleFunc
然后我们看了打了一段话叫"Hello. GopherCon SG"
这个是一个我们的默认的HTTP的一个HandleFunc
我们注册完之后
我们就启动一个启动一个HTTP的ListenAndServe
监听了8080端口
如果它启动失败的话
我们会调log.Fatal(err)
因为它报错了
启动这个失败了
我就直接退出了
但是如果我以后台开一个go function的方式
在后台去执行监听的工作
我们的主函数是不是就退出了
因为你go function执行以后它是在后台执行的
也就是说你的main函数可以继续向下执行
继续走 对吧
如果你没有一个在尾部有一个
地方让他pending住的话
你的main函数退出的话
我还记得我们刚刚讲的
如果主线程退出
主goroutine退出
全部结束
我看过有人怎么写
就放一个空的select
那么它会永远阻塞
这是一种方式
避免我们的我们的监听的代码
一一启动监听然后就退出了
我们挂一个空select以后
它就会永远pending在这个地方
我们再看一下
如果是这样一种方式的话大家会觉得有什么问题呢
回过头来看这个课件的标题
叫"Keep yourself busy or do the work yourself"
监听这个工作是
委派给另外一个goroutine的人去执行的
对吧
我相当于委派给另外一个人让他在后台执行
那么这个标题的意思是什么呢
脏活累活自己干
这种类型的工作最好是自己做
所以我们代码改了一下
我们为什么不直接让监听工作
让这个main函数直接做
所以我们把这个go function去掉了让他自己做
为什么要这样做呢
如果你的goroutine在从另外一个goroutine获得结果之前无法取得进展
你需要等另外一个goroutine
等它的结果
那么通常这个情况下
你自己去做这个活
比委派给其他人做要更简单得多
这个可以理解吧
就是说你当前的goroutine如果要等待另外一个goroutine
它的工作完成
你才能继续执行
可能你等着他完成
你一般会怎么做
你是不是可能会用一个WaitGroup
或者用一个channel操作
你在等它的结果返回
你再继续执行 对吧
通常来说你自己干这个活
代码是最简单的
因为你委派出去的话
你要等那个goroutine执行完了
再回调结果告诉你
我们如果从这个角度来看
启动一个监听是不是main函数自己做反而是更简单的
这个代码就变得更简单一些
但是这样的一个写法会有什么问题呢
目前满足要求对吧
我们有启动更多的server
比方启动一个两个三个
比方说我要启动8080
是作为我的业务请求的一个HTTP server
然后我内部还要启动一个HTTP server
用来做什么
做profiling
做性能监控
你启两个(server)你这个地方不就堵住了吗
因为你无法启动两个(server) 对吧
这个问题我们先想一想
我们后面接着讲
我们再来看一个demo
因为我们刚刚server的那个demo我们在后面还会继续
我们现在看另外一个demo
这段话叫什么
"Leave concurrency to the caller"
让并发的特性
让调用者来决定要不要并发
我们再看一下这两个API
这两个API也是非常经典的
当然我们现在熟悉Go
开一个goroutine
后面我们又熟悉Go的channel的语法之后
很多同学的API设计的时候就写飞了
为什么会写飞
就是各种炫技
因为他没有搞清楚
有一些并行的原则在里面
比方说就像我们这段话里面的
"Leave concurrency to the caller"
让调用者来决定要不要并行
这两个API是列举目录里面的文件的(列表) 就是list
有点像我们的ls -a
列举文件和目录的这些信息 对吧
把它的路径给返回回来
然后如果我们是查一个大的路径
这个路径下很多东西 对吧
我们ListDirectory是不是会耗时很长
就第一个API它会把整个目录
所有读到的信息放到一个slice(切片)里面
然后返回整个切片
如果出现错误的话把我们的error可以赋值 对吧
那么这个时候这个值([]string)就不能做任何假设了
因为它报错了 对吧
这就是一个标准的同步调用
那么ListDirectory的调用方会阻塞
注意
你去调ListDirectory
比方说你去列举一个根目录
是不是这个函数就堵住了
直到根目录下所有的路径都被返回列举出来
直到读取所有的目录条目
全部读取完了
你才会返回
那么根据目录的大小
有可能大目录你就要等很长的时间
如果里面目录里面没什么东西
可能很快就返回
并且会分配一个超大的内存来构建slice
因为你的内容非常多
你的目录下非常多
10万个文件 对吧
它可能花很长的时间
同步(的方式)时间又要等得很久
也不知道等多久
因为我们知道如果去list一个超大目录花的时间是非常久的
可能我们学完Go以后就想着能不能
让它更快一点让它并行去执行
然后或者说它ListDirectory要花很长时间
能不能它执行完了之后再告诉我
对吧
我可以做别的事情
好开始我们设计的第二个API
那么第二个API的做法
它返回的是一个chan string
我相信有很多很多写过Go的同学的人
都会设计出类似的API 对吧
返回一个chan string的方式
像一个队列一样
你可以不断的去chan string里面去不断的循环去读取内容
对吧
这个时候我们只要返回的一个chan string
我不断循环去读里面的这个channel
我就可以获取(值)
因为它是扫一个往里面投递一个
我就取一个 对吧
好这样的一个API它有什么问题呢
首先你你要知道什么时候读完了
我们可能要么就是往这个channel里面发一个空字符串
或者说我们要把channel关闭了
channel关闭的时候表示不再有新的目录
全部被你读完了
对吧
通过关闭channel来知道
由于就是在这个函数调用完之后就开始做填充操作
相当于
我们可以想象一下
你调完ListDirectory
这个方法返回一个channel string
是不是内部一定有一个启动的goroutine
它在不断的去扫目录的信息
然后往里面去填充
因为你的ListDirectory是直接返回的
不可能等它边扫边投递
你肯定是在后台启动一个goroutine
然后它肯定是在不断地发送
相当于内部启动了一个goroutine
但是对调用者来说
可能对你来说感知不是那么强烈
你只知道channel能够一直读的东西
这是两种API的一个区别
ListDirectory带channel这个版本
它会有两个问题
我们分别来看一下
第一通过使用一个关闭的通道做为不再需要处理的一个信号
就是已关闭通道表示数据没了
作为这样的一个不再处理的信号
通过这个通道的返回
你无法告诉调用者到底是中途报错了还是没数据了
对吧
你没有一个error
你无法拿到一个error
为什么
因为你现在是返回的单签名
如果读完了
通道会关闭
如果中途报错了
也是通道会关闭
你无法区分空目录和从目录读取(发生)错误之间的一个区别
无法区分空目录和错误
这个其实是不好的
这两种方法报错
读完之后没有任何东西的
我们叫读空了
它都是通过通道的关闭来告诉你的
那么意味着这个通道关闭它是有二义性的
它是有两层意思
第二调用者必须持续地从通道读取内容
直到它关闭
调用者肯定是知道内部可能是启动goroutine
不断的去发送 对吧
你这个时候就很尴尬
如果我在获取ListDirectory结果集里面
你可能只需要其中的某一个目录
你读到一半的时候
我其实不想再处理了
你其实就不想处理了
但是它这个channel
goroutine无法关闭
全部的往里面去吐
这个时候还有一个很尴尬的问题
如果channel的buffer太小
意味着内部的goroutine发着发着发满了
它是不是就堵住
因为你已经取到你要的东西了
然后你这个channel可能就不管了
你也不会去取东西
你有这种情况
已经拿到你想要的东西了
然后你这个channel不管了
然后结果人家内部的goroutine被堵住了
是不是就产生泄漏了
这个也是它的一个设计上的严重的缺陷
如果我们同学可能想你把buffer
把channel的buffer设置的非常大
这样的话就占了很多内存
你想不泄露
你就必须获取到你感兴趣的目录以后还得持续不断地把它全部消耗完
只不过那个结果你完全不care
你完全就直接丢掉
这个其实就是设计上的一个缺陷
怎么办
我们看一下
实际上我们可以更聪明一点
使用一个函数的callback
我们看一下这个方法
你传一个callback的function
那么这个function第一个参数是string
其实就是告诉你这个path是什么
这样的一种回调callback的方式
我觉得是比较好的解决这个问题的
既不需要启动goroutine
然后同时占用的内存也非常少
它因为来一个调你一次
要调那个fn这个函数指针
要调很多次 对吧
这样的话我觉得非常好
我们标准库里面filepath.WalkDir也是类似的模型
我们刚刚讲了你在内部启动goroutine这个做法
有可能如果你不去消费channel string里面的内容的话
它就会阻塞对吧
我们再回到这个标题
"Leave concurrency to the caller"
如果是让调用者来决定要不要并行
你能够控制goroutine到底是什么时候启动
什么时候停止
我们就不太容易出现goroutine泄漏
将异步执行函数或者叫将后台执行函数的决定权最好是交给函数的调用方
通常来说是更容易得多
否则如果它内部启动一个goroutine
如果他没有给你一个很明确的方法
能够stop这个goroutine
就容易出现goroutine的泄漏
所以说
其实就是个很简单的Go的关键字
但是你要用好它其实并不容易
那么把并行(执行)或者后台执行或者叫异步执行的决定权
交给使用者是好的多
那就像我们回到前面启动一个HTTP server
其实是一个道理
如果你要去异步或者后台执行一个方法的时候
最好是调用者它自己来决定它
我们再来看一个非常有意思的泄露的demo
我们看一下
这个demo其实非常简单
make了一个channel
然后我开了一个go function
注意这个地方
我们还记得我们刚刚说的那句话
叫"Leave concurrency to the caller"
我们leak方法在内部启动了一个goroutine
也就是说它内部其实是有个并行逻辑的
有个后台执行逻辑或者叫异步执行的一个逻辑
这个时候并发决定权并不在你身上
并不在调用者身上
而是在leak函数里面
不是交给外面的人去处理的
而是内部就处(理了)
启了一个goroutine 对吧
我们看一下它是如何被泄露的
从channel里面获取一个值
然后打印它
就这么简单
我们来简单看一下它为什么会泄露
因为没有任何东西发送给channel
但是goroutine它会pending在从channel里面获取一个值(的代码位置)
然后导致它永远无法被退出
那么goroutine就会被永远地pending住
那么这个时候就产生了一个泄露
其实这个代码很不容易被看出来的
如果你乍一看好像感觉没什么问题 对吧
但其实是有一定风险的
这个其实也是一个goroutine泄露的例子
我们再来看一个有意思的例子
我们现在有一个search函数
它是一个模拟实现
我们看一下这个search函数
它可能是根据我们关键字做一个检索操作
全文搜索这种
我们做了一个sleep
其实就是模仿一个长耗时的一个调用
我再定义了一个process函数
process函数它其实也是一样的接收一个关键字
然后它会去调search函数
拿到结果以后它会进行打印
就这么简单
因为这种顺序调用
假设我这个search要花一秒钟
这个process函数也就要花一秒钟
可能同学会想这个地方
这个search操作让我pending住很久
我可能要做别的一些事情
我要把它改成异步执行
这个时候我们把代码改成这样
开了一个go function
让它去异步去执行
执行完之后我们还定义了一个channel
把这个结果(result)发给它
我们在代码的下面开了一个select
为什么
我们同学可能工作习惯非常好
他知道要设定一个代码一般要设置超时
他使用了Go的context来做超时
这个时候他做了个case
做了一个context.Done
然后如果Done返回了就表示超时了 对吧
cancel了或者是超时了
如果是收到结果的话
表示的是成功
就这么一个代码
其实也会出现泄露
我们看一下为什么
因为如果Done先执行
假设这个(Done)先返回的
那么它就return出去了 对吧
意味着channel内容没人去获取
有可能如果你的channel不是一个buffer channel
就是一个make(chan result)
它没有任何buffer
那么这个时候goroutine就会永远得堵住
没有任何人能够来获取到这个channel里面的内容
我们的改法也很简单
把channel改成一个buffer为1的不就好了吗
它顶多能够缓冲一个result
对吧
这个地方它就不会阻塞了
都是一些很简单的道理
很简单的一些demo
但是它实际上都会导致goroutine的泄露
我们在用goroutine的时候我们记住两个(问题)
一定要注意两个问题
第一它什么时候会结束
就是goroutine启动以后
它什么时候会结束
第二你有没有一个办法能够结束它
第一个是它什么时候会结束
第二你有没有一个什么手段能够结束它
你只有搞清楚这两个问题
你在启动任何一个goroutine的时候
你就心里有底
才不会做到泄露
回到刚刚那个启动HTTP server的这个demo里面来
我这个时候代码一样的
我要启动两个端口
8080用于应用程序的流量
8081用于一个debug pprof
这个就是我们profiling
Go的profiling抓性能数据的
同学这个时候很尴尬
两个都会阻塞
我只能是开一个goroutine
把它一步去执行(在后台执行)
然后我的主线程pending在8080端口
是不是解决了
我相信大家都写过这种代码
我们最早的时候如果启多端口一般也是这么写的
但是这样有个什么问题
这代码揉在一起了 对吧
我们现在简单叫它改得看起来爽一点
怎么改呢
我们拆了两个函数
拆成了一个serveApp和serveDebug
拆成了两个函数
注意我们这个函数里面没有开任何的go function 对吧
符合我们刚才说的"Leave concurrency to the caller"
我们将并行的逻辑扔给调用者自己来决定
我们内部不做任何假设
它就是一个serveApp或者serveDebug
它就是这么两个方法
将并发的特性扔给调用者
然后我们的main函数
刚刚说了
我们肯定是开一个go函数
下面那个就是让它去堵在这个地方
我们函数其实一个是后台执行一个是前台执行
但是这样会有个问题
如果serverApp因为一些原因它的监听断掉了挂掉了
它就会返回
那么整个main函数它就会结束
整个程序它就会什么被暂停掉 对吧
或者说serveDebug意外退出了
我的业务进程是正常的
但是哪天我突然要去整个问题的时候
通过debug pprof这个接口去获取Go的内存 CPU的一些数据 获取不到了
因为它退出了
你根本不知道
因为它是在后台执行的
你完全不知道
这个其实是不太好的
一个是serveApp退出以后整个main函数退出
一个如果它在后台执行的serveDebug退出你完全不知道
所以好像似乎不能解决问题 对吧
怎么办
我们把这个代码再做了一些优化
我们要求两个HTTP server
必须同时serving
必须同时提供服务
我们又引入了空的select
我们看到这个代码
我们先看函数
我们把它改成两个goroutine在后台去执行
然后我们的main函数执行一个select
这样的话就不会出现有一个没退出不知道
我们现在怎么办
我们把serveApp和serveDebug稍微改了改
如果http.ListenAndServe这个函数会堵住
因为他是要负责监听的
那么堵住之后
假设他意外退出了
就会返回error
我这个时候使用log.Fatal
如果你掉了这个函数(log.Fatal)
它会让整个进程直接退出
相当于是掉了一个os.exit就退出
直接让它退出了
这个程序整个就退出了
注意os.exit这种方式是不会执行defer的
defer是来不及执行就直接整个进程就退出了
不是那么得好
我们看一下
因为log.Fatal会调用底层的os.exit
它是无条件终止
defer不会被调用到
所以我们通常建议
或者说对于我来说
我是不建议大家在生产环境去调log.Fatal这个函数的
如果能调那么它也只有两个地方
一个是Init函数
Go的Init函数初始失败是可以考虑使用的
因为它是一种fatal error
就是叫game over
就只能挂掉了
那你可以调
不管3721直接掉
还有一种情况就是main
我们的main函数可能做一些配置解析
解析失败的无法启动可以调这个函数
其他任何地方都不应该使用log.Fatal
那么我在开源的一些仓库里面有看到
他们的代码里面初始化失败以后会掉这个函数
其实对于一个调用人来说
如果他调了这个函数你完全不知道
可能你的程序会意外退出
因为它的第三方库
对于你来说可能会被降级
但是你无法处理
所以我们不建议
我们只建议在应用程序
application的main函数或者Init函数里面去使用
只有这两种场景
所以好像也不是特别好(这个做法)
怎么办
我们再来看一个高级一点的demo
我们看一下这个main函数
这个main函数改得非常复杂了
还是一样的
我启动一个serveDebug
启动一个serveDebug是在后台执行的
但是我们看到
我们看一下
它跟之前稍稍有一些区别了
我们现在抽象一个serve的函数
它接收一个监听地址
接收一个http.Handler
然后接收一个stop的信号量
接收一个stop的信号
这个地方还是一样的
它会去ListenAndServe开个监听
但是我们注意在这个函数里面
它会在后台启动一个function
它会接受stop信号
然后会调Shutdown方法
我们注意为什么这样写是可以的
它接收stop信号以后
意味着我们在后台启动的goroutine
我们记得还刚刚那两句话
你什么时候goroutine能够结束
以及你有没有一个办法能够让这个goroutine结束
那么我这个地方的goroutine
我是通过像stop信号
通过close stop channel
我可以close stop channel
stop channel里面发东西
有办法能够让它收到消息
然后goroutine就有机会退出的
因为我这边收到信号之后
我立马会调server的Shutdown
让这个server可以退出了
也就是说我符合第二个条件
外面的人能够控制我这个goroutine退出
所以这个是没问题的
如果调了Shutdown
我实际上ListenAndServe这个方法也会被退出的
也就是说由goroutine决定了外面serve这个方法的生命周期
它来决定这个函数能不能返回
这样的一个方法抽象以后
我们在外面在main函数make了一个这样的stop信号
传入到serveDebug或者是serveApp
传入到进去它会返回error
我们再把这两个它分别返回的error
再发送到一个done的信号
就是这样的话
我就知道他们两个人
任何一个人如果有退出这个channel我就会收到有人退出的一个信号
这个时候我通过stop来控制退出
通过stop控制退出
通过done可以知道有没有人退出
我们在下面的代码里面怎么做
开个for循环收这个done信号
如果收到一个表示debug和app这两个HTTP server有其中任何一个退出
我都会打印这个错误
同时如果有一个人退出
我就会去调close这个stop channel
把stop channel给close掉
一旦close会发生什么情况
serveDebug和serveApp这两个goroutine
它都会这个地方(被)唤醒
唤醒以后都会去调查得到
这两个后台在server里面启动了后台的goroutine都能够退出
并且这个ListenAndServe也能够退出
所以是不是他们的生命周期都被你管住了
我们通过stop来控制serve这个方法里面的一个Shutdown的goroutine能够关闭
我们通过done能够知道其中任意一个serveDebug或者是serveApp
它们其中任意一个goroutine退出我都知道
也就是他们的生命周期都在我的掌握之中
大家可以看一下
work group其实管理一个work
管理一个工作或者是管理一个application的生命周期
大家别看启动HTTP server这个代码这么简单
实际上它包含了对一个工作的生命周期的goroutine的一个管理
我们再往远一点做
其实我们在0期的时候也是我们的一个作业
我们要自己去实现一个
用channel也好
或者是用goroutine也好
或者用errgroup也好
或者用WaitGroup也好
来实现一个生命周期的托管
这个叫application lifecycle
对于一个应用的服务的管理
应用的服务管理是什么
就是我们写一个main函数代码
我一般要写启动一些rpc的服务
或者启动一些HTTP的服务 对吧
要启动好多端口的监听
我们一般会抽象一个叫application lifecycle的管理
叫应用生命周期的管理
方便服务的启动暂停
比方像我们的开源的go-kratos框架
我们这个基础库也按照类似的思路做了应用的生命周期托管
但我们还扩展了一些功能
包括我们可以注册应用的信息
注册应用的版本
需要启动哪些server
我们对它的启动和暂停
我们这个地方启动一个HTTP server
启动一个GRPC server
我在这个地方处理了
能够做信号处理
因为我们函数除了去启动监听
其实还要处理一些信号
比方说kill信号怎么处理
killall信号怎么处理
还有就是我们服务发现的注册
服务注册也要处理
那么kit的使用者
最终我们基础库的使用者
它可以非常方便的对application的资源进行托管
就应用级别的资源进行托管
kratos我们是使用的errgroup
大家可以看一下errgroup
自己去搜一下
和functional options就是一种
可扩展的函数参数签名这种方式进行了设计
那么我在讲完errgroup
我再会把这个题目对着我们的源代码再讲一遍
你可以先自己尝试写一写
我们当时是出了一个作业的
大家基本上都好多同学都做出来了做得非常不错
好咱们继续
我们再来看几个泄露的例子
我们现在有一个Tracker用来做服务端埋点
要记录一些事件
那么这个Tracker类它有一个Event方法就是记录事件
这时候我们做了一个sleep
用来模拟就是网络延迟之类的
可能会sleep1秒5秒10秒有可能2分钟
因为一些意外的情况
我们这个地方因为是模拟代码
所以我们没有真实的埋点的一个上报
只是把它记录到日志里面
我见过很多初学者他会怎么写呢
假设我现在有一个APP有个application
它提供了一个HTTP的Handler
这个地方都是做一些各种各样的工作
然后这边是输出HTTP的头
这个地方就开始做这个事件记录
同学可能他觉得上报事件不是我的主干逻辑对吧
它是我的旁路逻辑
他能分清楚什么是主干逻辑什么是旁路逻辑
然后他把旁路逻辑做成什么
做成一个后台执行的异步执行的
开了一个goroutine
这个地方其实就已经出现了一个bug
还记得我们之前说的话
搞清楚你的goroutine什么时候能够结束
你有没有一个手段能够控制它结束
对于这个地方来说
你是完完全全不知道这个goroutine
这个goroutine什么时候结束
不知道
因为取决于它这个里面的网络耗时 对吧
那么如果你无法管理它的生命周期
意味着如果你的main函数哪天要退出
收到一个kill信号的时候
你的服务shutdown的时候
就会有一些事件丢失
因为还有一些野生的goroutine在外面跑着
然后你的服务main函数已经退出了
你没有办法知道它们什么时候结束
相当于你的HTTP server
启动了一些goroutine去做事件上报
这个时候你的main函数要退出了
没有办法等到它们都执行完
你肯定有些事情就会丢失
怎么办
我们知道sync包有个叫WaitGroup的
WaitGroup其实非常好用
它可以用来追踪每一个创建的goroutine
所以我们这个代码再改吧改吧
看一下
Tracker里面我们引入了WaitGroup wg
然后在每次Event上报的时候
先做一个技术追加
再开个goroutine去做异步上报
在这个里面使用defer
如果这个事件上报一旦结束
我就defer调一个Done函数
它们是一一匹配的
加一这里就减一
这里加一这里减一
这样的话我就能够知道
通过WaitGroup
这个Tracker可以调Shutdown方法
等所有的上报事件都结束了
我再返回
那么我的main函数改吧改吧
就这个main函数尾部
我可以等它的Shutdown
等它全部结束了
我的事件全部都完成了
然后我的main函数再退出那就不会丢数据了
但是demo我仍然不喜欢
我见过很多人这样在HTTP请求里面去启很多个goroutine
虽然goroutine是非常轻量的
可以大量创建的
但是这种方式其实它不是一个非常好的工作模型
你会启动大量的goroutine去做一个事件的上报
我们怎么办
我们再把这个demo再改一改
我们再看一下
如果是我的话我可能会这样改
我们看一下
首先这个地方还有一个问题
WatiGroup虽然调到Shutdown
但是因为我这里是模拟的sleep一毫秒
但是假设它sleep两分钟
你的main函数就永远退不出去了 对吧
你可能等大量时间你都还退不出去
所以它没有一个超时的控制
对于超时控制我们一般是怎么做的
我们一般都会引入context
我这个地方把它简化了一下
使用的一个context来做超时处理
我们先来看一下代码
完整的一个代码
我们看一下
这个是我最后改的
我没有用WaitGroup
因为我刚刚讲了
我们大量启动了goroutine这个方式我不太喜欢
我们怎么办
我这地方很简单我引入了一个channel
因为channel的话
它就是一个所谓的叫worker的工作模型
我只需要启动一两个或者两三个goroutine
在后台去消费channel里面的数据
它在背后做上报就好了
我不需要大量启动goroutine
是不是就好很多
这个性能
比方说我们这个地方你看
我使用channel来做数据的buffer
Run这个函数
它会去从goroutine里面不断的消费数据
然后打印出来
我就是模拟成上报
一直会去消费里面的channel数据的
然后我们注意
我这个地方在Tracker里面还加了一个stop信号
也就是说我需要有一种机制能够让我的这个channel让它暂停
怎么暂停
close这个channel相当于暂停了
就不会发送数据了 对吧
那么意味着这个地方就会退出
那么这个地方退出以后
我再发送一个信号给我的stop信号量
也就是意味着我这个Run函数终于退出了
Run函数退出我的Shutdown就有一个机会能够接收我stop信号
当然我接受的外面的一个参数是context传参的
这样的话我又可以Shutdown也可以加个超时
也就是说我通过stop知道Run函数什么时候退出
然后外面的人调我的Shutdown的时候
其实是先阻断数据的发送
然后阻断数据发送以后Run函数退出
然后再收到stop信号
stop信号一收到以后我的Shutdown就能退出
如果Shutdown退出可能花的时间非常久
那么这个时候我再可以做一个
超时context超时控制
所以这个代码就变成这个样子了
所以我们看一下这个代码
先new一个Tracker
然后注意这个地方是让外面的人决定它并行执行
大家知道为什么
还有一个逻辑
为什么要让你有"Leave concurrency to the caller"
为什么要这样做
你如果内部启动了goroutine
外面的人不知道的时候
它又启动一个goroutine
外面的人还会这样写一次
实际上是起了两层
所以说最好是让外面的人自己去处理
同时万一别人刚好是因为一些原因
它可能跟另外一个工作的goroutine要混在一起
你把它启动过goroutine以后
它自己就没法处理了
它想自己托管生命周期
它就没法处理
所以我觉得这个也是一个点
我们这个代码就是在做了两个事件上报
最终我们有一个三秒的超时时间
可以控制它的Shutdown
我们注意这个地方Tracker的代码风险点是什么呢
我们用过Go的channel的时候
一定要知道
数据的发送者才能决定这个channel什么时候能被关闭
因为有人在读channel
有些人在写channel
如果这个时候你已经关闭了这个通道
然后还有人在写这个通道它就会什么
就会panic
所以说一定是交给写的owner才能去关闭这个channel
所以在我们这个场景里面一般是怎么做
如果这个代码要非常安全的话
要怎么做
一定是让HTTP先Shutdown
HTTP Shutdown意味着没有任何的HTTP在后台再执行了
我这个时候再来调Tracker的Shutdown
因为所有的写的人都停止了
我才能调它的Shutdown
才去安全的close这个channel 对吧
这样的话就不会panic
所以写的owner才能决定channel的生命周期
这个一定要注意
如果你觉得你办不到
我给一个不太建议的办法
就是往里面发个nil发个空字符串
就发一个特定的标识表示它结束了
但是这种情况其实是不好的
因为有可能会导致一些事件的丢失
为什么
因为你发一个shutdown信号过去
其实外面的还有可能有别的人继续往这个channel发信号 对吧
你其实这些数据就丢了
因为你收到这个信号要退出
以后你就不处理了
你就会丢一些
所以最好的是让写的这个人来决定什么时候能够close这个channel
这个是非常重要的一个点
这个地方一定要注意
一定要注意
所以我们从这种大量创建goroutine这种方式
变成了一个后台有一个worker的goroutine
在背后不断的自己在处理 对吧
这样方式会好得多
所以这一章我们关于goroutine的基础的
goroutine生命周期的一些用法就介绍到这里
我们下面要介绍一个非常重要的
概念叫Memory Model
Memory Model是指Go的内存的模型