IO模型
BIO
BIO全称为 Blocking I/O,是一种同步阻塞IO。最开始的网络通信就是BIO模型,服务端创建一个ServerSocket,客户端创建一个 Socket 去连接服务端,这样客户端与服务端便可以进行通信了。
产生的问题:
这个过程是阻塞的,因为服务端需要一直等待客户端的连接请求,当客户端连接之后,服务器在处理客户端请求时,客户端只能进行等待,如果客户端暂时没有请求,服务器将再次进入阻塞状态。
解决办法:
一般有两种方法解决办法:第一种是使用多线程,另一种是使用线程池。
使用多线程:每连接一个客户端时,便创建一个线程来专门处理该客户端的请求,主线程则继续等待其他客户端的连接请求。
使用线程池是对上面的使用多线程进行了优化,不需要对每一个请求都创建线程,只需要去线程池中取一个线程即可,可是底层的通信机制依然是同步阻塞的,所以又被称为"伪异步IO"。
不足:
这两种方法虽然对解决阻塞有一定的帮助,但是开启大量线程需要消耗大量的内存资源,而且线程之间频繁的切换会降低程序的性能。并且当请求的客户端特别多时,可能会因为开销过大导致系统崩溃。
NIO
NIO全称为 New I/O,也可以理解为 Non Blocking I/O,是一种同步非阻塞IO。实现NIO需要通过I/O的多路复用。多路复用是指一个进程来监视多个网络连接的描述符,一旦某一个客户端的文件描述符就绪,就能通知相应的程序进行处理。多路复用有 select、poll、epoll 这三种机制。
select:
当有客户端连接服务器时,服务器会为每一个客户端随机创建一个不同的文件描述符,select模型首先将所有的文件描述符收集到 bitmap 集合中,然后将集合从用户态拷贝到内核态,让内核判断哪一个文件描述符有数据。下面代码的72行,程序获取到了最大的文件描述符,在82行的 select 函数中,将 max+1 作为参数,让程序扫面max+1 内的文件描述符,如果都没有文件描述符被置位,则程序将会阻塞在82行的 select 处。当有客户端发来请求,对应客户端的文件描述符将会被置位,select 函数将会被返回,程序则从阻塞状态解除,继续执行之后的代码,由于程序不知道具体哪几个文件描述符被置位,所以在84行处,需要对所有的文件描述符进行扫描。
#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <wait.h>
#include <signal.h>
#include <errno.h>
#include <sys/select.h>
#include <sys/time.h>
#include <unistd.h>
#define MAXBUF 256
void child_process(void)
{
sleep(2);
char msg[MAXBUF];
struct sockaddr_in addr = {0};
int n, sockfd,num=1;
srandom(getpid());
/* Create socket and connect to server */
sockfd = socket(AF_INET, SOCK_STREAM, 0);
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
connect(sockfd, (struct sockaddr*)&addr, sizeof(addr));
printf("child {%d} connected \n", getpid());
while(1){
int sl = (random() % 10 ) + 1;
num++;
sleep(sl);
sprintf (msg, "Test message %d from client %d", num, getpid());
n = write(sockfd, msg, strlen(msg)); /* Send message */
}
}
int main()
{
char buffer[MAXBUF];
int fds[5];
struct sockaddr_in addr;
struct sockaddr_in client;
int addrlen, n,i,max=0;;
int sockfd, commfd;
fd_set rset;
for(i=0;i<5;i++)
{
if(fork() == 0)
{
child_process();
exit(0);
}
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof (addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
listen (sockfd, 5);
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
if(fds[i] > max)
max = fds[i];
}
while(1){
FD_ZERO(&rset);
for (i = 0; i< 5; i++ ) {
FD_SET(fds[i],&rset);
}
puts("round again");
select(max+1, &rset, NULL, NULL, NULL);
for(i=0;i<5;i++) {
if (FD_ISSET(fds[i], &rset)){
memset(buffer,0,MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer);
}
}
}
return 0;
}
产生的问题:
- 每一个文件描述符都需要一个 bit 来表示,48行定义的rset是一个 fd_set 类型的,fd_set 的是一个32个整数的数组,所以最多只能存放1024(32 * 8 * 4)个文件描述符。
- fd_set 被设置之后需要进行重置。在78行处,每进行一次 select 操作之后,下一次操作时,都需要将 rset 进行重新赋值。
- 因为需要内核对文件描述符进行监控,所以需要将 rset 拷贝到内核。但是 rset 执行一次操作后都需要重新赋值,所以会导致每一次 rset 重新赋值完之后,需要重新拷贝到内核中,这个过程开销很大。
- 程序无法确定具体哪几个文件描述符被置位。所以在84行处,需要对所有的文件描述符进行扫描,时间复杂度为O(n)。
poll:
poll对比与select有了一些提升。poll存储文件描述符是通过一个 pollfd 结构体中的 revents 来存储,比select 通过数组来存储的方式数量更多。在下面代码23行处,不需要每次将 revents 重新赋值,在遍历之后会自动将 revents 置 0。但是,poll 依然没有解决 select 中用户态与内核态之间频繁拷贝与扫描文件描述符高时间复杂度的问题。
struct pollfd {
int fd;
short events;
short revents;
};
------------------手动分隔符--------------------
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN;
}
sleep(1);
while(1){
puts("round again");
poll(pollfds, 5, 50000);
for(i=0;i<5;i++) {
if (pollfds[i].revents & POLLIN){
pollfds[i].revents = 0;
memset(buffer,0,MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer);
}
}
}
epoll:
在代码的第2行处,epoll_create 首先会创建一个空间用来存放需要监听的所有 socket 描述符,存储这些描述符的数据结构为红黑树,对于11行处的 epoll_ctl 插入与删除操作有很高的效率。***(注意:epoll的源码中并没有实现mmap)***
当内核创建红黑树的同时也会创建一个双向链表 rdlist,在16行处,当有客户端准备就绪时,会将描述符放入 rdlist,epoll_wait 会返回就绪文件的数量,然后对 rdlist 进行遍历。不需要对所有的描述符进行遍历,epoll遍历的时间复杂度为O(1)。
struct epoll_event events[5];
int epfd = epoll_create(10);
...
...
for (i=0;i<5;i++) {
static struct epoll_event ev;
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
ev.events = EPOLLIN;
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while(1){
puts("round again");
nfds = epoll_wait(epfd, events, 5, 10000);
for(i=0;i<nfds;i++) {
memset(buffer,0,MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
工作模式:
epoll有两种工作模式,LT(level trigger)和ET(edge trigger),其中LT模式是默认模式,叫做水平触发模式;ET模式是高速模式,叫做边缘触发模式。
**LT模式:**当 epoll_wait 检测到描述符对应的事件发生时通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。
**ET模式:**应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。
AIO
AIO全称为 Asynchronous I/O,也被称为 NIO 2.0,是一种异步非阻塞IO。当通过AIO发起IO操作时,程序不需要阻塞等待底层执行完,可以继续执行接下来的操作。当IO操作结束后,操作系统会回调你设置的接口。对比于BIO那种主动询问操作系统,AIO可以让操作系统来通知程序IO操作完成,在这期间程序可以执行其他操作。所以被称为异步非阻塞的。
代码参考:https://devarea.com/linux-io-multiplexing-select-vs-poll-vs-epoll/#.XoHckNIzaHv