Webserver面试问题总结1
仅供学习交流,如有侵权,请联系删除!
Webserver项目流程总结
C++ Linux Web Server 面试基础篇-操作系统(四、线程通信)
【WebServer】项目总体流程
Tinywebserver——服务器面试题
C++ LinuxWebServer 2万7千字的面经长文(上)
C++ LinuxWebServer 2万7千字的面经长文(下)
***为什么要做webserver的项目呢?
串联了C++的相关基础知识,语⾔(C/C++全覆盖,可以扩展⾄C++11/17)+操作系统(含有⼤量的I/O 系统调⽤及其封装,还有 EPOLL 等多路复⽤机制)+计算机⽹络(本身就是⼀个⽹络框架,对⽹络异常的处理)+数据库(注册中⼼的数据库语句、负载均衡等)。
***Webserver的结构是怎样的?
参考Tinywebserver
***线程池线程数怎么确定?
Nthreads=Ncpu*(1+w/c)
公式 Nthreads = Ncpu * (1 + w/c)
是用于估计并发线程数的一个经验公式,其中:
Nthreads
表示估计的并发线程数。Ncpu
表示计算机的CPU核心数。w
表示等待时间(等待任务完成的时间,通常以毫秒为单位)。c
表示计算时间(执行任务所需的时间,通常以毫秒为单位)。
这个公式的目的是根据CPU核心数以及任务的等待时间和计算时间来估算可以并行执行的线程数。这在多线程编程和并行计算中很有用,可以帮助确定合适的线程数,以充分利用计算资源而不过度消耗内存和CPU。(chatgpt)
标准的面试八股文答案:
假设机器有N个CPU,那么对于计算密集型的任务,应该设置线程数为N+1;对于IO密集型的任务,应该设置线程数为2N;对于同时有计算工作和IO工作的任务,应该考虑使用两个线程池,一个处理计算任务,一个处理IO任务,分别对两个线程池按照计算密集型和IO密集型来设置线程数。
IO密集型:如果存在IO,那么肯定w/c>1(阻塞耗时一般是计算耗时的很多倍),但是需要考虑系统内存有限(每开启一个线程都需要内存空间),这里需要上服务器测试具体多少个线程数适合(CPU占比、线程数、总耗时、内存消耗)。如果不想去测试,保守点取1即,Nthreads=Ncpu*(1+1)=2Ncpu+1。
计算密集型:假设没有等待w=0,则W/C=0. Nthreads=Ncpu+1。在拥有N个处理器的系统上,当线程池的大小为N+1时,通常能实现最优的效率。(即使当计算密集型的线程偶尔由于缺失故障或者其他原因而暂停时,这个额外的线程也能确保CPU的时钟周期不会被浪费。)
如果一台服务器上只部署这一个应用并且只有这一个线程池,那么这种估算或许合理,具体还需自行测试验证。
服务器性能IO优化 中发现一个估算公式:
|
最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目 |
比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32。这个公式进一步转化为:
|
最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)* CPU数目 |
可以得出一个结论:
线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
一个系统最快的部分是CPU,决定一个系统吞吐量上限的是CPU。增强CPU处理能力,可以提高系统吞吐量上限。但根据短板效应,真实的系统吞吐量并不能单纯根据CPU来计算。那要提高系统吞吐量,就需要从“系统短板”(比如网络延迟、IO)着手:
- 尽量提高短板操作的并行化比率,比如多线程下载技术
- 增强短板能力,比如用NIO替代IO
第一条可以联系到Amdahl定律,这条定律定义串行系统并行化后的加速比计算公式:
|
加速比=优化前系统耗时 / 优化后系统耗时 |
加速比越大,表明系统并行化的优化效果越好。Addahl定律还给出系统并行度、CPU数目和加速比的关系,加速比为Speedup,系统串行化比率(指串行执行代码所占比率)为F,CPU数目为N:
|
Speedup <= 1 / (F + ( 1 -F)/N) |
当N足够大时,串行化比率F越小,加速比Speedup越大。
是否使用线程池就一定比使用单线程高效呢?
答案是否定的,比如Redis就是单线程的,但它却非常高效,基本操作都能达到十万量级/s。从线程这个角度来看,部分原因在于:
- 多线程带来线程上下文切换开销,单线程就没有这种开销
- 锁
当然“Redis很快”本质的原因在于:Redis基本都是内存操作,这种情况下单线程可以很高效地利用CPU。而多线程适用场景一般是:存在相当比例的IO和网络操作。
所以即使有上面的简单估算方法,也许看似合理,但实际上也未必合理,需要结合系统真实情况(比如是IO密集型或者是CPU密集型或者是纯内存操作)和硬件环境(CPU、内存、硬盘读写速度、网络状况等)来不断尝试达到一个符合实际的合理估算值。
***超过100个用户持续访问,服务器会怎么样?
(chatgpt)
当有超过100个用户持续访问服务器时,服务器可能会面临一系列挑战和潜在问题,这取决于服务器的性能、配置、网络带宽以及访问模式等因素。以下是一些可能发生的情况:
-
服务器性能问题:服务器可能无法处理大量并发请求,导致性能下降。这可能表现为延迟增加、响应时间变长以及服务器负载过高。
-
资源耗尽:服务器的CPU、内存和磁盘等资源可能会耗尽,导致服务器宕机或变得不稳定。这可能需要优化服务器配置或升级硬件来处理更多的并发请求。
-
网络带宽受限:服务器的网络带宽可能会成为瓶颈,导致响应时间增加。这可以通过增加带宽或使用内容分发网络(CDN)来缓解。
-
数据库连接问题:如果应用程序与数据库交互,并且数据库连接数有限,那么数据库连接池可能会耗尽,导致应用程序无法处理请求。需要优化数据库连接和查询,或者增加数据库服务器的容量。
-
缓存失效:如果服务器使用了缓存,而缓存策略不当,可能导致缓存失效,需要重新生成缓存数据,增加了服务器负载。
-
安全问题:服务器可能面临更多的安全威胁,例如DDoS攻击或恶意请求。需要实施安全措施来保护服务器免受攻击。
-
错误处理问题:大量并发请求可能导致应用程序的错误处理能力不足,从而导致应用程序崩溃或不稳定。
为了解决这些问题,可以考虑以下措施:
- 优化服务器性能和配置。
- 增加服务器资源,例如CPU、内存和带宽。
- 使用负载均衡来分散流量。
- 实施缓存策略和合理的缓存失效处理。
- 使用数据库连接池。
- 实施安全措施来防御攻击。
- 进行性能测试和监控,及时发现和解决问题。
- 考虑使用云计算服务,以便根据需要动态扩展服务器资源。
最终,服务器的承受能力取决于服务器硬件、配置和应用程序的设计,因此需要根据具体情况来采取适当的措施。
(网上)
1、服务器过载
由过多流量引起的最常见的服务器问题之一是服务器过载。当服务器过载时,这意味着它收到的请求多于它可以处理的数量,这可能导致响应时间变慢甚至服务器崩溃。当流量突然激增时,例如在热门活动或促销期间,可能会发生这种情况。
为了解决服务器过载问题,您可以实施负载平衡,将传入流量分配到多台服务器以确保没有一台服务器过载。您还可以通过减少不必要的后台进程或升级服务器硬件来优化服务器资源。
2、带宽限制
流量过大引起的另一个服务器问题是带宽限制。带宽是指可以通过网络连接传输的数据量。当服务器接收到大量流量时,它会很快用完可用带宽,从而导致页面加载时间变慢甚至停机。
要解决带宽限制问题,您可以通过升级 Internet 连接或实施内容分发网络 (CDN) 来增加服务器的带宽容量。CDN 将您网站的内容缓存在世界各地的多个服务器上,从而减少提供内容所需的带宽量。
3、数据库过载
过多的流量也会导致数据库过载,当数据库服务器收到的请求超过其处理能力时,就会发生这种情况。这可能导致响应时间变慢甚至数据库崩溃,从而导致数据丢失或损坏。
要解决数据库过载问题,您可以通过减少不必要的查询或实施数据库缓存来优化数据库查询。您还可以升级数据库服务器的硬件或考虑实施数据库集群解决方案,将工作负载分布到多个数据库服务器上。
4、安全漏洞
过多的流量还会增加安全漏洞的风险,例如 DDoS 攻击或暴力攻击。当服务器被来自多个来源的请求淹没,服务器不堪重负并导致其崩溃时,就会发生 DDoS 攻击。当黑客试图通过猜测登录凭据来访问您的服务器时,就会发生暴力攻击。
为了解决安全漏洞,您可以实施防火墙或入侵检测系统来监控和阻止恶意流量。您还可以实施双因素身份验证或强密码策略来降低暴力攻击的风险。
过多的流量会导致许多服务器问题,从而影响您的网站或应用程序的性能和可用性。通过实施负载平衡、带宽优化、数据库优化和安全措施等解决方案,您可以确保您的服务器能够处理用户的需求并提供可靠和安全的用户体验。
***介绍webserver项目中线程池 io多路复用
webserver里面是用epoll,这题应该是回答epoll的相关知识。
select,poll,epoll的优缺点,区别
select:是最初解决 IO 阻塞问题的方法。用结构体 fd_set 来告诉内核监听多个文件描述符, 该结构体被称为描述符集。由数组来维持哪些描述符被置位了。对结构体的操作封装在三个宏定 义中。通过轮寻来查找是否有描述符要被处理
select存在的问题: 1. 内置数组的形式使得 select 的最大文件数受限与 FD_SIZE;
2. 每次调用 select 前都要重新初始化描述符集,将 fd 从用户态拷贝到内核态,每次调用 select 后,都需要将 fd 从内核态拷贝到用户态;
3. 轮寻排查当文件描述符个数很多时,效率很低;
poll:通过一个可变长度的数组解决了 select 文件描述符受限的问题。数组中元素是结构体,该结构体保存描述符的信息,每增加一个文件描述符就向数组中加入一个结构体,结构体只需要拷贝一次到内核态。poll 解决了 select 重复初始化的问题。轮寻排查的问题未解决。
epoll:轮寻排查所有文件描述符的效率不高,使服务器并发能力受限。因此,epoll 采用只返回状态发生变化的文件描述符,便解决了轮寻的瓶颈。epoll 对文件描述符的操作有两种模式:LT(level trigger)和 ET(edge trigger)。
epoll怎么判断事件是否就绪?
epoll中如何将fd的写事件加入进去,怎么区分哪个是accept哪个是读事件?怎么加写事件,写完后如何发送(实际上数据准备后不需要加事件,直接返回出去?
只要我的socket的写缓存区有空间,触发EPOLLOUT,就将写事件加入进去
要根据具体的项目去判断,具体是通过epoll_wait函数返回的文件描述符来区别
epoll读完的标志是什么?
read函数返回值
(chatgpt)在使用epoll
进行事件驱动的I/O操作时,读取套接字数据的标志通常是EPOLLIN
。当套接字上有数据可供读取时,epoll
会将该套接字的文件描述符与EPOLLIN
标志一起通知给你的程序,这表示可以从套接字中读取数据。
在epoll
中,EPOLLIN
标志表示以下情况之一:
- 套接字上有新的数据可供读取。
- 套接字上有连接请求(用于监听套接字)。
- 套接字上有带外数据(out-of-band data)可供读取。
在处理EPOLLIN
事件时,你可以使用套接字的recv
函数或者其他适合的方法来读取数据。读取完成后,你通常会处理接收到的数据,然后可能会再次注册套接字的EPOLLIN
事件,以便在有更多数据可读时再次得到通知。
注意,EPOLLIN
标志只是一个表示套接字上有数据可读的指示,它不会告诉你有多少数据可读。你需要根据应用程序的需要,使用适当的方式来确定可以读取多少数据,例如设置合适的缓冲区大小,并在recv
函数中指定该大小。
总之,EPOLLIN
是epoll
机制中用于标志套接字上有数据可读的标志,你可以在epoll
事件中检查它,然后读取套接字的数据。
epoll
epoll就是解耦了select的模型:
设想一个场景:有100万用户同时与一个进程保持着TCP连接,而每一时刻只有几十个或几百个TCP连接是活跃的(接收TCP包),也就是说在每一时刻进程只需要处理这100万连接中的一小部分连接。那么,如何才能高效的处理这种场景呢?进程是否在每次询问操作系统收集有事件发生的TCP连接时,把这100万个连接告诉操作系统,然后由操作系统找出其中有事件发生的几百个连接呢?实际上,在Linux2.4版本以前,那时的select或者poll事件驱动方式是这样做的.
这里有个非常明显的问题,即在某一时刻,进程收集有事件的连接时,其实这100万连接中的大部分都是没有事件发生的。因此如果每次收集事件时,都把100万连接的套接字传给操作系统(这首先是用户态内存到内核态内存的大量复制),而由操作系统内核寻找这些连接上有没有未处理的事件,将会是巨大的资源浪费,然后select和poll就是这样做的,因此它们最多只能处理几千个并发连接。而epoll不这样做,它在Linux内核中申请了一个简易的文件系统,把原先的一个select或poll调用分成了3部分:
首先介绍一下epoll的函数,主要由三个:
int epoll_create(int size); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);
使用epoll_create向操作系统申请创建文件描述符的空间(这个文件描述符都是在内核空间中),即建立一个epoll对象。epoll_ctl将刚创立的socket加入到epoll中进行监控,或者将某个正在监控的socket移除,不在监控。epoll_wait即监控socket有状态发生变化时候,就返回用户态的进程
从这三个函数就可以看到epoll函数的优越性。当调用select时需要传递所有监视的socket给系统调用,意味着需要将用户态的fd_set拷贝到内核态,可想而知效率非常低效。但是epoll中内核通过epoll_ctl函数已经拿到监视socket列表。所以,实际上在你调用epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。
调用epoll_create建立一个epoll对象(在epoll文件系统中给这个句柄分配资源);
调用epoll_ctl向epoll对象中添加这100万个连接的套接字;
调用epoll_wait收集发生事件的连接。
epoll会开辟出自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。同时还会建立一个双向链表,每个节点保存着满足读写条件,返回给用户的事件。
epoll高效的原因主要是epoll_wait这个函数。由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的文件描述符到用户态而已,如何能不高效?!
那么,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个文件描述符的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,数据copy到内核中后就来把socket插入到准备就绪链表里了。
总结: 一颗红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。
https://mp.weixin.qq.com/s/miOOrLrC4HWXigLy9Ml-jw
epoll相关函数的定义:
-
epoll_create函数和epoll_create1函数
int epoll_create(int size); int epoll_create1(int flags);
epoll_create 是 epoll 的初始化函数,用于创建一个 epoll 对象。其中,size 表示最多可以监听的文件描述符数量。该函数返回一个整数值,表示创建的 epoll 对象的文件描述符。如果出错,返回值为 -1。
epoll_create1 也用于创建一个epoll对象,并返回一个文件描述符,但它可以通过 flags 参数来设置一些标志位,以改变 epoll 对象的行为。常用的标志位包括:
EPOLL_CLOEXEC
:设置文件描述符的 close-on-exec 标志,即在执行 exec 系统调用时自动关闭文件描述符。EPOLL_NONBLOCK
:设置文件描述符的非阻塞标志,即对该文件描述符的操作都是非阻塞的。
总的来说,epoll_create1 函数比 epoll_create 函数更加灵活,可以通过标志位来设置一些 epoll 对象的属性,而 epoll_create 函数则比较简单,只能创建默认属性的 epoll 对象。在实际编程中,建议优先使用 epoll_create1 函数。
-
epoll_ctl函数
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl 函数负责把服务端和客户端建立的 socket 连接注册到 eventpoll 对象里。其中,第一个参数 epfd 是 epoll 对象的文件描述符。第二个参数 op 表示操作类型,可以是以下三个值之一:
操作类型 描述 EPOLL_CTL_ADD 在epoll的监视列表中添加一个文件描述符(即参数fd),指定监视的事件类型(参数event),常量值为1。 EPOLL_CTL_DEL 将某监视列表中已经存在的描述符(即参数fd)删除,参数event传NULL,常量值为2。 EPOLL_CTL_MOD 修改监视列表中已经存在的描述符(即参数fd)对应的监视事件类型(参数event),常量值为3。 在centos这个发行版中,EPOLL_CTL_ADD、EPOLL_CTL_MOD和EPOLL_CTL_DEL的定义在/usr/include/sys/epoll.h文件下找到。
第三个参数 fd 是要添加、修改或删除的文件描述符。第四个参数 event 是一个 struct epoll_event 结构体,用于描述要监听的事件类型和数据。该结构体的定义如下:
struct epoll_event { uint32_t events; /* 监听的事件类型 */ epoll_data_t data; /* 用户数据 */ }; typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t;
其中,events 表示要监听的事件类型,可以是以下几个值之一:
EPOLLIN
:表示文件描述符可读;EPOLLOUT
:表示文件描述符可写;EPOLLERR
:表示文件描述符发生错误;EPOLLHUP
:表示文件描述符被挂起。
data 是一个 epoll_data_t 类型的联合体,用于存储用户数据。它可以是一个指针、一个文件描述符、一个 32 位整数或一个 64 位整数。epoll_ctl 函数返回 0 表示成功,-1 表示失败。
-
epoll_wait函数
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll_wait 用于等待文件描述符上的事件发生。其中,epfd 是 epoll 对象的文件描述符,events 是一个指向 struct epoll_event 数组的指针,用于存储发生事件的文件描述符和事件类型,maxevents 表示最多等待的事件数量,timeout 表示等待的超时时间,单位是毫秒。epoll_wait 函数返回发生事件的文件描述符数量,如果出错,返回值为 -1。
epoll的底层原理
在理解epoll底层原理之前,需要知道
struct eventpoll
(epoll对象)的作用。 epoll 对象是用于管理多个文件描述符的机制,它可以同时监听多个文件描述符上的事件,包括套接字的可读、可写、错误等事件。尽管 epoll 对象和套接字的功能不同,但它们之间也有联系。在实际应用中,通常会将套接字的文件描述符加入到 epoll 对象中,以便能够监听套接字上的事件。这样,当套接字上有数据可读或可写时,epoll 对象就会通知应用程序进行相应的操作。因此,epoll 对象和套接字是可以结合使用的。
epoll对象的定义如下:
struct eventpoll { wait_queue_head_t wq; // sys_epoll_wait用到的等待队列 struct list_head rdllist; // 接收就绪的描述符都会放到这里 struct rb_root rbr; // 每个epoll对象中都有一颗红黑树...... }
- wq: 等待队列。如果当前进程没有数据需要处理,会把当前进程描述符和
回调函数
default_wake_functon (这个回调函数等会要考)构造一个等待队列项,放入当前 wq 对待队列中,等到数据就绪的时候会通过 wq 来找到阻塞在 epoll 对象上的用户进程。 - rdllist: 就绪的描述符的队列。当有的连接就绪的时候(socket监听的事件完成的时候),内核会把就绪的socket的文件描述符放到 rdllist 队列里。这样应用进程只需要判断队列就能找出就绪进程,而不用去遍历整棵树。
- rbr: 一棵红黑树。为了支持对海量连接的高效查找、插入和删除,eventpoll 内部使用了一棵红黑树。通过这棵树来管理用户进程下添加进来的所有 socket 连接。
struct eventpoll 的结构如下图所示:
在创建好epoll对象后,接下来就是将socket的文件描述符和epoll对象关联起来并指定需要监听的事件了。这就是epoll_ctl函数的作用,也就是说epoll_ctl函数可以把socket的文件描述符以及监听的事件添加
(也可以是删除或更新)到epoll对象中。那具体是如何关联的呢?实际上,epoll_ctl函数首先会创建一个epitem对象。epitem结构体如下所示:
struct epitem{ struct rb_node rbn; // 红黑树节点 struct epoll_filefd ffd; // socket文件描述符信息 struct eventpoll *ep; // 所归属的epoll对象 struct list_head pwqlist; // 等待队列 }
这个epitem对象其实就是epoll对象里面的红黑树中的一个节点。epitem对象也是对socket的一种抽象概括,也就是说从epitem对象中能够得到socket的部分关键信息。我们也可以把epitem对象理解为socket对象和epoll对象关联的一个桥梁。当socket上监听的事件没有发生时,socket就会变为阻塞状态。总的来说,epoll_ctl函数主要做了下面这三件事情:
-
创建红黑树节点对象epitem
-
将等待事件添加到socket的等待队列中,通过
pwqlist
(等待队列)设置数据就绪的回调函数为ep_poll_callback。(当事件发生后,软中断处理程序就会调用ep_poll_callback) -
将epitem节点插入到epoll对象的红黑树中
接下来就开始静静的等待事件的发生。显然,这就是epoll_wait函数的作用。epoll_wait 函数的动作比较简单,检查 epoll对象的就绪队列
(里面放的是就绪的文件描述符)是否有数据到达,如果没有就把当前的进程描述符添加到一个等待队列项里,加入到 epoll对象的进程等待队列里,设置等待项回调函数default_wake_function,然后阻塞自己,等待数据到达时通过回调函数被唤醒。
是的,当没有 IO 事件的时候, epoll 也是会阻塞掉当前进程。这个是合理的,因为没有事情可做了占着 CPU 也没啥意义。网上的很多文章有个很不好的习惯,讨论阻塞、非阻塞等概念的时候都不说主语。这会导致你看的云里雾里。拿 epoll 来说,epoll 本身是阻塞的,但一般会把 socket 设置成非阻塞。只有说了主语,这些概念才有意义。
当有数据到达时,通过下面几个步骤唤醒对应的进程处理数据:
- (前面的过程参考select系统调用)。
- 中断处理程序根据数据包里面的IP和端口号就能找到对应的socket对象,将内存中的数据包拷贝到socket的接收队列中(事件被触发) ,再调用socket的等待队列中等待项设置的回调函数ep_poll_callback。
- ep_poll_callback 函数根据等待队列项找到epitem。
- 由于epitem保存了已就绪的socket的文件描述符,并且epitem对象是epoll对象的一个红黑树节点,所以ep_poll_callback函数可以将就绪的socket的文件描述符添加到epoll对象的就绪队列中。
- ep_poll_callback 函数检查epoll对象的等待队列上是否有等待项。
- 如果没有等待项,说明用户进程并未阻塞,此时软中断结束。
- 如果有等待项,则通过调用回调函数 default_wake_func 唤醒这个进程。
- 当进程醒来后,继续从epoll_wait时暂停处的代码继续执行,把epoll对象就绪队列的事件返回给用户进程,让用户进程调用recv把已经到达socket的数据拷贝到用户空间使用。
感想:
研究了整整一天终于看懂了epoll这个系统调用的底层原理。真的感觉这个系统调用的底层原理非常复杂。过不了几天,我感觉就能把这些东西忘的差不多了。为了更方便的回顾,画了epoll对象、epitem对象和socket对象的关系图。
epoll的优缺点
优点:
- 高效处理高并发下的大量连接,有非常有益的性能。(红黑树将存储 epoll 所监听的 FD,高效的数据结构,本身插入和删除性能比较好;通过epoll对象中的就绪队列可以直接知道哪些文件描述符已就绪,减少了遍历文件描述符集的时间开销; mmap 的引入,将用户空间的一块地址和内核空间的一块地址同时映射到相同的一块物理内存地址,减少用户态和内核态之间的数据交换。)
缺点:
- 跨平台性不够好,目前只支持Linux操作系统。MacOS和Windows操作系统不支持该函数。
- 在监听的文件描述符或事件较少的时候,可能select和poll的性能更优。
epoll_ctl模式设置
epoll_wait 函数的触发方式可以通过 epoll_ctl 函数的 EPOLL_CTL_ADD 操作来设置。在添加文件描述符到 epoll 对象时,可以通过设置 epoll_event 结构体中的 events 字段来指定触发方式。具体来说:
- 如果将 events 字段设置为
EPOLLIN | EPOLLET
或EPOLLOUT | EPOLLET
,则表示该文件描述符采用边缘触发方式。 - 如果将 events 字段设置为
EPOLLIN
或EPOLLOUT
,则表示该文件描述符采用水平触发方式。
其中,EPOLLIN 表示文件描述符可读,EPOLLOUT 表示文件描述符可写,EPOLLET 表示边缘触发方式。
需要注意的是,边缘触发方式下,epoll_wait 函数只会在文件描述符上发生状态变化时才返回,而水平触发方式下,epoll_wait 函数会在文件描述符上有数据可读或可写时就返回。因此,边缘触发方式下需要更加谨慎地处理事件,否则可能会出现遗漏事件的情况。
应用场景
基于IO多路复用(epoll实现)的Web服务器
基于epoll多路复用的方式写一个并发的Web服务器对于理解epoll多路复用很有帮助。
客户端代码:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <arpa/inet.h> #include <sys/socket.h>
#define PORT 8080int main(int argc, char const *argv[]) {
int client_fd, valread;struct sockaddr_in server_addr; char buffer[1024] = {0}; const char *http_request = "GET / HTTP/1.1\r\nHost: 127.0.0.1:8080\r\nConnection: close\r\n\r\nHello World!"; client_fd = socket(AF_INET, SOCK_STREAM, 0); // 创建socket server_addr.sin_family = AF_INET; // sin_family用来定义是哪种地址族,AF_INET表示使用IPv4进行通信 server_addr.sin_port = htons(PORT); // 指定端口号,htons()将短整型数据转换成网络字节顺序 inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr); // 将IPv4地址从点分十进制转换为二进制格式// 连接服务器 if (connect(client_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0)
{ perror("connect failed"); exit(EXIT_FAILURE);
}
send(client_fd, http_request, strlen(http_request), 0);// 发送HTTP请求 return 0; }
服务器代码:
#include <stdio.h> #include <fcntl.h> #include <errno.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/epoll.h> #include <sys/socket.h>#define MAX_EVENTS 10 // epoll_wait函数每次最多返回的就绪事件数量 #define BUF_SIZE 1024 // 缓冲区大小
int main(int argc, char *argv[]) {
int nfds, i, n; char buffer[BUF_SIZE]; int server_fd, client_fd, epoll_fd; struct epoll_event ev, events[MAX_EVENTS]; // ev是添加到epoll对象中的事件,events[]用于存储epoll_wait函数返回的就绪事件 short port = 8080; // 服务器的监听端口 char listen_addr_str[] = "127.0.0.1"; // 服务器的IP地址 int server_socket, client_socket; // 定义服务端的socket和客户端的 socketstruct sockaddr_in server_addr, client_addr; // 定义服务端和客户端的IPv4的套接字地址结构(定长,16字节) size_t listen_addr = inet_addr(listen_addr_str); // 将点分十进制的IPv4地址转换成网络字节序列的长整型// 对IPv4的套接字地址结构做初始化 bzero(&server_addr, sizeof(server_addr)); // 将server_addr结构体的前sizeof(serveraddr)个字节清零,与memset()差不多 server_addr.sin_family = AF_INET; // sin_family用来定义是哪种地址族,AF_INET表示使用IPv4进行通信 server_addr.sin_port = htons(port); // 指定端口号,htons()将短整型数据转换成网络字节顺序 server_addr.sin_addr.s_addr = listen_addr; // 指定服务器的IP地址 socklen_t client_len = sizeof(client_addr);
server_fd = socket(PF_INET, SOCK_STREAM, 0); // 创建套接字,SOCK_STREAM表示使用TCP协议 fcntl(server_fd, F_SETFL, fcntl(server_fd, F_GETFL, 0) | O_NONBLOCK); // 设置非阻塞 bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)); // 绑定端口// 监听端口,SOMAXCONN默认值为128,表示TCP服务可以同时接受的连接请求的最大数量 listen(server_fd, SOMAXCONN);// 创建 epoll 对象 if ((epoll_fd = epoll_create1(0)) < 0){ perror("epoll_create1 error");exit(1); } ev.events = EPOLLIN; // 添加事件 ev.data.fd = server_fd; // 添加服务器socket到epoll对象中 if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &ev) < 0){ perror("epoll_ctl error");exit(1); } // 开始循环监听 while (1){ // 等待事件发生,返回发生事件的文件描述符数量 nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); if (nfds < 0){
perror("epoll_wait error");
exit(1);
}// 处理所有就绪事件 for (i = 0; i < nfds; i++){ if (events[i].data.fd == server_fd) // 如果是服务器socket有新连接请求 { while ((client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len)) > 0) { // 设置客户端socket非阻塞 fcntl(client_fd, F_SETFL, fcntl(client_fd, F_GETFL, 0) | O_NONBLOCK);// 添加客户端socket到epoll对象中 ev.events = EPOLLIN | EPOLLET; // ET模式,缓冲区状态变化时触发事件 ev.data.fd = client_fd;if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &ev) < 0){ perror("epoll_ctl error");
exit(1);
}
}
}else{
while ((n = read(events[i].data.fd, buffer, BUF_SIZE)) > 0) // 如果是客户端socket有数据到达 { printf("Received: %s\n", buffer); // 输出从客户端接收到的数据 char *response = "HTTP/1.1 200 OK\r\nContent-Length: 12\r\n\r\nHello World!"; write(events[i].data.fd, response, strlen(response)); // 回复客户端 close(events[i].data.fd); // 关闭客户端socket } } } } close(server_fd); // 关闭服务器socketexit(0); }
运行结果如下:
以上代码的大致流程如下:
Redis的IO多路复用模型简单分析
Redis采用IO多路复用技术,其底层实现原理如下:
-
事件驱动:Redis采用事件驱动的方式处理客户端请求。当有客户端请求到达时,Redis会将请求放入一个事件队列中,然后通过IO多路复用技术来监听事件。当有事件到达时,Redis会从事件队列中取出事件,并根据事件类型进行处理。
-
IO多路复用:Redis采用IO多路复用技术来处理客户端请求。通过IO多路复用技术,Redis可以同时监听多个客户端请求,从而实现高并发的读写操作。
-
非阻塞IO:Redis采用非阻塞IO来处理客户端请求。通过非阻塞IO,Redis可以在等待客户端请求的同时,继续处理其他请求,从而提高系统的吞吐量。
-
事件循环:Redis采用事件循环的方式处理客户端请求。事件循环是指Redis在等待客户端请求的同时,不断地进行事件处理。通过事件循环,Redis可以在保证高并发的同时,保持低延迟和高吞吐量。
Redis的IO多路复用模型是基于epoll实现的。在Linux系统中,有多种IO多路复用模型,包括select、poll和epoll等。Redis最初是基于select模型实现的,但由于select模型在大量连接的情况下性能不佳,因此Redis从2.6版本开始采用epoll模型。
综上所述,Redis的IO多路复用模型是Redis能够实现高性能、高并发、低延迟的关键。通过IO多路复用技术的应用,Redis能够同时监听多个客户端请求,从而实现高并发的读写操作。同时采用非阻塞IO和事件循环的方式处理客户端请求,在保证高并发的同时,保持低延迟和高吞吐量。
问:redis的IO多路复用模型是基于epoll实现,由于epoll系统调用只持支Linux操作系统,为什么windows也能使用redis的IO多路复用模型?
Redis是使用epoll系统调用作为IO多路复用模型的底层实现,而Windows操作系统不支持epoll系统调用。因此,在Windows上使用Redis时,Redis不能直接使用epoll作为底层I/O模型。Redis在Windows上会使用类似epoll的技术实现I/O多路复用。
具体来说,Redis在Windows上使用了IOCP(Input/Output Completion Ports)技术来实现I/O多路复用。IOCP是Windows专有的技术,它是一种高效的I/O调度机制,它可以支持一组I/O操作的异步完成通知。通过IOCP,Redis可以在Windows平台上实现高效的异步I/O,并且可以避免使用epoll等Linux专有的API。
需要注意的是,由于Windows和Linux的底层实现机制不同,导致在Windows上使用IOCP和在Linux上使用epoll并不完全相同,因此,在跨平台开发时需要注意这种差异。
***ET vs LT
基本概念
Edge Triggered (ET)边缘触发:
在边缘触发模式下,当一个文件描述符上的状态发生变化时(例如从不可读变为可读,或者从不可写变为可写),epoll会通知应用程序。
与水平触发模式不同的是,边缘触发模式只在状态变化的瞬间通知应用程序,通知仅发送一次。如果应用程序没有及时处理完这个事件,下次等待时将会错过该事件,即使事件仍然处于就绪状态。因此,在边缘触发模式下,应用程序需要确保尽可能完整地处理每个事件,以避免遗漏事件。
边缘触发模式适用于需要及时响应状态变化的场景,通常可以提供更高的性能,因为它最大程度上减少了不必要的事件通知。
-
socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
-
socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件
Level Triggered (LT) 水平触发:
在水平触发模式下,当一个文件描述符上的I/O事件就绪时,epoll会立即通知应用程序,然后应用程序可以对就绪事件进行处理。即,只要文件描述符处于就绪状态,epoll就会持续通知应用程序,直到应用程序处理完所有就绪事件并且再次进入阻塞等待状态。
对于非阻塞I/O,如果一个文件描述符上有可读或可写事件发生,应用程序可以立即进行读或写操作,即使读写操作无法一次完成。如果读或写操作不能立即完成,应用程序可以再次调用epoll等待新的事件通知。
-
socket接收缓冲区不为空,有数据可读,则读事件一直触发
-
socket发送缓冲区不满可以继续写入数据,则写事件一直触发
epoll的水平触发模式(LT)
默认模式
在LT(水平触发)模式下,只要这个文件描述符还有数据可读,每次 epoll_wait都会返回它的事件,提醒用户程序去操作。
epoll的边缘触发模式(ET)
ET(边缘触发)模式下,在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,对于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN 为止,否则下次的 epoll_wait 不会返回余下的数据,会丢掉事件。如果ET模式不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。
为什么会有ET模式?
答:如果采用EPOLLLT模式的话,系统中一旦有大量你不需要读写的就绪文件描述符,它们每次调用epoll_wait都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率.。而采用EPOLLET这种边缘触发模式的话,当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符。减少epoll_wait的调用次数,系统调用开销是很大的
ET,LT,在什么场景下使用?
ET模式如何保证缓冲区里的数据一次读完?(ET模式如何保证缓冲区里的数据一次读完,线程池线程之间的通信方式?)
给你一个内存很小的机器,使用ET,还是LT,为什么?
https://zhuanlan.zhihu.com/p/441677252
边缘触发如果没读完数据的话会有什么情况?
在边缘触发模式下,如果没有将可用数据全部读取完毕,将发生以下情况:
-
阻塞: 边缘触发通知仅在套接字或文件描述符状态发生变化时触发,而不是在数据可用时触发。如果没有读取完数据,套接字或文件描述符的状态将保持不变,导致下一次的边缘触发事件不会触发,因此应用程序可能会陷入阻塞状态,直到发生状态变化或者数据可用。
-
数据丢失: 边缘触发要求应用程序在每个事件中尽量读取尽可能多的数据。如果应用程序没有读取完数据并且不进行进一步处理,那么尚未读取的数据将被丢弃,因为在下一个事件触发之前不会再次通知可用数据。
-
处理复杂性: 边缘触发通常要求应用程序具有更高的复杂性,因为它需要追踪已经读取的数据,并确保在事件触发后继续读取剩余的数据。这可能需要使用缓冲区和状态管理来有效地处理数据。
综上所述,在边缘触发模式下,应用程序必须谨慎处理数据,确保及时读取和处理,否则可能会导致阻塞、数据丢失或处理复杂性增加的问题。因此,边缘触发通常需要更高的编程复杂性,但在某些情况下可以提供更好的性能和效率。
两种触发模式在编码上有什么区别?
***one thread one loop的理解
https://zhuanlan.zhihu.com/p/636167841
说一下one thread one loop的理解?
一个线程有且只有一个reactor,执行以上loop循环。程序中通常有多个Reactor,例如一种常见的模式是mainReactor只监听connfd连接事件,当发生连接后就交给subReactor去监听IO事件。在这里由于要将新连接交给其他线程的reactor,那么reactor的实现就得是线程安全的。
为什么不能多个线程一个loop?
多个线程进行同一个loop,会导致大量的竞争条件,影响性能,这种做法也没多大意义。
多个线程同时沉睡在同一个epoll_wait上,可能会发生惊群效应。
**定义:**惊群效应(thundering herd)是指多进程(多线程)在同时阻塞等待同一个事件的时候(休眠状态),如果等待的这个事件发生,那么他就会唤醒等待的所有进程(或者线程),但是最终却只能有一个进程(线程)获得这个时间的“控制权”,对该事件进行处理,而其他进程(线程)获取“控制权”失败,只能重新进入休眠状态,这种现象和性能浪费就叫做惊群效应。
危害:
- Linux 内核对用户进程(线程)频繁地做无效的调度、上下文切换等使系统性能大打折扣。上下文切换(context switch)过高会导致 cpu 像个搬运工,频繁地在寄存器和运行队列之间奔波,更多的时间花在了进程(线程)切换,而不是在真正工作的进程(线程)上面。直接的消耗包括 cpu 寄存器要保存和加载(例如程序计数器)、系统调度器的代码需要执行。间接的消耗在于多核 cache 之间的共享数据。
- 为了确保只有一个进程(线程)得到资源,需要对资源操作进行加锁保护,加大了系统的开销。目前一些常见的服务器软件有的是通过锁机制解决的,比如 Nginx(它的锁机制是默认开启的,可以关闭);还有些认为惊群对系统性能影响不大,没有去处理,比如 lighttpd
Linux 解决方案之 Accept
Linux 2.6 版本之前,监听同一个 socket 的进程会挂在同一个等待队列上,当请求到来时,会唤醒所有等待的进程。Linux 2.6 版本之后,通过引入一个标记位 WQ_FLAG_EXCLUSIVE,解决掉了 Accept 惊群效应。
为什么不能一个线程多个loop?
reactor可以认为是一个死循环,一个线程只可能同时执行一个循环就可以了。(没有意义)
为什么要用红黑树做定时器,不能其他的吗?
定时器是哪个系统调用?
https://blog.csdn.net/shift_wwx/article/details/128562373
alarm() 精确到秒
setitimer() 精确到微秒
time_create() 精确到纳秒
timerfd:以文件描述符形式监听时间变化,配合select、poll、epoll使用
timerfd_create()
timerfd_settime()
timerfd_gettime()
read()/close()
如何实现无锁任务队列?
https://zhuanlan.zhihu.com/p/352723264
大文件传输问题
writev() 一次函数调用中,读多个非连续的缓冲区。
***如果要设计web服务器的使用API,该怎么写?
***服务器是如何应对高并发请求的?实现一个线程池
thread pool中任务队列的设计
thread pool的大小
线程池线程数怎么确定
***io多路复用的好处
***线程池大小如何确定
***主线程做什么,工作线程做什么
***webserver线程池怎么应对大量连接的
***项目如何与数据库进行链接
***线程池和连接池的差异,为什么池化技术能够增大并发量,性能瓶颈在什么地方
面试的顺藤摸瓜式问答
* 先问线程池,问每个线程都在做什么。
不断调用`epoll_wait`获取当前`Epoll`所管理的监听事件并处理(虽然回答不是这样回答的)
* 怎么分配的工作,如果当前线程管理的这些连接都比较活跃怎么保证高性能
回答的是如果是某一个线程的所有连接都比较活跃的话,可能这个线程中的连接就会大量堵塞。但是其他线程的不会收到印象,如果想要进一步提高性能。就必须重新去调度,将活跃的连接分配给空闲的线程。
* 怎么去重新调度
应该可以在服务器运行期间记录连接的活跃程度,然后重新分配叭。具体怎么做,不太清楚,没有在服务器中用到。
* 你还做了个定时器,拿他来关闭长连接,为什么要这么做
就是如果有连接长时间不活动,就关闭吗,减少服务负载。
* tcp连接不是有个保活机制吗,为什么你要自己做这个呢。
(被误导了)我还以为tcp会自动关闭长连接的,后续查了一下保活机制是指如果长时间没有信息会发探测帧确认连接是否存活。和这个自动关闭连接不冲突啊
参考链接:
https://blog.csdn.net/m0_49476241/article/details/132380734
https://blog.csdn.net/qq_54015483/article/details/130943574
https://www.xjx100.cn/news/467302.html
http://ifeve.com/how-to-calculate-threadpool-size/
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 展开说说关于C#中ORM框架的用法!