【golang】TCP服务框架

前两天在小破站看到zinx框架的教程,于是跟着学了学,实现完了换了个名叫 Kinx hhhhh~。有意愿的可以star一波~ https://github.com/k-si/Kinx

附一张整体架构图:

设计思想

tcp通信在代码层面上是非常简单的,因为几乎所有的语言都提供了套接字,套接字就是对底层操作系统通信细节的封装,只需要调用封装好的api,就可以完成复杂的通信操作。tcp通信典型的有server-client模式,对于TCP服务器来说扮演的肯定是server的角色。它通过监听端口,循环阻塞获取连接句柄,然后通过句柄进行发收消息。客户端只需要拿到服务端的ip和端口,使用tcp协议连接上服务器,开始发送接受消息即可。

但是对于服务器来说,是需要提供一定的并发量的,不能一个server只给一个client使用吧。所以肯定是多线程的给每个client提供服务。ok,到这我们就能确定,每一个client的连接,都需要一个goroutine/thread进行处理。连接成功之后,开始处理业务,一般业务划分为读/写两种,那么对于每一个连接的处理,读和写的处理必须保证是同时的,不能写完一个再读一个,应该是读和写互不影响的。由此我们可以确定,对于每一个连接,我们需要分别再开两个线程来处理读写业务。

控制并发量

我们再考虑一个问题,服务端提供的服务一定是有限的,不可能有一亿个客户端来连接,服务端就必须开辟一亿个goroutine来进行处理吧,服务的机器性能不允许,即使能开一亿个线程,线程之间的切换会消耗大量的线程,反而得不偿失。

有什么解决办法呢?你可能会说,我们可以限制client连接数量,一旦达到某个值,就拒绝连接。这当然可以,但是就是有点浪费资源。为什么?每个连接都要开一个goroutine,有的连接可能业务非常简单,这样它的3个goroutine空闲的时间将非常多,我们希望的是充分利用每一个goroutine,让他们不能停歇(心疼goroutine一秒)。为实现这个目的,我们使用线程池+任务队列来限制过多的goroutine。

假设有100个client连接,那么会启动100个goroutine处理连接,然后每个连接启动2个goroutine处理读写,这一共就是300个goroutine。这其中最耗时的操作是什么呢?应该是处理的读业务,一般写业务是比较简单的,麻烦的是读取一个数据之后进行的逻辑业务,写业务只是把结果写回就可以了。所以关键就在这100个读业务goroutine上。

我们可以开辟一个线程池,在启动server的时候就初始化线程池,存放10个线程,在处理读业务的时候,将任务push给一个线程,并通过某种均衡算法使得这10个线程处理的任务量是均衡的。那么我们就必须为每一个线程绑定一个任务队列,任务队列在golang中直接使用channel实现就好,非常方便~

那么现在再有100个client连接后,每个连接的业务任务都会分配到这10个线程中,每个线程都是100%进行工作的,当然也可能不是100%,这住要取决于均衡算法的实现。但是整体来说资源的利用率肯定是大大提升了,整体的处理速度也不一定会差很多。这种实现方式,很像GMP模型中的调度器,每一个p就是一个任务,由调度器决定p分给哪个内核线程。可见处理问题的思路是相同的~
todo:消息均衡算法的优化

TCP粘包

涉及tcp通信不可避免的就是tcp黏包问题,在代码中读取数据实际上是需要一个byte数组一个byte数组这样读的,并不能是像水流一样源源不断的读取,肯定是有截断的。那么万一传输的数据比较长,中间被截断了,怎么给联系起来呢?这里就需要我们自定义一种协议,有一种简单的TLV协议,即每次发送的消息应该包含消息类型、消息内容、消息长度这三个变量。并且server和client都要遵守这个协议。这样在读取的时候,就可以判断数据的长度,然后再从连接中读取正确长度的数据,并且数据类型还能方面的指示数据的业务类型,方便消息的分类。

心跳检测

心跳检测是tcp服务必备的手段,用来检测客户端是否假死,及时清理无用连接。tcp虽然是可靠的,但是也只是相对可靠,仍然有很多不可抗力因素导致网络断开。如果网络断开,客户端服务端就都不知道对方是否活着。那么就有必要一直通过收发消息进行确认存活。

一般客户端是心跳包的发起者,服务端持续进行心跳检测,检查是否客户端发来了心跳包,如果长时间没有发来,就认为该客户端死亡,需要将它的句柄fd关闭。这里实现的较为简单,服务端起一个goroutine,每5s循环检测内存中维护的连接表,并且每次都给连接的fresh字段+1。同时服务端的读业务可以识别心跳包,检测到该包是心跳包,将该连接的fresh置为0。当一个连接的fresh达到5就清除该连接。客户端发送只有header的包表示是心跳包。
todo:当存在大量连接,循环遍历时间可能过长,需要优化算法减少检测时间。

读写业务交互和业务函数注册

每个连接的读写业务是需要沟通的,读业务读取数据,并处理,最终结果需要输出给写业务,这就用到了channel,这非常的合乎时宜,channel就是用来做不同goroutine之间通信的。处理完的数据直接塞到channel里,然后写业务轮询读取就好了。

对于框架来说,是给人使用的,它本身并不能实现具体的业务逻辑。框架一般都会提供一个接口,开发者使用时可以专注于业务的处理,而不是钻这些并发、消息处理上的牛角尖。所以Kinx是需要提供这样一个函数或者接口,开发者在其中实现具体业务逻辑,然后服务运行时就会将函数嵌入到框架中,自然就处理了业务。这个概念对应框架中的router模块,该模块提供了prehandle,handle,posthandle三个函数来对应处理业务前、处理业务时、处理业务后三个阶段。模块的具体实现也挺有意思的,router结构体带有这三个函数,框架获取连接后调用router的三个函数.......这里不太好用文字描述,直接去看代码吧~

另外就是一些模块的抽象,怎么去抽象、然后将抽象实现。这就对应面向对象设计中的结构体/类,和对应的方法了。

开发过程中的坑

1、使用defer conn.close() 导致used closed connection
2、connectionManager 的每个操作都加锁导致在 清除connection时死锁

解决方法:(todo)

posted @ 2021-11-20 22:14  moon_orange  阅读(1527)  评论(0编辑  收藏  举报