网络编程socket,BIO,NIO

Socket理解

Socket是什么?Socket在JDK包里就是一个普普通通的类,里面定义了一组跟网络编程相关的方法(API),比如容易理解的connect(),bind()方法,分别是客户端建立连接,服务端绑定端口的API,从这个层面理解Socket就是一组处理网络io的API。它把TCP/IP协议族中网络通信代码(如TCP建立连接的过程)进行封装,相当于介于应用程序和TCP/IP协议族之间的中间软件抽象层。

以TCP协议为例,一台主机(client)上的应用程序需要与另一个主机(server)上的应用程序通信就必须通过Socket套接字建立连接,client端每建立一个连接就会创建一个socket实例,而server端每接受一个连接就会创建一个socket实例与client的socket进行通信,有多少个客户端建立连接就会server就会创建多少个socket实例。

短连接,长连接

短连接:建立连接——>数据传输——>关闭连接:数据传输完毕就关闭连接

长连接:建立连接——>数据传输——>数据传输。。。。——>关闭连接:即使没有数据传输也会保持连接状态,直到某一方关闭连接

网络IO编程

各种I/O模型

阻塞I/O模型:应用进程在调用recvfrom开始直到数据包到达并且被复制到应用程序空间缓冲区一直等待阻塞,TCP为例,从建立TCP连接开始直到读取数据完成线程一直处于等待阻塞状态。

非阻塞I/O模型:应用进程系统调用后,不再一直等待,而是轮询检查内核是否有数据就绪,当有数据就绪时,线程从内核拷贝数据到用户空间

I/O复用:linux提供的select/poll,进程将一个或多个fd提交给select/poll调用,进程阻塞于select操作上,这样进程就可以检查select是否有就绪的fd(可能多个)。select/poll是顺序扫描fd是否集合,因此在linux上会有制约(限制在1024个fd),因此linux提供了另一种效率更高的epoll系统调用,epoll使用事件驱动的方式替代了顺序扫描,当有数据就绪时,立即回调函数。在java里,我理解I/O复用就是非阻塞I/O模型。

信号驱动I/O模型:进程首先开启信号驱动功能,并执行一个信号处理函数。当数据准备就绪时,就位该进程生成一个信号,通过信号回调通知进程执行recvfrom读取数据。

异步I/O:进程告知内核启动某个操作,当有数据准备完成后(数据已经从内核拷贝到用户空间)通知我。和信号驱动I/O模型的区别在于,异步I/O是数据拷贝完成后通知进程,而信号驱动是告知进程可以进行I/O操作了。

I/O多路复用中select、poll、epoll有什么区别?

open-jdk中NIO底层通过epoll实现的,epoll和select/poll最大的区别就是select/poll是通过轮询所有的fd(连接通道)判断是否有事件发生,而epoll不用轮询,在操作系统中有rdList缓存,操作系统会把活跃的socket会被加入到这个rdList,应用程序只需要从这个rdList获取活跃的socket就完成了。

1、文件描述符限制大小

select受到fd的限制,linux设置为1024,他是通过数组实现;poll不受fd约束,通过链表实现;epoll也没有制约,它的fd上限受操作系统的内存影响。

2、I/O效率是否文件描述大小影响

select/poll是通过顺序扫描fd,也就是所有socket,而epoll不用扫描,它只会对活跃的socket进行操作,这是靠每个fd上的callback函数实现的,只有活跃的socket才回去调用callback函数。如果所有的socket都处于活跃状态,那么epoll就没有什么优势了。

3、使用mmap加速消息传递

数据始终都需要从内核拷贝到用户进程,epoll使用mmap内存映射的方式使用户和内核使用同一块内存空间,加快数据的传递。

BIO(Blocking IO)

BIO,即阻塞的IO。server创建ServerSocket实例并绑定IP地址和端口号,调用accept()启动监听器。当client端创建Socket对server端发起连接时,server端接收到连接创建socket实例与client端socket实例进行通信,读取数据并处理业务,如图:单线程的BIO

 client代码

    public static void main(String[] args) throws IOException {
        Socket socket = new Socket();
        OutputStream os = null;
        try {
            socket.connect(new InetSocketAddress("localhost", 8888));
            os = socket.getOutputStream();
            os.write("hello world".getBytes("UTF-8"));
            os.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (os != null) {
                os.close();
            }
        }
    }

server代码

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8888);
        System.out.println("BIO server start.");
        while (true) {
            Socket accept = null;
            try {
                accept = serverSocket.accept();
                InputStream is = accept.getInputStream();
                byte[] b = new byte[1];
                int current;
                StringBuilder sb = new StringBuilder();
                //is.read(b): 会把is流中的数据读到字节数组中,返回这一次读取的字节数,
                //当字节数组满了,则会进行下一次循环。最后如果没有数据可读返回-1。
                while ((current = is.read(b)) != -1) {
                    sb.append(new String(b, 0, current));
                }
                System.out.println("read data: " + sb.toString());
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                if (accept != null) {
                    accept.close();
                }
            }
        }
    }

上面代码client向server发送"hello world",server读取数据并打印出来之后就关闭socket。

既然BIO是阻塞IO,那么阻塞体现在哪里呢?主要体现在两点

一、server启动应用程序调用serverSocket.accept()方法,线程就会一直阻塞在这里即使没有接收到连接,知道有连接才会继续执行。

二、启动server应用程序后,client1端debug断点打在建立连接之后,发送数据之前,并启动这个client1,此时同样的代码再启动一个client2向server发送数据,client2很快执行完成了,但是server此时没有任何打印,这是因为server端一直阻塞在read()等待client1发送数据过来。

由此看出,单线程模式的BIO处理效率有限,如果由于某client数据发送出现问题,会导致server段阻塞,自然会想到多线程去处理任务。

多线程处理任务BIO 

server每接收到一个连接,就创建一个线程处理该socket实例读取数据,执行业务。

多线程BIO server代码 

    public static void main(String[] args) {
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(8888);
            System.out.println("BIO server start.");
            while (true) {
                Socket accept = serverSocket.accept();
                new Thread(new Handler(accept)).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    static class Handler implements Runnable {

        private Socket socket;

        public Handler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            InputStream is = null;
            try {
                byte[] b = new byte[1024];
                int current;
                StringBuilder sb = new StringBuilder();
                is = socket.getInputStream();
                //is.read(b): 会把is流中的数据读到字节数组中,返回这一次读取的字节数,
                //当字节数组满了,则会进行下一次循环。最后如果没有数据可读返回-1。
                while ((current = is.read(b)) != -1) {
                    sb.append(new String(b, 0, current));
                }
                System.out.println("read data: " + sb.toString());
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                if (is != null) {
                    try {
                        is.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
                if (socket != null) {
                    try {
                        socket.close();
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
    }

这种方式处理少数连接没有太大的问题,但是如果连接多了,很明显这种处理方式比较浪费线程资源,所以我们可以把线程资源通过线程池管理起来。

伪异步AIO

伪异步AIO把线程资源管理起来,通过一定数量的线程去处理多个连接。

代码就不演示了,比较简单。这种模式优势在于对线程资源的控制,但是限制了线程数量,在大并发量,数据读取比较慢时,其他连接就只能等待,这就是最大的弊端。

NIO

NIO即nonblock-IO,非阻塞IO。

它和IO最大的区别就是,NIO是面向缓冲区的,而java IO是面向流的。java IO在读取数据时,一次只能顺序读一个字节或多个字节,直到读取所有的数据;而NIO是从缓冲区读取或向缓冲区写入数据,缓冲区数据可以通过指针灵活读取数据。其次java IO在调用read()和write()方法时,会一直阻塞直到有数据可读或者可写。而java NIO的非阻塞模式体现在从通道读取数据到buffer时,只会读取目前可用的数据,如果没有可用的数据,它不会一直等待阻塞,而是什么都不做或者去处理其他事情。通过缓冲区向通道写入数据时,线程不需要等数据完全写入,同时可以去处理其他事情。java NIO通过利用线程空闲时间去做其他事情充分利用线程资源,因此一个线程可以管理多个输入和输出通道。

NIO之reactor模式

reactor译为反应堆,看各种资料“反应”即“倒置”或者“控制反转”,从中文角度来讲我有点想不通。无论如何,reactor模式表达的是client向server发起连接或者发送数据后,server端并不是被动执行连接读取数据,而是把ServerSocketChannel注册selector选择器上,再通过Reactor线程轮询selector读取这些已经就绪的事件(读、写或连接事件)。

单线程Reactor模式

单线程Reactor模式只有一个线程工作,它不仅负责监听轮询就绪事件,还需要负责处理连接读写数据,编解码业务等工作。 

单线程Reactor-工作者线程池 

这种模式跟单线程Reactor模式的区别就是,把读取的数据,需要处理业务交由线程池来处理,提高系统的性能 

多线程Reactor模式 

多线程Reactor模式也可以理解为主从架构模式,主线程只负责监听轮询是否有连接事件发生,当有连接事件过来,会把客户端的channel读事件注册到subReactor线程负责处理的selector选择器上,由subReactor负责处理读写事件,subReactor线程会把业务交给线程池来执行。

NIO三大核心组件

selector选择器

selector选择器是主要有两个作用:其一提供channel注册事件的容器,其二提供轮询事件的API,如:select(),select(int timeout)等。对于客户端和服务端在启动的时候,客户端会向Selector实例中注册一个连接事件,而这些事件存放在而线程会轮询检查是否到该事件。

channel通道

通道是被建立的一个应用程序和操作系统进行交互事件、传递数据的渠道。代码中体现出来的就是ServerSocketChannel和SocketChannel,从这两个channel中可以获取ServerSocket和Socket,不难看出,应用程序可以通过它们读取数据,同时也可以向它们写入数据。注意,通道是面向缓冲区,他只能操作缓冲区(buffer)

buffer缓冲区

buffer说到底就是一块内存空间(字节组数或者其他类型数组),应用程序通过channel可以向缓冲区写入数据,也可以从缓冲区读取数据。

三大组件处理数据方式

参考Netty权威指南NIO源码示例:

1、NIO server启动,创建selector选择器对象和ServerSocketChannel对象(简称ssc),将ssc通道设置为非阻塞模式,通过ssc向selector注册该通道感兴趣的接收事件。

2、NIO client启动,创建selector2选择器对象和SocketChannel对象(简称sc),将sc通道设置为非阻塞模式,通过sc.connect()向NIO server端发起tcp异步连接,如果连接成功则向selector2注册读事件,如果失败则注册连接事件。

3、server接收到连接事件,轮询调用selector.select()会收到就绪的事件并交由感兴趣的通道ssc处理,ssc接收到连接并创建socketChannel,socketChannel向selector注册感兴趣的读事件

4、client连接server成功后,调用doWrite()向server发送数据,server通过轮询selector.select()感知到有数据就绪(即读事件),通过selectionKey获取到socketChannel将数据读到buffer缓冲区,交由应用程序进行业务处理。

5、应用程序处理完成后需要响应客户端,将数据复制到buffer写缓冲,调用socketChannel.write()方法将buffer数据写出去,这是直接写出去;也可以通过调用socketChannel.register()向selector注册感兴趣的读事件,与此同时也会向就绪的事件集合中添加写事件,让selector能轮询到,最后通过write()发送给客户端,通过这种方式需要注意防止写空转的问题,在将数据发送给客户端以后可以通过调用key.interestOps(SelectionKey.OP_READ);将原来注册的写事件覆盖,否则selector中就绪事件集合中会一直存在写事件导致发生写空转,与此同时isWritable()是判断写缓冲是否有空闲或者说是否可以写入数据,那么它会一直返回true。

nio-client流程

nio-server流程

在openjdk中,当server端通过Selector.open()方法创建selector时,在底层会调用C语言函数epoll_create()来创建epoll实例,将他们与之对应。线程在轮询selector.select()时,底层其实通过调用epoll_ctl()函数使用文件描述符epfd引用的epoll实例,对目标文件描述符(ServerSocketChannel)执行op操作,并通过调用epoll_wait()函数等待文件描述符epfd上的事件

直接内存为什么比堆内存快

server向client发送数据时,需要将数据拷贝到buffer缓冲区中,如果需要发送的数据存放在堆内存中,需要将数据拷贝到直接内存中,再将数据拷贝到缓冲区,多了一次拷贝。实际上在NIO框架中,采用的是堆外内存(即直接内存)来做数据存放,而不是直接使用堆内存,这样可以避免多一次的拷贝,提升性能。

为什么使用堆内存需要将数据拷贝到堆外内存呢?jvm堆内存由于gc过程导致数据地址发生变化,那么有可能在拷贝的过程中,数据发生移动导致拷贝的数据出错。

零拷贝

零拷贝 ( 英语 : Zero-copy) 技术是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。

DMA(Direct Memory Access)直接内存存取

在早期计算机中,应用程序读取磁盘数据到内存时,需要CPU中断和CPU参与,在此期间需要占用CPU资源,大量的IO请求和中断导致CPU频繁的切换上下文(即用户态和内核态切换),而CPU是非常宝贵的资源,导致CPU效率低下。从而诞生了DMA,CPU只需要发出相关指令,磁盘数据的IO操作就交由DMA来处理,实现减少CPU负载,提高CPU执行效率。

假设应用程序需要将磁盘文件中某数据发送到另一台计算机,需要两次系统调用(四次上下文切换)才能将数据发送出去。DMA执行读操作从磁盘读取文件内容,将数据拷贝到套接字读缓冲区,CPU再将文件读取缓冲区数据拷贝到应用程序中,并将数据拷贝到套接字写缓冲区中,最后通过DMA将套接字写缓冲区的数据拷贝到网卡中,网卡负责发送数据。在此期间会发生4次上下文切换和4次拷贝。那么通过零拷贝技术可以减少上下文切换和拷贝次数

linux支持的常见零拷贝技术

mmap内存映射

mmap是通过磁盘文件的位置和应用程序缓冲区直接进行映射,直接由DMA将数据拷贝到用户应用程序,从而避免了磁盘文件到套接字缓冲区的数据拷贝,达到减少一次拷贝的目的,但是还是会经历4次上下文切换。

sendfile

当调用sendfile()时,DMA直接从磁盘文件将数据拷贝内核文件缓冲区,再通过CPU拷贝将数据拷贝到套接字缓冲区中,但是数据并未真正复制到套接字缓冲区中,而是将文件的位置和长度的描述符复制到套接字缓冲区。DMA直接将数据从内核文件缓冲区拷贝到网卡中。一共经历3次拷贝和2次上下文切换。

splice

splice和sendfile类似,区别在于DMA将数据拷贝到内核态文件缓冲区后,直接和套接字缓冲区建立管道,连CPU拷贝都不需要了,相比sendfile减少了一次CPU拷贝,只需要经历2次DMA拷贝和两个上下文切换就可以将数据发送出去。

在java生态圈里,支持mmap和sendfile两种零拷贝技术。NIO中FileChannel支持mmap技术和sendfile技术。kafka通过mmap文件映射实现数据顺序写入磁盘,使用sendfile技术实现网络发送数据。netty也支持了零拷贝,具体实现后期分享。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值