面经:服务器相关

阻塞IO

当你去读一个阻塞的文件描述符时,如果在该文件描述符上没有数据可读,那么它会一直阻塞(通俗一点就是一直卡在调用函数那里),直到有数据可读。当你去写一个阻塞的文件描述符时,如果在该文件描述符上没有空间(通常是缓冲区)可写,那么它会一直阻塞,直到有空间可写。以上的读和写我们统一指在某个文件描述符进行的操作,不单单指真正的读数据,写数据,还包括接收连接accept(),发起连接connect()等操作…

非阻塞IO

当你去读写一个非阻塞的文件描述符时,不管可不可以读写,它都会立即返回,返回成功说明读写操作完成了,返回失败会设置相应errno状态码,根据这个errno可以进一步执行其他处理。它不会像阻塞IO那样,卡在那里不动!!!

Level_triggered(水平触发)

当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小),那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你!!!如果系统中有大量你不需要读写的就绪文件描述符,而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!!!

LT的缺点

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

方法1:每次要写数据时,将fd绑定EPOLLOUT事件,写完后将fd同EPOLLOUT从epoll中移除。

方法2:方法一中每次写数据都要操作epoll。如果数据量很少,socket很容易将数据发送出去。可以考虑改成:数据量很少时直接send,数据量很多时在采用方法1
为什么ET模式下一定要设置非阻塞?

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

理论上是等于 客户端IP数✖客户端的端口数,即2的32次方✖2的16次方即2的48次方,但是实际上受到文件描述符数量限制和内存空间限制。

Edge_triggered(边缘触发)

当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小),那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你!!!这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符!!!
所以ET所以循环处理,保证能将数据读取完毕,即同时要保证非阻塞IO,不然最后会被阻塞

Reactor

主线程往epoll内核上注册socket读事件,主线程调用epoll_wait等待socket上有数据可读,当socket上有数据可读的时候,主线程把socket可读事件放入请求队列。睡眠在请求队列上的某个工作线程被唤醒,处理客户请求,然后往epoll内核上注册socket写请求事件。主线程调用epoll_wait等待写请求事件,当有事件可写的时候,主线程把socket可写事件放入请求队列。睡眠在请求队列上的工作线程被唤醒,处理客户请求。

Proactor

主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读完成后如何通知应用程序,主线程继续处理其他逻辑,当socket上的数据被读入用户缓冲区后,通过信号告知应用程序数据已经可以使用。应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后调用aio_write函数向内核注册socket写完成事件,并告诉内核写缓冲区的位置,以及写完成时如何通知应用程序。主线程处理其他逻辑。当用户缓存区的数据被写入socket之后内核向应用程序发送一个信号,以通知应用程序数据已经发送完毕。应用程序预先定义的数据处理函数就会完成工作。

reactor模式和Proactor模式对比

reactor模式:同步阻塞I/O模式,注册对应读写事件处理器,等待事件发生进而调用事件处理器处理事件。 proactor模式:异步I/O模式。

Reactor和Proactor模式的主要区别就是真正的读取和写入操作是有谁来完成的,Reactor中需要应用程序自己读取或者写入数据,Proactor模式中,应用程序不需要进行实际读写过程。

Reactor:非阻塞同步网络模型,可以理解为:来了事件我通知你,你来处理

Proactor:异步网络模型,可以理解为:来了事件我来处理,处理完了我通知你。

理论上:Proactor比Reactor效率要高一些。

webserver相关

并发模型

面向对象的Reactor模型,和面向过程的程序不同,面向对象的程序耦合度更低,可以将每一个行为分离开来,形成一个个事物,如果某一个事物阻塞了或者出现了异常可以及时切换到其他事物中,不会阻塞住整个系统,muduo网络库,Redis和Nginx都使用了Reactor模式。通过多线程或者多进程技术,提高了系统的效率。本系统采用多线程提高并发度,因为多线程粒度更小切换的消耗更低,性能更好。使用线程池是为避免线程频繁创建和销毁带来的开销,在程序的开始创建固定数量的线程,线程数量和计算机内存的数量保持一致可以有较好的CPU利用率,通常IO多的程序开更多的线程,CPU操作多的程序开更少的线程。使用Epoll作为IO多路复用的实现方式,为了调试方便在mac使用了Kqueue作为实现方式。
一个主Reactor主要用来处理accept的连接并负责分配client的连接请求给副Reactor,它是一种面向对象的IO复用模式。在建立连接后用轮询的方式分配给工作线程,因为涉及到多线程的任务分配会有竞争问题,可以使用eventfd或者条件变量实现异步唤醒工作线程,线程会从Epoll_wait中醒来,获取从主线程中获取活跃连接进行处理,主线程会删除这个连接,这也类似于生产者消费者模型,主线程处理新的连接,等第二次来数据的连接时唤醒消费者线程处理,本系统这里的互斥锁由某个特定线程中loop创建,不会出现惊群的情况只会被该线程和主线程中使用。

定时任务功能实现

服务器程序通常管理着众多定时事件,因此有效地组织这些定时事件,使之能在预期的时间点被触发且不影响服务器的主要逻辑,对于服务器的性能有着至关重要的影响。为此,我们要将每个定时事件分别封装成定时器,并使用某种容器类数据结构,比如链表、排序链表和时间轮,将所有定时器串联起来,以实现对定时事件的统一管理。本项目是为了方便释放那些超时的非活动连接,关闭被占用的文件描述符,才使用定时器。
每个副Reactor持有一个定时器,用于处理超时请求和长时间不活跃的连接。定时器可以使用时间轮,红黑树,双向链表和小根堆实现,我使用了使用了C++标准库中的priority_queue优先队列,它底层是堆,通过惰性删除的方法提高效率,因为在时间片到期的时候并不会马上删除超时节点,而是每次连接通信结束的时候循环的检查,会将超时的节点全部删除,由于小根堆的特点越早超时的节点越在堆的上方,检查时间队列的间隔和频率减少了,效率就会提高。

Epoll边缘模式

Epoll的触发模式在这里我选择了ET模式,muduo使用的是LT,这两者IO处理上有很大的不同。ET模式要比LE复杂许多,它对用户提出了更高的要求,即每次读,必须读到不能再读直到出现EAGAIN,每次写,写到不能再写知道出现EAGAIN。而LT则简单的多,可以选择也这样做,也可以为编程方便,比如每次只read一次,muduo就是这样做的,这样可以减少系统调用次数。

线程池模块

线程池是使用了已经创建好的线程进行循环处理任务,避免了大量线程的频繁创建与销毁的成本。
实现思想:利用生产者消费者队列,创建多个线程并全部初始化去运行,通过条件变量判断队列中是否有任务,没有任务就等待,当有任务时就可以通过条件变量来唤醒阻塞中的线程去处理这个任务。
代码实现:类主要有两个类,一个类是任务类,一个类是线程池类,其中任务类有两个成员变量,一个是数据和处理数据方式的一个函数指针,成员函数有两个,一个是用于接收任务和数据的处理方式的函数,一个是run函数,其执行这个处理数据的函数。线程池类中的成员变量主要有线程池的最大数量、一个缓冲队列、一个互斥锁,用来保护对队列的操作、一个条件变量,用于实现线程池中线程的同步。threadpool_create是线程的的入口函数,每个线程都在一个死循环里等待任务,当有任务到来,就可以获取队列中的任务对象,然后去执行任务对象中的回调函数来处理数据。

核心模块

Channel类:Channel和一个 EventLoop绑定是一种事物,在Channel类管理一个fd文件描述符,存储这个事件的数据类型event以及对应的函数,当事件活跃的时候会调用到之前保存在类中的函数。因此,程序中所有带有读写时间的对象都会和一个Channel关联,包括loop中的eventfd,listenfd,HttpData等。
EventLoop:One loop per thread意味着每个线程只能有一个EventLoop对象,EventLoop即是时间循环,每次从poller里拿活跃事件,并给到Channel里分发处理。EventLoop中的loop函数会在最底层Thread中被真正调用,开始无限的循环,直到某一轮的检查到退出状态后从底层一层一层的退出。

建立连接

建立连接的时候服务器端首先使用socket()创建套接字,其次使用bind()将如IPv4绑定到套接字,最后调用listen()监听连接,使用Epoll IO复用的ET边缘模式监听listenfd的读请求,数据的通信已经由操作系统帮我们完成了,这里的通信是指3次握手的过程,这个过程不需要应用程序参与,当应用程序感知到连接时,此时该连接已经完成了3次握手的过程,accept()会在收到最后ACK文件后放回。另一个原因是一般情况下,连接是客户端主动发起,服务器端被动连接,也不会出现同时建立的情况。检查这个连接的fd描述符,如果是第一次建立连接则会讲这个描述符加入Epoll的红黑数中。第二次同一个连接活跃的时候就可以讲这个fd加入Epoll的就绪队列中,使用ET模式会比LT电平模式麻烦,与LT不同的是,LT模式当有活跃事物的时候会不停的触发提醒直到处理完这个事物,而ET模式的活跃事物只会提醒一次,因此开发的时候要无限循环读直到就绪连接为空。
假设server只监听一个端口,一个连接就是一个四元组原ip,原port,对端ip,对端port,那么理论上可以建立2^48个连接,可是,fd可没有这么多这是由于操作系统限制和用户进程限制实际上主要首先于内存,可以通过Linux中的终端命令ulimit临时修改fd数量,通过vim /etc/security/limits.conf文件中修改永久的fd数量。为了避免连接满了无法处理新的连接,新的连接会阻塞在connect()上,防止空等,在第一次客户端发起半连接包后就不在处理的DOS攻击,避免就绪队列满了后会导致新连接无法建立。本系统采取的方案是参考muduo网络库,准备一个空的文件描述符,限制最大连接数,不要在连接满了才进行处理,达到一定数量的连接后,accept()阻塞返回后直接close(),这样对端不会收到RST,客户端可以知道服务器还存活着而不是宕机。

优雅关闭

通常server和client都可以主动发Fin来关闭连接,这是TCP的特点全双工连接。对于client(非Keep-Alive),发送完请求后就可以半关闭写端,这个时候就是告诉客户端服务器端不在写数据但是可以继续读数据,这也是符合TCP协议的逻辑,然后收到server发来的应答后读空read(),最后关闭连接。也可以不使用shutdown()半关闭写端,等读完直接close()。对于Keep-Alive长连接的情况,需要观察客户端的行为,服务器端应该保证不主动断开主动权掌握在客户端手里。

共享内存

使用mmap加速内核与用户空间的消息传递。无论是select,poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存拷贝就很重要,在这点上,epoll是通过内核于用户空间mmap同一块内存实现的。(mmap 内存共享 少一份拷贝吧)

epoll开发相关

1、单个epoll并不能解决所有问题,特别是你的每个操作都比较费时的时候,因为epoll是串行处理的。 所以你有还是必要建立线程池来发挥更大的效能。
2、如果fd被注册到两个epoll中时,如果有时间发生则两个epoll都会触发事件。
3、如果注册到epoll中的fd被关闭,则其会自动被清除出epoll监听列表。
4、如果多个事件同时触发epoll,则多个事件会被联合在一起返回。
5、epoll_wait会一直监听epollhup事件发生,所以其不需要添加到events中。
6、为了避免大数据量io时,et模式下只处理一个fd,其他fd被饿死的情况发生。linux建议可以在fd联系到的结构中增加ready位,然后epoll_wait触发事件之后仅将其置位为ready模式,然后在下边轮询ready fd列表
ET模式仅当状态发生变化的时候才获得通知,这里所谓的状态的变化并不包括缓冲区中还有未处理的数据,也就是说,如果要采用ET模式,需要一直read/write直到出错为止,很多人反映为什么采用ET模式只接收了一部分数据就再也得不到通知了,大多因为这样;而LT模式是只要有数据没有处理就会一直通知下去的.

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值