掌握 Python 的 asyncio 底层网络。本深入探讨涵盖了传输和协议,并提供了构建高性能定制网络应用程序的实用示例。
揭秘 Python 的 Asyncio 传输:深入了解底层网络
在现代 Python 的世界中,asyncio
已经成为高性能网络编程的基石。开发人员通常从其漂亮的高级 API 入手,使用 async
和 await
以及 aiohttp
或 FastAPI
等库,以惊人的轻松程度构建响应式应用程序。由 asyncio.open_connection()
等函数提供的 StreamReader
和 StreamWriter
对象提供了一种非常简单、顺序的方式来处理网络 I/O。但是,如果抽象不够用怎么办?如果您需要实现一个复杂的、有状态的或非标准的网络协议怎么办?如果您需要通过直接控制底层连接来挤出最后一滴性能怎么办?这就是 asyncio 网络功能的真正基础所在:底层传输和协议 API。虽然乍一看可能令人生畏,但了解这对强大的组合可以解锁新的控制和灵活性,使您能够构建几乎任何可以想象的网络应用程序。本综合指南将揭开抽象层,探索传输和协议之间的共生关系,并通过实际示例引导您掌握 Python 中的低级异步网络。
Asyncio 网络的双重面孔:高级与低级
在我们深入研究低级 API 之前,了解它们在 asyncio 生态系统中的位置至关重要。Asyncio 智能地为网络通信提供了两个不同的层,每个层都针对不同的用例。
高级 API:流
高级 API,通常称为“流”,是大多数开发人员首先遇到的。当您使用 asyncio.open_connection()
或 asyncio.start_server()
时,您会收到 StreamReader
和 StreamWriter
对象。此 API 旨在实现简单易用。
- 命令式风格: 它允许您编写看起来是顺序的代码。您
await reader.read(100)
以获取 100 个字节,然后writer.write(data)
以发送响应。这种async/await
模式直观且易于推理。 - 便捷的助手: 它提供了诸如
readuntil(separator)
和readexactly(n)
之类的方法,这些方法可以处理常见的帧处理任务,从而使您无需手动管理缓冲区。 - 理想的用例: 非常适合简单的请求-响应协议(例如基本的 HTTP 客户端)、基于行的协议(例如 Redis 或 SMTP),或通信遵循可预测的线性流的任何情况。
但是,这种简单性是有代价的。对于高度并发、事件驱动的协议,流式方法可能效率较低,在这种协议中,未经请求的消息可以随时到达。顺序的 await
模型会使处理同时发生的读取和写入或管理复杂的连接状态变得繁琐。
低级 API:传输和协议
这是高级流 API 实际构建的基础层。低级 API 使用基于两个不同组件的设计模式:传输和协议。
- 事件驱动风格: 当事件发生时(例如,建立连接,收到数据),asyncio 会调用您对象上的方法,而不是您调用函数来获取数据。这是一种基于回调的方法。
- 关注点分离: 它清楚地将“什么”与“如何”分开。协议定义了如何处理数据(您的应用程序逻辑),而传输处理了如何通过网络发送和接收数据(I/O 机制)。
- 最大控制: 此 API 使您可以精细控制缓冲、流量控制(反压)和连接生命周期。
- 理想的用例: 对于实现自定义二进制或文本协议、构建处理数千个持久连接的高性能服务器或开发网络框架和库至关重要。
可以这样想:流 API 就像订购膳食包服务。您将获得预先分配的成分和简单的食谱。传输和协议 API 就像在专业厨房中担任厨师,拥有原始成分并可以完全控制流程的每一步。两者都可以制作出美味佳肴,但后者提供了无限的创造力和控制力。
核心组件:更仔细地观察传输和协议
低级 API 的强大之处在于协议和传输之间优雅的交互。它们是任何低级 asyncio 网络应用程序中截然不同但不可分割的合作伙伴。
协议:您应用程序的大脑
协议是您编写的类。它继承自 asyncio.Protocol
(或其变体之一),并包含处理单个网络连接的状态和逻辑。您自己不实例化此类;您将其提供给 asyncio(例如,loop.create_server
),并且 asyncio 会为每个新的客户端连接创建协议的新实例。
您的协议类由一组事件处理程序方法定义,事件循环在连接生命周期的不同时间点调用这些方法。其中最重要的有:
connection_made(self, transport)
当成功建立新连接时,只调用一次。这是您的入口点。您可以在这里接收代表连接的 transport
对象。您应该始终保存对它的引用,通常保存为 self.transport
。这是执行任何按连接初始化(例如设置缓冲区或记录对等方的地址)的理想位置。
data_received(self, data)
您协议的核心。每当从连接的另一端收到新数据时,都会调用此方法。data
参数是一个 bytes
对象。重要的是要记住,TCP 是一种流协议,而不是消息协议。来自您的应用程序的单个逻辑消息可能会在多个 data_received
调用中拆分,或者多个小消息可能会捆绑到单个调用中。您的代码必须处理此缓冲和解析。
connection_lost(self, exc)
连接关闭时调用。发生这种情况的原因有多种。如果连接已干净地关闭(例如,另一方关闭了连接,或者您调用了 transport.close()
),则 exc
将为 None
。如果连接由于错误而关闭(例如,网络故障、重置),则 exc
将是一个详细说明错误的异常对象。这是您执行清理、记录断开连接或尝试重新连接(如果您正在构建客户端)的机会。
eof_received(self)
这是一个更微妙的回调。当另一端发出信号表明它不会发送更多数据时(例如,通过在 POSIX 系统上调用 shutdown(SHUT_WR)
),会调用此方法,但连接可能仍然打开供您发送数据。如果您从此方法返回 True
,则传输将关闭。如果您返回 False
(默认值),您将负责稍后自己关闭传输。
传输:通信通道
传输是由 asyncio 提供的对象。您不创建它;您在协议的 connection_made
方法中接收它。它充当底层网络套接字和事件循环的 I/O 调度的更高级别抽象。它的主要工作是处理数据的发送和连接的控制。
您通过其方法与传输进行交互:
transport.write(data)
发送数据的主要方法。data
必须是 bytes
对象。此方法是非阻塞的。它不会立即发送数据。相反,它将数据放入内部写入缓冲区,并且事件循环会尽可能高效地在后台通过网络发送数据。
transport.writelines(list_of_data)
一种更有效的方式,可将 bytes
对象的序列一次性写入缓冲区,从而可能减少系统调用的次数。
transport.close()
这会启动一个正常关闭。传输将首先刷新其写入缓冲区中剩余的任何数据,然后关闭连接。调用 close()
后,不能再写入任何数据。
transport.abort()
这会执行硬关闭。连接立即关闭,并且丢弃写入缓冲区中挂起的任何数据。这应该在特殊情况下使用。
transport.get_extra_info(name, default=None)
一种非常有用的内省方法。您可以获取有关连接的信息,例如对等方的地址 ('peername'
)、底层套接字对象 ('socket'
) 或 SSL/TLS 证书信息 ('ssl_object'
)。
共生关系
这种设计的美妙之处在于清晰的、循环的信息流:
- 设置: 事件循环接受新连接。
- 实例化: 循环创建
Protocol
类的实例和一个表示连接的Transport
对象。 - 链接: 循环调用
your_protocol.connection_made(transport)
,将两个对象链接在一起。您的协议现在有一种发送数据的方式。 - 接收数据: 当数据到达网络套接字时,事件循环会唤醒,读取数据,并调用
your_protocol.data_received(data)
。 - 处理: 您的协议的逻辑处理收到的数据。
- 发送数据: 根据其逻辑,您的协议调用
self.transport.write(response_data)
以发送回复。数据被缓冲。 - 后台 I/O: 事件循环处理通过传输非阻塞地发送缓冲的数据。
- 拆卸: 当连接结束时,事件循环调用
your_protocol.connection_lost(exc)
以进行最终清理。
构建实际示例:回显服务器和客户端
理论很棒,但理解传输和协议的最佳方式是构建一些东西。让我们创建一个经典的回显服务器和一个相应的客户端。服务器将接受连接,并简单地发回它收到的任何数据。
回显服务器实现
首先,我们将定义我们的服务器端协议。它非常简单,展示了核心事件处理程序。
import asyncio
class EchoServerProtocol(asyncio.Protocol):
def connection_made(self, transport):
# A new connection is established.
# Get the remote address for logging.
peername = transport.get_extra_info('peername')
print(f"Connection from: {peername}")
# Store the transport for later use.
self.transport = transport
def data_received(self, data):
# Data is received from the client.
message = data.decode()
print(f"Data received: {message.strip()}")
# Echo the data back to the client.
print(f"Echoing back: {message.strip()}")
self.transport.write(data)
def connection_lost(self, exc):
# The connection has been closed.
print("Connection closed.")
# The transport is automatically closed, no need to call self.transport.close() here.
async def main_server():
# Get a reference to the event loop as we plan to run the server indefinitely.
loop = asyncio.get_running_loop()
host = '127.0.0.1'
port = 8888
# The `create_server` coroutine creates and starts the server.
# The first argument is the protocol_factory, a callable that returns a new protocol instance.
# In our case, simply passing the class `EchoServerProtocol` works.
server = await loop.create_server(
lambda: EchoServerProtocol(),
host,
port)
addrs = ', '.join(str(sock.getsockname()) for sock in server.sockets)
print(f'Serving on {addrs}')
# The server runs in the background. To keep the main coroutine alive,
# we can await something that never completes, like a new Future.
# For this example, we'll just run it "forever".
async with server:
await server.serve_forever()
if __name__ == "__main__":
try:
# To run the server:
asyncio.run(main_server())
except KeyboardInterrupt:
print("Server shut down.")
在此服务器代码中,loop.create_server()
是关键。它绑定到指定的主机和端口,并告诉事件循环开始侦听新连接。对于每个传入连接,它会调用我们的 protocol_factory
(lambda: EchoServerProtocol()
函数)以创建一个专用于该特定客户端的全新协议实例。
回显客户端实现
客户端协议稍微复杂一些,因为它需要管理自己的状态:要发送什么消息以及何时认为其工作“完成”。一种常见的模式是使用 asyncio.Future
或 asyncio.Event
将完成信号发回给启动客户端的主协程。
import asyncio
class EchoClientProtocol(asyncio.Protocol):
def __init__(self, message, on_con_lost):
self.message = message
self.on_con_lost = on_con_lost
self.transport = None
def connection_made(self, transport):
self.transport = transport
print(f"Sending: {self.message}")
self.transport.write(self.message.encode())
def data_received(self, data):
print(f"Received echo: {data.decode().strip()}")
def connection_lost(self, exc):
print("The server closed the connection")
# Signal that the connection is lost and the task is complete.
self.on_con_lost.set_result(True)
def eof_received(self):
# This can be called if the server sends an EOF before closing.
print("Received EOF from server.")
async def main_client():
loop = asyncio.get_running_loop()
# The on_con_lost future is used to signal the completion of the client's work.
on_con_lost = loop.create_future()
message = "Hello World!"
host = '127.0.0.1'
port = 8888
# `create_connection` establishes the connection and links the protocol.
try:
transport, protocol = await loop.create_connection(
lambda: EchoClientProtocol(message, on_con_lost),
host,
port)
except ConnectionRefusedError:
print("Connection refused. Is the server running?")
return
# Wait until the protocol signals that the connection is lost.
try:
await on_con_lost
finally:
# Gracefully close the transport.
transport.close()
if __name__ == "__main__":
# To run the client:
# First, start the server in one terminal.
# Then, run this script in another terminal.
asyncio.run(main_client())
在这里,loop.create_connection()
是客户端对应于 create_server
的方法。它尝试连接到给定的地址。如果成功,它会实例化我们的 EchoClientProtocol
并调用其 connection_made
方法。on_con_lost
Future 的使用是一种关键模式。main_client
协程 await
此 future,有效地暂停其自身的执行,直到协议通过从 connection_lost
中调用 on_con_lost.set_result(True)
来发出其工作已完成的信号。
高级概念和真实场景
回显示例涵盖了基础知识,但真实世界的协议很少如此简单。让我们探讨一些您不可避免地会遇到的更高级的主题。
处理消息帧和缓冲
在掌握基础知识之后,要掌握的最重要的概念是 TCP 是一个字节流。没有固有的“消息”边界。如果客户端发送“Hello”,然后发送“World”,则您的服务器的 data_received
可能会被调用一次,其中 b'HelloWorld'
、两次,其中 b'Hello'
和 b'World'
,甚至多次,其中包含部分数据。
您的协议负责“帧处理”——将这些字节流重新组装成有意义的消息。一种常见的策略是使用分隔符,例如换行符 (\n
)。
这是一个修改后的协议,它缓冲数据直到找到换行符,一次处理一行。
class LineBasedProtocol(asyncio.Protocol):
def __init__(self):
self._buffer = b''
self.transport = None
def connection_made(self, transport):
self.transport = transport
print("Connection established.")
def data_received(self, data):
# Append new data to the internal buffer
self._buffer += data
# Process as many complete lines as we have in the buffer
while b'\n' in self._buffer:
line, self._buffer = self._buffer.split(b'\n', 1)
self.process_line(line.decode().strip())
def process_line(self, line):
# This is where your application logic for a single message goes
print(f"Processing complete message: {line}")
response = f"Processed: {line}\n"
self.transport.write(response.encode())
def connection_lost(self, exc):
print("Connection lost.")
管理流量控制(反压)
如果您的应用程序向传输写入数据的速度快于网络或远程对等方可以处理的速度,会发生什么情况?数据堆积在传输的内部缓冲区中。如果这种情况持续不受控制,缓冲区可能会无限增长,从而消耗所有可用内存。此问题称为缺少“反压”。
Asyncio 提供了一种处理此问题的机制。传输会监视其自身的缓冲区大小。当缓冲区增长超过某个高水位线时,事件循环会调用您协议的 pause_writing()
方法。这是向您的应用程序发出的停止发送数据的信号。当缓冲区已排空到低于低水位线时,循环会调用 resume_writing()
,指示可以再次安全地发送数据。
class FlowControlledProtocol(asyncio.Protocol):
def __init__(self):
self._paused = False
self._data_source = some_data_generator() # Imagine a source of data
self.transport = None
def connection_made(self, transport):
self.transport = transport
self.resume_writing() # Start the writing process
def pause_writing(self):
# The transport buffer is full.
print("Pausing writing.")
self._paused = True
def resume_writing(self):
# The transport buffer has drained.
print("Resuming writing.")
self._paused = False
self._write_more_data()
def _write_more_data(self):
# This is our application's write loop.
while not self._paused:
try:
data = next(self._data_source)
self.transport.write(data)
except StopIteration:
self.transport.close()
break # No more data to send
# Check buffer size to see if we should pause immediately
if self.transport.get_write_buffer_size() > 0:
self.pause_writing()
超越 TCP:其他传输
虽然 TCP 是最常见的用例,但传输/协议模式并不限于它。Asyncio 为其他通信类型提供了抽象:
- UDP: 对于无连接通信,您可以使用
loop.create_datagram_endpoint()
。这会为您提供一个DatagramTransport
,并且您将实现一个asyncio.DatagramProtocol
,其中包含诸如datagram_received(data, addr)
和error_received(exc)
之类的方法。 - SSL/TLS: 添加加密非常简单。您可以将
ssl.SSLContext
对象传递给loop.create_server()
或loop.create_connection()
。Asyncio 会自动处理 TLS 握手,并且您会获得一个安全的传输。您的协议代码根本不需要更改。 - 子进程: 为了通过子进程的标准 I/O 管道与子进程进行通信,可以将
loop.subprocess_exec()
和loop.subprocess_shell()
与asyncio.SubprocessProtocol
一起使用。这使您可以以完全异步、非阻塞的方式管理子进程。
战略决策:何时使用传输与流
有了两个强大的 API 可供您使用,一个关键的架构决策是为作业选择正确的 API。这是一个帮助您做出决定的指南。
当...时,选择流(StreamReader
/StreamWriter
)
- 您的协议很简单且基于请求-响应。 如果逻辑是“读取请求、处理它、写入响应”,那么流是完美的。
- 您正在为众所周知的、基于行的或固定长度的消息协议构建客户端。 例如,与 Redis 服务器或简单的 FTP 服务器进行交互。
- 您优先考虑代码可读性和线性、命令式风格。 对于不熟悉异步编程的开发人员来说,使用流的
async/await
语法通常更容易理解。 - 快速原型设计是关键。 您只需几行代码即可使用流启动并运行一个简单的客户端或服务器。
当...时,选择传输和协议
- 您正在从头开始实现复杂的或自定义的网络协议。 这是主要用例。考虑用于游戏、金融数据馈送、IoT 设备或对等应用程序的协议。
- 您的协议是高度事件驱动的,而不是纯粹的请求-响应。 如果服务器可以随时向客户端发送未经请求的消息,那么协议的基于回调的性质更自然。
- 您需要最大的性能和最小的开销。 协议为您提供了通向事件循环的更直接路径,从而绕过了与流 API 相关的一些开销。
- 您需要对连接进行细粒度控制。 这包括手动缓冲区管理、显式流量控制 (
pause/resume_writing
) 以及连接生命周期的详细处理。 - 您正在构建网络框架或库。 如果您正在为其他开发人员提供工具,那么协议/传输 API 的强大而灵活的性质通常是正确的基础。
结论:拥抱 Asyncio 的基础
Python 的 asyncio
库是分层设计的杰作。虽然高级流 API 提供了一个可访问且高效的入口点,但低级传输和协议 API 代表了 asyncio 网络功能的真正、强大的基础。通过将 I/O 机制(传输)与应用程序逻辑(协议)分离,它为构建复杂的网络应用程序提供了一个强大、可扩展且极其灵活的模型。
理解这种低级抽象不仅仅是一种学术练习;它是一种实用技能,使您能够超越简单的客户端和服务器。它使您有信心应对任何网络协议,有能力在压力下优化性能,并有能力构建下一代 Python 中的高性能异步服务。下次您遇到具有挑战性的网络问题时,请记住隐藏在地表之下的力量,并且不要犹豫去寻求传输和协议的优雅组合。