python并发编程 多进程 多线程 多协程

385 阅读12分钟

本文是通过观看蚂蚁学Python老师的视频后写的观看笔记,如果有人需要建议去看老师的视频讲的非常好,如果你想快速阅读老师视频中的内容那本篇笔记一定能帮助到你www.bilibili.com/video/BV1bK…

希望你可以来我的网站看看给我点意见:www.codehunter.cn/

简介

1.为什么要引入并发编程?

  • 引入并发,就是为了提升程序运行速度
  • 学习掌握并发编程,是高级别+高薪资程序员的必备能力

2.有哪些程序提速的方法?

单线程,多线程,多CPU,多机器并行

img

3.python多并发编程的支持

  • 多线程:threading,利用CPU和IO可以同时执行的原理,让CPU不会干巴巴的等待IO完成
  • 多进程:multiprocessing,利用多核CPU的能力,真正的并行执行任务
  • 异步IO:asyncio,在单线程中利用CPU和IO可以同时执行的原理,实现函数异步执行

提供辅助功能的其他函数

  • 使用Lock对资源加锁,防止访问冲突
  • 使用Queue实现不同线程/进程之间的数据通信,实现生产者-消费者模式
  • 使用线程池Pool/进程池Pool,简化线程/进程的任务提交、等待结束、获取结果
  • 使用subprocess启动外部程序的进程,并进行输入输出交互

python 并发编程的三种方式

多线程Thread、多进程Process、多协程Coroutine

1.什么是CPU密集型计算、IO密集型计算?

  • CPU密集型(CPU-bound):CPU密集型也叫做计算密集型,是指IO在很短的时间内就可以完成,CPU需要大量的计算和处理,特点是CPU占用率高。例如压缩解压缩、加密解密、正则表达式搜索
  • IO密集型(IO-bound):IO密集型是指系统运作大部分时间是CPU在等待I/O(硬盘/内存)的读/写操作,CPU占用率较低。例如文件处理程序,网络爬虫程序,读写数据库程序

2.多进程、多线程、多协程对比

avatar

3.怎样根据任务选择对应技术?

avatar

全局解释器锁GIL

1.python速度慢的原因?

相比于C/C++/JAVA,Python确实慢。在一些特殊场景下,python比C++慢100~200倍。由于速度慢的原因,很多公司的基础架构代码仍然用C/C++开发。比如各大公司的推荐引擎,搜索引擎,存储引擎等底层对性能要求高的模块。

  • python是动态类型语言,边解释(从源码转换机械码)边执行
  • python的变量是可变的,导致需要随时检查变量类型
  • 由于GIL的存在导致python无法利用多核GPU并发执行的优势

2.GIL是什么?

全局解释器锁(Global Interpreter Lock)是python解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行,即便是在多核处理器上,使用GIL的解释器也只允许同一时间执行一个线程

avatar

上图表示当同时执行多个线程时的真是情况是,一个运行中的线程拥有GIL锁其他线程不执行,当运行线程遇到IO时会放开GIL,另一个线程则会获得GIL并运行直到遇到IO。在GIL的作用下同一时间只会执行一个线程,所以对于并发编程来说python相比于C++/JAVA慢。

3.为什么要设置GIL?

python设计初期,为了规避并发问题引入GIL,解决多线程之间数据完整性核状态同步问题,简化了python对于共享资源的管理,但现在想去掉也去不掉了。

如python在对象的管理,是使用引用计数器,当计数为0时释放对象。两个线程会共用一个计数器,而线程之间会随时切换调度权,所以在多线程中对于共享资源极来说会资源冲突,造成代码报错,程序崩溃等问题。

4.怎么规避GIL带来的限制?

  • 多线程IO密集型计算:因为在IO期间线程会释放GIL锁,所以在释放期间实现了CPU和IO的并行,因此多线程用于IO密集型计算可以大幅提速速度,但对于CPU密集型计算反而会降低速度,因为只有一个CPU在运行,同时多线程的切换造成开销,会拖慢单CPU执行的正常速度
  • 多进程利用multiprocessing:为应对GIL的问题,python提供了multiprocessing的多进程机制实现了并行计算、利用多核CPU优势。

多线程实例

1.python创建多线程的方法

  • 创建函数
  • 创建线程
  • 开启线程
  • 等待线程结束

2.多线程爬虫实例

多线程代码的运行速度比单线程快6倍。

注:截至2022/3/8时如函数craw一样爬取代码中的url存在bug,无论page改为多少都只能爬取到第一页的数据,需要根据其他链接用post的方法请求数据才行,但本文主要专注于并发所以忽略该缺陷。


import requests
import threading
import time

url_num = 10
urls = [f'https://www.cnblogs.com/#p{page}' for page in range(1, url_num + 1)]


def craw(url):
    r = requests.get(url)
    print(url, len(r.text))


class TestThread:

    @staticmethod
    def single_thread():
        """单线程"""
        start = time.time()
        for url in urls:
            craw(url)
        end = time.time()
        print('single thread cost:', end - start, 'seconds')

    @staticmethod
    def multi_thread():
        """多线程"""
        start = time.time()
        threads = []
        for url in urls:
            # 创建一个线程
            threads.append(threading.Thread(target=craw, args=(url,)))

        for thread in threads:
            # 开启线程
            thread.start()

        for thread in threads:
            # 等待线程结束
            thread.join()
        end = time.time()
        print('multi thread cost:', end - start, 'seconds')


if __name__ == '__main__':
    TestThread.single_thread()
    TestThread.multi_thread()

实现生产者-消费者模式的多线程

1.多组件的Pipeline技术架构

将复杂的事情分为多个中间步骤一步步完成,而生产者-消费者模式就是一个典型的pipeline架构。以输入数据做为生产者的原料生成了许多中间数据,消费者接受这些中间数据进行消费输出结果数据。

img

2.生产者消费者爬虫的构架

定义到爬虫中的生产者消费者架构就如下图所示。

img

将生产者消费者区分的好处是可以分别开发,并且可以配置不同的系统资源例如线程数。需要思考的问题是在两个线程组之间交互的中间数据是如何传输的呢?

3.多线程数据通信的queue.Queue

queue.Queue可以用于多线程之间的、线程安全的数据通信。线程安全指的是多个线程并发同时的访问数据不会造成冲突。、

queue.Queue的使用:

  • 导入类库:import queue
  • 创建Queue:q = queue.Queue
  • 添加元素:item = q.put()
  • 获取元素:item = q.get()
  • 查询状态:q.qsize() #查看元素的多少 q.empty() #判断是否为空 q.full() #判断是否已满

其中put()get()是阻塞的,如果队列满了则会等到有位置时再添加,或如果队列为空则会等待有数据时再取出。

4.代码实现

该代码并不会自动关闭需要手动关闭

    @staticmethod
    def do_craw(url_queue: queue.Queue, html_queue: queue.Queue):
        """生产者"""
        while True:
            url = url_queue.get()
            html_queue.put(craw(url))
            print(threading.current_thread().name, f'craw{url}', 'url_queue.size=', url_queue.qsize())
            time.sleep(random.randint(1, 2))

    @staticmethod
    def do_parse(html_queue: queue.Queue, f_out):
        """消费者"""
        while True:
            html = html_queue.get()
            results = parse(html)
            for result in results:
                f_out.write(str(result) + '\n')
            print(threading.current_thread().name, 'results.size=', len(results), 'html_queue.size=',
                  html_queue.qsize())
            # 将缓冲池里的数据写入文件避免之后手动结束程序数据未导入
            f_out.flush()
            time.sleep(random.randint(1, 2))

    def producer_consumer_spider(self):
        """生产者-消费者模式"""
        url_queue = queue.Queue()
        html_queue = queue.Queue()

        # 初始化输入数据
        for url in urls:
            url_queue.put(url)

        # 创建生产者线程
        for idx in range(3):
            t = threading.Thread(target=self.do_craw, args=(url_queue, html_queue), name=f'parse{idx}')
            t.start()

        # 创建消费者线程
        f_out = open(r'C:\Users\hzr\Desktop\hzr\test.txt', 'w')
        for idx in range(2):
            t = threading.Thread(target=self.do_parse, args=(html_queue, f_out), name=f'parse{idx}')
            t.start()

线程安全问题及解决方法

1.线程安全概念介绍

线程安全指某个函数、函数库在多线程环境中被调用时,能够正常地处理多个线程之间的共享变量,使程序功能正确完成。

由于线程的执行随时会发生切换,就造成了不可预料的结果,出现线程不安全,且发生的bug很难调试因为bug是随机出现的,例如银行取钱问题。

def draw(account, amount):
    # 检查取钱数是否小于账户余额
    if account.balance >= amount:
        # 取钱
        # 若该线程还没有取钱但线程的切换了,使另一个线程在计算后也能取钱,导致亏损
        account.balance -= amount

2.lock用于解决线程安全问题

img

3.代码实现

import threading
import time

lock = threading.Lock()


class Account:
    def __init__(self, balance):
        self.balance = balance


def draw(account, amount):
    # 配置lock后解决所有问题,先注释下面一行代码感受线程不安全问题后再开启
    with lock:
        if account.balance >= amount:
            print(threading.current_thread().name, "取钱成功")
            time.sleep(0.1)  # 没有停止问题不一定会出现,但有停止后一定会导致当前进程的阻塞,发生线程的切换
            account.balance -= amount
            print(threading.current_thread().name, "余额", account.balance)
        else:
            print(threading.current_thread().name, "取钱失败,余额不足")


def start():
    account = Account(1000)
    for i in range(0, 10):
        t = threading.Thread(name=f'{i}', target=draw, args=(account, 800))
        t.start()


if __name__ == '__main__':
    start()

没有锁的时候共享资源余额冲突造成错误,致使余额为负;有锁时线程安全。

img

线程池ThreadPoolExecutor

1.线程池的原理

线程的生命周期如下图所示

img

新建线程系统需要分配资源,终止线程系统需要回收资源,如果可以重用线程,则可以减去新建/终止的开销,这就是线程池的作用。

通过任务队列和可重用的线程实现线程池。先初始化一些线程放入线程池等待执行,若有新的任务则加入任务队列中,线程池里的线程会挨个取出任务依次执行,任务完成后再次取任务,若没有任务则回到线程池,并不销毁而是等待下一个任务。

img

2.线程池的好处

  • 提升性能:因为减去了大量新建、终止线程的开销,重用了线程资源
  • 使用场景:适合处理突发性大量请求或需要大量线程完成任务、但实际任务处理时间短(时间长不适合线程池)
  • 防御功能:能有效避免系统因为创建线程过多,而导致系统负荷过大响应变慢等问题
  • 代码优势:使用线程池语法比自己创建线程执行线程更加简洁方便

3.ThreadPoolExecutor的使用语法

from concurrent.futures import ThreadPoolExecutor,as_completed

"""方法一"""
with ThreadPoolExecutor as pool:
    '''urls 这里时参数列表包含很多参数'''
    results = pool.map(craw, urls)
    '''map的结果和入参顺数时对应的'''
    for result in results:
        print(result)

"""方法二"""
with ThreadPoolExecutor as pool:
    '''这里传入的时url,一个参数'''
    futures = [pool.submit(craw, url) for url in urls]

    '''第一种遍历方法,会一直等待所有线程结束,是按顺序输出'''
    for future in futures:
        print(future.result())

    '''第二种遍历方法,只要有线程结束就会执行'''
    for future in as_completed(futures):
        print(future.result())

4.代码实现

class ThreadPool:
    @staticmethod
    def start():
        with concurrent.futures.ThreadPoolExecutor() as pool:
            htmls = pool.map(craw, urls)
            htmls = list(zip(urls, htmls))
            for url, html in htmls:
                print(url, len(html))

        print('craw over')

        futures = {}
        with concurrent.futures.ThreadPoolExecutor() as pool:
            for url, html in htmls:
                future = pool.submit(parse, html)
                futures[future] = url

            # for futures, url in futures.items():
            #     print(url, future.result())

            for future in concurrent.futures.as_completed(futures):
                url = futures[future]
                print(url, future.result())

使用线程池在web服务中实现加速

1.web服务的架构以及特点

  • web服务对响应时间要求非常高,比如要求200MS返回
  • web服务有大量的依赖IO操作的调用,比如磁盘文件、数据库、远程API
  • web服务经常需要处理几万人、几百万人的同时请求

2.使用线程池ThreadPoolExecutor加速的好处

  • 方便的将磁盘文件、数据库、远程API的IO调用并发执行
  • 线程池的线程数目是固定的不会无线创建(导致系统挂掉),具有防御功能

3.代码实现

在全局对象里初始化一个pool对象,在每次使用submit调用方法,result获取结果,即可完成线程池的配置。

import flask
import json
import time
from concurrent.futures import ThreadPoolExecutor

app = flask.Flask(__name__)
pool = ThreadPoolExecutor()


def read_file():
    time.sleep(0.1)
    return "file result"


def read_db():
    time.sleep(0.2)
    return "db result"


def read_api():
    time.sleep(0.3)
    return "api result"


@app.route("/v1")
def index_v1():
    """单线程"""
    result_file = read_file()
    result_db = read_db()
    result_api = read_api()
    return json.dumps({
        "result_file": result_file,
        "result_db": result_db,
        "result_api": result_api
    })


@app.route("/v2")
def index_v2():
    """线程池"""
    result_file = pool.submit(read_file)
    result_db = pool.submit(read_db)
    result_api = pool.submit(read_api)
    return json.dumps({
        "result_file": result_file.result(),
        "result_db": result_db.result(),
        "result_api": result_api.result()
    })


if __name__ == '__main__':
    app.run()

通过以上代码创建web服务端,index_v1,index_v2分别对应这利用单线程,多线程实现同一个方法。现在通过不同接口链接的响应时间来观察多线程池带来的好处。

  • 获取链接响应时间

视频中老师通过再命令窗口运行time curl 链接来获取响应时间,但在windows上不能这样直接操作,所以这里换如下方法进行获取。

import requests

r1 = requests.get("http://127.0.0.1:5000/v1")
r2 = requests.get("http://127.0.0.1:5000/v2")
print(r1.elapsed.microseconds)
print(r2.elapsed.microseconds)

其中v1花费626毫秒,v2花费309毫秒。单线程的花费时间为所有方法的时间加和,线程池的花费时间为最长耗时函数花费的时间。

多进程multiprocessing

1.有了多线程,为什么要使用多进程

因为遇到CPU密集型计算,多线程反而会降低执行速度

img

虽然有GIL但因为IO的存在有线程的切换多线程依然可以加速运行,但CPU密集型计算IO很少同时伴随的线程切换GIL的释放获取反而会减慢运行速度

multiprocessing模块就是python为了解决GIL缺陷引入的一个模块,原理时用多进程在多CPU上并行执行

2.多进程multiprocessing对比多线程threading

img

3.代码实现

通过执行100次判断一个数是否是素数的函数,观察单线程,多线程,多进程的执行效率。

import math
import time
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ProcessPoolExecutor

PRIMES = [112272535095293] * 100


def is_prime(n):
    if n < 2:
        return False
    if n == 2:
        return True
    if n % 2 == 0:
        return False
    sqrt_n = int(math.floor(math.sqrt(n)))
    for i in range(3, sqrt_n + 1, 2):
        if n % i == 0:
            return False
    return True


def single_thread():
    start = time.time()
    for number in PRIMES:
        is_prime(number)
    end = time.time()
    print("single thread,cost:", end - start, 'seconds')


def multi_thread():
    start = time.time()
    with ThreadPoolExecutor() as pool:
        pool.map(is_prime, PRIMES)
    end = time.time()
    print("multi thread,cost:", end - start, 'seconds')


def multi_process():
    start = time.time()
    with ProcessPoolExecutor() as pool:
        pool.map(is_prime, PRIMES)
    end = time.time()
    print("multi process,cost:", end - start, 'seconds')


if __name__ == '__main__':
    single_thread()
    multi_thread()
    multi_process()

最后的运行结果如下,CPU密集型计算多线程拖慢了运行速度,多进程明显加快了运行速度:

single thread,cost: 37.827959060668945 seconds
multi thread,cost: 38.23858332633972 seconds
multi process,cost: 10.439623594284058 seconds

使用进程池在web服务中实现加速

多线程共享当前进程的所有环境,但多线程之间的环境是完全隔离的,所以执行时就会遇到一些问题。例如如果把创建进程放到mian函数之前,则相当于导入了改模块即又执行了一遍文件,则会无线递归导入模块创建进程,所以在windows创建进程时应该放在mian函数之后,因为main函数不会执行被导入模块

import flask
import json
from concurrent.futures import ProcessPoolExecutor
from thread_process_cpu_bound import is_prime

app = flask.Flask(__name__)


@app.route('/is_prime/<numbers>')
def api_is_prime(numbers):
    numbers_list = [int(x) for x in numbers.split(',')]
    results = process_pool.map(is_prime, numbers_list)
    return json.dumps(dict(zip(numbers_list, results)))


if __name__ == '__main__':
    process_pool = ProcessPoolExecutor()
    app.run()

异步IO

异步IO在单线程中实现并发的核心原理:

  • 用一个超级循环(其实就是while True)循环
  • 配合IO多路复用原理(IO时CPU可以做其他事情)

img

1.python异步IO库介绍:asyncio

img

当程序遇到await的时候不会进行阻塞而是进入到下一个程序的执行。在异步IO编程中依赖的库必须支持异步IO特性其实就是在await的时候不能阻塞

2.多协程代码执行

import asyncio
import aiohttp
import time
from blog_spider import urls


async def async_craw(url):
    print("craw url:", url)
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as resp:
            result = await resp.text()
            print(f"craw url:{url},{len(result)}")


loop = asyncio.get_event_loop()

tasks = [
    loop.create_task(async_craw(url))
    for url in urls
]
start = time.time()
loop.run_until_complete(asyncio.wait(tasks))
end = time.time()
print("use time seconds:", end - start)

3.异步IO中使用信号量控制爬虫并发度

信号量(Samaphore)

img