目录

前言

流程图 && 架构图

1)什么是 WebServer

2)服务器基本框架

3)Reactor && Proactor 模式

4)同步 I/O 模拟Proactor模式(Linux)

5)主从Reactor模式

6)并发模式中的 同步读 && 异步读

7)半同步/半反应堆

8)半同步/半异步

9)解析报文(主从状态机 && 状态转移过程)

10)从状态机逻辑

11)响应报文

12)信号处理机制

13)日志系统

14)GET,POST 请求下页面跳转

15)架构图

16)源码目录解析

面试题(上)

1)项目介绍

为什么要做 WebServer?

介绍下你的项目

2)线程池

线程池了解过吗

设计一个线程池需要考虑哪些因素

手写线程池

线程同步机制有哪些

线程池中的工作线程是一直等待吗?

线程池中工作线程处理完一个任务后的状态是?

同时2000个客户端访问,线程数不多,如何及时响应?

同时有大量客户并发建立连接,服务器如何处理

一个请求占用线程很长事件,影响到了接下来的请求处理,如何解决

3)并发模型

服务器使用的并发模型是?

Reactor,Proactor,主从Reactor 模型的区别

多路复用了解吗,讲一下 I/O 多路复用

select, poll, epoll 的区别

为什么用 epoll,还有其他IO复用方式吗,区别是

select, poll, epoll 各自的优缺点

介绍下 epoll 的 LT, ET


前言

另外 2 篇博客

WebServer — 面试题(下)-CSDN博客

WebServer — 八股(终章)-CSDN博客

说个大实话,webserver 就是个玩具,八股触发器,入门级,烂大街,没人要的破烂东西,但是你不能不会。
它是不行,但是面试官默认你会了。有了webserver的基础,你才有了点知识储备去做深一点,难一点的开源项目,或者参与到企业级开发里。

退一万步说,就算以后不做后端甚至不做C++了,用来打打 Linux,网络编程,数据库的基础还是好的。

本项目即将结束,手敲 14 篇万字博客,画了 20 多个流程图,整理了 40 多道相关八股

最后3篇博客:架构图 && 面试题(上),面试题(下) && 八股(上),八股(下)

流程图 && 架构图

所有图我都画了一遍,然后,结合画的图,对照着看一遍源码的实现

(实际上,就是对照着流程图,回顾前面写过的博客,将每一个接口联系起来)

Excalidraw | Hand-drawn look & feel • Collaborative • Secure

哈哈哈,回看之前,自己亲手写的 11 篇博客,敲代码过程中的困惑也慢慢解开,融会贯通的感觉

1)什么是 WebServer

2)服务器基本框架

3)Reactor && Proactor 模式

4)同步 I/O 模拟Proactor模式(Linux)

5)主从Reactor模式

6)并发模式中的 同步读 && 异步读

7)半同步/半反应堆

8)半同步/半异步

9)解析报文(主从状态机 && 状态转移过程)

10)从状态机逻辑

11)响应报文

12)信号处理机制

13)日志系统

14)GET,POST 请求下页面跳转

15)架构图

16)源码目录解析

每个目录,我会结合(README 和 架构图)进行解析

总目录

总目录,我将它拆成上下两部分

  1. CGImysql: 处理MySQL数据库相关的 CGI 程序
  2. http: 处理HTTP 请求和响应
  3. lock: 实现锁机制,确保线程安全
  4. log: 实现 日志系统,记录服务器的运行状态
  5. root: 服务器的根目录,存放网站的 静态资源文件,用于构建用户界面
  6. test_pressure: 压力测试
  7. threadpool: 实现 线程池
  8. timer: 定时器
  9. LICENSE: 许可证文件,规定使用该项目的条款
  10. README.md: 项目的说明文档
  11. build.sh: 构建项目的脚本
  12. config.cppconfig.h: 存放 配置信息 的代码文件
  13. main.cpp: 主程序 入口文件
  14. makefile: 用于 编译链接项目 的Makefile文件
  15. webserver.cppwebserver.h: 包含 Web服务器 的主要逻辑代码

http — I/O处理单元

CGImysql — 数据库连接

log — 日志系统

lock — 锁机制

timer — 定时器处理非活动连接

CGImysql

校验 && 数据库连接池

数据库连接池

  • 单例模式,保证唯一
  • list 实现连接池
  • 连接池为静态大小
  • 互斥锁实现线程安全

校验

  • HTTP请求采用POST方式
  • 登录用户名和密码校验
  • 用户注册及多线程安全

http

http 连接处理类

根据状态转移,通过 主从状态机 封装了 http连接类

其中,主状态机在内部调用从状态机,从状态机将处理状态和数据传给主状态机

  • 客户端发出 http 连接请求
  • 从状态机读取数据,更新自身状态和接受数据,传给主状态机
  • 主状态机根据从状态机状态,更新自身状态,决定响应请求还是继续读取

lock

线程同步机制包装类

多线程同步,确保任一时刻只能有一个线程进入关键代码段

  • 信号量
  • 互斥锁
  • 条件变量

log

同步/异步日志系统

同步 / 异步日志系统主要涉及两个模块,一个是日志模块,一个是阻塞队列模块

加入阻塞队列模块的目的:实现 异步写入日志

  • 自定义阻塞队列
  • 单例模式创建日志
  • 同步日志
  • 异步日志
  • 实现按天,超行分类

root

界面跳转

对 html 中的 action 行为设置标志位,将 method 设置为 POST

  • 0 注册
  • 1 登录
  • 2 登陆检测
  • 3 注册检测
  • 5 请求图片
  • 6 请求视频
  • 7 关注我

test_pressure

服务器压力测试

(1)

(2)

(3)

webbench — 网站压测工具

  • 测试相同硬件不同服务的性能 && 不同硬件同一个服务的运行状况
  • 展示服务器的两项内容:每秒响应请求数 && 每秒传输数据量

threadpool

半同步 / 半反应堆线程池

使用一个工作队列,完全解除了主线程和工作线程的耦合关系:

主线程往工作队列插入任务,工作线程通过竞争来取得任务并执行它

  • 同步 IO 模拟 proactor 模式
  • 半同步 / 半反应堆
  • 线程池

timer

定时器处理非活动连接

由于非活跃连接占用连接资源(占着茅坑不拉屎),严重影响服务器性能

通过实现一个服务器定时器,处理这种非活跃连接,释放连接资源

利用 alarm 函数,周期性地出发 SIGALRM 信号

该信号处理函数利用管道通知主循环,执行链表上的定时任务

  • 统一事件源
  • 基于升序链表的定时器
  • 处理非活动连接

接着,重看一遍前面写过的博客(梳理逻辑,熟悉源码和接口,为下一步搞定面试题做准备)

面试题(上)

1)项目介绍

为什么要做 WebServer?

专业课学过C++,Linux,计网,Mysql等知识,所以想通过本项目巩固网络编程和Linux,将分散的知识串联起来,顺便入门服务器。

介绍下你的项目

  • 该项目通过C++,在Linux环境下开发
  • 使用webbench进行压力测试,达到上万并发量
  • 引入定时器,处理非活跃连接,及时释放连接资源,提升性能
  • alarm 函数周期性触发 SIGALRM 信号,使用管道通知主循环执行定时任务,实现统一事件源和基于升序链表的定时器
  • 采用线程池,半同步/半反应堆架构,解耦主线程和工作线程,提高并发处理能力
    (因为主线程和工作线程之前有个工作队列,所以两者间没有耦合性)
  • 引入同步/异步日志系统,实现异步写入日志功能
  • 通过主从状态机封装 http 连接类,实现对 HTTP 请求的处理
  • 实现数据库连接池,采用单例和互斥锁保证线程安全
  • CGImysql处理数据库连接,log模块记录日志,lock模块实现锁机制,timer模块处理非活动连接
  • 还支持处理大文件(包括视频文件和图片)
  • 同时支持Reactor和Proactor模式,ET / LT均支持

补充解释

利用CGI与MySQL提升网站性能(cgimysql)-数据运维技术 (dbs724.com)

2)线程池

详情请看

webserver 之 线程同步 && 线程池(半同步半反应堆)-CSDN博客

线程池了解过吗

每提交一个任务,如果线程池没满,就创建一个线程;

如果满了,就会加入等待队列;

如果等待队列也满了,就会增加线程;

如果达到最大线程数量,就按照丢弃策略处理

设计一个线程池需要考虑哪些因素

  1. 线程池大小:
    要根据 系统的【负载情况】和【任务性质】确定。线程池太小会导致任务排队等待;线程池太大,会浪费系统资源
  2. 任务队列:
    线程池中的任务,需要一个任务队列来存储。任务队列可以是阻塞或非阻塞
  3. 线程工厂:
    用于创建新线程,默认 或 自定义
  4. 拒绝策略:
    当线程池中线程都处于忙碌状态时,新提交的任务会被放入任务队列等待执行。
    此时需要设置一个拒绝策略,防止任务一直被放入队列而无法执行。
    比如 抛出异常,丢弃任务 等
  5. 饱和策略:
    线程数量达到最大值时,新提交的任务可能会被拒绝。
    此时,饱和策略可以防止线程池过度扩展
  6. 线程池监控:
    添加一些监控功能,如,获取当前线程池状态,获取正在执行的任务

手写线程池

a. 定义

线程处理函数 worker() 和 执行任务函数 run() —- 私有

只提供 构造,析构,添加任务 的公共接口

templateclass threadpol {public:// thread_num 线程数量// max_requests 请求数量(请求队列中 最多允许 && 等待处理)// connpool 数据库连接池 指针threadpool(connection_pool *connpool, int thread_number = 8, int max_request = 10000);~threadpool();// 请求队列 插入任务请求bool append(T* request);private:// 工作线程运行的函数// 不断从工作队列取出任务 并执行static void *worker(void *arg); // 声明为 static 的原因,下面构造函数解释void run();private:int thread_number; // 线程数int m_max_requests; // 请求队列最大请求书pthread_t *m_threads; // 描述线程池的数组,大小 m_thread_numstd::list m_workqueue; // 请求队列locker m_queuelocker; // 保护请求队列的互斥锁sem m_queuestat; // 是否有任务需要处理bool m_stop; // 结束线程connection_pool *m_connPool; // 数据库连接池};

b. 构造

涉及线程池的 创建和回收

pthread_create() 将类的对象作为参数,传递给 静态函数 worker()

在静态函数引用这个独享,并调用其动态方法 run()

具体地,类对象传递时用 this 指针,传递给静态函数后,转换为线程池类,并调用私有 run()

templatethreadpool::threadpool( connection_pool *connPool, int thrad_number, int max_requests) // 构造函数参数列表 : m_thread_number(thread_number), // 线程数 m_max_requests(max_requests), // 最大请求数 m_stop(false), m_threads(NULL), // 结束线程 && 线程池数组 m_connPool(connPool) // 数据库连接池指针{if (thread_number <= 0 || max_requests <= 0)throw std::exception();// 线程 id 初始化m_threads = new pthread_t[m_thread_number]; // 数组if (!m_threads)throw std::exception();for (int i = 0; i < thread_number; ++i) {// thread_create(线程标识符, 线程属性, worker()指针, worker()的参数)// 因为 worker指针,指向线程处理函数的地址,而且 worker() 作为类成员函数// 且指向threadpool对象的 this 指针,作为默认参数被传入 worker() 中// 此时会和 worker(void* arg) 的类型 void* 不匹配// 所以上面才将 worker() 声明为 static// 循环创建线程if (thread_create(m_threads + i, NULL, worker, this) != 0) {delete [] m_threads;throw std::exception();}// 线程分离后,不用单独回收工作线程,便于资源释放if (thread_detach(m_threads[i])) {delete [] m_threads;throw std::exception();}}}

c. 析构

templatethreadpool::~threadpool(){delete[] m_threads;}

d. append() 添加任务

list 容器创建 请求队列

向队列添加任务时,通过 互斥锁 保证线程安全

添加完毕后,通过 信号量 提醒 “有任务要处理”

最后注意线程同步 ↓↓↓

使用了互斥锁 m_queuelocker.lock()m_queuelocker.unlock() 来保护对任务队列 m_workqueue 的访问,防止多个线程同时访问引起数据竞争。

使用信号量 m_queuestat.post() 来通知空闲线程有任务需要处理,避免了一个线程获取多个任务的情况,也确保每个任务都能得到及时处理

templatebool threadpool::append(T* request){m_queuelocker.lock(); // 关键代码段加锁// 根据硬件,预先设置请求队列最大值if (m_workqueue.size() > m_max_requests) {m_queuelocker.unlock();return false;}// 添加任务m_workqueue.push_back(request);m_queuelocker.unlock(); // 解锁// 信号量 提醒有任务处理m_queuestat.post();return true;}

e. worker() 线程处理

内部访问私有函数 run(),完成线程处理要求

// 前面构造函数中 pthread_create 调用了 workertemplatevoid* threadpool::worker(void* arg){// 调用时 *arg 是 this// 所以该操作其实是获取 threadpool 对象地址// 参数强转线程池类,调用成员方法threadpool* pool = (threadpool*)arg;// 线程池每一个线程创建都会调用 run(),睡眠在队列中pool->run();return pool;}

f. run() 执行任务

工作线程 从 请求队列 取出某个任务进行处理,注意线程同步

// 线程池所有线程睡眠状态,等待请求队列新增任务templatevoid threadpool::run(){while (!m_stop) {// 信号量等待// m_queuestat 是否有任务需要处理m_queuestat.wait();// 被唤醒后先加互斥锁// m_workqueue 请求队列m_queuelocker.lock();if (m_workqueue.empty()) {m_queuelocker.unlock();continue;}// 请求队列取 第一个任务request// 任务从请求队列 删除T* request = m_workqueue.front();m_workqueue.pop_front();m_queuelocker.unlock();if (!request) continue;// 连接池取出一个 数据库连接request->mysql = m_connPool->GetConnection();// process(模板类中的方法,这里是 http 类) 进行处理request->process();// 数据库连接 放回连接池m_connPool->ReleaseConnection(request->mysql);}}

线程同步机制有哪些

1)RAII

  • 之所以把 RAII 加到线程同步机制里,因为它可以用来管理 信号量,互斥量,条件变量等资源
  • RAII — Resource Acquisition is Initialization,资源获取即初始化
  • 资源与对象的生命周期绑定,构造函数分配资源,析构函数释放资源
  • 比如智能指针

2)信号量(限制访问某个资源的线程数量

a. sem_init() 初始化 信号量 b. sem_destory() 销毁 信号量 c. sem_wait() 原子操作方式,信号量 -1;信号量 == 0,sem_wait() 阻塞 d. sem_post() 原子操作方式,信号量 +1;信号量 > 0,唤醒调用 sem_post()的线程

信号量就像是一个可以控制进程访问共享资源的门禁系统。这个门禁系统支持两种操作

  • 等待 (P) 操作:当一个进程试图访问共享资源时(比如想要通过门禁进入),它首先检查信号量的值。如果信号量大于 0(门禁开着),那么进程可以顺利通过,同时信号量减一(门禁关闭)。但是,如果信号量等于 0(门禁关着,有其他进程在使用资源),进程必须等待(挂起执行),不能继续执行直到有其他进程释放资源(信号量增加)
  • 信号 (V) 操作:当一个进程使用完共享资源时(比如离开了房间),它会执行信号操作。如果有其他进程因为等待资源而被挂起,那么这个信号会唤醒其中一个等待的进程,让其继续执行。否则,如果没有进程在等待资源,信号量会自增,表示资源又变得可用

3)条件变量(线程间通信;实现线程等待唤醒机制)

a. pthread_cond_init() 初始化 b. pthread_cond_destory() 销毁 c. pthread_cond_broadcast() 广播方式,唤醒所有等待目标条件变量的 线程 d. pthread_cond_wait() 等待目标条件变量调用时,传入 mutex 参数 (加锁的互斥锁)执行时,1) 调用线程 放入条件变量的 请求队列 2) 互斥锁 mutex 解锁 3) 函数返回 0 时,互斥锁再次被锁上 4) 也就是说,函数内部,会有一次 解锁 和 加锁 操作

当一个线程调用 pthread_cond_wait() 等待目标条件变量时,它会将自己放入条件变量的请求队列中,并传入一个已经加锁的互斥锁(mutex参数)。接着,互斥锁会被解锁,让其他线程有机会操作共享资源。当函数返回 0 时,表示条件满足,此时会再次对互斥锁进行加锁操作,以确保线程安全地访问共享资源。因此,在 pthread_cond_wait() 函数内部会有一次解锁和加锁的操作,确保线程的正确执行顺序和共享资源的安全访问

4)互斥量(一次只允许一个线程访问资源)

即 互斥锁:保护关键代码段,确保 独占式 访问

a. 进入关键代码段 — 获得互斥锁并加锁

b. 离开关键代码段 — 唤醒等待该互斥锁的线程

a. pthread_mutex_init() 初始化互斥锁 b. pthread_mutex_destory() 销毁互斥锁 c. pthread_mutex_lock() 原子操作方式,给互斥锁,加锁 d. pthread_mutex_unlock() 原子操作方式,给互斥锁,解锁

线程池中的工作线程是一直等待吗?

  • 工作线程睡眠在工作队列上,当主线程将新任务添加到工作队列时,就会唤醒某个一直在等待的工作线程
  • 该工作线程从队列中取出任务并执行,其他工作线程则继续睡眠在工作队列上

线程池中工作线程处理完一个任务后的状态是?

  • 请求队列为空,则该线程进入线程池继续等待
  • 队列不为空,就和其他线程一起竞争任务

同时2000个客户端访问,线程数不多,如何及时响应?

1,采用 线程池

  • 主线程与工作线程分离,使用线程池管理工作线程,避免主线程阻塞
  • 考虑扩大线程池容量
  • 线程池复用线程资源,避免频繁创建和销毁小城

2,采用 ET 模式和非阻塞 socket

  • ET模式和非阻塞socket,确保及时处理
  • 通过 epoll 的EPOLLONESHOT特性,降低可读,可写和异常事件被触发次数

3,采用 半同步/半异步并发模式

  • 通过 Reactor 或 Proactor 模式,满足并发量要求

4,采用 集群或者分布式

  • 通过分布式,将大任务拆分成小任务,各节点协同完成
  • 通过集群,增加服务器(节点)数量

什么是分布式,分布式和集群的区别又是什么?这一篇让你彻底明白!_什么叫分布式-CSDN博客

同时有大量客户并发建立连接,服务器如何处理

  1. 多线程同步阻塞
    每个客户端连接到来时,都会创建一个新的线程来处理,即可实现并发处理
  2. I/O多路复用 socket 的建立
    一个线程中同时监听多个端口。新的连接请求到来时,该线程将连接请求分配给其他空闲线程处理

一个请求占用线程很长事件,影响到了接下来的请求处理,如何解决

  • 采取 异步非阻塞 模式,接收到新的请求后,不立即处理,而是安排一个以后的时间再发起请求,并且继续执行当前请求
  • 采取 超时设置,对每个请求设置合理的超时时间,如果处理时间超过给定阈值,则取消该请求 或 返回超时错误信息,及时释放线程资源
  • 采取 断点续传,对任务进行分段处理,某个处理阶段完成后暂停任务,等待下一次触发继续处理
  • 任务分片,长时间任务拆分成多个小任务,每个小任务完成后释放线程资源

补充理解

深入理解同步阻塞、同步非阻塞、异步阻塞、异步非阻塞_同步阻塞 同步非阻塞 异步阻塞 异步非阻塞-CSDN博客

3)并发模型

服务器使用的并发模型是?

采用 半同步半反应堆 作为并发模型

Proactor 事件处理模式为例

  1. 主线程充当异步线程,负责监听所有 socket 上的时间和处理 I/O 操作
  2. 新请求到达时,主线程接收连接 socket,并注册读写时间到 epoll 内核事件表中
  3. 如果连接 socket 上有读写事件发生,主线程从 socket 上接收数据,并将数据封装成请求对象插入请求队列
  4. 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(互斥锁)获得任务接管权
  5. 工作线程仅负责业务逻辑,比如处理客户请求;主线程与内核配合实现底层的异步 I/O 操作

总的来说

采用半同步半反应堆并发模型的 Proactor 事件处理模式,主线程负责底层 I/O 操作和事件监听,工作线程专注于业务逻辑处理,通过异步 I/O 实现高效并发处理

Reactor,Proactor,主从Reactor 模型的区别

先通过Java了解下 主从Reactor

Reactor(主从)原理详解与实现_主从reactor-CSDN博客

回答:

1,Reactor(事件来了,我通知你,你来处理)(我 — 操作系统内核;事件 — 新连接)

  • 采用同步 I/O,负责监听文件描述符上是否有事件发生
  • 主线程(I/O 处理单元)负责事件感知和通知工作线程(同步IO向应用程序通知的是IO就绪事件),将读写事件放入请求队列,并由工作线程完成实际的数据读写,接收新连接 和 处理客户请求
  • 应用进程需要主动调用 read / write 方法,进行数据的读取和写入,处理过程是同步的
  • 比如,快递员在楼下通知你快递送达,需要你自己下楼拿快递

2,Proactor(事件来了,我处理完,再通知你)

  • 采用异步 I/O,只负责发起 I/O 操作,真正的 I/O 实现由操作系统处理
  • 主线程和操作系统负责处理读写数据,接收新连接等 IO 操作,工作线程仅负责业务逻辑,如处理客户请求
  • 应用进程无需主动发起读写操作,操作系统完成读写后,会通知应用进程直接处理数据(异步IO向应用程序通知的是IO完成事件
  • 比如,快递员将快递送达你家门口后,再通知你

3,主从Reactor模式

  • 主反应堆线程,负责分发连接建立事件,已连接套接字上的 IO 事件,交给子反应堆线程处理
  • 子反应堆线程数量,可以根据CPU核数来设置,负责具体的 I/O 事件处理
  • 主反应堆线程,只负责调用 accept 获取已连接套接字,并将其分配给响应的子反应堆线程
  • 结合了 Reactor 和 Proactor 的特点

概括地说,Reactor模式和Proactor模式都是基于事件分发的网络编程模式,区别在于 I/O 事件处理的方式

Reactor基于待完成的 I/O 事件,而Proactor基于已完成的 I/O 事件,主从Reactor结合了两者的优点

补充

  • Reactor 核心是将事件处理逻辑 和 事件分发机制解耦,以便用非阻塞方式处理多个 I/O 事件
    (主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生,读写数据、接受新连接及处理客户请求均在工作线程中完成)
  • Proactor 核心是将I/O操作 和 业务逻辑解耦
    (主线程和内核负责处理读写数据、接受新连接等I/O操作,工作线程仅负责业务逻辑,如处理客户请求)
  • Reactor是非阻塞同步网络模型,如果把I/O操作改为异步就能够进一步提升性能,这就是异步网络模型Proactor

    (这里 “同步” 指用户进程在执行read 和 send 这类 I/O 操作的时候是同步的)

多路复用了解吗,讲一下 I/O 多路复用

  • IO多路复用是处理多个I/O流的技术
  • 它允许单个进程同时监视多个文件描述符,当一个或多个文件描述符准备好读 / 写时,就可以立即响应
  • 可以提高系统的并发性和响应速度,减少资源浪费
  1. select
    最早的 IO 多路复用机制,只能同时监视 1024 个 fd,而且每次只能监视部分 fd 的状态变化
  2. poll
    类似 select,但是最大 fd 监视数量达到了 65536
  3. epoll
    Linux 特有,可以支持更多的 fd,还支持 ET(边缘触发)

select, poll, epoll 的区别

  1. select, poll 没有本质区别,都是用【线性结构】存储进程关注的 socket 集合
  2. 使用时,首先将关注的 socket 集合通过 select / poll 系统调用,从用户态拷贝到内核态,然后由内核检测事件;当有网络事件产生时,内核需要遍历进程关注的 socket 集合,找到对应的 socket,并将其设置为 可读/可写 socket,然后再处理
  3. 显然,select, poll 的缺陷在于,客户端越多,socket 集合越大,socket集合的遍历和拷贝会带来很大开销,因此很难应对C10K(Concurrent 10,000 Connections,上万并发量)
  4. epoll 是解决 C10K 问题的利器,两方面入手
  • epoll 在内核中用【红黑树】关注进程所有待检测的 socket,红黑树是高效的数据结构,增删改的时间复杂度 O(logn);借助这棵红黑树,不需要像 select / poll 一样,每次操作都要传入整个 socket 集合,减少了内核和用户空间里,大量的数据拷贝和内存分配
  • epoll 使用事件驱动机制,内核里维护了一个【链表】来记录就绪事件,只将有事件发生的 socket 集合传递给应用程序,不需要像 select/poll 一样轮询整个集合(有 / 无事件的 socket 都包含),提高了检测效率

为什么用 epoll,还有其他IO复用方式吗,区别是

常用的是 select,poll,epoll,当然,这里会补充下对 io_uring 的说明

选择 epoll 从两点出发:

  1. 性能:
    1)在文件描述符数量较多且活跃度不一的情况下,epoll 能提升性能
    2)因为 epoll 将文件描述符维护在内核态,每次添加文件描述符,只需要执行一个系统调用
    3)而且能够直接返回触发事件的文件描述符,避免了遍历整个文件描述符集合的性能损耗
  2. 数据结构:
    1)select 使用线性表创建文件描述符集合,而且上限 1024
    2)poll 使用链表
    3)epoll 底层采用红黑树来构建,并维护一个 ready list,能够在 epoll_wait() 调用时,只观察已就绪事件
    4)epoll 支持 LT 和 ET 两种工作模式,而 select 和 poll 只能工作在相对低效的 LT 下

区别是:

  • select, poll 都需要将文件描述符集合从用户态拷贝到内核态,而epoll只用拷贝需要修改的文件描述符,避免了集体拷贝的开销
  • select,poll 最大开销来自内核判断是否有文件描述符就绪,需要遍历整个文件描述符集合,而 epoll 直接返回触发事件的文件描述符
  • select, poll, epoll 都是同步 I/O,而 io_uring 是异步 I/O,它通过用户和内核之间的共享内存映射来避免数据拷贝,减少CPU开销,还支持事件批处理,降低系统调用次数和上下文切换的开销

下面介绍下 io_uring:

  1. 零拷贝:io_uring 通过共享内存映射来避免数据拷贝,将用户空间和内核空间之间的数据传输最小化
  2. 批处理:它还支持事件批处理,即一次性可以提交多个 I/O 请求给内核,减少系统调用和上下文切换的开销,提高吞吐量
  3. Ring Buffer 机制:io_uring 使用环形缓冲区(ring buffer),作为用户和内核之间的通信机制。用户将 IO 请求放入Ring Buffer,内核会异步处理这些请求,并将结果返回到 Ring Buffer,用户再从 Ring Buffer 读取
  4. 高效的事件通知机制:使用 IO Completion Event(IO完成事件)机制,通过事件通知的方式告知用户空间 IO 操作的完成情况,避免用户频繁的轮询内核,提高响应速度

select, poll, epoll 各自的优缺点

select 缺点

  • select() 检测数量有限,最大值 1024 bit,每个比特位对应一个监听的文件描述符
  • fd_set 被内核修改后,不能重用,每次都需要被重置
  • 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,fd 较多时开销很大
  • 每次调用 select,都需要在内核遍历传入的所有 fd,fd较多时开销也很大,时间 O(n)

poll 缺点

  • 需要从用户态拷贝到内核态 + 需要在内核态遍历 fd,开销还是很大
  • 而且不能像 select 一样跨平台

epoll 优点

  • 底层是 红黑树,增删改效率高
  • 就绪描述符的链表:连接就绪时,内核将就绪的连接放到 ready list 链表里,应用进程只需要判断链表就能找出就绪进程,不用遍历整棵树

介绍下 epoll 的 LT, ET

  • LT 是默认方式,相当于效率很高的 poll 模型;而 ET 是高效方式
  • LT
    1)epoll_wait 检测到文件描述符有事件发生,就将其通知到应用程序,应用程序可以不立即处理该事件
    2)当下一次调用 epoll_wait 时,epoll_wait 还会再次向应用程序报告,直到被处理
  • ET
    而 ET 模式下,epoll_wait 检测到文件描述符…通知到应用程序,应用程序会立即处理
    很大程度上降低了 epoll 的触发次数