详细梳理go和nginx实现高并发的原理
好久没有写技术方向相关的文章了。今天想写这个主题,是因为我在部署完自己网站伐谋以后,要做一个图片的防盗链的功能,所以就要对nginx进行配置,但是本地性能感觉还好,由于我线上生产环境是部署在香港阿里云上,所以,线上感觉性能就很普通,就想对它提提速。nginx提供了很多扩展的参数,读到processor设置的时候,联想到go中runtime.GOMAXPROCS也建议设置成与系统核数相同,我就觉得两者应该有什么关联关系,于是就查资料,要一探究竟,本文随即产生。
nginx是现在web部署中普遍使用的反向代理服务器。通常nginx是以daemon(守护进程)的方式运行,当nginx运行以后,由哪部分来做request请求的处理呢?request的处理其实都是依赖于服务器本身的资源,也就是服务器的处理器,处理的方式是有master进程和worker进程,对请求处理的时候,可以处理请求的worker进程是需要master进程fork之后才能进行请求的处理。master进程的功能主要包括:接收来自外界的信号,向各个worker发送信号,监控worker进程中的状态,当worker中的进程异常退出时,会自动重新启动新的worker进程。而多个worker之间在处理请求方面的机会是对等的,他们同等竞争来自客户端的请求,各个进程之间是相互独立的。一个请求,只可能在一个worker中处理,一个worker进程也不可能处理其他进程的请求,这里我们可以类比一下golang中非抢占式的并发功能,就是goroutine协程处理完自己协程内的任务之后,M自动寻找其他goroutine中是否有等待处理的任务,如果有就把任务拿过来在自己的goroutine中进行处理,如果发现已经没有需要处理的任务,那么,看本地队列中是否有空闲位置,如果有就放入本地队列之中,如果发现本地队列中已经没有停放goroutine的位置,那么,就会放到全局队列中。这样一对比就比较明显了,也是go现在作为高并发服务开发的首选语言的原因了。
而且go语言中goroutine的默认值,也就是你不在runtime.GOMAXPROCS中进行设置的话,它就是默认系统的核数,这样做在nginx和golang中都存在的好处就是,go语言中M就是对应着操作系统的线程,如果把操作系统的线程数默认设置成等于CPU核数,这样就不至于如果指定的操作系统级线程数过多,与操作系统CPU核数不对应,会导致操作系统为了接洽这些线程要做上下文的切换,指定cpu的核数和goroutine的数量相等,就可以把系统开销降到最低。nginx中也是一样,如果你指定与系统cpu核数不一定,也会导致上下文环境切换,降低系统的性能。
但是为什么go的性能会比nginx的性能更加高呢?
你光分配任务等待被执行也不行,对吧?目的是把任务完成,所以,M还要疯狂的往外取,怎么个顺序呢?首先去处理器私有队列里取G执行,如果取完的话就去全局队列去取,如果全局队列也没有话,那就去其他队列的处理器中偷,如果哪里都没有找到要执行的G呢?那M和P就会断开连接。看到了吧,go语言中是无阻塞顺序的编程,所有装配完毕等待发车的Goroutine都被M最充分的利用上了,这样可以将操作系统资源最大程度的利用。Nginx性能其实也是蛮高的,只是他使用的方式是异步回调,但是两者都是基于高并发编程的两个核心:epoll和NonBlock。
如果我们仔细研究go runtime所提供的接口,会发现它们都是无阻塞的,用epoll方式来实现效率是非常高的。强调非阻塞,就是因为Block几乎是高并发开发最大的敌人,比如说go遇到cgo或者需要系统调用就会出问题,阻塞操作占用了大量系统M,系统线程猛增,gc由于系统线程出现也加进来凑热闹,所以性能也就会变得低下。这也是我们前面解释为什么无论是go还是nginx都将有效的协程数量或者worker数量设置与操作系统cpu核数相同的原因。epoll开发要多注意触发模式,默认是LT,即水平触发,只要有数据可读写,epoll_wait返回就一直携带FD。大部分服务都使用ET边缘触发模式,即从无数据到有数据,从不可写到可写,状态变化才触发epoll,如果一次没有读取完,内核还要待读取数据,那么epoll就不会触发,所以ET使用的正确姿势是抱住FD,一直读写,直到遇到EAGAIN,EWOULDBLOCK错误。nginx对此进行优化的方式是如果面临大段读取或发送的场景下,他会分多次来调用。
记得我之前有文章讲过自己实现延迟队列的方式,就是利用了redis中hash来存将要执行job的元数据,然后用zset(有序集合)来存job元数据对应的job_id及未来触发的时间戳,由于zset可以做范围查找,因此,你如果想查未来5秒内将被触发的任务,用zrangebyscore就可以轻松做到,再根据job_id取到元数据,执行你的延迟任务就可以了。现在市面上流行高效的定时器也不过三种,go使用的堆结构,linux kernel使用的时间轮,再有就是nginx的红黑树。go在1.10前使用一个全局的四叉小顶堆结构,在面对大量连接时,性能非常差,因此1.10之后引入了runtime层64个定时器,也就是64个四叉小顶堆定时器,性能提升不少。相比二叉,更扁平一些?为啥扁平之后性能就更好一些呢?mysql查询引擎底层实现方式是b+树都众所周知,如果查询主键在内数据的时候,会用到聚簇索引,不用回行,如果是普通的avl树或者是红黑树,虽然可以保证查询的高效性,但是由于平衡二叉树平衡属性的存在,要保证这个属性,难免会导致数的高度会不断增加,因此mysql才优化成b+树,叶节点上存索引也存具体的值,而且一个节点上存的是个有规律的范围值,这样的好处就是即可以使某些查询不用回行,直接从叶节点上就能返回数据,另外就是可以大幅度降低树的高度,而降低树高度好处就是做查询操作的时候,不会因为树高太高,定位到相应叶节点需要经历更多次分裂,效率就更高,而go中实现定时器用64个四叉小顶堆来实现定时器,做成如此扁平的目的,也是为了更高的操作效率,数据显示,此数据结构实现定时器增加删除都是O(log4N)级别,查询是O(1),当然有得有失,更好的时间复杂度也使得维护这个结构要有O(log4N)。
好了,这篇有关nginx与go实现高并发原理梳理的文章至此结束,感谢阅读。