GIL(全局解释器锁)
什么是全局解释器锁?
根据我们之前学到过的互斥锁,我们知道了锁在我们进程与线程中的作用就是为了让进程与线程在读写同一份数据时不会发生混乱的,那么全局解释器锁本质上也就是一把互斥锁,但全局解释器锁只在Cpython解释器中有。那么全局解释器锁又有何作用?
首先我们来回顾一下python程序运行的三个步骤:
⑴启动python解释器,申请内存空间将解释器的代码放进去
⑵将python文件由硬盘加载到刚刚已经申请过的内存空间
⑶再由python解释器读取python文件解释执行代码
全局解释器的作用
之前我们学到过进程与线程的理论知识,所以我们知道每个进程都含有一条执行任务的主线程,而主线程在执行代码时会分支出多个子线程,由于我们的python解释器与我们的python文件此时都在同一内存空间内,所以多个子线程都会去争抢python解释器来解释执行代码,若不加以管理则会有可能导致多个线程并发执行的去修改进程内的数据,这就是进程安全问题。但解释器只有一个,所以全局解释器锁的作用就是在一个进程内多个线程只能有一个执行,即每个要执行代码的线程都要先抢到这把加在解释器身上的锁才可以执行。保证了Cpython解释器内存管理的安全性。
当然,GIL全局解释器锁的缺点也是显而易见的,它也导致在同一线程内的所有线程同一时刻只能有一个线程执行,会导致Cpython解释器的多线程无法实现并行效果,在遇到计算密集类型时的操作则会影响执行效率,Cpython的解决方案则是在多核cpu的前提下再开一个进程来实现一个并行的效果,而单核cpu时无论多进程还是多线程本质上的区别是不大的。在遇到IO密集类型的多个任务时,使用多线程则会比多进程更加节省cpu资源,不会占用多个cpu。
Cpython解释器并发效率验证
首先我们看一下多进程在进行多个运算时的时间
使用多进程同时计算多个数据:
from multiprocessing import Process import time def task1(): res = 0 for i in range(1, 100000000): res += i def task2(): res = 0 for i in range(1, 100000000): res += i def task3(): res = 0 for i in range(1, 100000000): res += i def task4(): res = 0 for i in range(1, 100000000): res += i if __name__ == '__main__': s = time.time() p1 = Process(target=task1) p2 = Process(target=task2) p3 = Process(target=task3) p4 = Process(target=task4) p1.start() p2.start() p3.start() p4.start() p1.join() p2.join() p3.join() p4.join() print(time.time() - s)
输出结果为:17.96002721786499
使用多线程计算多个数据:
from threading import Thread import time def task1(): res = 0 for i in range(1, 100000000): res += i def task2(): res = 0 for i in range(1, 100000000): res += i def task3(): res = 0 for i in range(1, 100000000): res += i def task4(): res = 0 for i in range(1, 100000000): res += i if __name__ == '__main__': s = time.time() t1 = Thread(target=task1) t2 = Thread(target=task2) t3 = Thread(target=task3) t4 = Thread(target=task4) t1.start() t2.start() t3.start() t4.start() t1.join() t2.join() t3.join() t4.join() print(time.time() - s)
输出结果为:31.936826705932617
目前我们可以看出在计算类型操作时多进程的并行效果要远超与多线程的依次运算的,下面我们来看一下IO类型操作的速度。
使用多进程并行执行IO类型操作:
from multiprocessing import Process import time def task1(): time.sleep(3) def task2(): time.sleep(3) def task3(): time.sleep(3) def task4(): time.sleep(3) if __name__ == '__main__': s = time.time() p1 = Process(target=task1) p2 = Process(target=task2) p3 = Process(target=task3) p4 = Process(target=task4) p1.start() p2.start() p3.start() p4.start() p1.join() p2.join() p3.join() p4.join() print(time.time() - s) 输出结果为:3.1831820011138916
使用多线程执行IO类型操作:
from threading import Thread import time def task1(): time.sleep(3) def task2(): time.sleep(3) def task3(): time.sleep(3) def task4(): time.sleep(3) if __name__ == '__main__': s = time.time() t1 = Thread(target=task1) t2 = Thread(target=task2) t3 = Thread(target=task3) t4 = Thread(target=task4) t1.start() t2.start() t3.start() t4.start() t1.join() t2.join() t3.join() t4.join() print(time.time() - s)
输出结果为:3.001171588897705
结论:通过两次验证证明多线程在执行IO类型操作时效率看起来只比多进程快一点点,因为多进程的开销与开启时间都比较大,但两者之间是差着数量级的,算起来也是多进程的百倍左右。而多进程在计算类型操作时效率也是要远超多线程,但事实是我们编写的程序例如之前的ATM购物车,选课系统等多数的操作都是要与用户交互数据操作,而这些操作就是一些大量的IO操作,真正意义上的纯运算程序其实特别少,所以我们以后编写程序时也会遇到大量的IO操作,假设真的有纯运算的程序,我们也确实应该使用多进程。
GIL与互斥锁的关联
首先我们要明白,虽然外表上看起来GIL与互斥锁的作用是相等的,但GIL的作用只是保护解释器级别的数据在同一时间只能有一个线程执行,若该线程在执行代码时遇到了IO操作系统则会回收解释器锁以供下面的进程抢锁,所以这并不能保证线程自己执行的代码的安全性。
GIL配合互斥锁
from threading import Thread, Lock import time mutex = Lock() count = 0 def task(): global count mutex.acquire() # 后来拿到互斥锁的线程会卡在此处直到第一个拿到锁的人离开锁 temp = count time.sleep(0.1) # 线程在拿到GIL解释器锁之后执行了IO操作,所以解释器锁会将锁回收 # 下个进程抢到锁后也会执行到此处进行IO操作,依次循环 # 第一个先睡完的线程会再次抢GIL解释器锁 # 拿到锁后更改数据 count = temp + 1 mutex.release() if __name__ == '__main__': t_l = [] for i in range(2): t = Thread(target=task) t_l.append(t) # 每建立一个线程对象都添加到列表内,节省代码 t.start() for t in t_l: # 从列表内依次join t.join() print('主', count)
进程池与线程池
在了解了并发编程之后,我们可以使用线程与进程不间断的为用户提供服务,但是我们还要思考一个问题,那就是我们的服务器即便再强大也是有负载上限的,你可以运行一千个一万个进程,但几百万几千万呢?很显然在面对未知数的客户端时我们并不能保证我们的服务器永远也不会崩溃。所以我们就要想到一种办法来解决这个问题了。就是进程池与线程池!
1.什么是进程池?什么是线程池?
首先我们知道,池的意思就是用来存放东西的,例如我们的水池,花池等等。那顾名思义进程池与线程池就是用来存放进程与线程的一种容器。而进程池与线程池在我们python中就是限制我们计算机并发执行的任务数量,使我们的计算机在一个自己可承受的范围内并发的去执行任务。
2.关键字:from concurrent.futures import ProcessPoolExecutor/ThreadPoolExecutor
使用线程池完成并发套接字通信
服务端代码: from socket import * # 导入线程池模块 from concurrent.futures import ThreadPoolExecutor # 创建一个线程池对象, 括号内的max_worker指的是最大线程数 # 默认为cpu的核数乘5, 也可指定相应数字, 默认乘5 # 线程数若超出上限则会等待线程池内有线程空闲下来再执行给线程绑定的功能 T_pool = ThreadPoolExecutor(max_workers=4) # 线程执行的功能 def task(conn): while True: try: data = conn.recv(1024) if not data: break conn.send(data.upper()) except ConnectionResetError: break conn.close() def servers(): # 创建服务器对象并配置信息 server = socket(AF_INET, SOCK_STREAM) server.bind(('127.0.0.1', 9898)) server.listen(5) # 为套接字通信加上链接循环 while True: conn, client_address = server.accept() print(client_address) # 使用关键字submit发送创建线程信号并传入相应的参数 # 发送完信号后再循环等待接收链接请求 T_pool.submit(task, conn) if __name__ == '__main__': servers()
客户端代码: from socket import * client = socket() client.connect(('127.0.0.1', 9898)) while True: msg = input('>>:') if not msg: continue if msg == 'q': break client.send(msg.encode('utf-8')) data = client.recv(1024) print(data.decode('utf-8')) client.close()
进程池除了名称不同其他属性都与线程池的属性是一样的,这里不使用进程池的原因是因为我们所写的套接字通信大都是IO操作,基本没有运算任务,所以此处使用线程则会提高程序的执行效率。
强调:
一定要清楚的知道进程与线程的最大区别与什么时候使用进程与线程。
进程池要放入的都是运算密集类型的进程任务
线程池要放入的都是IO密集类型的线程任务