使用Python中的线程进行网络编程的入门教程
引言
对于Python来说,并不缺少并发选项,其标准库中包括了对线程、进程和异步I/O的支持。在许多情况下,通过创建诸如异步、线程和子进程之类的高层模块,Python简化了各种并发方法的使用。除了标准库之外,还有一些第三方的解决方案,例如Twisted、Stackless和进程模块。本文重点关注于使用Python的线程,并使用了一些实际的示例进行说明。虽然有许多很好的联机资源详细说明了线程API,但本文尝试提供一些实际的示例,以说明一些常见的线程使用模式。
全局解释器锁(GlobalInterpretorLock)说明Python解释器并不是线程安全的。当前线程必须持有全局锁,以便对Python对象进行安全地访问。因为只有一个线程可以获得Python对象/CAPI,所以解释器每经过100个字节码的指令,就有规律地释放和重新获得锁。解释器对线程切换进行检查的频率可以通过sys.setcheckinterval()函数来进行控制。
此外,还将根据潜在的阻塞I/O操作,释放和重新获得锁。有关更详细的信息,请参见参考资料部分中的GilandThreadingState和ThreadingtheGlobalInterpreterLock。
需要说明的是,因为GIL,CPU受限的应用程序将无法从线程的使用中受益。使用Python时,建议使用进程,或者混合创建进程和线程。
首先弄清进程和线程之间的区别,这一点是非常重要的。线程与进程的不同之处在于,它们共享状态、内存和资源。对于线程来说,这个简单的区别既是它的优势,又是它的缺点。一方面,线程是轻量级的,并且相互之间易于通信,但另一方面,它们也带来了包括死锁、争用条件和高复杂性在内的各种问题。幸运的是,由于GIL和队列模块,与采用其他的语言相比,采用Python语言在线程实现的复杂性上要低得多。
使用Python线程
要继续学习本文中的内容,我假定您已经安装了Python2.5或者更高版本,因为本文中的许多示例都将使用Python语言的新特性,而这些特性仅出现于Python2.5之后。要开始使用Python语言的线程,我们将从简单的"HelloWorld"示例开始:
hello_threads_example
importthreading importdatetime classThreadClass(threading.Thread): defrun(self): now=datetime.datetime.now() print"%ssaysHelloWorldattime:%s"% (self.getName(),now) foriinrange(2): t=ThreadClass() t.start()
如果运行这个示例,您将得到下面的输出:
#pythonhello_threads.py Thread-1saysHelloWorldattime:2008-05-1313:22:50.252069 Thread-2saysHelloWorldattime:2008-05-1313:22:50.252576
仔细观察输出结果,您可以看到从两个线程都输出了HelloWorld语句,并都带有日期戳。如果分析实际的代码,那么将发现其中包含两个导入语句;一个语句导入了日期时间模块,另一个语句导入线程模块。类ThreadClass继承自threading.Thread,也正因为如此,您需要定义一个run方法,以此执行您在该线程中要运行的代码。在这个run方法中唯一要注意的是,self.getName()是一个用于确定该线程名称的方法。
最后三行代码实际地调用该类,并启动线程。如果注意的话,那么会发现实际启动线程的是t.start()。在设计线程模块时考虑到了继承,并且线程模块实际上是建立在底层线程模块的基础之上的。对于大多数情况来说,从threading.Thread进行继承是一种最佳实践,因为它创建了用于线程编程的常规API。
使用线程队列
如前所述,当多个线程需要共享数据或者资源的时候,可能会使得线程的使用变得复杂。线程模块提供了许多同步原语,包括信号量、条件变量、事件和锁。当这些选项存在时,最佳实践是转而关注于使用队列。相比较而言,队列更容易处理,并且可以使得线程编程更加安全,因为它们能够有效地传送单个线程对资源的所有访问,并支持更加清晰的、可读性更强的设计模式。
在下一个示例中,您将首先创建一个以串行方式或者依次执行的程序,获取网站的URL,并显示页面的前1024个字节。有时使用线程可以更快地完成任务,下面就是一个典型的示例。首先,让我们使用urllib2模块以获取这些页面(一次获取一个页面),并且对代码的运行时间进行计时:
URL获取序列
importurllib2 importtime hosts=["http://yahoo.com","http://google.com","http://amazon.com", "http://ibm.com","http://apple.com"] start=time.time() #grabsurlsofhostsandprintsfirst1024bytesofpage forhostinhosts: url=urllib2.urlopen(host) printurl.read(1024) print"ElapsedTime:%s"%(time.time()-start)
在运行以上示例时,您将在标准输出中获得大量的输出结果。但最后您将得到以下内容:
ElapsedTime:2.40353488922
让我们仔细分析这段代码。您仅导入了两个模块。首先,urllib2模块减少了工作的复杂程度,并且获取了Web页面。然后,通过调用time.time(),您创建了一个开始时间值,然后再次调用该函数,并且减去开始值以确定执行该程序花费了多长时间。最后分析一下该程序的执行速度,虽然“2.5秒”这个结果并不算太糟,但如果您需要检索数百个Web页面,那么按照这个平均值,就需要花费大约50秒的时间。研究如何创建一种可以提高执行速度的线程化版本:
URL获取线程化
#!/usr/bin/envpython importQueue importthreading importurllib2 importtime hosts=["http://yahoo.com","http://google.com","http://amazon.com", "http://ibm.com","http://apple.com"] queue=Queue.Queue() classThreadUrl(threading.Thread): """ThreadedUrlGrab""" def__init__(self,queue): threading.Thread.__init__(self) self.queue=queue defrun(self): whileTrue: #grabshostfromqueue host=self.queue.get() #grabsurlsofhostsandprintsfirst1024bytesofpage url=urllib2.urlopen(host) printurl.read(1024) #signalstoqueuejobisdone self.queue.task_done() start=time.time() defmain(): #spawnapoolofthreads,andpassthemqueueinstance foriinrange(5): t=ThreadUrl(queue) t.setDaemon(True) t.start() #populatequeuewithdata forhostinhosts: queue.put(host) #waitonthequeueuntileverythinghasbeenprocessed queue.join() main() print"ElapsedTime:%s"%(time.time()-start)
对于这个示例,有更多的代码需要说明,但与第一个线程示例相比,它并没有复杂多少,这正是因为使用了队列模块。在Python中使用线程时,这个模式是一种很常见的并且推荐使用的方式。具体工作步骤描述如下:
- 创建一个Queue.Queue()的实例,然后使用数据对它进行填充。
- 将经过填充数据的实例传递给线程类,后者是通过继承threading.Thread的方式创建的。
- 生成守护线程池。
- 每次从队列中取出一个项目,并使用该线程中的数据和run方法以执行相应的工作。
- 在完成这项工作之后,使用queue.task_done()函数向任务已经完成的队列发送一个信号。
- 对队列执行join操作,实际上意味着等到队列为空,再退出主程序。
在使用这个模式时需要注意一点:通过将守护线程设置为true,将允许主线程或者程序仅在守护线程处于活动状态时才能够退出。这种方式创建了一种简单的方式以控制程序流程,因为在退出之前,您可以对队列执行join操作、或者等到队列为空。队列模块文档详细说明了实际的处理过程,请参见参考资料:
join()
保持阻塞状态,直到处理了队列中的所有项目为止。在将一个项目添加到该队列时,未完成的任务的总数就会增加。当使用者线程调用task_done()以表示检索了该项目、并完成了所有的工作时,那么未完成的任务的总数就会减少。当未完成的任务的总数减少到零时,join()就会结束阻塞状态。
使用多个队列
因为上面介绍的模式非常有效,所以可以通过连接附加线程池和队列来进行扩展,这是相当简单的。在上面的示例中,您仅仅输出了Web页面的开始部分。而下一个示例则将返回各线程获取的完整Web页面,然后将结果放置到另一个队列中。然后,对加入到第二个队列中的另一个线程池进行设置,然后对Web页面执行相应的处理。这个示例中所进行的工作包括使用一个名为BeautifulSoup的第三方Python模块来解析Web页面。使用这个模块,您只需要两行代码就可以提取所访问的每个页面的title标记,并将其打印输出。
多队列数据挖掘网站
importQueue importthreading importurllib2 importtime fromBeautifulSoupimportBeautifulSoup hosts=["http://yahoo.com","http://google.com","http://amazon.com", "http://ibm.com","http://apple.com"] queue=Queue.Queue() out_queue=Queue.Queue() classThreadUrl(threading.Thread): """ThreadedUrlGrab""" def__init__(self,queue,out_queue): threading.Thread.__init__(self) self.queue=queue self.out_queue=out_queue defrun(self): whileTrue: #grabshostfromqueue host=self.queue.get() #grabsurlsofhostsandthengrabschunkofwebpage url=urllib2.urlopen(host) chunk=url.read() #placechunkintooutqueue self.out_queue.put(chunk) #signalstoqueuejobisdone self.queue.task_done() classDatamineThread(threading.Thread): """ThreadedUrlGrab""" def__init__(self,out_queue): threading.Thread.__init__(self) self.out_queue=out_queue defrun(self): whileTrue: #grabshostfromqueue chunk=self.out_queue.get() #parsethechunk soup=BeautifulSoup(chunk) printsoup.findAll(['title']) #signalstoqueuejobisdone self.out_queue.task_done() start=time.time() defmain(): #spawnapoolofthreads,andpassthemqueueinstance foriinrange(5): t=ThreadUrl(queue,out_queue) t.setDaemon(True) t.start() #populatequeuewithdata forhostinhosts: queue.put(host) foriinrange(5): dt=DatamineThread(out_queue) dt.setDaemon(True) dt.start() #waitonthequeueuntileverythinghasbeenprocessed queue.join() out_queue.join() main() print"ElapsedTime:%s"%(time.time()-start)
如果运行脚本的这个版本,您将得到下面的输出:
#pythonurl_fetch_threaded_part2.py [<title>Google</title>] [<title>Yahoo!</title>] [<title>Apple</title>] [<title>IBMUnitedStates</title>] [<title>Amazon.com:OnlineShoppingforElectronics,Apparel, Computers,Books,DVDs&more</title>] ElapsedTime:3.75387597084
分析这段代码时您可以看到,我们添加了另一个队列实例,然后将该队列传递给第一个线程池类ThreadURL。接下来,对于另一个线程池类DatamineThread,几乎复制了完全相同的结构。在这个类的run方法中,从队列中的各个线程获取Web页面、文本块,然后使用BeautifulSoup处理这个文本块。在这个示例中,使用BeautifulSoup提取每个页面的title标记、并将其打印输出。可以很容易地将这个示例推广到一些更有价值的应用场景,因为您掌握了基本搜索引擎或者数据挖掘工具的核心内容。一种思想是使用BeautifulSoup从每个页面中提取链接,然后按照它们进行导航。
总结
本文研究了Python的线程,并且说明了如何使用队列来降低复杂性和减少细微的错误、并提高代码可读性的最佳实践。尽管这个基本模式比较简单,但可以通过将队列和线程池连接在一起,以便将这个模式用于解决各种各样的问题。在最后的部分中,您开始研究如何创建更复杂的处理管道,它可以用作未来项目的模型。参考资料部分提供了很多有关常规并发性和线程的极好的参考资料。
最后,还有很重要的一点需要指出,线程并不能解决所有的问题,对于许多情况,使用进程可能更为合适。特别是,当您仅需要创建许多子进程并对响应进行侦听时,那么标准库子进程模块可能使用起来更加容易。有关更多的官方说明文档,请参考参考资料部分。