不断的学习,我们才能不断的前进
一个好的程序员是那种过单行线马路都要往两边看的人

BIO/NIO

Linux 提供了五种IO模型:阻塞IO、非阻塞IO、IO复用模型、信号驱动IO模型、异步IO模型

前导知识

什么是中断
当CPU接受到中断请求时,会暂停正在运行的程序并保留程序上下文,转而执行一个称为中断处理器或中断服务程序的特定程序,服务完毕后再返回去继续运行被暂时中断的程序。
软中断与硬中断
硬件中断是由外设引发的, 软中断是执行中断指令产生的。
硬件中断是由与系统相连的外设(比如网卡 硬盘 键盘等)自动产生的,主要是用来通知操作系统系统外设状态的变化。 每个设备或设备集都有他自己的IRQ(中断请求), 基于IRQ, CPU可以将相应的请求分发到相应的硬件驱动上(注: 硬件驱动通常是内核中的一个子程序, 而不是一个独立的进程). 比如当网卡受到一个数据包的时候, 就会发出一个中断)。
软中断的处理类似于硬中断,但是软中断仅仅由当前运行的进程产生, 所以软中断跟硬中断相比,不会中断CPU,但是会中断当前运行的程序。

发送数据的时候,会先把数据从用户态拷贝到内核态的OS输出缓存区里面,然后包装成数据报文的协议,然后路由到服务器端。
读取数据的时候从OS输入缓存区里面读取数据,然后从内核态拷贝到用户态里面。

Socket

本地的进程间通信方式有:消息传递(管道、FIFO、消息队列)、同步(互斥量、条件变量、读写锁、信号量)、共享内存;Socket 就是用来解决网络进程之间 的通信。套接字(socket)是一个抽象层,应用程序可以通过它发送或接收数据,可对其进行像对文件一样的打开、读写和关闭等操作。套接字允许应用程序将I/O插入到网络中,并与网络中的其他应用程序进行通信。网络套接字是IP地址与端口的组合。
服务器网卡接受到客户端发送的报文信息,网卡会通过DMA,直接将报文存入RAM内存缓冲区里面。然后网卡会发送一个中断,CPU接受到这个中断后,会让线程由用户态切换成内核态 响应中断请求,读取内存缓存区里面的数据到Socket里面。然后Socket里面读缓冲区有数据了, 进程在内核态可以直接读取数据。

BIO

BIO中的B是阻塞的意思,就是阻塞型IO,在进行服务端开发的时候,使用ServerSocket绑定端口号之后,会监听该端口,等待accept事件的发送,accept会阻塞当前主线程。当接受到accept事件时,程序就会拿到一个客户端与当前服务器连接的socket,通过这个socket可以进行读写,但是针对这个socket的读写会阻塞当前线程。一般使用多线程的方式去进行C/S交互,但是很难做到C10K,一万个客户端就需要一万个线程,然后线程之间上下文切换,会让服务器出故障。

NIO

NIO 在Java里面提供了一个非阻塞的接口,就不用为每一个C/S连接提供一个单独的处理线程,而是通过一个线程去检查所有的Socket,在Java里面提供了一个Selector,需要把检查的Socket 注册到这个Selector中,然后主线程阻塞在Selector.select 方法,当选择器发现某个Socket 就绪后,就会唤醒主线程,然后可以通过Selector 获取到就绪状态的socket 进行相应的处理。

这个Selector 是底层java包装的一个native api,是JVM去使用的系统调用systemCall kernel 去实现的。
epoll

select

内核的select函数,每次都涉及到用户态和内核态之间的切换,而且还需要传递需要检查的socket文件描述符集合(fd),因为在linux里面一切皆文件,fd就是文件系统中对应socket生成的文件描述符。
select 工作的步骤
内核的select函数被调用后,会按照fd 集合去检查内存中的socket套接字状态,时间复杂度是O(n),然后检查完一次后:

  • 如果有就绪状态的socket,那就标记就绪状态的socket文件描述符,表示socket已经就绪,然后直接返回一个int值,表示有几个socket就绪,java程序获取到结果后,需要轮询检查每个socket的就绪状态,并且涉及到内核态到用户态的拷贝。
  • 否则就是当前fd集合对应的socket 没有就绪状态的,那么就需要阻塞当前调用线程,并保留在socket对应的等待队列里面,当前进程由工作队列中移除。直到某个socket有数据之后,才唤醒线程。

select 监听文件集合大小
fd_set最大连接是有限制的,最大只能是1024个(fd_set数据结结构采用的是bitmap,这个fd_set会在内核态跟用户态直接拷贝),需要修改的话需要重新编译操作系统内核。
默认选择1024是考虑性能的原因:当select函数检查到就绪状态的socket后,会做两件事:

  • 到就绪状态的socket对应的文件描述符中,设置一个标记,表示fd对应的socket就绪了
  • 返回select函数,唤醒对应的Java线程,返回的是int值,表示有几个socket就绪了,但是java程序不知道具体是那几个,所以java程序通过时间复杂度为O(n)的系统调用,检查fd_set中的每一个socket的就绪状态,涉及到用户态和内核态的切换,如果bitmap太大是比较耗费性能的

select 什么时候确定有socket就绪
select 函数第一次轮询时,没有发现就绪状态的socket,就会把当前进程,保留在需要检查的socket等待队列中socket 有三块核心区域:读缓存、写缓存、等待队列
当select 函数把当前进程保留到每个需要检查的socket等待队列之后,就会把当前进程从工作队列中移除,移除后挂起当前进程,select函数就阻塞。

假设当客户端向服务器发送数据后,通过网线到网卡,然后网卡接受到数据的时候,通过DMA直接把数据写到内存里面,然后网卡通过中断信号通知cpu有数据到达,cpu会保存当前执行的程序的上下文,然后去执行中断程序。此处的中断程序主要有两项功能:

  1. 是先根据网络数据包进行解析,确定是哪一个socket的数据(tcp/ip 协议,数据包是有端口号的),根据端口号写入数据到对应socket的读缓冲区里面,然后去检查socket的等待队列是否有等待者,如果有就把等待者移动到工作队列。最后进程回到工作队列,有机会获得CPU的时间片。
  2. 然后当前进程执行select函数再检查就发现有就绪状态的socket,然后给就绪状态的socket打标记,表示socket就绪,然后返回到Java层面,就涉及到内核态到用户态的切换,java程序通过轮询检查每一个socket是否就绪。

poll

poll 跟 select 最大区别是传参不一样了,select它使用的是bitmap,它表示需要检查的socket集合。poll使用的是数组结构,表示需要检查的socket集合。主要是为了解决select这个bitmap长度1024的问题。poll 使用数组就没有这个限制,它就可以让我们的线程监听超过1024个socket限制。

epoll

为什么有epoll?select、poll的问题

  1. select/poll 每次调用都需要提供所有需要监听的socket文件描述符集合,而且主线程是死循环调用select/poll函数,这涉及到用户态到内核态的拷贝问题,这个操作比较耗费性能。其实fd_set集合是比较稳定的,可能它每次只有1-2个socket_fd需要更改,但是每次都需要传整个。因为select/poll在内核层面保留任何数据信息,所以说每次调用都需要进行数据拷贝。、
  2. select和poll函数它的返回值是个int整型,只能代表有几个socket就绪了,导致我们程序被唤醒之后,还需要新一轮的系统调用,去检查哪个socket是就绪状态的。
    epoll是为了解决 select/poll 函数调用时参数需要内核态和用户态拷贝的问题 和 系统调用返回后不知道哪些socket就绪的问题。

epoll

epoll为了解决这两个问题就需要在内核空间里面有一个对应数据结构去存储数据(eventpoll),采用的是红黑树的结构,eventpoll对象通过epoll_creat去创建,创建完成之后,系统函数返回一个eventpoll对象的id,相当于在内核开辟了一小块空间,并且返回了在内存空间的位置。eventpoll对象的结构有:

  1. 检查列表:存放需要监听的socket_fd文件描述符,数据结构采用的是红黑树
  2. 就绪列表:存放就绪状态的socket信息,双向链表结构
  3. 等待队列:把调用epollwait函数的进程放到这里面去。
    还提供了epoll_ctl和epoll_wait函数:
  • epoll_ctl 根据eventpoll_id 增删改 内核空间上的eventpoll 对象的检查列表
  • epoll_wait 参数是eventpoll_id,表示此次系统调用需要监听、检测的socket_fd集合,默认情况会阻塞调用线程,直到eventpoll 关联的某个socket就绪后才会返回。

epoll_ctl把需要监视的socket加入到检查列表里面

基本函数

int epoll_create(int size):创建 eventpoll(rb-tree(红黑树))和 ready-list (就绪链表):

int epoll_ctl(int epfd,int op,int fd,struct epoll_event * event): 把epitem放入eventpoll(rb-tree) 并向内核中断处理程序注册ep_poll_callback,callback触发时把该epitem放进ready-list。

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

epoll 就绪列表怎么维护

select函数调用时会把当前调用进程从工作队列里面拿出来,然后把进程引用追加到当前进程关注的每一个socket对象的等待队列中,当socket连接的客户端发送数据之后,数据还是通过硬件DMA方式写入到内存,然后硬件发出相应的中断请求,CPU接受到中断请求后,保存正在执行程序的上下文信息,然后解析内存中的数据包,根据端口号 把数据写入到对应的socket读缓冲区里面,然后把socket等待队列中的进程全部移动到工作队列内,再然后select函数返回。

epoll 工作流程类似,当执行epoll_ctl函数时,内核程序会把当前eventpoll对象,追加到这个socket等待队列里面,当socket连接的客户端发送数据之后,数据还是通过网线到网卡,让网卡通过DMA方式写入到内存,然后网卡向CPU发出相应的中断请求,CPU接受到中断请求后,保存正在执行程序的上下文信息,然后响应中断请求,解析内存中的数据包,根据端口号 把数据写入到对应的socket读缓冲区里面,然后检查socket等待队列,发现等待队列中等待的不是进程,而是eventpoll对象的引用,然后根据eventpoll引用,将当前socket对象的引用追加到eventpoll的就绪列表末尾。再继续检查eventpoll对象的等待队列,如果有进程,就会把进程转移到工作队列。转移后进程就又会获得cpu执行时间片,然后就是调用epoll_wait函数,把这个进程返回到java层面。

epoll 怎么知道哪些就绪的socket

epoll_wait的返回值 0表示没有就绪的socket,大于0表示有几个就绪的socket,-1表示异常。
epoll_wait在调用时,会传入一个epoll_event事件数组指针,epoll_wait 在正常返回之前,就会把就绪的socket事件信息,拷贝到这个指针表示的数组里面。然后java程序通过这个数组拿到就绪列表。
epoll_wait会传入阻塞事件的长度,传入0 表示非阻塞,每次调用都会检查就绪列表。

epoll与select比较

select 低效的原因之一是将“维护等待队列”和“阻塞进程”两个步骤合二为一。
每次调用 select 都需要这两步操作,然而大多数应用场景中,需要监视的 socket 相对固定,并不需要每次都修改。
epoll 将这两个操作分开,先用 epoll_ctl 维护等待队列,再调用 epoll_wait 阻塞进程。显而易见地,效率就能得到提升。
select 低效的另一个原因在于程序不知道哪些 socket 收到数据,只能一个个遍历。如果内核维护一个“就绪列表”,引用收到数据的 socket,就能避免遍历。

epoll的工作模式

epoll支持水平触发(LT)也支持边缘触发(ET),默认是水平触发
水平触发
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会一直通知用户程序去读写,如果这个描述符是用户不关心的,它每次都返回通知用户,则会导致用户对于关心的描述符的处理效率降低。这种模式下要注意多次读写的情况下,效率和资源利用率情况
复用型IO中的select和poll都是使用的水平触发模式。

边缘触发
当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知用户程序去读写,它只会通知用户进程一次,这需要用户一次把内容读取完,相当于水平触发,效率更高。如果用户一次没有读完数据,epoll_wait()再次请求时,不会立即返回,直到该文件描述符上出现第二次可读写事件时才会通知你。这种模式下读写数据要注意一次是否能读写完成

hong-fa-fang-shi

java Nio源码

java Nio主要有三大核心模块:多路复用选择器(Selector)、缓冲区(Buffer)和通道(Channel)。
Channel 主要用于通信,客户端和服务端之前通过channel互相发送数据。通常情况下流的读写是单向的,从发送端到接收端。而通道支持双向同时通信,客户端和服务端可以同时在通道中发送和接收数据。
Buffer 指的是通道中的数据读数据必须从缓冲区读,写数据也必须要写到缓冲区。
Selector 主要负责与Channel 进行交互,每个channel把自己注册到Selector上面,Selector可以同时监控多个Channel的状态,如果发生了某个事件,则通知Channel 进行相应的处理。

java代码实现NIO

/**
	 * 可以通过telnet localhost  9999 命令模拟客户端连接
	 * @param args
	 * @throws Exception
	 */
	public static void main(String[] args) throws Exception {
		// 创建NIO ServerSocketChannel
		ServerSocketChannel serverSocket = ServerSocketChannel.open();
		// 监听 9999端口
		serverSocket.socket().bind(new InetSocketAddress(9999));
		serverSocket.configureBlocking(false) ; // 设置ServerSocketChannel为非阻塞
		// 打开Selector处理ServerSocketChannel,创建epoll
		Selector selector = Selector.open();

		//注册ServerSocketChannel到selector上面,并且selector对客户端的accept连接操作感兴趣
		serverSocket.register(selector, SelectionKey.OP_ACCEPT);
		System.out.println("服务启动成功");

		while (true){
			// 阻塞等待需要处理的事件发送
			selector.select();
			// 获取selector中注册的全部事件的SelectionKey实例
			Set<SelectionKey> selectionKeys = selector.selectedKeys();
			Iterator<SelectionKey> iterator = selectionKeys.iterator();
			// 遍历selectedKeys对事件进行处理
			while (iterator.hasNext()){
				SelectionKey key = iterator.next();
				// 如果是OP_ACCEPT事件,则进行连接获取和事件注册
				if (key.isAcceptable()){
					ServerSocketChannel server = (ServerSocketChannel)key.channel();
					SocketChannel socketChannel = server.accept();
					socketChannel.configureBlocking(false);
					// 这里只注册了读事件,如果需要给客户端发送写数据可以组注册写事件
					socketChannel.register(selector,SelectionKey.OP_READ);
					System.out.println("客户端连接成功");

				}else if(key.isReadable()){ // 如果是OP_ACCEPT事件,则进行读取和打印
					SocketChannel socketChannel = (SocketChannel)key.channel();
					ByteBuffer byteBuffer = ByteBuffer.allocate(128);
					int len = socketChannel.read(byteBuffer);
					if(len>0) System.out.println("接受到消息:"+new String(byteBuffer.array()));
					else if(len==-1){ //客户端断开连接,关闭socker
						System.out.println("客户端断开连接");
						socketChannel.close();
					}
				}
				// 从事件集合里面,删除本次处理的Key,防止下次selector重复
				iterator.remove();
			}

		}
	}

AIO

AIO是 NIO 的升级版本,提供了异步非堵塞的 IO 操作方式,所以人们叫它 AIO(Asynchronous IO),异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作

Reference

IO多路复用面试
IO多路复用底层详解

如何用Netty写一个高性能的分布式服务框架


目录