课堂引入
在前面的学习中,我们已经学习了很多程序的基础知识,但这些都只是单线程的模式,整个程序只有一个主线程在运行,就好比吃饭的时候不能洗澡,开车的时候不能跑步一样,因为这些都是毫不相干的事情,没办法一起来完成!但是,有很多时候需要它们一起运行,比如大家玩PC游戏或APP游戏,游戏里面又会绘制游戏场景,还能播放背景音乐,一边接受用户输入命令,一边还要控制NPC按照既定线路运行,这些都是同时运行的。怎么实现的呢?
试试自己能不能一只手画画,另一只手写字!左右手互搏术只有小说里的人物能够使用,因为人的大脑是单线程的,没办法同时专注多件事情。要同时完成前面所说的事情,可以找多个人来一起完成,那么,电脑要同时完成多个事情,也可以使用多个线程来做不同的事情!
授课进程
1、多线程简介
多线程类似于同时执行多个不同程序,多线程运行有如下优点:
- 使用线程可以把占据长时间的程序中的任务放到后台去处理。
- 用户界面可以更加吸引人,这样比如用户点击了一个按钮去触发某些事件的处理,可以弹出一个进度条来显示处理的进度。
- 程序的运行速度可能加快。
- 在一些等待的任务实现上如用户输入、文件读写和网络收发数据等,线程就比较有用了。在这种情况下我们可以释放一些珍贵的资源如内存占用等等。
线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。
每个线程都有他自己的一组CPU寄存器,称为线程的上下文,该上下文反映了线程上次运行该线程的CPU寄存器的状态。
指令指针和堆栈指针寄存器是线程上下文中两个最重要的寄存器,线程总是在进程得到上下文中运行的,这些地址都用于标志拥有线程的进程地址空间中的内存。
- 线程可以被抢占(中断)。
- 在其他线程正在运行时,线程可以暂时搁置(也称为睡眠),这就是线程的退让。
线程可以分为:
- 内核线程:由操作系统内核创建和撤销。
- 用户线程:不需要内核支持而在用户程序中实现的线程。
2、threading模块
创建线程:Thread(target=func[args=(value1,value2,……)])
- target,指定线程需要执行的任务(通常是函数名或者方法名,不要加括号)
- args,指定线程执行的任务需要传入的实参,以元组形式传入
- 启动线程:start()
import threading
import time
def f1(n):
for i in range(n):
print('.')
time.sleep(0.1)
def f2(n):
for i in range(n):
print('.')
time.sleep(0.1)
if __name__ == '__main__':
th1 = threading.Thread(target=f1,args=(10,)) # 创建子线程
th2 = threading.Thread(target=f2,args=(10,))
th1.start() # 启动子线线程
th2.start()
print('主线程结束')
线程守护
- 作用:子线程跟随主线程结束而结束
- 方法:setDaemon(True),必须在线程启动前设置
import threading
import time
def f1():
for i in range(10):
print('.')
time.sleep(0.1)
def f2():
for i in range(10):
print('.')
time.sleep(0.1)
if __name__ == '__main__':
th1 = threading.Thread(target=f1) # 创建子线程
th2 = threading.Thread(target=f2)
th1.setDaemon(True) # 线程守护
th2.setDaemon(True)
th1.start() # 启动线程
th2.start()
print('主线程结束')
线程阻塞
- 作用:主线程在子线程结束后结束
- 方法:join(),需要在线程启动后设置
import threading
import time
def f1():
for i in range(10):
print('.')
time.sleep(0.1)
def f2():
for i in range(10):
print('.')
time.sleep(0.1)
if __name__ == '__main__':
th1 = threading.Thread(target=f1) # 创建子线程
th2 = threading.Thread(target=f2)
th1.start() # 启动线程
th2.start()
th1.join() # 线程阻塞
th2.join()
print('主线程结束')
3、Python多线程的小尴尬
启动与CPU核心数量相同的N个线程,在4核CPU上可以监控到CPU占用率仅有102%,也就是仅使用了一核。 但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢?
因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。 GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。 所以,在Python中,可以使用多线程,但不要指望能有效利用多核。如果一定要通过多线程利用多核,那只能通过C扩展来实现,不过这样就失去了Python简单易用的特点。 不过,也不用过于担心,Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。
课程小结
- 使用Thread方法创建线程时,target参数必须传入函数名,args参数传入元组
- start()方法启动线程,如果子线程较多可以将子线程保存在列表中,通过 遍历列表的方式逐个启动子线程。
- 线程守护,设置子线程跟随主线程结束而结束,必须在线程启动前设置
- 线程阻塞,设置主线程在子线程结束后结束,必须在线程启动后设置
随堂作业
- 投篮训练:若干个球员进行投篮训练,每个球员的命中率不同,统计2分钟内每个球员的训练情况
import random,time
import threading
class Player:
def __init__(self,name,rate):
self.name = name
self.rate = rate # 历史命中率
self.count = 0 # 投篮次数
self.hit = 0 # 命中数
self.li = [] # 保存1或者0,用于模拟命中率,1-命中,0-未命中
self.mock_rate()
def mock_rate(self): # 模拟命中率
# 命中率:87.37%
for i in range(10000):
if i < self.rate*100:
self.li.append(1)
else:
self.li.append(0)
def shoot(self):
self.count += 1 # 每调用1次shoot方法,投篮次数+1
if random.choice(self.li) == 1: # 获取投篮结果
self.hit += 1 # 命中数+1
r = '命中'
else:
r = '未命中'
print(f'{self.name}--第{self.count:2}次投篮--{r}')
time.sleep(random.randint(1,3)) # 随机暂停1-3秒
def training(ps,name,rate):
p = Player(name, rate) # 创建一个球员
ps.append(p) # 将球员加入列表
while True:
p.shoot()
if __name__ == '__main__':
players = {'姚明':85.78,'詹姆斯':81.98,'哈登':89.00,'库里':92.01,'杜兰特':87.52}
ps = [] # 保存球员类对象
threads = [] # 保存子线程
for i in players:
th = threading.Thread(target=training,args=(ps,i,players[i])) # 创建子线程
threads.append(th)
for i in threads:
i.setDaemon(True) # 线程守护
i.start() # 启动线程
time.sleep(120) # 主线程暂停2分钟
print('训练结束'.center(50,'*'))
for p in ps:
print(f'姓名:{p.name}\t投篮次数:{p.count}\t命中数:{p.hit}\t命中率:{p.hit*100/p.count:.2f}%')