手记

Python多线程之threading.Thread实现

Python多线程之threading.Thread
并行和串行

串行

所谓串行,就好比我们走路一样,一条马路,一次只能过一辆车,这样速度就会很受限制。

并行

理解了串行,并行就更好理解了,就是好多条路。路越多,车流量就越大。

多线程就是并行的一种。当然,实际发生在计算机内部的时候,并不能单纯的理解为多了一条路。因为我们的计算机一个CPU核心,同时只能处理一个任务。在CPU只有一个核心的情况下,多线程我们就可以理解为开辟出了许多条道路,但是我们的出口只有一个。每条路上面的车都会你争我抢谁也不让,哪条路抢到了通行权这条路上的汽车就会赶快通过,直到下条路抢到通行权,其他路上的汽车都会进入等待状态。实际发生在计算机内部的时候,线程之间的切换都是毫秒级的,所以人的无法感觉出来线程之间有等待的,在人看来所有的线程都是同时运行的。使用多线程,可以大大的增加程序的性能和效率。

Python标准库threading.Thread

线程创建

1. 使用Thread类创建

# 导入Python标准库中的Thread模块
from threading import Thread
# 创建一个线程
t = Thread(target=function_name, args=(function_parameter1, function_parameterN))
# 启动刚刚创建的线程
t.start()

function_name: 需要线程去执行的方法名

args: 线程执行方法接收的参数,该属性是一个元组,如果只有一个参数也需要在末尾加逗号。

2. 使用继承类创建

from threading import Thread
# 创建一个类,必须要继承Thread
class MyThread(Thread):
    # 继承Thread的类,需要实现run方法,线程就是从这个方法开始的
    def run(self):
        # 具体的逻辑
        function_name(self.parameter1)

    def __init__(self, parameter1):
        # 需要执行父类的初始化方法
        Thread.__init__(self)
        # 如果有参数,可以封装在类里面
        self.parameter1 = parameter1

# 如果有参数,实例化的时候需要把参数传递过去
t = MyThread(parameter1)
# 同样使用start()来启动线程
t.start()

线程等待

在上面的例子中,我们的主线程不会等待子线程执行完毕再结束自身。可以使用Thread类的join()方法来子线程执行完毕以后,主线程再关闭。

from threading import Thread

class MyThread(Thread):
    def run(self):
        function_name(self.parameter1)

    def __init__(self, parameter1):
        Thread.__init__(self)
        self.parameter1 = parameter1

t = MyThread(parameter1)
t.start()
# 只需要增加一句代码
t.join()

上面的方法只有一个线程,如果有多个线程,可以把每个线程放在一个数组中。

thread_list = []
for i in range(1, 11):
    t = MyThread(parameter1)
    thread_list.append(t)
    t.start()

# 在这里统一执行线程等待的方法
for t in thread_list:
    t.join()

CPU密集型操作和IO密集型操作

CPU密集型操作

在我们的计算机中,需要大量用到CPU计算的事情,我们称为CPU密集型操作。

如,我们计算9的一亿次方,这种大型的运算,或者是进行文件格式的转换,这些都是属于CPU密集型操作。

注意:上面的运算会消耗很长的计算时间,有兴趣可以从小到大慢慢尝试一下

IO密集型操作

所谓IO密集型操作,就是涉及到大量的输入输出,比如频繁的数据库访问,频繁的web服务器访问,这种情况都属于IO密集型操作。

不幸的GIL

线程同步

我们都知道,多线程最大的一个问题就是线程之间的数据同步问题。在计算机发展过程中,各个CPU厂商,为了提升自己的性能,引入了多核概念。但是多个核心之间如果做到数据同步让所有人都花费了很多的时间和金钱,甚至最后消耗了CPU很多的性能才得以实现。

Python是如何做的?

了解Python的朋友都知道,Python默认的实现是CPython,而CPython使用的是C语言的解释器。而由于历史原因,CPython中不幸的拥有了一个在未来非常影响Python性能的因素,那就是GIL。GIL全称Global Interpreter Lock,又叫全局解释器锁。GIL是计算机程序设计语言解释器用于同步线程的工具,而CPython中正是支持了GIL的特性,使得Python的解释器同一时间只能有一条线程运行,一直等到这个线程执行完毕释放了全局锁以后,才能有其他的线程来执行。也就是说,CPython本身实际上是一个单线程语言,甚至在多核CPU上面使用CPython的多线程反而性能不如单线程高。

人们对于GIL还存在很大的误解,GIL只存在于Python中的CPython,使用Jython或者PyPy则不存在这个问题。

  • Python的线程是操作系统线程。在Linux上为pthread,在Windows上为Win thread,完全由操作系统调度线程的执行。一个python解释器进程内有一条主线程,以及多条用户程序的执行线程。即使在多核CPU平台上,由于GIL的存在,所以禁止多线程的并行执行。
  • Python解释器进程内的多线程是合作多任务方式执行。当一个线程遇到I/O任务时,将释放GIL。计算密集型(CPU-bound)的线程在执行大约100次解释器的计步(ticks)时,将释放GIL。计步(ticks)可粗略看作Python虚拟机的指令。计步实际上与时间片长度无关。可以通过sys.setcheckinterval()设置计步长度。
  • 在单核CPU上,数百次的间隔检查才会导致一次线程切换。在多核CPU上,存在严重的线程颠簸(thrashing)。

为什么CPython中使用了GIL

我们都知道计算机一开始只是单核的,在那个年代人们并不会想到多核这种情况,于是为了应对多线程的数据同步问题,人们发明了锁。但是如果自己来写一个锁,不仅耗时耗力,而且还会隐藏许多未知的BUG等问题。于是在这样的大背景下,Python社区选择了最简单粗暴的方式,实现GIL,这样做有以下几点好处:

  1. 可以增加单线程程序的运行速度(不再需要对所有数据结构分别获取或释放锁)
  2. 容易和大部分非线程安全的 C 库进行集成
  3. 容易实现(使用单独的 GIL 锁要比实现无锁,或者细粒度锁的解释器更容易)

但是令Python社区没想到的是,CPU乃至计算机发展的如此迅速,双核,四核,甚至多CPU计算机的出现,让Python在很长一段时间内背负着运行效率低下的称号。而当Python社区和众多的Python库作者回过头想修改这些问题的时候却发现,代码与代码之间牢牢的依赖于GIL,面对庞大的绕成一团的线,也只能抽丝剥茧般的慢慢剔除。

Python的新生

值得庆幸的是,虽然我们不知道这一过程用了多久,但是在Python3.2中开始使用了全新的GIL,将大大的提升CPython的性能。

那Python3.2以下版本的多线程有什么用?

很多人提到Python就想到爬虫,因为爬虫在某些程度上来说,Python的缺点完全不存在,而且还成了优点。我们来分析一下爬虫的运行过程:

  1. 发送请求
  2. 等待服务器响应
  3. 收到服务器响应数据
  4. 解析

我们来看一下,以当前的计算机配置来说,对爬虫获取到的数据来进行解析处理的话,可能只需要几毫秒甚至更短的时间就能完成。那么一个爬虫程序最影响性能的地方在哪里?

是IO操作。没错,我们的爬虫发出请求以后要等待对方的服务器来响应,这一过程是最耗时的,有时可能会需要一两秒的时间。此时,我们就可以在请求发送出去以后,立刻释放我们的全局锁,然后让下一个线程执行。直到某一个线程的响应回来以后消耗几毫秒处理数据,然后再次开始发送请求,而由于同一时间只有一条线程运行不需要考虑其他的问题,所以性能也会大大的提升。

及时在爬虫上使用了真正意义的多线程,无非就是在解析数据的时候多几个线程来处理罢了。那么0.2毫秒和0.02毫秒,乃至无限至今于0毫秒的时间,他们之间的区别又是什么呢?同样都是人类无法分辨出来的差距,而我们又要对线程进行大量的安全性的处理,得不偿失。

线程间通信

在了解了线程以后,我们可能需要在多个线程之间通信。实现这一点,我们可以声明一个全局的存储对象,所有的线程都调用这一个对象来进行数据的存和取,这样就可以做到线程间的通信。

我们使用传统的列表或元组都是可以的,但是列表和元组他们都是线程不安全的存储结构,我们需要自己加锁来保证线程安全。或者我们可以直接使用Python内置的线程安全的存储结构,Queue。

Queue的使用如下:

# Python2.x
from Queue import Queue
# Python3.x
import queue

# Python2.x
q = Queue()
# Python3.x
q = queue.Queue()

# 存储一个元组到Queue中
q.put((1, 'a'))
# q.get()每次获取一个数据,使用下面这种方式可以直接拆分元组
int_data, str_data = q.get()

注意:Queue每次获取数据以后都会把获取的数据删除从内部,所以不用担心获取到重复的数据。使用q.queue属性,可以得到里面所有的数据,返回的是一个deque对象。

线程事件通知

有时我们希望让某一个线程进入等待状态来进行一些其他的处理,当我们某些事情处理完成以后,再唤醒线程,让它从刚才停止的地方继续运行。使用标准库下面的Threading.Event就可以实现。

休眠事件:wait()

唤醒事件:set()

清除事件:clear()

from threading import Event, Thread

# 接收一个Event对象
def test_event(e):
    print('run...')
    # 让这个线程进入睡眠状态
    e.wait()
    # 当线程被唤醒以后,会输出下面的语句
    print('end...')

e = Event()
t = Thread(target=test_event, args=(e,))
# 这里会看到输出了 run...
t.start()

print('Set Event...')

# 唤醒线程会看到 end...
e.set()

上面程序最终运行结果为:

run...
Set Event...
end...

注意:当我们程序在一起运行周期内,重复调用e.wait(),第二次调用就无法让线程进入休眠状态了,需要调用e.clear()清除以后,才能再次进入休眠状态。

from threading import Event, Thread

def test_event(e):
    print('run...')
    e.wait()
    # 为了重复使用,需要加上e.clear()
    # e.clear()
    print('end...')

e = Event()
t = Thread(target=test_event, args=(e,))
t.start()
# 第一次成功休眠
print('Set Event1...')
e.set()

t = Thread(target=test_event, args=(e,))
t.start()
# 第二次休眠失败
print('Set Event2...')
e.set()

不去掉e.clear()的注释,根据线程的切换顺序,可能得到各种输出结果,可以自己多次尝试看看有什么不同的结果。

去掉e.clear()的注释以后,输出结果如下:

run...
Set Event1...
end...
run...
Set Event2...
end...

守护线程

在多线程环境中,我们有多个继承了Thread的类,他们之间相互调用。假设我们此时有一个MyThread的类,它是为其他线程服务的。现在,其他线程的所有操作已经全部完成了,而我们的MyThread的run方法里面有一个死循环,我们怎么在其他线程都完成工作,停止了以后,停止我们的MyThread类中的死循环?

from threading import Thread

class MyThread(Thread):
    def run(self):
        while True:
            # 控制其他各个线程的代码
            pass

    def __init__(self):
        Thread.__init__(self)
        # 设置守护线程
        self.setDaemon(True)

__init__中,self实际上就是Thread类的对象,所以setDaemon实际上是Thread类的一个方法,当设置为True就可以把当前类变成一个守护线程,等到其他线程都停止以后,它会自动停止。

创建线程本地数据

有些场景下,我们希望每个线程,都有自己独立的数据,他们使用同一个变量,但是在每个线程内的数据都是独立的互不干扰的。

我们可以使用threading.local()来实现:

import threading

L = threading.local()
L.num = 1
# 此时操作的是我们当前主线程的threading.local()对象,输出结果为1
print(L.num)

def f():
    print(L.num)

# 创建一个子线程,去调用f(),看能否访问主线程中定义的L.num
t = threading.Thread(target=f)
t.start()
# 结果提示我们:
# AttributeError: '_thread._local' object has no attribute 'num'

对上面的稍作修改:

import threading

L = threading.local()
L.num = 1
# 此时操作的是我们当前主线程的threading.local()对象,输出结果为1
print(L.num)

def f():
    L.num = 5
    # 这里可以成功的输出5
    print(L.num)

# 创建一个子线程,去调用f(),看能否访问主线程中定义的L.num
t = threading.Thread(target=f)
t.start()
# 主线程中的L.num依然是1,没有发生任何改变
print(L.num)

程序运行结果为:

1
5
1

由此可见,threading.local()创建的对象中的属性,是对于每个线程独立存在的,它们相互之间无法干扰,我们称它为线程本地数据。

by. 秋名山车神

site: 慕课网

END

17人推荐
随时随地看视频
慕课网APP

热门评论

print("it is good artical")


print("it is good artical")


通俗易懂,适合我等小白入门的好文章,膜拜大神。

额,大神,“线程事件通知”处,将e.clear()去除后,结果应该是,小弟亲测:

run...
Set Event1...
end...
run...
end...
Set Event2...


查看全部评论