Python 多线程
线程
一个进程中的各个线程与主线程共享同一片数据空间,因此相对于进程,线程间的信息共享与通讯更加便捷。线程以并发方式执行,得益于这种并行与数据共享的机制,使得多任务协作的实现更加简单。
Python代码的执行是由Python虚拟机控制。在 CPython 中,由于存在 全局解释器锁(GIL),同一时刻只有一个线程可以执行。这种限制使得python的多线程就像在单CPU上跑多进程,只能做到并发,无法做到并行。
某些 Python I/O 例程 (调用了内置的操作系统 C 代码的那种),GIL 会在 I/O 调用前被释放,以允许其他线程在 I/O 执行的时候运行。而对于那些没有太多 I/O 操作的代码而言,更倾向于在该线程整个时间片内始终占有处理器(和 GIL)。总而言之,I/O 密集型的 Python 程序要比计算密集型的代码能够更好地利用多线程环境。
守护线程
守护线程一般是一个在后台为了等待某个事件发生并相应它的线程。例如某个守护线程运行在服务器端,等待客户端服务请求。如果没有客户端请求,守护进程就一直空闲。python程序将在所有的非守护线程结束后才退出。当所有的非守护线程结束后,程序会突然关闭,这时候守护线程也会戛然而止。他们的资源(例如打开的文档,数据库事物)可能还没有正确释放。
Thread类 ——线程对象
知其然,更要知其所以然。为了能更好使用多线程,我们先来看一下Thread类的构造函数。
Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
#group 为日后拓展ThreadGroup类实现而保留 不填
#name 线程的名字,是的 也可以为线程起名字
#target 是用于run()方法调用的对象,默认None,表示不调用任何方法
#args 是传入target目标函数的参数 默认是()
#kwargs 是传入target目标函数的关键字参数 默认是{}
#daemon(译:守护程序) 如果为Ture则将线程设置为守护线程,否则新线程将继承当前线程的守护模式属性。因为主线程(python程序里初始的控制线程)是非守护线程,所以它创建的线程都默认继承了他的属性都是非守护线程
Thread线程对象被创建后并不意味着一个独立的线程已经被创建。直到调用Thread实例的start()方法被调用时,一个独立的线程才会被创建。这个线程被创建后会做些什么呢?它的工作流程是固定的,即调用Thread实例的run()方法,run()咋写的这个独立线程就咋办。那这个run()方法是咋写的嘞? run() 的默认方式是通过构造函数传入的target,kwargs参数来调用target。 args,kwatgs,target都是我们在构造函数中传进来的,主要通过这三个参数来并发运行我们想要并发的东东。
- start()方法 创建一个新的独立线程
- 在这个独立线程中运行实例的run()方法
- run()方法的默认行为:将构造函数中的 args,kwargs参数传入target
- 当run()方法运行结束,这个独立的线程就结束了
这就是创建一个线程,到一个线程销毁的全过程。有了这些理论知识,现在开始动手实践验证。
未引进线程
from time import sleep, time
def func(name, t):
print(name, "开始", int(time()))
sleep(t)
print(name, "结束", int(time()))
print("程序开始执行")
start = time()
func("猪", 4)
func("牛", 4)
end = time()
print("总共运行:",int( end - start))
运行结果
程序开始执行
猪 开始 1618821733
猪 结束 1618821737
牛 开始 1618821737
牛 结束 1618821741
总共运行: 8
方式一:创建Thread实例,传给它一个函数
既然我们知道了run()的默认行为是将args和kwatgs传入target 并运行target,那咱们就顺着他来。
from threading import Thread
from time import sleep, time
def func(name, t):
print(name, "开始", int(time()))
sleep(t) #睡眠t秒
print(name, "结束", int(time()))
T1 = Thread(target=func,args=('猪',4)) #构造函数每个参数都有默认参数,因此可以不使用kwargs
T2 = Thread(target=func,args=('牛',4)) #注意args期待一个元祖,如果只传一个参数arg1 这样:(arg1,)
print("主线程开始执行")
start = time()
T1.start() #线程开始执行
T2.start() #线程开始执行
end = time()
print("主线程共运行:",int( end - start))
运行结果
主线程开始执行
猪 开始 1618822143
牛 开始 1618822143
主线程共运行: 0
猪 结束 1618822147
牛 结束 1618822147
解释
当T1.start() T2.start() 调用时,会分别创建两个独立的线程,加上主线程一共有三个独立线程在python虚拟机中运行。三个独立线程以不可预测的进度运行。因此有了上面的运行结果。
方式二:继承Thread,并重新定义run方法
start()方法调用后会在一个新的独立线程中运行实例的run()方法。我们可以在继承中重写run()方法,干掉它的默认行为。
如果子类型重载了构造函数,它一定要确保在做任何事前,先发起调用基类构造器(Thread.init())。!!!!!
from threading import Thread
from time import sleep, time
def func(name, t):
print(name, "开始", int(time()))
sleep(t)
print(name, "结束", int(time()))
# 简单版本
class MyThreadEasy(Thread):
def run(self):
#想要并行的干啥直接写死就好
#T_easy = MyThreadEasy()
#T.start()
# 通用版本
class MyThread(Thread):
def __init__(self,target,args=(), kwargs={}):
Thread.__init__(self)
#如果子类型重载了构造函数,它一定要确保在做任何事前,先发起调用基类构造器(Thread.__init__())。
self.target = target
self.args = args
self.kwargs = kwargs
def run(self):
self.target(*(self.args),**(self.kwargs))
print("主线程开始执行")
start = time()
T1 = MyThread(func,('猪',4))
T2 = MyThread(func,('牛',4))
T1.start()
T2.start()
end = time()
print("主线程总共运行:",int( end - start))
结果
主线程开始执行
猪 开始 1618824594
牛 开始 1618824594
主线程总共运行: 0
猪 结束 1618824598
牛 结束 1618824598
深入了解Thread类
以上就是python中线程并发的入门用法了。各个独立线程以不可预测的速度独立运行,往往我们需要对各个线程的运行时序有一些要求。比如A线程要等到B线程运行完才开始运行;主线程要等到其他线程都结束才结束。。。要实现这种需求就需要继续了解Thread类的其他方法,以及锁对象。我们先来看看Thread类中还有什么其他的方法和属性。
属性
name | 给线程起一个名字。默认情况下,由 "Thread-N" 格式构成一个唯一的名称。 |
---|---|
ident | 这个线程的 '线程标识符。'name可重复,ident不会,如果线程尚未开始则为 None 。当一个线程退出而另外一个线程被创建,线程标识符会被复用。 |
native_id | 它的值可能被用来在全系统范围内唯一地标识这个特定线程(直到线程终结,在那之后该值可能会被 OS 回收再利用)。 |
daemon | 一个表示这个线程是(True)否(False)守护线程的布尔值。一定要在调用 start() 前设置好,不然会抛出 RuntimeError 。初始值继承于创建线程;主线程不是守护线程,因此主线程创建的所有线程默认都是 daemon = False。当没有存活的非守护线程时,整个python程序才会退出。 |
方法
start() | 开始线程活动。 它在一个线程里最多只能被调用一次。它安排对象的 run() 方法在一个独立的控制进程中运行。如果同一个线程对象中调用这个方法的次数大于一次,会抛出 RuntimeError 。 |
---|---|
run() | 代表线程活动的方法。你可以在子类型里重载这个方法。 标准的 run() 方法会对作为 target 参数传递给该对象构造器的可调用对象(如果存在)发起调用,并附带从 args 和 kwargs 参数分别获取的位置和关键字参数。 |
is_alive() | 当 run() 方法刚开始直到 run() 方法刚结束,这个方法返回 True 。否则返回False。 |
join(timeout=None) | 有A,B两个线程对象。在A线程中的run()方法中 这样写到 'B.join()' 则A线程会阻塞,直到B线程结束。注意到join函数有一个参数timeout,表示最多被阻塞时长(以秒为单位)。 B.join(1.11),则A最多被阻塞1.11秒。join()总是返回None,因此在调用join()后调用B.is_alive()来判断是否发生超时。如果B.is_alive()返回True 则说明超时。 |
实践
娜宝与橘子10米赛跑,娜宝0.2秒跑一米。橘子0.1秒跑一米。
公平竞争下
from threading import Thread
from time import sleep, time
class Nana(Thread):
def run(self):
print("娜宝起跑")
for x in range(10):
sleep(0.2)
print("娜宝^_^ ", x)
print("------------娜宝冲线-----------")
N = Nana()
N.start()
print("橘子起跑")
for x in range(10):
sleep(0.1)
print("橘子+_+ ", x)
print("------------橘子冲线-----------")
运行结果
娜宝起跑
橘子起跑
橘子+_+ 0
娜宝^_^ 0
橘子+_+ 1
橘子+_+ 2
娜宝^_^ 1
橘子+_+ 3
橘子+_+ 4
娜宝^_^ 2
橘子+_+ 5
橘子+_+ 6
娜宝^_^ 3
橘子+_+ 7
橘子+_+ 8
娜宝^_^ 4
橘子+_+ 9
------------橘子冲线-----------
娜宝^_^ 5
娜宝^_^ 6
娜宝^_^ 7
娜宝^_^ 8
娜宝^_^ 9
------------娜宝冲线-----------
使用join() , is_alive()
橘子一直等娜宝
from threading import Thread
from time import sleep, time
class Nana(Thread):
def run(self):
print("娜宝起跑")
for x in range(10):
sleep(0.2)
print("娜宝^_^ ", x)
print("------------娜宝冲线-----------")
N = Nana()
N.start()
print("橘子起跑")
for x in range(10):
sleep(0.1)
print("橘子+_+ ", x)
####################################################
N.join() #主线程会一直阻塞,直到N线程的run运行结束
####################################################
print("------------橘子冲线-----------")
运行结果
娜宝起跑
橘子起跑
橘子+_+ 0
娜宝^_^ 0
橘子+_+ 1
橘子+_+ 2
娜宝^_^ 1
橘子+_+ 3
橘子+_+ 4
娜宝^_^ 2
橘子+_+ 5
橘子+_+ 6
娜宝^_^ 3
橘子+_+ 7
橘子+_+ 8
娜宝^_^ 4
橘子+_+ 9
娜宝^_^ 5
娜宝^_^ 6
娜宝^_^ 7
娜宝^_^ 8
娜宝^_^ 9
------------娜宝冲线-----------
------------橘子冲线-----------
橘子只等一会儿娜宝
from threading import Thread
from time import sleep, time
class Nana(Thread):
def run(self):
print("娜宝起跑")
for x in range(10):
sleep(0.2)
print("娜宝^_^ ", x)
print("------------娜宝冲线-----------")
N = Nana()
N.start()
print("橘子起跑")
for x in range(10):
sleep(0.1)
print("橘子+_+ ", x)
############################################
N.join(0.7) #只等娜宝0.7秒
############################################
print("------------橘子冲线-----------")
if N.is_alive(): #如果娜宝还在跑,说明刚才的阻塞解除是因为等待超过0.7秒
print("橘子等不及了")
else: #如果娜宝跑完了,说明阻塞解除是因为娜宝线程结束了
print("娜宝已经跑完了")
运行结果
娜宝起跑
橘子起跑
橘子+_+ 0
娜宝^_^ 0
橘子+_+ 1
橘子+_+ 2
娜宝^_^ 1
橘子+_+ 3
橘子+_+ 4
娜宝^_^ 2
橘子+_+ 5
橘子+_+ 6
娜宝^_^ 3
橘子+_+ 7
橘子+_+ 8
娜宝^_^ 4
橘子+_+ 9
娜宝^_^ 5
娜宝^_^ 6
娜宝^_^ 7
------------橘子冲线-----------
橘子等不及了
娜宝^_^ 8
娜宝^_^ 9
------------娜宝冲线-----------
解释
N.join(0.7)会最多阻塞主线程0.7秒。主线程解除阻塞有两种可能行,一N线程结束,二等待超时。通过检查N线程是否在运行可以做出判断。
全局解释器锁GIL
CPython 解释器所采用的一种机制,它确保同一时刻只有一个线程在执行 Python bytecode。此机制通过设置对象模型(包括 dict 等重要内置类型)针对并发访问的隐式安全简化了 CPython 实现。给整个解释器加锁使得解释器多线程运行更方便,其代价则是牺牲了在多处理器上的并行性。
不过,某些标准库或第三方库的扩展模块被设计为在执行计算密集型任务如压缩或哈希时释放 GIL。此外,在执行 I/O 操作时也总是会释放 GIL。
创建一个(以更精细粒度来锁定共享数据的)“自由线程”解释器的努力从未获得成功,因为这会牺牲在普通单处理器情况下的性能。据信克服这种性能问题的措施将导致实现变得更复杂,从而更难以维护
如果想利用多核心计算机的计算资源,推荐使用 multiprocessing
或 concurrent.futures.ProcessPoolExecutor
。
Python虚拟机按照下面所述方式来切换线程
切换线程被放在一个互斥锁中
-
设置GIL
-
执行某个线程A
-
执行下面操作之一
- 执行一定数量的A的python代码(字节码指令)
- 线程主动让出控制权
-
将A的执行状态保存,以备下次执行
-
解锁GIL