C++实现Linux下高并发服务器的技术要点及实现细节

前言

备战秋招面试,记录目前项目的技术要点,欢迎访问!觉得有帮助的可以点个关注,后续更新面试情况。
后续同时更新如何在一个Linux高并发服务器上实现文件上传下载的网盘功能,以及如何实现聊天通讯功能。
为什么要进行业务扩展?字节等大厂面试官在提问的时候,对于webserver其实真的没什么可问的,面试官们都建议做出一个系统级配套客户端的有实际应用价值的项目。一个基础的网页服务器可以用来做个人网站展示一下图文,用处有限,但是如果做成提供网盘的功能,相当于自己有一个私有服务器可以进行存储文件是不是用处就更大了些呢?同时面试官也肯定更感兴趣你是如何设计整个功能的,体现你的架构设计能力。

1、设计模式

Reactor是一种基于事件驱动的设计模式,该模式下存在几个模块:

  1. IO多路复用,用来监听多个socket上的就绪事件;
  2. 事件分发器,将事件分发到正确的事件处理器上处理;
  3. 事件处理器,具体的业务处理逻辑。

Reactor的三种模式:

  1. 单Reactor单线程模式:只有一个reactor同时负责accept新连接、连接的读事件、写事件。单线程处理效率低,无法利用多核CPU的优势,无多线程的并发问题。
  2. 单Reactor多线程模式:只有一个reactor同时负责accept新连接、连接的读事件、写事件。主线程负责处理accept新连接、分发连接的读事件、写事件并处理IO。线程池负责处理业务逻辑(HTTP解析等)。
  3. 主从Reactor模型: 主Reactor一个epoll负责监听listenfd一个socket上的就绪事件,并调用accept接受连接并将其分发注册到不同的从Reactor的epoll上,多个从Reactor监听各自epoll上的读写事件并处理IO,线程池处理业务逻辑。
  4. 以上单Reactor多线程模式与TinyWebserver的实现不同,TinyWebserver中主线程Reactor不负责处理IO,线程池处理IO并且处理业务逻辑。

2、IO多路复用

select、poll、epoll等三种IO多路复用方式

  1. 其中select底层实现是用线性表(数组)来管理文件描述符(fd),fd数量有上限1024;poll用链表来管理fd,epoll用红黑树和就绪链表来管理fd。
  2. select、poll管理的fd都是在用户态被加入集合中,每次监听都需要将整个集合给拷贝到内核态,然后遍历集合看看哪个fd有事件就绪,修改状态,然后再拷贝回用户态,这个过程需要遍历三次(两次拷贝遍历,一次查找遍历),如果就绪事件的fd少,那么遍历过程中的命中率就很低效率不高。即如果连接中只有一小部分活跃,经尝有就绪事件,其他连接沉寂,用select和poll就会效率低。因此,如果能将对fd的管理给转向对就绪事件的管理,不用遍历集合所有,直接遍历找出来的就绪事件,那么效率就会很高了。
  3. epoll的fd集合维护在内核态,不用像前两个一样需要大量进行内核态与用户态的数据拷贝,同时epoll实行的是对就绪事件的管理,epoll_wait返回的是就绪事件集合而不是全部fd。

epoll详解

  1. epoll的核心数据结构,1个红黑树1个双向链表,还有3个核心API。
  2. 其中链表和红黑树实际上管理的是同一种节点。节点的成员变量有fd、树的左右孩子指针、链表的前后指针、事件event等。没有事件发生时所有节点都在树上每个节点的链表前后指针为空,即红黑树管理的是所有IO(fd)。当内部IO就绪时就会调用epoll的回调机制,将相应IO节点的添加到就绪链表上,所有链表上的节点也同时在树上。即该数据结构存在链表和树的特性。
    链表节点同时也都是树节点
  3. 为什么红黑树?
    使用场景上:大量并发IO请求,涉及大量查找(修改监听事件状态,是设置监听读事件还是监听写事件都需要去内核的红黑树找到对应fd)删除(超时连接或者客户端主动关闭close)添加(新连接到accept),而红黑树三个操作的时间复杂度都为logn操作较为高效。
  4. 为什么双向链表?
    使用场景上:就绪队列需要快速插入与删除以实现对epoll_wait的快速响应。
    epoll优势:1、事件驱动,避免遍历所有文件描述符的开销;2、内核与用户空间的数据拷贝少;支持边缘触发模式,避免低效循环检查通知。
  5. 三个API
epoll_create()

① 创建一个 epoll 对象,在内核会创建一个 eventpoll。
② 返回的是一个 fd(文件描述符),该 fd 代表内核态的一个 eventpoll 对象,后边 epoll_ctl(), epoll_wait() 都是基于这个 fd 进行操作; 用 fd 表示内核态的 epoll 对象,体现了 linux 一切皆文件的思想。
③参数列表是一个epoll 实例的大小,即可以监听的文件描述符的数量的一个估计值,现已无实际意义。

epoll_ctl()

这个 api 通过三个命令字完成控制操作:
① EPOLL_CTL_ADD,往 epoll 对象增加一个 fd,fd 加到 epoll 里边之后,epoll 才会对这个 fd 上的事件进行监听。
② EPOLL_CTL_DEL,语义与 EPOLL_CTL_ADD 相反
③ EPOLL_CTL_MOD,修改已经加入到 epoll 里边的 fd 需要监听的事件类型。
参数列表(4):epollfd,option,lfd,监听事件的类型EPOLLIN等

epoll_wait()

等待事件的就绪,成功时从内核返回就绪的事件数目,参数中的event是引用传递所以也传回了对应个数的事件数组,调用失败时返回 -1,等待超时返回 0。根据返回的事件数量遍历events数组,返回的数组元素有fd成员,能根据fd(数字)的类型来进行不同的处理。因为监听套接字唯一,所以不是lfd的就是cfd。
参数列表(4):epollfd ;第二个参数 events 用来从内核得到就绪事件的集合;第三个参数 maxevents 告诉内核这个 events 有多大;第四个参数 timeout 表示等待时的超时时间,以毫秒为单位。

3、Socket编程细节

  1. 创建socket对象,返回lfd。第一个参数为地址协议族:选用IPv4,第二个为套接字类型:流格式套接字选用TCP连接传输?第三个参数为传输协议:0表示自动选择与套接字类型相对应的传输协议。

  2. setsocket函数()设置优雅关闭连接使用 SO_LINGER 选项可以有多种用途,比如:① 在服务器关闭监听套接字时,如果还有客户端连接的请求在队列中,服务器可以设置 SO_LINGER 来确保这些请求被处理完毕。②在客户端关闭套接字时,如果还有数据需要发送,设置 SO_LINGER 可以避免发送缓冲区中的数据丢失。

  3. 创建sockadrr_in地址结构体,其中有三个成员变量。 第一个为地址族协议,第二个为网络端口,第三个为网络地址。 其中后两个参数需要用htonl和htons将主机端口地址转化为网络端口地址。

  4. 绑定bind,传入lfd,地址结构体,地址结构体长度。

  5. setsocket函数()设置允许重用本地地址及端口。两个原因:快速重启:如果服务器需要快速重启,它可能需要立即开始监听相同的端口。如果该端口已经被其他套接字绑定,新启动的服务器将无法绑定到该端口。启用 SO_REUSEADDR 选项后,即使该端口已被其他套接字绑定,服务器也可以绑定到该端口并开始监听新的连接请求。避免端口绑定冲突:在某些情况下,多个服务器可能需要绑定到相同的端口。启用 SO_REUSEADDR 选项后,这些服务器可以同时绑定到该端口,而不会相互干扰。

  6. 监听listen传入两个参数无返回。第一个参数为lfd,第二个为backlog,曾经的含义为:半连接和全连接队列之和不能超过这个数。

  7. 接受accept传入lfd,客户端连接地址,地址长度。accept就是用来 从已完成连接队列中的队首【队头】位置取出来一项【每一项都是一个已经完成三次握手的TCP连接】,返回给进程。如果已完成连接队列是空的呢?那么accept()会一致卡在这里【休眠】等待,一直到已完成队列中有一项时才会被唤醒。所以accept一般是循环阻塞
    (1)如果半连接队列和全连接队列都满了,客户端来了一个syn报文会怎么样?服务端会忽略掉,然后过一段时间客户端超时重传。
    (2)从连接被扔到已经完成队列中去,到accept()从已完成队列中把这个连接取出这个之间是有个时间差的,如果还没等accept()从已完成队列中把这个连接取走的时候,客户端如果发送来数据,这些数据被怎样处理?
    即第三次握手中携带了数据,但连接未被accept,数据不会丢失。当客户端发送数据时,这些数据首先被存储在内核缓冲区中(即使该连接没有被accept但实际上数据已经被缓冲到对应的fd了,只等accept返回fd通过fd就能读取),这个缓冲区被称为接收缓冲区。内核会维护一个数据队列,其中包含了等待传输到应用程序的数据。
    现在会存在满了的状态吗?用io多路复用管理取出来的连接交给epoll管理不受select1024的限制?

  8. connect调用后发起第一次握手,第二次握手到达时返回。

4、HTTP协议解析细节

4.1 如何确定消息边界

HTTP报文在实现的过程中规定了请求行、请求头的每一行、空行的末尾用回车符和换行符来分隔作为字段边界。消息体的末尾没有回车符和换行符,那么怎么确定一个请求读完了呢。HTTP协议中在请求头中写有Content-length字段记录着请求体的长度,通过该长度我们能控制消息体的结束位置。(http协议是如何解决粘包问题的)

4.2 如何确保读到的是完整的请求

读处理过程:用应用层(用户态)定义的read_buf缓冲区去接受内核读缓冲区的数据,有可能read_buf定义的大小不够一次性将内核缓冲区的数据读完,该怎么办?(面美团快手字节的时候都被问到了关键考点?
如果读到了read_buf的末尾还没有读完(一般都是数据体太大了导致没读完),我们已经通过Content-length字段设置用to_read_Bytes记录将要读多少字节,接下来返回LINE-OPEN(状态机,下面提及)表示消息未读完,此时将该lfd的监听事件继续设为读事件监听,让readbuf继续去接受内核缓冲区的数据。继续根据to_read_Bytes将剩下的消息读完。

4.3 解析HTTP协议的过程,主从状态机分析

  1. 将HTTP协议解析设计成主从状态机可以将复杂的解析任务分解成多个小的、可管理的状态,每个状态只处理特定的解析任务,使代码更加模块化。状态机模型可以直观的表示协议解析的各个阶段,使开发者更容易理解协议的结构工作流程。
  2. 主状态机分为三个状态:解析请求行(CHECK_STATE_REQUESTLINE)、解析请求头(CHECK_STATE_HEADER)、解析请求体(CHECK_STATE_CONTENT)。
  3. 从状态机三个状态:行读成功(LINE_OK)、报文语法错误(LINE_BAD)、行读取不完整(LINE_OPEN)。
  4. 主状态机负责管理整个HTTP消息的处理流程,例如请求行的解析、头部字段的解析、消息体的处理等。从状态机则负责具体任务的解析,如解析特定类型的头部字段。主状态机驱动从状态机解析具体协议,从状态机返回的结果又驱动主状态机进行状态转移。
  5. 状态机初始化了八种HTTP请求的处理结果情形,在报文解析时只涉及到四种。NO_REQUEST :请求不完整,需要继续读取请求报文数据;GET_REQUEST获得了完整的HTTP请求;BAD_REQUESTHTTP请求报文有语法错误;INTERNAL_ERROR服务器内部错误,该结果在主状态机逻辑switch的default下,一般不会触发。
    GET_REQUEST是读完完整请求时才会返回的结果,下面两是错误处理,出现行解析错误的标志就返回对应结果。
    LINE_OPEN --> NOREQUSET : 请求不完整,需要继续读取请求报文数据,跳转主线程继续监测读事件。
    LINE_BAD --> BAD_REQUEST : HTTP请求报文有语法错误或请求资源为目录,跳转process_write完成响应报文(发送类似404的html页面)

4.4 读处理分析

前文提及如何判断消息边界,该小节判断如何具体的读处理及可能出现的问题。

  1. 请求行解析
    m_url = strpbrk(text, " \t");
    if (!m_url)
    {
        return BAD_REQUEST;
    }
    *m_url++ = '\0';
    char *method = text;
    if (strcasecmp(method, "GET") == 0)
        m_method = GET;
    else if (strcasecmp(method, "POST") == 0)
    {
        m_method = POST;
        cgi = 1;
    }
    else
        return BAD_REQUEST;

text起始为read_buf的首地址,strpbrk() 函数正在查找字符串 text 中第一次出现的空格 (’ ‘) 或制表符 (’\t’) 的位置,并将该位置的指针赋值给 m_url 变量。我们都知道请求行的方法、URL、协议及版本号之间都是用空格分隔的,所以m_url的位置是空格所在位置,将其改为字符串结束符后自增指向URL的起始位置(实际上可能不是起始位置)。

strcasecmp(method, “GET”)是一个比较字符串的方法,传入的是字符数组的指针,巧的是咱之前把空格换成了‘\0’,这下method起始位置往后遇到的第一个字符串结束符中间的字符作为一个字符串与"GET"进行比较,判断方法名。

    m_url += strspn(m_url, " \t");
    m_version = strpbrk(m_url, " \t");
    if (!m_version)
        return BAD_REQUEST;
    *m_version++ = '\0';
    m_version += strspn(m_version, " \t");
    if (strcasecmp(m_version, "HTTP/1.1") != 0)
        return BAD_REQUEST;
    if (strncasecmp(m_url, "http://", 7) == 0)
    {
        m_url += 7;
        m_url = strchr(m_url, '/');
    }

    if (strncasecmp(m_url, "https://", 8) == 0)
    {
        m_url += 8;
        m_url = strchr(m_url, '/');
    }

    if (!m_url || m_url[0] != '/')
        return BAD_REQUEST;
    //当url为/时,显示判断界面
    if (strlen(m_url) == 1)
        strcat(m_url, "judge.html");
    m_check_state = CHECK_STATE_HEADER;
    return NO_REQUEST;

m_url += strspn(m_url, " \t");通过strspn函数跳过遇到的空格保证指向URL的第一个字符。但此时并没有立即解析URL。

指针继续指向URL末尾的空格,如果不存在说明格式不对,报错。否则就替换"\0",然后再跳过可能存在的空格,指向协议版本的开头。接下来先判断协议支持不支持,不支持就没必要解析URL及后面的消息了。

后面针对可能出现的URL进行了识别替换,然后返回初始界面。
TinyWebserver的请求行解析中不会通过对method进行判断来决定响应,只通过URL进行响应,且在完整读完请求行后给出了GET方法的响应。

POST方法的响应也是在请求头和请求体的末尾给出的,do_request()是一个CGI选择器,在完整的读完请求头、请求体后根据URL给出不同的响应。为什么这里不能跟请求行一样读完就做出响应呢?

因为POST请求一般要根据请求体的信息来做出回应,所以需要读到请求体。那为什么有的响应放在读完请求头呢?

因为有的POST请求其实跟GET方法一样,并没有严格按照POST请求的定义来。这些POST请求的也是静态资源,也不携带消息体。因为不携带消息体所以放在读完消息头后面。这一步其实放在解析请求行里完全没有问题。

        case CHECK_STATE_HEADER:
        {
            ret = parse_headers(text);
            if (ret == BAD_REQUEST)
                return BAD_REQUEST;
            else if (ret == GET_REQUEST)
            {
                return do_request();
            }
            break;
        }
        case CHECK_STATE_CONTENT:
        {
            ret = parse_content(text);
            if (ret == GET_REQUEST)
                return do_request();
            line_status = LINE_OPEN;
            break;
        }
  1. 请求头解析
  2. 请求体解析**(POST方法专有)**
http_conn::HTTP_CODE http_conn::parse_content(char *text)
{
    if (m_read_idx >= (m_content_length + m_checked_idx))
    {
        text[m_content_length] = '\0';
        //POST请求中最后为输入的用户名和密码
        m_string = text;
        return GET_REQUEST;
    }
    return NO_REQUEST;
}

4.5 写处理分析

4.n 协议解析过程中出现的问题

5. HTTP连接类(字节已问)

6. 线程池

7. 连接池

C++实现简易数据库连接池的研究

8. 定时器

9. 日志系统

C++实现异步日志系统的研究

10. 面试情况

10.1 字节跳动 客户端开发

8月20号晚上七点面的,服务器项目回答了三个点。

1. 有遇到过缓冲区没完整的读取一个请求的情况吗,怎么处理的?
2. HTTP连接是用的现成的框架吗?

不是,是自己建立的HTTPconection类,其中设置了成员属性fd及一些其他的诸如定时器等成员来记录连接的状态,通过将accept接受的客户端socket(一个文件描述符fd,即连接)赋值给该类实例对象的成员属性fd以绑定连接,这样我们直接对HTTPconection类对象进行操作也就是对客户端连接进行操作。

3. 异步日志系统是什么工作的?

这个地方当时没答好,只答了日志系统的单例、生产者消费者模型、日志的创建,日志记录的内容具体是什么忘了回答。
明明以前在帖子里记录的很详细,后面又忘了该怎么描述整个系统。还得锻炼语言描述整个系统的能力。
C++实现异步日志系统的研究

4. 连接池的数量是固定的还是动态创建的?一次性创建多少个数据库连接?有没有感觉服务器在启动的时候会比较久?

固定的,然后面试官没继续问了,哈哈哈,不知道这里面试官本来想问啥?
随便答的十几个连接,然后有了后面的问题,我说没感觉到启动很慢,启动到浏览器发送请求过去的这段时间能正常收发无延迟。

5. 反问环节问了面试官有何不足,需要改进的?

面试官直言,网页服务器项目是一个很好的框架,但是感觉像是一个大作业。
建议:

  1. 对每个项目提出十个问题,你想想是不是这个项目很难有十个问题。
  2. 可以考虑做一个系统级的项目,如增加对应的客户端,让项目更完整。
  3. 如果在该项目上进行扩展的话可以考虑做成网盘。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值