Python多執行緒程式設計

昀溪發表於2018-08-05

本文大綱

  1. 程式與執行緒
  2. Python的GIL
  3. 多執行緒程式設計及執行緒間通訊

程式與執行緒

程式就是一堆程式碼也就是在磁碟上的一個或多個檔案。當程式執行起來也就被載入到記憶體中開始執行它的指令這時候才是真正的程式。執行中的QQ、Word就是一個程式。

那執行緒又是什麼呢?無論怎麼說一個程式至少包含一個執行緒作為它的指令執行體,執行緒你可以理解為程式中要執行的一個任務,那麼其實程式可以包含多個任務,可是在單核心CPU的時代這些任務只能順序執行,哪怕這些任務是獨立的。比如Word的自動儲存功能,你在編寫文件時定期會自動儲存這時候其實就是一個執行緒執行了這個任務。

程式管理資源而將執行緒分配到某個cup上執行,也就是說執行緒是CPU排程的最小單位。一個程式可以擁有多個執行緒,如果程式執行在多核心CPU上,那就可以把多個執行緒分配到多個核心上去執行,最大化並行處理。就算是單核心CPU也可以透過模擬出來並行(CPU時間片概念),雖然這帶來更多上下文切換但是執行緒的切換比程式的切換開銷要小的多,因為除了CPU資源之外其他的資源程式內的執行緒都是共享的。

注意:執行緒分為核心執行緒和使用者執行緒,區分標準就是執行緒的排程者在核心內部還是外部。核心執行緒更利於併發使用多核心處理器的資源,而使用者執行緒更多考慮的是上下文切換開銷。目前主流作業系統中都是兩者結合使用只是組合會有差異。我們這裡說的多執行緒或者多程式其實是使用者執行緒,無論你是用Python還是Java編寫的程式。

場景描述

單程式模型:
比如一個WEB伺服器,程式正在執行,監聽在某一個埠,這時候如果有一個使用者請求,那麼是不是應該讓這個程式來處理呢?可以,但是一般不會這麼做,因為如果來了第二個請求,顯然之前那個程式還在處理第一個請求,根本無法處理第二個。所以不會讓那個程式來處理,而是基於它產生一個子程式,讓子程式來處理,這樣看似問題解決了,但是你想,來10個請求我可以產生10個子程式,如果來1萬呢?比如一個子程式消耗1M記憶體,那麼這就要消耗將近10G記憶體,這顯然不可接受,這還沒有考慮產生一個子程式的其他系統開銷,如果每個程式都訪問主頁,那麼就要把主頁資料載入到記憶體,如果一個主頁消耗2M記憶體,那麼1個請求就要佔用1萬個2M記憶體空間,加上之前的1M,總量就將近30G。如果CPU只有一顆的話,那效能將會非常糟糕,會有頻繁的CPU切換。

單程式多執行緒模型:
上面是程式模型,如果改用執行緒模型的話效率會大大提高,一個父程式生產一個子程式,一個子程式內部產生多個執行緒,因為產生一個執行緒基本沒有什麼記憶體佔用或者是非常小(CentOS的預設執行緒棧8K),而且執行緒是可以共享程式資源的,還是上面的例子,只需要開啟一次2M的主頁,1萬個請求共享這一個,這就大大節約了記憶體空間。但是如果你還是隻有一個CPU的話,那麼還是無法實現多個執行緒同時執行,依然存在切換問題。如果是多核心則會更好,如果還沒有資源徵用(典型的場景就是A執行緒要讀取一個正在被B執行緒寫入的檔案)那麼效果會更好。

任務可以分為計算密集型和IO密集型。前者主要是大量佔用CPU後者主要是訪問磁碟或者網路請求等。上面的兩個例子其實是IO密集型,會有大量使用者請求傳送到伺服器,這種任務型別如果不使用多執行緒那麼就需要使用多路複用。典型的就是Nginx。

Python中的GIL

Python程式碼的執行是由python直譯器(直譯器主迴圈)進行控制的。在主迴圈中同時只能有一個控制執行緒在執行,就像單核心CPU中的多程式一樣。儘管Python直譯器中可以執行多個執行緒,但是在任意某一時刻只能有一個執行緒在執行。

GIL:全域性直譯器鎖,這個是程式級別的,程式裡面任何的執行緒要想被執行都要經過這個鎖,這就意味著程式內多個執行緒同一時刻只能有一個執行緒被執行其他都睡眠或者等待IO,哪怕你有多個核心的CPU也不行,簡單來說就是Python中的執行緒不是併發的,在JAVA中執行緒可以併發。這個鎖曾經嘗試去掉過但是去掉之後導致單執行緒程式效能下降很多,雖然去掉會帶來多執行緒的效能提升但是經過權衡覺得不值得,最後還是加上了。Python為什麼一開始不設計真正的多執行緒?這個跟年代有關Python誕生在1989年,那個年代INTEL才推出80486其實就是80386的加強版,別多多核心,也就是剛剛加入了浮點運算,當然同期摩托羅拉的CPU也不是多核心。而JAVA是1995年誕生,那時候雖然也沒有多核心但是效能要高的多,單核心效能高了我們就可以利用CPU時間片概念模擬多執行緒併發;另外就是我們常用的Python是CPython直譯器是C寫的它的多執行緒依賴作業系統,這種Python有GIL,如果是JPython或者PYPY則沒有GIL限制,所以所GIL不是語言決定的,而是執行這個語言的直譯器決定的。另外Python的程式也是依賴作業系統的,呼叫作業系統的庫函式實現的,程式沒有GIL限制。

那麼這裡就涉及到一個問題,如果一個多執行緒程式,其中一個執行緒等待IO(網路或者磁碟)時間很長那麼其他執行緒就一直阻塞在哪裡嗎?顯然不是,那如何處理的呢?一個執行緒無論任何時候開始睡眠或者等待IO,其他執行緒都有機會獲取GIL然後執行自己的Python程式碼,這就是協同式多工處理,另外Python還支援搶佔式多工。

協同時多工

比如當一個執行緒進行網路IO操作時,其實無法估計這個IO需要多久,此時這個執行緒就阻塞在這裡並且沒有執行任何Python程式碼,那麼這個執行緒就會釋放GIL(被直譯器強制釋放),從而讓其他執行緒獲取GIL來執行他們的Python程式碼。這就是協同式多工,它允許併發;多個執行緒同時等待不同事件。
比如2個執行緒開啟2個套接字,兩個執行緒同一時刻只能有一個執行Python,但一旦執行緒開始連線它就會放棄GIL,這樣其他執行緒就可以執行,這就意味著2個執行緒併發等待套接字連線。其實你可以看到它允許順序的併發等待,當IO有返回時也就是它需要重新獲得鎖來執行Python程式碼的時候,在這個時間片裡只能有一個執行緒執行。

協同時多工也叫做協作式多工,本意是指程式或者執行緒自己控制執行時間,A執行緒執行完了就通知B執行緒,看起來很完美但是如果A執行緒程式碼有問題導致無法正常執行完畢那後面的執行緒就都卡死了。在Python中直譯器就充當了那個中間人的作用它保證某個執行緒卡死不會一直佔用CPU。

搶佔式多工

搶佔式顧名思義就是程式或者執行緒可以在執行時被任意打斷也就是被別的執行緒或者程式搶去它的執行時間。搶的機制避免了程式碼問題執行緒卡死但是也帶了其他問題就是所有執行緒或者程式的執行時間無法公平。

上面說的是執行緒主動放棄鎖,那麼對應的就有主動獲取鎖,當多個執行緒都有返回的時候(所有執行緒都處在一個可以繼續執行的狀態),就會發生對鎖的爭搶(搶GIL)。總之最終只有一個執行緒被執行。假設有一個獲取鎖的執行緒繼續執行但是它的資料有問題導致執行緒變成死迴圈那後面的執行緒是不是就無法執行了呢?答案是否定的。Python程式碼執行首先會被翻譯成二進位制位元組碼,然後直譯器函式讀取這些二進位制碼然後執行,在Python 2 中檢測間隔為在虛擬機器中執行的位元組碼數量達到一定值就強制執行緒釋放GIL,在Python 3.2及以後改變了GIL的釋放和獲取機制,預設是0.005秒也就是5毫秒換句話說如果一個執行緒5毫秒沒有釋放鎖就被強制釋放,也就是說結直譯器會主動定期輪詢所有執行緒而不會讓一個執行緒一直執行。那這個GIL能保證執行緒安全嗎?

Python的執行緒安全

一個執行緒可以隨時失去GIL那麼可以得出GIL是無法保證執行緒安全的,從開發這角度來說程式是一條一條執行,但是翻譯成位元組碼我們看起來的一條語句可能是多條尤其是對於原子性操作。比如 += 這樣的操作,看下圖

無論是GIL是按照位元組流多少還是按照時間,假設剛執行了上面0、3操作就需要釋放GIL,那麼後去的6、7則無法執行,相當於 += 操作沒有完成。所以作為開發人員仍然需要手動加鎖。我這裡的例子並沒有加迴圈進行累計雖然發生機率不高,但是也要引起注意。下圖為手動加鎖:

那麼對於原子性操作比如sort()方法,是不能被中斷的即使到了該是否GIL的時候。因為sort是單位元組碼,因此執行緒沒有機會在呼叫期間抓取GIL。有時候你分不清哪些是原子的哪些不是,所以可以遵循一個原則:始終圍繞共享可變狀態的讀取和寫入加鎖。反正threading.Lock是廉價的。

如果你使用JAVA語言則需要程式設計師自行加、解鎖來保證執行緒安全。在Python中是粗粒度鎖,也就是語言本身就提供一種機制來保證執行緒安全。所以在Python中除了特殊需要一般情況下沒必要使用更加細粒度的執行緒鎖。
從上面來看可以更加詳細的瞭解Python多執行緒,而不是一味的認為它的多執行緒沒有意義。所以如果真的需要平行計算呢?答案就是多程式,那麼到底具體什麼場景需要使用多執行緒什麼時候多程式呢?

計算密集型:要用多程式,大量使用CPU計算能力的程式或者任務,而程式本身不太去訪問IO裝置。比如計算圓周率、對影片進行解碼、解壓縮和壓縮、加密解密。雖然多程式會更叫消耗資源,但是可以更多的利用CPU核心的計算能力。
IO密集型:要用多執行緒,因為IO操作不佔用CPU能力,所以不用利用CPU的多核心。IO分為網路IO和磁碟IO,就是CPU的等待時間主要消耗在等待輸入和輸出上,比如網路爬蟲這種型別屬於IO密集型,在一個執行緒等待IO的同時會放棄GIL,然後讓後面的執行緒工作,這顯然比單執行緒要效率高。

從運算結果來看GIL不能保證執行緒安全

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Author: rex.cheny
# E-mail: rex.cheny@outlook.com
import multiprocessing
import threading

import time


def foo(n):
    # print(n)
    global count
    count += 1
    time.sleep(1)
    count -= 1
    print("子程式 %d 執行完畢。" % n)


count = 0
def main():
    thList = []
    for i in range(200):
        t = threading.Thread(target=foo, args=(i,))
        t.start()
        thList.append(t)

    for t in thList:
        t.join()

    print(count)
    print("主執行緒執行完畢")


if __name__ == '__main__':
    main()

如果是執行緒安全的那麼結果就是0,可是真的結果呢?不一定是多少,我這次執行的是6,如果你不在count += 1和 -= 1之間加睡眠你是看不到效果的。如果你沒有count -= 1這一步,你是看不到效果的,因為雖然你用sleep模擬了執行時間長來觸發執行緒之間的GIL釋放和獲取但是你要明白 count += 1這個是一個完整操作在睡眠前就已經執行完畢了,所以是否睡眠與結果毫無相關,所以必須是兩次對同一資料進行操作然後兩次操作中間增加睡眠時間來觸發執行緒釋放GIL才能有直觀的效果。

如果要想保證執行緒安全怎麼辦呢?用鎖

這樣就保證了結果,上例子中應該去掉sleep,因為這樣程式就是序列的了,但是如果面對這種根本無法觸發釋放GIL鎖的操作,你不加睡眠的話人為加鎖對執行結果沒有影響。鎖和訊號量會在後面講到。

多執行緒程式設計及執行緒間通訊

在Python中有兩個模組可以實現執行緒,就是thread模組和threading模組的Thread類。thread可以實現的功能threading也可以實現,可以把threading看做高階的thread,在做多執行緒程式設計的時候推薦使用threading,所以我這裡也只用這個來舉例。

我們先說一下建立執行緒的方法:

  • 建立執行緒例項,然後傳給它一個函式
  • 建立執行緒例項,然後傳遞給它一個可呼叫的類(也就是實現了類裡的__call__方法)
  • 派生執行緒子類,實現run()方法,然後基於子類建立例項並執行它

三種方法用哪一個呢?總之不推薦使用第二種因為難以閱讀。如果是簡單任務第一個種就夠了,如果是複雜程式就要遵循物件導向程式設計思想那麼第三種更合適。

我們先用第一種方法來建立

(例子1)

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import sys
from time import sleep
from threading import Thread


# 測試使用 被執行緒執行的方法
def foo1(arg):
    print("子執行緒任務-獲取的引數值為:", arg)
    sleep(2)
    print("子執行緒任務執行完畢。")

def simpleExample():
    """
    建立一個執行緒例項,然後傳遞一個需要執行緒執行的函式 target=foo1
    傳遞一組引數給需要執行的函式,形式為元祖,如果沒有引數就使用空元祖 args=(1,)
    還可以設定執行緒名稱,當然不是必須的,如果不設定執行緒名稱以 Thread-開頭後面跟一個數字,預設從1開始
    設定執行緒名稱還可以使用 Thread.name = "子執行緒" 不推薦使用 .setName和.getName來設定和獲取執行緒名稱
    """
    t1 = Thread(target=foo1, args=(1,), name="子執行緒")

    # 啟動執行緒
    t1.start()
    # 獲取執行緒名稱
    print(t1.name)

    """
    獲取執行緒是否還存活,執行緒執行完畢後,也就是返回了,那麼這個值就是False。執行緒一旦start()那麼就是活著的,
    所以執行緒是在start到返回之間是存活的。
    """
    print("執行緒是否還活著:", t1.isAlive())

    print("主執行緒程式碼執行完畢。")


def main():
    simpleExample()


if __name__ == "__main__":
    sys.exit(main())

這個程式很簡單就是傳遞一個函式去執行。當執行緒執行時,主執行緒繼續執行直到執行完所有主執行緒程式碼,從上圖可以看出“子執行緒任務執行完畢”是最後一個輸出,這個是子執行緒輸出的。這時候你可能回想,上面的主執行緒不等子執行緒完成它自己的程式碼就執行完了,如果我的主執行緒需要獲取子執行緒執行結果然後再繼續執行主執行緒怎麼辦?看下面的程式碼

下面的程式碼是建立5個子執行緒,然後每個子執行緒都在一個列表裡寫上自己的名字,然後主執行緒顯示這個列表。

(例子2)

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import sys
from time import sleep
from threading import Thread

def foo2(arg, name):
    arg.append(name)
    sleep(2)
    print("子執行緒: %s 任務執行完畢。" % name)

def aboutJoin():
    namelist = ["Thread-A", "Thread-B", "Thread-C", "Thread-D", "Thread-E"]
    threadlist = []

    testlist = []
    for i in namelist:
        t = Thread(target=foo2, args=(testlist, i), name="子執行緒-" + str(i))
        t.start()
        threadlist.append(t)

    # for i in namelist:
    #     threadlist[namelist.index(i)].start()

    for i in namelist:
        """
        執行緒啟動後阻塞主執行緒,主執行緒需要等待執行緒完成後才可以繼續執行(join後面的語句),你線上程呼叫的方法中加入sleep效果最明顯。
        你不加阻塞其實不影響執行,只是輸出資訊會亂因為主執行緒會繼續執行,執行緒也會執行,那具體排程執行順序由系統控制,
        所以不加阻塞每次輸出的資訊前後順序會不一樣。
        join(timeout),它可以加一個超時,不加就是等待執行緒執行完畢,加超時則是等待多久無論執行緒是否執行完畢都向後繼續執行主執行緒。
        """
        threadlist[namelist.index(i)].join()

    for i in testlist:
        print(i)
    print("主執行緒程式碼執行完畢")

def main():
    aboutJoin()


if __name__ == "__main__":
    sys.exit(main())

說明:上面那一段註釋的程式碼是啟動執行緒,我寫的是建立時執行緒例項就啟動,其實你可以先全部建立(建立後並不馬上呼叫start),然後在啟動(用註釋的程式碼)。

從執行結果來看,主執行緒等待所有子程式完畢,然後輸出那個列表,當主執行緒執行完最後一條程式碼後,程式退出。

守護執行緒

thread模組不支援守護執行緒,所以我們必須是用threading模組。守護執行緒的意思是當主執行緒退出時所有子執行緒也將終止,無論子執行緒的任務是否完成。守護執行緒一般用來做那些不重要的任務,比如等待客戶端請求,如果沒有請求那麼這個執行緒就是空閒的。它只負責接收請求然後把請求轉發給其他執行緒來處理,然後自己繼續等待請求。所以對於這種不具有重要邏輯處理的執行緒可以作為守護執行緒。換句話說主執行緒活著守護執行緒就活著,主執行緒沒了守護執行緒也就沒了。

有人說把處理客戶請求的執行緒弄成守護執行緒,這也是可以的,但是有個問題,如果要重啟應用或者手動停止服務,當前沒有處理完請求的那些執行緒難道直接終止嗎?留給大家思考,不過很多人也應該聽過最佳化停機這種說法,在這種場景下執行了停止服務操作,可以不再接收新的請求,但是要處理完老的請求。

(例子3)

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import sys
from time import sleep
from threading import Thread

def foo3(arg, name):
    sleep(2)
    arg.append(name)
    print("子執行緒: %s 任務執行完畢。" % name)


def demoForDaemon():
    namelist = ["Thread-A", "Thread-B", "Thread-C", "Thread-D", "Thread-E"]
    threadlist = []
    """
    建立5個執行緒,然後它們設定為守護執行緒,這時候你會發現主執行緒很快執行完畢然後程式退出
    這時候你看不到任何執行緒輸出內容。因為所有執行緒都隨主執行緒的退出而退出了。但是如果你註釋掉 subT.daemon = True
    你會發雖然主執行緒程式碼執行完畢但是不會退出,它會等著執行緒,然後你就看到輸出的列表.
    如果把執行緒設定為守護執行緒則表示這個執行緒其實不重要,程式退出時可以不需要管這個執行緒是否執行完了自己的程式碼。
    """
    for name in namelist:
        subT = Thread(target=foo3, args=(threadlist, name))

        """
        這個值預設是False,這個是設定子執行緒為守護模式,就是說主執行緒是不是等待執行緒執行完畢在退出。
        比如你設定了True,也就是主執行緒不等待,那麼如果主執行緒執行程式碼需要1秒鐘,而執行緒執行它裡面的程式碼
        需要10秒,那1秒過後主執行緒執行完畢退出,執行緒也會隨之退出這時候執行緒的程式碼根本沒有執行完。通常來講
        主執行緒產生的所有執行緒都應該隨主執行緒退出而銷燬,這樣就可以避免執行緒出現死迴圈主執行緒退出了子執行緒還在繼續執行的情況。
        必須在start()方法之前設定。
        """
        # subT.daemon = True
        subT.start()

    # 這個指令是為了演示雖然把主執行緒設定為所有執行緒的守護程式,但是因為睡眠了所以主執行緒沒有退出,這時候
    # 你就會看到執行緒的正常輸出,至於執行緒是否執行完畢則取決於主執行緒什麼時候退出,這裡設定睡眠可以理解為
    # join()操作。只不過你不能真的當join()來使用,畢竟真實場景中子執行緒的執行時間不可預知。
    # sleep(5)
    print(threadlist)
    print("主執行緒程式碼執行完畢。")


def main():
    demoForDaemon()


if __name__ == "__main__":
    sys.exit(main())

下面開啟daemon

(例子4)

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import sys
from time import sleep
from threading import Thread

def foo3(arg, name):
    sleep(2)
    arg.append(name)
    print("子執行緒: %s 任務執行完畢。" % name)


def demoForDaemon():
    namelist = ["Thread-A", "Thread-B", "Thread-C", "Thread-D", "Thread-E"]
    threadlist = []
    """
    建立5個執行緒,然後它們設定為守護執行緒,這時候你會發現主執行緒很快執行完畢然後程式退出
    這時候你看不到任何執行緒輸出內容。因為所有執行緒都隨主執行緒的退出而退出了。但是如果你註釋掉 subT.daemon = True
    你會發雖然主執行緒程式碼執行完畢但是不會退出,它會等著執行緒,然後你就看到輸出的列表.
    如果把執行緒設定為守護執行緒則表示這個執行緒其實不重要,程式退出時可以不需要管這個執行緒是否執行完了自己的程式碼。
    """
    for name in namelist:
        subT = Thread(target=foo3, args=(threadlist, name))

        """
        這個值預設是False,這個是設定子執行緒為守護模式,就是說主執行緒是不是等待執行緒執行完畢在退出。
        比如你設定了True,也就是主執行緒不等待,那麼如果主執行緒執行程式碼需要1秒鐘,而執行緒執行它裡面的程式碼
        需要10秒,那1秒過後主執行緒執行完畢退出,執行緒也會隨之退出這時候執行緒的程式碼根本沒有執行完。通常來講
        主執行緒產生的所有執行緒都應該隨主執行緒退出而銷燬,這樣就可以避免執行緒出現死迴圈主執行緒退出了子執行緒還在繼續執行的情況。
        必須在start()方法之前設定。  set/getDaemon() 方法已經過時,不推薦使用了.
        """
        subT.daemon = True
        subT.start()

    # 這個指令是為了演示雖然把主執行緒設定為所有執行緒的守護程式,但是因為睡眠了所以主執行緒沒有退出,這時候
    # 你就會看到執行緒的正常輸出,至於執行緒是否執行完畢則取決於主執行緒什麼時候退出,這裡設定睡眠可以理解為
    # join()操作。只不過你不能真的當join()來使用,畢竟真實場景中子執行緒的執行時間不可預知。
    # sleep(5)
    print(threadlist)
    print("主執行緒程式碼執行完畢。")


def main():
    demoForDaemon()


if __name__ == "__main__":
    sys.exit(main())

執行緒安全

之前說過GIL並不能保證執行緒安全,那在某些場景下又需要執行緒安全,那應該怎麼做呢?通常有兩種方式,鎖和訊號量。在說這兩個東西之前,先說兩個概念

  • 臨界資源:是同一時刻僅允許一個程式/執行緒使用的共享資源。各程式/執行緒採取互斥的方式,實現共享的資源稱作臨界資源。臨界資源是資源。
  • 臨界區:每個程式/執行緒中訪問臨界資源的那段程式碼稱為臨界區。臨界區是程式碼。

簡單來說多個執行緒/程式同一時刻可能對同一個資源又新增又修改或者是僅修改可是每次修改的數量不同。典型場景就是購物,多個人購買一個商品,但是商品庫存是一定的,可是每個人買的數量不同。控制不好可能會出現銷售數量大於庫存數量。

模擬使用者搶購商品,在扣減庫存的時候進行鎖定,扣減完畢釋放鎖

(例子5)

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Author: rex.cheny
# E-mail: rex.cheny@outlook.com

import random
from threading import Thread
from threading import Lock


# 庫存數量,這個就屬於臨界資源
STOCK = 100
#
LOCK = Lock()


def buy(username, amount):
    """
    購買商品方法,從 LOCK.acquire() 到 LOCK.release() 之間屬於臨界區,因為這中間的程式碼就開始對STOCK進行操作了
    :param username: 使用者名稱稱
    :param amount: 購買數量
    :return:
    """
    LOCK.acquire()
    global STOCK
    print("使用者 %s 下單購買 %d 個商品" % (username, amount))
    if STOCK >= amount:
        STOCK = STOCK - amount

        print("%s 成功的購買了 %d 個商品。" % (username, amount))
        print("當前庫存數量:%d" % STOCK)
    else:
        print("使用者 %s 您好,當前庫存不足,您最多可以購買 %d 個商品, 請重新下單" % (username, STOCK))
    LOCK.release()


def main():
    print("庫存數量:%d" % STOCK)
    userlist = ['User1', 'User2', 'User3', 'User4', 'User5', 'User6', 'User7', 'User8', 'User9']
    userthreadlist = []
    for user in userlist:
        userT = Thread(target=buy, args=(user, random.randint(10, 100)))
        userT.start()
        userthreadlist.append(userT)

    for userT in userthreadlist:
        userT.join()

    print("搶購後當前庫存數量:%d" % STOCK)


if __name__ == '__main__':
    main()

執行效果

這個程式只是為了展示鎖如何使用以及效果,所以程式還有很多不完整的地方,比如不能重新下單直到庫存為0,有興趣可以自己去完善。更加簡潔的使用鎖的方式

# -*- coding: utf-8 -*-
# @Time    : 2020/2/13 14:48
# @Author  : rex.chen
# @Email   : rex.cheny@outlook.com
# @File    : lockTest.py
# @Software: PyCharm

import threading
import time

locker = threading.Lock()
cups = []


def produce_cups(count=100):
    while True:
        """
        這裡等同於那種 with open() as 語句,透過上下文的方式使用鎖,進入with語法塊說明獲取所成功,程式碼執行完畢自動釋放。
        它的原理就是呼叫__enter__語句獲取鎖,最後呼叫__exit__語句釋放鎖。 RLock裡面也是這麼實現的。
        """
        with locker:
            if len(cups) < count:
                time.sleep(0.001)
                cups.append(1)

        """
        這裡跳出迴圈為什麼不寫在 with 語句塊裡呢?因為在with 語句裡面直接break後就跳出去了,它不會執行__exit__
        所以也就不會釋放鎖。
        """
        if len(cups) == count:
            break


def main():
    t_list = []
    for i in range(20):
        t = threading.Thread(target=produce_cups, args=(1000,))
        t_list.append(t)
        t.start()

    for t in t_list:
        t.join()

    print(len(cups))


if __name__ == '__main__':
    main()

 

訊號量

下面我演示的是和上面一樣的功能只是使用了訊號量,我這裡用的是二進位制訊號量。訊號量解釋網上很多,我這裡摘抄一個。

訊號量是執行緒的同步機制,它其實就是一個計數器,當資源消耗時遞減,當資源釋放是遞增。
程式或者執行緒可以共享訊號量,如果一個執行緒或者程式執行獲取訊號量操作時,它將得到訊號量,然後就可以執行,而後一個程式獲取訊號量時如果是0就會
被阻塞,那麼它必須等待前一個程式釋放。所以如果訊號量不是0那麼該程式就可以執行。這裡說的是二進位制訊號量,也就是0或者1.

(例子6)

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Author: rex.cheny
# E-mail: rex.cheny@outlook.com

import random
from threading import Thread, BoundedSemaphore


# 庫存數量,這個就屬於臨界資源
STOCK = 100
# 例項化一個訊號量物件,BoundedSemaphore 的一個功能就是計數器永不會超過初始值,為了防止釋放次數多餘獲得次數
candytray = BoundedSemaphore(2)


def buy(username, amount):
    """
    購買商品方法,從 candytray.acquire() 到 candytray.release() 之間屬於臨界區,因為這中間的程式碼就開始對STOCK進行操作了
    :param username: 使用者名稱稱
    :param amount: 購買數量
    :return:
    """
    # 下面的語句是訊號量遞減,如果某個執行緒執行到這裡發現訊號量是0,則會被阻塞並等待其他執行緒執行release()操作
    candytray.acquire()
    global STOCK
    print("使用者 %s 下單購買 %d 個商品" % (username, amount))
    if STOCK >= amount:
        STOCK = STOCK - amount

        print("%s 成功的購買了 %d 個商品。" % (username, amount))
        print("當前庫存數量:%d" % STOCK)
    else:
        print("使用者 %s 您好,當前庫存不足,您最多可以購買 %d 個商品, 請重新下單" % (username, STOCK))
    # 下面的語句是訊號量遞增
    candytray.release()


def main():
    print("庫存數量:%d" % STOCK)
    userlist = ['User1', 'User2', 'User3', 'User4', 'User5', 'User6', 'User7', 'User8', 'User9']
    userthreadlist = []
    for user in userlist:
        userT = Thread(target=buy, args=(user, random.randint(1, 100)))
        # userT.start()
        userthreadlist.append(userT)

    for userT in userthreadlist:
        userT.start()

    for userT in userthreadlist:
        userT.join()

    print("搶購後當前庫存數量:%d" % STOCK)


if __name__ == '__main__':
    main()

執行效果

對於非二進位制訊號量來說就是一個更大的整數。

所以如果為了控制對共享資源的訪問那麼通常會把訊號量設定為1.如果你設定大於1,則表示針對該資源將有可能出現同一時刻被多於1個執行緒或程式訪問。
那麼有沒有需要讓訊號量大於1的時候呢?當然有,比如購買火車票,售票大廳容納500人,售票視窗肯定比500少,這時候就需要兩種訊號量,一種代表
售票大廳的容納人數其訊號量就為500、另一種代表售票視窗,如果售票視窗就1個,那麼此時代表售票視窗的訊號量就為1.

其實很多時候我們並不需要用訊號量來控制執行緒或者程式的多少,這個可以用池來解決。通常使用訊號量是為了避免多個執行緒或者程式對同一資源訪問從而
造成資料不一致。當然解決這種問題除了使用訊號量之外最常規的就是使用鎖來實現。

執行緒通訊

在本節(例子2)其實就演示主執行緒和執行緒通訊的效果,就是傳遞一個列表到所有執行緒,每個子執行緒在列表中新增自己的名字,然後在主執行緒中顯示。當然這個並不算是嚴謹的執行緒間通訊雖然它也通訊了,雖然使用列表或者字典等傳遞給執行緒也可以共享資料但是這不是執行緒安全的。其實執行緒通訊比較容易,因為每個執行緒共享程式的記憶體空間,所以這也就為什麼會有執行緒安全的問題。因為畢竟每個程式都是獨立的記憶體空間是隔離的。為了讓執行緒安全通訊我們之前說了鎖和訊號量,不過在python標準庫中還有一個叫做Queue的物件。這個模組可以提供執行緒共享資料而且是執行緒安全的。

(例子7)

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Author: rex.cheny
# E-mail: rex.cheny@outlook.com

import random
from threading import Thread
from queue import Queue, LifoQueue, PriorityQueue
from time import sleep

"""
Queue本身就包含了鎖,所以執行緒可以安全的傳遞資料。
q = Queue(10) 建立一個先進先出的佇列,如果設定了最大佇列長度,那麼在佇列滿的時候將被阻塞,否則佇列長度則沒有限制
    LifoQueue(MAX_SIZE) 後入先出佇列
    PriorityQueue(MAX_SIZE) 優先順序佇列  put((優先順序,資料)), 優先順序可以是字母也可以是數字,數字越小優先順序越高

常用方法:
    qsize() 獲取佇列當前長度
    empty() 判斷當前佇列是否為空,如果佇列為空則返回True
    full() 判斷佇列是否已經滿了,如果滿了則返回 True
    put(DATA) 將資料放入佇列
    get(DATA) 從佇列中取出一個資料

"""


def productmessage(queue):
    count = 1
    while True:
        if count >= 10:
            print("寫入訊息退出命令 EXIT。")
            queue.put("EXIT")
            break
        msg = random.randint(50, 100)
        print("寫入訊息 %d" % msg)
        queue.put(msg)
        print("當前佇列長度:%d" % queue.qsize())
        count += 1
        sleep(random.randint(1, 3))


def consumemessage(queue):
    while True:
        msg = queue.get(1)
        if msg == "EXIT":
            print("收到退出命令 EXIT。")
            break
        print("執行緒讀取了一條資料:%s 當前佇列長度:%d" % (msg, queue.qsize()))
        sleep(random.randint(1, 3))


def main():
    # 建立佇列並設定佇列長度
    q = Queue(10)

    wT = Thread(target=productmessage, args=(q, ))
    rT = Thread(target=consumemessage, args=(q, ))

    wT.start()
    rT.start()


if __name__ == '__main__':
    main()

在使用put和get的時候它有一個非同步方式。因為如果使用同步方式,那麼當佇列空了你還繼續get將會阻塞,預設是這種方式,同理put也是,如果佇列沒有空餘位置,那麼就會等待這個等待時間由timeout設定。put方法預設是阻塞的。這裡我們使用了Queue,它其實本身使用的是dqueue,而這個東西在位元組碼的層面上就已經實現了執行緒安全。

事件通知

Event也是執行緒通訊的一種,它使用起來比較簡單它不能傳遞具體資料只能用於傳送通知。這種在某些場景下需要。

(例子7)

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
from threading import Event
from threading import Thread
import time

"""
執行緒的事件驅動
伺服器和客戶端同時會監聽一個標誌,預設是Fasle,如果有事件觸發,則觸發的一方會把標誌設定為True,這時候需要處理的一方
就知道有事件,那麼它將處理這個事情。
"""


def Server(event):
    print("Server: 等活兒中。。。。。")
    # 這個是事件驅動的等待,屬於阻塞的等待,這就是伺服器去等待這個標誌,如果不要阻塞的就用 event.isSet() 判斷是否為True,不是True就幹其他的事情
    event.wait()
    print("Server: 客戶端觸發了一個事件。")
    print("Server: 伺服器開始處理。。。。。。")

    # 這個是把標準位恢復為False,因為客戶端一點呼叫event.set(),那麼標誌就是True,如果這裡不清空,那麼就一直是True,
    # 那麼下面Client裡面 event.wait()就會立刻獲取True,而不會等待伺服器下面的處理。
    event.clear()
    # time.sleep(3)

    print("Server: 伺服器處理完畢。")
    # 伺服器也要去更新這個標誌,表示已經處理完了,用於通知客戶端,因為客戶端此時在關注這個事件
    event.set()


def Client(event):
    print("Client: 我去派個活兒。。。。。")
    # 這個是事件驅動的通知,就是伺服器在等待的時候,客戶端呼叫這個方法表示去更新那個標誌
    event.set()

    print("Client: 活兒已派等待伺服器處理事件:")
    # time.sleep(1)
    # 客戶端等待伺服器更新標誌位
    event.wait()   # 這裡要想執行就必須等待上面 Server 裡面的 event.set()執行完了才可以
    print("Client: 謝謝伺服器。")


def main():
    # 建立一個事件物件
    event = Event()

    srvT = Thread(target=Server, args=(event,))
    srvT.start()

    cliT = Thread(target=Client, args=(event,))
    cliT.start()


if __name__ == "__main__":
    try:
        main()
    finally:
        sys.exit()

透過派生子類的方式建立執行緒

 透過派生子類建立執行緒比較簡單,子類的使用方式和直接使用Thread是一樣的,不過至少要重寫run方法,這裡就是具體執行你自己的邏輯,構造方法可以省略。

(例子8)

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
from threading import Thread


"""
這裡演示透過整合Thread類來執行執行緒
之前是透過  t = Thread(target=FUN, args(X,)) 這種方式把需要讓執行緒執行的方法或者說是FUN傳遞進去,然後透過t.start()來執行執行緒
實際上 Thread類裡的 start()方法 呼叫的是 Thread中的run()方法,這個方法裡面非常簡單就是單純的執行我們傳遞進去的方法和引數,那麼
如果我們自己來寫一個類,這個類從Thread繼承,然後透過重寫run()也是可以的,看下面例項。
"""


class MyThread(Thread):
    # 如果你自己重寫了構造方法,那麼你就必須在這個方法裡呼叫父類的構造方法,否則初始化會失敗
    def __init__(self, name):
        # 呼叫父類的構造方法,至於是否傳遞引數根據需要,我這裡只是傳遞一個名字,其實也可以省略。
        super(MyThread, self).__init__(name=name)

    def run(self):
        print("我的執行緒執行了。")


def main():
    mt = MyThread("AAA")
    mt.start()
    print(mt.getName())


if __name__ == "__main__":
    try:
        main()
    finally:
        sys.exit()

為什麼main函式的程式碼是主執行緒呢?

加入該py檔名稱為a.py。你透過python ./a.py執行,這裡的程式是python直譯器或者是虛擬機器,a.py裡面的程式碼就是該程式主要執行的程式碼段,這就是一項任務,也就是主執行緒。

如果我執行20個執行緒你看看效果,在休眠20秒內,你檢視系統只有一個程式存在,也就是Python直譯器。

如果改為20個程式呢?

再次執行,這次我們把程式號列印出來,你主程式是19311,其他20個的父程式都是19311,這20個都是fork出來的子程式。在類Unix作業系統有fork,在Windows上沒有,它應該是透過其他方式產生的子程式。

真的有20個,其實是21個,為什麼?還是一樣的道理,20個是你生成的,那這20個是由誰生成的呢?當然是你上面這段程式碼,這段程式碼由誰執行呢,當然是Python直譯器啊,所以先由直譯器執行起來執行這段程式碼(一個程式),然後產生的20個程式。所以是21個。

這裡是為什麼是22個,哈哈,本條命令本身就包含關鍵詞python,所以也算一個統計值但是它並不算是程式就是一個關鍵字而已。

相關文章