探究Redis网络模型究竟有多强大(上)

电子说

1.3w人已加入

描述

如果面试官问我:Redis为什么这么快?

我肯定会说:因为Redis是内存数据库!如果不是直接把数据放在内存里,甭管怎么优化数据结构、设计怎样的网络I/O模型,都不可能达到如今这般的执行效率。

但是这么回答多半会让我直接回去等通知了。。。因为面试官想听到的就是数据结构和网络模型方面的回答,虽然这两者只是在内存基础上的锦上添花。

说这些并非为了强调网络模型并不重要,恰恰相反,它是Redis实现高吞吐量的重要底层支撑,是“高性能”的重要原因,却不是“快”的直接理由。

本文将从BIO开始介绍,经过NIO、多路复用,最终说回Redis的Reactor模型,力求详尽。本文与其他文章的不同点主要在于:

1、不会介绍同步阻塞I/O、同步非阻塞I/O、异步阻塞I/O、异步非阻塞I/O等概念,这些术语只是对底层原理的一些概念总结而已,我觉得没有用。底层原理搞懂了,这些概念根本不重要,我希望读完本文之后,各位能够不再纠结这些概念。

2、不会只拿生活中例子来说明问题。之前看过特别多的文章,这些文章举的“烧水”、“取快递”的例子真的是深入浅出,但是看懂这些例子会让我们有一种我们真的懂了的错觉。尤其对于网络I/O模型而言,很难找到生活中非常贴切的例子,这种例子不过是已经懂了的人高屋建瓴,对外输出的一种形式,但是对于一知半解的读者而言却犹如钝刀杀人。

牛皮已经吹出去了,正文开始。

1. 一次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()系统调用;
  • 操作系统发生上下文切换,由用户态(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,我还想向各位说明下面几件事情:

  1. 我们编写的程序本身并不能对文件进行读写操作,这个步骤必须依赖于操作系统,换个词儿就是「内核」;
  2. 一个看似简单的I/O操作却在底层引发了多次的用户空间和内核空间的切换,并且数据在内核空间和用户空间之间拷贝来拷贝去。

不同于本地I/O是从本地的文件中读取数据,网络I/O是通过网卡读取网络中的数据,网络I/O需要借助Socket来完成,所以接下来我们重新认识一下Socket。

2. 什么是Socket

这部分在一定程度上是我的强迫症作祟,我关于文章对知识点讲解的完备性上对自己近乎苛刻。我觉得把Socket讲明白对接下来的讲解是一件很重要的事情,看过我之前的文章的读者或许能意识到,我尽量避免把前置知识直接以链接的形式展示出来,我认为会割裂整篇文章的阅读体验。

不割裂的结果就是文章可能显得很啰嗦,好像一件事情非得从盘古开天辟地开始讲起。因此,如果各位觉得对这个知识点有足够的把握,就直接略过好了~

我们所做的任何需要和远程设备进行交互的操作,并非是操作软件本身进行的数据通信。举个例子就是我们用浏览器刷B站视频的时候,并非是浏览器自身向B站请求视频数据的,而是必须委托操作系统内核中的协议栈。

多路复用

网络I/O

而Socket库就是操作系统提供给我们的,用于调用协议栈网络功能的一堆程序组件的集合,也就是我们平时听过的操作系统库函数,Socket库和协议栈的关系如下图所示。

多路复用

Socket库和协议栈的关系

用户进程向操作系统内核的协议栈发出委托时,需要按照指定的顺序来调用 Socket 库中的程序组件。

本文的所有案例都以TCP协议为例进行讲解。

大家可以把数据收发想象成在两台计算机之间创建了一条数据通道,计算机通过这条通道进行数据收发的双向操作,当然,这条通道是逻辑上的,并非实际存在。

多路复用

TCP连接有逻辑通道

数据通过管道流动这个比较好理解,但是问题在于这条管道虽然只是逻辑上存在,但是这个“逻辑”也不是光用脑袋想想就会出现的。就好比我们手机打电话,你总得先把号码拨出去呀。

对应到网络I/O中,就意味着双方必须创建各自的数据出入口,然后将两个数据出入口像连接水管一样接通,这个数据出入口就是上图中的套接字,就是大名鼎鼎的socket。

客户端和服务端之间的通信可以被概括为如下4个步骤:

  1. 服务端创建socket,等待客户端连接(创建socket阶段);
  2. 客户端创建socket,连接到服务端(连接阶段);
  3. 收发数据(通信阶段);
  4. 断开管道并删除socket(断开连接)。

每一步都是通过特定语言的API调用Socket库,Socket库委托协议栈进行操作的。socket就是调用Socket库中程序组件之后的产成品,比如Java中的ServerSocket,本质上还是调用操作系统的Socket库,因此下文的代码实例虽然采用Java语言,但是希望各位读者注意: 只有语法上抽象与具体的区别,socket的操作逻辑是完全一致的

但是,我还是得花点口舌啰嗦一下这几个步骤的一些细节,为了不至于太枯燥,接下来将这4个步骤和BIO一起讲解。

3. 阻塞I/O(Blocking I/O,BIO)

我们先从比较简单的客户端开始谈起。

3.1 客户端的socket流程

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(或称:套接字)就是接下来要讲的内容,我会尽量在描述过程中不产生混淆,大家注意根据上下文进行辨析。

3.1.1 何为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到底是什么意思》

3.1.2 何为连接?

connect(<描述符>, <服务器IP地址和端口号>, ...);

socket刚创建的时候,里边没啥有用的信息,别说自己即将通信的对象长啥样了,就是叫啥,现在在哪儿也不知道,更别提协议栈,自然是啥也知道!

因此,第1件事情就是应用程序需要把服务器的IP地址端口号告诉协议栈,有了街道和门牌号,接下来协议栈就可以去找服务器了。

对于服务器也是一样的情况,服务器也有自己的socket,在接收到客户端的信息的同时,服务器也得知道客户端的IP端口号啊,要不然只能单线联系了。因此对客户端做的第1件事情就有了要求,必须把客户端自己的IP以及端口号告知服务器,然后两者就可以愉快的聊天了。

这就是 3次握手

一句话概括连接的含义: 连接实际上是通信的双方交换控制信息,并将必要的控制信息保存在各自的socket中的过程

连接过后,每个socket就被4个信息唯一标识,通常我们称为四元组:

多路复用

socket四元组

趁热打铁,我们赶紧再说一说服务器端创建socket以及接受连接的过程。

3.2 服务端的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的服务端代码,代码的含义就是:

  1. 创建socket;
  2. 将socket设置为等待连接状态;
  3. 接受客户端连接;
  4. 收发数据。

这些步骤调用的底层代码的伪代码如下:

// 创建socket
打开APP阅读更多精彩内容
声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉

全部0条评论

快来发表一下你的评论吧 !

×
20
完善资料,
赚取积分