python:并发编程之多线程
一、线程介绍
1、什么是线程
(1)线程(Thread)也叫轻量级进程,是操作系统能够进行运算调度的最小单位,它被包涵在进程之中,是进程中的实际运作单位,而进程是操作系统进行资源分配的最基本单位。那么也就是说一个程序的运行必须有一个进程,就是主进程,而该进程中必须有一个线程,就是主线程,那么就可以理解为一个程序的运行不仅要有一个主进程,也要有一个主线程。
(2)线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其它线程共享进程所拥有的全部资源。
(3)一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。
2、为什么要使用线程
(1)线程在程序中是独立的、并发的执行流。与分隔的进程相比,进程中线程之间的隔离程度要小,它们共享内存、文件句柄和其他进程应有的状态。
(2)因为线程的划分尺度小于进程,使得多线程程序的并发性高。进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
(3)线程比进程具有更高的性能,这是由于同一个进程中的线程都有共性多个线程共享同一个进程的虚拟空间。线程共享的环境包括进程代码段、进程的公有数据等,利用这些共享的数据,线程之间很容易实现通信。
(4)操作系统在创建进程时,必须为该进程分配独立的内存空间,并分配大量的相关资源,但创建线程则简单得多。因此,使用多线程来实现并发比使用多进程的性能要高得多。
3、多线程编程的优点
(1)进程之间不能共享内存,但线程之间共享内存非常容易。
(2)操作系统在创建进程时,需要为该进程重新分配系统资源,但创建线程的代价则小得多。因此,使用多线程来实现多任务并发执行比使用多进程的效率高。
二、多线程实现
python的标准库提供了两个模块,thread 和 threading ,thread 是低级模块,而 threading 是高级模块,threading 对 thread 进行了封装,绝大多数情况下我们只需用使用 threading 模块来实现多线程。
1、导入线程模块
import threading
2、实例化线程对象
# t1 = threading.Thread(group, target, name, args, kwargs)
# 该对象的参数构造
# group : 线程组,目前只能使用None
# target :执行的目标任务名,即方法名称
# name : 线程名,一般不会设置
# args :以元组的方式给目标任务传参
# kwargs : 以字典的方式给目标任务传参
3、threading模块提供的方法
# run() :用以表示线程活动的方法
# start():启动线程
# join():主线程等待至子线程终止
4、多线程示例
# 导入threading模块下的Thread类
from threading import Thread
# 导入time模块
import time
def upload():
print("开始上传...")
for i in range(1,6):
# 格式化字符串和字符串输出结合使用
print(f"已上传:{'{:.1%}'.format(i/5)}")
# 让其延迟1秒钟,要不然for循环太快了
time.sleep(1)
print("上传成功...")
def download():
print("开始下载...")
for i in range(1,6):
print(f"已下载{'{:.1%}'.format(i/5)}")
# 让其延迟1秒钟,要不然for循环太快了
time.sleep(1)
print("下载成功...")
if __name__ == "__main__":
# 创建子线程对象
up = Thread(target=upload)
down = Thread(target=download)
# 开启子线程
up.start()
down.start()
5、查看线程的数量
threading.enumerate
# 描述:以列表形式返回当前所有存储的Thread对象,可以使用len()方法来获取数量
# 注意;必须将其放在创建了子线程之后,不然数据不准确
# 实例输出结果:
[<_MainThread(MainThread, started 140006904641344)>, <Thread(Thread-1, started 140006887585536)>, <Thread(Thread-2, started 140006807041792)>]
三、线程的特点
(1)线程执行代码的封装
通过使用threading模块能够完成多任务的程序开发,为了让每个线程的封装性更完美,所以使用threading模块时,往往会自定义一个新的子类class,只要继承 threading.Thread 即可实现,其实就是自定义线程类,该类必须继承threading.Thread ,并且重写run方法,一般情况下一个线程的入口函数是run,而start将调用run函数
import threading
import time
class MyThread(threading.Thread):
def run(self):
print("开始上传...")
for i in range(1, 6):
# 格式化字符串和字符串输出结合使用
print(f"已上传:{'{:.1%}'.format(i / 5)}")
# 让其延迟1秒钟,要不然for循环太快了
time.sleep(1)
print("上传成功...")
self.downloda()
def downloda(self):
print("开始下载...")
for i in range(1, 6):
print(f"已下载{'{:.1%}'.format(i / 5)}")
# 让其延迟1秒钟,要不然for循环太快了
time.sleep(1)
print("下载成功...")
if __name__ == "__main__":
# 创建子线程对象
mythread = MyThread()
mythread.start()
(2)多线程共享全局变量
在一个函数中,对全局变量进行修改的时候,怎么判断是否修改了全局变量?我们要看它在执行的时候是否对全局变量进行了引用修改。如果修改了引用,也就是说让全局变量指向了一个新的引用地址;如果仅仅修改了引用的数据,那么就不用担心变量被分化。
import time
import threading
# 1.声明一个全局变量
num = 0
# 该函数去修改全局变量
def work1():
global num
for i in range(30000000):
num += i
print(f"在work1最终的数据是:{num}")
# 并且最后输出引用地址
print(id(num))
# 该函数去获取全局变量
def work2():
global num
print(f"在work2最终的数据是:{num}")
# 并且最后输出引用地址
print(id(num))
if __name__ == '__main__':
# 创建两个子线程
# 他
# t1去修改全局变量
t1 = threading.Thread(target=work1)
# t2去获取全局变量
t2 = threading.Thread(target=work2)
# 最后开启全局变量
t1.start()
t2.start()
# 主线程,停留5秒后再输出
time.sleep(5)
print(f"最终的数据是:{num}")
# 输出引用地址
print(id(num))
输出结果:
**结果:**我们发现不管 t1 如何修改全局变量,线程 t2 拿到的全局变量引用地址和t1以及主线程的引用地址相同,那么也就是说线程之间是共享同属进程的全局资源
在work2最终的数据是:4899262578
3096308368848
在work1最终的数据是:449999985000000
3096308368848
最终的数据是:449999985000000
3096308368848
(3)分析:那么线程之间共享全局变量到底是好事还是坏事?
经过测试,我们发现:如果两个线程要对全局变量进行修改,如果数据小的话,那么基本上没有什么大的影响,但是数据量变大以后,我们发现了新的问题:输出的结果已经不是我们想要的结果了,为什么?这两个子线程哪一个开始我们不能确定,但是当他们在修改的时候,一个还没修改完呢另一个就开始修改了,也就是说他们在高并发的工作,导致最后输出的数据不是我们想要的。
测试代码:
import time
import threading
# 1.声明一个全局变量
num = 0
def work1():
global num
for _ in range(1,1000001):
num += 1
print(f"在work1最终的数据是:{num}")
def work2():
global num
for _ in range(1,1000001):
num += 1
print(f"在work2最终的数据是:{num}")
if __name__ == '__main__':
t1 = threading.Thread(target=work1)
t2 = threading.Thread(target=work2)
t1.start()
t2.start()
time.sleep(5)
print(f"最终的数据是:{num}")
运行结果:
在work2最终的数据是:1105383
在work1最终的数据是:1127902
最终的数据是:1127902
# 最终的结果不应该是2000000吗?
那么针对以上高并发修改的问题,我们该如何解决呢?来我们先了解一下什么是同步把。
四、什么是同步
1、什么是同步
**同步是指:**协同步调,按照预定好的先后次序来进行运作
2、分析:如何解决多个线程同时修改全局变量
线程或者进程通过同步,可以与多个线程或进程进行配合,比如 线程A 与 线程B 一起配合工作,A执行到一定程度需要依靠B的结果进行执行,如果B还没有执行完,算出结果,那么A将停下来等待B的结果,反之如此。也就是说两个人合作呢,但是都互相依赖对方的结果。
那么,我们就可以使用这种合作的方式,去来解决多个线程同时修改全局变量的问题,那么我们接下来要引入一个新的东西——互斥锁
五、互斥锁
1、分析:
当多个线程几乎同时修改某一个共享数据时,我们需要进行同步控制。线程的同步能够保证多个线程安全访问竞争资源,最简单的同步机制就是引入互斥锁。
2、了解互斥锁
互斥锁为资源引入了一个状态:(锁定) 和 (非锁定)
当某个线程需要更改共享数据时,要先将其锁定,此时资源的状态为 “锁定” 状态,其他线程无法进行修改,直到该线程释放资源,将资源的状态变为 “非锁定” 状态,其他线程才能够再次锁定共享资源。
互斥锁保证了每次只能有一个线程进行写入或修改工作,从而保证了多线程 情况下修改或写入资源的 数据正确性。
3、使用互斥锁
(1)threading模块中定义了Lock类,可以方便的处理锁定
# 导入threading模块
import threading
(2)创建锁对象
lock = threading.Lock()
(3)获取锁
lock.aquire()
(4)释放锁
lock.release()
实例:
from threading import Lock,Thread
import time
a = 0
lock1 = Lock()
def add1():
global a
for i in range(1,10000001):
lock1.acquire()
a+=1
lock1.release()
print(f"add1运算的结果为:{a}")
def add2():
global a
for i in range(1, 10000001):
lock1.acquire()
a += 1
lock1.release()
print(f"add2运算的结果为:{a}")
if __name__ == '__main__':
a1 = Thread(target=add1)
a2 = Thread(target=add2)
a1.start()
a2.start()
time.sleep(7)
print(f"最终的结果为:{a}")
4、上锁解锁过程
(1)当一个线程调用锁的 acquire( ) 方法获得锁时,锁就进入了锁定状态,其他线程需要等待这个锁释放,才能锁定资源。
(2)每次只有一个线程可以获得锁,如果此时另外一个线程试图获得这个锁,该线程就会进入阻塞状态,直到拥有锁的线程调用锁的 release( ) 方法释放锁之后,这个锁进入了释放状态。
(3) 线程调度程序从处于同步阻塞状态的的线程中选择一个来获得锁,并使得该线程进入进入运行状态
5、锁的好处
(1)确保了某段关键代码只能由一个线程从头到尾完整的执行
(2)确保代码正常的情况下,尽量给最少的代码上锁,因为上锁的代码只能以单线程执行,效率低
6、锁的坏处
(1)阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,就大大的降低了代码的执行效率
(2)由于可以存在多个锁,不同的线程持有不同的锁,并且试图获取对方持有的锁,所有很可能会造成死锁
六、死锁
1、什么是死锁
死锁就是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞现象,若无外作用,他们将无法推进下去,此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程成为死锁进程。
2、死锁产生的必要条件
(1)多个不同的锁
(2)多个线程
(3)多个锁嵌套,也就是试图得到对方的锁,或者说互相得到对方的资源
(4)这个锁已经被一个线程占用了并且没有释放,而另一个线程获得了另外的一个锁,并且对方都没有打开锁,而是一直等待对方打开锁,但此时他们都在试图获取对方的锁
3、死锁案例
# 注意:这是一个典形的死锁程序,使用timeout无法解开,因为锁嵌套了,并且嵌套的都是在最后才能打开,但是中间都在试图获得对方的锁,所以无法解开,只能调换锁的顺序才能解开
import time
import threading
# 创建锁对象
mA = threading.Lock()
mB = threading.Lock()
def test1():
# mA上锁
mA.acquire()
# 延迟1秒
print("test1————上锁A")
time.sleep(1)
# 阻塞,因为mB已经被另外的线程抢险上锁了
mB.acquire()
print("test1————上锁B")
mB.release()
print("test1-----解锁A")
# 解锁B
mA.release()
print("test1-----解锁B")
def test2():
# mB上锁
mB.acquire()
# 延迟一秒
print("test2————上锁B")
time.sleep(1)
# 此时阻塞,因为mA已经被另外的线程抢先上锁
mA.acquire()
print("test2————上锁A")
mB.release()
print("test2-----解锁B")
#解锁mA
mA.release()
print("test2-----解锁A")
if __name__ == '__main__':
t1 = threading.Thread(target=test1)
t2 = threading.Thread(target=test2)
t1.start()
t2.start()
4、如何防止死锁
(1)acquire设置时间
# timeout 译为:超时
锁对象.acquire(timeout)
# 什么意思呢,就是你在一定的时间你可以把数据锁上,但是超过时间,不管你任务有没有完成都要让下一个线程去使用该锁,或者修改资源
(2)再程序设计时尽量避免(银行家算法/思想)
所谓银行家算法就是:比如有三个客户要来贷款,那么你总不能因为钱不够而拒绝别人吧,所以他们之间在商量,比如银行有100万,用户A要90万,用户B要30万,用户C要80万,那么银行它肯定是先要把钱一次性给用户B,让它在一两个月内还清,然后用户A,用户C肯定都要给,不能说是等用户C还钱了再给,所以可以分期到账,用户A的钱分3期到账,或者4期到账,而用户C的分2期到账,那么银行就是使用100万在做200万的生意。那么CPU就是银行,多线程或者多进程就是客户,也就让三个线程都先运行,等运行时间短的运行完了再把资源分配给另一个运行时间相对较短的,最后把最后一个线程所需要的资源都给全,其实就是这样的。