关于协程的理解

  关于协程,我从进程和线程出发梳理一下它们之间的关系。

进程

  一个程序的执行必定是会产生进程的,简单的说进程就是一个程序的执行过程。

  早期,操作系统中一直都是以进程作为独立运行的基本单位,这也就意味着一个进程既是资源的拥有者又是任务的执行者。通常情况下,这没什么问题。但是,程序中一旦出现了耗时操作,往往会引发效率问题(比如一个程序需要做不同的事情,每个事情之间互不相干,那么进程只能顺序执行下去,一旦一个事件耗时较高就会影响到其他事件的执行)。因此,就有了多进程通过并发来提高程序的执行效率。

  但是,多进程又带来一些问题:

    1、进程的创建、切换需要消耗一定的内存和时间

      a、进程是资源分配的基本单位,那么每创建一个进程都需要分配与主进程同等的资源和一些子进程独有的数据(进程号、堆栈等)。

      b、进程的切换受操作系统的控制和调度的,所以每次切换操作系统都需要从用户态到内核态,完成调度又要返回到用户态,这样就带来一定的时间消耗。并且切换时,还要保存当前进程的状态(包括各种寄存器的信息、程序状态字PSW等)。

    2、进程间数据隔离,需要通过IPC机制来解决

      一旦多任务需要共享一些数据就需要额外的开销。

  为了解决这些问题,便提出了线程的概念。

线程

  线程是进程的一条执行流程(简称执行流,也被叫作轻型进程)。

  为了解决早期进程并发带来的问题,重新对进程作了解释:

    进程只作为资源分配的基本单位,把一组相关的资源组合起来,构成一个资源平台,包括地址空间、打开的文件等各种资源。

  而线程就是执行和调度的基本单位,因此线程被称为进程的执行流(一个进程至少包含一个线程,这个线程被称为主线程)。

  由于线程是进程的执行流,线程不能脱离进程独立运行,必须依存于进程中。并且线程满足:

    1、线程本身并不拥有系统资源,仅有一点必不可少的能够保证独立运行的资源,因此开设线程的空间成本要远小于进程。

    2、线程的切换仅需保存和设置少量的寄存器内容,并且同一个进程下的线程切换不会引起进程的切换,所以时间开销也远小于进程切换的开销。

  需要注意的是,因为线程间共享同一个进程的资源,所以程序并发过程中,很可能会出现资源竞争问题,导致数据紊乱,此时可以使用互斥锁来保证数据的安全。但是,使用锁的时候也需要注意“死锁问题”。

  导致死锁的必要条件(缺一不可):

    1、互斥条件

    2、请求和保持条件

    3、不可抢占条件

    4、循环等待条件

  死锁的现象:双方或多方互相等待对方已经获得的资源,导致进程运行停滞不前的现象。

  解决死锁的方法:

    1、添加超时时间(对于已经获得部分资源但是长时间无法获得剩余资源的线程,就将它已经获得的资源释放给其他线程使用)

    2、 银行家算法(避免系统进入不安全状态)

      每当有线程请求一个可用资源的时候,都需要对该线程的本次请求进行计算,判断本次请求过后系统是否会进入不安全状态。若导致进入不安全状态就拒绝本次请求。反之,同意该请求。

  线程的提出,已经大大提高了程序的并发效率,并且极大程度的降低了并发过程中所带来的开销。但是,由于线程还是受os控制和调度的(一般所说的线程通常是指内核级线程),它在执行过程中,难免也会遇到阻塞(系统调用,IO请求等),那么os就会将其挂起,将cpu分配给其他的线程使用。这样的话,单个线程的执行效率就受到一定的影响。同时,由于python的GIL机制,导致同一个进程下的多线程无法利用多核的优势(同一个进程下的多线程同一时间只能有一个运行,在运行前通过抢GIL来获得运行权)。

  为了使单个线程的执行效率得到提升,便提出了协程的概念。

协程

  前面提到线程是进程的一条执行流,那么协程其实就可以看作是线程的一条执行流。

  协程是一个用户级线程,也被称为微线程。协程的管理和调度完全由用户来决定,因此,与进程和线程相比,协程的执行顺序是明确的,而进程和线程的执行顺序不固定,由操作系统来决定。

  直观上看,协程的切换就是在函数之间的来回切换执行,它的空间消耗和切换时的时间消耗与线程相比还要少。因为线程的切换是受操作系统控制的,线程间切换产生的上下文操作以及用户态与内核态的切换成本都要大于协程。

  协程最主要的作用:为了提高单线程的执行效率,在单线程中开设多协程,当某个协程遇到阻塞就切换至其他协程继续执行,充分利用阻塞的时间。

  但是,协程也存在一些问题:

    1、如果一个协程阻塞了,那么整个进程都会进入阻塞,并且操作系统无法感知到它(协程是用户级线程,不属于操作系统管理的范畴),也就无法对相应的阻塞做出处理(这个问题后续会用到gevent模块来解决)。

    2、一个协程运行后,除非主动交出CPU的使用权,否则无法被其他协程抢占。

    3、操作系统在分配时间片的时候,是针对进程或线程级别来分配的,对于拥有多个协程的进程或线程而言,每个协程相对获得的执行时间较少,性能也就无法得到充分利用。

  

  python原生的generator就可以实现协程,通过yield关键字可以在切换过程中,完成数据交换和运行状态的保存。举个栗子:

def producer():
c = consumer()
c.send(None)
for i in range(3):
msg = c.send(i)
print(msg)
c.close()


def consumer():
msg = None
while True:
num = yield msg
msg = "consumer从producer那获得到了:" + str(num)


if __name__ == '__main__':
producer()
# 执行结果: 
consumer从producer那获得到了: 0
consumer从producer那获得到了:
1
consumer从producer那获得到了:
2

  不过generator实现的协程,只能简单地完成调用者与generator的实例之间的切换,一旦协程数量增多,切换就变得困难。而greenlet模块(比generator更高级的协程)能够实现了协程之间的任意切换。

  注意:

    协程不一定就能够使程序提高执行效率,在计算密集型的场景下,反而会影响效率。协程更适合于IO密集型的场景,至于如何将阻塞时间充分利用起来,可以去了解一下gevent模块。

    

import time


def task1():
    for i in range(10000000):
        i += 1
        yield


def task2():
    g = task1()
    for i in range(10000000):
        i += 1
        next(g)


if __name__ == '__main__':
    start = time.time()
    task2()
    print(time.time() - start)  # 2.470390558242798s 
    # 计算密集型的情况下,使用协程实现并发不但不会提高效率,反而还会降低效率
    # 因为对于协程来实现并发而言,初衷就是为了将程序执行过程中的阻塞时间充分利用,减少不必要的实际浪费,从而提高效率
    # 而计算密集型的场景下,本身就没有阻塞事件,反而是协程之间的切换会带来不必要的开销
def task3():
    for i in range(10000000):
        i += 1



def task4():
    for i in range(10000000):
        i += 1


if __name__ == '__main__':
    start = time.time()
    task3()
    task4()
    print(time.time() - start)  # 1.025256872177124s,此时采用串行执行效果更佳

进程、线程、协程的区别

  1、进程和线程都是由操作系统内核调用,而协程是由用户来决定调度的。也就是说进程和线程的上下文是在内核态中保存并恢复的,而协程是在用户态保存并恢复的。很显然用户态的代价要更低。并且进程和线程的调度顺序不固定,而协程的调度执行顺序是确定的。

  2、进程和线程会被抢占,而协程不会。除非协程主动让出CPU,否则其他协程无法得到执行的权利。

  3、对内存的占用不同,进程占用内存最大,线程次之,协程最小。

  4、协程依赖于线程,线程依赖于进程

  5、进程资源消耗最大,线程次之,协程最小。

 

  最后通过一个例子来看一下多进程、多线程以及多协程三者的并发效果:

from multiprocessing import Process
from threading import Thread
import gevent
import time


def task1():
    for _ in range(1000):
        time.sleep(0.001)


def task2():
    for _ in range(1000):
        time.sleep(0.001)


def task3():
    """用于协程测试用"""
    for _ in range(1000):
        gevent.sleep(0.001)


def task4():
    """用于协程测试用"""
    for _ in range(1000):
        gevent.sleep(0.001)



def multiprocess(task_list):
    start = time.time()
    processes = [Process(target=task) for task in task_list]
    [process.start() for process in processes]
    [process.join() for process in processes]
    print("多进程耗时:", time.time() - start)


def multithread(task_list):
    start = time.time()
    threads = [Thread(target=task) for task in task_list]
    [thread.start() for thread in threads]
    [thread.join() for thread in threads]
    print("多线程耗时:", time.time() - start)


def multicoroutine(task_list):
    start = time.time()
    jobs = [gevent.spawn(task) for task in task_list]
    gevent.joinall(jobs)
    print("多协程耗时:", time.time() - start)


if __name__ == '__main__':
    task_list1 = [task1, task2]
    task_list2 = [task3, task4]
    multiprocess(task_list1)
    multithread(task_list1)
    multicoroutine(task_list2)

  执行结果如下:  

    多进程耗时: 2.1703639030456543
    多线程耗时: 1.9359626770019531
    多协程耗时: 1.698333740234375

  例子中只有两个任务并发,由于多进程在cpu核数大于任务数的情况下,多任务是并行执行的。所以,这里的多进程耗时没有切换进程的消耗,只是创建进程和销毁进程的消耗。

  由于python的GIL机制,一个进程下的多线程也只能共用一个cpu,所以,这里的多线程耗时,主要是线程切换导致的。

  多协程只存在于一个线程中执行,所以,这里的多协程耗时,主要是协程切换导致的。

  可以看到,结果和预期是一致的:进程资源消耗最大,线程次之,协程最小

posted @ 2020-10-22 20:57  yamx  阅读(511)  评论(0编辑  收藏  举报