并发编程总结(1)
- 并发编程的重点
- 多道技术
- 操作系统的发展和作用(详情请看我的上一篇博客)
- 程序的并发和并行
- 进程
- 进程的调度 (详情请看我的上一篇博客)
- 进程的同步和异步
- 进程的阻塞和非阻塞
- 创建进程(其中join 方法很重要)
- 进程的回收
- 线程
- 线程的创建
- 线程操作——锁(进程也可以使用,方法相同)
- 队列
- IPC(Inter-Process Communication 进程间通信)
- GIL(全局解释器锁)
一、多道技术
-
针对单核,实现并发:
就是一个内存中可以存放多道相互独立的程序,在一道程序进行io操作或等待消息时(即不需要使用CPU的时间段),就把他对的CPU使用权解除,保存其当前的作业进度,再把CPU的使用权交给另一道程序来使用。
-
多道技术首次出现了 空间复用 和 时间复用。
-
上面所说的不需要使用CPU的阶段为后来程序运行中其进程的阻塞状态。阻塞状态的触发条件是,当一个进程遇到io操作和等待消息等,对CPU的占用时间过长不会阻塞。
-
批处理:在进行批处理时,不允许用户与作业发生交互作用。
-
多道技术:
-
多道技术的核心是:切换 + 保存状态
- 一个内存中同时放入多道任务
- 多道任务在CPU上轮流执行,遇到IO或者占用CPU时间过长就切换
- 切换时会保存作业进度
- 产生背景:针对单核,实现并发
ps:现在的主机一般是多核,那么每个核都会利用多道技术。有4个cpu,运行于cpu1的某个程序遇到io阻塞,会等到io结束再重新调度,会被调度到4个cpu中的任意一个,具体由操作系统调度算法决定。
- 空间上的复用:将内存分为几部分,每个部分放入一个程序,这样,同一时间内存中就有了多道程序。
- 时间上的复用:当一个程序在等待I/O时,另一个程序可以使用cpu,如果内存中可以同时存放足够多的作业,则cpu的利用率可以接近100%,类似于我们小学数学所学的统筹方法。(操作系统采用了多道技术后,可以控制进程的切换,或者说进程之间去争抢cpu的执行权限。这种切换不仅会在一个进程遇到io时进行,一个进程占用cpu时间过长也会切换,或者说被操作系统夺走cpu的执行权限)
强调:遇到io切换,占用cpu时间过长也切换,核心在于切之前将进程的状态保存下来,这样才能保证下次切换回来时,能基于上次切走的位置继续运行。
二、操作系统的发展和作用
1. 操作系统的发展
- 穿孔卡片
- 联机批处理系统
- 脱机批处理系统
- 多道程序设计技术
- 多道批处理系统
- 分时系统
- 实时系统
- 通用操作系统
2. 操作系统的作用
- 隐藏丑陋复杂的硬件接口,提供良好的抽象接口
- 管理、调度进程,并且将多个进程对硬件的竞争变得有序
三、程序的并行和并发
-
并行和并发是早期产生的一种说法,因为当时的技术还达不到现在的水平,没有对程序的微观运行的认识(即进程和线程)。所以并行和并发一般都是针一个个对程序来说的。
-
并发
CPU在不同程序之间来回切换,实现宏观上的同时刻运行(因为CPU的计算速度太快,人感觉不到这种切换),但微观上其实还是轮流运行(单核)
-
并行
一个个程序真正意义上的同时运行(只有通过多核实现,单核情况下,不可能实现并行)
-
-
注意: python中,多核下,单个进程中的线程也不能实现并行, 只能实现并发,这是由于GIL的存在造成的。但我们可以通过创建多进程实现多进程的并行。其他语言在多核下,单个进程中的线程可以实现并行。但无论python还是其他语言,单个进程中的线程之间数据是共享的。
四、进程
- 进程是一个程序的执行过程,也可以说是运行一个程序产生的实例。官方说法是一个是操作系统的一个资源单位。
- 进程的三种状态: 就绪态 运行态 阻塞态
五、进程的调度
调度算法:
- 先来先服务调度算法(FCFS)
- 短作业优先调度算法(SJ/PF)
- 时间片轮转法 (只能调度分配一些可抢占的资源,如CPU。但是打印机是不可抢占资源)( Round Robin,RR )
- 多级反馈队列(目前公认的较好的调度算法)(也不能调度分配不可抢占资源)
六、进程的同步和异步
- 进程的同步、异步是后来操作系统发展成熟起来后,对程序的微观运行有了进一步认识尔提出来的一种概念。
- 他们和程序并行、并发意思其实差不多。只不过分别针对的对象不同,然后稍有差别,比如同步和并行,概念区别较大,但是异步和并发有时可以等价使用。
- 也就是说,并发和并行 与 同步和异步 他们是不同时期的产物。即不同时期 对程序的不同的认识,他们的说法都没错,有些意思还非常相似,如并发和异步。所以有时候会混用。异步一般是在有进程阻塞(io操作或者CPU占用时间过长)时,使用的一种方法。
- 异步是程序一种的执行方式,实现这种方式的具体操作就叫并发。
同步
- 一个任务的执行需要等待里一个任务执行结束才能开始。
异步
- 一个任务的开始不需要等待另一个任务执行结束,相当于同时执行或者并发运行。
七、进程的阻塞和非阻塞
-
阻塞和非阻塞是进程的一种状态。
-
阻塞
是当进程遇到io操作或者 消息等待时(自己占用CPU时间过长不会阻塞,进程会直接回到就绪态),而触发的一种状态。当io操作或者消息等待结束后,进程又会重新进入就绪态队列的末尾。当遇到使用队列的 put 或 get 方法时hold 住了,也会进入阻塞,且使用不同的模块中的队列方法 可能会阻塞也可能不会阻塞。(进程模块里有队列方法,线程模块里也有队列方法)
-
非阻塞就是没有遇到阻塞的这些触发条件
八、创建进程
-
创建一个进程,就开辟一个进程的内存空间,进程与进程之间是独立的。
-
孤儿进程:是指一个子进程的父进程意外结束,此子进程依然在运行。最后由操作系统来回收。
-
UNIX和Windows创建进程
- 在UNIX中该系统调用是:fork,fork会创建一个与父进程一模一样的副本,二者有相同的存储映像、同样的环境字符串和同样的打开文件(在shell解释器进程中,执行一个命令就会创建一个子进程)
- 在Windows中该系统调用是:CreateProcess,CreateProcess既处理进程的创建,也负责把正确的程序装入新进程。
关于创建子进程,UNIX和Windows:
- 相同的是:进程创建后,父进程和子进程有各自不同的地址空间(多道技术要求物理层面实现进程之间内存的隔离),任何一个进程的在其地址空间中的修改都不会影响到另外一个进程。
- 不同的是:在UNIX中,子进程的初始地址空间是父进程的一个副本,提示:子进程和父进程是可以有只读的共享内存区的。但是对于Windows系统来说,从一开始父进程与子进程的地址空间就是不同的。
-
在windows中使用
Process
模块的注意事项
在Windows操作系统中由于没有fork(linux和Mac操作系统中创建进程的机制,即创建子进程就会把父进程的代码拷贝一次,只一次。),在创建子进程的时候会自动 import
启动它的这个文件,而在 import 的时候又执行了整个文件。因此如果将Process()直接写在文件中就会无限递归创建子进程报错。所以必须把创建子进程的部分使用if __name__ =='__main__'
判断保护起来,import
的时候,就不会递归运行了。
1.创建进程的两种方式
使用python中的multiprocessing
中的Process
模块, Process
模块是一个创建进程的模块,借助这个模块,就可以完成进程的创建
-
方式一
import time from multiprocessing import Process def f(name): print('hello', name) print('我是子进程') if __name__ == '__main__': p = Process(target=f, args=('bob',)) p.start() time.sleep(1) print('执行主进程的内容了')
-
方式二
import os from multiprocessing import Process class MyProcess(Process): def __init__(self,name): super().__init__() self.name=name def run(self): print(os.getpid()) print('%s 正在和女主播聊天' %self.name) if __name__ == '__main__': p1=MyProcess('wupeiqi') p2=MyProcess('yuanhao') p3=MyProcess('nezha') p1.start() # start会自动调用run p2.start() # p2.run() p3.start() p1.join() p2.join() p3.join()
2. 进程属性介绍
p.daemon
:默认值为False,如果设为True,代表p为后台运行的守护进程,当p的父进程终止时,p也随之终止,并且设定为True后,p不能创建自己的新进程,必须在p.start()
之前设置p.name
:进程的名称p.pid
:进程的pid ,也可以通过导入os 模块,使用os.getpid
获得当前进程pid号,os.getppid
获得父进程的pid号。 在终端使用tasklist
命令打印当前所有进程,tasklist |findstr pid号
直接搜索到这个进程。
3.进程的方法介绍
p.start()
:启动进程,并调用该子进程中的p.run()p.run()
:进程启动时运行的方法,正是它去调用target指定的函数,我们自定义类的类中一定要实现该方法p.terminate()
:强制终止进程p,不会进行任何清理操作,如果p创建了子进程,该子进程就成了僵尸进程,使用该方法需要特别小心这种情况。如果p还保存了一个锁那么也将不会被释放,进而导致死锁p.is_alive()
:如果p仍然运行,返回Truep.join([timeout])
:主线程等待p终止(强调:是主线程处于等的状态,而p是处于运行的状态)。timeout是可选的超时时间,需要强调的是,p.join只能join住start开启的进程,而不能join住run开启的进程。(其实就是当父进程的主线程执行到创建子进程的代码时,当所有的子进程创建完成后,先执行子进程,不管子进程有没有io操作,都是所有子进程执行结束后,再执行父进程之后的代码,父进程结束。没有join的话,就先执行完父进程的代码,(若父进程中有io也会切换到子进程)再去执行创建的子进程代码。) 线程中的join 和进程的join 的作用是一样的。就是主线程和子线程的关系。
4.join的两种使用
- 第一种
import time
from multiprocessing import Process
def f(name):
print('hello', name)
time.sleep(1)
if __name__ == '__main__':
p_lst = []
for i in range(5):
p = Process(target=f, args=('bob',))
p.start()
p_lst.append(p)
p.join()
print('父进程在执行')
# 这种join的使用,结果是让创建的子进程同步执行,先创建的先执行,当所有子进程执行结束后,在执行父进程中剩下的代码
- 第二种
import time
from multiprocessing import Process
def f(name):
print('hello', name)
time.sleep(1)
if __name__ == '__main__':
p_lst = []
for i in range(5):
p = Process(target=f, args=('bob',))
p.start()
p_lst.append(p)
[y.join() for y in p_lst]
print('父进程在执行')
# 这种join只是让子进程们先执行结束后,再执行父进程中剩下的代码,再父进程再结束。子进程之间的执行顺序就是按分级反馈队列来。
九、进程的回收
- 父进程回收子进程PID号的两种方式:
- join
- 父进程正常结束后
- 孤儿进程:子进程还未结束,父进程意外结束,导致子进程在结束后没有父进程来回收它,此使子进程就像一个"孤儿",然后由操作系统自带的"福利院"来回收.
十、线程
1. 什么是线程?
-
线程与进程都是虚拟单位,目的是为了更好地描述某种事物.
-
通常是在一个进程中包括多个线程,每个线程都是作为利用CPU的基本单位,是花费最小开销的实体
-
- 进程: 资源单位 - 线程: 执行单位 开启一个进程,一定会有一个线程,线程才是真正执行者。
2. 进程与线程的区别
-
节省内存资源.
- 开启进程: 1) 开辟一个名称空间,每开启一个进程都会占用一份内存资源. 2) 会自带一个线程 - 开启线程 1) 一个进程可以开启多个线程 2) 线程的开销远小于进程.
-
注意: python中,多核下,线程也不能实现并行, 只能实现并发,这是由于GIL的存在造成的。其他语言在多核下,线程可以实现并行。同一个进程中的线程之间数据是共享的。
-
比喻: 父进程就像一个工厂, 子进程就像一个工厂车间, 线程就像车间内的流水线.
-
由于同一个进程内的线程共享内存和文件,所以线程之间互相通信不必调用内核
-
一个进程可以有多个线程。进程与进程之间空间独立,数据不互通,但可以通过队列(管道)进行数据交互,一个进程中的多个线程可以数据共享。
-
在一个进程中的多个线程之间,可以并发执行,甚至允许在一个进程中所有线程都能并发执行;同样,不同进程中的线程也能并发执行,充分利用和发挥了处理机与外围设备并行工作的能力。
十一、线程的创建
1. 创建线程的两种方式
- 创建线程只是把线程里的代码执行一下,不会发生创建进程时遇到的循环导入问题
-
方式一
from threading import Thread import time def sayhi(name): time.sleep(2) print('%s say hello' %name) if __name__ == '__main__': t=Thread(target=sayhi,args=('nick',)) t.start() print('主线程')
-
方式二
from threading import Thread import time class Sayhi(Thread): def __init__(self,name): super().__init__() self.name=name def run(self): time.sleep(2) print('%s say hello' % self.name) if __name__ == '__main__': t = Sayhi('nick') t.start() print('主线程')
2. 线程的方法(代码中的方法和进程方法作用相同)
isAlive()
:返回线程是否活动的。getName()
:返回线程名。current_thread().name
获得当前线程名。使用前需要导入:from threading import current_thread
setName()
:设置线程名。join()
:同进程的join用法
3. 守护线程
- 无论是进程还是线程,都遵循:守护xx会等待主xx运行完毕后被销毁。需要强调的是:运行完毕并非终止运行。
- 对主进程来说,运行完毕指的是主进程代码运行完毕
- 对主线程来说,运行完毕指的是主线程所在的进程内所有非守护线程统统运行完毕,主线程才算运行完毕
十二、线程操作——锁(进程也可以用,方法相同)
- 进程或线程加锁的方式一样,作用也一样。同步锁就是整个功能的代码加上锁,互斥锁是指一个功能的部分代码加上锁,未加锁部分并发执行,加锁部分串行执行,速度慢,数据安全。
1. 同步锁
from threading import Thread,Lock
import os,time
def work():
global n
lock.acquire()
temp=n
time.sleep(0.1)
n=temp-1
lock.release()
if __name__ == '__main__':
lock=Lock()
n=100
l=[]
for i in range(100):
p=Thread(target=work)
l.append(p)
p.start()
for p in l:
p.join()
print(n) #结果肯定为0,由原来的并发执行变成串行,牺牲了执行效率保证了数据安全
2. 互斥锁
#不加锁:并发执行,速度快,数据不安全
from threading import current_thread,Thread,Lock
import os,time
def task():
global n
print('%s is running' %current_thread().getName())
temp=n
time.sleep(0.5)
n=temp-1
if __name__ == '__main__':
n=100
lock=Lock()
threads=[]
start_time=time.time()
for i in range(100):
t=Thread(target=task)
threads.append(t)
t.start()
for t in threads:
t.join()
stop_time=time.time()
print('主:%s n:%s' %(stop_time-start_time,n))
'''
Thread-1 is running
Thread-2 is running
......
Thread-100 is running
主:0.5216062068939209 n:99
'''
#不加锁:未加锁部分并发执行,加锁部分串行执行,速度慢,数据安全
from threading import current_thread,Thread,Lock
import os,time
def task():
#未加锁的代码并发运行
time.sleep(3)
print('%s start to run' %current_thread().getName())
global n
#加锁的代码串行运行
lock.acquire()
temp=n
time.sleep(0.5)
n=temp-1
lock.release()
if __name__ == '__main__':
n=100
lock=Lock()
threads=[]
start_time=time.time()
for i in range(100):
t=Thread(target=task)
threads.append(t)
t.start()
for t in threads:
t.join()
stop_time=time.time()
print('主:%s n:%s' %(stop_time-start_time,n))
'''
Thread-1 is running
Thread-2 is running
......
Thread-100 is running
主:53.294203758239746 n:0
'''
# 有的同学可能有疑问:既然加锁会让运行变成串行,那么我在start之后立即使用join,就不用加锁了啊,也是串行的效果啊
# 没错:在start之后立刻使用jion,肯定会将100个任务的执行变成串行,毫无疑问,最终n的结果也肯定是0,是安全的,但问题是
# start后立即join:任务内的所有代码都是串行执行的,而加锁,只是加锁的部分即修改共享数据的部分是串行的
# 单从保证数据安全方面,二者都可以实现,但很明显是加锁的效率更高.
3. 递归锁
- 先导入
from threading import RLock
- 为什么有递归锁,因为当使用多个互斥锁时可能会出现死锁现象。
死锁实例:死锁就是 线程1接下来要使用的锁,线程2正在使用。线程2接下来要使用的锁,线程1正在使用。
from threading import Lock, Thread, current_thread
import time
mutex_a = Lock()
mutex_b = Lock()
# print(id(mutex_a))
# print(id(mutex_b))
class MyThread(Thread):
# 线程执行任务
def run(self):
self.func1()
self.func2()
def func1(self):
mutex_a.acquire()
# print(f'用户{current_thread().name}抢到锁a')
print(f'用户{self.name}抢到锁a')
mutex_b.acquire()
print(f'用户{self.name}抢到锁b')
mutex_b.release()
print(f'用户{self.name}释放锁b')
mutex_a.release()
print(f'用户{self.name}释放锁a')
def func2(self):
mutex_b.acquire()
print(f'用户{self.name}抢到锁b')
# IO操作,会交换线程对CPU的使用权,即暂停当前线程,开始下一个线程
time.sleep(1)
mutex_a.acquire()
print(f'用户{self.name}抢到锁a')
mutex_a.release()
print(f'用户{self.name}释放锁a')
mutex_b.release()
print(f'用户{self.name}释放锁b')
for line in range(10):
t = MyThread()
t.start()
- 递归锁
- 递归锁就是在普通锁的外面加了一个大锁,这个大锁包含了普通锁。使用递归锁里的普通锁就是在使用这个大锁,每使用一个递归锁中的普通锁,这个大锁的引用计数就加一。只有这个大锁的引用计数为0时,别的线程才能使用这个大锁里的小锁。
递归锁实例:
from threading import RLock, Thread, Lock
import time
mutex_a = mutex_b = RLock() # 定义一个递归锁,当前这个递归锁包含 锁a 和锁b , 使用锁a或者锁b的任意一个就是在使用递归锁。
class MyThread(Thread):
# 线程执行任务
def run(self):
self.func1()
self.func2()
def func1(self):
mutex_a.acquire()
# print(f'用户{current_thread().name}抢到锁a')
print(f'用户{self.name}抢到锁a')
mutex_b.acquire()
print(f'用户{self.name}抢到锁b')
mutex_b.release()
print(f'用户{self.name}释放锁b')
mutex_a.release()
print(f'用户{self.name}释放锁a')
def func2(self):
mutex_b.acquire()
print(f'用户{self.name}抢到锁b')
# IO操作
time.sleep(1)
mutex_a.acquire()
print(f'用户{self.name}抢到锁a')
mutex_a.release()
print(f'用户{self.name}释放锁a')
mutex_b.release()
print(f'用户{self.name}释放锁b')
for line in range(10):
t = MyThread()
t.start()
4. 使用多线程实现服务端并发
- 多进程也可以实现,只需要注意主进程在创建子进程时的循环导入问题。
# 服务端
import socket
import time
from threading import Thread
server = socket.socket()
server.bind(
('127.0.0.1', 9527)
)
server.listen(5)
print('启动服务端...')
# 线程任务,执行接收客户端消息与发送消息给客户端
def working(conn):
while True:
try:
data = conn.recv(1024)
if len(data) == 0:
break
print(data)
time.sleep(1)
conn.send(data.upper())
except Exception as e:
print(e)
break
conn.close()
while True:
conn, addr = server.accept()
print(addr)
t = Thread(target=working, args=(conn, ))
t.start()
# 客户端
import socket
import time
client = socket.socket()
client.connect(
('127.0.0.1', 9527)
)
print('启动客户端...')
while True:
client.send(b'hello')
data = client.recv(1024)
print(data)
time.sleep(1)
十三、队列
- 队列的出现解决了进程与进程之间的通信时数据的供需矛盾。队列里的数据被取一次,该数据就会被回收
创建队列的方法
from multiprocessing import Queue # 方式一
import queue # 方式二 这是python自带的库
# Queue 和 queue 的使用方法基本一致
# 实例化一个队列对象
q = Queue() # 方式一
q = queue.Queue() # 方式二
1.队列的方法
-
put
和put_nowait
,get
和get_nowait
。put
和put_nowait
会把数据放进队列中,如果队列满了,put
方法会hold 住,就是代码在这里会卡主,直到队列有空位置可以把数据放进去。而put_nowait
如果队列满了,则会直接报错get
和get_nowait
会把数据从队列中取出来。如果队列空了,get
方法会hold 住,就是代码在这里会卡主,直到队列有数据取。而get_nowait
如果队列空了,则会直接报错 -
empty()
和full()
方法前者判断队列是否为空,后者判断队列是否满了
-
queue队列:使用
import queue
,用法与进程Queue一样q = queue.Queue()
,括号里填整数,是几就可以放几次数据。不填则默认可以无限放。(放的越多内存占的越多)
2. 先进先出队列(FIFO Queue)
import queue
q=queue.Queue() # 重点是调用的类不同
q.put('first')
q.put('second')
q.put('third')
print(q.get())
print(q.get())
print(q.get())
'''
结果(先进先出):
first
second
third
'''
3. 后进先出队列(LIFO Queue)
import queue
q=queue.LifoQueue() # 重点是调用的类不同
q.put('first')
q.put('second')
q.put('third')
print(q.get())
print(q.get())
print(q.get())
'''
结果(后进先出):
third
second
first
'''
4. 优先级队列(Priority Queue)
-
这种队列的优先级判断应该是根据
utf-8
对每个字符的编码的二进制数大小的比较。越小的优先级越高。 -
具体流程:
首先把每个
put
后的元组里的元素拼接成字符串,上下对齐后,再按照从左到右的顺序一个字符一个字符比较。就是字符串之间的比较方法。
import queue
q=queue.PriorityQueue()
#put进入一个元组,元组的第一个元素是优先级(通常是数字,也可以是非数字之间的比较),数字越小优先级越高
q.put((20,'a'))
q.put((10,'b'))
q.put((30,'c'))
print(q.get())
print(q.get())
print(q.get())
'''
结果(数字越小优先级越高,优先级高的优先出队):
(10, 'b')
(20, 'a')
(30, 'c')
'''
十四、IPC(Inter-Process Communication 进程间通信)
- 通过队列作为数据的一个中转站。产生的数据放在队列里,使用数据从队列里取。队列里的数据被取一次,该数据就会被回收。
实例:
from multiprocessing import Process
from multiprocessing import Queue
def test1(q):
data = '数据hello'
q.put(data)
print('进程1开始添加数据到队列中..')
def test2(q):
data = q.get()
print(f'进程2从队列中获取数据{data}')
if __name__ == '__main__':
q = Queue()
p1 = Process(target=test1, args=(q, ))
p2 = Process(target=test2, args=(q, ))
p1.start()
p2.start()
print('主')
十五、GIL(全局解释器锁)
1. python解释器的分类
- CPython :源码用C语言编写 , 有GIL,我们一般使用的都是这个解释器
- JPython : 源码用java语言编写, 没有GIL
- PPython: 源码用python语言编写
2. 什么是GIL,GIL的作用
- 因为我们现在一般使用的都是CPython,所以现在是基于CPython来讨论GIL
- GIL本质上就是一个互斥锁,最初就是一个防止多线程并发执行机器码的一个Mutex
- 首先需要明确的一点是GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念 , GIL并不是Python的特性,Python完全可以不依赖于GIL 。
- GIL是CPython解释器的一个大锁,因为线程是最小的执行单位,相当于是与CPU直接交互的东西,而GIL是让Cpython解释器解释代码生成的线程,一个一个的出来,再与CPU交互。
因为这把大锁的存在:导致在多核的情况下,进行密集计算时,多进程效率高,进行密集io时,多线程效率高。
3. 为什么会有GIL
由于物理上得限制,各CPU厂商在核心频率上的比赛已经被多核所取代。为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。即使在CPU内部的Cache也不例外,为了有效解决多份缓存之间的数据同步时各厂商花费了不少心思,也不可避免的带来了一定的性能损失。
Python当然也逃不开,为了利用多核,Python开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁。 于是有了GIL这把超级大锁,而当越来越多的代码库开发者接受了这种设定后,他们开始大量依赖这种特性(即默认python内部对象是thread-safe的,无需在实现时考虑额外的内存锁和同步操作)。
慢慢的这种实现方式被发现是蛋疼且低效的。但当大家试图去拆分和去除GIL的时候,发现大量库代码开发者已经重度依赖GIL而非常难以去除了。有多难?做个类比,像MySQL这样的“小项目”为了把Buffer Pool Mutex这把大锁拆分成各个小锁也花了从5.5到5.6再到5.7多个大版为期近5年的时间,本且仍在继续。MySQL这个背后有公司支持且有固定开发团队的产品走的如此艰难,那又更何况Python这样核心开发和代码贡献者高度社区化的团队呢?
所以简单的说GIL的存在更多的是历史原因。如果推到重来,多线程的问题依然还是要面对,但是至少会比目前GIL这种方式会更优雅。
4. 解决进程之间争抢GIL的问题
-
用multiprocess 里的 Process替代Thread
multiprocess库的出现很大程度上是为了弥补thread库因为GIL而低效的缺陷。它完整的复制了一套thread所提供的接口方便迁移。唯一的不同就是它使用了多进程而不是多线程。每个进程有自己的独立的GIL,因此也不会出现进程之间的GIL争抢。
当然multiprocess也不是万能良药。它的引入会增加程序实现时线程间数据通讯和同步的困难。就拿计数器来举例子,如果我们要多个线程累加同一个变量,对于thread来说,申明一个global变量,用thread.Lock的context包裹住三行就搞定了。而multiprocess由于进程之间无法看到对方的数据,只能通过在主线程申明一个Queue,put再get或者用share memory的方法。这个额外的实现成本使得本来就非常痛苦的多线程程序编码,变得更加痛苦了
-
用其他解析器
之前也提到了既然GIL只是CPython的产物,那么其他解析器是不是更好呢?没错,像JPython和IronPython这样的解析器由于实现语言的特性,他们不需要GIL的帮助。然而由于用了Java/C#用于解析器实现,他们也失去了利用社区众多C语言模块有用特性的机会。所以这些解析器也因此一直都比较小众。毕竟功能和性能大家在初期都会选择前者,Done is better than perfect。
-
所以没救了么?
当然Python社区也在非常努力的不断改进GIL,甚至是尝试去除GIL。并在各个小版本中有了不少的进步