Python 高階 11
1.1 GIL
學習目標
1. 能夠說出 GIL 是什麼
2. 能夠說出 GIL 和 執行緒互斥鎖的區別
3. 能夠說出什麼是計算密集型程式
4. 能夠說出什麼是IO密集型程式
5. 能夠說出 GIL 對計算密集型程式程式有什麼影響
6. 能夠說出 GIL 對IO密集型程式程式有什麼影響
7. 能夠說出如何改善 GIL 對程式產生的影響
--------------------------------------------------------------------------------
Python語言和GIL沒有半毛錢關係。僅僅是由於歷史原因在Cpython虛擬機器(直譯器),難以移除GIL。
GIL:全域性直譯器鎖。每個執行緒在執行的過程都需要先獲取GIL,保證同一時刻只有一個執行緒可以執行程式碼。
執行緒釋放GIL鎖的情況: 在IO操作等可能會引起阻塞的system call之前,可以暫時釋放GIL,但在執行完畢後,
必須重新獲取GIL Python 3.x使用計時器(執行時間達到閾值後,當前執行緒釋放GIL)或Python 2.x,tickets計數
達到100
1.1.1 GIL 概述
GIL ( Global Interperter Lock ) 稱作全域性直譯器鎖。首先需要明確一點,我們所講的 GIL 並不是 Python 語言的特性,它是在實現 Python 直譯器時引用的一個概念。GIL 只在CPython 直譯器上存在。
1.1.2 回顧執行緒互斥鎖
由於多執行緒非同步競爭共享資料資源時,導致問題產生。可以使用執行緒互斥鎖來解決。
通過回顧互斥鎖,我們知道使用鎖的目的是為了解決多執行緒競爭共享資源的問題。
1.1.3 互斥鎖和 GIL 的區別
上面多執行緒程式的執行流程如下圖:
由上圖分析得到結論如下:
1. Python 直譯器也是一個應用程式
2. GIL 只在 CPython 直譯器中存在
3. 執行緒互斥鎖是 Python 程式碼層面的鎖,解決 Python 程式中多執行緒共享資源的問題
4. GIL 是 Python 解釋層面的鎖,解決直譯器中多個執行緒的競爭資源問題。
1.1.4 GIL 對程式的影響
下面對上圖做進一步的分析,從CPU的角度來分析 GIL 鎖對程式產生什麼影響
計算密集型程式
通過分析得到結論如下:
1. 在 Python 中同一時刻有且只有一個執行緒會執行。
2. Python 中的多執行緒由於 GIL 鎖的存在無法利用多核 CPU
3. Python 中的多執行緒不適合計算密集型的程式。
4. 如果程式需要大量的計算,利用多核CPU資源,可以使用多程序來解決
IO密集型程式(IO, input寫入,output讀取)
大部分的程式在執行時,都需要大量IO操作,比如網路資料的收發,大檔案的讀寫,(磁碟讀取,web服務)這樣的程式稱為IO密集型程式。
Python 3.x使用計時器(執行時間達到閾值後,當前執行緒釋放GIL)或Python 2.x,tickets計數達到100,這樣對CPU密集型程式更加友好
IO密集型程式在執行時,需要大量的時間進行等待,那麼這時如果IO操作不完成,程式就無法執行後面的操作,導致CPU空閒。
在Python直譯器執行程式時,由於GIL的存在,導致同一時刻只能有一個執行緒執行,那麼程式執行效率非常低,那麼在程式進行IO讀取時,CPU實際並沒有做任何工作,為了提高CPU的使用率,那麼Python解釋在程式執行IO等待時,會釋放 GIL 鎖,讓其它執行緒執行,提高Python程式的執行效率。
1.1.5 如何改善 GIL 產生的問題
因為 GIL 鎖是直譯器層面的鎖,無法去除 GIL 鎖在執行程式時帶來的問題。只能去改善。
1. 更換更高版本的直譯器,比如3.6,從3.2版本開始,Python對解釋做了優化,但並不理想
2. 更換直譯器,比如 Jython,但是由於比較小眾,支援的模組較少,導致開發效率降低
3. Python為了解決程式使用多核的問題,使用多程序替代多執行緒
1.1.6 小結
1. GIL ( Global Interpreter Lock ) 全域性直譯器鎖。
2. GIL 不是 Python 語言的特性,是CPython中的一個概念。
3. Python 直譯器也是一個應用程式
4. 執行緒互斥鎖是 Python 程式碼層面的鎖,解決 Python 程式中多執行緒共享資源的問題
5. GIL 是 Python 直譯器層面的鎖,解決直譯器中多個執行緒的競爭資源問題。
6. 由於 GIL 的存在, Python程式中同一時刻有且只有一個執行緒會執行。
7. Python 中的多執行緒由於 GIL 鎖的存在無法利用多核 CPU
8. Python 中的多執行緒不適合計算密集型的程式。
9. GIL 鎖在遇到IO等待時,會釋放 GIL 鎖,可以提高Python中IO密集型程式的效率
10. 如果程式需要大量的計算,利用多核CPU資源,可以使用多程序來解決
1.2 深拷貝和淺拷貝
學習目標
1. 能夠說出什麼是物件引用
2. 能夠說出什麼是不可變物件
3. 能夠說出什麼是可變物件
4. 能夠說出什麼是引用賦值
5. 能夠說出什麼是淺拷貝
6. 能夠說出什麼是深拷貝
7. 能夠說出淺拷貝對可變物件有什麼影響
8. 能夠說出深拷貝對可變物件有什麼影響
9. 能夠說出淺拷貝的幾種實現方式
10. 能夠說出淺拷貝的優點
--------------------------------------------------------------------------------
1.2.1 深拷貝和淺拷貝概述
在程式開發過程中,經常涉及到資料的傳遞,在資料傳遞使用過程中,可能會發生資料被修改的問題。為了防止資料被修改,就需要在傳遞一個副本,即使副本被修改,也不會影響原資料的使用。為了生成這個副本,就產生了拷貝。
1.2.2 技術點回顧
一切皆物件
在 Python 中,所有的資料都被當成物件來處理,無論是數字,字串,還是函式,甚至是模組。
不可變物件
在 Python 中,int, str, tuple 等型別的資料都是不可變物件,不可變物件的特性是數字不可被修改。
可變物件
在 Python 中,list, set,dict 等型別的資料都是可變物件,相對於不可變物件而言,可變物件的資料可以被修改
引用
在 Python 程式中,每個物件都會在記憶體中申請開闢一塊空間來儲存該物件,該物件在記憶體中所在位置的地址被稱為引用
在開發程式時,所定義的變數名實際就物件的地址引用
引用實際就是記憶體中的一個數字地址編號,在使用物件時,只要知道這個物件的地址,就可以操作這個物件,但是因為這個數字地址不方便在開發時使用和記憶,所以使用變數名的形式來代替物件的數字地址。 在 Python 中,變數就是地址的一種表示形式,並不開闢開闢儲存空間。
就像 IP 地址,在訪問網站時,實際都是通過 IP 地址來確定主機,而 IP 地址不方便記憶,所以使用域名來代替 IP 地址,在使用域名訪問網站時,域名被解析成 IP 地址來使用。
# 使用 id() 函式可以檢視物件的引用
1.2.2 引用賦值
賦值的本質就是讓多個變數同時引用同一個物件的地址。 那麼在對資料修改時會發生什麼問題呢?
不可變物件的引用賦值
在不可變物件賦值時,不可變物件不會被修改,而是會新開闢一個空間
程式原理圖:
可變物件的引用賦值
在可變物件中,儲存的並不真正的物件資料,而物件的引用。 當對可變物件進行修改時,只是將可變物件中儲存的引用進行更
程式原理圖:
函式在傳遞引數時,實際上就是實參對形參的賦值,如果實參是可變物件,那麼就可以在函式的內部修改傳入的資料。
1.2.3 淺拷貝
為了解決函式傳遞後被修改的問題,就需要拷貝一份副本,將副本傳遞給函式使用,就算是副本被修改,也不會影響原始資料 。
拷貝物件需要匯入 copy 模組。
import copy
使用 copy 模組中的 copy 方法就可以拷貝物件了。
不可變物件的拷貝
因為不可變物件只有在修改時才會開闢新空間,所以拷貝也相當於讓多個引用同時引用了一個數據,所以不可變物件的淺拷貝和賦值沒有區別
可變物件的拷貝
程式原理圖:
程式原理圖:
通過上圖發現,copy() 函式在拷貝物件時,只是將指定物件中的所有引用拷貝了一份,如果這些引用當中包含了一個可變物件的話,那麼資料還是會被改變。 這種拷貝方式,稱為淺拷貝。
1.2.4 深拷貝
相對於淺拷貝只拷貝頂層的引用外,copy模組還提供了另外一個拷貝方法 deepcopy() 函式,這個函式可以逐層進行拷貝引用,直到所有的引用都是不可變引用為止。
程式原理圖:
但是大多數的情況下,我們並不希望這樣,反而希望資料可以被修改,以達在函式間共享資料的目的。
1.2.5 淺拷貝的幾種方式
copy 模組的 copy 方法
import copy
a = [1, 2]
b = [3, 4, a]
c = copy.copy(b)
物件本身的 copy 方法
a = [1, 2]
b = [3, 4, a]
c = b.copy()
工廠方法
通過類建立物件
a = [1, 2]
b = [3, 4, a]
c = list(b)
切片
a = [1, 2]
b = [3, 4, a]
c = b[0:]
1.2.6 淺拷貝的優勢
時間角度,淺拷貝花費時間更少
空間角度,淺拷貝花費記憶體更少
效率角度,淺拷貝只拷貝頂層資料,一般情況下比深拷貝效率高。
1.2.7 小結
不可變物件在賦值時會開闢新空間
可變物件在賦值時,修改一個的值,另一個也會發生改變
深淺拷貝對不可變物件拷貝時,不開闢新空間,相當於賦值操作
淺拷貝在拷貝時,只拷貝第一層中的引用,如果元素是可變物件,並且被修改,那麼拷貝的物件也會發生變化
深拷貝在拷貝時,會逐層進行拷貝,直到所有的引用都是不可變物件為止。
Python 中有多種方式實現淺拷貝,copy模組的 copy 函式 ,物件的 copy 函式 ,工廠方法,切片等。
大多數情況下,編寫程式時,都是使用淺拷貝,除非有特定的需求
淺拷貝的優點:拷貝速度快,佔用空間少,拷貝效率高
1.3 模組匯入
學習目標
1. 能夠說出模組在載入時的搜尋過程
2. 能夠說出如何新增模組搜尋路徑
3. 能夠說出如何動態載入模組
4. 能夠說出 import 和 from-import 兩種匯入模組的區別
5. 能夠說出迴圈匯入會出現什麼問題
--------------------------------------------------------------------------------
1.3.1 模組匯入概述
在 Python 開發過程中,需要使用大量的系統模組,第三方模組,自定義模組。這些模組以 Python 檔案的形式進行組織。
當需要使用模組中提供的功能時,只需要將模組匯入到當前檔案中即可。
如果有多個模組可以將這些模組放在一個檔案中,並建立一個 __init__.py 的檔案,這個資料夾稱為 package。
1.3.3 模組匯入方式
現有如圖中的模組組織方式
import module
import BB
BB.show()
import package.module
import MyModules.AA
MyModules.AA.show()
from module import 成員
from BB import show
show()
from package import module
from MyModules import AA
AA.show()
from package.module import 成員
from MyModules.AA import show
show()
1.3.4 模組別名 as
在匯入模組時,特別是在從包中匯入模組時,如果包名和模組名都特別長,在使用時,非常不方便。
可以使用 as 給 模組起一個別名,編寫程式碼時就可以直接使用別名代替。
import MyModules.AA as MMAA
MMAA.show()
1.3.5 模組搜尋路徑
在匯入模組時,程式是依據什麼找到這些模組的呢?
在 sys 模組中有一個 path 變數,記錄了程式在匯入模組時的查詢位置,返回的是一個列表型別。
import sys
path_list = sys.path
print(path_list)
模組的搜尋順序是:
當前程式所在目錄
當前程式根目錄
PYTHONPATH
標準庫目錄
第三方庫目錄site-packages目錄
如果匯入的模組不在 path 儲存的路徑中,那麼匯入模組時就會報錯
ModuleNotFoundError: No module named 'CC'
可以在程式中向 path 變數中新增模組所在的路徑。
假定在路徑 /Users/KG/Desktop 下有一個 CC.py 模組
在程式中將 /Users/KG/Desktop 路徑加到 path中去
import sys
sys.path.append('/Users/KG/Desktop')
print(sys.path)
import CC
CC.show()
程式執行結果:
['/Users/KG/PycharmProjects/TestDay12', '/Users/KG/PycharmProjects/TestDay12', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python36.zip', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/lib-dynload', '/Library/Frameworks/Python.framework/Versions/3.6/lib/python3.6/site-packages', '/Users/KG/Desktop']
CC-Show Run ...
path 變數本質就是一個列表,使用 append() 方法可以將新路徑加入到 path 中
也可以使用 insert 方式新增
1.3.6 重新載入模組
模組匯入成功後,在使用模組過程中,如果被匯入的模組對資料進行了修改,那麼正在使用該模組的程式並不會修改。
因為PyCharm不能保持程式執行,所以使用命令列驗證
就算重新匯入修改後的模組也不行
因為 程式在匯入模組時,會將模組建立一個物件儲存到一個字典中,如果之前匯入過一次,再次匯入時就不會再建立這個物件。(後面有圖示)
可以通過 sys 模組下的 modules 屬性來檢視一個檔案中匯入過的模組。sys.modules 返回一個字典
如果需要使用修改後的資料 ,需要重新載入模組。
重新載入模組需要使用 imp 模組下的 reload 函式(已經被廢棄了)
重新載入模組需要使用 importlib 模組下的 reload 函式
from importlib import reload
1.3.7 import 和 from-import 匯入的使用區別
import 方式
import 方式匯入時,只是在當前檔案中建立了一個模組的引用,通過模組的引用來使用模組內的資料 。
使用這種方式匯入時,訪問控制權限對檔案內級別的資料不起作用,通過模組名都可以進行訪問。
相當於將一個模組中所有的內容都匯入到當前檔案中使用。
AA.py
x = 1
_y = 2
__z = 3
BB.py
import AA
print(AA.x)
print(AA._y)
print(AA.__z) # 雖然不提示,但是依然可以用
程式執行結果
1
2
3
可以將 import 匯入方式理解成淺拷貝,只是拷貝了模組的一個引用。通過引用可以使用所有的資料 。
from-import 方式
from-import 方式在匯入資料時,會將匯入模組中資料複製一份到當前檔案中,所以可以直接使用模組中的變數,函式,類等內容。
在使用 from-import 方式匯入時,檔案內私有屬性 _xxx 形式的資料不會被匯入。
在使用 from-import 方式匯入時,如果模組內和當前檔案中有識別符號命名重名,會引用命名衝突,當前檔案中的內容會覆蓋模組的資料
BB.py
from AA import *
print(x)
# print(_y) # 禁止匯入,不能使用
# print(_AA.__z) #因為在檔案內,也不能匯入
# 定義了一個和AA模組中的x同名的函式
# 在當前檔案中 x 就不在代表 x 變量了,而是函數了
def x():
print('x is function now!')
print(x)
x()
程式執行結果:
1
<function x at 0x101f62e18>
x is function now!
from-import 方式可以理解成深拷貝,被匯入模組中的資料被拷貝了一份放在當前檔案中。
__all__ 魔法變數 在 Python 中還提供種方式來隱藏或公開資料 ,就是使用 __all__
__all__ 本質是一個列表,在列表中以字串形式加入要公開的資料
在使用 from-import 匯入模組時,如果模組中存在這個變數,那麼就按這個變數裡的內容進行匯入,沒有包含的不匯入。 AA.py
__all__ = ['_y','__z']
x = 1
_y = 2
__z = 3
BB.py
from AA import *
print(_y) #雖然是私有的,但是在 __all__中公開了就可以匯入
print(__z)
# print(x) # 沒有公開,不能使用
# show()
程式執行結果
2
3
小結
從使用便利的角度,使用from-import
從命名衝突的角度,使用 import
1.3.8 迴圈匯入問題
在開發過程中,可能會遇到這種情況。兩個模組相互之間進行匯入。這樣的話,會造成程式出現死迴圈。程式執行時就會報錯。
AA.py
from BB import *
def ashow():
print('A - show')
bshow()
BB.py
from AA import *
def bshow():
print('A - show')
ashow()
程式執行結果:
NameError: name 'ashow' is not defined
這是因為模組在匯入時要經過這麼幾步:
在sys.modules 中去搜索匯入的模組物件
如果沒有找到就建立一個空模組並加入到sys.modules中,如果找到就不在建立
然後讀取模組中的資料對空模組初始化
對存在的模組直接建立引用在當前檔案中使用
迴圈引用出錯的原因是建立完空模組後,對模組初始化時,又遇到了另外一個模組的匯入。這時重複執行建立空模組初始化操作。 但是在第二個模組中發現又是在匯入模組。但是這時會發現,這個模組以第一次的時候已經建立過了,就不在建立。但是模組並未初始化成功。 在使用時對一個空的模組內容進行呼叫。最後報錯。
可以通過下圖來理解迴圈匯入出錯的過程
下面的程式碼嘗試去解決迴圈引用問題: AA.py
def ashow():
print('A - show')
import BB
BB.bshow()
BB.py
def bshow():
print('B - show')
import AA
AA.ashow()
程式輸出結果:
A - show
B - show
A - show
結果還是有問題
程式碼中使用 import 替代了 from-impot 。 程式在執行 BB.py ,由於要建立兩次相互匯入時的模組到 sys.modules 中,在初始化模組過程中會執行模組內的語句,所以輸出結果 多了前兩次。
迴圈匯入不是語法知識,也不止在 Python 中出現。這是在程式設計時的邏輯出現了問題。 不要想出現邏輯錯誤的時候怎麼修改。而是要從根本上去避免不能出現這種設計邏輯。就像函式呼叫死迴圈一樣。 切記切記!!!
1.3.9 總結
在Python中,一個檔案就是一個模組
使用模組時,可以使用 import 或 from-import 來將模組匯入
匯入模組時,程式到 sys.path 路徑中去搜索,如果路徑中沒有指定的模組會報錯
可以向 sys.path 中去新增搜尋路徑
模組匯入後,在執行過程中是不可更新的,如果模組發生了變化,需要使用 imp 模組中的reload 函式重新匯入
import 匯入類似淺拷貝,使用模組的引用操作模組中的資料
from-import 匯入類似深拷貝,相當於複製了一份模組中的資料到當前檔案中,可能會命名衝突
迴圈匯入模組會出錯,這不是語法,是思想邏輯錯誤,不要想著怎麼改,要想怎麼避免發生
1.4 with 語句
學習目標
1. 能夠說出with的執行過程
2. 能夠說出with的作用
3. 能夠說出為什麼使用 with
--------------------------------------------------------------------------------
1.4.1 with 概述
with 語句是 Pyhton 提供的一種簡化語法,with 語句是從 Python 2.5 開始引入的一種與異常處理相關的功能。
with 語句適用於對資源進行訪問的場合,確保不管使用過程中是否發生異常都會執行必要的“清理”操作,釋放資源。
比如檔案使用後自動關閉、資料庫的開啟和自動關閉等。
1.4.3 with 語句的使用
with open('test', 'w') as f:
f.write('Python好')
通過 with 語句在編寫程式碼時,會使程式碼變得更加簡潔。
在編寫程式碼時,不用再顯示的去關閉檔案。
1.4.4 with 語句的執行過程
在執行 with 語句時,首先執行 with 後面的 open 程式碼
執行完程式碼後,會將程式碼的結果通過 as 儲存到 f 中
然後在下面實現真正要執行的操作
在操作後面,並不需要寫檔案的關閉操作,檔案會在使用完後自動關閉
1.4.5 with 語句的執行原理
實際上,在檔案操作時,並不是不需要寫檔案的關閉,而是檔案的關閉操作在 with 的上下文管理器中的協議方法裡已經寫好了。
當檔案操作執行完成後, with語句會自動呼叫上下文管理器裡的關閉語句來關閉檔案資源。
上下文(環境)管理器
ContextManager ,上下文是 context(環境)直譯的叫法,在程式中用來表示程式碼執行過程中所處的前後環境。 簡單理解,在檔案操作時,需要開啟,關閉檔案,而在檔案在進行讀寫操作時,就是處在檔案操作的上下文中,也就是檔案操作環境中。
說明
很多計算機術語在由英文翻譯成中文的過程中,因為語境或翻譯人的各人理解的關係,導致一些中文術語都晦澀難懂,大家只需要記住這個術語,理解這個術語表示的意義即可。不需要在此糾結。 比如 file 大陸地區直接翻譯成檔案,而臺灣地址則會翻譯成文件或檔案。 個人理解:context 翻譯成環境可能會更貼切
with 語句在執行時,需要呼叫上下文管理器中的 __enter__ 和 __exit__ 兩個方法。
__enter__ 方法會在執行 with 後面的語句時執行,一般用來處理操作前的內容。比如一些建立物件,初始化等。
__exit__ 方法會在 with 內的程式碼執行完畢後執行,一般用來處理一些善後收尾工作,比如檔案的關閉,資料庫的關閉等。
1.4.6 自定義上下文管理器
在自定義上下文管理器時,只需要在類中實現 __enter__ 和 __exit__ 兩個方法即可。
模擬檔案開啟過程:
import time
class MyOpen(object):
def __init__(self,file, mode):
self.__file = file
self.__mode = mode
def __enter__(self):
print('__enter__ run ... 開啟檔案')
self.__handle = open(self.__file, self.__mode)
return self.__handle
def __exit__(self, exc_type, exc_val, exc_tb):
print('__exit__... run ... 關閉檔案')
self.__handle.close()
with MyOpen('test','w') as f:
f.write('Python 大法好')
time.sleep(3)
print('over')
程式執行結果:
__enter__ run ... 開啟檔案
__exit__ run ... 關閉檔案
over
1.4.8 __exit__ 方法的引數
__exit__ 方法中有三個引數,用來接收處理異常,如果程式碼在執行時發生異常,異常會被儲存到這裡。
exc_type : 異常型別
exc_val : 異常值
exc_tb : 異常回溯追蹤
class MyCount(object):
def __init__(self,x, y):
self.__x = x
self.__y = y
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
print('Type: ', exc_type)
print('Value:', exc_val)
print('TreacBack:', exc_tb)
def div(self):
return self.__x / self.__y
with MyCount(1, 0) as mc:
ret = mc.div()
print('ret = ', ret)
程式執行結果:
Type: <class 'ZeroDivisionError'>
Traceback (most recent call last):
Value: division by zero
TreacBack: <traceback object at 0x10410de08>
File "/Users/KG/PycharmProjects/TestDay12/AA.py", line 18, in <module>
ret = mc.div()
File "/Users/KG/PycharmProjects/TestDay12/AA.py", line 14, in div
return self.__x / self.__y
ZeroDivisionError: division by zero
因為程式發生了除零錯誤,所以出現異常,異常資訊被儲存到這三個變數中。
Type: <class 'ZeroDivisionError'> # 異常型別
Value: division by zero # 異常值
TreacBack: <traceback object at 0x10410de08> # 異常追蹤物件
異常資訊的處理
當with中執行的語句發生異常時,異常資訊會被髮送到 __exit__ 方法的引數中,這時可以根據情況選擇如何處理異常。
class MyCount(object):
def __init__(self, x, y):
self.__x = x
self.__y = y
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
# 通過 引數接收到的值,來判斷程式執行是否出現異常
# 如果是 None ,說明沒有異常
if exc_type == None:
print('計算正確執行')
else:
# 否則出現異常,可以選擇怎麼處理異常
print(exc_type,exc_val)
# 返回值決定了捕獲的異常是否繼續向外丟擲
# 如果是 False 那麼就會繼續向外丟擲,程式會看到系統提示的異常資訊
# 如果是 True 不會向外丟擲,程式看不到系統提示資訊,只能看到else中的輸出
return True
def div(self):
print(self.__x / self.__y)
with MyCount(6, 0) as mc:
mc.div()
在 __exit__函式執行異常處理時,會根據函式的返回值決定是否將系統丟擲的異常繼續向外丟擲。
如果返回值為 False 就會向外丟擲,使用者就會看到。 如果返回值為 True 不會向外丟擲,可以將異常顯示為更加友好的提示資訊。
1.4.9 總結
with 語句主要是為了簡化程式碼操作。
with 在執行過程中,會自動呼叫上下文管理器中的 __enter__ 和 __exit__ 方法
__enter__ 方法主要用來做一些準備操作
__exit__ 方法主要用來做一些善後工作