操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。进程的切换要经历保存被切换进程上下文(包括程序计数器和其他寄存器)、更新PCB信息、选择另一个进程、更新PCB、恢复上下文;总而言之就是很耗资源;
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是
不占用CPU资源 的。
很多人会将这两个概念混淆,实际上这是两个完全不同的东西;
代码见如下非阻塞IO
;文件描述符(File
descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
应用进程的一次完整的读写操作,都需要在用户空间与内核空间中来回拷贝,并且每一次拷贝,都需要 CPU
进行一次上下文切换(由用户进程切换到系统内核,或由系统内核切换到用户进程),这样是不是很浪费 CPU
和性能呢?那有没有什么方式,可以减少进程间的数据拷贝,提高数据传输的效率呢?
网络IO读写流程
所谓的零拷贝,就是取消用户空间与内核空间之间的数据拷贝操作,应用进程每一次的读写操作,都可以通过一种方式,让应用进程向用户空间写入或者读取数据,就如同直接向内核空间写入或者读取数据一样,再通过
DMA 将内核中的数据拷贝到网卡,或将网卡中的数据 copy 到内核。
可以想到将用户空间与内核空间都将数据写到一个地方,就不需要拷贝;
主要通过两种方式:mmap+write 和 sendfile,mmap+write 方式的核心原理就是通过虚拟内存来解决的
虚拟内存
说到网络通信,就不得不提网络IO模型,因为所谓的两台PC机之间的网络通信,实际上就是两台PC机对网络IO的操作;
常见的网络IO模型分为四种:同步阻塞IO(BIO)、同步非阻塞IO(NIO)、IO多路复用、异步非阻塞IO(AIO);只有AIO为异步IO,其他都是同步IO,最常用的就是
同步阻塞IO 和 IO多路复用 ;
最简单、最常见的 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();
}
相比于阻塞IO,内核在处理时,如果没有数据就 直接返回 ,基于这个特点可以实现一个线程内同事处理多个socket的IO请求;
如下代码是利用Java的NIO(New IO)来实现的非阻塞IO模型;
LinkedList<SocketChannel> 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();
}
}
}
如果有1万个客户端只有一个发来数据,那么另外9999个是不需要遍历的,并且99%情况都是没有数据来的,但是它一直在空转浪费CPU,还不如阻塞着;
linux下会调用内核的select(poll、epoll)方法,传入1万个FD(文件描述符),内核会 阻塞
地监听1万个socket连接是否有数据,当有数据时内核会返回具体是哪个socket;这时用户进程再调用read操作,将数据从内核中拷贝到用户进程;(下文会介绍Linux下的三种多路复用的实现方式)
image.png
多路复用 IO 是在高并发场景中使用最为广泛的一种 IO 模型,如 Java 的 NIO、Redis、Nginx 的底层实现就是此类 IO
模型的应用,经典的 Reactor 模式也是基于此类 IO 模型。
异步IO只有高版本的Linux系统内核才会支持;
IO多路复用本质是同步IO,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是同步的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。
Linux下实现IO多路复用有select、poll、epoll等方式,监视多个描述符(fd),一旦某个描述符就绪(读/写/异常)就能通知程序进行相应的读写操作。读写操作都是自己负责的,也即是阻塞的,所以本质上都是同步(堵塞)IO
// 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);
每次调select获取哪个连接有数据时都要传入所有的fd,如果fd_set很大,那么拷贝和遍历的开销会很大;
poll的实现和select非常相似,轮询+遍历+根据描述符的状态处理,只是fd_set换成了pollfd,而且去掉了最大描述符数量限制,其他的局限性同样存在。
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);
优化了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;
}
epoll
Java的NIO指的是JDK 1.4引入的new IO包,不是上面说的NIO指的是非阻塞IO概念;
目的是解决传统IO中面向数据流的同步阻塞问题,NIO是面向缓存区的;
Java NIO也属于IO多路复用模型,底层是调用相应的内核函数(Select、Poll、Epoll)。
传统IO和JavaNIO对比
一个Socket连接对应一个Channel通道;
用于和Channel进行数据交互;
可以理解为Java NIO的多路复用器,多个Channel注册到同一个Selector选择器上,一个选择器同时监听多个连接通道(单线程);
在执行Selector.select()方式时,如果内核采用select实现的多路复用,则调用select函数,如果是Epoll,则调用epoll_wait函数,阻塞的获取多个socket连接或者数据请求;
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<SelectionKey> 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());
}
很多框架、中间件底层都是基于Reactor模式来实现的,比如Netty、Redis等,而其核心又是IO多路复用;
根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。