消息队列、IPC机制、线程介绍、理论、GIL全局解释器锁

今日学习内容总结

      在昨日的学习中,我们已经学习了通过代码实现进程的方式,那就是process模块。对该模块的语法,以及内置方法进行了一个学习。而今天的主要学习内容主要是了解线程。

消息队列

内置队列

      在之前的学习中我们提到过队列的特点:先进先出。而python中自带的队列模块有四种:

  1. FIFO: 先进先出队列
  2. LifoQueue: 先进后出队列
  3. PriorityQueue: 优先队列
  4. deque: 双端队列

      使用方式:

  from queue import Queue
   
  # maxsize设置队列中,数据上限,小于或等于0则不限制,容器中大于这个数则阻塞,直到队列中的数据被消掉
  q = Queue(maxsize=0)

      成员函数:

  1. Queue.qsize() 返回队列的大致大小。
  2. Queue.empty() 如果队列为空,返回 True 否则返回 False
  3. Queue.full() 如果队列是满的返回 True ,否则返回 False 
  4. Queue.put(item, block=True, timeout=None)
      4.1 常用时忽略默认参数,即使用 Queue.put(item)。
      4.2 将 item 放入队列,如果可选参数 block 是 true 并且 timeout 是 None (默认),则在必要时阻塞至有空闲插槽可用。
      4.3 如果 timeout 是个正数,将最多阻塞 timeout 秒,如果在这段时间没有可用的空闲插槽,将引发 Full 异常。
      4.4 反之 (block 是 false),如果空闲插槽立即可用,则把 item 放入队列,否则引发 Full 异常 ( 在这种情况下,timeout 将被忽略)。
  5. Queue.get(block=True, timeout=None)
      5.1 常用时忽略默认参数,即使用 Queue.get()。
      5.2 从队列中移除并返回一个项目。如果可选参数 block 是 true 并且 timeout 是 None (默认值),则在必要时阻塞至项目可得到。
      5.3 如果 timeout 是个正数,将最多阻塞 timeout 秒,如果在这段时间内项目不能得到,将引发 Empty 异常。反之 (block 是 false),如果一个项目立即可得到,则返回一个项目,否则引发 Empty 异常 (这种情况下,timeout 将被忽略)。 

      使用案例:

from multiprocessing import Queue


q = Queue(5)  # 自定义队列的长度
# 朝队列中存放数据
  q.put(111)
  q.put(222)
  q.put(333)
  print(q.full())  # False  判断队列是否满了
  q.put(444)
  q.put(555)
  print(q.full())  # True
  q.put(666)  # 超出最大长度 原地阻塞等待队列中出现空位
  print(q.get())
  print(q.get())
  print(q.empty())  # False  判断队列是否空了
  print(q.get())
  print(q.get())
  print(q.get())
  print(q.empty())  # True
  print(q.get())  # 队列中没有值 继续获取则阻塞等待队列中给值
  print(q.get_nowait())  # 队列中如果没有值 直接报错

      full()、empty()、get_nowait()这些方法不能再并发的场景下精确使用,之所以介绍队列是因为它可以支持进程间数据通信。

IPC机制

      IPC机制就是进程间的数据交互,可以是主进程与子进程数据交互,也可以是子进程间的数据交互。其实就是在不同内存空间中的进程数据交互。实例:

  from multiprocessing import Process, Queue

  def producer(q):
      # print('子进程producer从队列中取值>>>:', q.get())
      q.put('子进程producer往队列中添加值')

  def consumer(q):
      print('子进程consumer从队列中取值>>>:', q.get())


  if __name__ == '__main__':
      q = Queue()
      p = Process(target=producer, args=(q, ))
      p1 = Process(target=consumer, args=(q,))
      p.start()
      p1.start()
      q.put(123)  # 主进程往队列中存放数据123
      print('主进程')

  # 打印结果
  '''
    # 当往主线程队列中存放数据时
    主进程
    子进程consumer从队列中取值>>>: 123

    # 当没有往主线程队列中存放数据时(就是将q.put(123)注释掉)
    主进程
    子进程consumer从队列中取值>>>: 子进程producer往队列中添加值
  '''

线程

生产者消费者模型

      在并发编程中使用生产者和消费者模式能够解决绝大多数并发问题。该模式通过平衡生产线程和消费线程的工作能力来提高程序的整体处理数据的速度。

      什么是生产者消费者模式呢:

  1. 产生数据的模块称为生产者。
  2. 处理数据的模块称为消费者。
  3. 在生产者与消费者之间的缓冲区称之为仓库。
  4. 生产者负责往仓库运输商品,而消费者负责从仓库里取出商品,这就构成了生产者消费者模式。

      生产者消费者模型

      生产者消费者模式的优点:

      1.解耦

      假设生产者和消费者分别是两个线程。如果让生产者直接调用消费者的某个方法,那么生产者对于消费者就会产生依赖(也就是耦合)。如果未来消费者的代码发生变化,可能会影响到生产者的代码。而如果两者都依赖于某个缓冲区,两者之间不直接依赖,耦合也就相应降低了。

      生活案例:我们去邮局投递信件,如果不使用邮箱(也就是缓冲区),你必须得把信直接交给邮递员。有同学会说,直接给邮递员不是挺简单的嘛?其实不简单,你必须得认识谁是邮递员,才能把信给他。这就产生了你和邮递员之间的依赖(相当于生产者和消费者的强耦合)。万一哪天邮递员换人了,你还要重新认识一下(相当于消费者变化导致修改生产者代码)。而邮箱相对来说比较固定,你依赖它的成本就比较低(相当于和缓冲区之间的弱耦合)。

      2.并发

      由于生产者与消费者是两个独立的并发体,他们之间是用缓冲区通信的,生产者只需要往缓冲区里丢数据,就可以继续生产下一个数据,而消费者只需要从缓冲区拿数据即可,这样就不会因为彼此的处理速度而发生阻塞。

      继续上面的例子,如果我们不使用邮箱,就得在邮局等邮递员,直到他回来,把信件交给他,这期间我们啥事儿都不能干(也就是生产者阻塞)。或者邮递员得挨家挨户问,谁要寄信(相当于消费者轮询)。

      3.支持忙闲不均

      当生产者制造数据快的时候,消费者来不及处理,未处理的数据可以暂时存在缓冲区中,慢慢处理掉。而不至于因为消费者的性能造成数据丢失或影响生产者生产。

      比如在寄信的例子中:假设邮递员一次只能带走1000封信,万一碰上情人节(或是圣诞节)送贺卡,需要寄出去的信超过了1000封,这时候邮箱这个缓冲区就派上用场了。邮递员把来不及带走的信暂存在邮箱中,等下次过来时再拿走。

      代码实例:

  from multiprocessing import Process, Queue, JoinableQueue
  import time
  import random


  def producer(name, food, q):
      for i in range(5):
          data = f'{name}生产了{food}{i}'
          print(data)
          time.sleep(random.randint(1, 3))  # 模拟产生过程
          q.put(data)


  def consumer(name, q):
      while True:
          food = q.get()
          # if food == None:
          #     print('完蛋了 没得吃了 要饿死人了')
          #     break
          time.sleep(random.random())
          print(f'{name}吃了{food}')
          q.task_done()  # 每次去完数据必须给队列一个反馈


  if __name__ == '__main__':
      # q = Queue()
      q = JoinableQueue()
      p1 = Process(target=producer, args=('大厨jason', '韭菜炒蛋', q))
      p2 = Process(target=producer, args=('老板kevin', '秘制小汉堡', q))
      c1 = Process(target=consumer, args=('涛涛', q))
      c2 = Process(target=consumer, args=('龙龙', q))
      c1.daemon = True
      c2.daemon = True
      p1.start()
      p2.start()
      c1.start()
      c2.start()
      # 生产者生产完所有数据之后 往队列中添加结束的信号
      p1.join()
      p2.join()
      # q.put(None)  # 结束信号的个数要跟消费者个数一致才可以
      # q.put(None)
      """队列中其实已经自己加了锁 所以多进程取值也不会冲突 并且取走了就没了"""
      q.join()  # 等待队列中数据全部被取出(一定要让生产者全部结束才能判断正确)
      """执行完上述的join方法表示消费者也已经消费完数据了"""

线程理论

      在传统操作系统中,每个进程有一个地址空间,而且默认就有一个控制线程。那么线程是什么呢?

      线程顾名思义,就是一条流水线工作的过程,一条流水线必须属于一个车间,一个车间的工作过程是一个进程。车间负责把资源整合到一起,是一个资源单位,而一个车间内至少有一个流水线。流水线的工作需要电源,电源就相当于cpu。所以,进程只是用来把资源集中到一起(进程只是一个资源单位,或者说资源集合),而线程才是cpu上的执行单位。

      多线程(即多个控制线程)的概念是,在一个进程中存在多个控制线程,多个控制线程共享该进程的地址空间,相当于一个车间内有多条流水线,都共用一个车间的资源。

      进程有很多优点,它提供了多道编程,可以提高计算机CPU的利用率。既然进程这么优秀,为什么还要线程呢?其实,仔细观察就会发现进程还是有很多缺陷的。主要体现在以下几个方面:

  1. 进程只能在一个时间做一个任务,如果想同时做两个任务或多个任务,就必须开启多个进程去完成多个任务。
  2. 进程在执行的过程中如果阻塞,例如等待输入,整个进程就会挂起,即使进程中有些工作不依赖于输入的数据,也将无法执行。
  3. 每个进程都有自己的独立空间,所以多进程的创建,销毁相比于多线程更加耗时,也更加占用系统资源。

      进程与线程的区别:

  1. 地址空间:每个进程都有自己独立的内存空间,也就是说一个进程内的数据在另一个进程是不可见的。但同一进程中的各线程间数据是共享的。
  2. 通信:由于每个进程有自己独立的内存空间,所以进程间通信需要IPC,而进程内的数据对于多个线程来说是共有的,每个线程都可以访问。
  3. 调度和切换:线程上下文切换比进程上下文切换要快得多。
  4. 在多线程操作系统中,进程不是一个可执行的实体,它主要的功能是向操作系统申请一块内存空间,然后在内存空间中开线程来执行任务,相当于一个容器,容器中的线程才是真正的执行体。一个进程可以包含多个线程,而一个线程是不能包含进程的。因为进程是系统分配资源的最小单位,所以线程不能向操作系统申请自己的空间,但一个线程内可以包含多个线程。

      线程的特点:

  # 在多线程的操作系统中,通常是在一个进程中包括多个线程,每个线程都是作为利用CPU的基本单位,是花费最小开销的实体。线程具有以下属性。
  1. 轻型实体:线程实体基本上不拥有系统资源,只是有一点必不可少的、能保证独立运行的资源。线程的实体包括程序、数据和TCB。线程是动态概念,它的动态特性由线程控制块TCB(Thread Control Block)描述。
  2. 独立调度和分派的基本单位:在多线程OS中,线程是能独立运行的基本单位,因而也是独立调度和分派的基本单位。由于线程很“轻”,故线程的切换非常迅速且开销小(在同一进程中的)。
  3. 共享进程资源:在同一进程中的各个线程,都可以共享该进程所拥有的资源,这首先表现在所有线程都具有相同的进程id,这意味着,线程可以访问该进程的每一个内存资源,此外,还可以访问进程所拥有的已打开文件、定时器、信号量机构等。由于同一个进程内的线程共享内存和文件,所以线程之间互相通信不必调用内核。
  4. 可并发执行:在一个进程中的多个线程之间,可以并发执行,甚至允许在一个进程中所有线程都能并发执行;同样,不同进程中的线程也能并发执行,充分利用和发挥了处理机与外围设备并行工作的能力。

开设线程的两种方式

      进程与线程的代码实操几乎是一样的。

  from threading import Thread
  import time

  def task(name):
      print(f'{name} is running')
      time.sleep(3)
      print(f'{name} is over')

  # 创建线程无需在__main__下面编写 但是为了统一 还是习惯在子代码中写
  t = Thread(target=task, args=('jason', ))
  t.start()  # 创建线程的开销极小 几乎是一瞬间就可以创建
  print('主线程')


  class MyThread(Thread):
      def __init__(self, username):
          super().__init__()
          self.username = username
      def run(self):
          print(f'{self.username} jason is running')
          time.sleep(3)
          print(f'{self.username} is over')

  t = MyThread('jasonNB')
  t.start()
  print('主线程')

  # 打印结果
  '''
  jason is running
  主线程
  jasonNB jason is running
  主线程
  jason is over
  jasonNB is over

  Process finished with exit code 0

  '''

      threading.Thread类参数与内置方法:

  # 语法
  class threading.Thread(group=None, target=None, name=None, args=(), kwargs={}, *, daemon=None)
  
  # 参数介绍
  group:目前此参数为None,在实现ThreadGroup类时为将来的扩展保留。
  target:target接收的是一个函数的地址,由run()方法调用执行函数中的内容。默认为无,表示未调用任何内容。
  name :线程名,可自行定义。
  args:target接收的是函数名,此函数的位置参数以元组的形式存放在args中,用于执行函数时调用。
  kwargs :target接收的是函数名,此函数的关键字参数以字典的形式存放在kwargs中,用于执行函数时调用。
  daemon:如果为True表示该线程为守护线程。
  # 内置方法
  1. start():开启线程,一个Thread对象只能调用一次start()方法,如果在同一线程对象上多次调用此方法,则会引发RuntimeError。
  2. run():执行start()方法会调用run(),该方将创建Thread对象时传递给target的函数名,和传递给args、kwargs的参数组合成一个完整的函数,并执行该函数。run()方法一般在自定义Thead类时会用到。
  3. join(timeout=None):join会阻塞、等待线程,timeout单位为秒,因为join()总是返回none,所以在设置timeout调用join(timeout)之后,需要使用isalive()判断线程是否执行完成,如果isalive为True表示线程在规定时间内没有执行完,线程超时。如果join(timeout=None)则会等待线程执行完毕后才会执行join()后面的代码,一般用于等待线程结束。
  4. name:获取线程名。
  5. getName():获取线程名。
  6. setName(name):设置线程名。
  7. ident:“线程标识符”,如果线程尚未启动,则为None。如果线程启动是一个非零整数。
  8. is_alive():判断线程的存活状态,在run()方法开始之前,直到run()方法终止之后。如果线程存活返回True,否则返回False。
  9. daemon:如果thread.daemon=True表示该线程为守护线程,必须在调用Start()之前设置此项,否则将引发RuntimeError。默认为False
  10. isDaemon():判断一个线程是否是守护线程。
  11. setDaemon(daemonic):设置线程为守护线程。

多进程和多线程的效率对比

  from threading import Thread
  from multiprocessing import Process
  import time

  def thread_work(name):
      print(f"{name}")
  def process_work(name):
      print(f"{name}")

  if __name__ == "__main__":
      # 进程执行效率
      pro = []
      start = time.time()
      for i in range(3):
          p = Process(target=process_work,args=(("进程-"+str(i)),))
          p.start()
          pro.append(p)
      for i in pro:
          i.join()
      end = time.time()
      print("进程运行了:%s" %(end - start))
      # 线程执行效率
      thread_l = []
      start = time.time()
      for i in range(3):
          t = Thread(target=process_work, args=(("线程-" + str(i)),))
          t.start()
          thread_l.append(t)
      for i in thread_l:
          i.join()
      end = time.time()
      print("进程运行了:%s" % (end - start))

  # 打印内容如下
  '''
  进程-0
  进程-1
  进程-2
  进程运行了:0.18501067161560059
  线程-0
  线程-1
  线程-2
  进程运行了:0.004000186920166016
  '''

线程的join方法

  from threading import Thread
  import time

  def task(name):
      print(f'{name} is running')
      time.sleep(3)
      print(f'{name} is over')


  t = Thread(target=task, args=('jason', ))
  t.start()
  t.join()  # 主线程代码等待子线程代码运行完毕之后再往下执行
  print('主线程')

  # 打印结果
  '''
  jason is running
  jason is over
  主线程
  '''

      主线程为什么要等着子线程结束才会结束整个进程?因为主线程结束也就标志着整个进程的结束,要确保子线程运行过程中所需的各项资源。

同一个进程内的多线程数据共享

  from threading import Thread

  money = 10000000000
  def task():
      global money
      money = 1

  t = Thread(target=task)
  t.start()
  t.join()
  print(money)

  # 打印结果
  '''
  1
  '''

      线程更改进程内数据,数据也会被更改

守护线程

      主线程会等待所有非守护线程执行完毕后,才结束主线程。主进程是进程内的代码结束后就结束主进程。对比守护进程,代码执行完毕后立即关闭守护进程,因为在主进程看来代码执行完毕,主进程结束了,所以守护进程在代码结束后就被结束了。

      守护线程会等待主线程的结束而结束。这是因为如果主线程结束意味着程序结束,主线程会一直等着所有非守护线程结束,回收资源然后退出程序,所以当所有非守护线程结束后,守护线程结束,然后主线程回收资源,结束程序。

      守护进程:

  from threading import Thread
  import time


  def task(name):
      print(f'{name} is running')
      time.sleep(3)
      print(f'{name} is over')

  t1 = Thread(target=task, args=('jason',))
  t2 = Thread(target=task, args=('kevin',))
  t1.daemon = True
  t1.start()
  t2.start()
  print('主线程')

  # 打印结果
  '''
  jason is running
  kevin is running
  主线程
  jason is over
  kevin is over

  Process finished with exit code 0
  '''

GIL全局解释器锁

      GIL 是最流程的 CPython 解释器(平常称为 Python)中的一个技术术语,中文译为全局解释器锁,其本质上类似操作系统的 Mutex。GIL 的功能是:在 CPython 解释器中执行的每一个 Python 线程,都会先锁住自己,以阻止别的线程执行。

      python解释器的类别有很多: Cpython Jpython Ppython 。而GIL只存在于CPython解释器中,不是python的特征。GIL是一把互斥锁用于阻止同一个进程下的多个线程同时执行。原因是因为CPython解释器中的垃圾回收机制不是线程安全的。

      GIL是加在CPython解释器上面的互斥锁,同一个进程下的多个线程要想执行必须先抢GIL锁,所以同一个进程下多个线程肯定不能同时运行,即无法利用多核优势。所有的解释型语言都无法做到同一个进程下多个线程利用多核优势。

posted @ 2022-04-20 21:09  くうはくの白  阅读(105)  评论(1)    收藏  举报