《Linux高性能服务器编程》第8章 高性能服务器框架 -- 学习笔记


第8章 高性能服务器框架

摘要

  • 将服务器解构分为如下三个模块,
    • I/O处理单元
      • 介绍I/O处理单元的四种I/O模型和两种高效事件处理模式
    • 逻辑单元
      • 逻辑单元的两种高并发模式,以及高效的逻辑处理方式–有限状态机
    • 存储单元
      • 本书不讨论存储单元

8.1 服务器模型

8.1.1 c/s模型

服务器中 父线程监听客户端连接(I/O复用),建立连接,然后fork()创建子进程为这个客户端分配逻辑单元
服务器在处理一个客户请求的同时还会继续监听其他客户请求

8.1.1 P2P模型

P2P模型可以看做C/S模型的扩展:每台主机既是客户端,又是服务器。

8.2 服务器编程框架

8.3 I/O模型

对于网络编程中的I/O模型,我们要了解阻塞模型和非阻塞模型的区别。socket在创建时默认是阻塞的,可以在socket系统调用的第二个参数传递SOCK_NONBLOCK标志或者通过fcntl将其设置为非阻塞;阻塞和非阻塞的概念能应用于所有文件描述符,而不仅仅是socket。

我们称阻塞的文件描述符为阻塞I/O,称非阻塞的文件描述符为非阻塞I/O。

  1. 针对阻塞I/O的系统调用可能因为无法立即完成而被系统挂起,直到等待的事件发生为止。
  2. 针对非阻塞I/O的系统调用则总是会立即返回,如果事件没有立即发生,这些系统调用就返回-1,和出错的情况一样。
    此时我们必须根据errno来区分,通常来讲,accept、send和recv事件未发生时errno被设置成EAGAIN或EWOULDBLOCK,对connect而言,errno则为EINPROGRESS。

只有在事件已经发生的情况下操作非阻塞I/O(读写等),才能提高程序的效率。因此,非阻塞I/O通常与其他I/O通知机制一起使用,如I/O复用和SIGIO信号。
I/O复用是最常用的通知机制,应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数将就绪的事件通知应用程序,常用的有select、poll和epoll,I/O复用函数本身也是阻塞的,其能提高效率的原因在于能同时监听多个I/O事件。
我们来比较一下不同的I/O模型:

I/O模型读写操作和阻塞阶段
阻塞I/O程序阻塞于读写函数
I/O复用程序阻塞于I/O复用系统调用,但可以监听多个I/O事件,读写本身非阻塞
SIGIO信号信号触发读写就绪事件,用户程序执行读写操作,程序没有阻塞阶段。
异步I/O内核执行读写操作并触发读写完成事件,程序没有阻塞阶段。

8.4 两种高效的事件处理模式

两种高效的事件处理模式:通常使用同步I/O模型的Reactor 和 通常使用异步I/O模型的Proactor,但是我们也有用同步I/O实现Proactor的方法。

8.4.1 Reactor模式

Reactor要求主线程只负责监听是否有事件发生,如果有就立即将该事件通知工作线程,除此之外不进行任何实质性工作,读写数据,接受新连接,以及处理客户请求都由工作线程完成。
也就是说主线程只负责监听和分发事件。

以epoll为例,使用同步I/O模型实现Reactor的工作流程是:
1)主线程向epoll内核事件表中注册socket上的读就绪事件。
2)主线程调用epoll_wait等待socket上有数据可读。
3)当socket上有数据可读时,epoll_wait通知主线程,主线程则将socket可读事件放入请求队列。
4)睡眠在请求队列上的某个工作线程被唤醒,从socket读取数据,并处理客户请求,然后向epoll内核事件表注册该socket上的写就绪事件。
5)主线程调用epoll_wait等待socket可写。
6)当socket可写时,epoll_wait通知主线程,主线程将可写事件放入请求队列。
7)睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。

8.4.2 Proactor模式

Proactor模式将所有I/O操作都交给主线程和内核来做,工作线程仅仅负责业务逻辑;

以aio_read和aio_write为例,使用异步I/O模型实现Proactor模式的工作流程是:
1)主线程调用aio_read向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序。
2)主线程继续处理其他逻辑。
3)当socket上的数据被读入用户缓冲区,内核向应用程序发送一个信号,以通知应用程序数据已经可用。
4)应用程序预先定义好的信号处理函数选择一个工作线程处理客户请求,工作线程处理完客户请求后调用aio_write向内核注册写完成时间,并告诉内核用户缓冲区的位置以及如何通知应用程序。
5)主线程继续处理其他逻辑。
6)当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕。
7)应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如是否关闭socket。

在这种模式下,主线程调用的epoll_wait仅能监听socket上的连接请求,而不能检测连接socket上的读写事件。

8.4.3 模拟Proactor模式

前面说到我们可以用同步I/O来模拟Proactor模式,具体工作流程如下:
1)主线程向epoll内核事件表中注册socket上的读就绪事件。
2)主线程调用epoll_wait等待socket上有数据可读。
3)当socket上有数据可读时,epoll_wait通知主线程,主线程从socket循环队列读取数据直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。
4)睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件。
5)主线程调用epoll_wait等待socket可写。
6)当socket可写时,epoll_wait通知主线程,主线程向socket上写入服务器处理客户请求的结果。

8.5 两种高效的并发模式

并发编程的目的是为了让程序“同时”执行多个任务。如果程序是计算密集型,则并发编程并没有优势,反而会因为任务的切换使效率降低;如果程序是I/O密集型,由于I/O的速度远没有CPU的计算速度快,所以并发模式的CPU利用率会显著提高。

并发编程主要有多进程和多线程两种方式。

并发模式,是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。

服务器主要有两种并发编程模式:半同步(half-sync)/半异步(half-async)模式 和 领导者/追随者(Leader/Followers)模式。

8.5.1 半同步(half-sync)/半异步(half-async)模式

在半同步/半异步模式中同步和异步的概念与I/O模型中的同步和异步不同。
在I/O模型中,同步和异步主要区分的是内核向应用程序通知的是就绪事件还是完成事件,以及该由应用程序还是内核完成I/O读写。
而在并发模式中,同步指的是程序完全按照代码序列的顺序执行,而异步是程序的执行需要由系统事件来驱动,比如中断、信号等。(图8-8)

在这里插入图片描述

而按照同步方式执行的线程是同步线程,按异步方式执行的线程是异步线程,它们各有优缺点。
半同步/半异步模式。(图8-9)

其中,同步线程主要用于处理客户逻辑,异步线程用于处理I/O事件,异步线程监听到客户请求就将其封装成请求对象并插入请求队列,请求队列通知某个同步线程来读取或处理该对象。

在这里插入图片描述

半同步/半异步模式有几种变形,其中一种是半同步/半反应堆模式,
半同步/半反应堆模式:(图8-10)
异步线程只有一个,就是主线程,其余工作线程都睡眠在请求队列上,以竞争方式获得任务接管权,所以只有空闲的工作线程才能处理新任务。而其缺点也很明显,首先请求队列是互斥资源,每次访问需要加锁,消耗了CPU时间;其次每个工作线程同一时间只能处理一个客户请求,当客户数量大时只能通过增加工作线程的方式解决问题,而工作线程的切换也将耗费大量CPU时间。

在这里插入图片描述

另外一种更为高效的半同步/半异步模式,
高效的半同步/半异步模式:(图8-11)
每个工作线程都能处理多个客户连接,我们考虑一个问题,既然主线程可以用epoll来对多个文件描述符进行监听,那么工作线程呢?所以,每个工作线程都使用epoll_wait监听多个文件描述符,当主线程监听到连接请求,就向它和工作线程的管道中写数据,工作线程检测到管道有数据可读时,就分析是否是一个新客户连接,如果是就将其注册到自己的内核事件表中。

在这里插入图片描述

8.5.2 领导者/追随者(Leader/Followers)模式

领导者/追随者模式是多个工作线程轮流获得事件源集合,轮流监听、分发并处理时间的一种模式。
在这种模式下,没有主线程和工作线程的区分,就好像P2P模式一样,每个工作线程都可以负责监听事件源集合,也可以负责事务逻辑;而半同步/半异步就好像C/S模式一样,主线程是服务器,将工作派发给工作线程。
领导者/追随者模式在同一时刻只有一个领导者进程,负责监听I/O事件,而其他进程为追随者,他们处在休眠状态等待成为新的领导者,如果当前领导者监听到了I/O事件,则首先要从线程池中推选出新的领导者线程,然后旧领导者线程去处理I/O事件,新领导者继续监听I/O事件,这样实现了并发。但是很明显,这样做的缺点就是没法像高效的半同步/半异步模式那样一个工作线程处理多个客户连接。
领导者/追随者模式包含句柄集、线程集、事件处理器和具体事件处理器。

8.6 有限状态机

有限状态机是一种很好的高效编程方法,其概念比较简单,但建模较难,我们以一个HTTP请求的读取和分析程序来分析一下,在服务器读取HTTP请求时,如果没有利用有限状态机,就需要等读取到表示头部结束的空行才能对头部进行解析,但是用有限状态机之后可以一边接受数据一边进行分析,其效率更高。

8-3httpparser.cpp

char *strpbrk(const char *s1, const char *s2);

在源字符串(s1)中找出最先含有搜索字符串(s2)中任一字符的位置并返回,若找不到则返回空指针。
 #include <strings.h> // (不是C/C++的标准头文件,区别于string.h [1]  )
 
int strcasecmp (const char *s1, const char *s2);
函数说明:strcasecmp()用来比较参数s1和s2字符串,比较时会自动忽略大小写的差异。
返回值:  若参数s1和s2字符串相等则返回0。s1大于s2则返回大于0 的值,s1 小于s2 则返回小于0的值。
strcasecmp函数是二进制且对大小写不敏感。此函数只在Linux中提供,相当于windows平台的 stricmp。


int strncasecmp(const char *s1, const char *s2, size_t n);
函数说明:strncasecmp()用来比较参数s1 和s2 字符串前n个字符,比较时会自动忽略大小写的差异。
返回值: 若参数s1 和s2 字符串相同则返回0。s1 若大于s2 则返回大于0 的值,s1 若小于s2 则返回小于0 的值。
C 标准库 - <string.h>

size_t strspn(const char *str1, const char *str2)

C 库函数 
size_t strspn(const char *str1, const char *str2) 
检索字符串 str1 中第一个不在字符串 str2 中出现的字符下标。

参数
	str1 -- 要被检索的 C 字符串。
	str2 -- 该字符串包含了要在 str1 中进行匹配的字符列表。
返回值
	该函数返回 str1 中第一个不在字符串 str2 中出现的字符下标。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>

// 读缓冲区大小
#define BUFFER_SIZE 4096

//主状态机的两种可能状态,分别表示:当前正在分析请求行、当前正在分析头部字段
enum CHECK_STATE
{
    CHECK_STATE_REQUESTLINE = 0,
    CHECK_STATE_HEADER,
    CHECK_STATE_CONTENT
};

//从状态机的三种可能状态,即行的读取状态,分别表示:读取到一个完整的行、行出错和行数据尚且不完整
enum LINE_STATUS
{
    LINE_OK = 0,
    LINE_BAD,
    LINE_OPEN
};

/*服务器处理HTTP请求的结果:NO_REQUEST	表示请求不完整,需要继续读取客户数据;
							GET_REQUEST	表示获得了一个完整的客户请求;
							BAD_REQUEST	表示客户请求有语法错误;
							FORBIDDEN_REQUEST	表示客户对资源没有足够的访问权限
							INTERNAL_ERROR表示	表示服务器内部错误;
							CLOSED_CONNECTION	表示客户端已经关闭连接了*/
enum HTTP_CODE
{
    NO_REQUEST,
    GET_REQUEST,
    BAD_REQUEST,
    FORBIDDEN_REQUEST,
    INTERNAL_ERROR,
    CLOSED_CONNECTION
};

//
static const char *szret[] = {"I get a correct result\n", "Something wrong\n"};

/* 从状态机,用于解析出一行内容 */
LINE_STATUS parse_line(char *buffer, int &checked_index, int &read_index)
{
    /*	checked_id_index指向buffer的正在分析的字节,
		read_index指向buffer中的最后一个字节的下一个字节
		即从[0, checked_index -1]是已分析完毕,[checked_index,read_index-1]待分析	*/

    char temp;
    for (; checked_index < read_index; ++checked_index)
    {
        temp = buffer[checked_index];

        /*	如果当前的字符是'\r',即回车符,则说明可能读取到一个完整的行	*/
        if (temp == '\r')
        {
            //如果'\r'是最后一个被读入的客户端数据,则说明没有读取到一个完整的行,需要继续读取客户端数据
            if ((checked_index + 1) == read_index)
            {
                return LINE_OPEN;
            }
            //如果下一个字符是'\n'则说明读取到了完整的行
            else if (buffer[checked_index + 1] == '\n')
            {
                buffer[checked_index++] = '\0';
                buffer[checked_index++] = '\0';
                return LINE_OK;
            }
            //否则说明HTTP请求存在语法问题
            return LINE_BAD;
        }
        /*	如果当前的字符是'\n',即换行符,则也说明可能读取到一个完整的行	*/
        else if (temp == '\n')
        {
            if ((checked_index > 1) && buffer[checked_index - 1] == '\r')
            {
                buffer[checked_index - 1] = '\0';
                buffer[checked_index++] = '\0';
                return LINE_OK;
            }
            return LINE_BAD;
        }
    }
    /* 如果所有内容都分析完毕也没有遇到'\r'字符,则返回LINE_OPEN,表示还需要继续读取客户端数据才能进一步分析*/
    return LINE_OPEN;
}

/* 分析请求行 */
HTTP_CODE parse_requestline(char *szTemp, CHECK_STATE &checkstate)
{
    char *szURL = strpbrk(szTemp, " \t");
    //如果请求行中没有空白字符 或 '\t'字符,则HTTP请求必有问题
    if (!szURL)
    {
        return BAD_REQUEST;
    }
    *szURL++ = '\0';

    char *szMethod = szTemp;
    if (strcasecmp(szMethod, "GET") == 0)
    {
        printf("The request method is GET\n");
    }
    else
    {
        return BAD_REQUEST;
    }

    szURL += strspn(szURL, " \t");
    char *szVersion = strpbrk(szURL, " \t");
    if (!szVersion)
    {
        return BAD_REQUEST;
    }
    *szVersion++ = '\0';
    szVersion += strspn(szVersion, " \t");
    if (strcasecmp(szVersion, "HTTP/1.1") != 0)
    {
        return BAD_REQUEST;
    }

    if (strncasecmp(szURL, "http://", 7) == 0)
    {
        szURL += 7;
        szURL = strchr(szURL, '/');
    }

    if (!szURL || szURL[0] != '/')
    {
        return BAD_REQUEST;
    }

    //URLDecode( szURL );
    printf("The request URL is: %s\n", szURL);
    checkstate = CHECK_STATE_HEADER;
    return NO_REQUEST;
}

/* 分析头部字段 */
HTTP_CODE parse_headers(char *szTemp)
{
    // 遇到一个空行,说明我们得到了一个正确的HTTP请求
    if (szTemp[0] == '\0')
    {
        return GET_REQUEST;
    }
    //处理'HOST'字段
    else if (strncasecmp(szTemp, "Host:", 5) == 0)
    {
        szTemp += 5;
        szTemp += strspn(szTemp, " \t");
        printf("the request host is: %s\n", szTemp);
    }
    //其他头部字段都不处理
    else
    {
        printf("I can not handle this header\n");
    }

    return NO_REQUEST;
}

/* 分析HTTP请求的入口函数 */
HTTP_CODE parse_content(char *buffer, int &checked_index, CHECK_STATE &checkstate, int &read_index, int &start_line)
{
    LINE_STATUS linestatus = LINE_OK; //记录当前行的读取状态
    HTTP_CODE retcode = NO_REQUEST;   //记录HTTP请求的处理结果

    // 主状态机:从buffer中取出所有完整的行
    while ((linestatus = parse_line(buffer, checked_index, read_index)) == LINE_OK)
    {
        char *szTemp = buffer + start_line; //start_line是 行在buffer中的起始位置
        start_line = checked_index;         //记录下一行的起始位置
        switch (checkstate)                 // checkstate记录着主状态机当前的状态
        {
        case CHECK_STATE_REQUESTLINE: //第一个状态,分析请求行
        {
            retcode = parse_requestline(szTemp, checkstate);
            if (retcode == BAD_REQUEST)
            {
                return BAD_REQUEST;
            }
            break;
        }
        case CHECK_STATE_HEADER: //第二个状态,分析头部字段
        {
            retcode = parse_headers(szTemp);
            if (retcode == BAD_REQUEST)
            {
                return BAD_REQUEST;
            }
            else if (retcode == GET_REQUEST)
            {
                return GET_REQUEST;
            }
            break;
        }
        default:
        {
            return INTERNAL_ERROR;
        }
        }
    }
    // 若没有读到一个完整的行,则表示还需要继续读取客户端数据才能进一步分析
    if (linestatus == LINE_OPEN)
    {
        return NO_REQUEST;
    }
    else
    {
        return BAD_REQUEST;
    }
}

int main(int argc, char *argv[])
{
    if (argc <= 2)
    {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1]; //服务端的ip
    int port = atoi(argv[2]); //服务端监听的port

    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);

    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);

    int ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(listenfd, 5);
    assert(ret != -1);

    struct sockaddr_in client_address;
    socklen_t client_addrlength = sizeof(client_address);
    int fd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
    if (fd < 0)
    {
        printf("errno is: %d\n", errno);
    }
    else
    {
        char buffer[BUFFER_SIZE]; //读缓冲区
        memset(buffer, '\0', BUFFER_SIZE);
        int data_read = 0;
        int read_index = 0;                               //当前已经读取了多少字节的客户数据
        int checked_index = 0;                            //当前已经分析完了多少字节的客户数据
        int start_line = 0;                               //行在buffer中的起始位置
        CHECK_STATE checkstate = CHECK_STATE_REQUESTLINE; //设置: 主状态机的初始状态
        while (1)                                         //循环读取客户数据并分析之
        {
            data_read = recv(fd, buffer + read_index, BUFFER_SIZE - read_index, 0);
            if (data_read == -1)
            {
                printf("reading failed\n");
                break;
            }
            else if (data_read == 0)
            {
                printf("remote client has closed the connection\n");
                break;
            }

            read_index += data_read;
            //分析目前已经获得的所有客户数据
            HTTP_CODE result = parse_content(buffer, checked_index, checkstate, read_index, start_line);
            if (result == NO_REQUEST) //尚未得到一个完整的HTTP请求
            {
                continue;
            }
            else if (result == GET_REQUEST) //得到一个完整的、正确的HTTP请求
            {
                send(fd, szret[0], strlen(szret[0]), 0);
                break;
            }
            else //其他情况表示发生错误
            {
                send(fd, szret[1], strlen(szret[1]), 0);
                break;
            }
        }
        close(fd);
    }

    close(listenfd);
    return 0;
}

分析一下发现,这里面存在着两个有限状态机,分别是主状态机和从状态机,从状态机就是一个parse_line函数,负责从buffer中解析出一个行,其初始状态为LINE_OK,原始驱动力来源于buffer中新到达的数据,而当从状态机读取到了一个完成的行,就需要将这个行交给主状态机处理,主状态机中根据当前状态调用不同的函数对报文进行解析,从而实现状态转移。

8.7 提高服务器性能的其他建议

前面的 几种高效的事件处理模式和并发模式,以及高效的逻辑处理方式–有限状态机
还有其他几个提高服务器性能的方面:池、数据复制、上下文切换

8.7.1 池

由于临时申请进程或线程等资源的CPU消耗比较大,所以我们事先申请好资源,如果不够再临时申请

内存池通常用于socket的接收缓存和发送缓存
进程池和线程池都是并发编程常用的“伎俩”
连接池通常用于服务器或服务器机群的内部永久连接

8.7.2 数据复制

复制数据的过程中,尽量使用零拷贝函数,也尽量少进行数据复制

应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核之间的时候

sendfile “零拷贝”

8.7.3 上下文切换

是减少上下文切换和锁的使用

半同步 / 半异步模式 ,它允许一个线程同时处理多个客户连接。

如果服务器必须使用“锁”,则可以考虑减小锁的粒度,比如使用读写锁。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值