python多任务
多任务
- 多任务概念:在同一时刻,操作系统可以运行多个任务
- 单核CPU也能执行"多任务":采用时间片轮转或者优先级调度的策略,操作系统让各个任务交替执行,即任务1执行一段时间,然后切换到任务2,任务2执行一段时间,然后切换到任务3,任务3执行一段时间,然后切换到任务4…如此反复执行下去。表面上看每个任务是交替执行的,但是由于CPU执行速度非常快,让我们的感觉就是所有任务在同时执行
- 真正多任务执行只能在多核CPU上实现—— 并行
- 由于任务的数量一般会多于CPU的核心数量,所以操作系统也会自动把很多任务轮流调度到每个CPU核上执行——并发
线程
- 线程: 一个程序运行起来之后,执行代码的独立的执行流
- 线程是一个轻量级的进程,通过线程可以实现多任务
- 一个程序运行起来之后一定有一个线程叫做主线程
- python的thread模块是比较底层的模块,python的threading模块是对thread做了一些包装
创建子线程
- 创建子线程方式1: 调用threading模块中的Thread类并创建一个实例对象,调用Thread类后,执行start方法时,主线程会创建一个子线程,子线程执行传递的引用对象所指向的函数(也可以传递参数args=(参数,)),同时主线程接着向下执行
- 创建子线程方式2: 创建一个类,继承threading.Thread类,并创建一个名为run的方法,创建该类的实例对象,然后调用对象的start方法即创建了一个子线程,注意:子线程只会执行该类中的run方法,如果想让子线程执行该类中的其他方法,可以在run方法中调用其他方法
- 如果主线程已经全部执行完毕,则会进行等待,直到其所有子线程执行完毕后,主线程结束,当主线程结束后,整个程序执行完成
- 线程执行的先后顺序并不确定,如果想让谁先执行,可以使用延时sleep
- 子线程在其执行的函数结束后就会结束
- 主线程一定在所有子线程结束后才会结束
查看正在运行的线程
- 调用threading.enumerate,其返回一个列表,可以查看线程的数量,以及所有线程的信息
多线程的全局变量
- 在方法内部使用全局变量是否需要使用global关键字:需要看是否该全局变量的类型为可变类型或者对全局变量的指向进行了修改
⭕ 如果该全局变量的类型为不可变类型,则需要加global关键字
⭕ 如果该全局变量为可变类型,并且修改了全局变量的指向,则需要加global关键字
⭕ 如果该全局变量为可变类型,但是没有修改全局变量的指向,则可以不加global关键字
⭕ 为了便于区别全局变量和局部变量,一般在方法内加上global关键字 - 在多线程中,子线程是共享全局变量的,因为子线程一般是相互配合的
- 子线程共享全局变量存在的问题:资源竞争
⭕ 如果同一时刻多个子线程都在操作全局变量,由于CPU进行调度,使得一个执行中的子线程并没有真正写入全局变量就让另一个子线程执行,从而出现了错误
⭕ 例如上图中线程1执行到第3步时,CPU进行了调度(让线程1先出去,让线程2进入),则线程2中全局变量g_num的值加1为1,执行完后CPU再进行调度,则线程1直接执行第3步(保存第2步的值1),从而出现了错误
同步
- 同步:协同步调,按预定的先后次序进行运行
互斥锁
- 互斥锁:当多个线程几乎同时修改某一个共享数据的时候,需要进行同步控制
- 解决线程资源竞争的办法:线程同步,最简单的同步机制就是引入互斥锁
- 互斥锁为资源引入一个状态:锁定/非锁定
- 某个线程要更改共享数据时,先将其锁定,此时资源的状态为"锁定",其他线程不能更改,直到该线程释放资源,将资源的状态变成"非锁定",其他的线程才能再次锁定该资源
- 互斥锁保证每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性
- 互斥锁的创建
- 如果这个锁之前是没有上锁的,那么acquire不会堵塞
- 如果在调用acquire对这个锁上锁之前它已经被其他线程上了锁,那么此时acquire会堵塞,直到这个锁被解锁为止
死锁
- 死锁:在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源,就会造成死锁
- 死锁情况很少发生,但是一旦发生就会造成应用的停止响应
- 避免死锁:添加超时时间、银行家算法
进程
- 程序与进程:可执行文件没有运行的时候存在硬盘中存放的时候叫做程序,可执行文件运行并获得了各种资源的时候叫做进程
- 进程是CPU资源分配的最小单位
- 进程的各种状态
- 进程也可以实现多任务,但是进程占用的资源非常大,造成的资源浪费很大
创建子进程
- 子进程的创建与子线程的创建非常类似,只是调用的模块名不一样
- 子进程创建:调用multiprocessing模块中的Process类并创建一个实例对象,在执行其start方法时,主进程会创建一个子进程,同时主进程继续向下执行,子进程会执行传递的引用对象所指向的函数(也可以传递参数args=(参数,))
- 子进程创建时,主进程的资源也会被拷贝,例如内存会拷贝,程序(代码)是共享的
- 写时拷贝:如果子进程会对程序(代码)进行修改,则子进程也会拷贝主进程的程序(代码)
进程与线程的对比
- 功能
⭕ 进程:能够完成多任务,比如在一台电脑上同时运行多个QQ
⭕ 线程:能够完成多任务,比如在一个QQ上开启多个聊天窗口 - 先有进程,才有线程,一个进程中一定有一个主线程进行执行
- 进程与线程用下面两张示意图进行比较(第一张为多线程,第二张为多进程)
- 可以将进程理解为工厂里的流水线,线程理解为流水线上的工人
- 线程和进程在使用上各有优缺点:线程执行开销小,但不利于资源的管理和保护;而进程正好相反
进程间的通信
- 进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率
- 进程间的通信:socket(通过网络进行进程间的通信)、队列(Queue)
- 使用队列(Queue)可以在进程之间建立通道,也可以实现解耦
- 队列Queue只能用在同一台电脑上的同一个进程,Redis可以实现分布式的
进程池
- 进程池Pool:如果需要创建的子进程数量很多,手动地创建进程的工作量巨大,此时就可以用到进程池(Pool)以进行重复利用
- 进程池减少了进程的创建(利用已有的进程)与销毁的次数,提升了程序运行的效率
- 任务数量较少且固定,可以使用Process类手动创建进程,当任务数量较多或者任务数量不确定,可以使用Pool类创建进程池,并使用apply_async方法执行进程,使用Pool.close()关闭进程池
- 注意:创建进程池执行任务时,主进程并不会等待子进程都执行完毕
- 使用Pool.join()等待进程池中所有子进程执行完成(堵住主进程,不让主进程继续向下执行),注意Pool.join()必须放在Pool.close()语句后面
协程
迭代器
- 迭代:在原有的基础上增加新的内容。在集合中是访问集合元素的一种方式
- 迭代器:一个可以记住遍历位置的对象。迭代器对象从集合的第一个元素访问,直到所有的元素都被访问完结束,迭代器只能往前不会后退。
- 可迭代对象:非数值型基本类型(列表、元组、字符串、字典、集合)以及一些与Iterable有直接继承(或者间接继承)关系的对象都是可以迭代的,可迭代对象可以使用for循环遍历,是生成数据的方式
- 普通的自定义的类无法实现迭代,在类中加上__ iter__ 内置方法
- 判断对象是否可以迭代:调用collections包中的Iterable类,使用其isinstance(对象名, Iterable)方法,如果返回True,则该对象可以迭代,否则不行
- __iter__方法:返回一个对象的引用,这个对象除了实现了__iter__方法之外,还需要实现__next__内置方法
- for循环的工作机制(for 变量 in obj: pass)如下
⭕ 判断obj是否是可迭代对象
⭕ 在是可迭代对象的基础上,调用iter函数(iter为魔法方法(相当于Java中的重载,即对内置方法__iter__的重载)自动调用obj的__iter__方法得到其返回值
⭕ __iter__方法的返回值就是一个迭代器
⭕ for循环取值就是通过迭代器中的__next__方法进行取值 - 判断是否为迭代器:调用collections包中的Iterator类,使用其isinstance(引用对象名, Iterator)方法
- 通过next(引用对象名)方法即可得到结果,并且得到的结果就是for循环遍历的结果
- 通过自定义类实现for循环遍历自己:在自定义的类中的__iter__方法返回self自己,该类中添加__next__方法即可
- 多次调用__next__方法取完元素之后让for循环终止:在__next__方法中增加终止判断条件:如果取完了就抛出(raise)StopIteration异常,for循环捕获到该异常之后就会终止遍历
- 使用迭代器的目的:能读取大量数据并且不需要占用非常大的空间,读取的速度很快,并且能够将遍历序列的操作和序列底层分开,以达到对序列操作的人并不需要知道其底层结构即可实现
- 补充知识点:在python2中使用range()函数返回的是一个列表,如果range中给的参数很大,在函数执行会消耗非常大的内存空间并且不一定能够生成,而xrange()返回的是一个可迭代对象,可以临时进行生成。在python3中range()函数相当于xrange(),所以不存在该问题
- 迭代器使用的其他方式:for,类型转换list()、tuple()等等:类型转换不是简单的转换类型,而是通过调用原有迭代对象的__next__方法进行数据读取,然后放入新生成的类型中后返回
生成器
- 生成器是一类特殊的迭代器(不需要类、__iter__方法、__next__方法)
- 函数变为生成器:只要函数内部有yield语句,那么这个函数就是一个生成器。调用带有yield的函数则是创建一个生成器对象,使用 next(生成器对象) 可以调用生成器
- 生成器的作用:yield会把生成器相当于暂停在yield所在的位置,并且返回yield后面所跟的数据,下一次使用 next(生成器对象) 启动该生成器时,会直接从yield下面的语句开始执行,而不是从函数开始的位置执行(相当于冻结该函数)
- 不同生成器对象之间互不影响
- 想要得到生成器里面的return值:通过捕获异常对象,调用 异常对象.value 即可得到
- 使用send启动生成器:生成器对象.send(参数)
- send与next区别:send可以传递参数到yield的位置作为上一次yield的结果,而next不能
- 注意:如果第一次调用send时,send中有参数并且不是None,解释器会报错TypeError(因为没有接收参数的变量)
迭代器与生成器
- 迭代器的核心:迭代器保存的是生成数据的代码(生成方式),而不是保存的生成数据的结果
- 生成器的核心:生成器可以保证函数执行一部分返回一个结果,它与return的最大区别是return返回后函数就直接调用结束
- 迭代器可以保证将来用极小的代码和空间生成想要的数据;生成器可以让函数暂停执行,并且函数中的变量不会被扔掉,并且下一次调用时会恢复到上一次执行的状态
生成器实现多任务(协程:yield)
例如下面这个案例,通过协程实现了多任务并发(交替打印1和2)
使用包grennlet、gevent完成多任务
- greenlet是对yield的简单封装
- 上图中使用greenlet包实现协程方法如下
⭕ 调用greenlet包:from greenlet import greenlet
⭕ 调用greenlet类创建两个对象gr1、gr2,这两个对象即为生成器对象
⭕ 使用gr1.switch()即切换到了task_1函数中
⭕ 在task_1和task_2的永真循环while True中分别调用对方实例对象的switch()方法,即可切换到对方函数中,实现了交替打印,完成了单线程的多任务 - yield和greenlet遇到延时操作整个程序会全部卡住,因此yield和greenlet并不是真正的多任务
- gevent是对greenlet的再次封装
- 上图中使用gevent包实现协程方法如下
⭕ 通过gevent.spawn(函数名,函数参数)即可创建greenlet对象g1、g2
⭕ 如果遇到g1.join()、g2.join()等等延时操作,gevent会利用等待时间查看创建的Greenlet对象,并且自动切换函数,最终实现多任务 - 多协程的意义在于:利用了原来耗时的操作的等待时间进行做别的任务
- 线程结束,协程也结束
- 协程依赖于线程,线程依赖于进程
- 直接加入延时、堵塞等等耗时操作如 time.sleep() 是无法进行多任务的(因为需要将阻塞式系统调用改为协作式系统调用),程序执行仍然是单任务,所以gevent进行多任务操作需要把耗时操作改成gevent中的耗时操作例如把time.sleep()改成 gevent.sleep() 等等才能实现多协程的多任务
- 但是耗时操作一个个改比较麻烦,可以使用下面两种方式
⭕ 使用 gevent 中的 monkey(from gevent import monkey) 模块,然后调用monkey.patch_all()即可将程序中的耗时操作替换为 gevent 中的耗时操作。但是monkey模块会改变python很多模块和函数的原始状态,在一些情境下会出现灾难性的后果,尤其python3.6,所以一般使用下面这种方法
⭕ 使用gevent.joinall([列表内部可以创建多个greenlet对象如gevent.spawn()]),不需要写多个g.join()
线程、进程、协程对比总结
- 多任务的各种实现通俗理解如下
- 进程是资源分配的最小单位
- 线程是操作系统调度的最小单位
- 进程切换需要的资源最大,效率最低
- 线程切换需要的资源一般,效率也一般(在不考虑GIL的情况下)
- 协程切换任务资源最小,效率最高
- 多进程、多线程根据CPU核数不同可能是并行的,但是协程是在一个线程中,所以协程一定是并发的