深入浅出网络IO

IO作为网络通信中最重要的部分,面试中经常会问到;本文将从计算机组成基础讲起,围绕几种常见的IO模型,介绍其原理和使用;接着会探究Linux等平台下多路复用的实现方式;搞懂这些基础知识及内核IO的原理,再来学习JavaNIO、netty等框架就很简单了,很多RPC框架(比如Dubbo)底层的网络IO模式都是基于这些基础框架,本文也会介绍Redis、Dubbo等框架的网络IO方式;

知识点汇总

1. 基本概念

1.1. 用户空间与内核空间

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

1.2. 进程切换

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

1.3. 进程的阻塞

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

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

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

  • 同步异步与消息的同步方式相关
    同步是一个主动去要结果的过程,而异步是被动通知结果;
  • 阻塞非阻塞与执行过程中的状态相关
    如果一个方法是阻塞的,程序执行时会一直等待数据返回(代码卡死,线程挂起),而如果是非阻塞的,不管有没有结果也立即返回(如果没数据就返回空)代码见如下非阻塞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 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 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:
    Reactor 在一个单独的线程中运行,负责监听和分发事件,分发给适当的处理程序来对 IO 事件做出反应。 它就像公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
  • Handlers:
    处理程序执行 I/O 事件要完成的实际事件,类似于客户想要与之交谈的公司中的实际官员。Reactor 通过调度适当的处理程序来响应 I/O 事件,处理程序执行非阻塞操作。

5.3. 模式分类

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

  • 单 Reactor 单线程
  • 单 Reactor 多线程
  • 主从 Reactor 多线程

6. IO多路复用之Netty

todo
Netty是对Java NIO的封装,提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。

7. IO多路复用之Redis

todo

8. IO多路复用之Dubbo

todo

你可能感兴趣的:(深入浅出网络IO)