python多任务--线程
一、基本概念
什么是线程
线程(Thread)也叫轻量级进程,是操作系统能够进行运算调度的最小单位,它被包涵在进程之中,是进程中的实际运作单位。线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。
并发和并行
并发:
指的是任务数多于cpu核数,通过操作系统的任务调度算法,实现用多个任务“一起”执行(实际上总有一些任务不在执行,为交替执行的状态,因为切换任务的速度相当快,看上去一起执行而已)
特点
- 微观角度:所有的并发处理都有排队等候,唤醒,执行等这样的步骤,在微观上他们都是序列被处理的,如果是同一时刻到达的请求(或线程)也会根据优先级的不同,而先后进入队列排队等候执行。
-
宏观角度:多个几乎同时到达的请求(或线程)在宏观上看就像是同时在被处理。
image.png
并行:
指的是任务数小于等于cpu核数,即任务真的是一起执行的
特点
- 同一时刻发生,同时执行。
-
不存在像并发那样竞争,等待的概念。
image.png
同步和异步
同步(synchronous): 所谓同步就是一个任务的完成需要依赖另外一个任务时,只有等待被依赖的任务完成后,依赖的任务才能算完成,这是一种可靠的任务序列。
简言之,要么成功都成功,失败都失败,两个任务的状态可以保持一致。
异步(asynchronous):所谓异步是不需要等待被依赖的任务完成,只是通知被依赖的任务要完成什么工作,依赖的任务也立即执行,只要自己完成了整个任务就算完成了。至于被依赖的任务最终是否真正完成,依赖它的任务无法确定,所以它是不可靠的任务序列。
为什么要使用多线程?
线程在程序中是独立的、并发的执行流。与分隔的进程相比,进程中线程之间的隔离程度要小,它们共享内存、文件句柄和其他进程应有的状态。
因为线程的划分尺度小于进程,使得多线程程序的并发性高。进程在执行过程之中拥有独立的内存单元,而多个线程共享内存,从而极大的提升了程序的运行效率。
线程比进程具有更高的性能,这是由于同一个进程中的线程都有共性,多个线程共享一个进程的虚拟空间。线程的共享环境包括进程代码段、进程的共有数据等,利用这些共享的数据,线程之间很容易实现通信。
操作系统在创建进程时,必须为改进程分配独立的内存空间,并分配大量的相关资源,但创建线程则简单得多。因此,使用多线程来实现并发比使用多进程的性能高得要多。
二、线程实现
threading模块
import time
import datetime
from threading import Thread
# Thread类可以用来创建线程对象
# target:指定执行线程执行的任务(任务函数)
# args:kwargs: 接收任务函数的参数
# name:指定线程的名字
def work1(name):
for i in range(4):
time.sleep(1)
print(f'{name}浇花的第{i + 1}秒')
def work2(name):
for i in range(3):
time.sleep(1)
print(f'{name}打墙的第{i + 1}秒')
# 创建2个线程
t1 = Thread(target=work1, args=('小狼',), name="线程一")
t2 = Thread(target=work2, kwargs={"name": "liang"}, name='线程二')
# 启动线程 :异步执行的状态
begin_time = datetime.datetime.now().strftime('%H:%M:%S.%f')
print("begin_time:",begin_time)
t1.start()
t2.start()
t1.join() # 默认等待子线程1执行结束
t2.join() # 默认等待子线程2执行结束
end_time = datetime.datetime.now().strftime('%H:%M:%S.%f')
print("end_time:",end_time)
# 主线程等待子线程执行结束之后再往下执行
print("执行结束")
#输出
begin_time: 14:45:15.724900
liang打墙的第1秒
小狼浇花的第1秒
小狼浇花的第2秒
liang打墙的第2秒
liang打墙的第3秒
小狼浇花的第3秒
小狼浇花的第4秒
end_time: 14:45:19.729378
执行结束
可以看到,如果两个任务单线程分别执行,则会消耗7秒的时间,而运用多线程实现多个任务,耗费的总时间不是多个任务时间之和,而是单个运行时间最长的任务(4s)。
自定义线程
继承threading.Thread来自定义线程类,其本质是重构Thread类中的run方法,同时执行多个相同任务,就是创建多次线程对象
import time
from threading import Thread
"""
通过线程类的形式来实现多线程
"""
class MyThread(Thread):
"""自定义的线程类"""
def __init__(self, name):
super().__init__()
self.name = name
def run(self):
"""线程执行的任务函数"""
for i in range(4):
time.sleep(1)
print(f'{self.name}浇花的第{i + 1}秒')
m = MyThread('小狼')
m2 = MyThread('liang')
m.start()
m2.start()
m.join()
m2.join()
print('执行结束')
#输入
liang浇花的第1秒
小狼浇花的第1秒
liang浇花的第2秒
小狼浇花的第2秒
liang浇花的第3秒
小狼浇花的第3秒
小狼浇花的第4秒
liang浇花的第4秒
执行结束
三、多线程特点
守护线程
里使用setDaemon(True)把所有的子线程都变成了主线程的守护线程,因此当主进程结束后,子线程也会随之结束。所以当主线程结束后,整个程序就退出了。
import time
from threading import Thread
"""
通过线程类的形式来实现多线程
"""
class MyThread(Thread):
"""自定义的线程类"""
def __init__(self, name):
super().__init__()
self.name = name
def run(self):
"""线程执行的任务函数"""
for i in range(4):
time.sleep(1)
print(f'{self.name}浇花的第{i + 1}秒')
m = MyThread('小狼')
m2 = MyThread('liang')
m.setDaemon(True)
m2.setDaemon(True)
m.start()
m2.start()
我们可以发现,设置守护线程之后,当主线程结束时,子线程也将立即结束,不再执行。
主线程等待子线程结束
为了让守护线程执行结束之后,主线程再结束,我们可以使用join方法,让主线程等待子线程执行。
#此处自定义线程类代码与之前一致,省略
m = MyThread('小狼')
m2 = MyThread('liang')
m.start()
m2.start()
m.join()
m2.join()
time.sleep(2.5)
print('执行结束')
多线程共享全局变量
线程是进程的执行单元,进程是系统分配资源的最小单位,所以在同一个进程中的多线程是共享资源的。
n = 0
def work1():
global n
for i in range(10):
n += 1
def work2():
global n
for i in range(10):
n += 1
t1 = Thread(target=work1)
t2 = Thread(target=work2)
# 启动线程
t1.start()
t2.start()
t1.join()
t2.join()
print("n:", n)
#输出
n: 20
可以看出,多线程之间是共享全局变量的
互斥锁
由于线程之间是进行随机调度,并且每个线程可能只执行n条执行之后,当多个线程同时修改同一条数据时可能会出现脏数据,所以,出现了线程锁,即同一时刻允许一个线程执行操作。
由于线程之间是进行随机调度,如果有多个线程同时操作一个对象,如果没有很好地保护该对象,会造成程序结果的不可预期,我们也称此为“线程不安全”。
当把计算的数据很大,就会出现资源竞争,导致执行的结果不准确
image.png
为了方式上面情况的发生,就出现了互斥锁(Lock),在可能出现资源竞争的地方加上互斥锁,保证线程的安全
import threading
n = 100
def work1():
global n
for i in range(500000):
lock.acquire()
n += 1
lock.release()
def work2():
global n
for i in range(500000):
lock.acquire()
n += 1
lock.release()
# 创建一把锁
lock = threading.Lock()
# 上锁
t1 = threading.Thread(target=work1)
t2 = threading.Thread(target=work2)
# 启动线程
t1.start()
t2.start()
t1.join()
t2.join()
print("n:", n)
三、GIL(Global Interpreter Lock)全局解释器锁
在非python环境中,单核情况下,同时只能有一个任务执行。多核时可以支持多个线程同时执行。但是在python中,无论有多少核,同时只能执行一个线程。究其原因,这就是由于GIL的存在导致的。
GIL的全称是Global Interpreter Lock(全局解释器锁),来源是python设计之初的考虑,为了数据安全所做的决定。某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,并且在一个python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。GIL只在cpython中才有,因为cpython调用的是c语言的原生线程,所以他不能直接操作cpu,只能利用GIL保证同一时间只能有一个线程拿到数据。而在pypy和jpython中是没有GIL的。
Python多线程的工作过程:
python在使用多线程的时候,调用的是c语言的原生线程。
- 拿到公共数据
- 申请GIL
- python解释器调用os原生线程
- os操作cpu执行运算
- 当该线程执行时间到后,无论运算是否已经执行完,gil都被要求释放
- 进而由其他进程重复上面的过程
- 等其他进程执行完后,又会切换到之前的线程(从他记录的上下文继续执行),整个过程是每个线程执行自己的运算,当执行时间到就进行切换(context switch)。
python中线程的缺陷,以及适用场景:
由于GIL锁的存在,python中的多线程在同一时间没办法同时执行(即没办法实现并行)
1、CPU密集型代码(各种循环处理、计算等等),在这种情况下,由于计算工作多,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。
2、IO密集型代码(文件处理、网络爬虫等涉及文件读写的操作),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。
练习:
有10000个url地址(假设请求每个地址需要0.5秒),请设计程序一个程序,获取列表中的url地址,使用1000个线程去发送这10000个请求,计算出总耗时!
# 计算时间的装饰器
def decorator(func):
def wrapper():
# 函数执行之前获取系统时间
start_time = time.time()
func()
# 函数执行之后获取系统时间
end_time = time.time()
print('执行时间为:', end_time - start_time)
return end_time - start_time
return wrapper
#生成器生成10000个url地址
urls_g = (f"https://www.baidu.com{j}" for j in range(10000))
#定义线程类
class MyThread(threading.Thread):
#重写run方法,指定要执行的任务逻辑
def run(self):
while True:
try:
url = next(urls_g)
except StopIteration:
break
else:
print(F'{self}发送请求{url}')
time.sleep(0.5)
@decorator
def main():
t_list = []
# 创建4个线程
for i in range(1000):
t = MyThread() # 创建线程对象
t.start() # 开启该线程
t_list.append(t)
# 遍历所有线程对象,设置主线等待子线程执行完
for t in t_list:
t.join()
res = main()
最终执行时间为5.16秒,如果单线程执行的话至少需要5000多秒,体现了多线程在执行IO密集型任务时的性能效率