总算搞明白了!进程,线程,协程,生成器,迭代器搞的我脑子好乱!
你是否曾经被迭代器,生成器,进程,线程,协程搞的脑子很乱?
而且剪不断,理还乱:
这不怪你,这是有历史原因。本文试图把东西都给理顺了。
一篇不行,咱们就再来一篇,使劲点赞。
两个问题,三种协程
先来看两个问题:
生成器(generator)和协程有什么关系? 爬虫应该用线程,还是协程?
这两个问题中提到的协程严格来说并不是说的同一种东西,因为协程有3种:
简单协程:基于yield和send 基于生成器的协程:基于@asyncio.coroutine 原生协程:基于async和wait
这种复杂性是随着Python的演进而引入的,但很多困惑就来源于这里。网上看的文章,不一定说的是哪一种,增加了困惑度。
第一个问题中指的是简单协程,而第二个问题指的是原生协程。
三种协程的代码例子
先来各给一个例子,快速直观的认识3种协程,后面再详细解释。
1. 简单协程:基于yield和send
# 共有num个座位,每次调用就分配下一个座位给name
def get_seat(num):
print('座位号初始化中...')
print('大家可以陆续进来的')
for i in range(num):
name = (yield)
print('{}号座位分配给:{}'.format(i, name))
# get_seat是一个函数,是一个生成器函数
print(get_seat)
# 调用生成器函数会生成一个生成器对象
corou = get_seat(100)
print(corou)
# 这个生成器对象可以接受输入,也就是简单形式的协程
# 通过next()首次调用next会开始执行get_seat
# 执行到第一个yield停下来,等待外部输入值
corou.__next__()
# 输入值,可以输入100次。
corou.send('麦叔')
corou.send('Kelvin。')
这种简单协程其实就是生成器的扩展。
2. 基于生成器的协程:@asyncio.coroutine
这种写法在Python 3.10就不支持了。所以知道就行了,新人没必要深究。
import asyncio
@asyncio.coroutinedef old_style_coroutine(): yield from asyncio.sleep(1)
async def main(): await old_style_coroutine()
3. 原生协程:基于async和wait
这是第二个问题中的协程,经常和进程,线程放在一起讨论。
import asyncio, time
async def say_after(delay, what):
await asyncio.sleep(delay)
print(what)
async def main():
task1 = asyncio.create_task(
say_after(1, 'hello'))
task2 = asyncio.create_task(
say_after(2, 'world'))
print(f'started at {time.strftime('%X')}')
# 并发运行两个任务
await task1
await task2
print(f'finished at {time.strftime('%X')}')
asyncio.run(main())
不明白?还没开始讲呢。继续往下看。
生成器和协程有什么关系
1. 迭代器概念
说生成器不得不从迭代器说起。看代码:
nums = [9, 5, 2, 7]for i in nums: print(i)
nums这个list可以被for循环遍历是因为它可以被迭代(iterable)。
for循环内部执行过程是这样的:
通过iter(nums)生成nums的迭代器(iterator),假设为名字是it_nums。 不停的使用next(it_nums)获得里面的下一个元素,赋值给i,然后执行代码块中的代码。 当it_nums中迭代完成时,会抛出StopIteration异常,for循环结束。
2. 自己写迭代器
我们自己也可以写一个迭代器。
迭代器是一个类,关键是两个函数:
iter()函数:用来返回迭代器,一般都直接返回self。 next()函数:用来获得下一个元素,如果__iter__返回的不是self,那么被返回的类需要有这个函数。
下面的生成器生成100个幸运数字,每次需要就获取一次,用完为止。
import random
class LuckNum:
def __init__(self, num):
self.lucks = []
self.total = num
# 返回生成器对象,一般就返回自己
def __iter__(self):
return self
# 获得下一个元素,每次for循环都调用这个函数
def __next__(self):
if self.total > 0:
self.total -= 1
return random.randint(1, 1000)
else:
# 没有下一个就抛出这个异常,for循环就知道结束了
raise StopIteration('幸运数字用光了')
luck = LuckNum(100)
for i in luck:
print(i)
3. 生成器的出现是为了简化迭代器的写法
迭代器的写法是基于两个函数(iter和next),写起来比较复杂。
生成器(generator)最初的出现是为了简单这种写法。提供了Python语言本身的支持和关键字。
把上面的例子用生成器语法改写一下:
import randomdef luck_num(num): while num > 0: # yield就相当于return,但函数先挂起,可以继续执行 yield random.randint(1, 1000) num -= 1
luck = luck_num(100)for i in luck: print(i)
运行一下看,完全一样的效果,但是代码确简单多了,一个luck_num函数搞定!
来看一下过程:
luck_num是一个函数,是一个生成器函数。 对生成器函数的调用 luck_num(100)
生成了一个生成器对象(generator),也就是说luck是一个generator。for循环可以循环generator。
来看一下generator函数的特点和执行过程
generator函数没有return语句,使用yield代替了return返回数据给调用者。 调用者使用next()函数调用迭代器:例子中for循环就是调用 next(luck)
yield返回后数据后,函数并没有执行结束,而是挂起等待下一次被调用。直到代码执行完成。
对比一下,普通的函数一次调用直接就执行完成了,不可能暂停在某行。而生成器函数执行完一次yield会停住,下次继续。就像挤牙膏一样,一次挤一点,挤完为止:
另外关于生成器的两个知识点:
生成器和list等容器对比,具有省内存省,初始化快的优势。 生成器还可以用类似列表推导式的语法创建(用小括号)。
4. 简单协程和生成器是兄弟
生成器引入的这个yield关键字挺好用的。但在生成器中yield是生成东西的(挤牙膏出来),属于生产者。
但函数之间相互协作完成任务,我们也需要能够接收外面数据的生成器,这就是简单协程。
看最开始给的代码例子:
# 共有num个座位,每次调用就分配下一个座位给name
def get_seat(num):
print('座位号初始化中...')
print('大家可以陆续进来的')
for i in range(num):
name = (yield)
print('{}号座位分配给:{}'.format(i, name))
# get_seat是一个函数,是一个生成器函数
print(get_seat)
# 调用生成器函数会生成一个生成器对象
corou = get_seat(100)
print(corou)
# 这个生成器对象可以接受输入,也就是简单形式的协程
# 通过next()首次调用next会开始执行get_seat
# 执行到第一个yield停下来,等待外部输入值
corou.__next__()
# 输入值,可以输入100次。
corou.send('麦叔')
corou.send('Kelvin。')
仔细看代码中的注释,应该能看懂代码。来说一下简单协程的几个特点:
使用yield关键词,但是yield不是用来返回数据,而是用来接收数据,出现在赋值语句的右边。 简单协程就是一种特殊的生成器,使用前,也要先调用生成器函数生成一个生成器对象: corou = get_seat(100)
简单协程用yield关键字接收数据,为了进入可接收状态,要先主动调用它让它执行到第一个yield: corou.__next__()
接下来使用send函数传送参数给它: corou.send('麦叔')
每次被send,它就会执行一段代码,到下一个yield处停下来等待下次被投喂。 如果代码结束,没有后面的yield了,再次被调用会抛出StopIteration异常。
这也就是生成器和简单协程的区别了。简单协程比生成器可以有更复杂的用法,下面的例子,把几个协程串了起来。这个例子看不懂,可以跳过。过几天再来看一遍。
def producer(sentence, next_coroutine): ''' 分割字符串,并调用其他协程处理风格好的关键词 ''' tokens = sentence.split(' ') for token in tokens: next_coroutine.send(token) next_coroutine.close()
def pattern_filter(pattern='ing', next_coroutine=None): ''' 寻找符合模式的关键词 ''' print('寻找 {}'.format(pattern)) try: while True: token = (yield) if pattern in token: next_coroutine.send(token) except GeneratorExit: print('过滤结束!!')
def print_token(): ''' 简单但因收到的关键词,现实中可以是更复杂的处理,比如保存数据库 ''' print('我在处理,我现在只会打印) try: while True: token = (yield) print(token) except GeneratorExit: print('打印结束!')
pt = print_token()pt.__next__()pf = pattern_filter(next_coroutine=pt)pf.__next__()
sentence = 'Bob is running behind a fast moving car'producer(sentence, pf)
如果还是有点迷糊,可以多看两遍。
进程,线程和协程
现在要来说第二个问题了:爬虫应该用线程,还是协程?
扩展一下问题:多进程,多线程,协程在什么情况下用什么?
这里说的协程主要是指原生协程,就是使用asyncio的协程。
它的出现是为了更轻量级的实现并发,支持基于事件的编程,在某些场合下代替线程。但它的热度其实被高估了,很多人根本不需要使用协程,凑热闹也要使用。
一篇文章讲完asyncio有点困难,这里我们主要来澄清一些概念,解答上面的问题。
1. 计算机如何执行程序
CPU运行一个程序或软件,会在电脑内存中启动一个进程。比如QQ软件,或者一个Python程序。 一个进程中至少有一个线程,但绝大部分进程都有多个线程。 进程和线程是操作系统级别的概念,操作系统知道他们的存在,给他们分配CPU时间片。 线程内部可以有协程,是编程语言本身自己创造的基于事件的并发执行机制,和操作系统无关。 程序在CPU上执行,CPU有多核,相当于多个小CPU。线程的一次执行只能在一个核上运行。 由于Python的线程执行必须先获得GIL(全局解释器锁),所以Python的多线程在一个时间点上只能有一个线程执行,不能利用多核。
如果这里看不懂,没关系,请继续往下看。看完再回来消化。
2. 并发和并行
一个进程通常有多个线程,只有一个线程能获得GIL,当它获得GIL,它拥有可以运行的权利。这个权利会在线程间轮流。
并发:线程A和线程B在同一个进程中,CPU的核只有一个,他们轮流使用。以上厕所为例,一个人上完,另外一个人上。大部分时间大家都在工作,没在上厕所,所以轮流使用没问题。但任何一个时间点,只能有一个人在上厕所,这叫做并发。这里是只有一个坑的单人厕所。
回到程序例子,对于IO密集型操作(需要读取硬盘,读取网络,等待用户输入等),并发能够起到很好的作用。可以很多线程公用同一个CPU的核。
但如果单位所有的人都是懒人屎尿多类型(可能因为拉肚子),一上厕所就蹲个2小时,那并发就不行了。这时候必须多来几个厕所才能解决问题,这时候需要并行。
并行:线程A和线程B使用不同的CPU核。可以在同一时间点上同时执行。对于计算密集型操作,需要使用并行。比如神经网络的数据计算。
相对其他编程语言,Python有一个缺陷,它的设计基于GIL,所以单个Python进程(多个线程)无法做并行。
所以,到底要用多进程,还是多线程取决于你的使用场景。实际上它们不是互相排斥的关系,完全可以一起使用。至于协程,稍微晚点再说。
3. 多进程 vs 多线程
线程更加轻量级,更容易创建和销毁,使用资源更少。线程共享内存空间(除了栈),所以更方便资源共享和通信。
进程之间完全独立,多进程更加可靠,一个进程死了不会影响其他进程。但是线程就不一样了,一个线程有问题很可能会造成整个程序死掉。
总的来说,多线程适合协作型任务,多进程适合独立的任务。
多进程的运行例子:Spark, Hadoop,分布式计算等。
4. IO密集型任务
对于IO密集型任务,我们一般使用多线程和协程,因为任务的大部分时间花在等待IO操作上:硬盘读写,网络读写等。
例子:Web服务器,Tornado, Gevent等。
5. 多线程和协程
在绝大部分情况下,使用多线程就够了。除非你需要创建大量的线程(至少超过1000个)才需要考虑使用协程。
使用协程的理由包括:
线程是由操作系统管理的,操作系统对线程的数量有一定的限制。 线程的创建开销比协程要大的多,但仍然比进程要小的多。 大量的线程会造成操作系统频繁切换任务,带来更大的额外开销。
但线程的开销仍然是很小的,除非你需要创建大量的线程,否则线程就够了。
6. 协程比线程快吗?
有一种误解认为协程比线程快。协程和线程都是并发,都是多个执行块共享一个CPU核的执行时间,其实是一样的。
虽然协程开销比线程小一点,但这点差别是可以忽略的。除非线程数量足够大的时候才可以看到明显的差别。
所以使用协程主要原因在于前面说的理由,而不是单纯的快。
7. 总结一下
对于IO密集型(比如爬虫),使用多线程或者协程。1000个线程以下使用线程就可以了。更多使用协程。当然这个数字不绝对,取决于你的电脑和操作系统。 对于计算密集型(比如人工智能运算),使用多进程。 可以结合多进程和多线程使用。 协程是一种基于事件的轻量级并发框架,Python中除了asyncio,还是有gevent, Tornado等。
越看越迷糊?建议多看几遍,再补充一些周边知识,这本来就是个复杂的话题。
来源:麦叔编程