大并发服务器框架
大并发服务器设计目标
- 高性能(High Performance). 要求编写出来的服务器能够最大限度发挥机器性能, 使得机器在满负荷的情况下能够处理尽可能多的并发请求, 对于大量并发请求能够及时快速做出响应
- 高可用(High Availability). 要求服务器7*24小时服务, 故障转移
- 伸缩性(Scalability). 服务器具有良好框架, 分层设计, 业务分离, 并且能够进行灵活部署
分布式:
- 负载均衡
- 分布式存储
- 分布式计算
C/S结构: 任何网络系统都可以抽象为C/S结构(客户端, 服务端)
网络I/O+服务器高性能编程技术+数据库
超出数据库连接数: 数据库并发连接数10个, 应用服务器这边有1000个并发请求, 将会有990个请求失败. 解决办法: 增加一个中间层DAL(数据库访问控制层), 一个队列进行排队
超出时限: 数据库并发连接数10个, 数据库1秒钟之内最能处理1000个请求, 应用服务器这边有10000个并发请求, 会出现0-10秒的等待. 如果系统规定响应时间5秒, 则该系统不能处理10000个并发请求, 这时数据库并发能力5000, 数据出现瓶颈.
数据库瓶颈缓解
提高数据库的并发能力
- 队列+连接池(DAL)
- 主要逻辑挪到应用服务器处理, 数据库只做辅助的业务处理. 在数据库上进行计算能力或处理处理逻辑不如操作系统效率高. --> 很有限降低数据库的压力, 加缓存
- 使用缓存减少对数据库的访问,
缓存更新(缓存同步):
- 缓存具有一定时效, 如果缓存失效, 重新去数据库中查询. 实时性较差
- 一旦数据库中数据更新, 立即通知前端的缓存更新. 实时性比较高
当缓存服务器内存不足时, 缓存换页: 内存不足, 将不活跃的数据换出内存. 算法FIFO, LRU(least recently used, 最后一次访问时间距离现在最久的换出), LFU(least frequently used 最近最不频繁使用的换出)
nosql: 反sql, 主要用来存放非关系数据, sql存储对数据一致性要求较高的数据, nosql存储对数据一致性要求较低的数据, 基于key-value数据, 将nosql当作缓存来使用.
将cache与应用服务器放到同一台机器中, 则这个缓存不是全局缓存, 是局部缓存, 另一台机器无法访问这个缓存. 如果部署在独立机器上面, 并且使用分布式缓存机制, 则各个机器都可访问
分布式缓存如: redis, memcached
对数据库写操作, 会把数据库加锁, 则对数据库的读操作会阻塞 --> 把数据库读写分离, 对大部分服务器对数据库读操作比写操作多, 对数据库进行负载均衡, 读写分离. 使用replication机制, 一些数据库只做读操作, 一些数据库只做写操作
若数据库容量太大会影响并发, 对数据分区
- 垂直分区, 数据库可以按照一定的逻辑, 把表分散到不同的数据库. 如数据库有: 用户相关的表, 业务相关的表, 基础相关的表, 把这三个表分成三个库, 每个库的数据量变小, 提高并发, 但会影响DAL的逻辑
- 水平分区, 把数据库水平切割为多个库, 每个库都有用户表, 业务表, 基础表, 只不过将表的记录水平分隔到不同的库. 比较容易的拓展数据库 --> 常用
应用服务器瓶颈缓解
应用服务器的负载均衡:
- 应用服务器被动接受任务服务器来分发的任务. 任务服务器可以监视应用服务器的负载, cpu高, io高, 并发高, 内存换页高, 任务服务器查询到这些信息后选取一个负载最低的服务器分配任务
- 应用服务器主动到任务服务接收任务处理. 优点: 一个服务器处理完任务后就空闲了, 到任务服务器取一个任务处理. 缺点: 应用服务器只能处理特定的业务会增加任务服务器实现的复杂度
方式2最科学, 因为任务有自己的特征, 可能任务数量多的服务器先于任务数少的服务器处理结束.
任务服务器要有多台, 当其中一个任务服务器产生故障失效, 另一个服务器可通过HA(心跳)机制来立即启动
服务器性能四大杀手
- 数据拷贝, 服务器内部缓存, 内核数据拷贝到应用层
- 环境切换, 理性创建线程, 线程的切换, 该不该用多线程, 单线程好还是多线程好. 如果是单核服务器, 大量任务提交到单核服务器并不能并行处理, 这时使用多线程会增加线程的切换开销, 这时使用状态机的编程方式效率最佳. 多核服务器, 多线程能够充分发挥多核服务器的性能, 这时并不是线程越多越好, 同样会有切换开销
- 内存分配, 内存池, 减少向操作系统申请内存
- 锁竞争, 通过逻辑避免锁的使用
常见服务器并发方案
1. 循环式/迭代式服务器
循环中执行操作, 每处理完一个请求就关闭连接, 短链接模式. 循环式服务器只能使用短链接而不能使用长连接. 若使用长连接则在处理完请求执行write后, 不关闭连接回到read处, 而整个程序是一个单线程的程序, 若再有客户端连接则执行不到accept.
单线程程序, 不是真正意义上的并发服务器, 无法充分利用多核cpu, 不适合执行时间较长的服务
2. 并发式(concurrent)服务器
one connection per process(一个连接一个进程)
one connection pre thread(一个连接一个线程)
长连接, 能够处理多个客户端, 适合执行时间比较长的服务
3. perfor or per threaded(UNP2e第27章)
预先创建进程或者预先创建线程, 与并发服务器类似, 但减小的创建进程/线程的开销, 可以提高响应速度
问题: 当一个客户端连接过来后由于多个子进程处于循环状态中, 多个进程的accept都有返回, 那么只有一个进程的返回值是正确的, 其他进程返回值失败. --> 惊群现象
4. 反应式(reactive)服务器(reactor模式)
单线程轮询多个客户端
reactor使用select/poll/epoll来实现. 并发处理多个请求, 实际上是在一个线程中完成的, 单线程轮询, 无法充分利用多核cpu.
但是并发量比并发式服务器要多, 因为并发式服务器能够创建的进程/线程数量有限
不适合执行时间比较长的服务, 所以为了让客户感觉是在"并发"处理而不是"循环"处理, 每个请求必须在相对较短时间内执行. 解决方案: 有限状态机, 但并不使用, 有更好的解决方案
没有充分利用多核cpu
5. reactor + thread per request(过度方案)
每个请求过来都创建一个线程出来, 能够充分利用多cpu.
如果有很多的请求过来可能就会产生很多线程出来
6. reactor + worker thread(过度方案)
每个连接过来都在一个工作线程中完成, 能够充分利用cpu. 一共两个线程, 一个reactor线程, 一个工作线程. 不如直接利用并发式模型
7. reactor + thread pool(能适应密集计算)
第5种方案的改进
reactor在一个线程中(IO线程), 读取请求包, 把请求包丢到线程池中处理. 线程池中取出一个工作线程来处理请求包. 即使请求计算量大比较耗费cpu也没关系, 因为是在线程池中线程执行的, 不会影响到IO线程.
线程池中线程不负责数据发送, 要响应数据包必须丢到IO线程中发送, 或者说异步调用IO线程的发送方法发送数据
单个线程处理网络IO
8. multiple reactors(能适应更大的突发I/O)
reactors in threads(one loop per thread)
reactors in process
每个线程/进程都有一个Reactor
比如有mainReactor, subReactor_1, subReactor_2, mainReactor收到conn_1, 放入subReactor_1中并负责conn_1的IO, mainReactor接收到conn_2, 放入subReactor_2并负责conn_2的IO, mainReactor接收到的conn轮询放到subReactor
多个事件循环, 每个reactor都是一个线程或进程. 如果只有一个线程处理网络IO可能会产生瓶颈, 一个reactor可适应一个千兆网口. 若有三个网口加上manReactor共4个subReactor. 这些subReactor可以看作是IO的线程池, 这些线程池中线程的个数一般是固定的, 根据千兆网卡的数量来设置reactor的数量
多个线程处理网络IO
9. multiple reactors + thread pool(one loop per thread + threadpool)(突发I/O与密集计算)
这里multiple reactors不能用进程来实现, 因为如果用进程则后面的线程池没有办法共享(进程没有办法共享线程池). 多个subReactor共享一个线程池
10. proactor服务器(proactor模式, 基于异步I/O)
理论上proactor比reactor效率要高一些
异步I/O能够让I/O操作与计算重叠. 充分利用DMA特性
linux异步IO:
glibc aio(aio_*), 有bug
kernel native aio(io_), 也不完美. 目前仅支持O_DIRECT方式来对磁盘读写, 跳过系统缓存. 要自己实现缓存, 难度不小
boost asio实现proactor, 实际上不是真正意义上的异步I/O, 底层是用epoll来实现的, 模拟异步I/O
linux下没有比较成熟的异步I/O, windows下可基于完成端口来实现异步I/O
同步: I/O并没有完成, 需从内核缓冲区 拉 到应用缓存区中
异步: I/O完成, 直接把数据 推 到应用缓存区的过程
总结
muduo库支持4, 7, 8, 9模型, 模型9 multiple reactors + threadpool是最好的并发方式
常见问题
linux能同时启动多少个线程?
对于32bit linux, 一个进程的地址空间是4G, 其中用户态能访问3G左右, 而一个线程的默认栈(stack)大小是10M, 心算可知, 一个进程大约最多支持300个线程左右
多线程能提高并发度吗?
如果指的是"并发连接数", 不能
假如单纯采用thread per connection的模型, 那么并发连接数大约300, 这远远低于基于事件的单线程程序所能轻松达到的并发连接数(几千上万, 甚至几万). 所谓"基于事件", 指的是用IO multiplexing event loop的编程模型, 又称reactor模式
多线程能提高吞吐量吗?
对于计算密集型服务, 不能
如果要在一个8核的机器上压缩100个1G的文本文件, 每个core的处理能力为200MB/s, 那么"每次起8个进程, 一个进程压缩一个文件"与"只启动一个进程(8线程并发压缩一个文件)", 这两种方式总耗时相当, 但是第二种方式能够较快的拿到第一个压缩完的文件, 可提高响应速度
多线程可以提高响应时间吗?
可以, 如压缩100个1G文件时, 只启动一个进程(8线程并发)可较快拿到第一个压缩完的文件
多线程如果让I/O和计算重叠, 降低latency(迟延)
例: 日志(logging), 多个线程写日志, 由于文件操作比较慢, 服务器会等在IO上, 让cpu空闲, 增加响应时间
解决办法: 单独用一个logging线程负责写磁盘文件, 通过BlockQueue提供对外接口, 别的线程要写日志的时候往队列一塞就行, 这样服务线程的计算和logging线程的磁盘IO就可以重叠
如果异步IO成熟的话, 可以使用proactor模式, 让同一个线程的IO和计算重叠, 充分利用DMA特性
线程池大小的选择?
如果池中执行任务时, 密集计算所占时间比重P(0<P<=1), 而系统一共有C个cpu, 为了让C个cpu跑满而不过载, 线程池大小的经验公式T=C/P, 即T*P=C(让cpu刚好跑满). P过小, 如小于0.2, 如0.001, 此时则需根据测试选择固定的线程数量
假设C=8, P=1.0, 线程池的任务完全密集计算, 只要8个活动线程就能让cpu饱和
假设C=8, P=0.5, 线程池的任务有一半是计算, 一半是IO, 那么T=16, 也就是16个"50%繁忙的线程能让8个cpu忙个不停"
线程分类:
IO线程(这里特指网络IO), reactor
计算线程, cpu
第三方库所用线程, 如logging, 又比如database, 不能放入计算线程中
网络IO
多路IO转接服务器也叫多任务IO服务器. 该类服务器实现的主旨思想是, 不再有应用程序自己见识客户端连接, 取而代之有内核替应用程序监视文件
linux下有三种IO复用模型: (1) select; (2) poll; (3) epoll
两种信号
如果客户端关闭套接字close, 而服务端调用了一层write, 服务端会接收一个RST segment(TCP传输层)
如果服务器再次调用write, 这时就会产生SIGPIPE信号, 如果没有忽略该信号, 默认处理方式退出整个进程
TIME_WAIT状态在一段时间内, 内核会保留一些内核资源, 对大并发服务器的影响, 应该尽可能在服务器端避免出现TIME_WAIT状态
如果服务器主动断开连接(先于client调用close), 服务端就会进入TIME_WAIT, 这样在一定时间范围内内核会hold住内存资源
协议设计上, 应该让客户端主动断开连接, 这样就把TIME_WAIT状态分散到大量的客户端
问题: 如果客户端不活跃, 一些恶意客户端不断开连接占用服务端资源, 这时服务端要有个机制来踢掉不活跃的连接close, 这样也会后来
select
select缺点:
- 同时监听文件描述符上限是1024, 修改配置文件后依旧是1024, 重新编译内核可改
- 使用for循环文件描述符判断文件描述符. 可使用自定义数据结构: 数据, 需要用户自己维护, 监听描述符自己保存
- 监听集合和满足监听条件的集合是一个集合, 要将原有的集合保存
int select(
int nfds, // 监听所有的文件描述符中, 最大的文件描述符+1
fd_set *readfds, // 监听的文件描述符"可读"事件
fd_set *writefds, // 监听的文件描述符"可写"事件
fd_set *exceptfds, // 监听的文件描述符"异常"事件
struct timeval *timeout);
// 返回值:
// 成功: 所监听的所有的监听集合中, 满足条件的总数
// 失败: -1
void FD_ZERO(fd_set *set); // 将set清空
void FD_CLR(int fd, fd_set *set); // 将fd从set中清除出去
void FD_SET(int fd, fd_set *set); // 将fd设置到set集合中
int FD_ISSET(int fd, fd_set *set); // 判断fd是否在set集合中
// 返回值: 满足--> 1
FD_SETSIZE // 一个进程中select所能操作的文件描述符的最大数目. 一般情况下被定义为1024. 修改需重新编译内核
示例程序
poll
文件描述符突破1024, 修改配置文件
监听和返回集合分离
搜索范围变小
监听同一文件描述符的两种事件时, 可定义两个相同的文件描述符, event设置不同即可
poll模式相当于epoll的ET(电平触发模式)
read 可能并没有把connf所对应的缓冲区的数据都读完, 那么connfd仍然是处于活跃状态, 也就是说下一次poll时, connfd对应的POLLIN事件仍然会触发, 需要再次调用read接收请求, 这没有问题, 但是应用层发来的数据包刚好分包(一个数据包需要两次read才能接受完全, 粘包问题)
应该将读到的数据保存在connfd的应用层缓冲区, 也就是说对每一个已连接的套接字分配一个应用层缓冲区, read时只管把数据追加到应用层缓冲区末尾, 下一次读时也最加到末尾, 解析协议到应用缓冲区
write 对请求数据进行应答, 如果应答数据过大, 如应答一万个字节, 只write了一千个字节connfd对应的内核发送满了, write调用不会阻塞(connfd是非阻塞套接字, 阻塞模式不会出现这个问题, 但阻塞把整个线程阻塞住了, 降低程序的并发性), 这时不能把未发送的数据丢弃掉
这时也应该有一个应用层的发送缓冲区, 数据没有write完, 应该关注connfd的POLLOUT事件, POLLOUT事件触发条件是connfd对应的内核缓冲区可以容纳数据
不能一开始accept接收到connfd时就关注POLLOUT事件, 一开始connfd对应的发送缓冲区没有数据, 又没有发送数据, 这时会一直触发POLLOUT事件, 会出现busy loop忙等待
int poll(
struct pollfd *fds, // 输入输出参数, 结构体指针, 每次都需要把关注事件从用户缓冲区拷贝到内核缓冲区, 效率底
nfds_t nfds, // 监听文件描述符的个数
int timeout // 超时时间, 负数阻塞等待直至发生事件
);
// timeout:
// -1: 阻塞等, #define INFTIM -1, linux中没有定义此宏
// 0: 立即返回, 不阻塞
// >0: 等待指定毫秒数, 如当前系统时间精度不够毫秒, 向上取值
struct pollfd {
int fd; /* file descriptor, 文件描述符 */
short events; /* requested events, 请求的事件 */
short revents; /* returned events, 返回的事件 */
};
示例程序
epoll
epoll是linux下多路复用I/O接口select和poll的增强版本, 他能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率, 因为它会复用文件描述符集合来传递结果, 而不用迫使开发者每次等待事件之前都必须重新准备要被监听的文件描述符集合. 另一点原因就是获取事件的时候, 它无须遍历整个被监听的描述符集, 只要遍历那些被内核IO事件异步唤醒二加入Read列的描述符集合就可以
目前epoll是linux大规模并发网络程序中的热门首选模型, 适用于连接描述符多, 监听描述符少的情况
EPOLLIN事件
内核中的socket接收缓冲区 为空 低电平
内核中的socket接收缓冲区 不为空 高电平
即使缓冲区没有读完, 仍然会触发事件, 因为处于高电平状态直至数据读完
EPOLLOUT事件
内核中的socket发送缓冲区 不满 高电平
内核中的socket发送缓冲区 满 低电平
两种触发模式: (1)Level-Triggered; (2)Edge-Triggered
LT电平触发
高电平触发
ET边沿触发
低电平 -> 高电平 触发
高电平 -> 低电平 触发
如果采用LT, 那什么时候关注EPOLLOUT事件? 会不会造成busy-loop?
得到套接字立即关注会出现busy loop, 因为这时内核缓冲区是空, 一直处于可发送状态
read: 同poll
write:: 同poll
如果采用ET, 那什么时候关注EPOLLOUT事件?会不会造成busy-loop?
得到套接字立即关注不会出现busy loop, 因为这时内核缓冲区是空, 一直处于可发送状态(高电平状态), 电平并没有发生变化
read: 一定要读到EAGAIN, 表示数据都读完, 如果有1000个数据, 只读取10个字节, 那么今后不管对方发送多少个数据过来, 由于是ET模式所以都不会再触发了
write: 写数据后不需要再关注EPOLLOUT事件, 因为在接收文件描述符时, 已经关注EPOLLOUT了. 发送数据要把应用层缓冲区发送完, 若不能发送完一定要发送至返回EAGAIN.
如应用层要发200个字节, 内核缓冲区可容纳100个字节:
(1)只发送50个字节, 应用层还剩余50个字节没有发送, 但是没有发送至EAGAIN, 一直处于可发送状态相当于一直处于高电平状态, 就永远不会再发送剩余150个字节
(2)发送100个字节把内核缓冲区填满, 处于不可发送状态相当于底电平状态, 对方接收走数据后相当于高电平状态, 会触发EPOLLOUT事件, 剩余100个字节才可以再发送
epoll下ET模式为何一定要用要用非阻塞的模式
ET 模式是一种边沿触发模型,在它检测到有 I/O 事件时,通过 epoll_wait 调用会得到有事件通知的文件描述符,每于每一个被通知的文件描述符,如可读,则必须将该文件描述符一直读到空,让 errno 返回 EAGAIN 为止,否则下次的 epoll_wait 不会返回余下的数据,会丢掉事件。而如果你的文件描述符如果不是非阻塞的,那这个一直读或一直写势必会在最后一次阻塞。
普通模型
epoll --> 服务器 --> 监听 --> fd --> 可读 --> epoll返回 --> read --> 小写转大写 --> write --> epoll继续监听
反应堆模型
epoll --> 服务器 --> 监听 --> cfd --> 可读 --> epoll返回 --> read --> cfd从树上摘下 --> 设置监听cfd写时间(滑动窗口), 操作 --> 小写转大写 --> 等待epoll_wait返回 --> 回写客户端 --> cfd从树上摘下 --> 设置监听cfd读事件, 操作 --> epoll继续监听
#include <sys/epoll.h>
// 相当于内核开辟一个空间处理关注事件
int epoll_create(int size); // epoll能够处理的文件描述符个数, 这个值现在可以忽略, epoll所能管理文件描述符的个数依赖系统资源的限制
int epoll_create1(int flags); // 可以指定标志位, 如EPOLL_CLOEXEC
// 向内核中的空间添加或删除事件
int epoll_ctl(
int epfd, // epoll_create的句柄
int op, // 动作, EPOLL_CTL_ADD-->注册新的fd到epfd中, EPOLL_CTL_MOD-->修改已注册到epfd中的事件, EPOLL_CTL_DEL-->删除epfd中的fd事件
int fd,
struct epoll_event *event); // 内核要监听的事件
// events: 输出参数, 关注事件已经从epoll_ctl中传递进去加入, 不需要再把关注的事件拷贝到内核, 与poll相比效率提高, 不需要每次从用户空间拷贝到内核空间
int epoll_wait(
int epfd,
struct epoll_event *events, // 输出参数, 用来存储内核得到事件的集合, 不像在poll中在所有已连接套接字数组中进行遍历, 返回的套接字都是活跃的
int maxevents, // 告之这个events的大小, 不能大于epoll_create时的size
int timeout
);
// timeout: 超时时间
// -1: 阻塞
// 0: 立即返回
// >0: 指定毫秒数
typedef union epoll_data { // 最大值8个字节
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events, 关注事件 */
epoll_data_t data; /* User data variable, 用户数据 */
};
// events:
// EPOLLIN: 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
// EPOLLOUT: 表示对应的文件描述符可以写
// EPOLLERR: 表示对应的文件描述符发送错误
// EPOLLET: 将EPOLL设置问边沿触发模式
客户端主动断开连接返回POLLIN
服务端主动断开连接返回POLLIN|POLLHUP两个事件
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
EPOLLIN == POLLIN);
EPOLLOUT == POLLOUT);
EPOLLPRI == POLLPRI);
EPOLLERR == POLLERR);
EPOLLHUP == POLLHUP);
EPOLLRDHUP == POLLRDHUP);
EMFILE处理
EMFILE: 进程打开的文件描述符超出上限
腾出来一个文件描述符用于接收,
出现一个EMFILE后, 如果没有退出程序, 事件没有处理(可用套接字满了), 监听套接字仍然处于活跃状态(高电平状态), 下一次调用poll时, accept仍会触发可读事件, 调用accept仍然会返回EMFILE错误, 会出现busy loop状态,
返回一个EMFILE后, 由于没有accept成功, listenfd仍然处于活跃状态, 下一次poll时仍然返回EMFILE, 处于busy loop状态
accept返回EMFILE的处理
- 调高进程文件描述符数目, 治标不治本, 系统资源总是有限, 怎么提到都做不到大并发
- 死等, 等待其他进程关闭套接字, 效率底
- 退出程序, 暂时性资源不足, 满足不了服务器7*24小时服务
- 关闭监听套接字, 下一次不能监听其他客户端的连接过来, 需要重新打开, 那什么时候重新打开呢?
- 如果是epoll模型, 可以改用ET, 这样不会出现busy loop. 问题是如果漏掉了一次accept(2), 程序再也不会收到新连接.
- 准备一个空闲的文件描述符. 遇到这种情况, 先关闭这个空闲文件, 获得一个文件描述符名额; 再accept(2)拿到socket连接的文件描述符; 随后立刻close(2), 这样就优雅地断开了与客户端的连接; 最后重新打开空闲文件, 把“坑”填上, 以备再次出现这种情况时使用
如果是epoll模型使用ET模式, accept时没有办法把文件描述符返回出来(没有从高电平到底电平的转换), 因为文件描述符打开满了, accept返回失败, 监听描述符不会再触发, 相当于一直处于高电平状态, 电平没有变化不会出现busy loop. 但是漏读一次事件, 后续新的客户端连接过来一直处于高电平状态
总结
原理
select监听fd受限,使用的是内核设定的long数组
poll监听fd不受限,使用的是pollfd的动态数组
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理. 这样所带来的缺点是:
- 单个进程可监视的fd数量被限制
- 需要维护一个用来存放大量fd的数据结构, 这样会使得用户空间和内核空间在传递该结构时复制开销大(共有两次传递)
- 对socket进行扫描时是线性扫描(内核空间扫描一次, 用户空间扫描一次)
poll本质上和select没有区别, 它将用户传入的数组拷贝到内核空间, 然后查询每个fd对应的设备状态, 如果设备就绪则在设备等待队列中加入一项并继续遍历, 如果遍历完所有fd后没有发现就绪设备, 则挂起当前进程, 直到设备就绪或者主动超时, 被唤醒后它又要再次遍历fd. 这个过程经历了多次无谓的遍历.
它没有最大连接数的限制, 原因是它是基于链表来存储的, 但是同样有一个缺点:
大量的fd的数组被整体复制于用户态和内核地址空间之间, 而不管这样的复制是不是有意义.
poll还有一个特点是“水平触发”, 如果报告了fd后, 没有被处理, 那么下次poll时会再次报告该fd.
在前面说到的复制问题上, epoll使用mmap减少复制开销.
还有一个特点是, epoll使用“事件”的就绪通知方式, 通过epoll_ctl注册fd, 一旦该fd就绪, 内核就会采用类似callback的回调机制来激活该fd, epoll_wait便可以收到通知
epoll改进select缺陷主要在: (1)红黑树; (2)rdlist(就绪描述符列表)
-
红黑树是用来存储这些描述符的,因为红黑树的特性,就是良好的插入,查找,删除性能O(lgN)。
当内核初始化epoll的时候(当调用epoll_create的时候内核也是个epoll描述符创建了一个文件,毕竟在Linux中一切都是文件,而epoll面对的是一个特殊的文件,和普通文件不同),会开辟出一块内核高速cache区,这块区域用来存储我们要监管的所有的socket描述符,当然在这里面存储一定有一个数据结构,这就是红黑树,由于红黑树的接近平衡的查找,插入,删除能力,在这里显著的提高了对描述符的管理。 -
rdlist就绪描述符链表这是一个双链表,epoll_wait()函数返回的也是这个就绪链表。
当内核创建了红黑树之后,同时也会建立一个双向链表rdlist,用于存储准备就绪的描述符,当调用epoll_wait的时候在timeout时间内,只是简单的去管理这个rdlist中是否有数据,如果没有则睡眠至超时,如果有数据则立即返回并将链表中的数据赋值到events数组中。这样就能够高效的管理就绪的描述符,而不用去轮询所有的描述符。所以当管理的描述符很多但是就绪的描述符数量很少的情况下如果用select来实现的话效率可想而知,很低,但是epoll的话确实是非常适合这个时候使用。
对与rdlist的维护:当执行epoll_ctl时除了把socket描述符放入到红黑树中之外,还会给内核中断处理程序注册一个回调函数,告诉内核,当这个描述符上有事件到达(或者说中断了)的时候就调用这个回调函数。这个回调函数的作用就是将描述符放入到rdlist中,所以当一个socket上的数据到达的时候内核就会把网卡上的数据复制到内核,然后把socket描述符插入就绪链表rdlist中。
实现原理:当一个socket描述符的中断事件发生,内核会将数据从网卡复制到内核,同时将socket描述符插入到rdlist中,此时如果调用了epoll_wait会把rdlist中的就绪的socekt描述符复制到用户空间,然后清理掉这个rdlist中的数据,最后epoll_wait还会再次检查这些socket描述符,如果是工作在LT模式下,并且这些socket描述符上还有数据没有读取完成,那么L就会再次把没有读完的socket描述符放入到rdlist中,所以再次调用epoll_wait的时候是会再次触发的,而ET模式是不会这么干的。
一个进程所能打开的最大连接数
select: 单个进程所能打开的最大连接数有FD_SETSIZE宏定义, 其大小是32个整数的大小(在32位的机器上, 大小就是32*32, 同理64位机器上FD_SETSIZE为32*64), 当然我们可以对进行修改, 然后重新编译内核, 但是性能可能会受到影响, 这需要进一步的测试.
poll: 本质上和select没有区别, 但是它没有最大连接数的限制, 原因是它是基于链表来存储的, 1G内存的机器上可以打开10万左右的连接, 2G内存的机器可以打开20万左右的连接
epoll: 虽然连接数有上限, 但是很大, 1G内存的机器上可以打开10万左右的连接, 2G内存的机器可以打开20万左右的连接
FD剧增后带来的IO效率问题
select: 因为每次调用时都会对连接进行线性遍历, 所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”.
poll: 同上
epoll: 因为epoll内核中实现是根据每个fd上的callback函数来实现的, 只有活跃的socket才会主动调用callback, 所以在活跃socket较少的情况下使用epoll没有前面两者的线性下降的性能问题. 但是已连接套接字不太大并且套接字都非常活跃时, 可能会有性能问题, 因为epoll内部实现更复杂, 需要更复杂的代码逻辑. epoll优势在于大并发.
消息传递方式
select: 内核需要将消息传递到用户空间, 都需要内核拷贝动作
poll: 同上
epoll: epoll通过内核和用户空间共享一块内存来实现的.
示例程序
基于管道, 边缘触发时父进程一次显示4个字符, 触发时一次显示全部字符: epoll_pipe
阻塞epoll: epoll_blocking_server.c
非阻塞epoll, 边缘触发并且非阻塞模式减少epoll_wait函数调用次数: epoll_nonblocking_server.c
客户端固定一次发送10个字符: epoll_client.c
epoll反应堆: epoll_reactor.c
CMake
#!/bin/sh
set -x # 追踪所有执行命令
SOURCE_DIR=`pwd` # 反斜号, 执行命令
BUILD_DIR=${BUILD_DIR:-build} # BUILD_DIR判断, 为空取build值
# 下面反斜号后面不能有注释空格
mkdir -p $BUILD_DIR
&& cd $BUILD_DIR
&& cmake $SOURCE_DIR
&& make $*