天天看點

python之多線程

注:本文是廖大的教程文章,本人也在學習,因為老是記不住,自己手打一邊,代碼也是親自測試。 廖大傳送門

多程序

多個任務可以由多程序完成,也可以由一個程序内的多線程完成。

一個線程由多個程序組成,一個程序至少有一個線程。

由于線程是作業系統直接支援的單元,是以,進階語言都内置多線程的支援,python 也不例外,并且,python 的線程是真正的 Posix Thread ,不是模拟出來的線程。

python 的标準庫提供了兩個子產品:_thread 和 threading ,_thread 是低級子產品,threading 是進階子產品。絕大多數的情況下,我們隻用 threading 就可以了。

啟動一個線程就是把函數傳入并建立 Thread 執行個體,然後調用 start() 函開始執行就可以了。

import time
import threading

#線程執行的代碼
def loop():
    print('thread %s is running' % threading.current_thread().name)
    n = 0
    while n < 5:
        n += 1
        print('thread %s >>> %s' % (threading.current_thread().name,n))
        time.sleep(1)
    print('thread %s end' % threading.current_thread().name)

print('thread %s is running...' % threading.current_thread().name)
t = threading.Thread(target=loop,name='LoopTread')
t.start()
t.join()
print('thread %s end' % threading.current_thread().name)
           

運作結果

thread MainThread is running...
thread LoopTread is running
thread LoopTread >>> 1
thread LoopTread >>> 2
thread LoopTread >>> 3
thread LoopTread >>> 4
thread LoopTread >>> 5
thread LoopTread end
thread MainThread end
           

由于任何程序都會預設開啟一個線程,我們把該線程稱為主線程,主線程又可以開啟新的線程,Python 的 threading 子產品有個 current_thread() 函數,它永遠傳回目前線程的執行個體。主線程執行個體的名字叫 MainThread ,子線程的名字在建立時指定,我們用 LoopThread 命名子線程。名字僅僅在列印時用來顯示,完全沒有其他意義,如果不起名字 Python 就自動給線程命名為 Thread-1,Thread-2……

Lock

多程序和多線程最大的不同在于,多程序中,同一個變量,各自有一份拷貝到每個程序,互不影響,而線程中,所有變量都是又所有線程共享所有,任何一個變量都可以被任何一個線程修改,是以,線程之間共享資料最大的危險在于多線程同時修改同一個變量,把内容給改亂了。

舉個例子

#假定這是你的銀行存款
balance = 0

def change_it(n):
    #先存後取
    global balance
    balance += n
    balance -= n

def run_thread(n):
    for i in range(100000):
        change_it(n)

t1 = threading.Thread(target=run_thread,args=(5,))
t2 = threading.Thread(target=run_thread,args=(8,))

t1.start()
t2.start()
t1.join()
t2.join()
print(balance)
           

我們定義了一個共享變量balance,初始值為0,并且啟動兩個線程,先存後取,理論上結果應該為0,但是,由于線程的排程是由作業系統決定的,當t1、t2交替執行時,隻要循環次數足夠多,balance的結果就不一定是0了。

運作結果:

5
           

原因是因為進階語言的一條語句在 CPU 執行時是若幹條語句,即使一個簡單的計算

balance += n
           

也要分兩步

  • 計算 balance + n 結果存到臨時變量中,
  • 将臨時變量的值賦給 balance

究其原因,是因為修改 balance 需要多條語句,而執行這幾條語句時,線程可能中斷,進而導緻多個線程把同一個對象的内容改亂了。

兩個線程同時一存一取,就可能導緻餘額不對,你肯定不希望你的銀行存款莫名其妙地變成了負數,是以,我們必須確定一個線程在修改 balance的時候,别的線程一定不能改。

如果我們要確定 balance 計算正确,就要給 change_it() 上一把鎖,當某個線程開始執行 change_it() 時,我們說,該線程因為獲得了鎖,是以其他線程不能同時執行 change_it(),隻能等待,直到鎖被釋放後,獲得該鎖以後才能改。由于鎖隻有一個,無論多少線程,同一時刻最多隻有一個線程持有該鎖,是以,不會造成修改的沖突。建立一個鎖就是通過threading.Lock() 來實作:

lock = threading.Lock()
def run_thread(n):
    for i in range(100000):
        #先要擷取鎖
        lock.acquire()
        try:
            #放心改吧
            change_it(n)
        finally:
            #改完記得釋放鎖哦
            lock.release()
           

當多個線程同時執行 lock.acquire() 時,隻有一個線程能成功地擷取鎖,然後繼續執行代碼,其他線程就繼續等待直到獲得鎖為止。

獲得鎖的線程用完後一定要釋放鎖,否則那些苦苦等待鎖的線程将永遠等待下去,成為死線程。是以我們用 try...finally 來確定鎖一定會被釋放。

  • 鎖的好處就是確定了某段關鍵代碼隻能由一個線程從頭到尾完整地執行。
  • 壞處當然也很多,首先是阻止了多線程并發執行,包含鎖的某段代碼實際上隻能以單線程模式執行,效率就大大地下降了。
  • 其次,由于可以存在多個鎖,不同的線程持有不同的鎖,并試圖擷取對方持有的鎖時,可能會造成死鎖,導緻多個線程全部挂起,既不能執行,也無法結束,隻能靠作業系統強制終止。

多核CPU

如果你不幸擁有一個多核CPU,你肯定在想,多核應該可以同時執行多個線程。

如果寫一個死循環的話,會出現什麼情況呢?

打開Mac OS X的Activity Monitor,或者Windows的Task Manager,都可以監控某個程序的CPU使用率。

我們可以監控到一個死循環線程會100%占用一個CPU。

如果有兩個死循環線程,在多核CPU中,可以監控到會占用200%的CPU,也就是占用兩個CPU核心。

要想把N核CPU的核心全部跑滿,就必須啟動N個死循環線程。

試試用Python寫個死循環:

import threading, multiprocessing

def loop():
    x = 0
    while True:
        x = x ^ 1

for i in range(multiprocessing.cpu_count()):
    t = threading.Thread(target=loop)
    t.start()
           

啟動與CPU核心數量相同的N個線程,在4核CPU上可以監控到CPU占用率僅有102%,也就是僅使用了一核。

但是用C、C++或Java來改寫相同的死循環,直接可以把全部核心跑滿,4核就跑到400%,8核就跑到800%,為什麼Python不行呢?

因為Python的線程雖然是真正的線程,但解釋器執行代碼時,有一個GIL鎖:Global Interpreter Lock,任何Python線程執行前,必須先獲得GIL鎖,然後,每執行100條位元組碼,解釋器就自動釋放GIL鎖,讓别的線程有機會執行。這個GIL全局鎖實際上把所有線程的執行代碼都給上了鎖,是以,多線程在Python中隻能交替執行,即使100個線程跑在100核CPU上,也隻能用到1個核。

GIL是Python解釋器設計的曆史遺留問題,通常我們用的解釋器是官方實作的CPython,要真正利用多核,除非重寫一個不帶GIL的解釋器。

是以,在Python中,可以使用多線程,但不要指望能有效利用多核。如果一定要通過多線程利用多核,那隻能通過C擴充來實作,不過這樣就失去了Python簡單易用的特點。

不過,也不用過于擔心,Python雖然不能利用多線程實作多核任務,但可以通過多程序實作多核任務。多個Python程序有各自獨立的GIL鎖,互不影響。