电子说
如果面试官问我:Redis为什么这么快?
我肯定会说:因为Redis是内存数据库!如果不是直接把数据放在内存里,甭管怎么优化数据结构、设计怎样的网络I/O模型,都不可能达到如今这般的执行效率。
但是这么回答多半会让我直接回去等通知了。。。因为面试官想听到的就是数据结构和网络模型方面的回答,虽然这两者只是在内存基础上的锦上添花。
说这些并非为了强调网络模型并不重要,恰恰相反,它是Redis实现高吞吐量的重要底层支撑,是“高性能”的重要原因,却不是“快”的直接理由。
本文将从BIO开始介绍,经过NIO、多路复用,最终说回Redis的Reactor模型,力求详尽。本文与其他文章的不同点主要在于:
1、不会介绍同步阻塞I/O、同步非阻塞I/O、异步阻塞I/O、异步非阻塞I/O等概念,这些术语只是对底层原理的一些概念总结而已,我觉得没有用。底层原理搞懂了,这些概念根本不重要,我希望读完本文之后,各位能够不再纠结这些概念。
2、不会只拿生活中例子来说明问题。之前看过特别多的文章,这些文章举的“烧水”、“取快递”的例子真的是深入浅出,但是看懂这些例子会让我们有一种我们真的懂了的错觉。尤其对于网络I/O模型而言,很难找到生活中非常贴切的例子,这种例子不过是已经懂了的人高屋建瓴,对外输出的一种形式,但是对于一知半解的读者而言却犹如钝刀杀人。
牛皮已经吹出去了,正文开始。
我们都知道,网络I/O是通过Socket实现的,在说明网络I/O之前,我们先来回顾(了解)一下本地I/O的流程。
举一个非常简单的例子,下面的代码实现了文件的拷贝,将file1.txt的数据拷贝到file2.txt中:
public static void main(String[] args) throws Exception {
FileInputStream in = new FileInputStream("/tmp/file1.txt");
FileOutputStream out = new FileOutputStream("/tmp/file2.txt");
byte[] buf = new byte[in.available()];
in.read(buf);
out.write(buf);
}
这个I/O操作在底层到底经历了什么呢?下图给出了说明:
本地I/O示意图
大致可以概括为如下几个过程:
in.read(buf)
执行时,程序向内核发起 read()
系统调用;out.write(buf)
;之所以先拿本地I/O举个例子,是因为我想说明I/O模型并非仅仅针对网络IO(虽然网络I/O最常被我们拿来举例),本地I/O同样受到I/O模型的约束。比如在这个例子中,本地I/O用的就是典型的BIO,至于什么是BIO,稍安勿躁,接着往下看。
除此之外,通过本地I/O,我还想向各位说明下面几件事情:
不同于本地I/O是从本地的文件中读取数据,网络I/O是通过网卡读取网络中的数据,网络I/O需要借助Socket来完成,所以接下来我们重新认识一下Socket。
这部分在一定程度上是我的强迫症作祟,我关于文章对知识点讲解的完备性上对自己近乎苛刻。我觉得把Socket讲明白对接下来的讲解是一件很重要的事情,看过我之前的文章的读者或许能意识到,我尽量避免把前置知识直接以链接的形式展示出来,我认为会割裂整篇文章的阅读体验。
不割裂的结果就是文章可能显得很啰嗦,好像一件事情非得从盘古开天辟地开始讲起。因此,如果各位觉得对这个知识点有足够的把握,就直接略过好了~
我们所做的任何需要和远程设备进行交互的操作,并非是操作软件本身进行的数据通信。举个例子就是我们用浏览器刷B站视频的时候,并非是浏览器自身向B站请求视频数据的,而是必须委托操作系统内核中的协议栈。
网络I/O
而Socket库就是操作系统提供给我们的,用于调用协议栈网络功能的一堆程序组件的集合,也就是我们平时听过的操作系统库函数,Socket库和协议栈的关系如下图所示。
Socket库和协议栈的关系
用户进程向操作系统内核的协议栈发出委托时,需要按照指定的顺序来调用 Socket 库中的程序组件。
本文的所有案例都以TCP协议为例进行讲解。
大家可以把数据收发想象成在两台计算机之间创建了一条数据通道,计算机通过这条通道进行数据收发的双向操作,当然,这条通道是逻辑上的,并非实际存在。
TCP连接有逻辑通道
数据通过管道流动这个比较好理解,但是问题在于这条管道虽然只是逻辑上存在,但是这个“逻辑”也不是光用脑袋想想就会出现的。就好比我们手机打电话,你总得先把号码拨出去呀。
对应到网络I/O中,就意味着双方必须创建各自的数据出入口,然后将两个数据出入口像连接水管一样接通,这个数据出入口就是上图中的套接字,就是大名鼎鼎的socket。
客户端和服务端之间的通信可以被概括为如下4个步骤:
每一步都是通过特定语言的API调用Socket库,Socket库委托协议栈进行操作的。socket就是调用Socket库中程序组件之后的产成品,比如Java中的ServerSocket,本质上还是调用操作系统的Socket库,因此下文的代码实例虽然采用Java语言,但是希望各位读者注意: 只有语法上抽象与具体的区别,socket的操作逻辑是完全一致的 。
但是,我还是得花点口舌啰嗦一下这几个步骤的一些细节,为了不至于太枯燥,接下来将这4个步骤和BIO
一起讲解。
我们先从比较简单的客户端开始谈起。
public class BlockingClient {
public static void main(String[] args) {
try {
// 创建套接字 & 建立连接
Socket socket = new Socket("localhost", 8099);
// 向服务端写数据
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bufferedWriter.write("我是客户端,收到请回答!!\\n");
bufferedWriter.flush();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
String line = bufferedReader.readLine();
System.out.println("收到服务端返回的数据:" + line);
} catch (IOException e) {
// 错误处理
}
}
}
上面展示了一段非常简单的Java BIO的客户端代码,相信你们一定不会感到陌生,接下来我们一点点分析客户端的socket操作究竟做了什么。
Socket socket = new Socket("localhost", 8099);
虽然只是简单的一行语句,但是其中包含了两个步骤,分别是创建套接字、建立连接,等价于下面两行伪代码:
<描述符> = socket(<使用IPv4>, <使用TCP>, ...);
connect(<描述符>, <服务器IP地址和端口号>, ...);
注意:
文中会出现多个关于*ocket的术语,比如Socket库,就是操作系统提供的库函数;socket组件就是Socket库中和socket相关的程序的统称;socket()函数以及socket(或称:套接字)就是接下来要讲的内容,我会尽量在描述过程中不产生混淆,大家注意根据上下文进行辨析。
上文已经说了,逻辑管道存在的前提是需要各自先创建socket(就好比你打电话之前得先有手机),然后将两个socket进行关联。客户端创建socket非常简单,只需要调用Socket库中的socket组件的socket()
函数就可以了。
<描述符> = socket(<使用IPv4>, <使用TCP>, ...);
客户端代码调用socket()
函数向协议栈申请创建socket,协议栈会根据你的参数来决定socket是IPv4
还是IPv6
,是TCP
还是UDP
。除此之外呢?
基本的脏活累活都是协议栈完成的,协议栈想传递消息总得知道目的IP和端口吧,要是你用的是TCP
协议,你甚至还得记录每个包的发送时间以及每个包是否收到回复,否则TCP
的超时重传就不会正常工作。。。等等。。。
因此,协议栈会申请一块内存空间,在其中存放诸如此类的各种控制信息,协议栈就是根据这些控制信息来工作的,这些控制信息我们就可以理解为是socket的实体。怎么样,是不是之前感觉虚无缥缈的socket突然鲜活了起来?
我们看一个更鲜活的例子,我在本级上执行netstat -anop
命令,得到的每一行信息我们就可以理解为是一个socket,我们重点看一下下图中标注的两条。
这两条都是redis-server
的socket信息,第1条表示redis-server
服务正在IP为127.0.0.1
,端口为6379
的主机上等待远程客户端连接,因为Foreign address为0.0.0.0:*
,表示通信还未开始,IP无法确定,因此State为LISTEN
状态;第2条表示redis-server
服务已经建立了与IP为127.0.0.1
的客户端之间的连接,且客户端使用49968
的端口号,目前该socket的状态为ESTABLISHED
。
协议栈创建完socket之后,会返回一个描述符给应用程序。描述符用来识别不同的socket,可以将描述符理解成某个socket的编号,就好比你去洗澡的时候,前台会发给你一个手牌,原理差不多。
之后对socket进行的任何操作,只要我们出示自己的手牌,啊呸,描述符,协议栈就能知道我们想通过哪个socket进行数据收发了。
描述符就是socket的号码牌
至于为什么不直接返回socket的内存地址以及其他细节,可以参考我之前写的文章《2>&1到底是什么意思》
connect(<描述符>, <服务器IP地址和端口号>, ...);
socket刚创建的时候,里边没啥有用的信息,别说自己即将通信的对象长啥样了,就是叫啥,现在在哪儿也不知道,更别提协议栈,自然是啥也知道!
因此,第1件事情就是应用程序需要把服务器的IP地址
和端口号
告诉协议栈,有了街道和门牌号,接下来协议栈就可以去找服务器了。
对于服务器也是一样的情况,服务器也有自己的socket,在接收到客户端的信息的同时,服务器也得知道客户端的IP
和端口号
啊,要不然只能单线联系了。因此对客户端做的第1件事情就有了要求,必须把客户端自己的IP
以及端口号
告知服务器,然后两者就可以愉快的聊天了。
这就是 3次握手 。
一句话概括连接的含义: 连接实际上是通信的双方交换控制信息,并将必要的控制信息保存在各自的socket中的过程 。
连接过后,每个socket就被4个信息唯一标识,通常我们称为四元组:
socket四元组
趁热打铁,我们赶紧再说一说服务器端创建socket以及接受连接的过程。
public class BIOServerSocket {
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket(8099);
System.out.println("启动服务:监听端口:8099");
// 等待客户端的连接过来,如果没有连接过来,就会阻塞
while (true) {
// 表示阻塞等待监听一个客户端连接,返回的socket表示连接的客户端信息
Socket socket = serverSocket.accept();
System.out.println("客户端:" + socket.getPort());
// 表示获取客户端的请求报文
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// 读操作也是阻塞的
String clientStr = bufferedReader.readLine();
System.out.println("收到客户端发送的消息:" + clientStr);
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
bufferedWriter.write("ok\\n");
bufferedWriter.flush();
}
} catch (IOException e) {
// 错误处理
} finally {
// 其他处理
}
}
}
上面一段是非常简单的Java BIO的服务端代码,代码的含义就是:
这些步骤调用的底层代码的伪代码如下:
// 创建socket
全部0条评论
快来发表一下你的评论吧 !