前言补充
阻塞与非阻塞
-
同步阻塞IO (Blocking IO) : 传统IO模型
-
同步非阻塞IO (Non-blocking IO): 默认创建的socket都是阻塞的,若是要设置成非阻塞IO需要socket被设置成NONBLOCK。
-
IO多路复用(IO Multiplexing): 经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型
-
异步IO (Asynchronous IO): 经典的Proactor设计模式,也称为异步非阻塞IO
阻塞与非阻塞IO
对于操作系统内核而言,发生在等待资源阶段,根据发起IO请求是否阻塞来判断
阻塞IO
这种模式下下一个用户进程在发起一个IO操作之后,CPU只有在收到响应或者超时后才可以处理其他事情,否则IO将会一直阻塞,例如读取磁盘上一段文件,系统内核在完成磁盘寻道、读取数据、复制数据到内存之中,这个调用才算完成,阻塞的这段时间对CPU资源来说是浪费掉了。
非阻塞IO
这种模式下一个用户进程发起一个IO操作后,如果数据没有就绪,则会立刻返回,同时标记数据资源不可用,此时CPU时间片可以做其他的事情
同步与异步IO
发生在使用资源阶段,根据实际IO操作来判断
同步IO
应用发送或者接收数据后,如果不返回,那么就阻塞等待,直到数据成功或者失败返回
异步IO
应用发送或者接收数据后立刻返回,数据写入OS缓存,由OS完成数据发送或者接收,等OS处理完成发送或接受后再通知应用,通常是通过回调的方式,Node.js就是典型的异步编程的例子
无论是BIO,NIO(同步非阻塞)还是多路复用,都是同步IO模型,其中BIO是同步阻塞模型,NIO和多路复用是同步非阻塞模型。
多路IO复用
多路IO复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序执行相应的读写操作,没有文件句柄就绪时监视线程会被阻塞,交出CPU,直到有文件/连接就绪。多路指的是网络连接,复用指的是同一个线程可以被复用来服务多个文件描述符
常见的IO多路复用模型有:select模型,poll模型,epoll模型
select模型
select函数的API
cpp
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
//功能: 监听多个文件描述符的属性变化(读,写,异常)
//参数:
// nfds : 最大文件描述符+1
// readfds : 需要监听的读的文件描述符存放集合
// writefds :需要监听的写的文件描述符存放集合 NULL
// exceptfds : 需要监听的异常的文件描述符存放集合 NULL
// timeout: 多长时间监听一次 固定的时间,限时等待 NULL 永久监听
// struct timeval {
// long tv_sec; /* seconds */ 秒
// long tv_usec; /* microseconds */微妙
// };
//返回值: 返回的是变化的文件描述符的个数
//注意: 变化的文件描述符会存在监听的集合中,未变化的文件描述符会从集合中删除
//监视的文件描述符有三类,readfds,writefds,exceptfds
//调用后函数会阻塞,直到有描述符就绪(有数据读、写、或者有except),或者超时(timeout指定时间,如果立即返回设置null),函数返回
//当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。
select模型是最古老的IO多路复用机制之一,使用fd_set数据结构来保存文件描述符集合,并提供了select()函数来等待文件描述符的就绪状态。它有一个限制,即所监视的文件描述符数量有一个上限,通常是1024。
每次调用select()时,都需要将整个文件描述符集合从用户空间复制到内核空间,同时只是返回变化的文件描述符的个数,具体哪个那个变化需要遍历,这都可能带来性能问题。
优点:良好的跨平台性
缺点:单个进程能够监视的文件描述符的数量存在最大限制 在Linux上为1024可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但这样会造成效率的降低 每次调用 select,都需要把 fd 集合从用户态拷贝到内核态,这个开销在 fd 很多时会很大 对 socket 扫描时是线性扫描,采用轮询的方法,效率较低(高并发)
线性扫描:select 函数会扫描所有被监视的 socket,依次检查每个 socket 的就绪状态;
轮询方式:select 函数会不断地轮询检查所有被监视的 socket,直到有 socket 就绪或者超时,这种轮询方式会占用大量 CPU 资源,因为即使没有 socket 就绪,CPU 也在不停地轮询检查。
效率问题:
-
在高并发场景下,被监视的 socket 数量会非常大,线性扫描和轮询的方式会导致 select 函数的性能下降。
-
因为每次调用 select 函数时,都需要扫描所有的 socket,当 socket 数量增加时,扫描的时间也会随之增加。
-
这种低效的扫描方式会导致 CPU 利用率偏高,无法支撑高并发的场景。
poll模型
cpp
poll API
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
功能: 监听多个文件描述符的属性变化
参数:
fds : 监听的数组的首元素地址
nfds: 数组有效元素的最大下标+1
timeout : 超时时间 -1是永久监听 >=0 限时等待
数组元素:
struct pollfd
struct pollfd {
int fd; /* file descriptor */ 需要监听的文件描述符
short events; /* requested events */需要监听文件描述符什么事件 EPOLLIN 读事件 EPOLLOUT写事件
short revents; /* returned events */ 返回监听到的事件 EPOLLIN 读事件 EPOLLOUT写事
};
相比于select的优点就是没有文件描述符1024的限制,不过缺点一样:每次都需要将需要监听的文件描述符从应用层拷贝到内核每次都需要将数组中的元素遍历一遍才知道那个变化了
epoll模型
Linux特有的IO多路复用机制,自从2.5.44内核版本引入后成为主流。它使用基于事件的方式来管理文件描述符,使用一个事件表(event table)来保存文件描述符和事件信息,并提供了epoll_create()、epoll_ctl()和epoll_wait()等函数来操作事件表。
cpp
epollAPI
1. 创建红黑树
#include <sys/epoll.h>
int epoll_create(int size);
参数:
size : 监听的文件描述符的上限, 2.6版本之后写1即可,
返回: 返回树的句柄
2. 上树 下树 修改节点
epoll_ctl
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
参数:
epfd : 树的句柄
op : EPOLL_CTL_ADD 上树 EPOLL_CTL_DEL 下树 EPOLL_CTL_MOD 修改
fd : 上树,下树的文件描述符
event : 上树的节点
typedef union epoll_data {
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 */ 需要监听的文件描述符
};
例如,将cfd上树
int epfd = epoll_create(1);
struct epoll_event ev;
ev. data.fd = cfd;
ev.events = EPOLLIN; //监听读事件,其他事件包括EPOLLOUT,EPOLLERR,EPOLLHUP,各自的含义是监听写事件、错误事件、挂起事件
epoll_ctl(epfd, EPOLL_CTL_ADD,cfd, &ev);
3. 监听
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
功能: 监听树上文件描述符的变化
epfd : 数的句柄
events : 接收变化的节点的数组的首地址
maxevents : 数组元素的个数
timeout : -1 永久监听 大于等于0 限时等待
返回值: 返回的是变化的文件描述符个数
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。(读,写,错误,挂起),这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是logn,其中n为红黑树元素个数)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback ,它会将发生的事件添加到rdlist双链表中,在epoll中,对于每一个事件,都会建立一个epitem结构体,如下所示:
cpp
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
当调用epoll_wait检查是否有事件发生时,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户。
优点:
-
效率提升,不是轮询方式,不会随着文件描述符数量增加而效率下降
-
没有最大并发连接1024的限制
-
只有活跃的文件描述符才会使用回调函数,只管理活跃连接,而与连接总数无关
-
同时使用了内存拷贝,利用mmap()文件映射内存加速与内核空间的消息传递,即epoll使用mmap减少复制开销
工作方式有两种:一种是水平触发:LT ,一种是边沿触发:ET
从电路信号来讲,水平触发就是持续的高电平或者持续的低电平
而边沿触发则是电平从低到高的一个变化,或者电平从高到底的变化
-
监听读缓冲区变化
-
水平触发:只要读缓冲区有数据就会触发epoll_wait()
-
边沿触发:数据来一次,epoll_wait()只触发一次
-
-
监听写缓冲区的变化
-
水平触发:只要可以写,那么就会触发
-
边沿触发:数据从有到无,才会触发
-
因为如果设置为水平触发,只要缓存区有数据epoll_wait就会被触发,epoll_wait是一个系统调用,尽量少调用
所以尽量使用边沿触发,边沿出触发数据来一次只触发一次,这个时候要求一次性将数据读完,所以while循环读,读到最后read默认带阻塞,不能让read阻塞,因为不能再去监听,设置cfd为非阻塞,read读到最后一次返回值为-1.判断errno的值为EAGAIN,代表数据读干净
工作中,边沿触发+非阻塞 = 高速模式