玩转Python多进程和多线程,你学废了吗

前言

进程(process)和线程(thread)是非常抽象的概念, 也是程序员必需掌握的核心知识。关于多进程和多线程,教科书上最经典的一句话是“进程是资源分配的最小单位,线程是CPU调度的最小单位”。
这样一句话虽然简单,但是在实际的开发当中可没有这一句话这么简单,毕竟多进程和多线程编程对于代码的并发执行,提升代码效率和缩短运行时间至关重要。接下来将介绍如何使用python的multiprocess(进程)threading(线程) 模块进行多线程和多进程编程。
在这里插入图片描述

1.什么是进程和线程

这里引用阮一峰的博客进行解释:

  1. 计算机的核心是CPU,它承担了所有的计算任务。它就像一座工厂,时刻在运行
  2. 假定工厂的电力有限,一次只能供给一个车间使用。也就是说,一个车间开工的时候,其他车间都必须停工。背后的含义就是:单个CPU一次只能运行一个任务
  3. 进程就好比工厂的车间,它代表CPU所能处理的单个任务。任一时刻,CPU总是运行一个进程,其他进程处于非运行状态
  4. 线程就好比车间里的工人,一个车间里,可以有很多工人,他们协同完成一个任务,所以一个进程可以包括多个线程
  5. 车间的空间是工人们共享的,比如许多房间是每个工人都可以进出的。这象征一个进程的内存空间是共享的,每个线程都可以使用这些共享内存
  6. 可是,每间房间的大小不同,有些房间最多只能容纳一个人,比如厕所。里面有人的时候,其他人就不能进去了。这代表一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存
  7. 一个防止他人进入的简单方法,就是门口加一把锁。先到的人锁上门,后到的人看到上锁,就在门口排队,等锁打开再进去。这就叫"互斥锁"(Mutual exclusion,缩写 Mutex),防止多个线程同时读写某一块内存区域
  8. 还有些房间,可以同时容纳n个人,比如厨房。也就是说,如果人数大于n,多出来的人只能在外面等着。这好比某些内存区域,只能供给固定数目的线程使用
  9. 这时的解决方法,就是在门口挂n把钥匙。进去的人就取一把钥匙,出来时再把钥匙挂回原处。后到的人发现钥匙架空了,就知道必须在门口排队等着了。这种做法叫做"信号量"(Semaphore),用来保证多个线程不会互相冲突
  10. 不难看出,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中时,如果池还没有满,就会创建一个新的进程来执行请求。如果池满,请求就会告知先等待,直到池中有进程结束,才会创建新的进程来执行这些请求。主要方法介绍如下:
1apply()
	函数原型:apply(func[, args=()[, kwds={}]])
	该函数用于传递不定参数,同python中的apply函数一致,主进程会被阻塞直到函数执行结束(不建议使用,并且3.x以后不在出现)。

2、apply_async
  函数原型:apply_async(func[, args=()[, kwds={}[, callback=None]]])
  与apply用法一致,但它是非阻塞的且支持结果返回后进行回调

3map()
  函数原型:map(func, iterable[, chunksize=None])
  Pool类中的map方法,与内置的map函数用法行为基本一致,它会使进程阻塞直到结果返回。 
  注意:虽然第二个参数是一个迭代器,但在实际使用中,必须在整个队列都就绪后,程序才会运行子进程

4map_async()
  函数原型:map_async(func, iterable[, chunksize[, callback]])
  与map用法一致,但是它是非阻塞的。其有关事项见apply_async
  
5close()
  关闭进程池(pool),使其不在接受新的任务

6terminal()
  结束工作进程,不在处理未处理的任务

7join()
  主进程阻塞等待子进程的退出, 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问题的方案

  1. 使用其它语言,例如C,Java

  2. 使用其它解释器,如java的解释器jython

  3. 使用多进程

4.总结

由于GIL的存在,很多人认为Python多进程编程更快,针对多核CPU,理论上来说也是采用多进程更能有效利用资源。网上很多人已做过比较,直接告诉你结论:

  • 对CPU密集型代码(比如循环计算) - 多进程效率更高
  • 对IO密集型代码(比如文件操作,网络爬虫) - 多线程效率更高

为什么是这样呢?对于IO密集型操作,大部分消耗时间其实是等待时间,在等待时间中CPU是不需要工作的,那你在此期间提供双CPU资源也是利用不上的,相反对于CPU密集型代码,2个CPU干活肯定比一个CPU快很多。那么为什么多线程会对IO密集型代码有用呢?这时因为python碰到等待会释放GIL供新的线程使用,实现了线程间的切换。

参考地址

  1. 一文看懂Python多进程与多线程编程(工作学习面试必读)
  2. 维基百科-全局解释器锁
  3. Python GIL全局解释器锁详解(深度剖析)

在这里插入图片描述

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值