说一下网络IO

1. 基本概念

1.1. 用户空间与内核空间

操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。

1.2. 进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。进程的切换要经历保存被切换进程上下文(包括程序计数器和其他寄存器)、更新PCB信息、选择另一个进程、更新PCB、恢复上下文;总而言之就是很耗资源;

1.3. 进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是

不占用CPU资源 的。

1.4. 同步异步 和 阻塞非阻塞 的关系

很多人会将这两个概念混淆,实际上这是两个完全不同的东西;

  • 同步异步与消息的同步方式相关undefined同步是一个主动去要结果的过程,而异步是被动通知结果;
  • 阻塞非阻塞与执行过程中的状态相关undefined如果一个方法是阻塞的,程序执行时会一直等待数据返回(代码卡死,线程挂起),而如果是非阻塞的,不管有没有结果也立即返回(如果没数据就返回空)代码见如下非阻塞IO

1.5. 文件描述符fd

文件描述符(File

descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

1.6. 零拷贝

应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU

进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样是不是很浪费 CPU

和性能呢?那有没有什么方式,可以减少进程间的数据拷贝,提高数据传输的效率呢?

网络IO读写流程

所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过

DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。

可以想到将用户空间与内核空间都将数据写到一个地方,就不需要拷贝;

主要通过两种方式:mmap+write 和 sendfile,mmap+write 方式的核心原理就是通过虚拟内存来解决的

虚拟内存

2. 常见的网络IO模型

说到网络通信,就不得不提网络IO模型,因为所谓的两台PC机之间的网络通信,实际上就是两台PC机对网络IO的操作;

常见的网络IO模型分为四种:同步阻塞IO(BIO)、同步非阻塞IO(NIO)、IO多路复用、异步非阻塞IO(AIO);只有AIO为异步IO,其他都是同步IO,最常用的就是

同步阻塞IOIO多路复用

2.1. 阻塞IO(BIO)

最简单、最常见的 IO 模型,在Linux中,默认所有的socket都是blocking的(如下代码);

首先,应用进程发起IO系统调用后,会转到内核空间处理,内核开始等待数据,等到来数据时再将内核中的数据拷贝到用户内存中,整个IO处理完毕后返回进程;

整个过程中(等待数据、拷贝数据)应用进程中IO操作的线程一直处于阻塞状态,如果要同时处理多个连接,势必每一个IO操作都要占用一个线程;

ServerSocket server = new ServerSocket(9090);

while (true) {

    Socket client = server.accept(); // 接受客户端-阻塞1

    new Thread(() -> {

        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(client.getInputStream()));

        String str = bufferedReader.readLine(); // 读取IO数据-阻塞2

    }).start();

}

2.2. 非阻塞IO(NIO)

相比于阻塞IO,内核在处理时,如果没有数据就 直接返回 ,基于这个特点可以实现一个线程内同事处理多个socket的IO请求;

如下代码是利用Java的NIO(New IO)来实现的非阻塞IO模型;

LinkedList<SocketChannel> clients = new LinkedList<>();

ServerSocketChannel ss = ServerSocketChannel.open();

ss.bind(new InetSocketAddress(9090));

ss.configureBlocking(false); // 内核级别非阻塞,只让接受客户端不阻塞

// 单线程:即处理连接客户端,也处理遍历每个连接

while (true) {

    // 接受客户端连接-非阻塞,没有客户端时,阻塞模式下回一直卡着,非阻塞下回直接返回-1;

    SocketChannel client = ss.accept();

    if (client != null) {

        // 设置对client的连接也是非阻塞的(内核级别)

        client.configureBlocking(false); 

        clients.add(client);

    }

    ByteBuffer buffer =ByteBuffer.allocateDirect(4096); // buffer缓冲区

    // 遍历所有客户端,处理IO数据

    for (SocketChannel c : clients) {

        // 获取IO数据-非阻塞,BIO下这里会阻塞,所以没法用一个线程遍历所有socket连接

        int num = c.read(buffer);

        if (num > 0) {  // 有数据时返回大于0

            buffer.flip();

            String str = new String(buffer.array());

            buffer.clear();

        }

    }

}

2.3. IO多路复用(IO multiplexing)

上面的代码有什么弊端?

如果有1万个客户端只有一个发来数据,那么另外9999个是不需要遍历的,并且99%情况都是没有数据来的,但是它一直在空转浪费CPU,还不如阻塞着;

IO多路复用解决了这个问题:

linux下会调用内核的select(poll、epoll)方法,传入1万个FD(文件描述符),内核会 阻塞

地监听1万个socket连接是否有数据,当有数据时内核会返回具体是哪个socket;这时用户进程再调用read操作,将数据从内核中拷贝到用户进程;(下文会介绍Linux下的三种多路复用的实现方式)

image.png

一句话解释 IO多路复用:单线程或单进程同时监测若干个文件描述符是否可以执行IO操作的能力。

多路复用 IO 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO

模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。

各平台实现IO多路复用的方式
  • Linux:Select、Poll、Epoll(目前一般都使用Epoll)
  • MacOS:Kqueue
  • Windows:IOCP

2.4. 异步非阻塞IO(AIO)

异步IO只有高版本的Linux系统内核才会支持;

IO多路复用本质是同步IO,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是同步的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。

3. Select、Poll、Epoll

Linux下实现IO多路复用有select、poll、epoll等方式,监视多个描述符(fd),一旦某个描述符就绪(读/写/异常)就能通知程序进行相应的读写操作。读写操作都是自己负责的,也即是阻塞的,所以本质上都是同步(堵塞)IO

3.1. Select

// select接口,调用时会堵塞,linux下执行man 2 select查阅

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

执行过程
  1. 使用copy_from_user从用户空间拷贝fd_set到内核空间
  2. 内核遍历[0,nfds)范围内的每个fd,调用fd所对应的设备的驱动poll函数,poll函数可以检测fd的可用流(读流、写流、异常流)。
  3. 检查是否有流发生,如果有发生,把流设置对应的类别,并执行4,如果没有流发生,执行5。或者timeout=0,执行4。
  4. select返回。
  5. select阻塞进程,等待被流对应的设备唤醒时执行2,或timeout到期,执行4。
select的局限性:

每次调select获取哪个连接有数据时都要传入所有的fd,如果fd_set很大,那么拷贝和遍历的开销会很大;

3.2. Poll

poll的实现和select非常相似,轮询+遍历+根据描述符的状态处理,只是fd_set换成了pollfd,而且去掉了最大描述符数量限制,其他的局限性同样存在。

int poll ( struct pollfd * fds, unsigned int nfds, int timeout);

3.3. Epoll

优化了select和poll中的缺点,与select和poll只提供一个接口函数不同的是,epoll提供了三个接口函数及一些结构体:

/*建一个epoll句柄*/

int epoll_create(int size);

/*向epoll句柄中添加需要监听的fd和时间event*/

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

/*返回发生事件的队列*/

int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);

struct eventpoll{

    /*红黑树的根节点,这棵树中存储着所有添加到epoll中的事件,也就是这个epoll_ctl监控的事件*/  

    struct rb_root rbr;

    /*双向链表rdllist保存着将要通过epoll_wait返回给用户的、满足条件的事件*/  

    struct list_head rdllist;

}

  • 使用红黑树而不是数组存放描述符和事件,增删改查非常高效,轻易可处理大量并发连接。
  • 红黑树及双向链表都在内核cache中,避免拷贝开销。
  • 采用回调机制,事件的发生只需关注rdllist双向链表即可。

epoll

4. Java的NIO

Java的NIO指的是JDK 1.4引入的new IO包,不是上面说的NIO指的是非阻塞IO概念;

目的是解决传统IO中面向数据流的同步阻塞问题,NIO是面向缓存区的;

Java NIO也属于IO多路复用模型,底层是调用相应的内核函数(Select、Poll、Epoll)。

传统IO和JavaNIO对比

4.1. Java NIO组成三大构件

4.1.1. Channel通道

一个Socket连接对应一个Channel通道;

4.1.2. Buffer缓冲区

用于和Channel进行数据交互;

4.1.3. Selector选择器

可以理解为Java NIO的多路复用器,多个Channel注册到同一个Selector选择器上,一个选择器同时监听多个连接通道(单线程);

在执行Selector.select()方式时,如果内核采用select实现的多路复用,则调用select函数,如果是Epoll,则调用epoll_wait函数,阻塞的获取多个socket连接或者数据请求;

Selector的IO事件类型
  • 可读 OP_READ
  • 可写OP_WRITE
  • 连接OP_CONNECT 完成了对端的握手,就处于连接就绪状态。
  • 接收OP_ACCEPT 当检测到一个新的连接到来则处于接收就绪转态。

image.png

public void init() {

    private Selector selector;

    ServerSocketChannel ssc =ServerSocketChannel.open();

    ssc.configureBlocking(false); // 非阻塞

    ssc.bind(new InetSocketAddress(port));

    selector = Selector.open();

    ssc.register(selector, SelectionKey.OP_ACCEPT);

    System.out.println("NioServer started ......");

}

public void start() {

    this.init();

    while (true) {

        // 阻塞,等待连接请求、接受数据操作,调的内核底层的select、poll、epoll?

        int events = selector.select();

        if (events > 0) {

            Iterator<SelectionKey> selectionKeys = selector.selectedKeys().iterator();

            while (selectionKeys.hasNext()) {

                SelectionKey key = selectionKeys.next();

                selectionKeys.remove();

                if (key.isAcceptable()) {

                    // 处理连接请求

                    accept(key);

                } else {

                    // 处理接受数据请求,多线程事件处理

                    service.submit(new NioServerHandler(key));

                }

            }

        }

    }

}

public void accept(SelectionKey key) {

    ServerSocketChannel ssc = (ServerSocketChannel) key.channel();

    SocketChannel sc = ssc.accept();

    sc.configureBlocking(false);

    // 将多个Channel注册到同一个Selector上

    sc.register(selector, SelectionKey.OP_READ);

    System.out.println("accept a client : " + sc.socket().getInetAddress().getHostName());

}

5. Reactor模式

很多框架、中间件底层都是基于Reactor模式来实现的,比如Netty、Redis等,而其核心又是IO多路复用;

5.1. 基本设计思想:I/O复用+线程池

  • Reactor 模式,通过一个或多个输入同时传递给服务处理器的模式(基于事件驱动)
  • 服务器端程序处理传入的多个请求,并将它们同步分派到相应的处理线程, 因此Reactor模式也叫 Dispatcher模式
  • Reactor 模式使用IO复用监听事件, 收到事件后,分发给某个线程(进程), 这点就是网络服务器高并发处理关键

5.2. 核心组成

  • Reactor:undefinedReactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。
    它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
  • Handlers:undefined处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O
    事件,处理程序执行非阻塞操作。

5.3. 模式分类

根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:

  • 单 Reactor 单线程
  • 单 Reactor 多线程
  • 主从 Reactor 多线程
正文完