并发和GIL

发布时间 2023-08-15 20:37:15作者: 昵称已经被使用

并发和GIL

参考博客:

Python的多进程和多线程

Python中协程、多线程、多进程、GIL锁

基于线程的并行

基于进程的并行

基础知识

并发

当有多个线程在操作时,如果系统只有一个CPU,则它根本不可能真正同时进行一个以上的线程,它只能把CPU运行时间划分成若干个时间段,再将时间 段分配给各个线程执行,在一个时间段的线程代码运行时,其它线程处于挂起状。这种方式我们称之为并发(Concurrent)。

并行

当系统有一个以上CPU时,则线程的操作有可能非并发。当一个CPU执行一个线程时,另一个CPU可以执行另一个线程,两个线程互不抢占CPU资源,可以同时进行,这种方式我们称之为并行(Parallel)。

进程

  我们都知道计算机是由硬件和软件组成的。硬件中的CPU是计算机的核心,它承担计算机的所有任务。 操作系统是运行在硬件之上的软件,是计算机的管理者,它负责资源的管理和分配、任务的调度。 程序是运行在系统上的具有某种功能的软件,比如说浏览器,音乐播放器等。程序并不能单独运行,只有将程序装载到内存中,系统为它分配资源才能运行,而这种执行的程序就称之为进程。程序和进程的区别就在于:程序是指令的集合,它是进程运行的静态描述文本进程是程序的一次执行活动,属于动态概念。比如当我们要运行音乐播放器的时候,系统就会为该程序的运行的创建一个独立的运行空间(包括 文本区域(text region)、数据区域(data region)和 堆栈(stack region)等),在这个独立的空间里完成关于某程序的数据集合的运行活动。

  因为每个进程都有各自的一块独立的内存,保证进程彼此间的内存地址空间的隔离。所以,在多道编程中,我们允许多个程序同时加载到内存中(多进程就意味着需要在内存中为进程开辟多个相互独立的地址空间。那么就会占据较大的内存空间),在操作系统的调度下,可以实现并发地执行。这样的设计大大提高了CPU的利用率。进程的出现让每个用户感觉到自己独享CPU,因此,进程就是为了在CPU上实现多道编程而提出的。

  专业点来讲:进程是一个具有一定功能的程序在一个数据集上的一次动态执行过程。进程由程序,数据集合和进程控制块三部分组成。程序用于描述进程要完成的功能,是控制进程执行的指令集;数据集合是程序在执行时需要的数据和工作区;程序控制块(PCB)包含程序的描述信息和控制信息,是进程存在的唯一标志。

  进程是指一个程序在给定数据集合上的一次执行过程,是系统进行资源分配和运行调用的独立单位。可以简单地理解为操作系统中正在执行的程序。也就说,每个应用程序都有一个自己的进程。

  每一个进程启动时都会最先产生一个线程,即主线程。然后主线程会再创建其他的子线程。

  有了进程为什么还要线程?

  进程有很多优点,它提供了多道编程,让我们感觉我们每个人都拥有自己的CPU和其他资源,可以提高计算机的利用率。很多人就不理解了,既然进程这么优秀,为什么还要线程呢?其实,仔细观察就会发现进程还是有很多缺陷的,主要体现在两点上:

  • 进程只能在一个时间干一件事,如果想同时干两件事或多件事,进程就无能为力了。
  • 进程在执行的过程中如果阻塞,例如等待输入,整个进程就会挂起,即使进程中有些工作不依赖于输入的数据,也将无法执行。

线程

  线程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。一个进程可以拥有多个线程,而且属于同一个进程的多个线程间会共享该进行的资源。

  由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效地提高系统内多个程序间并发执行的程度,从而显著提高系统资源的利用率和吞吐量。

协程

​ 协程是轻量级的线程,又称微线程,协程由程序自身控制,在一个线程里执行,没有线程切换的开销,有极高的执行效率。

优点:

  • 无需线程上下文切换的开销
  • 无需原子操作锁定及同步的开销
  • 方便切换控制流,简化编程模型
  • 高并发+高扩展性+低成本:一个CPU支持上万的协程都不是问题。所以很适合用于高并发处理。

所谓原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程)。
原子操作可以是一个步骤,也可以是多个操作步骤,但是其顺序是不可以被打乱,或者切割掉只执行部分。视作整体是原子性的核心。

缺点:

  • 无法利用多核资源:协程的本质是个单线程,它不能同时将 单个CPU 的多个核用上,协程需要和进程配合才能运行在多CPU上.当然我们日常所编写的绝大部分应用都没有这个必要,除非是cpu密集型应用。
  • 进行阻塞(Blocking)操作(如IO时)会阻塞掉整个程序

多进程

  多进程是由父进程克隆生成子进程,子进程将会拥有和父进程相同的资源,生成的子进程之间以及与父进程都是相互独立的。 所以多进程的创建会消耗系统大量的资源。

  因为进程间是相互独立的,所以想要实现进程间的通信,必须使用中间进程

多线程

  当我们要运行某个程序时,操作系统首先会为其创建至少一个进程(资源分配的基本单元),进程又会为自己创建一个主线程(线程才是指令执行的实体)。当需要多线程的时候,将会由主线程创建出子线程,创建出的子线程将独立执行任务,但将同主线程和其他子线程,共同享用进程资源。

  因线程间是在一个进程实体中,同时共享进程资源,所以线程间可以直接交流。

进程和线程的区别

  进程(process)和线程(thread)是操作系统的基本概念,但是它们比较抽象,不容易掌握。关于多进程和多线程,教科书上最经典的一句话是“进程是资源分配的最小单位,线程是CPU调度的最小单位”。线程是程序中一个单一的顺序控制流程。进程内一个相对独立的、可调度的执行单元,是系统独立调度和分派CPU的基本单位指运行中的程序的调度单位。在单个程序中同时运行多个线程完成不同的工作,称为多线程。

  进程是资源分配的基本单位。所有与该进程有关的资源,都被记录在进程控制块PCB中。以表示该进程拥有这些资源或正在使用它们。另外,进程也是抢占处理机的调度单位,它拥有一个完整的虚拟地址空间。当进程发生调度时,不同的进程拥有不同的虚拟地址空间,而同一进程内的不同线程共享同一地址空间。

  与进程相对应,线程与资源分配无关,它属于某一个进程,并与进程内的其他线程一起共享进程的资源。线程只由相关堆栈(系统栈或用户栈)寄存器和线程控制表TCB组成。寄存器可被用来存储线程内的局部变量,但不能存储其他线程的相关变量。

  通常在一个进程中可以包含若干个线程,它们可以利用进程所拥有的资源。在引入线程的操作系统中,通常都是把进程作为分配资源的基本单位,而把线程作为独立运行和独立调度的基本单位。

  由于线程比进程更小,基本上不拥有系统资源,故对它的调度所付出的开销就会小得多,能更高效的提高系统内多个程序间并发执行的程度,从而显著提高系统资源的利用率和吞吐量。

  因而近年来推出的通用操作系统都引入了线程,以便进一步提高系统的并发性,并把它视为现代操作系统的一个重要指标。

Python的多进程和多线程_上下文切换

  线程与进程的区别可以归纳为以下:

  • 地址空间和其它资源(如打开文件):进程间相互独立,同一进程的各线程间共享。某进程内的线程在其它进程不可见。
  • 通信:进程间通信IPC,线程间可以直接读写进程数据段(如全局变量)来进行通信——需要进程同步和互斥手段的辅助,以保证数据的一致性。
  • 调度和切换:线程上下文切换比进程上下文切换要快得多。
  • 在多线程OS中,进程不是一个可执行的实体。
  • 线程必须在某个进程中执行。
  • 一个进程可包含多个线程,其中有且只有一个主线程。
  • 多线程共享同个地址空间、打开的文件以及其他资源。
  • 多进程共享物理内存、磁盘、打印机以及其他资源。
  • 进程之间相互独立,而同一个进程下的线程共享程序的内存空间(如代码段,数据集,堆栈等)。某进程内的线程在其他进程不可见。换言之,线程共享同一片内存空间,而进程各有独立的内存空间

上下文的切换

  上下文的切换:分为进程的上下文切换,线程的上下文的切换,用户态与内核态之间的上下文切换(当用户程序需要调用硬件设备的时候,内核就需要将用户程序切换成系统调用)

  独享cpu的效果的实现,是因为任务调度采用时间片轮转的抢占式方式进行进程调度,简称为进程的上下文切换,进程的切换实质上就是被中止运行进程与待运行进程上下文的切换。举例:这就像多个同学要分时使用同一张课桌一样,说是要收回正在使用课桌同学的课桌使用权,实质上就是让他把属于他的东西拿走;而赋予某个同学课桌使用权,就是让他把他的东西放到课桌上。

  进程切换分两步:

  1. 切换页目录以使用新的地址空间;

  2. 切换内核栈和硬件上下文。

    对于linux来说,线程上下文切换和进程上下文切换一个最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的
      对于线程切换第1步是不需要做的,第2步是进程和线程切换都要做的。所以明显是进程切换代价大这两种上下文切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能损耗是:将寄存器中的内容切换出。
      从时间占比上来看,进程的切换需要的时间相对于cpu而言却是很耗时的,在每秒几十上百次的切换中,每次切换都需要纳秒量级的时间。所以,上下文切换对系统来说意味着消耗大量的 CPU 时间,事实上,这可能是操作系统中时间消耗最大的操作。

  上下文切换瓶颈判断:

  1.如果CPU在满负荷运行,应该符合下列分布:
    a) User Time:65%~70%
    b) System Time:30%~35% 若该指标过高,基本可以判断为上下文切换过于频繁。
    c) Idle:0%~5%

  2.对于上下文切换要结合CPU使用率来看,如果CPU使用满足上述分布,大量的上下文切换也是可以接受的。

GIL锁

  其他语言,CPU是多核时是支持多个线程同时执行。但在Python中,无论是单核还是多核,同时只能由一个线程在执行。其根源是GIL的存在。GIL的全称是Global Interpreter Lock(全局解释器锁),来源是Python设计之初的考虑,为了数据安全所做的决定。某个线程想要执行,必须先拿到GIL,我们可以把GIL看作是“通行证”,并且在一个Python进程中,GIL只有一个。拿不到通行证的线程,就不允许进入CPU执行。

  而目前Python的解释器有多种,例如:

  • CPython:CPython是用C语言实现的Python解释器。 作为官方实现,它是最广泛使用的Python解释器。
  • PyPy:PyPy是用RPython实现的解释器。RPython是Python的子集, 具有静态类型。这个解释器的特点是即时编译,支持多重后端(C, CLI, JVM)。PyPy旨在提高性能,同时保持最大兼容性(参考CPython的实现)。
  • Jython:Jython是一个将Python代码编译成Java字节码的实现,运行在JVM (Java Virtual Machine) 上。另外,它可以像是用Python模块一样,导入并使用任何Java类。
  • IronPython:IronPython是一个针对 .NET 框架的Python实现。它可以用Python和 .NET framework的库,也能将Python代码暴露给 .NET框架中的其他语言。

GIL只在CPython中才有,而在PyPy和Jython中是没有GIL的。

每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。这就导致打印线程执行时长,会发现耗时更长的原因。

  并且由于GIL锁存在,Python里一个进程永远只能同时执行一个线程(拿到GIL的线程才能执行),这就是为什么在多核CPU上,Python 的多线程效率并不高的根本原因。

下面看一下官方的说法

定义:

In CPython, the global interpreter lock, or GIL, is a mutex that prevents multiple

native threads from executing Python bytecodes at once. This lock is necessary mainly

because CPython’s memory management is not thread-safe. (However, since the GIL

exists, other features have grown to depend on the guarantees that it enforces.)

翻译结果:

  1、GIL 不是 Python 的特点,而是 CPython 解释器的特点;

  2、在 CPython 解释器中,GIL 是一把互斥锁,用来阻止同一个进程下多个线程的同时执行

  3、因为 CPython 解释器的内存管理并不安全( 内存管理—垃圾回收机制)

  稍加解释:在没有 GIL 锁的情况下,有可能多线程在执行一个代码的同时,垃圾回收机制线程对所执行代码的变量直接回收,导致运行报错;

重点:

1、GIL 不是 Python 的特点,而是 CPython 解释器的特点;

2、GIL 锁是加在 CPython 解释器上的,是保证解释器级别的数据的安全;

3、GIL 锁会导致同一个进程下多个线程的不能同时执行

4、不同的数据除了 GIL 锁,还需要一把互斥锁,来保证数据处理不会错乱

注意:GIL 锁是加在 CPython 解释器上的,进程先获取 GIL 锁,在获取 CPython 解释器

GIL锁有什么好处和坏处

  简单来说,它在单线程的情况更快,并且在和 C 库结合时更方便,而且不用考虑线程安全问题,这也是早期 Python 最常见的应用场景和优势。另外,GIL的设计简化了CPython的实现,使得对象模型,包括关键的内建类型如字典,都是隐含可以并发访问的。锁住全局解释器使得比较容易的实现对多线程的支持,但也损失了多处理器主机的并行计算能力。

在多线程环境中,Python 虚拟机按以下方式执行:

  1. 设置GIL
  2. 切换到一个线程去运行
  3. 运行直至指定数量的字节码指令,或者线程主动让出控制(可以调用sleep(0))
  4. 把线程设置为睡眠状态
  5. 解锁GIL
  6. 再次重复以上所有步骤

  Python3.2前,GIL的释放逻辑是当前线程遇见IO操作或者ticks计数达到100(ticks可以看作是python自身的一个计数器,专门做用于GIL,每次释放后归零,这个计数可以通过 sys.setcheckinterval 来调整),进行释放。因为计算密集型线程在释放GIL之后又会立即去申请GIL,并且通常在其它线程还没有调度完之前它就已经重新获取到了GIL,就会导致一旦计算密集型线程获得了GIL,那么它在很长一段时间内都将占据GIL,甚至一直到该线程执行结束。

  Python 3.2开始使用新的GIL。新的GIL实现中用一个固定的超时时间来指示当前的线程放弃全局锁。在当前线程保持这个锁,且其他线程请求这个锁时,当前线程就会在5毫秒后被强制释放该锁。该改进在单核的情况下,对于单个线程长期占用GIL的情况有所好转。

  在单核CPU上,数百次的间隔检查才会导致一次线程切换。在多核CPU上,存在严重的线程颠簸(thrashing)。而每次释放GIL锁,线程进行锁竞争、切换线程,会消耗资源。单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低。

  另外,从上面的实现机制可以推导出,Python的多线程对IO密集型代码要比CPU密集型代码更加友好。

针对GIL的应对措施:

  • 使用更高版本Python(对GIL机制进行了优化)
  • 使用多进程替换多线程(多进程之间没有GIL,但是进程本身的资源消耗较多)
  • 指定cpu运行线程(使用affinity模块)
  • 使用Jython、IronPython等无GIL解释器
  • 全IO密集型任务时才使用多线程
  • 使用协程(高效的单线程模式,也称微线程;通常与多进程配合使用)
  • 将关键组件用C/C++编写为Python扩展,通过ctypes使Python程序直接调用C语言编译的动态链接库的导出函数。(with nogil调出GIL限制)

好处:

  • 保护共享数据:GIL 锁可以保护共享数据,防止多个线程同时对共享数据进行修改,从而避免数据不一致的问题。
  • 提高性能:GIL 锁可以提高性能,因为它可以防止多个线程同时运行,从而减少了线程切换的开销。
  • 避免死锁:GIL 锁可以避免死锁,因为它可以防止多个线程同时运行,从而避免了线程之间的竞争。

坏处:

  • 单个进程下,开启多个线程,无法实现并行,只能实现并发,牺牲执行效率。
  • 由于GIL锁的限制,所以多线程不适合计算密集型任务,更适合IO密集型任务(常见IO密集型任务:网络IO(抓取网页数据)、磁盘操作(读写文件)、键盘输入)

为什么会cpython有GIL锁,jpython没有?

Python 是一门解释型的语言,这就意味着代码是解释一行,运行一行,它并不清楚代码全局;因此,每个线程在调用 cpython 解释器 在运行之前,需要先抢到 GIL 锁,然后才能运行。编译型的语言就不会存在 GIL 锁,编译型的语言会直接编译所有代码,就不会出现这种问题。Java是个混合型的语言,所以在jpython上就没有GIL锁

额外扩展

编译型语言

  定义:在程序运行之前,通过编译器将源程序编译成机器码(可运行的二进制代码),以后执行这个程序时,就不用再进行编译了。

  优点:编译器一般会有预编译的过程对代码进行优化。因为编译只做一次,运行时不需要编译,所以编译型语言的程序执行效率高,可以脱离语言环境独立运行。

  缺点:编译之后如果需要修改就需要整个模块重新编译。编译的时候根据对应的运行环境生成机器码,不同的操作系统之间移植就会有问题,需要根据运行的操作系统环境编译不同的可执行文件。

  总结:执行速度快、效率高;依靠编译器、跨平台性差些。

  代表语言:C、C++、Pascal、Object-C以及Swift。

解释型语言

  定义:解释型语言的源代码不是直接翻译成机器码,而是先翻译成中间代码,再由解释器对中间代码进行解释运行。在运行的时候才将源程序翻译成机器码,翻译一句,然后执行一句,直至结束。

  优点:有良好的平台兼容性,在任何环境中都可以运行,前提是安装了解释器(虚拟机)。灵活,修改代码的时候直接修改就可以,可以快速部署,不用停机维护。

  缺点:每次运行的时候都要解释一遍,性能上不如编译型语言。

  总结:执行速度慢、效率低;依靠解释器、跨平台性好。

  代表语言:JavaScript、Python、Erlang、PHP、Perl、Ruby。

混合型语言

  定义:既然编译型和解释型各有缺点就会有人想到把两种类型整合起来,取其精华去其糟粕,就出现了半编译,半解释型语言。

  比如C#,C#在编译的时候不是直接编译成机器码而是中间码,.NET平台提供了中间语言运行库运行中间码,中间语言运行库类似于Java虚拟机。.NET在编译成IL代码后,保存在dll中,首次运行时由JIT在编译成机器码缓存在内存中,下次直接执行。严格来说混合型语言属于解释型语言,C#更接近编译型语言。

  Java即是编译型的,也是解释型语言,总的来说Java更接近解释型语言。

  可以说它是编译型的。因为所有的Java代码都是要编译的,.java不经过编译就什么用都没有。同时围绕JVM的效率问题,会涉及一些如JIT、AOT等优化技术,例如JIT技术,会将热点代码编译成机器码。而AOT技术,是在运行前,通过工具直接将字节码转换为机器码。

  可以说它是解释型的。因为Java代码编译后不能直接运行,它是解释运行在JVM上的,所以它是解释运行的。

为什么cpython要加一把锁?怎么去加锁的?

  分为2部分,并发和并行是2回事,python的并发并没有问题,到了c解析器那边的时候,因为GIL的原因,就不予许并发了。(重点:Python是可以并发的,C解析器因为GIL原因不予许并发,跟python毫无关系)

不是说python受GIL锁的影响,不会出现这种非线程安全的问题吗?

  首先需要明确的一点是GIL并不是Python的特性,它是在实现Python解析器(CPython)时所引入的一个概念。就好比C++是一套语言(语法)标准,但是可以用不同的编译器来编译成可执行代码。有名的编译器例如GCC,INTEL C++,Visual C++等。Python也一样,同样一段代码可以通过CPython,PyPy,Psyco等不同的Python执行环境来执行。像其中的JPython就没有GIL。然而因为CPython是大部分环境下默认的Python执行环境。所以在很多人的概念里CPython就是Python,也就想当然的把GIL归结为Python语言的缺陷。所以这里要先明确一点:GIL并不是Python的特性,Python完全可以不依赖于GIL。

  简单来说,python调用c的原生线程去执行,那么是c的特性上了锁,python完全不依赖,只是默认我们用的都是cpython,所以受了c的锁的影响。

GIL锁里面说的线程安全是什么意思?

  线程安全指的是内存的安全,在每个进程的内存空间中都会有一块特殊的公共区域,通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因。

  所以线程安全指的是,在堆内存中的数据由于可以被任何线程访问到,在没有限制的情况下存在被意外修改的风险。即堆内存空间在没有保护机制的情况下,对多线程来说是不安全的地方,因为你放进去的数据,可能被别的线程“破坏”。

为什么python多线程是伪多线程,还要使用?或者说速度比单线程快?

  这个问题前面4个问题提了很多次,python说是伪多线程的原因是因为默认大家使用的都是cpython,而c解释器有GIL锁。所以可以说他是伪多线程。

  但是还是那句话,并发和并行是2回事,python的并发多线程受到了cpython的影响,导致大家认为他是伪多线程,可以这么说但是不绝对,因为python的并发是可以执行的,但是到了c解析器那边,只允许并行一个任务,拖了python的后腿,就比如jpython就是没有这个GIL锁的。

  至于第二个问题,为什么还要使用?因为python多并发是可以执行的,只是到了C解析器的时候被Gil拦住了,只予许执行一个线程

为什么GIL锁还没有删除

  有一些历史原因在里面在Cpython虚拟机(解释器),难以移除GIL,同时删除GIL会使得Python 3在处理单线程任务方面比Python 2慢,可以想像会产生什么结果。你不能否认GIL带来的单线程性能优势,这也就是为什么Python 3中仍然还有GIL。

  小提示(GIL锁的历史原因):

  简单的来说:当初Python语言在设计的时候,市面上并没有多核CPU,因此线程都是单线程的。随着时间的推移、科技的发展,逐渐出现多核CPU,各CPU厂商在核心频率上的比赛已经被多核所取代,为了更有效的利用多核处理器的性能,就出现了多线程的编程方式,而随之带来的就是线程间数据一致性和状态同步的困难。

  Python为了利用多核CPU,开始支持多线程。而解决多线程之间数据完整性和状态同步的最简单方法自然就是加锁,于是有了GIL这把超级大锁。因为有了GIL,所以我们的Python可以实现多进程,但是这是一个假的多进程,虽然它会利用多个CPU共同协作,但实则是利用一个CPU的资源。

  但是这种GIL导致我们的多进程并不是真正的多进程,所以它的效率很低。但当大家试图去拆分和去除GIL的时候,发现大量库代码开发者已经重度依赖GIL而非常难以去除了。如果推到重来,多线程的问题依然还是要面对,但是至少会比目前GIL这种方式会更优雅。所以简单的说:GIL的存在更多的是历史原因。

Python多线程

创建多线程

  Python提供两个模块进行多线程的操作,分别是thread和threading,前者是比较低级的模块,用于更底层的操作,一般应用级别的开发不常用。

方法1:直接使用threading.Thread()

import threading
 
# 这个函数名可随便定义
def run(n):
    print("current task:", n)
 
if __name__ == "__main__":
    t1 = threading.Thread(target=run, args=("thread 1",))
    t2 = threading.Thread(target=run, args=("thread 2",))
    t1.start()
    t2.start()

方法2:继承threading.Thread来自定义线程类,重写run方法

import threading
 
class MyThread(threading.Thread):
    def __init__(self, n):
        super(MyThread, self).__init__()  # 重构run函数必须要写
        self.n = n
 
    def run(self):
        print("current task:", n)
 
if __name__ == "__main__":
    t1 = MyThread("thread 1")
    t2 = MyThread("thread 2")
 
    t1.start()
    t2.start()

线程合并join

  join函数执行顺序是逐个执行每个线程,执行完毕后继续往下执行。主线程结束后,子线程还在运行,join函数使得主线程等到子线程结束时才退出。不然一般情况下,主线程执行完成,程序退出了,都没有管子进程有没有执行完成

import threading
 
def count(n):
    while n > 0:
        n -= 1
 
if __name__ == "__main__":
    t1 = threading.Thread(target=count, args=("100000",))
    t2 = threading.Thread(target=count, args=("100000",))
    t1.start()
    t2.start()
    # 将 t1 和 t2 加入到主线程中
    t1.join()
    t2.join()

线程同步与互斥锁

  如果多个线程共同对某个数据修改,则可能出现不可预料的结果,为了保证数据的正确性,需要对多个线程进行同步。

  使用 Thread 对象的 Lock 和 Rlock 可以实现简单的线程同步,这两个对象都有 acquire 方法和 release 方法,对于那些需要每次只允许一个线程操作的数据,可以将其操作放到 acquire 和 release 方法之间。如下:

  多线程的优势在于可以同时运行多个任务(至少感觉起来是这样)。但是当线程需要共享数据时,可能存在数据不同步的问题。

  考虑这样一种情况:一个列表里所有元素都是0,线程"set"从后向前把所有元素改成1,而线程"print"负责从前往后读取列表并打印。

  那么,可能线程"set"开始改的时候,线程"print"便来打印列表了,输出就成了一半0一半1,这就是数据的不同步。为了避免这种情况,引入了锁的概念。

  锁有两种状态——锁定和未锁定。每当一个线程比如"set"要访问共享数据时,必须先获得锁定;如果已经有别的线程比如"print"获得锁定了,那么就让线程"set"暂停,也就是同步阻塞;等到线程"print"访问完毕,释放锁以后,再让线程"set"继续。

  经过这样的处理,打印列表时要么全部输出0,要么全部输出1,不会再出现一半0一半1的尴尬场面。

  用法的基本步骤:

#创建锁
mutex = threading.Lock()
#锁定
mutex.acquire([timeout])
#释放
mutex.release()

  其中,锁定方法acquire可以有一个超时时间的可选参数timeout。如果设定了timeout,则在超时后通过返回值可以判断是否得到了锁,从而可以进行一些其他的处理。具体用法见示例代码:

import threading
import time
 
num = 0
mutex = threading.Lock()
 
class MyThread(threading.Thread):
    def run(self):
        global num 
        time.sleep(1)
 
        if mutex.acquire(1):  
            num = num + 1
            msg = self.name + ': num value is ' + str(num)
            print(msg)
            mutex.release()
 
if __name__ == '__main__':
    for i in range(5):
        t = MyThread()
        t.start()

可重入锁(递归锁)

  为了满足在同一线程中多次请求同一资源的需求,Python提供了可重入锁(RLock)。RLock内部维护着一个Lock和一个counter变量,counter记录了acquire 的次数,从而使得资源可以被多次require。直到一个线程所有的acquire都被release,其他的线程才能获得资源。具体用法如下:

  简单来说,就是一个大锁中还要再包含子锁

import threading, time

def run1():
    print("grab the first part data")
    lock.acquire()
    global num
    num += 1
    lock.release()
    return num

def run2():
    print("grab the second part data")
    lock.acquire()
    global num2
    num2 += 1
    lock.release()
    return num2

def run3():
    lock.acquire()
    res = run1()
    print('--------between run1 and run2-----')
    res2 = run2()
    lock.release()
    print(res, res2)


if __name__ == '__main__':

    num, num2 = 0, 0
    '''
    如果不加入Rock,那么这么多锁,锁就混乱了,不知道谁是谁的锁,输出的结果一定是一个死循环
    rlock可以理解成为一个锁关系的记录表,让每一层知道谁是谁的锁。
    '''
    lock = threading.RLock()
    for i in range(10):
        t = threading.Thread(target=run3)
        t.start()

while threading.active_count() != 1:
    print(threading.active_count())
else:
    print('----all threads done---')
    print(num, num2)

信号量

  互斥锁 同时只允许一个线程更改数据,而Semaphore是同时允许一定数量的线程更改数据 ,比如厕所有3个坑,那最多只允许3个人上厕所,后面的人只能等里面有人出来了才能再进去。

线程同步与交互

守护线程

  如果希望主线程执行完毕之后,不管子线程是否执行完毕都随着主线程一起结束。我们可以使用setDaemon(bool)函数,它跟join函数是相反的。它的作用是设置子线程是否随主线程一起结束,必须在start() 之前调用,默认为False。

定时器

  如果需要规定函数在多少秒后执行某个操作,需要用到Timer类。具体用法如下:

from threading import Timer
 
def show():
    print("Pyhton")
 
# 指定一秒钟之后执行 show 函数
t = Timer(1, hello)
t.start()

线程池

  一般都是第三方模块,用的比较少,需要的时候在去查吧。

Python 多进程

创建多进程

  Python要进行多进程操作,需要用到muiltprocessing库,其中的Process类跟threading模块的Thread类很相似。所以直接看代码熟悉多进程。包括什么信号量,多进程里面也有,用法和多线程类似,这里就不做过多解释了

方法1:直接使用Process, 代码如下:

from multiprocessing import Process  
 
def show(name):
    print("Process name is " + name)
 
if __name__ == "__main__": 
    proc = Process(target=show, args=('subprocess',))  
    proc.start()  
    proc.join()

方法2:继承Process来自定义进程类,重写run方法, 代码如下:

from multiprocessing import Process
import time
 
class MyProcess(Process):
    def __init__(self, name):
        super(MyProcess, self).__init__()
        self.name = name
 
    def run(self):
        print('process name :' + str(self.name))
        time.sleep(1)
 
if __name__ == '__main__':
    for i in range(3):
        p = MyProcess(i)
        p.start()
    for i in range(3):
        p.join()

多进程通信

  进程之间不共享数据的。如果进程之间需要进行通信,则要用到Queue模块或者Pipe模块来实现。

  • Queue

    Queue是多进程安全的队列,可以实现多进程之间的数据传递。它主要有两个函数put和get

    put() 用以插入数据到队列中,put还有两个可选参数:blocked 和timeout。如果blocked为 True(默认值),并且timeout为正值,该方法会阻塞timeout指定的时间,直到该队列有剩余的空间。如果超时,会抛出 Queue.Full异常。如果blocked为False,但该Queue已满,会立即抛出Queue.Full异常。

    get()可以从队列读取并且删除一个元素。同样get有两个可选参数:blocked和timeout。如果blocked为True(默认值),并且 timeout为正值,那么在等待时间内没有取到任何元素,会抛出Queue.Empty异常。如果blocked为False,有两种情况存在,如果Queue有一个值可用,则立即返回该值,否则,如果队列为空,则立即抛出Queue.Empty异常。

    Queue 模块中的常用方法:

    • Queue.qsize() 返回队列的大小
    • Queue.empty() 如果队列为空,返回True,反之False
    • Queue.full() 如果队列满了,返回True,反之False。Queue.full 与 maxsize 大小对应
    • Queue.get([block[, timeout]])获取队列,timeout等待时间
    • Queue.get_nowait() 相当Queue.get(False)
    • Queue.put(item) 写入队列,timeout等待时间
    • Queue.put_nowait(item) 相当Queue.put(item, False)
    • Queue.task_done() 在完成一项工作之后,Queue.task_done()函数向任务已经完成的队列发送一个信号
    • Queue.join() 实际上意味着等到队列为空,再执行别的操作
        

    具体用法如下:

from multiprocessing import Process, Queue
 
def put(queue):
    queue.put('Queue 用法')
 
if __name__ == '__main__':
    queue = Queue()
    pro = Process(target=put, args=(queue,))
    pro.start()
    print(queue.get())   
    pro.join()
  • Pipe
    Pipe的本质是进程之间的用管道数据传递,而不是数据共享,这和socket有点像。pipe() 返回两个连接对象分别表示管道的两端,每端都有send()和recv()函数。如果两个进程试图在同一时间的同一端进行读取和写入那么,这可能会损坏管道中的数据,具体用法如下:
from multiprocessing import Process, Pipe
 
def show(conn):
    conn.send('Pipe 用法')
    conn.close()
 
if __name__ == '__main__':
    parent_conn, child_conn = Pipe() 
    pro = Process(target=show, args=(child_conn,))
    pro.start()
    print(parent_conn.recv())   
    pro.join()

多进程数据共享

  上面也说了,这里更适合叫做进程间的数据传递,但是也有可以类似进程间数据共享的方式,那什么叫做进程间数据共享?那么就是说2个进程都可以修改这个数据,比如一个字典,2个进程都可以同时往里面放数据,见以下代码。

  通过manages实现

  manager()返回的管理器对象控制一个服务器进程,该进程持有Python对象,并允许其他进程使用代理操作它们。

  由manager()返回的管理器将支持列表、字典、命名空间、锁、RLock、信号量、BoundedSemaphore、条件、事件、Barrier、队列、值和数组类型。例如,

进程池

  创建多个进程,我们不用傻傻地一个个去创建。我们可以使用Pool模块来搞定。Pool 常用的方法如下:

Python的多进程和多线程_Python_11

  具体用法见示例代码:

#coding: utf-8
import multiprocessing
import time
 
def func(msg):
    print("msg:", msg)
    time.sleep(3)
    print("end")
 
if __name__ == "__main__":
    # 维持执行的进程总数为processes,当一个进程执行完毕后会添加新的进程进去
    pool = multiprocessing.Pool(processes = 3)
    for i in range(5):
        msg = "hello %d" %(i)
        # 非阻塞式,子进程不影响主进程的执行,会直接运行到 pool.join()
        pool.apply_async(func, (msg, ))   
 
        # 阻塞式,先执行完子进程,再执行主进程
        # pool.apply(func, (msg, ))   
 
    print("Mark~ Mark~ Mark~~~~~~~~~~~~~~~~~~~~~~")
    # 调用join之前,先调用close函数,否则会出错。
    pool.close()
    # 执行完close后不会有新的进程加入到pool,join函数等待所有子进程结束
    pool.join()   
    print("Sub-process(es) done.")

  如上,进程池Pool被创建出来后,即使实际需要创建的进程数远远大于进程池的最大上限,p.apply_async(test)代码依旧会不停的执行,并不会停下等待;相当于向进程池提交了10个请求,会被放到一个队列中;

实际的进程数只有5条,计算机每次最多5条进程并行。

  当Pool中有进程任务执行完毕后,这条进程资源会被释放,pool会按先进先出的原则取出一个新的请求给空闲的进程继续执行;

需要调用join函数去回收。

join函数是主进程等待子进程结束回收系统资源的,如果没有join,主程序退出后不管子进程有没有结束都会被强制杀死;

  创建Pool池时,如果不指定进程最大数量,默认创建的进程数为系统的内核数量.

python实现多协程

使用gevent实现协程


import gevent
import time
from gevent import monkey
 
monkey.patch_all()
 
def f1(n):
    for i in range(n):
        print ("------f1---------", i)
        time.sleep(1)
        #gevent.sleep(1)
 
def f2(n):
    for i in range(n):
        print ("------f2---------", i)
        time.sleep(1) 
        #gevent.sleep(1)   
 
def f3(n):
    for i in range(n):
        print ("------f3---------", i)
        time.sleep(1) 
        #gevent.sleep(1)
 
g1 = gevent.spawn(f1,5) 
g2 = gevent.spawn(f2,5) 
g3 = gevent.spawn(f3,5) 
g1.join()
g2.join()
g3.join()

使用gevent实现协程多任务

import gevent
import random
import time
from gevent import monkey
 
monkey.patch_all()
 
def coroutine_work(coroutine_name):
    for i in range(10):
        print(coroutine_name,i)
        time.sleep(random.random())
 
def coroutine_work2(coroutine_name):
    for i in range(10):
        print(coroutine_name,i)
        time.sleep(random.random())
 
gevent.joinall([
    gevent.spawn(coroutine_work,"work1"),
    gevent.spawn(coroutine_work2,"work2")
 
])

选择多线程还是多进程?

  在这个问题上,首先要看下你的程序是属于哪种类型的。一般分为两种:CPU密集型和I/O密集型。

CPU 密集型:程序比较偏重于计算,需要经常使用CPU来运算。例如科学计算的程序,机器学习的程序等。
I/O 密集型:顾名思义就是程序需要频繁进行输入输出操作。爬虫程序就是典型的I/O密集型程序。
  如果程序是属于CPU密集型,建议使用多进程。而多线程就更适合应用于I/O密集型程序。

Python的多进程和多线程_Python_12

  • 需要频繁创建销毁的优先用线程

  原因请看上面的对比。

  这种原则最常见的应用就是Web服务器了,来一个连接建立一个线程,断了就销毁线程,要是用进程,创建和销毁的代价是很难承受的

  • 需要进行大量计算的优先使用线程

  所谓大量计算,当然就是要耗费很多CPU,切换频繁了,这种情况下线程是最合适的。

  这种原则最常见的是图像处理、算法处理。

  • 强相关的处理用线程,弱相关的处理用进程

  什么叫强相关、弱相关?理论上很难定义,给个简单的例子就明白了。

  一般的Server需要完成如下任务:消息收发、消息处理。“消息收发”和“消息处理”就是弱相关的任务,而“消息处理”里面可能又分为“消息解码”、“业务处理”,这两个任务相对来说相关性就要强多了。因此“消息收发”和“消息处理”可以分进程设计,“消息解码”、“业务处理”可以分线程设计。

  当然这种划分方式不是一成不变的,也可以根据实际情况进行调整。

  • 可能要扩展到多机分布的用进程,多核分布的用线程

  原因请看上面对比。

  • 都满足需求的情况下,用你最熟悉、最拿手的方式

  至于“数据共享、同步”、“编程、调试”、“可靠性”这几个维度的所谓的“复杂、简单”应该怎么取舍,我只能说:没有明确的选择方法。但我可以告诉你一个选择原则:如果多进程和多线程都能够满足要求,那么选择你最熟悉、最拿手的那个。

可通过多进程 + 协程充分利用多核 CPU