I/O 多路复用
I/O 多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是 读就绪 或者 写就绪 ),能够通知程序进行相应的读写操作。
select 、 poll 和 epoll 是 Linux API 提供的 I/O 复用方式。.
本质上就是,把本应该由程序员的事情交给内核负责:检测这些读写缓冲区是否可用(需要调用阻塞函数);检测一轮以后内核将可操作的fd告诉我们,我们再去操作就不会再阻塞。
select和poll的底层是线性表
服务器的文件描述符
服务器包含两种文件描述符,每一个都有两个缓存区:
1、监听LFD:用于标志是否有数据是否能够读写数据,需要调用accept系统调用。(有且仅有一个)
2、通讯FD1:读取和输出数据,调用read/recv、write/send方法。(N,每建立一次连接加1个)
问题在于该三个方法(系统调用)都是阻塞的,当全部在一个线程里就会出现一个阻塞都阻塞。
使用多路IO复用,四个buffer就不需要自己去维护了;交由内核维护。
当内核检测之后会通知那些缓冲区 可以读写,再去系统调用,由于已经就绪不会再有堵塞发生。
当处理数据并不是同时进行,而是优先到后进行的(因为是单线程)。
select
概述
可跨平台
select 系统调用是用来让我们的程序**监视多个文件句柄的状态变化的** 。
select 函数监视的文件描述符分 3 类:writefds 、 readfds 、和 exceptfds 。
调用后 select 函数会阻塞 ,直到有描述符就绪(有数据可读 、可写 、或者有 except ),或者超时(timeout 指定等待时间,如果立即返回设为 null 即可),函数返回 。
缺点
select 本质上是通过设置或者检查存放 fd 标志位的数据结构来进行下一步处理。这样所带来的缺点是:
1 、 单个进程可监视的 fd 数量被限制,即能监听端口的大小有限;
2 、 它仅仅知道了,有I/O事件发生了,却并不知道是哪那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。所以select具有**O(n)**的无差别轮询复杂度,同时处理的流越多,无差别轮询时间就越长。
3 、需要维护一个用来存放大量 fd 的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大。
select的实现
1、假如程序同时监视如下图的sock1、sock2和sock3三个socket,那么在调用select之后,操作系统把 进程A 分别加入这三个socket的等待队列中。
2、当有任意socket收到数据之后,中断程序唤醒进程A,因此进程A加入工作队列;
3、当进程A被加入工作进程说明起码会有一个socket有数据,因此接下来就需要遍历所有的socket即可。
问题:
1、进程A需要加入到 所有监视socket的等待队列 中,并且当被唤醒时有需要从 每一个socket中被移除 ;
2、进程A被唤醒后,程序并不知道哪些socket收到数据,还需要遍历一次。
函数
int select (int n, fd_set *readfds , fd_set *writefds , fd_set *exceptfds ,
struct timeval *timeout);
n:要检测的文件描述符集合里,最大的文件描述符的值+1;这是因为fd是线性表(数组)的下标;或者直接写1024。
readfds:读集合,检测一系列文件描述符的读缓冲区。
传入传出参数,读集合(放需要写的fd) 一般情况下都是需要检测的,这样才知道通过哪个文件描述符接收数据。
writefds:写集合,检测一系列文件描述符的读缓冲区。
传入传出参数,如果不需要使用这个参数可以指定为NULL。
exceptfds :异常集合
slect函数检测这些集合里的文件描述符,如果这些集合都没有满足条件(读集合里fd的读缓存区全为空,写集合里fd的写缓冲区全为慢的)select会一直在检测;timeout设置最多检测时间;timeout=0,select函数不阻塞,直接退出。
为什么类型是fd_set *?
因为我们需要把一块内存传给select,在函数体内部修改这个内存;也就是缺点3。
fd_set
占据1024bit,128Byte,int[32];=但是以bit去处理数据。
每一个文件标识符对应一个bit。
如果集合中标志位为 0代表不检测 这个fd状态;
如果集合中标志位为 1代表检测 这个fd状态;这个fd在fd_set里
fd3 6 9 10读缓存区有数据,记录下来并回传给用户空间;
遍历检查fs_set是否有为1的,为1的说明已经准备就绪,分辨一下是什么类型的缓存区。
void FD_CLR(int fd, fd_set *set);
// 判断文件描述符fd是否在set集合中 == 读一下fd对应的标志位到底是0还是1
int FD_ISSET(int fd, fd_set *set);
// 将文件描述符fd添加到set集合中 == 将fd对应的标志位设置为1
void FD_SET(int fd, fd_set *set);
// 将set集合中, 所有文件文件描述符对应的标志位设置为0, 集合中没有添加任何文件描述符
void FD_ZERO(fd_set *set);
select流程
- 先将需要监控读、写或异常的fd分别存入对应的集合中;
- 这三个集合会被拷入进内核中;内核基于一个线性表去轮询这些集合的事件;
- 内核发现出现了事件(可以读写或者出现异常),传出三个集合;
- 传出的三个集合会被内核写到传入的三个集合的地址上(readfds 、writefds 、exceptfds 被内核修改)。
举个栗子:
集合 | 传入 | 传出 |
---|---|---|
readfds | 5、6、7、8、9 | 5、6、7、8、9 |
writefds | 7、8、9、10 | 9、10 |
exceptfds | 5-10 | 空 |
传出和传入的集合在同一块内存上,由内核负责改写。
读集合中有5和6号描述符,判断一下到底是用于监听的还是数据的;如果是监听的调用accept,与客户端建立链接;读数据就是调用read/rev。
写集合有9、10,调用send。
服务器代码示例
- 初始化fd_set,服务器的套接字fd并绑定
- while(1)
- 运行select,并把select之后的fd_set提出来进行遍历,分辨是监听的还是数据的
- 如果是监听的,将新的fd加入fd_set中
初始化服务器套接字并绑定
/*服务器描述符,创建服务器基本性质,但不包含地址信息,需要使用bind绑定部分信息*/
int iSocketServer= socket(AF_INET, SOCK_STREAM, 0);
/* 端口复用,服务器关闭后端口可以like使用 */
int opt = 1;
setsockopt(iSocketServer, SOL_SOCKET, SO_REUSEADDR, (void*)&opt, sizeof(opt));
/*创建服务器的Addr信息,IP和端口*/
struct sockaddr_in tSocketServerAddr;
/*客户端IP和端口*/
struct sockaddr_in tSocketClientAddr;
/*定义服务器的相关性质*/
tSocketServerAddr.sin_family =</