飞道的博客

远程通信协议原理(TCP/IP 协议)

437人阅读  评论(0)

一个 http 请求的整个流程

那么在分布式架构中,有一个很重要的环节,就是分布式网络中的计算机节点彼此之间需要
通信。这个通信的过程一定会涉及到通信协议相关的知识点,当然大家也可能知道一些,但
是我会尽可能的把通信这一块的内容串起来,加深大家的理解。我们每天都在用浏览器访问各种网站,作为用户来说,只需要需要输入一个网址并且正确跳转就行。但是作为程序员,看到的可能就是这个响应背后的整体流程。所以我想通过一个 http请求的整个流程来进行讲解通信的知识

负责域名解析的 DNS 服务

首先,用户访问一个域名,会经过 DNS 解析
DNS(Domain Name System),它和 HTTP 协议一样是位于应用层的协议,主要提供域名到
IP 的解析服务。我们其实不用域名也可以访问目标主机的服务,但是 IP 本身不是那么容易
记,所以使用域名进行替换使得用户更容易记住。

加速静态内容访问速度的 CDN

我这里只讲了比较简单的解析流程,在很多大型网站,会引入 CDN 来加速静态内容的访问,
这里简单给大家解释一下什么是 CDN(Content Delivery Network),表示的是内容分发网
络。CDN 其实就是一种网络缓存技术,能够把一些相对稳定的资源放到距离最终用户较近的
地方,一方面可以节省整个广域网的带宽消耗,另外一方面可以提升用户的访问速度,改进
用户体验。我们一般会把静态的文件(图片、脚本、静态页面)放到 CDN 中。
如果引入了 CDN,那么解析的流程可能会稍微复杂一点,大家有空自己去了解一下。比如阿
里云就提供了 cdn 的功能。

HTTP 协议通信原理

域名被成功解析以后,客户端和服务端之间,是怎么建立连接并且如何通信的呢?
说到通信,大家一定听过 tcp 和 udp 这两种通信协议,以及建立连接的握手过程。而 http 协议的通信是基于 tcp/ip 协议之上的一个应用层协议,应用层协议除了 http 还有哪些呢(FTP、DNS、SMTP、Telnet 等)。
涉及到网络协议,我们一定需要知道 OSI 七层网络模型和 TCP/IP 四层概念模型,OSI 七层网络模型包含(应用层、表示层、会话层、传输层、网络层、数据链路层、物理层)、TCP/IP 四层概念模型包含(应用层、传输层、网络层、数据链路层)。

请求发起过程,在 tcp/ip 四层网络模型中所做的事情

当应用程序用 T C P 传送数据时,数据被送入协议栈中,然后逐个通过每一层直到被当作一
串比特流送入网络。其中每一层对收到的数据都要增加一些首部信息(有时还要增加尾部信
息)

客户端如何找到目标服务

在客户端发起请求的时候,我们会在数据链路层去组装目标机器的 MAC 地址,目标机器的
mac 地址怎么得到呢? 这里就涉及到一个 ARP 协议,这个协议简单来说就是已知目标机器
的 ip,需要获得目标机器的 mac 地址。(发送一个广播消息,这个 ip 是谁的,请来认领。认
领 ip 的机器会发送一个 mac 地址的响应)
有了这个目标 MAC 地址,数据包在链路上广播,MAC 的网卡才能发现,这个包是给它的。
MAC 的网卡把包收进来,然后打开 IP 包,发现 IP 地址也是自己的,再打开 TCP 包,发
现端口是自己,也就是 80 端口,而这个时候这台机器上有一个 nginx 是监听 80 端口。
于是将请求提交给 nginx,nginx 返回一个网页。然后将网页需要发回请求的机器。然后层层封装,最后到 MAC 层。因为来的时候有源 MAC 地址,返回的时候,源 MAC 就变成了目标 MAC,再返给请求的机器。

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

接收端收到数据包以后的处理过程

当目的主机收到一个以太网数据帧时,数据就开始从协议栈中由底向上升,同时去掉各层协
议加上的报文首部。每层协议都要去检查报文首部中的协议标识,以确定接收数据的上层协
议。


为什么有了 MAC 层还要走 IP 层呢?
之前我们提到,mac 地址是唯一的,那理论上,在任何两个设备之间,我应该都可以通过
mac 地址发送数据,为什么还需要 ip 地址?
mac 地址就好像个人的身份证号,人的身份证号和人户口所在的城市,出生的日期有关,
但是和人所在的位置没有关系,人是会移动的,知道一个人的身份证号,并不能找到它这个
人,mac 地址类似,它是和设备的生产者,批次,日期之类的关联起来,知道一个设备的
mac,并不能在网络中将数据发送给它,除非它和发送方的在同一个网络内。
所以要实现机器之间的通信,我们还需要有 ip 地址的概念,ip 地址表达的是当前机器在网
络中的位置,类似于城市名+道路号+门牌号的概念。通过 ip 层的寻址,我们能知道按何种
路径在全世界任意两台 Internet 上的的机器间传输数据。

TCP/IP 的分层管理

TCP/IP 协议按照层次分为 4 层:应用层、传输层、网络层、数据链路层。对于分层这个概念,
大家一定不陌生,比如我们的分布式架构体系中会分为业务层、服务层、基础支撑层。比如
docker,也是基于分层来实现。所以我们会发现,复杂的程序都需要分层,这个是软件设计的要求,每一层专注于当前领域的事情。如果某些地方需要修改,我们只需要把变动的层替换掉就行,一方面改动影响较少,另一方面整个架构的灵活性也更高。 最后,在分层之后,整个架构的设计也变得相对简单了。

分层负载

了解了分层的概念以后,我们再去理解所谓的二层负载、三层负载、四层负载、七层负载就
容易多了。
一次 http 请求过来,一定会从应用层到传输层,完成整个交互。只要是在网络上跑的数据包,都是完整的。可以有下层没上层,绝对不可能有上层没下层。

二层负载

二层负载是针对 MAC,负载均衡服务器对外依然提供一个 VIP(虚 IP),集群中不同的机器采用相同 IP 地址,但是机器的 MAC 地址不一样。当负载均衡服务器接受到请求之后,通过改写报文的目标 MAC 地址的方式将请求转发到目标机器实现负载均衡

二层负载均衡会通过一个虚拟 MAC 地址接收请求,然后再分配到真实的 MAC 地址

三层负载均衡

三层负载是针对 IP,和二层负载均衡类似,负载均衡服务器对外依然提供一个 VIP(虚 IP),但是集群中不同的机器采用不同的 IP 地址。当负载均衡服务器接受到请求之后,根据不同的负载均衡算法,通过 IP 将请求转发至不同的真实服务器

三层负载均衡会通过一个虚拟 IP 地址接收请求,然后再分配到真实的 IP 地址

四层负载均衡

四层负载均衡工作在 OSI 模型的传输层,由于在传输层,只有 TCP/UDP 协议,这两种协议
中除了包含源 IP、目标 IP 以外,还包含源端口号及目的端口号。四层负载均衡服务器在接受到客户端请求后,以后通过修改数据包的地址信息(IP+端口号)将流量转发到应用服务器。

四层通过虚拟 IP + 端口接收请求,然后再分配到真实的服务器

七层负载均衡

七层负载均衡工作在 OSI 模型的应用层,应用层协议较多,常用 http、radius、dns 等。七层负载就可以基于这些协议来负载。这些应用层协议中会包含很多有意义的内容。比如同一个Web 服务器的负载均衡,除了根据 IP 加端口进行负载外,还可根据七层的 URL、浏览器类别来决定是否要进行负载均衡

七层通过虚拟的 URL 或主机名接收请求,然后再分配到真实的服务器。

TCP/IP 协议的深入分析

通过前面一个案例的分析,基本清楚了网络的通信流程,在 http 协议中,底层用到了 tcp 的
通信协议,我们接下来给大家简单介绍一下 tcp 的通信协议原理。
我们如果需要深入学习网络协议,就要先把一些基本的协议的作用和工作过程搞清楚,网络
设备还没智能到人脑的程度,它是由人类创造出来的,它的工作过程肯定是符合人类的交流
习惯并按照人类的交流习惯来设计的。所以要以人类的思维方式去理解这些协议。
例如,你给别人打电话,不可能电话一接通你就啪啦啪啦地说一大通,万一对方接通电话后
因为有事还没来得及倾听呢?这不太符合正常人类的交流习惯。一般是电话接通后,双方会
有个交互的过程,会先说一声“你好”,然后对方也回复一声“你好”,双方通过各自一句“你好”
明确对方的注意力都放在了电话沟通上,然后你们双方就可以开始交流了,这才是正常的人
类交流方式,这个过程体现在计算机网络里就是网络协议!我们通过 TCP 协议在两台电脑建立网络连接之前要先发数据包进行沟通,沟通后再建立连接,然后才是信息的传输。而 UDP协议就类似于我们的校园广播,广播内容已经通过广播站播放出去了,你能不能听到,那就与广播站无关了,正常情况下,不可能你说没注意听然后再让广播站再播放一次广播内容。
基于这些思路,我们先去了解下 TCP 里面关注比较多的握手协议

TCP connection


客户端与服务器之间数据的发送和返回的过程当中需要创建一个叫TCP connection的东西;

由于TCP不存在连接的概念,只存在请求和响应,请求和响应都是数据包,它们之间都是经过由TCP创建的一个从客户端发起,服务器接收的类似连接的通道,这个连接可以一直保持,http请求是在这个连接的基础上发送的;

在一个TCP连接上是可以发送多个http请求的,不同的版本这个模式不一样。

在HTTP/1.0中这个TCP连接是在http请求创建的时候同步创建的,http请求发送到服务器端,服务器端响应了之后,这个TCP连接就关闭了;

HTTP/1.1中可以以某种方式声明这个连接一直保持,一个请求传输完之后,另一个请求可以接着传输。这样的好处是:在创建一个TCP连接的过程中需要“三次握手”的消耗,“三次握手”代表有三次网络传输。

如果TCP连接保持,第二个请求发送就没有这“三次握手”的消耗。HTTP/2中同一个TCP连接里还可以并发地传输http请求。

TCP报文格式简介


其中比较重要的字段有:

(1)序号(sequence number):Seq序号,占32位,用来标识从TCP源端向目的端发送的字节流,发起方发送数据时对此进行标记。

(2)确认号(acknowledgement number):Ack序号,占32位,只有ACK标志位为1时,确认序号字段才有效,Ack=Seq+1。

(3)标志位(Flags):共6个,即URG、ACK、PSH、RST、SYN、FIN等。具体含义如下:
URG:紧急指针(urgent pointer)有效。
ACK:确认序号有效。
PSH:接收方应该尽快将这个报文交给应用层。
RST:重置连接。
SYN:发起一个新连接。
FIN:释放一个连接。
需要注意的是:
不要将确认序号Ack与标志位中的ACK搞混了。确认方Ack=发起方Seq+1,两端配对。

TCP的三次握手(Three-Way Handshake)

所以 TCP 消息的可靠性首先来自于有效的连接建立,所以在数据进行传输前,需要通过三次
握手建立一个连接,所谓的三次握手,就是在建立 TCP 链接时,需要客户端和服务端总共发
送 3 个包来确认连接的建立,在 socket 编程中,这个过程由客户端执行 connect 来触发

握手之前主动打开连接的客户端结束CLOSED阶段,被动打开的服务器端也结束CLOSED阶段,并进入LISTEN阶段。随后开始“三次握手”:
(1)首先客户端向服务器端发送一段TCP报文,其中:

  • 标记位为SYN,表示“请求建立新连接”;
  • 序号为Seq=X(X一般为1);
  • 随后客户端进入SYN-SENT阶段。

(2)服务器端接收到来自客户端的TCP报文之后,结束LISTEN阶段。并返回一段TCP报文,其中:

  • 标志位为SYN和ACK,表示“确认客户端的报文Seq序号有效,服务器能正常接收客户端发送的数据,并同意创建新连接”(即告诉客户端,服务器收到了你的数据);
  • 序号为Seq=y;
  • 确认号为Ack=x+1,表示收到客户端的序号Seq并将其值加1作为自己确认号Ack的值;随后服务器端进入SYN-RCVD阶段。

(3)客户端接收到来自服务器端的确认收到数据的TCP报文之后,明确了从客户端到服务器的数据传输是正常的,结束SYN-SENT阶段。并返回最后一段TCP报文。其中:

  • 标志位为ACK,表示“确认收到服务器端同意连接的信号”(即告诉服务器,我知道你收到我发的数据了);
  • 序号为Seq=x+1,表示收到服务器端的确认号Ack,并将其值作为自己的序号值;
  • 确认号为Ack=y+1,表示收到服务器端序号Seq,并将其值加1作为自己的确认号Ack的值;
  • 随后客户端进入ESTABLISHED阶段。

服务器收到来自客户端的“确认收到服务器数据”的TCP报文之后,明确了从服务器到客户端的数据传输是正常的。结束SYN-SENT阶段,进入ESTABLISHED阶段。

在客户端与服务器端传输的TCP报文中,双方的确认号Ack和序号Seq的值,都是在彼此Ack和Seq值的基础上进行计算的,这样做保证了TCP报文传输的连贯性。一旦出现某一方发出的TCP报文丢失,便无法继续"握手",以此确保了"三次握手"的顺利完成。
此后客户端和服务器端进行正常的数据传输。这就是“三次握手”的过程。

那 TCP 在三次握手的时候,IP 层和 MAC 层在做什么呢?当然是 TCP 发送每一个消息, 都会带着 IP 层和 MAC 层了。因为,TCP 每发送一个消息,IP 层和 MAC 层的所有机制都 要运行一遍。而你只看到 TCP 三次握手了,其实,IP 层和 MAC 层为此也忙活好久了。

三次握手”的动态过程

三次握手”的通俗理解


举个栗子:把客户端比作男孩,服务器比作女孩。用他们的交往来说明“三次握手”过程:
(1)男孩喜欢女孩,于是写了一封信告诉女孩:我爱你,请和我交往吧!;写完信之后,男孩焦急地等待,因为不知道信能否顺利传达给女孩。

(2)女孩收到男孩的情书后,心花怒放,原来我们是两情相悦呀!于是给男孩写了一封回信:我收到你的情书了,也明白了你的心意,其实,我也喜欢你!我愿意和你交往!;

写完信之后,女孩也焦急地等待,因为不知道回信能否能顺利传达给男孩。

(3)男孩收到回信之后很开心,因为发出的情书女孩收到了,并且从回信中知道了女孩喜欢自己,并且愿意和自己交往。然后男孩又写了一封信告诉女孩:你的心意和信我都收到了,谢谢你,还有我爱你!

女孩收到男孩的回信之后,也很开心,因为发出的情书男孩收到了。由此男孩女孩双方都知道了彼此的心意,之后就快乐地交流起来了~~

这就是通俗版的“三次握手”,期间一共往来了三封信也就是“三次握手”,以此确认两个方向上的数据传输通道是否正常。

为什么要进行第三次握手?

为了防止服务器端开启一些无用的连接增加服务器开销以及防止已失效的连接请求报文段突然又传送到了服务端,因而产生错误。

由于网络传输是有延时的(要通过网络光纤和各种中间代理服务器),在传输的过程中,比如客户端发起了SYN=1创建连接的请求(第一次握手)。

如果服务器端就直接创建了这个连接并返回包含SYN、ACK和Seq等内容的数据包给客户端,这个数据包因为网络传输的原因丢失了,丢失之后客户端就一直没有接收到服务器返回的数据包。

客户端可能设置了一个超时时间,时间到了就关闭了连接创建的请求。再重新发出创建连接的请求,而服务器端是不知道的,如果没有第三次握手告诉服务器端客户端收的到服务器端传输的数据的话,

服务器端是不知道客户端有没有接收到服务器端返回的信息的。

这个过程可理解为:

这样没有给服务器端一个创建还是关闭连接端口的请求,服务器端的端口就一直开着,等到客户端因超时重新发出请求时,服务器就会重新开启一个端口连接。那么服务器端上没有接收到请求数据的上一个端口就一直开着,长此以往,这样的端口多了,就会造成服务器端开销的严重浪费。

还有一种情况是已经失效的客户端发出的请求信息,由于某种原因传输到了服务器端,服务器端以为是客户端发出的有效请求,接收后产生错误。

所以我们需要“第三次握手”来确认这个过程,让客户端和服务器端能够及时地察觉到因为网络等一些问题导致的连接创建失败,这样服务器端的端口就可以关闭了不用一直等待。

也可以这样理解:“第三次握手”是客户端向服务器端发送数据,这个数据就是要告诉服务器,客户端有没有收到服务器“第二次握手”时传过去的数据。若发送的这个数据是“收到了”的信息,接收后服务器就正常建立TCP连接,否则建立TCP连接失败,服务器关闭连接端口。由此减少服务器开销和接收到失效请求发生的错误。

SYN 攻击

在三次握手过程中,Server 发送 SYN-ACK 之后,收到 Client 的 ACK 之前的 TCP 连接称为半连接(half-open connect),此时 Server 处于 SYN_RCVD 状态,当收到 ACK 后,Server转入 ESTABLISHED 状态。SYN 攻击就是 Client 在短时间内伪造大量不存在的 IP 地址,并向Server 不断地发送 SYN 包,Server 回复确认包,并等待 Client 的确认,由于源地址是不存在的,因此,Server 需要不断重发直至超时,这些伪造的 SYN 包将产时间占用未连接队列,导致正常的 SYN 请求因为队列满而被丢弃,从而引起网络堵塞甚至系统瘫痪。SYN 攻击时一种典型的 DDOS 攻击,检测 SYN 攻击的方式非常简单,即当 Server 上有大量半连接状态且源 IP 地址是随机的,则可以断定遭到 SYN 攻击了

TCP的四次挥手(Four-Way Wavehand)

  • 单工:数据传输只支持数据在一个方向上传输
  • 半双工:数据传输允许数据在两个方向上传输,但是在某一时刻,只允许在一个方向上传输,实际上有点像切换方向的单工通信
  • 全双工:数据通信允许数据同时在两个方向上传输,因此全双工是两个单工通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力

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


(1)首先客户端想要释放连接,向服务器端发送一段TCP报文,其中:

  • 标记位为FIN,表示“请求释放连接“;
  • 序号为Seq=U;
  • 随后客户端进入FIN-WAIT-1阶段,即半关闭阶段。并且停止在客户端到服务器端方向上发送数据,但是客户端仍然能接收从服务器端传输过来的数据。

注意:这里不发送的是正常连接时传输的数据(非确认报文),而不是一切数据,所以客户端仍然能发送ACK确认报文。

(2)服务器端接收到从客户端发出的TCP报文之后,确认了客户端想要释放连接,随后服务器端结束ESTABLISHED阶段,进入CLOSE-WAIT阶段(半关闭状态)并返回一段TCP报文,其中:

  • 标记位为ACK,表示“接收到客户端发送的释放连接的请求”;
  • 序号为Seq=V;
  • 确认号为Ack=U+1,表示是在收到客户端报文的基础上,将其序号Seq值加1作为本段报文确认号Ack的值;
  • 随后服务器端开始准备释放服务器端到客户端方向上的连接。

客户端收到从服务器端发出的TCP报文之后,确认了服务器收到了客户端发出的释放连接请求,随后客户端结束FIN-WAIT-1阶段,进入FIN-WAIT-2阶段
前"两次挥手"既让服务器端知道了客户端想要释放连接,也让客户端知道了服务器端了解了自己想要释放连接的请求。于是,可以确认关闭客户端到服务器端方向上的连接了

(3)服务器端自从发出ACK确认报文之后,经过CLOSED-WAIT阶段,做好了释放服务器端到客户端方向上的连接准备,再次向客户端发出一段TCP报文,其中:

  • 标记位为FIN,ACK,表示“已经准备好释放连接了”。注意:这里的ACK并不是确认收到服务器端报文的确认报文。
  • 序号为Seq=W;
  • 确认号为Ack=U+1;表示是在收到客户端报文的基础上,将其序号Seq值加1作为本段报文确认号Ack的值。

随后服务器端结束CLOSE-WAIT阶段,进入LAST-ACK阶段。并且停止在服务器端到客户端的方向上发送数据,但是服务器端仍然能够接收从客户端传输过来的数据。

4)客户端收到从服务器端发出的TCP报文,确认了服务器端已做好释放连接的准备,结束FIN-WAIT-2阶段,进入TIME-WAIT阶段,并向服务器端发送一段报文,其中:

  • 标记位为ACK,表示“接收到服务器准备好释放连接的信号”。
  • 序号为Seq=U+1;表示是在收到了服务器端报文的基础上,将其确认号Ack值作为本段报文序号的值。
  • 确认号为Ack=W+1;表示是在收到了服务器端报文的基础上,将其序号Seq值作为本段报文确认号的值。
  • 随后客户端开始在TIME-WAIT阶段等待2MSL

为什么要客户端要等待2MSL呢?
两次挥手”既让客户端知道了服务器端准备好释放连接了,也让服务器端知道了客户端了解了自己准备好释放连接了。于是,可以确认关闭服务器端到客户端方向上的连接了,由此完成“四次挥手”。

服务器端收到从客户端发出的TCP报文之后结束LAST-ACK阶段,进入CLOSED阶段。由此正式确认关闭服务器端到客户端方向上的连接。
客户端等待完2MSL之后,结束TIME-WAIT阶段,进入CLOSED阶段,由此完成“四次挥手”。

与“三次挥手”一样,在客户端与服务器端传输的TCP报文中,双方的确认号Ack和序号Seq的值,都是在彼此Ack和Seq值的基础上进行计算的,这样做保证了TCP报文传输的连贯性,一旦出现某一方发出的TCP报文丢失,便无法继续"挥手",以此确保了"四次挥手"的顺利完成。

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

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

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

使用协议进行通信

tcp 连接建立以后,就可以基于这个连接通道来发送和接受消息了,TCP、UDP 都是在基于 Socket 概念上为某类应用场景而扩展出的传输协议,那么什么是 socket 呢?socket 是一种 抽象层,应用程序通过它来发送和接收数据,就像应用程序打开一个文件句柄,把数据读写 到磁盘上一样。使用socket可以把应用程序添加到网络中,并与处于同一个网络中的其他应 用程序进行通信。不同类型的 Socket 与不同类型的底层协议簇有关联。主要的 socket 类型 为流套接字(stream socket)和数据报文套接字(datagram socket)。 stream socket把TCP 作为端对端协议(底层使用 IP 协议),提供一个可信赖的字节流服务。数据报文套接字 (datagram socket)使用UDP协议(底层同样使用IP协议)提供了一种“尽力而为”的数据 报文服务。

接下来,我们使用Java提供的API来展示TCP协议的客户端和服务端通信的案例和UDP 协议的客户端和服务端通信的案例,然后更进一步了解底层的原理

基于 TCP 协议实现通信

实现一个简单的从客户端发送一个消息到服务端的功能

service

public class ServerSocketDemo {


    public static void main(String[] args) {
        ServerSocket serverSocket=null;
        BufferedReader in=null;
        try {
            /*TCP 的服务端要先监听一个端口,一般是先调用
            bind 函数,给这个 Socket 赋予一个 IP 地址和端
            口。为什么需要端口呢?要知道,你写的是一个应用
            程序,当一个网络包来的时候,内核要通过 TCP 头里
            面的这个端口,来找到你这个应用程序,把包给你。
            为什么要 IP 地址呢?有时候,一台机器会有多个网
            卡,也就会有多个 IP 地址,你可以选择监听所有的
            网卡,也可以选择监听一个网卡,这样,只有发给这
            个网卡的包,才会给你。*/
            serverSocket=new ServerSocket(8080);
            // 阻塞等待客户端连接
            /*接下来,服务端调用 accept 函数,拿出一个已经完
            成的连 接进行处理。如果还没有完成,就要等着。*/
            Socket socket=serverSocket.accept();
            /*连接建立成功之后,双方开始通过 read 和 write
            函数来读写数据,就像往一个文件流里面写东西一
            样。*/
            in=new BufferedReader(new InputStreamReader(socket.getInputStream ()));
            System.out.println(in.readLine());
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if(in!=null){
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(serverSocket!=null){}
            try {
                serverSocket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

client

public class ClientSocketDemo {

    public static void main(String[] args) {
        Socket socket=null;
        PrintWriter out=null;
        try {
            socket=new Socket("127.0.0.1",8080);
            out=new PrintWriter(socket.getOutputStream(),true);
            out.println("Hello, socket");
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if(out!=null){
                out.close();
            }
            if(socket!=null){
                try {
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

基于 TCP 实现双向通信对话功能

TCP 是一个全双工协议,数据通信允许数据同时在两个方向上传输,因此全双工是两个单工 通信方式的结合,它要求发送设备和接收设备都有独立的接收和发送能力。 我们来做一个简单的实现
Server 端

public class ServerSocketDemo {
    public static void main(String[] args) {
        try {
            //创建一个ServerSocket在端口8080监听客户请求
            ServerSocket serverSocket = new ServerSocket(8080);
            //阻塞
            Socket socket = serverSocket.accept();
            String line;
            //由Socket对象得到输入流,并构造响应的BufferedReader对象
            BufferedReader in=new BufferedReader(new InputStreamReader
                    (socket.getInputStream()));
            //由Socket对象得到输出流,并构造PrintWriter对象
            PrintWriter out=new PrintWriter(socket.getOutputStream());
            //由系统标准输入设备构造BufferedReader对象
            BufferedReader sin=new BufferedReader(new InputStreamReader(System.in));
            //打印从客户端读入的字符串
            System.out.println("Client:"+in.readLine());
            line=sin.readLine(); //获得控制台输入的一行数据
            //如果是bye,则停止循环
            while(!line.equals("bye")){
                //向客户端输出该字符串
                out.println(line); //写回到客户端
                //刷新输出流,使client马上收到该字符串
                out.flush();
                //在系统标准输出上打印读入的字符串
                System.out.println("Server:"+line);
                //在Client读入一字符串,并打印到标准输出上
                System.out.println("Client:"+in.readLine()); //读取客户端传过来的数据
                line=sin.readLine(); //重新读取控制台的数据
            }
            out.close();
            in.close();
            socket.close();
            serverSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }


    }
}

Client

public class ClientSocketDemo {
    public static void main(String[] args){
        try {
            //找到目标的ip和端口,发出客户请求
            Socket socket=new Socket("localhost",8080);
            //由Socket对象得到输入流,并构造响应的BufferedReader对象
            BufferedReader in=new BufferedReader(new InputStreamReader
                    (socket.getInputStream()));
            //由Socket对象得到输出流,并构造PrintWriter对象
            PrintWriter out=new PrintWriter(socket.getOutputStream(),true);
            //由系统标准输入设备构造BufferedReader对象
            BufferedReader sin=new BufferedReader(new InputStreamReader(System.in));
            String readline;
            readline=sin.readLine(); //获得控制台输入的一行数据
            //如果是bye,则停止循环
            while(!readline.equals("bye")){
                out.println(readline);
                out.flush();
                System.out.println("Client:"+readline);
                System.out.println("Server:"+in.readLine());
                readline=sin.readLine(); //重新获取
            }
            out.close();
            in.close();
            socket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }


    }
}

总结

我们通过一个图来简单描述一下socket链接建立以及通信的模型

理解 TCP 的通信原理及 IO 阻塞

通过上面这个简单的案例,基本清楚了在 Java 应用程序中如何使用 socket 套接字来建立一 个基于tcp协议的通信流程。接下来,我们在来了解一下tcp的底层通信过程是什么样的

了解 TCP 协议的通信过程

首先,对于 TCP 通信来说,每个 TCP Socket 的内核中都有一个发送缓冲区和一个接收缓冲区,TCP的全双工的工作模式及TCP的滑动窗口就是依赖于这两个独立的Buffer和该Buffer 的填充状态。

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

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

前面我们提到,Socket的接收缓冲区被TCP用来缓存网络上收到的数据,一直保存到应用进程读走为止。如果应用进程一直没有读取,那么Buffer满了以后,出现的情况是:通知对端 TCP协议中的窗口关闭,保证TCP接收缓冲区不会移除,保证了TCP是可靠传输的。如果对 方无视窗口大小发出了超过窗口大小的数据,那么接收方会把这些数据丢弃。

滑动窗口协议

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

发送窗口

就是发送端允许连续发送的幀的序号表。 发送端可以不等待应答而连续发送的最大幀数称为发送窗口的尺寸。

接收窗口

接收方允许接收的幀的序号表,凡落在 接收窗口内的幀,接收方都必须处理,落在接收窗口 外的幀被丢弃。 接收方每次允许接收的幀数称为接收窗口的尺寸。

理解阻塞到底是什么回事

了解了基本通信原理以后,我们再来思考一个问题,在前面的代码演示中,我们通过 socket.accept 去接收一个客户端请求,accept 是一个阻塞的方法,意味着 TCP 服务器一次 只能处理一个客户端请求,当一个客户端向一个已经被其他客户端占用的服务器发送连接请 求时,虽然在连接建立后可以向服务端发送数据,但是在服务端处理完之前的请求之前,却 不会对新的客户端做出响应,这种类型的服务器称为“迭代服务器”。迭代服务器是按照顺序处 理客户端请求,也就是服务端必须要处理完前一个请求才能对下一个客户端的请求进行响应。 但是在实际应用中,我们不能接收这样的处理方式。所以我们需要一种方法可以独立处理每 一个连接,并且他们之间不会相互干扰。而Java提供的多线程技术刚好满足这个需求,这个 机制使得服务器能够方便处理多个客户端的请求。

一个客户端对应一个线程

为每个客户端创建一个线程实际上会存在一些弊端,因为创建一个线程需要占用CPU的资 源和内存资源。另外,随着线程数增加,系统资源将会成为瓶颈最终达到一个不可控的状 态,所以我们还可以通过线程池来实现多个客户端请求的功能,因为线程池是可控的。

非阻塞模型

上面这种模型虽然优化了IO的处理方式,但是,不管是线程池还是单个线程,线程本身的处 理个数是有限制的,对于操作系统来说,如果线程数太多会造成CPU上下文切换的开销。因 此这种方式不能解决根本问题
所以在Java1.4以后,引入了NIO(New IO)的功能

阻塞 IO

前面其实已经简单讲过了阻塞 IO 的原理,我想在这里重申一下什么是阻塞 IO 呢? 就是当 客户端的数据从网卡缓冲区复制到内核缓冲区之前,服务端会一直阻塞。以 socket接口为例, 进程空间中调用recvfrom,进程从调用recvfrom开始到它返回的整段时间内都是被阻塞的, 因此被成为阻塞IO模型

非阻塞 IO

那大家思考一个问题,如果我们希望这台服务器能够处理更多的连接,怎么去优化呢? 我们第一时间想到的应该是如何保证这个阻塞变成非阻塞吧。 所以就引入了非阻塞IO模型, 非阻塞 IO 模型的原理很简单,就是进程空间调用 recvfrom,如果这个时候内核缓冲区没有 数据的话,就直接返回一个 EWOULDBLOCK 错误,然后应用程序通过不断轮询来检查这个 状态状态,看内核是不是有数据过来。

I/O 复用模型

我们前面讲的非阻塞仍然需要进程不断的轮询重试。能不能实现当数据可读了以后给程序一 个通知呢?所以这里引入了一个IO多路复用模型,I/O多路复用的本质是通过一种机制(系 统内核缓冲I/O数据),让单个进程可以监视多个文件描述符,一旦某个描述符就绪(一般是 读就绪或写就绪),能够通知程序进行相应的读写操作

【 什么是 fd :在 linux 中,内核把所有的外部设备都当成是一个文件来操作,对一个文件的读 写会调用内核提供的系统命令,返回一个 fd ( 文件描述符 ) 。 而对于一个 socket 的读写也会有 相应的文件描述符,成为 socketfd 】

常见的IO多路复用方式有【select、poll、epoll】,都是Linux API提供的IO复用方式,那么
接下来重点讲一下select、和epoll这两个模型

select:进程可以通过把一个或者多个 fd传递给 select系统调用,进程会阻塞在 select操作 上,这样select可以帮我们检测多个fd是否处于就绪状态。 这个模式有二个缺点

  1. 由于他能够同时监听多个文件描述符,假如说有1000个,这个时候如果其中一个fd 处于 就绪状态了,那么当前进程需要线性轮询所有的 fd,也就是监听的 fd 越多,性能开销越 大。
  2. 同时,select在单个进程中能打开的fd是有限制的,默认是1024,对于那些需要支持单机 上万的TCP连接来说确实有点少

epoll:linux还提供了epoll的系统调用,epoll是基于事件驱动方式来代替顺序扫描,因此性 能相对来说更高,主要原理是,当被监听的 fd 中,有 fd 就绪时,会告知当前进程具体哪一 个fd就绪,那么当前进程只需要去从指定的fd上读取数据即可 另外,epoll所能支持的fd上线是操作系统的最大文件句柄,这个数字要远远大于1024

【由于 epoll 能够通过事件告知应用进程哪个 fd 是可读的,所以我们也称这种 IO 为异步非 阻塞 IO ,当然它是伪异步的 ,因为它还需要去把数据从内核同步复制到用户空间中 ,真正的 异步非阻塞,应该是数据已经完全准备好了,我只需要从用户空间读就行 】

多路复用的好处

I/O 多路复用可以通过把多个 I/O 的阻塞复用到同一个 select 的阻塞上,从而使得系统在单 线程的情况下可以同时处理多个客户端请求。它的最大优势是系统开销小,并且不需要创建 新的进程或者线程,降低了系统的资源开销

一台机器理论能支持的连接数

首先,在确定最大连接数之前,大家先跟我来先了解一下系统如何标识一个 tcp 连接。系统 用一个四元组来唯一标识一个 TCP 连接:(source_ip, source_port, destination_ip, destination_port)。即(源IP,源端口,目的IP,目的端口)四个元素的组合。只要四个元素的 组合中有一个元素不一样,那就可以区别不同的连接,

比如: 你的 IP 地址是 1 1 .1.2.3, 在 8080 端口监听
那么当一个来自 22.4.5.6 ,端口为 5555 的连接到达后,那么建立的这条连接的四元组为 : (11.1.2.3, 8080, 22.4.5.6, 5555)
这时,假设上面的那个客户( 22.4.5.6 )发来第二条连接请求,端口为 6666 ,那么,新连接 的四元组为 (11.1.2.3, 8080, 22.4.5.6, 5555)
那么,你主机的 8080 端口建立了两条连接

通常来说,服务端是固定一个监听端口,比如8080,等待客户端的连接请求。在不考虑地址 重用的情况下,及时server端有多个ip,但是本地监听的端口是独立的。所以对于tcp连接 的4元组中,如果destination_ip和destination_port不变。那么只有source_ip和source_port 是可变的,因此最大的tcp连接数应该为 客户端的ip数 乘以 客户端的端口数。在IPV4中, 不考虑ip分类等因素,最大的ip数为 2的32次方 ;客户端最大的端口数为2的16次方, 也就是65536. 也就是服务端单机最大的tcp连接数约为2的48次方。

当然,这只是一个理论值,以linux服务器为例,实际的连接数还取决于

  1. 内存大小(因为每个TCP连接都要占用一定的内存)、
  2. 文件句柄限制,每一个tcp连接都需要占一个文件描述符,一旦这个文件描述符使用完了, 新来的连接会返回一个“Can’t open so many files”的异常。如果大家知道对于操作系统最 大可以打开的文件数限制,就知道怎么去调整这个限制
    a) 可以执行【ulimit -n】得到当前一个进程最大能打开 1024 个文件,所以你要采用此默 认配置最多也就可以并发上千个TCP连接。
    b) 可以通过【vim /etc/security/limits.conf】去修改系统最大文件打开数的限制 * soft nofile 2048 * hard nofile 2048 * 表示修改所有用户限制、soft/hard表示软限制还是硬限制,2048表示修改以后的值
    c) 可以通过【cat /proc/sys/fs/file-max】查看linux系统级最大打开文件数限制,表示当 前这个服务器最多能同时打开多少个文件 当然,这块还有其他很多的优化的点,这里不是这节课的目标
  3. 带宽资源的限制

转载:https://blog.csdn.net/weixin_38087538/article/details/107907085
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场