第八章 高性能服务器程序框架
第八章 高性能服务器程序框架(后续章节的总览)
1.一些概念性的东西:I/O处理单元与逻辑单元、C/S与P2P
I/O处理单元与逻辑单元:
- I/O处理单元:接受客户的请求,并启动逻辑单元对客户发送的信息进行处理。常见的I/O处理单元为select、poll和epoll。
- 逻辑单元:逻辑单元可能是一个线程、进程或其他,用于处理客户发送的请求。
C/S与P2P:
- C/S模型非常适合资源相对集中的场合,并且它的实现也很简单,但其缺点也很明显:服务器是通信的中心,当访问量过大时,可能所有客户都将得到很慢的响应。
- P2P模型可以看作C/S模型的扩展:每台主机既是客户端,又是服务器。
服务器编程框架:I/O处理单元、请求队列、逻辑单元、网络存储单元
- I/O处理单元:
单个服务器程序:等待并接受新的客户连接。也有可能接收客户数据,将服务器响应数据返回给客户端
服务器机群:它实现负载均衡,从所有逻辑服务器中选取负荷最小的一台来为新客户服务。 - 逻辑单元:
单个服务器程序:它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端。一个逻辑单元通常是一个进程或线程
服务器机群:一个逻辑单元本身就是一台逻辑服务器。 - 网络存储单元:可以是数据库、缓存和文件,甚至是一台独立的服务器。但它不是必须的。
- 请求队列:请求放在一个队列中,通过逻辑单元来处理队列中的请求。
2.阻塞I/O和非阻塞I/O:修改阻塞为非阻塞、I/O通知机制、C++异步IO
阻塞I/O和非阻塞I/O:
- 阻塞与非阻塞的含义:系统调用可能因为无法立即完成而被操作系统挂起,直到等待的事件发生为止。如调用recv后,需要等待对方send的数据送达。非阻塞I/O中,调用recv后,程序会继续执行下一条命令,在对方send的数据送达时,程序会接收到相应通知(通知一般是内核发送给程序的),并调用相应的处理函数。socket的基础API中,可能被阻塞的系统调用包括accept、send、recv和connect。
- 修改阻塞为非阻塞:socket在创建的时候默认是阻塞的。我们可以给socket系统调用的第2个参数传递SOCK_NONBLOCK标志,或者通过fcntl系统调用的F_SETFL命令,将其设置为非阻塞的。
- I/O通知机制:当套接字可读或可写时,发送特定消息,我们可以根据此消息,调用read或write函数。常见的I/O通知机制如下:
1.IO复用技术:select、poll和epoll。I/O复用函数本身是阻塞的,它们能提高程序效率的原因在于它们具有同时监听多个I/O事件的能力,当监听到相应的事件就给相应的程序发送相应的通知。
2.SIGIO信号可以实现非阻塞I/O:如数据送达时,程序会接收到SIGIO信号,SIGIO信号的信号处理函数将被触发,只要在信号处理函数中调用read函数就可以读取到来的数据。
套接字设为非阻塞的好处举例:
- 对于等待客户请求的套接字sockfd来说,可以使用epoll监听sockfd,当有新连接来时,epoll_wait()被触发,然后调用accept获取客户连接。如果在epoll_wait()被触发并且还未accept时,客户连接断开,非阻塞的sockfd不会阻塞在accept处。
- 对于与客户连接的套接字connfd来说,也有上述类似的好处。
- 如果connfd设置为ET模式时,connfd必须设为非阻塞。ET模式时,需要通过循环将数据一次全部读出来,所以我们必须知道什么时候数据读完了,这就需要设置文件描述符为非阻塞。因为文件描述符设置为阻塞时,即使数据读取完成也不会发出任何信息,而是卡在read处。
非阻塞IO返回的errno:
- 非阻塞IO中,对accept、send和recv而言,事件未发生时errno通常被设置成EAGAIN或者EWOULDBLOCK;对connect而言,errno则被设置成EINPROGRESS(意为“在处理中”)。
- errno为EAGAIN和errno为EWOULDBLOCK的区别:如在非阻塞套接字中,当套接字上没有可读数据时,不会阻塞在read函数处,read函数会返回一个错误EAGAIN,代表此时没有数据可读。一般还会使用epoll监听此套接字,当有数据可读的时候,重新调用read函数。在VxWorks和Windows上,EAGAIN的名字叫做EWOULDBLOCK,所以程序一般写为
errno == EAGAIN || errno == EWOULDBLOCK
。
C++异步IO :在异步IO中,首先设置好文件需要读取到哪个变量中,然后文件读取到此变量中的过程都是在内核中操作的,用户程序并不会因为读文件操作而阻塞。当文件读取完毕时,通过特定的方式通知用户程序,比如发送信号SIGIO通知用户程序文件已经读取完毕了。我们可以给信号SIGIO设置回调函数,在回调函数中将获取到的文件信息打印出来看看,注意这里只是将获取的到信息打印出来,并不是从文件中读取信息,读取信息早就在内核中进行过了。你可以这样认为,同步I/O向应用程序通知的是I/O就绪事件,而异步I/O向应用程序通知的是I/O完成事件。
3.读写操作是否发生在主线程和内核中:Reactor模式和Proactor模式
两种高效的事件处理模式:Reactor和Proactor。同步I/O模型通常用于实现Reactor模式,异步I/O模型则用于实现Proactor模式。不过,也可以使用同步I/O方式模拟出Proactor模式。
- Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述上是否有事件发生,主线程不负责对文件描述符上数据的读写,有的话就立即将该事件通知工作线程(逻辑单元)。如epoll_wait——注册事件,事件发生后通知主线程,主线程将已经发生的事件放入请求队列,并唤醒请求队列上的某个线程去处理这个事件。
- Proactor模式:与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑。以aio_read和aio_write为例——注册事件并告诉内核读(写)缓冲区的位置,当socket上的数据被读入用户缓冲区后 或 当用户缓冲区的数据被写入socket之后,向应用程序发送一个信号,以通知应用程序数据处理。Proactor模式就是使用异步IO,将数据的读/写操作都放在内核中运行,当读/写完毕以后,通知工作线程走后续处理(工作线程不做读/写操作)。
- 同步I/O方式模拟出Proactor模式:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一“完成事件”。
综上,Reactor模式和Proactor模式的区别在于:文件读写是否在主线程和内核中。
4.主线程与工作线程之间的工作方式(并发模式)
服务器的两种高效的并发模式:半同步/半异步(half-sync/half-async)模式和领导者/追随者(Leader/Followers)模式。对异步和同步的说明:在I/O模型中,“同步”和“异 步”区分的是该由谁来完成I/O读写(是应用程序还是内核),在并发模式中的同步和异步和I/O模型中所说的不同,如下:
- 同步:指的是程序完全按照代码序列的顺序执行;
- 异步:程序顺序执行的过程中,可能有有各种事件触发各种函数的执行。
4.1 半同步/半异步模式
半同步/半异步模式:结合同步和异步。异步线程监听客户请求、将请求插入请求队列中。请求队列将通知某个工作在同步模式的工作线程来读取并处理该请求对象。
1.半同步/半异步模式的变种——半同步/半反应堆(half-sync/half-reactive)模式:主线程(异步线程)接收客户连接并监听连接上的读写事件。当客户连接上有读写事件发生时,主线程将此连接放到请求队列中,然后多个工作线程从请求队列中获取待处理连接。如下图:
半同步/半反应堆模式存在如下缺点:
- 需要对请求队列加锁,浪费cpu资源。主线程往请求队列中添加任务,或者工作线程从请求队列中取出任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。
- 每个工作线程在同一时间只能处理一个客户请求。如果客户数量较多,而工作线程较少,则请求队列中将堆积很多任务对象,客户端的响应速度将越来越慢。如果通过增加工作线程来解决这一问题,则工作线程的切换也将耗费大量CPU时间。
2.高效的半同步/半异步模式,如下图:
图8-11中,主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受之并将新返回的连接socket派发给某个工作线程,此后该新socket上的任何I/O操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据(我觉得就是将新的客户连接对应的套接字(int类型的数)写入管道就行了)。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epoll内核事件表中。
4.2 领导者/追随者模式
领导者/追随者模式:程序都仅有一个领导者线程,它负责监听I/O事件。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现了并发。领导者检测到I/O事件之后,也可以保持领导者的地位不变,指定其他追随者来处理事件。
- 优点:领导者/追随者模式还可以用于提高系统的容错性和可扩展性。如果领导者线程发生故障或崩溃,其他线程可以立即接管其任务,保证系统的正常运行。另外,通过动态调整领导者线程的数量,系统可以自动适应负载和性能需求的变化,从而提高了系统的可扩展性。
- 缺点:领导者/追随者的一个明显缺点是仅支持一个事件源集合,因此也无法让每个工作线程独立地管理多个客户连接
- 需要注意的是,在并发编程中使用领导者/追随者模式需要注意线程安全和同步问题,确保多线程之间的数据访问正确和有序,避免出现竞态条件和死锁等问题。
5.有限状态机
有限状态机(finite state machine):根据函数返回东西的不同,执行不同的代码,常使用switch case
进行实现。这里的状态指的就是函数返回东西,即函数返回不同的东西代表不同的状态。
6.提高服务器性能的其他建议
池:从池中获取硬件资源,池中的资源是预先静态分配的,如果资源不够时,就再动态分配一些并加入池中。池可分为多种,常见的有内存池、进程池、线程池和连接池
-
内存池:用于socket的接收缓存和发送缓存
-
进程池和线程池:用于并发编程。拿一个进程或线程出来处理新来到的客户请求
-
连接池:很多建好的与数据库的连接,只需要从连接池中取出连接,就可以访问数据库。
数据复制
-
当应用程序不关心数据内容时,可以直接用零拷贝。如ftp服务器就无须把目标文件的内容完整地读入到应用程序缓冲区中并调用send函数来发送,而是可以使用“零拷贝”函数sendfile来直接将其发送给客户端。sendfile函数首先会将文件读取到内核中,然后直接从内核发送出去,并没有经过应用程序缓冲区。
【注】零拷贝:数据完全在内核中操作,从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,这种数据传递也叫零拷贝。 -
当两个工作进程之间要传递大量的数据时,使用共享内存来在它们之间直接共享这些数据,而不是使用管道或者消息队列来传递。当所有工作线程都只读取一块共享内存的内容时,不需要锁住这块区域。只有当其中某一个工作线程需要写这块内存时,系统才必须去锁住这块区域。
上下文切换和锁
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?