分布式微服务架构下网络通信的底层实现原理

在分布式架构中,网络通信是底层基础,没有网络,也就没有所谓的分布式架构。只有通过网络才能使得一大片机器互相协作,共同完成一件事情。

同样,在大规模的系统架构中,应用吞吐量上不去、网络存在通信延迟、我们首先考虑的都是网络问题,因此网络的重要性不言而喻。

作为现代化应用型程序员,要开发一个网络通信的应用,是非常简单的。不仅仅有成熟的api,还有非常方便的通信框架。

可能大家已经忘记了网络通信的重要性,本篇文章会详细分析网络通信的底层原理!!

1.1 理解通信的本质

如图1-1所示,当我们通过浏览器访问一个网址时,一段时间后该网址会渲染出访问的内容,这个过程是怎么实现的呢?

<center>图1-1</center>

我想站在今天,在做的同学都知道,它是基于http协议来实现数据通信的,这里有两个字很重要,就是"协议"。

两个计算机之间要实现数据通信,必须遵循同一种协议,否则,就像一个中国人和一个外国人交流时,一个讲英语另一个讲解中文,肯定是无法正常交流。在计算机中,协议非常常见。

1.1.1 协议的组成

我们写的Java代码,计算机能够理解并且执行,原因是人和计算机之间遵循了同一种语言,那就是Java,如图1-2所示,.java文件最终编译成.class文件这个过程,也同样涉及到协议。

<center>图1-2 java编译过程</center>

所以,在计算机中,协议是指大家需要共同遵循的规则,只有实现统一规则之后,才能实现不同节点之间的数据通信,从而让计算机的应用更加强大。

组成一个协议,需要具备三个要素:

  • 语法,就是这一段内容要符合一定的规则和格式。例如,括号要成对,结束要使用分号等。
  • 语义,就是这一段内容要代表某种意义。例如数字减去数字是有意义的,数字减去文本一般来说就没有意义。
  • 时序,就是先干啥,后干啥。例如,可以先加上某个数值,然后再减去某个数值。

1.1.2 http协议

理解了协议的作用,那协议是长什么样的呢?

那么再来看图1-3的场景,人们通过浏览器访问网站,用到了http协议。

<center>图1-3 http协议</center>

http协议包含包含几个部分:

  • http请求组成
    • 状态行
    • 请求头
    • 消息主体
  • http响应组成
    • 状态行
    • 响应头
    • 响应正文

Http响应报文如图1-4所示,那么这个协议的三要素分别是:

  • 语法: http协议的消息体由状态、头部、内容组成。
  • 语义: 比如状态,200表示成功,404表示请求路径不存在等,通信双方必须遵循该语义。
  • 时序: 组成消息体的三部分的排列顺序,必须要有request,才会产生response。

而浏览器按照http协议做好了相关的处理后,才能让大家通过网址访问网络上的各种信息。

<center>图1-4</center>

1.1.3 常用的网络协议

DNS协议、Http协议、SSH协议、TCP协议、FTP协议等,这些都是大家比较常用的协议类型。无论哪种协议,本质上仍然是由协议的三要素组成,只是应用场景不同。

DNS、HTTP、HTTPS 所在的层我们称为应用层。经过应用层封装后,浏览器会将应用层的包交给下一层去完成,通过 socket 编程来实现。下一层是传输层。传输层有两种协议,一种是无连接的协议 UDP,一种是面向连接的协议 TCP。对于通信可靠性要求的场景来说,往往使用 TCP 协议。所谓的面向连接就是,TCP 会保证这个包能够到达目的地。如果不能到达,就会重新发送,直至到达。

1.3 TCP/IP通信原理分析

一次网络通信到底是怎么完成的呢?

涉及到网络通信,那我们一定会提到一个网络模型的概念,如图1-5所示。表示TCP/IP的四层概念模型和OSI七层网络模型,它是一种概念模型,由国际标准化组织提出来的,试图让全世界范围内的计算机能基于该网络标准实现互联。

<center>图1-5</center>

网络模型为什么要分层呢?其实从我们现在的业务分层架构中就不难发现,任何系统一旦变得复杂,就都会采用分层设计。它的主要好处是

  • 实现高内聚低耦合
  • 每一层有自己单一的职责
  • 提高可复用性和降低维护成本

1.2.1 http通信过程的发送数据包

由于我们的课程并不是专门来讲网络,所以只是提及一下网络分层模型,为了让大家更简单的理解网络分层模型的工作原理,我们仍然以一次网络通信的数据包传输为例进行分析,如图1-6所示。

<center>图1-6</center>

图1-6的工作流程描述如下:

  • 假设我们要登录某一个网站,此时基于Http协议会构建一个http协议报文,这个报文中按照http协议的规范组装,其中包括要传输的用户名和密码。这个是属于应用层协议。

  • 经过应用层封装后,浏览器会把应用层的包交给TCP/IP四层模型中的下一层,也就是传输层来完成,传输层有两种协议:

    • TCP协议,可靠的通信协议,该协议会确保数据包能达到目的地
    • UDP协议,不可靠通信协议,可能会存在数据丢失

    在http通信中使用了TCP协议,TCP协议会有两个端口,一个是浏览器监听的端口,一个是目标服务器进程的端口。操作系统会根据端口来判断这个数据包应该分发给那个进程。

  • 传输层封装完成后,该数据包会技术交给网络层来处理,网络层协议是IP协议,IP协议中会包含源IP地址(也就是客户端及其的IP)和目标服务器的IP地址。

  • 操作系统知道了目标IP地址后,就开始根据这个IP来寻找目标机器,而目标服务器一定是部署在不同的地方,这种跨网络节点的访问,需要经过网关(所谓网关就是一个网络到另外一个网络的关口)。

    所以数据包首先需要先通过自己当前所在网络的网关出去,然后访问到目标服务器,但是在数据包传输到目标服务器之前,需要再组装MAC头信息。

    Mac头包含本地的Mac地址和目标服务器的Mac地址,这个MAC地址怎么获得的呢?

    • 获取本机MAC地址的方法是,操作系统会发送一个广播消息询问网关地址(192.168.1.1)是谁?收到该广播消息的网关会回应一个MAC地址。这个广播消息是基于ARP协议实现的(这个协议简单来说就是已知目标机器的ip,需要获得目标机器的mac地址。(发送一个广播消息,这个ip是谁的,请来认领。认领ip的机器会发送一个mac地址的响应))。

      为了避免每次都用 ARP 请求,机器本地也会进行 ARP 缓存。当然机器会不断地上线下线,IP 也可能会变,所以 ARP 的 MAC 地址缓存过一段时间就会过期。

    • 获取远程机器的MAC地址的方法也同样是基于ARP协议实现的。

完成MAC地址组装后,一个完整的数据包就构成了。这个时候会把这个数据包给到网卡,网卡再把这个数据包发出去,由于这个数据包中包含MAC地址,因此它能够到达网关进行传输。网关收到包之后,会根据路由信息,判断下一步应该怎么走。网关往往是一个路由器,到某个 IP 地址应该怎么走,这个叫作路由表。

1.2.2 http通信过程中的接收数据包

当数据包发送到网关后,会根据网关的路由信息判断该数据包要传输到那个网段上。数据从客户端发送到目标服务器,可能会经过多个网关,所以数据包根据网关路由进入到下一个网关后,继续根据下一个网关的MAC地址寻找下下一个网关,直到到达目标网络服务器上。

这个时候服务器收到包之后,最后一个网关知道这个网络包就是要去当前局域网的,于是拿着目标IP通过ARP协议大喊一声这是谁? 目标服务器就会给网关回复一个MAC地址。 然后网络包在最后那个网关修改目标的MAC地址,通过这个MAC地址,网络包找到了目标服务器。

当目标服务器和MAC地址对上后,开始取出MAC头信息,接着把数据包发送给操作系统的网络层。网络层会取出IP头信息,IP头里面会写上一层封装的是TCP协议,于是交给传输层来处理,实现过程如图1-7所示。

在这一层中,对于收到的每个数据包都会有一个回复,表示服务器端已经收到了该数据包。如果过一段时间客户端没有收到该确认包,发送端的 TCP 层会重新发送这个包,还是上面的过程,直到最终收到回复。

这个重试是TCP协议层来实现的,不需要我们应用来主动发起。

<center>图1-7</center>

为什么有了MAC层还要走IP层呢?

之前我们提到,mac地址是唯一的,那理论上,在任何两个设备之间,我应该都可以通过mac地址发送数据,为什么还需要ip地址?

mac地址就好像个人的身份证号,人的身份证号和人户口所在的城市,出生的日期有关,但是和人所在的位置没有关系,人是会移动的,知道一个人的身份证号,并不能找到它这个人,mac地址类似,它是和设备的生产者,批次,日期之类的关联起来,知道一个设备的mac,并不能在网络中将数据发送给它,除非它和发送方的在同一个网络内。

所以要实现机器之间的通信,我们还需要有ip地址的概念,ip地址表达的是当前机器在网络中的位置,类似于城市名+道路号+门牌号的概念。通过ip层的寻址,我们能知道按何种路径在全世界任意两台Internet上的的机器间传输数据。

1.4 详解TCP可靠性通信特性

我们知道,TCP协议是属于可靠性通信协议,它能够确保数据包不被丢失。首先我们先了解一下TCP的三次握手和四次挥手。

1.4.1 TCP的三次握手

两个节点需要进行数据通信,首先得先建立连接。而在建立连接时,TCP采用了三次握手来实现连接建立。如图1-8所示。

<center>图1-8</center>

第一次握手(SYN=1, seq=x)

客户端发送一个 TCP的 SYN 标志位置1的包,指明客户端打算连接的服务器的端口,以及初始序号 X,**保存在包头的序列号(Sequence Number)**字段里。发送完毕后,客户端进入 SYN_SEND 状态。

第二次握手(SYN=1, ACK=1, seq=y, ACK num=x+1):

服务器发回确认包(ACK)应答。即 SYN 标志位和 ACK 标志位均为1。服务器端选择自己 ISN 序列号,放到Seq 域里,同时将确认序号(Acknowledgement Number)设置为客户的 ISN 加1,即X+1。 发送完毕后,服务器端进入 SYN_RCVD 状态。

第三次握手(ACK=1,ACK num=y+1)

客户端再次发送确认包(ACK),SYN标志位为0,ACK标志位为1,并且把服务器发来 ACK的序号字段+1,放在确定字段中发送给对方,并且在数据段放写ISN发完毕后,客户端进入 ESTABLISHED 状态,当服务器端接收到这个包时,也进入 ESTABLISHED 状态,TCP握手结束。

1.4.2 TCP为什么是三次握手?

TCP是全双工,如果没有第三次的握手,服务端不能确认客户端是否ready,不知道什么时候可以往客户端发数据包。三次的握手刚好两边都互相确认对方已经ready。

我们假设网络的不可靠性,

A发起一个连接,当发起一个请求没有得到反馈的时候,会有很多可能性,比如请求包丢失,或者超时,或者B没有响应

由于A不能确认结果,于是再发,当有一个请求包到了B之后,A并不知道这个数据包已经到了B,所以可能还会重试。

所以B收到请求之后,知道了A的存在并且要和我建立连接,这个时候B会发送ack给到A,告诉A我收到了请求包。

对于B来说,这个应答包也是一个网络通信,我怎么知道能不能到达A呢?所以这个时候B不能很主观的认为连接已经建立好了,还需要等到A再次发送应答包来确认。

1.4.3 TCP的四次挥手

如图1-9所示,TCP的连接断开,会通过所谓的四次挥手完成。

四次挥手表示TCP断开连接的时候,需要客户端和服务端总共发送4个包以确认连接的断开;客户端或服务器均可主动发起挥手动作(因为TCP是一个全双工协议),在 socket 编程中,任何一方执行 close() 操作即可产生挥手操作。

<center>图1-9</center>

上述交互过程如下:

  • 断开的时候,我们可以看到,当A客户端说说"我要断开连接",就进入 FIN_WAIT_1 的状态。

  • B 服务端收到"我要断开连接"的消息后,发送"知道了"给到A客户端,就进入 CLOSE_WAIT 的状态。

  • A 收到"B 说知道了",就进入 FIN_WAIT_2 的状态,如果这个时候 B 服务器挂掉了,则 A 将永远在这个状态。TCP 协议里面并没有对这个状态的处理,但是 Linux 有,可以调整 tcp_fin_timeout 这个参数,设置一个超时时间。

  • 如果 B 服务器正常,则发送了"B 要关闭连接"的请求到达 A 时,A 发送"知道 B 也要关闭连接"的 ACK 后,从 FIN_WAIT_2 状态结束。

  • 按说这个时候 A 可以退出了,但是最后的这个 ACK 万一 B 收不到呢?则 B 会重新发一个"B 要关闭连接",这个时候 A 已经跑路了的话,B 就再也收不到 ACK 了,因而 TCP 协议要求 A 最后等待一段时间 TIME_WAIT,这个时间要足够长,长到如果 B 没收到 ACK 的话,"B 说不玩了"会重发的,A 会重新发一个 ACK 并且足够时间到达 B。

这个等待实现是2MSL,MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃(此时A直接进入CLOSE状态)。协议规定 MSL 为 2 分钟,实际应用中常用的是 30 秒,1 分钟和 2 分钟等。

第一次挥手(FIN=1,seq=x)

假设客户端想要关闭连接,客户端发送一个 FIN 标志位置为1的包,表示自己已经没有数据可以发送了,但是仍然可以接受数据。发送完毕后,客户端进入 FIN_WAIT_1 状态。

第二次挥手(ACK=1,ACKnum=x+1)

服务器端确认客户端的 FIN包,发送一个确认包,表明自己接受到了客户端关闭连接的请求,但还没有准备好关闭连接。发送完毕后,服务器端进入 CLOSE_WAIT 状态,客户端接收到这个确认包之后,进入 FIN_WAIT_2 状态,等待服务器端关闭连接。

第三次挥手(FIN=1,seq=w)

服务器端准备好关闭连接时,向客户端发送结束连接请求,FIN置为1。发送完毕后,服务器端进入 LAST_ACK 状态,等待来自客户端的最后一个ACK。

第四次挥手(ACK=1,ACKnum=w+1)

客户端接收到来自服务器端的关闭请求,发送一个确认包,并进入 TIME_WAIT状态,等待可能出现的要求重传的 ACK包。服务器端接收到这个确认包之后,关闭连接,进入 CLOSED 状态。

【问题1】为什么连接的时候是三次握手,关闭的时候却是四次握手?

答:三次握手是因为因为当Server端收到Client端的SYN连接请求报文后,可以直接发送SYN+ACK报文。其中ACK报文是用来应答的,SYN报文是用来同步的。但是关闭连接时,当Server端收到FIN报文时,很可能并不会立即关闭SOCKET(因为可能还有消息没处理完),所以只能先回复一个ACK报文,告诉Client端,"你发的FIN报文我收到了"。只有等到我Server端所有的报文都发送完了,我才能发送FIN报文,因此不能一起发送。故需要四步握手。

【问题2】为什么TIME_WAIT状态需要经过2MSL(最大报文段生存时间)才能返回到CLOSE状态?

答:虽然按道理,四个报文都发送完毕,我们可以直接进入CLOSE状态了,但是我们必须假象网络是不可靠的,有可以最后一个ACK丢失。所以TIME_WAIT状态就是用来重发可能丢失的ACK报文。

1.4.4 TCP协议的报文传输

连接建立好之后,就开始进行数据包的传输了。那TCP作为一个可靠的通信协议,如何保证消息传输的可靠性呢?

TCP采用了消息确认的方式来保证数据报文传输的安全性,也就是说客户端发送了数据包到服务端后,服务端会返回一个确认消息给到客户端,如果客户端没有收到确认包,则会重新再发送。

为了保证顺序性,每一个包都有一个 ID。在建立连接的时候,会商定起始的 ID 是什么,然后按照 ID 一个个发送。为了保证不丢包,对于发送的包都要进行应答,但是这个应答也不是一个一个来的,而是会应答某个之前的 ID,表示都收到了,这种模式称为累计确认或者累计应答(cumulative acknowledgment)

如图1-10所示,为了记录所有发送的包和接收的包,TCP协议在发送端和接收端分别拿会有发送缓冲区和接收缓冲区,TCP的全双工的工作模式及TCP的滑动窗口就是依赖于这两个独立的Buffer和该Buffer的填充状态。

接收缓冲区把数据缓存到内核,若应用进程一直没有调用Socket的read方法进行读取,那么该数据会一直被缓存在接收缓冲区内。不管进程是否读取Socket,对端发来的数据都会经过内核接收并缓存到Socket的内核接收缓冲区。

read所要做的工作,就是把内核接收缓冲区中的数据复制到应用层用户的Buffer里。进程调用Socket的send发送数据的时候,一般情况下是将数据从应用层用户的Buffer里复制到Socket的内核发送缓冲区,然后send就会在上层返回。换句话说,send返回时,数据不一定会被发送到对端。

<center>图1-10</center>

发送端/接收端的缓冲区中是按照包的 ID 一个个排列,根据处理的情况分成四个部分。

  • 第一部分:发送了并且已经确认的。
  • 第二部分:发送了并且尚未确认的。需要等待确认后,才能移除。
  • 第三部分:没有发送,但是已经等待发送的。
  • 第四部分:没有发送,并且暂时还不会发送的。

这里的第三部分和第四部分之所以做一个区分,其实是因为TCP采用做了流量控制,这里采用了滑动窗口的方式来实现流量整形,避免出现数据拥堵的情况。

<center>图1-11</center>

1.4.5 滑动窗口协议

上述地址中动画演示的部分,其实就是数据包发送和确认机制,同时还涉及到互动窗口协议。

滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题;发送和接受方都会维护一个数据帧的序列,这个序列被称作窗口

发送窗口

就是发送端允许连续发送的幀的序号表。

发送端可以不等待应答而连续发送的最大幀数称为发送窗口的尺寸。

接收窗口

接收方允许接收的幀的序号表,凡落在 接收窗口内的幀,接收方都必须处理,落在接收窗口外的幀被丢弃。

接收方每次允许接收的幀数称为接收窗口的尺寸。

1.5 理解阻塞通信的本质

理解了TCP通信的原理后,在Java中我们会采用Socket套接字来实现网络通信,下面这段代码演示了Socket通信的案例。

复制代码
public class ServerSocketExample {

    public static void main(String[] args) throws IOException {
        final int DEFAULT_PORT = 8080;
        ServerSocket serverSocket = null;
        serverSocket = new ServerSocket(DEFAULT_PORT);
        System.out.println("启动服务,监听端口:" + DEFAULT_PORT);
        while (true) {
            Socket socket = serverSocket.accept();
            System.out.println("客户端:" + socket.getPort() + "已连接");
            new Thread(new Runnable() {
                Socket socket;
                public Runnable setSocket(Socket s){
                    this.socket=s;
                    return this;
                }
                @Override
                public void run() {
                    try {
                        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                        String clientStr = null; //读取一行信息
                        clientStr = bufferedReader.readLine();
                        System.out.println("客户端发了一段消息:" + clientStr);
                        BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()));
                        bufferedWriter.write("我已经收到你的消息了");
                        bufferedWriter.flush(); //清空缓冲区触发消息发送
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }.setSocket(socket)).start();

        }
    }
}

在我们讲Redis的专题中详细讲到过,上述通信是BIO模型,也就是阻塞通信模型,阻塞主要体现的点是

  • accept,阻塞等待客户端连接
  • io阻塞,阻塞等待客户端的数据传输。

相信大家和我一样有一些以后,这个阻塞和唤醒到底是怎么回事,下面我们简单来了解一下。

1.5.1 阻塞操作的本质

阻塞是指进程在等待某个事件发生之前的等待状态,它是属于操作系统层面的调度,我们通过下面操作来追踪Java程序中有多少程序,每一个线程对内核产生了哪些操作。

strace,Linux操作系统中的指令

  1. 把ServerSocketExample.java,去掉package导入头,拷贝到linux服务器的 /data/app目录下。

  2. 使用javac ServerSocketExample.java进行编译,得到.class文件

  3. 使用下面这个命令来追踪(打开一个新窗口)

    按照strace官网的描述, strace是一个可用于诊断、调试和教学的Linux用户空间跟踪器。我们用它来监控用户空间进程和内核的交互,比如系统调用、信号传递、进程状态变更等。

    复制代码
    strace -ff -o out java ServerSocketExample
    • -f 跟踪目标进程,以及目标进程创建的所有子进程
    • -o 把strace的输出单独写到指定的文件
  4. 上述指令执行完成后,会在/data/app目录下得到很多out.*的文件,每个文件代表一个线程。因为Java本身是多线程的。

    复制代码
    [root@localhost app]# ll
    total 748
    -rw-r--r--. 1 root root  14808 Aug 23 12:51 out.33320 //最小的表示主线程
    -rw-r--r--. 1 root root 186893 Aug 23 12:51 out.33321
    -rw-r--r--. 1 root root    961 Aug 23 12:51 out.33322
    -rw-r--r--. 1 root root    917 Aug 23 12:51 out.33323
    -rw-r--r--. 1 root root    833 Aug 23 12:51 out.33324
    -rw-r--r--. 1 root root    819 Aug 23 12:51 out.33325
    -rw-r--r--. 1 root root  23627 Aug 23 12:53 out.33326
    -rw-r--r--. 1 root root   1326 Aug 23 12:51 out.33327
    -rw-r--r--. 1 root root   1144 Aug 23 12:51 out.33328
    -rw-r--r--. 1 root root   1270 Aug 23 12:51 out.33329
    -rw-r--r--. 1 root root   8136 Aug 23 12:53 out.33330
    -rw-r--r--. 1 root root   8158 Aug 23 12:53 out.33331
    -rw-r--r--. 1 root root   6966 Aug 23 12:53 out.33332
    -rw-r--r--. 1 root root   1040 Aug 23 12:51 out.33333
    -rw-r--r--. 1 root root 445489 Aug 23 12:53 out.33334
  5. 打开out.33321这个文件(主线程后面的一个文件),shift+g到该文件的尾部,可以看到如下内容。

    下面这些方法,都是属于系统调用,也就是调用操作系统提供的内核指令触发相关的操作。

    复制代码
    # 创建socket fd 
    socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 5 
    ....
    # 绑定8888端口
    bind(5, {sa_family=AF_INET6, sin6_port=htons(8888), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, 28) = 0
    # 创建一个socket并监听申请的连接, 5表示sockfd,50表示等待队列的最大长度
    listen(5, 50)                           = 0
    mprotect(0x7f21d00df000, 4096, PROT_READ|PROT_WRITE) = 0
    write(1, "\345\220\257\345\212\250\346\234\215\345\212\241\357\274\214\347\233\221\345\220\254\347\253\257\345\217\243\357\274\23288"..., 34) = 34
    write(1, "\n", 1)                       = 1
    lseek(3, 58916778, SEEK_SET)            = 58916778
    read(3, "PK\3\4\n\0\0\10\0\0U\23\213O\336\274\205\24X8\0\0X8\0\0\25\0\0\0", 30) = 30
    lseek(3, 58916829, SEEK_SET)            = 58916829
    read(3, "\312\376\272\276\0\0\0004\1\367\n\0\6\1\37\t\0\237\1 \t\0\237\1!\t\0\237\1\"\t\0"..., 14424) = 14424
    # poll, 把当前的文件指针挂到等待队列,文件指针指的是fd=5,简单来说就是让当前进程阻塞,直到有事件触发唤醒
    * events: 表示请求事件,POLLIN(普通或优先级带数据可读)、POLLERR,发生错误。
    poll([{fd=5, events=POLLIN|POLLERR}], 1, -1

从这个代码中可以看到,Socket的accept方法最终是调用系统的poll函数来实现线程阻塞的。

通过在linux服务器输入 man 2 poll

man: 帮助手册

2: 表示系统调用相关的函数

复制代码
DESCRIPTION
       poll()  performs  a  similar  task  to  select(2): it waits for one of a set of file
       descriptors to become ready to perform I/O.

poll类似于select函数,它可以等待一组文件描述符中的IO就绪事件

  1. 通过下面命令访问socket server。

    复制代码
    telnet 192.168.221.128 8888

    这个时候通过tail -f out.33321这个文件,发现被阻塞的poll()方法,被POLLIN事件唤醒了,表示监听到了一次连接。

    复制代码
    poll([{fd=5, events=POLLIN|POLLERR}], 1, -1) = 1 ([{fd=5, revents=POLLIN}])
    accept(5, {sa_family=AF_INET6, sin6_port=htons(53778), inet_pton(AF_INET6, "::ffff:192.168.221.1", &sin6_addr), sin6_flowinfo=0, sin6_scope_id=0}, [28]) = 6

1.5.2 阻塞被唤醒的过程

如图1-12所示,网络数据包通过网线传输到目标服务器的网卡,再通过2所示的硬件电路传输,最终把数据写入到内存中的某个地址上,接着网卡通过中断信号通知CPU有数据到达,操作系统就知道当前有新的数据包传递过来,于是CPU开始执行中断程序,中断程序的主要逻辑是

  • 先把网卡接收到的数据写入到对应的Socket接收缓冲区中
  • 再唤醒被阻塞在poll()方法上的线程

<center>图1-12</center>

1.5.3 阻塞的整体原理分析

操作系统为了支持多任务处理,所以实现了进程调度功能,运行中的进程表示获得了CPU的使用权,当进程(线程)因为某些操作导致阻塞时,就会释放CPU使用权,使得操作系统能够多任务的执行。

当多个进程是运行状态等待CPU调度时,这些进程会保存到一个可运行队列中,如图1-13所示。

<center>图1-13</center> 当进程A执行创建Socket语句时,在Linux操作系统中会创建一个由文件系统管理的Socket对象,这个Socket对象包含发送缓冲区、接收缓冲区、等待队列等,其中等待队列是非常重要的结构,它指向所有需要等待当前Socket事件的进程,如图1-14所示。

当进程A调用poll()方法阻塞时,操作系统会把当前进程A从工作队列移动到Socket的等待队列中(将进程A的指针指向等待队列,后续需要进行唤醒),此时A被阻塞,CPU继续执行下一个进程。

<center>图1-14</center>

当Socket收到数据时,等待该Socket FD的进程会收到被唤醒,如图1-15所示,计算机通过网卡接收到客户端传过来的数据,网卡会把这个数据写入到内存,然后再通过中断信号通知CPU有数据到达,于是CPU开始执行中断程序。

当发生了中断,就意味着需要操作系统的介入,开展管理工作。由于操作系统的管理工作(**如进程切换、分配IO设备)需要使用特权指令,因此CPU要从用户态转换为核心态。**中断就可以使CPU从用户态转换为核心态,使操作系统获得计算机的控制权。因此,有了中断,才能实现多道程序并发执行。

此处的中断程序主要有两项功能,先将网络数据写入到对应 Socket 的接收缓冲区里面(步骤 ④),再唤醒进程 A(步骤 ⑤),重新将进程 A 放入工作队列中。

<center>图1-15</center>

1.5 Linux中的select/poll模型本质

前面在1.4节中讲的其实是Recv()方法,它只能监视单个Socket。而在实际应用中,这种单Socket监听很明显会影响到客户端连接数,所以我们需要寻找一种能够同时监听多个Socket的方法,而select/poll就是在这个背景下产生的,其中poll方法在前面的案例中就讲过,默认情况下使用poll模型。

先来了解一下select模型,由于在前面的分析中我们知道Recv()只能实现对单个socket的监听,当客户端连接数较多的时候,会导致吞吐量非常低,所以我们想,能不能实现同时监听多个socket,只要任何一个socket连接存在IO就绪事件,就触发进程的唤醒。

如图1-16所示,假设程序同时监听socket1和socket2这两个socket连接,那么当应用程序调用select方法后,操作系统会把进程A分别指向这连个个socket的等待队列中。当任何一个Socket收到数据后,中断程序会唤醒对应的进程。

当进程 A 被唤醒后,它知道至少有一个 Socket 接收了数据。程序只需遍历一遍 Socket 列表,就可以得到就绪的 Socket。

<center>图1-16</center>

select模式有二个问题,

  • 就是每次调用select都需要将进程加入到所有监视器socket的等待队列,每次唤醒都需要从等待队列中移除,这里涉及到两次遍历,有一定的性能开销。
  • 进程被唤醒后,并不知道哪些socket收到了数据,所以还需要遍历一次所有的socket,得到就绪的socket列表

由于这两个问题产生的性能影响,所以select默认规定只能监视1024个socket,虽然可以通过修改监视的文件描述符数量,但是这样会降低效率。而poll模式和select基本是一样,最大的区别是poll没有最大文件描述符限制。

1.6 Linux中的epoll模型

有没有更加高效的方法,能够减少遍历也能达到同时监听多个fd的目的呢?epoll模型就可以解决这个问题。

epoll 其实是event poll的组合,它和select最大的区别在于,epoll会把哪个socket发生了什么样的IO事件通知给应用程序,所以epoll实际上就是事件驱动,具体原理如图1-17所示。

在epoll中提供了三个方法分别是epoll_create、epoll_ctl、epoll_wait。具体执行流程如下

  • 首先调用epoll_create方法,在内核创建一个eventpoll对象,这个对象会维护一个epitem集合,它是一个红黑树结构。这个集合简单理解成fd集合。
  • 接着调用epoll_ctl函数将当前fd封装成epitem加入到eventpoll对象中,并给这个epitem加入一个回调函数注册到内核。当这个fd收到网络IO事件时,会把该fd对应的epitem加入到eventpoll中的就绪列表rdlist(双向链表)中。同时再唤醒被阻塞的进程A。
  • 进程A继续调用epoll_wait方法,直接读取epoll中就绪队列rdlist中的epitem,如果rdlist队列为空,则阻塞等待或者等待超时。

从epoll的原理中可以得知,由于rdlist的存在,使得进程A被唤醒后知道哪些Socket(fd)发生了IO事件,从而在不需要遍历的情况下获取所有就绪的socket连接。

相关推荐
艾希逐月8 小时前
分布式唯一 ID 生成方案
分布式
齐木卡卡西在敲代码12 小时前
kafka的pull的依据
分布式·kafka
lllsure12 小时前
RabbitMQ 基础
分布式·rabbitmq
Wgllss12 小时前
Kotlin 享元设计模式详解 和对象池及在内存优化中的几种案例和应用场景
android·架构·android jetpack
DN金猿16 小时前
rabbitmq发送的延迟消息时间过长就立即消费了
分布式·rabbitmq
程序员不迷路16 小时前
微服务学习
微服务·架构
fanly1116 小时前
使用surging 常见的几个问题
微服务·surging
Sadsvit16 小时前
源码编译安装LAMP架构并部署WordPress(CentOS 7)
linux·运维·服务器·架构·centos