网络IO原理.模型演进BIO->NIO->POLL->EPOLL

395 阅读9分钟

先来看两个demo程序

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
public class IOServer {

    private static final int RECEIVE_BUFFER = 10;
    private static final int SO_TIMEOUT = 0;
    private static final boolean REUSE_ADDR = false;
    private static final int BACK_LOG = 2;
    private static final boolean CLI_KEEPALIVE = false;
    private static final boolean CLI_OOB = false;
    private static final int CLI_REC_BUF = 20;
    private static final boolean CLI_REUSE_ADDR = false;
    private static final int CLI_SEND_BUF = 20;
    private static final boolean CLI_LINGER = true;
    private static final int CLI_LINGER_N = 0;
    private static final int CLI_TIMEOUT = 0;
    private static final boolean CLI_NO_DELAY = false;
    public static void main(String[] args) {
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket();
            serverSocket.bind(new InetSocketAddress(9090),BACK_LOG);
            serverSocket.setReceiveBufferSize(RECEIVE_BUFFER);
            serverSocket.setReuseAddress(REUSE_ADDR);
            serverSocket.setSoTimeout(SO_TIMEOUT);
        }catch (Exception e){

        }
        System.out.println("server up 9090");

        while (true){
            try {
                System.in.read();

                Socket client = serverSocket.accept();
                System.out.println("client port:"+client.getPort());
                client.setKeepAlive(CLI_KEEPALIVE);
                client.setOOBInline(CLI_OOB);
                client.setReceiveBufferSize(CLI_REC_BUF);
                client.setSendBufferSize(CLI_SEND_BUF);
                client.setSoLinger(CLI_LINGER,CLI_LINGER_N);
                client.setSoTimeout(CLI_TIMEOUT);
                client.setTcpNoDelay(CLI_NO_DELAY);

                new Thread(()->{
                    while (true){
                        try {
                            InputStream in = client.getInputStream();
                            BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                            char[] data = new char[1024];
                            int num = reader.read(data);
                            if (num > 0){
                                System.out.println("client read data "+ num+"val:"+new String(data,0,num));
                            }else if (num == 0){
                                System.out.println("read nothing");
                                continue;
                            }else{
                                System.out.println("read -1");
                                client.close();
                                break;
                            }
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            }catch (Exception e){

            }
        }
    }
}

import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.Socket;

public class IOClient {
    public static void main(String[] args) {
        try {
            Socket client = new Socket("localhost", 909);

            client.setSendBufferSize(20);
            client.setTcpNoDelay(true);
            OutputStream out = client.getOutputStream();
            InputStream in = System.in;
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));

            while (true){
                String line = reader.readLine();
                if (line != null){
                    byte[] bb = line.getBytes();
                    for (byte b : bb) {
                        out.write(b);
                    }
                }
            }
        }catch (Exception e){

        }
    }
}

先查看服务端开启后 netstat -antp 的变化. linux产生了一个 LISTEN 状态的 socket。由 进程号为 4143的java程序处理客户端的三次握手的请求,后续的业务逻辑会开辟新的线程来处理 在这里插入图片描述 使用 ==lsof -p pid== 的方式查看文件描述符.看到server开启了一个 5的文件描述符监听在这里插入图片描述 2. 启动客户端程序后,查看 服务端抓包情况。发现抓到了三次握手的请求 在这里插入图片描述 3. 查看服务端的 netstat -antp。 发现服务端建立了一个和客户端的socket 但是没有 PID来处理这个socket 在这里插入图片描述 4. 客户端发送数据后查看服务端 在这里插入图片描述 发现有数据在内核中产生积压 在这里插入图片描述 怎么理解TCP协议是面向连接的? 三次握手之后,双方就会开辟资源,可以为对方提供服务,就产生了连接。不需要应用程序,内核程序就已经完成了。 5. 服务器端开始接受客户端请求 在这里插入图片描述 再次查看 netstat,发现原来没人处理的socket已经被 5643 PID 的java进程处理了 在这里插入图片描述 查看 5643 PID 的文件描述符 ,发现多出来了一个 文件描述符为 6的读取 socket数据 在这里插入图片描述

聊聊 SOCKET

socket = 客户端IP+客户端PORT+服务端IP+服务端PORT。 是一个内核级别的资源,就算应用程序不调用 accept 也就是生成一个文件描述符来消费socket。内核也会缓存socket的部分数据 ==只要是socket的四个维度有一个不一样,就可以开启一个socket== 例如 客户端 A 的ip是 192.168.10.1 服务端B 的 ip 是 192.168.10.20, 服务端开启了一个tomcat 8080.然后还有一个nginx 80. 客户端A 同时访问tomcat 和nginx的时候 可以只开启 9999一个端口 在这里插入图片描述 但是为什么有的时候起服务器的时候经常会出现端口被占用的情况呢 在这里插入图片描述 因为在服务端开启程序监听请求的时候 创建的 socket 是 ==0.0.0.0:9090-> 0.0.0.0:*== 这么一个socket,再次启动该服务端,还是这个socket,所以就冲突了。

所以A客户端用了65535个port访问了B服务器的一个端口,还可以用65535个port访问B服务的另一个端口

网络IO 模型

client 和服务端三次握手之后,内核产生一个socket,并且开辟一个buffer空间。如果服务端程序accept了这个socket,就会产生一个 系统调用,生成一个文件描述符来对socket读写 在这里插入图片描述

BACK_LOG 作用

back_log 是服务端可以缓存客户端的socket,再多余的客户端来就会被拒绝 在这里插入图片描述 当设置 BACK_LOG = 2 的时候,服务器没有接受socket,让内核自己缓存socket,发现最多可以建立 3个socket,再多的时候就会拒绝。

TCP_NODELAY

不延迟发送,如果数据量没有达到 buffersize 的时候是否先不发送。 默认是false, 表示延迟发送。开启延迟发送,客户端可以超过buffersize的数量还可以不发送数据。

服务端 KEEP_ALIVE

Socket client = serverSocket.accept();
client.setKeepAlive(true);

TCP层面的KEEPALIVE 在建立连接之后,如果长时间不通信,如何知道对方还存活。 就是使用Keepalive。服务端会定时发送数据确认客户端还存活。如果超过多少次没有回就认为死亡,断开连接。

网络IO模型演化

BIO->NIO->EPOLL

BIO

BIO 叫 Blocking IO ,阻塞IO,我们来看看为什么会阻塞 BIO服务端

public static void main(String[] args) throws Exception {
        ServerSocket  serverSocket = new ServerSocket(9090);
        System.out.println("server up 9090");
        while (true) {
            try {
                Socket client = serverSocket.accept();
                System.out.println("client port:" + client.getPort());
                new Thread(() -> {
                    while (true) {
                        try {
                            InputStream in = client.getInputStream();
                            BufferedReader reader = new BufferedReader(new InputStreamReader(in));
                            char[] data = new char[1024];
                            int num = reader.read(data);
                            if (num > 0) {
                                System.out.println("client read data " + num + "val:" + new String(data, 0, num));
                            } else if (num == 0) {
                                System.out.println("read nothing");
                            } else {
                                System.out.println("read -1");
                                client.close();
                                break;
                            }
                        } catch (IOException e) {
                            e.printStackTrace();
                        }
                    }
                }).start();
            } catch (Exception e) {
            }
        }
    }

BIO客户端

 public static void main(String[] args) {
        try {
            Socket client = new Socket("192.168.0.94", 9090);
            OutputStream out = client.getOutputStream();
            InputStream in = System.in;
            BufferedReader reader = new BufferedReader(new InputStreamReader(in));

            while (true){
                String line = reader.readLine();
                if (line != null){
                    byte[] bb = line.getBytes();
                    out.write(bb);
                }
            }
        }catch (Exception e){

        }
    }

通过命令 ==strace -ff -o out java BIOServer== 追踪BIO服务端程序。 strace 命令是记录程序产生的系统调用。 在这里插入图片描述 看到java产生的系统调用如下, 创建一个socket 的文件描述符3,然后把3绑定到端口8090上,并开启listen监听状态。 在文件的最后看到打印阻塞住了 这个阻塞就对应着 Socket client = serverSocket.accept(); 这段代码

用 nc 连接 服务端

nc 192.168.0.94 9090

看到服务端的strace 打印。看到原来阻塞的accept后面产生了一个文件描述符5 在这里插入图片描述 clone 表示创建一个新的进程 在这里插入图片描述 clone 里面的flags 表示共享文件系统 CLONE_FS, CLONE_FILES 等等表示新创建出来的这个轻量级进程和父进程共享的资源。 然后在主线程的打印里面又看到重新阻塞的 accept 3 这个文件描述符,3文件描述符就是 serversocket监听的端口。 那么接下来看创建的子进程的 系统调用日志。子进程的日志就阻塞在等待5的输入 在这里插入图片描述

BIO 总结

serverSocket的 accept 和 client的read 都会在系统调用产生阻塞,所以 BIO架构的服务端来了一个连接就需要创建一个线程来处理。如果客户端很多的话,假设10万个客户端连接,服务端就会产生10万个线程,线程间的切换也是一个重量级操作,也会浪费时间 在这里插入图片描述

NIO

NIO server 初步模型

BIO的问题是因为内核提供的 accept 和 receive 方法是阻塞的。其实内核也提供非阻塞的方法,我们来看看NIO的服务端如何实现

 public static void main(String[] args) throws Exception{
        ServerSocketChannel server = ServerSocketChannel.open();
        server.bind(new InetSocketAddress(9090));
        server.configureBlocking(false);
        List<SocketChannel> channelList = new ArrayList<>();
        while (true){
            Thread.sleep(1000);
            SocketChannel client = server.accept();
            if (client == null){
                System.out.println("没有客户端连接.....");
            }else{
                client.configureBlocking(false);
                int port = client.socket().getPort();
                System.out.println("client...port..."+port);
                channelList.add(client);
            }
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(4096);
            for (SocketChannel socketChannel : channelList) {
                int read = socketChannel.read(byteBuffer);
                if (read > 0){
                    byteBuffer.flip();
                    byte[] bytes = new byte[byteBuffer.limit()];
                    byteBuffer.get(bytes);
                    System.out.println(socketChannel.socket().getPort()+":"+new String(bytes));
                    byteBuffer.clear();
                }

            }
        }
    }

需要注意的是NIO服务端的 server 和 client 都需要 configureBlocking(false) 同样用strace 追踪看日志 在这里插入图片描述 发现系统调用的accept已经,通过 NC连接发送请求也是正常的。 这个模型已经没有开辟新的线程了,一个线程就可以处理所有的客户端请求 在这里插入图片描述

**这个模型的一个弊端是随着客户端连接的增多,循环遍历socketClient的数量就会增多,而 socketChannel.read 虽然是非阻塞 ==但是还是系统调用!!!!!== **

NIO 多路复用器

原有NIO模型, 有多少路IO就要调用多少次 read 方法,就算没有数据返回-1也是一次系统调用 在这里插入图片描述 多路复用器模型,调用 一次内核方法返回所有IO的 ==状态==,然后再由用户自己去对有状态的IO进行读取,所以叫多路复用,一次复用了多次的调用 只要是用户自己读取,就是同步的模型

在这里插入图片描述

同步异步阻塞非阻塞概念

  1. 同步 用户需要自己对内核执行 read write 操作读取数据
  2. 异步 内核主动对IO 执行 read write 操作, 然后放到某块内存中, 对于用户来说就是内存中某时刻就会有数据
  3. 阻塞 read write的时候会阻塞
  4. 非阻塞. 读取不会阻塞 线程常用的是 同步阻塞和同步非阻塞

多路复用器

==多路复用器并不是只能在非阻塞使用, jdk1.8的bio server accept底层就是poll== 并不冲突,调用了多路复用器之后知道了客户端的fd,对于fd的读取是不是阻塞的才是关键.

select

在这里插入图片描述 参数解释

  1. nfds: 传入的fd数量. 最大不能超过1024
  2. readfds 和 writefds 是要读写的文件描述符
  3. timeout 阻塞时间,如果是0的话表示一直阻塞

poll

在这里插入图片描述

没有最大fd限制,在java8 bio模型下的调用如下,会传入需要监听的文件描述符5,也就是serversocket的文件描述符,然后阻塞当解除阻塞的时候表示 5 有数据了,接下来的 accept就能马上读到数据. 本质 poll 和 select 没有太大区别

poll([{fd=5, events=POLLIN|POLLERR}], 1, -1) = 1 ([{fd=5, revents=POLLIN}])
accept(5, {sa_family=AF_INET, sin_port=htons(53668), sin_addr=inet_addr("172.20.24.226")}, [16]) = 6

epoll

在这里插入图片描述

原本的NIO 模型 需要调用 n 次系统调用. 经过 多路复用器之后, 只需要经过一次询问状态,然后返回m个有数据的io ,在调用 m 次的 receive系统调用即可. 其实不论NIO还是select,poll都是需要遍历所有fd,只是一个是用户态内核态切换,一个是全部在内核态完成 epoll 通过在内核中开辟一个空间存储 fd,由红黑树组成. 当网卡中有事件产生的时候,遍历红黑树,找到对应的fd,把fd放入另一个链表中,当用户想获得fd的时候直接返回有链表即可. epoll最核心的一点就是规避了对所有文件描述符的遍历过程,在产生中断的时候就已经把相应的fd放入链表中了

epoll 提供了3个 系统调用 epoll_create, epoll_ctl, epoll_wait 首先用户调用 epoll_create 在内核开辟一个红黑树空间,返回这个空间的文件描述符 fd6 当有一个新的链接进入的时候, 调用 epoll_ctl(fd6,ADD,fd7) 把fd7放入红黑树中. 最后用户想要获得数据的时候调用 epoll_wait 就把链表中的数据拿回来. ==红黑树搬运到链表的操作由内核完成==