进程和线程
多任务:操作系统可以同时运行多个任务。比如一遍浏览器上网一遍听音乐,Word写文档等,这就是多任务。
单核CPU怎么进行多任务处理?操作系统轮流让各个任务交替进行,任务1执行0.01s,任务2执行0.01s,任务3执行0.01s,反复执行下去。表面上看每个任务都是交替执行的,但是由于CPU的执行速度很快,感觉所有任务是在同时执行一样。
真正的并行执行多任务只能在多核cpu上实现,但是由于任务数远远多于CPU的核心数,所以操作系统也会自动把很多任务轮流调度到每个核心上执行。
对于操作系统来说,一个任务是一个进程(process),比如打开一个浏览器就是启动了一个浏览器进程,打开一个记事本就是启动了一个记事本进程。
有些进程不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内这些子任务称为线程(thread)。
由于每个进程至少要做一件事,所以一个进程最少有一个线程。像Word这种复杂的进程可以有多个线程,多个线程可以同时执行,多线程的执行方式和多进程一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来像同时执行一样。
前面写的Python程序都是执行单任务的进程,也就是只有一个线程,如果要同时执行多个任务怎么办?有两种解决方案:
一种是启动多个进程,每个进程虽然只有一个线程,但多个进程可以一块执行多个任务。
还有一种方法是启动一个进程,在一个进程内启动多个线程,这样多个线程也可以一块执行多个任务。
还可以启动多个进程,每个进程启动多个线程。
同时执行的多个任务通常各个任务之间并不是没有关联的,而是需要相互通信和协调,有时,任务1必须暂停等待任务2完成后才能继续执行,任务3和任务4不能共同执行 ,所以多进程和多线程的程序的复杂度要远远高于我们前面写的单进程单线程的程序。
Python既支持多进程又支持多进程。
多进程
要让Python实现多进程(multiprocessing),我们先了解操作系统的相关知识。
Unix/Linux操作系统提供了一个fork()
系统调用,它非常特殊。普通的函数调用,调用一次返回一次。但是fork()
调用一次返回两次,因为操作系统自动把当前进程复制一份,然后分别在父进程和子进程内返回。
子进程永远返回0,而父进程返回子进程的ID。这样做的理由是一个父进程可以fork出很多子进程,多以父进程要记下每个子进程的ID,而子进程只需要调用getppid()
就可以拿到父进程的ID。
Python的os
模块封装了常见的系统调用,其中就包括fork
,可以在Python程序中轻松创建子进程:
由于Windows系统没有fork调用,上面的代码无法再windows运行。
###multiprocessing
Linux、Unix编写多进程的服务程序是正确的选择。由于windows没有fork
调用,可以用multiprocessing
模块进行多进程编程。
multiprocessing
模块提供了一个Process
类来代表一个进程对象:
执行结果如下:
创建子进程时,只需要传入一个执行函数和函数的参数,创建一个Process
实例,用start
方法启动,这样创建的进程比fork
要简单。
join()
方法可以等待子进程结束再继续往下运行,通常用于进程间的同步。
Pool
如果要启动大量的子进程,可以用进程池的方式批量创建子进程:
运行结果:
代码解读:
对Pool
对象调用join()
方法会等待所有子进程执行完毕,调用join()
之前必须先调用close()
,调用close()
之后就不能继续添加新的Process了。
注意输出结果,task0,1,2,3是立刻执行的,而4要等待前面某个task完成后才执行,这是因为Pool的默认大小在我的电脑上是4,因此最多同时执行四个进程。这是Pool有意设计的限制,并不是操作系统的限制,改为p=Pool(5)
就可以同时跑五个进程。Pool的默认大小是CPU的核心数。
子进程
很多时候,子进程并不是自身,而是一个外部进程。我们创建了子进程后,还需要控制子进程的输入和输出。
subprocess
模块可以让我们非常方便的启动一个子进程,然后控制其输入和输出。下面例子演示了如何在python代码中运行命令nslookup www.python.org
,这和行命令直接运行的效果是一样的:
如果子进程还需要输入,则可以通过communicate()
方法输入:
运行结果:
进程间通信
Process
之间肯定是需要通信的,操作系统提供了很多机制来实现进程间的通信。Python的multiprocessing
模块包装了底层的机制,提供了Queeu
、Pipes
等方式来交换数据。
以Queue
为例,在父进程中创建两个子进程,一个往Queue
里写数据,一个从Queue
里读取数据:
运行结果:
多线程
多任务可以由多进程完成,也可以有一个进程池内的多线程完成。一个进程最少有一个线程。Python内置了多线程的支持,Python的线程是真正的Posix Thread,不是模拟出来的线程。
Python的标准库提供了两个模块:_thread
和threading
,_thread
是低级模块,threading
是高级模块,对_thread
进行了封装。
启动一个线程就是把一个函数传入并创建Thread
实例,然后调用start()
开始执行:
运行结果如下:
由于任何进程默认就会启动一个线程,我们把这个线程称为主线程,主线程又可以启动信的线程,Python的threading
模块有个current_thresd()
函数,永远返回当前进程的实例。主线程实例的名字叫做MainThread,子线程的名字在创建时指定。名字仅仅在打印时用来显示,没有任何其他意义。
Lcok
多线程和多进程最大的不同在于,在多进程中,同一个变量,各自有一份拷贝存于每个进程当中,互不影响,而多线程中,所有变量都由所有线程共享,所以,任何一个变量都可以被任何一个线程修改,因此,线程之间共享数据最大 的危险在于多个线程同时修改一个变量,内容会变混乱。参看下例:
我们定义了一个共享变量balance
,初始值为0
,并且启动两个线程,先存后取,理论上结果应该为0
,但是由于线程的调度是由操作系统决定的,当t1,t1交替执行时,只要循环次数够多,balance
的结果就不一定是0
了。
|
|
原因为高级语言的一条语句在CPU执行时是若干条语句,即使一个简单的计算balance=balance+n
也要分成两步计算:
1.计算
balance+n
,存入临时变量中
2.将临时变量的值赋给balance
由于x是局部变量,两个线程各自都有自己的x,当代码正常执行时:
|
|
但是t1和t2是交替运行的,如果操作系统以下面的顺序执行t1、t2:
|
|
究其原因,是因为修改balance需要多条语句,而执行这几条语句时,线程可能中断,从而导致多个线程把同一个对象的内容改乱了。
两个线程同时一存一取,就可能导致余额不足,我们必须确保有一个线程在修改balance的时候,别的线程一定不能改。如果我们要确保balance
计算正确,就要给change_it()
上一把锁,因此其他线程不能同时执行change_it()
,只能等待,直到锁被释放后,获得该锁以后才能改。由于锁只有一个,无论多少进程,同一时刻最多只有一个线程持有该锁,所以不会造成修改的冲突。创建一个锁就是通过threading.Lock()
来实现:
当多个线程同时执行lock.acquire()
时,只有一个线程能成功地获得锁,然后继续执行代码,其他线程就继续等待直到获得锁为止。
获得锁的线程用完后一定要释放锁,否则那些裤裤等待锁的线程将永远等待下去,称为死进程。所以我们用try..finally
来确保锁一定会释放。
锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整的执行,坏处是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大降低了。其次由于可以存在多个锁,不同的线程持有不同的锁,并试图获取对方持有的锁时,可能会造成死锁,导致多个线程全部挂起,即不能执行,也无法结束,只能靠操作系统强制终止。
多核CPU
多核可以同时执行多个线程,如果写一个死循环,会100%占用一个CPU。要把N核CPU全部跑满,就必须启动N个死循环线程。写一个python死循环:
启动和CPU核心数量相同的N个线程,在4核CPU上可以监控到CPU占有率仅为102%,也就是只使用了一个核。用C、C++、Java来改写相同的死循环,直接可以把全部核新跑满,Python就不行。因为python的现场虽然是真的线程,但是解释器执行代码时,有一个GIL锁:Global Interpr Lock,任何Python线程在执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁。所以在多线程python中只能交替执行。
所以在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过线程利用多核,只能通过C扩展实现,不过这样就失去了Python简单易用的特性。
不过可以通过多进程实现多核任务,多个Python进程有各自独立的GIL锁,互不影响。
ThreadLocal
在多线程环境下,每个线程都有自己的数据。一个线程使用自己的局部变量比使用全局变量好,因为局部变量只有线程自己能看见,不会影响其他线程,而全局变量的修改必须加锁。
但是局部变量的问题在于函数调用的时候,传递起来很麻烦:
每个函数一层一层的调用都传参数会很麻烦。也不能用全局变量,因为每个线程处理不同的Student
对象,不能共享。如果用一个全局dict
存放所有的Student
对象,然后以thread
自身作为key
获取线程对应的Student
对象:
这种方法理论上是可行的,最大的优点是消除了std
对象在每层函数中的传递问题。Python还提供了更简单的方式,ThreadLocal
:
执行结果:
全局变量local_school
就是一个ThreadLocal
对象,每个Thread
对它都可以读写student
属性,但互不影响。你可以把local_school
看成全局变量,但每个属性如local_school.student
都是线程的局部变量,可以任意读写而互不干扰,也不用管理锁的问题,ThreadLocal
内部会处理。
可以理解为全局变量local_school
是一个dict
,不但可以用local_school.student
,还可以绑定其他变量。
ThreadLocal
最常用的地方就是为每个线程绑定一个数据库连接,HTTP请求,用户身份信息等,这样一个线程的所有调用到的处理函数都非常方便的访问这些资源。
进程vs线程
讨论进程和线程的优缺点:
首先,要实现多任务,通常我们会设计Master-Worker模式,master负责分配任务,worker负责执行任务,因此多任务环境下,通常是一个Master,多个worker。
如果用多进程实现Master-Worker,主进程就是Master,其他进程就是Worker。
如果用多进程实现Master-Worker,主线程就是Master,其他线程就是Worker。
多进程模式的缺点是创建进程的代价大,在Unix/Linux系统下,用fork
调用还不算麻烦。在Windows下创建进程开销巨大。另外操作系统能同时运行的进程数也是有限的,在内存和CPU的限制下,如果有几千个进程同时运行,操作系统调度都很难。
多线程模式通常比多进程快一点,但是有限。而且多线程模式致命的缺点是任何一个线程挂掉都可能造成整个进程的崩 溃,以内所有的线程共享进程的内存。在Windows上,如果一个线程的代码处理问题,操作系统会强制结束整个进程。
在WIndows下,多线程的效率比多进程要高,所以微软的IIS服务器默认采用多线程模式。由于多线程存在稳定性的问题,IIS的稳定性就不如Apache。为了缓解这个问题,IIS和Apache现在又有了多进程+多进程的混合模式。
线程切换
无论是多进程还是多线程,只要数量多了,效率就低了。
操作系统在切换进程或者线程时,首先要保存当前执行的现场环境(CPU寄存器状态、内存页等),然后把新任务的执行环境准备好(恢复上次的寄存器状态,切换内存页等),才能开始执行。这个切换过程虽然很快,也需要耗费时间,如果有几千个任务同时进行,操作系统可能会主要忙着切换任务,执行任务的时间会减少,造成系统处于假死状态。多任务一旦到达一个限度,就会消耗掉系统所有的资源,效率急剧下降。
计算密集型vs.IO密集型
是否采用多任务的第二个考虑是任务的类型。
计算密集型任务的特点是要进行大量的计算,消耗CPU的资源。计算密集型任务虽然也可以用多任务完成,但是任务越多,任务切换的时间就越多,CPU执行任务的效率就越低。要高效的利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。
计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。Python这样的脚本语言运行效率很低,完全不适应计算密集型任务。对于计算密集型任务,最好用C语言编写。
IO密集型任务涉及到网络、磁盘IO的任务,这类任务的特点是CPU消耗很少,任务的大部分时间在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。
IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少,因此,用运行速度极快的C语言替换用python这样运行速度极低的脚本语言,完全无法提升运行效率。对于IO密集型任务,最合适的语言就是开发效率最高的语言。
异步IO
考虑到CPU和IO之间巨大的速度差异,一个任务在执行过程中大部分时间都在等待IO操作,单进程单线程模型会导致别的任务无法并行执行,因此我们才需要多进程模型或者多线程模型来支持多任务并发执行。
现代操作系统对IO操作已经做了巨大的改进,最大的特点就是支持异步IO。如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程的模型来执行多任务,这种全新的模型称为事件驱动模型,Nginx就是支持异步IO的Web服务器,它在单核CPU上采用单进程模型就可以高效的支持多任务。在多核CPU上可以运行多个进程,充分利用多核CPU。由于系统总的进程数量十分有限,因此操作系统调度非常高效。用异步IO编程模型来实现多任务是一个主要的趋势。
分布式进程
在Thread和Process中,应当优选Process,因为Process更稳定,而且Process可以分布到多台机器上,而Thread最多只能分布到一台机器的多个CPU上。
Python的multiprocessing
模块不但支持多进程,其中managers
子模块还支持把多个进程分布到多台机器上。一个服务进程可以作为调度者,将任务分布到其他多个进程中,依靠网络通信。由于managers
模块的封装很好,不必了解网络通信的细节,就可以很容易地编写分布式多进程程序。
例如:如果我们已经有一个通过Queue
通信的多进程程序在同一台机器上运行,现在,由于处理任务的进程任务繁重,希望把发送任务的进程和处理任务的进程分布到两台机器上。怎么用分布式进程实现呢?
原有的Queue
可以继续使用,但是,通过managers
模块把Queue
通过网络暴露出去,就可以让其他机器的进程访问Queue
了。
我们先看服务进程,服务进程负责启动Queue
,把Queue
注册到网络上,然后往Queue
里面写入任务:
当我们在一台机器上写多进程程序时,创建的Queue
可以直接拿来用,但是再分布式多进程环境下,添加任务到Queue
不可以直接对原始的task_queue
进行操作,那样就绕开了QueueManager
的封装,必须通过manager.get_task_queue()
获得Queue
接口添加。
然后在另外一台机器上启动任务程序:
任务进程要通过网络连接到服务进程,所以要指定任务进程的IP。
首先启动task_master.py
服务进程:
task_master.py
进程发送完任务后,开始等待result
队列的结果,现在启动task_worker.py
进程:
task_worker.py
进程结束,在task_master.py
进程中会继续打印出结果:
这个简单的Master/Worker模型就是一个简单但真正的分布式计算,把代码稍加改变,启动多个Worker,就可以把任务分布到几台甚至几十台机器上,比如把计算n*n的代码换为发送邮件,就实现了邮件队列的异步发送。
Queue对象存储在task_master.py
中:
而Queue
可以通过QueueManager
实现通过网络访问。由于QueueManager
管理的不止一个Queue
,所以要给每个Queue
的网络调用接口起个名字,比如get_task_queue
。
authkey
是为了保证两台计算机正常通信,不被其他机器恶意干扰。
Python的分布式进程接口简单,封装良好,适合需要把繁重任务分布到多台机器的环境下。注意Queue的作用是用来传递任务和接收任务,每个任务的描述数据量要尽量小。