目录
一.前提知识
1.IO过程
(1)IO一般分两步进行:
- ①等待IO就绪
- ②拷贝IO数据到内核或外设
- ③即 IO = 等 + 拷贝
(2)本质上IO真正有效的步骤是拷贝。
(3)什么叫高效的IO?
- 在特定的时间段内,大大的减少等的比重,增加拷贝的比重即为高效IO
2.钓鱼的例子
(1)钓鱼分几步? 钓鱼 = 等 + 钓
(2)具体场景
- 张三:自己盯着鱼竿,什么也不做(阻塞 IO)
- 李四:定时检查鱼竿,检查完之后没有动静做自己的事(非阻塞IO)
- 王五:鱼竿上弄了个铃铛,等铃铛响了拉起鱼竿,其他时间做自己的事情(信号驱动IO)
- 赵六:弄了100个鱼竿,都插到岸边,定期检测100个鱼竿 (多路复用,多路转接)
- 田七:给桶,鱼竿让下属去钓,钓好了给我打电话,我自己办自己的事(异步lO)
(3)一些问题和解释
① 张三,李四,王五的钓鱼效率是否一样?
- 一样的,因为钓鱼的方式是一样的,只有一个杆;但是等的方式不一样
- 钓鱼期间他们做的任何事情我们不关心
②张三,李四,王五,赵六谁的钓鱼效率最高?
- 赵六,单位时间内赵六鱼竿上有鱼的概率是张李王的100倍
- 赵六一次等待多个鱼竿,可以将“ 等 ”的时间重叠
③张三,李四,王五,赵六 全部都是同步IO(只不过是等的方式不一样),因为最终 “ 钓 ” 的动作是自己做的 ; 而田七是异步IO,不用自己等,不用自己 “ 钓 ” ,直接拿到最后的结果。
④阻塞IO、非阻塞IO和信号驱动IO本质上是不能提高IO的效率的,但非阻塞IO和信号驱动IO能提高整体做事的效率。
⑤钓鱼过程中 :
- 钓鱼者 : 用户起的若干进程/线程
- 水桶: 缓冲区
- 电话:通知方式
- 鱼竿:文件描述符fd / 套接字
- 鱼:数据
- 河 : 内核
3.补充
(1)IO是站在硬件的角度上进行输入输出,谁在输入输出? 进程/线程
(2)关于不同设备进行IO
- 对文件进行的读写操作本质就是一种IO,文件IO对应的外设就是磁盘。
- 对网络进行的读写操作本质也是一种IO,网络IO对应的外设就是网卡。
(3)网卡上面有数据,OS如何得知?
- 输入就是操作系统将数据从外设拷贝到内存的过程,操作系统一定要通过某种方法得知特定外设上是否有数据就绪。
- 并不是操作系统想要从外设读取数据时外设上就一定有数据。比如用户正在访问某台服务器,当用户的请求报文发出后就需要等待从网卡当中读取服务器发来的响应数据,但此时对方服务器可能还没有收到我们发出的请求报文,或是正在对我们的请求报文进行数据分析,也有可能服务器发来的响应数据还在网络中路由。
- 但操作系统不会主动去检测外设上是否有数据就绪,这种做法一定会降低操作系统的工作效率,因为大部分情况下外设当中都是没有数据的,因此操作系统所做的大部分检测工作其实都是徒劳的。
- 操作系统实际采用的是硬件中断的方式来得知外设上是否有数据就绪的,当某个外设上面有数据就绪时,该外设就会向CPU当中的中断控制器发送中断信号,中断控制器再根据产生的中断信号的优先级按顺序发送给CPU。
- 每一个中断信号都有一个对应的中断处理程序,存储中断信号和中断处理程序映射关系的表叫做中断向量表,当CPU收到某个中断信号时就会自动停止正在运行的程序,然后根据该中断向量表执行该中断信号对应的中断处理程序,处理完毕后再返回原被暂停的程序继续运行。
- 即一旦有数据,硬件会向硬件发送中断信息,CPU针脚是有编号的所以CPU知道哪个硬件就绪了,然后就去执行这个外设曾经预装的方法,将数据拷贝到内存
- 注意,CPU不直接和外设打交道指的是在数据层面上,而外设其实是可以直接将某些控制信号发送给CPU当中的某些控制器的。
(4)报文理解
① 操作系统任何时刻都可能会收到大量的数据包,因此操作系统必须将这些数据包管理起来。所谓的管理就是“先描述,再组织”,在内核当中有一个结构叫做sk_buff,该结构就是用来管理和控制接收或发送数据包的信息的。
- 当操作系统从网卡当中读取到一个数据包后,会将该数据依次交给链路层、网络层、传输层、应用层进行解包和分用,最终将数据包中的数据交给了上层用户,那对应到这个sk_buff结构来说具体是如何进行数据包的解包和分用的呢?
- 当操作系统从网卡中读取到一个数据包后,就会定义出一个sk_buff结构,然后用sk_buff结构当中的data指针指向这个读取到的数据包,并将定义出来的这个sk_buff结构与其他sk_buff结构以双链表的形式组织起来,此时操作系统对各个数据包的管理就变成了对双链表的增删查改等操作。
- 接下来我们需要将读取上来的数据包交给最底层的链路层处理,进行链路层的解包和分用,此时就是让sk_buff结构当中的mac_header指针指向最初的数据包,然后向后读取链路层的报头,剩下的就是需要交给网络层处理的有效载荷了,此时便完成了链路层的解包,。
- 这时链路层就需要将有效载荷向上交付给网络层进行解包和分用了,这里所说的向上交付只是形象的说法,实际向上交付并不是要将数据从链路层的缓冲区拷贝到网络层的缓冲区,我们只需要让sk_buff结构当中的network_header指针,指向数据包中链路层报头之后的数据即可,然后继续向后读取网络层的报头,便完成了网络层的解包。
- 紧接着就是传输层对数据进行处理了,同样的道理,让sk_buff结构当中的transport_header指针,指向数据包中网络层报头之后的数据,然后继续向后读取传输层的报头,便完成了传输层的解包。
- 传输层解包后就可以根据具体使用的传输层协议,对应将剩下的数据拷贝到TCP或UDP的接收缓冲区供用户读取即可
- 发送数据时对数据进行封装也是同样的道理,就是依次在数据前面拷贝上对应的报头,最后再将数据发送出去(UDP)或拷贝到发送缓冲区(TCP)即可。也就是说,数据包在进行封装和解包的过程中,本质数据的存储位置是没有发生变化的,我们实际只是在用不同的指针对数据进行操作而已。
② 内核中的sk_buff并不像上面那样简单:
- 一方面,为了保证高效的网络报文处理效率,这就要求sk_buff的结构也必须是高效的。
- 另一方面,sk_buff结构需要被内核协议中的各个协议共同使用,因此sk_buff必须能够兼容所有的网络协议。
- sk_buff结构实际是非常复杂的,图示只是简化过程
(5)阻塞和非阻塞
①所谓的阻塞,是用户层的感受;在内核的本质是进程被挂起(状态由R设为S等其他状态,放到阻塞队列里),需要等待某种事件就绪
- 进程为什么会被挂起 ? 等待数据就绪
- 事件就绪时是由OS唤醒进程/线程
- OS阻塞,OS唤醒
②所谓非阻塞轮询的本质是什么? 在做事件就绪的检测工作
- 非阻塞轮询是由用户发起,OS执行;
(6)read / recv , write / send
- 读lO=等待读事件就绪+内核数据拷贝至用户空间
- 写lO=等待写事件就绪+用户数据拷贝至内核空间
二.五种IO模型
1.阻塞IO
在内核将数据准备好之前, 系统调用会一直等待. 所有的套接字, 默认都是阻塞方式.
比如当调用recvfrom函数从某个套接字上读取数据时,可能底层数据还没有准备好,此时就需要等待数据就绪,当数据就绪后再将数据从内核拷贝到用户空间,最后recvfrom函数才会返回。
在recvfrom函数等待数据就绪期间,在用户看来该进程或线程就阻塞住了,本质就是操作系统将该进程或线程的状态设置为了某种非R状态,然后将其放入等待队列当中,当数据就绪后操作系统再将其从等待队列当中唤醒,然后该进程或线程再将数据从内核拷贝到用户空间。
以阻塞方式进行IO操作的进程或线程,在“等”和“拷贝”期间都不会返回,在用户看来就像是阻塞住了,因此我们称之为阻塞IO。
2.非阻塞IO
- 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码.
- 非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询. 这对CPU来说是较大的浪费, 一般只有特定场景下才使用.
- 比如当调用recvfrom函数以非阻塞方式从某个套接字上读取数据时,如果底层数据还没有准备好,那么recvfrom函数会立马错误返回,而不会让该进程或线程进行阻塞等待。
- 因为没有读取的数据,因此该进程或线程后续还需要继续调用recvfrom函数,检测底层数据是否就绪,如果没有就绪则继续错误返回,直到某次检测到底层数据就绪后,再将数据从内核拷贝到用户空间然后进行成功返回。
- 每次调用recvfrom函数读取数据时,就算底层数据没有就绪,recvfrom函数也会立马返回,在用户看来该进程或线程就没有被阻塞住,因此我们称之为非阻塞IO。
- 阻塞IO和非阻塞IO的区别在于,阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由操作系统发起的,而非阻塞IO当数据没有就绪时,后续检测数据是否就绪的工作是由用户发起的。
3.信号驱动IO
- 内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作.
- 当底层数据就绪的时候会向当前进程或线程递交SIGIO信号,因此可以通过signal或sigaction函数将SIGIO的信号处理程序自定义为需要进行的IO操作,当底层数据就绪时就会自动执行对应的IO操作。
- 比如我们需要调用recvfrom函数从某个套接字上读取数据,那么就可以将该操作定义为SIGIO的信号处理程序。
- 当底层数据就绪时,操作系统就会递交SIGIO信号,此时就会自动执行我们定义的信号处理程序,进程将数据从内核拷贝到用户空间。
- 信号的产生是异步的,但信号驱动IO是同步IO的一种。
- 我们说信号的产生异步的,因为信号在任何时刻都可能产生。
- 但信号驱动IO是同步IO的一种,因为当底层数据就绪时,当前进程或线程需要停下正在做的事情,转而进行数据的拷贝操作,因此当前进程或线程仍然需要参与IO过程。
- 判断一个IO过程是同步的还是异步的,本质就是看当前进程或线程是否需要参与IO过程,如果要参与那就是同步IO,否则就是异步IO。
4.IO多路转接
- 虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态
5.异步IO
- 由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据).
- 进行异步IO需要调用一些异步IO的接口,异步IO接口调用后会立马返回,因为异步IO不需要你进行“等”和“拷贝”的操作,这两个动作都由操作系统来完成,你要做的只是发起IO。
- 当IO完成后操作系统会通知应用程序,因此进行异步IO的进程或线程并不参与IO的所有细节。
6.补充
(1)当我在收数据时肯定是OS先收到数据,所以你所谓的读取数据本质是∶
- 1.OS把数据拿到
- 2.把内核收到的数据拷贝至用户空间
(2)从体系结构角度去理解,先收到数据的一定是硬件,再往上面是驱动——OS——用户。
(3)有一个信号29-SIGIO,当我们OS收到了对应的数据时OS是会主动向目标进程发送SIGIO,问题是之前怎么不知道呢?
- SIGIO的默认处理动作是忽略 , 实际在处理的时候可以注册一个SIGIO的处理方法,当我收到SIGIO的时候直接调用recv,这样就相当于我不用管IO了,当底层好了就直接给我发信号,我直接回调式的调用recv进行数据读取。但是这个在简单IO是可以的,复杂IO用的场景并不多
- 29-SIGIO是一个普通信号,普通信号对于我们的计算机来说在任何一个时刻只允许发送一个信号即进程只会记录一个普通信号,所有一般的信号都有可能丢失。
(4)在网络当中,因为数据经过长距离传输,如果采用阻塞式的recv你就会发现90%的时间都在等,真正的IO时间很少,高效的IO是我们考虑的。一个网络的链接能传多少数据时固定的,用户什么时候发,怎么发完全是不确定的,理论上高效IO是让对方发快点,不断地发,但是我们不能对对方提要求,因为对方什么时候发,怎么发和对方的网络以及发送情况密切相关,所以我们只能从自己的角度去考量。
三.几个高级IO概念
1.同步通信 vs 异步通信
(1)同步和异步关注的是消息通信机制.
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回. 但是一旦调用返回,就得 到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步 过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、通知来通知调用 者,或通过回调函数处理这个调用
(2)这里的同步通信和进程之间的同步是完全不想干的概念
- 进程/线程同步也是进程/线程之间直接的制约关系
- 是为完成某种任务而建立的两个或多个线程,这个线程需要在某些位置上协调他们的工作次序而等待,传递信息所产生的制约关系. 尤其是在访问临界资源的时候
- 进程/线程同步指的是,在保证数据安全的前提下,让进程/线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,谈论的是进程/线程间的一种工作关系。
- 而同步IO指的是进程/线程与操作系统之间的关系,谈论的是进程/线程是否需要主动参与IO过程。
-
以后看到“同步”,搞清楚,是同步通信异步通信的同步 , 还是同步与互斥的同步.
2.阻塞 vs 非阻塞
- 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回.
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程
3.阻塞IO
- 调用read函数, 如果我们不进行输入操作,此时该进程就会阻塞住,根本原因就是因为此时底层数据不就绪,因此read函数需要进行阻塞等待。
- 我们进行了输入操作,此时read函数就会检测到底层数据就绪,然后立马将数据读取到从内核拷贝到我们传入的buffer数组当中,并且将读取到的数据输出到显示器上面,最后我们就看到了我们输入的字符串。
- C++当中的cin和C语言当中的scanf也可以读取从键盘输入的字符,但是cin和scanf会提供用户缓冲区,为了避免这些因素的干扰,因此这里选择使用read函数进行读取
4.非阻塞IO
(1)函数fcntl
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ )
参数:
- fd:已经打开的文件描述符。
- cmd:需要进行的操作。
- …:可变参数,传入的cmd值不同,后面追加的参数也不同。
fcntl函数常用的5种功能与其对应的cmd取值如下:
- 复制一个现有的描述符(cmd=F_DUPFD)。
- 获得/设置文件描述符标记(cmd=F_GETFD或F_SETFD)。
- 获得/设置文件状态标记(cmd=F_GETFL或F_SETFL)。
- 获得/设置异步I/O所有权(cmd=F_GETOWN或F_SETOWN)。
- 获得/设置记录锁(cmd=F_GETLK, F_SETLK或F_SETLKW)。
返回值:
- 如果函数调用成功,则返回值取决于具体进行的操作。
- 如果函数调用失败,则返回-1,同时错误码会被设置。
(2)实现一个非阻塞通信
①SetNonBlock代码
- 使用F_GETFL将当前的文件描述符对应的文件状态标记(这是一个位图结构)
- 在获取到的文件状态标记上添加非阻塞标记O_NONBLOCK,再次调用fcntl 函数对文件状态标记进行设置,此时调用fcntl函数时传入的cmd值为 F_SETFL。
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL); //将当前fd的属性取出来
if(fl < 0){
std::cerr << "fcntl error" << std::endl;
return false;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK); //设置为非阻塞
return true;
}
②整体代码
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <cstring>
#include <cerrno>
bool SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if (fl < 0){
std::cerr << "fcntl error" << std::endl;
return false;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
return true;
}
int main()
{
SetNonBlock(0); // 0号fd设置为非阻塞
char buffer[1024];
while (true){
ssize_t size = read(0, buffer, sizeof(buffer)-1);
if (size < 0){
if (errno == EAGAIN || errno == EWOULDBLOCK){ //底层数据没有就绪
std::cout << strerror(errno) << std::endl;
sleep(1);
continue;
}
else if (errno == EINTR){ //在读取数据之前被信号中断
std::cout << strerror(errno) << std::endl;
- 底层的数据没有就绪,你再轮询检测一下,try again !
sleep(1);
continue;
}
else{
std::cerr << "read error" << std::endl;
- 底层的数数据就绪未知,被信号打断
break;
}
}
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
return 0;
}
- 需要注意的是,当read函数以非阻塞方式读取标准输入时,如果底层数据不就绪,那么read函数就会立即返回,但当底层数据不就绪时,read函数是以出错的形式返回的,此时的错误码会被设置为
EAGAIN
或EWOULDBLOCK
。 - 因此在以非阻塞方式读取数据时,如果调用read函数时得到的返回值是-1,此时还需要通过错误码进一步进行判断,如果错误码的值是EAGAIN 或 EWOULDBLOCK,说明本次调用read函数出错是因为底层数据还没有就绪,因此后续还应该继续调用read函数进行轮询检测数据是否就绪,当数据继续时再进行数据的读取。
- 调用read函数在读取到数据之前可能会被其他信号中断,此时read函数也会以出错的形式返回,此时的错误码会被设置为
EINTR
,此时应该重新执行read函数进行数据的读取 - 在以非阻塞的方式读取数据时,如果调用read函数读取到的返回值为-1,此时并不应该直接认为read函数在底层读取数据时出错了,而应该继续判断错误码,如果错误码的值为
EAGAIN
、EWOULDBLOCK
或EINTR
则应该继续调用read函数再次进行读取。
③结果
- 运行代码后,当我们没有输入数据时,程序就会不断调用read函数检测底层数据是否就绪。
- 一旦我们进行了输入操作,此时read函数就会在轮询检测时检测到,紧接着立马将数据读取到从内核拷贝到我们传入的buffer数组当中,并且将读取到的数据输出到显示器上面。
四.IO多路转接之select
1.前提
(1) select 是一种就绪事件的通知机制,等就绪事件发生通知上层
(2)事件就绪
- read,底层数据只要从无->有,有->多 ,即读事件就绪
- write,底层缓冲区剩余空间只要从无->有,有->多 ,即写事件就绪
- 只要底层有数据,只要底层有空间都叫做select的读事件写事件就绪,调用read / recv / write / send等函数就不会被阻塞
(3)select 一次可以等多个fd
2.初识select
- select系统调用是用来让我们的程序监视多个文件描述符的状态变化的;
- 程序会停在select这里等待,直到被监视的文件描述符有一个或多个发生了状态改变,select才会成功返回并将对应文件描述符的就绪事件告知调用者。
3.select函数
函数:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval* timeout )参数:
- 参数nfds是需要监视的最大的文件描述符值+1;
- rdset,wrset,exset分别对应于需要检测的可读文件描述符的集合,可写文件描述符的集 合及异常文件描 述符的集合;
- 参数timeout为结构timeval,用来设置select()的等待时间
参数 timeout 取值 :
- NULL:则表示select() 没有timeout,select将一直被阻塞,直到某个文件描述符上发生了事件;
- 0:非阻塞等待,仅检测描述符集合的状态,然后立即返回,并不等待外部事件的发生。
- 特定的时间值:select调用后在指定的时间内进行阻塞等待,如果在指定的时间段里没有事件发生,select将超时返回。
返回值:
- 执行成功则返回文件描述词状态已改变的个数
- 如果返回0代表在描述词状态改变前已超过timeout时间,没有返回
- 当有错误发生时则返回-1,错误原因存于errno,此时参数readfds,writefds, exceptfds和timeout的值变成不可预测。
select调用是被, 错误值可能为:
- EBADF : 文件描述词为无效的或该文件已关闭
- EINTR : 此调用被信号所中断
- EINVAL : 参数nfds 为负值。
- ENOMEM : 核心内存不足
(1)select在等待的多个描述符值中,最大的文件描述符+1,对多个文件描述符进行轮询检测; 可以理解为 i =0 ; i < nfds ; ++i
(2)fd_set 类似于 sigset_t , 是一个位图结构
- 调用select函数之前就需要用fd_set结构定义出 文件描述符集,然后将需要监视的文件描述符添加到文件描述符集当中,这个添加的过程本质就是在进行位操作,但是这个位操作不需要用户自己进行,系统提供了一组专门的接口,用于对fd_set类型的位图进行各种操作。
void FD_CLR(int fd, fd_set *set); // 用来清除描述词组set中相关fd 的位
int FD_ISSET(int fd, fd_set *set); // 用来测试描述词组set中相关fd 的位是否为真
void FD_SET(int fd, fd_set *set); // 用来设置描述词组set中相关fd的位
void FD_ZERO(fd_set *set); // 用来清除描述词组set的全部位
(3)用户调用select , select是一个系统级接口 ,所有的参数是给OS的 ,以读为例
- 用户调用的时候 user->kernal [ 是用户想要告诉OS什么?] : 请OS帮我检测一下在readfds中,所有的 fd 的状态,是否有[读事件]就绪的。
- select调用返回的时候 kernal->user [ 是OS想要告诉用户什么?] : 用户你好,你让我检测的readfds中的特定 fd 已经有就绪的了。
(4)关于timeval结构
- timeval结构用于描述一段时间长度,如果在这个时间内,需要监视的描述符没有事件发生则函数返回,返回值为0。
- timeval结构既做输入,又做输出 ;输出时表示在输入时间的时间内检测到了事件发生,剩余的时间。
- 如果timeval设置为NULL则表示我是阻塞式的等,如果我检测不到文件描述符就绪我就不返回;如果设置为0意味着我是非阻塞的等,检测─遍没有文件描述符就绪我就立马返回,如果设置成具体时间5s则表示在5S之内我以阻塞方式等,5s时间一到我就立马返回了。
4.select就绪条件
①读就绪
- socket内核中,接收缓冲区中的字节数,大于等于低水位标记SO_RCVLOWAT,此时可以无阻塞的读取该文件描述符,并且返回值大于0。
- socket TCP通信中,对端关闭连接,此时对该socket读,则返回0。
- 监听的socket上有新的连接请求。
- socket上有未处理的错误。
②写就绪
- socket内核中,发送缓冲区中的可用字节数,大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。
- socket的写操作被关闭(close或者shutdown),对一个写操作被关闭的socket进行写操作,会触发SIGPIPE信号。
- socket使用非阻塞connect连接成功或失败之后。
- socket上有未读取的错误。
③异常就绪
- socket上收到带外数据。
- 注:带外数据和TCP的紧急模式相关,TCP报头当中的URG标志位和16位紧急指针搭配使用,就能够发送/接收带外数据。
5.理解select执行过程
(1)因为内核会修改用户的fds集合,所以第二轮,第三轮? 每一轮都要重新设置 readfds
(2)为什么每次都要重新设定select?
- 等的多个fd中只要有一个fd就绪就会返回,返回的时候可能其他fd并没有就绪,只能在下次select的时候再等
(3)select 调用,每一次都需要进行对关心的fd进行重新设置,即对fd_set readfds进行设置;在你还没有readfds时你所有的fd在哪呢?
- 所以,select通常需要借助数组来吧自己历史上的所有fd进行保存
6.select基本工作流程
①要实现一个简单的select服务器,该服务器要做的就是读取客户端发来的数据并进行打印,那么这个select服务器的工作流程应该是这样的:
- 先初始化服务器,完成套接字的创建、绑定和监听。
- 定义一个fd_array数组用于保存监听套接字和已经与客户端建立连接的套接字,刚开始时就将监听套接字添加到fd_array数组当中。
- 然后服务器开始循环调用select函数,检测读事件是否就绪,如果就绪则执行对应的操作。
- 每次调用select函数之前,都需要定义一个读文件描述符集readfds,并将fd_array当中的文件描述符依次设置进readfds当中,表示让select帮我们监视这些文件描述符的读事件是否就绪。
- 当select检测到数据就绪时会将读事件就绪的文件描述符设置进readfds当中,此时我们就能够得知哪些文件描述符的读事件就绪了,并对这些文件描述符进行对应的操作。
- 如果读事件就绪的是监听套接字,则调用accept函数从底层全连接队列获取已经建立好的连接,并将该连接对应的套接字添加到fd_array数组当中。
- 如果读事件就绪的是与客户端建立连接的套接字,则调用read函数读取客户端发来的数据并进行打印输出。
- 当然,服务器与客户端建立连接的套接字读事件就绪,也可能是因为客户端将连接关闭了,此时服务器应该调用close关闭该套接字,并将该套接字从fd_array数组当中清除,因为下一次不需要再监视该文件描述符的读事件了。
② 说明一下:
- 因为传入select函数的readfds、writefds和exceptfds都是输入输出型参数,当select函数返回时这些参数当中的值已经被修改了,因此每次调用select函数时都需要对其进行重新设置,timeout也是类似的道理。
- 因为每次调用select函数之前都需要对readfds进行重新设置,所以需要定义一个fd_array数组保存与客户端已经建立的若干连接和监听套接字,实际fd_array数组当中的文件描述符就是需要让select监视读事件的文件描述符。
- 我们的select服务器只是读取客户端发来的数据,因此只需要让select帮我们监视特定文件描述符的读事件,如果要同时让select帮我们监视特定文件描述符的读事件和写事件,则需要分别定义readfds和writefds,并定义两个数组分别保存需要被监视读事件和写事件的文件描述符,便于每次调用select函数前对readfds和writefds进行重新设置。
- 服务器刚开始运行时,fd_array数组当中只有监听套接字,因此select第一次调用时只需要监视监听套接字的读事件是否就绪,但每次调用accept获取到新连接后,都会将新连接对应的套接字添加到fd_array当中,因此后续select调用时就需要监视监听套接字和若干连接套接字的读事件是否就绪。
- 由于调用select时还需要传入被监视的文件描述符中最大文件描述符值+1,因此每次在遍历fd_array对readfds进行重新设置时,还需要记录最大文件描述符值。
7. 利用select实现多路转接程序
(1)Sock类
- 编写一个Sock类,对套接字相关的接口进行一定程度的封装,为了让外部能够直接调用Socket类当中封装的函数,于是将这些函数定义成了静态成员函数。
#pragma once
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <cstring>
#include <cstdlib>
namespace ns_sock
{
class Sock{
public:
//创建套接字
static int Socket()
{
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0){
std::cerr << "socket error" << std::endl;
exit(2);
}
//设置端口复用
int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
return sock;
}
//绑定
static void Bind(int sock, int port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = INADDR_ANY;
socklen_t len = sizeof(local);
if (bind(sock, (struct sockaddr*)&local, len) < 0){
std::cerr << "bind error" << std::endl;
exit(3);
}
}
//监听
static void Listen(int sock, int backlog)
{
if (listen(sock, backlog) < 0){
std::cerr << "listen error" << std::endl;
exit(4);
}
}
};
}
(2)Select_Server类
#pragma once
#include "sock.hpp"
#include <sys/select.h>
#define BACK_LOG 5
#define NUM 1024
#define DFL_FD - 1
class Select_Server{
private:
int listen_sock; //监听套接字
int port; //端口号
public:
Select_Server(int _port)
: port(_port)
{}
~Select_Server()
{
if (listen_sock >= 0){
close(listen_sock);
}
}
public:
void InitSelectServer()
{
_listen_sock = ns_sock::Sock::Socket();
ns_sock::Sock::Bind(listen_sock, port);
ns_sock::Sock::Listen(listen_sock, BACK_LOG);
}
void Run()
{
fd_set readfds; //定义读文件描述符集
int fd_array[NUM]; //保存需要被监视读事件是否就绪的文件描述符
ClearFdArray(fd_array, NUM, DFL_FD); //将数组中的所有位置设置为无效
fd_array[0] = listen_sock; //将监听套接字添加到fd_array数组中下标为0的位置
for (;;){
//时间也是输入输出,所以如果你是间隔性的timeout返回,那么就需要对时间也进行重新设定
//struct timeval timeout = {5, 0}; //每隔 5s timeout一次 ,输出表示的是剩余时间
//对所有的合法fd进行在select中重新设定
FD_ZERO(&readfds); //清空readfds
//将fd_array数组当中的文件描述符添加到readfds当中,并记录最大的文件描述符
int maxfd = DFL_FD;
//第一次循环的时候,我们fd_array数组中至少已经有了一个fd,listen_sock
for (int i = 0; i < NUM; i++){
if (fd_array[i] == DFL_FD) //跳过无效的位置
continue;
FD_SET(fd_array[i], &readfds); //将有效位置的文件描述符添加到readfds当中
if (fd_array[i] > maxfd) //更新最大文件描述符
maxfd = fd_array[i];
}
switch (select(maxfd + 1, &readfds, nullptr, nullptr, nullptr)){
case 0:
std::cout<<"timeout..."<<std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
//正常的事件处理
//std::cout<<"有事件发生..."<<std::endl;
HandlerEvent(readfds, fd_array, NUM);
break;
}//end switch
}//end for
}
void HandlerEvent(const fd_set& readfds, int fd_array[], int num)
{
//如何判定那些文件描述符就绪了呢? 只需要判定特定的fd是否在rfds集合中
//我都有那些文件描述符呢? fd_array[]
for (int i = 0; i < num; i++){
if (fd_array[i] == DFL_FD){ //跳过无效的位置
continue;
}
if (fd_array[i] == listen_sock && FD_ISSET(fd_array[i], &readfds)){ //连接事件就绪
//获取连接
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
socklen_t len = sizeof(peer);
int sock = accept(_listen_sock, (struct sockaddr*)&peer, &len);
if (sock < 0){ //获取连接失败
std::cerr << "accept error" << std::endl;
continue;
}
std::string peer_ip = inet_ntoa(peer.sin_addr);
int peer_port = ntohs(peer.sin_port);
std::cout << "get a new link[" << peer_ip << ":" << peer_port << "]" << std::endl;
if (!SetFdArray(fd_array, num, sock)){ //将获取到的套接字添加到fd_array当中
close(sock);
std::cout << "select server is full, close fd: " << sock << std::endl;
}
}
else if (FD_ISSET(fd_array[i], &readfds)){ //读事件就绪
char buffer[1024];
ssize_t size = read(fd_array[i], buffer, sizeof(buffer)-1);
if (size > 0){ //读取成功
buffer[size] = '\0';
std::cout << "echo# " << buffer << std::endl;
}
else if (size == 0){ //对端连接关闭
std::cout << "client quit" << std::endl;
close(fd_array[i]);
fd_array[i] = DFL_FD; //将该文件描述符从fd_array中清除
}
else{
std::cerr << "read error" << std::endl;
close(fd_array[i]);
fd_array[i] = DFL_FD; //将该文件描述符从fd_array中清除
}
}
else{
//保持逻辑完整性
}
}
}
private: //内部使用函数
bool SetFdArray(int fd_array[], int num, int fd)
{
for (int i = 0; i <num; i++){
if (fd_array[i] == DFL_FD){ //该位置没有被使用
fd_array[i] = fd;
return true;
}
}
return false; //fd_array数组已满
}
void ClearFdArray(int fd_array[], int num, int default_fd)
{
for (int i = 0; i < num; i++){
fd_array[i] = default_fd;
}
}
};
①构造,析构 + InitSelectServer函数
- 在构造Select_Server对象时,需要指明select服务器的端口号,当然也可以在初始化select服务器的时候指明。
- 在初始化select服务器的时候调用So类当中的函数,依次进行套接字的创建、绑定和监听即可。
- 在析构函数中可以选择调用close函数将监听套接字进行关闭,但实际也可以不进行该动作,因为服务器运行后一般是不退出的。
②Run函数
服务器初始化完毕后就应该周期性的执行某种动作了,而select服务器要做的就是不断调用select函数,当事件就绪时执行对应的某种动作即可。
- 首先,在select服务器开始死循环调用select函数之前,需要先定义一个fd_array数组,先把数组中所有的位置初始化为无效,并将监听套接字添加到该数组当中,fd_array数组当中保存的就是需要被select监视读事件是否就绪的文件描述符。
- 此后,select服务器就不断调用select函数监视读事件是否就绪,每次调用select函数之前都需要重新设置readfds,具体设置过程就是遍历fd_array数组,将fd_array数组当中的文件描述符添加到readfds当中,并同时记录最大的文件描述符值maxfd,因为后续调用select函数时需要将maxfd+1作为第一个参数传入。
- 当select函数返回后,如果返回值为0,则说明timeout时间耗尽,此时直接准备进行下一次select调用即可。如果select的返回值为-1,则说明select调用失败,此时也让服务器准备进行下一次select调用,但实际应该进一步判断错误码,根据错误码来判断是否应该继续调用select函数。
- 如果select的返回值大于0,则说明select函数调用成功,此时已经有文件描述符的读事件就绪,接下来就应该对就绪事件进行处理。
③HandlerEvent函数
- 当select检测到有文件描述符的读事件就绪并成功返回后,接下来就应该对就绪事件进行处理了
- 在进行事件处理时需要遍历fd_array数组当中的文件描述符,依次判断各个文件描述符对应的读事件是否就绪,如果就绪则需要进行事件处理。
- 当一个文件描述符的读事件就绪后,还需要进一步判断该文件描述符是否是监听套接字,如果是监听套接字的读事件就绪,那么就应该调用accept函数将底层的连接获取上来。但是只是调用accept将连接获取上来还不够,为了下一次调用select函数时能够让select帮我们监视新连接的读事件是否就绪,在连接获取上来后还应该将该连接对应的文件描述符添加到fd_array数组当中,这样在下一次调用select函数前对readfds重新设置时就能将该文件描述符添加进去了。
- 如果是与客户端建立的连接对应的读事件就绪,那么就应该调用read函数读取客户端发来的数据,如果读取成功则将读到的数据在服务器端进行打印。如果调用read函数读取失败或者客户端关闭了连接,那么select服务器也应该调用close函数关闭对应的连接,但此时只是关闭连接也是不够的,还应该将该连接对应的文件描述符从fd_array数组当中清除,否则后续调用的select函数还会帮我们监视该连接的读事件是否就绪,但实际已经不需要了。
- 调用accept函数从底层获取上来连接后,不能立即调用read函数读取该连接当中的数据,因为此时新连接当中的数据可能并没有就绪,如果直接调用read函数可能需要进行阻塞等待,我们应该将这个等待过程交给select函数来完成,因此在获取完连接后直接将该连接对应的文件描述符添加到fd_array数组当中就行了,当该连接的读事件就绪时select函数会告知我们,那个时候我们再进行数据读取就不会被阻塞住了。
- 添加文件描述符到fd_array数组当中,本质就是遍历fd_array数组,找到一个没有被使用的位置将该文件描述符添加进去即可。但有可能fd_array数组中全部的位置都已经被占用了,那么文件描述符就会添加失败,此时就只能将刚刚获取上来的连接对应的套接字进行关闭,因为此时服务器已经没有能力处理这个连接了。
④timeout测试
- main函数: 实例化一个SelectServer类对象,对select服务器进行初始化后调用Run成员函数运行服务器
#include "select_server.hpp"
#include <string>
static void Usage(std::string proc)
{
std::cerr << "Usage: " << proc << " port" << std::endl;
}
int main(int argc, char* argv[])
{
if (argc != 2){
Usage(argv[0]);
exit(1);
}
int port = atoi(argv[1]);
Select_Server* svr = new Select_Server(port);
svr->InitSelectServer();
svr->Run();
return 0;
}
- 阻塞
- 当服务器调用select函数时直接将timeout设置为了nullptr,因此select函数调用后会进行阻塞等待。而服务器在第一次调用select函数时只让select监视监听套接字的读事件,所以运行服务器后如果没有客户端发来连接请求,那么读事件就不会就绪,而服务器则会一直在第一次调用的select函数中进行阻塞等待。
switch (select(maxfd + 1, &readfds, nullptr, nullptr, nullptr)){
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
//正常的事件处理
std::cout << "有事件发生..." << std::endl;
//HandlerEvent(readfds, fd_array, NUM);
break;
}
- 借助telnet工具向select服务器发起连接请求后,select函数就会立马检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出,因为当前程序并没有对就绪事件进行处理,此后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句。
- 非阻塞
- 如果服务器在调用select函数时将timeout的值设置为0,那么select函数调用后就会进行非阻塞等待,无论被监视的文件描述符上的事件是否就绪,select检测后都会立即返回。
- 此时如果select监视的文件描述符上有事件就绪,那么select函数的返回值就是大于0的,如果select监视的文件描述符上没有事件就绪,那么select的返回值就是等于0的。
struct timeval timeout = { 0, 0 }; //每隔0秒timeout一次
switch (select(maxfd + 1, &readfds, nullptr, nullptr, &timeout)){
case 0:
std::cout << "timeout..." << std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
//正常的事件处理
std::cout << "有事件发生..." << std::endl;
//HandlerEvent(readfds, fd_array, NUM);
break;
}
- 运行服务器后如果没有客户端发来连接请求,那么select服务器就会一直调用select函数进行轮询检测,但每次检测时读事件都不就绪,因此每次select函数的返回值都是0,因此就会不断打印“timeout…”提示语句。
- 当有客户端发来连接请求后,select在某次轮询检测时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出。
- 非阻塞轮询,每隔5s轮询一次
- 如果服务器在调用select函数时将timeout的值设置为特定的时间值,比如我们这里将timeout的值设置为5秒,那么select函数调用后的5秒内会进行阻塞等待,如果5秒后依旧没有读事件就绪,那么select函数将会进行超时返回。
- 我们可以将select函数超时返回和成功返回时timeout的值进行打印,以验证timeout是一个输入输出型参数
struct timeval timeout = { 5, 0 }; //每隔5秒timeout一次
switch (select(maxfd + 1, &readfds, nullptr, nullptr, &timeout)){
case 0:
std::cout << "timeout: " << timeout.tv_sec << std::endl;
break;
case -1:
std::cerr << "select error" << std::endl;
break;
default:
//正常的事件处理
std::cout << "有事件发生... timeout: " << timeout.tv_sec << std::endl;
//HandlerEvent(readfds, fd_array, NUM);
break;
}
- 运行服务器后如果没有客户端发来连接请求,那么每次select函数调用5秒后都会进行超时返回,并且每次打印输出timeout的值都是0,也就意味着timeout的时间是被耗尽了的。
- 当有客户端发来连接请求后,在某次调用select函数时就会检测到监听套接字的读事件就绪,此时select函数便会成功返回,并将我们设置的提示语句进行打印输出。
- 因为当前程序并没有对就绪事件进行处理,因此在第一次select检测到读事件就绪后,之后每次select函数一调用就会检测到读事件就绪并成功返回,因此会看到屏幕不断打印输出提示语句,并且后续打印输出timeout的值都是4,表示本次select检测到读事件就绪时timeout的剩余时间为4秒。
- 因为timeout和readfds、writefds与exceptfds一样,它们都是输入输出型参数,因此如果要使用timeout参数,那么在每次调用select函数之前也都需要对timeout的值进行重新设置。
(3)select服务器测试
- 用telnet工具连接我们的服务器,此时通过telnet向服务器发送的数据就能够被服务器读到并且打印输出了。
- 虽然当前的select服务器是一个单进程的服务器,但它却可以同时为多个客户端提供服务,根本原因就是因为select函数调用后会告知select服务器是哪个客户端对应的连接事件就绪了,此时select服务器就可以读取对应客户端发来的数据,读取完后又会调用select函数等待某个客户端连接的读事件就绪
- 当服务器检测到客户端退出后,也会关闭对应的连接,并将对应的套接字从fd_array数组当中清除。
(4)该程序存在的问题
- 服务器没有对客户端发进行响应。select服务器如果要向客户端发送数据,不能直接调用write函数,因为调用write函数时实际也分为“等”和“拷贝”两步,我们也应该将“等”的这个过程交给select函数,因此在每次调用select函数之前,除了需要重新设置readfds还需要重新设置writefds,并且还需要一个数组来保存需要被监视写事件是否就绪的文件描述符,当某一文件描述符的写事件就绪时我们才能够调用write函数向客户端发送数据。
- 没有定制协议。代码中读取数据时并没有按照某种规则进行读取,此时就可能造成粘包问题,根本原因就是因为我们没有定制协议,比如HTTP协议规定在读取底层数据时读取到空行就表明读完了一个HTTP报头,此时再根据HTTP报头当中的Content-Length属性得知正文的长度,最终就能够读取到一个完整的HTTP报文,HTTP协议通过这种方式就避免了粘包问题。
- 没有对应的输入输出缓冲区。代码中直接将读取的数据存储到了字符数组buffer当中,这是不严谨的,因为本次数据读取可能并没有读取到一个完整的报文,此时服务器就不能进行数据的分析处理,应该将读取到的数据存储到一个输入缓冲区当中,当读取到一个完整的报文后再让服务器进行处理。此外,如果服务器要能够对客户端进行响应,那么服务器的响应数据也不应该直接调用write函数发送给客户端,应该先存储到一个输出缓冲区当中,因为响应数据可能很庞大,无法一次发送完毕,可能需要进行分批发送
8.select优点
- 可以同时等待多个文件描述符,并且只负责等待,实际的IO操作由accept、read、write等接口来完成,这些接口在进行IO操作时不会被阻塞。
- select同时等待多个文件描述符,因此可以将“等”的时间重叠,提高了IO的效率。
- 这也是所有多路转接接口的优点, select ,poll ,epoll
9.select缺点
- select可监控的文件描述符数量太少, 能同时等待的 fd 是有限的
- 每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大。
- 同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大。
- select需要和OS交互数据,涉及到较多数据的来回拷贝,当select面临的连接很多,就绪的fd也较多的时候,会因为数据拷贝,而导致效率降低
- select每次调用,都必须手动重新添加fd,一定会影响程序运行的效率 ,从接口使用角度来说也非常不便,而且非常麻烦容易出错
- select(nfds .. . ),maxfd+1 : 操作系统在检测fd就绪的时候,需要遍历的。所以当有大量的连接的时候,内核同步select底层遍历,成本会变得越来越高!
(1)select可监控的文件描述符个数
- 调用select函数时传入的readfds、writefds以及exceptfds都是fd_set结构的,fd_set结构本质是一个位图,它用每一个比特位来标记一个文件描述符,因此select可监控的文件描述符个数是取决于fd_set类型的比特位个数的。
- 通过以下代码来看看fd_set类型有多少个比特位。
#include <iostream>
#include <sys/types.h>
int main()
{
std::cout << sizeof(fd_set)* 8 << std::endl;
return 0;
}
- 我们实现的select服务器当中将fd_array数组的大小设置为1024是足够的,因为readfds当中最多就只能添加1024个文件描述符,但不同环境下fd_set的大小可能是不同的,并且fd_set的大小也是可以调整的(涉及重新编译内核),因此之前select服务器当中对NUM的宏定义正确写法应该是这样的。
#define NUM (sizeof(fd_set)*8)
(2)一个进程能打开的文件描述符个数
- 进程控制块task_struct当中有一个files指针,该指针指向一个struct files_struct结构,进程的文件描述符表fd_array就存储在该结构当中,其中文件描述符表fd_array的大小定义为NR_OPEN_DEFAULT,NR_OPEN_DEFAULT的值实际就是32。
- 但并不意味着一个进程最多只能打开32个文件描述符,进程能打开的文件描述符个数实际是可以扩展的,比如我当前使用的云服务器默认就是把进程能打开的文件描述符设置得很高的,通过ulimit -a命令就可以看到进程能打开的文件描述符上限。
10.select适用场景
- 多路转接接口select、poll和epoll,需要在一定的场景下使用,如果场景选择的不适宜,可能会适得其反。
- 多路转接接口一般适用于多连接,且多连接中只有少部分连接比较活跃。因为少量连接比较活跃,也就意味着几乎所有的连接在进行IO操作时,都需要花费大量时间来等待事件就绪,此时使用多路转接接口就可以将这些等的事件进行重叠,提高IO效率。
- 对于多连接中大部分连接都很活跃的场景,其实并不适合使用多路转接。因为每个连接都很活跃,也就意味着任何时刻每个连接上的事件基本都是就绪的,此时根本不需要动用多路转接接口来帮我们进行等待,毕竟使用多路转接接口也是需要花费系统的时间和空间资源的。
- 多连接中只有少量连接是比较活跃的,比如聊天工具,我们登录QQ后大部分时间其实是没有聊天的,此时服务器端不可能调用一个read函数阻塞等待读事件就绪。
- 多连接中大部分连接都很活跃,比如企业当中进行数据备份时,两台服务器之间不断在交互数据,这时的连接是特别活跃的,几乎不需要等的过程,也就没必要使用多路转接接口了。