首先要理解的概念
IO即输入输出事件,而我们知道不论是标准IO还是文件IO,其实都是要和内存管理和磁盘文件等硬件有关,且Linux下一切皆文件,而在系统中想要实现用户层和内核层交互,只能调用系统调用接口,而对于IO来说,无论是c语言的文件IO函数还是其他语言的IO函数,底层也都是封装了系统调用文件IO来实现用户层与系统层的交互的。而常用的系统文件IO接口read/write。
任何IO过程中, 都包含两个步骤. 第一是
等待
(等待io事件就绪), 第二是拷贝
(真正意义上的io数据搬迁);读写事件是否就绪通常要与文件描述符相关
而且在实际的应用场景中, 等待消耗的时间往往都远远高于拷贝的时间, 想要让IO更高效, 最核心的办法就是让等待的时间尽量少.
阻塞与非阻塞
阻塞和非阻塞关注的是程序在等待调用结果(消息,返回值)时的状态.
- 阻塞调用是指调用结果返回之前,当前线程会被挂起. 调用线程只有在得到结果之后才会返回.
- 非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程.
1.阻塞IO模型
最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。即:在内核将数据准备好之前, 系统调用会一直等待.
流程:
当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。
注意:由于所有的IO默认都是阻塞方式。
典型的阻塞IO模型的例子为:
read函数读取标准输入的用户输入,但是一直没有输入的情况下,线程就会阻塞在等待输入事件的逻辑中。即如果数据没有就绪,就会一直阻塞在read方法。
特点
- 在等待的过程当中用户线程执行流是被挂起的(不能做其他事),对CPU的利用率是很低的;
- 在IO就绪到拷贝之间,实时性比较高;
- 代码编写的流程比较简单;
2.非阻塞IO模型
当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error(EWOULDBLOCK错误码)时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。
典型的非阻塞IO模型的例子为:
while(1){
if(read()!= error){
处理数据
break;
}
//否则 立即返回错误码 然后再一直轮询
}
特点:
- 如果内核还未将数据准备好, 系统调用仍然会直接返回, 并且返EWOULDBLOCK错误码.
- 非阻塞IO往往需要程序员循环的方式反复尝试读写文件描述符, 这个过程称为轮询。
- 在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪(while循环中需要不断地去询问内核数据是否就绪),也就说非阻塞IO不会交出CPU,而会一直占用CPU。这对CPU来说是较大的浪费, 一般只有特定场景下才使用.
3.信号驱动IO模型
在信号驱动IO模型中,当用户线程发起一个IO请求操作,会给对应的IO事件发生注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。
典型的信号驱动IO模型的例子为:
//先注册信号处理函数
void sigcallback(int signo)
{
拷贝recv;//再去发起IO操作
}
//用户线程代码
signal(SIGIO,sigcallback);//注册信号
//用户线程执行流正常逻辑执行
//。。。。。。
//。。。。
//直到触发了IO事件,信号触发,用户层执行流再去发起IO操作,被挂起,内核去执行信号处理函数里的IO操作完成数据的拷贝
特点:
- 实时性较高,内核将数据准备好的时候, 就使用SIGIO信号通知应用程序进行IO操作.
- 不需要重复发起IO调用,但是需要在代码当中增加自定义信号的逻辑
4.多路复用IO模型
多路复用IO模型是目前(并发编程里)使用得比较多的模型。
在多路复用IO模型中,会有一个线程不断去轮询多个文件描述符的状态,只有当文件描述符真正有读写事件触发时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个IO事件,系统不需要建立新的进程或者线程,也不必维护这些线程和进程,并且只有在真正有IO读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
虽然从流程图上看起来和阻塞IO类似. 实际上最核心在于IO多路转接一个线程能够同时等待多个文件描述符的就绪状态.
特点:
- 系统提供select函数去监控每个通道是否有到达事件,如果没有事件,则一直阻塞在那里,因此这种方式也会导致用户线程的阻塞。
- 与多线程+ 阻塞IO 的方式对比:
–确实采用多线程+ 阻塞IO 也能达到类似的效果,但是由于在多线程 + 阻塞IO 中,每个文件描述符对应一个线程,这样会造成很大的资源占用,并且尤其是对于长连接来说,线程的资源一直不会释放,如果后面陆续有很多连接的话,就会造成性能上的瓶颈。
– 而多路复用IO模式,通过一个线程就可以管理多个文件描述符,只有当文件描述符真正有读写事件发生才会占用资源来进行实际的读写操作。因此,多路复用IO比较适合连接数比较多的情况。 - 另外多路复用IO为何比非阻塞IO模型的效率高是因为在非阻塞IO中,不断地询问文件描述符状态时通过用户线程去进行的,而在多路复用IO中,轮询每个文件描述符状态是内核在进行的,这个效率要比用户线程要高的多。
- 不过要注意的是,多路复用IO模型是也通过轮询的方式来检测是否有事件到达,并且对到达的事件逐一进行响应。因此对于多路复用IO模型来说,一旦事件响应体很大,那么就会导致后续的事件迟迟得不到处理,并且会影响新的事件轮询。
5.异步IO模型
前面四种IO模型实际上都属于同步IO,只有最后一种是真正的异步IO,因为无论是多路复用IO还是信号驱动模型,IO操作的第2个阶段(真正的IO数据拷贝阶段)都会引起用户线程阻塞(被挂起),也就是内核进行数据拷贝的过程都会让用户线程阻塞(被挂起)。
异步IO模型才是最理想的IO模型,在异步IO模型中,当用户线程调用aio_read函数告诉内核描述字缓冲区指针和缓冲区的大小、文件偏移及通知的方式,然后立即返回(继续执行自己的事),而另一方面,从内核的角度,当它受到asynchronous read之后,它会立刻返回,说明read请求已经成功发起了,因此不会对用户线程产生任何阻塞。然后,内核会等待数据准备完成,然后当内核将数据拷贝到缓冲区后。当这一切都完成之后,内核会给用户线程发送一个信号通知,告诉它read操作完成了。也就说用户线程完全不需要关心实际的整个IO操作是如何进行的,只需要先发起一个请求,当接收内核返回的成功信号时表示IO操作已经完成,可以直接去使用数据了。
特点:
也就说在异步IO模型中,IO操作的两个阶段都不会阻塞用户线程,这两个阶段都是由内核自动完成,然后发送一个信号告知用户线程操作已完成。用户线程中不需要再次调用IO函数进行具体的读写。这点是和信号驱动模型有所不同的,在信号驱动模型中,当用户线程接收到信号表示数据已经就绪,然后需要用户线程调用IO函数进行实际的读写操作;而在异步IO模型中,收到信号表示IO操作已经完成,不需要再在用户线程中调用iO函数进行实际的读写操作。
注意:异步IO是需要操作系统的底层支持;
同步通信与异步通信的概念
同步和异步关注的是消息通信机制.
- 所谓同步,就是在发出一个调用时,在没有得到结果之前,该调用就不返回,但是一旦调用返回,就得到返回值了; 换句话说,就是由调用者主动等待这个调用的结果;
- 异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果; 换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果; 而是在调用发出后,被调用者通过状态、信号来通知调用者,或通过回调函数处理这个调用.
另外, 我们回忆在讲多进程多线程的时候, 也提到同步和互斥. 这里的同步通信和进程之间的同步是完全不想干的概念.
进程/线程同步是进程/线程之间直接的制约关系
是为完成某种任务而建立的两个或多个线程,这个线程在访问临界资源的时候需要在某些位置上协调他们的工作次序而等待、传递信息所产生的制约关系.
非阻塞IO模型例子的具体控制
接着我们上面给的非阻塞IO模型例子的伪码,来具体的模拟控制。
fcntl函数
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ ); //失败返回-1
之前也说过一个文件描述符, 默认都是阻塞IO,而fcntl函数是专门更改文件描述符属性的函数。
根据传入的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).
实现函数SetNoBlock
基于fcntl, 我们实现一个SetNoBlock函数, 将文件描述符设置为非阻塞.
我们此处只是用第三种功能, 获取/设置文件状态标记, 就可以将一个文件描述符设置为非阻塞.
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);//使用F_GETFL将当前的文件描述符的属性取出来(这是一个位图).
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);//设置此文件描述符状态,按位或 O_NONBLOCK,表示将其状态设置为非阻塞
}
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
void SetNoBlock(int fd) {
int fl = fcntl(fd, F_GETFL);
if (fl < 0) {
perror("fcntl");
return;
}
fcntl(fd, F_SETFL, fl | O_NONBLOCK);
}
int main() {
SetNoBlock(0);//将标准输入设置成非阻塞状态,即没有IO事件便会立即返回,而用户层线程使用while循环能否进行IO操作
while (1) {
char buf[1024] = {0};
ssize_t read_size = read(0, buf, sizeof(buf) - 1);//本身要是不设置成非阻塞的话,程序就会一直阻塞到read函数中,直到有IO事件发生,内核拷贝数据回来。但是设置成非阻塞,read在没有IO事件发生时,会返回错误码,而用户线程又会一直请求,而read也会一直但会错误码
if (read_size < 0) {
perror("read");
sleep(1);
continue;
}
printf("input:%s\n", buf);//直到有了IO事件触发,打印出来
}
return 0;
}
信号驱动IO模型例子的具体控制
先使用 man 2 fcntl 命令查看两个功能
GETOWN
返回文件描述符fd_上当前接收SIGIO和SIGURG信号的进程ID或进程组(作为函数结果)。进程id返回正值,进程组id以负数的形式返回.
SETOWN
将接收文件描述符fd_上事件的SIGIO和SIGURG信号的进程ID或进程组ID设置为arg中给定的ID。进程ID指定为正值;进程组ID被指定为负值。
最常见的用法是: 将自己指定为所有者
如果使用fcnt()的F_SETFL命令在文件描述符上设置异步状态标志,则在该文件描述符上可能的输入或输出时发送SIGIO信号
信号驱动IO模型一般用于udp套接字通信,这里就只给出 如何去设置一个套接字属性,来处理SIGIO,从而触发信号驱动
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
int sockfd = -1;
void sigcb(int signo)//信号处理函数
{
printf("signo is [%d] form sigcb func\n", signo);
//recvfrom
char buf[1024] = { 0 };
struct sockaddr_in peeraddr;
socklen_t peeraddr_len = sizeof(struct sockaddr_in);
recvfrom(sockfd, buf, sizeof(buf) - 1, 0, (struct sockaddr*)&peeraddr, &peeraddr_len);
}
int main()
{
signal(SIGIO, sigcb);//注册信号
//如何去设置一个套接字,来处理SIGIO
sockfd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);//创建udp套接字
if(sockfd < 0)
{
perror("socket");
return 0;
}
fcntl(sockfd, F_SETOWN, getpid());将自己指定为所有者
int flag = fcntl(sockfd, F_GETFL);//取得套接字属性
fcntl(sockfd, F_SETFL, flag | O_ASYNC);//设置为异步状态,则在该文件描述符上可能的输入或输出时发送SIGIO信号
//这样该套接字便可以在有IO事件触发时,便会产生SIGIO信号,接着调用回调函数的逻辑
//bind
//。。。
while(1)
{
sleep(1);
}
return 0;
}