【Python入門基礎】程式和執行緒

ZoomToday發表於2020-12-16

程式

  程式是作業系統中執行的一個程式,作業系統以程式為單位分配儲存空間,每個程式都有自己的地址空間、資料棧以及其他用於追蹤程式執行的輔助資料,作業系統管理所有程式的執行,為它們合理的分配資源。程式可以通過fork或spawn的方式來建立新的程式來執行其他的任務,不過新的程式也有自己獨立的記憶體空間,因此必須通過程式間通訊機制(IPC,Inter-Process Communication)來實現資料共享,具體的方式包括管道、訊號、套接字、共享記憶體區等。
  一個程式還可以擁有多個併發的執行線索,簡單的說就是擁有多個可以獲得CPU排程的執行單元,這就是所謂的執行緒。由於執行緒在同一個程式下,它們可以共享相同的上下文,因此相對於程式而言,執行緒間的資訊共享和通訊更加容易。
  Python既支援多程式又支援多執行緒,因此使用Python實現併發程式設計主要有3種方式:多程式、多執行緒、多程式+多執行緒。
  這裡假設有兩個下載任務,如果程式只能按順序一點點的往下執行,那麼即使執行兩個毫不相關的下載任務,也需要先等待一個檔案下載完成後才能開始下一個下載任務,很顯然是並不合理也沒有效率的,此時可以使用多程式的方式將兩個下載任務放在不同的程式中。

from multiprocessing import Process
from os import getpid
from random import randint
from time import time, sleep


def download_task(filename):
    print('啟動下載程式,程式號[%d].' % getpid())
    print('開始下載%s...' % filename)
    time_to_download = randint(5, 10)
    sleep(time_to_download)
    print('%s下載完成! 耗費了%d秒' % (filename, time_to_download))


def main():
    start = time()
    p1 = Process(target=download_task, args=('下載任務1', ))
    p1.start()
    p2 = Process(target=download_task, args=('下載任務2', ))
    p2.start()
    p1.join()
    p2.join()
    end = time()
    print('總共耗費了%.2f秒.' % (end - start))


if __name__ == '__main__':
    main()

在這裡插入圖片描述

  在上面的程式碼中,通過Process類建立了程式物件,通過target引數我們傳入一個函式來表示程式啟動後要執行的程式碼,後面的args是一個元組,它代表了傳遞給函式的引數。Process物件的start方法用來啟動程式,而join方法表示等待程式執行結束。執行上面的程式碼可以明顯發現兩個下載任務“同時”啟動了,而且程式的執行時間將大大縮短,不再是兩個任務的時間總和。
  可以使用subprocess模組中的類和函式來建立和啟動子程式,然後通過管道來和子程式通訊

多執行緒

  目前的多執行緒開發推薦使用threading模組,該模組對多執行緒程式設計提供了更好的物件導向的封裝。
  直接使用threading模組的Thread類來建立執行緒,可以從已有的類建立新類,也可以通過繼承Thread類的方式來建立自定義的執行緒類,然後再建立執行緒物件並啟動執行緒。

from random import randint
from threading import Thread
from time import time, sleep


class DownloadTask(Thread):

    def __init__(self, filename):
        super().__init__()
        self._filename = filename

    def run(self):
        print('開始下載%s...' % self._filename)
        time_to_download = randint(5, 10)
        sleep(time_to_download)
        print('%s下載完成! 耗費了%d秒' % (self._filename, time_to_download))


def main():
    start = time()
    t1 = DownloadTask('下載任務1')
    t1.start()
    t2 = DownloadTask('下載任務2')
    t2.start()
    t1.join()
    t2.join()
    end = time()
    print('總共耗費了%.2f秒.' % (end - start))


if __name__ == '__main__':
    main()

  因為多個執行緒可以共享程式的記憶體空間,因此要實現多個執行緒間的通訊相對簡單,大家能想到的最直接的辦法就是設定一個全域性變數,多個執行緒共享這個全域性變數即可。但是當多個執行緒共享同一個變數(我們通常稱之為“資源”)的時候,很有可能產生不可控的結果從而導致程式失效甚至崩潰。如果一個資源被多個執行緒競爭使用,那麼我們通常稱之為“臨界資源”,對“臨界資源”的訪問需要加上保護,否則資源會處於“混亂”的狀態。例如100個執行緒分別向某一個賬戶轉入1元錢,會一起執行在0上+1的操作,因此會得到錯誤的結果,不會得到100。在這種情況下,“鎖”就可以派上用場了。我們可以通過“鎖”來保護“臨界資源”,只有獲得“鎖”的執行緒才能訪問“臨界資源”,而其他沒有得到“鎖”的執行緒只能被阻塞起來,直到獲得“鎖”的執行緒釋放了“鎖”,其他執行緒才有機會獲得“鎖”,進而訪問被保護的“臨界資源”。

from time import sleep
from threading import Thread, Lock


class Account(object):

    def __init__(self):
        self._balance = 0
        self._lock = Lock()

    def deposit(self, money):
        # 先獲取鎖才能執行後續的程式碼
        self._lock.acquire()
        try:
            new_balance = self._balance + money
            sleep(0.01)
            self._balance = new_balance
        finally:
            # 在finally中執行釋放鎖的操作保證正常異常鎖都能釋放
            self._lock.release()

    @property
    def balance(self):
        return self._balance


class AddMoneyThread(Thread):

    def __init__(self, account, money):
        super().__init__()
        self._account = account
        self._money = money

    def run(self):
        self._account.deposit(self._money)


def main():
    account = Account()
    threads = []
    for _ in range(100):
        t = AddMoneyThread(account, 1)
        threads.append(t)
        t.start()
    for t in threads:
        t.join()
    print('賬戶餘額為: ¥%d元' % account.balance)


if __name__ == '__main__':
    main()

  無論是多程式還是多執行緒,只要數量一多,效率肯定上不去,為什麼呢?我們打個比方,假設你不幸正在準備中考,每天晚上需要做語文、數學、英語、物理、化學這5科的作業,每項作業耗時1小時。如果你先花1小時做語文作業,做完了,再花1小時做數學作業,這樣,依次全部做完,一共花5小時,這種方式稱為單任務模型。如果你打算切換到多工模型,可以先做1分鐘語文,再切換到數學作業,做1分鐘,再切換到英語,以此類推,只要切換速度足夠快,這種方式就和單核CPU執行多工是一樣的了,以旁觀者的角度來看,你就正在同時寫5科作業。

但是,切換作業是有代價的,比如從語文切到數學,要先收拾桌子上的語文書本、鋼筆(這叫儲存現場),然後,開啟數學課本、找出圓規直尺(這叫準備新環境),才能開始做數學作業。作業系統在切換程式或者執行緒時也是一樣的,它需要先儲存當前執行的現場環境(CPU暫存器狀態、記憶體頁等),然後,把新任務的執行環境準備好(恢復上次的暫存器狀態,切換記憶體頁等),才能開始執行。這個切換過程雖然很快,但是也需要耗費時間。如果有幾千個任務同時進行,作業系統可能就主要忙著切換任務,根本沒有多少時間去執行任務了,這種情況最常見的就是硬碟狂響,點視窗無反應,系統處於假死狀態。所以,多工一旦多到一個限度,反而會使得系統效能急劇下降,最終導致所有任務都做不好。

是否採用多工的第二個考慮是任務的型別,可以把任務分為計算密集型和I/O密集型。計算密集型任務的特點是要進行大量的計算,消耗CPU資源,比如對視訊進行編碼解碼或者格式轉換等等,這種任務全靠CPU的運算能力,雖然也可以用多工完成,但是任務越多,花在任務切換的時間就越多,CPU執行任務的效率就越低。計算密集型任務由於主要消耗CPU資源,這類任務用Python這樣的指令碼語言去執行效率通常很低,最能勝任這類任務的是C語言,我們之前提到了Python中有嵌入C/C++程式碼的機制。

除了計算密集型任務,其他的涉及到網路、儲存介質I/O的任務都可以視為I/O密集型任務,這類任務的特點是CPU消耗很少,任務的大部分時間都在等待I/O操作完成(因為I/O的速度遠遠低於CPU和記憶體的速度)。對於I/O密集型任務,如果啟動多工,就可以減少I/O等待時間從而讓CPU高效率的運轉。有一大類的任務都屬於I/O密集型任務,這其中包括了我們很快會涉及到的網路應用和Web應用。

引自廖雪峰官方網站的《Python教程》

單執行緒+非同步I/O

  現代作業系統對I/O操作的改進中最為重要的就是支援非同步I/O。如果充分利用作業系統提供的非同步I/O支援,就可以用單程式單執行緒模型來執行多工,這種全新的模型稱為事件驅動模型。Nginx就是支援非同步I/O的Web伺服器,它在單核CPU上採用單程式模型就可以高效地支援多工。在多核CPU上,可以執行多個程式(數量與CPU核心數相同),充分利用多核CPU。用Node.js開發的伺服器端程式也使用了這種工作模式,這也是當下實現多工程式設計的一種趨勢。

  在Python語言中,單執行緒+非同步I/O的程式設計模型稱為協程,有了協程的支援,就可以基於事件驅動編寫高效的多工程式。協程最大的優勢就是極高的執行效率,因為子程式切換不是執行緒切換,而是由程式自身控制,因此,沒有執行緒切換的開銷。協程的第二個優勢就是不需要多執行緒的鎖機制,因為只有一個執行緒,也不存在同時寫變數衝突,在協程中控制共享資源不用加鎖,只需要判斷狀態就好了,所以執行效率比多執行緒高很多。如果想要充分利用CPU的多核特性,最簡單的方法是多程式+協程,既充分利用多核,又充分發揮協程的高效率,可獲得極高的效能。

將耗時間的任務放線上程中以獲得更好的使用者體驗

  zai有“下載”和“關於”兩個按鈕,用休眠的方式模擬點選“下載”按鈕會聯網下載檔案需要耗費10秒的時間,如果不使用“多執行緒”,我們會發現,當點選“下載”按鈕後整個程式的其他部分都被這個耗時間的任務阻塞而無法執行了,如果使用多執行緒將耗時間的任務放到一個獨立的執行緒中執行,這樣就不會因為執行耗時間的任務而阻塞了主執行緒。

import time
import tkinter
import tkinter.messagebox
from threading import Thread


def main():

    class DownloadTaskHandler(Thread):

        def run(self):
            time.sleep(10)
            tkinter.messagebox.showinfo('提示', '下載完成!')
            # 啟用下載按鈕
            button1.config(state=tkinter.NORMAL)

    def download():
        # 禁用下載按鈕
        button1.config(state=tkinter.DISABLED)
        # 通過daemon引數將執行緒設定為守護執行緒(主程式退出就不再保留執行)
        # 線上程中處理耗時間的下載任務
        DownloadTaskHandler(daemon=True).start()

    def show_about():
        tkinter.messagebox.showinfo('關於', 'v1.0')

    top = tkinter.Tk()
    top.title('單執行緒')
    top.geometry('200x150')
    top.wm_attributes('-topmost', 1)

    panel = tkinter.Frame(top)
    button1 = tkinter.Button(panel, text='下載', command=download)
    button1.pack(side='left')
    button2 = tkinter.Button(panel, text='關於', command=show_about)
    button2.pack(side='right')
    panel.pack(side='bottom')

    tkinter.mainloop()


if __name__ == '__main__':
    main()

使用多程式對複雜任務進行“分而治之”

先去建立了一個列表容器然後填入了100000000個數,當我們將這個任務分解到8個程式中去執行的時候,我們暫時也不考慮列表切片操作花費的時間,只是把做運算和合並運算結果的時間統計出來。


from multiprocessing import Process, Queue
from random import randint
from time import time


def task_handler(curr_list, result_queue):
    total = 0
    for number in curr_list:
        total += number
    result_queue.put(total)


def main():
    processes = []
    number_list = [x for x in range(1, 100000001)]
    result_queue = Queue()
    index = 0
    # 啟動8個程式將資料切片後進行運算
    for _ in range(8):
        p = Process(target=task_handler,
                    args=(number_list[index:index + 12500000], result_queue))
        index += 12500000
        processes.append(p)
        p.start()
    # 開始記錄所有程式執行完成花費的時間
    start = time()
    for p in processes:
        p.join()
    # 合併執行結果
    total = 0
    while not result_queue.empty():
        total += result_queue.get()
    print(total)
    end = time()
    print('Execution time: ', (end - start), 's', sep='')


if __name__ == '__main__':
    main()

使用多程式後由於獲得了更多的CPU執行時間以及更好的利用了CPU的多核特性,明顯的減少了程式的執行時間,而且計算量越大效果越明顯。當然,如果願意還可以將多個程式部署在不同的計算機上,做成分散式程式,具體的做法就是通過multiprocessing.managers模組中提供的管理器將Queue物件通過網路共享出來(註冊到網路上讓其他計算機可以訪問)

相關文章