- 使用
selectors 构建简单的非阻塞套接字事件循环
在第1章和第2章中,我们介绍了协程、任务和事件循环。我们也探讨了如何并发运行长时间操作,并了解了一些能帮助实现这一点的 asyncio API。但到目前为止,我们只是用 sleep 函数模拟了长时间操作。
既然我们要构建的不只是演示程序,那我们就得用真实的阻塞操作来展示如何创建一个能同时处理多个用户的服务器。我们只用一个线程,这样比使用多线程或多进程的方案更节省资源,也更简单。我们将运用之前学到的协程、任务和 asyncio 的 API 方法,通过套接字搭建一个命令行回显服务器应用。学完本章后,你就能用 asyncio 做出能在单线程下同时服务多个用户、基于套接字的网络应用了。
首先,我们会学习如何用阻塞套接字发送和接收数据。然后尝试用这些套接字搭建一个多客户端回显服务器。在这个过程中,我们会发现:仅靠单个线程,根本无法让服务器同时正常响应多个客户端。接着,我们会通过将套接字设为非阻塞模式,并利用操作系统的事件通知机制来解决这个问题。这有助于我们理解 asyncio 事件循环底层的工作原理。最后,我们会用 asyncio 提供的非阻塞套接字协程,让多个客户端能顺利连接并并发收发消息。最终,我们还会给应用加上自定义关闭逻辑,确保服务器关闭时,正在传输的消息也能有时间完成。
在第1章中,我们介绍了套接字的概念。还记得吗?套接字是一种在网络上读写数据的方式。你可以把它想象成一个邮箱:你把信放进邮箱,它就会送到收件人地址。收件人打开信件阅读,甚至可能给你回信。
现在开始,我们要先创建主邮箱套接字,也就是我们的“服务器套接字”。这个套接字会先接收想要和我们通信的客户端连接请求。一旦服务器确认了连接,我们再创建一个用于和客户端通信的套接字。这样一来,我们的服务器就从一个单一邮箱变成了拥有多个邮局信箱的邮局。而客户端这边,依然可以看作只有一个邮箱,因为他们只用一个套接字和我们通信。当客户端连接上我们的服务器时,我们给他们分配一个信箱。之后,就用这个信箱来回传递消息(见图3.1)。
图 3.1 客户端连接到我们的服务器套接字。服务器随后创建一个新的套接字与客户端通信。
我们可以用 Python 内置的 socket 模块来创建这个服务器套接字。这个模块提供了读写和操作套接字的功能。我们先创建一个简单的服务器,监听来自客户端的连接,并在成功连接时打印一条消息。这个套接字将绑定到一个主机名和一个端口,成为所有客户端通信的主“服务器套接字”。
创建套接字需要几步。我们首先用 socket 函数来创建一个套接字:
import socketserver_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
这里,我们向 socket 函数传了两个参数。第一个是 socket.AF_INET——它告诉我们这个套接字能处理什么类型地址;在这里,就是主机名和端口号。第二个是 socket.SOCK_STREAM;这意味着我们使用的是 TCP 协议进行通信。
TCP,即传输控制协议,是一种设计用来在应用程序之间通过网络传输数据的协议。这个协议的设计重点是可靠性。它会做错误检查,保证数据按顺序送达,并且在需要时能重传数据。这种可靠性是有代价的,会带来一些额外开销。绝大多数互联网应用都是基于 TCP 构建的。与之相对的是 UDP(用户数据报协议),它可靠性较低,但开销小得多,性能通常更高。本书将专注于使用 TCP 套接字。
我们还调用了 setsockopt 来设置 SO_REUSEADDR 标志位为 1。这允许我们在停止并重启应用后重复使用端口号,避免出现“地址已被占用”的错误。如果不这么做,操作系统可能需要一段时间才能释放这个端口,导致我们的应用启动失败。
调用 socket.socket 让我们创建了一个套接字,但我们还不能立即开始通信,因为还没把这个套接字绑定到一个客户端能访问的地址上(我们的邮局总得有个门牌号吧!)。在这个例子中,我们将套接字绑定到本机的 127.0.0.1 地址,并选择一个任意的端口号 8000:
address = (127.0.0.1, 8000)server_socket.bind(server_address)
现在,我们的套接字已经设置在 127.0.0.1:8000 地址上了。这意味着客户端可以用这个地址向我们的服务器发送数据,而如果我们向客户端写入数据,他们看到的来源地址也会是这个。
接下来,我们需要主动监听来自想要连接到我们服务器的客户端的连接。为此,我们可以调用套接字的 listen 方法。这告诉套接字开始监听传入的连接,从而允许客户端连接到我们的服务器套接字。然后,我们通过调用套接字的 accept 方法来等待连接。这个方法会阻塞,直到收到连接为止,一旦收到连接,它就会返回一个连接对象和客户端的地址。这个连接本身也是一个套接字,我们可以用它来读取和写入客户端的数据:
server_socket.listen()connection, client_address = server_socket.accept()
有了这些组件,我们就拥有了构建一个基于套接字的服务器应用所需的所有基础构件,它能等待连接,并在建立连接后打印一条消息。
import socketserver_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # ❶server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)server_address = ('127.0.0.1', 8000) # ❷server_socket.bind(server_address) # ❸server_socket.listen()connection, client_address = server_socket.accept() # ❹print(f'I got a connection from {client_address}!')
- ❷ 将套接字地址设置为
127.0.0.1:8000。
在上面的代码中,当有客户端连接时,我们会获取他们的连接套接字和地址,并打印出已建立连接的消息。
现在,我们已经搭好了这个应用,那怎么连接它来测试呢?虽然有很多工具可以做到这一点,但在本章中,我们将使用 telnet 命令行应用程序。