一、概述

  Kikilib网络库是轻量,高性能,纯c++ 11,更符合OOP语言特点且易用的一个Linux服务器网络库。并发模型使用的Reactor模型+非阻塞IO,坚持One Loop One Thread,使用Round Robin派发新连接。
  什么是面向对象使用的网络库呢?
  之前我们使用的网络库一般都是写一个回调函数,然后set_callback进网络库中,并配合context上下文指针使用。但是在c++ 中,为何不直接写一个类作为回调函数和上下文成员共同的载体呢,其中上下文的内容作为private成员(这也是符合语义的),回调函数作为类的成员函数,这样做也更符合C++这个OOP语言的特点,何乐而不为。基于这个想法,我写了这个网络库。
Github源码地址: https://github.com/YukangLiu/kikilib

二、使用

  这个网络库的使用非常简单,只需要实现一个EventService子类就可以了。以我的echo为例:

class EchoService : public kikilib::EventService
	{
	public:
		EchoService(kikilib::Socket sock, kikilib::EventManager* evMgr)
			: EventService(sock, evMgr)
		{ };

		~EchoService() {};

		void handleReadEvent()
		{
			std::string str = readAll();
			sendContent(std::move(str));
			forceClose();
		};
	};

	int main()
	{
		kikilib::EventMaster<EchoService> evMaster;
		evMaster.init(4, 80);
		evMaster.loop();
		return 0;
	}

  从头到尾都不需要设置回调函数和对应一个连接的上下文指针。
  所以使用这个网络库的核心是一个EventService类,这个类提供了一些供用户使用的API,还有几个处理各种事件的虚函数。这个类可以理解成“为一个连接中的各种事件服务”的类,用户继承这个类,上下文记录信息作为该类的私有成员,实现要处理的事件的处理函数即可。
  这里就会出现一个问题,网络库如何实例化用户的这个对象呢?有两种方法,一是使用模板,二是使用工厂。这里我选择了将两种方式融合,即用户只需要将自己实现的具体EventService子类放在EventMaster的模板中就可以使用了,而在网络库内部是用工厂生产对象的。这样做的原因如下:第一,如果让用户再去实例化一个工厂那使用起来太麻烦了,这个是主要原因。第二,那为何还要加入工厂呢,因为将生产对象这个动作剥离出来的话,可以让类的职责更加分明,未来需要加入EventService对象池的话可以嵌到工厂中。
  具体的使用方法可以参看http和chatroom,分别是我用这个库实现的一个简单的静态网页服务器和一个聊天室(广播)服务器。

三、实现

1、框架

模型如下:
图片10.png
  并发模型使用的多Reactor模型+非阻塞IO,One Loop Per Thread,默认给事件最少的线程派发新连接。实现这些主要依赖以下一些类:EventService,EventEpoller,EventManager,EventMaster。

(1)EventService

  这个类如它的名字,是一个为事件服务的类,用户需要继承这个类,实现处理事件的方法。当一个连接到来时,网络库会实例化一个该类对象,为这个连接上的事件服务。
  这个类还有一个重要身份,那就是API集合,它封装了供用户使用的网络库接口,以便处理连接上的各种事件。它提供自身socket的操作API,自身事件相关的操作API,定时器相关的操作API,线程池工具的操作API,socket缓冲区的读写操作API。

(2)EventEpoller

  该类功能很简单,一个是监视epoll中是否有事件发生,一个是向epoll中添加、修改、删除监视的fd。值得注意的是,该类并不存储事件服务对象实体,也不维护任何事件对象实体的生命期,这个工作是EventManager做的。
  这里的epoll用的LT模式,原因如下:
  read事件到来时,若server的业务并不会每次readall并进行及时处理,那么,如果遭遇client疯狂发送巨大包体,ET模式必须每次将内容读进内存,而server不及时处理就会导致内容堆积,内存爆满,使用LT不会出现这个问题。

(3)EventManager

  该类是事件服务对象实体的管理器,拥有一个EventEpoller和一个Timer定时器对象,提供插入事件,移除,修改事件的接口(对EventEpoller接口的封装),提供定时器的使用接口(对Timer接口的封装)。维护所有事件服务对象实体的生命期,维护定时器和EventEpoller的生命期。
该类主要就是在Loop函数中创建了一个线程,然后使用EventEpoller循环扫描其管理的事件,若有激活的对象,则首先会按照优先级放到不同队列中,然后根据事件优先级先后处理事件——根据事件类型调用其相关函数。处理完所有的事件,最后会销毁被要求销毁的事件服务对象实体。

(4)EventMaster

  该类有三个职责:
  一是循环监听端口,当有一个新连接到来时,首先使用用户实现的工厂实例化事件服务对象,然后调用其HandleConnectionEvent()函数,若该连接没有被关闭,则Round Robin将其插入到一个EventManager中,让该EventManager一直循环监视其事件。这样有一个好处,就是不会发生“惊群”现象,因为只在EventMaster这一个线程上进行了accept。这里还有一个问题,就是当使用的fd到达上限,即服务器无法接收新连接的时候,需要close掉那些新来的连接,显示地告诉客户端服务器不能再接收连接了。有两种做法:一是程序启动时会保留一个fd, 当fd分发满了时,就把这个保留的fd close掉然后给新的,然后立刻把新的close掉;二是设置fd上限(小于系统设置的上限值),超过了就把该连接close掉。本网络库采用的第二种方法,因为第一种存在竞态,测试结果不太好,故使用了第二种。每次来了一个新连接会先判断fd是不是大于上限值,大于了就close。
  二是管理EventManager生命周期,负责EventManager的创建与销毁。
  三是负责线程池工具实体的创建与销毁,这里还有另外一层含义,即线程池工具可以理解为是全局唯一的,因为它仅仅在EventMaster这个主线程中创建,并且不会再增加。

2、Socket

  生命期的管理一直都是一个需要考虑的重点,以内存泄漏为例,当有高并发+ 5x24h运转的需求时,一点点的内存泄露很容易就会积累,所以在这个网络库中,处处强调每个类的生命应该由谁负责。
设计这个类的初衷也不例外,是为了管理fd的生命期,防止串话。
这个类中封装了一个fd,一个引用计数,一个ip字符串,一个端口号,发生拷贝构造和转移构造时会将引用计数+1,析构时会将引用计数-1,为0时会调用::close(fd)。
  同时这个类封装了一些fd的操作,供用户使用,当然,用户更多时候还是应该调用EventService中更高层的封装。

3、定时器

  定时器主要使用的linux的timerfd_create创建的时钟fd配合一棵红黑树实现。每个EventManager中都有一个定时器对象。没有用优先队列(小根堆)的原因是考虑到未来可能会有remove定时器任务的需求,这个需求用优先队列实现比较麻烦。
  这里的红黑树(用的std::map<Time,std::function<void()>>)中存放的是时间(任务要执行的时刻)和任务函数的映射,这里有一个隐含信息,定时器中存放的不是事件服务,而是任务函数。那么,虽然Time的精度是微秒,很难有相同的,但是如果确实遇到了相同的如何解决冲突呢?此处会将新的定时器事件设置的时间+1us,如果还有冲突,继续如此。这种解决冲突的方法简单粗暴有效。
  首先,程序初始化时会timerfd_create一个timefd,然后由EventManager将该fd放进epoll中,当有地方调用RunAt函数时候,会先将新来的任务函数插入到红黑树std::map<Time,std::function<void()>>中,然后判断它是不是最近的任务,如果是的话调用timerfd_settime更新事件。
  当epoll_wait检测到定时器事件的时候,会执行TimerEventService(EventService的子类对象)中的handleEvent函数,这个函数实际调用的是定时器的RunExpired函数,该函数会执行当前所有超时的任务函数,方法是每次取红黑树的最小元素与当前时间比较,到时间了就执行。另外,定时器事件在该网络库中设置的优先级是最高的。
  定时器的实现最开始还有另一个更简单的方案,就是每个EventManager中的Loop中的epoll_wait的timeout时间间隔很小,每次循环检查定时器的红黑树,超时就执行。两种方法在高并发的场景下其实区别不大,但是在并发量不大的时候,因为这种方法的epoll_wait时间间隔很小,相当于一直在Loop,很耗电。所以最后选择了库中实现的方法。
  由于定时器类型只有RunAt一个接口,所以EventManager另外对其封装了三个接口:RunAfter(),RunEvery(),RunEveryUntil()。
分别是time时间后执行,每过time时间执行,每过time时间执行,直到条件不满足停止。

4、线程池

  使用的双缓冲队列,一条队列给生产者生产,一条给线程池消费。提供一个enqueue接口,用来将任务函数放入线程池中。
  实现线程池的目的主要是为了让用户可以配合定时器将几乎所有会阻塞的任务异步执行。具体做法下面以数据库的读写举例:
有一个数据库读函数ReadDB(),一个数据库写函数WriteDB()。
  写数据库任务:这个比较简单,因为不需要返回值,直接将WriteDB放进线程池中就可以了。
  读数据库任务:这个需要配合定时器,首先在用户事件服务类中设置一个用于接收数据库数据的对象(可以是缓冲区),然后将ReadDB放进线程池,然后设置一个时间t,调用RunEveryUntil接口,让定时器过t毫秒后检查这个对象是否已经被ReadDB成功修改,若有,即可处理这个ReadDB产生的数据,若没有,则继续检查。

5、读写缓冲区

  库中对读写缓冲区进行了更高层的封装:SocketReader,SocketWritter。这样做的原因主要是职责更明细,实现起来更容易,使用起来也更方便。
  SocketReader:维护一个vector作为缓冲区,维护一个左边界下标,一个右边界下标,每次read时,会先判断当前缓冲区是否有足够用户需求的数据量,若没有,则按用户需求读取指定个数内容,然后返回给用户。当缓冲区左边一块区域都是已读数据的时候,会把后面的内容move到前面,具体什么时候move,可以在Parameter文件中设置当左边已读数据占缓冲区的多少时就会把后面的内容move到前面。
  SocketWritter:会直接尝试send,send后若还有数据没成功发出去,就把剩下内容缓存起来,并关注写事件,下次可写时再继续写。

6、日志

  日志系统分为前端和后端,前端为生产者,负责将日志信息写进日志系统,后端为消费者,将日志信息写进磁盘。Kikilib的日志系统用的环形队列,可在参数文件中设置环形队列的长度(需要是2的n次幂),当队列满了会放弃当前日志消息(因为这时候如陈硕老师所说,早期的日志信息更加有价值),当消息全部写进了日志文件,后端会阻塞,有消息时会唤醒。值得一提的是,这里使用了disruptor的思想,即隔离生产者与消费者,最终实现lock free的方法。因为是lock free的,测试得到效率比原来的双缓存队列要快一倍左右。
  磁盘中的日志文件会有两个,Log.txt0,Log.txt1,当一个文件写满(参数设定的最大占用磁盘大小)了之后,会丢弃掉另一个文件,重写写另一个文件。这里的0,1并不代表新旧关系。在日志文件的第一行会记录当前日志文件是服务器本次开机运行至今记录的第n个文件。

四、测试

  首先对函数功能进行了测试,可以参看test文件夹的工程,加上http和chatroom已经将所有函数都使用上了。
  其次对http(解析了GET方法,读取html文件并发送)进行了压力测试,轻松抗住10000client,四核3.7GHz机器,QPS两万多:
图片11.png

五、遇到的问题mark

1、SIGPIPE信号
在send函数中的flag位用了MSG_NOSIGNAL,这样对方不会发信号过来。

2、日志写爆磁盘导致core dump
加入两个日志文件,估算已写大小,满了覆盖重写。

3、在云服务器上bind不成功
云服务器的公网ip不是本机ip,不能显示绑定,换用INANY_ADDR即可。

4、自旋的空while会被编译器优化掉
在while里面加上一个volatile变量。

5、多个线程都会访问的同一个变量一定一定一定要加volatile!!!!血的教训呀,因为编译器会做优化,认为这个值在这个函数中没有被修改就认为这个值在当前函数中不变,殊不知值已经被其它线程修改了,今天被这个坑惨了,调了一晚上这个bug才找到问题。

六、后续

老哥们有什么问题,bug,需求,都欢迎加我微信liuyukang315反馈,毕竟一个人很难做到面面俱到~
mark一下下个版本V0.03想改的地方,持续更新:
1、加入对象池。√
2、加入负载均衡策略,每次的新连接派发给任务最少的EventManager。√
3、持续debug和调优。
4、增加性能测试报告。