Linux高并发服务器开发项目逻辑+面试题整理

linux web服务器介绍


[Linux高并发服务器开发](https://www.nowcoder.com/courses/cover/live/504),是一个非常经典的网络 编程/C++高性能相关的入门项目,这里是非常推荐大家学习的,当作入门都行,当然,也能当作以后的面试项目,还能 用来当作很多知识的入门。

因为这个项目,一共有 5 个章节,前面四章会是教你入门Linux系统编程,网络编程等,只有第五章,才是项目,所以大家不要在意烂大街啥的,没关系,核心是你能学到东西,这才是最重点的。

所以呢,大家做这个项目的时候,如果前面四章觉得都学过都懂,也能直接跳到第五章。

另外,如果你不想看视频,想看书的话,也可以看《Linux高性能服务器》 这本书,这本书和那个视频差不多一样吧,也是最后写了一个web服务器。

大家学完之后,以后想要面试,直接看下面关于面试准备即可。

关于本项目面试准备:前言(面试时看哈)


为了让大家更快着复习,帅地找了几个做过这个项目,并且面试过很多场这项目的大佬,按照我的思路去整理了这个项目相关的内容,包括项目整体逻辑 + 相关面试题。

不过一个项目可以问的非常非常多了,我肯定是无法全部都整理了,因为有很多和我们平时学的八股文是重合的,为了让大家更好去「突击」复习,我这里关于面试部分,只整理了那些被问的最多的问题。

大家可以做完项目之后,用这个来复习,之后再回过头去复习自己的项目代码。

简历书写模版


简历的话,你可以参考这个写

项目整体复习逻辑

1、概述

要想了解到这个项目一些基本流程,首先我们要对这个项目本身的定位以及功能有一个认知。一个Web Server就是一个服务器软件,或者是运行这个服务器软件的硬件,主要实现的功能是通过HTTP协议与客户端(通常是浏览器(Browser))进行通信,在项目中需要实现对来自客户端的HTTP请求进行处理相关操作,并对其请求做出HTTP响应,返回给客户端其请求的内容(文件、网页等)或返回一个Error信息。在本格项目中主要是对HTTP响应中的GET以及POST请求进行响应。在了解这个服务器所应该实现的功能之后,就可以对服务器进行设计与开发。

2、web服务器如何与服务端进行通信

想一下我们平时访问一个页面的过程:浏览器中键入“域名”或“IP地址:端口号”,浏览器先将域名解析为IP地址或者直接使用ip地址对需要访问的服务器发送一个HTTP请求。这个过程的话就是经典的八股问题:“在浏览器中输入一个域名后都发生了什么”,在这里不过多赘述。

我们知道不同主机之间的通信方式有socket通信,这种通信方式就应用在本服务器中,可以说web服务器的主要框架就是进行一个socket通信的一个过程,通过socket中的一些系统函数实现以下功能:通过TCP协议的三次握手建立与目标Web服务器的连接,然后HTTP协议生成针对目标Web服务器的HTTP请求报文,通过TCP、IP等协议发送到目标Web服务器上,服务器针对请求报文进行解析,并返回response报文。

3、服务器如何运行的

在搞清楚服务器中实现通信的过程后,就可以对具体的模块进行分析,在考虑服务器如何运行这个问题上,我们脑子中一定要有一个socket通信的过程,服务器运行的框架始终贯穿贯穿这个通信的过程,各种模块都是服务于这个通信的过程。

项目中的主要模块:

  1. 日志模块:实现了同步日志以及异步日志;
  2. 线程池模块:通过线程池使用线程池对任务进行处理;
  3. 项目配置模块:对项目的一些属性进行配置(日志写入模式、触发模式、端口号…);
  4. 定时器模块:在建立HTTP连接后将连接加到定时器中,实现对连接的监听,定期淘汰非活跃连接;
  5. 启动类:在主程序中main函数中的参数进行命令行解析,并将解析行中的属性注入到整个项目中以此实现属性的配置。解析后进行日志模块、线程池模块进行初始化,最后进行对事件监听与运行的操作。

在了解到服务器中的各个模块后,我们接下来将这些模块串起来,来去分析这个服务器是如何运行的。

4、服务器启动

首先在主程序中main中对下面中的参数进行命令行解析并注入命令行中输入的指令:

int main(int argc, char *argv[]);
config.parse_arg(argc, argv);

其中如果命令行没有特别的去规定一些模式,那么就会选择默认的模式:默认端口9006、默认日志写入方式 同步、默认触发模式listenfd、connfd都为LT、默认不使用优雅关闭连接、默认线程池中的线程个数、默认并发模型是proactor。

之后初始化一个websever对象,并且通过调用webserver类中的init()方法将响应的属性注入到服务器中,初始化之后将日志模块、线程池、触发模式都创建或者构建出来,在准备工作进行之后,就可以开始服务器中最关键的步骤:监听事件与运行。(这个监听事件和运行其实就是socket通信中的过程,监听事件这个过程是建立通信并将listen_fd放到内核中,运行就是服务器对事件的处理)

5、监听:eventListen()

主要的流程框架就是linux网络编程socket通信服务端的动作,总而言之就是这部分完成设置listen_fd与统一事件源,并且创建了一个带有节点的epoll树,同时完成了超时设定。

在服务端,网络编程的基本步骤应该已经熟知:socket()、bind()、listen()、accept(),在这一过程主要涉及前三部分,accept()动作主要在eventLoop()中进行处理。

  • 创建网络通信listen_fd套接字(socket()):
m_listenfd = socket(PF_INET, SOCK_STREAM, 0);

socket()函数第一个字段:表示地址族,也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6,分别代表IPV4与IPV6。

第二个字段:表示数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字)。

第三个字段:表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。一般可以通过前两个字段就推出使用了何种传输协议,因此最后一个字段通常设为0,让系统自动推演出使用何种协议。

  • 绑定(bind())

socket()函数用来创建套接字,确定套接字的各种属性,然后服务器端要用 bind() 函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。

bind()的使用方式和代码如下(模式就固定的):

int ret = 0;

struct sockaddr_in address; //创建sockaddr_in结构体变量
 // bzero() 会将内存块(字符串)的前n个字节清零;
 // s为内存(字符串)指针,n 为需要清零的字节数。
 // 在网络编程中会经常用到。
bzero(&address, sizeof(address)); //字节填充为0
address.sin_family = AF_INET; //使用IPv4地址
address.sin_addr.s_addr = htonl(INADDR_ANY);  //具体的IP地址
address.sin_port = htons(m_port);  //端口
int flag = 1;
//SO_REUSEADDR 允许套接口和一个已在使用中的地址捆绑,主要是用做服务器重启时能使用在挥手过程中的time_wait阶段的端口号与ip。
setsockopt(m_listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
ret = bind(m_listenfd, (struct sockaddr *)&address, sizeof(address));
//将套接字和IP、端口绑定
  • 真正的监听(listen())

对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态,这里只是让套接字处于被监听的状态,并没有接受请求,最后需要调用 accept() 函数,就可以接受、响应客户端的请求了。

//表示已连接和未连接的最大队列数总和为5
ret = listen(m_listenfd, 5);

listen的第一个参数是代表的是套接字的fd,第二个参数是请求队列的最大长度。

  • epoll相关(重点,考察较多

在socket通信时,一般都是一对一进行通信的,那么如何让服务端处理更多客户端的请求呢?项目里使用了io多路复用技术去对fd进行监听,让一个进程去监听维护多个fd,达到高性能的要求。epoll相关的具体细节我们之后再讲。

首先epoll创建内核事件表:

epoll_event events[MAX_EVENT_NUMBER];
m_epollfd = epoll_create(6);

然后将上文创建的listen_fd放到epoll底层中的红黑树进行监听:

utils.addfd(m_epollfd, m_listenfd, false, m_LISTENTrigmode);
http_conn::m_epollfd = m_epollfd;

addfd()这个函数就是自己提前写好的工具包中的方法,参数的话主要说明了该事件的触发模式、以及是否使用EPOLLONESHOT,在定义好这些属性后使用epoll_ctl()函数将事件注册到红黑树中。

  • 创建管道用于进程通信

在服务器中,一般信号处理与IO处理不走一条路,在这里,信号处理的问题主要就是超时问题,记不记得项目还有个定时器的模块。

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

创建管道套接字并将写端设置为非阻塞:

ret = socketpair(PF_UNIX, SOCK_STREAM, 0, m_pipefd);
//socketpair()函数用于创建一对无名的、相互连接的套接子。
utils.setnonblocking(m_pipefd[1]);
//pipefd[0]:读端  pipefd[1]:写段,读写端存的都是connfd。

将管道写端设置为阻塞的原因是:send()会将信息发给套接字缓冲区,缓冲区满了会阻塞,这时会增加信号处理函数的执行时间,因此一般设置为非阻塞的。

接着使用epoll去监听管道的读端:

utils.addfd(m_epollfd, m_pipefd[0], false, 0);

注意:不能注册EPOLLONESHOT事件,否则应用程序只能处理一个客户连接。

传递给主循环:

//传递给主循环的信号值,这里只关注SIGALRM和SIGTERM
utils.addsig(SIGALRM, utils.sig_handler, false);
utils.addsig(SIGTERM, utils.sig_handler, false);

总结:在此模块我们完成了三件事:1、创建监听socket m_listenfd并且初始化(bind,listen);

2、epoll创建内核事件表m_epollfd,将m_listenfd插入事件表中;3、创建管道m_pipefd, 用于之后处理信号(定时器发送的等),并插入到m_epollfd中。

6、运行:eventloop()

在完成上面的一些工作后,可以说我们的初始化操作就完成啦,在这个模块中我们将对服务器的具体功能进行剖析。

在这个模块进行一个while循环,只要服务器是没有被关闭的话,那么就一直去进行一个循环去处理任务。那么该如何去获取任务和事件呢?上述我们已经将一些处理放在了epoll的内核中,并且内核中有一个就绪队列,那么我们将就绪队列中的任务取出来就可以了。

具体取出来的方式:使用epoll_wait()进行,然后去遍历取出来的这些任务,然后交给服务器的各个模块进行处理。主要的任务有处理新到的客户端连接的请求、服务端关闭的请求、处理一些信号的请求以及一些处理读写事件的请求,在这个过程中我们要去判断一下这个连接是不是过期的,模块中的时间处理就是来判断过期情况的。

如果事件是一个新的连接请求的话:

其实这个部分还是进行socket通信建立连接的过程,具体的过程就是在服务端使用accept()函数,从TCP三次握手中的全连接队列中取出一个连接请求,要注意accept()函数产生了一个新的fd:connfd,这个fd主要是用来之后的read()、write()这些请求所使用的fd(之前监听所用的fd是listenfd,用于监听)。建立连接的过程同时要调用定时器模块的函数将此任务添加到定时器进行监视。

处理信号:

进程之间的信号通信是通过管道来进行的,如果说sockfd= =m_pipefd[0],这就证明是要处理信号,通过调用recv()函数将m_pipefd[0]端的信号读出来,在本系统中信号主要有两个信号:SIGALRM、SIGTERM,分别用于对升序链表上所有定时器进行处理,以及关闭整个服务器这两个操作。(SIGALRM信号是由alarm函数周期性的去触发的)

处理关闭连接:

在循环过程中如果满足以下的条件:events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR),就是如果事件是要关闭的事件或者是出错的事件,就需要去进行一个关闭连接的操作,这个操作就是将此个连接的timer在时间升序链表中进行删除。

处理读事件:

读写事件的话,就是需要服务器进行一个处理。这个处理的话在本系统中是用开出的线程进行处理的,那么就要设计线程的创建与销毁,在这里我们使用线程池来避免这个过程,减少线程的切换与创建。因此我们要把这些事件加入到线程池中的任务队列中,在这里就有reactor并发模式以及模拟proactor的两种模式,在reactor模式下,我们只需要将这个任务交给缓冲池的任务队列等待工作线程去处理就行了,在模拟proactor模式下,我们应该先进行一个循环读取客户端数据直到没有数据可读或者服务器关闭。

处理写事件:

写事件处理的逻辑和上述的读事件是一样的。

在框架外的一些具体功能的实现

(1)定时器

功能:处理非活跃的连接,非活跃的连接指的是客户端(这里是浏览器)与服务器端建立连接后,长时间不交换数据,一直占用服务器端的文件描述符的连接。

定时器的容器设计:项目中的定时器容器为带头尾结点的升序双向链表,在建立连接的时候我们将这个节点添加到链表中,并按照超时时间升序排序,保证双向链表是升序的,在之后可以定期将时间小的定时器进行删除,删除的策略就是从链表头开始遍历,如果时间是比当前时间小的,那么就可能使非活跃的连接,进行一个删除操作就行了。这个容器的设计主要涉及双向链表的插入和删除操作,添加时可能时间复杂度为O(n),时间复杂度还是过高,进行优化时可以考虑使用小根堆进行操作,降低时间复杂度。(可以在面试时候说自己做过优化的一些策略)。

如何触发定时器:通过alarm函数生成了SIGALRM信号,在这时使用统一事件源,放到epoll内核的红黑树中,epoll_wait读取到该事件后,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器。

处理的逻辑:容器是一个升序链表,因此我们只需要找最小的,遍历定时器升序链表容器,从头结点开始依次处理每个定时器,直到遇到尚未到期的定时器,将链表中过期的定时器进行删除。

(2)日志系统

这个部分涉及到的内容比较多了,比如设计模式中的单例模式、生产者消费者模型以及其中的同步互斥的保证、条件变量机制等。

单例模式的话就是为了保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享,在这里实现的思路如下:私有化它的构造函数,以防止外界创建单例类的对象;使用类的私有静态指针变量指向类的唯一实例,并用一个公有的静态方法获取该实例。具体的单例模式的设计模式需要主动去学习。

本系统的日志分为同步日志和异步日志。日志是在工作线程工作的时候产生的,由服务器创建的记录运行状态、错误信息等。同步日志就是向日志文件中写日志这一IO动作是与工作线程串行执行的,那么系统可能由于要写一个大的日志,导致写日志这个动作阻塞整个系统,此时就提出了异步日志。异步日志我感觉像是某种消息队列,就像rabbitmq中的异步消息队列一样,将所写的日志内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日志。

然后我们来分析这个阻塞队列,阻塞队列就是将生产者-消费者模型进行封装,使用循环数组实现队列,作为两者共享的缓冲区。我们需要对这一临界资源实现同步与互斥,生产者和消费者是互斥关系,两者对缓冲区进行互斥的访问,同时生产者和消费者又是一个相互协作同步的关系,就是写日志的动作必须在产生日志了之后。

这里主要使用了条件变量去实现了同步与互斥,要注意条件变量一般是和互斥锁一起使用的。(在这里面试官可能会问,无论是对这些临界区进行上锁或者条件变量,都可能效率低下,有没有什么方式更高效率?这里的一个答案可以说使用无锁数据结构,比如通过cas去实现无锁队列,更加高效,有兴趣可以去了解一下)。

(3)线程池

线程池实际上就是池化技术的一种,为了解决一些高并发,减少线程的建立销毁过程。

本系统的线程池的整体框架主要是:在服务器启动时进行一个线程池的初始化,然后利用epoll将端口事件请求放在任务请求队列中,接着工作线程从请求队列中取出任务进行执行。根据上述框架得出线程池的一个主要结构就是:一个请求队列及其保证请求队列的互斥锁等。

在这个模块主要要搞明白两个过程。

一是pthread_create()这个函数,这个函数的第三个参数是一个函数指针,实际上就是回调函数,就是创建的线程要去执行的任务,这函数指针是一个void *的格式,因此回调的函数应该设置为静态成员函数,那么这时就有一个问题,静态成员函数只能访问静态成员变量,因此我们在这个worker静态成员函数中调用一个非静态成员函数取访问非静态的资源。

第二个是在一中创建好的工作线程去取工作队列中的任务的这个过程,这里通过信号量和锁来实现,信号量去判断这个工作队列中是否有要执行的任务(就是PV操作),然后就是一个互斥锁使工作线程互斥的去访问这个工作队列。之后的动作就是工作线程具体去处理任务的过程(HTTP的request和response以及一些读写什么的),至此线程池的功能分析完毕。

总结


总而言之,web服务器的复习要着眼于通信框架,关注于几个部分:线程池、同步异步日志、以及处理非活跃连接的模块,

面试中相关面试题整理

多路复用 IO 部分

你用了epoll,说一下为什么你用epoll,还有其他多路复用的方式吗?区别是什么?

还有select和poll两种多路复用的方式:

对于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和poll的种种缺点(时间效率和大小限制两方面),我选择使用epoll:

epoll最大的优点就是,当出现满足条件的事件时,直接返回的是一个个满足条件结构体保存在结构体数组中,不需要像select和poll那样还需要循环依次判断每个是否满足时间发生条件,或者说不需要专门的数组去记录满足的事件。epoll最适合链接的很多,但是使用的很少的场景高并发低低传输的场景。另外epoll也可以突破最大文件上限。

ET和LT的区别

首先ET是多路复用epoll模式下的一个特殊的一个触发模式,这两种触发模式就像是数电模电中的水平和边沿触发一样。具体的区别:

ET是一次事件只会触发一次,如一次客户端发来消息,fd可读,epoll_wait返回.等下次再调用epoll_wait则不会返回了。

LT是一次事件会触发多次,如一次客户端发消息,fd可读,epoll_wait返回,不处理这个fd,再次调用epoll_wait,立刻返回。

由于上述区别的特性,因此在系统中对应模式下的对事件的处理方式也是不同的,在ET模式下时,需要用一个while循环将事件处理完,如果不处理完的话那么由于事件只会触发一次,这时就导致事件丢失了,因此在系统中具体的处理要注意写。

如果要问ET和LT哪个较为高效的话,我们具体场景具体分析:对于读事件,ET模式下的编程需要一直进行一个读取的操作,直到read到EAGAIN的状态,如果发来的事件多且并发量比较大时,可能由于ET模式的特性,会阻塞在读,可能造成其他事件的饥饿。而LT模式下,一次不必读取太多数据,因为没读完的事件下次还是会触发,效率相对较高。对于写事件,ET会高效一点,因为在LT模式下,不需要写事件时需要手动将事件移除,避免不必要的触发,浪费CPU的资源;而在ET模式下,写事件触发后,只需要等下一次的写事件触发驱动任务,需要继续注册一次检测可写事件。

你了解EPOLLONSHOT事件的用法吗

考虑以下场景:一个线程在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中该socket 上又有新数据可读,此时另外一个线程被唤醒来读取这些新的数据,那么是不是两个线程同时在处理一个socket。如果要实现一个socket在任意时间只能被一个线程操作,那么就要使用epollonshot事件。
具体的用法:注册了 EPOLLONESHOT 事件的文件描述符操作系统只能触发一次可读、可写或者异常事件,这样其他线程就无法去进行另外的操作。但是在使用的过程中要注意:注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕, 该线程就应该立即重置这个socket 上的 EPOLLONESHOT 事件,来确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket。

讲一下reactor、proactor模型

Reactor和Proactor模式的主要区别就是真正的读取和写入操作是有谁来完成的:

reactor模式:同步阻塞I/O模式,需要应用程序自己读取、写入数据,proactor模式:异步I/O模式,不需要进行读取或者写入。

一般来说,proactor模式是调用aio_read来实现异步操作的,由于linux下的异步操作并不成熟以及调试起来比较困难,因此项目里使用的是模拟的proactor模式,是使用同步IO方式去模拟异步IO,模拟的方式:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理。而reactor模式下是不需要主线程进行一个读写数据的操作,直接交给工作线程进行读写操作和逻辑处理操作。

线程池相关

讲一下你设计线程池的目的是什么,以及这个线程池大致是怎么实现的?

实现线程池主要是用于并发的去处理用户的请求,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等等)。通过线程池实现了并发(多线程并发),为每个就绪的文件描述符分配一个逻辑单元(线程)来处理。另外,当需要限制你应用程序中同时运行的线程数时,线程池非常有用。因为启动一个新线程会带来性能开销,每个线程也会为其堆栈分配一些内存等。为了任务的并发执行,我们可以将这些任务任务传递到线程池,而不是为每个任务动态开启一个新的线程。从而避免线程频繁创建、销毁加大系统开销

大致的实现:

  • 线程池中主要包含任务队列工作线程集合,将任务添加到队列中,然后在创建线程后,自动启动这些任务。使用了一个固定线程数的工作线程,限制线程最大并发数。
  • 多个线程共享任务队列,所以需要进行线程间同步,工作线程之间对任务队列的竞争采用条件变量互斥锁结合使用,为了可以同时处理多个连接,需要在让一个线程的处理函数不间断的进行while循环。
  • 一个工作线程先加互斥锁,当任务队列中任务数量为0时候,阻塞在条件变量,当任务数量大于0时候,用条件变量通知阻塞在条件变量下的线程,这些线程来继续竞争获取任务。
  • 对任务队列中任务的调度采用先来先服务算法。

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

线程池中的工作线程是处于一直阻塞等待的模式下的。因为在我们创建线程池之初时,我们通过循环调用pthread_create往线程池中创建几个工作线程,工作线程处理函数接口为pthread_create函数原型中第三个参数函数指针所指向的worker函数(自定义的函数),然后调用线程池类成员函数run(自定义)。要注意pthread_create的第三个参数worker函数必须是一个静态函数,只有是静态函数才没有this指针,才不会有问题,那么我们知道这个worker静态函数只能访问类内的静态成员变量,那么就需要去调用线程池类内的成员函数run去访问类内的非静态成员变量。

在run函数中,我们为了能够处理高并发的问题,将线程池中的工作线程都设置为阻塞等待在请求队列是否不为空的条件上,因此项目中线程池中的工作线程是处于一直阻塞等待的模式下的。

线程池中的线程数量是根据什么确定的?

是根据CPU的数量来决定的。

假如CPU是4核的,对于CPU密集型的,线程池中的线程数量最好也是4;对于IO密集型的,线程数一般要多于CPU的核数,因为线程之间竞争的是IO而不是CPU,IO处理比较慢,多于cores数的线程将为CPU争取更多的任务,不至在线程处理IO的过程造成CPU空闲导致资源浪费。

另外说一下从大多数开源的项目来看,每一类任务使用一个线程池也就是:每一类任务使用独立的线程池,不与其他的任务共享线程池。原因分析:独立线程池不影响不同任务类型的任务作业,有利于保证任务的独立性和完整性每一个线程池最好做单一任务,尽可能的减少多任务类型混合使用一个线程池。

这个问题也可以这样问:设置线程池大小的依据原则、把线程池大小设置越大越好吗?

当然不是,把线程池大小设置越大,会增加上下文切换成本,意味着消耗了大量的 CPU 时间,之后的可以依照上面的回答进行描述。

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

  1. 如果处理完任务后,请求队列为空,没有要处理的任务时,这个线程就重新回到阻塞等待的状态;
  2. 如果处理完任务后,请求队列不为空,代表还有要处理的任务,那么这个线程就处于与其他线程竞争资源的状态,谁获得锁谁就获得了处理事件的资格。

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

这种问题就是在问你的服务器是具体如何去处理高并发的请求的,在本项目中是通过在线程池中对子线程循环调用来解决高并发问题的。子线程通过run调用函数进行while循环,让每一个线程池中的任务在服务器启动的期间永远都不会终止,就是一个线程池工作线程处理完一个任务后的状态要么是继续去处理另外待处理的任务,要么就是回到阻塞等待的状态。要注意不要对子线程进行pthread_detach操作,因为这样会使子线程处理完任务后,资源自动回收,子线程就没有了,那么就代表每个子线程只能处理一个任务,就无法达到高并发处理任务的目的。

如果说采用这种方式速度还是慢的话,那么只能去增大线程池的容量,或者去考虑分布式集群等做法。

线程池有什么改进的空间吗?

由于线程池是为了去处理任务的,我们知道任务可能分为计算任务或者说是IO任务,因此在服务器中设置线程池可以将线程池根据处理任务的种类,去具体实现不同的线程池,比如用于处理IO的线程池和用于处理计算(CPU)的线程池。

能详细讲一下有限状态机怎么解析http报文吗

在我的项目里处理的报文主要有两种HTTP报文的get请求和post请求,主要实现的是get请求请求到一个静态的html页面返回给客户端,那么就拿这个解析get请求报文为例。

首先流程大致如下,当浏览器向我们的服务器发送请求后,服务器要去解析这个发送来的请求,就是解析这个请求报文,解析完之后服务器需要向浏览器发送响应报文。在解析请求报文时,HTTP的报文由请求行、请求头、一个空行和请求体组成,get和post的报文又不相同,需要对具体的报文进行一个解析。

在解析前首先要说明要解析的报文从哪里来:浏览器端发出http连接请求后,主线程创建http对象接收请求并将所有数据读入对应buffer,将该对象插入任务队列,工作线程从任务队列中取出一个任务进行处理。在接受完一个报文后,就可以开始去进行解析,这里使用了有限状态机去解析报文。

有限状态机是一种用来处理逻辑单元内部的高效编程,在服务器内部,可以根据状态或者消息类型进行不同的处理逻辑,使得程序清晰明了。在本项目里我使用了主从状态机去解析报文的每一个部分,逻辑清晰,主状态机有三种状态,用于解析报文具体的位置:

  • CHECK_STATE_REQUESTLINE,解析请求行
  • CHECK_STATE_HEADER,解析请求头
  • CHECK_STATE_CONTENT,解析消息体,仅用于解析POST请求

从状态机也有三种状态,用去判断从缓存区读取到的每一行的读取状态,在本项目中从状态机也负责对buffer中的数据进行读取与修改:在HTTP报文中,每一行的数据由\r\n作为结束字符,空行则是仅仅是字符\r\n,因此可以通过查找\r\n将报文拆解成单独的行进行解析,在从状态机中,读取完buffer中的数据后,将每行末尾的\r\n置为\0\0,并且更新要读取的下一个位置的index。做完这些工作后,就可以驱动主状态机去解析对应的部分了,从状态机的三种状态:

  • LINE_OK,完整读取一行
  • LINE_BAD,报文语法有误
  • LINE_OPEN,读取的行不完整

具体的解析过程就是使用一个while循环,去解析从缓冲区中读到的报文,通过switch..case来区解析对应的部分,逻辑十分清晰。

定时器

为什么使用定时器?

这个定时器实际上就是我们自己规定的数据结构,将不同的定时事件封装起来,在本项目中只涉及了关闭连接的事件,封装好之后,在将这个定时器串联起来,达成最终的一个结构,实现对定时事件的统一管理。在本项目中定时器主要是为了去关闭非活跃连接,释放一些资源。

在本项目中这个定时器串联起来的结构使用的是一个升序的双向链表,去遍历找时间过期的定时器,然后去进行对应的定时任务,这个定时任务就是将超时的定时器从结构中删除掉。(时间复杂度可能偏高,可以吹一下自己的改进:肖小根堆以及更为复杂的时间轮 linux系统中的定时器使用的就是时间轮,比较高效)

定时器模块的工作的原理

在主循环socket通信的时候,建立连接后需要将对应的连接添加到定时器容器中,在主循环接收到处理定时任务的事件时,我们转向去处理定时任务。定时任务的就是去遍历双向链表进行删除超时定时器。这里我们知道这个定时任务的事件也是epoll监听的fd,如何去判断是去处理定时器呢?
这里就用到了管道,如何去通知主循环?答:用了管道,利用alarm函数周期性地触发SIGALRM信号,信号处理函数利用管道通知主循环,在主循环中利用如下语句进行判断:

(sockfd == m_pipefd[0]) && (events[i].events & EPOLLIN)

如果满足的话,就转去处理信号就可以了。

Web服务器为什么要去关闭⻓时间没有请求的连接?

首先这个连接是在服务器和客户端之间已经建立了的,那么建立一个连接就需要消耗一些服务器的资源,例如端口号什么的,因此要及时关闭长时间没有请求的连接主要去释放相关资源,让服务器能够更好地处理新的连接请求,从而提高了服务器的响应速度和处理能力,提高了服务器的性能和资源利用率。

日志模块

讲一下日志模块的用处

日志是由服务器自动创建的,用于记录运行状态,错误信息等状态,我们需要将日志写入到本地的磁盘中去,因此引入了日志模块。日志的写入的类型主要分为同步日志与异步日志,同步日志指的是日志写入函数与工作线程串行执行;异步日志指的是将日志写入函数交予另外的线程来进行,比较高效。在本项目中异步日志的实现主要是通过一个基于生产者消费者模型的阻塞队列来实现的,将所写的日志内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日志。

讲一下你的日志模块的运行流程

main()函数中的初始化日志模块会指定日志的类型(同步还是异步)、日志的写入位置等信息。在这里是通过单例模式,使用Log::get_instance()->init()来初始化具体的实例。
在初始化之后,服务器启动后就开始进行一个日志的记录的动作,根据之前指定的同步或者异步进行不同的日志写入。

为什么使用了异步日志,讲一讲这个异步日志是怎么实现的

由于同步日志中日志写入函数与工作线程串行执行,那么出现如下的场景:当单条日志比较大的时候,同步模式会阻塞整个处理流程,服务器所能处理的并发能力将有所下降,尤其是在峰值的时候,写日志可能成为系统的瓶颈。那么采用异步日志的话就可以解决此问题,IO操作的时间不在主线程,主线程就有更多的时间去处理更多的并发。
异步日志主要是采用生产者消费者模型来实现一个缓冲队列,写操作扮演一个生产者的模型,读取日志写到具体的日志文件中扮演一个消费者的模型,缓冲区实际上就是一个循环数组,在这里要实现临界缓冲区的同步、互斥操作。(使用锁的话可能效率过低,如果了解无锁数据结构CAS什么的可以吹一下无锁数据结构)

性能测试相关

1、你的服务器支持多少的并发量,你是如何做压力测试的?

并发量的话达到了2W+,QPS8K+的效果,压力测试是使用linux下的webbench这个工具进行的。

2、你这个webbench是什么,介绍一下原理?

webbench是linux系统中一个最常用的web 性能压力测试工具,可以根据修改参数对服务器进行访问。

这个工具的原理大概就是父进程fork产生若干个子进程,每个子进程在用户要求时间或默认的时间内对目标web循环发出实际访问请求,父子进程通过管道进行通信,子进程将自己访问web若干次后得到的信息放在管道的写端,父进程通过管道的读端读取子进程发来的信息,父进程在所有子进程退出后统计所有子进程所传来的信息统计并给使用者显示出测试结果,然后退出。

3、cpu忽然百分之百了该如何排查?

查看进程/线程的CPU占用率情况:使用系统自带的 tophtop 命令可以查看系统中各个进程/线程的CPU占用率,定位到具体的进程/线程。

检查资源使用情况:检查系统中的资源使用情况,比如内存、磁盘等。如果这些资源也出现了不正常的使用率,说明服务器可能面临着资源竞争问题,需要优化系统资源的使用。

检查网络连接情况:检查网络连接情况,网络连接异常也可能导致CPU使用率过高。可以使用 netstat 命令查看当前网络连接状态(在本项目中主要是看建立的socket连接时的三次握手的状态,看是不是由于是因为网络的部分)

分析日志:分析服务器的日志文件,查看是否有其他异常行为或错误导致了CPU使用率的异常。

优化代码:如果以上步骤都没有解决问题,就需要分析代码优化。通常可以通过代码剖析工具,如 perfgprofvalgrind 分析优化代码。

设计模式

你的项目里用了哪些设计模式?

主要在线程池和定时器这两部分用了单例模式,保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。

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

单例模式带来了什么问题?

单例模式一般没有接口,扩展困难。如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。

在并发测试中,单例模式不利于代码调试。在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。

延伸

有没有了解过Nginx,Nginx⾥使⽤的连接模式是怎样的

Nginx 是一个高性能的 Web 服务器和反向代理服务器,其连接模式主要是基于事件驱动模型的异步非阻塞 I/O。这种模式可以使 Nginx 在连接大量客户端的情况下保持低的资源消耗和高的并发处理能力。

Nginx 通过在主事件循环中轮询事件来实现对连接的管理。在启动时,Nginx 会预先初始化一些连接对象,然后将每个连接对象与一个事件对象关联。当一个连接对象有事件到达时,它会激活事件对象并加入事件队列,使得主事件循环可以及时获知事件并做出响应处理。

具体来说,Nginx 主要使用了以下几种 I/O 模型:

  • 处理器模型:每个进程/线程负责处理一部分连接,具有较好的资源隔离性,但需要考虑进程/线程切换的开销。
  • epoll 模型:使用 epoll 系统调用来监控事件,具有高效和可扩展性,适合大量连接的情况。
  • kqueue 模型:类似于 epoll,但只能在 BSD 系统下使用。
  • select/poll 模型:使用 select/poll 系统调用来监控事件,适用于轻量级连接的情况。

总之,Nginx 的连接模式是基于事件驱动的异步非阻塞 I/O,通过 epoll、kqueue 等系统调用来实现高效的连接管理和事件处理。

你系统中用了哪些技术来实现这个高并发高性能?

主要使用了池化技术(线程池)以及异步日志来提高并发。(具体讲线程池和异步日志的话参考上述写的)。还可以在说一下使用epoll。epoll+线程池这样说。上面也都总结有了。
在这里也可以说一下对于文件发送在用户态去调用sendfile()来实现零拷贝,避免用户态和内核态的切换以及上下文的切换,同时避免了一些数据的拷贝,可以加快系统的性能。

你觉得现在⼀般⼤⼚使⽤的⽀持百万千万并发的服务器与你这个项⽬是⼀个概 念吗?

肯定不是一个级别,我这个项目其实是一个练手的项目,让我熟悉了一个网络编程以及服务器的一些相关技术的使用,但是在大厂里,所需要处理的数据量和并发请求量要远远超过一般项目,所使用的硬件和架构必须能够支持百万或千万级别的并发请求,就是可能架构更为复杂,使用的技术可能不是linux开发时的一些原生的工具,是许多高性能、高并发的框架组成的架构,以此来达成一个百万千万并发的服务器的请求。

为什么要做这么一个所谓的烂大街的项目?

首先我想说明一下这个项目对于我来说是一个练手的项目,在做的过程中学习到了一些基本的知识(往自己会回答上的地方引),在做项目的过程中,我巩固了cpp的基础,学习到了一些优秀的思想epoll多路复用、reactor并发模型、线程池、异步日志等,最重要的是我明白了socket通信在服务端的一个基本流程以及使用,使我对linux下的socket通信有了一个较好的了解。(说自己擅长的东西就对了)

发表评论

后才能评论