今天同事分享了wrk和ab的测压工具的使用,讲到了”IO多路复用、事件驱动、reactor模式“,具体还是没了解的很深,下次分享会准备讲解一下这块,同事分享了相关的资料,提前先收藏看一下,到时候就不会什么都听不懂了。
1、什么是文件
可以将其理解为一个N byte的序列:b1, b2, b3, b4, … bN。
实际上所有的I/O设备都被抽象为了文件这个概念,一切皆文件,Everything is File,磁盘、网络数据、终端,甚至进程间通信工具管道pipe等都被当做文件对待,所有的I/O操作也都可以通过文件读写来实现,这一非常优雅的抽象可以让程序使用一套接口就能对接所有外设I/O操作。
常用的I/O操作接口一般有以下几类:
打开文件,open
改变读写位置,seek
文件读写,read、write
关闭文件,close
2、文件描述符
要想进行I/O读操作,像磁盘数据,我们需要指定一个buff用来装入数据
read(buff);
这里我们忽略了一个关键问题,那就是虽然我们指定了往哪里写数据,但是我们该从哪里读数据呢?
假设去一个饭店吃饭,服务员会给一个排队序号,通过这个序号服务员就能找到你,这里的好处就是服务员无需记住你是谁、你的名字是什么、来自哪里、喜好是什么等等,这里的关键点就是服务员对你一无所知,但依然可以通过一个号码就能找到你。
同样的,在Linux里要想使用文件,我们也需要借助一个号码,这个号码就被称为了文件描述符,file descriptors。
因此,文件描述仅仅就是一个数字而已,但是通过这个数字我们可以操作一个打开的文件。
有了文件描述符,进程可以对文件一无所知,比如文件在磁盘的什么位置、加载到内存中又是怎样管理的等等,这些信息统统交由操作系统打理,进程无需关心,操作系统只需要给进程一个文件描述符就足够了。
因此我们来完善上述程序:
int fd = open(file_name); // 获取文件描述符read(fd, buff);
3、文件描述符太多了怎么办
server的处理逻辑通常是读取客户端请求数据,然后执行某些特定逻辑:
if(read(con_fd, request_buff) > 0) { do_something(request_buff); }
假设同时处理两个客户端的请求:
if(read(socket_fd1, buff) > 0) { // 处理第一个 do_something(); } if(read(socket_fd2, buff) > 0) { // 处理第二个 do_something(); }
这是非常典型的阻塞式I/O,如果此时没有数据可读那么进程会被阻塞而暂停运行,就无法处理第二个请求,即使第二个请求的数据已经就位,这也就意味着处理某一个客户端时由于进程被阻塞导致剩下的所有其它客户端必须等待,在同时处理几万客户端的server上,这显然是不能容忍的。
或许会想到使用多线程,为每个客户端请求开启一个线程,注意,在高并发下,我们不可能为成千上万个请求开启成千上万个线程,大量创建销毁线程会严重影响系统性能。
4、如何解决
这里的关键点在于,我们事先并不知道一个文件描述对应的I/O设备是否是可读的、是否是可写的,在外设的不可读或不可写的状态下进行I/O只会导致进程阻塞被暂停运行。
如果有文件描述符可读写就主动告诉我。
比如,生活中会接到推销电话,一天下来接上十个八个推销电话会被烦死,我们需要的是不要打电话给我,我可以记下你们的电话,有需要我会主动打给你们。
在这个例子中,你,就好比内核,推销者就好比应用程序,电话号码就好比文件描述符,和你用电话沟通就好比I/O。
因此一种更好的方法是,我们把这些感兴趣的文件描述符扔给内核,内核替我监视着它们,有可以读写的文件描述符时就告诉我,我再处理,而不是去内核轮循。
5、IO多路复用,IO multiplexing
多路复用实现了一个线程处理多个 I/O 句柄的操作,多路指的是多个数据通道,复用指的是使用一个或多个固定线程来处理每一个 Socket,select、poll、epoll 都是 I/O 多路复用的具体实现,线程一次 select 调用可以获取内核态中多个数据通道的数据状态,多路复用解决了同步阻塞 I/O 和同步非阻塞 I/O 的问题,是一种非常高效的 I/O 模型。
与多进程和多线程技术相比,I/O多路复用技术的最大优势是系统开销小,系统不必创建进程/线程,也不必维护这些进程/线程,从而大大减小了系统的开销。
所谓I/O多路复用指的是这样一个过程:
1. 拿到了一堆文件描述符(不管是网络相关的、还是磁盘文件相关等等,任何文件描述符都可以)
2. 通过调用某个函数告诉内核:"这个函数你先不要返回,替我监视着这些描述符,当这堆文件描述符中有可以进行I/O读写操作的时候再返回"(这个函数调用的时候,内核就会用内核线程来监听这些描述符)
3. 当调用的这个函数返回后我们就能知道哪些文件描述符可以进行I/O操作了。
select、poll、epoll方法都是IO多路复用的机制,但是他们的机制有很大的区别
6、select
参数详解
#include <sys/select.h> #include <sys/time.h> // 返回值:就绪描述符的数目,超时返回0,出错返回-1 int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
1. 第一个参数maxfdp1指定待测试的描述字个数,它的值是待测试的最大描述字加1(因此把该参数命名为maxfdp1),描述字0、1、2…maxfdp1-1均将被测试。
2. 中间的三个参数readset、writeset和exceptset指定我们要让内核测试读、写和异常条件的描述字。如果对某一个的条件不感兴趣,就可以把它设为空指针。struct fd_set可以理解为一个集合,这个集合中存放的是文件描述符,可通过以下四个宏进行设置:
void FD_ZERO(fd_set *fdset); // 清空集合 void FD_SET(int fd, fd_set *fdset); // 将一个给定的文件描述符加入集合之中 void FD_CLR(int fd, fd_set *fdset); // 将一个给定的文件描述符从集合中删除 int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
3. timeout告知内核等待所指定描述字中的任何一个就绪可花多少时间, 其timeval结构用于指定这段时间的秒数和微秒数。
struct timeval{ long tv_sec; //seconds long tv_usec; //microseconds };
这个参数有三种可能:
(1)永远等待下去:仅在有一个描述字准备好I/O时才返回,为此,把该参数设置为空指针NULL。
(2)等待一段固定时间:在有一个描述字准备好I/O时返回,但是不超过由该参数所指向的timeval结构中指定的秒数和微秒数。
(3)根本不等待:检查描述字后立即返回,这称为轮询,为此,该参数必须指向一个timeval结构,而且其中的定时器值必须为0。
描述
在select这种I/O多路复用机制下,把要监控的fd_set(文件描述集合)通过函数参数的形式告诉select,然后select会将fd_set拷贝到内核中(描述符原来是在用户线程空间,现在内核线程要监控,所有要拷贝到内核线程空间),数据拷贝是有性能损耗的,因此为了减少这种数据拷贝带来的性能损耗,Linux内核对fd_set的大小做了限制,并规定用户监控的fd_set不能超过1024个,同时当select返回后我们仅仅能知道有些fd_set可以读写了,但是不知道是哪一个。
监听文件描述符list,进行一个线性的查找 O(n)。
缺点
1. 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
2. 线程只能知道有文件描述符满足要求了,但是不知道是哪个,所以每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大,
3. select支持的文件描述符数量太小了,默认是1024
7、poll
poll的实现和select非常相似,只是描述fd集合的方式不同,poll使用pollfd结构而不是select的fd_set结构,其他的都差不多,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制,poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的
8、epoll
epoll 是select和poll的改进版本,可以避免select的三个缺点。
select和poll都只提供了一个函数——select或者poll函数,而epoll提供了三个函数,epoll_create、epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄,epoll_ctl是注册要监听的事件类型,epoll_wait则是等待事件的产生。
epoll使用了内核文件级别的回调机制O(1)。
对于第一个缺点,epoll的解决方案在epoll_ctl函数中,每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝,epoll保证了每个fd在整个过程中只会拷贝一次。
对于第二个缺点,在select和poll机制下,进程要亲自下场去各个文件描述符上等待,任何一个文件描述可读或者可写就唤醒进程,但是进程被唤醒后还要遍历一遍,epoll的解决方案是只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个callback,只有活跃的socket才会主动调用callback,再把就绪的fd加入一个就绪链表,epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd(利用schedule_timeout()实现睡一会,判断一会的效果)。
对于第三个缺点,epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目。
9、事件驱动 reactor模式
在epoll这种机制下,实际上利用的就是 "如果有文件描述符可读写就主动告诉我" 这种策略,进程不需要去遍历各个文件描述符,这种机制实际上就是事件驱动,Event-driven。
通常IO操作都是阻塞I/O的,当调用read时,如果没有数据收到,那么线程或者进程就会挂起,直到收到数据。
当服务器需要处理1000个连接的的时候,那么会需要1000个线程或进程来处理1000个连接,而1000个线程大部分是被阻塞起来的,由于CPU的核数或超线程数一般都不大,比如4核要跑1000个线程,那么每个线程的时间槽非常短,而线程切换非常频繁。
这样是有问题的:
(1)线程是有内存开销的,1个线程可能需要512K(或2M)存放栈,那么1000个线程就要512M(或2G)内存。
(2)线程的切换或者说上下文切换是有CPU开销的,当大量时间花在上下文切换的时候,分配给真正的操作的CPU就要少很多。
那么,就要引入非阻塞I/O的概念,通过fcntl(POSIX)或ioctl(Unix)设为非阻塞模式,当调用read时,如果有数据收到,就返回数据,如果没有数据收到,就返回一个错误,这样是不会阻塞线程了,但是还是要不断的轮询来读取或写入。
于是,我们需要引入IO多路复用,这样在处理1000个连接时,只需要1个线程监控就绪状态,对就绪的每个连接开一个线程处理就可以了,这样需要的线程数大大减少,减少了内存开销和上下文切换的CPU开销。
1. 早期的socket服务器是这样的:核心是一个线程处理一个socket。
ClientSocket s = accept (ServerSocket); //阻塞 new Thread (s, function() { while (have data) { s.read(); //阻塞 s.send('xxx'); } s.close(); });
2. 核心是一个线程处理一个socket。
ClientSocket s = accept (ServerSocket); //阻塞 waitting_set.add(s); while(1) { set_have_data ds = select(waitting_set); //多路复用器 //多路复用器是说多个socket数据通路 //一个select就可以监听 //就好像他们是一个数据通路一样 foreach(ds as item) { item.read(); //reactor这里不会阻塞,采用异步IO item.send('xxx'); } item.close(); }
举例,模拟一个tcp服务器处理30个客户socket
假设你是一个老师,让30个学生解答一道题目,然后检查学生做的是否正确,你有下面几个选择:
第一种选择:按顺序逐个检查,先检查A,然后是B,之后是C、D。。。这中间如果有一个学生卡主,全班都会被耽误,这种模式就好比,用循环挨个处理socket,根本不具有并发能力。
第二种选择:你创建30个分身,每个分身检查一个学生的答案是否正确,这种类似于为每一个用户创建一个进程或者线程处理连接。
第三种选择:你站在讲台上等,谁解答完谁举手,这时C、D举手,表示他们解答问题完毕,你下去依次检查C、D的答案,然后继续回到讲台上等,此时E、A又举手,然后去处理E和A。。。
第三种就是IO复用模型,Linux下的select、poll和epoll就是干这个的,将用户socket对应的fd注册进epoll,然后epoll帮你监听哪些socket上有消息到达,这样就避免了大量的无用操作,此时的socket应该采用非阻塞模式,这样,整个过程只在调用select、poll、epoll这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的reactor模式。
推荐