现在, 多核CPU已经非常普及了, 但是, 即使过去的单核CPU, 也可以执行多任务。 CPU执行代码都是顺序执行的, 那么, 单核CPU是怎么实现多任务的呢?
答案就是操作系统轮流让各个任务交替执行, 任务1执行0.01秒, 切换到任务2, 任务2执行0.01秒, 再切换到任务3, 执行0.01秒……这样反复执行下去。
从CPU的工作状态上看, 每个任务都是交替着单独执行的, 但是, 由于CPU的执行速度非常快, 我们感觉就像所有任务都在同时执行一样。其实真正的并发执行多任务只能在多核CPU上实现, 但是, 由于任务数量远远多于CPU的核数量, 所以, 操作系统也会自动把很多任务轮流调度到每个核上交替执行。
程序和进程的差别不言而喻,编写完毕的代码,在没有运行的时候,称之为程序,正在运行着的代码,就成为进程。进程除了包含代码以外,还有需要运行的环境等。
进程的五态模型:
一、在Linux的Ubuntu系统下创建多进程:
Python的os模块封装了常见的系统调用,其中就包括创建子进程的fork函数。fork这个系统函数非常特殊,普通的函数调用一次,返回一次,但是fork()调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。子进程永远返回0,而父进程返回子进程的pid。这样做的理由是,一个父进程可以fork出很多子进程,所以,父进程要记下每个子进程的ID,而子进程只需要调用getppid()就可以拿到父进程的ID。需要注意的是,fork函数只在Unix/Linux/Mac上运行,windows不支持。
1、看一个Ubuntu下创建多进程的例子:
仔细看上述代码和运行结果,程序的执行结果并不是你想象的打印了四次,在这里需要说明的是,当主进程(9584)执行到第一条fork语句时,要创建一个子进程,在这个时候,会将主进程的所有内容,复制一份给子进程(9585),也就说,子进程(9585)中也会包含主进程(9584)中的第二条fork语句,即子进程(9585)也会再创建子进程(9587),主进程(9584)执行到第二条fork语句时,会再创建一个子进程(9586),这样就导致了这段代码会有六次的print,这样说可能依然不是很清楚,下面画一个简单的进程关系图帮助理解:
说明:主进程、 子进程的执行顺序没有规律, 完全取决于操作系统的调度算法
2、多进程修改全局变量
多进程中,子进程相当于是主进程的一个深拷贝, 每个进程中所有数据( 包括全局变量) 都各有拥有一份, 互不影响。
二、Windows下实现多进程
如果你打算编写多进程的服务程序, Unix/Linux无疑是正确的选择。 由于Windows没有fork函数, 难道在Windows上就无法用Python编写多进程的程序?由于Python是跨平台的, 当然也应该提供跨平台的多进程支持。multiprocessing模块就是跨平台版本的多进程模块。multiprocessing模块提供了一个Process类用来创建进程对象, 下面将介绍两种通过multiprocessing这种跨平台的方式实现多进程的方法。
1、创建Process类的进程对象
2、定义类,让其继承于Process类,并重写其run(名称不能改)方法,创建定义的类的实例对象
在这里要特别注意一下,在定义类时,其初始化函数一定要调用Process类的__init__方法(很重要),因为在Process类中,其__init__函数其中有一个关键字参数target,如果不给target赋值,即不指定子进程函数的话,会默认执行其run方法,此处必须调用Process类的__init__函数,并且不给target赋值,因为新定义的类中重写了run方法,所以其中的run方法才会执行。
看一下Process类的help信息,其初始化魔法方法中除了target,args,kwargs等此参数之外还有一个daemon参数,该参数代表该进程是否是守护进程,若daemon=True,则表示该进程为守护进程,当主进程执行完毕时,若只剩下该守护进程,则该守护进程即使没有执行完毕也自动退出,若主进程执行完毕时,还有其他非守护进程未执行完毕,则该守护进程不会退出。
下面看一个例子对比一下是否设置为守护进程的区别:
这是一个普通的通过继承Process类的方式创建子进程的方式,从结果来看不出意料,整个进程等到所有的父进程和子进程都执行结束算是执行完毕:
下面将子进程设置为守护进程再来看看输出结果,可以看出整个进程并没有等待父进程和子进程都执行完,而是父进程执行结束就整个都结束了,没有等待子进程输出:
两种创建子进程的方式的比较:
继承类是以面向对象考虑这个事的,所以业务逻辑复杂,建议使用继承类,更好理解。
3、进程池
当需要创建的子进程数量不多时, 可以直接利用multiprocessing中的Process动态生成多个进程, 但如果是上百甚至成千上万的目标, 这种方法就显得十分不方便, 此时就可以用到multiprocessing模块提供的Pool方法。
初始化Pool时, 可以指定一个最大进程数, 当有新的请求提交到Pool中时,如果池还没有满, 那么就会创建一个新的进程来执行该请求; 但如果池中的进程数已经达到指定的最大值, 那么该请求就会等待, 直到池中有进程结束, 才会创建新的进程, 请看下面的实例:
.apply_async函数是进程池中的多个进程采用非阻塞的方式并发执行,.apply函数采用阻塞的方式执行,即进程池中只能有一个进程在执行,退出一进入一个。
4、进程间通信
Process之间有时需要通信, 操作系统提供了很多机制来实现进程间的通信。可以使用multiprocessing模块的Queue实现多进程之间的数据传递, Queue本身是一个消息列队程序,
说明:
1、初始化Queue()对象时( 例如: q=Queue()) , 若括号中没有指定最大可接收的消息数量, 或数量为负值, 那么就代表可接受的消息数量没有上限( 直到内存的尽头) ;
2、Queue.qsize(): 返回当前队列包含的消息数量;
3、Queue.empty(): 如果队列为空, 返回True, 反之False ;
4、Queue.full(): 如果队列满了, 返回True,反之False;
5、Queue.get([block[, timeout]]): 获取队列中的一条消息, 然后将其从列队中移除, block默认值为True;Queue.get_nowait(): 相当Queue.get(False);
a) 如果block使用默认值, 且没有设置timeout( 单位秒) , 消息列队如果为空, 此时程序将被阻塞( 停在读取状态) , 直到从消息列队读到消息为止,如果设置了timeout, 则会等待timeout秒, 若还没读取到任何消息, 则抛出"Queue.Empty"异常;
b) 如果block值为False, 消息列队如果为空, 则会⽴刻抛出"Queue.Empty"异常;
6、Queue.put(item,[block[, timeout]]): 将item消息写入队列, block默认值为True;Queue.put_nowait(item): 相当Queue.put(item, False);
a) 如果block使用默认值, 且没有设置timeout( 单位秒) , 消息列队如果已经没有空间可写入, 此时程序将被阻塞( 停在写入状态) , 直到从消息列队腾出空间为止, 如果设置了timeout, 则会等待timeout秒, 若还没空间, 则抛出"Queue.Full"异常;
b) 如果block值为False, 消息列队如果没有空间可写入, 则会立刻抛出"Queue.Full"异常;
看下面在Ubuntu系统上运行的例子,可以看出读和写进程操作的是同一个消息队列:
5、进程池中的Queue
如果要使用Pool创建进程, 就需要使用multiprocessing.Manager()中的Queue(), 而不是multiprocessing.Queue(), 否则会抛异常。
在这里需要强调一下,使用apply阻塞的方式实现进程池中进程的相互通信时,理论上讲,读和写进程操作的应该是同一个消息队列,即对同一个消息队列执行读写的操作,并且这里采用阻塞的方式,最要的是为了让读进程在读的时候,写进程已经全部写入了,因为两个进程如果采用非阻塞的方式放入进程池的话,它们抢占消息队列进行操作的机会是平等且不可预估的,这样就造成了可能读进程进行读取的操作在写进程写入之前,这样如果读进程不使用死循环的话可能就无法读取全部的数据。看一下通过apply阻塞的方式实现进程池间的相互通信在Ubuntu上的执行结果(Windows)上也一样: