1. 程式人生 > >Python 多執行緒、多程序 (一)之 原始碼執行流程、GIL

Python 多執行緒、多程序 (一)之 原始碼執行流程、GIL

Python 多執行緒、多程序 (一)之 原始碼執行流程、GIL
Python 多執行緒、多程序 (二)之 多執行緒、同步、通訊
Python 多執行緒、多程序 (三)之 執行緒程序對比、多執行緒

一、python程式的執行原理

許多時候,在執行一個python檔案的時候,會發現在同一目錄下會出現一個__pyc__資料夾(python3)或者.pyc字尾(python2)的檔案
Python在執行時,首先會將.py檔案中的原始碼編譯成Python的byte code(位元組碼),然後再由Python Virtual Machine(Python虛擬機器)來執行這些編譯好的byte code。

1、執行流程

原始碼.py ——(編譯處理)——>位元組碼.pyc ———>python虛擬機器——(編譯)——>程式

2、編譯

執行 python demo.py 後,將會啟動 Python 的直譯器,然後將 demo.py 編譯成一個位元組碼物件 PyCodeObject。

在 Python 的世界中,一切都是物件,函式也是物件,型別也是物件,類也是物件(類屬於自定義的型別,在 Python 2.2 之前,int, dict 這些內建型別與類是存在不同的,在之後才統一起來,全部繼承自 object),甚至連編譯出來的位元組碼也是物件,.pyc 檔案是位元組碼物件(PyCodeObject)在硬碟上的表現形式。

在執行期間,編譯結果也就是 PyCodeObject 物件,只會存在於記憶體中,而當這個模組的 Python 程式碼執行完後,就會將編譯結果儲存到了 pyc 檔案中,這樣下次就不用編譯,直接載入到記憶體中。pyc 檔案只是 PyCodeObject 物件在硬碟上的表現形式。
這個 PyCodeObject 物件包含了 Python 原始碼中的字串,常量值,以及通過語法解析後編譯生成的位元組碼指令。PyCodeObject 物件還會儲存這些位元組碼指令與原始程式碼行號的對應關係,這樣當出現異常時,就能指明位於哪一行的程式碼。

3、pyc檔案

一個 pyc 檔案包含了三部分資訊:Python 的 magic number、pyc 檔案建立的時間資訊,以及 PyCodeObject 物件。

magic number 是 Python 定義的一個整數值。一般來說,不同版本的 Python 實現都會定義不同的 magic number,這個值是用來保證 Python 相容性的。比如要限制由低版本編譯的 pyc 檔案不能讓高版本的 Python 程式來執行,只需要檢查 magic number 不同就可以了。由於不同版本的 Python 定義的位元組碼指令可能會不同,如果不做檢查,執行的時候就可能出錯。

4、位元組碼指令

為什麼 pyc 檔案也稱作位元組碼檔案?因為這些檔案儲存的都是一些二進位制的位元組資料,而不是能讓人直觀檢視的文字資料。
Python 標準庫提供了用來生成程式碼對應位元組碼的工具 dis。dis 提供一個名為 dis 的方法,這個方法接收一個 code 物件,然後會輸出 code 物件裡的位元組碼指令資訊。

# test1.py

import dis

def add(a):
    a = a+1
    return a

print(dis.dis(add))

# 輸出
 10           0 LOAD_FAST                0 (a)
              3 LOAD_CONST               1 (1)
              6 BINARY_ADD
              7 STORE_FAST               0 (a)

 11          10 LOAD_FAST                0 (a)
             13 RETURN_VALUE

5、python虛擬機器

demo.py 被編譯後,接下來的工作就交由 Python 虛擬機器來執行位元組碼指令了。Python 虛擬機器會從編譯得到的 PyCodeObject 物件中依次讀入每一條位元組碼指令,並在當前的上下文環境中執行這條位元組碼指令。我們的程式就是通過這樣迴圈往復的過程才得以執行。

二、程序執行緒

1、程序

程式僅僅只是一堆程式碼而已,而程序指的是程式的執行過程。需要強調的是:同一個程式執行兩次,那也是兩個程序。
程序:資源管理單位(容器)。
執行緒:最小執行單位,管理執行緒的是程序。

程序就是一個程式在一個數據集上的一次動態執行過程。程序一般由程式、資料集、程序控制塊三部分組成。我們編寫的程式用來描述程序要完成哪些功能以及如何完成;資料集則是程式在執行過程中所需要使用的資源;程序控制塊用來記錄程序的外部特徵,描述程序的執行變化過程,系統可以利用它來控制和管理程序,它是系統感知程序存在的唯一標誌。

2、執行緒

執行緒的出現是為了降低上下文切換的消耗,提高系統的併發性,並突破一個程序只能幹一樣事的缺陷,使到程序內併發成為可能。
執行緒也叫輕量級程序,它是一個基本的CPU執行單元,也是程式執行過程中的最小單元,由執行緒ID、程式計數器、暫存器集合和堆疊共同組成。執行緒的引入減小了程式併發執行時的開銷,提高了作業系統的併發效能。執行緒沒有自己的系統資源。

3、執行緒與程序關係

在傳統作業系統中,每個程序有一個地址空間,而且預設就有一個控制執行緒。
多執行緒(即多個控制執行緒)的概念是,在一個程序中存在多個控制執行緒,控制該程序的地址空間。
程序只是用來把資源集中到一起(程序只是一個資源單位,或者說資源集合),而執行緒才是cpu上的執行單位。

程序和執行緒的關係:
(1)一個執行緒只能屬於一個程序,而一個程序可以有多個執行緒,但至少有一個執行緒。
(2)資源分配給程序,同一程序的所有執行緒共享該程序的所有資源。
(3)CPU分給執行緒,即真正在CPU上執行的是執行緒。

4、序列,並行與併發

比較重要的就是,無論是並行還是併發,在使用者看來都是'同時'執行的,而一個cpu同一時刻只能執行一個任務。
並行:同時執行,只有具備多個cpu才能實現並行。
併發:是偽並行,即看起來是同時執行,單個cpu+多道技術。
多道技術:記憶體中同時存入多道(多個)程式,cpu從一個程序快速切換到另外一個,並且切換時間十分短暫,所以給人的感覺是我可以邊打遊戲邊聽歌。多個程式並行執行,其實是偽並行即併發。

阮一峰老師關於執行緒程序更形象介紹[傳送門]

5、同步與非同步

同步就是指一個程序在執行某個請求的時候,若該請求需要一段時間才能返回資訊,那麼這個程序將會一直等待下去,直到收到返回資訊才繼續執行下去;非同步是指程序不需要一直等下去,而是繼續執行下面的操作,不管其他程序的狀態。當有訊息返回時系統會通知程序進行處理,這樣可以提高執行的效率。

打電話時就是同步通訊,發短息時就是非同步通訊。

6、生產者與消費者

生產者消費者模式:

某些模組負責生產資料,這些資料由其他模組來負責處理(此處的模組可能是:函式、執行緒、程序等)。產生資料的模組稱為生產者,而處理資料的模組稱為消費者。在生產者與消費者之間的緩衝區稱之為倉庫。生產者負責往倉庫運輸>商品,而消費者負責從倉庫裡取出商品,這就構成了生產者消費者模式。
比如在網路I/O的時候,一個物件負責請求資料,另一個物件負責處理資料,中間就需要一個容器來負責資料的緩衝,平衡兩個物件之間的處理速度的協調。

優點:

  • 解耦:由於兩個物件之間的方法獨立,資料的獲取只需要通過介面的呼叫,所以兩者的依賴性低,可重用性高
  • 平衡了生產力與消費力,就是生產者一直不停的生產,消費者可以不停的消費,因為二者不再是直接溝通的,而是通過資料緩衝區溝通的。生產者的資料直接丟入緩衝區,消費者直接從緩衝區那資料,就不會造成因為資料因為過剩造成生產者阻塞,或者資料過少消費者阻塞的問題

舉例

男生:我負責掙錢養家,你呢?
女生:我負責貌美如花。
男生:那如果錢不夠?
女生:那就等錢夠了再娶我,我等著!
男生:如果錢太多呢?
女生:那就存著,我慢慢花!

從上面可以抽象出三個物件,生產者(男生),消費者(女生),資料(錢),而資料暫存到哪,一般是為了解決加鎖問題,放到佇列而不是簡單的容器型別。

三、全域性直譯器鎖

全域性直譯器鎖(Global Interpreter Lock):簡稱GIL,多程序(mutilprocess) 和 多執行緒(threading)的目的是用來被多顆CPU進行訪問, 提高程式的執行效率。 但是多執行緒之間資料完整性和狀態同步是一個很大的問題,所以在python內部存在一種機制(GIL),在多執行緒時同一時刻只允許一個執行緒來訪問CPU,也就是不同執行緒對共享資源的互斥。 在一個執行緒擁有了直譯器的訪問權之後,其他的所有執行緒都必須等待它釋放直譯器的訪問權,即使這些執行緒的下一條指令並不會互相影響。GIL 並不是Python的特性,它是在實現Python解析器(CPython)時所引入的一個概念。因為CPython是大部分環境下預設的Python執行環境。所以在把GIL之殤歸結給Python是不對的。GIL並不是Python的特性,Python完全可以不依賴於GIL。例如Jython(java編寫的python直譯器)就不會存在GIL。

  • python中一個執行緒對應於c語言中的一個執行緒
  • GIL使得同一個時刻只有一個執行緒在一個CPU上執行位元組碼, 無法將多個執行緒對映到多個cpu上執行,因此python是無法利用多核CPU實現多執行緒的
  • 大量的第三方包都是基於CPython編寫的,所以短期內想把GIL去掉不太可能

1、GIL優缺點

缺點:多處理器退化為單處理器;
優點:避免大量的加鎖解鎖操作

2、GIL釋放

要實現python的多執行緒就需要藉助標準庫threading

# test2.py

import threading

total = 0

def add():
    # 連續執行total的加操作
    global total
    for i in range(1000000):
        total += 1

def reduce():
    # 連續執行total的減操作
    global total
    for i in range(1000000):
        total -= 1

# 建立兩個執行緒
thread1 = threading.Thread(target=add)
thread2 = threading.Thread(target=desc)

# 執行緒開始
thread1.start()
thread2.start()

# 執行緒結束
thread1.join()
thread2.join()
print(total)

使用total作為標誌,通過total的值判斷執行緒的實現。
如果實現GIL沒有釋放的的話,那麼兩個執行緒先後完成,列印結果應該是0,而實際列印結果卻不是0,並且每次列印結果也都不一致,說明實現了GIL主動釋放掉了。
total變數是一個全域性變數,其實在add與reduce內部的賦值語句total+=1與total-=1時,高階語言每一條語句在CPU上執行的時候又被對應成許多語句,比如total+=1對應成x1=total+1,total=x1,而total-=1被對應成x2=toal-1,total=x2,每一個x都是函式內部的區域性變數。
可以對應位元組碼指令來理解,可以參照上面GIL中的例項使用dis模組獲取位元組碼檢視,PVM(python虛擬機器)其實執行的也就是位元組碼指令。。
正常執行:

初始total=0

add:
x1 = total +1  # x1 = 1
total = x1
total = 1

reduce:
x2 = total-1  # x2 = 0
total = x2
total = 0

最終迴圈一次結果0
正常應該是無論多少次迴圈結果total都是0

多執行緒共享變數,兩個執行緒交替佔用cpu,:

total=0

add:

x1 = total + 1  # x1 = 1

reduce:
x2 = total - 1  # x2 = -1
total = x2 # total = -1

add:
total = x1
total =1

最終迴圈結果為1
只要進行足夠多的迴圈,total的值就會出現不可預計的結果

所以,在修改total值的時候,需要多條語句。所以我覺得上面的例子可以這麼理解:就是當一個執行緒在執行的時候也就是PVM在執行位元組碼指令,當位元組碼指令到達一定數目(ticks專門計數),此執行緒不再擁有GIL(釋放GIL,release)並且釋放CPU資源,但是其他的執行緒又過來搶,這個執行緒沒搶過它,GIL就這樣別搶走了,CPU資源就暫時交給其他的執行緒了(嗯,天道有輪迴,下次我還會搶回來的)。因此,執行緒之間共享資料最大的危險在於多個執行緒同時改一個變數。所以在進行python多執行緒變成的時候,一般會進行細粒度的自定義加鎖,以保證安全性。

問題:GIL什麼時候會釋放?

  • 執行的位元組碼行數到達一定閾值
  • 通過時間片劃分,到達一定時間閾值
  • 在遇到IO操作時,主動釋放

關於GIL,強烈推薦參閱Understand GIL:[傳送門],在Understand中作者在Python2.X的環境中隊多核CPU,單核CPU上,多執行緒以及單執行緒做了詳細對比,並且對CPython的執行緒執行做了詳細的跟蹤,從根本上解釋了GIL對python 多執行緒程式設計的影響和GIL的趨勢。雖然英文原版,但是除了一些英文術語詞彙,沒有太難的句子,對英語渣還是很友好的。

有了預備知識來看下一篇吧,Python 多執行緒、多程序 (二)之 多執行緒、同步、通訊