这些天go的学习,相比较php,给我最大的感觉就是go 更偏向底层,相比较php有更多的接口能让我们和os交互。再者就是go 天生支持高并发,也就是goroutine (coroutine 协程),相比较php 的多进程 (process)和 java的多线程(thread)更轻量。
我们在我们项目代码中可以随意的通过 go func(){} () 生成一个goroutine, 不同的goroutine 可以执行不同的逻辑,我们的主进程也可以看成一个goroutine,他会不等待 其他的goroutine, 如果main 执行完了,其他的goroutine 会自动结束,不管有没有执行完, 所以我们经常在代码的尾部,sleep ,等待其他goroutine执行完。但这只是demo,真正工作中无法知道一个goroutine 要执行的时间,比如 curl的返回,多种方法可以解决上述的问题,等待goroutine 执行完。
- 我们可以通过go 的sync 包,同步阻塞,等待所有的goroutine 执行完。
1 | // 为了解决goroutine 一起完成我们引入了 |
sync.waitGroup 只是让goroutine 在没有channel 的执行下也阻塞住了,等待goroutine的执行,但并没有解决线程安全的问题。线程安全解决方式
1.互斥锁。但比如 web, 读多写少,互斥锁并不是很友好。
1 | // 为了解决读写不一致问题 |
2.读写锁。相比较互斥锁,只是读写互斥,读读不互斥,更友好点。
1 | // 为了解决上述问题,出现了读写锁 |
3.并发安全的map。
4.data++ 这种操作原来也会出现线程不安全问题 。除了锁之外,还可以通过原子性操作解决并发问题
- 通过channel ,我们在channel 的使用过程中经常会发生死锁问题,这恰恰是我们可以通过channel 等待goroutine 执行的关键所在。
https://studygolang.com/articles/18800
这篇文章很好的解释了channel 造成死锁的几个原因。
https://juejin.im/post/5d216f07e51d4550bf1ae8e0
掘金的这篇文章比较简单,但也说明了channel的几个使用场景。
关于并发:
首先想说一下关于并发的场景,其实并发在生活中很常见,只要用户量足够多,同一时刻很可能有两个人在做同样的事情,但这不是高并发,高并发常见的场景就是秒杀的时候,很多用户在某一个时刻被召集起来争抢有限的资源。总结:就是某一时刻某个api 被大量请求,这就是我们压测的原因, 在高并发的情况下我们的服务是否稳定。
其实很多时候我们的接口在高并发下其实没啥影响,比如幂等的接口(获取某个后台配置),只要我们的服务器撑得住,多少的并发量不是问题,这不是目前我想表达的并发,想说的是非幂等性接口在高并发下的危险性,比如秒杀情况下的超卖。为什么会出现超卖,就是我们在获取数据的同时并不是立马就能处理逻辑,比如在我们减少库存的途中,另一个请求先完成了,导致库存数已经为0了,这时候虽然我们之前的库存1 ,减少为0 合情合理,但其实我们在减少库存1的时候库存数量已经是0了,我们并不知道,所以实际库存已经是 -1了,这就是超卖。
这个库存可以类比到我们代码中就是公共变量,在lnmp的架构中,因为我们的webserver其实是 fpm提供的,fpm 会有多个进程,每个进程中有php,我们的业务代码存在于每个进程中,每当请求结束,这个进程中的变量生命周期就结束了,这种同步模式不存在公共变量读写不一致问题,唯一可能的就是我们从第三方中间件比如redis 比如 mysql中数据读写不一致问题,一般是redis ,所以就有了 redis + lua 的 原子性操作。
而在go 中,当我们用go 的 net/http 包做webserver 的时候,对于全局变量,不同的goroutine会存在读写不一致问题, 这时候我们就会引入sync 包
模拟并发的出现也是个技术活
1 | go func() { |
go 对于并发问题的解决,经常用到goroutine,我们用goroutine 经常会造成 死锁,我们来分析一下这些原因
1 | package main |
我们平时工作中用到 协程的场景 (这篇文章的核心!!!!)
1.一个api接口中聚合了不同的逻辑,相互不干扰。比如我们的home/info, 我会启动5个goroutine,去处理不同的业务。我会启动两个channel, 一个代表finishChannel (chan bool),主要用来阻塞主协程的执行,一个resChannel, 用来读取数据。之所以不用resChannel 来阻塞主协程,是因为我在判断所有业务是否都执行完的时候并不想把这些数据都取出来(现在想想也可)。resChannel 中的消息体一般长这样
1
2
3
4
5type Message struct {
Flag string
Msg interface{}
Err err
}flag 代表不同的逻辑,下面一个switch 接入,进行不同的逻辑处理。msg 因为不同逻辑返回内容不一样,我们取出来之后要断言一次。err 就是错误信息。
2.同一个接口循环去做通样的事。比如没有群发消息,我们不能bulk,只能单个循环。我们可以利用goroutine。
多进程下面读写安全的map(其实很简单,读取的时候加读锁,写的时候加写锁) (可以读一下cache2go 来了解一下本地缓存, 核心也是一个安全的map, 本地缓存相比较分布式缓存有一个缺点是如果容器重启了,缓存就消失了,如果没有redis 这种落地数据的操作)
上面我的home/Info 还可以有个方式去解决 并发等待问题就是 errGroup, 类似于waitgroup,把常用的几个功能进行了分装 (add wait done), 主要作用如下
- errgroup 可以捕获和记录子协程的错误(只能记录最先出错的协程的错误)
- errgroup 可以控制协程并发顺序。确保子协程执行完成后再执行主协程 (waitgroup 的wait)
- errgroup 可以使用 context 实现协程撤销。或者超时撤销。子协程中使用 ctx.Done()来获取撤销信号
1 | // demo |
分析一下errGroup 的源码
1 | type Group struct { |
errGroup 虽然好,但是仅仅解决了我们 waitgroup 的工作 和 goroutine 中 err传递的工作,对于结果集的传递,感觉还是得造一个channel 用来传递
1 | package common |
1 | // 之前模拟过一个例子 |
关于 context ,很重要的一个知识点,对于并发,我们之前一直通过 waitgroup . add 去添加信号量,但是对于 树状的goroutine, 这样会越来越复杂,完美的方式还是通过 context 上下文的传递。 我们设置一个可以随时取消的上下文,当上下文被cancel 的时候,这个请求自然就结束了。
1 | // 本质上这些 控制并发用的都是 channel |
1 | // 关于 context 需要认知的几个东西 |
context 的衍生
1 | func WithCancel(parent Context) (ctx Context, cancel CancelFunc) |
上下文真是个好东西, 方便我们 协程隔离
1 | 之前写代码的过程中,发现大佬们在中间件解析用户uid 的时候都通过 constant.withValue 存储到上下文中,当时很不理解,为啥不直接 全局变量存储,后来想通了,go 中 包内变量多goroutine共享,很容易相互污染。 |