2020秋招_高性能服务器框架

总览

服务器三个主要模块:

  1. I/O处理单元
  2. 逻辑单元
  3. 存储单元

I/O处理单元

四种IO模型,两种高效事件处理模式。

四种IO模型

  • 阻塞IO:程序阻塞于读写函数。
  • I/O复用:程序阻塞于IO复用系统调用(如epoll_wait),但同时可以监听多个IO事件。对IO本身的读写操作是非阻塞的。
  • SIGIO信号(信号驱动I/O):信号触发读写就绪事件,用户程序执行读写操作。程序没有阻塞阶段。
  • 异步I/O:内核执行读写操作并触发读写完成事件。程序没有阻塞阶段。

阻塞与非阻塞IO:阻塞与非阻塞的概念能应用于所有文件描述符(如socket文件描述符),阻塞的文件描述符称为阻塞IO,非阻塞的文件描述符为非阻塞IO。

同步与异步IO:同步IO向应用程序通知的是IO就绪事件,要求用户代码自行执行IO操作;异步IO向应用程序通知的是IO完成事件,由内核执行IO操作。

对于非阻塞IO执行的系统调用总是立即返回,而不管事件是否已经发生。如果事件没有立即发生,这些系统调用就返回-1,和出错情况一样。此时,需要根据errno来区分两种情况。对于accept、send和recv而言,事件未发生时errno通常被设置成EAGAIN(”再来一次“)或者EWOULDBLOCK(”期望阻塞“);对connect而言,errno则被设置成EINPROCESS(”在处理中“)。

为什么要用非阻塞的soacket文件描述符

IO多路复用保证了每次调用的IO事件都是就绪事件,此时使用阻塞IO和非阻塞IO在效率上应该区别不大。但在epoll的ET模式下必须使用非阻塞的socket,因为ET模式下就绪的事件只会被通知一次。(个人理解而已)

IO复用:select、poll、epoll

select没有统一事件类型,poll和epoll统一了事件类型(把文件描述符和事件定义在一个结构体中)。

struct epoll_event {
    
    
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

selec和poll采用轮询的方式检测就绪事件,O(n);epoll_wait采用回调的方式,O(1)。内核检测到就绪的文件描述符时,将触发回调函数,回调函数将文件描述符上的事件插入内核就绪链表中。内核最后在适当的时候将该就绪链表中的内容拷贝到用户空间。

epoll_wait适用于连接数量多,但活动连接较少的情况。(webserver属于IO密集型应用,不是计算密集型。因此,每个request的大部分生命都是在网络传输中,实际上花在server机器上的时间片不多。因此,webserver就属于连接数量多,但是实际活动连接较少的应用,epoll_wait适用于它。)当活动连接比较多的时候,epoll_wait的效率未必比select和poll高,因为此时回调函数被触发得过于频繁。

epoll支持的事件类型和和poll基本相同,单epoll有两个额外的事件类型:EPOLLETEPOLLONESHOT,它们对于epoll的高效运作非常关键。

  • 当往epoll内核事件表中注册一个文件描述符上的EPOLLET事件时,epoll将以ET模式,边沿触发(默认LT模式,水平触发)来操作该文件描述符。ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。
  • 即使使用ET模式,一个socket上的某个事件还是可能被粗发多次。比如一个线程在读取完摸个socket上的数据后开始处理这些数据,而在数据处理过程中该socket上又有新数据可读(EPOLLIN在次被触发),此时另外一个线程被唤醒来读取这些新的数据。这就会出现两个线程同时操作一个socket的局面。我们期望一个socket连接在任何一个时刻都只被一个线程处理。这可以使用EPOLLONESHOT事件实现(对每个非监听文件描述符注册EPOLLONESHOT事件)。

事件处理模式和并发模式区别

  • 事件处理模式是指IO事件该交给主线程还是工作线程处理。
  • 并发模式是指I/O事件和业务逻辑的处理方式是用同步还是异步。半反应堆/半同步模式就是I/O事件交给模拟的异步主线程处理,业务逻辑交给同步线程处理。

两种高效事件处理模式

服务器程序通常需要处理三类事件:I/O事件、信号以及定时事件。Reactor模式和Proactor模式
Reactor模式(单Reactor+多线程):主线程只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。除此之外,主线程不做任何其他实质性的工作。读写数据以及处理客户端请求均在工作线程中完成
Proactor模式:将所有I/O操作都交给内核来处理,工作线程仅仅负责业务逻辑
模拟Proactor模式主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。

Reactor模式-----单Reactor多线程与主从Reactor多线程
主从Reactor模式(多Reactor+多线程/多进程):MainReactor只负责监视socket描述符的连接事件,接收到连接事件并处理后,将向内核注册了可读事件的socket描述符分配给SubReactor, SubReactor将socket描述符加入到可读(已连接)队列进行监视,并创建handler进行各种事件处理。

方案说明:

  1. Reactor主线程MainReactor对象通过epoll连接事件,收到事件后,通过Acceptor处理连接事件;
  2. 当Acceptor处理连接事件后,MainReactor将连接分配给SubReactor;
  3. subReactor将连接加入到连接队列进行监听,并创建handler进行各种事件处理;
  4. 当有新的事件发生时,subReactor就会调用对应的handler进行处理;
  5. handler通过rand读取数据,会分发给后面的worker线程进行处理;
  6. wroker线程池会分独立的worker线程进行业务处理,并返回结果;
  7. handler收到响应的结果后,再通过sned将结果返回给client;
  8. Reactor主线程可以对应多个Reactor子线程,即MainReactor可以关联多个subReactor。

优点:

  • 父线程与子线程的数据交互简单,职责明确,父线程只需要接收新连接,子线程完成后续的业务处理。
  • 父线程与子线程的数据交互简单,Reactor主线程只需要把新连接传给子线程,子线程无需返回数据。

缺点:

  • 编程复制度较高。

统一事件源

统一事件源,是指将信号事件与其他事件(I/O事件)一样被处理。

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

逻辑单元

两种高效并发模式,高效的逻辑处理方式——有限状态机。

两种高效的并发模式

并发编程的目的是让程序“同时”执行多个任务,提高CPU的利用率。

半同步/半异步模式

异步线程执行效率高,实时性强,但编写以异步方式执行的程序相对复杂,难于调试和扩展,不适合于大量的并发;同步线程执行效率相对较低,实时性较差,但逻辑简单。服务器这种既要求较好的实时性,又要求能同时处理多个客户请求的应用程序,适合采用半同步/半异步模式来实现。
同步线程用于处理客户逻辑(逻辑单元),异步线程用于处理I/O事件(I/O处理单元)。主线程负责充当异步线程,负责监听socket上事件。

综合考虑两种事件处理模式和几种I/O模型,半同步/半异步模式有多种变体:半同步/半反应堆模式高效的半同步/半异步模式

半同步/半反应堆模式(单reactor+多线程)的缺点:

  • 主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列取出任务,都需要对请求队列加锁保护,从而白白浪费CPU时间。
  • 每个工作线程在同一时间只能处理一个客户请求。如果客户数量越来越多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的相应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。

高效的半同步/半异步模式(多reactor+多线程/进程):每个工作线程都能同时处理多个客户连接。在这种模式下,每个线程都工作在异步模式,所以它并非严格意义上的半同步/半异步模式。主线程向工作线程派发socket的最简单方式是往它和工作线程之间的管道写数据,避免了锁的使用。

领导者/追随者模式

状态机

为什么使用状态机? -> 状态机是有限状态自动机的简称,是现实事物运行规则抽象而成的一个数学模型。使用状态机,在预先设定的有限个状态之间转移,可以使代码逻辑更加清晰。
在服务器中使用主从状态机解析HTTP报文:从状态机负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。

提高服务器性能的其它建议

  • :以空间换时间的方式来提高服务器的性能。池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源分配。常见的有内存池、进程/线程池、连接池。
    内存池通常用于socket的接收缓存和发送缓存。进程池和线程池用于并发编程。连接池通常用于服务器或服务器机群的内部永久连接,是服务器预先和数据库程序建立的一组连接的集合。
  • 数据复制:高性能服务器应该避免不必要的数据复制,尤其是当数据复制发生在用户代码和内核代码之间的时候。如果内核可以直接处理从socket或者文件读入的数据,则应用程序就没必要将这些数据从内核缓冲区复制到应用程序缓冲区中。
    此外,用户代码内部(不访问内核)的数据复制也应该避免eryieryi。举例来说,当两个工作进程之间要传递大量的数据时,我们就应该考虑使用共享内存来在它们之间直接共享这些数据。
  • 上下文切换和锁:并发程序需要考虑共享资源的加锁保护,但是锁通常被认为是导致服务器效率低下的一个因素,因为它引入的代码不仅不处理任何业务逻辑,而且需要访问内核资源。
    服务器有更好的解决方案,就应该避免使用锁。例如,前面提到的高效的半同步/半异步模式半同步/半反应堆模式效率高。再例如,服务器必须使用“锁“,则可以考虑减小锁的粒度,比如使用读写锁。(C++多线程:互斥锁、自旋锁、条件变量、读写锁的定义与使用

线程池

线程的同步机制

项目中线程池使用互斥锁mtx_queue保护任务队列一次只能被一个工作线程访问;使用判断队列非空的条件变量cond_not_empty同步线程,如果队列为空,则阻塞当前空闲的工作线程this->cond_not_empty.wait(locker),直到有新的任务进入队列,去唤醒阻塞的工作线程cond_not_empty.notify_one()

线程同步的机制还有哪些? -> 信号量(建立在原子操作基础上)、自旋锁、读写锁
sem m_queuestat; //信号量表示任务队列中的task数量。
调用sem::wait()以原子操作的方式将信号量减1,信号量为0时,阻塞当前线程。(P操作)
调用sem::post()以原子操作的方式将信号量加1(当有新的任务进入队列,则调用post通知阻塞的工作线程),信号量大于0时,唤醒阻塞的线程。(V操作)

定时器

为什么要用定时器? -> HTTP/1.1起,默认使用长连接,用以保持连接特性。客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。因此,需要为每个HTTP连接设置一个定时器,处理这些非活跃连接。

定时器的工作原理

服务器主循环为每一个连接创建一个定时器,并对每个连接进行定时。此外,利用升序双向时间链表将所有定时器串联起来,若主循环接收到定时通知,则在链表中依次执行定时任务(将定时器的预设超时时间与当前实际实际比较,若小于当前实际时间,则执行定时任务,即关闭非活跃连接的文件描述符,释放连接资源)。

定时任务处理过程

  • 统一事件源:信号处理函数使用管道将信号传递给主循环,信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出信号值,使用I/O复用系统调用来监听管道读端的可读事件,这样信号事件与其他文件描述符都可以通过epoll来监测,从而实现统一处理。
	/* WebServer::eventListen() */
	
	// SIG_IGN是信号处理方式,表示忽略SIGPIPE信号(详见书189页)
	utils.addsig(SIGPIPE, SIG_IGN);
	//传递给主循环的信号值,这里只关注SIGALRM和SIGTERM
    utils.addsig(SIGALRM, utils.sig_handler, false);
    utils.addsig(SIGTERM, utils.sig_handler, false);
    
    // 每隔TIMESLOT时间触发SIGALRM信号(项目中设置 TIMESLOT=5s )
    alarm(TIMESLOT);
// 自定义信号处理函数
// 信号处理函数中仅仅通过管道发送信号值,不处理信号对应的逻辑,缩短异步执行时间,减少对主程序的影响。
void Utils::sig_handler(int sig)
{
    
    
    //为保证函数的可重入性,保留原来的errno
    //可重入性表示中断后再次进入该函数,环境变量与之前相同,不会丢失数据
    int save_errno = errno;
    int msg = sig;

    //将信号值从管道写端写入,传输字符类型,而非整型
    send(u_pipefd[1], (char *)&msg, 1, 0);

    //将原来的errno赋值为当前的errno
    errno = save_errno;
}

//设置信号函数
void Utils::addsig(int sig, void(handler)(int), bool restart)
{
    
    
    //创建sigaction结构体变量
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));

    // handler为传入的信号处理函数
    sa.sa_handler = handler;
    if (restart){
    
    
        sa.sa_flags |= SA_RESTART;
    }  
    //将所有信号添加到信号集中
    sigfillset(&sa.sa_mask);

    //执行sigaction函数
    assert(sigaction(sig, &sa, NULL) != -1);
}
  • 在主循环WebServer::eventLoop()中通过epoll监测统一的事件源,当管道对应文件描述符发生可读事件时,在主循环中执行信号对应的逻辑代码。如果从管道读到的是SIGALRM信号值(alarm函数设置的实时闹钟一旦超时,将触发SIGALARM信号),则timeout标志设置为true。
bool WebServer::dealwithsignal(bool& timeout, bool &stop_server){
    
    
    int ret = 0;
    int sig;
    char signals[1024];
    //从管道读端读出信号值,成功返回字节数,失败返回-1
    //正常情况下,这里的ret返回值总是1,只有14和15两个ASCII码对应的字符(SIGALRM->14, SIGTERM->15)
    ret = recv(m_pipefd[0], signals, sizeof(signals), 0);
    if (ret == -1){
    
    
        return false;
    }else if (ret == 0){
    
    
        return false;
    }else{
    
    
        /*信号本身是整型数值,管道中传递的是ASCII码表中整型数值对应的字符。
          switch的变量一般为字符或整型,当switch的变量为字符时,case中可以是字符,也可以是字符对应的ASCII码。*/
        //处理信号值对应的逻辑
        for (int i = 0; i < ret; ++i){
    
    
	            //signals[i]是字符
	            switch (signals[i]){
    
    
	            //SIGALRM、SIGTERM是整型
	            case SIGALRM: // 时钟定时信号
	            {
    
    
	                timeout = true;
	                break;
	            }
	            case SIGTERM: // 程序结束信号
	            {
    
    
	                stop_server = true;
	                break;
	            }
            }
        }
    }
    return true;
}
  • 如果超时标志timeout为ture,则在主循环中处理定时任务,并重新定时以不断触发SIGALRM信号(一次alarm调用只会引起一次SIGALARM信号,因此需要重新定时,以不断的触发SIGALARM信号)。
        if (timeout){
    
    
            utils.timer_handler();
            LOG_INFO("%s", "timer tick");
            timeout = false;
        }
  • tick()相当于一个心搏函数,它每隔固定的时间就执行一次(由前步骤可知,每隔TIMESLOT时间,tick函数就会执行一次),以检测并处理到期的任务。
void Utils::timer_handler()
{
    
    
    m_timer_lst.tick();
    alarm(m_TIMESLOT);
}

void sort_timer_lst::tick()
{
    
    
    if (!head)
    {
    
    
        return;
    }
    //获取当前时间
    time_t cur = time(NULL);
    util_timer *tmp = head;
    //遍历定时器链表
    while (tmp)
    {
    
    
        //链表容器为升序排列
        //当前时间小于定时器的超时时间,后面的定时器也没有到期
        if (cur < tmp->expire)
        {
    
    
            break;
        }
        //当前定时器到期,则调用回调函数,执行定时事件
        // tmp->cb_func(tmp->user_data);
        tmp->run(cb_func, tmp->user_data);

        //将处理后的定时器从链表容器中删除,并重置头结点
        head = tmp->next;
        if (head)
        {
    
    
            head->prev = NULL;
        }
        delete tmp;
        tmp = head;
    }
}
  • 定时回调函数执行定时事件。
// 定时器回调函数,删除非活动socket上的注册事件,并关闭
void cb_func(client_data *user_data)
{
    
    
    //删除非活动连接在socket上的注册事件
    epoll_ctl(Utils::u_epollfd, EPOLL_CTL_DEL, user_data->sockfd, 0);
    assert(user_data);

    //关闭文件描述符
    close(user_data->sockfd);

    //减少连接数
    http_conn::m_user_count--;
}

最小堆优化

  • 每次当最小堆堆顶的定时器到期后才处理定时时间。(最小堆使用优先队列实现)
  • 双向链表插入O(n),删除O(1);最小堆插入O(lgn),删除O(1)。
  • 双向链表插入时间复杂度为O(n)是因为需要遍历升序链表,根据定时器超时时间查找到插入定时器的位置。
  • 最小堆删除时间复杂度为O(1)的原因是删除仅仅只是将目标定时器的回调函数设置为空,即不执行定时操作,延迟销毁。

数据库登录注册

保存状态了吗?如果要保存,你会怎么做? -> Session和Cookie(由于HTTP协议是无状态的协议,所以服务端需要记录用户的状态时,就需要用某种机制来识别具体的用户,这个机制就是Session)看完就彻底懂了session和cookie

Cookie保存状态过程:第一次客户端请求报文没有cookie信息,服务端生成并在响应报文中加入cookie信息返回给客户端;客户端下次发送请求报文的时候,自动发送保存着的cookie信息。

Session是在服务端保存的一个数据结构,用来跟踪用户的状态,这个数据可以保存在集群、数据库、文件中;Cookie是客户端保存用户信息的一种机制,用来记录用户的一些信息,也是实现Session的一种方式。

数据库连接池概念

连接池中的资源为一组数据库连接,由程序动态地对池中的连接进行使用,释放。当系统开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配;当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源。

为什么要用连接池? -> 若系统需要频繁访问数据库,则需要频繁创建和断开数据库连接,而创建数据库连接是一个很耗时的操作,也容易对数据库造成安全隐患。

数据库连接池的定义

  1. 单例模式创建,使用局部静态变量懒汉模式创建连接池。
 class connection_pool{
    
    
 public:
     //局部静态变量单例模式
     static connection_pool *GetInstance();
 
 private:
     connection_pool();
     ~connection_pool();
}

connection_pool *connection_pool::GetInstance(){
    
    
    static connection_pool connPool;
    return &connPool;
}
  1. 初始化
//初始化mysql
MYSQL *con = NULL;
con = mysql_init(con);
//连接本地数据库
con = mysql_real_connect(con, url.c_str(), User.c_str(), PassWord.c_str(), DBName.c_str(), Port, NULL, 0);
// 建立好的连接加入连接池中
connList.push(con);
  1. 获取、释放连接:获取连接时,若连接池内没有连接了,则需要阻塞等待,这通过条件变量实现,并配合互斥锁保证共享的连接池资源的操作安全。(也可以用信号量配合互斥锁实现)

  2. 销毁连接池

//销毁数据库连接池
void connection_pool::DestroyPool(){
    
    
	unique_lock<mutex> locker(mtx);
	while(!connList.empty())
	{
    
    
		MYSQL *con = connList.front();
		connList.pop();
		mysql_close(con);
	}		
	m_CurConn = 0;
	m_FreeConn = 0;
}
  1. 将数据库连接的获取与释放通过RAII机制封装,避免手动释放。
// 定义
class connectionRAII{
    
    
public:
	connectionRAII(MYSQL **con, connection_pool *connPool);
	~connectionRAII();
private:
	MYSQL *conRAII;
	connection_pool *poolRAII;
};

//实现
connectionRAII::connectionRAII(MYSQL **SQL, connection_pool *connPool){
    
    
	*SQL = connPool->GetConnection();
	
	conRAII = *SQL;
	poolRAII = connPool;
}

connectionRAII::~connectionRAII(){
    
    
	poolRAII->ReleaseConnection(conRAII);
}

数据库访问流程

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

登录注册原理

使用数据库连接池(单例模式和队列实现)实现服务器访问数据库的功能,使用POST请求完成注册和登录的校验工作(登录校验2,注册校验3,当使用POST请求时CGI标志设置为1)。
详细过程:首先,在网站输入URL http://127.0.0.1:9006/,网站发送GET请求,服务器响应GET请求,使网站跳转到注册或登录页面。然后在页面上输入用户名和密码后,点击注册或登录,网站便发送POST请求,请求实体包含了注册或登录的用户名和密码,服务器解析POST请求,完成注册或登录的校验,然后根据校验结果跳转到相应的页面。

  1. 载入数据库表:将数据库中的用户名和密码载入到服务器的map中,map中的key为用户名,value为密码。
map<string, string> users;
// 同步校验,使用连接池
void http_conn::initmysql_result(connection_pool *connPool){
    
    
    //先从连接池中取一个连接
    MYSQL *mysql = NULL;
    connectionRAII mysqlcon(&mysql, connPool);
    //在user表中检索username,passwd数据,浏览器端输入
    //查询成功返回0,失败返回非0值。
    if (mysql_query(mysql, "SELECT username,passwd FROM user")){
    
    
        LOG_ERROR("SELECT error:%s\n", mysql_error(mysql));
    }
    //从表中检索完整的结果集
    MYSQL_RES *result = mysql_store_result(mysql);
    //返回结果集中的列数
    int num_fields = mysql_num_fields(result);
    //返回所有字段结构的数组
    MYSQL_FIELD *fields = mysql_fetch_fields(result);
    //从结果集中获取下一行,将对应的用户名和密码,存入map中
    while (MYSQL_ROW row = mysql_fetch_row(result)){
    
    
        string temp1(row[0]);
        string temp2(row[1]);
        users[temp1] = temp2;
    }
}
  1. 提取用户名和密码
  2. 页面跳转

mysql和redis

Redis和MySQL的区别与使用(redis做mysql的缓存并且数据同步)
登录时候用户名和密码load到本地,使用unordered_map存储,如果有10亿数据,load到本地后查询也是很耗时的,怎么优化? -> 将Redis用于缓存。把表中经常访问的记录放在了Redis中,然后用户查询时先去查询Redis再去查询MySQL,实现读写分离,也就是Redis只做读操作。由于缓存在内存中,所以查询会很快。

Redis基于内存,读写速度快,也可做持久化,但是内存空间有限,当数据量超过内存空间时,需扩充内存,但内存价格贵。
MySQL基于磁盘,读写速度没有Redis快,但是不受空间容量限制,性价比高。

大多数的应用场景是MySQL(主)+Redis(辅),MySQL做为主存储,Redis用于缓存,加快访问速度。需要高性能的地方使用Redis,不需要高性能的地方使用MySQL。存储数据在MySQL和Redis之间做同步。

现在大量的软件使用redis作为mysql在本地的数据库缓存,然后在适当的时候和mysql同步。

日志相关

日志系统的运行机制:单例模式创建日志系统,多生产者(工作线程)-单消费者(写线程)模式实现异步的日志写入。(多个工作线程 ->缓冲区(循环数组or队列) -> 写线程)

同步日志就是同一个工作线程完成日志的生成和写入,异步日志就是日志生成和写入在不同的线程进行,中间通过一个阻塞队列实现。
为什么要异步? -> 工作线程处理业务的同时生成日志,同步的IO会阻塞工作线程的执行,从而导致服务器处理并发的能力下降。

面经题

介绍下项目

为什么做这个项目:增加自己开发经历,同时写web服务器的过程能够学习到很多知识,比如HTPP协议、socket编程、线程池,IO模型,并发模型等。
轻量级的web服务器,主要实现的功能解析HTTP报文,支持GET和POST请求;定时器处理非活跃的连接;访问本的mysql数据库实现用户的注册和登录;实现异步日志系统,记录服务器运行状态。


IO多路复用

IO多路复用那部分你是怎么去抽象这个事情的?
主线程负责接收连接和读写,工作线程进行报文的解析。


怎么去实现业务逻辑和核心逻辑的区分是怎么去组织这个代码?
请求队列是各单元之间的通信方式的抽象。主线程往任务队列里加入任务,工作线程组从任务队列获取任务。


怎么保证你epoll的代码可以尽量的被复用呢? -> 不太理解此问题,问的是epoll怎么实现IO多路复用?


epoll的原理你知道么?
首先epoll_create创建一个epoll文件描述符,底层同时创建一个红黑树,和一个就绪链表;红黑树存储所监控的文件描述符的节点数据,就绪链表存储就绪的文件描述符的节点数据;epoll_ctl将会添加新的描述符,首先判断是红黑树上是否有此文件描述符节点,如果有,则立即返回。如果没有, 则在树干上插入新的节点,并且告知内核注册回调函数。当接收到某个文件描述符过来数据时,那么内核将该节点插入到就绪链表里面。epoll_wait将会接收到消息,并且将数据拷贝到用户空间,清空链表。对于LT模式epoll_wait清空就绪链表之后会检查该文件描述符是哪一种模式,如果为LT模式,且必须是该节点确实有事件未处理,那么就会把该节点重新放入到刚刚删除掉的且刚准备好的就绪链表,epoll_wait马上返回。ET模式不会检查,只会调用一次。


epoll的边沿触发和水平触发这个了解么?说一下
水平触发LT,只要socketfd的状态为就绪状态(如socketfd的内核接收缓存区中还有未读完的数据),下次调用epoll_wait()仍然会向应用程序通知此就绪事件;边沿触发ET,只有当socketfd的状态由未就绪变成就绪(如socketfd的内核接收缓存区中有新的数据到来),调用epoll_wait()才会向应用程序通知此就绪事件。

ET模式下事件被触发的次数要比LT模式下少很多。(减少了内核态与用户态切换的消耗)

PS:读的时候,设置应用程序的缓存区长度大于报文的长度;写的时候,如果内核缓冲区满了,就重新注册写事件,返回true。


你使用的是哪种模式呢?
ET


为什么epoll在ET模式下必须设置非阻塞的socketfd?
在ET模式下,内核只通知应用程序一次就绪事件,需要一次性读完内核缓存区的数据。如果设置成阻塞IO,进行循环的read操作,当某次读完内核缓冲区数据时候,程序一定会阻塞在下一次read操作。因为当内核缓冲区没有数据时,阻塞IO不会返回errno,会一直阻塞当前线程;而非阻塞IO会不断地check,当内核缓冲区没有数据时会立即返回errno,EAGAIN或者EWOULDBLOCK,通过返回的errno可以判断是否读完。

什么情况下一次读不完内核缓冲区数据?

PS:Socket 读写就绪条件
当满足下列条件之一时,一个套接字准备好读:

  • 该套接字接收缓冲区中的数据字节数大于等于套接字接收缓冲区低水位标记的当前大小。对这样的套接字执行读操作不会阻塞并将返回一个大于 0 的值(也就是返回准备好读入的数据)。我们可以使用 SO_RCVLOWAT 套接字选项设置该套接字的低水位标记。对于 TCP 和 UDP 套接字而言,其默认值为 1。
  • 该连接的读半部关闭(也就是接收了 FIN 的 TCP 连接)。对这样的套接字的读操作将不阻塞并返回 0 (也就是返回 EOF)。
  • 该套接字是一个监听套接字且已完成的连接数不为 0。对这样的套接字的 accept 通常不会阻塞。
  • 其上有一个套接字错误待处理。对这样的套接字的读操作将不阻塞并返回 -1(也就是返回一个错误),同时把 errno 设置成确切的错误条件。这些待处理错误也可以通过指定 SO_ERROR 套接字选项调用 getsockopt 获取并清除。

选择边沿触发,虽然只通知一次但是你应用层还是要把这个状态记录下来的,那么一个是应用层消耗一个是内核态消耗,有考虑过么?
ET模式,循环读/写,一次性处理完是应用层消耗;LT模式,未读/写完成的由内核再次通知,这是内核态消耗。(LT模式下事件多次通知,需要内核态和用户态多次切换)
ET模式减少了内核态和用户态切换的消耗。(内核态和用户态频繁切换消耗比较大)


EPOLLONESHOT:
保证一个socket连接在任一时刻都只被一个线程处理。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。


线程池线程数设置多少

线程数,射多少更舒适?
主要考虑IO密集型还是CPU密集型。web服务器是IO密集型。

  • 对于 CPU 密集型计算,多线程本质上是提升多核 CPU 的利用率,所以对于一个 8 核的 CPU,每个核一个线程,理论上创建 8 个线程就可以了。
  • 对于 IO 密集型任务最大线程数一般会大于 CPU 核心数很多倍,因为 IO 读写速度相比于 CPU 的速度而言是比较慢的,如果我们设置过少的线程数,就可能导致 CPU 资源的浪费。而如果我们设置更多的线程数,那么当一部分线程正在等待 IO 的时候,它们此时并不需要 CPU 来计算,那么另外的线程便可以利用 CPU 去执行其他的任务,互不影响,这样的话在任务队列中等待的任务就会减少,可以更好地利用资源。

猜你喜欢

转载自blog.csdn.net/XindaBlack/article/details/106590225