小豬的Python學習之旅 —— 7.Python併發之threading模塊(1)

小豬的Python學習之旅 —— 7.Python併發之threading模塊(1)

標籤:Python


引言

從本節開始的連續幾節我們都會圍繞着Python併發進行學習,
本節學習的是 threading 這個線程相關模塊,附上官方文檔:
https://docs.python.org/3/library/threading.html
跟官方文檔走最穩健,網上的文章都是某一時期的產物,IT更新
換代那麼快,過了一段時間可能就改得面目全非了,然後你看了
小豬現在的文章然後寫代碼,這不行那不行就開始噴起我來了,我表示

另外,在查閱相關資料的時候發現很多文章還是用的 thread模塊
在高版本中已經使用threading來替代thread了!!!如果你在
Python 2.x版本想使用threading的話,可以使用dummy_threading
話不多說開始本節內容~


1.threaing模塊提供的可直接調用函數

  • active_count():獲取當前活躍(alive)線程的個數;
  • current_thread():獲取當前的線程對象;
  • get_ident():返回當前線程的索引,一個非零的整數;(3.3新增)
  • enumerate():獲取當前所有活躍線程的列表;
  • main_thread():返回主線程對象,(3.4新增);
  • settrace(func):設置一個回調函數,在run()執行之前被調用;
  • setprofile(func):設置一個回調函數,在run()執行完畢之後調用;
  • stack_size():返回創建新線程時使用的線程堆棧大小;
  • threading.TIMEOUT_MAX:堵塞線程時間最大值,超過這個值會棧溢出!

2.線程局部變量(Thread-Local Data)

先說個知識點:

在一個進程內所有的線程共享進程的全局變量,線程間共享數據很方便
但是每個線程都可以隨意修改全局變量,可能會引起線程安全問題
這個時候,可以對全局變量進行加鎖來解決。對於線程私有數據可以
通過使用局部變量,只有線程自身可以訪問,其他線程無法訪問,
除此之外,Python還給我們提供了ThreadLocal變量,本身是一個全局
變量,但是線程們卻可以使用它來保存私有數據

用法也很簡單,定義一個全局變量:data = thread.local(),然後就可以
往裏面存數據啦,比如data.num = xxx,寫個簡單例子來驗證下:
:如果data沒有設置對應的屬性,直接取會報AttributeError異常,
使用時可以捕獲這個異常,或者先調用hasattr(對象,屬性)判斷對象中
是否有該屬性!

輸出結果

厲害了,不同線程訪問果然是返回的不同值,小豬這種求知慾
旺盛的人肯定是要扒一波看看是怎麼實現的啦,跟源碼會比較
枯燥,先簡單說下實現套路:

threading.local()實例化一個全局對象,這個全局對象裏有
一個大字典,鍵值爲兩個弱引用對象 {線程對象,字典對象},
然後可以通過current_thread()獲得當前的線程對象,然後根據
這個對象可以拿到對應的字典對象,然後進行參數的讀或者寫。

是的大概套路就是這樣,接下來就是剖析源碼環節了,挺枯燥的,
可以不看,看的話,相信你會收穫非常多,小豬昨天下午開始看
_threading_local.py這個模塊的源碼,僅僅246行,卻看到了晚上
十點才捨得回家,收益頗豐,Get了N多知識點,至少在那些什麼
Python教程裏沒看到過,每弄懂一個都會忍不出發出:

這樣的感嘆!快上老司機小豬的車吧,上車只需五個滑稽幣:


*3._threading_local源碼解析

按住ctrl點local()方法,會進到threading.py模塊,會定位到這一行:

_thread 模塊上節也說了threading模塊的基礎模塊,應該儘量使用
threading 模塊替代,而我們代碼裏也沒導入這個模塊,所以會走
_threading_local ,點進去看下這個模塊,246行代碼,不多,嘿嘿,
點擊PyCharm左側的Structure看看代碼結構

關注點在_localimpllocal兩個類上,我們先把這個模塊的源碼
全選,然後新建一個Python文件,把內容粘貼到裏面,爲什麼要
這樣做呢?

:因爲這樣方便我們進行代碼執行跟蹤啊,Debug調試
或打Log跟蹤方法運行順序,或者查看某個時刻某些變量的值!

很多小夥伴可能只會print不會使用Debug調試,這裏順道簡單
介紹下怎麼用,掌握這個對跟源碼非常有用,務必掌握!!!

1.PyCharm調試速成

點擊左側邊欄可以下斷點,在調試模式下運行的話,運行到
這一行的時候會暫時掛起,並激活調試器窗口:

點擊頂部的小蟲子標記即可進入調試模式:
運行到我們埋下斷點的這一行後,就會掛起並激活下面這個
調試器窗口:

MainThread這個表示當前斷點上的線程,下面是該線程的堆棧幀
右側Variables是變量調試窗口,可以查看此時的變量情況!
接着就來一一說下一些調試技巧吧:

單步調試Step Over(F8),程序向下執行一行,如果該行
函數被調用,直接執行完返回,然後執行下一行;

當單步調試執行到某一個函數,如果你不想直接運行完,切到下
一行而是想看進去這個函數的運行過程的話,可以點擊
Step Into(F7)

上面這一步,遇到官方類庫的函數也會進去,如果只想在碰到
自己定義函數才進去的話,可以點擊
Step Into My Code(Alt + Shift + F7)

進入函數後確定沒什麼問題了,可以點擊 Step Out(Shift + F8)
跳出這個函數,返回該函數被調用處的下一行語句。

如果想快速執行到下一個斷點的位置,可以點擊
Run to Cursor(Alt + F9)

跨斷點調試,點擊左側欄的:,直接跳過當前斷點,
進入下一個斷點。

監視變量,有時右側Variables,顯示的變量有很多時,而你
想關注某一個變量而已,可以點擊這個小眼鏡:,然後
輸入你想監視的變量名,如果名字太長或者懶,可以直接右鍵
變量,Add To Watches即可!不想監視時可右鍵Remove Watch

停止調試,點擊左側紅色按鈕即可跳過調試,不是停止程序!:

斷點設置,點擊左側:,可以打開斷點設置窗口,可以在此
看到所有的斷點,設置條件斷點(滿足某個條件時,暫停程序執行),
刪除斷點,或者臨時禁用斷點等。

好的,關於PyCharm調試就先說這麼多,基本夠用了,
回到我們的源碼,我們使用了threading.local()初始化了實例,
按照我們第一節學的類內容,類會走構造函數__init__()對吧?
然而,在local類裏,並沒有發現這個函數,只有一個__new__(cls, *args, **kw)
這又是一個新的知識點了!


2.Python中的經典類和新式類

在Python 2.x中默認都是經典類,除非顯式繼承object纔是新式類;
而在Python 3.x中默認都是新式類,不用顯式繼承object;
新式類相比經典類增加了很多內置屬性,比如__class__
獲得自身類型(type),__slots__內置屬性,還有這裏的
_new_()函數等。


3.__new__() 函數

在調用_init_()方法前,_new_(cls, *args, **kw)可決定是否使用該
_init_()方法,可以調用其他類的構造方法或者直接返回別的對象
來作爲本類的實例cls表示需要實例化的類,該參數在實例化時由
Python解釋器自動提供。另外還要注意一點,_new_必須有返回值,
可以返回父類_new_()出來的實例object的_new_()出來的實例
如果new()沒有成功返回cls類型的對象,是不會調用_init_()
來對對象進行初始化的!!!

臥槽,騷氣,代碼裏也剛好這樣做了,返回的是一個_localimpl()對象:

直接實例化的_localimpl(),然後設置了localargslocallock
以及調用了create_dict()方法。先定位到_localimpl類的localargs

又觸發新知識點:黑魔法_slots_


4.Python黑魔法__slots__內置屬性

作用是阻止在實例化類時爲實例分配dict,使用這個東西會帶來:
更快的屬性訪問速度減少內存消耗。此話怎麼說?

默認情況下,Python的類實例都會有一個_dict_來存儲實例的屬性,
注意:只保存實例的變量,不會保存類屬性!!!
可以調用內置屬性_dict_進行訪問,比如下面的例子:

輸出結果

看上去是挺靈活的,在程序裏可以隨意設置新屬性,只是每次
實例化類對象的時候,都需要去分配一個新的dict,如果是對於
擁有固定屬性的class來說,這就有點浪費內存了,特別是在需要
創建大量實例的時候,這個問題就尤爲突出了。Python在新式類中給
我們提供了_slots_屬性來幫助我們解決這個問題。
_slots_是一個元組,包括了當前能訪問到的屬性,定義後
slots中定義的變量變成了類的描述符,相當於java裏的成員變量
聲明,不能再增加新的變量。還有一點要注意:
定義定義了_slots_後,就不再有_dict_!!!可以寫個例子驗證下:

輸出結果

Python內置的dict(字典) 本質是一個哈希表,通過空間換時間
在實例化對象達到萬級,和_slots_元組對比耗費的內存就不是
一點半點了!另外屬性訪問速度也是比dict快的,相關對比以及
更多內容可見:https://www.cnblogs.com/rainfd/p/slots.html
和:Saving 9 GB of RAM with Python’s _slots_

瞭解完_slots_後,我們回到我們的源碼,回到localimpl的__init_()

設置了一個key,規則是:_threading_local._localimpl. 拼接上對象所在的內存地址
這裏的id()函數作用是獲得對象的內存地址。接着初始化了一個dicts大詞典
拿來存放鍵值對的:(弱引用的線程對象,該線程在_localimpl對象裏對應的數據字典)
就是每個線程對象,對應_localimp裏不同的字典對象,這些字典對象都放在
大字典裏。

接着回到local類_new_() 函數,這裏是一個設置屬性的方法:

local__impl屬性在上面通過__slots_定義了

簡單點理解就是爲local設置了一個_localimpl對象,後面
可以根據根據這個name = _local__impl拿到對應的_localimpl對象!

而且這裏沒那麼簡單,local類裏對這個函數進行了重寫:

這裏前面判斷name是否爲dict,猜測是權限控制,不允許
外部通過_setattr__delattr_來操作字典,只允許通過
_patch()方法來修改操作字典!

接着繼續來跟下_patch()方法:

@contextmanager 又是什麼東西???

又是新的知識點~


5.@contextmanager

這就涉及到我們以前學習的with結構了,在爬蟲寫入文件那裏用過,
不用自己寫finally,然後在裏面去close()文件,以避免不必要的錯誤,
不知道你還記不記得,不記得的話回頭翻翻吧。

對於類似於文件關閉這種不想遺忘的重要操作,我們可以自己封裝
一個with結構來進行處理,封裝也很簡單,再定義你那個類的時候
重寫_enter_方法和_exit_方法,比如文件關閉那個可以自定義
成這樣的:

如果覺得上面這種實現起來比較麻煩的話,就可以用
@contextmanager啦,直接就一個方法,比定義類簡單多了~

知道@contextmanager之後,繼續來分析_patch()方法,先根據
_local__impl這個值拿到了local裏的_localimpl對象,然後
調用impl的get_dict()想獲得一個數據字典:

current_thread()獲得當前線程,然後獲得線程的內存地址,查找dicts裏
此線程對應的字典,此時,如果dicts裏沒有這個線程對應的數據字典,
會引發KeyError異常,執行:

調用create_dict()方法創建字典:

創建空字典,設置key,獲得當前線程,獲得當前線程的內存地址;
就是做一些準備工作,接着看到定義了兩個方法,先跳過,往下看:

然後又是新的知識點:Python弱引用函數ref()


6.Python弱引用函數ref()

ref()這個函數是weakref模塊 提供的用於創建一個弱引用的函數,
參數異常是想建立弱引用的對象當弱引用的對象被刪除後的回調函數
爲什麼要用弱引用?

Python和其他高級語言一樣,使用垃圾回收器來自動銷燬不再使用的對象,
每個對象都有一個引用計數,當這個計數爲0時,Python才能夠安全地銷燬
這個對象,當對象只剩下弱引用時也會回收!

這裏的local_deleted()thread_deleted() 這兩個回調參數
就是在_localimpl對象線程對象被回收時觸發:

localimpl對象被回收時把線程裏持有localimpl對象的弱引用刪除掉,
線程對象對象被回收時,彈出大字典中該線程對應的數據字典;

剩下的三句就是保存localimpl對象的弱引用到thread的__dict_裏,
localimpl對象添加鍵值對(線程弱引用,線程對應的數據字典)
大字典中,然後返回線程對應的數據字典

又回到patch()方法,拿到參數,然後又調用__init_函數
然後調用了_init_函數,這裏不是很明白動機,猜測是如果
另外重寫了local的_init_函數,可以調用一些其他的操作吧。

再接着又有一個知識點了,操作數據字典時的加鎖,正常來說
私用Lock或RLock,需要自己去調用acquire()和release(),
而使用with關鍵字,就無需你自己去操心了,原因是RLock
類裏重寫了_enter__exit_函數。

最後yield返回一個生成器對象。

到此,_threading_local模塊的完整的源碼實現套路就浮出水面了,
不錯,Get了很多新的姿勢,如果你還有些疑惑的話,可以自己Debug,
跟跟方法的調用順序,慢慢體會。


4.線程對象(threading.Thread)

使用threading.Thread創建線程

可以通過下面兩種方法創建新線程:

  • 1.直接創建threading.Thread對象,並把調用對象作爲參數傳入
  • 2.繼承threading.Thread類重寫run()方法;

這裏寫代碼測試個東西:到底使用多線程快還是單線程快~

兩次運行結果採集:

測試環境:Ubuntu 14.04 爲了儘量公平,把單線程運行那個也另外放到
一個線程中,結果發現,多線程並沒有比單線程快,反而還慢了一些。
出現這個原因是以爲Python中的:全局解釋器鎖(GIL),上一節已經
介紹過了,這裏就不再複述了。

Thread類構造函數

參數依次是

  • group:線程組
  • target:要執行的函數
  • name:線程名字
  • args/kwargs:要傳入的函數的參數
  • daemon:是否爲守護線程

相關屬性與函數

  • start():啓動線程,只能調用一次;
  • run():線程執行的操作,可繼承Thread重寫,參數可從args和kwargs獲取;
  • join([timeout]):堵塞調用線程,直到被調用線程運行結束或超時;如果
    沒設置超時時間會一直堵塞到被調用線程結束。
  • name/getName():獲得線程名;
  • setName():設置線程名;
  • ident:線程是已經啓動,未啓動會返回一個非零整數;
  • is_alive():判斷是否在運行,啓動後,終止前;
  • daemon/isDaemon():線程是否爲守護線程;
  • setDaemon():設置線程爲守護線程;

3.Lock(指令鎖)與RLock(可重入鎖)

上節就說過了,多個線程併發地去訪問臨界資源可能會引起線程同步
安全問題,這裏寫個簡單的例子,多線程寫入同一個文件

打開test.txt,發現結果並沒有按照我們預想的1-20那樣順序打印,而是亂的。

threading模塊中提供了兩個類來確保多線程共享資源的訪問:
LockRLock

Lock指令鎖,有兩種狀態(鎖定與非鎖定),以及兩個基本函數:
使用acquire()設置爲locked狀態,使用release()設置爲unlocked狀態。
acquire()有兩個可選參數:blocking=True:是否堵塞當前線程等待;
timeout=None:堵塞等待時間。如果成功獲得lock,acquire返回True,
否則返回False,超時也是返回False。
使用起來也很簡單,在訪問共享資源的地方acquire一下,用完release就好:

這裏把循環次數改成了100,test.txt中寫入順序也是正確的,有效~
另外需要注意:如果鎖的狀態是unlocked,此時調用release會
拋出RuntimeError異常!

RLock可重入鎖,和Lock類似,但RLock卻可以被同一個線程請求多次
比如在一個線程裏調用Lock對象的acquire方法兩次:

你會發現程序卡住不動,因爲已經發生了死鎖…但是在都在同一個主線程裏,
這樣不就很搞笑嗎?這個時候就可以引入RLock了,使用RLock編寫一樣代碼:

     輸出結果:

並沒有出現Lock那樣死鎖的情況,但是要注意使用RLockacquire與release需要
成對出現,就是有多少個acquire,就要有多少個release,才能真正釋放鎖!

有點意思,點進去看看源碼是怎麼實現的,顯示acquire方法:

如果調用acquire方法是同一線程的話,計數器_count加1;在看下release:

哈哈,一樣的套路,_count減1。


小結

本節我們開始來啃Python併發裏的threading,在學習線程局部變量的時候,
順道把模塊源碼擼了一遍,而且還Get了很多以前沒學過的東西,開森,
本節要消化的內容已經挺多的了,就先寫那麼多吧~


參考文獻


來啊,Py交易啊

想加羣一起學習Py的可以加下,智障機器人小Pig,驗證信息裏包含:
PythonpythonpyPy加羣交易屁眼 中的一個關鍵詞即可通過;

驗證通過後回覆 加羣 即可獲得加羣鏈接(不要把機器人玩壞了!!!)~~~
歡迎各種像我一樣的Py初學者,Py大神加入,一起愉快地交流學♂習,van♂轉py。


發佈了306 篇原創文章 · 獲贊 1857 · 訪問量 1661萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章