深入 HTTP/3(一)|从 QUIC 链接的建立与关闭看协议的演进
文|曾柯(花名:毅丝 )
蚂蚁集团高级工程师
负责蚂蚁集团的接入层建设工作
主要方向为高性能安全网络协议的设计及优化
本文 10279 字 阅读 18 分钟
PART. 1 引言
作为系列文章的第一篇,引言部分就先稍微繁琐一点,让大家对这个系列文章有一些简单的认知。
先介绍下这个系列文章的诞生背景。QUIC、HTTP/3 等字眼想来对大家而言并不陌生。从个人的视角来看,大部分开发者其实都已经有了一些背景知识,比如 HTTP/3 的核心是依赖 QUIC 来实现传输层及 TLS 层的能力。而谈及其中细节之时,大家却又知之甚少,相关的文章大多只是浅尝辄止的对一些 HTTP/3 中的机制和特性做了介绍,少有深入的分析,而对于这些机制背后诞生原因,设计思路的分析,就更难得一见了。
从个人并不大量的 RFC 阅读及 draft 写作经历来看,和撰写论文文献一样,为了保证一份 RFC 的精简以及表述准确,当然也是为了编写过程的简单。在涉及到其他相关协议时,作者往往是通过直接引用的方式来进行表述。这也就意味着直接通过阅读 RFC 来学习和了解网络协议是一个曲线相对比较陡峭的过程,往往读者在阅读到一个关键部分的时候,就不得不跳转到其他文档,然后重复这个令人头痛的过程,而当读者再次回到原始文档时,可能都已经忘了之前的上下文是什么。
而 HTTP/3,涉及到 QUIC、TLS、HTTP/2、QPACK 等标准文档,而这些标准文档各自又有大量的关联文档,所以学习起来并不是一个容易的事。
当然,系列文章的立题为“深入 HTTP/3”,而不是“深入 QUIC”,其背后的原因就是 HTTP/3 并不仅仅只是 QUIC 这么一个点,其中还包含有大量现有 HTTP 协议和 QUIC 的有机结合。在系列文章的后续,也会对这一部分做大篇幅的深入分析。
一个协议的性能优秀与否,除了本身的设计之外,也离不开大量的软硬件优化,架构落地,专项设计等工程实践经验,所以本系列除了会针对 HTTP/3 本身特性进行分享之外,也会针对 HTTP/3 在蚂蚁落地的方案进行分享。
引言的最后,也是本文的正式开始。
据统计,人类在学习新的知识时,比较习惯从已有的知识去类比和推断,以产生更深刻的感性和理性认识。我想对大部分同学而言,“TCP 为什么要三次握手以及四次挥手?”这个问题,颇有点经典的不能再经典的味道,所以今天这篇文章也将从 QUIC 链接的建立流程及关闭流程入手,开始我们系列的第一篇文章。
PART. 2 链接建立
2.1 重温 TCP
“TCP 为什么要三次握手?”
在回答问题之前我们需要了解 TCP 的本质,TCP 协议被设计为一种面向连接的、可靠的、基于字节流的全双工传输层通信协议。
“可靠”意味着使用 TCP 协议传输数据时,如果 TCP 协议返回发送成功,那么数据一定已经成功的传输到了对端,为了保证数据的“可靠”传输,我们首先需要一个应答机制来确认对端已经收到了数据,而这就是我们熟悉的 ACK 机制。
“流式”则是一种使用上的抽象(即收发端不用关注底层的传输,只需将数据当作持续不断的字节流去发送和读取就好了)。“流式”的使用方式强依赖于数据的有序传输,为了这种使用上的抽象,我们需要一个机制来保证数据的有序,TCP 协议栈的设计则是给每个发送的字节标示其对应的 seq(实际应用中 seq 是一个范围,但其真实效果就是做到了每个字节都被有序标示),接收端通过检视当前收到数据的 seq,并与自身记录的对端当前 seq 进行比对,以此确认数据的顺序。
“全双工”则意味着通信的一端的收发过程都是可靠且流式的,并且收和发是两个完全独立,互不干扰的两个行为。
可以看到,TCP 的这些特性,都是以 seq 和 ACK 字段作为载体来实现的,而所有 TCP 的交互流程,都是在为了上述特性服务,当然三次握手也不例外,我们再来看 TCP 的三次握手的示意图:
为了保证通信双方都能确认对端数据的发送顺序,收发端都需要各自记录对端的当前 seq,并确认对端已经同步了自己的 seq 才可以实现,为了保证这个过程,起码需要 3 个 RTT。而实际的实现为了效率考虑,将 seq 和 ACK 放在了一个报文里,这也就形成了我们熟知的三次握手。
当然,三次握手不仅仅是同步了 seq,还可以用来验证客户端是一个正常的客户端,比如 TCP 可能会面临这些问题:
(1)有一些 TCP 的攻击请求,只发 syn 请求,但不回数据,浪费 socket 资源;
(2)已失效的连接请求报文段突然又传送到了服务端,这些数据不再会有后续的响应,如何防止这样的请求浪费资源?
而这些问题只是三次握手顺手解决的问题,不是专门为了它们设计的三次握手。
细心的你,可能已经发现了一个问题,如果我们约定好 client 和 server 的 seq 都是从 0(或者某个大家都知道的固定值)开始,是不是就可以不用同步 seq 了呢?这样似乎也就不需要三次握手那么麻烦了?可以直接开始发送数据?
当然,协议的设计者肯定也想过这个方案,至于为什么没这么实现,我们在下一章来看看 TCP 面临什么样的问题。
2.2 TCP 面临的问题
2.2.1 seq 攻击
在上一节我们提到,TCP 依赖 seq 和 ACK 来实现可靠,流式以及全双工的传输模式,而实际过程中却需要通过三次握手来同步双端的 seq,如果我们提前约定好通信双方初始 seq,其实是可以避免三次握手的,那么为什么没有这么做呢?答案是安全问题。
我们知道,TCP 的数据是没有经过任何安全保护的,无论是其 header 还是 payload,对于一个攻击者而言,他可以在世界的任何角落,伪造一个合法 TCP 报文。
一个典型的例子就是攻击者可以伪造一个 reset 报文强制关闭一条 TCP 链接,而攻击成功的关键则是 TCP 字段里的 seq 及 ACK 字段,只要报文中这两项位于接收者的滑动窗口内,该报文就是合法的,而 TCP 握手采用随机 seq 的方式(不完全随机,而是随着时间流逝而线性增长,到了 2^32 尽头再回滚)来提升攻击者猜测 seq 的难度,以增加安全性。
为此,TCP 也不得不进行三次握手来同步各自的 seq。当然,这样的方式对于 off-path 的攻击者有一定效果,对于 on-path 的攻击者是完全无效的,一个 on-path 的攻击者仍然可以随意 reset 链接,甚至伪造报文,篡改用户的数据。
所以,虽然 TCP 为了安全做出过一些努力,但由于其本质上只是一个传输协议,安全并不是其原生的考量,在当前的网络环境中,TCP 会遇到大量的安全问题。
2.2.2 不可避免的数据安全问题
相信 SSL/TLS/HTTPS 这一类的字眼大家都不陌生,整个 TLS(传输安全层)实际上解决的是 TCP 的 payload 安全问题,当然这也是最紧要的问题。
比如对一个用户而言,他可能能容忍一次转账失败,但他肯定无法容忍钱被转到攻击者手上去了。TLS 的出现,为用户提供了一种机制来保证中间人无法读取,篡改的 TCP 的 payload 数据,TLS 同时还提供了一套安全的身份认证体系,来防止攻击者冒充 Web 服务提供者。然而 TCP 的 header 这一层仍然是不在保护范围内的,对于一个 on/off-path 攻击者,仍然具备理论上随时关闭 TCP 链接的能力。
2.2.3 为了安全引发的效率问题
在当前的网络环境中,安全通信已经成为了最基本的要求。熟悉 TLS 的同学都知道,TLS 也是需要握手和交互的,虽然 TLS 协议经过多年的实践和演进,已经设计并落地了大量的优化手段(如 TLS1.3、会话复用、PSK、0-RTT 等技术),但由于 TLS 和 TCP 的分层设计,一个安全数据通道的建立实际上仍是一个相对繁琐的流程。以一次基于 TLS1.3 协议的数据安全通道新建流程为例,其详细交互如下图:
可以看到,在一个 client 正式开始发送应用层数据之前,需要 3 个 RTT 的交互,这算是一个非常大的开销。而从流程上来看,TCP 握手和 TLS 的握手似乎比较相似,有融合在一起的可能。的确有相关的文献探讨过在 SYN 报文里融合 ClientHello 的可行性,不过由于以下原因,这部分的探索也慢慢不了了之。
- TLS 本身也是基于有序传输设计的协议,融合在 TCP 中需要做大量的重新设计;
- 出于安全的考虑,TCP 的 SYN 报文被设计为不能携带数据,如果要携带 clienthello,则需要对协议栈做大量改动,而由于 TCP 是一个内核协议栈,改动和迭代是一个痛苦且难以落地的过程;
- 新的协议难以和传统 TCP 兼容,大面积使用的可能性也很低。
2.2.4 TCP 的设计问题
出于 TCP 设计的历史背景,当时的网络情况并没有现在这么复杂,整个网络的瓶颈在于带宽,所以整个 TCP 的字段设计非常精简,然而造成的效果就是将控制通道和数据通道被耦合的设计在了一起,在某些场景下就会形成问题。
比如:
seq 的二义性问题:设想这样的一个场景,发送端发送了一个 TCP 报文,由于通信的中间设备发生了阻塞,导致该报文被延迟转发了,发送端迟迟未收到 ACK,便重新发送了一个 TCP 报文,在新的 TCP 报文达到接收端时,被延迟转发的报文也达到了接收端,接收端只会响应一个 ACK。而客户端收到 ACK 时,并不清楚这个 ACK 是对延迟转发的报文的 ACK,还是新的报文的 ACK,带来的影响也就是 RTT 的估计会不准确,从而影响拥塞控制算法的行为,降低网络效率。
难用的 TCP keepalive:比如 TCP 连接中的另一方因为停电突然断网,我们并不知道连接断开,此时发送数据失败会进行重传,由于重传包的优先级要高于 keepalive 的数据包,因此 keepalive 的数据包无法发送出去。只有在长时间的重传失败之后我们才能判断此连接断开了。
队头阻塞问题:严格来说这并不算 TCP 自身的问题,因为 TCP 本身是一个面向链接的协议,它保证了一个链接上的数据可靠传输,也算完成了任务。然而随着互联网的普及,人们利用网络传输的数据越来越多,如果将所有数据都放在一个 TCP 链接上传输,其中某一个数据发生丢包,后面的数据的传输都会被 block 住,严重影响效率。当然,使用多个 TCP 链接传输数据是一种解决方案,但多个链接又会带来新的开销问题及链接管理问题。
了解了 TCP 的这些问题,我们就能从 QUIC 的一系列复杂的机制中抽丝剥茧,看清 QUIC 本身设计的源头思路。
2.3 QUIC 的建联设计
和 TCP 一样,QUIC 的首要目标也是提供一个可靠、有序的流式传输协议。不仅如此,QUIC 还要保证原生的数据安全以及传输的高效。
可以说,QUIC 就是在以一种更简洁高效的机制去对标 TCP+TLS。当然,和 TCP+TLS 一样,QUIC 建联流程的本质都是在为上述特性服务,由于 QUIC 是基于 UDP 重新设计的协议,便也就没那么多的历史包袱,我们先来整理下我们对这个新的协议的诉求:
整理好需求之后,我们再来看看 QUIC 实现的效果。
先来看一个 QUIC 链接的建立流程,一次 QUIC 链接建立的粗略示意图如下:
可以看到,QUIC 相比于 TCP+TLS,只需要 1.5 个 RTT 就能完成建联,大大提升了效率。熟悉 TLS 的同学可能会发现 QUIC 的建联流程似乎跟 TLS 握手没有太大区别,TLS 本身又是一个强依赖于数据有序可靠传输的协议,然而 QUIC 又依赖 TLS 去达成有序且可靠的能力,这似乎成为了一个鸡和蛋的问题,那么 QUIC 是如何解决这个问题的呢?
我们需要更深一步去看看 QUIC 建联的流程,粗略示意图仅仅只能帮我们粗略感受下 QUIC 相比于 TCP+TLS 流程的高效,我们来进一步看看更精细化的 QUIC 建联流程:
这里的图显得有些繁琐,抛去 TLS 握手的细节(关于 QUIC 的 TLS 设计,我们会在系列文章的后续专门用一篇文章讲解),整个流程实际上还是和 TCP 一样是一个请求-响应的模式,然而相比于 TCP+TLS,我们还看到了一些不一样的地方:
1.图中多了"init packet"、"handshake packet"、"short-header packet"的概念;
2.图中多了 pkt_number的概念以及stream+offset的概念;
3.pkt_number 的下标变化似乎有些奇怪。
而这些不同机制就是 QUIC 实现相比于 TCP 来说更高效的点,让我们来逐一分析。
2.3.1 pkt_number 的设计
pkt_number 从流程图看起来,和 TCP 的 seq 字段比较类似,然而实际上还是有不少差别,可以说,pkt_number 的设计就是为了解决前面提到的 TCP 的问题的,我们来看看 pkt_number 的设计:
- 从 0 开始的下标
前面我们提到过,如果 TCP 的 seq 是一个从 0 开始的字段,那么其实不需要握手,就可以开始数据的有序发送,所以解决 TLS 和有序可靠传输这个鸡和蛋问题的方案非常简单。即 pkt_number 从 0 开始计数,便可直接保证 TLS 数据的有序。
- 加密 pkt_number 以保障安全
当然 pkt_number 从 0 开始技术便也就遇上了和 TCP一样的安全问题,解决方案也很简单,就是用为 pkt_number 加密,pkt_number 加密后,中间人便无法获取到加密的 pkt_number 的 key,便也无法获取到真实的 pkt_number,也就无法通过观测 pkt_number 来预测后续的数据发送。而这里又引申出了另一个问题,TLS 需要握手完成后才能得到中间人无法获取的 key,而 pkt_number 又在 TLS 握手之前又存在,这看起来又是一个鸡和蛋的问题,至于其解决方案,这里先卖一个关子,留到后面 QUIC-TLS 的专题文章再讲。
- 细粒度的 pkt_number space 的设计
TLS 严格来说并不是一个状态严格递进的协议,每进入一个新的状态,还是有可能会收到上一个状态的数据,这么说有点抽象。
举个例子,TLS1.3 引入了一个 0-RTT 的技术,该技术允许 client 在通过 clientHello 发起 TLS 请求时,同时发送一些应用层数。我们当然期望这个应用层数据的过程相对于握手过程来说是异步且互不干扰的,而如果他们都是用同一个 pkt_number 来标示,那么应用层数据的丢包势必会导致对握手过程的影响。所以,QUIC 针对握手的状态设计了三种不同的 pkt_number space:
(1) init;
(2) Handshake;
(3) Application Data。
分别对应:
(1) TLS 握手中的明文数据传输,即图中的 init packet;
(2) TLS 中通过 traffic secret 加密的握手数据传输,即图中 handshake packet;
(3)握手完成后的应用层数据传输及 0-RTT 数据传输,即图中的 short header packet 以及图中暂未画出的 0-RTT packet。
三种不同的 space 保证了三个流程的丢包检测互不影响。关于这部分在系列文章后续(关于 QUIC 丢包检测)还会再次深入剖析。
- 永远自增的 pkt_number
这里的永远自增指的是 pkt_number 的明文随每个 QUIC packet 发送,都会自增 1。pkt_number 的自增解决的是二义性问题,接收端收到 pkt_number 对应的 ACK 之后,可以明确的知道到底是重传的报文被 ACK 了,还是新的报文被 ACK 了,这样 RTT 的估计及丢包检测,就可以更加精细,不过仅仅只靠自增的 pkt_number 是无法保证数据的有序的,我们再来看看 QUIC 提供了什么样的机制保证数据的有序。
2.3.2 基于 stream 的有序传输
我们知道 QUIC 是基于 UDP 实现的协议,而 UDP 是不可靠的面向报文的协议,这和 TCP 基于 IP 层的实现并没有什么本质上的不同,都是:
(1) 底层只负责尽力而为的,以 packet 为单位的传输;
(2) 上层协议实现更关键的特性,如可靠,有序,安全等。
从前面我们知道 TCP 的设计导致了链接维度的队头阻塞问题,而仅仅依靠 pkt_number 也无法实现数据的有序,所以 QUIC 必须要一种更细粒度的机制来解决这些问题:
- 流既是一种抽象,也是一种单位
TCP 队头阻塞的根因来自于其一条链接只有一个发送流,流上任意一个 packet 的阻塞都会导致其他数据的失败。当然解决方案也不复杂,我们只需要在一条 QUIC 链接上抽象出多个流来即可,整体的思路如下图:
只要能保证各个 stream 的发送独立,那么我们实际上就避免了 QUIC 链接本身的队头阻塞,即一个流阻塞我们也可以在其他流上发送数据。
有了单链接多流的抽象,我们再来看 QUIC 的传输有序性设计,实际上 QUIC 在 stream 层面之上,还有更细粒度的单位,称作 frame。一个承载数据的 frame 携带有一个 offset 字段,表明自己相对于初始数据的偏移量。而初始偏移量为 0,这个思路等价于 pkt_number 等于 0,即不需要握手即可开始数据的发送。熟悉 HTTP/2、GRPC 的同学应该比较清楚这个 offset 字段的设计,和流式数据的传输是一样的。
- 一个 TLS 握手也是一个流
虽然 TLS 数据并没有一个固定的 stream 来标示,但其可以被看作为一个特定的 stream,或者说是所有其他 stream 能建立起来的初始 stream,因为它其实也是基于 offset 字段和固定的 frame 来承载的,这也就是 TLS 数据有序的保障所在。
- 基于 frame 的控制
有了 frame 这一层的抽象之后,我们当然可以做更多的事情。除了承载实际数据之外,也可以承载一些控制数据,QUIC 的设计汲取了 TCP 的经验教训,对于 keepalive、ACK、stream 的操作等行为,都设置了专门的控制 frame,这也就实现了数据和控制的完全解耦,同时保证了基于 stream 的细粒度控制,让 QUIC 成为更精细化的传输层协议。
讲到这里,其实可以看到我们从 QUIC 建联流程的探讨中,已经明确了 QUIC 设计的目标,正如文章中一直在强调的概念:
“无论是建联还是什么流程,都是在为实现 QUIC 的特性而服务”。
我们现在对 QUIC 的特性及实现已经有了一些认知,来小结一下:
此时,我们再来看看 QUIC 的建联过程中的一些设计,就不会再被其复杂的流程所困扰,更能直击它的本质,因为这些设计是在 QUIC 建联大框架确认下来之后,一些细枝末节的点,而这些点往往又会在 RFC 中占据不小的篇幅,消耗读者的心力。
举个例子,比如针对 QUIC 的放大攻击及其处理方式:放大攻击的原理为,TLS 握手过程中 clientHello 数据很少。但 server 可能响应很多数据,这就可能形成放大攻击,比如 attacker A 发起大量 clientHello,但把自己的 src ip 修改为 client B,这样 attacker A 就成倍的放大了自己的流量,以攻击 client B,其解决方案也很简单,QUIC 要求每个 client 的首包都 padding 到一定的长度,并且在服务端提供了 address validation 机制,同时在握手完成之前,限制服务端响应的数据大小。
RFC9000 中花了一章节来介绍这个机制,但其本质来说只是针对 QUIC 当前握手流程的问题的修补,而不是为了设计这个机制再去设计了握手流程。
PART. 3 链接关闭
从 TCP 看 QUIC 链接的优雅关闭
链接关闭是一个简单的诉求,可以简单梳理为两个目标:
1.用户可以主动优雅关闭链接,并能通知对方,释放资源。
2.当通信一端无法建立的链接时,有一个通知对方的机制。
然而诉求很简单,TCP 的实现却很不简单,我们来看看这个 TCP 链接关闭流程状态机的转移图:
这个流程看起来就够复杂了,牵扯出来的问题就更不少,比如经典面试题:
“为什么需要 TIME_WAIT?”
“TIME_WAIT 的连接数过多需要怎么处理?”
“tcp_tw_reuse 和 tcp_tw_recycle 等内核参数的作用和区别?”
而这一切问题的根因都来源于 TCP 的链接和流的绑定,或者说是控制信令和数据通道的耦合。
我们不禁要提出一个灵魂拷问“我们需要的是全双工的数据传输模式,但我们真的需要在链接维度做这个事情吗?”这么说似乎有一些抽象,还是以 TCP 的 TIME_WAIT 设计为例子来说明,我们来自底向上的看看 TCP 的问题:
再回到我们的问题上,如果我们将流和链接区分开,在链接维度保证流的控制指令可靠传输,链接本身实现一个简单的单工关闭过程,即通信一端主动关闭链接,则整个链接关闭,是否一切就简单起来了呢?
当然这就是 QUIC 的解法,有了这一层思路之后,我们来整理下 QUIC 链接关闭的诉求:
抛开流的关闭流程设计(关于流的这部分会在系列文章后续关于 stream 的设计进行分享),在链接维度我们就可以得到一个清爽的状态机:
可以看到,得益于单工的关闭模式,在整个 QUIC 链接关闭的流程里,关闭指令只有一个,即图中的 CONNECTION_CLOSE,而关闭的状态也只有两个,即图中的 closing 和 draing。我们先来看看两种状态下终端的行为:
- closing 状态:当用户主动关闭链接时,即进入该状态,该状态下,终端收到所有的应用层数据都将只会回复 CONNECTION_CLOSE
- draining 状态:当终端收到 CONNECTION_CLOSE 时,即进入该状态,该状态下终端不再回复任何数据
更简单的是,CONNECTION_CLOSE 是一个不需要被 ACK 的指令,也就意味着不需要重传。因为从链接维度而言,我们只需要保证最后能成功关闭的链接,并且新的链接不被老的关闭指令影响即可,这种简单的 CONNECTION_CLOSE 指令就能实现所有的诉求。
3.2 更安全的 reset 的方式
当然,链接关闭也分为多种情况,和 TCP 一样,除了上一节提到的 QUIC 一端主动关闭链接的模式,QUIC 也需要提供无法回复响应时的,直接 reset 对端链接的能力。
而 QUIC reset 对端链接的方式相比于 TCP 来说更加安全,该机制被称作 stateless reset,这并不是一个十分复杂的机制。在 QUIC 链接建立好之后,QUIC 双方会同步一个 token,而后续的链接关闭将通过校验这个 token 来来判断该对端是否有权限来 reset 这个链接,这种机制从根本上规避了前面提到的 TCP 被恶意 reset 的这种攻击模式,整个流程如下图:
当然,stateless reset 的方案并不是银弹,安全的代价是更窄的使用范围。因为为了保障安全,token 必须通过安全的数据通道进行传输(在 QUIC 中被限定为 NEW_TOKEN frame),并且接收端需要维持一个记录这个 token 的状态,也只有通过这个状态才可以保证 token 的有效性。
因此 stateless reset 被限定为 QUIC 链接关闭的最后手段,并且也只能在只能使用在客户端和服务端均处于一个相对正常的情况下正常工作,比如这样的情况 stateless reset 就不适用,服务端并没有监听在 443 端口,但客户端发送数据到 443 端口,而这种情况在 TCP 协议栈下是可以 RST 掉的。
3.3 工程考量的超时断链
keepalive 机制本身并没有什么花样,都是一个计时器加探测报文即可搞定,而 QUIC 得益于链接和数据流的拆分,关闭链接变成了一个非常简单的事情,keepalive 也就变得更简单易用,QUIC 在链接维度上提供了名为 PING frame 的控制指令,用于主动探测保活。
更简单的是,QUIC 在超时后关闭链接的方式是 silently close,即不通知对端,本机直接释放掉链接所有的资源。silently close 的好处是资源可以立即得到释放,特别是对于 QUIC 这种单链接上既要维护 TLS 状态,也要维护流状态的协议来说,有很大的收益。
但其劣势在于后续如果有之前链接数据到来,则只能通过 stateless reset 的方式通知对端关闭链接,stateless reset 的关闭相对来说 CONNECTION_CLOSE 开销更大。因此这部分可以说完全是一个 tradeoff,而这部分的设计方案的最终敲定,更多来自于大量工程实践的经验与结果。
PART. 4 从 QUIC 链接的建立与关闭看协议的演进
从 TCP 到 QUIC,虽然只是网络协议技术的演进,但我们也可以管中窥豹地看一下整个网络发展的趋势。链接的建立与关闭只是我们对 QUIC 协议的切入点,正如文章一直在强调的部分,无论是建联还是什么流程,都是在为实现 QUIC 的特性而服务,而本文除了在详细分析 QUIC 的链接建立的关闭流程之外,更是在总结这些特性的由来及设计思路。
通读全文,我们可以看到,一个现代化的网络协议已经绕不开安全这个诉求了,可以说“安全是一切的基础,效率则是永恒的追求”。
而 QUIC 首先从收敛分层协议的思路出发,统一了安全和可靠两种交互诉求,这似乎也在提示我们,未来协议的发展,似乎也不必再完全遵从 OSI 的模型。分层是为了各个组件更良好分工协作,而收敛则是极致的性能追求,TCP+TLS 可以收敛到 QUIC,那么就如华为提出的 NEW IP 技术一样,如果我们把智能路由等技术结合起来,所有三层及以上网络协议也未尝不能收敛到一个全新的 IPSEC 协议中去。
当然这些都来的太远了,QUIC 本身是一个非常接地气的协议,在其以开源为主导的标准形成过程中,吸收了大量工程经验,使其不至于有太多理想化的特性,且可扩展性非常强,我想未来这样的工作模式,也将是一种主流的模式。
结 语
撰写本文是一个痛苦的过程,正如阅读 RFC 一样,想要将 QUIC 某个方向的技术完全自包含在一篇文章之内几乎不可能,而本文选择将一些其他的依赖技术用弱化的方式来表达,并期望能在将来以单一文章的方式去着重介绍。
因此读者若想要全面理解 HTTP/3 或者 QUIC,也请关注后续的文章,并拉通阅读,方能有更深的体会。
当然,本文都是基于作者自己的个人理解,难免存在纰漏之处,如果读者发现有相关问题,欢迎随时一起深入探讨。
本周推荐阅读
云原生运行时的下一个五年
积跬步至千里:QUIC 协议在蚂蚁集团落地之综述
网商双十一基于 ServiceMesh 技术的业务链路隔离技术及实践
Service Mesh 在中国工商银行的探索与实践
推荐阅读
-
深入 HTTP/3(一)|从 QUIC 链接的建立与关闭看协议的演进
-
go语言Socket编程-Socket编程 什么是Socket Socket,英文含义是插座、插孔,一般称之为套接字,用于描述IP地址和端口。可以实现不同程序间的数据通信。 Socket起源于Unix,而Unix基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。Socket就是该模式的一个实现,网络的Socket数据传输是一种特殊的I/O,Socket也是一种文件描述符。Socket也具有一个类似于打开文件的函数调用:Socket,该函数返回一个整型的Socket描述符,随后的连接建立、数据传输等操作都是通过该Socket实现的。 套接字的内核实现较为复杂,不宜在学习初期深入学习,了解到如下结构足矣。 套接字通讯原理示意 在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。 常用的Socket类型有两种:流式Socket(SOCK_STREAM)和数据报式Socket(SOCK_DGRAM)。流式是一种面向连接的Socket,针对于面向连接的TCP服务应用;数据报式Socket是一种无连接的Socket,对应于无连接的UDP服务应用。 网络应用程序设计模式 C/S模式 传统的网络应用设计模式,客户机(client)/服务器(server)模式。需要在通讯两端各自部署客户机和服务器来完成数据通信。 B/S模式 浏览器(Browser)/服务器(Server)模式。只需在一端部署服务器,而另外一端使用每台PC都默认配置的浏览器即可完成数据的传输。 优缺点 对于C/S模式来说,其优点明显。客户端位于目标主机上可以保证性能,将数据缓存至客户端本地,从而提高数据传输效率。且,一般来说客户端和服务器程序由一个开发团队创作,所以他们之间所采用的协议相对灵活。可以在标准协议的基础上根据需求裁剪及定制。例如,腾讯所采用的通信协议,即为ftp协议的修改剪裁版。 因此,传统的网络应用程序及较大型的网络应用程序都首选C/S模式进行开发。如,知名的网络游戏魔兽世界。3D画面,数据量庞大,使用C/S模式可以提前在本地进行大量数据的缓存处理,从而提高观感。 C/S模式的缺点也较突出。由于客户端和服务器都需要有一个开发团队来完成开发。工作量将成倍提升,开发周期较长。另外,从用户角度出发,需要将客户端安插至用户主机上,对用户主机的安全性构成威胁。这也是很多用户不愿使用C/S模式应用程序的重要原因。 B/S模式相比C/S模式而言,由于它没有独立的客户端,使用标准浏览器作为客户端,其工作开发量较小。只需开发服务器端即可。另外由于其采用浏览器显示数据,因此移植性非常好,不受平台限制。如早期的偷菜游戏,在各个平台上都可以完美运行。 B/S模式的缺点也较明显。由于使用第三方浏览器,因此网络应用支持受限。另外,没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制。应用的观感大打折扣。第三,必须与浏览器一样,采用标准http协议进行通信,协议选择不灵活。 因此在开发过程中,模式的选择由上述各自的特点决定。根据实际需求选择应用程序设计模式。 简单的C/S模型通信 Server端:Listen函数 func Listen(network, address string) (Listener, error) network:选用的协议:TCP、UDP, 如:“tcp”或 “udp” address:IP地址+端口号, 如:“127.0.0.1:8000”或 “:8000” Listener 接口: type Listener interface { Accept (Conn, error) Close error Addr Addr } Conn 接口: type Conn interface { Read(b byte) (n int, err error) Write(b byte) (n int, err error) Close error LocalAddr Addr RemoteAddr Addr SetDeadline(t time.Time) error SetReadDeadline(t time.Time) error SetWriteDeadline(t time.Time) error } 参看 [<u>https://studygolang.com/pkgdoc</u>](https://studygolang.com/pkgdoc) 中文帮助文档中的demo: 示例代码:TCP服务器.go package main import ( "net" "fmt" ) func main { // 创建监听 listener, err:= net.Listen("tcp", ":8000") if err != nil { fmt.Println("listen err:", err) return } defer listener.Close // 主协程结束时,关闭listener fmt.Println("服务器等待客户端建立连接...") // 等待客户端连接请求 conn, err := listener.Accept if err != nil { fmt.Println("accept err:", err) return } defer conn.Close // 使用结束,断开与客户端链接 fmt.Println("客户端与服务器连接建立成功...") // 接收客户端数据 buf := make(byte, 1024) // 创建1024大小的缓冲区,用于read n, err := conn.Read(buf) if err != nil { fmt.Println("read err:", err) return } fmt.Println("服务器读到:", string(buf[:n])) // 读多少,打印多少。 }
-
epoll简介及触发模式(accept、read、send)-epoll的简单介绍 epoll在LT和ET模式下的读写方式 一、epoll的接口非常简单,一共就三个函数:1. int epoll_create(int size);创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大。这个参数不同于select中的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close关闭,否则可能导致fd被耗尽。2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);epoll的事件注册函数,它不同与select是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create的返回值,第二个参数表示动作,用三个宏来表示:EPOLL_CTL_ADD:注册新的fd到epfd中;EPOLL_CTL_MOD:修改已经注册的fd的监听事件;EPOLL_CTL_DEL:从epfd中删除一个fd;第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,struct epoll_event结构如下:struct epoll_event { __uint32_t events; /* Epoll events */ epoll_data_t data; /* User data variable */};events可以是以下几个宏的集合:EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭); EPOLLIN事件:EPOLLIN事件则只有当对端有数据写入时才会触发,所以触发一次后需要不断读取所有数据直到读完EAGAIN为止。否则剩下的数据只有在下次对端有写入时才能一起取出来了。现在明白为什么说epoll必须要求异步socket了吧?如果同步socket,而且要求读完所有数据,那么最终就会在堵死在阻塞里。 EPOLLOUT:表示对应的文件描述符可以写; EPOLLOUT事件:EPOLLOUT事件只有在连接时触发一次,表示可写,其他时候想要触发,那要先准备好下面条件:1.某次write,写满了发送缓冲区,返回错误码为EAGAIN。2.对端读取了一些数据,又重新可写了,此时会触发EPOLLOUT。简单地说:EPOLLOUT事件只有在不可写到可写的转变时刻,才会触发一次,所以叫边缘触发,这叫法没错的!其实,如果真的想强制触发一次,也是有办法的,直接调用epoll_ctl重新设置一下event就可以了,event跟原来的设置一模一样都行(但必须包含EPOLLOUT),关键是重新设置,就会马上触发一次EPOLLOUT事件。1. 缓冲区由满变空.2.同时注册EPOLLIN | EPOLLOUT事件,也会触发一次EPOLLOUT事件这个两个也会触发EPOLLOUT事件 EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);EPOLLERR:表示对应的文件描述符发生错误;EPOLLHUP:表示对应的文件描述符被挂断;EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);等待事件的产生,类似于select调用。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。-------------------------------------------------------------------------------------------- 从man手册中,得到ET和LT的具体描述如下EPOLL事件有两种模型:Edge Triggered (ET)Level Triggered (LT)假如有这样一个例子:1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符2. 这个时候从管道的另一端被写入了2KB的数据3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作4. 然后我们读取了1KB的数据5. 调用epoll_wait(2)......Edge Triggered 工作模式:如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。在上面的例子中,会有一个事件产生在RFD句柄上,因为在第2步执行了一个写操作,然后,事件将会在第3步被销毁。因为第4步的读取操作没有读空文件输入缓冲区内的数据,因此我们在第5步调用 epoll_wait(2)完成后,是否挂起是不确定的。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。最好以下面的方式调用ET模式的epoll接口,在后面会介绍避免可能的缺陷。 i 基于非阻塞文件句柄 ii 只有当read(2)或者write(2)返回EAGAIN时才需要挂起,等待。但这并不是说每次read时都需要循环读,直到读到产生一个EAGAIN才认为此次事件处理完成,当read返回的读到的数据长度小于请求的数据长度时,就可以确定此时缓冲中已没有数据了,也就可以认为此事读事件已处理完成。Level Triggered 工作模式相反的,以LT方式调用epoll接口的时候,它就相当于一个速度比较快的poll(2),并且无论后面的数据是否被使用,因此他们具有同样的职能。因为即使使用ET模式的epoll,在收到多个chunk的数据的时候仍然会产生多个事件。调用者可以设定EPOLLONESHOT标志,在 epoll_wait(2)收到事件后epoll会与事件关联的文件句柄从epoll描述符中禁止掉。因此当EPOLLONESHOT设定后,使用带有 EPOLL_CTL_MOD标志的epoll_ctl(2)处理文件句柄就成为调用者必须作的事情。然后详细解释ET, LT:LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表.ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once),不过在TCP协议中,ET模式的加速效用仍需要更多的benchmark确认(这句话不理解)。在许多测试中我们会看到如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当我们遇到大量的idle- connection(例如WAN环境中存在大量的慢速连接),就会发现epoll的效率大大高于select/poll。(未测试)另外,当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,读数据的时候需要考虑的是当recv返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取: 这里只是说明思路(参考《UNIX网络编程》) while(rs) {buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);if(buflen < 0){// 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读// 在这里就当作是该次事件已处理处.if(errno == EAGAIN)break; else return; }else if(buflen == 0) { // 这里表示对端的socket已正常关闭. } if(buflen == sizeof(buf) rs = 1; // 需要再次读取 else rs = 0; } 还有,假如发送端流量大于接收端的流量(意思是epoll所在的程序读比转发的socket要快),由于是非阻塞的socket,那么send函数虽然返回,但实际缓冲区的数据并未真正发给接收端,这样不断的读和发,当缓冲区满后会产生EAGAIN错误(参考man send),同时,不理会这次请求发送的数据.所以,需要封装socket_send的函数用来处理这种情况,该函数会尽量将数据写完再返回,返回-1表示出错。在socket_send内部,当写缓冲已满(send返回-1,且errno为EAGAIN),那么会等待后再重试.这种方式并不很完美,在理论上可能会长时间的阻塞在socket_send内部,但暂没有更好的办法. ssize_t socket_send(int sockfd, const char* buffer, size_t buflen) { ssize_t tmp; size_t total = buflen; const char *p = buffer; while(1) { tmp = send(sockfd, p, total, 0); if(tmp < 0) { // 当send收到信号时,可以继续写,但这里返回-1. if(errno == EINTR) return -1; // 当socket是非阻塞时,如返回此错误,表示写缓冲队列已满, // 在这里做延时后再重试. if(errno == EAGAIN) { usleep(1000); continue; } return -1; } if((size_t)tmp == total) return buflen; total -= tmp; p += tmp; } return tmp; } 二、epoll在LT和ET模式下的读写方式 在一个非阻塞的socket上调用read/write函数, 返回EAGAIN或者EWOULDBLOCK(注: EAGAIN就是EWOULDBLOCK) 从字面上看, 意思是: * EAGAIN: 再试一次 * EWOULDBLOCK: 如果这是一个阻塞socket, 操作将被block * perror输出: Resource temporarily unavailable 总结: 这个错误表示资源暂时不够, 可能read时, 读缓冲区没有数据, 或者, write时,写缓冲区满了 。 遇到这种情况, 如果是阻塞socket, read/write就要阻塞掉。 而如果是非阻塞socket, read/write立即返回-1, 同 时errno设置为EAGAIN. 所以, 对于阻塞socket, read/write返回-1代表网络出错了. 但对于非阻塞socket, read/write返回-1不一定网络真的出错了. 可能是Resource temporarily unavailable. 这时你应该再试, 直到Resource available. 综上, 对于non-blocking的socket, 正确的读写操作为: 读: 忽略掉errno = EAGAIN的错误, 下次继续读 写: 忽略掉errno = EAGAIN的错误, 下次继续写 对于select和epoll的LT模式, 这种读写方式是没有问题的. 但对于epoll的ET模式, 这种方式还有漏洞. epoll的两种模式 LT 和 ET
-
windows下进程间通信的(13种方法)-摘 要 本文讨论了进程间通信与应用程序间通信的含义及相应的实现技术,并对这些技术的原理、特性等进行了深入的分析和比较。 ---- 关键词 信号 管道 消息队列 共享存储段 信号灯 远程过程调用 Socket套接字 MQSeries 1 引言 ---- 进程间通信的主要目的是实现同一计算机系统内部的相互协作的进程之间的数据共享与信息交换,由于这些进程处于同一软件和硬件环境下,利用操作系统提供的的编程接口,用户可以方便地在程序中实现这种通信;应用程序间通信的主要目的是实现不同计算机系统中的相互协作的应用程序之间的数据共享与信息交换,由于应用程序分别运行在不同计算机系统中,它们之间要通过网络之间的协议才能实现数据共享与信息交换。进程间通信和应用程序间通信及相应的实现技术有许多相同之处,也各有自己的特色。即使是同一类型的通信也有多种的实现方法,以适应不同情况的需要。 ---- 为了充分认识和掌握这两种通信及相应的实现技术,本文将就以下几个方面对这两种通信进行深入的讨论:问题的由来、解决问题的策略和方法、每种方法的工作原理和实现、每种实现方法的特点和适用的范围等。 2 进程间的通信及其实现技术 ---- 用户提交给计算机的任务最终都是通过一个个的进程来完成的。在一组并发进程中的任何两个进程之间,如果都不存在公共变量,则称该组进程为不相交的。在不相交的进程组中,每个进程都独立于其它进程,它的运行环境与顺序程序一样,而且它的运行环境也不为别的进程所改变。运行的结果是确定的,不会发生与时间相关的错误。 ---- 但是,在实际中,并发进程的各个进程之间并不是完全互相独立的,它们之间往往存在着相互制约的关系。进程之间的相互制约关系表现为两种方式: ---- (1) 间接相互制约:共享CPU ---- (2) 直接相互制约:竞争和协作 ---- 竞争——进程对共享资源的竞争。为保证进程互斥地访问共享资源,各进程必须互斥地进入各自的临界段。 ---- 协作——进程之间交换数据。为完成一个共同任务而同时运行的一组进程称为同组进程,它们之间必须交换数据,以达到协作完成任务的目的,交换数据可以通知对方可以做某事或者委托对方做某事。 ---- 共享CPU问题由操作系统的进程调度来实现,进程间的竞争和协作由进程间的通信来完成。进程间的通信一般由操作系统提供编程接口,由程序员在程序中实现。UNIX在这个方面可以说最具特色,它提供了一整套进程间的数据共享与信息交换的处理方法——进程通信机制(IPC)。因此,我们就以UNIX为例来分析进程间通信的各种实现技术。 ---- 在UNIX中,文件(File)、信号(Signal)、无名管道(Unnamed Pipes)、有名管道(FIFOs)是传统IPC功能;新的IPC功能包括消息队列(Message queues)、共享存储段(Shared memory segment)和信号灯(Semapores)。 ---- (1) 信号 ---- 信号机制是UNIX为进程中断处理而设置的。它只是一组预定义的值,因此不能用于信息交换,仅用于进程中断控制。例如在发生浮点错、非法内存访问、执行无效指令、某些按键(如ctrl-c、del等)等都会产生一个信号,操作系统就会调用有关的系统调用或用户定义的处理过程来处理。 ---- 信号处理的系统调用是signal,调用形式是: ---- signal(signalno,action) ---- 其中,signalno是规定信号编号的值,action指明当特定的信号发生时所执行的动作。 ---- (2) 无名管道和有名管道 ---- 无名管道实际上是内存中的一个临时存储区,它由系统安全控制,并且独立于创建它的进程的内存区。管道对数据采用先进先出方式管理,并严格按顺序操作,例如不能对管道进行搜索,管道中的信息只能读一次。 ---- 无名管道只能用于两个相互协作的进程之间的通信,并且访问无名管道的进程必须有共同的祖先。 ---- 系统提供了许多标准管道库函数,如: pipe——打开一个可以读写的管道; close——关闭相应的管道; read——从管道中读取字符; write——向管道中写入字符; ---- 有名管道的操作和无名管道类似,不同的地方在于使用有名管道的进程不需要具有共同的祖先,其它进程,只要知道该管道的名字,就可以访问它。管道非常适合进程之间快速交换信息。 ---- (3) 消息队列(MQ) ---- 消息队列是内存中独立于生成它的进程的一段存储区,一旦创建消息队列,任何进程,只要具有正确的的访问权限,都可以访问消息队列,消息队列非常适合于在进程间交换短信息。 ---- 消息队列的每条消息由类型编号来分类,这样接收进程可以选择读取特定的消息类型——这一点与管道不同。消息队列在创建后将一直存在,直到使用msgctl系统调用或iqcrm -q命令删除它为止。 ---- 系统提供了许多有关创建、使用和管理消息队列的系统调用,如: ---- int msgget(key,flag)——创建一个具有flag权限的MQ及其相应的结构,并返回一个唯一的正整数msqid(MQ的标识符); ---- int msgsnd(msqid,msgp,msgsz,msgtyp,flag)——向队列中发送信息; ---- int msgrcv(msqid,cmd,buf)——从队列中接收信息; ---- int msgctl(msqid,cmd,buf)——对MQ的控制操作; ---- (4) 共享存储段(SM) ---- 共享存储段是主存的一部分,它由一个或多个独立的进程共享。各进程的数据段与共享存储段相关联,对每个进程来说,共享存储段有不同的虚拟地址。系统提供的有关SM的系统调用有: ---- int shmget(key,size,flag)——创建大小为size的SM段,其相应的数据结构名为key,并返回共享内存区的标识符shmid; ---- char shmat(shmid,address,flag)——将当前进程数据段的地址赋给shmget所返回的名为shmid的SM段; ---- int shmdr(address)——从进程地址空间删除SM段; ---- int shmctl (shmid,cmd,buf)——对SM的控制操作; ---- SM的大小只受主存限制,SM段的访问及进程间的信息交换可以通过同步读写来完成。同步通常由信号灯来实现。SM非常适合进程之间大量数据的共享。 ---- (5) 信号灯 ---- 在UNIX中,信号灯是一组进程共享的数据结构,当几个进程竞争同一资源时(文件、共享内存或消息队列等),它们的操作便由信号灯来同步,以防止互相干扰。 ---- 信号灯保证了某一时刻只有一个进程访问某一临界资源,所有请求该资源的其它进程都将被挂起,一旦该资源得到释放,系统才允许其它进程访问该资源。信号灯通常配对使用,以便实现资源的加锁和解锁。 ---- 进程间通信的实现技术的特点是:操作系统提供实现机制和编程接口,由用户在程序中实现,保证进程间可以进行快速的信息交换和大量数据的共享。但是,上述方式主要适合在同一台计算机系统内部的进程之间的通信。 3 应用程序间的通信及其实现技术 ---- 同进程之间的相互制约一样,不同的应用程序之间也存在竞争和协作的关系。UNIX操作系统也提供一些可用于应用程序之间实现数据共享与信息交换的编程接口,程序员可以通过自己编程来实现。如远程过程调用和基于TCP/IP协议的套接字(Socket)编程。但是,相对普通程序员来说,它们涉及的技术比较深,编程也比较复杂,实现起来困难较大。 ---- 于是,一种新的技术应运而生——通过将有关通信的细节完全掩盖在某个独立软件内部,即底层的通讯工作和相应的维护管理工作由该软件内部来实现,用户只需要将通信任务提交给该软件去完成,而不必理会它的具体工作过程——这就是所谓的中间件技术。 ---- 我们在这里分别讨论这三种常用的应用程序间通信的实现技术——远程过程调用、会话编程技术和MQSeries消息队列技术。其中远程过程调用和会话编程属于比较低级的方式,程序员参与的程度较深,而MQSeries消息队列则属于比较高级的方式,即中间件方式,程序员参与的程度较浅。 ---- 4.1 远程过程调用(RPC)