2018-02-01 16:48:35 zk3326312 阅读数 2111

高性能图像处理服务器的实现

  最近打算写一个高性能的图像处理服务器,功能大致为单帧图像或多帧图像的超分辨率恢复,并且支持远端图像存储到服务器端,支持高并发情况下的高效处理。
  大致构思了一下思路,决定服务器采用c++编写,具体架构参照陈硕大大提倡的reactors in threads的思想,然后图像传输方式以http的方式传输,本地数据库采用MySQL(可能内存数据库会选择用Redis),图像恢复算法实现用Python或者c++来实现(算法预计采用基于GAN的图像超分辨率恢复技术),具体实现架构大致如下。
  这里写图片描述
  项目托管在GitHub上,网址为https://github.com/zk3326312/ZK_ImageServer
  关于整个服务器的技术实现和技术细节,我会在我的系列博客高性能图像处理服务器里面进行详细的阐述。
  现已测试并通过的功能有:支持高并发量下文件的高效传输(并发量由于机器限制只模拟测试了10000台,均能成功连接,测试方式为客户端开启10000个线程,每个线程连接服务器并等待1s后做echo任务)。client端可以远程登录server端,并向server端上传或者从server端下载指定文件,并可以指定相应的图片进行中值滤波,锐化等基本图像处理操作,图像的超分辨率恢复功能还在编写当中。

2018-02-08 18:54:39 zk3326312 阅读数 694

传统Unix并发的解决方案(为每一个新的连接fork()一个子进程)比较适合以下的情况:并发量很小并且计算的工作量远大于一个fork()的开销,但这种方案情况比较适合长连接,不太适合短连接,并发量大时这种模式也无法满足,因此不太适合本项目。

对传统Unix并发方案差的一种小改进是thread per connection(即为每一个连接分配一个线程),这种方案比起上一种在一些场景下会好一些,比如thread的开销通常比fork()小的时候(在windows下进程开销很大,在linux下thread和fork的开销差不多),或者进程间需要有数据通讯时(进程间数据通讯很麻烦,而线程由于共享地址空间和数据空间,可以直接互相访问数据,注意写的时候要加锁)。但是,这个解决方案依然存在着一样的问题,适合长连接,不太适合短连接,无法解决高并发量下的情况。

现代服务器编程模式普遍采用reactor模式, reactor设计模式,是一种基于事件驱动的设计模式。Reactor框架是ACE各个框架中最基础的一个框架,其他框架都或多或少地用到了Reactor框架。在事件驱动的应用中,将一个或多个客户的服务请求分离(demultiplex)和调度(dispatch)给应用程序。在事件驱动的应用中,同步地、有序地处理同时接收的多个服务请求。 在reactor模式中,IO复用模型有3种,select,poll,epoll,虽然epoll在大部分情况下性能表现都远优于select和poll,但在低并发的情况下select比epoll会快一些,下面简单介绍下这3种IO复用模型,借此说明下为什么要选择epoll作为本项目的IO复用模型。

1.select

selcect的调用过程如下:
(1)使用copy_from_user从用户空间拷贝fd_set到内核空间
(2)注册回调函数__pollwait
(3)遍历所有fd,调用其对应的poll方法(对于socket,这个poll方法是sock_poll,sock_poll根据情况会调用到tcp_poll,udp_poll或者datagram_poll)
(4)以tcp_poll为例,其核心实现就是__pollwait,也就是上面注册的回调函数。
(5)__pollwait的主要工作就是把current(当前进程)挂到设备的等待队列中,不同的设备有不同的等待队列,对于tcp_poll来说,其等待队列是sk->sk_sleep(注意把进程挂到等待队列中并不代表进程已经睡眠了)。在设备收到一条消息(网络设备)或填写完文件数据(磁盘设备)后,会唤醒设备等待队列上睡眠的进程,这时current便被唤醒了。
(6)poll方法返回时会返回一个描述读写操作是否就绪的mask掩码,根据这个mask掩码给fd_set赋值。
(7)如果遍历完所有的fd,还没有返回一个可读写的mask掩码,则会调用schedule_timeout是调用select的进程(也就是current)进入睡眠。当设备驱动发生自身资源可读写后,会唤醒其等待队列上睡眠的进程。如果超过一定的超时时间(schedule_timeout指定),还是没人唤醒,则调用select的进程会重新被唤醒获得CPU,进而重新遍历fd,判断有没有就绪的fd。
(8)把fd_set从内核空间拷贝到用户空间。
select存在几大缺点:
(1)每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
(2)同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
(3)select支持的文件描述符数量太小了,默认是1024
2 poll
poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多。总的来说,poll 去掉了select中1024个链接的限制,于是要多少链接呢, 主人你开心就好。poll 从设计上来说,不再修改传入数组,不过这个要看你的平台了。但依然还是存在着高并发时轮询效率低下的问题。
3、epoll
  epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。
  对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。
  对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果,和select实现中的第7步是类似的)。
  对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。

      说到这里,再提一下epoll的ET工作模式(edge trigger边缘模式)和LT工作模式(level trigger电平模式),这两种模式有一定的区别,首先,默认情况下epoll是工作在LT模式下的,LT模式下既支持block模式,也支持non-block模式,ET模式下只支持non-block模式,ET模式是更高效的模式,它高效在哪呢?LT模式下,当epoll_wait函数检测到有事件发生并将通知应用程序,而应用程序不一定必须立即进行处理,这样epoll_wait函数再次检测到此事件的时候还会通知应用程序,直到事件被处理。而ET模式,只要epoll_wait函数检测到事件发生,通知应用程序立即进行处理,后续的epoll_wait函数将不再检测此事件。因此ET模式在很大程度上降低了同一个事件被epoll触发的次数,因此效率比LT模式高。举个例子说明,当服务器端的fd收到“OK”这个字符串时,epoll_wait会通知该fd就绪,在LT模式下,不对这个fd进行处理,在下一个循环中epoll_wait还是会通知该fd就绪,而在ET模式下,该fd只会通知一次,也就是说,第一次不进行处理的话,该fd就不会再通知了,ET模式强制编写代码时每次都对fd进行处理

总结一下这3种模型的优缺点:
(1)select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
(2)select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
      因此,本项目采用epoll作为IO复用的模型(也不是说epoll就是绝对的高效,如果并发数量小而且每个连接都比较活跃,那么select可能会更高效一点)。

 仅靠epoll就可以完成一个简单的支持高并发的服务器(在我的GitHub上有一个简单的用epoll实现的支持高并发的echo服务器,网址https://github.com/zk3326312/EpollServer/),但这样的服务器存在以下问题:如果你想修改业务逻辑,那么你就需要去修改网络部分的代码,这样非常不利于代码的扩展,如果想将业务代码抽象出来,就需要依靠reactor模式。因此,我们可以知道这种模式比起thread per connection和for per connection有以下几个优点:

1.支持高并发,能处理大量的用户连接。

2.epoll需配合非阻塞IO使用,因此,这种方案适合IO密集型的应用。

3.结合reactor模式可以将业务逻辑抽象出来,利于代码扩展。

单线程的reactor处理高并发和IO密集的任务时可以有很好的表现,但并没有发挥出现在服务器设备的多核优势,大量的用户连接时服务器的连接处理能力可能会有所下降,因此,本项目采用的是reactors in threads的方案,用一个main reactors来处理连接请求,连接成功后就将其挂载在一个sub reactors中,这和muduo和netty的方案是一样的(如果你的线程间彼此没有很多信息交互,那么采用Nginx的reactors in processes也是很好的)。总的方案框架图如下:


      
2018-02-24 23:55:08 zk3326312 阅读数 346

      在我之前的博客高性能图像处理服务器的实现(三)reactors in threads服务器模式中代码流程详解中说到了reactors模式的好处,就是可以轻松的将业务逻辑抽象出来,完全不会影响代码的整体框架,整个图像处理服务器封装在server中的ImageServer类中(server中的echo类是个简单的echo服务器,用来测试服务器的功能是否正常以及测试高并发状态下是否能正常连接),整个代码的业务逻辑只需写在ImageServer中的onMessage()中,onMessage是注册在TcpConnection中的回调函数,每当有消息到达,就会调用onMessage来处理消息。

      消息处理也有一些问题,因为是采用的非阻塞IO,于是可能存在收发的半包问题(数据未完全发送出去或完全接收到),解决这个问题的方法是在net中实现了一个NetBuffer类,这个类用来将数据先缓存下来,然后根据业务逻辑去读取数据,这里要先详细介绍下:

      每次收数据的时候不是用的read()和recv()函数,而是用的readv(),这个函数的作用是当一次收到的数据量太大时分散读到几个缓存buffer中去,这里准备了两个buffer,一个为小一点的buffer,每次读数据基本都会将数据读进小buffer,当一次收到数据太大时,就会把多的数据读到大buffer中,当小的buffer有位置时再读到小的buffer中去,这样基本保证数据的有序性,也不会因为数据量太大没法一次读完,也不会因为为了保证数据量全部读完,准备太多的buffer,浪费空间。

      NetBuffer类中有一个writeIndex和readIndex,通过这两个index来控制读写的位置,NetBuffer中实际存储的数据是用std::string来进行存储的,从NetBuffer中读取数据主要通过retrieveAsString()等系列retrieve函数来进行的。

      然后在ImageServer中有一个std::string Buffer,这个buffer用来接住从NetBuffer中的数据,只有数据相应的数据段真正的处理掉才会将NetBuffer中的数据段retrieve掉,不然只是拷贝方式的读出来,这样可以防止半包情况,比如,client发送了一个Logging消息,但是比如发送方的发送buffer满了或者接受方读取阻塞了,ImageServer中的Buffer只读到一个Lo,这时把它retrieve掉了,后续的消息也就直接作废了。

       ImageServer中把Netbuffer中的消息读出来之后,通过各种handle函数进行处理,这里说下消息格式,我的服务器设计消息格式为以特殊字符*开始再以*结束,提取中间的消息为操作,如提取到Logging操作,则调用handleLogging()函数,然后做相应的业务处理。服务器端发送数据通过其持有的TcpConnectionPtr的sendInLoop()函数,将send任务queue到eventLoop的一次循环末去处理。

       ImageServer中实现功能主要是处理登录,注册,以及上传,下载,还有图像处理任务,因此会持有一个MysqlDB类,登录注册以及上传下载都是通过MysqlDB根据用户名信息usr_name去进行寻找相应信息,其中注册功能主要是向数据库中写入用户的用户名和密码,然后为其分配一个相应的文件夹,将其记录到usr_location中,然后去相应的位置为其新建一个文件夹,用来存储该用户的所有文件,登录功能就不说了,就是根据用户的usr_name匹配其数据库中的usr_password,然后和登录密码比较,上传和下载以及图像处理功能就是指定传输文件,然后进入usr_location文件夹中去操作指定文件。

2018-02-24 23:31:58 zk3326312 阅读数 159

      服务器的数据库部分逻辑上并不复杂,所做的就是连接数据库,并对数据库做增删改查,数据库类MysqlDB在代码的database文件夹中,实现的基本功能为如下四个接口:sqlInsert(),sqlDelete(),sqlModify(),sqlQuery()对应的增删改查,还有一点基本功能比如根据用户名获取用户的存储路径,密码等函数,都很简单,下面来说下服务器端的数据库表单设计。

       数据库的名称为ImageServerDB,里面有一张BasicInfoTable的表单,表单设计如下:

       id-----usr_name-----usr_password-----usr_location-----submission_date

       其中id和usr_name为primary key,id为auto_increment, 用来做编号,设计为primary key, usr_name因为会频繁用来做查询条件(我的数据库设计几乎查询条件都是基于用户名的),并且不能有重复,所以设计为primary key,usr_password就不多说了,用来记录密码,为not null,usr_location用来记录每个用户其单独的文件夹位置,也为not null,submission_date用来记录用户上次登录的时间,可以为null。

        

2018-02-09 16:44:46 zk3326312 阅读数 331

    在我的GitHub中上传了关于高性能图像处理服务器的网络库方面的代码,网址为:https://github.com/zk3326312/ZK_ImageServer/tree/master/net,整个框架为reactors in threads的框架,总体来说就是由一个主reactors作为接受器,当有新的client连接上时将连接挂载在sub reactors上,总体框架为:


下面就以一个echo服务器为例详细的说明一下一个client从连接到处理再到关闭的过程在代码上是怎么反映的:

      首先,我先说明下一个reactor是怎么实现的,我的reactor的实现参照的是陈硕大大的one loop per thread思想,即一个线程一个循环,在代码中,我的一个eventloop就是一个reactor,每个eventloop里面持有一个Epoll类对象,每个eventloop的主循环(调用eventloop.loop()启动主循环)里面调用Epoll.process()处理已经就绪的fd(Epoll里注册在epoll_wait()里的fd)。

      然后,再从整体布局来对代码进行说明,首先main函数里新建了一个echoServer对象,一个eventLoop对象(这里称之为baseLoop),这个baseLoop就是main Reactor,他的主要任务是处理accepter中的逻辑,acceptor会通过持有baseLoop的指针的方式持有这个reactor,acceptor与baseLoop的交互方式我会在下面详细说明。echoServer中会持有一个TcpServer的对象,TcpServer是一个封装好的整个服务器处理逻辑的类,echoSever要做的就是向TcpServer中注册onConnection(连接建立时需执行的任务)回调函数和onMessage(消息处理任务)回调函数。一个TcpServer中持有了一个acceptor(用来处理连接过程),一个eventLoopThreadPool(这个eventLoop线程池就是sub reactor,使用线程池的方式可以有效的减少频繁创建新线程和新对象的开销),TcpServer的主要任务就是协调acceptor接受到的新连接,将其以轮询的方式挂载到eventLoopThreadPool中去,为实现这个功能,TcpServer实现了一个newConnection()函数,并将其以回调的方式注册到了acceptor中,当acceptor接受到新的连接后就会调用这个函数进行处理了。

      这里再详细的说明一下acceptor和baseLoop是怎样互相进行交互的,这里用到了实现的一个Channel类,每一个channel只对应一个eventLoop(channel会以指针的方式持有eventLoop),每一个channel也只对应一个fd(一个channel处理一个文件描述符,这里即连接),acceptor里面会持有一个channel,并且实现读写回调函数功能再注册到channel类中,这个channel的功能就是用来与eventLoop交互,channel中实现了enableRead(),enableWrite()等控制函数,以enableRead()为例,每当channel调用enableRead(),channel所持有的eventLoop就会对channel进行更新,将channel持有的文件描述符fd,以及channel的指针以及事件(EPOLL_IN,EPOLL_OUT等)以struct epoll_event的方式注册到eventLoop中持有的Epoll中,每当fd就绪,eventLoop就会在主循环中调用Epoll.process()函数,Epoll.process()中又会调用到Channel.handleEvent()函数,通过Channel.handleEvent(),就调用到注册到channel里的回调了。不仅是acceptor,后面会提到的TcpConnection(挂载在sub reactor上,用来处理连接建立之后的IO事件等)与其持有的eventLoop也是这样交互的,这就是典型的reactor模式的实现。整个过程大致如下图:


      然后接着上面继续详细的介绍下在acceptor连接建立好之后,系统又是怎样运作的:当连接建立好之后,acceptor会调用到newConnection()这个回调函数,这个函数的功能就是将建立好的连接fd(这里称之为connfd)挂载到sub reactor(即eventLoopThreadPool)中,那么是如何挂载的呢?这里就用到了我刚才提到的TcpConnection类,这个类的作用很简单,就是为已成功建立连接的fd约束一个eventLoop,一个channel,然后向channel中注册各种读写回调函数(TcpConnection中的读写回调函数就是在echo中实现的onConneciton(读),onMessage(消息处理,写),这样就可以将业务逻辑完全抽象到最外层,如果需要功能修改,就只需修改onConnection和onMessage,这就是reactor的好处),这就跟acceptor是一样的,都是向channel中注册读写回调,然后注册到所属的eventLoop中的Epoll,然后fd就绪后调用相应的回调函数处理。

      整个代码的功能模块如下图所示,至此,服务器的网络功能模块就基本完成了,还需注意的一点是,epoll这种IO复用方式需要配合非阻塞IO使用,那么非阻塞IO下的数据的读取和写入就需要通过一个buffer来进行缓冲,不然会出现数据读不完或没有完全发送的情况,在我的代码中实现了一个NetBuffer类,功能很简单,就是按长度读取和根据读写的长度来调整读写的位置等,这里就不赘述了。


      

没有更多推荐了,返回首页