并发编程实战:用多线程、多进程、多协程加速程序运行
一、为什么要引入并发编程
场景一:一个网络爬虫,按顺序爬花了一个小时,采用并发下载减少到20分钟
场景二:一个APP应用,优化前每次打开页面需要3秒,采用异步编发提升到每次200毫秒
引入并发,就是为了提升程序运行速度
二、有哪些程序提速的方法
三、Python对并发编程的支持
①多线程:threading,利用CPU和IO可以同时执行的原理,让CPU不会干巴巴等待IO完成
②多进程:multiprocessing,利用多核CPU的能力,真正的并行执行任务
③异步IO:asyncio,在单线程利用CPU和IO同时执行的原理,实现函数异步执行
④使用Lock对资源加锁,防止冲突访问
⑤使用Queue实现不同线程/进程之间的数据通信,实现生产者-消费者模式
⑥使用线程池Pool/进程池Pool,简化线程/进程的任务提交、等待结束、获取结果使用subprocess启动外部程序的进程,并进行输入输出交互
四、Python并发编程的三种方式
①多线程(Thread)
②多进程(Process)
③多协程(Coroutine)
五、CPU密集型计算、IO密集型计算
1、CPU密集型(CPU-bound)
CPU密集型也叫计算密集型,是指I/O在很短的时间就可以完成,CPU需要大量的极端和处理,特点是CPU占用率相当高。
例如:压缩解压缩、加密解密、正则表达式搜索。
2、IO密集型(I/O-bound)
IO密集型指的是系统运作大部分的状况是CPU在等I/O(硬盘/内存)的读/写操作,CPU占用率较低。
例如:文件处理程序、网络爬虫程序、读写数据程序。
六、多进程、多线程、多协程
一个进程中可以启动多个线程,一个线程中可以启动多个协程
1、多进程 Process(multiprocessing)
优点:可以利用多核CPU并行运算
缺点:点用资源最多、可启动数目比线程少
适用于:CPU密集型计算
2、多线程Thread(threading)
优点:相比进程,更轻量级、占用资源少缺点:
相比进程:多线程只能并发执行,不能利用多CPU(GIL)
相比协程:启动数目有限制,占用内存资源,有线程切换开销
适用于:IO密集型计算、同时运行的任务数目要求不多
3、多协程 Coroutine(asyncio)
优点:内存开销最少、启动协程数量最多
缺点:支持的库有限制(aiohttp vs requests)、代码实现复杂
适用于:IO密集型计算、需要超多任务运行、但有现成库支持的场景
七、如何根据任务选择对应技术
八、GIL
1、Python速度慢的两大原因
①动态类型语言,解释型语言,边解释边执行
②GIL,无法利用多核CPU并发执行
2、GIL是什么
GIL(Global Interpreter Lock):全局解释器锁
是计算机程序设计语言解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行。
即便在多核心处理器上,使用 GIL 的解释器也只允许同一时间执行一个线程。
由于GIL的存在,即使电脑有多核CPU,但是时刻也只能使用1个,相比并发加速的C++/JAVA速度慢。
上面这张图,就是 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。
读者可能会问,为什么 Python 线程会去主动释放 GIL 呢?毕竟,如果仅仅要求 Python 线程在开始执行时锁住 GIL,且永远不去释放 GIL,那别的线程就都没有运行的机会。其实,CPython 中还有另一个机制,叫做间隔式检查(check_interval),意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况,每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。
GIL 是python的全局解释器锁,同一进程中假如有多个线程运行,一个线程在运行python程序的时候会霸占python解释器(加了一把锁即GIL),使该进程内的其他线程无法运行,等该线程运行完后其他线程才能运行。如果线程运行过程中遇到耗时操作,则解释器锁解开,使其他线程运行。所以在多线程中,线程的运行仍是有先后顺序的,并不是同时进行。
多进程中因为每个进程都能被系统分配资源,相当于每个进程有了一个python解释器,所以多进程可以实现多个进程的同时运行,缺点是进程系统资源开销大
3、为什么会存在GIL
①简而言之:Python设计初期,为了规避并发问题引入了GIL,现在想去除却去不掉了。
②为了解决多线程之间数据完整性和状态同步问题
③Python中对象的管理,是使用引用计数器进行的,引用数为0则释放对象
例子:线程A和线程B都引用了对象obj,obj.ref_num=2, 线程A和B都想撤销对obj的引用,如下图片,如果没有GIL锁,则线程A以及线程B对同一个资源进行释放,有可能造成内存破坏
GIL确实有好处:简化了Python对共享资源的管理
4、怎样规避GIL带来的限制
①多线程 threading 机制依然是有用的,用于IO密集型计算
因为在 I/O(read、write、send、recv)期间,线程会释放GIL,实现CPU和IO的并行因此多线程用于IO密集型计算依然可以大幅提升速度
但是多线程用于CPU密集型计算时,只会更加拖慢速度。
②使用multiprocessing的多进程机制实现并行计算、利用多核CPU优势。为了应对GIL的问题,Python提供了multiprocessing。
九、使用多线程,python爬虫被加速10倍
import requests import threading import time urls = [] for i in range(2, 52): urls.append('https://www.cnblogs.com/#p{0}'.format(i)) def craw(url): r = requests.get(url) print(url, len(r.text)) # 单线程:耗时:single_thread cost: 3.8452553749084473 seconds def single_thread(): print('single_thread start') for url in urls: craw(url) print('single_thread end') # 多线程:耗时:multi_thread cost: 0.36998510360717773 seconds def multi_thread(): print('multi_thread start') 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() print('multi_thread end') if __name__ == '__main__': start = time.time() single_thread() end = time.time() print('single_thread cost:', end - start, 'seconds') start = time.time() multi_thread() end = time.time() print('multi_thread cost:', end - start, 'seconds')
十、python实现生产者消费者爬虫
1、多组件的Pipeline技术结构
复杂的事情一般不会一下子做完,而是会分成很多中间步骤一步步完成。
2、生产者消费者爬虫的架构
3、多线程数据通信的queue.Queue
4、单线程:获取第一页的所有文章的链接的标题
import requests from bs4 import BeautifulSoup urls = [] for i in range(2, 52): urls.append('https://www.cnblogs.com/#p{0}'.format(i)) def craw(url): r = requests.get(url) return r.text def parse(html): soup = BeautifulSoup(html, 'html.parser') links = soup.find_all('a', class_='post-item-title') return [(link['href'], link.get_text()) for link in links] if __name__ == '__main__': for result in parse(craw(urls[0])): print(result)
5、多线程实现生产者消费者模型
import requests import queue from bs4 import BeautifulSoup import time import threading urls = [] for i in range(2, 52): urls.append('https://www.cnblogs.com/#p{0}'.format(i)) def craw(url): r = requests.get(url) return r.text def parse(html): soup = BeautifulSoup(html, 'html.parser') links = soup.find_all('a', class_='post-item-title') # 返回页面中的每篇文章的链接和标题 return [(link['href'], link.get_text()) for link in links] # 生产者生产任务 def do_craw(url_queue: queue.Queue, html_queue: queue.Queue): while True: url = url_queue.get() html = craw(url) html_queue.put(html) print('生产者:', threading.current_thread().name, url, 'url_queue.size={0}'.format(url_queue.qsize())) time.sleep(2) # 消费者消费任务 def do_parse(html_queue: queue.Queue, fout): while True: html = html_queue.get() results = parse(html) for res in results: fout.write(str(res) + '\n') print('消费者', threading.current_thread().name, 'results.size', len(results), 'html_queue.size={0}'.format(html_queue.qsize())) time.sleep(2) if __name__ == '__main__': url_queue = queue.Queue() html_queue = queue.Queue() for url in urls: url_queue.put(url) # 生产者开启3个线程 for id in range(3): t = threading.Thread(target=do_craw, args=(url_queue, html_queue), name='craw{0}'.format(id)) t.start() # 消费者开启2个线程 # 把消费的任务写到文件中 fout = open('02.data.txt', 'w') for id in range(2): t = threading.Thread(target=do_parse, args=(html_queue, fout), name='parse{0}'.format(id)) t.start()
十一、python线程安全问题以及解决方法
①概念:线程安全指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。
由于线程的执行随时会发生切换,就造成了不可预料的结果,出现线程不安全
问题的代码:
import threading import time class Account: def __init__(self, balance): self.balance = balance def draw(account, amount): if account.balance >= amount: time.sleep(1) print(threading.current_thread().name, '取钱成功') account.balance -= amount print(threading.current_thread().name, '余额', account.balance) else: print(threading.current_thread().name, '取钱失败,余额不足') if __name__ == '__main__': account = Account(1000) ta = threading.Thread(name='ta', target=draw, args=(account, 800)) tb = threading.Thread(name='tb', target=draw, args=(account, 800)) ta.start() tb.start()
打印结果:
发现执行的是错误的结果。开启了两个线程,去取钱,第一个线程去取钱(1000-800)还剩200,第二个线程取钱应该执行的是else后面的代码,打印余额不足才对。但是因为多个线程去执行时会发生线程切换,当第一个线程在减去余额之前,切换了第二个线程去取钱,这个时候,账户还是1000元,这就是线程安全问题。
②Lock用于解决线程安全问题
import threading import time lock = threading.Lock() class Account: def __init__(self, balance): self.balance = balance def draw(account, amount): with lock: if account.balance >= amount: time.sleep(1) print(threading.current_thread().name, '取钱成功') account.balance -= amount print(threading.current_thread().name, '余额', account.balance) else: print(threading.current_thread().name, '取钱失败,余额不足') if __name__ == '__main__': account = Account(1000) ta = threading.Thread(name='ta', target=draw, args=(account, 800)) tb = threading.Thread(name='tb', target=draw, args=(account, 800)) ta.start() tb.start()
十二、线程池
1、线程池的原理
2、使用线程池的好处
①提升性能:因为减去了大量新建、终止线程的开销,重用了线程资源。
②适用场景:适合处理突发性大量请求或需要大量线程完成任务、但实际任务处理时间较短。
③防御功能:能有效避免系统因为创建线程过多,而导致系统负荷过大相应变慢等问题。
④代码优势:使用线程池的语法比自己新建线程执行线程更加简洁。
3、 ThreadPoolExecutor的使用语法
4、使用线程池改造爬虫程序
import concurrent.futures import requests from bs4 import BeautifulSoup urls = [] for i in range(2, 52): urls.append('https://www.cnblogs.com/#p{0}'.format(i)) def craw(url): r = requests.get(url) return r.text def parse(html): soup = BeautifulSoup(html, 'html.parser') links = soup.find_all('a', class_='post-item-title') return [(link['href'], link.get_text()) for link in links] with concurrent.futures.ThreadPoolExecutor() as pool: htmls = pool.map(craw, urls) htmls = list(zip(urls, htmls)) for url, html in htmls: pass print(url, len(html)) print('生产者:end') with concurrent.futures.ThreadPoolExecutor() as pool: futures = {} for url, html in htmls: future = pool.submit(parse, html) futures[future] = url for future, url in futures.items(): print(url, future.result()) print('消费者:end')
十三、Python使用线程池在web服务中实现加速
1、web服务的架构以及特点
web后台服务的特点:
①web服务对响应时间要求非常高,比如要求200ms返回
②web服务有大量的依赖IO操作的调用,比如磁盘文件、数据库、远程API
③web服务经常需要处理几万人、几百万人的同时请求
2、使用线程池ThreadPoolExecutor加速
使用线程池ThreadPoolExecutor的好处:
①方便的将磁盘文件、数据库、远程API的IO调用并发执行
②线程池的线程数目不会无限创建(导致系统挂掉),具有防御功能
import flask import json import time app = flask.Flask(__name__) 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('/') def index(): 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, }) if __name__ == '__main__': app.run()
运行程序,获取花费多长时间:
600多毫秒。windows可以下载postman软件来查看耗时。
然后下面我们采用此案城池来进行加速:
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('/') def index(): 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()
运行程序,获取花费多长时间:
300多毫秒,时间相比较加速了一倍。
十四、使用多进程multiprocessing加速
1、有了多线程threading,为什么还要用多进程multiprocessing
如果遇到了CPU密集型计算,多线程反而会降低执行速度。
mutilprocessing模块就是python为了解决GIL缺陷引入的一个模块,原理是用多进程在多CPU上并行执行。
2、多进程multiprocessing知识梳理(对比多线程threading)
3、代码实战:单线程、多线程、多进程对比CPU密集计算速度
代码演示:计算一个cpu密集型任务,单线程、多线程、多进程三种方法执行的效率
import math import time from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor num_list = [112272535095293] * 100 # 判断一个数字是否为素数:cpu计算密集型任务 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(): for num in num_list: is_prime(num) # 多线程 def multi_thread(): with ThreadPoolExecutor() as pool: pool.map(is_prime, num_list) # 多进程 def multi_process(): with ProcessPoolExecutor() as pool: pool.map(is_prime, num_list) if __name__ == '__main__': start = time.time() single_thread() end = time.time() print('单线程:', end - start, '秒') start = time.time() multi_thread() end = time.time() print('多线程:', end - start, '秒') start = time.time() multi_process() end = time.time() print('多进程:', end - start, '秒')
运行程序,输出结果查看,多线程花费的时间比单进程花费的时间还多,多进程花费的时间相比就很快。
4、python在Flask项目中使用多进程池进行加速
import math import json import flask from concurrent.futures import ProcessPoolExecutor app = flask.Flask(__name__) # 判断一个数字是否为素数 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 @app.route('/is_prime/<num_list>') def api_is_prime(num_list): num_list = [int(x) for x in num_list.split(',')] result = pool.map(is_prime, num_list) return json.dumps(dict(zip(num_list, result))) if __name__ == '__main__': pool = ProcessPoolExecutor() app.run()
运行程序,访问:
十五、python异步IO实现并发爬虫
1、原理
2、asyncio使用
代码演示:
import asyncio import aiohttp import time urls = [] for i in range(2, 52): urls.append('https://www.cnblogs.com/#p{0}'.format(i)) # 定义协程 async def async_craw(url): async with aiohttp.ClientSession() as session: async with session.get(url) as resp: result = await resp.text() print('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', end - start, '秒')
单线程异步爬虫花费的时间是0.4秒。之前我们演示的单线程爬虫耗时:8秒,和多线程爬虫耗时1秒(见章节九)
3、在异步IO中使用信号量控制爬虫并发度:信号量(Semaphore)
信号量(Semaphore):又称为信号量、旗语。是一个同步对象,用于保持在0至指定最大值之间的一个计数值。
当线程完成一次对该semaphore对象的等待(wait)时,该计数值减一。
当线程完成一次对semaphore对象的释放(release)时,计数值加一。
当计数值为0,则线程等待该semaphore对象不再能成功直至该semaphore对象变成signaled状态。
semaphore对象的计数值大于0,为signaled状态;计数值等于0,为nonsignaled状态。
代码演示:
import asyncio import aiohttp import time # 加入信号量,控制并发度 semaphore = asyncio.Semaphore(10) urls = [] for i in range(2, 52): urls.append('https://www.cnblogs.com/#p{0}'.format(i)) # 定义协程 async def async_craw(url): async with semaphore: async with aiohttp.ClientSession() as session: async with session.get(url) as resp: result = await resp.text() # 做演示,方便观察,睡3秒 await asyncio.sleep(3) print('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', end - start, '秒')
十六、python使用subprocess播放歌曲
1、介绍
2、使用
3、代码演示
播放歌曲:
import subprocess proc = subprocess.Popen( ['start', 'E:\My_Study\data\燕无歇-蒋雪儿.mp3'], shell=True ) proc.communicate()
运行程序,实现播放歌曲。