py并发编程:GIL锁、进程、线程、协程

py并发编程:GIL锁、进程、线程、协程

  • 1、进程、线程概念引入
    • 1.1 进程的由来
    • 1.2 线程的由来
      • 1.2.1 创建进程
      • 1.2.2 撤消进程
      • 1.2.3 进程切换
    • 1.3 ==线程与进程的关系与区别==
      • 1.3.1 进程和线程的关系
      • 1.3.2 进程和线程的区别
  • 2、Python下的多线程实现
    • 2.1 多线程的基本语法
      • 2.1.1 创建多线程的两种方式:
    • 2.2 线程实例对象方法
      • 2.2.1 **join方法和setDaemon方法**
      • 2.2.2 **其他方法**
      • 2.2.3 ==GIL(全局解释器锁)==
      • 2.2.4 同步锁【解决多线程不安全】
      • 2.2.5 死锁【Lock】与递归锁【RLock 解决死锁】
      • 2.2.6 信号量
      • 2.2.6 队列与生产者消费者模型
  • 3、python下多进程的语法实现
    • 3.1 多进程基本语法
    • 3.2 进程池
    • 3.3 进程通信
      • 3.3.1 **进程队列**
      • 3.3.2 **管道通信**
      • 3.3.3 **manager**
  • 4、协程(coroutine)
    • 4.1 进程、线程、协程的对比
    • 4.2 yield实现的协程
    • 4.3 greenlet实现的协程
    • 4.4 gevent实现的协程
    • 4.5 monkey补丁【猴子补丁】

1、进程、线程概念引入

1.1 进程的由来

  • 进程就是一个程序在一个数据集上的一次动态执行过程。是系统进行资源分配和调度的基本单位,是操作系统结构的基础
  • 进程一般由程序、数据集、进程控制块(PCB)三部分组成。
    • 我们编写的程序用来描述进程要完成哪些功能以及如何完成;
    • 数据集则是程序在执行过程中所需要使用的资源;
    • 进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。

这里需要注意的是程序和进程的区别。一个程序是一个可执行的文件,而一个进程则是一个执行中的程序实例。

举一例说明进程:
想象一位有一手好厨艺的计算机科学家正在为他的女儿烘制生日蛋糕。他有做生日蛋糕的食谱,厨房里有所需的原料:面粉、鸡蛋、糖、香草汁等。

在这个比喻中,做蛋糕的食谱就是程序(即用适当形式描述的算法)计算机科学家就是处理器(cpu),而做蛋糕的各种原料就是输入数据。进程就是厨师阅读食谱、取来各种原料以及烘制蛋糕等一系列动作的总和。

现在假设计算机科学家的儿子哭着跑了进来,说他的头被一只蜜蜂蛰了。计算机科学家就记录下他照着食谱做到哪儿了(保存进程的当前状态),然后拿出一本急救手册,按照其中的指示处理蛰伤。

这里,我们看到处理机从一个进程(做蛋糕)切换到另一个高优先级的进程(实施医疗救治),每个进程拥有各自的程序(食谱和急救手册)。

当蜜蜂蛰伤处理完之后,这位计算机科学家又回来做蛋糕,从他离开时的那一步继续做下去。

1.2 线程的由来

既能支持并发,又能降低开销

假设,一个文本程序,需要接受键盘输入,将内容显示在屏幕上,还需要保存信息到硬盘中。

若只有一个进程,势必造成同一时间只能干一样事的尴尬(当保存时,就不能通过键盘输入内容)。

若有多个进程,每个进程负责一个任务,进程A负责接收键盘输入的任务,进程B负责将内容显示在屏幕上的任务,进程C负责保存内容到硬盘中的任务。

这里进程A,B,C间的协作涉及到了进程通信问题,而且有共同都需要拥有的东西——-文本内容,不停的切换造成性能上极大的损失。

若有一种机制,可以使任务A,B,C共享资源,这样上下文切换所需要保存和恢复的内容就少了,同时又可以减少通信所带来的性能损耗,那就好了。是的,这种机制就是线程。

如果说,在操作系统中引入进程的目的,是为了使多个程序能并发执行,以提高资源利用率和系统吞吐量,那么,在操作系统中再引入线程,则是为了减少程序在并发执行时所付出的时空开销,使OS具有更好的并发性。

为了说明这一点,我们首先来回顾进程的两个基本属性:

  • ① 进程是一个可拥有资源的独立单位;
  • ② 进程同时又是一个可独立调度和分派的基本单位。

正是由于进程有这两个基本属性,才使之成为一个能独立运行的基本单位,从而也就构成了进程并发执行的基础。然而,为使程序能并发执行,系统还必须进行以下的一系列操作。

1.2.1 创建进程

系统在创建一个进程时,必须为它分配其所必需的、除处理机以外的所有资源,如内存空间、I/O设备,以及建立相应的PCB。

1.2.2 撤消进程

系统在撤消进程时,又必须先对其所占有的资源执行回收操作,然后再撤消PCB。

1.2.3 进程切换

对进程进行切换时,由于要保留当前进程的CPU环境和设置新选中进程的CPU环境,因而须花费不少的处理机时间。

换言之,由于进程是一个资源的拥有者,因而在创建、撤消和切换中,系统必须为之付出较大的时空开销。正因如此,在系统中所设置的进程,其数目不宜过多,进程切换的频率也不宜过高,这也就限制了并发程度的进一步提高。

如何能使多个程序更好地并发执行同时又尽量减少系统的开销呢?若能将进程的上述两个属性分开,由操作系统分开处理,亦

即对于作为调度和分派的基本单位,不同时作为拥有资源的单位,以做到“轻装上阵”;而对于拥有资源的基本单位,又不对之进行频繁的切换。正是在这种思想的指导下,形成了线程的概念。

py并发编程:GIL锁、进程、线程、协程_第1张图片

1.3 线程与进程的关系与区别

1.3.1 进程和线程的关系

  • (1)一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程
  • (2)资源分配给进程,同一进程的所有线程共享该进程的所有资源
  • (3)线程是最小的执行单元。处理机分给线程,即真正在处理机上运行的是线程
  • (4)线程在执行过程中,需要协作同步。不同进程的线程间要利用消息通信的办法实现同步。线程是指进程内的一个执行单元,也是进程内的可调度实体.

1.3.2 进程和线程的区别

  • (1) 调度

    在传统的操作系统中,作为拥有资源的基本单位和独立调度、分派的基本单位都是进程。而在引入线程的操作系统中,则把线程作为调度和分派的基本单位,而进程作为资源拥有的基本单位,把传统进程的两个属性分开,使线程基本上不拥有资源,这样线程便能轻装前进,从而可显著地提高系统的并发程度。在同一进程中,线程的切换不会引起进程的切换,但从一个进程中的线程切换到另一个进程中的线程时,将会引起进程的切换。

  • (2) 并发性

    在引入线程的操作系统中,不仅进程之间可以并发执行,而且在一个进程中的多个线程之间亦可并发执行,使得操作系统具有更好的并发性,从而能更加有效地提高系统资源的利用率和系统的吞吐量。例如,在一个未引入线程的单CPU操作系统中,若仅设置一个文件服务进程,当该进程由于某种原因而被阻塞时,便没有其它的文件服务进程来提供服务。在引入线程的操作系统中,则可以在一个文件服务进程中设置多个服务线程。当第一个线程等待时,文件服务进程中的第二个线程可以继续运行,以提供文件服务;当第二个线程阻塞时,则可由第三个继续执行,提供服务。显然,这样的方法可以显著地提高文件服务的质量和系统的吞吐量。

  • (3) 拥有资源

    不论是传统的操作系统,还是引入了线程的操作系统,进程都可以拥有资源,是系统中拥有资源的一个基本单位。一般而言,线程自己不拥有系统资源(也有一点必不可少的资源),但它可以访问其隶属进程的资源,即一个进程的代码段、数据段及所拥有的系统资源,如已打开的文件、I/O设备等,可以供该进程中的所有线程所共享。

  • (4) 系统开销

    在创建或撤消进程时,系统都要为之创建和回收进程控制块,分配或回收资源,如内存空间和I/O设备等,操作系统所付出的开销明显大于线程创建或撤消时的开销。类似地,在进程切换时,涉及到当前进程CPU环境的保存及新被调度运行进程的CPU环境的设置,而线程的切换则仅需保存和设置少量寄存器内容,不涉及存储器管理方面的操作,所以就切换代价而言,进程也是远高于线程的。此外,由于一个进程中的多个线程具有相同的地址空间,在同步和通信的实现方面线程也比进程容易。在一些操作系统中,线程的切换、同步和通信都无须操作系统内核的干预。

2、Python下的多线程实现

2.1 多线程的基本语法

2.1.1 创建多线程的两种方式:

  • 方式1:Thread类直接创建
import time
import threading

def add(x, y):  # 定义某个线程要运行的函数

    print("%s + %s = %s" % (x, y, x + y))
    time.sleep(3)


def mul(x, y):
    print("%s * %s = %s" % (x, y, x * y))
    time.sleep(5)


# 实例化线程对象
print(time.ctime())

t1 = threading.Thread(target=add, args=(1, 2))
t2 = threading.Thread(target=mul, args=(1, 4))

# 启动线程
t1.start()
t2.start()

# 等待子线程结束
t1.join()
# t2.join()

print("ending...", time.ctime())
  • 方式2:继承Thread式创建
import time
import threading

class MyThread(threading.Thread):

    def __init__(self,x,y):
        # 注意 调用父类
        super().__init__()
        self.x = x
        self.y = y

    #  注意这里 为啥是 run
    def run(self):
        print("%s + %s = %s" % (self.x, self.y, self.x + self.y))
        time.sleep(3)

t1=MyThread(56,44)
t2=MyThread(78,12)

t1.start()
t2.start()
print("ending")

2.2 线程实例对象方法

2.2.1 join方法和setDaemon方法

  • join():在子线程完成运行之前,这个子线程的父线程将一直被阻塞
  • setDaemon(True):守候线程,必须设置在start之前。当非守候线程结束时,守候线程自动结束
A boolean value indicating whether this thread is a daemon thread (True) or not (False). This must be set before start() is called, otherwise RuntimeError is raised. Its initial value is inherited from the creating thread; the main thread is not a daemon thread and therefore all threads created in the main thread default to daemon = False.
The entire Python program exits when no alive non-daemon threads are left.
当daemon被设置为True时,如果主线程退出,那么子线程也将跟着退出,反之,子线程将继续运行,直到正常退出。

在python脚本中,py主线程可以启动其他子线程,当所有线程都运行结束时,进程结束。如果有一个线程没有退出,py进程就不会退出。所以,必须保证所有线程都能及时结束。但是有一种线程的目的就是无限循环,例如,一个定时触发任务的线程,如果这个线程不结束,py进程就无法结束。问题是,由谁负责结束这个线程?然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,py进程又必须要结束,怎么办?答案是使用守护线程(Daemon Thread)。

  • 应用:比如监控进程。进程存活时上报监控数据,进程挂则守护同挂
import threading
from time import ctime,sleep

def listen_music(name):

        print ("Begin listening to {name}. {time}".format(name=name,time=ctime()))
        sleep(3)
        print("end listening {time}".format(time=ctime()))

def write_blog(title):

        print ("Begin recording the {title}. {time}".format(title=title,time=ctime()))
        sleep(5)
        print('end recording {time}'.format(time=ctime()))


if __name__ == '__main__':

    # 线程列表
    threads = []

    t1 = threading.Thread(target=listen_music, args=('FILL ME',))
    t2 = threading.Thread(target=write_blog, args=('python之路',))

    threads.append(t1)
    threads.append(t2)

    # t2.setDaemon(True) # 将t2设置为守护线程
    t1.setDaemon(True) # 将t1设置为守护线程

    for t in threads:

        #t.setDaemon(True) #注意:一定在start之前设置
        t.start()

        # t.join()

    # for t in threads:
    #     t.join()

    print ("all over %s" %ctime())

2.2.2 其他方法

Thread实例对象的方法
  # isAlive(): 返回线程是否活动的。
  # getName(): 返回线程名。
  # setName(): 设置线程名。

threading模块提供的一些方法:
  # threading.currentThread(): 返回当前的线程变量。
  # threading.enumerate(): 
  # 返回一个包含正在运行的线程的list。正在运行指线程启动后、结束前,不包括启动前和终止后的线程。
  # threading.activeCount(): 返回正在运行的线程数量,与len(threading.enumerate())有相同的结果。

2.2.3 GIL(全局解释器锁)

GIL的影响:同一时刻同一进程只有一个线程可以被CPU执行!

解决解释器级别的线程抢占数据安全的问题。但 用户级别 的多线程还是不安全的!

'''

定义:
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.)

'''

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

当然,CPython 不可能容忍一个线程一直独占解释器,它会轮流执行 Python 线程。这样一来,用户看到的就是“伪”并行,即 Python 线程在交替执行,来模拟真正并行的线程。为什么龟叔设计出 GIL 呢?其实,这和 CPython 的底层内存管理有关。CPython 使用引用计数来管理内容,所有 Python 脚本中创建的实例,都会配备一个引用计数,来记录有多少个指针来指向它。当实例的引用计数的值为 0 时,会自动释放其所占的内存。

>>> import sys
>>> a = []
>>> b = a
>>> sys.getrefcount(a)
3

可以看到,a 的引用计数值为 3,因为有 a、b 和作为参数传递的 getrefcount 都引用了一个空列表。

假设有两个 Python 线程同时引用 a,那么双方就都会尝试操作该数据,很有可能造成引用计数的条件竞争,导致引用计数只增加 1(实际应增加 2),这造成的后果是,当第一个线程结束时,会把引用计数减少 1,此时可能已经达到释放内存的条件(引用计数为 0),当第 2 个线程再次视图访问 a 时,就无法找到有效的内存了。所以,CPython 引进 GIL,可以最大程度上规避类似内存管理这样复杂的竞争风险问题。

GIL工作示意图:

py并发编程:GIL锁、进程、线程、协程_第2张图片

上面这张图,就是 GIL 在 Python 程序的工作示例。其中,Thread 1、2、3 轮流执行,每一个线程在开始执行时,都会锁住 GIL,以阻止别的线程执行;同样的,每一个线程执行完一段后,会释放 GIL,以允许别的线程开始利用资源。为什么 Python 线程会去主动释放 GIL 呢?毕竟,如果仅仅要求 Python 线程在开始执行时锁住 GIL,且永远不去释放 GIL,那别的线程就都没有运行的机会。其实,CPython 中还有另一个机制,叫做间隔式检查(check_interval),意思是 CPython 解释器会去轮询检查线程 GIL 的锁住情况,每隔一段时间,Python 解释器就会强制当前线程去释放 GIL,这样别的线程才能有执行的机会。注意,不同版本的 Python,其间隔式检查的实现方式并不一样。早期的 Python 是 100 个刻度(大致对应了 1000 个字节码);而 Python 3 以后,间隔时间大致为 15 毫秒。当然,我们不必细究具体多久会强制释放 GIL,读者只需要明白,CPython 解释器会在一个“合理”的时间范围内释放 GIL 就可以了。

那么是不是python的多线程就完全没用了呢? 当然不是!

在这里我们进行分类讨论:

1、CPU密集型代码(各种循环处理、计数等等),在这种情况下,由于计算工作多,ticks计数很快就会达到阈值,然后触发GIL的释放与再竞争(多个线程来回切换当然是需要消耗资源的),所以python下的多线程对CPU密集型代码并不友好。

2、IO密集型代码(文件处理、网络爬虫等),多线程能够有效提升效率(单线程下有IO操作会进行IO等待,造成不必要的时间浪费,而开启多线程能在线程A等待时,自动切换到线程B,可以不浪费CPU的资源,从而能提升程序执行效率)。所以python的多线程对IO密集型代码比较友好。

而在python3.x中,GIL不使用ticks计数,改为使用计时器(执行时间达到阈值后,当前线程释放GIL),这样对CPU密集型程序更加友好,但依然没有解决GIL导致的同一时间只能执行一个线程的问题,所以效率依然不尽如人意。

请注意:多核多线程比单核多线程更差,原因是单核下多线程,每次释放GIL,唤醒的那个线程都能获取到GIL锁,所以能够无缝执行,但多核下,CPU0释放GIL后,其他CPU上的线程都会进行竞争,但GIL可能会马上又被CPU0拿到,导致其他几个CPU上被唤醒后的线程会醒着等待到切换时间后又进入待调度状态,这样会造成线程颠簸(thrashing),导致效率更低

回到最开始的问题:经常我们会听到老手说:“python下想要充分利用多核CPU,就用多进程”,原因是什么呢?

原因是:每个进程有各自独立的GIL,互不干扰,这样就可以真正意义上的并行执行,所以在python中,多进程的执行效率优于多线程(仅仅针对多核CPU而言)。

所以在这里说结论:多核下,想做并行提升效率,比较通用的方法是使用多进程,能够有效提高执行效率

GIL的影响:同一时刻同一进程只有一个线程可以被CPU执行!

2.2.4 同步锁【解决多线程不安全】

解决用户级别的多线程数据不安全问题!!!将数据不安全的地方加一把锁,数据处理加锁的地方进行串行处理,处理完成后释放。

Python GIL不能绝对保证线程安全,有了 GIL,并不意味着 Python 程序员就不用去考虑线程安全了,因为即便 GIL 仅允许一个 Python 线程执行,但别忘了 Python 还有 check interval 这样的抢占机制。

import time
import threading

def addNum():
    global num #在每个线程中都获取这个全局变量
    #num-=1

    temp=num
    time.sleep(0.1)  # 模拟耗时0.1s  已经线程已经进行抢占资源,数据则不安全!在0.1s中足以遍历100个线程进行赋值,快到将所有的num都赋值成100
    num =temp-1  # 对此公共变量进行-1操作

num = 100  #设定一个共享变量

thread_list = []

for i in range(100):
    t = threading.Thread(target=addNum)
    t.start()
    thread_list.append(t)

for t in thread_list: #等待所有线程执行完毕
    t.join()

print('Result: ', num)

锁通常被用来实现对共享资源的同步访问。为每一个共享资源创建一个Lock对象,当你需要访问该资源时,调用acquire方法来获取锁对象(如果其它线程已经获得了该锁,则当前线程需等待其被释放),待资源访问完后,再调用release方法释放锁:

import threading

R=threading.Lock()

R.acquire()
'''
对公共数据的操作
'''
R.release()
import time
import threading

lock = threading.Lock()


def jianNum():

    global num #在每个线程中都获取这个全局变量
    # num-=1
    # num = num -1

    # 锁住线程
    lock.acquire()
    temp = num
    time.sleep(0.001)
    num = temp -1
    # 放开锁
    lock.release()



num = 100  #设定一个共享变量

thread_list = []

for i in range(100):
    t = threading.Thread(target=jianNum)
    t.start()
    thread_list.append(t)

for t in thread_list: #等待所有线程执行完毕
    t.join()

print('Result: ', num)

2.2.5 死锁【Lock】与递归锁【RLock 解决死锁】

所谓死锁: 是指两个或两个以上的进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

import threading
import time

mutexA = threading.Lock()
mutexB = threading.Lock()

class MyThread(threading.Thread):

    def __init__(self):
        threading.Thread.__init__(self)

    def run(self):
        self.func1()
        self.func2()

    def func1(self):

        mutexA.acquire()  # 如果锁被占用,则阻塞在这里,等待锁的释放

        print ("I am %s , get res: %s---%s" %(self.name, "ResA",time.time()))

        mutexB.acquire()
        print ("I am %s , get res: %s---%s" %(self.name, "ResB",time.time()))
        mutexB.release()
        mutexA.release()


    def func2(self):

        mutexB.acquire()
        print ("I am %s , get res: %s---%s" %(self.name, "ResB",time.time()))
        time.sleep(0.2)

        mutexA.acquire()
        print ("I am %s , get res: %s---%s" %(self.name, "ResA",time.time()))
        mutexA.release()
        mutexB.release()

if __name__ == "__main__":

    print("start---------------------------%s"%time.time())

    for i in range(0, 10):
        my_thread = MyThread()
        my_thread.start()

在Python中为了支持在同一线程中多次请求同一资源,python提供了可重入锁RLock。这个RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次require。直到一个线程所有的acquire都被release,其他的线程才能获得资源。上面的例子如果使用RLock代替Lock,则不会发生死锁:

mutex = threading.RLock()
import threading
import time

# mutexA = threading.Lock()
# mutexB = threading.Lock()

mutex = threading.RLock()

class MyThread(threading.Thread):

    def __init__(self):
        threading.Thread.__init__(self)

    def run(self):
        self.func1()
        self.func2()

    def func1(self):
        mutex.acquire()  # 如果锁被占用,则阻塞在这里,等待锁的释放

        print ("I am %s , get res: %s---%s" %(self.name, "ResA",time.time()))

        mutex.acquire()
        print ("I am %s , get res: %s---%s" %(self.name, "ResB",time.time()))
        mutex.release()
        mutex.release()


    def func2(self):
        mutex.acquire()
        print ("I am %s , get res: %s---%s" %(self.name, "ResB",time.time()))
        mutex.acquire()
        print ("I am %s , get res: %s---%s" %(self.name, "ResA",time.time()))
        mutex.release()
        mutex.release()

if __name__ == "__main__":

    print("start---------------------------%s"%time.time())

    for i in range(0, 10):
        my_thread = MyThread()
        my_thread.start()

2.2.6 信号量

什么是信号量?

  • 互斥锁同时只允许一个线程更改数据,而信号量Semaphore是同时允许一定数量的线程更改数据 。
import threading
import time

semaphore = threading.Semaphore(3)

def func():
    if semaphore.acquire():
        print (threading.currentThread().getName() + ' get semaphore')
        time.sleep(2)
        semaphore.release()

for i in range(20):
  t1 = threading.Thread(target=func)
  t1.start()

2.2.6 队列与生产者消费者模型

Python的Queue模块中提供了同步的、线程安全的队列类,包括FIFO(先入先出)队列Queue,LIFO(后入先出)队列LifoQueue,和优先级队列PriorityQueue。这些队列都实现了锁原语,能够在多线程中直接使用。可以使用队列来实现线程间的同步。

常用方法:

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.task_done() 在完成一项工作之后,Queue.task_done()函数向任务已经完成的队列发送一个信号。每个get()调用得到一个任务,接下来task_done()调用告诉队列该任务已经处理完毕。
Queue.join() 实际上意味着等到队列为空,再执行别的操作

tase_done()的作用:只有消费者把队列所有的数据处理完毕,queue.join()才会停止阻塞

生产者消费者模式并不是GOF提出的众多模式之一,但它依然是开发同学编程过程中最常用的一种模式

img

生产者模块儿负责产生数据,放入缓冲区,这些数据由另一个消费者模块儿来从缓冲区取出并进行消费者相应的处理。该模式的优点在于:

  • 解耦:缓冲区的存在可以让生产者和消费者降低互相之间的依赖性,一个模块儿代码变化,不会直接影响另一个模块儿
  • 并发:由于缓冲区,生产者和消费者不是直接调用,而是两个独立的并发主体,生产者产生数据之后把它放入缓冲区,就继续生产数据,不依赖消费者的处理速度

在线程世界里,生产者就是生产数据的线程,消费者就是消费数据的线程。在多线程开发当中,如果生产者处理速度很快,而消费者处理速度很慢,那么生产者就必须等待消费者处理完,才能继续生产数据。同样的道理,如果消费者的处理能力大于生产者,那么消费者就必须等待生产者。为了解决这个问题于是引入了生产者和消费者模式。

生产者消费者模式是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。

这就像我们邮寄信一样!

import time
from queue import Queue
from threading import Thread
q = Queue()

def produce():
    for i in range(10):
        q.put(i)
        print('生产:',i)
    print('生产任务完毕!')
    q.join()
    print(produce.__name__,'函数结束!')

def consumer():
    for i in range(10):
        print('消费:', q.get())
        q.task_done()
        # if i == 4:
        #     print('休息1s...')
        #     time.sleep(1)#sleep作用:查看生产者是否阻塞
    print(consumer.__name__,'函数结束!')



pro = Thread(target=produce)
con = Thread(target=consumer)

pro.start()
con.start()

con.join()
print('消费者任务完成')
pro.join()
print('生产者任务完成')

3、python下多进程的语法实现

3.1 多进程基本语法

from multiprocessing import Process
import os
import time
def info(name):

    print("name:",name)
    print('parent process:', os.getppid())
    print('process id:', os.getpid())
    print("------------------")
    time.sleep(1)

if __name__ == '__main__':

    info('main process')

    p1 = Process(target=info, args=('yuan',))
    p2 = Process(target=info, args=('alex',))
    p1.start()
    p2.start()

    p1.join()
    p2.join()

    print("ending")

python解释器是一份本地化的程序,本质上是可执行的文件,是静态的概念。程序运行起来成为进程,是动态的概念。python程序是跑在解释器上的,严格来讲,是跑在解释器实例上的,一个解释器实例其实就是解释器跑起来的进程,二者合起来称之为一个Python进程。各个解释器实例之间是相互隔离的

3.2 进程池

进程池:定义了一个池子,在里面放上固定数量的进程,有需求来了,就拿这个池中的一个进程来处理任务,等到处理完毕,进程并不关闭,而是将进程再放回进程池中继续等待任务。如果有许多任务需要执行,池中的进程数量不够,任务就要等待之前的进程执行任务完毕归来,拿到空闲进程才能继续执行。

import multiprocessing
import os
import time

from concurrent.futures import ProcessPoolExecutor


def run_case(*text):
    print('入参:{0},当前进程号:{1},父进程号:{2}'.format(text, os.getpid(),os.getppid()))
    time.sleep(1)
    return 123


if __name__ == '__main__':
    pool_num = ProcessPoolExecutor(4)
    print('主进程:{0}'.format(os.getpid()))
    print('子进程开始咯')
    for i in range(20):
        ret = pool_num.submit(run_case, i)
        # print(ret.result())
    pool_num.shutdown()
    print('子进程结束了')
    print('主进程结束了')

总结:进程池的优势在于不会立即销毁进程,不会重新启动新的进程。效率更高。

3.3 进程通信

3.3.1 进程队列

from multiprocessing import Process, Queue
import time

def f(q,n):
    time.sleep(2)
    q.put(n*n+1)

if __name__ == '__main__':
    q = Queue()
    for i in range(3):
        p = Process(target=f, args=(q,i))
        p.start()

    print(q.get())
    print(q.get())
    print(q.get())

3.3.2 管道通信

from multiprocessing import Process, Pipe


def f(conn):
    conn.send([12, {
     "name": "yuan"}, 'hello'])
    response = conn.recv()
    print("子进程接收:", response)
    conn.close()


if __name__ == '__main__':

    parent_conn, child_conn = Pipe()

    p = Process(target=f, args=(child_conn,))
    p.start()
    data = parent_conn.recv()
    print("主进程接收:",data)  # prints "[42, None, 'hello']"
    parent_conn.send("儿子你好!")
    p.join()

3.3.3 manager

Queue和pipe只是实现了数据交互,并没实现数据共享,即一个进程去更改另一个进程的数据。

from multiprocessing import Process, Manager

def f(d, l,n):

    d[n] = n
    d["name"] ="alvin"
    l.append(n)

    #print("l",l)

if __name__ == '__main__':

    with Manager() as manager:

        d = manager.dict()
        l = manager.list(range(5))
        p_list = []

        for i in range(5):
            p = Process(target=f, args=(d,l,i))
            p.start()
            p_list.append(p)

        for res in p_list:
            res.join()

        print(d)
        print(l)

4、协程(coroutine)

协程(coroutine)可以理解为是线程的优化,又称之为轻量级进程。它是一种比线程更节省资源、效率更高的系统调度机制。协程具有这样的特点,即在同时开启的多个任务中,一次只执行一个,只有当前任务遭遇阻塞,才会切换到下一个任务继续执行。这种机制可以实现多任务的同步,又能够成功地避免线程中使用锁的复杂性,简化了开发。早先的协程是使用生成器关键字 yield 来实现的,代码特别复杂难懂。自从Python3.5之后,确定了协程的语法,使得创建协程的方式得到改善。Python 中,能够实现协程的模块有多个,如 asyncio、tornado 或 gevent。

协程不是被操作系统内核所管理的,而是完全由程序所控制,也就是在用户态执行。这样带来的好处是性能大幅度的提升,因为不会像线程切换那样消耗资源。

协程不是进程也不是线程,而是一个特殊的函数,这个函数可以在某个地方挂起,并且可以重新在挂起处外继续运行。所以说,协程与进程、线程相比并不是一个维度的概念。

一个进程可以包含多个线程,一个线程也可以包含多个协程。简单来说,一个线程内可以由多个这样的特殊函数在运行,但是有一点必须明确的是,一个线程的多个协程的运行是串行的。如果是多核CPU,多个进程或一个进程内的多个线程是可以并行运行的,但是一个线程内协程却绝对是串行的,无论CPU有多少个核。毕竟协程虽然是一个特殊的函数,但仍然是一个函数。一个线程内可以运行多个函数,但这些函数都是串行运行的。当一个协程运行时,其它协程必须挂起。

4.1 进程、线程、协程的对比

  • 协程既不是进程也不是线程,协程仅仅是一个特殊的函数,协程它与进程和线程不是一个维度的。
  • 一个进程可以包含多个线程,一个线程可以包含多个协程。
  • 一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用CPU多核能力。
  • 协程与进程一样,切换是存在上下文切换问题的。

上下文切换

  • 进程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户是无感知的。进程的切换内容包括页全局目录、内核栈、硬件上下文,切换内容保存在内存中。进程切换过程是由“用户态到内核态到用户态”的方式,切换效率低。
  • 线程的切换者是操作系统,切换时机是根据操作系统自己的切换策略,用户无感知。线程的切换内容包括内核栈和硬件上下文。线程切换内容保存在内核栈中。线程切换过程是由“用户态到内核态到用户态”, 切换效率中等。
  • 协程的切换者是用户(编程者或应用程序),切换时机是用户自己的程序所决定的。协程的切换内容是硬件上下文,切换内存保存在用户自己的变量(用户栈或堆)中。协程的切换过程只有用户态,即没有陷入内核态,因此切换效率高。

4.2 yield实现的协程

import time

"""
传统的生产者-消费者模型是一个线程写消息,一个线程取消息,通过锁机制控制队列和等待,但一不小心就可能死锁。
如果改用协程,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高。
"""


# 注意到consumer函数是一个generator(生成器):
# 任何包含yield关键字的函数都会自动成为生成器(generator)对象

def consumer():
    r = ''
    while True:
        # 3、consumer通过yield拿到消息,处理,又通过yield把结果传回;
        #    yield指令具有return关键字的作用。然后函数的堆栈会自动冻结(freeze)在这一行。
        #    当函数调用者的下一次利用next()或generator.send()或for-in来再次调用该函数时,
        #    就会从yield代码的下一行开始,继续执行,再返回下一次迭代结果。通过这种方式,迭代器可以实现无限序列和惰性求值。
        n = yield r
        if not n:
            return
        print('[CONSUMER] ←← Consuming %s...' % n)
        time.sleep(1)
        r = '200 OK'


def produce(c):
    # 1、首先调用c.next()启动生成器
    next(c)
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] →→ Producing %s...' % n)
        # 2、然后,一旦生产了东西,通过c.send(n)切换到consumer执行;
        cr = c.send(n)
        # 4、produce拿到consumer处理的结果,继续生产下一条消息;
        print('[PRODUCER] Consumer return: %s' % cr)
    # 5、produce决定不生产了,通过c.close()关闭consumer,整个过程结束。
    c.close()


if __name__ == '__main__':
    # 6、整个流程无锁,由一个线程执行,produce和consumer协作完成任务,所以称为“协程”,而非线程的抢占式多任务。
    c = consumer()
    produce(c)

4.3 greenlet实现的协程

from greenlet import greenlet
import time


def task_1():
    print("task_1...")
    while True:
        print("--This is task 1!--")
        g2.switch()  # 切换到g2中运行
        time.sleep(0.5)


def task_2():
    print("task_2...")
    while True:
        print("--This is task 2!--")
        g1.switch()  # 切换到g1中运行
        time.sleep(0.5)


if __name__ == "__main__":
    g1 = greenlet(task_1)  # 定义greenlet对象
    g2 = greenlet(task_2)

    g1.switch()  # 切换到g1中运行

greenlet已经实现了协程,但是这个需要人工切换,很麻烦。python中还有一个比greenlet更强大的并且能够自动切换任务的模块gevent,其原理是当一个greenlet遇到IO(比如网络、文件操作等)操作时,比如访问网络,就自动切换到其他的greenlet,等到IO操作完成,再在适当的时候切换回来继续执行。由于IO操作非常耗时,经常使程序处于等待状态,有了gevent为我们自动切换协程 ,就保证总有greenlet在运行,而不是等待IO。

4.4 gevent实现的协程

import gevent


def task_1(num):
    for i in range(num):
        print(gevent.getcurrent(), i)
        gevent.sleep(1)  # 模拟一个耗时操作,注意不能使用time模块的sleep


if __name__ == "__main__":
    g1 = gevent.spawn(task_1, 5)  # 创建协程
    g2 = gevent.spawn(task_1, 5)
    g3 = gevent.spawn(task_1, 5)

    g1.join()  # 等待协程运行完毕
    g2.join()
    g3.join()

上述结果,在不添加gevent.sleep(1)时,是3个greenlet依次运行,而不是交替运行的。在添加gevent.sleep(1)后,程序运行到这后,交出控制权,执行下一个协程,等待这个耗时操作完成后再重新回到上一个协程,运行结果时交替运行。

4.5 monkey补丁【猴子补丁】

monkey补丁 不必强制使用gevent里面的sleep、sorcket等等了

from gevent import monkey
monkey.patch_all()
import gevent
from urllib import request
import time

def f(url):
    print('GET: %s' % url)
    resp = request.urlopen(url)
    data = resp.read()
    print('%d bytes received from %s.' % (len(data), url))

start=time.time()

gevent.joinall([
        gevent.spawn(f, 'https://itk.org/'),
        gevent.spawn(f, 'https://zhihu.com/'),
])

# f('https://itk.org/')
# f('https://zhihu.com/')

print(time.time()-start)

你可能感兴趣的:(Python,python,协程,GIL)