多线程、多进程、多协程、多核、Gevent、asyncio、multiprocessing的Pool总结

本文探讨了CPU的核心概念,区分了物理核与超线程技术下的可并行线程,介绍了进程与线程的差异,强调了多线程、多进程和协程在并行计算中的优缺点,特别是在Python中的GIL影响,以及协程在内存和性能方面的优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Python例子

主要区别:

  • asyncio需要手动做async和await的转换,gevent可以用monkey.patch_all()来自动做。这俩都是基于协程的,但是gevent实现的时候用了greenlets和猴子补丁,底层基于libev/libuv来实现;而asyncio是python3.3之后标准库的实现,上下文Content切换更快
  • multiprocessing.dummy中的Pool实际是线程Pool,Python的多线程在多核CPU上,只对于IO密集型计算产生正面效果;而当有至少有一个CPU密集型线程存在,那么多线程效率会由于GIL而大幅下降,由于有GIL锁的存在,可以参考 Python的GIL是什么鬼,多线程性能究竟如何
import asyncio
import aiohttp
import gevent
from multiprocessing.dummy import Pool as ThreadPool
from multiprocessing import Pool
import gevent
# from gevent import monkey
# monkey.patch_all()
from PIL import Image
import io
import os
import requests
import base64

class ParallelTask(object):
# 多进程版本
    def __init__(self, pool_num=4, timeout=None, raise_err=False):
        self.timeout = timeout
        self.raise_err = raise_err
        self.tasks = []
        self.pool_num = pool_num
        self.pool = Pool(pool_num)

    def add_task(self, task, **kwargs):
        self.tasks.append(self.pool.apply_async(task, kwds=kwargs))

    def run(self):
        try:
            self.pool.close()
            self.pool.join()
        except Exception as ex:
            raise ex
        return [task.get() for task in self.tasks]

class ParallelTaskThread(object):
    '''
    输出如下,但是Python的多线程在多核CPU上,只对于IO密集型计算产生正面效果;而当有至少有一个CPU密集型线程存在,那么多线程效率会由于GIL而大幅下降。这里多线程效果还行

    parallel_id 0 starts
    parallel_id 1 starts
    parallel_id 1 download url finished http://gips3.baidu.com/it/u=100751361,1567855012&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280
    parallel_id 1 finish
    parallel_id 0 download url finished http://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280
    parallel_id 0 download url finished http://gips3.baidu.com/it/u=3886271102,3123389489&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960
    parallel_id 0 finish
    parallel_id 0 finishcode 0
    parallel_id 1 finishcode 1
    '''
    def __init__(self, thread_num=10, timeout=None, raise_err=False):
        self.timeout = timeout
        self.raise_err = raise_err
        self.tasks = []
        self.pool = ThreadPool(thread_num)

    def add_task(self, task, **kwargs):
        self.tasks.append(self.pool.apply_async(task, kwargs.values()))

    def run(self):
        try:
            self.pool.close()
            self.pool.join()
        except Exception as ex:
            raise ex
        return [task.get() for task in self.tasks]

class ParallelTaskGevent(object):
    def __init__(self, timeout=None, raise_err=False):
        self.timeout = timeout
        self.raise_err = raise_err
        self.tasks = []

    def add_task(self, task, **kwargs):
        self.tasks.append(gevent.spawn(task, **kwargs))

    def run(self):
        try:
            gevent.joinall(self.tasks, timeout=self.timeout, raise_error=self.raise_err)
        except Exception as ex:
            raise ex
        return [task.value for task in self.tasks]

async def download_image_list_async(urls, parallel_id, directory_path):
    '''
    输出如下:
    parallel_id 0 urls ['http://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280', 'http://gips3.baidu.com/it/u=3886271102,3123389489&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960']
    parallel_id 1 urls ['http://gips3.baidu.com/it/u=100751361,1567855012&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280']
    parallel_id 1 finish
    parallel_id 0 finish
    '''
    print('parallel_id {} urls {}'.format(parallel_id, urls))
    for idx,url in enumerate(urls):
        try:
            async with aiohttp.ClientSession() as session:
                async with session.get(url) as resp:
                    if resp.status == 200:
                        img_raw = await resp.read()
                        raw_image = Image.open(io.BytesIO(img_raw)).convert("RGB")
                        raw_image.save(os.path.join(directory_path, "parallel_id_{}_seq_id_{}.jpg".format(parallel_id, idx)))
        except:
            print('failed for idx {} url {}'.format(idx, url))
    print('parallel_id {} finish'.format(parallel_id))

def b64_encode(image):
    return base64.b64encode(image).decode('utf-8')

def get_image(url):
    for retry in range(3):
        try:
            response = requests.get(url, timeout=5)
            response.raise_for_status()
            return response.content
        except:
            import traceback
            print('get image failed, url: %s, error: %s', url, traceback.format_exc())
            continue
    return None

async def download_image_list(urls, parallel_id, directory_path):
    '''
    用这个输出如下,起不到并行效果,所以需要里面也改成async的形式:
    parallel_id 0 urls ['http://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280', 'http://gips3.baidu.com/it/u=3886271102,3123389489&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960']
    parallel_id 0 url http://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280
    parallel_id 0 url http://gips3.baidu.com/it/u=3886271102,3123389489&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960
    parallel_id 0 finish
    parallel_id 1 urls ['http://gips3.baidu.com/it/u=100751361,1567855012&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280']
    parallel_id 1 url http://gips3.baidu.com/it/u=100751361,1567855012&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280
    parallel_id 1 finish
    '''
    print('parallel_id {} urls {}'.format(parallel_id, urls))
    for idx,url in enumerate(urls):
        try:
            b64 = b64_encode(get_image(url))
            print('parallel_id {} url {}'.format(parallel_id, url))
        except:
            print('failed for idx {} url {}'.format(idx, url))
    print('parallel_id {} finish'.format(parallel_id))

def download_image_list2(urls, parallel_id, directory_path):
    '''
    如果使用patch_all,gevent不会由b64_encode(get_image(url))阻塞,否则会由b64_encode(get_image(url))阻塞!!!
    
    带着patch_all输出如下:
    from gevent import monkey
    monkey.patch_all()
    run_asyncio.py:7: MonkeyPatchWarning: Monkey-patching ssl after ssl has already been imported may lead to errors, including RecursionError on Python 3.6. It may also silently lead to incorrect behaviour on Python 3.7. Please monkey-patch earlier. See https://github.com/gevent/gevent/issues/1016. Modules that had direct imports (NOT patched): ['aiohttp.connector (/opt/miniconda3/envs/torch20/lib/python3.8/site-packages/aiohttp/connector.py)', 'aiohttp.client_exceptions (/opt/miniconda3/envs/torch20/lib/python3.8/site-packages/aiohttp/client_exceptions.py)', 'aiohttp.client_reqrep (/opt/miniconda3/envs/torch20/lib/python3.8/site-packages/aiohttp/client_reqrep.py)']. 
      monkey.patch_all()
    parallel_id 0 starts
    parallel_id 1 starts
    parallel_id 0 download url finished http://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280
    parallel_id 1 download url finished http://gips3.baidu.com/it/u=100751361,1567855012&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280
    parallel_id 1 finish
    parallel_id 0 download url finished http://gips3.baidu.com/it/u=3886271102,3123389489&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960
    parallel_id 0 finish
    parallel_id 0 finishcode 0
    parallel_id 1 finishcode 1

    不带patch_all输出如下:
    parallel_id 0 starts
    parallel_id 0 download url finished http://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280
    parallel_id 0 download url finished http://gips3.baidu.com/it/u=3886271102,3123389489&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960
    parallel_id 0 finish
    parallel_id 1 starts
    parallel_id 1 download url finished http://gips3.baidu.com/it/u=100751361,1567855012&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280
    parallel_id 1 finish
    parallel_id 0 finishcode 0
    parallel_id 1 finishcode 1
    '''

    print('parallel_id {} starts'.format(parallel_id))
    for idx,url in enumerate(urls):
        try:
            b64 = b64_encode(get_image(url))
            print('parallel_id {} download url finished {}'.format(parallel_id, url))
        except:
            print('failed for idx {} url {}'.format(idx, url))
    print('parallel_id {} finish'.format(parallel_id))
    return parallel_id

def save_download_response():
    urls = [
        'http://gips3.baidu.com/it/u=1821127123,1149655687&fm=3028&app=3028&f=JPEG&fmt=auto?w=720&h=1280',
        'http://gips3.baidu.com/it/u=3886271102,3123389489&fm=3028&app=3028&f=JPEG&fmt=auto?w=1280&h=960',
        'http://gips3.baidu.com/it/u=100751361,1567855012&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280',
    ]
    parallelnum = 2
    bs = len(urls) // parallelnum + 1
    parallel_list = []
    directory_path = '/your/path/image_download/'

    for off in range(0, len(urls), bs):
        batch = urls[off:off+bs]
        parallel_list.append({
            'urls': batch, 
            'parallel_id': off // bs,
            'directory_path': directory_path
        })
    
    # asyncio parallel
    '''
    loop = asyncio.get_event_loop()
    tasks = [download_image_list_async(**parallel_dic) for parallel_dic in parallel_list]
    ts = asyncio.gather(*tasks)
    loop.run_until_complete(ts)
    '''

    # gevent和ThreadPool对比
    parallel_task = ParallelTaskThread()
    for parallel_dic in parallel_list:
        parallel_task.add_task(download_image_list2, **parallel_dic)
    task_results = parallel_task.run()
    for idx, finishcode in enumerate(task_results):
        print('parallel_id {} finishcode {}'.format(idx, finishcode))

if __name__ == '__main__':
    save_download_response()

概念总结

  1. 计算机的cpu物理核数是同时可以并行的线程数量(cpu只能看到线程,线程是cpu调度分配的最小单位),由于超线程技术,实际上可以并行的线程数量通常是物理核数的两倍,这也是操作系统看到的核数。我们只care可以并行的线程数量,所以之后所说的核数是操作系统看到的核数,所指的核也是超线程技术之后的那个核(不是物理核)。
  2. 进程是操作系统资源分配(内存,显卡,磁盘)的最小单位,线程是执行调度(即cpu调度)的最小单位(cpu看到的都是线程而不是进程),一个进程可以有一个或多个线程,线程之间共享进程的资源,通过这样的范式,就可以减少进程的创建和销毁带来的代价,可以让进程少一点,保持相对稳定,不断去调度线程就好。如果计算机有多个cpu核,且计算机中的总的线程数量小于核数,那线程就可以并行运行在不同的核中,如果是单核多线程,那多线程之间就不是并行,而是并发,即为了均衡负载,cpu调度器会不断的在单核上切换不同的线程执行,但是我们说过,一个核只能运行一个线程,所以并发虽然让我们看起来不同线程之间的任务是并行执行的,但是实际上却由于增加了线程切换的开销使得代价更大了。如果是多核多线程,且线程数量大于核数,其中有些线程就会不断切换,并发执行,但实际上最大的并行数量还是当前这个进程中的核的数量,所以盲目增加线程数不仅不会让你的程序更快,反而会给你的程序增加额外的开销。
  3. 任务可以分为计算密集型和IO密集型,假设我们现在使用一个进程来完成这个任务,对计算密集型任务,可以使用【核心数】个线程,就可以占满cpu资源,进而可以充分利用cpu,如果再多,就会造成额外的开销;对于IO密集型任务(涉及到网络、磁盘IO的任务都是IO密集型任务),线程由于被IO阻塞,如果仍然用【核心数】个线程,cpu是跑不满的,于是可以使用更多个线程来提高cpu使用率。
  4. 实现并行计算有三种方式,多线程,多进程,多进程+多线程。如果是多进程,因为每个进程资源是独立的(地址空间和数据空间),就要在操作系统层面进行通信,如管道,队列,信号等;多线程的话会共享进程中的地址空间和数据空间,一个线程的数据可以直接提供给其他线程使用,但方便的同时会造成变量值的混乱,所以要通过线程锁来限制线程的执行
  5. 其他语言,CPU 是多核时是支持多个线程同时执行。但在 Python 中,无论是单核还是多核,一个进程同时只能由一个线程在执行。其根源是 GIL 的存在。GIL 的全称是 Global Interpreter Lock(全局解释器锁),来源是 Python 设计之初的考虑,为了数据安全所做的决定。某个线程想要执行,必须先拿到 GIL,我们可以把 GIL 看作是“通行证”,并且在一个 Python 进程中,GIL 只有一个。拿不到通行证的线程,就不允许进入 CPU 执行。所以多线程在python中很鸡肋。但是Python的GIL在IO并发的code上还是有效果的,在计算密集的code上没啥用,可以参考 Python的GIL是什么鬼,多线程性能究竟如何
  6. 多线程的概念主要有两种:一种是用户态多线程;一种是内核态多线程,对于内核态多线程(java1.2之后用内核级线程),在操作系统内核的支持下可以在多核下并行运行
  7. 协程的暂停完全由程序控制,发生在用户态上;而线程的阻塞状态是由操作系统内核来进行切换,发生在内核态上。因此,协程的开销远远小于线程的开销,也就没有了ContextSwitch上的开销。由于协程的特性, 适合执行大量的I/O 密集型任务, 而多线程在这方面弱于协程。协程涉及到函数的切换,多多线程涉及到多线程的切换, 所以都有执行上下文, 但是协程不是被操作系统内核所管理, 而完全是由程序所控制(也就是在用户态执行), 这样带来的好处就是性能得到了很大的提升, 不会像多线程那样需要在内核态进行上下文切换来消耗资源,因此协程的开销远远小于多线程的开销。
  8. 协程就是用户态的线程。通常创建协程时,会从进程的堆中分配一段内存作为协程的栈。线程的栈有8MB,而协程栈的大小通常只有几十KB。而且,C库内存池也不会为协程预分配内存,它感知不到协程的存在。这样,更低的内存占用空间为高并发提供了保证,毕竟十万并发请求,就意味着10万个协程。
  9. 每个协程有独立的栈,而栈既保留了变量的值,也保留了函数的调用关系、参数和返回值,CPU中的栈寄存器SP指向了当前协程的栈,而指令寄存器IP保存着下一条要执行的指令地址。在GO语言中,语言的运行时系统会帮助我们自动地创建和销毁系统级的线程
  10. 协程相比线程的好处:一是节省CPU,避免系统内核级的线程频繁切换,造成的CPU资源浪费;二是节约内存,在64位的Linux中,一个线程需要分配8MB栈内存和64MB堆内存,系统内存的制约导致我们无法开启更多线程实现高并发。而在协程编程模式下,可以轻松有十几万协程,这是线程无法比拟的三是稳定性,前面提到线程之间通过内存来共享数据,这也导致了一个问题,任何一个线程出错时,进程中的所有线程都会跟着一起崩溃。

转载自: https://zhuanlan.zhihu.com/p/82123111

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值