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各類免費最新入門學習資料!