unix/linux下几种常见的I/O模型:
(以下图片均引用自UNP一书)
阻塞式I/O:
顾名思义就是当进行I/O时,数据还没有准备好,就会阻塞在I/O上。
下面配合代码进行讲解:
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#define SERV_PORT 8000
int main(void)
{
struct sockaddr_in serv_addr, clie_addr;
socklen_t clie_addr_len;
int sockfd;
char buf[BUFSIZ];
char str[INET_ADDRSTRLEN];
int i, n;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(SERV_PORT);
bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
printf("Accepting connections ...\n");
while (1)
{
clie_addr_len = sizeof(clie_addr);
//当客户端没发送数据时,会一直阻塞
n = recvfrom(sockfd, buf, BUFSIZ,0, (struct sockaddr *)&clie_addr, &clie_addr_len);
if (n == -1)
perror("recvfrom error");
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)),
ntohs(clie_addr.sin_port));
n = sendto(sockfd, buf, n, 0, (struct sockaddr *)&clie_addr, sizeof(clie_addr));
if (n == -1)
perror("sendto error");
}
close(sockfd);
return 0;
}
当运行到recvfrom
时,如果客户端的数据还未发过来,则程序就阻塞在recvfrom上,等数据到了之后内核将数据拷贝给用户的缓冲区中或者因为某些原因调用失败了才会返回,中间的过程是一直阻塞在recvform
函数上的。这种I/O方式就称为阻塞式I/O模型。
非阻塞I/O模型:
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#define SERV_PORT 8000
int main(void)
{
struct sockaddr_in serv_addr, clie_addr;
socklen_t clie_addr_len;
int sockfd;
char buf[BUFSIZ];
char str[INET_ADDRSTRLEN];
int i, n;
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(SERV_PORT);
bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr));
//设置非阻塞
int flag = fcntl(sockfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(sockfd, F_SETFL, flag);
printf("Accepting connections ...\n");
while (1)
{
clie_addr_len = sizeof(clie_addr);
//由于我们对sockfd文件描述符设置了非阻塞,所以recvfrom调用会一直返回-1并设置errno为EAGAIN直到接收到数据
n = recvfrom(sockfd, buf, BUFSIZ,0, (struct sockaddr *)&clie_addr, &clie_addr_len);
if (n == -1 && errno == EAGAIN)
perror("recvfrom error");
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &clie_addr.sin_addr, str, sizeof(str)),
ntohs(clie_addr.sin_port));
n = sendto(sockfd, buf, n, 0, (struct sockaddr *)&clie_addr, sizeof(clie_addr));
if (n == -1)
perror("sendto error");
}
close(sockfd);
return 0;
}
设置了文件描述符为非阻塞态之后,recvfrom
就不会等待数据到达了,而是直接返回。所以当我们采用非阻塞I/O模型时,需要轮询内核,查看数据是否准备就绪。这样的做法会耗费大量的cpu资源,因为每次都要在内核和用户态之间切换。
I/O复用模型:
select、poll、epoll都属于I/O复用模型。当系统调用这几个系统调用时,会阻塞在这几个系统调用上,但不是阻塞在真正的I/O系统调用上。这样做的好处是可以一次性监控多个文件描述符,而不用像阻塞I/O模型那样每次只处理一个文件描述符。
这里只贴出select的server端代码以供讲解:
#include <iostream>
using namespace std;
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define MAXLINE 80
#define SERV_PORT 6666
int main(int argc, char *argv[])
{
int i, max_i, max_fd, listen_fd,
conn_fd, sock_fd;
//用一个client数组专门来存放就绪的描述符
int nready, client[FD_SETSIZE];
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
socklen_t cliaddr_len;
struct sockaddr_in cliaddr, servaddr;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(listen_fd, 20);
//记录监控的最大的文件描述符
max_fd = listen_fd;
//max_i用来记录client数组的有效下标
max_i = -1;
//初始化client数组
for(i = 0; i < FD_SETSIZE; i++)
{
client[i] = -1;
}
//初始化文件描述符集合
FD_ZERO(&allset);
FD_SET(listen_fd, &allset);
while(1)
{
//allset用来存放需要监听的所有文件描述符集合,而rset是指就绪的文件描述符集合
rset = allset;
//阻塞等待可读的文件描述符,如果有,则立即返回,否则阻塞
nready = select(max_fd + 1, &rset, NULL, NULL, NULL);
if(nready < 0)
{
perror("select error");
exit(1);
}
//如果建立socket连接的描述符处于就绪
if(FD_ISSET(listen_fd, &rset))
{
cliaddr_len = sizeof(cliaddr);
conn_fd = accept(listen_fd, (struct sockaddr *)&cliaddr, &cliaddr_len);
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)),
ntohs(cliaddr.sin_port));
for(i = 0; i < FD_SETSIZE; i++)
{
if(client[i] < 0)
{
client[i] = conn_fd;
break;
}
}
if(i == FD_SETSIZE)
{
fputs("too many clients\n", stderr);
exit(1);
}
//将新的描述符加入监听的文件描述符集合中
FD_SET(conn_fd, &allset);
//更新最大的文件描述符和最大下标
if(conn_fd > max_fd)
{
max_fd = conn_fd;
}
if(i > max_i)
{
max_i = i;
}
//如果没有就绪的文件描述符就退出本次循环,重新调用select监听
if(--nready == 0)
{
continue;
}
}
//轮询找到就绪的文件描述符
for(i = 0; i <= max_i; i++)
{
if((sock_fd = client[i]) < 0)
{
continue;
}
if(FD_ISSET(sock_fd, &rset))
{
if((n = read(sock_fd, buf, MAXLINE)) == 0)
{
close(sock_fd);
FD_CLR(sock_fd, &rset);
client[i] = -1;
}
else
{
int j;
for(j = 0; j < n; j++)
buf[j] = toupper(buf[j]);
write(sock_fd, buf, n);
}
if(--nready == 0)
{
break;
}
}
}
}
close(listen_fd);
return 0;
}
可以看到,select一次可以处理多个就绪的文件描述符,I/O复用模型就是这样的一个特点。效率方面比前几种都要好的多。而epoll则是poll的加强版,select和poll都是采用轮询查看哪个文件描述符就绪,而epoll是采用的回调机制来激活文件描述符,将就绪的文件描述符加入链表中,返回时,就知道了具体是哪些文件描述符处于就绪中。
信号驱动式I/O模型:
信号驱动这种方式的意思就是我们事先调用sigaction
函数捕捉SIGIO信号并定义处理函数,等到数据已经准备就绪时,内核就发送SIGIO信号,当前进程就可以执行SIGIO处理函数了,在处理函数中调用recvfrom
读取数据即可。
这种I/O模型的优点是当进程调用recvfrom
时,数据必定已经可以读取了,因为当内核发送SIGIO信号时,数据已经准备就绪了。所以我们可以执行其它代码,而不用阻塞在某一处了。
异步I/O模型:
前面的I/O模型都是需要我们在读写事件就绪后自己负责进行读写,而异步I/O则不需要,是内核替我们做了这些事,最后只需要当I/O完成时才通知我们。注意它和信号驱动式I/O的不同,信号驱动式I/O是通知我们何时可以进行I/O,而异步I/O是通知我们何时完成了I/O。
最后贴上阻塞式I/O和非阻塞式I/O用于测试的client端代码:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <ctype.h>
#define SERV_PORT 8000
int main(int argc, char *argv[])
{
struct sockaddr_in servaddr;
int sockfd, n;
char buf[BUFSIZ];
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
servaddr.sin_port = htons(SERV_PORT);
while (fgets(buf, BUFSIZ, stdin) != NULL) {
n = sendto(sockfd, buf, strlen(buf), 0, (struct sockaddr *)&servaddr, sizeof(servaddr));
if (n == -1)
perror("sendto error");
n = recvfrom(sockfd, buf, BUFSIZ, 0, NULL, 0); //NULL:不关心对端信息
if (n == -1)
perror("recvfrom error");
write(STDOUT_FILENO, buf, n);
}
close(sockfd);
return 0;
}
测试I/O复用的话,直接使用nc 127.0.0.1 6666
来模拟client端即可。