Python高级技术之:`Python`的`sockets`编程:阻塞式和非阻塞式`socket`的内部实现。

各位观众老爷们,晚上好!我是今天的讲师,咱们今天的主题是Python的Sockets编程,重点是阻塞式和非阻塞式Socket的内部实现。别害怕,虽然标题听起来有点吓人,但咱们的目标是把它讲得像听相声一样轻松愉快。

一、Socket:网络世界的门面担当

首先,咱们得明白Socket是个啥玩意儿。简单来说,Socket就是应用程序访问网络世界的接口。你可以把它想象成你家门口的快递接收点。你想网购(发送数据),快递员(网络协议)就把东西送到你家门口(Socket),你再签收(接收数据)。或者你想寄快递(发送数据),你把东西放到你家门口(Socket),快递员(网络协议)再取走。

在Python里,socket 模块提供了访问底层操作系统Socket接口的能力。 用人话说,就是Python帮你把网络编程的复杂细节藏起来了,你只需要用简单的函数就能实现网络通信。

二、阻塞式Socket:老老实实等结果

阻塞式Socket,顾名思义,就是老老实实地等待结果。 当你调用 socket.recv() 接收数据时,如果数据还没来,你的程序就会卡在那里,啥也不干,直到收到数据为止。 这就像你打电话给朋友,如果他没接,你就得一直拿着电话等着,啥也干不了。

1. 例子:简单的阻塞式服务器

import socket

# 创建一个TCP/IP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# 绑定到地址和端口
server_address = ('localhost', 12345)
sock.bind(server_address)

# 监听连接
sock.listen(1)

print('等待连接...')

while True:
    # 等待连接
    connection, client_address = sock.accept()
    try:
        print('连接来自', client_address)

        # 接收数据并回复
        while True:
            data = connection.recv(16) # 阻塞在这里,直到收到数据
            if data:
                print('收到: ', data.decode())
                connection.sendall(data) # 阻塞在这里,直到数据发送完毕
            else:
                print('没有数据来自', client_address)
                break

    finally:
        # 清理连接
        connection.close()

在这个例子中,sock.accept()connection.recv() 都是阻塞式的。sock.accept() 会一直等待,直到有客户端连接过来。 connection.recv() 会一直等待,直到收到客户端发送的数据。 connection.sendall() 会一直等待,直到数据发送完毕。

2. 阻塞式Socket的内部实现

阻塞式Socket的实现依赖于操作系统的底层机制。 当你调用 recv() 时,操作系统会将你的程序置于等待状态,直到有数据到达。 操作系统会维护一个等待队列,记录所有等待数据的Socket。 一旦有数据到达,操作系统会唤醒相应的程序,并将数据传递给它。

更底层一点说,这涉及到系统调用(System Call)。 recv() 实际上会调用底层的 recv() 系统调用,该系统调用会进入内核态,由内核负责处理网络数据的接收。

3. 阻塞式Socket的优缺点

  • 优点: 编程简单,逻辑清晰,易于理解。
  • 缺点: 效率低,当一个Socket阻塞时,整个程序都会卡住,无法处理其他任务。不适合高并发场景。

三、非阻塞式Socket:我先看看有没有空

非阻塞式Socket则不然,它不会一直等待结果。 当你调用 socket.recv() 时,如果数据还没来,它会立即返回,告诉你“没收到数据”。 你可以过一会儿再来问问,看看数据是不是来了。 这就像你打电话给朋友,如果他没接,你就挂断电话,过一会儿再打。

1. 例子:简单的非阻塞式服务器

import socket
import select

# 创建一个TCP/IP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False) # 设置为非阻塞

# 绑定到地址和端口
server_address = ('localhost', 12345)
sock.bind(server_address)

# 监听连接
sock.listen(5)

inputs = [sock] # 监听的sockets列表

print('等待连接...')

while inputs:
    # 使用 select() 等待 socket 变为可读
    readable, writable, exceptional = select.select(inputs, [], [])

    # 处理可读的 socket
    for s in readable:
        if s is sock:
            # 新的连接
            connection, client_address = sock.accept()
            connection.setblocking(False)
            inputs.append(connection)
            print('连接来自', client_address)
        else:
            # 来自客户端的数据
            try:
                data = s.recv(16) # 非阻塞读取
                if data:
                    print('收到: ', data.decode(), '来自', s.getpeername())
                    s.sendall(data)
                else:
                    print('关闭连接', s.getpeername())
                    inputs.remove(s)
                    s.close()
            except ConnectionResetError:
                print('客户端强制关闭连接', s.getpeername())
                inputs.remove(s)
                s.close()

在这个例子中,我们首先使用 sock.setblocking(False) 将Socket设置为非阻塞模式。 然后,我们使用 select.select() 函数来监听Socket的状态。 select.select() 会返回三个列表:readable (可读的sockets), writable (可写的sockets), exceptional (发生异常的sockets)。

select.select() 本身也是阻塞的,但它允许你同时监听多个Socket。 当任何一个Socket变为可读时,select.select() 就会返回。 这意味着你可以同时处理多个客户端的请求,而不会被单个客户端阻塞。

2. 非阻塞式Socket的内部实现

非阻塞式Socket的实现也依赖于操作系统的底层机制。 当你调用 recv() 时,如果数据还没来,操作系统会立即返回一个错误码(通常是 EAGAINEWOULDBLOCK)。 你的程序需要检查这个错误码,并决定是否继续尝试接收数据。

select() 函数的实现也依赖于操作系统的底层机制。 在Linux上,select() 通常使用 epollpoll 系统调用来实现。 这些系统调用允许你注册多个Socket,并监听它们的状态。 当任何一个Socket的状态发生变化时,操作系统会通知你。

3. 非阻塞式Socket的优缺点

  • 优点: 效率高,可以同时处理多个客户端的请求,适合高并发场景。
  • 缺点: 编程复杂,需要处理更多的错误情况,逻辑也更难理解。你需要自己管理Socket的状态,例如是否可读、是否可写等。

四、阻塞与非阻塞的对比

为了更清晰地理解阻塞式和非阻塞式Socket的区别,我们用一个表格来总结一下:

特性 阻塞式Socket 非阻塞式Socket
recv() 阻塞,直到收到数据或连接关闭 立即返回,如果没有数据则返回错误码
send() 阻塞,直到数据发送完毕或连接关闭 立即返回,如果缓冲区满则返回错误码
效率 低,单个Socket阻塞会导致整个程序阻塞 高,可以同时处理多个Socket,提高并发能力
编程复杂度 低,逻辑简单,易于理解 高,需要处理更多的错误情况,逻辑复杂
适用场景 低并发,简单的网络应用 高并发,需要处理大量连接的网络应用,例如Web服务器、游戏服务器等。
需要的系统调用 通常使用 recv()send() 系统调用。 通常使用 recv(), send(), select(), epoll() 等系统调用。

五、selectpollepoll:非阻塞的幕后英雄

刚才提到了 select(),现在咱们深入聊聊它,顺便介绍一下它的两个升级版:pollepoll。 这三位都是非阻塞I/O模型的关键人物,它们的作用是让你能够同时监听多个Socket,而不会被单个Socket阻塞。

1. select

select 是最古老也是最简单的多路复用技术。 它可以同时监听多个文件描述符(File Descriptor,包括Socket)。

  • 原理: select 函数会遍历你传入的所有文件描述符,检查它们是否可读、可写或发生错误。 如果有任何一个文件描述符满足条件,select 就会返回。
  • 缺点:
    • 最大文件描述符数量限制: select 能够监听的文件描述符数量有限制,通常是1024。
    • 效率低: 每次调用 select 都需要遍历所有文件描述符,效率较低。

2. poll

poll 是对 select 的改进。 它解决了 select 的最大文件描述符数量限制。

  • 原理: poll 使用一个 pollfd 结构体数组来存储要监听的文件描述符和事件。 它可以监听的文件描述符数量没有上限(受系统资源限制)。
  • 缺点: 仍然需要遍历所有文件描述符,效率较低。

3. epoll

epoll 是Linux特有的多路复用技术,也是目前最先进的多路复用技术。 它解决了 selectpoll 的效率问题。

  • 原理: epoll 使用事件驱动的方式。 当你注册一个文件描述符到 epoll 实例时,操作系统会监听该文件描述符的状态。 一旦该文件描述符的状态发生变化(例如可读、可写),操作系统会立即通知你。
  • 优点:
    • 效率高: 不需要遍历所有文件描述符,只需要处理状态发生变化的文件描述符。
    • 支持边缘触发(Edge-Triggered)和水平触发(Level-Triggered): 边缘触发只通知一次,水平触发会一直通知,直到你处理完该事件。

4. 代码示例:epoll 的使用

import socket
import select

# 创建一个TCP/IP socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)

# 绑定到地址和端口
server_address = ('localhost', 12345)
sock.bind(server_address)

# 监听连接
sock.listen(5)

# 创建 epoll 对象
epoll = select.epoll()
epoll.register(sock.fileno(), select.EPOLLIN) # 注册监听socket的可读事件

connections = {} # 存储连接

try:
    while True:
        events = epoll.poll(1) # 等待事件发生,timeout=1秒
        for fileno, event in events:
            if fileno == sock.fileno():
                # 新的连接
                connection, address = sock.accept()
                connection.setblocking(False)
                epoll.register(connection.fileno(), select.EPOLLIN) # 注册连接socket的可读事件
                connections[connection.fileno()] = connection
                print('连接来自', address)
            elif event & select.EPOLLIN:
                # 可读事件
                connection = connections[fileno]
                try:
                    data = connection.recv(16)
                    if data:
                        print('收到: ', data.decode(), '来自', connection.getpeername())
                        connection.sendall(data)
                    else:
                        print('关闭连接', connection.getpeername())
                        epoll.unregister(fileno)
                        connection.close()
                        del connections[fileno]
                except ConnectionResetError:
                    print('客户端强制关闭连接', connection.getpeername())
                    epoll.unregister(fileno)
                    connection.close()
                    del connections[fileno]

finally:
    epoll.close()
    sock.close()

这个例子展示了如何使用 epoll 来实现非阻塞I/O。 我们首先创建了一个 epoll 对象,然后将监听Socket注册到 epoll 对象中,监听可读事件。 当有新的连接到来时,我们将新的连接Socket也注册到 epoll 对象中。 当某个Socket变为可读时,epoll.poll() 函数会返回该Socket的文件描述符和事件类型。 我们可以根据事件类型来处理该Socket。

六、总结

今天咱们聊了Python的Sockets编程,重点是阻塞式和非阻塞式Socket的内部实现。 阻塞式Socket简单易用,但效率较低,适合低并发场景。 非阻塞式Socket效率高,但编程复杂,适合高并发场景。 为了实现高效的非阻塞I/O,我们可以使用 selectpollepoll 等多路复用技术。

选择哪种方式取决于你的具体需求。 如果你的应用只需要处理少量的连接,阻塞式Socket可能就足够了。 如果你的应用需要处理大量的并发连接,非阻塞式Socket和 epoll 可能是更好的选择。

希望今天的讲解对大家有所帮助! 记住,编程就像相声,要幽默风趣,才能更好地理解它。 咱们下期再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注