python多线程的简单实现和GIL
多线程和多进程是每个计算机爱好者必须掌握的知识。在我接触编程的时候,几乎没有见过多线程/进程开发的demo,这导致我在很长一段时间都掌握得模模糊糊。在本篇博客中,我们将用python中的threading库为例,介绍并实现多线程,并用几个图片加深大家对进程的理解。
为什么在python使用多线程
I/O密集型(I/O bound)任务使用多线程,计密集型(CPU bound)任务使用多线程。处理本地系统文件(大部分时间在等内存的读写)属于I/O bound。下面我们举个例子。
import time
import threading
import concurrent.futures
start = time.perf_counter()
def wait(sec):
print(f'Waiting for {sec} seconds ...')
time.sleep(1)
print(f'{sec} waiting done ...')
with concurrent.futures.ThreadPoolExecutor() as executor:
secs = [5, 4, 3, 2, 1]
fs = [executor.submit(wait, sec) for sec in secs]
finish = time.perf_counter()
print(f'Consuming time {finish - start} seconds!')
运行结果:
根据上图,我们运行了5次sleep(1),但总共只花费了1秒,这说明多线程的优越性。
多线程中存在的基本概念
多线程的简单实现
import time
import threading
def say(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(2)
print("Bye {0} at {1}".format(name, time.ctime()))
def listen(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(4)
print("Bye {0} at {1}".format(name, time.ctime()))
if __name__ == "__main__":
# 初始化线程t1、t2
t1 = threading.Thread(target=say, args=("John", ))
t2 = threading.Thread(target=listen, args=("Simon", ))
t1.start()
t2.start()
print("It's main process!!!")
结果:
我们可以看到主线程中的print并不是等t1、t2线程跑完才输出的,这是因为t1、t2、主线程是同时跑的(兵分三路)。
代码运行结果分析:
抽象示意图:
堵塞
若我们需要线程t1、t2运行结束后,主线程再输出,则修改如下面的例子:
'''
Descripttion:
version:
Author: sch
Date: 2020-12-29 22:55:01
LastEditors: sch
LastEditTime: 2020-12-29 23:18:27
'''
import time
import threading
def say(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(2)
print("Bye {0} at {1}".format(name, time.ctime()))
def listen(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(4)
print("Bye {0} at {1}".format(name, time.ctime()))
if __name__ == "__main__":
t1 = threading.Thread(target=say, args=("John", ))
t2 = threading.Thread(target=listen, args=("Simon", ))
t1.start()
t2.start()
# threading.Thread.join(): 阻塞--在该子线程运行结束之前,子线程的父线程一直被阻塞
t1.join()
t2.join()
print("It's main process!!!")
运行结果:
观察本次结果与上次结果有什么不同。大家可以仅给线程t1.join(),再观察输出结果有什么不同。
抽象示意图:
守护进程
接下来我们引进守护进程。当某个子线程被设置成守护进程时,主线程就不管这个子线程了。当所有其他非守护线程退出后,主线程退出,在主线程退出的同时,守护强制进程退出。我们用下面的例子说明。
首先第一个例子,我们将t1设置成守护进程:
'''
Descripttion:
version:
Author: sch
Date: 2020-12-29 22:55:01
LastEditors: sch
LastEditTime: 2020-12-29 23:36:31
'''
import time
import threading
def say(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(2)
print("Bye {0} at {1}".format(name, time.ctime()))
def listen(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(4)
print("Bye {0} at {1}".format(name, time.ctime()))
if __name__ == "__main__":
t1 = threading.Thread(target=say, args=("John", ))
t2 = threading.Thread(target=listen, args=("Simon", ))
t1.setDaemon(True) # 需在线程运行前,将其设置为主线程
t1.start()
t2.start()
print("It's main process!!!")
运行结果:
抽象示意图:
若将t2设置成守护进程:
'''
Descripttion:
version:
Author: sch
Date: 2020-12-29 22:55:01
LastEditors: sch
LastEditTime: 2020-12-29 23:36:31
'''
import time
import threading
def say(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(2)
print("Bye {0} at {1}".format(name, time.ctime()))
def listen(name):
print("Hello {0} at {1}".format(name, time.ctime()))
time.sleep(4)
print("Bye {0} at {1}".format(name, time.ctime()))
if __name__ == "__main__":
t1 = threading.Thread(target=say, args=("John", ))
t2 = threading.Thread(target=listen, args=("Simon", ))
t2.setDaemon(True) # 需在线程运行前,将其设置为主线程
t1.start()
t2.start()
print("It's main process!!!")
输出结果:
抽象示意图:
我们可以看出,线程t2的并没有输出“Bye John at …”,这是因为我们将t2设置成了守护进程。线程t1结束后,主进程结束,主进程结束的同时,线程t2被强制结束。
锁
锁一般用来实现多线程对共享资源的访问,用于锁住公共资源。举个例子,当thread1访问公共资源(e.g.全局变量a)时,thread1获得了公共资源(e.g.全局变量a)的锁。thread2无法访问公共资源(e.g.全局变量a),此时thread2阻塞,直至thread1释放了锁,thread2才能获取锁,访问公共资源,thread2才能继续运行。
多线程访问公共资源(内存)
我们先看一下,没有锁的情况下,多线程访问公共资源会导致什么错误:
import threading
import time
num = 10
def func_sub():
global num
print("{}: n = n-1".format(t.name))
# 接下来的两行起到关键性作用
num2 = num
time.sleep(0.01)
num = num2 - 1
threads = []
for i in range(10):
t = threading.Thread(target=func_sub)
t.start()
threads.append(t)
for t in threads:
t.join()
print("n = {}".format(num))
运行结果:
我们调用10个线程,均对全局变量num做了减一操作,结果却是9,由此可见不加锁的情况下,多线程调用公共资源出现了问题。
同步锁
当我们给公共资源定义一个同步锁后,代码如下:
import threading
import time
num = 10
def func_sub():
global num
lock.acquire()
print("------加上锁------")
print("{0} 在使用公共资源".format(threading.current_thread().getName()))
num2 = num
time.sleep(0.01)
num = num2 - 1
lock.release()
print("------释放锁------")
if __name__ == "__main__":
# 定义一个同步锁
lock = threading.Lock()
threads = []
for i in range(10):
t = threading.Thread(target=func_sub)
t.start()
threads.append(t)
for t in threads:
t.join()
print("num = {}".format(num))
运行结果:
我们可以看到,最终结果不再是9,而是0。这个例子证明了同步锁可以避免多线程访问公共资源出现混乱。
死锁
我们先举一个例子,介绍死锁的概念:
1、A拿了一个苹果
2、B拿了一个香蕉
3、A现在想再拿个香蕉,就在等B释放这个香蕉
4、B同时想再拿个苹果,就在等A释放这个苹果
5、这样就陷入了僵局,这就是死锁。
note: 本例子中的香蕉和苹果属于两块公共资源
我们用代码实现一下上述例子:
import threading
import time
apple_lock = threading.Lock()
banana_lock = threading.Lock()
class MyThread(threading.Thread):
def __init__(self):
super(MyThread, self).__init__()
def run(self):
self.func1()
self.func2()
def func1(self):
apple_lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "苹果"))
banana_lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "香蕉"))
banana_lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "香蕉"))
apple_lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "苹果"))
def func2(self):
banana_lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "香蕉"))
# 注意time.sleep(0.1)的位置
time.sleep(0.1)
apple_lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "苹果"))
apple_lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "苹果"))
banana_lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "香蕉"))
if __name__ == "__main__":
for i in range(10):
t = MyThread()
t.start()
运行结果如下:
由上图可知,程序陷入死锁,无法继续进行。
递归锁
为了支持在同一线程多次请求同一资源,python提供了“递归锁”:threading.RLock。RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使公共资源可以被多次acquire。直到一个线程的所有acquire都被release,其他的线程才能使用公共资源。
用递归锁解决死锁问题:
import threading
import time
lock = threading.RLock()
class MyThread(threading.Thread):
def __init__(self):
super(MyThread, self).__init__()
def run(self):
self.func1()
self.func2()
def func1(self):
lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "苹果"))
lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "香蕉"))
lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "香蕉"))
lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "苹果"))
def func2(self):
lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "香蕉"))
# 注意time.sleep(0.1)的位置
time.sleep(0.1)
lock.acquire()
print("{0} 拿了 {1}".format(threading.current_thread().getName()
, "苹果"))
lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "苹果"))
lock.release()
print("{0} 放下 {1}".format(threading.current_thread().getName()
, "香蕉"))
if __name__ == "__main__":
for i in range(10):
t = MyThread()
t.start()
运行结果如下:
同步条件threading.Event()
同步条件存在的意义
在python中使用多线程时,各个线程竞争cpu的使用权,每个线程都是独立且状态不可预测的。大部分时候,我们要根据需求决定线程的运行顺序,这就要用到同步条件( threading.Event() )
threading.Event()的方法如下:
event.isSet():返回event的状态值
event.wait():如果 event.isSet()==False,将阻塞线程触发event.wait()
event.set(): 设置event的状态值为True,所有阻塞池的线程激活进入就绪状态, 等待执行
event.clear():恢复event的状态值为False
同步条件例子
首先我们描述一个场景
1.老师说这堂课我们要做测试卷子,做完才能放学
2.学生叫苦连天,啊啊啊啊啊啊
3.学生做试卷中
4.做完试卷放学回家
代码如下:
import threading
import concurrent.futures
import time
# 同步条件:刚设置时为False(True: 线程执行;False:线程不执行)
event = threading.Event()
class Teacher(threading.Thread):
def run(self):
print("考完试再回家!")
print(event.isSet()) # event.isSet(): 打印出现同步条件状态
event.set() # event.set(): 设置同步条件为True
time.sleep(3)
print("考试结束...")
print(event.isSet())
event.set()
class Student(threading.Thread):
def run(self):
event.wait() # event.wait()等待同步条件为True时再执行
print("操!去考场吧")
event.clear() # event.clear(): 设置同步条件为False
event.wait()
print("回家喽!")
threads = []
for _ in range(3):
threads.append(Student())
threads.append(Teacher())
for t in threads:
t.start()
for t in threads:
t.join()
我们解释一下代码运行流程:
1.模拟1个老师和10个学生,进行考试,我们需要的目的是学生线程要等待老师线程说完“大家现在考试”,然后学生线程去考试,之后老师线程说“考试结束”,学生线程放学回家,学生线程的执行与否取决于老师线程,所以这里用的Event
2.学生线程开始event.wait(),这个说明如果event如果一直不设置的话,学生线程就一直等待,等待一个event.set()操作
3.老师线程说完"大家现在要考试",然后event.set(),执行event,设置完执行,学生线程就能够被唤醒继续执行下面的操作发出"啊啊啊啊啊啊"的叫苦连天
4.学生线程进行考试,并且执行event.clear(),清除event,因为他们在等老师说“考试结束”,之后他们在等老师线程的event.set()
5.老师线程执行event.set(),唤醒学生线程,然后下课回家.
运行结果:
信号量Threading.Semaphore()
信号量存在的意义
信号量用来控制并发数,Semaphore()内置一个计数器,semaphore.acquire()时计数器减一,并return True。semaphore.release()时计数器加一。计数器不能小于0,当计数器为 0时,acquire()将阻塞线程至同步锁定状态,直到其他线程调用release()。
我们可以把信号量想象成资源,>=0时即有资源时,新线程才能运行。
信号量实例
import threading
import concurrent.futures
import time
# 初始化信号量=5
semaphore = threading.Semaphore(5)
def wait(sec):
if semaphore.acquire():
print (threading.currentThread().getName() + '获取共享资源')
time.sleep(3)
semaphore.release()
for _ in range(10):
t1 = threading.Thread(target=wait, args=(3,))
t1.start()
上面一个简单的例子就是创建10个线程,让每次只让5个线程去执行func函数。
结果:5个线程一批一批的执行打印,中间停格2s
运行结果如下:
队列queue.Queue()
队列存在的意义
Queue是python标准库中的线程安全的队列实现,提供了一个适用于多线程编程的先进先出的数据结构,即队列,用来在生产者和消费者线程之间的信息传递。
queue.Queue的常用方法
创建一个“队列”对象
import Queue
q = Queue.Queue(maxsize = 10)
Queue.Queue类即是一个队列的同步实现。队列长度可为无限或者有限。可通过Queue的构造函数的可选参数maxsize来设定队列长度。如果maxsize小于1就表示队列长度无限。
将一个值放入队列中
q.put(10)
调用队列对象的put()方法在队尾插入一个项目。put()有两个参数,第一个item为必需的,为插入项目的值;第二个block为可选参数,默认为
1。如果队列当前为空且block为1,put()方法就使调用线程暂停,直到空出一个数据单元。如果block为0,put方法将引发Full异常。
将一个值从队列中取出
q.get()
调用队列对象的get()方法从队头删除并返回一个项目。可选参数为block,默认为True。如果队列为空且block为True,
get()就使调用线程暂停,直至有项目可用。如果队列为空且block为False,队列将引发Empty异常。
Python Queue模块有三种队列及构造函数:
1、Python Queue模块的FIFO队列先进先出。 class queue.Queue(maxsize)
2、LIFO类似于堆,即先进后出。 class queue.LifoQueue(maxsize)
3、还有一种是优先级队列级别越低越先出来。 class queue.PriorityQueue(maxsize)
此包中的常用方法(q = Queue.Queue()):
q.qsize() 返回队列的大小
q.empty() 如果队列为空,返回True,反之False
q.full() 如果队列满了,返回True,反之False
q.full 与 maxsize 大小对应
q.get([block[, timeout]]) 获取队列,timeout等待时间
q.get_nowait() 相当q.get(False)
非阻塞 q.put(item) 写入队列,timeout等待时间
q.put_nowait(item) 相当q.put(item, False)
q.task_done() 在完成一项工作之后,q.task_done() 函数向任务已经完成的队列发送一个信号
q.join() 实际上意味着等到队列为空,再执行别的操作
Productor–Consumer Model(生产者–消费者模型)
-
为什么要使用生产者和消费者模式?
在python线程中,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发当中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这个问题于是引入了生产者和消费者模式。
-
什么是生产者消费者模式?
生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
Productor–Consumer Model的实现
import threading
import concurrent.futures
import time
import queue
import random
q = queue.Queue(10)
class Producer(threading.Thread):
def __init__(self, name):
super(Producer, self).__init__()
self.name = name
def run(self):
count = 0
while count < 10:
if not q.full():
print("制造包子ing...")
time.sleep(random.randrange(3))
q.put(count)
print(f'生产者 {self.name} 生产了第 {count} 个包子')
count += 1
class Consumer(threading.Thread):
def __init__(self, name):
super(Consumer, self).__init__()
self.name = name
def run(self):
count = 0
time.sleep(4)
while count < 10:
if not q.empty():
data = q.get()
print(data)
print(f'消费者 {self.name} 买了第 {data} 个包子')
else:
print('暂时没有包子')
count += 1
pro = Producer('小明')
cu1 = Consumer('小花')
cu2 = Consumer('小灰')
pro.start()
cu1.start()
cu2.start()
pro.join()
cu1.join()
cu2.join()
运行结果如下:
GIL(全局解释器锁)
python线程的假并行、真并发
- 并行:一个系统具有同时处理多个任务的能力(cpu切 换,多道技术)
- 并发:一个系统具有处理多个任务的能力(cpu同时处理多个任务)
note:并发并未强调同时,因此也不一定提高程序的运行效率。
python的多线程兵分多路,看上去会提高程序运行效率,减小运行时间。但实际是这样的吗?接下来我们比较一下单线程与双线程运行时间。
单线程:
import time
import threading
# 这个函数用于消耗时间
def countDown(n):
while (n >= 0):
n -= 1
if __name__ == "__main__":
# 记录程序起始时间
start = time.perf_counter()
x = 1000000
countDown(x)
print("Single thread consumes time: \
{}".format(time.perf_counter() - start))
运行结果(运行时间):
多线程:
import time
import threading
# 这个函数用于消耗时间
def countDown(n):
while (n >= 0):
n -= 1
if __name__ == "__main__":
# 记录程序起始时间
start = time.perf_counter()
x = 1000000
t1 = threading.Thread(target=countDown, args=(x/2, ))
t2 = threading.Thread(target=countDown, args=(x/2, ))
t1.start()
t2.start()
# 注意此处一定要锁住主进程:阻塞
t1.join()
t2.join()
print("Double thread consumes time: \
{}".format(time.perf_counter() - start))
运行结果(运行时间):
我们发现双线程的程序反而比单线程慢一些,这是为什么呢?这就是python的假并行、真并发。
GIL的运行原理
GIL的运行原理如上,三个线程争夺CPU的使用权。任意时间仅能有一个线程在运行,当一个线程释放GIL后,另一个线程接着acquire GIL。
为什么Python会在线程运行结束前释放GIL呢?这是由于CPython的一个机制–间隔式检查(check-interval)。CPython每隔一段时间就会强制当前线程释放GIL。
GIL存在的意义
我们先引入一个新概念----引用计数。CPython使用引用计数来管理python建立的内容,引用计数等于指向实例的指针数。当实例的引用计数为0时,程序释放该实例。
如上图,a、b和sys.getrefcount()各引用一次,所以引用计数等于3。
如果有两个线程同时引用a,导致引用计数的条件竞争,最终导致引用计数只增加了1。当线程1引用结束后,a的引用计数减1,此时refcount=0,内存释放。线程2就无法访问该内存了。
GIL的安全性验证
我们通过下面的程序检验GIL的安全性。调用5个线程分别给全局变量n(最初n=5)减去1,若最后输出n=0,则证明GIL很安全。
import threading
n = 5
def func_sub():
global n
print("{}: n = n-1".format(t.name))
n -= 1
threads = []
for i in range(5):
t = threading.Thread(target=func_sub)
t.start()
threads.append(t)
for t in threads:
t.join()
print("n = {}".format(n))
运行结果如下:
concurrent.futures lib的使用
example 1
import time
import threading
import concurrent.futures
# 程序开始时间
start = time.perf_counter()
# 线程调用的函数,等待1秒
def wait(sec):
print(f'Waiting for {sec} seconds ...')
time.sleep(1)
return (f'{sec} waiting done ...')
# 调用concurrent.futures.ThreadPoolExecutor()类
with concurrent.futures.ThreadPoolExecutor() as executor:
secs = [5, 4, 3, 2, 1]
# 每一个executor返回一个future object。fs是存储future object的列表
fs = [executor.submit(wait, sec) for sec in secs]
#for f in fs:
#print(f.result())
for f in concurrent.futures.as_completed(fs):
print(f.result())
# 程序结束时间
finish = time.perf_counter()
print(f'Consuming time {finish - start} seconds!')
example 2
'''
Descripttion:
version:
Author: sch
Date: 2020-12-30 11:34:38
LastEditors: sch
LastEditTime: 2021-01-01 11:27:07
'''
import time
import threading
import concurrent.futures
# 程序开始时间
start = time.perf_counter()
# 线程调用的函数,等待1秒
def wait(sec):
print(f'Waiting for {sec} seconds ...')
time.sleep(1)
return (f'{sec} waiting done ...')
# 调用concurrent.futures.ThreadPoolExecutor()类
with concurrent.futures.ThreadPoolExecutor() as executor:
secs = [5, 4, 3, 2, 1]
# executor.map(fun, *args) 返回result_iterator
results = executor.map(wait, secs)
for result in results:
print(result)
# 程序结束时间
finish = time.perf_counter()
print(f'Consuming time {finish - start} seconds!')
附录(本博客中调用的threading库类/方法)
1.threading.Thread(target=func, args=(*args))
2.Thread类方法:
t1 = threading.Thread(target=func, args=(*args))
- t1.start() # 线程启动
- t1.join() # 线程堵塞
- t1.setDaemon(True) # 将子线程t1设置为守护进程
- t1.isAlive() # 返回线程是否活动
- t1.getName() # 返回线程名
- t1.getName() # 设置线程名
3.Lock类方法:
lock = threading.Lock() # 同步锁
- lock.acquire() # 获取锁
- lock.release() # 释放锁
4.RLock类方法:
rlock = threading.RLock() # 递归锁
- rlock.acquire()
- rlock.release()
5.其他方法
- threading.currentThread(): 返回当前的线程变量
- threading.enumerate(): 返回一个包含正在运行的线程的list。正在运行指线程启动后、结束前,不包括启动前和终止后的线程。
- threading.activeCount():返回正在运行的线程数量,与len(threading.enumerate())有相同的结果。