571
社区成员




在网络层之上,通常是由TCP和UDP负责传输层的几乎所有工作,在传输层之上再结合各类应用层协议实现可靠的通信或尽最大努力交付的通信。而在众多应用层协议中,应用最为广泛的是HTTP协议族,HTTP自身在最开始被设计时是一种无连接的协议,但由于HTTP又是基于TCP可靠传输的,为了适应网络快速发展带来的峰值流量,以免服务器因频繁的连接请求和释放而宕机,HTTP也迭代到了拥有持久连接的版本。且在大多数Web服务中都对数据交互的安全性有着较高的需求,因此又诞生了传输层安全协议TLS。至此,整个HTTP顶层协议栈的雏形就诞生了——TCP+TLS+HTTP,这也就构成了目前大多数Web应用背后的网络通信模块。
这一架构的弊端也随着网络流量和用户实时性需求的提高而日益突出,这是因为,基于TCP的服务的连接建立和释放过程实在过于烦琐,一次TCP请求就需要至少3次握手,而对于有这数据安全的需求来说,建立连接的开销则会更高。而且,面对动态需求以及弱网环境下,TCP更显笨拙。这些都还只是TCP协议弊病的冰山一角,而整个传输层又是与硬件高度耦合的,显然不可能大刀阔斧地对TCP协议进行修改,由此也就需要一个新的协议去承接TCP的工作,这就是QUIC。
既然传输层不方便作修改,而网络服务又不希望承担高额的连接开销,又想要保有TCP的可靠性,自然我们就会想到传输层的另一协议——UDP,这也是QUIC设计者的初衷。QUIC的全称是Quick UDP Internet Connection,即快速UDP互联网连接,是由Google团队提出的一种基于UDP的传输协议。时至2021年5月,IETF已推出了RFC9000,QUIC迎来了标准化。
那么QUIC究竟做了什么呢?
QUIC承接UDP,实现了TCP的连接服务,向上提供接口给HTTP,而承载了QUIC的HTTP也进一步标准化为HTTP3.0(基于TCP的HTTP为HTTP2.x)。(下图出自HTTP/3)
QUIC协议栈UDP+QUIC+HTTP/3对标的就是传统TCP协议栈中的TCP+TLS+HTTP/2。
QUIC与现有方案相比可以总结出几个特征:
接下来逐个分析:
(上图出自The Promise of QUIC: A Faster, More Flexible Transport Protocol)对于传统的TCP+TLS1.2服务,整个连接的建立至少要2个RTT,而在前向加密TLS基础上的QUIC大部分情况下可以达到0RTT,几乎0时延建立连接。
事实上,QUIC协议标准对于TCP中的拥塞控制部分是几乎没有修改的,而QUIC拥塞控制的优势依赖于用户态的实现,由于QUIC是脱离了硬件限制的用户态协议,因此对于拥塞控制上就有了更高的灵活性,应用程序完全可以根据自己的需要确定控制策略,而且由于不涉及硬件,这样的改动也可以大范围迅速地部署到客户端,即实现了子模块的热插拔,当然这并非拥塞控制本身的改进,其他模块也受益于用户态的实现。
TCP的可靠性依赖于采用ACK和SEQ保证有序性的传输机制,在网络内部,各序列虽然可以做到并行传输,但在接收端,由于接收窗口的存在,最终的序列同样是要求要按序接收的,倘若窗口中某一序号的包丢失,那么整个队列都会被阻塞,知道正确接收缺失的包。不仅如此,由于TCP的SEQ序列号是可复用的,这就造成了重传时可能发生的二义性问题,即同一个序列号可能对应不同的包。
为此,QUIC把SEQ升级为Packet Number,该序列号保证对于同一个连接是严格单调递增的,同时,Packet Number之间是没有依赖的,这就解决了传输的效率。
但这样做就无法保证数据流的顺序性了,为此,QUIC引入了Stream Offset来确定数据的具体顺序,而Packet Number只负责标识数据包。
为了实现多路复用,QUIC还提供了类似HTTP2的Connection和Stream两种流量控制方式。Stream的形式就类似于一次HTTP请求,而Connection则类似于TCP连接,多路复用即可能存在多个Stream共享同一个Connection。对于TCP,其接收窗口总是受限于已确认的字节数,而基于QUIC的超时重传机制中流控制和数据控制分离的思想,QUIC的窗口只取决于Offset,对于多流情况,只需要给各个流都分配一个窗口即可,摆脱了丢包带来的窗口限制。
由于QUIC同一个Connection中多个Stream的依赖已经消除,那么当某个Stream出现丢包时也不会对其他Streams造成影响,很大程度上就已经消除头阻塞问题。
但仔细分析还会发现,由于大部分HTTP中使用了TLS协议,这里又会引入一个TLS的头阻塞。针对这一问题,QUIC多路复用则采用了基于Packet的加密和认证,且Packet大小严格不超过MTU,这样就保证了验证只会发生在同一个Packet内部,取消了Packet间的依赖,消除了绝大多数场景下的TLS队头阻塞。
当然,QUIC的优势并不止于以上提到的几个核心方面,但从QUIC的设计思路中可以看出,其核心优势在于基于Packet的传输和用户态的实现,这两方面给了QUIC无限的可能性,当然QUIC也并不是万能的,作为一个新兴的协议(当然QUIC本身是有一定的历史了,但就其落地而言,目前还处于起势阶段),其标准的制定还在发展阶段,不过在最近(2021年5月)的RFC9000中,QUIC已经被标准化了,这对于QUIC的发展而言是里程碑式的进展,可以预见未来会有更多的应用支持QUIC协议,为用户带来更良好的网络体验。
为了更好地设计顶层应用,网络程序的开发者有必要对QUIC的机制和内核有一定的了解。由于QUIC基于用户态的特性,即便是在标准尚未统一之时,就已经出现了大量的QUIC开源实现。经典的库有基于C/C++的MsQUIC,即微软的开源库,以及Google Chromium中的QUIC实现,即QUIC提出者的实现,而由于Chromium过于庞杂,也就有一个剥离的版本。其他经典的实现还有基于QUIC-GO、Node.js-QUIC等,开发者开源根据自己的需要进行框架的选取,各大巨头的实现也都有着良好的社区氛围。
而本文旨在调研QUIC的核心机制,因此选用了基于Python的AIOQUIC。AIOQUIC是基于Python实现的QUIC协议第三方库,该库致力于支持想要迭代到QUIC的Python客户端和服务器,支持IPv4和IPv6、HTTP/3、TLS1.3,符合draft-ietf-quic-http-34协议栈草案。
AIOQUIC有着较为完善的API文档,对于轻量的Python C/S有着良好的支持。
基于Python的实现带来了比较简单的构建过程,以Windows 10,Python version=3.9为例,通过:
$ pip install aioquic
$ pip install aiofiles asgiref dnslib httpbin starlette wsproto
安装AIOQUIC模块和相关依赖,设置环境变量以链接OpenSSL获取数字证书:
> $Env:INCLUDE = "C:\Progra~1\OpenSSL-Win64\include"
> $Env:LIB = "C:\Progra~1\OpenSSL-Win64\lib"
通过:
$ python examples/http3_server.py --certificate tests/ssl_cert.pem --private-key tests/ssl_key.pem
即可运行样例服务器;然后通过:
$ python examples/http3_client.py --ca-certs tests/pycacert.pem https://localhost:4433/
即可模拟客户端向服务器发起一个QUIC连接的建立。
注意这里官方文档给出的
$ python examples/http3_client.py https://localhost:4433/
命令是不可用的,因为官方更新了证书,客户端通过该方式是无法获取到可信的服务器证书链的,QUIC连接会失败。
在成功请求后,客户端会向服务器发送一些测试数据。
通过WireShark对本地回环端口抓包可以获取到一系列QUIC协议相关的包。可以看到,在一次模拟连接中,Handshake在两个报文的交换之后就完成了,只用了1个RTT。
基于包和模块的构建方式,在现有项目中引入AIOQUIC协议是比较简单且友好的,通过
async def client_run(addr, port, **kwargs):
# QUIC 配置,包括:
# - 指定 Server 证书的 CA 文件
# - 指定 secrets_log_file 便于抓包并用于 wireshark 解密分析
configuration = QuicConfiguration(is_client=True)
configuration.load_verify_locations(cadata=None, cafile=SERVER_CACERTFILE)
configuration.secrets_log_file = open("keys.log.txt", "a")
# 除此外,还能指定使用的 QUIC 版本、使用的 session_ticket
# 发起连接,传递目标 ip 和 port 以及配置文件
# 对于 retry 和 session_ticket 的处理函数也是通过 connect 参数进行控制
async with connect(addr, port, configuration=configuration, **kwargs) as cn:
await cn.wait_connected()
reader, writer = await cn.create_stream()
writer.write(message.encode("utf-8"))
writer.write_eof()
response = await reader.read()
# 等待 AIOQUIC 连接关闭完成
await cn.wait_closed()
def main():
loop = asyncio.get_event_loop()
loop.run_until_complete(client_run("127.0.0.1", 443))
if __name__ == "__main__":
main()
即可在客户端发起一次简单的QUIC请求。
整个QUIC可以拆分为若干模块进行分析。
流(Stream)是QUIC协议体系中的一个重要概念,QUIC的所有服务都是基于流的,因此有必要先对流进行剖析。
通过之前的介绍我们知道了,QUIC是通过流ID来唯一表示一个流的,在上面测试使用版本中,遵循的草案中定义的流ID为62位大小,显然这是为了保证同一流中不出现重复ID,并且,整个QUIC都是采用了类似的形式去平衡效率和包体,也就导致了后续提到的Handshake报头超长的问题。
包(Packet)和帧(Frame)是QUIC网络通信的物理概念。
包是数据发送和响应的最小单位,在QUIC endpoint之间交换包进行通信,报个包都具有完整性和加密性;而帧是包的payload,这也就意味着,帧是没有编号的,但帧是拥有流ID的,标识了帧所属的流,而还有部分帧不持有流ID,这些一般是作为控制帧出现。
而在草案中并未提及将整个报文划分为包和帧嵌套的形式是出自什么用意,这里推测是为了实现更加灵活的加密和通信策略。但无论如何,整个QUIC Packet都可以理解为是Header与Payload的集合。
# connection.py
class FrameType(IntEnum):
DATA = 0x0
HEADERS = 0x1
PRIORITY = 0x2
CANCEL_PUSH = 0x3
SETTINGS = 0x4
PUSH_PROMISE = 0x5
GOAWAY = 0x7
MAX_PUSH_ID = 0xD
DUPLICATE_PUSH = 0xE
WEBTRANSPORT_STREAM = 0x41
QUIC协议中连接的建立其实是一个涵盖了版本协商、参数传递。加密参数等工作的复杂过程。因此AIOQUIC采用的也是一个超长的连接建立报头,如上面的demo所示,一个Handshake的首个报文会达到1000B以上。
连接建立首先要协商双方使用的QUIC协议版本,而这部分并未被标准化,因此这里只给出AIOQUIC中支持的版本。
# configuration.py
@dataclass
class QuicConfiguration:
# ...
supported_versions: List[int] = field(
default_factory=lambda: [
QuicProtocolVersion.VERSION_1,
QuicProtocolVersion.DRAFT_32,
QuicProtocolVersion.DRAFT_31,
QuicProtocolVersion.DRAFT_30,
QuicProtocolVersion.DRAFT_29,
]
)
verify_mode: Optional[int] = None
# ...
这里以加密传输为例,AIOQUIC的传输连接两个endpoint都是在initial阶段完成的:
# crypto.py
def setup_initial(self, cid: bytes, is_client: bool, version: int) -> None:
if is_client:
recv_label, send_label = b"server in", b"client in"
else:
recv_label, send_label = b"client in", b"server in"
if is_draft_version(version):
initial_salt = INITIAL_SALT_DRAFT_29
else:
initial_salt = INITIAL_SALT_VERSION_1
algorithm = cipher_suite_hash(INITIAL_CIPHER_SUITE)
initial_secret = hkdf_extract(algorithm, initial_salt, cid)
self.recv.setup(
cipher_suite=INITIAL_CIPHER_SUITE,
secret=hkdf_expand_label(
algorithm, initial_secret, recv_label, b"", algorithm.digest_size
),
version=version,
)
self.send.setup(
cipher_suite=INITIAL_CIPHER_SUITE,
secret=hkdf_expand_label(
algorithm, initial_secret, send_label, b"", algorithm.digest_size
),
version=version,
)
QUIC 连接握手是同时进行的,由 QUIC 的 CRYPTO Frame 来封装 TLS 的交换信息。以经典的1-RTT Longer Header Packet实现为例,Client 发起连接时会为Initial Packet填充至少 1200 字节,这是因为往往服务器包含证书信息,要返回给 Client 的数据量较大,而此时又因为没有完成地址验证(除非开启了 Retry 地址验证),所以 Server 无法返回足够的握手数据给 Client,这将会导致无法握手成功。
应用层的数据传输是基于Stream的,因此数据传输的核心就是流的生命周期。
流的创建是自动的,且不需要标识符,对于发送方,生成一个未被占用的流ID即完成了流的创建;而对于接收方,收到一个不曾接收的流ID也认为接收到了一个新的流,可以看到,QUIC对于流的管理其实是大道至简的,这种方式兼顾了效率和质量。
而客户端只需要管理一个空闲ID池即可实现流的分配创建。
# connection.py
def get_next_available_stream_id(self, is_unidirectional=False) -> int:
"""
Return the stream ID for the next stream created by this endpoint.
"""
stream_id = (int(is_unidirectional) << 1) | int(not self._is_client)
while stream_id in self._streams or stream_id in self._streams_finished:
stream_id += 4
return stream_id
有三种方式终止流:
AIOQUIC 的处理:
这里以官方test的为例:
# test_h3.py
def test_handle_control_stream_close(self):
"""
Closing the control stream is not allowed.
"""
quic_client = FakeQuicConnection(
configuration=QuicConfiguration(is_client=True)
)
h3_client = H3Connection(quic_client)
# receive SETTINGS
h3_client.handle_event(
StreamDataReceived(
stream_id=3,
data=encode_uint_var(StreamType.CONTROL)
+ encode_frame(FrameType.SETTINGS, encode_settings(DUMMY_SETTINGS)),
end_stream=False,
)
)
self.assertIsNone(quic_client.closed)
# receive unexpected FIN
h3_client.handle_event(
StreamDataReceived(
stream_id=3,
data=b"",
end_stream=True,
)
)
self.assertEqual(
quic_client.closed,
(
ErrorCode.H3_CLOSED_CRITICAL_STREAM,
"Closing control stream is not allowed",
),
)
可以看到在流不被允许时,则可以通过handle_event
处理流关闭事件,记录信息并报告上层。
一个建立好的 QUIC 连接可以被以下三种方式关闭:
在 AIOQUIC 中,Endpoint 为了能够正确处理连接关闭,有三种状态:
# connection.py
END_STATES = frozenset(
[
QuicConnectionState.CLOSING,
QuicConnectionState.DRAINING,
QuicConnectionState.TERMINATED,
]
)
以测试模块为例,AIOQUIC的endpoint在传递参数时告知对方:
并通过这些数值进行流量控制。
# test_packet.py
class ParamsTest(TestCase):
maxDiff = None
def test_params(self):
data = binascii.unhexlify(
"010267100210cc2fd6e7d97a53ab5be85b28d75c8008030247e404048005fff"
"a05048000ffff06048000ffff0801060a01030b0119"
)
# parse
buf = Buffer(data=data)
params = pull_quic_transport_parameters(buf)
self.assertEqual(
params,
QuicTransportParameters(
max_idle_timeout=10000,
stateless_reset_token=b"\xcc/\xd6\xe7\xd9zS\xab[\xe8[(\xd7\\\x80\x08",
max_udp_payload_size=2020,
initial_max_data=393210,
initial_max_stream_data_bidi_local=65535,
initial_max_stream_data_bidi_remote=65535,
initial_max_stream_data_uni=None,
initial_max_streams_bidi=6,
initial_max_streams_uni=None,
ack_delay_exponent=3,
max_ack_delay=25,
),
)
# serialize
buf = Buffer(capacity=len(data))
push_quic_transport_parameters(buf, params)
self.assertEqual(len(buf.data), len(data))
在后续如果需要修改流控参数,则通过以下 Frame 进行:
AIOQUIC丢包检测依旧是主要依赖于RTT,但不同的是,其根据QUIC协议优化了RTT的计算方式,在QUIC草案中定义了如下RTT参数:
RTT的估算是基于ACK的最大包号的,在AIOQUIC的实现中,RTT由QuicPacketRecovery
类负责。
# connection.py
class QuicPacketRecovery:
"""
Packet loss and congestion controller.
"""
# ...
def on_ack_received(
self,
space: QuicPacketSpace,
ack_rangeset: RangeSet,
ack_delay: float,
now: float,
) -> None:
"""
Update metrics as the result of an ACK being received.
"""
is_ack_eliciting = False
largest_acked = ack_rangeset.bounds().stop - 1
largest_newly_acked = None
largest_sent_time = None
if largest_acked > space.largest_acked_packet:
space.largest_acked_packet = largest_acked
for packet_number in sorted(space.sent_packets.keys()):
# ...
# nothing to do if there are no newly acked packets
if largest_newly_acked is None:
return
# ...
具体的计算则委托给QuicPacketRecovery
的_detect_loss
完成,在endpoint进程中维护了一个疑似丢包以及重传的定时器,在收到疑似触发丢包的ACK
时则会引起丢包检测。
# recovery.py
class QuicPacketRecovery:
"""
Packet loss and congestion controller.
"""
# ...
def _detect_loss(self, space: QuicPacketSpace, now: float) -> None:
"""
Check whether any packets should be declared lost.
"""
loss_delay = K_TIME_THRESHOLD * (
max(self._rtt_latest, self._rtt_smoothed)
if self._rtt_initialized
else self._rtt_initial
)
packet_threshold = space.largest_acked_packet - K_PACKET_THRESHOLD
time_threshold = now - loss_delay
lost_packets = []
space.loss_time = None
for packet_number, packet in space.sent_packets.items():
if packet_number > space.largest_acked_packet:
break
if packet_number <= packet_threshold or packet.sent_time <= time_threshold:
lost_packets.append(packet)
else:
packet_loss_time = packet.sent_time + loss_delay
if space.loss_time is None or space.loss_time > packet_loss_time:
space.loss_time = packet_loss_time
self._on_packets_lost(lost_packets, space=space, now=now)
Endpoint 探测到一个错误时,应该通过 CONNECTION_CLOSE 或 RESET_STREAM 通知 Peer。
CONNECTION_CLOSE 和 RESET_STREAM Frame 中会传递错误码,需要注意的是,错误码分为两种:
# connection.py
class ErrorCode(IntEnum):
H3_NO_ERROR = 0x100
H3_GENERAL_PROTOCOL_ERROR = 0x101
H3_INTERNAL_ERROR = 0x102
H3_STREAM_CREATION_ERROR = 0x103
H3_CLOSED_CRITICAL_STREAM = 0x104
H3_FRAME_UNEXPECTED = 0x105
H3_FRAME_ERROR = 0x106
H3_EXCESSIVE_LOAD = 0x107
H3_ID_ERROR = 0x108
H3_SETTINGS_ERROR = 0x109
H3_MISSING_SETTINGS = 0x10A
H3_REQUEST_REJECTED = 0x10B
H3_REQUEST_CANCELLED = 0x10C
H3_REQUEST_INCOMPLETE = 0x10D
H3_MESSAGE_ERROR = 0x10E
H3_CONNECT_ERROR = 0x10F
H3_VERSION_FALLBACK = 0x110
QPACK_DECOMPRESSION_FAILED = 0x200
QPACK_ENCODER_STREAM_ERROR = 0x201
QPACK_DECODER_STREAM_ERROR = 0x202
AIOQUIC目前应该还是处于起步阶段的,最近的一次修改(落笔时为20221月)是三个月前对于脚本和工作流的优化,内核部分的代码是长时间没有更新的。毕竟是个相对小众的实现版本,社区氛围不是特别活跃,对于项目中遇到的问题,很多情况下可能只能通过官方文档和自己调试解决。大型项目中就不是特别推荐了,还是应该尽量选择如QUIC-GO这样比较稳定的内核。不过对于QUIC的学习而言,AIOQUIC低成本的构建和测试还是可以让学习曲线放缓不少的。
作者:NP549
请问为什么会有Traceback (most recent call last):
File "E:\ \demo1\aioquic-main\examples\http3_client.py", line 564, in
asyncio.run(
File "E:\Anaconda3\lib\asyncio\runners.py", line 44, in run
return loop.run_until_complete(main)
File "E:\Anaconda3\lib\asyncio\base_events.py", line 647, in run_until_complete
return future.result()
File "E:\ \demo1\aioquic-main\examples\http3_client.py", line 387, in main
async with connect(
File "E:\Anaconda3\lib\contextlib.py", line 181, in aenter
return await self.gen.anext()
File "E:\Anaconda3\lib\site-packages\aioquic\asyncio\client.py", line 88, in connect
await protocol.wait_connected()
File "E:\Anaconda3\lib\site-packages\aioquic\asyncio\protocol.py", line 127, in wait_connected
await asyncio.shield(self._connected_waiter)
ConnectionError 的报错,运行的client
你好,想请教一下,为什么每次运行server端的时候,都会出现下面这个报错
python examples/http3_server.py --certificate tests/ssl_cert.pem --private-key tests/ssl_key.pem
Traceback (most recent call last):
File "D:\python_pro\aioquic\examples\http3_server.py", line 542, in
default=defaults.max_datagram_size,
^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'QuicConfiguration' object has no attribute 'max_datagram_size'. Did you mean: 'max_datagram_frame_size'?
请教个问题,为什么经常出现quic协议连接失败的现象,偶发的。比如遍历连接一批IP时就会出现某个连接失败
想请教一个问题,为啥我运行example程序用WireShark抓包是UDP...