一、高级IO相关
1.1 同步通信和异步通信
同步通信和异步通信是两种不同的通信方式,二者的概念如下:
- 同步通信是指通信双方需要在某时刻达成一致,才进行数据交换。在同步通信中,发送方会在发送数据时等待接收方的响应,直到接收到响应后才会继续执行后续任务。同步通信可以保证数据传输的可靠性和一致性,但是可能造成系统的阻塞和资源浪费。
- 异步通信是指通信双方可以独立的进行数据交换,不需要在某一时刻达成一致。在异步通信中,发送方会在发送数据后立即返回,而接收方会在接收到数据后立即进行处理。异步通信可以提高系统的并发性和效率,但是也可能会带来一些数据的不一致问题。
总的来说,同步通信适用于对数据传输的可靠性和一致性要求较高的场景,例如数据库的读写操作。而异步通信适用于对系统的并发性和效率要求较高的场景,如网络通信。具体选择哪种通信方式需要根据实际情况来考虑。
1.2 阻塞与非阻塞
阻塞和非阻塞是两种不同的操作方式,二者的概念如下:
- 阻塞是指在进行某种操作时,如果当前操作无法完成,那么程序就会一直等待,直到操作完成或者出现错误时才返回结果。阻塞操作会阻塞当前线程或进程的执行,直到操作完成,因此会占用CPU资源,并且可能造成系统的阻塞。
- 非阻塞是指在进行某种操作时,如果当前操作无法完成,则程序会立马返回,并且告诉调用者当前操作无法完成。非阻塞操作不会阻塞当前线程或进程的执行,因此不会占用CPU资源,并且可以让程序执行其他任务。
总的来说,阻塞和非阻塞是对于操作的执行方式的描述,阻塞操作会等待操作的完成,而非阻塞操作会立即返回。选择使用阻塞或非阻塞操作取决于应用程序的需求和实际情况。如果需要快速响应和处理多个并发请求,通常会使用非阻塞操作。而如果需要保证数据传输的可靠性和一致性,则可能需要使用阻塞操作。
1.3 fcntl 函数
fcntl
是一个Unix/Linux系统编程中的函数,用于控制文件描述符的一些属性和操作。其定义如下:
#include <unistd.h> #include <fcntl.h> int fcntl(int fildes, int cmd, ...);
参数fd是被参数cmd操作(如下面的描述)的文件描述符,传入的cmd的值不同,fcntl后面追加的参数也不相同。
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)
此处只是用第三种功能,获取/设置文件状态标记,就可以将一个文件描述符设置为非阻塞,如下面的例子:
基于fcntl
,我们实现一个SetNoBlock
函数,将文件描述符设置为非阻塞。
bool SetNoBlock(int sock) { int flag = fcntl(sock, F_GETFL); if(flag == -1) return false; int n = fcntl(sock, F_SETFL, flag | O_NONBLOCK); if(n == -1) return false; return true; }
先使用F_GETFL
将当前的文件描述符的属性取出来(这是一个位图),然后再使用F_SETFL
将文件描述符设置回去,设置回去的同时,加上一个O_NONBLOCK
参数,将这个文件描述符设置为非阻塞状态。
下面以轮询的方式读取标准输入:
#include <iostream> #include <vector> #include <cstring> #include <functional> #include <unistd.h> #include <fcntl.h> using namespace std; using func_t = std::function<void()>; void func1() { cout << "func1 " << endl; } void func2() { cout << "func2 " << endl; } void func3() { cout << "func3 " << endl; } bool SetNoBlock(int sock) { int flag = fcntl(sock, F_GETFL); if(flag == -1) return false; int n = fcntl(sock, F_SETFL, flag | O_NONBLOCK); if(n == -1) return false; return true; } int main() { std::vector<func_t> funcs; funcs.push_back(func1); funcs.push_back(func2); funcs.push_back(func3); SetNoBlock(0); char buff[1024]; while(true) { memset(buff, 0, sizeof buff); ssize_t read_size = read(0, buff, sizeof(buff) - 1); if(read_size < 0) { cerr << "errno:" << errno << "desc: " << strerror(errno) << endl; for(const auto& f : funcs) { f(); } } else { cout << "buff: " << buff << "read_size: " << read_size << endl; } sleep(1); } return 0; }
可以发现此时不输入时就不会阻塞而直接处理另外一个逻辑。
如果此时取消调用SetNoBlock
函数:
就按照阻塞的方式进行读取了。
二、五种IO模型
2.1 阻塞式IO模型
在阻塞式IO模型中,当应用程序发起一个IO请求时。程序会一直阻塞等待,直到数据传输完成才继续执行其他任务。这种模型的缺点是会造成CPU资源的浪费,降低系统的响应速度。但是阻塞式IO是最常见的IO模型,所有的套接字默认都是阻塞方式。
2.2 非阻塞式IO模型
在非阻塞式IO模型中,当应用程序发起一个IO请求时,即使数据没有准备好也会立即返回,并且通过轮询的方式不断地查询IO操作的状态,直到数据准备完成。这种模型可以减少CPU资源的浪费,但也会增加系统的负担,降低IO操作的效率。
2.3 多路复用IO模型
在多路复用IO模型中,应用程序可以将多个IO操作绑定到同一个事件轮询器中,然后等待IO操作完成的通知。这种模型可以有效地提高系统的并发性能,但是实现较为复杂。
2.4 信号驱动式IO模型
在信号驱动IO模型中,应用程序向操作系统注册一个信号,当IO操作完成时,操作系统会向应用程序发送信号通知。这种模型可以减少轮询带来的系统负担,提高系统的效率。
2.5 异步IO模型
在异步IO模型中,应用程序可以在发起IO请求后立即返回,并在IO操作完成后由操作系统通知应用程序。这种模型可以提高系统的并发性能和效率,但是实现较为复杂。
总之,不论是何种IO模型,都包含了两个步骤,第一是等待数据,第二是拷贝数据。而且在实际应用场景中,等待消耗的时间往往都远高于拷贝数据的时间。让IO变得更高效,最核心的办法就是尽量减少等待的时间。
三、认识IO多路复用
IO多路复用是一种高效的I/O处理机制,它允许在单个线程中同时监视和处理多个I/O操作,以提高程序的性能和可扩展性。
在传统的阻塞I/O模型中,每个I/O操作都会阻塞整个进程,直到该操作完成。这就意味着如果应用程序需要处理多个并发I/O操作,就需要建立多个线程或者进程来处理它们,这样就会导致系统的开销过高,并且导致可扩展性下降。
使用IO多路复用,应用程序可以将多个I/O操作注册到一个事件的循环中,然后使用一个线程来监视这些操作的状态。当其中任何一个操作就绪时,事件循环就会通知相应的应用程序,来执行相应的操作。这种方式允许应用程序同时处理多个I/O操作,并且无需创建多个线程或者进程,因此提高了系统的性能和可扩展性。
常见的IO多路复用技术包括select、poll、epoll等,其中epoll是最常用的技术之一,因为它可以更好地处理大量的并发连接。
四、select
4.1 认识select函数
select()函数是一种在 Unix/Linux 系统中实现多路复用 IO 的一种机制,它可以等待多个文件描述符(socket、文件等)中任何一个变为”就绪“状态(可读、可写、异常),然后立即进行处理,而不是阻塞在一个文件描述符上等待数据到来。
4.2 select函数原型
select
函数原型:
#include <sys/select.h> int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
参数说明:
- nfds:需要监听的最大文件描述符的值加 1,即文件描述符集合中最大的文件描述符值加 1;
- readfds:指向可读文件描述符集合的指针;
- writefds:指向可写文件描述符集合的指针;
- exceptfds:指向异常文件描述符集合的指针;
- timeout:超时时间,若设置为NULL则表示永远等待,直到有文件描述符就绪。函数返回值:
函数返回值:
- 若有文件描述符就绪,则返回就绪文件描述符的个数;
- 若超时或被信号中断,则返回 0;
- 若出现错误,则返回 -1,并且设置
errno
变量。
fd_set 结构体:
fd_set
是一个用于表示文件描述符的结构体,它包含了一组标志位,每个标志位的值表示了一个文件描述符是否在集合中。fd_set
结构体的定义如下:
/* The fd_set member is required to be an array of longs. */ typedef long int __fd_mask; /* fd_set for select and pselect. */ typedef struct { /* XPG4.2 requires this member name. Otherwise avoid the name from the global namespace. */ #ifdef __USE_XOPEN __fd_mask fds_bits[__FD_SETSIZE / __NFDBITS]; # define __FDS_BITS(set) ((set)->fds_bits) #else __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS]; # define __FDS_BITS(set) ((set)->__fds_bits) #endif } fd_set;
其实这个结构就是一个整数数组,更严格的说是一个"位图",使用位图中对应的位来表示要监视的文件描述符。
为了使得操作fd_set变得更加方便,操作系统还专门提供了一组接口:
- FD_ZERO(fd_set *set):将集合中的所有位清零;
- FD_SET(int fd, fd_set *set):将指定文件描述符加入集合中;
- FD_CLR(int fd, fd_set *set):将指定文件描述符从集合中删除;
- FD_ISSET(int fd, fd_set *set):判断指定的文件描述符是否在集合中。
timeval 结构体:timeval
结构体用于描述一段时间长度,如果在这段时间内,需要监听的文件描述符没有就绪则函数返回,返回值为 0。timeval
结构体的定义如下:
/* A time value that is accurate to the nearest microsecond but also has a range of years. */ struct timeval { __time_t tv_sec; /* Seconds. */ __suseconds_t tv_usec; /* Microseconds. */ };
理解socket就绪状态:
读就绪:
- 在socket内核中,接收缓冲区中的字节数大于等于低水位标记SO_RCVLOWAT,此时就可以无阻塞的读取该缓冲区,并且读取的返回值大于 0;
- 监听的socket上有新的连接请求;
- socket上有未处理的错误;
- socket TCP通信中,对端断开连接,此时对该socket读,则返回0。
写就绪:
- 在socket内核中,发送缓冲区中的可用字节数(发送缓冲区的空闲位置大小),大于等于低水位标记SO_SNDLOWAT,此时可以无阻塞的写,并且返回值大于0。
- socket的写操作被关闭(close或者shutdown)。对一个写操作被关闭的socket进行写操作,会触发SIGPIPE信号;
- socket使用非阻塞connect连接成功或失败之后;
- socket上有未处理的错误。
异常就绪:
- socket上收到带外数据关于带外数据,和TCP紧急模式相关。
高级IO以及IO多路复用(select、poll、epoll网络编程)2:https://developer.aliyun.com/article/1384008