C++ TinyWebServer项目实践

目录

  1. 1. 项目简介
  2. 2. 大纲
    1. 2.1. 1. 什么是Web Server(网络服务器)?
    2. 2.2. 2. 用户如何与你的Web服务器进行通信?
    3. 2.3. 3. Web服务器如何接收客户端发来的HTTP请求报文呢?
      1. 2.3.1. 什么是socket?
      2. 2.3.2. 什么是EPOLL I/O复用?
    4. 2.4. 4. Web服务器如何处理以及响应接收到的HTTP请求报文呢?
      1. 2.4.1. 什么是线程池?
      2. 2.4.2. 为什么要使用线程池?
      3. 2.4.3. 关于HTTP请求?
    5. 2.5. 5. 数据库连接池是如何运行的?
    6. 2.6. 6. 什么是CGI校验?
    7. 2.7. 7. 如何生成HTTP响应并返回给用户?
  3. 3. 00 Linux网络开发环境搭建
  4. 4. 01 线程同步机制封装类
    1. 4.0.1. C++的RSII机制
    2. 4.0.2. Linux中的条件变量
    3. 4.0.3. Linux中的互斥量
    4. 4.0.4. Linux中的信号量
  • 5. 02 数据库连接池
  • 6. 03 半同步半反应堆线程池
    1. 6.0.1. 服务器编程基本框架
    2. 6.0.2. 五种I/O模型
    3. 6.0.3. 事件处理模式
    4. 6.0.4. 同步I/O模拟proactor模式
    5. 6.0.5. 并发编程模式
    6. 6.0.6. 线程池
    7. 6.0.7. 静态成员变量
    8. 6.0.8. 静态成员函数
    9. 6.0.9. pthread_create陷阱
    10. 6.0.10. 线程池设计
  • 7. 04 HTTP连接处理
    1. 7.1. IO复用
      1. 7.1.1. epoll_create函数
      2. 7.1.2. epoll_ctl函数
      3. 7.1.3. epoll_wait函数
      4. 7.1.4. select/poll/epoll
      5. 7.1.5. ET、LT、EPOLLONESHOT
    2. 7.2. HTTP报文
      1. 7.2.1. 请求报文
      2. 7.2.2. 响应报文
      3. 7.2.3. 响应状态码
    3. 7.3. HTTP请求解析
      1. 7.3.1. 主从状态机
      2. 7.3.2. HTTP_CODE含义
      3. 7.3.3. 解析报文整体流程
        1. 7.3.3.1. 循环条件
        2. 7.3.3.2. CHECK_STATE_REQUESTLINE
        3. 7.3.3.3. CHECK_STATE_HEADER
        4. 7.3.3.4. CHECK_STATE_CONTENT
    4. 7.4. HTTP响应处理
      1. 7.4.1. 基础知识:
        1. 7.4.1.1. stat
        2. 7.4.1.2. mmap
        3. 7.4.1.3. iovec
        4. 7.4.1.4. writenv
      2. 7.4.2. do_request()
      3. 7.4.3. process_write()
      4. 7.4.4. http_conn::write()
  • 8. 05定时器处理非活动连接
    1. 8.1. 信号通知流程
      1. 8.1.1. 统一事件源
      2. 8.1.2. 信号处理机制
    2. 8.2. 定时器容器
  • 9. 06日志系统
    1. 9.1. 单例模式
    2. 9.2. 阻塞队列
  • 10. 07WebServer
    1. 10.1. 总流程
  • 项目简介

    源-项目地址: qinguoyi/TinyWebServer: Linux下C++轻量级Web服务器学习 (github.com)

    Linux下C++轻量级Web服务器助力初学者快速实践网络编程搭建属于自己的服务器.

    • 使用 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
    • 使用状态机解析HTTP请求报文支持解析GET和POST请求
    • 访问服务器数据库实现web端用户注册登录功能可以请求服务器图片和视频文件
    • 实现同步/异步日志系统记录服务器运行状态
    • 经Webbench压力测试可以实现上万的并发连接数据交换

    大纲

    1. 什么是Web Server网络服务器?

    一个Web Server就是一个服务器软件程序或者是运行这个服务器软件的硬件计算机其主要功能是通过HTTP协议与客户端通常是浏览器Browser进行通信来接收存储处理来自客户端的HTTP请求并对其请求做出HTTP响应返回给客户端其请求的内容文件网页等或返回一个Error信息

    2. 用户如何与你的Web服务器进行通信?

    通常用户使用Web浏览器与相应服务器进行通信在浏览器中键入域名IP地址:端口号浏览器则先将你的域名解析成相应的IP地址或者直接根据你的IP地址向对应的Web服务器发送一个HTTP请求这一过程首先要通过TCP协议的三次握手建立与目标Web服务器的连接然后HTTP协议生成针对目标Web服务器的HTTP请求报文通过TCPIP等协议发送到目标Web服务器上

    3. Web服务器如何接收客户端发来的HTTP请求报文呢?

    什么是socket?

    Web服务器端通过socket监听来自用户的请求

    #include <sys/socket.h>  
    #include <netinet/in.h>  
    /* 创建监听socket文件描述符 */  
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);  
    /* 创建监听socket的TCP/IP的IPV4 socket地址 */  
    struct sockaddr_in address;  
    bzero(&address, sizeof(address));  
    address.sin_family = AF_INET;  
    address.sin_addr.s_addr = htonl(INADDR_ANY);  /* INADDR_ANY<span class="bd-box"><h-char class="bd bd-beg"><h-inner>:</h-inner></h-char></span>将套接字绑定到所有可用的接口 */  
    address.sin_port = htons(port);
    
    int flag = 1;  
    /* SO_REUSEADDR 允许端口被重复使用 */  
    setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));  
    /* 绑定socket和它的地址 */  
    ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));    
    /* 创建监听队列以存放待处理的客户连接<span class="bd-box"><h-char class="bd bd-beg"><h-inner>,</h-inner></h-char></span>在这些客户连接被accept()之前 */  
    ret = listen(listenfd, 5);
    

    远端的很多用户会尝试去connect()这个Web Server上正在listen的这个port而监听到的这些连接会排队等待被accept()由于用户连接请求是随机到达的异步事件每当监听socketlistenfdlisten到新的客户连接并且放入监听队列我们都需要告诉我们的Web服务器有连接来了accept这个连接并分配一个逻辑单元来处理这个用户请求

    什么是EPOLL I/O复用?

    参考: 浅谈Linux IO复用 - 知乎 (zhihu.com)

    IO复用I/O Multiplexing通俗的来说同时处理多个描述符IO事件的一种技术手段这些文件描述符包括socket套接字普通文件设备文件等

    举个简单例子tcp server同时处理两个文件描述符一个是标准输入一个是tcp连接当server接收标准输入时可能会因调用fgets()而阻塞从而无法及时处理另一个tcp连接的可读事件比如有tcp client发送了数据

    如果tcp server想要同时处理多个描述符的事件可能的做法是开启多个线程或进程各自等待描述符的可读可写事件但这样一来就需要引入线程间同步和通信问题大大增加编程的复杂性

    但IO复用技术的出现可以很好的解决上述问题它直接管理多个描述符选出IO事件ready的描述符列表这种操作方式允许应用程序以较低的成本较高的效率同时管理多个描述符的IO事件

    我们在处理用户请求的同时还需要继续监听其他客户的请求并分配其另一逻辑单元来处理并发同时处理多个事件后面会提到使用线程池实现并发这里服务器通过epoll这种I/O复用技术还有select和poll来实现对监听socketlistenfd和连接socket客户请求的同时监听 注意I/O复用虽然可以同时监听多个文件描述符但是它本身是阻塞的并且当有多个文件描述符同时就绪的时候如果不采取额外措施程序则只能按顺序处理其中就绪的每一个文件描述符所以为提高效率我们将在这部分通过线程池来实现并发多线程并发为每个就绪的文件描述符分配一个逻辑单元线程来处理

    服务器程序通常需要处理三类事件I/O事件信号及定时事件有两种事件处理模式

    • Reactor模式要求主线程I/O处理单元只负责监听文件描述符上是否有事件发生可读可写若有则立即通知工作线程逻辑单元将socket可读可写事件放入请求队列交给工作线程处理
    • Proactor模式将所有的I/O操作都交给主线程和内核来处理进行读工作线程仅负责处理逻辑如主线程读完成后users[sockfd].read()选择一个工作线程来处理客户请求pool->append(users + sockfd)

    通常使用同步I/O模型epoll_wait实现Reactor使用异步I/Oaio_readaio_write实现Proactor在此项目中我们使用的是同步I/O模拟的Proactor事件处理模式

    Linux下有三种IO复用方式epollselect和poll为什么用epoll它和其他两个有什么区别呢

    • 对于select和poll来说所有文件描述符都是在用户态被加入其文件描述符集合的每次调用都需要将整个集合拷贝到内核态epoll则将整个文件描述符集合维护在内核态每次添加文件描述符的时候都需要执行一个系统调用系统调用的开销是很大的而且在有很多短期活跃连接的情况下epoll可能会慢于select和poll由于这些大量的系统调用开销
    • select使用线性表描述文件描述符集合文件描述符有上限poll使用链表来描述epoll底层通过红黑树来描述并且维护一个ready list将事件表中已经就绪的事件添加到这里在使用epoll_wait调用时仅观察这个list中有没有数据即可
    • select和poll的最大开销来自内核判断是否有文件描述符就绪这一过程每次执行select或poll调用时它们会采用遍历的方式遍历整个文件描述符集合去判断各个文件描述符是否有活动epoll则不需要去以这种方式检查当有活动产生时会自动触发epoll回调函数通知epoll文件描述符然后内核将这些就绪的文件描述符放到之前提到的ready list中等待epoll_wait调用后被处理
    • select和poll都只能工作在相对低效的LT模式下而epoll同时支持LT和ET模式
    • 综上当监测的fd数量较小且各个fd都很活跃的情况下建议使用select和poll当监听的fd数量较多且单位时间仅部分fd活跃的情况下使用epoll会明显提升性能

    从工作原理中也可以看出select与epoll的诸多不同
    优缺点对比如下

    • 支持监听的描述符数量
    • select支持的描述符数量受限因为需要遍历描述符列表多次用户空间到内核空间的内存拷贝
    • epoll可以支撑监听海量的描述符
      性能
    • select随着描述符数量的增多性能下降严重时间复杂度 > O(n)
    • epoll通过回调函数的方式在描述符增长的情况下依旧有出色的性能
      使用场景
    • select使用方式较为简单适用于描述符量级小的情况
    • epoll适用于同时监听大量的描述符

    Epoll对文件操作符的操作有两种模式LT电平触发和ET边缘触发二者的区别?

    • LT水平触发默认当时间发生时如果用户不处理的话内核会一直通知此事件
    • ET垂直触发只有当事件状态发生改变时内核才会通知如果描述符缓冲区还有未处理的数据内核下次不会再通知了因为状态没有发生改变

    4. Web服务器如何处理以及响应接收到的HTTP请求报文呢?

    参考: C++ 线程池 - 敬方的个人博客 | BY Blog (wangpengcheng.github.io)

    该项目使用线程池半同步半反应堆模式并发处理用户请求主线程负责读写工作线程线程池中的线程负责处理逻辑HTTP请求报文的解析等等 通过之前的代码我们将listenfd上到达的connection通过 accept()接收并返回一个新的socket文件描述符connfd用于和用户通信并对用户请求返回响应同时将这个connfd注册到内核事件表中等用户发来请求报文

    什么是线程池?

    这个过程是通过epoll_wait发现这个connfd上有可读事件了EPOLLIN主线程就将这个HTTP的请求报文读进这个连接socket的读缓存中users[sockfd].read()然后将该任务对象指针插入线程池的请求队列中pool->append(users + sockfd);线程池的实现还需要依靠锁机制以及信号量机制来实现线程同步保证操作的原子性

    • 所谓线程池就是一个pthread_t类型的普通数组通过pthread_create()函数创建m_thread_number线程用来执行worker()函数以执行每个请求处理函数HTTP请求的process函数通过pthread_detach()将线程设置成脱离态detached当这一线程运行结束时它的资源会被系统自动回收而不再需要在其它线程中对其进行 pthread_join() 操作
    • 操作工作队列一定要加锁locker因为它被所有线程共享
    • 我们用信号量来标识请求队列中的请求数通过m_queuestat.wait();来等待一个请求队列中待处理的HTTP请求然后交给线程池中的空闲线程来处理

    为什么要使用线程池?

    当你需要限制你应用程序中同时运行的线程数时线程池非常有用因为启动一个新线程会带来性能开销每个线程也会为其堆栈分配一些内存等为了任务的并发执行我们可以将这些任务任务传递到线程池而不是为每个任务动态开启一个新的线程

    关于HTTP请求?

    本项目中的HTTP请求的入口函数

    void http_conn::process() {  
        HTTP_CODE read_ret = process_read();  
        if(read_ret == NO_REQUEST) {  
            modfd(m_epollfd, m_sockfd, EPOLLIN);  
            return;  
        }  
        bool write_ret = process_write(read_ret);  
        if(!write_ret)  
            close_conn();  
        modfd(m_epollfd, m_sockfd, EPOLLOUT);  
    }
    

    HTTP请求报文由请求行request line请求头部header空行和请求数据四个部分组成有两种请求报文

    • GETExample
    GET /562f25980001b1b106000338.jpg HTTP/1.1  
    Host:img.mukewang.com  
    User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64)  
    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36  
    Accept:image/webp,image/*,*/*;q=0.8  
    Referer:[http://www.imooc.com/](http://www.imooc.com/)  
    Accept-Encoding:gzip, deflate, sdch  
    Accept-Language:zh-CN,zh;q=0.8  
    空行  
    请求数据为空
    
    • POSTExample注意POST的请求内容不为空
    POST / HTTP1.1  
    Host:www.wrox.com  
    User-Agent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)  
    Content-Type:application/x-www-form-urlencoded  
    Content-Length:40  
    Connection: Keep-Alive  
    空行  
    name=Professional%20Ajax&publisher=Wiley
    

    GET和POST的区别

    • 最直观的区别就是GET把参数包含在URL中POST通过request body传递参数
    • GET请求参数会被完整保留在浏览器历史记录里而POST中的参数不会被保留
    • GET请求在URL中传送的参数是有长度限制大多数浏览器通常都会限制url长度在2K个字节大多数服务器最多处理64K大小的url
    • GET产生一个TCP数据包POST产生两个TCP数据包对于GET方式的请求浏览器会把http header和data一并发送出去服务器响应200返回数据而对于POST浏览器先发送header服务器响应100指示信息—表示请求已接收继续处理continue浏览器再发送data服务器响应200 ok返回数据

    process_read()函数的作用就是将类似上述例子的请求报文进行解析因为用户的请求内容包含在这个请求报文里面只有通过解析知道用户请求的内容是什么是请求图片还是视频或是其他请求我们根据这些请求返回相应的HTML页面等项目中使用主从状态机的模式进行解析从状态机parse_line负责读取报文的一行主状态机负责对该行数据进行解析主状态机内部调用从状态机从状态机驱动主状态机每解析一部分都会将整个请求的m_check_state状态改变状态机也就是根据这个状态来进行不同部分的解析跳转的

    • parse_request_line(text)解析请求行也就是GET中的GET /562f25980001b1b106000338.jpg HTTP/1.1这一行或者POST中的POST / HTTP1.1这一行通过请求行的解析我们可以判断该HTTP请求的类型GET/POST而请求行中最重要的部分就是URL部分我们会将这部分保存下来用于后面的生成HTTP响应
    • parse_headers(text);解析请求头部GET和POST中空行以上请求行以下的部分
    • parse_content(text);解析请求数据对于GET来说这部分是空的因为这部分内容已经以明文的方式包含在了请求行中的URL部分了只有POST的这部分是有数据的项目中的这部分数据为用户名和密码我们会根据这部分内容做登录和校验并涉及到与数据库的连接

    5. 数据库连接池是如何运行的?

    在处理用户注册登录请求的时候我们需要将这些用户的用户名和密码保存下来用于新用户的注册及老用户的登录校验这种功能是服务器端通过用户键入的用户名密码和数据库中已记录下来的用户名密码数据进行校验实现的若每次用户请求我们都需要新建一个数据库连接请求结束后我们释放该数据库连接当用户请求连接过多时这种做法过于低效所以类似线程池的做法我们构建一个数据库连接池预先生成一些数据库连接放在那里供用户请求使用

    我们首先看单个数据库连接是如何生成的

    1. 使用mysql_init()初始化连接
    2. 使用mysql_real_connect()建立一个到mysql数据库的连接
    3. 使用mysql_query()执行查询语句
    4. 使用result = mysql_store_result(mysql)获取结果集
    5. 使用mysql_num_fields(result)获取查询的列数mysql_num_rows(result)获取结果集的行数
    6. 通过mysql_fetch_row(result)不断获取下一行然后循环输出
    7. 使用mysql_free_result(result)释放结果集所占内存
    8. 使用mysql_close(conn)关闭连接

    对于一个数据库连接池来讲就是预先生成多个这样的数据库连接然后放在一个链表中同时维护最大连接数MAX_CONN当前可用连接数FREE_CONN和当前已用连接数CUR_CONN这三个变量同样注意在对连接池操作时获取释放要用到锁机制因为它被所有线程共享

    6. 什么是CGI校验?

    OK弄清楚了数据库连接池的概念及实现方式我们继续回到第4部分对用户的登录及注册等POST请求服务器是如何做校验的

    CGI通用网关接口它是一个运行在Web服务器上的程序在编译的时候将相应的.cpp文件编程成.cgi文件并在主程序中调用即可通过社长的makefile文件内容也可以看出这些CGI程序通常通过客户在其浏览器上点击一个button时运行这些程序通常用来执行一些信息搜索存储等任务而且通常会生成一个动态的HTML网页来响应客户的HTTP请求我们可以发现项目中的sign.cpp文件就是我们的CGI程序将用户请求中的用户名和密码保存在一个id_passwd.txt文件中通过将数据库中的用户名和密码存到一个map中用于校验在主程序中通过execl(m_real_file, &flag, name, password, NULL);这句命令来执行这个CGI文件这里CGI程序仅用于校验并未直接返回给用户响应这个CGI程序的运行通过多进程来实现根据其返回结果判断校验结果使用pipe进行父子进程的通信子进程将校验结果写到pipe的写端父进程在读端读取

    7. 如何生成HTTP响应并返回给用户?

    通过以上操作我们已经对读到的请求做好了处理然后也对目标文件的属性作了分析若目标文件存在对所有用户可读且不是目录时则使用mmap将其映射到内存地址m_file_address并告诉调用者获取文件成功FILE_REQUEST 接下来要做的就是根据读取结果对用户做出响应了也就是到了process_write(read_ret);这一步该函数根据process_read()的返回结果来判断应该返回给用户什么响应我们最常见的就是404错误了说明客户请求的文件不存在除此之外还有其他类型的请求出错的响应具体的可以去百度然后呢假设用户请求的文件存在而且已经被mmapm_file_address这里了那么我们就将做如下写操作将响应写到这个connfd的写缓存m_write_buf中去

    case FILE_REQUEST: {  
        add_status_line(200, ok_200_title);  
        if(m_file_stat.st_size != 0) {  
            add_headers(m_file_stat.st_size);  
            m_iv[0].iov_base = m_write_buf;  
            m_iv[0].iov_len = m_write_idx;  
            m_iv[1].iov_base = m_file_address;  
            m_iv[1].iov_len = m_file_stat.st_size;  
            m_iv_count = 2;  
            bytes_to_send = m_write_idx + m_file_stat.st_size;  
            return true;  
        }  
        else {  
            const char* ok_string = "<html><body></body></html>";  
            add_headers(strlen(ok_string));  
            if(!add_content(ok_string))  
                return false;  
        }  
    }
    

    00 Linux网络开发环境搭建

    MacOS虽然也是UNIX操作系统, 但本项目中的许多库函数在Mac下并未得到支持, 所以本次项目使用Docker下的虚拟环境进行开发.

    #macos
    docker pull centos
    docker -it run centos
    
    #centos container
    yum update -y
    yum install gcc-c++
    yum install mysql
    yum install mysql-server
    
    ln -s /usr/lib64/libmysqlclient.so /usr/lib/libmysqlclient.so
    

    01 线程同步机制封装类

    C++的RSII机制

    • RAII全称是Resource Acquisition is Initialization直译过来是资源获取即初始化.
    • 在构造函数中申请分配资源在析构函数中释放资源因为C++的语言机制保证了当一个对象创建的时候自动调用构造函数当对象超出作用域的时候会自动调用析构函数所以在RAII的指导下我们应该使用类来管理资源将资源和对象的生命周期绑定
    • RAII的核心思想是将资源或者状态与对象的生命周期绑定通过C++的语言机制实现资源和状态的安全管理,智能指针是RAII最好的例子

    Linux中的条件变量

    linux中的条件变量的使用-CSDN博客

    条件变量提供了一种线程间的通知机制,当某个共享数据达到某个值时,唤醒等待这个共享数据的线程.

    • pthread_cond_init函数用于初始化条件变量

    • pthread_cond_destory函数销毁条件变量

    • pthread_cond_broadcast函数以广播的方式唤醒所有等待目标条件变量的线程

    • pthread_cond_wait函数用于等待目标条件变量.该函数调用时需要传入 mutex参数(加锁的互斥锁) ,函数执行时,先把调用线程放入条件变量的请求队列,然后将互斥锁mutex解锁,当函数成功返回为0时,互斥锁会再次被锁上. 也就是说函数内部会有一次解锁和加锁操作.

    Linux中的互斥量

    Linux线程-互斥锁-CSDN博客

    互斥锁,也成互斥量,可以保护关键代码段,以确保独占式访问.当进入关键代码段,获得互斥锁将其加锁;离开关键代码段,唤醒等待该互斥锁的线程.

    • pthread_mutex_init函数用于初始化互斥锁

    • pthread_mutex_destory函数用于销毁互斥锁

    • pthread_mutex_lock函数以原子操作方式给互斥锁加锁

    • pthread_mutex_unlock函数以原子操作方式给互斥锁解锁

    以上成功返回0失败返回errno

    Linux中的信号量

    信号量是一种特殊的变量它只能取自然数值并且只支持两种操作等待(P)和信号(V).假设有信号量SV对其的PV操作如下

    • P如果SV的值大于0则将其减一若SV的值为0则挂起执行
    • V如果有其他进行因为等待SV而挂起则唤醒若没有则将SV值加一

    信号量的取值可以是任何自然数最常用的最简单的信号量是二进制信号量只有0和1两个值.

    • sem_init函数用于初始化一个未命名的信号量

    • sem_destory函数用于销毁信号量

    • sem_wait函数将以原子操作方式将信号量减一,信号量为0时,sem_wait阻塞

    • sem_post函数以原子操作方式将信号量加一,信号量大于0时,唤醒调用sem_post的线程

    以上成功返回0失败返回errno

    02 数据库连接池

    什么是数据库连接池

    池是一组资源的集合这组资源在服务器启动之初就被完全创建好并初始化通俗来说池是资源的容器本质上是对资源的复用

    顾名思义连接池中的资源为一组数据库连接由程序动态地对池中的连接进行使用释放

    当系统开始处理客户请求的时候如果它需要相关的资源可以直接从池中获取无需动态分配当服务器处理完一个客户连接后,可以把相关的资源放回池中无需执行系统调用释放资源

    数据库访问的一般流程是什么

    当系统需要访问数据库时先系统创建数据库连接完成数据库操作然后系统断开数据库连接

    为什么要创建连接池

    从一般流程中可以看出若系统需要频繁访问数据库则需要频繁创建和断开数据库连接而创建数据库连接是一个很耗时的操作也容易对数据库造成安全隐患

    在程序初始化的时候集中创建多个数据库连接并把他们集中管理供程序使用可以保证较快的数据库读写速度更加安全可靠

    数据库连接池

    池可以看做资源的容器所以多种实现方法比如数组链表队列等这里使用单例模式和链表创建数据库连接池实现对数据库连接资源的复用

    项目中的数据库模块分为两部分其一是数据库连接池的定义其二是利用连接池完成登录和注册的校验功能具体的工作线程从数据库连接池取得一个连接访问数据库中的数据访问完毕后将连接交还连接池

    单例模式创建结合代码描述连接池的单例实现

    连接池代码实现结合代码对连接池的外部访问接口进行详解

    RAII机制释放数据库连接描述连接释放的封装逻辑

    数据库连接池

    • 单例模式保证唯一
    • list实现连接池
    • 连接池为静态大小
    • 互斥锁实现线程安全
    • connectionRAII类
    class connectionRAII{
        public:
        connectionRAII(MYSQL **con, connection_pool *connPool);
        ~connectionRAII();
    
        private:
        MYSQL *conRAII;
        connection_pool *poolRAII;
    
    };
    

    目的是实现connction的自动连接与释放, 以下显示了connectionRAII在用户代码是如何被使用, 起到了怎样的效果

    //引入RAII类前: 用户代码中需要手动获得和释放连接
    //从连接池中取出一个数据库连接  
    request->mysql = m_connPool->GetConnection();  
    //process(模板类中的方法,这里是http类)进行处理  
    request->process();  
    //将数据库连接放回连接池  
    m_connPool->ReleaseConnection(request->mysql);
    
    //引入RAII类之后: 连接不使用可以被自动释放
    connectionRAII mysqlcon(&request->mysql, m_connPool);
    request->process();
    

    03 半同步半反应堆线程池

    服务器编程基本框架

    主要由I/O单元逻辑单元和网络存储单元组成其中每个单元之间通过请求队列进行通信从而协同完成任务

    其中I/O单元用于处理客户端连接读写网络数据逻辑单元用于处理业务逻辑的线程网络存储单元指本地数据库和文件等

    五种I/O模型

    • 阻塞IO:调用者调用了某个函数等待这个函数返回期间什么也不做不停的去检查这个函数有没有返回必须等这个函数返回才能进行下一步动作

    • 非阻塞IO:非阻塞等待每隔一段时间就去检测IO事件是否就绪没有就绪就可以做其他事非阻塞I/O执行系统调用总是立即返回不管时间是否已经发生若时间没有发生则返回-1此时可以根据errno区分这两种情况对于acceptrecv和send事件未发生时errno通常被设置成eagain

    • 信号驱动IO:linux用套接口进行信号驱动IO安装一个信号处理函数进程继续运行并不阻塞当IO时间就绪进程收到SIGIO信号然后处理IO事件

    • IO复用:linux用select/poll函数实现IO复用模型这两个函数也会使进程阻塞但是和阻塞IO所不同的是这两个函数可以同时阻塞多个IO操作而且可以同时对多个读操作写操作的IO函数进行检测知道有数据可读或可写时才真正调用IO操作函数

    • 异步IO:linux中可以调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小文件偏移及通知的方式然后立即返回当内核将数据拷贝到缓冲区后再通知应用程序

    事件处理模式

    • reactor模式中主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生有的话立即通知工作线程(逻辑单元 )读写数据接受新连接及处理客户请求均在工作线程中完成通常由同步I/O实现

    • proactor模式中主线程和内核负责处理读写数据接受新连接等I/O操作工作线程仅负责业务逻辑如处理客户请求通常由异步I/O实现

    同步I/O模拟proactor模式

    由于异步I/O并不成熟实际中使用较少这里将使用同步I/O模拟实现proactor模式

    同步I/O模型的工作流程如下epoll_wait为例

    • 主线程往epoll内核事件表注册socket上的读就绪事件

    • 主线程调用epoll_wait等待socket上有数据可读

    • 当socket上有数据可读epoll_wait通知主线程,主线程从socket循环读取数据直到没有更多数据可读然后将读取到的数据封装成一个请求对象并插入请求队列

    • 睡眠在请求队列上某个工作线程被唤醒它获得请求对象并处理客户请求然后往epoll内核事件表中注册该socket上的写就绪事件

    • 主线程调用epoll_wait等待socket可写

    • 当socket上有数据可写epoll_wait通知主线程主线程往socket上写入服务器处理客户请求的结果

    并发编程模式

    并发编程方法的实现有多线程和多进程两种但这里涉及的并发模式指I/O处理单元与逻辑单元的协同完成任务的方法

    • 半同步/半异步模式

    • 领导者/追随者模式

    半同步/半反应堆并发模式是半同步/半异步的变体将半异步具体化为某种事件处理模式.

    并发模式中的同步和异步

    • 同步指的是程序完全按照代码序列的顺序执行

    • 异步指的是程序的执行需要由系统事件驱动

    半同步/半异步模式工作流程

    • 同步线程用于处理客户逻辑

    • 异步线程用于处理I/O事件

    • 异步线程监听到客户请求后就将其封装成请求对象并插入请求队列中

    • 请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象

    半同步/半反应堆工作流程以Proactor模式为例

    • 主线程充当异步线程负责监听所有socket上的事件

    • 若有新请求到来主线程接收之以得到新的连接socket然后往epoll内核事件表中注册该socket上的读写事件

    • 如果连接socket上有读写事件发生主线程从socket上接收数据并将数据封装成请求对象插入到请求队列中

    • 所有工作线程(线程池)睡眠在请求队列上当有任务到来时通过竞争如互斥锁获得任务的接管权

    线程池

    • 空间换时间,牺牲服务器的硬件资源,换取运行效率.

    • 池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源.

    • 当服务器进入正式运行阶段,开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配.

    • 当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源.

    静态成员变量

    将类成员变量声明为static则为静态成员变量与一般的成员变量不同无论建立多少对象都只有一个静态成员变量的拷贝静态成员变量属于一个类所有对象共享

    静态变量在编译阶段就分配了空间对象还没创建时就已经分配了空间放到全局静态区

    • 最好是类内声明类外初始化以免类名访问静态成员访问不到

    • 无论公有私有静态成员都可以在类外定义但私有成员仍有访问权限

    • 非静态成员类外不能初始化

    • 静态成员数据是共享的

    静态成员函数

    将类成员函数声明为static则为静态成员函数

    • 静态成员函数可以直接访问静态成员变量不能直接访问普通成员变量但可以通过参数传递的方式访问

    • 普通成员函数可以访问普通成员变量也可以访问静态成员变量

    • 静态成员函数没有this指针非静态数据成员为对象单独维护但静态成员函数为共享函数无法区分是哪个对象因此不能直接访问普通变量成员也没有this指针

    pthread_create陷阱

    static void *worker(void *args);
    
    pthread_create(m_threads[i], NULL, worker, this)!=0
    

    首先看一下该函数的函数原型
    pthread_create()函数创建线程 (biancheng.net)

    int pthread_create(pthread_t *thread,
                       const pthread_attr_t *attr,
                       void *(*start_routine) (void *),
                       void *arg);
    
    pthread_create(m_threads[i], NULL, worker, this)!=0
    //本质上工作线程的内容还是threadpool<T>::run()
    template <typename T>
    void *threadpool<T>::worker(void *arg)
    {
    threadpool *pool = (threadpool *)arg;
    pool->run();
    return pool;
    }
    

    函数原型中的第三个参数为函数指针指向处理线程函数的地址该函数要求为静态函数如果处理线程函数为类成员函数时需要将其设置为静态成员函数
    pthread_create的函数原型中第三个参数的类型为函数指针指向的线程处理函数参数类型为(void *),若线程函数为类成员函数则this指针会作为默认的参数被传进函数中从而和线程函数参数(void*)不能匹配不能通过编译

    静态成员函数就没有这个问题里面没有this指针

    线程池设计

    使用一个工作队列完全解除了主线程和工作线程的耦合关系主线程往工作队列中插入任务工作线程通过竞争来取得任务并执行它

    • 同步I/O模拟proactor模式
    • 半同步/半反应堆
    • 线程池

    线程池的设计模式为半同步/半反应堆其中反应堆具体为Proactor事件处理模式

    具体的主线程为异步线程负责监听文件描述符接收socket新连接若当前监听的socket发生了读写事件然后将任务插入到请求队列工作线程从请求队列中取出任务完成读写数据的处理

    04 HTTP连接处理

    IO复用

    一文看懂IO多路复用 - 知乎 (zhihu.com)
    Linux高性能服务器编程第九章 I/O复用-CSDN博客

    epoll涉及的知识较多这里仅对API和基础知识作介绍更多资料请查阅资料或查阅游双的Linux高性能服务器编程 第9章 I/O复用

    epoll_create函数

    #include <sys/epoll.h>
    int epoll_create(int size)
    

    创建一个指示epoll内核事件表的文件描述符该描述符将用作其他epoll系统调用的第一个参数size不起作用

    epoll_ctl函数

    #include <sys/epoll.h>
    int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
    

    该函数用于操作内核事件表监控的文件描述符上的事件注册修改删除

    • epfd为epoll_creat的句柄

    • op表示动作用3个宏来表示

    • EPOLL_CTL_ADD (注册新的fd到epfd)

    • EPOLL_CTL_MOD (修改已经注册的fd的监听事件)

    • EPOLL_CTL_DEL (从epfd删除一个fd)

    • event告诉内核需要监听的事件

    上述event是epoll_event结构体指针类型表示内核所监听的事件具体定义如下

    struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */};
    
    • events描述事件类型其中epoll事件类型有以下几种

    • EPOLLIN表示对应的文件描述符可以读包括对端SOCKET正常关闭

    • EPOLLOUT表示对应的文件描述符可以写

    • EPOLLPRI表示对应的文件描述符有紧急的数据可读这里应该表示有带外数据到来

    • EPOLLERR表示对应的文件描述符发生错误

    • EPOLLHUP表示对应的文件描述符被挂断

    • EPOLLET将EPOLL设为边缘触发(Edge Triggered)模式这是相对于水平触发(Level Triggered)而言的

    • EPOLLONESHOT只监听一次事件当监听完这次事件之后如果还需要继续监听这个socket的话需要再次把这个socket加入到EPOLL队列里

    epoll_wait函数

    #include <sys/epoll.h>
    int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout)
    

    该函数用于等待所监控文件描述符上有事件的产生返回就绪的文件描述符个数

    • events用来存内核得到事件的集合

    • maxevents告之内核这个events有多大这个maxevents的值不能大于创建epoll_create()时的size

    • timeout是超时时间

    • -1阻塞

    • 0立即返回非阻塞

    • 0指定毫秒

    • 返回值成功返回有多少文件描述符就绪时间到时返回0出错返回-1

    select/poll/epoll

    • 调用函数

    • select和poll都是一个函数epoll是一组函数

    • 文件描述符数量

    • select通过线性表描述文件描述符集合文件描述符有上限一般是1024但可以修改源码重新编译内核不推荐

    • poll是链表描述突破了文件描述符上限最大可以打开文件的数目

    • epoll通过红黑树描述最大可以打开文件的数目可以通过命令ulimit -n number修改仅对当前终端有效

    • 将文件描述符从用户传给内核

    • select和poll通过将所有文件描述符拷贝到内核态每次调用都需要拷贝

    • epoll通过epoll_create建立一棵红黑树通过epoll_ctl将要监听的文件描述符注册到红黑树上

    • 内核判断就绪的文件描述符

    • select和poll通过遍历文件描述符集合判断哪个文件描述符上有事件发生

    • epoll_create时内核除了帮我们在epoll文件系统里建了个红黑树用于存储以后epoll_ctl传来的fd外还会再建立一个list链表用于存储准备就绪的事件当epoll_wait调用时仅仅观察这个list链表里有没有数据即可

    • epoll是根据每个fd上面的回调函数(中断函数)判断只有发生了事件的socket才会主动的去调用 callback函数其他空闲状态socket则不会若是就绪事件插入list

    • 应用程序索引就绪文件描述符

    • select/poll只返回发生了事件的文件描述符的个数若知道是哪个发生了事件同样需要遍历

    • epoll返回的发生了事件的个数和结构体数组结构体包含socket的信息因此直接处理返回的数组即可

    • 工作模式

    • select和poll都只能工作在相对低效的LT模式下

    • epoll则可以工作在ET高效模式并且epoll还支持EPOLLONESHOT事件该事件能进一步减少可读可写和异常事件被触发的次数 

    • 应用场景

    • 当所有的fd都是活跃连接使用epoll需要建立文件系统红黑书和链表对于此来说效率反而不高不如selece和poll

    • 当监测的fd数目较小且各个fd都比较活跃建议使用select或者poll

    • 当监测的fd数目非常大成千上万且单位时间只有其中的一部分fd处于就绪状态这个时候使用epoll能够明显提升性能

    ETLTEPOLLONESHOT

    • LT水平触发模式

    • epoll_wait检测到文件描述符有事件发生则将其通知给应用程序应用程序可以不立即处理该事件

    • 当下一次调用epoll_wait时epoll_wait还会再次向应用程序报告此事件直至被处理

    • ET边缘触发模式

    • epoll_wait检测到文件描述符有事件发生则将其通知给应用程序应用程序必须立即处理该事件

    • 必须要一次性将数据读取完使用非阻塞I/O读取到出现eagain

    • EPOLLONESHOT

    • 一个线程读取某个socket上的数据后开始处理数据在处理过程中该socket上又有新数据可读此时另一个线程被唤醒读取此时出现两个线程处理同一个socket

    • 我们期望的是一个socket连接在任一时刻都只被一个线程处理通过epoll_ctl对该文件描述符注册epolloneshot事件一个线程处理socket时其他线程将无法处理当该线程处理完后需要通过epoll_ctl重置epolloneshot事件

    HTTP报文

    HTTP报文分为请求报文和响应报文两种每种报文必须按照特有格式生成才能被浏览器端识别
    其中浏览器端向服务器发送的为请求报文服务器处理后返回给浏览器端的为响应报文

    请求报文

    HTTP请求报文由请求行request line请求头部header空行和请求数据四个部分组成
    其中请求分为两种GET和POST具体的

    • GET
    1    GET /562f25980001b1b106000338.jpg HTTP/1.1 
    2    Host:img.mukewang.com 
    3    User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64) 4    AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36 5    Accept:image/webp,image/*,*/*;q=0.8 
    6    Referer:http://www.imooc.com/ 
    7    Accept-Encoding:gzip, deflate, sdch 
    8    Accept-Language:zh-CN,zh;q=0.8 9    空行10    请求数据为空
    
    • POST
    1    POST / HTTP1.1
    2    Host:www.wrox.com
    3    UserAgent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
    4    Content-Type:application/x-www-form-urlencoded
    5    Content-Length:40
    6    Connection: Keep-Alive
    7    空行
    8    name=Professional%20Ajax&publisher=Wiley
    

    响应报文

    HTTP响应也由四个部分组成分别是状态行消息报头空行和响应正文

    1 HTTP/1.1 200 OK 
    2 Date: Fri, 22 May 2009 06:07:21 GMT 
    3 Content-Type: text/html; charset=UTF-8 
    4 空行 
    5<html> 
    6<head></head> 
    7      <body> 
    8            <!--body goes here--> 
    9      </body>
    10</html>
    
    • 状态行由HTTP协议版本号 状态码 状态消息 三部分组成
      第一行为状态行HTTP/1.1表明HTTP版本为1.1版本状态码为200状态消息为OK

    • 消息报头用来说明客户端要使用的一些附加信息
      第二行和第三行为消息报头Date:生成响应的日期和时间Content-Type:指定了MIME类型的HTML(text/html),编码类型是UTF-8

    • 空行消息报头后面的空行是必须的

    • 响应正文服务器返回给客户端的文本信息空行后面的html部分为响应正文

    响应状态码

    HTTP有5种类型的状态码具体的

    • 1xx指示信息–表示请求已接收继续处理

    • 2xx成功–表示请求正常处理完毕

    • 200 OK客户端请求被正常处理

    • 206 Partial content客户端进行了范围请求

    • 3xx重定向–要完成请求必须进行更进一步的操作

    • 301 Moved Permanently永久重定向该资源已被永久移动到新位置将来任何对该资源的访问都要使用本响应返回的若干个URI之一

    • 302 Found临时重定向请求的资源现在临时从不同的URI中获得

    • 4xx客户端错误–请求有语法错误服务器无法处理请求

    • 400 Bad Request请求报文存在语法错误

    • 403 Forbidden请求被服务器拒绝

    • 404 Not Found请求不存在服务器上找不到请求的资源

    • 5xx服务器端错误–服务器处理请求出错

    • 500 Internal Server Error服务器在执行请求时出现错误

    HTTP请求解析

    有限状态机是一种抽象的理论模型它能够把有限个变量描述的状态变化过程以可构造可验证的方式呈现出来比如封闭的有向图

    有限状态机可以通过if-else,switch-case和函数指针来实现从软件工程的角度看主要是为了封装逻辑

    首先对http报文处理的流程进行简要介绍然后具体介绍http类的定义和服务器接收http请求的具体过程

    • 浏览器端发出http连接请求主线程创建http对象接收请求并将所有数据读入对应buffer将该对象插入任务队列工作线程从任务队列中取出一个任务进行处理

      • 工作线程取出任务后调用process_read函数通过主从状态机对请求报文进行解析

      • 解析完之后跳转do_request函数生成响应报文通过process_write写入buffer返回给浏览器端

    在HTTP报文中每一行的数据由\r\n作为结束字符空行则是仅仅是字符\r\n因此可以通过查找\r\n将报文拆解成单独的行进行解析项目中便是利用了这一点

    从状态机负责读取buffer中的数据将每行数据末尾的\r\n置为\0\0并更新从状态机在buffer中读取的位置m_checked_idx以此来驱动主状态机解析

    • 从状态机从m_read_buf中逐字节读取判断当前字节是否为\r

      • 接下来的字符是\n将\r\n修改成\0\0将m_checked_idx指向下一行的开头则返回LINE_OK

      • 接下来达到了buffer末尾表示buffer还需要继续接收返回LINE_OPEN

      • 否则表示语法错误返回LINE_BAD

    • 当前字节不是\r判断是否是\n一般是上次读取到\r就到了buffer末尾没有接收完整再次接收时会出现这种情况

      • 如果前一个字符是\r则将\r\n修改成\0\0将m_checked_idx指向下一行的开头则返回LINE_OK
    • 当前字节既不是\r也不是\n

      • 表示接收不完整需要继续接收返回LINE_OPEN

    主从状态机

    流程图和代码分别对状态机和服务器解析请求报文进行详解
    流程图部分描述主从状态机调用关系与状态转移过程
    代码部分结合代码对http请求报文的解析进行详解

    • 主状态机

    三种状态标识解析位置

    • CHECK_STATE_REQUESTLINE解析请求行

    • CHECK_STATE_HEADER解析请求头

    • CHECK_STATE_CONTENT解析消息体仅用于解析POST请求

    • 从状态机

    三种状态标识解析一行的读取状态

    • LINE_OK完整读取一行

    • LINE_BAD报文语法有误

    • LINE_OPEN读取的行不完整

    在http请求接收部分会涉及到init和read_once函数但init仅仅是对私有成员变量进行初始化不用过多讲解

    这里对read_once进行介绍read_once读取浏览器端发送来的请求报文直到无数据可读或对方关闭连接读取到m_read_buffer中并更新m_read_idx

    HTTP_CODE含义

    表示HTTP请求的处理结果在头文件中初始化了八种情形在报文解析时只涉及到四种

    • NO_REQUEST: 请求不完整需要继续读取请求报文数据

    • GET_REQUEST: 获得了完整的HTTP请求

    • BAD_REQUEST: HTTP请求报文有语法错误

    • INTERNAL_ERROR: 服务器内部错误该结果在主状态机逻辑switch的default下一般不会触发

    解析报文整体流程

    process_read通过while循环将主从状态机进行封装对报文的每一行进行循环处理

    循环条件
    while((m_check_state==CHECK_STATE_CONTENT && line_status==LINE_OK)||((line_status=parse_line())==LINE_OK))
    

    在GET请求报文中每一行都是\r\n作为结束所以对报文进行拆解时仅用从状态机的状态line_status=parse_line())==LINE_OK语句即可

    在POST请求报文中消息体的末尾没有任何字符所以不能使用从状态机的状态这里转而使用主状态机的状态作为循环入口条件

    • 判断条件

      • 主状态机转移到CHECK_STATE_CONTENT该条件涉及解析消息体

      • 从状态机转移到LINE_OK该条件涉及解析请求行和请求头部

      • 两者为或关系当条件为真则继续循环否则退出

    • 循环体

      • 从状态机读取数据

      • 调用get_line函数通过m_start_line将从状态机读取数据间接赋给text

      • 主状态机解析text

    解析完消息体后报文的完整解析就完成了但此时主状态机的状态还是CHECK_STATE_CONTENT也就是说符合循环入口条件还会再次进入循环这并不是我们所希望的

    为此增加了该语句并在完成消息体解析后将line_status变量更改为LINE_OPEN此时可以跳出循环完成报文解析任务

    CHECK_STATE_REQUESTLINE

    主状态机初始状态是CHECK_STATE_REQUESTLINE通过调用从状态机来驱动主状态机在主状态机进行解析前从状态机已经将每一行的末尾\r\n符号改为\0\0以便于主状态机直接取出对应字符串进行处理

    • CHECK_STATE_REQUESTLINE

      • 主状态机的初始状态调用parse_request_line函数解析请求行
      • 解析函数从m_read_buf中解析HTTP请求行获得请求方法目标URL及HTTP版本号
      • 解析完成后主状态机的状态变为CHECK_STATE_HEADER

    解析完请求行后主状态机继续分析请求头在报文中请求头和空行的处理使用的同一个函数这里通过判断当前的text首位是不是\0字符若是则表示当前处理的是空行若不是则表示当前处理的是请求头

    CHECK_STATE_HEADER
    • CHECK_STATE_HEADER

      • 调用parse_headers函数解析请求头部信息

      • 判断是空行还是请求头若是空行进而判断content-length是否为0如果不是0表明是POST请求则状态转移到CHECK_STATE_CONTENT否则说明是GET请求则报文解析结束

      • 若解析的是请求头部字段则主要分析connection字段content-length字段其他字段可以直接跳过各位也可以根据需求继续分析

      • 若解析的是请求头部字段则主要分析connection字段content-length字段其他字段可以直接跳过各位也可以根据需求继续分析 connection字段判断是keep-alive还是close决定是长连接还是短连接

      • content-length字段这里用于读取post请求的消息体长度

    如果仅仅是GET请求如项目中的欢迎界面那么主状态机只设置之前的两个状态足矣

    因为在上篇推文中我们曾说道GET和POST请求报文的区别之一是有无消息体部分GET请求没有消息体当解析完空行之后便完成了报文的解析

    但后续的登录和注册功能为了避免将用户名和密码直接暴露在URL中我们在项目中改用了POST请求将用户名和密码添加在报文中作为消息体进行了封装

    为此我们需要在解析报文的部分添加解析消息体的模块

    CHECK_STATE_CONTENT
    • 仅用于解析POST请求调用parse_content函数解析消息体

    • 用于保存post请求消息体为后面的登录和注册做准备

    HTTP响应处理

    基础知识:

    介绍statmmapiovecwritev

    stat

    stat函数用于取得指定文件的文件属性并将文件属性存储在结构体stat里这里仅对其中用到的成员进行介绍

    #include <sys/stat.h>
    
    //获取文件属性<span class="bd-box"><h-char class="bd bd-beg"><h-inner>,</h-inner></h-char></span>存储在statbuf中
    int stat(const char *pathname, struct stat *statbuf);
    
    //完整定义很长, 仅介绍用到的
    struct stat   
    {  
       mode_t    st_mode;        /* 文件类型和权限 */  
       off_t     st_size;        /* 文件大小<span class="bd-box"><h-char class="bd bd-beg"><h-inner>,</h-inner></h-char></span>字节数*/  
    };
    
    stat(m_real_file, &m_file_stat)<0
    
    mmap

    用于将一个文件或其他对象映射到内存提高文件的访问速度

    void* mmap(void* start,size_t length,int prot,int flags,int fd,off_t offset);
    int munmap(void* start,size_t length);
    
    • start映射区的开始地址设置为0时表示由系统决定映射区的起始地址

    • length映射区的长度

    • prot期望的内存保护标志不能与文件的打开模式冲突

      • PROT_READ 表示页内容可以被读取
    • flags指定映射对象的类型映射选项和映射页是否可以共享

      • MAP_PRIVATE 建立一个写入时拷贝的私有映射内存区域的写入不会影响到原文件
    • fd有效的文件描述符一般是由open()函数返回

    • off_toffset被映射对象内容的起点

    iovec

    定义了一个向量元素通常这个结构用作一个多元素的数组

    struct iovec
    {
      void *iov_base;	/* Pointer to data.  */
      size_t iov_len;	/* Length of data.  */
    };
    
    • iov_base指向数据的地址
    • iov_len表示数据的长度
    writenv

    writev函数用于在一次函数调用中写多个非连续缓冲区有时也将这该函数称为聚集写

    #include <sys/uio.h>
    ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
    
    • filedes表示文件描述符

    • iov为前述io向量机制结构体iovec

    • iovcnt为结构体的个数

    若成功则返回已写的字节数若出错则返回-1writev以顺序iov[0]iov[1]iov[iovcnt-1]从缓冲区中聚集输出数据writev返回输出的字节总数通常它应等于所有缓冲区长度之和

    特别注意 循环调用writev时需要重新处理iovec中的指针和长度该函数不会对这两个成员做任何处理writev的返回值为已写的字节数但这个返回值实用性并不高因为参数传入的是iovec数组计量单位是iovcnt而不是字节数我们仍然需要通过遍历iovec来计算新的基址另外写入数据的结束点可能位于一个iovec的中间某个位置因此需要调整临界iovec的io_base和io_len

    do_request()

    浏览器端发出HTTP请求报文服务器端接收该报文并调用process_read对其进行解析根据解析结果HTTP_CODE进入相应的逻辑和模块

    其中服务器子线程完成报文的解析与响应主线程监测读写事件调用read_oncehttp_conn::write完成数据的读取与发送

    process_read函数的返回值是对请求的文件分析后的结果一部分是语法错误导致的BAD_REQUEST一部分是do_request的返回结果.该函数将网站根目录和url文件拼接然后通过stat判断该文件属性另外为了提高访问速度通过mmap进行映射将普通文件映射到内存逻辑地址

    为了更好的理解请求资源的访问流程这里对各种各页面跳转机制进行简要介绍其中浏览器网址栏中的字符url可以将其抽象成ip:port/xxxxxx通过html文件的action属性进行设置

    m_url为请求报文中解析出的请求资源以/开头也就是/xxx项目中解析后的m_url有8种情况

    m_url为请求报文中解析出的请求资源以/开头也就是/xxx项目中解析后的m_url有8种情况

    • /

    • GET请求跳转到judge.html即欢迎访问页面

    • /0

    • POST请求跳转到register.html即注册页面

    • /1

    • POST请求跳转到log.html即登录页面

    • /2CGISQL.cgi

    • POST请求进行登录校验

    • 验证成功跳转到welcome.html即资源请求成功页面

    • 验证失败跳转到logError.html即登录失败页面

    • /3CGISQL.cgi

    • POST请求进行注册校验

    • 注册成功跳转到log.html即登录页面

    • 注册失败跳转到registerError.html即注册失败页面

    • /5

    • POST请求跳转到picture.html即图片请求页面

    • /6

    • POST请求跳转到video.html即视频请求页面

    • /7

    • POST请求跳转到fans.html即关注页面

    process_write()

    根据do_request的返回状态服务器子线程调用process_writem_write_buf中写入响应报文

    • add_status_line函数添加状态行http/1.1 状态码 状态消息

    • add_headers函数添加消息报头内部调用add_content_length和add_linger函数

    • content-length记录响应报文长度用于浏览器端判断服务器是否发送完数据

    • connection记录连接状态用于告诉浏览器端保持长连接

    • add_blank_line添加空行

    上述涉及的5个函数均是内部调用add_response函数更新m_write_idx指针和缓冲区m_write_buf中的内容

    响应报文分为两种一种是请求文件的存在通过io向量机制iovec声明两个iovec第一个指向m_write_buf第二个指向mmap的地址m_file_address一种是请求出错这时候只申请一个iovec指向m_write_buf

    • iovec是一个结构体里面有两个元素指针成员iov_base指向一个缓冲区这个缓冲区是存放的是writev将要发送的数据

    • 成员iov_len表示实际写入的长度

    http_conn::write()

    服务器子线程调用process_write完成响应报文随后注册epollout事件服务器主线程检测写事件并调用http_conn::write函数将响应报文发送给浏览器端

    该函数具体逻辑如下

    在生成响应报文时初始化byte_to_send包括头部信息和文件数据大小通过writev函数循环发送响应报文数据根据返回值更新byte_have_send和iovec结构体的指针和长度并判断响应报文整体是否发送成功

    • 若writev单次发送成功更新byte_to_send和byte_have_send的大小若响应报文整体发送成功,则取消mmap映射,并判断是否是长连接.

      • 长连接重置http类实例注册读事件不关闭连接

      • 短连接直接关闭连接

    • 若writev单次发送不成功判断是否是写缓冲区满了

      • 若不是因为缓冲区满了而失败取消mmap映射关闭连接

      • 若eagain则满了更新iovec结构体的指针和长度并注册写事件等待下一次写事件触发当写缓冲区从不可写变为可写触发epollout因此在此期间无法立即接收到同一用户的下一请求但可以保证连接的完整性

    05定时器处理非活动连接

    由于非活跃连接占用了连接资源严重影响服务器的性能通过实现一个服务器定时器处理这种非活跃连接释放连接资源利用alarm函数周期性地触发SIGALRM信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务.

    • 统一事件源
    • 基于升序链表的定时器
    • 处理非活动连接

    非活跃是指客户端这里是浏览器与服务器端建立连接后长时间不交换数据一直占用服务器端的文件描述符导致连接资源的浪费

    定时事件是指固定一段时间之后触发某段代码由该段代码处理一个事件如从内核事件表删除事件并关闭文件描述符释放连接资源

    定时器是指利用结构体或其他形式将多种定时事件进行封装起来具体的这里只涉及一种定时事件即定期检测非活跃连接这里将该定时事件与连接资源封装为一个结构体定时器

    定时器容器是指使用某种容器类数据结构将上述多个定时器组合起来便于对定时事件统一管理具体的项目中使用升序链表将所有定时器串联组织起来

    本项目中服务器主循环为每一个连接创建一个定时器并对每个连接进行定时另外利用升序时间链表容器将所有定时器串联起来若主循环接收到定时通知则在链表中依次执行定时任务

    Linux下提供了三种定时的方法:

    • socket选项SO_RECVTIMEO和SO_SNDTIMEO

    • SIGALRM信号

    • I/O复用系统调用的超时参数

    三种方法没有一劳永逸的应用场景也没有绝对的优劣由于项目中使用的是SIGALRM信号这里仅对其进行介绍另外两种方法可以查阅游双的Linux高性能服务器编程 第11章 定时器

    具体的利用alarm函数周期性地触发SIGALRM信号信号处理函数利用管道通知主循环主循环接收到该信号后对升序链表上所有定时器进行处理若该段时间内没有交换数据则将该连接关闭释放所占用的资源

    从上面的简要描述中可以看出定时器处理非活动连接模块主要分为两部分其一为定时方法与信号通知流程其二为定时器及其容器设计与定时任务的处理

    基础API描述sigaction结构体sigaction函数sigfillset函数SIGALRM信号SIGTERM信号alarm函数socketpair函数send函数

    信号通知流程介绍统一事件源和信号处理机制

    代码实现结合代码对信号处理函数的设计与使用进行详解

    信号通知流程

    Linux下的信号采用的异步处理机制信号处理函数和当前进程是两条不同的执行路线具体的当进程收到信号时操作系统会中断进程当前的正常流程转而进入信号处理函数执行操作完成后再返回中断的地方继续执行

    为避免信号竞态现象发生信号处理期间系统不会再次触发它所以为确保该信号不被屏蔽太久信号处理函数需要尽可能快地执行完毕

    一般的信号处理函数需要处理该信号对应的逻辑当该逻辑比较复杂时信号处理函数执行时间过长会导致信号屏蔽太久

    这里的解决方案是信号处理函数仅仅发送信号通知程序主循环将信号对应的处理逻辑放在程序主循环中由主循环执行信号对应的逻辑代码

    统一事件源

    统一事件源是指将信号事件与其他事件一样被处理

    具体的信号处理函数使用管道将信号传递给主循环信号处理函数往管道的写端写入信号值主循环则从管道的读端读出信号值使用I/O复用系统调用来监听管道读端的可读事件这样信号事件与其他文件描述符都可以通过epoll来监测从而实现统一处理

    信号处理机制

    每个进程之中都有存着一个表里面存着每种信号所代表的含义内核通过设置表项中每一个位来标识对应的信号类型

    • 信号的接收

    • 接收信号的任务是由内核代理的当内核接收到信号后会将其放到对应进程的信号队列中同时向进程发送一个中断使其陷入内核态注意此时信号还只是在队列中对进程来说暂时是不知道有信号到来的

    • 信号的检测

    • 进程从内核态返回到用户态前进行信号检测

    • 进程在内核态中从睡眠状态被唤醒的时候进行信号检测

    • 进程陷入内核态后有两种场景会对信号进行检测

    • 当发现有新信号时便会进入下一步信号的处理

    • 信号的处理

    • 内核 )信号处理函数是运行在用户态的调用处理函数前内核会将当前内核栈的内容备份拷贝到用户栈上并且修改指令寄存器eip将其指向信号处理函数

    • 用户 )接下来进程返回到用户态中执行相应的信号处理函数

    • 内核 )信号处理函数执行完成后还需要返回内核态检查是否还有其它信号未处理

    • 用户 )如果所有信号都处理完成就会将内核栈恢复从用户栈的备份拷贝回来同时恢复指令寄存器eip将其指向中断前的运行位置最后回到用户态继续执行进程

    至此一个完整的信号处理流程便结束了如果同时有多个信号到达上面的处理流程会在第2步和第3步骤间重复进行

    定时器容器

    项目中的定时器容器为带头尾结点的升序双向链表具体的为每个连接创建一个定时器将其添加到链表中并按照超时时间升序排列执行定时任务时将到期的定时器从链表中删除

    从实现上看主要涉及双向链表的插入删除操作其中添加定时器的事件复杂度是O(n),删除定时器的事件复杂度是O(1)

    升序双向链表主要逻辑如下具体的

    • 创建头尾节点其中头尾节点没有意义仅仅统一方便调整

    • add_timer函数将目标定时器添加到链表中添加时按照升序添加

    • 若当前链表中只有头尾节点直接插入

    • 否则将定时器按升序插入

    • adjust_timer函数当定时任务发生变化,调整对应定时器在链表中的位置

    • 客户端在设定时间内有数据收发,则当前时刻对该定时器重新设定时间这里只是往后延长超时时间

    • 被调整的目标定时器在尾部或定时器新的超时值仍然小于下一个定时器的超时不用调整

    • 否则先将定时器从链表取出重新插入链表

    • del_timer函数将超时的定时器从链表中删除

    • 常规双向链表删除结点

    06日志系统

    同步/异步日志系统主要涉及了两个模块一个是日志模块一个是阻塞队列模块,其中加入阻塞队列模块主要是解决异步写入日志做准备.

    • 自定义阻塞队列
    • 单例模式创建日志
    • 同步日志
    • 异步日志
    • 实现按天超行分类

    日志由服务器自动创建并记录运行状态错误信息访问数据的文件

    同步日志日志写入函数与工作线程串行执行由于涉及到I/O操作当单条日志比较大的时候同步模式会阻塞整个处理流程服务器所能处理的并发能力将有所下降尤其是在峰值的时候写日志可能成为系统的瓶颈

    生产者-消费者模型并发编程中的经典模型以多线程为例为了实现线程间数据同步生产者线程与消费者线程共享一个缓冲区其中生产者线程往缓冲区中push消息消费者线程从缓冲区中pop消息

    阻塞队列将生产者-消费者模型进行封装使用循环数组实现队列作为两者共享的缓冲区

    异步日志将所写的日志内容先存入阻塞队列写线程从阻塞队列中取出内容写入日志

    单例模式最简单也是被问到最多的设计模式之一保证一个类只创建一个实例同时提供全局访问的方法

    本项目中使用单例模式创建日志系统对服务器运行状态错误信息和访问数据进行记录该系统可以实现按天分类超行分类功能可以根据实际情况分别使用同步和异步写入两种方式

    其中异步写入方式将生产者-消费者模型封装为阻塞队列创建一个写线程工作线程将要写的内容push进队列写线程从队列中取出内容写入日志文件

    日志系统大致可以分成两部分其一是单例模式与阻塞队列的定义其二是日志类的定义与使用

    单例模式描述懒汉与饿汉两种单例模式并结合线程安全进行讨论

    生产者-消费者模型描述条件变量基于该同步机制实现简单的生产者-消费者模型

    代码实现结合代码对阻塞队列的设计进行详解

    单例模式

    单例模式作为最常用的设计模式之一保证一个类仅有一个实例并提供一个访问它的全局访问点该实例被所有程序模块共享

    实现思路私有化它的构造函数以防止外界创建单例类的对象使用类的私有静态指针变量指向类的唯一实例并用一个公有的静态方法获取该实例

    单例模式有两种实现方法分别是懒汉和饿汉模式顾名思义懒汉模式即非常懒不用的时候不去初始化所以在第一次被使用时才进行初始化饿汉模式即迫不及待在程序运行时立即初始化

    阻塞队列

    阻塞队列是一种特殊的队列数据结构它具有阻塞功能当队列为空时从队列中取元素的操作会被阻塞直到队列中有新的元素被加入同样地当队列已满时向队列中添加元素的操作也会被阻塞直到队列中有空闲位置

    通过阻塞队列我们可以很方便地实现线程间的协作和同步比如一个线程可以往阻塞队列中添加任务另一个线程可以从队列中取出任务并执行如果队列为空取任务的线程就会被阻塞直到有新的任务被添加到队列中如果队列已满添加任务的线程就会被阻塞直到队列中有空闲位置

    07WebServer

    总流程

    流程图描述服务器从报文中提取出用户名密码并完成注册和登录校验后实现页面跳转的逻辑

    载入数据库表结合代码将数据库中的数据载入到服务器中

    提取用户名和密码结合代码对报文进行解析提取用户名和密码

    注册登录流程结合代码对描述服务器进行注册和登录校验的流程

    页面跳转结合代码对页面跳转机制进行详解