前言
最近在学习redis相关的知识,里面涉及到多路复用器,仔细了解了一下,它是IO的内容,开始不是很懂,看了一些相关的文档之后,基本明白了从BIO到NIO,再到select和epoll的演变过程,下面简单总结一下。
1.计算机基础知识
计算机的内存会划分内核空间和用户空间,内核空间里面运行的是内核程序,用户空间运行的是我们自己app,而app是不能随便修改内核的,它会对内核空间开启一个保护模式,防止外部app随意修改内核,以保证系统的稳定性。而我们的app程序是不能直接访问硬件的,如网卡、硬盘等,这都需要通过内核来访问。即我们的app要访问硬件的数据,就需要app调用内核来访问。
app调用内核,又有一个问题,就是app是不能随意访问内核的,所以内核提供了一个syscall,就像是暴露对外的一个api接口,一类方法,所以app要访问IO,就必须要调用内核,而不是直接去访问我们的硬件,这样可以看到,这样就会增加一次调用,即程序要先调用内核才可以。
理解了上面的问题,我们现在再考虑一个问题,如果有一个单核的cpu,那我们的电脑上是如何能够同时运行多个程序呢?比如可以qq聊天,可以听音乐的呢?其实这就是cpu中断起的作用。什么是cpu的中断呢?计算机处于执行期间系统内发生了非寻常或非预期的急需处理事件,CPU暂时中断当前正在执行的程序而转去执行相应的事件处理程序,处理完毕后返回原来被中断处继续执行。中断分为硬中断和软中断,硬中断是由计算机的外设或一些接口功能产生,如键盘、打印机、串行口等硬件产生的,而软中断是多是由app访问内核进行的系统调用而产生的。
所以,从上面的知识我们可以大体知道,如果网卡上有读写请求,首先产生硬中断,来接收网卡的数据到特定的内存空间,而app要访问这个网卡接收的数据,就要通过系统调用,来调用内核,这就会产生软中断。
下面我用一个图来简单描述下:
2.IO的发展过程
1.BIO
我们先看一个简单的java程序:
public class ServerSocketTest {
public static void main(String[] args) throws Exception {
ServerSocket serverSocket = new ServerSocket(8080);
while (true) {
Socket client = serverSocket.accept();
new Thread(() -> {
try {
InputStream in = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
while (true) {
System.out.println(reader.readLine());
}
} catch (Exception e) {
}
});
}
}
}
从程序中我们可以看出,此时客户端如果要建立连接以后,server就需要accept来阻塞的等待连接进来,客户端连接进来以后,我们这时候还要通过client来获取数据,即recvfrom,此时也是阻塞的,为了避免这种阻塞,更好的利用cpu,提高cpu的利用率,上面代码直接新建了一个线程,即每建立一个连接,就开启一个线程,这样就能够满足同时多个连接同时访问的问题,不至于一直阻塞在那里。这其实也就是一个BIO的基本模型。
通过上面的模型我们看到,利用多线程,BIO很好的解决了多连接的问题,能够很好的提供网络服务。但是它有没有缺点或者不足呢?基本上有以下几点:
- 创建线程,会在栈上分配一个栈空间,虽然每个栈的空间不是很大,但如果线程很多,还是会占用较大的内存;
- 创建的线程越多,那么主线程就要创建出很多的子线程,而创建子线程就会进行系统调用,消耗资源;
- 考虑假如一个cpu,线程过多,那么单位时间内,cpu在每个线程上分配的时间片就会越少,造成系统卡顿。
所以,我们考虑是否有什么方法,可以不用多线程,用一个线程就能搞定呢?
2.NIO
从上面的BIO模型我们看到,要想避免创建多线程,改用单线程,那么accept和recfrom就不能阻塞?那么在while循环中,如何做到呢?显然,假如我们再while循环中,accept和recfrom不阻塞,而是返回给我们是否有连接和链接 上是否有数据就行了,然后如果有数据,我们就接着处理,没数据,就继续下一轮的循环。但是单从app来看,是无法实现的,这需要内核去发展改进,所以linux又提供给我们一个NIO,即nonblock。
这里要区分一下这个概念,NIO其实有两层含义,在linux中,它代表的是非阻塞IO,即NON BLOCK IO,而java中也有一个NIO的概念,那个NIO指的是NEW IO,是一个新的IO模块,即可以使用nio,也可以使用bio。我们先看下面这段代码:
public class SocketNIO {
public static void main(String[] args) throws Exception {
LinkedList<SocketChannel> clients = new LinkedList<>();
//创建服务端
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//bind 端口
serverSocketChannel.bind(new InetSocketAddress(8080));
//设置为非阻塞模式
serverSocketChannel.configureBlocking(false);
//开始死循环
while (true) {
//接收客户端,此时由于上面设置了false,这是后accpet就不会阻塞了,accpet调用了内核NIO,会直接返回
//如果有新的连接,就会返回连接,如果没有,就返回null,这样代码就能直接往下走了,而不会卡死在那里
SocketChannel client = serverSocketChannel.accept();
if (client == null) {
//没有新的客户端连接
System.out.println("client is null no 连接");
} else {
//有新的客户端连接进来
//socket建立以后接收数据,此时设置为非阻塞,即recfrom为非阻塞
client.configureBlocking(false);
//将客户端连接加入到list中,方便下面的遍历取数据
clients.add(client);
}
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
//利用for进行串行化操作,而没有开启多个线程,因为client设置了非阻塞的模式
for (SocketChannel c : clients) {
//此时会返回0 或者大于0的数据,如果大于0,说明这个连接有数据,否则就说明没有数据进来
//由于是非阻塞的,此时程序可以继续往下执行
int num = c.read(buffer);
if (num > 0) {
//client连接有数据
buffer.flip();
byte[] bytes = new byte[buffer.limit()];
buffer.get(bytes);
String s = new String(bytes);
System.out.println(c.socket().getPort() + "--" + s);
buffer.clear();
}
}
}
}
}
从上面这段代码中我们看到,用一个线程就解决了客户端的多连接阻塞问题,问题的解决得益于采用非阻塞的IO。
这个方案看上去非常完美,但是世界上有完美的事物么?还有就是NIO是真的很完美吗?
其实,我们通过前面的基础知识想一下,如果此时有1万个连接,那么app每次要循环一万次来看客户端连接是否有数据,而app又不能直接内核里面的数据,只能通过系统调用访问,这就要进行1万次的系统调用,显然这个成本就比较高了。既然这样的成本高,那我们怎么解决呢?也就是说,如果只调用一次内核或者几次内核就能完成,那效率就会高很多。假如我能一次性把所有的client连接一次传入内核中,内核给我返回那些有数据的连接,那么就能极大的减少系统调用的次数。所以,NIO要再往前发展,显示也是需要内核的发展,只有内核支持,我们才能在一次系统调用中传递多个client连接。所以,内核给我们提供了多路复用模型。所谓多路复用,简单来说,就是将很多客户端连接,利用一次系统调用,就能传入内核,而不需要每个客户端连接都单独调用一次内核,即多个连接,共用一个系统调用,就能让app知道哪些客户端连接有数据,然后app只处理那些有数据的客户端连接就行了。
3.多路复用
1.select
select的概念我这里不给出了, 我们看一下它简单的模型图:
它的基本流程如下:
1.将多个客户端连接一次性从用户空间拷贝到内核空间;
2.内核遍历拷贝进来的多个客户端连接,找出哪些有数据,进行置位处理,进行标记;
3.置位处理完成后,app进行系统调用内核,同步接收这些有数据的客户端连接,然后遍历这些有数据的客户端连接,获得数据。
从上图来看,select模式下,减少了系统调用的次数,比nio是很大的一个进步。但是它也有局限性:
1.一次性传入内核的数据量太多,并且每次都要传入一个大的集合;
2.每系统调用一次,由于传入的客户端连接太多,那么内核也要进行多次的遍历;
3.由于有1024的限制,导致不可能承受很大并发连接;
那怎么解决上面的问题呢?对于1024个限制,linux内核又往前发展了poll。
2.poll
poll这里就不过多介绍了,它其实和select是一类,只是去掉了1024限制,不再限定只能1024个客户端连接了。并没有从根本上解决内核遍历次数太多的问题。所以,人们又在内核上发展了epoll。
3.epoll
epoll对select和poll进行了改进,它的简单模型如下:
与select和poll只提供一个接口函数不同的是,epoll提供了三个接口函数及一些结构体:
1.调用epoll_create:linux内核会在epoll文件系统创建一个file节点,同时创建一个eventpoll结构体,结构体中有两个重要的成员:rbr是一棵红黑树,用于存放epoll_ctl注册的socket和事件;rdllist是一条双向链表,用于存放准备就绪的事件供epoll_wait调用。
2.调用epoll_ctl:会检测rbr中是否已经存在节点,有就返回,没有则新增,同时会向内核注册回调函数ep_poll_callback,当有事件中断来临时,调用回调函数向rdllist中插入数据,epoll_ctl也可以增删改事件。
3.调用epoll_wait:返回或者判断rdllist中的数据即可。
3.小结
上面简单介绍了下IO的基本演变过程,nginx和redis都是使用的epoll多路复用模式,提高了性能。