描述
对于python来说, 多线程是python的软肋。在官方文档表明,根据程序的不同类型,如是I/O密集型,CPU密集型,分别使用多线程,多进程会使性能达到最佳。本文的主要目的是笔者在面试过程中,提及到线程,进程,协程对于爬虫来说,哪个性能会更好(笔者本人认为是进程加协程二者配合会达到更好的效果)。希望通过本文来让其他人对于爬虫的线程,进程,协程使用有更深的理解。
本文涉及的环境模块有:
ubuntu16. python3.5 cpu 1核 网络正常家庭带宽
multiprocessing , threading, gevent, requests, urllib
试验条件
访问特定url 100次,通过访问的整体耗时,来判别多线程,多进程, 协程的性能对于爬取网络I/O密集型程序的优劣。特定url选定为百度首页https://www.baidu.com。
实验前期准备
单线程/单进程 响应时间测速
单线程/单进程 响应时间测速 作为 对照组。在这里选用requests, urllib两个爬虫常用模块进行对比,也希望通过此进行比较这二者间性能差别。
上图为访问100次url,所获得的响应时间。此时间差为time模块获取,因此与实际响应时间偏大。但对于此次分析,不造成任何影响。有几次延迟时间特别高,推测该网站对于爬虫有一定的识别,不代表该模块的性能。
上图是requests,urllib 100此访问时间综合。但是值得考虑的是,笔者采用的是同一url进行100次访问,在网站有爬虫识别的技术,其访问时间会延迟。对于一个爬虫来说,访问者100次同一个网站,其实这很常见。而测试的源代码如下:
url = "http://www.baidu.com" def LoopRequests(): count = 0 t = [] while True: if count > 100: break s = time.time() req = requests.get(url) e = time.time() time.sleep(0.1) print(e-s) red.rpush('x1', e-s) count += 1 return t import urllib from urllib import request def LoopUrlib(): count = 0 t = [] while True: if count > 100: break s = time.time() req = request.Request(url=url) rep = request.urlopen(req) e = time.time() time.sleep(0.1) print(">>", e-s) #single process thread t.append(e - s) red.rpush('x2', e-s) count += 1 return t
协程, 进程,线程对比
上图为各自开启了100个协程,100个进程,100个线程进行响应时间对比图,其数值通过三次实验访问的时间均值。其数值为结束100个url请求与开启访问url请求时间之差。值得注意的是开启了100个进程,实际在操作过程中笔者是开启了进程数为10的进程池,在for循环的100次达到的。从上图可以看出,gevent开启100个协程请求访问毫无压力,其总响应时间远低于开启了100个线程。有人会问,“开启线程不应该怎样都会比原先更快吗?怎么比单线程单进程的访问请求更慢?” 其原因是由于python本身的GIL锁导致,详细原因请看文末。
文末结论
我们知道能够开启的线程数,进程数是根据电脑本身cpu核数而定的,因此其数量不能随便增加。在上面开启了100个线程,可能线程数太多造成不必要的线程开销?因此有以下实验,分别开启了10个线程循环十次,5个线程循环二十次:
从上图表明,在减少线程数量时,可以减少线程开启的开销,结果是整体访问时间减少(数据是3次实验均值)。但始终不及gevent开启的协程效率高。其原因是由于python的GIL锁。 笔者参考资料作以下解释,见下一节
python GIL锁
python 的GIL锁并不是python 的特性,而我们使用大多解释器为CPython,其中就有GIL锁的身影。他的存在是保证多线程之间的数据完整性以及状态同步性。在这里推荐读者自己前往python社区进行详细了解JPython, IronPython解释器。有人思考,这不就是相当于还是单线程?从上图表明,在访问url上,单线程的性能比多线程还要好,那岂不是没有用了?任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。而在爬虫中经常会遇到一些网络延迟的问题,导致线程切换,其间花费的开销是很大的。尤其当线程数很多的时间,很大比例的时间都花费在切换在锁的操作上。因此,不建议使用threading'多线程进行爬取,在官方社区推出multiprocess多进程模块,通过进程来增加python程序的工作效率。从上上图中,可以看出多进程与协程之间的时间相差无几。但是进程之间的通信只能通过QUEUE, SHARE MEMORY的方式进行。但是在一些简单IO密集型操作中,如读写文件,可以通过多线程增加其工作效率。
测试程序见如下
url = "http://www.baidu.com" def LoopRequests(): count = 0 t = [] while True: if count > 1: break s = time.time() req = requests.get(url) e = time.time() # time.sleep(0.1) await asyncio.sleep(.1) print(e-s) # single process thread t.append(e - s) es = 1 if e - s > 5 else 0 red.rpush('x1', es) count += 1 return t import urllib from urllib import request def LoopUrlib(): count = 0 t = [] while True: if count > 1: break s = time.time() req = request.Request(url=url) rep = request.urlopen(req) e = time.time() # time.sleep(0.1) await asyncio.sleep(.1) print(">>", e-s) #single process thread t.append(e - s) es = 1 if e - s > 5 else 0 red.rpush('x2', es) count += 1 return t import multiprocessing from multiprocessing import Pool def multiPool(mode = ''): if mode == 'Loopreuqests': st = time.time() pool = Pool(10) for i in range(100): pool.apply_async(LoopRequests) pool.close() pool.join() print('>>>', time.time()-st) elif mode == 'LoopUrlib': st = time.time() pool = Pool(10) for i in range(100): pool.apply_async(LoopUrlib) pool.close() pool.join() print('>>>', time.time() - st) def threadTest(mode): import threading if mode == 'Loopreuqests': st = time.time() th = [] for i in range(5): th.append(threading.Thread(target=LoopRequests)) for i in th: i.start() i.join() print('>>>', time.time() - st) elif mode == 'LoopUrlib': st = time.time() th = [] for i in range(5): th.append(threading.Thread(target=LoopUrlib)) for i in th: i.start() i.join() print('>>>', time.time() - st) def get(k): a = [] while True: try: x = red.lpop(k)[1] if x == None:raise ValueError a.append(x) except Exception as e: print(e) # break return a import gevent from gevent import monkey;monkey.patch_all() def GeventTest(): s = time.time() gt = [gevent.spawn(LoopUrlib) for i in range(1000)] gevent.joinall(gt) d = time.time() # print("gevent:", d-s) gt = [gevent.spawn(LoopRequests) for i in range(1000)] gevent.joinall(gt) print("gevent:", time.time() - d)