1.前言
在多线程出现之前,计算机程序的执行是由单个步骤序列组成的,该序列在主机的CPU中按照同步顺序执行。无论是任务本身需要按照步骤顺序执行,还是整个程序实际上包含多个子任务,都需要按照这种顺序方式执行。
假如我们设定这些子任务都是相互独立的,没有因果关系,各个子任务的结果并不影响其他子任务的结果,那么我们按照上面所述的执行方法会不会有些不合适呢?显而易见的是,我们采用并行的处理方式可以显著地提高整个任务的性能,这就是我们要说的多线程编程。
多线程编程对于下面这样的情况而言来说是非常理想的:本质上是异步的,需要多个并发活动,每个活动的处理顺序可能是不确定的,或者说是随机的,不可预测的。像这种编程任务可以被组织划分成多个执行流,其中每个执行流都有一个指定要完成的任务,根据任务的不同,这些子任务可能需要计算出中间结果,然后合并为最终的输出结果。
2.线程和进程
2.1 进程
进程是一个进行中的程序。每个程序都拥有自己的地址空间、内存、数据栈以及其他用于跟踪执行的辅助数据。OS管理所有进程的执行,并为这些进程合理地分配时间。进程也可以通过派生新的进程来执行其他任务,不过因为每个新进程也都拥有自己的内存和数据栈等,所以只能采用进程间通信(IPC)的方式来共享信息。
2.2 线程
线程和进程类似,不过它们是在同一个进程下执行的,并共享相同的上下文。可以将它们认为是在一个主进程或主线程中并行运行的迷你进程。线程包括开始、执行顺序和结束三部分。它有一个指令指针,用于记录当前运行的上下文。当其他线程运行时,它可以被抢占和临时挂起,我们称之为让步。
一个进程中的各个线程与主线程共享同一片数据空间,因此相比于独立的进程而言,线程间的共享和通信更加容易。
3.线程和Python
3.1 全局解释器锁
Python代码的执行是有Python虚拟机进行控制的。对于Python虚拟机的访问是由全局解释器锁(GIL)控制的。这个锁就是用来保证同时只能有一个线程运行的。在多线程环境中,Python虚拟机将按照下面所述的方式执行:
- 设置GIL
- 切换进一个线程去运行
- 执行下面的操作之一:指定数量的字节码指令、线程主动让出控制权
- 把线程设置回睡眠状态
- 解锁GIL
- 重复上述步骤
当你调用外部代码时,GIL会保持锁定,直至函数执行结束。
3.2 退出线程
当一个线程完成函数的执行时,它就会退出。你可以调用例如thread.exit()之类的退出函数,或者sys.exit()之类的退出Python进程的标准方法,或者抛出SysremExit异常。不过,你不能直接“终止”一个线程。
4.在Python中使用线程
Python虽然是支持多线程编程的,但是还是要取决于它所运行的操作系统。下面我们提到的操作系统是支持多线程的:绝大多数的UNIX平台、Windows平台。Python使用兼容的POSIX的线程,也就是众所周知的pthread。在下面的介绍过程中,我们将对比不同的使用线程和不使用线程以及使用不同的模块来实现多线程编程的情况,从程序运行的结果我们可以很清楚的看到区别。
4.1不使用多线程编程
不使用多线程的情况就是我们采用的是单线程的方式,这里我们用time.sleep()函数来演示这样的操作是怎样进行的。time.sleep()函数需要一个浮点型的参数,然后以这个给定的秒数进行睡眠的操作,也就是说程序的执行会暂时停止指定的时间。我们来看这样的一个程序。
from time import sleep,ctime
def loop0():
print 'start loop 0 at:',ctime()
sleep(4)
print 'loop 0 done at:',ctime()
def loop1():
print 'start loop 1 at:',ctime()
sleep(2)
print 'loop 1 done at:',ctime()
def main():
print 'starting at:',ctime()
loop0()
loop1()
print 'all DONE at:',ctime()
if __name__=='__main__':
main()
在这个程序里面,我们创建了两个时间循环:一个睡眠4秒,另一个睡眠2秒。如果在一个单进程或者单线程的程序中执行这两个循环,就会像我们下面看到的执行结果一样,整个执行时间将会达到6秒钟。而在启动loop0()和loop1()以及执行代码和其他代码的时候,也有可能存在其他的一些时间开销,这样计算下来,整个时间将会超过我们预期的6秒,下面是代码执行的结果,我们可以清楚的看到时间的差距。
starting at: Fri Aug 24 08:26:51 2018
start loop 0 at: Fri Aug 24 08:26:51 2018
loop 0 done at: Fri Aug 24 08:26:55 2018
start loop 1 at: Fri Aug 24 08:26:55 2018
loop 1 done at: Fri Aug 24 08:26:57 2018
all DONE at: Fri Aug 24 08:26:57 2018
现在我们提出这样的问题:假设这两个循环里面执行的不是睡眠操作,而是独立的计算操作,所有结论总成一个最终结果,那么,让这些分开的步骤并行执行会不会减少总得执行时间?我们就会采用多线程编程的方式来解决这样的问题。
Python中提供了很多个模块来支持多线程编程,包括有:thread、threading、Queue等。thread模块提供了基本的线程和锁定支持,threading模块提供了更高级别、功能更全面的线程管理,Queue可以创建一个队列数据结构,用于在多线程之间进行共享。
4.2 thread模块
前面我们说过thread模块提供的一些基本功能,下面我们再来看看thread模块里面的一些函数和方法,在实际编程中我们会经常使用到下面的方法。
函数/方法 | 描述 |
thread模块的函数 | |
start_new_thread(function,args,kwargs=None) | 派生一个新的线程,使用给定的args和可选的kwargs来执行function |
allocate_lock() | 分配LockType锁对象 |
exit() | 给线程退出指令 |
LockType锁对象的方法 | |
acquire(wait=None) | 尝试获取锁对象 |
locked() | 如果获取了锁对象则返回True,否则返回False |
release() | 释放锁 |
接下来,我们就看看使用thread模块的程序,注意要和前面的程序进行对比区别:
import thread
from time import sleep,ctime
def loop0():
print 'start loop 0 at:',ctime()
sleep(4)
print 'loop 0 done at:',ctime()
def loop1():
print 'start loop 1 at:',ctime()
sleep(2)
print 'loop 1 done at:',ctime()
def main():
print 'starting at:',ctime()
thread.start_new_thread(loop0,())
thread.start_new_thread(loop1,())
sleep(6)
print 'all DONE at',ctime()
if __name__=='__main__':
main()
start_new_thread函数必须包含开始的两个参数,于是即使要执行的函数不需要参数,也需要传递一个空元组。和前面单线程的程序相比,原来要运行6秒+的时间,现在的脚本只需要运行4秒,也就是最长的循环加上其他所有开销的时间之和。睡眠4和睡眠2是并发执行的,你可以可以很清楚的看到loop1是如何在loop0之前就结束的。
但是我们在这里却加了sleep(6)这样的一个操作,这是为何呢?如果我们没有阻止主线程继续执行,它将会继续执行下一条语句,显示“all done”然后退出,而loop0和loop1这两个线程将直接终止。我们没有写让主线程等待子线程全部完成后继续的代码,sleep(6)是作为一种同步约束而存在的,这是因为我们知道所有线程会在主线程倒计时到6秒之前完成。
starting at: Fri Aug 24 08:48:20 2018
start loop 0 at: Fri Aug 24 08:48:20 2018
start loop 1 at: Fri Aug 24 08:48:20 2018
loop 1 done at: Fri Aug 24 08:48:22 2018
loop 0 done at: Fri Aug 24 08:48:24 2018
all DONE at Fri Aug 24 08:48:26 2018
再一次修改代码的话,我们会引入锁,并去除单独的循环函数,我们就不需要再像上面的程序中那样等待额外的时间后才能结束,通过使用锁,我们可以在所有的线程全部完成执行后立即退出,来看看修改后的代码:
import thread
from time import sleep,ctime
loops=[4,2]
def loop(nloop,nsec,lock):
print 'start loop',nloop,'at:',ctime()
sleep(nsec)
print 'loop',nloop,'done at:',ctime()
lock.release()
def main():
print 'starting at:',ctime()
locks=[]
nloops=range(len(loops))
for i in nloops:
lock=thread.allocate_lock()
lock.acquire()
locks.append(lock)
for i in nloops:
thread.start_new_thread(loop,(i,loops[i],locks[i]))
for i in nloops:
while locks[i].locked():
pass
print 'all DONE at:',ctime()
if __name__=='__main__':
main()
在这里我们主要看main函数里面的一些操作,这里使用了3个独立的for循环。首先创建一个锁的列表,通过使用thread.allocate_lock()函数得到锁对象,然后通过acquire方法取得每个锁,获得锁的效果就是“把锁锁上”,一旦锁被锁上后,就可以把它添加到锁列表locks中。下一个循环用于派生线程,每个线程都会调用loop()函数,并传递循环号、睡眠时间以及用于该线程的锁这几个参数。
在每个线程执行完成时,它都会释放自己的锁对象。最后一个循环只是坐在那里等待,直到所有锁都被释放之后才会继续执行。我们来看一下具体的执行结果,很明显的我们可以看到与前面的不同。
starting at: Fri Aug 24 10:17:09 2018
start loop 0 at: Fri Aug 24 10:17:09 2018
start loop 1 at: Fri Aug 24 10:17:09 2018
loop 1 done at: Fri Aug 24 10:17:11 2018
loop 0 done at: Fri Aug 24 10:17:13 2018
all DONE at: Fri Aug 24 10:17:13 2018
4.3 threading模块
对比thread模块来说,我们推荐使用threading模块来进行多线程编程的一些操作。原因有以下方面:threading模块更加先进,有更好的线程支持,并且thread模块中的一些属性会和threading模块有冲突,还有就是低级别的thread模块拥有的同步原语很少。另一方面thread模块对于进程何时退出没有控制,当主线程结束时,其他线程也都强制结束,不会发出警告或者进行适当的处理。也就是没有守护线程这个概念,threading模块支持守护线程。
守护线程一般是一个等待客户端请求服务的服务器,如果没有客户端请求,守护线程就是空闲的。如果把一个线程设置为守护线程,就表示这个线程是不重要的,进程退出的时候不需要等待这个线程执行完成。
如果主线层要退出,不需要等待某些子线程完成,就可以为这些子线程设置守护线程标记,该标记值为真时,表示该线程是不重要的,或者说该线程只是用来等待客户端请求而不做任何其他事情。
threading模块的Thread类是主要的执行对象,有很多thread模块中没有的很多函数,下面列出来看看,有些我们会经常用到。
属性 | 描述 |
Thread对象数据属性 | |
name | 线程名 |
ident | 线程的标识 |
daemon | 布尔标志,表示这个线程是否是守护线程 |
Thread对象方法 | |
_init_(group=None,tatget=None,name=None,arg=(),kwargs={},verbose=None,daemon=None) | 实例化一个线程对象,需要有一个可调用的target,以及其参数args或kwargs。还可以传递name或group参数。verbose标志也是可选的,而daemon的值将会设定thread.daemon属性/标志 |
start() | 开始线程 |
run() | 定义线程功能的方法 |
join() | 直至启动的线程终止前一直挂起 |
getName() | 返回线程名 |
setName(name) | 设定线程名 |
isAlivel | 布尔标志,表示这个线程是否还存活 |
isDaemon | 如果是守护线程为True,否则为False |
setDaemon(daemonic) | 把线程的守护标志设定为布尔值damonic |
下面我们就来尝试使用一下threading模块进行一些操作。
import threading
from time import sleep,ctime
loops=[4,2]
def loop(nloop,nsec):
print 'Start loop',nloop,'at:', ctime()
sleep(nsec)
print 'loop',nloop,'done at:',ctime()
def main():
print 'Starting at:',ctime()
threads=[]
nloops=range(len(loops))
for i in nloops:
t=threading.Thread(target=loop,args=(i,loops[i]))
threads.append(t)
for i in nloops:
threads[i].start()
for i in nloops:
threads[i].join()
print 'all DONE at:',ctime()
if __name__=='__main__':
main()
对比前面的程序,使用thread模块时候实现的锁没有了,取而代之的是一组Thread对象。当实例化每个Thread对象时,把函数target和参数args传进去,然后得到返回的Thread实例。实例化Thread和调用thread.start_new_thread()的最大区别是线程不会立即开始执行。这是一个非常有用的同步功能,尤其是当你并不希望线程立即开始执行时。
当所有线程都分配完成后,通过调用每个线程的start()方法让它们开始执行,而不是在这之前就会执行。join()方法将等待线程结束,或者在提供了超时时间的情况下,达到超时时间。使用join方法要比等待锁释放的无限循环更加清晰。其实join()方法是在你需要等待线程完成的时候才是有用的。来看一下程序执行的结果:
Starting at: Sat Aug 25 17:15:44 2018
Start loop 0 at: Sat Aug 25 17:15:44 2018
Start loop 1 at: Sat Aug 25 17:15:44 2018
loop 1 done at: Sat Aug 25 17:15:46 2018
loop 0 done at: Sat Aug 25 17:15:48 2018
all DONE at: Sat Aug 25 17:15:48 2018
我们还可以这样做:创建Thread的实例,传给它一个可调用的类实例。
在创建线程的时候,与传入函数相似的一个方法是传入一个可调用的类的实例,用于线程执行,这种方法更像面向对象的多线程编程。这种可调用的类包含一个执行环境,比起一个函数或者从一组函数中选择而言,有更好的灵活性。你拥有了一个类对象!来卡看看:
import threading
from time import sleep,ctime
loops=[4,2]
class ThreadFunc(object):
def __init__(self,func,args,name=''):
self.name=name
self.func=func
self.args=args
def __call__(self):
self.func(*self.args)
def loop(nloop,nsec):
print 'Start loop',nloop,'at:', ctime()
sleep(nsec)
print 'loop',nloop,'done at:',ctime()
def main():
print 'Starting at:',ctime()
threads=[]
nloops=range(len(loops))
for i in nloops:
t=threading.Thread(target=ThreadFunc(loop,(i,loops[i]),loop.__name__))
threads.append(t)
for i in nloops:
threads[i].start()
for i in nloops:
threads[i].join()
print 'all DONE at:',ctime()
if __name__=='__main__':
main()
这次主要是添加了ThreadFunc类,并在实例化Thread对象时候做了一些改动,同时实例化了可调用类ThreadFunc。执行结果与上面相同,就不再阐述了。
另外最后一种处理方法是这样的:派生Thread的子类,并创建子类的实例。我们称为子类化的Thread:
import threading
from time import sleep,ctime
loops=[4,2]
class MyThread(threading.Thread):
def __init__(self,func,args,name=''):
threading.Thread.__init__(self)
self.name=name
self.func=func
self.args=args
def run(self):
self.func(*self.args)
def loop(nloop,nsec):
print 'Start loop',nloop,'at:', ctime()
sleep(nsec)
print 'loop',nloop,'done at:',ctime()
def main():
print 'Starting at:',ctime()
threads=[]
nloops=range(len(loops))
for i in nloops:
t=MyThread(loop,(i,loops[i]),loop.__name__)
threads.append(t)
for i in nloops:
threads[i].start()
for i in nloops:
threads[i].join()
print 'all DONE at:',ctime()
if __name__=='__main__':
main()
对比上面的程序主要区别是这样的:MyThread子类的构造函数必须先调用其基类的构造函数。之前特殊方法__call__()在这里写成run()。此外我们还对MyThread类进行修改增加一些调试信息的输出,并将其存储为一个myThread的独立模块,以便在接下来的例子中导入这个类。除了简单的调用函数外,还将把结果保存在实例属性self.res中,并创建一个新的方法getResult()来获取这个值。下面是修改的Thread子类myThread。
import threading
from time import ctime
class MyThread(threading.Thread):
def __init__(self,func,args,name=''):
threading.Thread.__init__(self)
self.name=name
self.func=func
self.args=args
def getResult(self):
return self.res
def run(self):
print 'Starting',self.name, 'at:',ctime()
self.res=self.func(*self.args)
print self.name,'finished at:',ctime()
4.4 单线程和多线程执行对比
现在就来看一个实例的例子,我们在以前也接触过这样的算法。下面的程序比较了递归求斐波那契、阶乘与累加函数的操作,程序先按照单线程的方式运行这三个函数,之后用多线程的方式执行同样的操作,两者对比着说明多线程的优点。来看看这个程序:
from myThread import MyThread
from time import ctime ,sleep
#斐波那契
def fib(x):
sleep(0.005)
if x<2: return 1
return (fib(x-2)+fib(x-1))
#阶乘操作
def fac(x):
sleep(0.1)
if x<2:return 1
return (x*fac(x-1))
#累加函数
def sum(x):
sleep(0.1)
if x<2:return 1
return (x+sum(x-1))
funcs=[fib,fac,sum]
n=12
def main():
nfuncs=range(len(funcs))
print '***SINGLE THREAD'
for i in nfuncs:
print 'Starting',funcs[i].__name__,'at',ctime()
print funcs[i](n)
print funcs[i].__name__,'finished at:',ctime()
print '***MUTIPLE THREAD'
threads=[]
for i in nfuncs:
t=MyThread(funcs[i],(n,),funcs[i].__name__)
threads.append(t)
for i in nfuncs:
threads[i].start()
for i in nfuncs:
threads[i].join()
print threads[i].getResult()
print 'all DONE'
if __name__=='__main__':
main()
以单线程运行时,只是简单地依次调用每个函数,并在函数执行结束后立即显示相应的结果。而以多线程模式运行时,并不会立即显示结果。因为我们希望MyThread类越通用越好,我们要一直等到所有的线程都结束,然后调用getResult()方法来最终显示每个函数的返回值。函数执行起来都特别快,所有我们为了减慢执行速度,加入了sleep()调用,以便让我们看到多线程是如何改善的。
***SINGLE THREAD
Starting fib at Mon Aug 27 08:35:32 2018
233
fib finished at: Mon Aug 27 08:35:34 2018
Starting fac at Mon Aug 27 08:35:34 2018
479001600
fac finished at: Mon Aug 27 08:35:36 2018
Starting sum at Mon Aug 27 08:35:36 2018
78
sum finished at: Mon Aug 27 08:35:37 2018
***MUTIPLE THREAD
Starting fib at:Starting Mon Aug 27 08:35:37 2018
fac Startingat: Mon Aug 27 08:35:37 2018
sum at: Mon Aug 27 08:35:37 2018
sum finished at: Mon Aug 27 08:35:38 2018
fac finished at: Mon Aug 27 08:35:38 2018
fib finished at: Mon Aug 27 08:35:39 2018
233
479001600
78
all DONE
从执行的结果,我们可以很清楚的看到结果的区分和多线程的优点所在。毋庸置疑的是单线程和多线程的结果肯定都是一样的。这部分内容到这里就算基本完结了,后续会更新全新的章节,敬请期待!