web服务器项目常见面试题目(C++)

项目介绍

1、为什么要做这样一个项目?

在学习CPP语言的时候,发现需要做一个项目来巩固一下,网上有推荐这个项目,然后就自己尝试做了一下。这个项目综合性比较强,从中既能学习Linux环境下的一些系统调用,也能熟悉网络编程。

Web服务器能够很好的贯穿之前所学的知识,之前看过的《C++ Primer》、《Effevtive C++》、《STL源码剖析》等书籍。涵盖了

  • TCP、HTTP协议
  • 多进程多线程
  • IO

等知识点。

2、介绍下你的项目

服务器基本框架:

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

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

web服务器项目常见面试题目(C++)_第1张图片

此项目是基于 Linux 的轻量级多线程 Web 服务器,应用层实现了一个简单的 HTTP 服务器,利用多路 IO 复用,可以同时监听多个请求,使用线程池处理请求,使用模拟 proactor 模式,主线程负责监听,监听有事件之后,从 socket 中循环读取数据,然后将读取到的数据封装成一个请求对象放入队列。睡眠在请求队列上的工作线程被唤醒进行处理,使用状态机解析 HTTP 请求报文,实现 同步/异步 日志系统,记录服务器运行状态,并对系统进行了压力测试。

 

3、你的项目的技术难点是什么?

1、如何提高服务器的并发能力

2、由于涉及到I/O操作,当单条日志比较大的时候,同步模式会阻塞整个处理流程

3、多线程并发的情况下,保证线程的同步

4、你是如何克服这个技术难点的?
5、你做这个项目的收获是什么?
6、为什么使用这个技术/组件?

7、如何解决项目中的BUG

(1)运行代码,发现错误,找到报错的位置。

(2)如果注释后运行正常了,那么就是注释掉的部分有误了

(3)若不是,则从main 函数里边调用的开始下手,查看定义,跳转到定义的功能,这很大程度上能让我们快速的找到bug。

8、为什么所有人都是这个服务器项目

自己接触到C++最好的练手项目就是webserver,也不知道其他人都做这个。 

9、项目的异常处理有哪些

 (1)登录异常

         * 用户不存在:根据账号在数据库查,如果查不到就是
         * 用户名或密码错误:数据库查询的用户密码和http请求用户输入的密码比对,如果不一致
         * 登录成功

try-catch语句

程序先执行 try 中的代码
如果 try 中的代码出现异常, 就会结束 try 中的代码, 看和 catch 中的异常类型是否匹配.
如果找到匹配的异常类型, 就会执行 catch 中的代码
如果没有找到匹配的异常类型, 就会将异常向上传递到上层调用者.
无论是否找到匹配的异常类型, finally 中的代码都会被执行到(在该方法结束之前执行).
如果上层调用者也没有处理的了异常, 就继续向上传递
一直到 main 方法也没有合适的代码处理异常, 就会交给 JVM 来进行处理, 此时程序就会异常终止
 

10、项目中用到了什么协议

 HTTP、TCP、DNS

11、后面需要增加的功能

添加文件上传功能,实现与服务器的真正交互。

线程池相关

1、为什么使用线程池

每个请求对应一个线程方法的不足之一是:为每个请求创建一个新线程的开销很大;为每个请求创建新线程的服务器在创建和销毁线程上花费的时间和消耗的系统资源要比花在处理实际的用户请求的时间和资源更多。

线程池是为了避免创建和销毁线程所产生的开销,避免活动的线程消耗的系统资源;

提高响应速度,任务到达时,无需等待线程即可立即执行;

提高线程的可管理性:线程的不合理分布导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。

2、怎么创建线程池(线程池运行逻辑)

 该项目使用线程池(半同步半反应堆模式)并发处理用户请求,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等)

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

线程池是空间换时间,浪费服务器的硬件资源,换取运行效率.

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

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

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

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

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

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

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

线程池的实现还需要依靠 锁机制 以及 信号量 机制来实现线程同步,保证操作的原子性。

 信号量来标识请求队列中的请求数

 

3、线程的同步机制有哪些?

(1)同步I/O

同步I/O指内核向应用程序通知的是就绪事件,比如只通知有客户端连接,要求用户代码自行执行I/O操作

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

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

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

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

 多路IO复用是一种同步IO模型,,它可以实现一个线程可以同时监视多个文件描述符,一旦有某个文件描述符准备就绪,就会通知应用程序,对该文件描述符进行操作。在监视的各个文件描述符没有准备就绪的时候,应用程序线程就会阻塞,交出cpu,让cpu去执行其他的任务,以此提高cpu的利用率。

(2)异步I/O

异步I/O是指内核向应用程序通知的是完成事件,比如读取客户端的数据后才通知应用程序,由内核完成I/O操作

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

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

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

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

(1) 当处理完任务后如果请求队列为空时,则这个线程重新回到阻塞等待的状态

(2) 当处理完任务后如果请求队列不为空时,那么这个线程将处于与其他线程竞争资源的状态,谁获得锁谁就获得了处理事件的资格。

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

本项目是通过对子线程循环调用来解决高并发的问题的。

首先在创建线程的同时就调用了pthread_detach将线程进行分离,不用单独对工作线程进行回收,资源自动回收。

我们通过子线程的run调用函数进行while循环,让每一个线程池中的线程永远都不会停止,访问请求被封装到请求队列(list)中,如果没有任务线程就一直阻塞等待,有任务线程就抢占式进行处理,直到请求队列为空,表示任务全部处理完成。

7、如果一个客户请求需要占用线程很久的时间,会不会影响接下来的客户请求呢,有什么好的策略呢?

会,因为线程池内线程的数量时有限的,如果客户请求占用线程时间过久的话会影响到处理请求的效率,当请求处理过慢时会造成后续接受的请求只能在请求队列中等待被处理,从而影响接下来的客户请求。

应对策略:

我们可以为线程处理请求对象设置处理超时时间, 超过时间先发送信号告知线程处理超时,然后设定一个时间间隔再次检测,若此时这个请求还占用线程则直接将其断开连接。

8、什么是虚假唤醒?

举个例子,我们现在有一个生产者-消费者队列和三个线程。

1) 1号线程从队列中获取了一个元素,此时队列变为空。

2) 2号线程也想从队列中获取一个元素,但此时队列为空,2号线程便只能进入阻塞(cond.wait()),等待队列非空。

3) 这时,3号线程将一个元素入队,并调用cond.notify()唤醒条件变量。

4) 处于等待状态的2号线程接收到3号线程的唤醒信号,便准备解除阻塞状态,执行接下来的任务(获取队列中的元素)。

5) 然而可能出现这样的情况:当2号线程准备获得队列的锁,去获取队列中的元素时,此时1号线程刚好执行完之前的元素操作,返回再去请求队列中的元素,1号线程便获得队列的锁,检查到队列非空,就获取到了3号线程刚刚入队的元素,然后释放队列锁。

6) 等到2号线程获得队列锁,判断发现队列仍为空,1号线程“偷走了”这个元素,所以对于2号线程而言,这次唤醒就是“虚假”的,它需要再次等待队列非空。

9、介绍一下几种典型的锁?

线程池的实现还需要依靠锁机制以及信号量机制来实现线程同步,保证操作的原子性 

(1)读写锁

    多个读者可以同时进行读
    写者必须互斥(只允许一个写者写,也不能读者写者同时进行)
    写者优先于读者(一旦有写者,则后续读者必须等待,唤醒时优先考虑写者)

(2)互斥锁

一次只能一个线程拥有互斥锁,其他线程只有等待

互斥锁是在抢锁失败的情况下主动放弃CPU进入睡眠状态直到锁的状态改变时再唤醒,而操作系统负责线程调度,为了实现锁的状态发生改变时唤醒阻塞的线程或者进程,需要把锁交给操作系统管理,所以互斥锁在加锁操作时涉及上下文的切换。
(3)条件变量

互斥锁一个明显的缺点是他只有两种状态:锁定和非锁定。而条件变量通过允许线程阻塞和等待另一个线程发送信号的方法弥补了互斥锁的不足,他常和互斥锁一起使用,以免出现竞态条件。当条件不满足时,线程往往解开相应的互斥锁并阻塞线程然后等待条件发生变化。一旦其他的某个线程改变了条件变量,他将通知相应的条件变量唤醒一个或多个正被此条件变量阻塞的线程。总的来说互斥锁是线程间互斥的机制,条件变量则是同步机制。

(4)自旋锁

如果进线程无法取得锁,进线程不会立刻放弃CPU时间片,而是一直循环尝试获取锁,直到获取为止。如果别的线程长时期占有锁,那么自旋就是在浪费CPU做无用功,但是自旋锁一般应用于加锁时间很短的场景,这个时候效率比较高。

10、如何销毁线程

  • 1、通过判断标志位,主动退出
  • 2、通过Thread类中成员方法interrupt(),主动退出
  • 3、通过Thread类中成员方法stop(),强行退出

11、detach和join有什么区别

(1)当调用join(),主线程等待子线程执行完之后,主线程才可以继续执行,此时主线程会释放掉执行完后的子线程资源。主线程等待子线程执行完,可能会造成性能损失。

(2)当调用detach(),主线程与子线程分离,他们成为了两个独立的线程遵循cpu的时间片调度分配策略。子线程执行完成后会自己释放掉资源。分离后的线程,主线程将对它没有控制权。

当你确定程序没有使用共享变量或引用之类的话,可以使用detch函数,分离线程。

12、每个线程占多大的内存

 32位系统,分配4G的虚拟内存给进程,每个线程约占10M的内存

13、线程池中有多少个线程,线程池数量如何设定

默认8

调整线程池中的线程数量的最主要的目的是为了充分并合理地使用 CPU 和内存等资源,从而最大限度地提高程序的性能。

Ncpu 表示 CPU的数量。

如果是CPU密集型任务,就需要尽量压榨CPU,参考值可以设为 Ncpu+1能够实现最优的CPU 利用率,+1 是保证当线程由于页缺失故障(操作系统)或其它原因 导致暂停时,额外的这个线程就能顶上去,保证CPU 时钟周期不被浪费

如果是IO密集型任务,参考值可以设置为 2 * Ncpu。因为线程间竞争的不是CPU的计算资源而是IO,IO的处理一般较慢,多于cores数的线程将为CPU争取更多的任务,不至在线程处理IO的过程造成CPU空闲导致资源浪费

最佳线程数量 = ((线程等待时间+线程CPU时间)/ 线程CPU时间)* CPU个数。

由公式可得,线程等待时间所占比例越高,需要越多的线程,线程CPU时间所占比例越高,所需的线程数越少。

14、socket 通信的基本流程(客户端和服务端的通信协议)

web服务器项目常见面试题目(C++)_第2张图片

简单描述一下Socket的通信流程:

(1)服务端这边首先创建一个Socket(Socket()),然后绑定IP地址和端口号(Bind()),之后注册监听(Listen()),这样服务端就可以监听指定的Socket地址了;
(2)客户端这边也创建一个Socket(Socket())并打开,然后根据服务器IP地址和端口号向服务器Socket发送连接请求(Connect());
(3) 服务器Socket监听到客户端Socket发来的连接请求之后,被动打开,并调用Accept()函数接收请求,这样客户端和服务器之间的连接就建立好了;
(4)成功建立连接之后,客户端和服务器就可以进行数据交互(Receive()、Send());
(5)在数据交互完之后,各自关闭连接(Close()),交互结束

15、listen 函数第二个参数 backlog 参数作用

int listen(int sockfd, int backlog);
   
   
     
     
     
     

 backlog是accept阻塞队列的长度,即等待accept的socket的最大数量。

16、listen底层用的是什么队列

a.半连接队列(Incomplete connection queue),又称 SYN 队列。……

b.全连接队列(Completed connection queue),又称 Accept 队列。……

 17、send函数在发送的数据长度大于发送缓冲区大小,或者大于发送缓冲区剩余大小时,socket会怎么反应

不管是windows还是linux,阻塞还是非阻塞,send都会分帧发送,分帧到缓冲区能够接收的大小

18、多线程中线程越多越好吗

不是

(1)假设现有8个CPU、8个线程,每个线程占用一个CPU,同一时间段内,若8个线程都运行往前跑,相比较5/6/7个线程,8个线程的效率高。
(2)但若此时有9个线程,只有8个CPU,9个线程同时运行,则此时牵扯到线程切换,而线程切换是需要消耗时间的。
(3)所以随着线程数越多,效率越来越高,但到一个峰值,再增加线程数量时,就会出现问题。线程太多要来回的切换,最终可能线程切换所用时间比执行时间业务所用时间还大。
(4) 随着线程数越多,由于线程执行的时序的问题,程序可能会崩溃或产生二义性。
 

并发模型相关

1、IO是什么

 在计算机中,输入/输出(即IO)是指信息处理系统(比如计算机)和外部世界(可以是人或其他信息处理系统)的通信。输入是指系统接收的信号或数据,输出是指从系统发出的数据或信号。

(数据从网卡或硬盘读到内核缓冲区)

2、几种I/O模型

(1)阻塞 blocking

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

(2)非阻塞 non-blockingNIO

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

(3)IO复用(IO multiplexing

Linux 用 select/poll/epoll 函数实现 IO 复用模型,这些函数也会使进程阻塞,但是和阻塞 IO 所不同的是这些函数可以同时阻塞多个 IO 操作。而且可以同时对多个读操作、写操作的 IO 函数进行检测。直到有数 据可读或可写时,才真正调用 IO 操作函数。

 多路IO复用是一种同步IO模型,,它可以实现一个线程可以同时监视多个文件描述符,一旦有某个文件描述符准备就绪,就会通知应用程序,对该文件描述符进行操作。在监视的各个文件描述符没有准备就绪的时候,应用程序线程就会阻塞,交出cpu,让cpu去执行其他的任务,以此提高cpu的利用率。 

(4)信号驱动(signal-driven

信号驱动 IO , 安装一个信号处理函数,进程继续运行并不阻塞, 当 IO 事件就绪,进程收到 SIGIO 信号,然后处理 IO 事件。
与非阻塞 IO 的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统 API 的调用次数,提高了效率。

(5)异步(asynchronous

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

3、简单说一下服务器使用的并发模型?两种高效的事件并发处理模式reactor、proactor?主从reactor模型

事件:I/O事件、信号及定时事件

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

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

由于异步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上写入服务器处理客户请求的结果。

(3) 主从Reactor模式:核心思想是,主反应堆线程只负责分发Acceptor连接建立,已连接套接字上的I/O事件交给sub-reactor负责分发。其中 sub-reactor的数量,可以根据CPU的核数来灵活设置。

主反应堆线程一直在感知连接建立的事件,如果有连接成功建立,主反应堆线程通过accept方法获取已连接套接字,接下来会按照一定的算法选取一个从反应堆线程,并把已连接套接字加入到选择好的从反应堆线程中。主反应堆线程唯一的工作,就是调用accept获取已连接套接字,以及将已连接套接字加入到从反应堆线程中。

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

(1)epoll的优点:epoll 是一种更加高效的 IO 复用技术

1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。

3、 内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递;即epoll使用mmap减少复制开销。

epoll 的使用步骤及原理如下:

1)调用epoll_create()会在内核中创建一个指示epoll内核事件表的文件描述符,该描述符将用作其他epoll系统调用的第一个参数。

 在这个结构体中有 2 个比较重要的数据成员:一个是需要检测的文件描述符的信息 struct_root rbr (红黑树),还有一个是就绪列表struct list_head rdlist,存放检测到数据发送改变的文件描述符信息 (双向链表);

2)调用epoll_ctl() 用于操作内核事件表监控的文件描述符上的事件:注册、修改、删除

3)调用epoll_wait() 可以让内核去检测就绪的事件,并将就绪的事件放到就绪列表中并返回,通过返回的事件数组做进一步的事件处理。

 epoll 的两种工作模式:

a)LT 模式(水平触发)LT(Level - Triggered)是缺省的工作方式,并且同时支持 Block 和 Nonblock Socket。 在这种做法中,内核检测到一个文件描述符就绪了,然后应用程序可以对这个就绪的 fd 进行 IO 操作。应用程序可以不立即处理该事件,如果不作任何操作,内核还是会继续通知。

b)ET 模式(边缘触发) ET(Edge - Triggered)是高速工作方式,只支持 Nonblock socket。 在这种模式下,epoll_wait检测到文件描述符有事件发生,则将其通知给应用程序,应用程序必须立即处理该事件。必须要一次性将数据读取完,使用非阻塞I/O,读取到出现EAGAIN。但是,如果一直不对这个 fd 进行 IO 操作(从而导致它再次变成未就绪 ),内核不会发送更多的通知(only once)。

 ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件描述符的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

3)EPOLLONESHOT

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

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

(2)I/O 多路复用是一种使得程序能同时监听多个文件描述符的技术,从而提高程序的性能。 Linux 下实现 I/O 复用的系统调用主要有select、poll 和 epoll。

 (3)select/poll/epoll区别 

1)调用函数

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

2)文件描述符数量

select通过线性表描述文件描述符集合,文件描述符有上限(与系统内存关系很大),32位机默认是1024个,64位机默认是2048。

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

epoll通过红黑树描述,最大可以打开文件的数目

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

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

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

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

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

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

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

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

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

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

6)工作模式

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

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

7)应用场景

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

当监测的fd数目较小,且全部fd都比较活跃,建议使用select或者poll

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


   
   
     
     
     
     
  1. 条件编译:
  2. #ifdef 标识符
  3. 程序段 1
  4. #else
  5. 程序段 2
  6. #endif

 

  • events描述事件类型,其中epoll事件类型有以下几种

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

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

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

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

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

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

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

5、LT和ET的使用场景

LT适用于并发量小的情况,ET适用于并发量大的情况。

ET在通知用户之后,就会将fd从就绪链表中删除,而LT不会,它会一直保留,这就会导致随着fd增多,就绪链表越大,每次都要从头开始遍历找到对应的fd,所以并发量越大效率越低。ET因为会删除所以效率比较高。

(LT模式下只读一次,ET模式下是无限循环读)

6、怎么解决LT的缺点?

LT模式下,可写状态的 fd 会一直触发事件,该怎么处理这个问题

数据量很少时直接 send 数据,数据量很多时每次要写数据时,将 fd 绑定 EPOLLOUT 事件,写完后将 fd 同 EPOLLOUT 从 epoll 中移除。

7、为什么ET模式一定要设置非阻塞

因为ET模式下是无限循环读,直到出现错误为 EAGAIN 或者 EWOULDBLOCK,这两个错误表示socket 为空,然后就停止循环。如果是阻塞,循环读在 socket 为空的时候就会阻塞到那里,主线程的 read()函数一旦阻塞住,当再有其他监听事件过来就没办法读了,给其他事情造成了影响,所以必须要设置为非阻塞。

8、epoll 如何判断数据已经读取完成

epoll ET(Edge Trigger)模式,才需要关注数据是否读取完毕了。使用select或者epoll的LT模式,不用关注,select/epoll检测到有数据可读去读就OK了。
两种做法:
1、针对TCP,调用recv方法,根据recv的返回值。如果返回值小于我们设定的 recv buff 的大小,那么就认为接收完毕。
2、TCP、UDP都适用,将 socket 设为 NOBLOCK 状态(使用fcntl函数),然后 selec t该 socket可读的时候,使用 read/recv 函数读取数据。当返回值为 -1,并且 errno 是 EAGAIN 或EWOULDBLOCK 的时候,表示数据读取完毕。

9、epoll为什么要用红黑树

epoll 内核中维护了一个内核事件表,它是将所有的文件描述符全部都存放在内核中,系统去检测有事件发生的时候触发回调,当你要添加新的文件描述符的时候也是调用 epoll_ctl 函数使用EPOLL_CTL_ADD 宏来插入,epoll_wait 也不是每次调用时都会重新拷贝一遍所有的文件描述符到内核态。当要在内核中长久的维护一个数据结构来存放文件描述符,并且时常会有插入,查找和删除的操作发生,这对内核的效率会产生不小的影响,因此需要一种插入,查找和删除效率都不错的数据结构来存放这些文件描述符,那么红黑树当然是不二的人选。

10、epoll_wait 函数


   
   
     
     
     
     
  1. #include
  2. 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

HTTP报文解析相关

1、用了状态机啊,为什么要用状态机?

在逻辑处理模块中,响应HTTP请求采用主从状态机来完成

传统的控制流程都是按照顺序执行的,状态机能处理任意顺序的事件,并能提供有意义的响应—即使这些事件发生的顺序和预计的不同。

项目中使用主从状态机的模式进行解析,从状态机(parse_line)负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。每解析一部分都会将整个请求的m_check_state状态改变,状态机也就是根据这个状态来进行不同部分的解析跳转的

2、状态机的转移图画一下

从状态机负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。

每解析一部分都会将整个请求的 m_check_state 状态改变,状态机也就是根据这个状态来进行不同部分的解析跳转的:

(1)parse_request_line(text),解析请求行,通过请求行的解析我们可以判断该HTTP请求的类型(GET/POST),而请求行中最重要的部分就是URL部分,我们会将这部分保存下来用于后面的生成HTTP响应。

(2)parse_headers(text):解析请求头部,就是GET和POST中空行以上,请求行以下的部分。

(3)parse_content(text);,解析请求数据,对于GET来说这部分是空的,因为这部分内容已经以明文的方式包含在了请求行中的URL部分了;只有POST的这部分是有数据的,项目中的这部分数据为用户名和密码,我们会根据这部分内容做登录和校验

web服务器项目常见面试题目(C++)_第3张图片

主状态机

三种状态,标识解析位置。

CHECK_STATE_REQUESTLINE,解析请求行

CHECK_STATE_HEADER,解析请求头

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

从状态机

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

LINE_OK,完整读取一行,该条件涉及解析请求行和请求头部

LINE_BAD,报文语法有误

LINE_OPEN,读取的行不完整

处理结果:

NO_REQUEST 请求不完整,需要继续读取请求报文数据
GET_REQUEST 获得了完整的HTTP请求
BAD_REQUEST HTTP请求报文有语法错误
INTERNAL_ERROR 服务器内部错误

 

解析报文整体流程

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

  • 判断条件

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

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

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

  • 循环体

    • 从状态机读取数据

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

    • 主状态机解析text

在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

 

主状态机逻辑

主状态机初始状态是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

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

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

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

    • connection字段判断是keep-alive还是close,决定是长连接还是短连接

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

在完成消息体解析后,将line_status变量更改为LINE_OPEN,此时可以跳出循环,完成报文解析任务。

  • CHECK_STATE_CONTENT

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

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

 

3、状态机的缺点

状态机的缺点就是性能比较低,一般一个状态做一个事情,性能比较差,在追求高性能的场景下一般不用,高性能场景一般使用流水线设计

4、HTTPS协议为什么安全?

5、HTTPSSSL连接过程

6、GET和POST的区别

(1)get主要用来获取数据,post主要用来提交或修改数据。

(2)get的参数有长度限制,最长2048字节,而post没有限制。

(3)get是明文传输,可以直接通过url看到参数信息,post是放在请求体中,除非用工具才能看到。

(4)get的参数会附加在url中,以 " ?"分割url和传输数据,多个参数用 "&"连接, 而post会把参数放在http请求体中。

(5)get请求会保存在浏览器历史记录中,也可以保存在web服务器日志中。 (6)get请求会被浏览器主动缓存,而post不会,除非手动设置。

(7)get在浏览器回退时是无害的,而post会再次提交请求。

(8)get请求只能进行url编码,而post支持多种编码方式。

(9)get请求的参数数据类型只接受ASCII字符,而post没有限制。

(10)get是幂等的,而post不是幂等的。 幂等性:对同一URL的多个请求应该返回同样的结果。

7、HTTP报文格式

(1)HTTP请求报文:请求行、请求头部、请求空行、请求数据

web服务器项目常见面试题目(C++)_第4张图片

1)请求行:用来说明请求方法,要访问的资源以及所使用的HTTP版本。

格式: 请求方法|空格|URL|空格|协议版本|回车符|换行符

2)请求头部:用来说明服务器要使用的附加信息

HTTP常见字段有哪些?

    HOST,给出请求资源所在服务器的域名。

   User-Agent,HTTP客户端程序的信息,该信息由你发出请求使用的浏览器来定义,并且在每个请求中自动发送等。

  connection,连接管理,可以是Keep-Alive或close。

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

 Content-Type 字段:用于服务器回应时,告诉客户端,本次数据是什么格式


   
   
     
     
     
     
  1. Content-Type: text/html; charset=utf -8
  2. // 上面的类型表明,发送的是网页,而且编码是UTF-8。

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

在报文中,请求头和空行的处理使用的同一个函数,这里通过判断当前的text首位是不是\0字符,若是,则表示当前处理的是空行,若不是,则表示当前处理的是请求头。判断是空行还是请求头,若是空行,进而判断content-length是否为0,如果不是0,表明是POST请求,则状态转移到CHECK_STATE_CONTENT,否则说明是GET请求,则报文解析结束。

(2)HTTP响应报文:状态行、响应头部、响应空行、响应正文

状态行:协议版本|空格|状态码|空格|状态码描述|回车符|换行符

8、HTTP常用的请求方法

(1)HTTP1.0 定义了三种请求方法: GET, POST 和 HEAD方法。

GET:请求获取资源

POST:提交或修改数据

HEAD:获得报文首部,与 GET 方法类似,只是不返回报文主体,一般用于验证 URI 是否有效。

(2)HTTP1.1新增了五种请求方法:OPTIONS, PUT, DELETE, TRACE 和 CONNECT 方法。

OPTIONS:可使服务器传回该资源所支持的所有 HTTP 请求方法。

PUT:从客户端向服务器上传的数据取代指定的文件

DELETE:请求服务器删除指定的文件。

TRACE :追踪路径。回显服务器收到的请求,主要用于测试或诊断。

CONNECT:HTTP/1.1协议中预留给能够将连接改为管道方式的代理服务器,通常用于SSL加密服务器的链接。

(3)HTTP2.0 新的二进制格式(Binary Format),HTTP1.x的解析是基于文本。基于文本协议的格式解析存在缺陷:文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。

9、HTTP的协议版本

10、HTTP常见状态码及使用场景

1xx消息——请求已被服务器接收,继续处理

             101 切换请求协议,从 HTTP 切换到 WebSocket
2xx成功——请求已成功被服务器接收、理解、并接受

             200 请求成功,有响应体
3xx重定向——需要后续操作才能完成这一请求

             301 永久重定向:会缓存

             302 临时重定向:不会缓存

             304 协商缓存命中
4xx请求错误——请求含有语法错误或者无法被执行

             400 请求报文存在语法错误

             404 资源未找到

             403 服务器禁止访问
5xx服务器错误——服务器在处理某个正确请求时发生错误

             500 服务器端错误

             503 服务器繁忙

             504 网关超时

11、HTTP状态码301和302的区别

301 永久重定向:页面永久性转移,表示为资源或页面永久性地转移到了另一个位置。

(1)用于防止收藏夹中的旧地址因网页扩展名改变而出错

(2)用于多个域名跳转至同一域名

302 临时重定向:页面暂时性转移,表示资源或页面暂时转移到另一个位置

用作网址劫持,容易导致网站降权,严重时网站会被封掉,不推荐使用

12、一次完整 HTTP 请求所经历的步骤

(当我们在 web 浏览器的地址栏中输入:www.baidu.com,然后回车,到底发生了什么?)

由域名→ IP 地址 寻找 IP 地址的过程依次经过了浏览器缓存、系统缓存、hosts 文件、路由器缓存、 递归搜索根域名服务器(DNS解析)。

建立 TCP/IP 连接(三次握手具体过程)。

由浏览器发送一个 HTTP 请求。

经过路由器的转发,通过服务器的防火墙,该 HTTP 请求到达了服务器。

服务器处理该 HTTP 请求,返回一个 HTML 文件。

浏览器解析该 HTML 文件,并且显示在浏览器端。

服务器关闭 TCP 连接(四次挥手具体过程)。

13、HTTP与HTTPS的缺点,以及区别

(1)HTTP 的不足

窃听风险: 通信使用明文(不加密),内容可能会被窃听;

冒充风险: 不验证通信方的身份,因此有可能遭遇伪装;

篡改风险: 无法证明报文的完整性,所以有可能已遭篡改;

(2)HTTPS 的缺点

HTTPS 协议多次握手,导致页面的加载时间延长近 50%;

HTTPS 连接缓存不如 HTTP 高效,会增加数据开销和功耗;

SSL 涉及到的安全算法会消耗 CPU 资源,对服务器资源消耗较大;

(3)区别

端口不同:HTTP 和 HTTPS 使用的是完全不同的连接方式,用的端口也不一样,前者是 80,后者是 443;

资源消耗:HTTP 是超文本传输协议,信息是明文传输,HTTPS 则是具有安全性的 ssl 加密传输协议,需要消耗更多的 CPU 和内存资源

开销:HTTPS 协议需要到 CA 申请证书,一般免费证书很少,需要交费;

安全性:HTTP 的连接很简单,是无状态的;HTTPS 协议是由 TLS+HTTP 协议构建的可进行加密传输、身份认证的网络协议,比 HTTP 协议安全

14、HTTP报文处理流程

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

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

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

15、 Web服务器如何接收客户端发来的HTTP请求报文

 Web服务器端通过 socket 监听来自用户的请求。远端的很多用户会尝试去connect()这个Web Server上正在listen的这个端口,而监听到的这些连接会排队等待被accept()。由于用户连接请求是随机到达的异步事件,每当监听socket(listenfdlisten到新的客户连接并且放入监听队列,我们都需要告诉我们的Web服务器有连接来了,accept这个连接,并分配一个逻辑单元来处理这个用户请求。而且,我们在处理这个请求的同时,还需要继续监听其他客户的请求并分配其另一逻辑单元来处理。这里,服务器通过epoll这种I/O复用技术(还有select和poll)来实现对监听socket(listenfd)和连接socket(客户请求)的同时监听。

定时器相关

1、为什么要用定时器?(优化:定时器处理非活跃连接)

为了定期删除非活跃事件,防止连接资源的浪费。

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

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

2、说一下定时器的工作原理

 定时器利用结构体将定时事件进行封装起来。定时事件,即定期检测非活跃连接。

服务器主循环为每一个连接创建一个定时器,并对每个连接进行定时。另外,利用升序双向链表将所有定时器串联起来,利用alarm函数周期性地触发SIGALRM信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源。

(信号处理函数仅仅发送信号通知程序主循环,将信号对应的处理逻辑放在程序主循环中,由主循环执行信号对应的逻辑代码。)

信号通知的逻辑:创建管道,其中管道写端写入信号值,管道读端通过I/O复用系统监测读事件

为什么管道写端要非阻塞?

send是将信息发送给套接字缓冲区,如果缓冲区满了,则会阻塞,这时候会进一步增加信号处理函数的执行时间,为此,将其修改为非阻塞。

3、定时任务处理函数的逻辑

使用统一事件源,SIGALRM信号每次被触发,主循环中调用一次定时任务处理函数,处理链表容器中到期的定时器。

(1)链表容器是升序排列,当前时间小于定时器的超时时间,后面的定时器也没有到期

(2)当前定时器到期,则调用回调函数,执行定时事件

(3)将处理后的定时器从链表容器中删除,并重置头结点

若有数据传输,则将定时器往后延迟3个单位

(回调函数用来方便删除定时器时将对应的HTTP连接关闭)

4、升序双向链表,删除和添加的时间复杂度说一下?还可以优化吗?

删除定时器的时间复杂度是O(1),添加和修改定时器的时间复杂度是O(n)(刚好添加在尾节点时)。

缺点:每次以固定的时间间隔触发SIGALRM信号,调用定时任务处理函数处理超时连接会造成一定的触发浪费。举个例子,若当前的TIMESLOT=5,即每隔5ms触发一次SIGALRM,跳出循环执行定时任务处理函数,这时如果当前即将超时的任务距离现在还有20ms,那么在这个期间,SIGALRM信号被触发了4次,定时任务处理函数也被执行了4次,可是在这4次中,前三次触发都是无意义的。

(1)在双向链表的基础上优化:

在添加新定时器时,除了检测新定时器是否小于头节点定时器,还应该检测是否大于尾节点定时器的时间,都不符合再使用常规插入。

(2)不使用双向链表优化:最小堆。

5、最小堆优化?说一下时间复杂度和工作原理

时间复杂度:添加:O(logn), 删除:O(1)

工作原理:

将所有定时器中超时时间最小的一个定时器的超时值,作为定时任务处理函数的定时值。这样,一旦定时任务处理函数被调用,超时时间最小的定时器必然到期,我们就可以在定时任务处理函数中处理该定时器。

然后,再次从剩余的定时器中找出超时时间最小的一个(堆),并将这段最小时间设置为下一次定时任务处理函数的定时值。如此反复,就实现了较为精确的定时。

日志相关

1、说下你的日志系统的运行机制?

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

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

超行、按天分文件逻辑,具体的,

  • 日志写入前会判断当前day是否为创建日志的时间,行数是否超过最大行限制
    • 若为创建日志时间,写入日志,否则按当前时间创建新log,更新创建时间和行数
    • 若行数超过最大行限制,在当前日志的末尾加count/max_lines为后缀创建新log
  • 日志文件
    • 局部变量的懒汉模式获取实例
    • 生成日志文件,并判断同步和异步写入方式
  • 同步
    • 判断是否分文件
    • 直接格式化输出内容,将信息写入日志文件
  • 异步
    • 判断是否分文件
    • 格式化输出内容,将内容写入阻塞队列,创建一个写线程,从阻塞队列取出内容写入日志文件

 

2、为什么要异步?和同步的区别是什么?

同步方式写入日志时会产生比较多的系统调用,若是某条日志信息过大,会阻塞日志系统,造成系统瓶颈。异步方式采用生产者-消费者模型,具有较高的并发能力。

生产者-消费者模型,并发编程中的经典模型。

以多线程为例,为了实现线程间数据同步,生产者线程与消费者线程共享一个缓冲区,其中生产者线程往缓冲区中push消息,消费者线程从缓冲区中pop消息。

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

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

可以提高系统的并发性能。

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

写入方式通过初始化时是否设置队列大小(表示在队列中可以放几条数据)来判断,若队列大小为0,则为同步,否则为异步。

若异步,则将日志信息加入阻塞队列,同步则加锁向文件中写

3、(创新点)异步日志系统的改进:生产者消费者模式 => 双缓冲机制

生产者消费者模式存在的问题:

消费者从消息队列每读取一条日志信息就,写入文件系统,但是写文件操作是很耗时的。频繁的从消息队列中获取数据,而且每次都要上锁,一定会对生产者的写日志效率产生影响,因为生产者也要对消息队列上锁才能把日志信息插入队列的头部,如果此时消息队列正好被消费者锁住了,那么生产者就必须伤心的等待了~~这样就会很大影响到日志系统整体的吞吐率。

web服务器项目常见面试题目(C++)_第5张图片

双缓冲机制的基本思路是:

准备两块 buffer: 1 和 2;

前端负责往 buffer 1 填数据(日志信息);

后端负责把 buffer 2 的数据写入文件。

当 buffer 1 写满之后,交换 1 和 2,让后端将 buffer 1 的数据写入文件,而前端则往 buffer 2 填入新的日志信息,如此反复。

web服务器项目常见面试题目(C++)_第6张图片

 缓冲区交换:

直接交换两个缓冲区的地址: 把生产者在写入数据时的指向缓冲区1的指针重新指向缓冲区2, 把消费者读取数据时指向的缓冲区2的指针重新指向缓冲区1,这样就达到了交换缓冲区的目的了。

双缓冲区的好处是:

在大部分的时间中,前台线程和后台线程不会操作同一个缓冲区,这也就意味着前台线程的操作,不需要等待后台线程缓慢的写文件操作(因为不需要锁定临界区)。

通过双缓冲技术,很好地解决了生产者和消费者之间的异步操作和速度不匹配问题,提高了日志系统的整体吞吐率。

为什么设计成双缓冲而不用更多的缓冲区?

前端缓冲不足时会自动扩展,但是双缓冲足够应付使用场景,因为日志只记录必要的信息,并不会太多

4、关于该项目用到的设计模式

(1)单例模式:单例对象的类只能允许一个实例存在,并提供一个访问它的全局访问点,该实例被所有程序模块共享。主要解决一个全局使用的类频繁的创建和销毁的问题,是一种创建型模式,提供了一种创建对象的最佳方式。

(2)单例模式三要素:

        1)单例类只能有一个实例。

        2)单例类必须自己创建自己的唯一实例。

        3)单例类必须给所有其他对象提供这一实例。

(3)单例设计模式的优缺点
优点:
1 )单例模式可以保证内存里 只有一个实例 , 减少了内存的开销 。
2 )可以避免对资源的 多重占用 。 (比如写文件操作)
3 )单例模式设置全局访问点,可以优化和共享资源的访问。
缺点:
1 )单例模式一般没有接口, 不能继承, 扩展困难 。 如果要扩展,则除了修改原来的代码,没有第二种途径,违背开闭原则。
2 )在并发测试中,单例模式 不利于代码调试 。 在调试过程中,如果单例中的代码没有执行完,也不能模拟生成一个新的对象。
3 )单例模式的功能代码通常写在一个类中, 如果功能设计不合理,则很容易违背单一职责原则。
(4)C++ 单例设计模式的实现 两步骤 
1 ) 私有化 构造函数 ,这样别处的代码就无法通过调用该类的构造函数来实例化该类的对象,只 有通过该类提供的静态方法来得到 该类的唯一实例 ;
2 ) 通过局部静态变量,利用其只初始化一次的特点,返回静态对象成员。
(5) 单例设计模式的种类
1 )懒汉式:获取该类的对象 时 才创建该类的实例
2 )饿汉式:获取该类的对象之 前 已经创建好该类的实例
(6)手撕单例模式
(懒汉模式)

    
    
      
      
      
      
  1. class  single{
  2. private:
  3.      single(){} // 私有化构造函数
  4.     ~ single(){}
  5. public:
  6. // 公有静态方法获取实例
  7.      static single* getinstance();
  8. };
  9. single* single::getinstance(){
  10.      static single obj;
  11.      return &obj;
  12. }

 使用函数内的局部静态对象无需加锁和解锁,因为C++11后编译器可以保证内部静态变量的线程安全性

(懒汉模式)加锁版本


   
   
     
     
     
     
  1. class  single{
  2. private:
  3.      static  pthread_mutex_t lock;
  4.      single(){
  5.          pthread_mutex_init(&lock,  NULL);
  6.     }
  7.     ~ single(){}
  8. public:
  9.      static single* getinstance();
  10. };
  11. pthread_mutex_t single::lock;
  12. single* single::getinstance(){
  13.      pthread_mutex_lock(&lock);
  14.      static single obj;
  15.      pthread_mutex_unlock(&lock);
  16.      return &obj;
  17. }

(饿汉模式)


   
   
     
     
     
     
  1. class  single{
  2. private:
  3.      static single* p;
  4.      single(){}
  5.     ~ single(){}
  6. public:
  7.      static single* getinstance();
  8. };
  9. single* single::p =  new  single();
  10. single* single::getinstance(){
  11.      return p;
  12. }

 饿汉模式不需要用锁,就可以实现线程安全。原因在于,在程序运行时就定义了对象,并对其初始化。之后,不管哪个线程调用成员函数getinstance(),都只是返回一个对象的指针

5、现在你要监控一台服务器的状态,输出监控日志,请问如何将该日志分发到不同的机器上?(消息队列)

数据库登录注册相关

1、 什么是数据库连接池,为什么要创建连接池?

(1)池是资源的容器,这组资源在服务器启动之初就被完全创建好并初始化,本质上是对资源的复用

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

(2)若系统需要频繁访问数据库,则需要频繁创建和断开数据库连接,而创建数据库连接是一个很耗时的操作,也容易对数据库造成安全隐患。

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

(3)使用单例模式和链表创建数据库连接池,实现对数据库连接资源的复用。

连接池的功能主要有:初始化,获取连接、释放连接,销毁连接池

连接池中的多线程使用信号量进行通信,使用互斥锁进行同步。

 

数据库连接的获取与释放通过RAII机制封装,避免手动释放。

 RAII机制

    RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”.
    RAII的核心思想是将资源或者状态与对象的生命周期绑定,通过C++的语言机制,实现资源和状态的安全管理,智能指针是RAII最好的例子
    具体来说:构造函数的时候初始化获取资源,析构函数释放资源

web服务器项目常见面试题目(C++)_第7张图片

2、 获取释放连接、销毁对象池

获取链接

  • 容器有空闲连接,直接用
  • 容器无空闲
    • 未达上限,自己创建
    • 达上限,报错打回等待

释放连接

  • 放回容器
  • 目前暂无较好的销毁连接策略

销毁对象池

  • 关闭销毁池中连接
  • 释放连接池对象
  • 完成释放

3、登录说一下?登录注册是POST请求

将数据库中的用户名和密码载入到服务器的map中来,map中的key为用户名,value为密码

服务器端解析浏览器的请求报文,当解析为POST请求时,提取出请求报文的消息体的用户名和密码。

POST请求中最后是用户名和密码,用&隔开。分隔符&,前是用户名,后是密码。

登录:将浏览器输入的用户名和密码在数据库中查找,直接判断。

注册:往数据库中插入数据,需要判断是否有重复的用户名。

最后进行页面跳转

通过m_url定位/所在位置,根据/后的第一个字符,使用分支语句实现页面跳转。具体的,

  • 0 — 跳转注册页面,GET
  • 1 — 跳转登录页面,GET
  • 5 — 显示图片页面,POST
  • 6 — 显示视频页面,POST
  • 7 — 显示关注页面,POST

4、登录与注册,服务器如何校验

CGI校验(通用网关接口),它是一个运行在Web服务器上的程序,在编译的时候将相应的.cpp文件编程成.cgi文件并在主程序中调用即可。这些CGI程序通常通过客户在其浏览器上点击一个button时运行。这些程序通常用来执行一些信息搜索、存储等任务,而且通常会生成一个动态的HTML网页来响应客户的HTTP请求。

CGI程序,将用户请求中的用户名和密码保存在一个id_passwd.txt文件中,通过将数据库中的用户名和密码存到一个map中用于校验。在主程序中通过execl(m_real_file, &flag, name, password, NULL);这句命令来执行这个CGI文件,这里CGI程序仅用于校验,并未直接返回给用户响应。这个CGI程序的运行通过多进程来实现,根据其返回结果判断校验结果(使用pipe进行父子进程的通信,子进程将校验结果写到pipe的写端,父进程在读端读取)。

5、你这个保存状态了吗?如果要保存,你会怎么做?(cookie和session)

可以利用session或者cookie的方式进行状态的保存。

cookie其实就是服务器给客户分配了一串“身份标识”,比如“123456789happy”这么一串字符串。每次客户发送数据时,都在HTTP报文附带上这个字符串,服务器就知道你是谁了;

session是保存在服务器端的状态,每当一个客户发送HTTP报文过来的时候,服务器会在自己记录的用户数据中去找,类似于核对名单;

6、登录中的用户名和密码你是load到本地,然后使用map匹配的,如果有10亿数据,即使load到本地后hash,也是很耗时的,你要怎么优化?

这个问题的关键在于大数据量情况下的用户登录验证怎么进行?将所有的用户信息加载到内存中耗时耗利,对于大数据最遍历的方法就是进行hash,利用hash建立多级索引的方式来加快用户验证。具体操作如下:

首先,将10亿的用户信息,利用大致缩小1000倍的hash算法进行hash,这时就获得了100万的hash数据,每一个hash数据代表着一个用户信息块(一级)

而后,再分别对这100万的hash数据再进行hash,例如最终剩下1000个hash数据(二级)

在这种方式下,服务器只需要保存1000个二级hash数据,当用户请求登录的时候,先对用户信息进行一次hash,找到对应信息块(二级),在读取其对应的一级信息块,最终找到对应的用户数据

7、用的mysql啊,redis了解吗?用过吗?

压测相关

1、服务器并发量测试过吗?怎么测试的?

补充知识:

系统吞吐量几个重要参数:QPS(TPS)、并发数、响应时间

TPS:Transactions Per Second,即服务器每秒响应的事务数

QPS:每秒查询率,每秒的响应请求数量

并发数: 系统同时处理的request/事务数

响应时间: 一般取平均响应时间

关系:QPS(TPS)= 并发数/平均响应时间

压力测试:每分响应请求数pages/min  和 每秒传输数据量bytes/sec

使用Webbench对服务器进行压力测试,创建1000个客户端,并发访问服务器10s,正常情况下有接近8万个HTTP请求访问服务器。

2、webbench是什么?介绍一下原理

WebBench是一款在Linux下使用非常简单的压力测试工具。

原理:父进程 fork 若干个子进程,每个子进程在用户要求时间或默认的时间内对目标 web 循环发出实际访问请求,父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息,子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出。Webbench最多可以模拟3万个并发连接去测试网站的负载能力。 

-c :子进程的个数,即并发数

-t :运行webbench的时间

3、测试的时候有没有遇到问题?

 Bug:使用Webbench对服务器进行压力测试,创建1000个客户端,并发访问服务器10s,正常情况下有接近8万个HTTP请求访问服务器。

结果显示仅有7个请求被成功处理,0个请求处理失败,服务器也没有返回错误。此时,从浏览器端访问服务器,发现该请求也不能被处理和响应,必须将服务器重启后,浏览器端才能访问正常。

解决办法:

排查:

通过查询服务器运行日志,对服务器接收HTTP请求连接,HTTP处理逻辑两部分进行排查。

日志中显示,7个请求报文为:GET / HTTP/1.0的HTTP请求被正确处理和响应,排除HTTP处理逻辑错误重点放在接收HTTP请求连接部分。其中,服务器端接收HTTP请求的连接步骤为socket -> bind -> listen -> accept

错误原因:错误使用epoll的ET模式

  • ET边缘触发模式

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

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

  • 当连接较少时,队列不会变满,即使listenfd设置成ET非阻塞,不使用while一次性读取完,也不会出现Bug

  • 若此时1000个客户端同时对服务器发起连接请求,连接过多会造成established 状态的连接队列变满。但accept并没有使用while一次性读取完,只读取一个。因此,连接过多导致TCP就绪队列中剩下的连接都得不到处理,同时新的连接也不会到来。

  • 解决方案

  • 将listenfd设置成LT阻塞,或者ET非阻塞模式下while包裹accept即可解决问题。

综合能力

1、你的项目解决了哪些其他同类项目没有解决的问题?

2、说一下前端发送请求后,服务器处理的过程,中间涉及哪些协议?

你可能感兴趣的:(服务器,面试,前端)