牛客WebServer项目实战

本章内容,为牛客上WebServer项目的相关知识点,该部分内容也是与本项目TinyWebServer息息相关的


一、知识点

1.异步IO

异步 I/O(输入/输出)是一种允许程序在等待 I/O 操作完成的同时继续执行其他任务的技术。这种方式提高了程序的效率和响应性,尤其是在涉及大量或慢速 I/O 操作时(如网络通信、大文件操作等)

工作原理

在同步 I/O 操作中,应用程序发出 I/O 请求后必须等待操作完成才能继续执行。这种方式可能导致程序在等待期间资源被闲置。相比之下,异步 I/O 允许应用程序在发出 I/O 请求后立即继续执行。当 I/O 操作完成时,程序通过回调函数、事件、信号或轮询等机制得到通知

image-20240517220929906


(1)IO多路复用是同步的

同步与异步

  • 同步I/O(Synchronous I/O):程序执行操作时会阻塞,直到I/O完成。同步I/O的例子包括常规的读写操作,直到数据被实际读取或写入,调用才返回。
  • 异步I/O(Asynchronous I/O):程序执行操作时不会阻塞,I/O操作在后台进行,程序可以继续执行其他任务。当I/O操作完成时,程序会以某种方式(如回调、事件、信号)被通知。

IO多路复用的性质

  • 同步性:尽管IO多路复用允许一个线程同时监视多个文件描述符,但当调用如select()poll()epoll_wait()等函数时,线程会阻塞在这些函数调用上,直到至少有一个文件描述符就绪(可读、可写或出现异常)。因此,从这个角度来看,IO多路复用是同步的,因为它会等待一个或多个I/O操作的就绪信号。
  • 非阻塞性:一旦IO多路复用函数指示某些文件描述符已就绪,对这些文件描述符的实际I/O操作(如读写)通常是非阻塞性的。这意味着这些操作应该立即完成,不会导致应用程序代码阻塞。

总结

IO多路复用本质上是同步的,因为它在执行期间会阻塞应用程序,但它提供了一种高效的方式来同时监视多个I/O流的状态,使得一旦有I/O流就绪,就可以立即进行非阻塞操作。这种模式特别适用于需要高效处理大量并发连接的服务器应用程序。



2.UNIX/Linux上五种IO模型

(1)阻塞IO (blocking)

调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的去检查这个函数有没有返回,必须等这个函数返回才能进行下一步动作。IO操作,是对某一个文件描述符进行操作,因此可以将文件描述符的属性设置为非阻塞。其流程图如下:

当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回

阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程

image-20240519093427103

read函数是一个系统调用,当内核数据没有准备好时,线程被阻塞,无法执行别的操作,需要等到数据就绪,当数据就绪,又需要将数据从内核拷贝到用于空间(应用程序自己去执行,因此是一个read同步的IO操作),然后返回,应用处理的数据


(2)非阻塞IO (non-blocking) NIO

非阻塞等待,每隔一段时间就去检测IO事件是否就绪(通过代码不断的轮询检测,IO接口数据是否就绪)。没有就绪就可以做其他事非阻塞I/O执行系统调用总是立即返回,不管事件是否已经发生,若事件没有发生,则返回-1,此时可以根据 errno 区分这两种情况,对于acceptrecv send,事件未发生时,errno 通常被设置成 EAGAIN

image-20240519094145823

image-20240520095947379

注意这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程

可以通过fcntl()函数设置文件描述符的属性,可以将其设置为非阻塞IO

当系统调用的 read 函数返回 -1 并且 errno 设置为 EAGAIN这表明非阻塞读取操作在没有数据可读的情况下被调用EAGAIN 是一个特殊的错误码,表示目前没有数据可供读取,但这并不是一个真正的失败,而是一个信号,告诉你现在没有数据可读

对于非阻塞 I/O(输入/输出)操作,当没有数据可读或者无法立即执行读操作时,read 函数会返回 EAGAIN。这让应用程序有机会继续执行其他任务,而不是停在那里等待数据。处理 EAGAIN 的一个常见方法是稍后重试读取操作,或者使用某种形式的事件通知机制(如 select()poll()epoll())来等待数据变得可用


(3)IO复用 (IO multiplexing)

Linux 用 select/poll/epoll 函数实现 IO 复用模型,这些函数也会使进程阻塞,但是和(单进程/单线程下的)阻塞IO所不同的是这些函数可以同时阻塞多个IO操作。而且可以同时对多个读操作、写操作的IO函数进行检测。直到有数据可读或可写时(只要有一个IO就绪),才真正调用IO操作函数

image-20240519095105713


(4)信号驱动 (signal-driven)

Linux 用套接口进行信号驱动 IO,安装一个信号处理函数,进程继续运行并不阻塞,当IO事件就绪,进程收到 SIGIO 信号,然后处理 IO 事件

image-20240519095433306

内核在第一个阶段是异步,在第二个阶段是同步;与非阻塞IO的区别在于它提供了消息通知机制,不需要用户进程不断的轮询检查,减少了系统API的调用次数,提高了效率


(5)异步 (asynchronous)

异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待

Linux中,可以调用 aio_read 函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回,当内核将数据拷贝到缓冲区后,再通知应用程序

当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。过程如下图:

image-20240519095803402

内核数据没有准备好时,系统调用立即返回之后,应用程序可以继续执行其他操作。当数据准备好,内核会帮助应用程序将数据从内核空间拷贝到用户空间(不需要应用程序自己拷贝,因此这个阶段为异步操作),当拷贝完成,会通过异步io函数注册的通知方式,告知应用程序去处理数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* Asynchronous I/O control block. */
struct aiocb
{
int aio_fildes; /* File desriptor. */
int aio_lio_opcode; /* Operation to be performed. */
int aio_reqprio; /* Request priority offset. */
volatile void *aio_buf; /* Location of buffer. */
size_t aio_nbytes; /* Length of transfer. */
struct sigevent aio_sigevent; /* Signal number and value. */
/* Internal members. */
struct aiocb *__next_prio;
int __abs_prio;
int __policy;
int __error_code;
__ssize_t __return_value;
#ifndef __USE_FILE_OFFSET64
__off_t aio_offset; /* File offset. */
char __pad[sizeof (__off64_t) - sizeof (__off_t)];
#else
__off64_t aio_offset; /* File offset. */
#endif
char __glibc_reserved[32];
};


3.web Server (网页服务器)

一个 Web Server 就是一个服务器软件(程序),或者是运行这个服务器软件的硬件(计算机)。其主要功能是通过 HTTP 协议与客户端(通常是浏览器(Browser))进行通信,来接收,存储,处理来自客户端的 HTTP 请求,并对其请求做出 HTTP 响应,返回给客户端其请求的内容(文件、网页等)或返回一个 Error 信息 (B/S网络结构模式)

image-20240519100702945

通常用户使用 Web 浏览器与相应服务器进行通信。在浏览器中键入“域名”或“IP地址:端口号”,浏览器则先将你的域名解析成相应的 IP 地址或者直接根据你的IP地址向对应的 Web 服务器发送一个 HTTP 请求。这一过程首先要通过 TCP 协议的三次握手建立与目标 Web 服务器的连接(**HTTP协议底层是通过TCP协议去通讯的**),然后HTTP协议生成针对目标 Web 服务器的HTTP请求报文,通过 TCPIP 等协议发送到目标 Web 服务器上



4.HTTP协议 (应用层协议)

超文本传输协议(Hypertext Transfer Protocol,HTTP)是一个简单的请求 - 响应协议,它通常运行在TCP 之上。它指定了客户端可能发送给服务器什么样的消息以及得到什么样的响应。请求和响应消息的头以 ASCII 形式给出;而消息内容则具有一个类似 MIME 的格式。HTTP是万维网的数据通信的基础

HTTP的发展是由蒂姆·伯纳斯-李于1989年在欧洲核子研究组织(CERN)所发起。HTTP的标准制定由万维网协会(World Wide Web Consortium,W3C)和互联网工程任务组(Internet Engineering TaskForce,IETF)进行协调,最终发布了一系列的RFC,其中最著名的是1999年6月公布的 RFC 2616,定义了HTTP协议中现今广泛使用的一个版本——HTTP 1.1

概述

HTTP 是一个客户端终端(用户)和服务器端(网站)请求和应答的标准(TCP)。通过使用网页浏览器、网络爬虫或者其它的工具,客户端发起一个HTTP请求到服务器上指定端口(默认端口为80)。我们称这个客户端为用户代理程序(user agent)。应答的服务器上存储着一些资源,比如 HTML 文件和图像。我们称这个应答服务器为源服务器(origin server)。在用户代理和源服务器中间可能存在多个“中间层”,比如代理服务器、网关或者隧道(tunnel

尽管 TCP/IP 协议是互联网上最流行的应用,HTTP 协议中,并没有规定必须使用它或它支持的层。事实上,HTTP可以在任何互联网协议上,或其他网络上实现。HTTP 假定其下层协议提供可靠的传输。因此,任何能够提供这种保证的协议都可以被其使用。因此也就是其在 TCP/IP 协议族使用 TCP 作为其传输层

通常,由HTTP客户端发起一个请求,创建一个到服务器指定端口(默认是80端口)的TCP连接。HTTP服务器则在那个端口监听客户端的请求。一旦收到请求,服务器会向客户端返回一个状态,比如”HTTP/1.1 200 OK“,以及返回的内容,如请求的文件、错误消息、或者其它信息

**工作原理(重点)**:

HTTP 协议定义Web客户端如何从 Web 服务器请求 Web 页面,以及服务器如何把Web 页面传送给客户端。HTTP 协议采用了请求/响应模型客户端向服务器发送一个请求报文,请求报文包含请求的方法、URL、协议版本、请求头部和请求数据。服务器以一个状态行作为响应,响应的内容包括协议的版本、成功或者错误代码、服务器信息、响应头部和响应数据


(1)HTTP请求/响应的步骤
  1. 客户端连接到web服务器

    • 一个HTTP客户端,通常是浏览器,与 Web 服务器的 HTTP 端口(默认为80)建立一个 TCP 套接字连接。例如,http://www.baidu.com。(URL)
  2. 发送HTTP请求

    • 通过 TCP 套接字,客户端向 Web 服务器发送一个文本的请求报文,一个请求报文由请求行、请求头部、空行和请求数据 4 部分组成
  3. 服务器接受请求并返回HTTP响应

    • Web 服务器解析请求,定位请求资源。服务器将资源复本写到 TCP 套接字,由客户端读取。一个响应由状态行、响应头部、空行和响应数据 4 部分组成
  4. 释放连接TCP连接

    • connection模式为 close,则服务器主动关闭 TCP连接,客户端被动关闭连接,释放TCP连接;若connection 模式为 keepalive,则该连接会保持一段时间,在该时间内可以继续接收请求
  5. 客户端浏览器解析HTML内容

    • 客户端浏览器首先解析状态行,查看表明请求是否成功的状态代码。然后解析每一个响应头,响应

      头告知以下为若干字节的 HTML文档和文档的字符集。客户端浏览器读取响应数据 HTML,根据

      HTML 的语法对其进行格式化,并在浏览器窗口中显示

例如:在浏览器地址栏键入URL,按下回车之后会经历以下流程

  1. 浏览器向DNS服务器请求解析该URL中域名所对应的IP地址
  2. 解析出 IP 地址后,根据该IP地址和默认端口 80,和服务器建立 TCP 连接
  3. 浏览器发出读取文件( URL 中域名后面部分对应的文件)的 HTTP 请求,该请求报文作为 TCP 三次握手的第三个报文的数据发送给服务器
  4. 服务器对浏览器请求作出响应,并把对应的 HTML 文本发送给浏览器
  5. 释放 TCP 连接
  6. 浏览器将该 HTML 文本并显示内容

image-20240519112520901

HTTP 协议是基于 TCP/IP 协议之上的应用层协议,基于 请求-响应 的模式HTTP 协议规定,请求从客户端发出,最后服务器端响应该请求并返回。换句话说,肯定是先从客户端开始建立通信的,服务器端在没有接收到请求之前不会发送响应


(2)HTTP请求/响应报文格式

请求报文格式

image-20240519112820437

image-20240519112837555

响应报文格式

image-20240519112912882

image-20240519112921058


(3)HTPP请求方法

HTTP/1.1 协议中共定义了八种方法(也叫“动作”)来以不同方式操作指定的资源:

其中GET、POST方法是web Server常用的请求方法

  1. **GET**:向指定的资源发出“显示”请求。使用 GET 方法应该只用在读取数据,而不应当被用于产生“副作用”的操作中,例如在 Web Application 中。其中一个原因是 GET 可能会被网络蜘蛛等随意访问

  2. HEAD:与 GET 方法一样,都是向服务器发出指定资源的请求。只不过服务器将不传回资源的本文部分。它的好处在于,使用这个方法可以在不必传输全部内容的情况下,就可以获取其中“关于该资源的信息”(元信息或称元数据)

  3. **POST**:向指定资源提交数据,请求服务器进行处理(例如提交表单或者上传文件)。数据被包含在请求本文中。这个请求可能会创建新的资源或修改现有资源,或二者皆有

  4. PUT:向指定资源位置上传其最新内容

  5. DELETE:请求服务器删除 Request-URI 所标识的资源

  6. TRACE:回显服务器收到的请求,主要用于测试或诊断

  7. OPTIONS:这个方法可使服务器传回该资源所支持的所有 HTTP 请求方法。用’*’来代替资源名称,

    Web 服务器发送 OPTIONS 请求,可以测试服务器功能是否正常运作

  8. CONNECTHTTP/1.1 协议中预留给能够将连接改为管道方式的代理服务器。通常用于SSL加密服务器的链接(经由非加密的 HTTP 代理服务器)

**GET与POST请求方法区别**:

  1. 用途:
    • GET主要用于从服务器检索数据。GET请求应该是幂等的,这意味着多次执行同一请求应该得到相同的结果,并且不应当改变资源的状态
    • POST用于向服务器提交数据,通常是为了创建新的资源或更新现有资源。POST请求不是幂等的,多次执行同一POST请求可能会在服务器上创建多个资源或多次改变资源状态
  2. 数据发送方式:
    • GET:将请求数据编码附加在URL中,形成查询字符串。这些数据通常可见,因为它们在URL中直接显示
    • POST:将数据作为请求的主体发送,不会在URL中显示。这允许POST请求发送更多的数据,因为它们不受URL长度限制,并且可以发送包括非ASCII字符在内的各种数据类型
  3. 安全性:
    • GET:由于数据在URL中显示,因此更容易受到安全风险的影响。例如,敏感数据(如密码或个人信息)可能会被保存在浏览器历史或服务器日志中
    • POST相对较安全,因为数据不会显示在URL中。适合发送敏感或大量的数据
  4. 缓存与历史:
    • GET:可以被浏览器或其他中间件缓存,以提高某些请求的效率。GET请求的结果也会出现在浏览器的历史记录中。
    • POST:通常不被缓存,也不出现在浏览器历史记录中,因为POST请求可能修改服务器状态或数据。

(4)HTTP状态码

所有HTTP响应的第一行都是状态行,依次是当前HTTP版本号,3位数字组成的状态代码,以及描述状态的短语,彼此由空格分隔

状态代码的第一个数字代表当前响应的类型:

  • 1xx消息——请求已被服务器接收,继续处理
  • 2xx成功——请求已成功被服务器接收、理解、并接受
  • 3xx重定向——需要后续操作才能完成这一请求
  • 4xx请求错误——请求含有词法错误或者无法被执行
  • 5xx服务器错误——服务器在处理某个正确请求时发生错误

虽然 RFC 2616 中已经推荐了描述状态的短语,例如”200 OK","404 Not Found“,但是WEB开发者仍然能够自行决定采用何种短语,用以显示本地化的状态描述或者自定义信息

image-20240519113800924

更多状态码信息:入口



5.DNS服务器

DNS服务器(域名系统服务器)是互联网上的一种关键技术服务,它负责将域名(如 www.example.com)转换成计算机可以理解的IP地址(如 192.168.1.1。这种转换是必需的,因为虽然域名对于人类来说易于记忆和使用,但网络上的设备通过IP地址来识别和通信

DNS服务器主要功能:

  • 域名解析:这是DNS的主要功能,即解析域名到对应的IP地址。当你输入一个网址或通过你的应用访问互联网服务时,你的设备会向DNS服务器查询该域名的IP地址
  • 域名记录管理DNS服务器管理着不同类型的记录,如A记录(将域名指向IPv4地址)、AAAA记录(将域名指向IPv6地址)、CNAME记录(将域名指向另一个域名)、MX记录(邮件交换记录,用于邮件服务)等
  • 负载均衡:通过DNS解析,可以实现对访问流量的分发,例如将访问请求分配给最近或响应时间最快的服务器
  • 缓存:为了减少解析时间和减轻根服务器的负担,DNS服务器通常会缓存域名解析的结果。这意味着一旦一个域名被解析,它的记录会在DNS服务器上保留一段时间,使得同一请求在此期间可以更快地被响应


6.服务器编程框架

虽然服务器程序种类繁多,但其基本框架都一样,不同之处在于逻辑处理

web服务器的业务逻辑:解析客户端发送的http请求报文;根据请求报文的相关信息(GET/POST请求或者其他等);服务器检索到客户端请求的资源(响应报文中的响应正文),并且封装组织http响应的报文,发送给客户端。

服务器基本编程框架如下图:

image-20240519145430317

模块 功能
I/O 处理单元 处理客户连接,读写网络数据
逻辑单元 业务进程或线程
网络存储单元 数据库、文件或缓存
请求队列 各单元之间的通信方式
  • IO处理单元服务器管理客户连接的模块。它通常要完成以下工作:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端。但是数据的收发不一定在 I/O 处理单元中执行,也可能在逻辑单元中执行,具体在何处执行取决于事件处理模式
  • 逻辑单元:一个逻辑单元通常是一个进程或线程。它分析并处理客户数据,然后将结果传递给 I/O 处理单元或者直接发送给客户端(具体使用哪种方式取决于事件处理模式)。服务器通常拥有多个逻辑单元,以实现对多个客户任务的并发处理
  • 网络存储单元:可以是数据库、缓存和文件,但不是必须的
  • 请求队列:请求队列是各单元之间的通信方式的抽象。I/O 处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理该请求。同样,多个逻辑单元同时访问一个存储单元时,也需要采用某种机制来协调处理竞态条件。请求队列通常被实现为池(线程池/进程池)的一部分


7.两种高效的事件处理模式

服务器程序通常需要处理三类事件:I/O 事件、信号及定时事件。有两种高效的事件处理模式:Reactor(反应堆)Proactor(前摄器)同步 I/O 模型通常用于实现 Reactor 模式,异步I/O模型通常用于实现Proactor模式

  • Reactor 是非阻塞同步网络模式,感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」
  • Proactor 是异步网络模式, 感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据。**Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」**
(1)Reactor模式

要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元),将 socket 可读可写事件放入请求队列,交给工作线程处理。除此之外,主线程不做任何其他实质性的工作。读写数据,接受新的连接,以及处理客户请求均在工作线程中完成

使用同步 I/O(以epoll_wait为例)实现的 Reactor 模式的工作流程是:

  1. 主线程往 epoll 内核事件表中注册 socket 上的读就绪事件
  2. 主线程调用epoll_wait等待 socket 上有数据可读
  3. socket 上有数据可读时, epoll_wait 通知主线程。主线程则将 socket 可读事件放入请求队列
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从 socket 读取数据,并处理客户请求,然后往 epoll内核事件表中注册该 socket 上的写就绪事件
  5. 当主线程调用 epoll_wait 等待 socket 可写
  6. socket 可写时,epoll_wait 通知主线程。主线程将 socket 可写事件放入请求队列
  7. 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果

Reactor模式工作流程

主线程,只负责监听epoll实例中的文件描述符是否有事件发生,工作子线程需要负责I/O数据读写,以及业务逻辑

image-20240519155954565


(2)Proactor模式

Proactor 模式将所有I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅仅负责业务逻辑。使用异步I/O模型(以 aio_readaio_write 为例)实现的Proactor模式的工作流程是:

  1. 主线程调用 aio_read 函数向内核注册 socket 上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(这里以信号为例)
  2. 主线程继续处理其他逻辑
  3. socket 上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求后,调用 aio_write 函数向内核注册 socket 上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序
  5. 主线程继续处理其他逻辑
  6. 当用户缓冲区的数据被写入 socket 之后,内核将向应用程序发送一个信号,以通知应用程序数据已经发送完毕
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭 socket

Proactor模式工作流程

主线程负责循环监听epoll实例上的文件描述符事件,并且发起异步I/O操作(如文件读写、网络数据发送接收等)和指定完成处理器(Completion Handlers),设置完成处理器是关键步骤,因为这些处理器定义了当I/O操作完成时将执行的回调函数或操作内核负责I/O数据的读写,当I/O操作完成时,内核将通知应用程序。这种通知通常是通过预设的回调函数或事件机制实现的;工作线程负责业务的处理逻辑(数据处理、数据验证、数据转换等业务逻辑)

image-20240519160630512


(3)使用同步IO模拟Proactor模式

使用同步I/O方式模拟出 Proactor 模式,原理是:主线程执行数据读写操作,读写完成之后,主线程向工作线程通知这一”完成事件“。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的结果进行逻辑处理

使用同步I/O模型(以 epoll_wait为例)模拟出的Proactor模式的工作流程如下:

  1. 主线程往 epoll 内核事件表中注册socket上的读就绪事件
  2. 主线程调用 epoll_wait 等待 socket 上有数据可读
  3. socket 上有数据可读时,epoll_wait 通知主线程。主线程从 socket 循环读取数据,直到没有更多数据可读,然后将读取到的数据封装成一个请求对象并插入请求队列
  4. 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册 socket 上的写就绪事件
  5. 主线程调用 epoll_wait 等待socket可写
  6. socket可写时,epoll_wait 通知主线程。主线程往 socket 上写入服务器处理客户请求的结果

同步 I/O 模拟 Proactor 模式的工作流程

主线程负责epoll实例中的文件描述符监听,以及IO的读写操作;而工作线程仅仅负责业务处理逻辑

image-20240519162654114


(4)其他博客入口 (重点)

华为云高性能网络模式Reactor与Proactor详解



8.线程池

线程池是由服务器预先创建的一组子线程,线程池中的线程数量应该和 CPU 数量差不多。线程池中的所有子线程都运行着相同的代码。当有新的任务到来时,主线程将通过某种方式选择线程池中的某一个子线程来为之服务。相比与动态的创建子线程,选择一个已经存在的子线程的代价显然要小得多。至于主线程选择哪个子线程来为新任务服务,则有多种方式:

  • 主线程使用某种算法来主动选择子线程。最简单、最常用的算法是随机算法Round Robin(轮流选取)算法,但更优秀、更智能的算法将使任务在各个工作线程中更均匀地分配,从而减轻服务器的整体压力
  • 主线程和所有子线程通过一个共享的工作队列来同步,子线程都睡眠在该工作队列上。当有新的任务到来时,主线程将任务添加到工作队列中。这将唤醒正在等待任务的子线程,不过只有一个子线程将获得新任务的”接管权“,它可以从工作队列中取出任务并执行之,而其他子线程将继续睡眠在工作队列上

线程池的一般模型为

image-20240520101705138

线程池中的线程数量最直接的限制因素是中央处理器(CPU)的处理器(processors/cores)的数量N :如果你的CPU4-cores的,对于CPU密集型的任务(如视频剪辑等消耗CPU计算资源的任务)来说,那线程池中的线程数量最好也设置为4(或者+1防止其他因素造成的线程阻塞);对于IO密集型的任务,一般要多于CPU的核数,因为线程间竞争的不是CPU的计算资源而是IOIO的处理一般较慢,多于cores数的线程将为CPU争取更多的任务,不至在线程处理IO的过程造成CPU空闲导致资源浪费

  • 空间换时间,浪费服务器的硬件资源,换取运行效率
  • 池是一组资源的集合,这组资源在服务器启动之初就被完全创建好并初始化,这称为静态资源
  • 当服务器进入正式运行阶段,开始处理客户请求的时候,如果它需要相关的资源,可以直接从池中获取,无需动态分配
  • 当服务器处理完一个客户连接后,可以把相关的资源放回池中,无需执行系统调用释放资源


9.epoll中事件标志EPOLLRDHUP

EPOLLRDHUP是一个事件标志,它是用来监控套接字上的对端关闭(half-close)或者完全关闭连接的情况

**解释 **:

  • **EPOLLRDHUP**:这个标志表示对端套接字已经关闭写操作或者完全关闭了连接。在TCP连接中,这通常对应于收到FIN包,意味着对端关闭了连接的发送方向,但可能仍能接收数据

为什么需要将事件设置为 EPOLLRDHUP:

  • 使用EPOLLRDHUP的主要优点是能更精确地检测对端是否关闭了连接,而不仅仅依赖于读操作返回0(EOF)来判断。这可以让应用程序更加灵活地处理半关闭的状态,即对端关闭了写操作但仍然可以接收数据的情况


10.epoll中事件标志EPOLLONESHOT

即使可以使用 ET 模式,一个socket 上的某个事件还是可能被触发多次。这在并发程序中就会引起一个问题。比如一个线程在读取完某个 socket 上的数据后开始处理这些数据,而在数据的处理过程中该socket 上又有新数据可读(EPOLLIN 再次被触发),此时另外一个线程被唤醒来读取这些新的数据。于是就出现了两个线程同时操作一个 socket 的局面。一个socket连接在任一时刻都只被一个线程处理,可以使用 epoll EPOLLONESHOT事件实现

对于注册了 EPOLLONESHOT 事件的文件描述符,操作系统最多触发其上注册的一个可读、可写或者异常事件,且只触发一次,除非我们使用 epoll_ctl 函数重置该文件描述符上注册的 EPOLLONESHOT 事件。这样,当一个线程在处理某个 socket 时,其他线程是不可能有机会操作该 socket 的。但反过来思考,注册了 EPOLLONESHOT 事件的 socket 一旦被某个线程处理完毕, 该线程就应该立即重置这个socket 上的EPOLLONESHOT事件,以确保这个 socket 下一次可读时,其 EPOLLIN 事件能被触发,进而让其他工作线程有机会继续处理这个 socket



11.有限状态机

逻辑单元内部的一种高效编程方法:有限状态机(finite state machine)。有的应用层协议头部包含数据包类型字段,每种类型可以映射为逻辑单元的一种执行状态,服务器可以根据它来编写相应的处理逻辑。如下是一种状态独立的有限状态机:

1
2
3
4
5
6
7
8
9
10
11
12
13
STATE_MACHINE( Package _pack )
{
PackageType _type = _pack.GetType();
switch( _type )
{
case type_A:
process_package_A( _pack );
break;
case type_B:
process_package_B( _pack );
break;
}
}

这是一个简单的有限状态机,只不过该状态机的每个状态都是相互独立的,即状态之间没有相互转移。状态之间的转移是需要状态机内部驱动,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
STATE_MACHINE()
{
State cur_State = type_A;
while( cur_State != type_C )
{
Package _pack = getNewPackage();
switch( cur_State )
{
case type_A:
process_package_state_A( _pack );
cur_State = type_B;
break;
case type_B:
process_package_state_B( _pack );
cur_State = type_C;
break;
}
}
}

该状态机包含三种状态:type_A、type_B 和 type_C,其中 type_A 是状态机的开始状态,type_C 是状态机的结束状态。状态机的当前状态记录在 cur_State 变量中。在一趟循环过程中,状态机先通过getNewPackage 方法获得一个新的数据包,然后根据 cur_State 变量的值判断如何处理该数据包。数据包处理完之后,状态机通过给 cur_State 变量传递目标状态值来实现状态转移。那么当状态机进入下一趟循环时,它将执行新的状态对应的逻辑