前言
进程(process)和线程(thread)是非常抽象的概念, 也是程序员必需掌握的核心知识。关于多进程和多线程,教科书上最经典的一句话是“进程是资源分配的最小单位,线程是CPU调度的最小单位”。
这样一句话虽然简单,但是在实际的开发当中可没有这一句话这么简单,毕竟多进程和多线程编程对于代码的并发执行,提升代码效率和缩短运行时间至关重要。接下来将介绍如何使用python的multiprocess(进程) 和 threading(线程) 模块进行多线程和多进程编程。
1.什么是进程和线程
这里引用阮一峰的博客进行解释:
- 计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行
- 假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是:单个CPU一次只能运行一个任务
- 进程就好比工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态
- 线程就好比车间里的工人,一个车间里,可以有很多工人,他们协同完成一个任务,所以一个进程可以包括多个线程
- 车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存
- 可是,每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存
- 一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域
- 还有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用
- 这时的解决方法,就是在门口挂n把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做"信号量"(Semaphore),用来保证多个线程不会互相冲突
- 不难看出,mutex是semaphore的一种特殊情况(n=1时)。也就是说,完全可以用后者替代前者。但是,因为mutex较为简单,且效率高,所以在必须保证资源独占的情况下,还是采用这种设计 。
操作系统的设计,因此可以归结为三点:
- 以多进程形式,允许多个任务同时运行
- 以多线程形式,允许单个任务分成不同的部分运行
- 提供协调机制,一方面防止进程之间和线程之间产生冲突,另一方面允许进程之间和线程之间共享资源
2. 基本使用
multiprocessing模块
这里先看一下普通代码和多进程代码执行速度的差异:首先定义两个方法dance()
and sing()
它们功能是都循化打印自己的方法名,每次循环间隔0.5s,如下所示:
# encoding: utf-8
import time
import multiprocessing as multipro
def dance():
for i in range(3):
print("dance...")
time.sleep(0.5)
def sing():
for i in range(3):
print("sing...")
time.sleep(0.5)
if __name__ == "__main__":
start_time = time.time()
# 设置daemon为True,当主进程结束后,子进程直接结束,大家可以试试效果!!
# dance_process = multipro.Process(target=dance,daemon=True)
dance_process = multipro.Process(target=dance)
sing_process = multipro.Process(target=sing)
dance_process.start()
sing_process.start()
dance_process.join()
sing_process.join()
end_time = time.time()
process_time = end_time - start_time
print('程序运行时间{}s'.format(str(process_time)))
运行输出如下
dance...
sing...
dance...
sing...
dance...
sing...
程序运行时间1.932910442352295s
Process finished with exit code 0
根据二者运行时间我们可以直观的看出多进程带给程序的优势,但是新创建的进程与进程的切换都是要耗资源的,所以平时工作中进程数不能开太大,同时可以运行的进程数一般受制于CPU的核数。
通过使用Process类我们创建了两个进程,那接下来就介绍这个类的使用
class Process():
name: str
daemon: bool
pid: Optional[int]
exitcode: Optional[int]
authkey: bytes
sentinel: int
# TODO: set type of group to None
def __init__(self,
group: Any = ...,
target: Optional[Callable] = ...,
name: Optional[str] = ...,
args: Iterable[Any] = ...,
kwargs: Mapping[Any, Any] = ...,
daemon: Optional[bool] = ...) -> None: ...
def start(self) -> None: ...
def run(self) -> None: ...
def terminate(self) -> None: ...
def is_alive(self) -> bool: ...
def join(self, timeout: Optional[float] = ...) -> None: ...
属性详情
属性 | 描述 |
---|---|
name | 进程的名字 |
pid | 进程的pid |
daemon | 默认值为False,如果设为True,代表p为后台运行的守护进程,当p的父进程终止时,p也随之终止,并且设定为True后,p不能创建自己的新进程,必须在p.start()之前设置 |
group | 参数未使用,值始终为None |
target | 表示调用对象,即子进程要执行的任务 |
args | 表示target调用方法的参数元组,args=(1,2,'a',) |
kwargs | 表示target调用方法的参数字典,kwargs={'name':'mike','age':18} |
方法详情 | |
方法名 | 描述 |
– | – |
start() | 启动进程,并调用该子进程中的run() |
run() | 进程启动时运行的方法,正是它去调用target指定的函数,我们自定义类的类中一定要实现该方法 |
terminate() | 强制终止进程p,不会进行任何清理操作,如果p创建了子进程,该子进程就成了僵尸进程,使用该方法需要特别小心这种情况。如果p还保存了一个锁那么也将不会被释放,进而导致死锁 |
is_alive() | 如果p仍然运行,返回True |
join([timeout]) | 主进程等待子进程终止,timeout可选超时时间 |
除了使用Process方法,我们还可以使用multiprocessing.Pool类创建多进程 | |
Pool类可以提供指定数量的进程供用户调用,当有新的请求提交到Pool中时,如果池还没有满,就会创建一个新的进程来执行请求。如果池满,请求就会告知先等待,直到池中有进程结束,才会创建新的进程来执行这些请求。主要方法介绍如下: |
1、apply()
函数原型:apply(func[, args=()[, kwds={}]])
该函数用于传递不定参数,同python中的apply函数一致,主进程会被阻塞直到函数执行结束(不建议使用,并且3.x以后不在出现)。
2、apply_async
函数原型:apply_async(func[, args=()[, kwds={}[, callback=None]]])
与apply用法一致,但它是非阻塞的且支持结果返回后进行回调
3、map()
函数原型:map(func, iterable[, chunksize=None])
Pool类中的map方法,与内置的map函数用法行为基本一致,它会使进程阻塞直到结果返回。
注意:虽然第二个参数是一个迭代器,但在实际使用中,必须在整个队列都就绪后,程序才会运行子进程
4、map_async()
函数原型:map_async(func, iterable[, chunksize[, callback]])
与map用法一致,但是它是非阻塞的。其有关事项见apply_async
5、close()
关闭进程池(pool),使其不在接受新的任务
6、terminal()
结束工作进程,不在处理未处理的任务
7、join()
主进程阻塞等待子进程的退出, join方法要在close或terminate之后使用
演示代码如下
# encoding: utf-8
import time
import os
import multiprocessing as multipro
def dance(i):
print("{}进程dance()pid:{}".format(str(i),str(os.getpid())))
for i in range(3):
print("I am dance...")
time.sleep(0.5)
def sing(i):
print("{}进程sing()pid:{}".format(str(i),str(os.getpid())))
for i in range(3):
print("I am sing...")
time.sleep(0.5)
if __name__ == "__main__":
start_time = time.time()
func_name = [dance,sing]
cpu_num = multipro.cpu_count()
print("当前电脑cpu是%s核"%cpu_num)
pool = multipro.Pool(3)
for i in range(5):
pool.apply_async(func_name[i % 2],args=(i+1,))
pool.close()
pool.join()
end_time = time.time()
process_time = end_time - start_time
print('程序运行时间{}s'.format(str(process_time)))
结果输出
当前电脑cpu是8核
1进程dance()pid:2172
I am dance...
2进程sing()pid:8524
I am sing...
3进程dance()pid:13240
I am dance...
I am dance...
I am sing...
I am dance...
I am dance...
I am sing...
I am dance...
4进程sing()pid:2172
I am sing...
5进程dance()pid:8524
I am dance...
I am sing...
I am dance...
I am sing...
I am dance...
程序运行时间3.4771392345428467s
Process finished with exit code 0
对Pool对象调用join()方法会等待所有子进程执行完毕,调用join()之前必须先调用close()或terminate()方法,让其不再接受新的Process了,此外multiprocessing 中有一个cpu_count()
方法返回当前电脑cpu核数。这里我开启了一个容量为3的进程池。3个进程需要计算5次,当3个进程并行3次计算任务后,还剩2次计算任务没有完成,系统会等待3个进程完成后重新安排一个进程来计算。当然这里的进程池的容量根据个人电脑配置来决定,配置越高可设置进程越多(不怕电脑卡死的😂)。每个定义的方法大约执行1.5s,循环5次下来节省了一半的时间,把线程池容量变大也可以节省程序运行的时间,这个自己可以试试。
多进程间的数据共享与通信
通常,进程之间是相互独立的,每个进程都有独立的内存。通过共享内存(nmap模块),进程之间可以共享对象,使多个进程可以访问同一个变量(地址相同,变量名可能不同)。多进程共享资源必然会导致进程间相互竞争,所以应该尽最大可能防止使用共享状态。还有一种方式就是使用队列queue来实现不同进程间的通信或数据共享,这一点和多线程编程类似。下例这段代码中中创建了2个独立进程,一个负责写, 一个负责读, 实现了共享一个队列queue:
# encoding: utf-8
import time
import os
import multiprocessing as multipro
def write(q):
print("write()pid:{}".format(str(os.getpid())))
for i in range(3):
q.put(i)
time.sleep(0.5)
def read(q):
print("read()pid:{}".format(str(os.getpid())))
# 判断队列是否为空,为空则停止进程
while not q.empty():
value = q.get()
print('Get %s from queue.' % value)
if __name__ == "__main__":
start_time = time.time()
queue = multipro.Queue()
pw = multipro.Process(target=write, args=(queue,))
pr = multipro.Process(target=read, args=(queue,))
# 启动子进程
pw.start()
pr.start()
# 等待子进程结束
pw.join()
pr.join()
end_time = time.time()
process_time = end_time - start_time
print('程序运行时间{}s'.format(str(process_time)))
运行结果
write()pid:12484
read()pid:8856
Get 0 from queue.
Get 1 from queue.
Get 2 from queue.
程序运行时间0.8372223377227783s
Process finished with exit code 0
threading模块
Python多线程的实现是依据threading模块,在掌握了上一个multiprocessing模块后,threading模块也变得易于学习,threading模块对象:
对象 | 描述 |
---|---|
Thread | 表示一个执行线程的对象 |
Lock | 锁原语对象 |
RLock | 可重入锁对象,使单一线程可以(再次)获得已持有的锁(递归锁) |
Condition | 条件变量对象,使得一个线程等待另一个线程满足特定的“条件”,比如改变状态或某个数据值 |
Event | 条件变量的通用版本,任意数量的线程等待某个事件的发生,在该事件发生后所有线程将被激活 |
Semaphore | 为线程间共享的有限资源提供了一个“计数器”,如果没有可用资源时会被阻塞 |
BoundedSemaphore | 与 Semaphore 相似,不过它不允许超过初始值 |
Timer | 与 Thread 相似,不过它要在运行前等待一段时间 |
Barrier | 创建一个障碍,必须达到指定数量的线程才可以继续 |
和Process
类一样,这里使用Thread
创建多进程:
属性详情
Thread 对象数据属性 | 描述 |
---|---|
name | 线程名 |
ident | 线程的标识符 |
daemon | 布尔标志,表示这个线程是否是守护线程 |
方法详情
Thread 对象方法 | 描述 |
---|---|
__init__(group=None, tatget=None,args=(), kwargs ={}, verbose=None, daemon=None) | 实例化一个线程对象,需要有一个可调用的 target,以及其参数 args或 kwargs。还可以传递 name 或 group 参数,不过后者还未实现。此外 , verbose 标 志 也 是 可 接 受 的。 而 daemon 的 值 将 会 设定thread.daemon 属性/标志 |
start() | 开始执行该线程 |
run() | 定义线程功能的方法(通常在子类中被应用开发者重写) |
join (timeout=None) | 直至启动的线程终止之前一直挂起;除非给出了 timeout(秒),否则会一直阻塞 |
getName() | 返回线程名 |
setName (name) | 设定线程名 |
isAlivel /is_alive () | 布尔标志,表示这个线程是否还存活 |
isDaemon() | 如果是守护线程,则返回 True;否则,返回 False |
setDaemon(daemonic) | 把线程的守护标志设定为布尔值 daemonic(必须在线程 start()之前调用) |
实例
# encoding: utf-8
import time
from threading import Thread
def dance(num):
for i in range(num):
print("dance...")
time.sleep(0.5)
def sing(num):
for i in range(num):
print("sing...")
time.sleep(0.5)
if __name__ == "__main__":
start_time = time.time()
'''
和Process的daemon属性一样,线程的daemon也是设=T置守护进程的
dance_thread = Thread(target=dance, args=(3,),daemonrue)
dance_thread.setDaemon(True)这个方法和上面的同理,不过要在start()之前设置
'''
dance_thread = Thread(target=dance,args=(3,))
sing_thread = Thread(target=sing,kwargs={"num":3})
dance_thread.start()
sing_thread.start()
# 设置daemon属性就不要设置join()了
dance_thread.join()
sing_thread.join()
end_time = time.time()
process_time = end_time - start_time
print('程序运行时间{}s'.format(str(process_time)))
运行结果
dance...
sing...
sing...dance...
sing...
dance...
程序运行时间1.5016872882843018s
Process finished with exit code 0
根据运行的结果可以看出多线程(多进程)运行是无序的,换行符还没打出来就把另一个结果先输出。当我们设置多线程时,主线程会创建多个子线程,在python中,默认情况下主线程和子线程独立运行互不干涉。如果希望让主线程等待子线程实现线程的同步,我们需要使用join()
方法。如果我们希望一个主线程结束时不再执行子线程, 我们可以设置daemon
属性。
除了使用Thread()方法创建新的线程外,我们还可以通过继承Thread类重写run()
创建新的线程,这种方法更灵活。下例中我们自定义的类为MyThread,随后建立两个线程运行:
# encoding: utf-8
import time
from threading import Thread
def dance(num):
for i in range(num):
print("dance...")
time.sleep(0.5)
def sing(num):
for i in range(num):
print("sing...")
time.sleep(0.5)
class MyThread(Thread):
def __init__(self, func, args, name='', ):
Thread.__init__(self)
self.func = func
self.args = args
self.name = name
self.result = None
def run(self):
self.func(*self.args)
print("这是run()……")
if __name__ == "__main__":
start_time = time.time()
my_dance = MyThread(func=dance,args=(3,))
my_sing = MyThread(func=sing,args=(3,))
my_dance.start()
my_sing.start()
my_dance.join()
my_sing.join()
end_time = time.time()
process_time = end_time - start_time
print('程序运行时间{}s'.format(str(process_time)))
运行结果
dance...
sing...
sing...
dance...
dance...
sing...
这是run()……
这是run()……
程序运行时间1.5025339126586914s
Process finished with exit code 0
线程间的数据共享
一个进程所含的不同线程间共享内存,这就意味着任何一个变量都可以被任何一个线程修改,因此线程之间共享数据最大的危险在于多个线程同时改一个变量,把内容给改乱了。如果不同线程间有共享的变量,其中一个方法就是在修改前给其上一把锁lock,确保一次只有一个线程能修改它。这和MySQL中的加锁是一致的。threading.lock()方法可以轻易实现对一个共享变量的锁定,修改完后release供其它线程使用。比如下例中number是一个共享变量,使用lock可以使其不被改乱:
# encoding: utf-8
import time
from threading import Thread,Lock
class Data():
lock = None
def __init__(self,lock):
self.lock = lock
self.number = 0
def add(self):
# 获取锁
self.lock.acquire()
for i in range(10):
self.number += 2
# 释放锁
self.lock.release()
def subtracte(self):
# 获取锁
self.lock.acquire()
for i in range(10):
self.number -= 3
# 释放锁
self.lock.release()
if __name__ == '__main__':
lock = Lock()
data = Data(lock=lock)
add_thread = Thread(target=data.add)
sub_thread = Thread(target=data.subtracte)
add_thread.start()
sub_thread.start()
add_thread.join()
sub_thread.join()
print("number =", data.number)
# number = -10
这里提出一个问题,如果把self.lock.acquire()
和self.lock.acquire()
注释掉,那么number的值还是-10吗?
另一种实现不同线程间数据共享的方法就是使用消息队列queue。不像列表,queue是线程安全的,可以放心使用:
from queue import Queue
from threading import Thread, Lock
class Data():
lock = None
queue = None
def __init__(self, lock, queue):
self.lock = lock
self.queue = queue
def add(self):
self.lock.acquire()
for i in "I like Python".split(" "):
self.queue.put(i)
self.lock.release()
def subtracte(self):
self.lock.acquire()
for i in range(3):
value = self.queue.get()
print("Get value:", value)
self.lock.release()
if __name__ == '__main__':
lock = Lock()
q = Queue()
data = Data(lock=lock, queue=q)
add_thread = Thread(target=data.add)
sub_thread = Thread(target=data.subtracte)
add_thread.start()
sub_thread.start()
add_thread.join()
sub_thread.join()
print('All threads finished!')
- 队列Queue的put()方法可以将一个对象obj放入队列中。如果队列已满,此方法将阻塞至队列有空间可用为止
- Queue的get()方法一次返回队列中的一个成员。如果队列为空,此方法将阻塞至队列中有成员可用为止。Queue同时还自带emtpy(), full()等方法来判断一个队列是否为空或已满
- 但是这些方法并不可靠,因为多线程和多进程,在返回结果和使用结果之间,队列中可能添加/删除了成员。所以在多进程数据共享中的read() 方法中的
while not q.empty():
严格来说并不安全
3.GIL全局解释器锁
除了之前学习的多进程和多线程,Python 多线程还有一个很重要的知识点,就是GIL 。
全局解释器锁(英语:Global Interpreter Lock,缩写GIL),是计算机程序设计语言解释器用于同步线程的一种机制,它使得任何时刻仅有一个线程在执行。即便在多核心处理器上,使用 GIL 的解释器也只允许同一时间执行一个线程。常见的使用 GIL 的解释器有CPython与Ruby MRI
—— 维基百科
我们要知道的一点是它并不是python语言的特性,仅仅是由于历史的原因在CPython解释器中难以移除。接下来简单做个小实验:
import time
from threading import Thread
def CountDown(n):
while n > 0:
n -= 1
start = time.time()
CountDown(100000)
print("Time used:", (time.time() - start))
'''
多线程运行
'''
def ThreadCountDown(n):
while n > 0:
n -= 1
start = time.time()
t1 = Thread(target=ThreadCountDown, args=[100000 // 2])
t2 = Thread(target=ThreadCountDown, args=[100000 // 2])
t1.start()
t2.start()
t1.join()
t2.join()
print("Thread Time used:", (time.time() - start))
运行结果如下:
Time used: 0.005013227462768555
Thread Time used: 0.0060160160064697266
Process finished with exit code 0
- 根据结果我们可以看到,多线程运行效率非但没有提高,反而降低了。是不是和你猜想的结果不一样?事实上,得到这样的结果是肯定的,因为 GIL 限制了 Python 多线程的性能不会像我们预期的那样
- 根据维基百科我们可以得到,GIL 是 CPython 解释器(平常称为 Python)中的一个技术术语,中文译为全局解释器锁,其本质上类似操作系统的 Mutex。GIL 的功能是:在 CPython 解释器中执行的每一个 Python 线程,都会先锁住自己,以阻止别的线程执行
- 当然,CPython 不可能容忍一个线程一直独占解释器,它会轮流执行 Python 线程。这样一来,用户看到的就是“伪”并行,即 Python 线程在交替执行,来模拟真正并行的线程
为什么使用CIL
既然 CPython 能控制线程伪并行,为什么还需要 GIL 呢?其实,这和 CPython 的底层内存管理有关:
CPython 使用引用计数来管理内容,所有 Python 脚本中创建的实例,都会配备一个引用计数,来记录有多少个指针来指向它。当实例的引用计数的值为 0 时,会自动释放其所占的内存。
import sys
a = []
b = a
c = sys.getrefcount(a)
print(c)
# 3
可以看到,a 的引用计数值为 3,因为有 a、b 和作为参数传递的 getrefcount 都引用了一个空列表。
假设有两个 Python 线程同时引用 a,那么双方就都会尝试操作该数据,很有可能造成引用计数的条件竞争,导致引用计数只增加 1(实际应增加 2),这造成的后果是,当第一个线程结束时,会把引用计数减少 1,此时可能已经达到释放内存的条件(引用计数为 0),当第 2 个线程再次视图访问 a 时,就无法找到有效的内存了。
所以,CPython 引进 GIL,可以最大程度上规避类似内存管理这样复杂的竞争风险问题。
GIL底层实现原理
上面这张图,就是 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。
为什么 Python 线程会去主动释放 GIL 呢?毕竟,如果仅仅要求 Python 线程在开始执行时锁住 GIL,且永远不去释放 GIL,那别的线程就都没有运行的机会。其实,CPython 中还有另一个机制,叫做间隔式检查(check_interval),意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况,每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。
- 在单核CPU上,数百次的间隔检查才会导致一次线程切换。在多核CPU上,存在严重的线程颠簸(thrashing)
- Python 3.2开始使用新的GIL。新的GIL实现中用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁时,当前线程就会在5毫秒后被强制释放该锁
我们不必细究具体多久会强制释放 GIL,我们只需要明白,CPython 解释器会在一个“合理”的时间范围内释放 GIL 就可以了
解决GIL问题的方案
-
使用其它语言,例如C,Java
-
使用其它解释器,如java的解释器jython
-
使用多进程
4.总结
由于GIL的存在,很多人认为Python多进程编程更快,针对多核CPU,理论上来说也是采用多进程更能有效利用资源。网上很多人已做过比较,直接告诉你结论:
- 对CPU密集型代码(比如循环计算) - 多进程效率更高
- 对IO密集型代码(比如文件操作,网络爬虫) - 多线程效率更高
为什么是这样呢?对于IO密集型操作,大部分消耗时间其实是等待时间,在等待时间中CPU是不需要工作的,那你在此期间提供双CPU资源也是利用不上的,相反对于CPU密集型代码,2个CPU干活肯定比一个CPU快很多。那么为什么多线程会对IO密集型代码有用呢?这时因为python碰到等待会释放GIL供新的线程使用,实现了线程间的切换。