网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解

Socket

在第一文网络与IO基本概念中说了一下对网络和IO的理解,第二文OSI七层详细的讲解了网络通信的过程,在过程当中感受到了会有一系列的系统调用,也就是IO的过程

换句话说,通信就是IO的过程,我们可以通过socket来完成通信。

我们来通过帮助文档详细的看一下Linux内核中的socket

man 2 socket

可以看到,如果得到socket会给我们返回了一个文件描述符
在这里插入图片描述

以及关于socket的一些命令方法
在这里插入图片描述

在得到socket过程当中,还有一些属性和选项,SOCK_NONBLOC:非阻塞,也就是说socket可以是阻塞或者非阻塞
网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第1张图片

再继续看,我们看socket的bind()方法

man 2 bind

可以看到文档有一个example例子,我们大概看一下干的什么
网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第2张图片
网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第3张图片

我们来梳理一下逻辑,我们现在是一个服务端,上来调用socket得到的文件描述符是服务端文件描述符,然后绑定地址端口号之后去监听它,如果有客户端连进来,会得到一个新的客户端描述符,也就是说每个客户端连进来都是新的描述符,也就是独立的单独的文件描述符,相当于是不同的连接了。

我们再来看Java中使用Socket
网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第4张图片

得到一个ServerSocket对象并绑定端口号,实例化以后相当于建立了连接,调用了listen,开启了监听状态,调用accept能拿到Socket,有别人连接进来,就可以拿到不为空的socket,也就是得到了一个新的客户端文件描述符。和linux中的socket基本一致的的。然后我们再继续看,拿到一个连接之后抛出了一个线程去处理这个连接,也就是每个连接让单独的线程去处理。
网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第5张图片

得到连接之后,可以获取到socket的一个流,得到它的输入流,就可以readerLine(),读取它的数据。得到输出流就可以发送数据。

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第6张图片

然后我们回到Linux中来,一切皆文件,我们再来看socket的read方法

man 2 read

可以看到需要传入文件描述符,就可以读取文件描述符

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第7张图片

read可以读取文件描述符,而socket的文件描述符可以是阻塞或者非阻塞的,那么阻塞与否是什么意思?

如果读取的是一个阻塞的文件描述符,那么我们这个read方法调起以后,就会在这卡住了,对方什么时候把数据发来,read才能读到东西,也就说我们这个线程可以被阻塞住。这就是BIO。

如果读取的是一个非阻塞的文件描述符,在read方法调起的时候,因为没有数据到达,它可以立刻返回一个没有数据,然后就可以去干别事,所以read就不会被阻塞住,等过一会再去read,就有可能读到了,也就是数据到了有可能需要等一会,但无论如何程序不会阻塞住。这就是NIO。

BIO

上面说的socket的bind,accept,read方法都是来自于kernel也就是系统内核,我们把上面的点连起来,如下图我们假如说要在服务器上跑一个java项目。
网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第8张图片

我们把项目放到tomcat中,启动tomcat,这时tomcat会先去访问内核,listen开启监听,比如说监听8080,就可以得到一个服务端文件描述符,比如说是6。

然后客户端想访问tomcat中的项目的话,客户端会先和服务器内核通信,在内核中,建立三次握手,握手的时候访问的就是8080,6这个文件描述符就可以监听到,然后会为这个客户端建立一个新文件描述符比如说8。

然后客户端和kernel连接成功以后,tomcat会开启一个线程去从kernel中读文件描述符8,也就是读客户端发过来的数据,完成通信。

如果又一个新的socket连接连进来,会再分配一个新的文件描述符比如说9,这样就可以把socket区分出来了。

在早期linux的kernel的文件描述符或者说socket,是阻塞的,那么read8这个线程读不到数据就会被阻塞了,再监听到fd9,又会开启一个线程去read9,fd9有数据了,T2就开始工作了,T1就得等有数据才会工作否则会一直阻塞着。这就是BIO模型。

它的弊端如下:

  • 线程越多,Context Switch就越多,而Context Switch是一个比较重的操作,会无谓浪费大量的CPU。

  • 每个线程会占用一定的内存作为线程的栈。比如有1000个线程同时运行,每个占用1MB内存,就占用了1个G的内存。

由于线程阻塞会引起以上问题,那么要是操作IO接口时,操作系统能够总是直接告诉有没有数据,而不是Block去等就好了。于是,NIO登场。

NIO

NIO被翻译为No-Block或者是New-Bolck,让socket能变成非阻塞,那么read就只需要开辟一个线程就够了,比如说它读fd8的时候,没有数据,它不会阻塞住,它会再去读fd9,伪代码就是循环一直监听读取新的连接就可以了。

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第9张图片

但NIO虽然可以不阻塞了,我们通过轮询,不断尝试数据是否到达,这比之前BIO好多了,起码程序不会被卡死了。但这样会带来两个新问题:

  • 如果有大量文件描述符都要等,那么就得一个一个的read。这会带来大量的Context Switch,因为read是系统调用,每调用一次就得在用户态和核心态切换一次

  • 休息一会的时间不好把握。这里是要猜多久之后数据才能到。等待时间设的太长,程序响应延迟就过大;设的太短,就会造成过于频繁的重试,干耗CPU。

要是操作系统能一口气告诉程序,哪些连接有数据到达了就好了。那么read能否只读一次就把所有连接读一遍看看是否有数据呢?

答案是不行的,read一次只能读一个连接,处理不了多个连接,所以内核就要发生变化,开辟一个新的系统调用来弥补资源浪费的情况

这时内核多了一个系统调用,即select,在Java中的NIO中叫Selector选择器。这种一次查询多个连接是否有数据到达的方式就叫多路复用。

多路复用IO

再说多路复用IO之前先来说一下它和NIO的关系,以免很多人会搞混

IO多路复用是要和NIO一起使用的。尽管在操作系统级别,NIO和IO多路复用是两个相对独立的事情。NIO仅仅是指IO API总是能立刻返回,不会被阻塞。

而IO多路复用仅仅是操作系统提供的一种便利的通知机制。操作系统并不会强制必须得一起用,你可以用NIO,但不用IO多路复用。也可以使用IO多路复用 + BIO,但这时效果还是当前线程被卡住。

所以IO多路复用和NIO是要配合一起使用才有实际意义。因此,在使用IO多路复用之前,总是先把fd设为SOCK_NONBLOC。

铺垫完以后我们先在Linux系统上来看select

man 2 select

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第10张图片

它接受3个文件描述符的数组,分别监听读取(readfds),写入(writefds)和异常(expectfds)事件,所以select允许监控更多的文件描述符,可以把所有的连接放进去,然后内核会推演哪些连接有数据然后返回给程序,程序再去处理。

详细来说就是为select构造一个fd数组,然后用select监听了read_fds中的多个socket的读取时间。调用select后,程序会Block住,直到一个事件发生了,或者等到最大1秒钟(tv定义了这个时间长度)就返回。之后,需要遍历所有注册的fd,挨个检查哪个fd有事件到达(FD_ISSET返回true)。如果true,就说明数据已经到达了,可以读取fd了。读取后就可以进行数据的处理。

在Java中的NIO也是相似的,JAVA NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区),Selector(选择器)。

传统IO是基于流的形式,而NIO基于Channel和Buffer进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector用于监听多个通道的事件,比如:连接打开,数据到达。因此,单个线程可以监听多个数据通道。

Channel

首先说一下Channel,国内大多翻译成“通道”。Channel和IO中的Stream流是差不多一个等级的。只不过Stream是单向的,譬如:InputStream, OutputStream。而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作,Channel的主要实现有:FileChannel、DatagramChannel、SocketChannel、ServerSocketChannel,通过看名字就可以猜出个所以然来:分别可以对应文件IO、UDP、TCP的Server和Client

Buffer

Buffer是一个缓冲区,是一个用于存储特定基本类型数据的容器,例如它的实现有:ByteBuffer、CharBuffer、 FloatBuffer、IntBuffer分别对应基本数据类型: byte、char、 float、int。可以把它理解成和Channel配合使用的,在进行读操作时,需要使用Buffer分配空间,然后将数据从Channel中读入Buffer中,对于Channel的写操作,也需要先将数据写入Buffer,然后将Buffer写入Channel中。

Selector

Selector 是NIO相对于BIO实现多路复用的基础,Selector 运行单线程处理多个 Channel,也就是说Selector可以监听多个通道的事件,比如连接打开,数据到达。

通过代码可以看到java怎么使用他们,如下:

//创建选择器
Selector selector=Selector.open();

//实例化通道 设置为非阻塞 绑定端口号,实际上ss就变成了listen状态的服务端文件描述符
ServerSocketChannel ss=ServerSocketChannel.open();
ss.configureBlocking(false);
ss.bind(new InetSocketAddress(8080));

//把选择器注册到通道中,就是把ss填充到selector中,让selector关注ss的accept事件
ss.register(selector,SelectionKey.OP_ACCEPT);

while (true){
  //获取到有状态的连接数
  if(selector.select()==0){
    continue;
  }

    //获取到有状态的连接
    Set<SelectionKey> selectionKeys=selector.selectedKeys();
    Iterator<SelectionKey> iterable=  selectionKeys.iterator();

    //根据事件、状态分别处理
    while (iterable.hasNext()){
        SelectionKey key=iterable.next();

    if(key.isAcceptable()){//想和当前key建立连接
            ServerSocketChannel cc= (ServerSocketChannel) key.channel();
            SocketChannel sc=cc.accept();
            sc.configureBlocking(false);
            //把新的连接再填充到selector中
            sc.register(selector,SelectionKey.OP_READ);
      }else if(key.isReadable()){//可读
            SocketChannel c1= (SocketChannel) key.channel();
            ByteBuffer data=ByteBuffer.allocate(1024);
            c1.read(data);
            data.flip();
            byte[] inData=new byte[1024];
            data.get(inData,0,inData.length);
            System.err.println(new String(inData,0,inData.length));
        }
        iterable.remove();
    }

}

如下就是多路复用模型,tomcat的一个线程去select看哪些连接有数据了然后把状态改成可读然后返回,程序再去看哪些状态为可读然后再去read读取这些连接。

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第11张图片

但是select有一些发指的缺点:

  • select能够支持的最大的fd数组的长度是1024。这对要处理高并发的web服务器是不可接受的。

  • fd数组按照监听的事件分为了3个数组,为了这3个数组要分配3段内存去构造,而且每次调用select前都要重设它们(因为select会改这3个数组);调用select后,这3数组要从用户态复制一份到内核态;事件到达后,要遍历这3数组。很不爽。

  • select返回后要挨个遍历fd,找到被“SET”的那些进行处理。这样比较低效。

  • select是无状态的,即每次调用select,内核都要重新检查所有被注册的fd的状态。select返回后,这些状态就被返回了,内核不会记住它们;到了下一次调用,内核依然要重新检查一遍。于是查询的效率很低。

poll

poll与select类似,它也是实现多路复用的IO模型。它大概长这样:

man 2 poll

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第12张图片

poll的代码例子和select差不多,本质上都是轮询,因此也就不赘述了。

poll优化了select的一些问题。比如不再有3个数组,而是1个polldfd结构的数组了,并且也不需要每次重设了。数组的个数也没有了1024的限制。但其他的问题依旧:

  • 依然是无状态的,性能的问题与select差不多一样;

  • 应用程序仍然无法很方便的拿到那些“有事件发生的fd“,还是需要遍历所有注册的fd。

目前来看,高性能的web服务器都不会使用select和poll。他们俩存在的意义仅仅是“兼容性”,因为很多操作系统都实现了这两个系统调用。

如果是追求性能的话,在BSD/macOS上提供了kqueue api;而在Linux上提供了epoll api。它们的出现彻底解决了select和poll的问题。Java NIO,nginx等在对应的平台的上都是使用这些api实现。

epoll实现IO多路复用

还是在Linux系统当中先去看说明

man 2 epoll

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第13张图片

注意打开的是七类杂项,描述了epoll的相关定义,我们具体去看它的二类系统调用。

与select和poll不同,要使用epoll是需要先创建一下的

man 2 epoll_create

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第14张图片

ep会在内核创建一个数据表,用于记录要注册的fd。接口会返回一个“epoll的文件描述符”指向这个表。

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第15张图片

epoll data就是内核提供的mmap,是一棵红黑树,他就是一个用户态和内核态的一个共享空间

为什么epoll要创建一个用文件描述符来指向的表呢?这里有两个好处:

  • epoll是有状态的,不像select和poll那样每次都要重新传入所有要监听的fd,这避免了很多无谓的数据复制。epoll的数据是用接口epoll_ctl来管理的(增、删、改)

  • epoll文件描述符在进程被fork时,子进程是可以继承的。这可以给对多进程共享一份epoll数据,实现并行监听网络请求带来便利。但这超过了本文的讨论范围,就此打住

epoll创建后,第二步是使用epoll_ctl接口来注册要监听的事件。

man 2 epoll_ctl

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第16张图片

注意epoll_ctl方法的参数

第一个参数就是上面创建的表epfd。

第二个参数op表示如何对文件名进行操作,共有3种:

  • EPOLL_CTL_ADD - 注册一个事件

  • EPOLL_CTL_DEL - 取消一个事件的注册

  • EPOLL_CTL_MOD - 修改一个事件的注册

第三个参数是要操作的fd,这里必须是支持NIO的fd,比如socket

第四个参数是一个epoll_event的类型的数据,表达了注册的事件的具体信息。

typedef union epoll_data {
    void    *ptr;
    int      fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;    /* Epoll events */
    epoll_data_t data;      /* User data variable */
};

比方说,想关注一个fd1的读取事件,并采用边缘触发(边沿触发把如何处理数据的控制权完全交给了开发者,提供了巨大的灵活性),大概要这么写:

struct epoll_data ev;
ev.events = EPOLLIN | EPOLLET; // EPOLLIN表示读事件;EPOLLET表示边缘触发
ev.data.fd = fd1;

通过epoll_ctl就可以灵活的注册/取消注册/修改注册某个fd的某些事件。


只要在创建的时候调用一次就可以了,把这个连接压到内核当中,内核就知道需要一直关注这个连接。

第三步,使用epoll_wait来等待事件的发生。

man 2 epoll_wait

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第17张图片

注意,这一步是"block"的。只有当注册的事件至少有一个发生,或者timeout达到时,该调用才会返回。

这与select和poll几乎一致。但不一样的地方是events,它是epoll_wait的返回数组,里面只包含那些被触发的事件对应的fd,而不是像select和poll那样返回所有注册的fd。
网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第18张图片

我们把以上理论结合起来,如下图

网络通信和IO(3):IO模型 / 什么是BIO / 什么是NIO /什么是多路复用 / epoll实现IO多路复用 / socket详解_第19张图片
tomcat先去做一个系统调用,调用epoll_create,假如是6号文件描述符,然后tomcat还要listen一个文件描述符比如说是8

然后tomcat再去调用epoll_ctl,把6和8传进去

然后再调用epoll_wite,等待事件的发生。

如果有客户端连接进来,那么就会被监听到产生文件描述符比如说是9,就会再调用epoll_ctl把6和9传进去,然后再调用epoll_wite。

代码实现大概就是下面这样

int epfd = epoll_create(POLL_SIZE);
    struct epoll_event ev;
    struct epoll_event *events = NULL;
    nfds = epoll_wait(epfd, events, 20, 500);
    {
        for (n = 0; n < nfds; ++n) {
            if (events[n].data.fd == listener) {
                //如果是主socket的事件的话,则表示
                //有新连接进入了,进行新连接的处理。client = accept(listener, (structsockaddr *)&local, &addrlen);
                if (client < 0) {
                    perror("accept");
                    continue;
                }
                setnonblocking(client);        //将新连接置于非阻塞模式
                ev.events = EPOLLIN | EPOLLET; //并且将新连接也加入EPOLL的监听队列。//注意,这里的参数EPOLLIN|EPOLLET并没有设置对写socket的监听,
                //如果有写操作的话,这个时候epoll是不会返回事件的,如果要对写操作
                //也监听的话,应该是EPOLLIN|EPOLLOUT|EPOLLET
                ev.data.fd = client;
                if (epoll_ctl(epfd, EPOLL_CTL_ADD, client, &ev) < 0) {
                    //设置好event之后,将这个新的event通过epoll_ctl加入到epoll的监听队列里面,
                    //这里用EPOLL_CTL_ADD来加一个新的epoll事件,通过EPOLL_CTL_DEL来减少一个
                    //epoll事件,通过EPOLL_CTL_MOD来改变一个事件的监听方式。fprintf(stderr, "epollsetinsertionerror:fd=%d", client);
                    return -1;
                }
            }
            else if(event[n].events & EPOLLIN)
            {
                //如果是已经连接的用户,并且收到数据,
                //那么进行读入
                int sockfd_r;
                if ((sockfd_r = event[n].data.fd) < 0)
                    continue;
                read(sockfd_r, buffer, MAXSIZE);
                //修改sockfd_r上要处理的事件为EPOLLOUT
                ev.data.fd = sockfd_r;
                ev.events = EPOLLOUT | EPOLLET;
                epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd_r, &ev)
            }
            else if(event[n].events & EPOLLOUT)
            {
                //如果有数据发送
                int sockfd_w = events[n].data.fd;
                write(sockfd_w, buffer, sizeof(buffer));
                //修改sockfd_w上要处理的事件为EPOLLIN
                ev.data.fd = sockfd_w;
                ev.events = EPOLLIN | EPOLLET;
                epoll_ctl(epfd, EPOLL_CTL_MOD, sockfd_w, &ev)
            }
            do_use_fd(events[n].data.fd);
        }
    }

所有的基于IO多路复用的代码都会遵循这样的写法:注册——监听事件——处理——再注册,无限循环下去。

IO模型总结

同步与异步

同步和异步是针对应用程序和内核的交互而言的。

同步指的是用户进程触发IO操作并等待或者轮询的去查看IO操作是否就绪。例如:自己上街买衣服,自己亲自干这件事,别的事干不了。

异步指的是用户进程触发IO操作以后便开始做其他的事情,而当IO操作已经完成的时候会得到IO完成的通知。例如:告诉朋友自己合适衣服的尺寸、颜色、款式,委托朋友去买,然后自己可以去干别的事。同时,你还需要告诉朋友你家衣柜在哪,方便朋友买完之后,直接将衣服放到你的衣柜。

使用异步I/O时,Java将I/O读写委托给OS处理,需要将数据缓冲区地址和大小传给OS

那么可以发现我们如上说的IO模型都是同步的,没有具体的异步模型。

阻塞与非阻塞

阻塞和非阻塞是针对进程在访问数据的时候,根据IO操作的就绪状态来采取的不同方式。

阻塞指的是当试图对该文件描述符进行读写时,如果当时没有东西可读,或暂时不可写,程序就进入等待状态,直到有东西可读或可写为止。去地铁站充值,发现这个时候充值员碰巧不在,然后我们就在原地等待,一直等到充值员回来为止。

非阻塞指的是如果没有东西可读,或不可写,读写函数马上返回,而不会等待。在银行里办业务时,领取一张小票,之后我们可以玩手机,或与别人聊聊天,当轮到我们时,银行的喇叭会通知,这时候我们就可以去办业务了。

IO概念

IO是指计算机与外部世界或者一个程序与计算机的其余部分的之间的接口。它对于任何计算机系统都非常关键,因为所有 I/O 的主体实际上是内置在操作系统中的。单独的程序一般是让系统为它们完成大部分的工作。

BIO(Blocking I/O)同步阻塞I/O

Java BIO即Block I/O , 同步并阻塞的IO,BIO就是传统的java.io包下面的代码实现。

那么什么是同步阻塞I/O模式呢,顾名思义就是数据的读取写入必须阻塞在一个线程内等待其完成。这是最基本与简单的I/O操作方式,其根本特性是做完一件事再去做另一件事,一件事一定要等前一件事做完。这很符合程序员传统的顺序来开发思想,因此BIO模型程序开发起来较为简单,易于把握。

但是BIO如果需要同时做很多事情(例如同时读很多文件,处理很多tcp请求等),就需要系统创建很多线程来完成对应的工作,因为BIO模型下一个线程同时只能做一个工作

如果线程在执行过程中依赖于需要等待的资源,那么该线程会长期处于阻塞状态。

我们知道在整个操作系统中,线程是系统执行的基本单位,在BIO模型下的线程 阻塞就会导致系统线程的切换,从而对整个系统性能造成一定的影响。

当然如果我们只需要创建少量可控的线程,那么采用BIO模型也是很好的选择,但如果在需要考虑高并发的web或者tcp服务器中采用BIO模型就无法应对了,如果系统开辟成千上万的线程,那么CPU的执行时机都会浪费在线程的切换中,使得线程的执行效率大大降低。

此外,关于线程这里说一句题外话,在系统开发中线程的生命周期一定要准确控制,在需要一定规模并发的情形下,尽量使用线程池来确保线程创建数目在一个合理的范围之内,切莫编写线程数量创建上限的代码。

NIO (New I/O) 同步非阻塞I/O

关于NIO,国内有很多技术博客将英文翻译成No-Blocking I/O,非阻塞I/O模型 ,当然这样就与BIO形成了鲜明的特性对比。

NIO本身是基于事件驱动的思想来实现的,其目的就是解决BIO的大并发问题,因为在BIO模型中,如果需要并发处理多个I/O请求,那就需要多线程来支持,为了解决这个问题出现了NIO。

NIO 与原来的 I/O 有同样的作用和目的, 他们之间最重要的区别是数据打包和传输的方式。

原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

面向流 的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的 I/O 通常相当慢。

而面向块 的 I/O 系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。

NIO同时支持阻塞与非阻塞模式,因为它使用了多路复用器机制,以socket使用来说,多路复用器通过不断轮询各个连接的状态,只有在socket有流可读或者可写时,应用程序才需要去处理它,在线程的使用上,就不需要一个连接就必须使用一个处理线程了,而是只是有效请求时(确实需要进行I/O处理时),才会使用一个线程去处理,这样就避免了BIO模型下大量线程处于阻塞等待状态的情景。

epoll

epoll是Linux内核为处理大批量文件描述符而作了改进的poll,是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率。

另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

epoll除了提供select/poll那种IO事件的水平触发(Level Triggered)外,还提供了边缘触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

AIO( Asynchronous I/O)

Java AIO即异步非阻塞的IO。异步非阻塞与同步非阻塞的区别在哪里?异步非阻塞无需一个线程去轮询所有IO操作的状态改变,在相应的状态改变后,系统会通知对应的线程来处理。

Java AIO就是Java作为对异步IO提供支持的NIO.2 ,Java NIO2 (JSR 203)定义了更多的 New I/O APIs, 提案2003提出,直到2011年才发布, 最终在JDK 7中才实现。JSR 203除了提供更多的文件系统操作API(包括可插拔的自定义的文件系统), 还提供了对socket和文件的异步 I/O操作。 同时实现了JSR-51提案中的socket channel全部功能,包括对绑定, option配置的支持以及多播multicast的实现。

从编程模式上来看AIO相对于NIO的区别在于,NIO需要使用者线程不停的轮询IO对象,来确定是否有数据准备好可以读了,而AIO则是在数据准备好之后,才会通知数据使用者,这样使用者就不需要不停地轮询了。当然AIO的异步特性并不是Java实现的伪异步,而是使用了系统底层API的支持,在Unix系统下,采用了epoll IO模型,而windows便是使用了IOCP模型。关于Java AIO,本篇只做一个抛砖引玉的介绍,如果你在实际工作中用到了,那么可以参考Netty在高并发下使用AIO的相关技术。

补充

IO实质上与线程没有太多的关系,但是不同的IO模型改变了应用程序使用线程的方式,NIO与AIO的出现解决了很多BIO无法解决的并发问题,当然任何技术抛开适用场景都是耍流氓,复杂的技术往往是为了解决简单技术无法解决的问题而设计的,在系统开发中能用常规技术解决的问题,绝不用复杂技术,否则大大增加系统代码的维护难度,学习IT技术不是为了炫技,而是要实实在在解决问题。

在多说一点,可以发现上述的IO模型都是系统内核再改变,不同的系统内核采用的IO模型实际上是不一样的。

各自适用场景

BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。

NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。

AIO方式适用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持

  • 对于socket的文件描述符才有所谓BIO和NIO。

  • 多线程+BIO模式会带来大量的资源浪费,而NIO+IO多路复用可以解决这个问题。

  • 在Linux下,基于epoll的IO多路复用是解决这个问题的最佳方案;epoll相比select和poll有很大的性能优势和功能优势,适合实现高性能网络服务。

你可能感兴趣的:(IO与网络通信)