webserver面试题汇总
项目描述:Linux环境下基于C++的轻量级多线程Web服务器,应用层实现了一个简单的HTTP服务器,支持静态资源访问
的功能。
工作内容:
- 利用IO复用技术Epoll(ET模式)与线程池实现单Reactor多线程并发模型;
- 利用正则表达式与状态机解析HTTP请求报文,实现处理静态资源的请求,利用分散写和mmap优化大文件传输;
- 利用容器实现动态增长的用户空间缓冲区;
- 基于小根堆实现的定时器,实现应用层的保活机制;
- 利用单例模式与阻塞队列实现异步的日志系统;
- 利用Webbench进行压力测试。
项目介绍
为什么要做这样一个项目?
研究生期间主要学习的是机器学习方向,本科虽然学过C++,但一是时间太久有点忘了,二是对网络编程不了了解,所以在github上找了一个开源的webserver项目,想要复习巩固一下C++相关的知识,学习一下网络编程。
介绍下你的项目
- 本项目主要是对浏览器的HTTP请求进行解析处理,处理完之后返回给客户端一个响应,如图片、文字、视频等;
- 服务端使用socket通信,利用IO多路复用,可以同时处理多个请求;
- 项目采用Reactor模式,主线程负责监听IO事件,收到事件后,根据事件类型处理,如果是连接建立事件,则主线程将accpet返回的套接字添加到epoll中进行管理,如果不是则根据事件类型将任务添加到任务队列中;
- 任务队列里面有了任务之后,线程池中睡眠的工作线程将被唤醒,进行数据读取以及后续的业务处理,利用状态机解析HTTP报文;
- 使用基于小根堆的定时器实现应用层保活机制,当客户端和服务端长达一定时间没有进行数据交互时,关闭连接。
压力测试
服务器并发量测试过吗?怎么测试的?
原项目用webbench测试,4核8G的服务器能达到百万级QPS,我在自己的服务器上使用webbench测试过,client数量达到4800是0 failed,但到4900,fork就会失败,提示进程已达上限。这时的QPS只有2500左右。
webbench是什么?介绍一下原理
- WebBench是一款在Linux下使用非常简单的压力测试工具。
- 原理:父进程 fork 若干个子进程,每个子进程在用户要求时间或默认的时间内对目标 web 循环发出实际访问请求,父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息,子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出。Webbench最多可以模拟3万个并发连接去测试网站的负载能力。
-c :子进程的个数,即并发数
-t :运行webbench的时间
wrk 工具
wrk 工具,它是一款简单的 HTTP 压测工具,它能够在单机多核 CPU的条件下,使用系统自带的高性能 I/O 机制,通过多线程和事件模式,对目标机器产生大量的负载。
测试的时候有没有遇到问题?
- 文件描述符的最大数量
- 全连接队列的大小()
- 服务端端口数(49152-65535)
- 服务器进程上限
- 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设置ET非阻塞模式下while包裹accept即可解决问题。
怎样应对服务器的大流量、高并发
- 客户端
- 尽量减少请求数:依靠客户端自身的缓存或处理能力
- 尽量对减少服务端资源的不必要消耗:重复利用某些资源,如连接池
- 服务端
- 增大资源供给:更大的网络带宽,使用更高配置的服务器
- 请求分流:使用集群,分布式的系统架构
- 应用优化:使用更高效的编程语言,优化处理业务逻辑的算法
综合能力
项目的亮点
- 采用Reactor并发模型
- 使用Epoll边缘触发+EPOLLONESHOT,非阻塞IO
- 为充分利用多核CPU的性能,以多线程的形式实现服务器,并实现线程池避免线程频繁创建销毁造成的系统开销
- 实现基于小根堆的定时器,实现应用层保活机制
- 实现可以自动增长的缓冲区,作为HTTP连接的输入和输出缓冲区
你的项目解决了哪些其他同类项目没有解决的问题?
没有,造轮子😄
说一下前端发送请求后,服务器处理的过程,中间涉及哪些协议?
- 浏览器端发出http连接请求,主线程创建http对象接收请求并将所有数据读入对应buffer,将该对象插入任务队列,工作线程从任务队列中取出一个任务进行处理
- 工作线程取出任务后,调用OnRead_函数,通过主、从状态机对请求报文进行解析
- 解析完之后,跳转OnProcess_调用process函数生成响应报文,通过MakeResponse将响应写入buffer,返回给浏览器端。
困难
服务器运行一会之后报段错误,核心已转储,在终端将core文件大小设置为1024后仍然不能生成core文件。ubuntu 的 cat /proc/sys/kernel/core_pattern输出|/usr/share/apport/apport %p %s %c %d %P %E,查了之后发现ubuntu预装了apport错误收集系统,sudo service apport stop之后就可以了。
vec[-1] 不报错
写服务器的第一个版本时,使用的是“边沿触发(EPOLLET)+非阻塞IO”模式,但是只调用了一次IO,没有循环遍历直到数据为空。这样就产生了一个问题,如下:如果给了1000个连接请求,在60S时间内,但是实际接收的连接数不到一半。这是因为每次触发只调用一次IO时,一次只能accept一个连接请求,那么需要不断的连接请求触发,才能继续accept连接,效率非常低。但是使用了while循环遍历直至数据为空之后,同样的测试,服务器能接收全部的连接请求,其原因就是一次触发就可以处理该次触发所接收的所有连接请求,大大减少了epoll_wait系统调用,减小了内核资源消耗。
有待改进的地方
「单 Reactor」的模式存在一个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方。这个也是WebServer这个项目存在的瓶颈之一。
并发模型
简单说一下服务器使用的并发模型?
- 项目采用Reactor模式,主线程负责监听IO事件,收到事件后,根据事件类型处理,如果是连接建立事件,则主线程将accpet返回的套接字添加到epoll中进行管理,如果不是则根据事件类型将任务添加到任务队列中;
- 任务队列里面有了任务之后,线程池中睡眠的工作线程将被唤醒,进行数据读取以及后续的业务处理,利用状态机解析HTTP报文;
主线程:
- 主线程中,epoll监听套接字,处理就绪套接字上的IO事件,包括已连接的客户请求(发送报文)或者新的客户连接请求;
- 如果是新的客户连接请求则创建一个连接套接字fd,并创建一个HttpConn对象与对应,为该Http连接添加定时器,设置超时时间,当超过一段时间没有请求时关闭该http连接,并将fd添加到epoll中进行管理;
- 如果是已连接的客户请求,则延长这个http连接的定时器有效期,并向线程池的任务队列中添加任务,epollin事件添加read任务,epollout事件添加write任务;
- 同时主线程还维护一个小顶堆实现的定时器,删除超时节点,实现应用层的保活机制。
子线程:
- 线程池中的工作线程等待任务队列不为空,拿到锁后从任务队列中取出任务,再释放锁,如果是read任务,则读取客户端发送过来的数据(ET模式),再进行后续业务逻辑的处理(如:解析请求、初始化响应);如果是write任务,则将生成的响应写入(分散写)socket中;
- task()结束前需要使用epoll_ctl设置fd的状态,决定是否关闭连接(Keep-Alive),是否继续监听套接字socketfd,监听什么事件:请求数据(EPOLLOUT),不请求数据(EPOLLIN)。
线程池的线程数量最直接的限制因素是CPU处理器的个数。
如果CPU是四核的,那么对于CPU密集的任务,线程池的线程数量最好也为4,或者+1防止其他因素导致阻塞。
如果是IO密集的任务,一般要多于CPU的核数,因为线程间竞争的不是CPU资源而是IO,IO的处理一般比较慢,多于核数的线程将为CPU争取更多的任务,不至于在县城处理IO的时候造成CPU空闲导致资源浪费。
reactor、proactor、主从reactor模型的区别?
- Reactor模式
- Reactor 对象通过 epoll (IO 多路复用接口) 监听事件,收到事件后通过 dispatch 进行分发,具体分发给 Acceptor 对象还是 Handler 对象,还要看收到的事件类型;
- 如果是连接建立的事件,则交由 Acceptor 对象进行处理,Acceptor 对象会通过 accept 方法 获取连接,并创建一个 Handler 对象来处理后续的响应事件;
- 如果不是连接建立事件, 则交由当前连接对应的 Handler 对象来进行响应;
- Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理;
- 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client;
- 主从Reactor模式
- 主线程中的 MainReactor 对象通过 epoll 监控连接建立事件,收到事件后通过 Acceptor 对象中的 accept 获取连接,将新的连接分配给某个子线程;
- 子线程中的 SubReactor 对象将 MainReactor 对象分配的连接加入 epoll 继续进行监听,并创建一个 Handler 用于处理连接的响应事件。
- 如果有新的事件发生时,SubReactor 对象会调用当前连接对应的 Handler 对象来进行响应。
- Handler 对象通过 read -> 业务处理 -> send 的流程来完成完整的业务流程。
多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:
- 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
- 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。
- Proactor模式
- Proactor Initiator 负责创建 Proactor 和 Handler 对象,并将 Proactor 和 Handler 都通过 Asynchronous Operation Processor 注册到内核;
- Asynchronous Operation Processor 负责处理注册请求,并处理 I/O 操作;
- Asynchronous Operation Processor 完成 I/O 操作后通知 Proactor;
- Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理;
- Handler 完成业务处理;
Reactor 和 Proactor 的区别:
- Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
- Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。
你用了epoll,说一下为什么用epoll,还有其他复用方式吗?区别是什么?
select/poll
select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。
存在的问题:对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。
select 使用固定长度的 BitsMap表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。
poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。
但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。
epoll
- epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。
- epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
补充:
- select和poll都只能工作在相对低效的LT模式下,而epoll同时支持LT和ET模式。
- 综上,当监测的fd数量较小,且各个fd都很活跃的情况下,建议使用select和poll;当监听的fd数量较多,且单位时间仅部分fd活跃的情况下,使用epoll会明显提升性能。
epoll为什么要使用红黑树
epoll 内核中维护了一个内核事件表,它是将所有的文件描述符全部都存放在内核中,系统去检测有事件发生的时候触发回调,当你要添加新的文件描述符的时候也是调用epoll_ctl 函数使用EPOLL_CTL_ADD 宏来插入,epoll_wait 也不是每次调用时都会重新拷贝一遍所有的文件描述符到内核态。当要在内核中长久的维护一个数据结构来存放文件描述符,并且时常会有插入,查找和删除的操作发生,这对内核的效率会产生不小的影响,因此需要一种插入,查找和删除效率都不错的数据结构来存放这些文件描述符,那么红黑树当然是不二的人选。
epoll边缘触发如何判断数据已经读取完成
两种做法:
- 针对TCP,调用recv方法,根据recv的返回值。如果返回值小于我们设定的 recv buff 的大小,那么就认为接收完毕。
- TCP、UDP都适用,将 socket 设为 NOBLOCK 状态(使用fcntl函数),然后 select该socket可读的时候,使用 read/recv 函数读取数据。当返回值为 -1,并且 errno 是 EAGAIN 或EWOULDBLOCK 的时候,表示数据读取完毕。
客户端断开连接,服务端epoll监听到什么事件?
在使用 epoll 时,客户端正常断开连接(调用 close()),在服务器端会触发一个 epoll 事件。
在早期的内核中,这个 epoll 事件一般是 EPOLLIN,即 0x1,代表连接可读。
连接池检测到某个连接发生 EPOLLIN 事件且没有错误后,会认为有请求到来,将连接交给上层进行处理。这样一来,上层尝试在对端已经 close() 的连接上读取请求,只能读到EOF(文件末尾),会认为发生异常,报告一个错误。
后期的内核中增加了 EPOLLRDHUP 事件,代表对端断开连接。对端连接断开触发的 epoll 事件会包含 EPOLLIN | EPOLLRDHUP,即 0x2001。有了这个事件,对端断开连接的异常就可以在底层进行处理了,不用再移交到上层。
epoll的线程安全问题
简要结论就是epoll是通过锁来保证线程安全的,epoll中粒度最小的自旋锁ep->lock(spinlock)用来保护就绪的队列, 互斥锁ep->mtx用来保护epoll的重要数据结构红黑树
主要的两个函数:
epoll_ctl():当需要根据不同的operation通过ep_insert() 或者ep_remove()等接口对epoll自身的数据结构进行操作时都提前获得了ep->mtx锁
epll_wait():获得自旋锁 ep->lock来保护就绪队列
SO_REUSEDADDR和SO_REUSEDPORT
在TCP连接下,如果服务器主动关闭连接(比如ctrl+c结束服务器进程),那么由于服务器这边会出现time_wait状态,所以不能立即重新启动服务器进程。在标准文档中,2MSL时间为两分钟。
一个端口释放后会等待两分钟之后才能再被使用,SO_REUSEADDR是让端口释放后立即就可以被再次使用。
如果不进行端口重用的话,客户端可能不受什么影响,因为在客户端主动关闭后,客户端可以使用另一个端口与服务端再次建立连接;但是服务端主动关闭连接后,其周知端口在两分钟内不能再次使用,就很麻烦
epoll的水平触发和边缘触发
水平触发
- 只要socket缓冲区有数据,epoll_wait就会一直触发,直到缓冲区为空;
- LT是epoll默认的工作模式
- 优缺点
- 优点:保证数据的完全读取
- 缺点:当数据量较大时,需要不断从用户态到内核态的切换,消耗了大量的服务器资源,影响服务器性能
- 应用场景:应用较少,一般用于连接请求数较少,且客户端发送数据量较少的服务器,可一次性接收所有的数据。此时若使用边缘触发,会多调用一次accept/read等来判断数据是否为空。
边缘触发
- 只有所监听的套接字事件状态改变或者有事件发生时,epoll_wait才会触发
- 边缘触发通常与非阻塞IO一起使用,其工作模式为:epoll_wait每触发一次,在while循环里非阻塞的读取数据,直到缓冲区为空
- 优缺点
- 优点:每次epoll_wait只触发一次就可以读取缓冲区内的所有数据,工作效率高,大大提高的服务器的性能
- 缺点:当数据量很小时,至少需要调用两次非阻塞IO函数,而水平触发只调用一次
什么时候用边缘触发,什么时候用水平触发
我的答案是:任何情况都应该优先选择“边沿触发(EPOLLET)+非阻塞IO”模式。
理由如下:根据以上水平触发和边沿触发的分析,毋庸置疑,当服务端连接请求多且数据量大的时候,应该选择“边沿触发+非阻塞IO”模式,因为只用触发一次epoll_wait,就可以读取缓冲区的所有数据。那么连接请求少而且数据量也小的时候偶,为什么也优先选择边沿触发+非阻塞IO呢?在我看来,既然数据量小,那么服务端性能要求自然也不高,即使非阻塞IO读取数据多了一次判断数据为空的情况,但是其影响也不大,而且边沿触发也能满足接收大量数据的情况。水平触发是否需要非阻塞IO
答案是:不管水平触发还是边沿触发,都要使用非阻塞IO。
理由如下:假设水平触发使用阻塞read读取数据,且设定一次性读取20字节,现在假设客户端只发送了10个字节,那么服务端内核就会阻塞在read调用中,等待客户端再发送至少10个字节的数据,才能返回继续执行程序。但是服务端已经阻塞在系统调用read中了,无法再调用epoll_wait来监听该客户端的下一次就绪事件,也就无法接受数据,read也不可能再达到20字节了,从而就形成死锁,因此水平触发也要使用非阻塞IO。
为什么ET模式一定要设置非阻塞
因为ET模式下是无限循环读,直到出现错误为EAGAIN 或者 EWOULDBLOCK,这两个错误表示socket 为空,然后就停止循环。如果是阻塞,循环读在 socket 为空的时候就会阻塞到那里,主线程的 read()函数一旦阻塞住,当再有其他监听事件过来就没办法读了,给其他事情造成了影响,所以必须要设置为非阻塞。
EPOLLONESHOT事件
即使可以使用边缘触发模式,一个socket上的某个时间还是可能被触发多次。比如一个线程在读取完某个socket上的数据后开始处理这些数据,而在数据的处理过程中,socket上又有了新数据可以读(EPOLLIN再次被触发),此时另外一个线程被唤醒来读取这些新的数据。就会出现两个线程同时操作一个socket的局面。一个socket连接在任意时刻都应该只被一个线程处理,可以使用epoll EPOLLONESHOT实现。
对于注册了EPOLLONESHOT事件的文件描述符有,操作系统最多出发其注册的一个可读、可写或异常事件,且只触发一次。除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。
这样一个线程在处理某个socket时,其他线程是不可能有机会操作该socket,但反过来要注意,注册了EPOLLONESHOT事件的socket一旦被某个线程处理完毕,该线程就应该立即重置socket上的EPOLLONESHOT事件,以确保这个socket下一次可读时,其EPOLLIN事件能被触发,进而可以让其他线程有机会处理这个socket。
线程池
为什么使用线程池
每个请求对应一个线程方法的不足之一是:为每个请求创建新线程的服务器在创建和销毁线程上花费的时间和消耗的系统资源要比花在处理实际的用户请求的时间和资源更多。
线程池的目的:
- 创建和销毁线程所产生的开销;
- 提高响应速度,任务到达时,无需等待线程即可立即执行;
- 提高线程的可管理性:线程的不合理分布会导致资源调度失衡,降低系统的稳定性。使用线程池可以进行统一的分配、调优和监控。
线程池与多线程的设计思路
- 整体思路:生产者-消费者 模型
- 任务队列(临界区):线程同步互斥锁
- 队列为不为空/满:信号量、条件变量
- 生产者:主线程,产生任务放入工作队列
- 消费者:子线程,从工作队列取出任务处理
- 工作流程
- 设计一个任务队列,作为临界区资源
- 初始化n个线程,开始运行,对任务队列加锁,取出任务执行
- 当任务队列为空时,所有工作线程阻塞(pool->cond.wait(locker))
- 当主线程监听的fd上有IO事件时,将任务添加到任务队列中,并唤醒一个等待的工作线程(pool_->cond.notify_one())
- 线程池关闭时,唤醒所有线程(pool_->cond.notify_all()),工作线程退出while循环
介绍生产者消费者模型
生产者和消费者主要用于对于数据的同步使用,生产者生产数据,然后放到共享缓冲区中,消费者在缓冲区没有数据之前会阻塞等待,当生产者生产数据之后,会唤醒阻塞的消费者线程,开始消费数据,而当数据生产充满缓冲区之后,生产者就会阻塞等待。其中的阻塞都使用条件变量。
手写线程池
1 |
|
线程的同步机制有哪些?
- 锁,通过锁机制实现线程间的同步。同一时刻只允许一个线程执行一个关键部分的代码;
- 信号量,信号量是一个计数器,用于控制访问有限共享资源的线程数;
- 条件变量,条件变量可以让调用线程在满足特定条件的情况下暂停。
线程池中的工作线程是一直等待吗?
我们为了能够处理高并发的问题,将线程池中的工作线程都设置为阻塞等待在请求队列是否不为空的条件上,因此项目中线程池中的工作线程是处于一直阻塞等待的模式下的。
你的线程池工作线程处理完一个任务后的状态是什么?
- 当处理完任务后如果请求队列为空时,则这个线程重新回到阻塞等待的状态
- 当处理完任务后如果请求队列不为空时,那么这个线程将处于与其他线程竞争资源的状态,谁获得锁谁就获得了处理事件的资格。
如果同时1000个客户端进行访问请求,线程数不多,怎么能及时响应处理每一个呢?
该项目是基于IO复用的并发模式。需要注意的是,不是一个客户连接就对应一个线程,本项目通过对子线程循环调用来解决高并发的问题的。
首先在创建线程的同时就调用了detach将线程进行分离,不用单独对工作线程进行回收,资源自动回收。
我们通过在子线程中进行while循环,让每一个线程池中的线程永远都不会停止,主线程监听到IO事件后将任务添加到任务队列中,如果没有任务,线程就一直阻塞等待,有任务线程就抢占式进行处理,直到请求队列为空,表示任务全部处理完成。
如果速度还是慢,那就只能够增大线程池容量,或者考虑集群分布式的做法。
如果一个客户请求需要占用线程很久的时间,会不会影响接下来的客户请求呢,有什么好的策略呢?
会,因为线程池内线程的数量时有限的,如果客户请求占用线程时间过久的话会影响到处理请求的效率,当请求处理过慢时会造成后续接受的请求只能在请求队列中等待被处理,从而影响接下来的客户请求。
应对策略:
- 可以为线程处理请求对象设置处理超时时间, 超过时间先发送信号告知线程处理超时,然后设定一个时间间隔再次检测,若此时这个请求还占用线程则直接将其断开连接;
- 给每一个线程处理任务设定一个时间阈值,当某一个客户请求时间过长,则将其置于任务请求最后,或断开连接。
什么是虚假唤醒
举个例子,我们现在有一个生产者-消费者队列和三个线程。
- 1号线程从队列中获取了一个元素,此时队列变为空。
- 2号线程也想从队列中获取一个元素,但此时队列为空,2号线程便只能进入阻塞(cond.wait()),等待队列非空。
- 这时,3号线程将一个元素入队,并调用cond.notify()唤醒条件变量。
- 处于等待状态的2号线程接收到3号线程的唤醒信号,便准备解除阻塞状态,执行接下来的任务(获取队列中的元素)。
- 然而可能出现这样的情况:当2号线程准备获得队列的锁,去获取队列中的元素时,此时1号线程刚好执行完之前的元素操作,返回再去请求队列中的元素,1号线程便获得队列的锁,检查到队列非空,就获取到了3号线程刚刚入队的元素,然后释放队列锁。
- 等到2号线程获得队列锁,判断发现队列仍为空,1号线程“偷走了”这个元素,所以对于2号线程而言,这次唤醒就是“虚假”的,它需要再次等待队列非空。
有哪些典型的锁机制
互斥锁与自旋锁
互斥锁加锁失败后,线程会释放CPU,给其他线程
互斥锁加锁失败时,会从用户态陷入内核态,让内核帮我们切换线程,存在两次线程上下文切换的成本。线程上下文切换的时间大概在几十纳秒到几微秒之间,如果锁住的代码执行事件比较短,可能切换上下文的时间比执行时间还长,这时应该选用自旋锁而不是互斥锁。
自旋锁加锁失败后,线程会忙等待,直到它拿到锁
自旋锁在用户态完成加锁和解锁的操作,开销比互斥锁小,但是在单核CPU上需要抢占式调度器。
读写锁
读写锁适用于能明确区分读写操作的场景
- 当写锁没有被线程持有时,多个线程能并发地持有读锁,这大大提高了共享资源的访问效率
- 一旦写锁被线程持有后,读线程的获取读锁操作就会被阻塞,而且其他写线程的获取写锁操作也会被阻塞
- 写锁是一种独占锁,而读锁是一种共享锁
乐观锁与悲观锁
- 悲观锁访问资源前,要先上锁
- 乐观锁先修改完共享资源,再验证这段时间内是否发生冲突,如果没有其它线程在修改资源,则操作完成,否则放弃本次操作(例子:在线文档;方案:版本号)
- 只有在冲突概率非常低,且加锁成本非常高的场景时,才采用乐观锁,因为一旦发生冲突,重试的成本非常高
线程池中有多少个线程,线程池的数量如何设定
默认8个
调整线程池中的线程数量的最主要的目的是为了充分并合理地使用 CPU 和内存等资源,从而最大限度地提高程序的性能。
Ncpu 表示 CPU的数量。
- 如果是CPU密集型任务,就需要尽量压榨CPU,参考值可以设为 Ncpu+1能够实现最优的CPU 利用率,+1 是保证当线程由于页缺失故障(操作系统)或其它原因 导致暂停时,额外的这个线程就能顶上去,保证CPU 时钟周期不被浪费
- 如果是IO密集型任务,参考值可以设置为 2 * Ncpu。因为线程间竞争的不是CPU的计算资源而是IO,IO的处理一般较慢,多于CPU数的线程将为CPU争取更多的任务,不至在线程处理IO的过程造成CPU空闲导致资源浪费
最佳线程数量 = ((线程等待时间+线程CPU时间)/ 线程CPU时间)* CPU个数。
由公式可得,线程等待时间(如I/O操作)所占比例越高,需要越多的线程,线程CPU时间所占比例越高,所需的线程数越少。
线程数是越多越好吗
不是
- 假设现有8个CPU、8个线程,每个线程占用一个CPU,同一时间段内,若8个线程都运行往前跑,相比较5/6/7个线程,8个线程的效率高。
- 但若此时有9个线程,只有8个CPU,9个线程同时运行,则此时牵扯到线程切换,而线程切换是需要消耗时间的。
- 所以随着线程数越多,效率越来越高,但到一个峰值,再增加线程数量时,就会出现问题。线程太多要来回的切换,最终可能线程切换所用时间比执行时间业务所用时间还大。
- 随着线程数越多,由于线程执行的时序的问题,程序可能会崩溃或产生二义性
服务器如何进行断点传输
解决方案:
- 分片传输
- 将需要上传的文件按照一定的分割规则,分割成相同大小的数据块;
- 初始化一个分片上传任务,返回本次分片上传唯一标识;
- 按照一定的策略(串行或并行)发送各个分片数据块;
- 发送完成后,服务端根据判断数据上传是否完整,如果完整,则进行数据块合成得到原始文件。
- 断点续传
- 在分片上传的过程中,如果因为系统崩溃或者网络中断等异常因素导致上传中断,这时候客户端需要记录上传的进度。在之后支持再次上传时,可以继续从上次上传中断的地方进行继续上传。
- 为了避免客户端在上传之后的进度数据被删除而导致重新开始从头上传的问题,服务端也可以提供相应的接口便于客户端对已经上传的分片数据进行查询,从而使客户端知道已经上传的分片数据,从而从下一个分片数据开始继续上传。
数据库
什么是数据库连接池,为什么要创建连接池?
池是资源的容器,这组资源在服务器启动之初就被完全创建好并初始化,本质上是对资源的复用。
当系统开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配;当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源。
若系统需要频繁访问数据库,则需要频繁创建和断开数据库连接,而创建数据库连接是一个很耗时的操作,也容易对数据库造成安全隐患。
在程序初始化的时候,集中创建多个数据库连接,并把他们集中管理,供程序使用,可以保证较快的数据库读写速度,更加安全可靠。
使用单例模式和链表创建数据库连接池,实现对数据库连接资源的复用。
连接池的功能主要有:初始化,获取连接、释放连接,销毁连接池
连接池中的多线程使用信号量进行通信,使用互斥锁进行同步。
数据库连接的获取与释放通过RAII机制封装,避免手动释放。
RAII机制
RAII全称是“Resource Acquisition is Initialization”,直译过来是“资源获取即初始化”.
RAII的核心思想是将资源或者状态与对象的生命周期绑定,通过C++的语言机制,实现资源和状态的安全管理,智能指针是RAII最好的例子
具体来说:构造函数的时候初始化获取资源,析构函数释放资源
获取释放连接,销毁连接池
- 获取链接
- 容器有空闲连接,直接用
- 容器无空闲
- 未达上限,自己创建
- 达上限,报错打回等待
- 释放连接
- 放回容器
- 目前暂无较好的销毁连接策略
- 销毁对象池
- 关闭销毁池中连接
- 释放连接池对象
- 完成释放
登录说一下?
将数据库中的用户名和密码载入到服务器的map中来,map中的key为用户名,value为密码
服务器端解析浏览器的请求报文,当解析为POST请求时,提取出请求报文的消息体的用户名和密码。
POST请求中最后是用户名和密码,用&隔开。分隔符&,前是用户名,后是密码。
登录:将浏览器输入的用户名和密码在数据库中查找,直接判断。
注册:往数据库中插入数据,需要判断是否有重复的用户名。
最后进行页面跳转
通过m_url定位/所在位置,根据/后的第一个字符,使用分支语句实现页面跳转。具体的,
- 0 — 跳转注册页面,GET
- 1 — 跳转登录页面,GET
- 5 — 显示图片页面,POST
- 6 — 显示视频页面,POST
- 7 — 显示关注页面,POST
登录与注册,服务器如何校验
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的写端,父进程在读端读取)。
你这个保存状态了吗?如果要保存,你会怎么做?(cookie和session)
可以利用session或者cookie的方式进行状态的保存。
cookie其实就是服务器给客户分配了一串“身份标识”,比如“123456789happy”这么一串字符串。每次客户发送数据时,都在HTTP报文附带上这个字符串,服务器就知道你是谁了;
session是保存在服务器端的状态,每当一个客户发送HTTP报文过来的时候,服务器会在自己记录的用户数据中去找,类似于核对名单;
登录中的用户名和密码你是load到本地,然后使用map匹配的,如果有10亿数据,即使load到本地后hash,也是很耗时的,你要怎么优化?
这个问题的关键在于大数据量情况下的用户登录验证怎么进行?将所有的用户信息加载到内存中耗时耗利,对于大数据最便利的方法就是进行hash,利用hash建立多级索引的方式来加快用户验证。具体操作如下:
- 首先,将10亿的用户信息,利用大致缩小1000倍的hash算法进行hash,这时就获得了100万的hash数据,每一个hash数据代表着一个用户信息块(一级);
- 而后,再分别对这100万的hash数据再进行hash,例如最终剩下1000个hash数据(二级)。
- 在这种方式下,服务器只需要保存1000个二级hash数据,当用户请求登录的时候,先对用户信息进行一次hash,找到对应信息块(二级),在读取其对应的一级信息块,最终找到对应的用户数据。
用的mysql啊,redis了解吗?用过吗?
不了解,没用过🤭
定时器
为什么要用定时器?
由于非活跃连接占用了连接资源,严重影响服务器的性能,通过实现一个服务器定时器,处理这种非活跃连接,释放连接资源。
说一下定时器的工作原理
由于定时器的触发是由于时间到了,因此只有时间最短的定时器会首先被触发,通过这个原理,我们可以采用最小堆,将按时间顺序排序,堆顶元素是时间最短的定时器,因此只要判断堆顶元素是否被触发即可。只有堆顶定时器的时间到了,才会到其他时间较晚的定时器的时间。
定时器利用结构体将Http连接对应的fd、有效期、回调函数等封装起来。
服务器主循环每建立一个连接则为该连接创建一个定时器节点,插入到最小堆中,主循环通过GetNextTick()清除超时的节点,关闭对应连接,释放连接资源,然后获取最先要超时的连接的超时的时间,并设置下一次epoll_wait()的超时时间为该时间。当已建立的连接有IO事件时,延长这个连接的超时时间,调整最小堆。
最小堆?说一下时间复杂度和工作原理
时间复杂度:添加:O(logn), 删除:O(logn)
工作原理:
将所有定时器中超时时间最小的一个定时器的超时值,作为定时任务处理函数的定时值。这样,一旦定时任务处理函数被调用,超时时间最小的定时器必然到期,我们就可以在定时任务处理函数中处理该定时器。
然后,再次从剩余的定时器中找出超时时间最小的一个(堆),并将这段最小时间设置为下一次定时任务处理函数的定时值。如此反复,就实现了较为精确的定时。
HTTP报文解析
用了状态机啊,为什么要用状态机?
在逻辑处理模块中,响应HTTP请求采用主从状态机来完成
传统的控制流程都是按照顺序执行的,状态机能处理任意顺序的事件,并能提供有意义的响应——即使这些事件发生的顺序和预计的不同。
项目中使用主从状态机的模式进行解析,从状态机(parse_line)负责读取报文的一行,主状态机负责对该行数据进行解析,主状态机内部调用从状态机,从状态机驱动主状态机。
每解析一部分都会将整个请求的check_state状态改变,状态机也就是根据这个状态来进行不同部分的解析跳转的。
状态机的转移图画一下
- 主状态机的三种状态,标志解析位置
- 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请求消息体,为后面的登录和注册做准备
状态机的缺点
状态机的缺点就是性能比较低,一般一个状态做一个事情,性能比较差,在追求高性能的场景下一般不用,高性能场景一般使用流水线设计。
GET和POST的区别
- get主要用来获取数据,而post是提交或修改数据;
- get有长度限制(2048字节)而post没有;
- get的参数是显式的,get的参数会附加在url之 中,以 “ ? “分割url和传输数据,多个参数用 “&”连接;而post是隐式的,post是放在请求体中;
- get请求会保存在浏览器历史记录中,也可以保存在web服务器日志中;
- get在浏览器回退时是无害的,而post会再次提交请求;
- get请求只能进行url编码,而post支持多种编码方式;
- get请求的参数数据类型只接受ASCII字符,而post没有限制。
HTTP请求怎么拆包
HTTP请求内容:请求行,请求头,空行,请求体
一个有报文的请求到服务器时,请求头里都会有content_length,这个指定了报文的大小。报文如果很大的时候,会通过一部分一部分的发送请求,直到结束。当这个过程中,出现多个请求,第一个请求会带有请求头信息,前面一个请求的发送的报文如果没有满时,会把后面一个请求的内容填上,这个操作就叫粘包。这样粘包后,它会通过content_length字段的大小,来做拆包。
在post发送大数据量时会分段发,接收端一次接收的只是部分post,源代码读到len=-1直接开始处理了,但实际上还有数据没发过来,在process内判断buffer收到的len和header中的content-length是否匹配,如果不匹配直接返回false,下一个post包发过来接着用buffer收,最后收完了再开始request处理。
HTTP怎么接受图片和视频流
使用Content-Type字段,说明响应头中的媒体数据类型。
缓冲区
用户空间缓冲区怎么实现的
在有IO事件发生时,要先把数据读取到缓冲区中,再从缓冲区读取出来进行处理。
首先我们在内存中创建一个缓冲区,缓冲区的大小为1024字节,同时定义两个指针,一个读指针,表示的是可以读取的数据的开始位置,一个写指针表示的是可以写入数据的起始位置,开始的时候两根指针都是指向内存开始的地方。
因为缓冲区的大小是固定的大小,但ET模式需要一次性将数据全部读取到缓冲区,那么就有可能装不下数据,所以需要临时创建一个缓冲区来缓解,将存不下的放到临时缓冲区,这样就可以一次性将所有的数据读入,这里利用临时缓冲区的技术是一个分散读的技术,即将数据分散读取到内存中不同的位置,再使用Append将两块内存中的数据拼接到一起。
Append在缓冲区空间不足时会发生扩容,扩容原则:
- WritableBytes() >= len,无需扩容
- WritableBytes() < len
- 已读+可写 >= len,覆盖掉已读内容
- 已读+可写 < len,resize数组
分散读和分散写
- 分散读发生在从socket缓冲区读取数据的阶段,第一块内存是用户缓冲区readBuff_,第二块内存是一块临时缓冲区,目的是为了保证将socket缓冲区的数据读完。
- 分散写发生在将响应写入socket阶段,第一块内存是writeBuff_,保存着响应的一些信息,组成响应头部;第二块内存使用mmap将被请求的文件映射到共享内存,调用writev将两块内存分散写入socket缓冲区。
大文件传输
- mmap+write
这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。
文件映射相关的系统调用接口:
1 |
|
- sendfile
它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:
1 |
|
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(_The Scatter-Gather Direct Memory Access_)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。这就是所谓的零拷贝(_Zero-copy_)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。如下图:
日志
说下你的日志系统的运行机制?
使用单例模式创建日志系统,对服务器运行状态、错误信息和访问数据进行记录,该系统可以实现按天分类,超行分类功能,可以根据实际情况分别使用同步和异步写入两种方式。
其中异步写入方式,将生产者-消费者模型封装为阻塞队列,创建一个写线程,工作线程将要写的内容push进队列,写线程从队列中取出内容,写入日志文件。
为什么要异步?和同步的区别是什么?
写入日志时会产生比较多的系统调用,若是某条日志信息过大,会阻塞日志系统,造成系统瓶颈。异步方式采用生产者-消费者模型,具有较高的并发能力。
生产者-消费者模型,并发编程中的经典模型。
以多线程为例,为了实现线程间数据同步,生产者线程与消费者线程共享一个缓冲区,其中生产者线程往缓冲区中push消息,消费者线程从缓冲区中pop消息。
阻塞队列,将生产者-消费者模型进行封装,使用循环数组实现队列,作为两者共享的缓冲区。
- 异步日志,将所写的日志内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日志。可以提高系统的并发性能。
- 同步日志,日志写入函数与工作线程串行执行,由于涉及到I/O操作,当单条日志比较大的时候,同步模式会阻塞整个处理流程,服务器所能处理的并发能力将有所下降,尤其是在峰值的时候,写日志可能成为系统的瓶颈。
写入方式通过初始化时是否设置队列大小(表示在队列中可以放几条数据)来判断,若队列大小为0,则为同步,否则为异步。
若异步,则将日志信息加入阻塞队列,同步则加锁向文件中写。
补充一下他的异步日志缓冲区刷新有滞后性。直接fputs就不管了。然后等着下一次的write_log之后的flush刷到文件里。但是不应该这样,应该异步fputs之后也刷一下。
现在你要监控一台服务器的状态,输出监控日志,请问如何将该日志分发到不同的机器上?(消息队列)
消息队列使用发布-订阅模式工作