如果面试官问我:Redis为什么这么快?
我肯定会说:因为Redis是内存数据库!如果不是直接把数据放在内存里,甭管怎么优化数据结构、设计怎样的网络I/O模型,都不可能达到如今这般的执行效率.
但是这么回答多半会让我直接回去等通知了...因为面试官想听到的就是数据结构和网络模型方面的回答,虽然这两者只是在内存基础上的锦上添花.
说这些并非为了强调网络模型并不重要,恰恰相反,它是Redis实现高吞吐量的重要底层支撑,是"高性能"的重要原因,却不是"快"的直接理由.
本文将从BIO开始介绍,经过NIO、多路复用,最终说回Redis的Reactor模型,力求详尽.本文与其他文章的不同点主要在于:
①.、不会介绍同步阻塞I/O、同步非阻塞I/O、异步阻塞I/O、异步非阻塞I/O等概念,这些术语只是对底层原理的一些概念总结而已,我觉得没有用.底层原理搞懂了,这些概念根本不重要,我希望读完本文之后,各位能够不再纠结这些概念.
牛皮已经吹出去了,正文开始.
我们都知道,网络I/O是通过Socket实现的,在说明网络I/O之前,我们先来回顾(了解)一下本地I/O的流程.
public static void main(String[] args) throws Exception { out.write(buf); }
这个I/O操作在底层到底经历了什么呢?下图给出了说明:
大致可以概括为如下几个过程:
in.read(buf)执行时,程序向内核发起 read()系统调用;
操作系统发生上下文切换,由用户态(User mode)切换到内核态(Kernel mode),把数据读取到内核缓冲区 (buffer)中;
内核把数据从内核空间拷贝到用户空间,同时由内核态转为用户态;
继续执行 out.write(buf);
再次发生上下文切换,将数据从用户空间buffer拷贝到内核空间buffer中,由内核把数据写入文件.
之所以先拿本地I/O举个例子,是因为我想说明I/O模型并非仅仅针对网络IO(虽然网络I/O最常被我们拿来举例),本地I/O同样受到I/O模型的约束.比如在这个例子中,本地I/O用的就是典型的BIO,至于什么是BIO,稍安勿躁,接着往下看.
除此之外,通过本地I/O,我还想向各位说明下面几件事情:
我们编写的程序本身并不能对文件进行读写操作,这个步骤必须依赖于操作系统,换个词儿就是「内核」;
一个看似简单的I/O操作却在底层引发了多次的用户空间和内核空间的切换,并且数据在内核空间和用户空间之间拷贝来拷贝去.
不同于本地I/O是从本地的文件中读取数据,网络I/O是通过网卡读取网络中的数据,网络I/O需要借助Socket来完成,所以此时此刻呢我们重新认识一下Socket.
这部分在一定程度上是我的强迫症作祟,我关于文章对知识点讲解的完备性上对自己近乎苛刻.我觉得把Socket讲明白对此时此刻呢的讲解是一件很重要的事情,看过我之前的文章的读者或许能意识到,我尽量避免把前置知识直接以链接的形式展示出来,我认为会割裂整篇文章的阅读体验.
不割裂的结果就是文章可能显得很啰嗦,好像一件事情非得从盘古开天辟地开始讲起.所以呢,如果各位觉得对这个知识点有足够的把握,就直接略过好了~
我们所做的任何需要和远程设备进行交互的操作,并非是操作软件本身进行的数据通信.举个例子就是我们用浏览器刷B站视频的时候,并非是浏览器自身向B站请求视频数据的,而是必须委托操作系统内核中的协议栈.
协议栈就是下边这些书的代码实现,里边包含了TCP/IP及其他各种网络实现细节,这样解释应该好理解吧.
而Socket库就是操作系统提供给我们的,用于调用协议栈网络功能的一堆程序组件的集合,也就是我们平时听过的操作系统库函数,Socket库和协议栈的关系如下图所示.
用户进程向操作系统内核的协议栈发出委托时,需要按照指定的顺序来调用 Socket 库中的程序组件.
本文的所有案例都以TCP协议为例进行讲解.
大家可以把数据收发想象成在两台计算机之间创建了一条数据通道,计算机通过这条通道进行数据收发的双向操作,当然,这条通道是逻辑上的,并非实际存在.
数据通过管道流动这个比较好理解,但是问题在于这条管道虽然只是逻辑上存在,但是这个"逻辑"也不是光用脑袋想想就会出现的.就好比我们手机打电话,你总得先把号码拨出去呀.
对应到网络I/O中,就意味着双方必须创建各自的数据出入口,然后将两个数据出入口像连接水管一样接通,这个数据出入口就是上图中的套接字,就是大名鼎鼎的socket.
收发数据(通信阶段);
断开管道并删除socket(断开连接).
每一步都是通过特定语言的API调用Socket库,Socket库委托协议栈进行操作的.socket就是调用Socket库中程序组件之后的产成品,比如Java中的ServerSocket,本质上还是调用操作系统的Socket库,所以呢下文的代码实例虽然采用Java语言,但是希望各位读者注意:只有语法上抽象与具体的区别,socket的操作逻辑是完全一致的.
public class BlockingClient {
}
}
Socket socket = new Socket("localhost", 8099);
虽然只是简单的一行语句,但是其中包含了两个步骤,分别是创建套接字、建立连接,等价于下面两行伪代码:
<描述符> = socket(<使用IPv4>, <使用TCP>, ...);
connect(<描述符>, <服务器IP地址和端口号>, ...);
注意:
文中会出现多个关于*ocket的术语,比如Socket库,就是操作系统提供的库函数;socket组件就是Socket库中和socket相关的程序的统称;socket()函数以及socket(或称:套接字)就是此时此刻呢要讲的内容,我会尽量在描述过程中不产生混淆,大家注意根据上下文进行辨析.
<描述符> = socket(<使用IPv4>, <使用TCP>, ...);
基本的脏活累活都是协议栈完成的,协议栈想传递消息总得知道目的IP和端口吧,要是你用的是TCP协议,你甚至还得记录每个包的发送时间以及每个包是否收到回复,否则TCP的超时重传就不会正常工作...等等...
所以呢,协议栈会申请一块内存空间,在其中存放诸如此类的各种控制信息,协议栈就是根据这些控制信息来工作的,这些控制信息我们就可以理解为是socket的实体.怎么样,是不是之前感觉虚无缥缈的socket突然鲜活了起来?
我们看一个更鲜活的例子,我在本级上执行netstat -anop命令,得到的每一行信息我们就可以理解为是一个socket,我们重点看一下下图中标注的两条.
协议栈创建完socket之后,会返回一个描述符给应用程序.描述符用来识别不同的socket,可以将描述符理解成某个socket的编号,就好比你去洗澡的时候,前台会发给你一个手牌,原理差不多.
之后对socket进行的任何操作,只要我们出示自己的手牌,啊呸,描述符,协议栈就能知道我们想通过哪个socket进行数据收发了.
connect(<描述符>, <服务器IP地址和端口号>, ...);
socket刚创建的时候,里边没啥有用的信息,别说自己即将通信的对象长啥样了,就是叫啥,现在在哪儿也不知道,更别提协议栈,自然是啥也知道!
所以呢,第1件事情就是应用程序需要把服务器的IP地址和端口号告诉协议栈,有了街道和门牌号,此时此刻呢协议栈就可以去找服务器了.
一句话概括连接的含义:连接实际上是通信的双方交换控制信息,并将必要的控制信息保存在各自的socket中的过程.
趁热打铁,我们赶紧再说一说服务器端创建socket以及接受连接的过程.
public class BIOServerSocket {
}
}
上面一段是非常简单的Java BIO的服务端代码,代码的含义就是:
创建socket;
将socket设置为等待连接状态;
收发数据.
这些步骤调用的底层代码的伪代码如下:
// 创建socket
= socket(<使用IPv4>, <使用TCP>, ...);
// 绑定端口号
bind(, <端口号等>, ...);
// 设置socket为等待连接状态
listen(, ...);
// 接受客户端连接
<新描述符> = accept(, ...);
// 从客户端连接中读取数据
<读取的数据长度> = read(<新描述符>, <接受缓冲区>, <缓冲区长度>);
// 向客户端连接中写数据
write(<新描述符>, <发送的数据>, <发送的数据长度>);
bind()函数会将端口号写入上一步生成的监听socket中,这样一来,监听socket就完整保存了服务端的IP和端口号.
listen(, <最大连接数>);
很多小伙伴一定会对这个listen()有疑问,监听socket都已经创建完了,端口也已经绑定完了,为什么还要多调用一个listen()呢?
半连接队列(未完成连接的队列)
已完成连接队列
文字太多了,有点干,上个图!
解释一下动图中的内容:
当进程调用accept()时,会将已连接队列头部的socket返回;如果已连接队列为空,那么进程将被睡眠,直到已连接队列中有新的socket,进程才会被唤醒,将这个socket返回.
呃...乖,咱就把它当成socket就好了,这样容易理解,其实具体里边存放的数据结构是啥,我也很想知道,等我写完这篇文章,我研究完了告诉你.
accept()函数是由服务端调用的,用于从已连接队列中返回一个socket描述符;如果socket为阻塞式的,那么如果已连接队列为空,accept()进程就会被睡眠.BIO恰好就是这个样子.
监听socket就像一个客服,我们给客服打电话,然后客服找到解决问题的人,帮助我们和解决问题的人建立联系,如果直接把监听socket返回,而不使用连接socket,就没有socket继续等待连接了.
哦对了,accept()返回的socket也有个名字,叫连接socket.
拿Server端的BIO来说明这个问题,阻塞在了serverSocket.accept()以及bufferedReader.readLine()这两个地方.有什么办法可以证明阻塞吗?
解决这个问题的核心就是别让代码卡在readLine()就可以了,我们可以使用新的线程来readLine(),这样代码就不会阻塞在readLine()上了.
public class BIOServerSocketWithThread {
}
}
所以我们改造完之后的程序是不是就是非阻塞IO了呢?
想多了...我们只是用了点奇技淫巧罢了,改造完的代码在系统调用层面该阻塞的地方还是阻塞,说白了,Java提供的API完全受限于操作系统提供的系统调用,在Java语言级别没能力改变底层BIO的事实!
此时此刻呢带大家看一下改造之后的BIO代码在底层都调用了哪一些系统调用,让我们在底层上对上文的内容加深一下理解.
给大家打个气,此时此刻呢的内容其实非常好理解,大家跟着文章一步步地走,一定能看得懂,如果自己动手操作一遍,那就更好了.
strace是Linux上的一个程序,该程序可以追踪并记录参数后边运行的进程对内核进行了哪些系统调用.
strace -ff -o out java BIOServerSocketWithThread
其中:
-o:
将系统调用的追踪信息输出到out文件中,不加这个参数,默认会输出到标准错误stderr.
-ff
我们运行strace命令之后,生成了很多个out文件.
可以看到图中的有非常多的行,说明我们写的这么几行代码其实默默调用了非常多的系统调用,抛开细枝末节,看一下上图中我重点标注的系统调用,是不是就是上文中我解释过的函数?我再详细解释一下每一步,大家联系上文,会对BIO的底层理解的更加通透.
额外说两点:
其一:可以看到,这么一句简单的打印输出在底层实际调用了两次write系统调用,这就是为什么不推荐在生产环境下使用打印语句的原因,多少会影响系统性能;
系统调用阻塞在了poll()函数,怎么看出来的阻塞?out文件的每一行运行完毕都会有一个 = 返回值,而poll()目前没有返回值,所以呢阻塞了.实际上poll()系统调用对应的Java语句就是serverSocket.accept();.
nc localhost 8099
到此为止,我们就通过底层的系统调用证明了BIO在accept()以及readLine()上的阻塞.最后用一张图来结束BIO之旅.
BIO之所以是BIO,是因为系统底层调用是阻塞的,上图中的进程调用recv,其系统调用直到数据包准备好并且被复制到应用程序的缓冲区或者发生错误为止才会返回,在此整个期间,进程是被阻塞的,啥也干不了.
上文花了太多的笔墨描述BIO,此时此刻呢的非阻塞IO我们只抓主要矛盾,其余参考BIO即可.
如果你看过其他介绍非阻塞IO的文章,下面这个图片你多少会有点眼熟.
非阻塞IO指的是进程发起系统调用之后,内核不会将进程投入睡眠,而是会立即返回一个结果,这个结果可能恰好是我们需要的数据,又或者是某些错误.
你可能会想,这种非阻塞带来的轮询有什么用呢?大多数都是空轮询,白白浪费CPU而已,还不如让进程休眠来的合适.
这个问题暂且搁置一下,我们先看Java在语法层面是如何提供非阻塞功能的,细节慢慢聊.
public class NoBlockingServer {
public static List channelList = new ArrayList<>();
}
}
Java提供了新的API,ServerSocketChannel以及SocketChannel,相当于BIO中的ServerSocket和Socket.此外,通过下面两行的配置,将监听socket和连接socket设置为非阻塞.
// 将监听socket设置为非阻塞
serverSocketChannel.configureBlocking(false);
// 将连接socket设置为非阻塞
socketChannel.configureBlocking(false);
我们上文强调过,Java自身并没有将socket设置为非阻塞的本事,一定是在某个时间点上,操作系统内核提供了这个功能,才使得Java设计出了新的API来提供非阻塞功能.
之所以需要上面两行代码的显式设置,也恰好说明了内核是默认将socket设置为阻塞状态的,需要非阻塞,就得额外调用其他系统调用.我们通过man命令查看一下socket()这个方法(截图的中间省略了一部分内容):
man 2 socket
我们可以看到socket()函数提供了SOCK_NONBLOCK这个类型,可以通过fcntl()这个方法将socket从默认的阻塞修改为非阻塞,不管是对监听socket还是连接socket都是一样的.
serverSocketChannel.accept();会立即返回调用结果.
下面给出一张accept()返回一个连接socket情况下的动图,希望对大家理解整个流程有帮助.
我将上面的程序在CentOS下再次用strace程序追踪一下,具体步骤不再赘述,下面是out日志文件的内容(我忽略了绝大多数没用的).
再放一遍这个图,有一个细节需要大家注意,系统调用向内核要数据时,内核的动作分成两步:
等待数据(从网卡缓冲区拷贝到内核缓冲区)
拷贝数据(数据从内核缓冲区拷贝到用户空间)
所以,一个自然而言的想法就是,能不能别让进程瞎轮询.
这个方案就是I/O多路复用.
剩下的内容另起一篇吧,现在处于发烧状态,八成是阳了,小伙伴们注意身体,下期见~
以上就是土嘎嘎小编为大家整理的Redis网络模型究竟有多强相关主题介绍,如果您觉得小编更新的文章只要能对粉丝们有用,就是我们最大的鼓励和动力,不要忘记讲本站分享给您身边的朋友哦!!