网络io

10 minute read

服务端: ServerSocket serverSocket = new ServerSocket(8080);

while(true) { Socket socket = serverSocket.accept(); socket.getInputStream().read(buffer); }

客户端: Socket socket = new Socket(“127.0.0.1”,8080); socket.getOutputStream().write(“向服务器发数据”.getBytes());

服务端启动后,在客户端还没有向服务端发起连接请求时,会阻塞在accept()函数,直到有客户端请求服务端连接

当客户端向服务端建立连接后,但还有发送数据时,会阻塞在read()函数,直到客户端有数据发送过来

BIO的特点是会有两次阻塞,一次在等待连接时accept()阻塞,一次在等待数据时read()阻塞

所以单线程版的BIO不能处理处理多个客户端的请求,因为accept()会阻塞新客户端的连接,所以BIO想要支持多个客户端连接,必须实现为一个连接一个线程去处理 这样带来的问题是,如果有很多客户端同服务端建立了连接,但没有发送数据,则需要建立同等数量的线程,会给服务器带来很大的压力

如果单线程服务器在等待数据时阻塞,第二个连接请求来时,服务器无法响应。如果是多线程服务器,又会为大量空闲连接产生新线程而占有系统资源。

将ServerSocket Socket替换为非阻塞的对象ServerSocketChannel SocketChannel,同时设置configureBlocking(false); //Java为非阻塞设置的类 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(8080)); //设置为非阻塞 serverSocketChannel.configureBlocking(false); wihle(true) { SocketChannel socketChannel = serverSocketChannel.accept(); socketChannel.configureBlocking(false); byteBuffer.flip(); //切换模式 写–>读 int effective = socketChannel.read(byteBuffer); }

这种方法不会阻塞,但如果服务器有新的连接请求,会把当前的连接覆盖掉。

缓存socket,遍历socket查看是否有数据ready,可以解决上述问题,同时实现单线程支持多客户端连接。但这种在应用层遍历socket的方式,并不合理。 假如建立的连接数量有100万,轮询这100万socket效率极其低下。

NIO方式:

public class NioHttpServer {

public static void main(String[] args) throws IOException { // 全局selector,至关重要 Selector selector = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); // 服务端开启对8082端口的监听 ssc.socket().bind(new InetSocketAddress(8082)); // 设置为非阻塞模式 // 思考1:阻塞和同步是一个概念吗?非阻塞=异步? ssc.configureBlocking(false); // 注册监听到selector上 ssc.register(selector, SelectionKey.OP_ACCEPT); while (!Thread.interrupted()) { // 因为是非阻塞模式,所以不论是否接收到请求,selector.select()都会立即返回。这里需要判断是否真正的accept if (selector.select() > 0) { // 处理接收到的事件 Set selectionKeys = selector.selectedKeys(); Iterator iterator = selectionKeys.iterator(); while (iterator.hasNext()) { SelectionKey key = iterator.next(); iterator.remove();

      if (key.isAcceptable()) {
        ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
        SocketChannel channel = serverSocketChannel.accept();
        channel.configureBlocking(false);
        // 注册读事件
        channel.register(selector, SelectionKey.OP_READ);
      } else if (key.isReadable()) {
        // 我们不会直接从channel中取出字节,而是将channel中的数据写入Buffer缓冲区
        SocketChannel sc = (SocketChannel) key.channel();
        ByteBuffer result = ByteBuffer.allocate(102400);
        ByteBuffer buffer = ByteBuffer.allocate(10);
        while (sc.read(buffer) > 0) {
          // 从buffer读数据之前,进行flip操作。
          // 思考2:为什么从buffer读数据要先进行flip操作
          buffer.flip();
          result.put(buffer);
          // 思考3:为什么向buffer写数据要先进行clear操作(新建的buffer不需要)
          buffer.clear();
        }
        // 继续注册写事件
        sc.register(selector, SelectionKey.OP_WRITE, new String(result.array(), StandardCharsets.UTF_8));
      } else if (key.isWritable()) {
        SocketChannel sc = (SocketChannel) key.channel();
        String attachment = (String) key.attachment();
        ByteBuffer buffer = ByteBuffer.wrap(ResponseUtils.getResponse(attachment).getBytes());
        while (buffer.hasRemaining()) {
          sc.write(buffer);
        }
        // 回写数据完成,关闭channel
        sc.close();
      }
    }
  }
}   } }

bio是阻塞式io,是同步io nio可以设置两种模式,阻塞模式(Unix多路复用io)和非阻塞模式(Unix非阻塞io),但数据从内核态加载到用户态这个过程,是同步阻塞的,所以nio也是同步的 aio是完全的异步非阻塞模式,也是真正的异步io

非阻塞,对于底层Unix-IO模型,都是指数据从磁盘加载到内核态的这个过程,是否阻塞。 异步是指整个IO操作(包含了两步:数据在内核态准备完成,数据从内核态转变为用户态)完成之后,系统通知应用程序(通过signal或callback)。

真实nio方式,底层使用select/poll/epoll,也就是代码中的 Selector selector = Selector.open();

nio channel:类似bio中的流,但不能直接从channel读写数据,channel只和缓冲区buffer打交道 nio buffer:底层是一个数组 nio selector:基于操作系统的epoll,一个selector可以同时监听多个channel上的事件,不必对每一个连接都建立线程

在NIO方式中,将轮询的步骤交给了操作系统来执行(select、epoll),在操作系统级别上调用select函数,主动感知有数据的socket。

select方式会将全量fd从用户态复制到内核态,在内核态判断每个请求是否准备好数据,避免频繁的上下文切换,效率会比在应用层轮询效率要高。

如果select没有查询到有数据的请求,将会一直阻塞。如果有一个或者多个请求的数据准备好,select会先将有数据的fd置位,然后select返回,再通过遍历查看那个请求有数据。

select是阻塞的,无论是通过操作系统的通知(epoll)还是不停的轮询(select,poll),这个函数是阻塞的。所以你可以放心大胆地在一个while(true)里面调用这个函数而不用担心CPU空转。

select缺点: 1,底层存储依赖bitmap,请求有上线,为1024 2,文件描述符会置位,如果当被置位的文件描述符需要重新使用,需要重新赋空值 3,fd从用户态拷贝到内核态有一笔开销 4,select返回后还要再次遍历,来获知哪一个请求有数据

poll内部使用了一个结构体 struct pollfd{ int fd; short events; short revents; } poll同样会将所有请求拷贝到内核态,和select一样,poll也是一个阻塞函数,当一个或多个请求有数据的时候,同样会进行置位,但它置位的是结构体中的events或revents,而不是对fd本身进行置位,下一次使用的 时候不需要再进行赋空值的操作。poll内部不依赖bitmap,使用pollfd数组来记录,数量不再有1024的限制,select的1、2缺点被解决。

epoll和上述两种方式的比较是: 它的fd是共享在用户态和内核态的,所以不必进行用户态到内核态的拷贝,节约系统资源。在select和poll中,某个请求的数据准备好,会将所有的请求都返回,供程序去遍历哪个请求有数据,但epoll只会返回有数据的 请求,因为epoll在发现某个请求有数据时,首先会进行一个重排操作,将所有有数据的fd放在前面,所以程序不必遍历所有请求。

aio:jdk1.7,这部分内容被称为NIO.2 当进行读写操作时,只需要调用api的read或write方法即可,这两种方法都是异步的。 对于读操作,当有流可读时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序 对于写操作,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。 可以理解为read/write方法都是异步的,完成后会主动回调函数

用户线程完全不关心io何时准备好,一切都交给操作系统,同时给操作系统提供一个缓冲区,当数据往缓冲区写好之后,通知应用程序即可。

public class AioHttpServer {

public static void main(String[] args) throws Exception { // 服务端启动8083端口 final AsynchronousServerSocketChannel channel = AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(8083)); channel.accept(channel, new CompletionHandler<AsynchronousSocketChannel, AsynchronousServerSocketChannel>() { @Override public void completed(final AsynchronousSocketChannel client, AsynchronousServerSocketChannel attachment) { // 思考1:accept again,why? attachment.accept(attachment, this);

    // 分配一块缓冲区,将客户端的数据写入缓冲区中。
    // 思考2:这样有什么缺点
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    client.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
      @Override
      public void completed(Integer result_num, ByteBuffer attachment) {
        // 准备冲缓冲区读数据,老规矩,先进行flip
        attachment.flip();
        byte[] body = new byte[attachment.remaining()];
        attachment.get(body);
        String response = ResponseUtils.getResponse(new String(body, StandardCharsets.UTF_8));
        ByteBuffer writeBuffer = ByteBuffer.wrap(response.getBytes());
        // 数据回写
        client.write(writeBuffer, writeBuffer, new CompletionHandler<Integer, ByteBuffer>() {
          @Override
          public void completed(Integer result, ByteBuffer attachment) {
            try {
              // 回写数据完成,关闭连接
              client.close();
            } catch (IOException e) {
            }
          }

          @Override
          public void failed(Throwable exc, ByteBuffer attachment) {/** write fail 咋办 */}
        });
      }

      @Override
      public void failed(Throwable exc, ByteBuffer attachment) {/** read fail 咋办 */}
    });
  }

  @Override
  public void failed(Throwable exc, AsynchronousServerSocketChannel attachment) {/** accept fail 咋办 */}
});
// 思考3:这行代码的必要性
System.in.read();   } }

AIO看起来比NIO更高效,为什么Netty使用NIO而不是AIO? 1.服务器大多是Linux系统,AIO的底层实现仍使用EPOLL,没有很好实现AIO,因此在性能上没有明显的优势。 2.AIO接收数据的时候需要预先分配缓冲区大小, 而不是NIO那种需要接收时才需要分配缓存, 所以对连接数量非常大但流量小的情况, 会造成内存浪费

异步通道类java.nio.channels AsynchronousSocketChannel AsynchronousServerSocketChannel AsynchronousFileChannel AsynchronousDatagramhannel

同步、异步 阻塞、非阻塞 同步和异步是针对应用程序和内核的交互而言, 同步是指用户进程触发io操作并等待或者轮询的去查看io操作是否就绪 异步是指用户进程触发io操作后便开始做自己的事情,当io操作完成后会得到io完成的通知

阻塞和非阻塞是针对于进程在访问数据的时候,根据io操作的就绪状态来采取的不同方式,说白了是一种读取或者写入函数的实现方式,阻塞方式下读取或者写入函数将一直等待,非阻塞方式下读取或者写入函数会立即返回状态值

同步阻塞io:用户进程发起一个io操作以后,必须等待io操作的完成,只有当io操作完成以后,用户进程才能运行 同步非阻塞io:用户进程发起一个io操作后可以返回做其它事情,但是用户进程需要时不时地询问io操作是否就绪,这要求用户进程不停地去询问,从而引起不必要的资源浪费 异步阻塞io:用户进程发起一个io操作后,不用等待内核io操作的完成,等内核完成io操作后会通知用户进程去读取数据,这就是同步和异步最关键的区别,同步必须等待或者主动询问io操作是否完成 异步非阻塞io:用户进程发起io操作后立即返回,等io操作完成以后,用户进程会得到io操作完成的通知,注意这时只需要对数据进行处理就好了,不需要进行实际的io读写操作,因为真正的io读取或写入操作已经由内核完成

在同步阻塞io中,用户线程等待的时间包括两部分,一个是等待数据的就绪,一个是等待数据的复制,对于网络io来讲,一般前者占据的时间更长一些。同步非阻塞的调用不会等待数据就绪,如果数据不可读或不可写,会立即返回结果通知用户线程

同步:自己亲自出马持银行卡到银行取钱(使用同步io时,Java自己处理io读写) 异步:委托一小弟拿银行卡到银行取钱,然后给你(使用异步io时,Java将io读写委托给os处理,需要将数据缓冲区地址和大小传给os,os需要支持异步io操作api) 阻塞:ATM排队取款,只能等待(使用阻塞io时,Java调用会一直阻塞到读写完成才返回) 非阻塞:柜台取款,取号后可以坐在椅子上办其他事,等号广播会通知你办理没到号不能办理,你可以问大堂经理排到没有,如果说没排到,就不能办理(使用非阻塞io时,如果不能读写调用会马上返回,当io事件分发器通知可读写时,再 继续进行读写,不断循环直到读写完成)

同步和异步,阻塞和非阻塞,两者不是一回事,修饰对象也不同。 阻塞和非阻塞是指当线程访问的数据是否就绪时,是否直接返回还是阻塞,其实是函数的内部实现机制。 同步和异步是指访问数据的机制,同步一般是指主动请求并等待io操作完毕的方式,阻塞和非阻塞其实是是否在进行第二个阶段数据读写时(也就是将数据从内核态拷贝到用户态)是否阻塞的区别,第一个阶段等待数据就绪是必须要阻塞的 异步是指主动请求数据后便可以继续处理其它任务,随后等待数据读写完成的通知,这样实现在数据读写时也不阻塞。

Java对bio/nio/aio的支持 bio:同步阻塞,一个连接一个线程,如果连接后不做事情,会造成不必要的线程开销 nio:同步非阻塞,一个请求一个线程,客户端发送的连接请求会注册在多路复用器上,多路复用器轮询连接是否有io请求,若有io请求才启动线程去处理 aio(nio.2):异步非阻塞,一个有效请求一个线程,客户端io请求都由os完成后再通知服务器应用启动线程处理

io属于底层操作,需要操作系统支持,并发也需要操作系统支持,性能方面不同操作系统差异会比较明显

bio适合于连接数组少且固定的架构,对服务器资源要求高,jdk1.4之前唯一选择 nio适合于连接数目多且连接比较短的架构,比如聊天服务器,jdk1.4开始支持 aio适合于连接数目多且连接比较长的架构,比如相册服务器,充分调用os参与并发操作,jdk1.7开始支持

在高性能io设计中,有两种著名模式,Reactor和Proactor,Reactor用于同步io,Proactor用于异步io

Reactor模式: 1,用户进程注册读就绪事件和相关联的事件处理器 2,事件分离器等待就绪事件的发生 3,当发生读就绪事件时,事件分离器调用注册的时间处理器 4,事件处理器首先执行实际的读取操作,然后根据读取到的内容作进一步处理

Proactor模式: 1,用户程序初始化一个异步读取操作,注册相应的事件处理器,此事件处理器不关注读就绪事件,而是关注读完成时间 2,事件分离器等待读取操作完成事件 3,在事件分离器等待读取操作完成时,操作系统调用内核线程完成读取操作,异步io都是操作系统负责将数据读写到用户进程传递进来的缓冲区地址。与Reactor不同的是,用户线程需要传递缓冲区 4,事件分离器收到读取完成以后,激活注册的事件处理器,用户线程直接去缓冲区操作数据

Reactor和Proactor模式的主要区别是真正的读取和写入操作是由谁来完成的,Reactor需要用户线程去读取或写入数据,在Proactor模式中,用户线程不需要进行实际的读写操作,它只 需要从缓冲区读取或写入即可,操作系统会读取或写入缓冲区的数据到真正的io设备

Reactor模式已经被广泛使用,著名的开源事件库libevent、libev、libuv都是使用Reactor模式。

Reactor模式的优点: 1,实现相对简单,对于耗时短的处理场景处理高效; 2,操作系统可以在多个事件源上等待,并且避免了多线程编程相关的性能开销和编程复杂性; 3,事件的串行化对应用是透明的,可以顺序的同步执行而不需要加锁; 4,事务分离:将与应用无关的多路分解和分配机制和与应用相关的回调函数分离开来。 Reactor模式的缺点: 1,Reactor处理耗时长的操作(如文件I/O)会造成事件分发的阻塞,影响到后续事件的处理。

因此涉及到文件I/O相关的操作,需要使用异步I/O,即使用Proactor模式效果更佳。

综上我们可以发现Reactor模式和Proactor模式的主要区别:

  1. Reactor实现同步I/O多路分发,Proactor实现异步I/O分发。

如果只是处理网络I/O单线程的Reactor尚可处理,但如果涉及到文件I/O,单线程的Reactor可能被文件I/O阻塞而导致其他事件无法被分发。所以涉及到文件I/O最好还是使用Proactor模式,或者用多线程模拟实现异步I/O的方式。

  1. Reactor模式注册的是文件描述符的就绪事件,而Proactor模式注册的是完成事件。

即Reactor模式有事件发生的时候要判断是读事件还是写事件,然后用再调用系统调用(read/write等)将数据从内核中拷贝到用户数据区继续其他业务处理。

而Proactor模式一般使用的是操作系统的异步I/O接口,发起异步调用(用户提供数据缓冲区)之后操作系统将在内核态完成I/O并拷贝数据到用户提供的缓冲区中,完成事件到达之后,用户只需要实现自己后续的业务处理即可。

  1. 主动和被动

Reactor模式是一种被动的处理,即有事件发生时被动处理。而Proator模式则是主动发起异步调用,然后循环检测完成事件。

以socket.read()为例子:

传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据。

对于NIO,如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不会阻塞。

最新的AIO(Async I/O)里面会更进一步:不但等待就绪是非阻塞的,就连数据从网卡到内存的过程也是异步的。

换句话说,BIO里用户最关心“我要读”,NIO里用户最关心”我可以读了”,在AIO模型里用户更需要关注的是“读完了”。

NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。

对于Redis来说,由于服务端是全局串行的,能够保证同一连接的所有请求与返回顺序一致。这样可以使用单线程+队列,把请求数据缓冲。然后pipeline发送,返回future,然后channel可读时,直接在队列中把future取回来,done()就可以了。

常见的RPC框架,如Thrift,Dubbo 这种框架内部一般维护了请求的协议和请求号,可以维护一个以请求号为key,结果的result为future的map,结合NIO+长连接,获取非常不错的性能。

通常情况下,操作系统的一次写操作分为两步: 1. 将数据从用户空间拷贝到系统空间。 2. 从系统空间往网卡写。同理,读操作也分为两步: ① 将数据从网卡拷贝到系统空间; ② 将数据从系统空间拷贝到用户空间。

使用NIO != 高性能,当连接数<1000,并发程度不高或者局域网环境下NIO并没有显著的性能优势。

NIO并没有完全屏蔽平台差异,它仍然是基于各个操作系统的I/O系统实现的,差异仍然存在。使用NIO做网络编程构建事件驱动模型并不容易,陷阱重重。

推荐大家使用成熟的NIO框架,如Netty,MINA等。解决了很多NIO的陷阱,并屏蔽了操作系统的差异,有较好的性能和编程模型。

PS: https://tech.meituan.com/2016/11/04/nio.html https://segmentfault.com/a/1190000022653494

tcp分段和ip分片在什么情况下会进行? https://cloud.tencent.com/developer/article/1173790 UDP不会分段,就由IP来分片。TCP会分段,当然就不用IP来分了! 采用TCP协议进行数据传输是不会造成IP分片的,因为一旦TCP数据过大,超过了MSS,则在传输层会对TCP包进行分段

Updated:

Leave a comment