/WEBSERVER

使用IO多路复用(Epoll)和线程池技术实现的reactor模式的高性能WEB服务器,使用nginx负载均衡实现了集群,并使用了webbenchh进行了压力测试

Primary LanguageC++

webserver

该项目是用C++11开发的WEB集群服务器。可以处理浏览器的请求并返回响应的网站页面,实现了与Mysql交互的用户注册以及登陆操作,利用Nginx实现了集群服务器的负载均衡,并使用webbench进行了压力测试

主要工作流程

  • 主要工作
    1. 利用IO多路复用Epoll,双模式线程池实现了Reactor事件处理模式***(通过GDB调试解决了线程池回收资源时产生的死锁问题)***
    2. 利用正则与状态机解析HTTP请求报文,实现处理静态资源的请求
    3. 利用标准库容器实现自动增长的缓冲区以及基于小根堆实现的定时器,可以关闭超时的非活动连接
    4. 利用单例模式实现了动态数据库连接池以及用户注册登录操作(*通过GDB调试解决了配置文件读取错误的问题*)
    5. 利用Nginx中的轮询算法实现了集群服务器的负载均衡

这个轻量级的web服务器是用一个单reactor模型实现的,即主线程持续监听新连接事件以及已连接的读写事件。如果有新连接事件发生时,主线程把这个连接加入已连接列表中并加入到epoll中管理;如果有已连接事件的读写事件发生时,主线程把读写事件的回调函数提交到线程池中的任务队列中并通知子线程处理任务。子线程首先把客户端的请求数据读出来,再处理业务逻辑。如果没有读到数据就继续读,如果读到数据就注册写事件;如果写完数据就(前提是keepalive)继续处理,如果没写完数据就继续写。每一个客户端都是一个httpconn对象,下属一个读缓冲区和一个写缓冲区。使用分散读可以保证数据能一次性读完,读不完就扩容;写缓冲区里面是响应头,响应体文件用文件映射获取文件的内存。执行业务流程时,判断读缓冲区有数据时,解析http请求,再生成响应信息到response中。

主要功能以及说明

利用IO多路复用Epoll,双模式线程池实现了Reactor事件处理模式(通过GDB调试解决了线程池回收资源时产生的死锁问题)

一、线程池

为了解决由于多个线程频繁创建与销毁以及多个线程之间调度引发的资源浪费的问题,我们可以提前创建好适当个数的子线程,当有任务到来时,主线程把任务放入已加锁的共享的工作队列中,并使用条件变量唤醒睡眠在工作队列中的子线程,让子线程执行任务。

  • 两个类:线程类和线程池类

    1. 线程类:线程函数对象,线程ID,构造函数(需要传一个线程函数并赋予线程对象线程ID),启动线程(创建底层的C++11线程类对象并把线程函数和线程ID传入,子线程就开始执行了)
    2. 线程池类:表示线程池的状态的数字(初始线程的数量,线程数量的上限阈值,空闲线程数量),线程池工作模式(固定个数,动态变化),表示线程池启动状态
  • 两个容器:存放任务的队列,线程号-线程对象哈希表

    1. 任务队列:任务队列是由数量上限阈值的。使用C++11的mutex与condition_variable,可以使用lock_guard与unique_lock这两个类,当创建对象时,就已加锁,当对象析构时会自动解锁,这样可以避免忘记解锁而造成死锁,其中unique_lock可以加锁解锁比较自由,创建条件变量时需要使用。提交任务的方法设计成了可变参模板,用户可以提交任意参数类型和个数的函数对象,但是任务队列中的任务是无参返回值为空的函数对象,那如何把任务放在任务队列中呢?首先把任务函数和参数用packaged_task打包再用智能指针包起来,然后在任务插入到任务队列的时候使用lambda表达式把这个智能指针解引用后的函数对象调用一下!

      submitTask(Func&& func, Args&&... args)
      taskQue_.emplace([task]() { (*task)(); });
    2. 哈希表:在回收线程资源时,可以把对应线程ID的线程从表中删除掉

  • 三个条件变量:任务队列不满、任务队列不空、资源是否回收

    1. 当用户把任务放进任务队列中,证明队列是不满的,此时唤醒不空的条件变量让线程执行任务,任务完成后,唤醒不满的条件变量让他继续提交任务。注意的一点是,当一个线程拿到互斥锁后等待在条件变量时,这个线程会把互斥锁释放掉供其他线程去抢,当条件变量唤醒时,这个线程会继续拿到锁。
    2. 当线程池析构的时候,要等待全部线程资源回收后再退出(线程函数return就表示子线程退出了),表示线程池开启状态的变量会改变,拿到互斥锁并且等待在退出的条件变量上直到线程表中的数量为0。
  • 两种模式

    1. 固定线程池的数量:一般和CPU的核心数量差不多,如果是CPU密集型的任务,比如图像处理和视频剪辑,那么线程数量和CPU核心数相等就行;如果是IO密集型的任务,线程数量一般要多于CPU的核数,比如设置为核心数的两倍。
    2. 动态变化的线程池的数量:在用户提交任务的时候,当任务队列的大小大于空闲线程数量并且现在的线程数小于线程数的阈值时,在这个线程创建新的线程对象并开启线程,在线程函数里面notEmpty等待,这个是一个wait_for,要判断返回的原因是由于超时1s返回还是由于有任务返回,因为要回收不活跃的线程的资源。如果创建的超过初始线程数的线程空闲时间(现在的时间-上一次执行完任务的时间)已经60s,那就需要回收。
  • 三个函数:提交任务、开启线程池、定义线程函数

  1. 开启线程池:使用make_unique初始化线程类的智能指针,构造函数参数为把线程函数绑定线程Id的函数对象,在开启线程。
  2. 定义线程函数:循环从队列中取任务,执行任务,任务队列为空,停在notEmpty条件变量上,如果是cache模式,使用notEmpty条件变量的wait_for函数等一秒,如果是因为超60s返回,就结束这个线程
  3. 提交任务:使用可变参数模板编程(Func&& func, Args&&... args),用户可以传入任意个数和类型的参数 c++11的线程类不能搞定返回值,所以加了一个类似function<>的函数包装器packaged_task搞定,他和function的不同在于可以通过get_future()方法获取返回值,返回值可以用future<>接收,Task就是函数对象,但是无法确定返回值,可以利用一个中间层function<void()>,
using RType = decltype(func(args...));

问题:回收线程池资源的时候发生了死锁的情况,用户把线程池类销毁的时候。

定位方法:先用ps -u发现这个进程的状态是Sl+,然后用gdb attach到死锁的线程上,info threads查看每个线程,然后bt查看线程堆栈,thread [线程号]切换到其他线程查看死锁的位置。

一开始的思路是,当线程池析构的时候,会把线程池结束标志设置为false,唤醒notEmpty条件变量,然后等待线程池里面所有的线程结束,此时线程可能有两种状态,1、在notEmpty上wait;2、执行任务中,所以需要一个条件变量exitCond去等待线程结束,如果线程容器中的size为0,可以往下走。在线程函数方,用一个循环,只要线程结束标志不为结束,就一直取任务执行任务,如果标志为结束,就结束这个线程并唤醒exitCond。但是有问题。

##########################version_1###############################

*************线程代码***************
while (isPoolRunning_) {
    Task task;--------------------------->位于这个位置,两种情况1、pool先获取锁2、thread先获取锁
    lock(taskQueMtx_);
    while (taskQue_.size() == 0) {        
        notEmpty_.wait(lock);//fix or cache  //case1
        if (!isPoolRunning_) {
            回收线程并exitCond_.notify_all();
        }
    }
    task();//case2
}
回收线程并exitCond_.notify_all();
*************线程池代码***************
~ThreadPool() {
    isPoolRunning_ = false;
    notEmpty_.notify_all();
    lock(taskQueMtx_);
    exitCond_.wait(lock, [&]() -> bool { return threads_.size() == 0; });
}

##########################version_2############################### 解决了两种死锁问题:1、把pool代码中的notify放到获取锁的前面;2、锁+双重判断,在notEmpty_.wait加一个判断,因为pool先获取锁证明isPoolRunning已经是false了。

*************线程代码***************
while (isPoolRunning_) {
    Task task;
    lock(taskQueMtx_);
    while (isPoolRunning_ && taskQue_.size() == 0) {        
        notEmpty_.wait(lock);//fix or cache  //case1
    }
    if (!isPoolRunning_) {
        break;
    }
    task();//case2
}
回收线程并exitCond_.notify_all();
*************线程池代码***************
~ThreadPool() {
    isPoolRunning_ = false;
    lock(taskQueMtx_);
    notEmpty_.notify_all();
    exitCond_.wait(lock, [&]() -> bool { return threads_.size() == 0; });
}

##########################version_3############################### 线程池对象除了作用域后,任务就不执行了

*************线程代码***************
for (;;) {
    Task task;
    lock(taskQueMtx_);
    while (taskQue_.size() == 0) {        
        if (!isPoolRunning_) {
            回收线程并exitCond_.notify_all();
        }
        notEmpty_.wait(lock);//fix or cache
    }
    task();
}
*************线程池代码***************
~ThreadPool() {
    isPoolRunning_ = false;
    lock(taskQueMtx_);
    notEmpty_.notify_all();
    exitCond_.wait(lock, [&]() -> bool { return threads_.size() == 0; });
}

二、IO复用技术epoll

主要是为了在单个线程或进程中,可以监测多个文件描述符的变化,提高并发能力。I/O复用虽然能同时监听多个文件描述符,但它本身是阻塞的。并且当多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的每一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。

  1. BIO模型:由于socket通信的中的accept和read等默认都是阻塞函数,所以单线程或进程的情况下,若有一个客户端连接并且无读事件时,其他客户端无法连接服务器,解决方法就是每一个客户端分配一个线程,但是如果客户端数量较大,此方法显然不行。

  2. NIO模型:把文件描述符都设置为非阻塞,程序就会一直轮询检查是否有客户端连接,但是还是只能一次监测一个文件描述符。

  3. IO多路复用:一次可以检测多个文件描述符。

    select:使用一个文件描述符集合检测多个文件描述符的变化。4个缺点。

    poll:解决了两个缺点。而且有较为丰富的事件类型的监测

    epoll:通过epoll_create()创建了一epoll对象,返回了一个用于操作这个内存的文件描述符,这个对象中维护了两个重要的数据结构,一个是我们要检测的文件描述符集合,是用红黑树实现的,我们可以通过epoll_ctl()向里面添加或删除需要检测的文件描述符和想要监测的事件,红黑树的遍历以及增删改查的效率在数据量大的时候是很客观的;另外一个是用一个双链表结构实现的一个就绪队列,当检测到文件描述符有事件发生时,就会把这个文件描述符添加到就绪队列中,此时用户得到的epoll_event数组中便是事件发生的文件描述符,只需遍历返回值个数即可。其中LT和ET为两种处理模式,当使用ET模式时,函数要设置为非阻塞,并且要一次性循环读取完数据。本项目对Epoll进行了封装

三、高效的事件处理模式

  1. Socket模型

    TCP Socket 调用流程是最简单、最基本的,它基本只能一对一通信,因为使用的是同步阻塞的方式,当服务端在还没处理完一个客户端的网络 I/O 时,或者 读写操作发生阻塞时,其他客户端是无法与服务端连接的。可如果我们服务器只能服务一个客户,那这样就太浪费资源了,于是我们要改进这个网络 I/O 模型,以支持更多的客户端。

  2. 多线程阻塞IO模型

    基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多线程,也就是为每个客户端分配一个线程来处理请求。当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出已连接 Socket 进程处理。上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。当一个连接对应一个线程时,线程一般采用「read -> 业务处理 -> send」的处理流程,如果当前连接没有数据可读,那么线程会阻塞在 read 操作上( socket 默认情况是阻塞 I/O),不过这种阻塞方式并不影响其他线程。但是引入了线程池,那么一个线程要处理多个连接的业务,线程在处理某个连接的 read 操作时,如果遇到没有数据可读,就会发生阻塞,那么线程就没办法继续处理其他连接的业务。要解决这一个问题,最简单的方式就是将 socket 改成非阻塞,然后线程不断地轮询调用 read 操作来判断是否有数据,这种方式虽然该能够解决阻塞的问题,但是解决的方式比较粗暴,因为轮询是要消耗 CPU 的,而且随着一个 线程处理的连接越多,轮询的效率就会越低。

  3. I/O多路复用reactor模型(NIO)

    一个线程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个线程,这就是多路复用,这种**很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。只有当连接上有数据的时候,线程才去发起读请求。I/O 多路复用技术会用一个系统调用函数来监听我们所有关心的连接,也就说可以在一个监控线程里面监控很多的连接。如果没有事件发生,线程只需阻塞在这个系统调用,而无需像前面的线程池方案那样轮训调用 read 操作来判断是否有数据;如果有事件发生,内核会返回产生了事件的连接,线程就会从阻塞状态返回,然后在用户态中再处理这些连接对应的业务即可。

    • 单线程reactor模型:以libevent, libev等event-loop库为典型。这个模型一般由一个event dispatcher等待各类事件,待事件发生后原地调用对应的event handler,全部调用完后等待更多事件,故为”loop”。这个模型的实质是把多段逻辑按事件触发顺序交织在一个系统线程中。一个event-loop只能使用一个核,故此类程序要么是IO-bound,要么是每个handler有确定的较短的运行时间(比如http server),否则一个耗时漫长的回调就会卡住整个程序,产生高延时。在实践中这类程序不适合多开发者参与,一个人写了阻塞代码可能就会拖慢其他代码的响应。由于event handler不会同时运行,不太会产生复杂的race condition,一些代码不需要锁。此类程序主要靠部署更多进程增加扩展性。

    image-20220728172315711

    • 多线程reactor模型:针对于单线程reactor模型的缺陷可以使用线程池实现多线程的reactor模型,这样就不会由于某一个回调卡住而使整个系统都无法继续运行,但是事件的分发和连接都在一个reactor上,在高并发的场景下可能会出现性能瓶颈。

    image-20220728172503670

    • 多线程主从reactor模型

      image-20220728172606987

利用正则与状态机解析HTTP请求报文,实现处理静态资源的请求

一、HTTP协议以及报文

HTTP是一个简单的请求-响应协议,首先客户端发送请求报文,服务器接受到请求报文将数据读取到readbuf和一块65k的缓存中,成功后利用状态机进行解析,得到诸如请求方法,请求资源路径以及请求头等报文信息,解析后把相应的响应报文进行封装,其中响应首行、响应头中的数据存在writebuf中,响应体为资源文件open得到一个文件描述符,通过内存映射得到一个指向映射的那块内存的指针。

其中报文由首行、头、空行、体组成,每行由/r/n结束。请求首行由请求方法(GET POST)、URL、等组成;响应首行由状态码(200 400 500)、状态码描述等组成。每个客户端信息对应一个httpconn对象,由一个哈希表以文件描述符作为key存储,其中用户个数用一个静态原子变量来存储,并封装了一个httprequest类和httpresponse类。

二、有限状态机

不同的报文字段类型对应一种状态,每种状态都对应业务逻辑,用while循环再用switch判断,当一种业务逻辑完成后,需要改变状态。比如先解析请求首行,成功时将状态改变为请求头,依次下去解析,同时调整readpos的位置。

三、正则表达式

描述了一种字符串匹配的模式(pattern),可以用来检查一个串是否含有某种子串、将匹配的子串替换或者从某个串中取出符合某个条件的子串。

利用标准库容器封装char,实现自动增长的缓冲区

每一个客户端对应一个httpconn对象,每个httpconn中又有封装的readbuf\writebuf,其中有一个具体装数据的vector初始设置为1k,用于把内核中的数据与用户区数据的交互,定义了两个索引变量readpos和writepos用于确定读写的位置。

  1. 在read过程中,又定义了一个65k的buff,保证能把所有的数据读出来。使用readv指定以上两块内存地址,在readbuf数据满了之后,把剩余的数据读到buff中。若buff中的数据大小够被readbuf装下(若可写的部分能装下就直接装,如不能,需要把已经解析的位置也用来装,先把数据向前移动解析的位置,在装),直接复制,若不能,则扩容buff数据的大小后再复制。
  2. 在write过程中,由于有两块内存,所以使用writev将数据写入。如果写到了第1块内存,调整下次写的位置与长度,并将readpos移动已写的长度;如果写到第二块内存,表明数据都写出去了,调整下次写的位置与长度,并将readpos和writepos都置为0。

基于小根堆实现的定时器,关闭超时的非活动连接

小根堆是由一个二叉树结构所维护的,它满足每一个子树的最小值都是头结点的值,通过heapinsert和heapity调整小根堆的结构。每一个客户端都有一个时间戳结构体,其里面维护关闭连接的回调函数、超时时间以及文件描述符等。当有新的客户端连接进来时会将其加入堆结构;当已连接的客户端有数据可写或可读时,会重新调整定时器的超时时间,小根堆会根据超时时间调整其在堆结构中的位置。

每次在epoll_wait之前要确定他的阻塞时长,如果不阻塞无法关闭非活动的连接,如果阻塞就会一直调用从而浪费cpu性能;所以我们要通过一个超时时间来设定他的阻塞时长,那么这个超时时间应该如何计算呢?那就是在调用epoll_wait前,首先把所有的超时的非活动连接全部关闭,此时经过小根堆调整之后,数组中的第一个元素就是距离超时时间最近的一个连接,用这个连接的时间减去当前时间就时需要的阻塞时间。

关闭连接是怎么做的呢?循环从小根堆里拿出第一个元素,将他的超时时间减去当前时间,如果大于零证明还没有到超时时间,若小于零则执行关闭连接的回调函数。

利用单例模式与阻塞队列实现异步的日志系统,记录服务器运行状态

日志的功能本质就是向文件中写数据,如果是同步的日志,当执行日志写数据的时候,不能向下执行程序,所以可以用异步的日志,即使用子线程维护一个队列去写数据

利用单例模式实现了数据库连接池,减少数据库连接建立与关闭的开销,同时实现了用户注册登录功能

频繁的数据库连接销毁操作会浪费内存资源,降低程序运行效率,所以预先准备好一个数据库连接池,可以提升程序运行的效率。

首先将MYSQL的一系列操作封装为一个类,用户对外调用接口即可进行数据库相应的操作,比如建立连接(mysql_real_connect)、以及对数据的增删改查操作(mysql_query)。

数据库连接池对象只需要一个就可以把多个数据库连接取出来,所以使用单例设计模型,此处使用懒汉式即对象在使用时才创建,所以在多线程时需要加锁避免创建多个对象,需要将构造函数设置为私有并且私有拷贝构造函数以及=的操作符重载。在C++11中,使用静态局部变量是线程安全的,所以提供一个静态方法返回这个唯一的对象。在数据库连接池中维护了一个存储数据库连接的队列,以及各种连接需要的信息。在构造函数中,创建最小连接数个数据库连接。创建两个子线程,其中一个负责监测有没有需要生产的连接,另一个负责监测有没有销毁的连接。当数据库连接池队列中的数据库连接小于最小连接个数时,就需要创建数据库连接,否则将线程阻塞;当数据库连接队列中最前面的数据库连接的空闲时长大于最大的空闲时长,就需要销毁数据库连接,否则将线程阻塞。最后利用一个对外的接口让用户可以获取到池子中的数据库连接,当使用完毕后把数据库连接放入池子中。使用一个共享的智能指针管理数据库连接,指定它的删除器对应的处理动作,即把数据库连接放入到池子中。 在读配置文件的时候,在window上创建的.ini文件后上传到了Linux服务器,由于windows中的换行是\r\n,Linux的换行是\n,所以导致配置文件读取失败,先使用netstat -tanp | grep 3306找到了连接,发现他们都是time_wait的状态,证明有连接问题,这可以怀疑这几点,1、连接被关闭了,然后的思路呢在connection的析构函数添加打印,发现没有走到,那说明,连接池并没有主动close过连接。2、是不是连接参数有错误呢?于是我用GDB一行一行的调试,到了读取配置文件的部分,打印出了读取的变量信息,发现没有读取成功。

使用POST请求解析表单数据,并将数据提交到数据库。使用post请求时,表单数据在请求体中。

利用Ngnix实现集群服务器的负载均衡

为了提高并发能力可以使用集群部署

  1. 当集群后,需要把客户端的请求按照负载算法(轮询、加权轮询、IP哈希)分发到具体的业务服务器上
  2. 能够和业务服务器保持心跳机制,监测服务器故障
  3. 能够发现新添加的业务服务器设备,方便扩展服务器数量:平滑加载配置文件启动 集群部署的服务器之间进行通信最好的方式就是引入中间件消息队列,解耦合服务器之间的关系。 将Ngnix源码编译后,在conf中有ngnix.conf配置文件,在http中所有的80端口的请求都负载均衡到MySever中,upstream就是负载均衡模块,其中weight是权重,要添加服务器只需要在配置文件加入就行。 共享 Session 信息 通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用。例如用户一的 Session 信息被存储在服务器一,但第二次访问时用户一被分配到服务器二,这个时候服务器并没有用户一的 Session 信息,就会出现需要重复登录的问题,问题在于分布式系统每次会把请求随机分配到不同的服务器。 基于Nginx的ip_hash负载均衡 其实就是对请求过来的ip地址对你的多少台可用的服务器进行取模,然后就会把你的请求通过Nginx的反向代理给分发到对应的服务器上。(这里会把可用的服务器放到一个数组中,如果取模得到的结果是几,就把请求分到服务器数组中的下标为几的服务器上)

环境要求

  • Linux
  • C++11
  • MySql
  • Ngnix

项目启动

需要先配置好对应的数据库,然后在项目目录自动构建可执行程序。

// 建立webserver库
create database webserver;

// 创建user表
USE webserver;
CREATE TABLE user(
    username char(50) NULL,
    password char(50) NULL
)ENGINE=InnoDB;

// 添加数据
INSERT INTO user(username, password) VALUES('name', 'password');
./autobuild.sh

./webbench -c 10000 -t 10 http://121.37.5.19:8000/index.html

面试问题

  1. 讲讲WEB服务器设计思路

  2. 讲讲线程池,子线程数量怎么选择的,线程池子线程在做什么

  3. 讲讲如何停止线程池

  4. 在哪里控制线程池的终止

  5. 你这个项目如何处理黏包问题的

  6. 你的服务器在客户端超时后怎么处理的

  7. 你是如何进行HTTP报文解析的,主从状态机怎么做的

  8. 线程池中的工作线程是一直等待吗?

    是的,若有新的任务就会唤醒睡眠在工作队列中的子线程。

  9. 你的线程池工作线程处理完一个任务后的状态是什么?

    如果请求队列为空,则该线程在线程池中等待;若不为空,则该线程跟其他线程一起进行任务的竞争。

  10. 如果同时1000个客户端进行访问请求,线程数不多,怎么能及时响应处理每一个呢?