牛客网webserver面试经验
一、项目简介二、高频提问1.主线程的逻辑2.是如何实现IO多路复用的?3.线程池怎么建立起来的4.为什么要用线程池5.请求队列中请求的插入与取出的过程6.讲一下proactor模式7.如何用同步I/O模拟实现proactor模式8.讲一下tcp/ip通讯的代码过程9. 是怎么给客户端的请求回复的?10.ET模式和IT模式区别与说明 11.线程同步的问题怎么解决 三. 其他问题
一、项目简介
本人去年十一月份用webserver这个项目面试了小米,字节,英特尔等诸多大厂的日常实习,这一篇是根据面试实战总结出来的面经。
还会包含一些没有被问到的问题总结。
二、高频提问
1.主线程的逻辑
主线程主要是用来监听、读取请求的。他主要实现是在main函数中。
1.参数解析: 解析命令行参数以获取服务器监听端口。
2.线程池初始化: 创建并初始化线程池,用于后续的请求处理。threadpool
被特化以使用 http_conn
类型,表示线程池将负责执行与HTTP连接相关的任务
threadpool
是一个泛型的线程池类,它可以被特化为使用不同类型的任务或作业。在这里,它被特化为使用 http_conn
类型,意味着线程池被配置为处理 http_conn
类型的对象或任务。
3.创建套接字和监听: 创建服务器监听套接字,并绑定到指定的端口和地址,然后开始监听。
4.创建epoll实例: 创建epoll事件监听实例,并将第三步监听的套接字注册到epoll事件监听列表中。
5. 进入主循环,等待并处理epoll事件(如新的连接、数据读写等)。
如果是新的连接请求,接受连接,并将新连接的套接字注册到epoll事件监听列表中。
如果是数据读取请求,读取数据,并根据读取的结果将请求加入线程池队列或关闭连接。
如果写入成功,继续保持连接,等待下一次事件;如果写入失败,关闭连接。
6.资源释放: 关闭epoll实例和监听套接字,释放分配的资源(如线程池和客户端数组)。
然后我们将这个网络套接字初始化加入到epoll注册表中,等待epoll_wait来读取。
epoll_wait( )是在一个无限循环中,循环遍历epoll注册表上的事件,注册表上的epoll事件有事件类型,有读事件和写事件还有异常事件,根据事件的类型来进行处理,一个客户端来了之后肯定首先是读事件,读完了交给线程处理完之后又变成了写事件,所以就通过改变网络套接字的事件类型来对应不同的处理。
2.是如何实现IO多路复用的?
创建epoll实例: 使用epoll_create
函数创建一个epoll实例。这个实例将用于管理多个socket连接。添加socket到epoll实例: 对于服务器端监听socket和每个客户端的连接socket,需要将其添加到epoll实例中进行监控。这是通过epoll_ctl
函数和EPOLL_CTL_ADD
操作完成的。设置socket为非阻塞模式: 使用非阻塞模式可以确保调用如accept
或read
时,如果没有数据可用或无法立即接受连接,调用将立即返回而不是挂起等待。等待事件: 使用epoll_wait
函数等待socket上的事件。这个函数会阻塞直到一个或多个监控的socket就绪(可以读取、写入或有异常)。处理事件: 当epoll_wait
返回时,它会提供一个事件列表,其中包含就绪的socket及其事件类型。服务器会遍历这个列表,根据每个socket的事件类型执行相应的操作,例如接受新连接、读取数据或写入数据。循环处理:服务器处理完事件后,会再次调用epoll_wait来等待更多事件,循环继续。 3.线程池怎么建立起来的
初始化线程池:
根据需要处理的任务量和系统资源,决定线程池的大小(即线程池中线程的数量)。
创建并启动这些线程。设置线程分离。(POSIX 线程(pthread)编程中,线程分离通过调用 pthread_detach
函数实现。分离的线程在结束时不需要其他线程对其进行回收(join),它会自动清理自己占用的系统资源。)
工作队列:
准备一个队列来存储待处理的任务
任务分配:
编写工作函数worker
:
当一个新的任务到来时,将其加入到工作队列的末尾。就是append函数
空闲的线程会从工作队列中提取任务并执行。 就是run函数;run
函数是线程池的工作循环,它持续检查工作队列,等待任务的到来。当有任务到来时(信号量m_queuestat
有值),它会取出任务并执行。
线程生命周期管理:
线程池通常还负责监视线程的生命周期,包括创建、工作、等待及销毁等状态。
总的来说,这段代码实现了一个典型的生产者-消费者模型,其中生产者将任务放入工作队列,消费者(即线程池中的线程)则从工作队列中取出任务并执行。使用信号量和互斥锁确保了对工作队列的同步访问。
4.为什么要用线程池
减少创建和销毁线程的开销: 频繁地创建和销毁线程是非常耗费资源的,尤其是在任务数量巨大的时候。线程池通过重用现有线程来避免这些开销。 提高响应速度: 当任务到达时,不需要等待线程的创建就可以立即执行,因为线程池中已经有可用的线程。 提高线程的可管理性: 线程池允许统一管理线程的分配、调度和监控,使得线程的使用更加高效和安全。 资源控制: 线程池允许限制系统中运行线程的数量,避免了过多线程导致的资源竞争和系统过载。 提高系统稳定性: 线程池通过对线程生命周期的管理,避免了线程泄露和资源耗尽的风险。5.请求队列中请求的插入与取出的过程
请求队列中请求的插入与取出是服务器处理并发请求的关键环节。在基于线程池的并发模型中,这个过程通常遵循以下步骤:
**插入过程(生产者):**append
接收请求:当一个新的连接请求到达时,服务器的主线程(或IO线程)会接收到这个请求。加锁:为了保证请求队列的线程安全,主线程在向队列添加请求之前会获取锁(互斥锁或其他同步机制)。检查队列容量:主线程会检查请求队列是否已满。如果队列已满,服务器可能会拒绝请求或者进行其他处理。插入请求:如果队列未满,主线程会将请求添加到请求队列的末尾。解锁:请求插入完成后,主线程会释放锁。通知:主线程可能会通知等待的工作线程(如果有的话),表示有新的请求可以处理了。**取出过程(消费者):**run函数
等待请求:工作线程从线程池中等待新的任务。如果请求队列为空,它们可能会处于阻塞状态。加锁:当工作线程准备从队列中取出请求时,它需要获取锁。取出请求:一旦获取到锁,工作线程会从请求队列的前端取出一个请求。解锁:取出请求后,工作线程会释放锁。处理请求:工作线程处理取出的请求。处理完成后,它可以返回线程池等待下一个任务。整个插入与取出的过程是被锁和同步机制所控制的,确保在高并发情况下服务器的稳定性和请求的正确处理。在实际实现中,通常会用到互斥锁、条件变量、信号量等同步原语来实现这些操作。
6.讲一下proactor模式
reactor模式是主线程只负责监听,proactor模式是主线程不止监听,还要负责处理数据、接收IO连接,工作线程只负责处理客户端请求。
主线程:运行main
函数,负责服务器初始化、监听网络、处理连接和事件分发。
工作线程:线程池中的线程,负责处理具体的客户端请求。
reactor模式中,主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话立即通知工作线程(逻辑单元 ),读写数据、接受新连接及处理客户请求均在工作线程中完成。通常由同步I/O实现。
proactor模式中,主线程和内核负责处理数据、接受新连接等I/O操作,工作线程仅负责业务逻辑,如处理客户请求。通常由异步I/O实现。
由于异步I/O并不成熟,实际中使用较少,这里将使用同步I/O模拟实现proactor模式。
7.如何用同步I/O模拟实现proactor模式
主线程往epoll内核事件表注册socket上的读就绪事件。主线程调用epoll_wait等待socket上有数据可读。当socket上有数据可读,epoll_wait通知主线程,主线程从socket循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列。睡眠在请求队列上某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件主线程调用epoll_wait等待socket可写。当socket上有数据可写,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果。同步IO指的是内核向用户态通知的是就绪事件,需要用户态自己设计代码来处理。而异步IO指的是内核向用户态应用通知的是完成事件,事件的处理是在内核中完成的,不需要用户态处理,此时是不用阻塞的。
8.讲一下tcp/ip通讯的代码过程
与 TCP/IP 通信相关的部分包括创建套接字、绑定地址和端口、监听连接、接受连接、以及读写数据的操作
9. 是怎么给客户端的请求回复的?
首先一开始时客户端的请求事件是EPOLLIN事件,表示要读事件,读事件会被加入到工作队列中等待线程去处理。
线程的处理结果就是先解析了客户端的请求数据,如果成功地获得了一个完整的客户请求,就创造一个内存映射并把数据放到了缓冲区中,根据解析的请求状况返回解析状态(即服务器处理HTTP请求的结果)。
根据服务器处理HTTP请求的结果,决定返回给客户端的内容。然后将双方建立连接的HTTP套接字改状态为EPOLLOUT,然后等待write回客户端,发送请求的结果是通过 temp = writev(m_sockfd, m_iv, m_iv_count);实现的,m_sockfd就是双方用于通信的套接字。
writev()
函数,作用是将多个缓冲区中的数据一次性写入到套接字中。writev()
函数的第一个参数是套接字的文件描述符 m_sockfd
,第二个参数是指向 iovec
结构体数组的指针 m_iv
,第三个参数是 iovec
结构体数组的元素个数 m_iv_count
。
writev()
函数会将 m_iv
数组中所有缓冲区的数据依次写入到套接字中,返回值是成功写入的字节数。如果返回值小于要求写入的总字节数,说明写入操作未完成,需要继续写入剩余的数据。
10.ET模式和IT模式
区别如下:
通知频率:LT 模式会不断通知,而 ET 模式仅在状态变化时通知一次。处理方式:在 ET 模式下,应用程序需要一次性处理所有事件,而在 LT 模式下可以分批处理。效率和复杂性:ET 模式通常更高效,但处理逻辑更复杂;LT 模式相对简单,但可能效率稍低。选择依据:应根据应用程序的特点和处理方式来选择合适的模式。ET 模式适合流量大、数据处理快的场景。具体解释:
水平触发(level triggered, LT)
同时支持 block 和 non-block socket在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的 fd 进行 IO 操作。如果你不作任何操作,内核还是会继续通知你的边沿触发(edge triggered, ET)
是高速工作方式,只支持 non-block socket,需要对监听文件描述符设置才能实现在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个 fd 作 IO 操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)区别与说明
ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高
epoll工作在 ET 模式的时候,必须使用非阻塞套接字,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死
所以如果使用ET且缓冲区内容不能一次性读完,需要写一个循环将内容全部读取,且需要将套接字设置为非阻塞
说明:假设委托内核检测读事件,即检测fd的读缓冲区,那么如果读缓冲区有数据 ,epoll检测到了会给用户通知
LT 用户不读数据,数据一直在缓冲区,epoll 会一直通知用户只读了一部分数据,epoll会通知缓冲区的数据读完了,不通知 ET 用户不读数据,数据一致在缓冲区中,epoll下次检测的时候就不通知了用户只读了一部分数据,epoll不通知缓冲区的数据读完了,不通知11.线程同步的问题怎么解决
用信号量和互斥锁
信号量的使用原理如下:
在threadpool
类中,m_queuestat
信号量用于表示工作队列中待处理任务的数量。当队列中添加了一个任务时,信号量增加(sem_post
),表示有任务可供线程处理。当线程准备从队列中取出任务时,它会执行等待操作(sem_wait
),信号量减少。如果信号量值为零,线程将阻塞,直到有新的任务加入队列等待新任务被加入队列: 信号量可能用于控制任务队列的状态。当队列为空(没有新任务)时,工作线程会被阻塞,直到新任务被加入队列,这时信号量的值会增加,从而唤醒等待的线程。 三. 其他问题
有没有做压力测试?怎么做的
你觉得现在一般大厂使用的支持百万千万并发的服务器与你这个项目是一个概念吗?
大厂支持百万、千万级别并发的服务器与你这个项目在核心概念和基础技术上是相同的,但在规模、复杂性、优化、容错、监控和安全等方面有着本质的差异。核心概念,如非阻塞I/O、I/O多路复用、线程池等,在高性能服务器中都是常见的。
但是,大型互联网公司的服务器通常会具有以下特点:
高度优化:在内核级别或使用更加底层的语言(如C或C++)进行优化,以减少每个请求的开销。负载均衡:使用负载均衡器分配请求到多个服务器实例,以实现水平扩展。高可用性:使用故障转移、热备份等技术来确保服务的持续可用性。容错和冗余:在软件和硬件级别上实现冗余,以应对部分系统故障。分布式系统设计:将服务分布在多个地理位置,不仅能够承受大规模并发请求,还能抵御单点故障和网络延迟。监控和日志:实施细粒度的监控和日志记录,以便及时发现问题并进行故障排除。安全措施:实施全面的安全策略,包括防火墙、DDoS保护、TLS/SSL加密等。微服务架构:将大型应用拆分成互相独立、单一职责的微服务,以便更容易扩展和维护。 因此,虽然基本的技术构建块相似,但是为了达到百万、千万级别的并发,还需要考虑许多其他因素和 技术。这个项目可以作为学习和入门的良好基础,但要达到商业级别的服务标准,还需要更多的学习和 实践。