目录
1. 五种IO模型
1.1 阻塞式IO
1.2 非阻塞IO
1.3 信号驱动IO
1.4 多路转接
1.5 异步IO
2. 同步通信与异步通信
3. 多路转接
3.1 select
总结
1. 五种IO模型
1.1 阻塞式IO
阻塞式IO最为常见,在内核将数据准备好之前, 系统调用会一直等待,所有的套接字默认都是阻塞方式;
最常见的就是C语言中调用scanf,程序启动,等待输入,不输入一直阻塞等待;
比如:小明去钓鱼,阻塞式IO就类似于小明一直盯着鱼竿,什么都不干就等着鱼上钩;
1.2 非阻塞IO
非阻塞IO:如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返回EWOULDBLOCK错误码;
非阻塞IO往往需要程序员使用循环的方式反复尝试读写文件描述符,这个过程称为轮询;这对CPU来说是较大的浪费, 一 般只有特定场景下才使用;
比如:小王也去钓鱼,在等待鱼上钩的这段时间,小王可以看看书,吃点零食...做其他的事情;
1.3 信号驱动IO
信号驱动IO:内核将数据准备好的时候, 使用SIGIO信号通知应用程序进行IO操作;
比如:小聪也去钓鱼,他在鱼竿加了一个铃铛,在等待鱼上钩的期间,他可以干其他事,等有鱼上钩铃铛就会提醒有鱼了;
1.4 多路转接
IO多路转接:多路转接能够同时等待多个文件 描述符的就绪状态.
从流程图上看起来和阻塞IO类似,如何理解?
比如:小张也是去钓鱼,他拿了很多鱼竿,同时使用多个鱼竿钓鱼;一个人盯着多个鱼竿;
1.5 异步IO
由内核在数据拷贝完成时, 通知应用程序(而信号驱动是告诉应用程序何时可以开始拷贝数据);
怎么去理解?
比如:小李是一个老板,走到鱼塘边看到这么多人在钓鱼,他想吃鱼了,但是他又不想自己钓,于是和自己的司机说,你去钓鱼,钓上来鱼给我,然后通知另一个司机来接他;小李全程不参与钓鱼,由他的司机给他钓;司机就相当于操作系统,小李相当于是一个线程,钓鱼是IO,鱼就是数据;
2. 同步通信与异步通信
同步通信
在同步通信中,发送方和接收方在通信过程中是时间上密切关联的。发送方发送消息后,需要等待接收方确认已经接收到消息,才能继续进行后续操作。
特点:
- 阻塞:发送方在发送消息后会被阻塞,直到接收方处理完毕并回复。这意味着发送方的执行流会暂停,直至接收方确认。
- 实现简单:因其简单的请求、响应模式,容易实现和理解。
- 实时性:适用于需要即时反馈的场景,比如电话通话或某些即时消息服务。
比如:打电话;
异步通信
在异步通信中,发送方和接收方之间的通信不需要严格的时间同步。发送方在发送完消息后,不必等待接收方的确认,可以继续执行其他任务。
特点:
- 非阻塞:发送方在发送消息后不会被阻塞,可以自由进行其他操作。接收方会在适当的时候处理接收到的消息。
- 复杂性高:由于涉及到消息的队列、存储和后续处理,通常实现起来比同步通信复杂。
- 适应性强:适用于高并发、跨网络或需要高响应性的场景,比如事件驱动的编程模型、某些消息队列系统。
比如:电子邮箱;
两种通信的关键点在于阻塞与非阻塞;
- 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回;
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程. 等待需要时可以再获取执行结果;
如何设置非阻塞?
fcntl
文件描述符, 默认都是阻塞IO,但是可以通过接口设置为非阻塞;
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
参数:
- fd:文件描述符
- cmd:操作命令,指定要执行的操作类型。
- arg:可选参数,根据 cmd 的不同而有所不同,某些操作不需要这个参数。
传入的cmd的值不同, 后面追加的参数也不相同;
fcntl函数有5种功能:
- 复制一个现有的描述符(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).
使用示例:
将文件描述符设置为非阻塞;
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
F_GETFL将当前的文件描述符的属性取出来(这是一个位图);该位图表示文件描述符的各种状态和行为;这里是获取出它的状态和行为,然后添加一个非阻塞的状态;
文件访问模式:
O_RDONLY
:以只读模式打开文件。O_WRONLY
:以只写模式打开文件。O_RDWR
:以读写模式打开文件。
文件状态标志:
O_APPEND
:文件指针在每次写操作后移到文件末尾。这意味着任何写入都将附加到文件的现有内容后面。O_NONBLOCK
:文件描述符被设置为非阻塞模式。对该文件描述符的读写操作不会导致进程阻塞。O_SYNC
:写入操作会在返回前完成同步,这样就可以确保数据已经写入磁盘。O_DSYNC
:确保数据已写入磁盘。
验证一下非阻塞IO:
#include <iostream>
#include <cstdlib>
#include <fcntl.h>
#include <unistd.h>
#include <cerrno>
void SetNonBlock(int fd)
{
int fl = fcntl(fd, F_GETFL);
if(fl < 0)
{
std::cerr << "fcntl error" << std::endl;
exit(0);
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main()
{
SetNonBlock(0);
while (true)
{
char buffer[1024];
ssize_t s = read(0, buffer, sizeof(buffer) - 1);
if (s > 0)
{
buffer[s] = 0;
std::cout << "echo# " << buffer << std::endl;
}
else if (s == 0)
{
std::cout << "end stdin" << std::endl;
break;
}
else
// 非阻塞等待, 如果数据没有准备好,返回值会按照出错返回, s == -1
// 数据没有准备好 vs 真的出错了 : 处理方式一定不是一样的。 s无法区分!
// 数据没有准备好,算读取错误吗?不算。read,recv以出错的形式告知上层,数据还没有准备好
{
if (errno == EWOULDBLOCK)
{
std::cout << "OS的底层数据未准备就绪, errno: " << errno << std::endl;
}
else if(errno == EINTR)//信号导致中断
{
std::cout << "IO interrupted by signal, try again" << std::endl;
}
else
{
std::cout << "read error!" << std::endl;
}
}
sleep(1);
}
return 0;
}
3. 多路转接
多路转接有什么优势?
比如:TCPServer,服务端需要创建一个监听套接字,然后接收客户端发来的连接请求,然后返回新的fd,服务端需要维护多个连接,对于每个连接都可能需要读数据/写数据;也就是说,服务端需要监控多个fd的IO事件;没有多路转接:
- 把所有的fd都设置非阻塞,然后不停的循环遍历每个fd,判断是否有IO事件;
- 创建多个线程,让线程去监控IO事件;
- 基于信号以及子进程的方式,让子进程去执行处理对应的IO操作,设置忽略SIGCHLD
- 使用孙子进程,子进程退出,自动回收资源
这几种方式都有很大的效率和内存开销的问题:
- 循环遍历的方式:CPU 资源浪费严重,因为即使没有 I/O 事件发生,CPU 也会不断地进行循环检查;可扩展性差,随着连接数的增加,性能会急剧下降;
- 线程/进程的创建和销毁开销较大,过多的线程/进程会导致系统资源耗尽;
应对多个连接的IO时,多路转接无疑是最合适的方式;
3.1 select
IO总结就两个过程:
- 等(阻塞,等待数据)
- 就是拷贝(读/写数据,read、write、recv、send)
多路转接帮我们解决的就是IO中的等;有IO事件就绪就返回;并且它可以一次等待多个文件描述符;
接口原型:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
参数:
nfds:需要监控的最大文件描述符编号加 1。select 函数会检查从 0 到 nfds - 1 的所有文件描述符。例如,如果要监控的文件描述符是 3、5、7,那么 nfds 应该设置为 8(最大文件描述符 7 加 1)。
readfds:指向一个 fd_set 类型的集合,该集合包含了需要监控读事件的文件描述符。当集合中的某个文件描述符,select 函数会将其标记为就绪。如果不需要监控读事件,可以将其设置为 NULL;
writefds:指向一个 fd_set 类型的集合,该集合包含了需要监控写事件的文件描述符。当集合中的某个文件描述符可以进行写操作时,select 函数会将其标记为就绪。如果不需要监控写事件,可以将其设置为 NULL。
exceptfds:指向一个 fd_set 类型的集合,该集合包含了需要监控异常事件的文件描述符。当集合中的某个文件描述符发生异常时,select 函数会将其标记为就绪。如果不需要监控异常事件,可以将其设置为 NULL;
timeout:指向一个 struct timeval 类型的结构体,用于设置 select 函数的超时时间。struct timeval 结构体定义如下:
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
// 示例
struct timeval timeout ={5,0};// 5秒以内阻塞等待,5秒以后非阻塞;(阻塞+非阻塞)
// 5秒以内阻塞式等待,5秒内就绪了select正常返回,5秒内没有就绪,返回值就为0(超时返回)
struct timeval timeout ={0,0};// 立即检测,有就绪的直接返回,没有返回0(非阻塞)
struct timeval timeout= nullptr;// (阻塞)
返回值:
- 大于 0:表示就绪的文件描述符的总数,即 readfds、writefds 和 exceptfds 三个集合中就绪的文件描述符数量之和。
- 0:表示超时,即在指定的时间内没有文件描述符就绪。
- -1:表示发生错误,此时会设置相应的 errno 变量,可以通过 perror 函数输出错误信息
设置位图不允许私自设置,系统也提供了接口:
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的全部位
示例:
简易的SelectServer,详细可见我的码云:SelectSever示例
Select的优缺点
优点:select只负责等待,可以等待多个fd,IO的时候效率比较高;
缺点:
- 每次都要对select的参数重置;
- 编写程序的时候,select要是有第三方数组,所以充满遍历,可能会影响select的效率
- 用户到内核,内核到用户,每次select调用和返回,都要对位图进行重新设置,内核和用户之间要一直进行数据拷贝
- select让OS在底层遍历要关心的所有fd,这也会导致效率低下,内核在检查文件描述符状态时,采用的是线性遍历的方式,即从 0 到 nfds - 1 逐个检查每个文件描述符是否就绪。随着要监控的文件描述符数量增加,遍历的时间也会线性增长;
- fd_set是系统提供的类型,fd_set大小是固定的;也就是说位图的大小也是固定的,select最多能检测的fd总数是有上限的;
后续会介绍其他实现多路转接的接口,他们解决了select中存在的一些问题,也是现在最为常用的方式;
总结
以上便是本文的全部内容,希望对你有所帮助,感谢阅读!