阻塞IO、非阻塞IO、以及多路复用原理

阻塞IO、非阻塞IO、以及多路复用原理


提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档


什么是I/O

在计算机系统中I/O就是输入(Input)和输出(Output)的意思,针对不同的操作对象,可以划分为磁盘I/O模型,网络I/O模型,内存映射I/O, Direct I/O、数据库I/O等,只要具有输入输出类型的交互系统都可以认为是I/O系统,也可以说I/O是整个操作系统数据交换与人机交互的通道,这个概念与选用的开发语言没有关系,是一个通用的概念。
在如今的系统中I/O却拥有很重要的位置,现在系统都有可能处理大量文件,大量数据库操作,而这些操作都依赖于系统的I/O性能,也就造成了现在系统的瓶颈往往都是由于I/O性能造成的。因此,为了解决磁盘I/O性能慢的问题,系统架构中添加了缓存来提高响应速度;或者有些高端服务器从硬件级入手,使用了固态硬盘(SSD)来替换传统机械硬盘;因此,一个系统的优化空间,往往都在低效率的I/O环节上,很少看到一个系统CPU、内存的性能是其整个系统的瓶颈。也正因为如此,Java在I/O上也一直在做持续的优化,从JDK 1.4开始便引入了NIO模型,大大的提高了以往BIO模型下的操作效率。
在介绍阻塞IO、非阻塞IO、以及多路复用的原理之前,先来看看一个网络IO的总体调用过程
1、客户端的进程想要向服务端的进程发送或申请数据
2、数据到达服务器端所在计算机的网卡,然后操作系统将其拷贝到内核所对应的socket缓冲区。
3、服务端进程将内核缓冲区的数据拷贝到自己的进程中,然后对数据进行处理(可能为读,可能为写)
4、服务端对处理完的数据发送到内核所对应的socket缓冲区,然后再从网卡发送到客户端。

一、BIO(阻塞IO)

BIO (Blocking I/O):同步阻塞I/O模式,数据的读取写入必须阻塞在一个线程内等待其完成。简单来试想一下一个服务端与客户端连接的一个场景。当有客户端连接时,服务器端需为其单独分配一个线程,如果该连接不做任何操作就会造成不必要的线程开销。BIO的缺点很明显,首先单独为每一个连接分配一个线程,线程的开销是很明显的,假如说有上万个连接,那么我就得分配上万个进程。对此的改进可能有一些人会想到用线程池,但是线程池要设多大,多大才合理,这个很难判断,但这也是一个优化的方法。
BIO总体流程:
1、服务器端启动一个SeverSocket
2、客户端启动Socket对服务器端发起通信,默认情况下服务器端需为每个客户端创建一个线程与之通讯
3、客户端发起请求后,先咨询服务器端是否有线程响应,如果没有则会等待或被拒绝
4、如果有线程响应,客户端线程会等待请求结束后,再继续执行

BIO的执行代码如下:

//BIO-服务器端
public class BIOSever {
    public static void main(String[] args) throws IOException {
        //在BIO中,可以使用线程池进行优化
        ExecutorService cachedThreadPool = Executors.newCachedThreadPool();
        ServerSocket serverSocket = new ServerSocket(6666);
        System.out.println("服务器已启动");

        while (true){
            System.out.println("等待客户端连接.....(阻塞中)");
            Socket socket = serverSocket.accept();
            System.out.println("客户端连接");
            cachedThreadPool.execute(new Runnable() {
                public void run() {
                    handler(socket);
                }
            });
        }
    }

    //从客服端socket读取数据
    public static void handler(Socket socket){
        try{
            InputStream inputStream = socket.getInputStream();
            byte[] b = new byte[1024];
            while (true){
                System.out.println("等待客户端输入.....(阻塞中)");
                int read = inputStream.read(b);
                if (read != -1){
                    System.out.println(new String(b, 0, read));
                }else {
                    break;
                }
            }
            inputStream.close();
        }catch (Exception e){
            e.printStackTrace();
        }finally {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
//BIO-客户端
public class BIOClient {
    public static void main(String[] args) throws IOException {
        Socket socket = new Socket("localhost", 6666);
        OutputStream outputStream = socket.getOutputStream();
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNextLine()){
            String message = scanner.nextLine();
            if ("exit".equals(message)) {
                break;
            }
            outputStream.write(message.getBytes());
        }
        outputStream.close();
        socket.close();
    }
}

可以看到,当服务端分配一个线程处理客户端的时候,其服务端所分配的线程会调用read()函数,此函数其实会一直阻塞,等待数据的返回,而且该函数其实涉及到系统调用,即会从当前进程的用户态陷入到内核态,这也是一个很大的开销

二、NIO

BIO的缺点显而易见,首先想一下为什么会出现BIO,也就是说为什么线程要阻塞,其根本原因是内核要阻塞,那内核程序能不能支持一下非阻塞的方式,也即我管理的连接,当没有数据到达的时候,我返回-1,让其不阻塞。因此NIO就出现了,源自内核的支持,NIO在原则上可以让一个线程管理上万个连接。来看看NIO是如何做的
1、客户端想要连接服务端,服务端监听到客户端的连接,然后将这个连接分配到一个用于管理这些连接的线程上(可以理解为这个线程管理了一个具有多个连接的数组)
2、线程开始遍历当前数据,通过read()操作轮询是否有可读事件发生,如果有可读的事件发送,则进行读处理,如果没有,那么内核直接返回一个没有读事件发生的标识给线程,那么线程就可以一次遍历所有的连接来处理读写事件。

先来说一下该非阻塞IO的优点
1、不用像BIO一样创建多个线程,用一个线程就可以管理多个连接,减少了创建线程所带来的开销
看似我们解决了阻塞IO带来的问题,但是该阻塞IO也有一些缺点
1、虽然能用一个线程管理了多个连接,但是,不管连接中有没有可读可写的数据,都要进行遍历一边,如果连接数一多,那么这个O(n)的遍历复杂度还是挺消耗性能的
2、注意到,每次进行连接的读写的时候,都要调用read()或write的操作,上面说到,每一次read()和write()操作都要涉及到系统调用,从用户态陷入到内核态,想一想,N个连接就是N次的系统调用,这个开销还是挺大的

Select、Poll、Epoll

上述的NIO中,都是在用户态来完成轮询read或write操作,N个连接就是N次的系统调用,那有没有一种方式,即把我需要轮询的连接read和write操作转移到内核中?即我通过调用一个函数,然后这个函数就在内核中帮我监听这些连接,然后返回可读写的事件返回给用户态。而这其实就是内核所提供的select、poll、epoll函数

Select

Select函数如下

扫描二维码关注公众号,回复: 15423701 查看本文章
/* According to POSIX.1-2001 */
#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
		fd_set *exceptfds, struct timeval *timeout);

void FD_CLR(int fd, fd_set *set);		//从fdset中删除fd
int  FD_ISSET(int fd, fd_set *set);		//判断fd是否已存在fdset
void FD_SET(int fd, fd_set *set);		//将fd添加到fdset
void FD_ZERO(fd_set *set);				//fdset所有位清0

select参数说明如下:

nfds:监控的文件描述符集中,待测试的最大描述符+1,这里可理解为用户空间需要监听的连接

readfds:监控有读数据到达文件描述符集合

writefds:监控有写数据到达文件描述符集合

exceptfds:监控异常发生达文件描述符集合

timeout:定时阻塞监控时间,3种情况:

1)NULL,永远等下去

2)设置 timeval ,等待固定时间

3)设置 timeval 里时间均为0,检查描述字后立即返回,轮询

这里不对源码做深入讲解,只需要知道内核提供了一个函数,帮助用户空间监听其连接,并返回有事件的连接,那么在用户空间,我们只需要轮询从select函数中返回的数据是否有可读写的标志,如果有,那么就做进一步的处理。而对比于上面的NIO,select将用户态的遍历转移到了内核态,也即通过一次系统调用就能返回可读写的事件。

poll

poll函数其实跟select的原理差不多,不同的地方是,select是把fd(可理解为连接)采用数组的方式存放,而poll是以链表的方式,采用链表的方式不需要一片连续的内存空间,这对内核空间来说非常重要。

epoll

epoll 是Linux内核中的一种可扩展IO事件处理机制,最早在 Linux 2.5.44内核中引入,可被用于代替POSIX select 和 poll 系统调用,并且在具有大量应用程序请求时能够获得较好的性能( 此时被监视的文件描述符数目非常大,与旧的 select 和 poll 系统调用完成操作所需 O(n) 不同, epoll能在O(1)时间内完成操作,所以性能相当高)

int epoll_create(int size);

创建一个epoll的句柄,size用来告诉内核需要监听的数目一共有多大。当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close() 关闭,否则可能导致fd被耗尽。

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

epoll的事件注册函数,第一个参数是 epoll_create() 的返回值,第二个参数表示动作,使用如下三个宏来表示:

EPOLL_CTL_ADD    //注册新的fd到epfd中;
EPOLL_CTL_MOD    //修改已经注册的fd的监听事件;
EPOLL_CTL_DEL    //从epfd中删除一个fd;

第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct 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 */
};
EPOLLIN     //表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT    //表示对应的文件描述符可以写;
EPOLLPRI    //表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR    //表示对应的文件描述符发生错误;
EPOLLHUP    //表示对应的文件描述符被挂断;
EPOLLET     //将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT//只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

参数events用来从内核得到事件的集合,maxevents 告之内核这个events有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的size,参数 timeout 是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时

总结

提示:这里对文章进行总结:

例如:以上就是今天要讲的内容,本文仅仅简单介绍了BIO、NIO、多路复用的原理

猜你喜欢

转载自blog.csdn.net/weixin_44821965/article/details/126885819