python入門系列:多線程

python中的GIL

GIL(Global Interpreter Lock),就是一個鎖。

Python中的一個線程對應于 C語言 中的一個線程。

GIL使得同一時刻只有一個線程在一個cpu上執行字節碼,無法將多個線程分配到多個cpu上進行同步運行。如果在單核cpu上,線程是并發運行,而不是并行。

首先,這樣效率不高,但是看似也不會產生數據訪問沖突的問題,畢竟同一時刻只有一個線程在一個核上運行嘛,然而:

sum = 0

def add():

global sum

for i in range(1000000):

sum += 1

def subtract():

global sum

for i in range(1000000):

sum -= 1

import threading

add_thread = threading.Thread(target=add)

sub_thread = threading.Thread(target=subtract)

add_thread.start()

sub_thread.start()

add_thread.join()

sub_thread.join()

print(sum)

如果按照上面的理解,線程間很安全,最后結果應該會是 0,運行三次代碼的結果如下:

# result:

# 358918

# 718494

# -162684

這說明兩個線程并沒有順序異步執行。在一些特定的情況,GIL這把鎖會被打開,一定程度上達到并行的效果。

GIL會根據線程執行的字節碼行數以及時間片以及遇到?I/O 操作打開,所以Python的多線程對 I/O 密集型代碼比較友好,比如,文件處理和網絡爬蟲。

多線程編程

線程模塊

在Python3中提供了兩個模塊來使用線程_thread和threading,前者提供了低級別、原始的線程以及一個簡單的鎖,相比后者功能還是比較有限的,所以我們使用threading模塊。

使用案例

直接使用Thread來實例化

import time

import threading

def learn(obj):

print("learning {sth} started".format(sth=obj))

time.sleep(2)

print("learning {sth} end".format(sth=obj))

def play(obj):

print("playing {sth} started".format(sth=obj))

time.sleep(4)

print("playing {sth} end".format(sth=obj))

# 創建出兩個線程對象

learn_thread = threading.Thread(target=learn, args=("Python",))

play_thread = threading.Thread(target=play, args=("Game",))

start_time = time.time()

# 啟動線程,開始執行

learn_thread.start()

play_thread.start()

end_time = time.time()

span = end_time - start_time

print("[lasting for {time_span}s]".format(time_span=span))

# result:

# learning Python started

# playing Game started

# [lasting for 0.0005965232849121094s]

# learning Python end

# playing Game end

可以看到,整個程序的運行時間基本上是 0s,這是因為整個程序中實際有三個線程:創建出來的兩個線程和主線程(MainThread),那兩個線程創建出來后就不受主線程控制了,他們的工作不占用主線程的時間,主線程除了計時就沒有其他邏輯了,因此主線程持續時間是幾乎是 0s。

但是主線程邏輯完成后并沒有退出,它等待了另外兩個線程運行的結束,如果主線程在其他兩個線程結束之前就退出了,意味著整個程序進程終止了,另外兩個線程會迅速終止。如果我們就是有這種需求,那就可以將另外兩個線程配置成守護線程,主線程結束,他們也立刻結束。

learn_thread.setDeamon(True)

play_thread.setDeamon(True)

# result:

# learning Python started

# playing Game started

# [lasting for 0.015601158142089844s]

主線程邏輯執行完后就退出了,其他兩個線程還沒來得及打印消息也被一并終止了。

如果主線程需要等到兩個線程執行完后再打印整個運行時間,就可以這么設置:

learn_thread.start()

play_thread.start()

# 主線程會在這里阻塞,創建出來的線程執行完后才繼續往下執行

learn_thread.join()

play_thread.join()

end_time = time.time()

span = end_time - start_time

print("[lasting for {time_span}s]".format(time_span=span))

# result:

# learning Python started

# playing Game started

# learning Python end

# playing Game end

# [lasting for 4.003938436508179s]

現在總的運行時間是 4s,意味著主線程等待了兩個子線程的運行,而且兩個子線程是同步執行的,2s + 4s = max(2s, 4s)。

通過繼承Thread實現多線程

import threading

class LearnThread(threading.Thread):

def __init__(self, obj):

self.sth = obj # 處理一下參數問題

super().__init__() # 委托給父類完成創建

# 這個函數在 start() 之后會自動調用,里面寫主要的業務邏輯

def run(self):

print("learning {sth} started".format(sth=self.sth))

time.sleep(2)

print("learning {sth} end".format(sth=self.sth))

用同樣的邏輯實現PlayThread子線程類,最后結果如下:

...

# 創建兩個線程實例

learn_thread = LearnThread("Python")

play_thread = PlayThread("BasketBall")

start_time = time.time()

# 啟動線程,開始執行

learn_thread.start()

play_thread.start()

learn_thread.join()

play_thread.join()

end_time = time.time()

span = end_time - start_time

print("[lasting for {time_span}s]".format(time_span=span))

# result:

# learning Python started

# playing BasketBall started

# learning Python end

# playing BasketBall end

# [lasting for 4.0020973682403564s]

這種方式更加靈活,在線程內可以自定義我們的邏輯,如果線程非常復雜,這樣寫可以使程序更加模塊化,也更容易后續維護。

線程間的通信

引言

如果程序中有多個線程,他們的推進順序可能相互依賴,a線程執行到某一階段后,b線程才能開始執行,b線程執行完畢后,a線程才能繼續進行。

這樣一種情況之下,線程之間就要進行通信,才能保證程序的正常運行。

通過共享變量來實現

線程安全不能保證,不推薦,就不詳細講解了。

通過Queue來進行線程通信

思路和共享變量差不多,只不過這里使用的數據結構是經過封裝的,是線程安全的,使用起來也更加方便。

import time

import threading

from queue import Queue

# 用來向 queue 中加入數據

def append(q):

for i in range(4):

print("[append_thread] putting data {data} to q...".format(data=i))

q.put(i)

time.sleep(1)

q.put(None) # end flag

# 用來向 queue 中取出數據

def pop(q):

while True:

data = q.get()

if data is None:

print("[pop_thread] all clear in the queue")

q.task_done()

break

else:

print("[pop_thread] get data mk5o97z".format(d=data))

time.sleep(3)

q.task_done()

q = Queue(2)

append_thread = threading.Thread(target=append, args=(q,))

pop_thread = threading.Thread(target=pop, args=(q,))

append_thread.start()

pop_thread.start()

q.join() # 利用 queue 來阻塞主線程

print("===Done===")

# result:

# [append_thread] putting data 0 to q...

# [pop_thread] get data 0

# [append_thread] putting data 1 to q...

# [append_thread] putting data 2 to q...

# [pop_thread] get data 1

# [append_thread] putting data 3 to q...

# [pop_thread] get data 2

# [pop_thread] get data 3

# [pop_thread] all clear in the queue

# ===Done===

q = Queue(2)

這里創建了一個Queue類型對象,接收一個整數參數,告知隊列的容量,如果傳入一個非正數,容量默認是正無窮(當然,這取決于你電腦的配置情況)。

q.get(block=True, timeout=None)

q.put(item, block=True, timeout=None)

這兩個方法向隊列中添加元素,或取出元素。默認情況下,如果隊列滿了,調用q.put()會進行阻塞,直到隊列中有空位才放入元素,完成整個函數調用??梢栽O置block=False將它轉為非阻塞調用,如果隊列滿了,則直接引發一個Full exception,通過timeout來設置一定的等待時間,如果在阻塞等待時間內任然沒空位放入元素,再拋出異常。q.get()方法邏輯類似,拋出異常Empty exception

q.task_done()

q.join()

q.join()通過隊列來阻塞主線程,隊列內部有一個計數器,每放入一個元素,計數器加一,當計數器重新歸零后,解除阻塞。q.task_done()就是將計數器減一的,一般和q.get()配合使用,如果使用過量,導致計數器 小于0 ,則引發ValueError Exception

q.qsize() # 獲得隊列中的元素個數

q.empty() # 隊列是否為空

q.full() # 隊列是否滿

線程同步

引言

再回到最開始的GIL案例,兩個線程,其中一個對全局變量進行一百萬次加1運算,另外一個進行一百萬次減1運算。最后全局變量的值是幾乎隨機的,與我們預想的 0

并不相同。因為兩個線程是異步修改這個變量,不能保證某一時刻的取值就是正確的。

因此,要對線程進行同步控制,當一個線程操作時,另一個等待,然后交換執行。

使用Lock

import threading

sum = 0

lock = threading.Lock()

def add():

global sum

for i in range(1000000):

lock.acquire() # lock here

sum += 1

lock.release() # unlock here

def subtract():

global sum

for i in range(1000000):

lock.acquire() # lock here

sum -= 1

lock.release() # unlock here

add_thread = threading.Thread(target=add)

sub_thread = threading.Thread(target=subtract)

add_thread.start()

sub_thread.start()

add_thread.join()

sub_thread.join()

print(sum)

# result:

# 0

通過Lock,我們可以在執行相關代碼之前申請,將一段代碼邏輯鎖起來,鎖資源全局只有一個,一個線程申請了另外一個就不能夠申請,它要等到資源釋放后才能申請。因此,就保證了同一時刻只有一個線程拿到鎖,只有一個線程能夠進行變量的修改。

這種方式比較影響性能,獲取鎖釋放鎖都需要時間,也可能引起死鎖問題,連續兩次執行lock.acquire()就可以引發死鎖,死鎖可以通過另外一個線程來解開。這也是后面使用Condition的一個核心理念。

import threading

lock = threading.Lock()

def dead_lock(lock):

lock.acquire()

lock.acquire() # 直接調用兩次會死鎖這個線程

print("unlock")

def un_lock(lock):

lock.release() # 用這個線程來開鎖

dead_lock_thread = threading.Thread(target=dead_lock, args=(lock,))

un_lock_thread = threading.Thread(target=un_lock, args=(lock,))

dead_lock_thread.start() # 注意調用順序

un_lock_thread.start() # 通過它,死鎖的線程會被打開,繼續執行打印結果

# result:

# unlock

使用RLock

在同一個線程中,可以連續多次調用lock.acquire(),注意最后獲取鎖和釋放鎖的次數要相同。

lock = threading.RLock()

def add():

global sum

for i in range(1000000):

lock.acquire() # lock here

do_sth(lock) # 在對變量 +1 之前,要對變量做其他操作,在函數中可以再次加鎖

sum += 1

lock.release() # unlock here

使用Condition

底層使用 RLock 實現的,實現了上下文管理器協議,可以用with語句進行操作,不用擔心acquire()和release()的問題。

import threading

msg = []

conn = threading.Condition()

def repeater_one(conn):

with conn:

global msg

for i in range(3):

data = "小伙子,沒想到你也是復讀機 ({idx})".format(idx=i)

msg.append(data)

print("[one]:", data)

conn.notify()

conn.wait()

def repeater_two(conn):

with conn:

global msg

for i in range(3):

conn.wait()

data = msg.pop()

print("[two]:", data)

conn.notify()

repeater_one_thread = threading.Thread(target=repeater_one, args=(conn,))

repeater_two_thread = threading.Thread(target=repeater_two, args=(conn,))

# 注意啟動的順序非常重要

repeater_two_thread.start()

repeater_one_thread.start()

# result:

# [one]: 小伙子,沒想到你也是復讀機 (0)

# [two]: 小伙子,沒想到你也是復讀機 (0)

# [one]: 小伙子,沒想到你也是復讀機 (1)

# [two]: 小伙子,沒想到你也是復讀機 (1)

# [one]: 小伙子,沒想到你也是復讀機 (2)

# [two]: 小伙子,沒想到你也是復讀機 (2)

這里實現了兩個復讀機線程,一個線程打印完數據后,另外一個線程進行復述,數據保留在全局變量msg中,通過Condition來協調兩個線程的訪問順序,實現復讀效果。

這種控制方式的思想就是當滿足了某些條件,線程才能繼續運行下去,否則,線程會一直阻塞,直到條件被滿足。

conn.notify()

conn.wait()

當條件滿足是,使用notify()用來喚醒等待的線程,要等待條件時,再使用wait()進行阻塞。repeater one首先說一句話,然后喚醒repeater two,自身進入等待狀態;repeater two等到有人說話后,進行復讀,然后喚醒repeater one再次說話,自身進入等待狀態。要保證的是,每一時刻,只能有一個線程處于等待狀態,否則兩個線程都會被阻塞。因此,線程啟動的順序和阻塞喚醒條件非常重要。

with conn:

# todo

# 或者

conn.require()

# todo

conn.release()

這里直接使用with語句,省略了一些邏輯,也可以使用完整的寫法,但是要注意操作的匹配。

Condition的底層其實使用了兩層鎖,當我們在一個線程中調用require()的時候,內部維護的一個鎖(Rlock)會自動鎖上,另外一個線程在調用require()時,就會被阻塞。

下面是wait()的主要邏輯,調用wait()會將鎖打開self._release_save(),這就允許了另外一個線程調用require(),同時建立第二層鎖,waiter,將它加入到隊列(底層是 deque)中,每調用一次就會產生一把鎖,同時調用waiter.acquire(),接著在后面,會用各種邏輯判斷再次調用waiter.acquire(),前面講過,連續兩次調用會造成這個線程的阻塞。 那這個鎖在哪里打開呢?在另外一個線程的notify()方法中,這個鎖打開了,它就可以繼續往下運行了。

使用Semaphore

用來控制資源使用數量的鎖,對于文件來說,讀操作可以有多個線程同時進行,共享文件資源,而寫操作,就只能有一個線程來獨占資源,一般用來控制線程的并發數量。

現在我們來控制讀線程的并發數量,每一時刻只有 3 個線程在工作,而且工作時間是 2s,總共有10個讀線程要完成操作。

import threading

import time

sem = threading.Semaphore(3) # 指明信號的數量

def read(sem):

sem.acquire() # 拿到信號

print("doing reading staff...")

time.sleep(2)

sem.release() # 釋放信號

for i in range(7):

read_thread = threading.Thread(target=read, args=(sem,))

read_thread.start()

"""

最后的結果是每兩秒就有三個線程開始讀操作

"""

# result:

# doing reading staff...

# doing reading staff...

# doing reading staff...

# doing reading staff...

# doing reading staff...

# doing reading staff...

# doing reading staff...

Semaphore的底層是使用Condition進行實現的,內部維護了一個_value變量,用來計數。

acquire()和release()內部的邏輯有些改變,在申請資源時,首先要看_value的值有沒有減到 0 ,如果有,再有線程執行acquire()就會執行wait()進行阻塞,資源釋放時要增加_value的值,同時使用notify()喚醒隊列中等待的一個線程。

concurrent線程池

引言

為什么要線程池呢?回看上面的Semaphore例子,我們定義了一個數量為 3 的信號量,保證了同一時刻只有 3 個線程存在于內存中。但是從程序開始運行到結束,我們一共使用過 7 個線程range(7),完成了 7 次同樣的讀操作,也就是說創建了 7 次線程,又銷毀了 7 次線程。如果每個線程的執行時間非常短,又需要創建大量的線程,那么資源都在創建/銷毀線程的過程中被消耗了。

能不能總共就使用 3 個線程達到同樣的效果呢?每個線程多做幾次同樣的操作邏輯就可以了,concurrent.futures就提供了這樣的管理方案,同時,還有下面這些優點:

主線程中可以獲取某一個線程狀態或者一個任務的狀態,還有返回值。

當一個線程完成工作后,主線程能立刻知道。

futures使得多線程多進程編碼接口一致。

使用案例

from concurrent.futures import ThreadPoolExecutor

import time

def read(sth):

print("Reading {sth}...".format(sth=sth))

time.sleep(1)

return "{sth} done".format(sth=sth)

executor = ThreadPoolExecutor(2) # 創建一個可以容納 兩個 線程的線程池

if __name__ == "__main__":

task_one = executor.submit(read, "books")

task_two = executor.submit(read, "newspaper")

task_three = executor.submit(read, "comics")

print(task_one.done())

time.sleep(4)

print(task_two.done())

print(task_three.done())

# result:

# Reading books...

# Reading newspaper...

# False

# Reading comics...

# True

# True

executor = ThreadPoolExecutor(2)

這里創建了一個容納兩個線程的線程池,如果不指定線程數量參數,它會以 5倍 cpu內核數量作為默認值。Python3在3.5, 3.6, 3.7版本的更新中都加入了可選參數,可以查看官方文檔熟悉新的使用方式。

submit(fn, *args, **kwargs)

task_one = executor.submit(read, "books") # reture Future object

task_two = executor.submit(read, "newspaper")

task_three = executor.submit(read, "comics")

這里使用submit()方法向線程池中提交任務,提交的任務數量可以大于線程池中申請的線程數量。第一個參數是任務函數,后面依次列出參數。一旦任務被提交,線程池中的線程自動進行調度,直到所有提交任務的完成。提交任務后,會返回一個Future對象。

"""

Future 類型對象會在 submit() 函數調用之后返回

"""

future.done() # 如果提交的任務完成,這個方法會返回 True

future.cancel() # 取消任務執行,如果任務已經調度執行,就不能取消

future.result() # 返回任務函數執行后的返回結果

在main中加入新的邏輯:

...

print(task_one.result()) # 這些都是阻塞式調用,獲得結果后才會繼續向下執行

print(task_two.result())

print(task_three.result())

# result:

# books done

# newspaper done

# comics done

上面的寫法其實比較麻煩,如果向線程池中提交的任務過多,這樣操作每個Future對象會相當繁瑣??梢耘窟M行任務的提交,將Future對象加入一個列表進行管理,配合使用模塊中的as_complete函數,可以一次性獲得所有執行完成任務函數的Future對象。

from concurrent.futures import as_completed

...

# ---修改main中的邏輯

items = ["books", "newspaper", "comics"]

task_list = [executor.submit(read, item) for item in items]

for future in as_completed(task_list):

print(future.result())

# result:

# newspaper done

# books done

# comics done

核心邏輯中,as_complete()將已經執行完的任務函數對應的Future對象通過yield進行返回,這里完成的順序和任務提交的順序并不一樣,和內部的調度邏輯有關,我多次執行結果沒有完全一樣。這里的yield邏輯是在一定的條件下才會發生的,因此,只要有線程沒有運行完,就無法yield結果,會在for這里進行阻塞,等到所有任務執行完畢之后,for結束。

還有一種更加簡潔的辦法,在executor中,有一個和Python內置函數map()邏輯相似的函數。它將任務函數和參數進行一一匹配調用,直接返回future.result()對象。這種方式是順序進行調度的,完成順序總是:books,newspaper, comics

...

items = ["books", "newspaper", "comics"]

for res in executor.map(read, items):

print(res)

補充

wait()函數

用來讓主線程在不同條件下等待線程池中線程的運行。

def wait(fs, timeout=None, return_when=ALL_COMPLETED):

"""

1. fs: Futures 對象的序列,當他們對應的任務函數都完成后,解除阻塞

2. timeout: 等待的時間,超過時間就不等了

3. return_when

FIRST_COMPLETED: 任何一個任務函數完成

FIRST_EXCEPTION: 任何一個任務函數執行時拋異常

ALL_COMPLETED: 所有都完成

3個參數條件,哪個先滿足就直接解除阻塞

from concurrent.futures import ThreadPoolExecutor, wait

import time

def read(times):

time.sleep(times)

print("read for {span}s".format(span=times))

executor = ThreadPoolExecutor(2)

time_list = [1, 2, 3, 4]

task_list = [executor.submit(read, times) for times in time_list]

print("done")

# result:

# done

# read for 1s

# read for 2s

# read for 3s

# read for 4s

這里修改了read()函數的邏輯,由讀不同的內容改為讀不同的時間長度。這里主線程沒有等待線程池中的任務,提交任務后直接執行了print()。

...

wait(task_list)

print("done")

# result:

# read for 1s

# read for 2s

# read for 3s

# read for 4s

# done

...

wait(task_list, 2) # 就等兩秒鐘

print("done")

# read for 1s

# read for 2s

# done

# read for 3s

# read for 4s

from concurrent.futures import FIRST_COMPLETED

# 注意常量值的使用,選一種合適的方法進行導入使用

# from concurrent import futures

# futures.FIRST_COMPLETED

...

wait(task_list, return_when=FIRST_COMPLETED)

print("done")

# result:

# read for 1s

# done

# read for 2s

# read for 3s

# read for 4s

Future對象

未來對象?隨便叫啥吧。前面也看到過,通過submit()提交任務函數后,就會返回這么一個對象,可以通過它來監控運行我們任務函數的那個線程:判斷是否運行完成、得到返回值等。

從源碼中,可以看出,任務提交之后,先創建了一個Future類型對象f,然后將這個對象連同我們提交的任務參數一起委托給了_WorkItem(),之后也返回一個對象w,將它加入隊列self._work_queue,這應該就是內部調度的邏輯了,最后返回f。

_adjust_thread_count()用來創建出我們需要的線程數量,并且target=worker,參數列表中有self._work_queue作為參數。_threads是用來存放線程的一個集合。我們提交的不同任務函數怎么變成了一個_worker函數呢?

_worker的主要邏輯中,有一個循環,從self._work_queue中不斷取出一個任務,它是_WorkItem類型的,在submit()函數中加入,然后調用它的run()方法,隨后del work_item將它刪除。多個任務線程讀取的是同一個任務隊列,直到任務全部完成。

在_WorkItem中,它的run()方法調用了我們提交的任務函數fn,并且記錄了它的返回值,進行了一些異常處理。

shutdown()

前面都在講線程池的使用方法和工作原理,還有一個細節需要補充的就是executor的shutdown()方法。

它是用來關閉線程池,如果在關閉之后繼續向里面提交任務,會拋出一個異常。調用之后,self._work_queue.put(None)往任務隊列中加入了一個標記,當線程調度時拿到這個標志就知道任務結束了,這與在前面使用Queue進行線程間通信的案例用了同樣的方式。

有一個可選參數wait,如果它是True,則主線程在這里阻塞,等待所有線程完成任務。如果是False,主線程繼續向下執行。

可以使用with語句,當所有的任務完成之后自動調用shutdown(),這里就不再多舉例子,官方文檔 給出了一個經典的例子,以作參考。

注:喜歡python + qun:839383765 可以獲取Python各類免費最新入門學習資料!

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 線程 操作系統線程理論 線程概念的引入背景 進程 之前我們已經了解了操作系統中進程的概念,程序并不能單獨運行,只有...
    go以恒閱讀 1,671評論 0 6
  • 一文讀懂Python多線程 1、線程和進程 計算機的核心是CPU,它承擔了所有的計算任務。它就像一座工廠,時刻在運...
    星丶雲閱讀 1,480評論 0 4
  • 環境 xubuntu anaconda pycharm python https://www.cnblogs.co...
    Ericoool閱讀 1,922評論 0 0
  • 寫在前面的話 代碼中的# > 表示的是輸出結果 輸入 使用input()函數 用法 注意input函數輸出的均是字...
    FlyingLittlePG閱讀 2,845評論 0 8
  • 多進程 要讓python程序實現多進程,我們先了解操作系統的相關知識。 Unix、Linux操作系統提供了一個fo...
    蓓蓓的萬能男友閱讀 633評論 0 1