關於協程的理解

yamx發表於2020-10-22

  關於協程,我從程式和執行緒出發梳理一下它們之間的關係。

程式

  一個程式的執行必定是會產生程式的,簡單的說程式就是一個程式的執行過程。

  早期,作業系統中一直都是以程式作為獨立執行的基本單位,這也就意味著一個程式既是資源的擁有者又是任務的執行者。通常情況下,這沒什麼問題。但是,程式中一旦出現了耗時操作,往往會引發效率問題(比如一個程式需要做不同的事情,每個事情之間互不相干,那麼程式只能順序執行下去,一旦一個事件耗時較高就會影響到其他事件的執行)。因此,就有了多程式通過併發來提高程式的執行效率。

  但是,多程式又帶來一些問題:

    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,所以,這裡的多執行緒耗時,主要是執行緒切換導致的。

  多協程只存在於一個執行緒中執行,所以,這裡的多協程耗時,主要是協程切換導致的。

  可以看到,結果和預期判斷的是一致的:程式資源消耗最大,執行緒次之,協程最小

相關文章