Python 3 爬虫|第1章:I/O Models 阻塞/非阻塞 同步/异步

  • 原创
  • Madman
  • /
  • /
  • 0
  • 3462 次阅读

Python 3 爬虫-min.png

Synopsis: Richard Stevens 所著的《UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking》书籍中章节 6.2 "IO Models",列出了五种 I/O 模型,本文将详细介绍这几种 I/O 模型,并说明阻塞(blocking)和非阻塞(non-blocking)的区别、同步(synchronous)和异步(asynchronous)的区别

代码已上传到 https://github.com/wangy8961/python3-concurrency/tree/master/io-models ,欢迎 star

1. 预备知识

1.1 CPU Ring

对于操作系统而言,稳定且可靠地运行是最重要的。现行的技术方案是将 内核 与用户进程、用户进程与用户进程之间进行分离,内核 可以管理用户进程,但是用户进程之间不能互相干扰,更不能 "侵入" 内核,即使用户的程序崩溃了,内核也不会受到影响

为了提升计算机安全,避免恶意操作,CPU 的硬件机制中一般将使用权划分为 4 个 特权级别

cpu ring

Ring 0 的级别最高,可以执行一切指令,包括像清空内存、磁盘 I/O 操作等 特权指令(privilege instruction) 和其它 非特权指令,内核代码就运行在此模式下。Ring 3 的级别最低,只能执行非特权指令,用户进程都运行在此模式下。所以,CPU 模式(CPU models) 就可以划分为:

  • kernel mode,也叫 内核态
  • user mode,也叫 用户态

计算机开机启动后,首先会加载内核,由于占了 先机,操作系统内核将自己设置为最高级别,而之后创建的用户进程都设置为最低级别。这样一来,内核就能控制 CPU、内存、磁盘等一切资源,而用户进程不能 直接使用 这些资源。例如,如果用户进程可以直接使用磁盘的话,就没必要在内核中实现一套文件系统的权限管理了

1.2 Kernel space vs. User space

不管是内核代码还是用户程序代码都需要加载到 内存 中才能运行,如果不对 内存 进行管理,就会出现用户代码与内核之间、用户代码与用户代码之间出现被覆盖的情况,所以内核将 内存 划分成两部分:

  • 内核空间(kernel space): 内核代码的运行空间
  • 用户空间(user space): 用户应用程序代码的运行空间

kernel-space-vs-user-space

用户进程只能访问 用户空间,而内核可以访问所有内存。因为内核已将用户进程设置为最低级别,用户进程只能运行在 CPU 的 Ring 3 上,所以如果用户进程要进行磁盘 I/O 或网络 I/O 等操作时,只能通过发起 系统调用(system call) 将请求发给内核,由内核代为执行相应的指令(CPU 模式会由 用户态 转成 内核态)。I/O 数据会先缓存到 内核空间 中,然后内核将数据从 内核空间 拷贝到 用户空间 中,之后用户进程才能继续处理数据(CPU 模式由 内核态 又转成 用户态

system call

Linux System Call Table

1.3 blocking vs. non-blocking

  • 阻塞(blocking): 用户进程在等待某个操作完成期间,自身无法继续干别的事情,则称进程在该操作上是阻塞的。Blocking I/O means that the calling system does not return control to the caller until the operation is finished
  • 非阻塞(non-blocking): 用户进程在等待某个操作完成期间,自身可以继续干别的事情,则称进程在该操作上是非阻塞的。A non-blocking synchronous call returns control to the caller immediately. The caller is not made to wait, and the invoked system immediately returns one of two responses: If the call was executed and the results are ready, then the caller is told of that. Alternatively, the invoked system can tell the caller that the system has no resources (no data in the socket) to perform the requested action. In that case, it is the responsibility of the caller may repeat the call until it succeeds. For example, a read() operation on a socket in non-blocking mode may return the number of read bytes or a special return code -1 with errno set to EWOULBLOCK/EAGAIN, meaning "not ready; try again later."

1.4 Synchronous I/O vs. Asynchronous I/O

  • A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes.
  • An asynchronous I/O operation does not cause the requesting process to be blocked.

如果用户进程因为 I/O 操作而阻塞的话,那么此 I/O 就是 同步 I/O,否则是 异步 I/O。后续要介绍的 blocking I/Ononblocking I/OI/O multiplexingsignal driven I/O 这四种 I/O 模型都是 同步 I/O,因为它们各自的第二阶段中的真正 I/O 操作(比如 recvfrom)都会阻塞用户进程,而只有 asynchronous I/O 模型才是真正的 异步 I/O

2. Unix 中五种 I/O 模型

  • blocking I/O
  • nonblocking I/O
  • I/O multiplexing (select, poll, epoll, kqueque)
  • signal driven I/O (SIGIO)
  • asynchronous I/O (the POSIX aio_read functions)

例如,用户进程要读取网络数据或磁盘数据时(Input),数据会经过两个阶段:

  • 内核空间 等待数据准备完成。Waiting for the data to be ready
  • 将数据从 内核空间 拷贝到 用户空间 中。Copying the data from the kernel to the process

网络套接字的输入操作第一步是等待网络数据包(此阶段对 CPU 来说耗时特别久),当数据包到达时,它会先被复制到 内核 的缓冲区中,第二步是将这些数据从 内核 的缓冲区复制到我们的应用程序缓冲区中(此阶段速度很快)。上述五种 I/O 模型的区别就在于 I/O 所经历的这两个阶段的差别上

2.1 blocking I/O

默认情况下,所有 套接字 都是阻塞的。下图中我们使用 UDP 的数据报套接字来说明网络 I/O 的两个阶段:

blocking io

首先是我们的用户进程运行(左边),当它需要获取网络 数据报(datagram) 时,用户进程只能通过 recvfrom 系统调用将请求发给 内核,然后在内核中运行(右边)

用户进程在两个阶段都是阻塞的,这期间它不能做任何其它事情,直到数据被拷贝到 用户空间(或发生错误,如系统调用被信号中断)后,我们的应用程序才能够继续处理数据报。即,用户进程从调用 recvfrom 到有数据返回的整个时间内,进程都是被阻塞的,所以它是 同步 I/O

举例来说,如果要下载 1000 张图片,用 阻塞 I/O(blocking I/O) 模型的话,必须依序下载,在等待第 1 张图片数据的时候,整个用户进程被阻塞,只有等第 1 张图片数据到达 内核空间,并被内核复制到 用户空间 后,才能保存到本地磁盘,然后依次类推,下载其它图片

(1) 单进程 TCP Server

如果 Web 服务器采用这种模式的话,那么同一时刻只能为 1 个客户服务。注意: 当服务器为这个客户服务的时候,只要服务器的 listen 队列还有空闲,那么当其它新的客户端发起连接后,服务器就会为新客户端建立连接,并且新客户端也可以发送数据,但服务器还不会处理。只有当第 1 个客户关闭连接后,服务器才会一次性将第 2 个客户发送的所有数据接收完,并继续只为第 2 个客户服务,依次类推:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# TCP Echo Server,单进程,阻塞 blocking I/O
import socket


# 创建监听socket
server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

# socket默认不支持地址复用,OSError: [Errno 98] Address already in use
server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

# 绑定IP地址和固定端口
server_address = ('', 9090)
print('TCP Server starting up on port {}'.format(server_address[1]))
server_sock.bind(server_address)

# socket默认是主动连接,调用listen()函数将socket变为被动连接,这样就可以接收客户端连接了
server_sock.listen(5)

try:
    while True:
        print('Main Process, waiting for client connection...')

        # client_sock是专为这个客户端服务的socket,client_addr是包含客户端IP和端口的元组
        client_sock, client_addr = server_sock.accept()
        print('Client {} is connected'.format(client_addr))

        try:
            while True:
                # 接收客户端发来的数据,阻塞,直到有数据到来
                # 事实上,除非当前客户端关闭后,才会跳转到外层的while循环,即一次只能服务一个客户
                # 如果客户端关闭了连接,data是空字符串
                data = client_sock.recv(4096)
                if data:
                    print('Received {}({} bytes) from {}'.format(data, len(data), client_addr))
                    # 返回响应数据,将客户端发送来的数据原样返回
                    client_sock.send(data)
                    print('Sent {} to {}'.format(data, client_addr))
                else:
                    print('Client {} is closed'.format(client_addr))
                    break
        finally:
            # 关闭为这个客户端服务的socket
            client_sock.close()
finally:
    # 关闭监听socket,不再响应其它客户端连接
    server_sock.close()

TCP 客户端测试代码如下:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import time
from datetime import datetime
import socket


server_ip = input('Please enter the TCP server ip: ')
server_port = int(input('Enter the TCP server port: '))
client_num = int(input('Enter the TCP clients count: '))

# 保存所有已成功连接的客户端TCP socket
client_socks = []

for i in range(client_num):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect((server_ip, server_port))
    client_socks.append(sock)
    print('Client {}[ID: {}] has connected to {}'.format(sock, i, (server_ip, server_port)))

while True:
    for s in client_socks:
        data = str(datetime.now()).encode('utf-8')
        s.send(data)
        print('Client {} has sent {} to {}'.format(s, data, (server_ip, server_port)))
    # 睡眠3秒后,继续让每个客户端连接向TCP Server发送数据
    time.sleep(3)

当你指定 50 个客户端时,你可以观察到服务端输出结果和客户端输出结果会如前所述

如果你使用 Windows 系统,也可以去下载 Hercules SETUP utility 当作 TCP 客户端。先打开一个 Hercules 使用 TCP Client 去连接服务器,如果再打开一个 Hercules,可以发现,也能够连接且可以发送数据,但服务器不会处理数据也就不会返回(此时,在 Linux 服务器上执行 watch -d -n 1 'netstat|grep tcp',可以查看到 TCP 连接状态会有很多 SYN_RECV

(2) 多进程 TCP Server

由于上面单进程版本中,client_sock.recv(4096) 会一直阻塞,所以实际上并不能跳转到外层 while 循环中去为其它新的客户端创建 socket,只能一次为 1 个客户服务。这根本满足不了实际应用需要,为了实现并发处理多个客户端请求,可以使用多进程,应用程序的主进程只负责为每一个新的客户端连接创建 socket,然后为每个客户创建一个子进程,用来分别处理每个客户的数据:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# TCP Echo Server,多进程,阻塞 blocking I/O
import os
import socket
from multiprocessing import Process


def client_handler(client_sock, client_addr
                                
                            
未经允许不得转载: LIFE & SHARE - 王颜公子 » Python 3 爬虫|第1章:I/O Models 阻塞/非阻塞 同步/异步

分享

作者

作者头像

Madman

如需 Linux / Python 相关问题付费解答,请按如下方式联系我

0 条评论

暂时还没有评论.

专题系列