文章目录
前言
相关关键词:进程、线程、协程、并发编程、IO多路复用、同步阻塞、异步非阻塞、setblocking(False)、socket、select、greenlet、gevent、Twisted、基于事件循环的异步非阻塞、scrapy…
一、进程、线程、协程之间的区别
- 进程是计算机资源分配的最小单元,主要被用来做数据隔离。线程是CPU调度和执行的基本单位。一个应用程序里边可以有多个进程,一个进程里可以有多个线程。
- 与其他语言相比,几乎用的是多线程,进程几乎不会在用;而在python中,I/O操作多的时候用多线程(通过threading模块),计算密集型的时候用多进程(通过multiprocessing模块)。
由此区别的原因是,Python的线程模型受到全局解释器锁(GIL)的影响,即一个进程中,在同一时刻GIL只允许一个线程被CPU所调度。如果想要利用计算机多核CPU优势,就只能通过多进程使用,
通过多线程是没用的。所以计算密集型用多进程,而I/O密集型的时候不占CPU,所以I/O密集型用多线程。 - 进程和线程是在计算机操作系统层面真实存在的概念,而协程更多是在编程语言层面实现的抽象概念,它不是操作系统直接管理的实体。协程的执行和调度通常由用户代码或特定的库来控制,而不是依赖于操作系统内核。
协程可以让函数之间相互切换。协程自己本身无法实现并发,单纯的协程没啥太大意义,但是遇到I/O时的应用就可以让协程之间切换,这块就有意义了。它使得线程在I/O相关代码块之间进行来回切换执行,整个线程运
行过程中,线程是没有等待的,一直在工作,遇到I/O等待就切换其他地方执行,等I/O操作返回数据时又会切换回来继续执行。python中一般用协程的模块是greenlet,而实现协程加I/O自动切换的模块是gevent。
二、IO多路复用简单实现并发
2.1 IO多路复用作用
- 并发处理能力增强:它允许单个进程或线程同时监控多个输入/输出(I/O)通道,如网络连接或文件描述符,而不需要为每个通道创建单独的线程或进程。这样,一个进程可以有效地服务多个客户端连接,提高了系统的并发处理能力。
- 资源节省:通过复用同一个线程或进程来处理多个I/O事件,避免了频繁创建和销毁线程的开销,减少了上下文切换的次数,从而节省了宝贵的CPU资源和内存。
- 高效率:在没有数据可读或写时,程序不会被阻塞,而是等待多路复用器的通知,一旦有文件描述符就绪,立即进行处理。这减少了不必要的等待时间,提高了系统的整体吞吐量。
- 异步行为:虽然IO多路复用本身是同步的,但它常被用于构建异步I/O模型,使得应用程序可以在等待I/O操作完成的同时执行其他任务,从而实现逻辑上的异步处理。
- 适应高并发场景:在Web服务器、数据库连接池等需要处理大量并发连接的应用中,IO多路复用技术能够有效应对连接数激增的情况,保持服务的稳定性和响应速度。
- 简化并发编程:通过集中管理多个I/O事件,开发者可以编写更简洁、更易于维护的代码,因为不需要为每个连接管理独立的线程或进程逻辑。
2.2 IO多路复用简单实现并发的例子
实现了一个基本的非阻塞TCP服务器,使用Python的 socket 和 select 模块。服务器可以处理多个客户端的连接,并在客户端发送数据时将其转发出去
import socket
import select
#socket 模块用于创建和管理网络连接。
#select 模块用于监视多个socket对象的状态,以便知道何时可以进行读操作、写操作或存在异常
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', 12345))
server_socket.listen(5)
server_socket.setblocking(False) # 设置为非阻塞模式
#setblocking(False):将socket设置为非阻塞模式,这样即使没有连接也不会阻塞后续的操作。
"""
inputs:包含所有需要监视的socket,当有可读数据时会使用。
outputs:保存需要写入数据的socket。
message_queues:字典,存储每个连接(socket)对应的消息队列,用于存放待发送的数据。
"""
inputs = [server_socket] # 监听新连接
outputs = [] # 用于发送数据的socket列表
message_queues = {} # 用于存储每个连接的消息队列
client_addresses = {} # 用于存储客户端地址
"""
select.select():监视inputs中的每个socket,等待可读、可写和异常事件。
readable:可读的socket列表。
writable:可写的socket列表。
exceptional:发生异常的socket列表
"""
while inputs:
readable, writable, exceptional = select.select(inputs, outputs, inputs)
"""
迭代readable中的每个socket(s)。
如果socket是server_socket,则意味着有新客户端连接:
接受连接并打印客户端地址。
将新连接的socket设置为非阻塞。
将新连接的socket添加到inputs和message_queues中。
如果不是新连接,说明有数据到来:
使用s.recv(1024)尝试读取数据。
如果收到数据,则将数据添加到该socket的消息队列中,并加入到outputs列表中(如果之前不在的话)。
如果未收到数据,说明客户端关闭了连接,处理关闭逻辑:
从outputs和inputs中移除该socket,并删除其消息队列。
"""
for s in readable:
if s is server_socket: # 新连接
connection, client_address = s.accept()
print('New connection from', client_address)
connection.setblocking(False)
inputs.append(connection)
message_queues[connection] = []
client_addresses[connection] = client_address # 存储客户端地址
else:
data = s.recv(1024)
if data:
message_queues[s].append(data)
if s not in outputs:
outputs.append(s)
else:
print('Closing connection from', client_addresses[s])
if s in outputs:
outputs.remove(s)
inputs.remove(s)
del message_queues[s]
del client_addresses[s] # 也移除客户端地址
for s in writable:
"""
对于每个可写的socket,从其消息队列中取出下一条消息并发送出去。如果消息队列为空,则没有操作。
"""
if message_queues[s]: # 检查消息队列是否为空
next_msg = message_queues[s].pop(0)
s.send(next_msg)
for s in exceptional:
"""
对于异常状态的socket,进行清理操作:
输出异常处理信息,并将该socket从inputs和outputs中移除。
删除该socket在message_queues中的记录。
"""
print('Handling exceptional condition for', s)
inputs.remove(s)
if s in outputs:
outputs.remove(s)
del message_queues[s]
if s in client_addresses:
del client_addresses[s] # 移除客户端地址
这个TCP服务器能够在非阻塞模式下接受多个客户端的连接,接收和发送消息,利用select来有效管理多个连接。它并不阻塞,不会因为等待某个连接而影响其他连接的处理。
IO多路复用的主要作用之一就是检测多个socket的状态变化,判断它们是否已经准备好进行读写操作,即是否“发生变化”。这意味着它能够同时监控多个socket连接,确定哪些连接已经接收到数据(可读),哪些连接可以发送数据(可写),或者是否有其他异常情况发生。通过这种方式,IO多路复用技术使得单个线程或进程能够有效地管理并响应多个I/O事件,而不需要为每个socket连接单独设置阻塞等待,从而提高了程序的并发处理能力和效率。
三、基于事件循环实现的异步非阻塞简单程序
3.1 相关概念内容了解
-
事件循环概念:事件循环是一种编程结构,它等待和分发事件或消息。在这种结构中,程序在执行时会反复检查是否有可以处理的事件,而不是单纯按顺序执行。事件循环的好处是能够处理高并发,同时可以避免线程的切换带来的开销。
select 函数:代码中的 select.select() 函数就是实现事件循环的核心。该函数监控多个 socket的状态,如果有可读或可写的 socket 到达,或者超时发生,它就会返回这些 socket 的列表。
rlist 是可读的 socket 列表,表示可以从中读取数据; wlist 是可写的 socket 列表,表示可以向其中发送数据;elist 是异常的 socket 列表,表示发生了错误的 socket -
异步
并发请求:可以同时发送多个网络请求,而不需要明确地管理多个线程或进程。每当一个请求可以写入或读取时,该请求就会被处理,而不需要等待所有请求的完成。这就是异步编程,允许一个主程序在等待 I/O 操作时同时进行其他操作。
回调机制:利用回调函数,每次接收到响应数据时,代码都会调用与连接关联的回调函数。这种设计模式允许在数据准备好时自动触发某种操作,而不必在主程序中进入一个阻塞等待状态。即数据准备好后,指示来完成相关工作。例如,在一个线程中执行某个函数时,如果该函数需要时间较长才能返回结果,线程将不会一直等待该函数的返回,而是继续执行后面的语句。当该函数返回结果后,通常会通过回调函数的方式通知线程,并在回调函数中处理该任务的结果。 -
非阻塞 I/O
非阻塞套接字:在这段代码中,每个 socket 使用 setblocking(False) 方法设置为非阻塞模式。这样,调用 connect()、recv() 和 sendall() 时不会阻塞主程序的执行。在连接未成功时,connect() 会立即引发 BlockingIOError,而不是等待连接完成。
处理过程:当一个 socket 处于可写状态时,代码会发送 HTTP 请求。此时,self.conn_list 中的相应对象会被移除,因为它已经进入到写请求阶段;当一个 socket 处于可读状态时,代码会尝试接收数据,而不会因为某个 socket 没有数据可读而造成整个程序的阻塞。
3.2 相关简单例子说明
import socket
import select
class Req(object):
def __init__(self, sk, func):
self.sock = sk
self.func = func
def fileno(self):
return self.sock.fileno()
class Nb(object):
def __init__(self):
self.conn_list = []
self.socket_list = []
def add(self, url, func):
client = socket.socket()
client.setblocking(False) # 非阻塞
try:
client.connect((url, 80))
except BlockingIOError as e:
pass
obj = Req(client, func)
self.conn_list.append(obj)
self.socket_list.append(obj)
def run(self):
while True:
rlist, wlist, elist = select.select(self.socket_list, self.conn_list, [], 0.005)
# wlist中表示已经连接成功的req对象
for sk in wlist:
# 发生变换的req对象
sk.sock.sendall(b'GET /s?wd=baby HTTP/1.0\r\nhost:www.baidu.com\r\n\r\n')
self.conn_list.remove(sk)
for sk in rlist:
chunk_list = []
while True:
try:
chunk = sk.sock.recv(8096)
if not chunk:
break
chunk_list.append(chunk)
except BlockingIOError:
break
body = b''.join(chunk_list)
# print(body.decode('utf-8'))
sk.func(body)
sk.sock.close()
self.socket_list.remove(sk)
if not self.socket_list:
break
def baidu_repsonse(body):
print('百度的下载结果:', body)
def sogou_repsonse(body):
print('搜狗的下载结果:', body)
def hao123_repsonse(body):
print('hao123下载结果:', body)
t1 = Nb()
t1.add('www.baidu.com', baidu_repsonse)
t1.add('www.sogou.com', sogou_repsonse)
t1.add('www.hao123.com', hao123_repsonse)
t1.run()
三、协程
import greenlet
def f1():
print(111)
grl2.switch()
print(222)
grl2.switch()
def f2():
print(333)
grl1.switch()
print(444)
# 协程 gr1
grl1 = greenlet.greenlet(f1)
# 协程 gr2
grl2 = greenlet.greenlet(f2)
grl1.switch()
111
333
222
444
协程:是微线程,对一个线程进程分片,使得线程在代码块之间进行来回切换执行,而不是在原来逐行执行。
协程自己本身无法实现并发,单纯的协程没啥太大意义,但是遇到I/O时的应用就可以让协程之间切换,这块就有意义了。 通过 gevent模块,管理并发请求变得相对简单,不需要显式地处理线程或进程的创建和切换。如下例:
from gevent import monkey
monkey.patch_all() # 以后代码中遇到IO都会自动执行greenlet的switch进行切换
"""
猴子补丁(Monkey Patching):monkey.patch_all() 的作用是将标准库中一些阻塞的函数(例如网络请求中的 socket, threading 和 time)替换为 gevent 版本的函数。这意味着,当使用这些函数时,它们将会自动执行协程切换(即 greenlet 的切换),从而允许其他协程在等待 I/O 操作完成时继续执行。这是将普通的 Python 代码转换为异步执行的关键一步。
"""
import requests
import gevent
def get_page1(url):
ret = requests.get(url)
print(url, ret.content)
def get_page2(url):
ret = requests.get(url)
print(url, ret.content)
def get_page3(url):
ret = requests.get(url)
print(url, ret.content)
gevent.joinall([
gevent.spawn(get_page1, 'https://www.python.org/'), # 协程1
gevent.spawn(get_page2, 'https://www.yahoo.com/'), # 协程2
gevent.spawn(get_page3, 'https://www.baidu.com/'), # 协程3
])
"""
1. 使用了 gevent 库来实现协程,这是一种非常有效的方式来处理 I/O 密集型操作(如网络请求),特别是当这些操作可能会阻塞时。
2. gevent.spawn():这个函数用于创建一个协程(绿色线程),它会执行指定的函数(如 get_page1、get_page2 和 get_page3)。每个协程都会接收一个 URL 作为参数。
3. gevent.joinall():这个函数用于等待所有启动的协程完成。当所有协程都执行完成后,程序才会结束。这使得我们可以并发地发起多个 HTTP 请求,而不必等待每个请求完成后再开始下一个请求。
"""
总体来说,这段代码演示了如何使用 gevent 来轻松地进行高效的并发网络请求。在 Python 中,使用协程可以有效地处理I/O密集型任务,极大地提高了程序的响应速度和吞吐量。
四、补充
- 提高并发方案:
- 多进程
- 多线程
- 单线程:①基于事件循环的异步非阻塞模块(Twisted); scrapy 框架就利用了Twisted这个异步网络框架
②协程+IO切换:gevent
- 什么是异步非阻塞?
- 非阻塞:即不等待。比如创建socket对某个地址进行connect、获取接收数据recv时默认都会等待(连接成功或接收到数据),才执行后续操作。如果设置setblocking(False),以上两个过程就不再等待,但是会报BlockingIOError的错误,只要捕获即可。
- 异步,即通知,执行完成之后自动执行回调函数或自动执行某些操作(通知)。比如做爬虫中向某个地址baidu.com发送请求,当请求执行完成之后自动执行回调函数。