WebServer -- 架构图 && 面试题(上)
千帐灯无此声 2024-06-17 12:03:02 阅读 92
👂 ▶ 情歌王 (163.com)
目录
🎂前言
🌼流程图 && 架构图
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博客
Github 地址
11days/TinyWebServer: TinyWebServer一百小时 (github.com)
说个大实话,webserver 就是个玩具,八股触发器,入门级,烂大街,没人要的破烂东西,但是你不能不会。
它是不行,但是面试官默认你会了。有了webserver的基础,你才有了点知识储备去做深一点,难一点的开源项目,或者参与到企业级开发里。
退一万步说,就算以后不做后端甚至不做C++了,用来打打 Linux,网络编程,数据库的基础还是好的。
本项目即将结束,手敲 14 篇万字博客,画了 20 多个流程图,整理了 40 多道相关八股
🌼流程图 && 架构图
所有图我都画了一遍,然后,结合画的图,对照着看一遍源码的实现
(实际上,就是对照着流程图,回顾前面写过的博客,将每一个接口联系起来)
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)架构图
大体分为 4 部分,蓝色的两块是日志系统,绿色的两块是线程池,红色是定时器。
右下角是 客户端,服务器的交互,包含了服务器的数据库中,用户名 / 密码的写入和提取。
16)源码目录解析
每个目录,我会结合(README 和 架构图)进行解析
总目录
总目录,我将它拆成上下两部分👇
CGImysql: 处理 MySQL数据库 相关的 CGI 程序 http: 处理 HTTP 请求和响应 lock: 实现 锁机制 ,确保线程安全 log: 实现 日志系统,记录服务器的运行状态 root: 服务器的根目录,存放网站的 静态资源文件,用于构建用户界面 test_pressure: 压力测试 threadpool: 实现 线程池 timer: 定时器 LICENSE: 许可证文件,规定使用该项目的条款 README.md: 项目的说明文档 build.sh: 构建项目的脚本 config.cpp 和 config.h: 存放 配置信息 的代码文件 main.cpp: 主程序 入口文件 makefile: 用于 编译链接项目 的 Makefile 文件 webserver.cpp 和 webserver.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?
1
专业课学过C++,Linux,计网,Mysql等知识,所以想通过本项目巩固网络编程和Linux,将分散的知识串联起来,顺便入门服务器。
介绍下你的项目
2
之前在 Ubuntu 下了 vscode,重写了一遍上传到 Github,主要语言是 C++项目最后用 webbench 压测,10001 个客户端,1万3 QPS,每秒能传输 148万 bytes 数据然后讲讲使用的模块吧,一个是定时器,处理非活跃连接,可以及时释放连接资源,定时器的底层是升序链表一个是线程池,通过半同步/半反应堆,解耦主线程和工作线程,为什么说解耦了呢(因为主线程和工作线程之前有个工作队列,所以两者间没有耦合性)第三个,异步日志系统,实现异步写入日志功能,其中的异步写入方式具体逻辑是:将生产者/消费者模型封装为阻塞队列,起到数据缓存和解耦的作用第四个呢就是,HTTP 请求的处理,这里用的是主从状态机封装的 http 连接类,其中,从状态机负责读取数据,主状态机负责解析数据,并完成状态的转换然后就是数据库连接池,提前创建好一组数据库连接,实现对资源的复用。调用数据库的过程中,通过单例模式还有锁机制,保证线程安全同时支持Reactor和Proactor两种网络模式,还有 ET / LT 两种触发模式还支持处理大文件(包括视频文件和图片)
补充解释👇
利用CGI与MySQL提升网站性能(cgimysql)-数据运维技术 (dbs724.com)
中望一面答的很差,这里重新整理下,整理了第 2 个版本的↓↓
1)
首先,最外层是线程池,是半同步/半反应堆线程池,这里的半同步/半反应堆的具体工作流程是,以 Proactor 为例:
主线程充当异步线程,如果 socket 上有事件发生,主线程读取后将数据封装成请求对象插入请求队列,然后请求队列上的工作线程,通过竞争获得任务接管权
这里通过一个工作队列,解除了主线程和工作线程的耦合关系
具体实现呢,有 5 部分,类定义,线程池的创建和回收,向请求队列添加任务,线程处理函数 work() 和 执行任务函数 run()
5 部分结合起来,就是常考的手撕线程池
2)
第二个呢,就是日志系统,项目中的日志系统是异步实现的,为什么不用同步呢,因为同步时,写入函数和工作线程是 串行的,当单条日志比较大,或者峰值的时候,写日志就会成为性能瓶颈。
而异步会将日志内容先存入阻塞队列,写线程先从阻塞队列取出内容,再写入,就可以避免阻塞。这里实际就是并发编程中经典的生产者/消费者模型,生产者线程和消费者线程共享同一个缓冲区,缓冲区底层是循环数组实现的阻塞队列
3)
第三个就是定时器模块,可以定期检测非活跃连接,及时释放连接资源。
项目中,具体就是,服务器为每个连接创建一个定时器,定时器放在升序链表中,按超时时间升序排列,到期的定时器会从链表中删除,这里插入定时器的时间复杂度是O(n),删除的复杂度是O(1)
4)
第四个,http 连接的处理,也是本项目最复杂的一部分,整个项目约3000行代码,其中 http 的 .h 和 .cpp 两个文件,就占了800行
分 3 部分请求报文的接收,解析和响应
请求报文接收,涉及到对 http 报文格式和状态码的处理
解析部分,主要是主从状态机这个抽象模型
通过从状态机读取一行,然后由主状态机解析,分别对请求行,请求头和请求内容进行处理,其中解析请求头包含对GET的解析,解析请求内容,包含对POST的解析
响应部分,调用了sys/stat等多个头文件
5)
最后就是数据库连接池,类似前面提到的线程池,本质都是空间换时间,对资源的复用
项目中,使用局部静态变量的懒汉模式,来创建连接池
连接池的释放,通过 RAII 机制封装,避免了手动释放
2)线程池
详情请看👇
webserver 之 线程同步 && 线程池(半同步半反应堆)-CSDN博客
线程池了解过吗
3
每提交一个任务,如果线程池没满,就创建一个线程;
如果满了,就会加入等待队列;
如果等待队列也满了,就增加线程数
如果达到最大线程数,就按照丢弃策略处理
设计一个线程池需要考虑哪些因素
4
线程池大小:要根据 系统的【负载情况】和【任务性质】确定。线程池太小会导致任务排队等待;线程池太大,会浪费系统资源任务队列:
线程池中的任务,需要一个任务队列来存储。任务队列可以是阻塞或非阻塞线程工厂:
用于创建新线程,默认 或 自定义拒绝策略:
当线程池中线程都处于忙碌状态时,新提交的任务会被放入任务队列等待执行。
此时需要设置一个拒绝策略,防止任务一直被放入队列而无法执行。
比如 抛出异常,丢弃任务 等饱和策略:
线程数量达到最大值时,新提交的任务可能会被拒绝。
此时,饱和策略可以防止线程池过度扩展线程池监控:
添加一些监控功能,如,获取当前线程池状态,获取正在执行的任务
手写线程池
5
a. 定义
线程处理函数 worker() 和 执行任务函数 run() ---- 私有
只提供 构造,析构,添加任务 的公共接口
template<typename T>class 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 m_thread_number; // 线程数 pthread_t *m_threads; // 描述线程池的数组,大小 m_thread_num bool m_stop; // 结束线程 int m_max_requests; // 请求队列最大请求数 std::list<T *> m_workqueue; // 请求队列 locker m_queuelocker; // 保护请求队列的互斥锁 sem m_queuestat; // 是否有任务需要处理 connection_pool *m_connPool; // 数据库连接池};
b. 构造
涉及线程池的 创建和回收
pthread_create() 将类的对象作为参数,传递给 静态函数 worker()
在静态函数引用这个独享,并调用其动态方法 run()
具体地,类对象传递时用 this 指针,传递给静态函数后,转换为线程池类,并调用私有 run()
template<typename T>threadpool<T>::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) { // thread_create() 返回 0 表示线程创建成功 // 创建不成功就释放内存 delete [] m_threads; throw std::exception(); } // 线程分离后,不用单独回收工作线程,便于资源释放 if (thread_detach(m_threads[i])) { delete [] m_threads; throw std::exception(); } }}
关于第 21 ~ 25 行注释的辅助解释👇
worker()
是类threadpool
的成员函数。在thread_create()
中传递函数指针时,如果该函数是一个成员函数,它的类型和参数列表会与标准的函数指针不匹配。这是因为成员函数有一个隐藏的参数,即指向类实例的指针(通常称为this
指针)。为了解决这个问题,通常会将成员函数声明为static
,这样它就不再依赖于任何特定的类实例,因此不需要隐藏的this
指针参数。这样就可以将该函数指针传递给标准的函数指针参数。因此,worker()
函数被声明为static
,以便在thread_create()
中正确使用,并且不需要实例化类对象即可调用。综上所述,
worker()
函数被声明为static
是为了让它在thread_create()
中可以被正确传递,并且不需要特定的类实例
C++ std::thread 中join()和detach()的区别_牛客博客 (nowcoder.net)
C++多线程:join与detach的用法 |21xrx.com
c. 析构
template<typename T>threadpool<T>::~threadpool(){ delete[] m_threads;}
d. append() 添加任务
list 容器 创建 请求队列
向队列添加任务时,通过 互斥锁 保证线程安全
添加完毕后,通过 信号量 提醒 “有任务要处理”
最后注意线程同步 ↓↓↓
使用了互斥锁 m_queuelocker.lock() 和 m_queuelocker.unlock() 来保护对任务队列 m_workqueue 的访问,防止多个线程同时访问引起数据竞争。
使用信号量 m_queuestat.post() 来通知空闲线程有任务需要处理,避免了一个线程获取多个任务的情况,也确保每个任务都能得到及时处理
template<typename T>bool threadpool<T>::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(); // 解锁 // 信号量+1 唤醒一个线程处理任务 m_queuestat.post(); return true;}
e. worker() 线程处理
内部访问私有函数 run(),完成线程处理要求
// 前面构造函数中 pthread_create 调用了 workertemplate<typename T>void* threadpool<T>::worker(void* arg){ // 调用时 *arg 是 this // 所以该操作其实是获取 threadpool 对象地址 // 参数强转线程池类,调用成员方法 threadpool* pool = (threadpool*)arg; // 线程池每一个线程创建都会调用 run(),睡眠在队列中 pool->run(); return pool;}
f. run() 执行任务
工作线程 从 请求队列 取出某个任务进行处理,注意线程同步
// 线程池所有线程睡眠状态,等待请求队列新增任务template<typename T>void threadpool<T>::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); }}
线程同步机制有哪些
6
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() 原子操作方式,给互斥锁,解锁
线程池中的工作线程是一直等待吗?
7
工作线程睡眠在工作队列上,当主线程将新任务添加到工作队列时,就会唤醒某个一直在等待的工作线程该工作线程从队列中取出任务并执行,其他工作线程则继续睡眠在工作队列上
线程池中工作线程处理完一个任务后的状态是?
8
请求队列为空,则该线程进入线程池继续等待队列不为空,就和其他线程一起竞争任务
同时2000个客户端访问,线程数不多,如何及时响应?
9
1,采用 线程池
主线程与工作线程分离,使用线程池管理工作线程,避免主线程阻塞考虑扩大线程池容量线程池复用线程资源,避免频繁创建和销毁小城
2,采用 ET 模式和非阻塞 socket
ET模式和非阻塞socket,确保及时处理通过 epoll 的EPOLLONESHOT特性,降低可读,可写和异常事件被触发次数
3,采用 半同步/半异步并发模式
通过 Reactor 或 Proactor 模式,满足并发量要求
4,采用 集群或者分布式
通过分布式,将大任务拆分成小任务,各节点协同完成通过集群,增加服务器(节点)数量
什么是分布式,分布式和集群的区别又是什么?这一篇让你彻底明白!_什么叫分布式-CSDN博客
同时有大量客户并发建立连接,服务器如何处理
10
多线程同步阻塞每个客户端连接到来时,都会创建一个新的线程来处理,即可实现并发处理I/O多路复用 socket 的建立
一个线程中同时监听多个端口。新的连接请求到来时,该线程将连接请求分配给其他空闲线程处理
一个请求占用线程很长事件,影响到了接下来的请求处理,如何解决
11
采取 异步非阻塞 模式,接收到新的请求后,不立即处理,而是安排一个以后的时间再发起请求,并且继续执行当前请求采取 超时设置,对每个请求设置合理的超时时间,如果处理时间超过给定阈值,则取消该请求 或 返回超时错误信息,及时释放线程资源采取 断点续传,对任务进行分段处理,某个处理阶段完成后暂停任务,等待下一次触发继续处理任务分片,长时间任务拆分成多个小任务,每个小任务完成后释放线程资源
补充理解
深入理解同步阻塞、同步非阻塞、异步阻塞、异步非阻塞_同步阻塞 同步非阻塞 异步阻塞 异步非阻塞-CSDN博客
3)并发模型
服务器使用的并发模型是?
12
采用 半同步半反应堆 作为并发模型
以 Proactor 事件处理模式为例
主线程充当异步线程,负责监听所有 socket 上的时间和处理 I/O 操作新请求到达时,主线程接收连接 socket,并注册读写时间到 epoll 内核事件表中如果连接 socket 上有读写事件发生,主线程从 socket 上接收数据,并将数据封装成请求对象插入请求队列所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(互斥锁)获得任务接管权工作线程仅负责业务逻辑,比如处理客户请求;主线程与内核配合实现底层的异步 I/O 操作
总的来说
采用半同步半反应堆并发模型的 Proactor 事件处理模式,主线程负责底层 I/O 操作和事件监听,工作线程专注于业务逻辑处理,通过异步 I/O 实现高效并发处理
Reactor,Proactor,主从Reactor 模型的区别
13
先通过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 多路复用
14
IO多路复用是处理多个I/O流的技术它允许单个进程同时监视多个文件描述符,当一个或多个文件描述符准备好读 / 写时,就可以立即响应可以提高系统的并发性和响应速度,减少资源浪费
select最早的 IO 多路复用机制,只能同时监视 1024 个 fd,而且每次只能监视部分 fd 的状态变化poll
类似 select,但是最大 fd 监视数量达到了 65536epoll
Linux 特有,可以支持更多的 fd,还支持 ET(边缘触发)
select, poll, epoll 的区别
15
select, poll 没有本质区别,都是用【线性结构】存储进程关注的 socket 集合使用时,首先将关注的 socket 集合通过 select / poll 系统调用,从用户态拷贝到内核态,然后由内核检测事件;当有网络事件产生时,内核需要遍历进程关注的 socket 集合,找到对应的 socket,并将其设置为 可读/可写 socket,然后再处理显然,select, poll 的缺陷在于,客户端越多,socket 集合越大,socket集合的遍历和拷贝会带来很大开销,因此很难应对C10K(Concurrent 10,000 Connections,上万并发量)epoll 是解决 C10K 问题的利器,两方面入手 epoll 在内核中用【红黑树】关注进程所有待检测的 socket,红黑树是高效的数据结构,增删改的时间复杂度 O(logn);借助这棵红黑树,不需要像 select / poll 一样,每次操作都要传入整个 socket 集合,减少了内核和用户空间里,大量的数据拷贝和内存分配epoll 使用事件驱动机制,内核里维护了一个【链表】来记录就绪事件,只将有事件发生的 socket 集合传递给应用程序,不需要像 select/poll 一样轮询整个集合(有 / 无事件的 socket 都包含),提高了检测效率
为什么用 epoll,还有其他IO复用方式吗,区别是
16
常用的是 select,poll,epoll,当然,这里会补充下对 io_uring 的说明
选择 epoll 从两点出发:
性能:
1)在文件描述符数量较多且活跃度不一的情况下,epoll 能提升性能
2)因为 epoll 将文件描述符维护在内核态,每次添加文件描述符,只需要执行一个系统调用
3)而且能够直接返回触发事件的文件描述符,避免了遍历整个文件描述符集合的性能损耗数据结构:
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:
零拷贝:io_uring 通过共享内存映射来避免数据拷贝,将用户空间和内核空间之间的数据传输最小化批处理:它还支持事件批处理,即一次性可以提交多个 I/O 请求给内核,减少系统调用和上下文切换的开销,提高吞吐量Ring Buffer 机制:io_uring 使用环形缓冲区(ring buffer),作为用户和内核之间的通信机制。用户将 IO 请求放入Ring Buffer,内核会异步处理这些请求,并将结果返回到 Ring Buffer,用户再从 Ring Buffer 读取高效的事件通知机制:使用 IO Completion Event(IO完成事件)机制,通过事件通知的方式告知用户空间 IO 操作的完成情况,避免用户频繁的轮询内核,提高响应速度
select, poll, epoll 各自的优缺点
17
select 缺点
select() 检测数量有限,最大值 1024 bit,每个比特位对应一个监听的文件描述符fd_set 被内核修改后,不能重用,每次都需要被重置每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,fd 较多时开销很大每次调用 select,都需要在内核遍历传入的所有 fd,fd较多时开销也很大,时间 O(n)
poll 缺点
需要从用户态拷贝到内核态 + 需要在内核态遍历 fd,开销还是很大而且不能像 select 一样跨平台
epoll 优点
底层是 红黑树,增删改效率高就绪描述符的链表:连接就绪时,内核将就绪的连接放到 ready list 链表里,应用进程只需要判断链表就能找出就绪进程,不用遍历整棵树
介绍下 epoll 的 LT, ET
18
LT 是默认方式,相当于效率很高的 poll 模型;而 ET 是高效方式LT1)epoll_wait 检测到文件描述符有事件发生,就将其通知到应用程序,应用程序可以不立即处理该事件
2)当下一次调用 epoll_wait 时,epoll_wait 还会再次向应用程序报告,直到被处理ET
而 ET 模式下,epoll_wait 检测到文件描述符...通知到应用程序,应用程序会立即处理
很大程度上降低了 epoll 的触发次数
声明
本文内容仅代表作者观点,或转载于其他网站,本站不以此文作为商业用途
如有涉及侵权,请联系本站进行删除
转载本站原创文章,请注明来源及作者。