python3中的并发编程补充的了解与学习


前言

       相关关键词:进程、线程、协程、并发编程、IO多路复用、同步阻塞、异步非阻塞、setblocking(False)、socket、select、greenlet、gevent、Twisted、基于事件循环的异步非阻塞、scrapy…


一、进程、线程、协程之间的区别

  1. 进程是计算机资源分配的最小单元,主要被用来做数据隔离。线程是CPU调度和执行的基本单位。一个应用程序里边可以有多个进程,一个进程里可以有多个线程。
  2. 与其他语言相比,几乎用的是多线程,进程几乎不会在用;而在python中,I/O操作多的时候用多线程(通过threading模块),计算密集型的时候用多进程(通过multiprocessing模块)。
    由此区别的原因是,Python的线程模型受到全局解释器锁(GIL)的影响,即一个进程中,在同一时刻GIL只允许一个线程被CPU所调度。如果想要利用计算机多核CPU优势,就只能通过多进程使用,
    通过多线程是没用的。所以计算密集型用多进程,而I/O密集型的时候不占CPU,所以I/O密集型用多线程。
  3. 进程和线程是在计算机操作系统层面真实存在的概念,而协程更多是在编程语言层面实现的抽象概念,它不是操作系统直接管理的实体。协程的执行和调度通常由用户代码或特定的库来控制,而不是依赖于操作系统内核。
    协程可以让函数之间相互切换。协程自己本身无法实现并发,单纯的协程没啥太大意义,但是遇到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密集型任务,极大地提高了程序的响应速度和吞吐量。

四、补充

  1. 提高并发方案:
    • 多进程
    • 多线程
    • 单线程:①基于事件循环的异步非阻塞模块(Twisted); scrapy 框架就利用了Twisted这个异步网络框架
                    ②协程+IO切换:gevent
  2. 什么是异步非阻塞?
    - 非阻塞:即不等待。比如创建socket对某个地址进行connect、获取接收数据recv时默认都会等待(连接成功或接收到数据),才执行后续操作。如果设置setblocking(False),以上两个过程就不再等待,但是会报BlockingIOError的错误,只要捕获即可。
    - 异步,即通知,执行完成之后自动执行回调函数或自动执行某些操作(通知)。比如做爬虫中向某个地址baidu.com发送请求,当请求执行完成之后自动执行回调函数。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值