3.2 Python中的线程

对于操作系统来说,一个任务就是一个进程(Process),比如打开一个浏览器就是启动一个浏览器进程,打开一个记事本就启动了一个记事本进程,打开两个记事本就启动了两个记事本进程。

有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。

线程是操作系统中程序执行和资源调度的基本单位。线程表示的是进程中不同的执行流程。同一个进程中的线程共享内存空间,因此开发者的任务就是控制和访问这些内存区域。

一般的爬虫都是单个线程的,不过一旦一个地方卡到不动了,或者一个地方报错了,导致整个程序不能执行,就会让我们的工作效率大打折扣,为此我们可以使用多线程或者多进程来处理。

不过多线程 thread 在 Python 里面被称作鸡肋的存在!通过python写爬虫,我们并不推荐使用多线程的方式,推荐使用多进程,不过还是需要进行简单的介绍。

1. 使用线程的优势和劣势

是否使用线程需要权衡利弊,这依赖于用于实现解决方案的编程语言和操作系统。

使用线程的优势如下所示:

  • 同一进程的不同线程之间交流的速度,数据单元(data location)的速度和共享信息的速度都很快
  • 创建线程的花费要远远少于创建进程的花费,这时因为它无需拷贝主进程上下文环境中的那些信息
  • 通过处理器的缓存机制可以优化内存存取,这样就能够充分利用数据局部性(data locality)的优势

使用线程的劣势如下:

  • 数据共享可以加速通讯.,然而新手使用线程也很容引进难以解决的错误
  • 数据共享限制了解决方案的灵活性,若想将其迁移到分布式架构上,会是件很头疼的事情. 总的来说,它限制了算法的可扩展性

就Python编程语言来说, 计算密集型的线程会由于GIL的存在而影响程序的性能。

2. 线程状态

在线程的生命周期中,有5中状态:

  • 新建: 该过程的主要动作就是创建一个新线程, 创建完新线程后,该线程被发送到待执行的线程队列中
  • 运行: 该状态下,线程获取到并消耗CPU资源
  • 就绪: 该状态下,线程在待执行的线程队列中排队,等待被执行
  • 阻塞: 该状态下,线程由于等待某个事件(例如I/O操作)的出现而被阻塞. 这时线程并不使用CPU
  • 死亡: 该状态下,线程释放执行时使用的资源并结束整个线程的生命周期

3. Python 多线程

如果要利用 Python 进行多线程编程,我们还需要了解一些背景知识。

GIL

GIL的全称是Global Interpreter Lock(全局解释器锁),来源是python设计之初的考虑,为了数据安全所做的决定。

每个CPU在同一时间只能执行一个线程(在单核CPU下的多线程其实都只是并发,不是并行,并发和并行从宏观上来讲都是同时处理多路请求的概念。但并发和并行又有区别,并行是指两个或者多个事件在同一时刻发生;而并发是指两个或多个事件在同一时间间隔内发生。)

在Python多线程下,每个线程的执行方式:

  1. 获取 GIL
  2. 执行代码直到 sleep 或者是 python 解释器将其挂起
  3. 释放 GIL

可见,某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,并且在一个python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。

在Python2.x里,GIL的释放逻辑是当前线程遇见IO操作或者ticks计数达到100(ticks可以看作是Python自身的一个计数器,专门做用于GIL,每次释放后归零,这个计数可以通过 sys.setcheckinterval 来调整),进行释放。

而每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。并且由于GIL锁存在,python里一个进程永远只能同时执行一个线程(拿到GIL的线程才能执行),这就是为什么在多核CPU上,python的多线程效率并不高。

针对 python 多线程的讨论

python 多线程是否有用,还需要进行分类讨论:

  1. CPU密集型代码(各种循环处理、计数等等),在这种情况下,由于计算工作多,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。
  2. IO密集型代码(文件处理、网络爬虫等),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。
  3. 多核多线程比单核多线程更差,单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行.但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸,导致效率更低。

在python3.x中,GIL不使用ticks计数,改为使用计时器(执行时间达到阈值后,当前线程释放GIL),这样对CPU密集型程序更加友好,但依然没有解决GIL导致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。

多进程的相对优势

每个进程有各自独立的GIL,互不干扰,这样就可以真正意义上的并行执行,所以在python中,多进程的执行效率优于多线程(仅仅针对多核CPU而言)。

所以结论是:多核下,想做并行提升效率,比较通用的方法是使用多进程,能够有效提高执行效率。

4. 多线程模块

Python提供了两个模块来实现基于系统的线程: _thread模块:提供了使用线程相关的较低层的API; 文档地址:http://docs.python.org/3.3/library/_thread.html

threading模块:提供了使用线程相关的较高层的API; 文档地址: http://docs.python.org/3.3/library/threading.html

threading模块提供的接口要比_thread模块的结构更友好一些. 至于具体选择哪个模块取决于开发者,如果我们觉得觉得在低层操作线程,实现自己的线程池,处理所及其其他原始特性(features)更顺手一些的话,可以使用_thread,否则threading会是更明智的选择。

_thread 案例

Python 中使用线程有2种方式:函数或者类来包装线程对象。函数式:调用 _thread 模块中的 start_new_thread() 函数来产生新线程。语法如下:

start_new_thread(function, args[, kwargs])

参数说明:

  • function – 线程函数。
  • args – 传递给线程函数的参数,他必须是个tuple类型。
  • kwargs – 可选参数。

这里我们用一个实例来感受一下:

import _thread
import time

def print_time(threadName, delay):
    count = 0
    while count < 5:
        time.sleep(delay)
        count += 1
        print("%s: %s" % (threadName, time.ctime(time.time())))


# 创建两个线程
try:
    _thread.start_new_thread(print_time, ("Thread-1", 2,))
    _thread.start_new_thread(print_time, ("Thread-2", 4,))
except:
    print("Error: unable to start thread")


while 1:
    pass

print("Main Finished")

可以发现,两个线程都在执行,睡眠2秒和4秒后打印输出一段话。注意到,在主线程写了:

while 1:
   pass

这是让主线程一直在等待。

如果去掉这两行,那就直接输出:

Main Finished

表明程序执行结束。最后的运行结果如下:

Thread-1: Mon Oct  9 16:00:07 2017
Thread-2: Mon Oct  9 16:00:09 2017Thread-1: Mon Oct  9 16:00:09 2017

Thread-1: Mon Oct  9 16:00:11 2017
Thread-2: Mon Oct  9 16:00:13 2017
Thread-1: Mon Oct  9 16:00:13 2017
Thread-1: Mon Oct  9 16:00:15 2017
Thread-2: Mon Oct  9 16:00:17 2017
......

5. 多线程爬虫

思路分析

我们知道,程序系统大部分在做计算、逻辑判断、循环导致 cpu 占用率很高的情况,称之为计算密集型;频繁网络传输、读取硬盘及其他 io 设备称之为 I/O 密集型。

假设一个爬虫步骤分为:获取,解析,存储。在三个步骤中,“获取”和“存储”是IO密集型任务,而“解析”大部分是计算密集型任务。

因为在 Python 中,多线程并不能并行执行,而是通过快速的多线程切换,让使用者感觉是并行执行的。所以,比较合理的思路应该是,N个线程进行“获取”、一个线程进行“解析”、一个线程进行“存储”。

当爬虫的解析任务非常重时,我们可以考虑配合多进程采集,思路是这样的:

  1. 主进程负责调度,共fork出“大于三个”子进程--抓取进程一个、解析进程N个、存储进程一个。
  2. 一个抓取进程生成多个抓取子线程,负责抓取任务。
  3. 多个解析进程分别负责解析不同的网页内容,同时把新的URL传给抓取进程。
  4. 存储进程负责存储任务。

多线程爬虫的实现

以 threading 模块为例,当我们开展多线程爬虫编程时,一般有如下步骤:

  1. 创建一个线程池 threads = []。
  2. 确认 url 队列是线程安全的 Queue Deque。
  3. 从队列去重 url,分配一个线程开始爬取。
  4. 如果线程已满,循环等待,直到线程结束。
  5. 从线程池移除已经完成下载的线程。
  6. 如果当前级别的url已经遍历完成,t.join()函数等待所有线程结束,然后开始下一级别的爬取。

多线程爬虫的注意事项

  1. 对于反爬严格的网站,速度提升有限。
  2. 复杂度提升,对编码要求更高。
  3. 线程越多,各个线程获得的时间就越少,同时线程的频繁切换也会带来一些额外开销。
  4. 线程之间的资源本身会有较大的竞争。

results matching ""

    No results matching ""