自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

深入理解GIL:如何寫出高性能及線程安全的Python代碼

開發(fā) 后端
讓我們解讀 CPython 解釋器源碼,找出 GIL 究竟是什么,為什么它存在于 Python 中,它又是怎么影響多線程程序的。我將通過舉例幫助你深入理解 GIL 。你將會(huì)學(xué)到如何寫出快速運(yùn)行和線程安全的 Python 代碼,以及如何在線程和進(jìn)程中做選擇。

6歲時(shí),我有一個(gè)音樂盒。我上緊發(fā)條,音樂盒頂上的芭蕾舞女演員就會(huì)旋轉(zhuǎn)起來,同時(shí),內(nèi)部裝置發(fā)出“一閃一閃亮晶晶,滿天都是小星星”的叮鈴聲。那玩意兒肯定俗氣透了,但我喜歡那個(gè)音樂盒,我想知道它的工作原理是什么。后來我拆開了,才看到它里面一個(gè)簡單的裝置,機(jī)身內(nèi)部鑲嵌著一個(gè)拇指大小的金屬圓筒,當(dāng)它轉(zhuǎn)動(dòng)時(shí)會(huì)撥弄鋼制的梳齒,從而發(fā)出這些音符。

[[192292]] 

在一個(gè)程序員具備的所有特性中,想探究事物運(yùn)轉(zhuǎn)規(guī)律的這種好奇心必不可少。當(dāng)我打開音樂盒,觀察內(nèi)部裝置,可以看出即使我沒有成長為一個(gè)卓越的程序員,至少也是有好奇心的一個(gè)。

奇怪的是,我寫 Python 程序多年,一直對全局解釋器鎖(GIL)持有錯(cuò)誤的觀念,因?yàn)槲覐奈磳λ倪\(yùn)作機(jī)理產(chǎn)生足夠好奇。我遇到其他對此同樣猶豫和無知的人。是時(shí)候讓我們來打開這個(gè)盒子一窺究竟了。讓我們解讀 CPython 解釋器源碼,找出 GIL 究竟是什么,為什么它存在于 Python 中,它又是怎么影響多線程程序的。我將通過舉例幫助你深入理解 GIL 。你將會(huì)學(xué)到如何寫出快速運(yùn)行和線程安全的 Python 代碼,以及如何在線程和進(jìn)程中做選擇。

(我在本文中只描述 CPython,而不是 Jython、PyPy 或 IronPython。因?yàn)槟壳敖^大多數(shù)程序員還是使用 CPython 實(shí)現(xiàn) Python 。)

瞧,全局解釋器鎖(GIL)

這里:

  1. static PyThread_type_lock interpreter_lock = 0; /* This is the GIL */ 

這一行代碼摘自 ceval.c —— CPython 2.7 解釋器的源代碼,Guido van Rossum 的注釋”This is the GIL“ 添加于2003 年,但這個(gè)鎖本身可以追溯到1997年他的***個(gè)多線程 Python 解釋器。在 Unix系統(tǒng)中,PyThread_type_lock 是標(biāo)準(zhǔn) C mutex_t 鎖的別名。當(dāng) Python 解釋器啟動(dòng)時(shí)它初始化:

  1. void 
  2.  
  3. PyEval_InitThreads(void) 
  4.  
  5.  
  6.     interpreter_lock = PyThread_allocate_lock(); 
  7.  
  8.     PyThread_acquire_lock(interpreter_lock); 
  9.  
  10.  

解釋器中的所有 C 代碼在執(zhí)行 Python 時(shí)必須保持這個(gè)鎖。Guido 最初加這個(gè)鎖是因?yàn)樗褂闷饋砗唵?。而且每次?CPython 中去除 GIL 的嘗試會(huì)耗費(fèi)單線程程序太多性能,盡管去除 GIL 會(huì)帶來多線程程序性能的提升,但仍是不值得的。(前者是Guido最為關(guān)切的, 也是不去除 GIL 最重要的原因, 一個(gè)簡單的嘗試是在1999年, 最終的結(jié)果是導(dǎo)致單線程的程序速度下降了幾乎2倍.)

GIL 對程序中線程的影響足夠簡單,你可以在手背上寫下這個(gè)原則:“一個(gè)線程運(yùn)行 Python ,而其他 N 個(gè)睡眠或者等待 I/O.”(即保證同一時(shí)刻只有一個(gè)線程對共享資源進(jìn)行存取) Python 線程也可以等待threading.Lock或者線程模塊中的其他同步對象;線程處于這種狀態(tài)也稱之為”睡眠“。

[[192293]] 

線程何時(shí)切換?一個(gè)線程無論何時(shí)開始睡眠或等待網(wǎng)絡(luò) I/O,其他線程總有機(jī)會(huì)獲取 GIL 執(zhí)行 Python 代碼。這是協(xié)同式多任務(wù)處理。CPython 也還有搶占式多任務(wù)處理。如果一個(gè)線程不間斷地在 Python 2 中運(yùn)行 1000 字節(jié)碼指令,或者不間斷地在 Python 3 運(yùn)行15 毫秒,那么它便會(huì)放棄 GIL,而其他線程可以運(yùn)行。把這想象成舊日有多個(gè)線程但只有一個(gè) CPU 時(shí)的時(shí)間片。我將具體討論這兩種多任務(wù)處理。

 

[[192294]] 

把 Python 看作是舊時(shí)的大型主機(jī),多個(gè)任務(wù)共用一個(gè)CPU。

協(xié)同式多任務(wù)處理

當(dāng)一項(xiàng)任務(wù)比如網(wǎng)絡(luò) I/O啟動(dòng),而在長的或不確定的時(shí)間,沒有運(yùn)行任何 Python 代碼的需要,一個(gè)線程便會(huì)讓出GIL,從而其他線程可以獲取 GIL 而運(yùn)行 Python。這種禮貌行為稱為協(xié)同式多任務(wù)處理,它允許并發(fā);多個(gè)線程同時(shí)等待不同事件。

也就是說兩個(gè)線程各自分別連接一個(gè)套接字:

  1. def do_connect(): 
  2.  
  3.     s = socket.socket() 
  4.  
  5.     s.connect(('python.org', 80))  # drop the GIL 
  6.  
  7.   
  8.  
  9. for i in range(2): 
  10.  
  11.     t = threading.Thread(target=do_connect) 
  12.  
  13.     t.start()  

兩個(gè)線程在同一時(shí)刻只能有一個(gè)執(zhí)行 Python ,但一旦線程開始連接,它就會(huì)放棄 GIL ,這樣其他線程就可以運(yùn)行。這意味著兩個(gè)線程可以并發(fā)等待套接字連接,這是一件好事。在同樣的時(shí)間內(nèi)它們可以做更多的工作。

讓我們打開盒子,看看一個(gè)線程在連接建立時(shí)實(shí)際是如何放棄 GIL 的,在 socketmodule.c 中:

  1. /* s.connect((host, port)) method */ 
  2.  
  3. static PyObject * 
  4.  
  5. sock_connect(PySocketSockObject *s, PyObject *addro) 
  6.  
  7.  
  8.     sock_addr_t addrbuf; 
  9.  
  10.     int addrlen; 
  11.  
  12.     int res; 
  13.  
  14.   
  15.  
  16.     /* convert (host, port) tuple to C address */ 
  17.  
  18.     getsockaddrarg(s, addro, SAS2SA(&addrbuf), &addrlen); 
  19.  
  20.   
  21.  
  22.     Py_BEGIN_ALLOW_THREADS 
  23.  
  24.     res = connect(s->sock_fd, addr, addrlen); 
  25.  
  26.     Py_END_ALLOW_THREADS 
  27.  
  28.   
  29.  
  30.     /* error handling and so on .... */ 
  31.  
  32.  

線程正是在Py_BEGIN_ALLOW_THREADS 宏處放棄 GIL;它被簡單定義為:

  1. PyThread_release_lock(interpreter_lock); 

當(dāng)然 Py_END_ALLOW_THREADS 重新獲取鎖。一個(gè)線程可能會(huì)在這個(gè)位置堵塞,等待另一個(gè)線程釋放鎖;一旦這種情況發(fā)生,等待的線程會(huì)搶奪回鎖,并恢復(fù)執(zhí)行你的Python代碼。簡而言之:當(dāng)N個(gè)線程在網(wǎng)絡(luò) I/O 堵塞,或等待重新獲取GIL,而一個(gè)線程運(yùn)行Python。

下面來看一個(gè)使用協(xié)同式多任務(wù)處理快速抓取許多 URL 的完整例子。但在此之前,先對比下協(xié)同式多任務(wù)處理和其他形式的多任務(wù)處理。

搶占式多任務(wù)處理

Python線程可以主動(dòng)釋放 GIL,也可以先發(fā)制人抓取 GIL 。

讓我們回顧下 Python 是如何運(yùn)行的。你的程序分兩個(gè)階段運(yùn)行。首先,Python文本被編譯成一個(gè)名為字節(jié)碼的簡單二進(jìn)制格式。第二,Python解釋器的主回路,一個(gè)名叫 pyeval_evalframeex() 的函數(shù),流暢地讀取字節(jié)碼,逐個(gè)執(zhí)行其中的指令。

當(dāng)解釋器通過字節(jié)碼時(shí),它會(huì)定期放棄GIL,而不需要經(jīng)過正在執(zhí)行代碼的線程允許,這樣其他線程便能運(yùn)行:

  1. for (;;) { 
  2.  
  3.     if (--ticker < 0) { 
  4.  
  5.         ticker = check_interval; 
  6.  
  7.   
  8.  
  9.         /* Give another thread a chance */ 
  10.  
  11.         PyThread_release_lock(interpreter_lock); 
  12.  
  13.   
  14.  
  15.         /* Other threads may run now */ 
  16.  
  17.   
  18.  
  19.         PyThread_acquire_lock(interpreter_lock, 1); 
  20.  
  21.     } 
  22.  
  23.   
  24.  
  25.     bytecode = *next_instr++; 
  26.  
  27.     switch (bytecode) { 
  28.  
  29.         /* execute the next instruction ... */ 
  30.  
  31.     } 
  32.  
  33.  

默認(rèn)情況下,檢測間隔是1000 字節(jié)碼。所有線程都運(yùn)行相同的代碼,并以相同的方式定期從他們的鎖中抽出。在 Python 3 GIL 的實(shí)施更加復(fù)雜,檢測間隔不是一個(gè)固定數(shù)目的字節(jié)碼,而是15 毫秒。然而,對于你的代碼,這些差異并不顯著。

Python中的線程安全

[[192295]] 

將多個(gè)線狀物編織在一起,需要技能。

如果一個(gè)線程可以隨時(shí)失去 GIL,你必須使讓代碼線程安全。 然而 Python 程序員對線程安全的看法大不同于 C 或者 Java 程序員,因?yàn)樵S多 Python 操作是原子的。

在列表中調(diào)用 sort(),就是原子操作的例子。線程不能在排序期間被打斷,其他線程從來看不到列表排序的部分,也不會(huì)在列表排序之前看到過期的數(shù)據(jù)。原子操作簡化了我們的生活,但也有意外。例如,+ = 似乎比 sort() 函數(shù)簡單,但+ =不是原子操作。你怎么知道哪些操作是原子的,哪些不是?

看看這個(gè)代碼:

  1. n = 0 
  2.  
  3. def foo(): 
  4.  
  5.     global n 
  6.  
  7.     n += 1  

我們可以看到這個(gè)函數(shù)用 Python 的標(biāo)準(zhǔn) dis 模塊編譯的字節(jié)碼:

  1. >>> import dis 
  2.  
  3. >>> dis.dis(foo) 
  4.  
  5. LOAD_GLOBAL              0 (n) 
  6.  
  7. LOAD_CONST               1 (1) 
  8.  
  9. INPLACE_ADD 
  10.  
  11. STORE_GLOBAL             0 (n)  

代碼的一行中, n += 1,被編譯成 4 個(gè)字節(jié)碼,進(jìn)行 4 個(gè)基本操作:

  1. 將 n 值加載到堆棧上
  2. 將常數(shù) 1 加載到堆棧上
  3. 將堆棧頂部的兩個(gè)值相加
  4. 將總和存儲回 n

記住,一個(gè)線程每運(yùn)行 1000 字節(jié)碼,就會(huì)被解釋器打斷奪走 GIL 。如果運(yùn)氣不好,這(打斷)可能發(fā)生在線程加載 n 值到堆棧期間,以及把它存儲回 n 期間。很容易可以看到這個(gè)過程會(huì)如何導(dǎo)致更新丟失:

  1. threads = [] 
  2.  
  3. for i in range(100): 
  4.  
  5.     t = threading.Thread(target=foo) 
  6.  
  7.     threads.append(t) 
  8.  
  9. for t in threads: 
  10.  
  11.     t.start() 
  12.  
  13. for t in threads: 
  14.  
  15.     t.join() 
  16.  
  17. print(n)  

通常這個(gè)代碼輸出 100,因?yàn)?100 個(gè)線程每個(gè)都遞增 n 。但有時(shí)你會(huì)看到 99 或 98 ,如果一個(gè)線程的更新被另一個(gè)覆蓋。

所以,盡管有 GIL,你仍然需要加鎖來保護(hù)共享的可變狀態(tài):

  1. n = 0 
  2.  
  3. lock = threading.Lock() 
  4.  
  5. def foo(): 
  6.  
  7.     global n 
  8.  
  9.     with lock: 
  10.  
  11.         n += 1  

如果我們使用一個(gè)原子操作比如 sort() 函數(shù)會(huì)如何呢?:

  1. lst = [4, 1, 3, 2] 
  2.  
  3. def foo(): 
  4.  
  5.     lst.sort()  

這個(gè)函數(shù)的字節(jié)碼顯示 sort() 函數(shù)不能被中斷,因?yàn)樗窃拥模?/p>

  1. >>> dis.dis(foo) 
  2.  
  3. LOAD_GLOBAL              0 (lst) 
  4.  
  5. LOAD_ATTR                1 (sort) 
  6.  
  7. CALL_FUNCTION            0  

一行被編譯成 3 個(gè)字節(jié)碼:

  1. 將 lst 值加載到堆棧上
  2. 將其排序方法加載到堆棧上
  3. 調(diào)用排序方法

即使這一行 lst.sort() 分幾個(gè)步驟,調(diào)用 sort 自身是單個(gè)字節(jié)碼,因此線程沒有機(jī)會(huì)在調(diào)用期間抓取 GIL 。我們可以總結(jié)為在 sort() 不需要加鎖。或者,為了避免擔(dān)心哪個(gè)操作是原子的,遵循一個(gè)簡單的原則:始終圍繞共享可變狀態(tài)的讀取和寫入加鎖。畢竟,在 Python 中獲取一個(gè) threading.Lock 是廉價(jià)的。

盡管 GIL 不能免除我們加鎖的需要,但它確實(shí)意味著沒有加細(xì)粒度的鎖的需要(所謂細(xì)粒度是指程序員需要自行加、解鎖來保證線程安全,典型代表是 Java , 而 CPthon 中是粗粒度的鎖,即語言層面本身維護(hù)著一個(gè)全局的鎖機(jī)制,用來保證線程安全)。在線程自由的語言比如 Java,程序員努力在盡可能短的時(shí)間內(nèi)加鎖存取共享數(shù)據(jù),減輕線程爭奪,實(shí)現(xiàn)***并行。然而因?yàn)樵?Python 中線程無法并行運(yùn)行,細(xì)粒度鎖沒有任何優(yōu)勢。只要沒有線程保持這個(gè)鎖,比如在睡眠,等待I/O, 或者一些其他失去 GIL 操作,你應(yīng)該使用盡可能粗粒度的,簡單的鎖。其他線程無論如何無法并行運(yùn)行。

并發(fā)可以完成更快

我敢打賭你真正為的是通過多線程來優(yōu)化你的程序。通過同時(shí)等待許多網(wǎng)絡(luò)操作,你的任務(wù)將更快完成,那么多線程會(huì)起到幫助,即使在同一時(shí)間只有一個(gè)線程可以執(zhí)行 Python 。這就是并發(fā),線程在這種情況下工作良好。

線程中代碼運(yùn)行更快

  1. import threading 
  2.  
  3. import requests 
  4.  
  5. urls = [...] 
  6.  
  7. def worker(): 
  8.  
  9.     while True
  10.  
  11.         try: 
  12.  
  13.             url = urls.pop() 
  14.  
  15.         except IndexError: 
  16.  
  17.             break  # Done. 
  18.  
  19.         requests.get(url) 
  20.  
  21. for _ in range(10): 
  22.  
  23.     t = threading.Thread(target=worker) 
  24.  
  25.     t.start()  

正如我們所看到的,在 HTTP上面獲取一個(gè)URL中,這些線程在等待每個(gè)套接字操作時(shí)放棄 GIL,所以他們比一個(gè)線程更快完成工作。

Parallelism 并行

如果想只通過同時(shí)運(yùn)行 Python 代碼,而使任務(wù)完成更快怎么辦?這種方式稱為并行,這種情況 GIL 是禁止的。你必須使用多個(gè)進(jìn)程,這種情況比線程更復(fù)雜,需要更多的內(nèi)存,但它可以更好利用多個(gè) CPU。

這個(gè)例子 fork 出 10 個(gè)進(jìn)程,比只有 1 個(gè)進(jìn)程要完成更快,因?yàn)檫M(jìn)程在多核中并行運(yùn)行。但是 10 個(gè)線程與 1 個(gè)線程相比,并不會(huì)完成更快,因?yàn)樵谝粋€(gè)時(shí)間點(diǎn)只有 1 個(gè)線程可以執(zhí)行 Python: 

  1. import os 
  2.  
  3. import sys 
  4.  
  5. nums =[1 for _ in range(1000000)] 
  6.  
  7. chunk_size = len(nums) // 10 
  8.  
  9. readers = [] 
  10.  
  11. while nums: 
  12.  
  13.     chunk, nums = nums[:chunk_size], nums[chunk_size:] 
  14.  
  15.     reader, writer = os.pipe() 
  16.  
  17.     if os.fork(): 
  18.  
  19.         readers.append(reader)  # Parent. 
  20.  
  21.     else
  22.  
  23.         subtotal = 0 
  24.  
  25.         for i in chunk: # Intentionally slow code. 
  26.  
  27.             subtotal += i 
  28.  
  29.         print('subtotal %d' % subtotal) 
  30.  
  31.         os.write(writer, str(subtotal).encode()) 
  32.  
  33.         sys.exit(0) 
  34.  
  35. # Parent. 
  36.  
  37. total = 0 
  38.  
  39. for reader in readers: 
  40.  
  41.     subtotal = int(os.read(reader, 1000).decode()) 
  42.  
  43.     total += subtotal 
  44.  
  45. print("Total: %d" % total)  

因?yàn)槊總€(gè) fork 的進(jìn)程有一個(gè)單獨(dú)的 GIL,這個(gè)程序可以把工作分派出去,并一次運(yùn)行多個(gè)計(jì)算。

(Jython 和 IronPython 提供單進(jìn)程的并行,但它們遠(yuǎn)沒有充分實(shí)現(xiàn) CPython 的兼容性。有軟件事務(wù)內(nèi)存的 PyPy 有朝一日可以運(yùn)行更快。如果你對此好奇,試試這些解釋器。)

結(jié)語

既然你已經(jīng)打開了音樂盒,看到了它簡單的裝置,你明白所有你需要知道的如何寫出快速運(yùn)行,線程安全的 Python 代碼。使用線程進(jìn)行并發(fā) I/O 操作,在進(jìn)程中進(jìn)行并行計(jì)算。這個(gè)原則足夠簡單,你甚至不需要把它寫在你的手上。

責(zé)任編輯:龐桂玉 來源: Python開發(fā)者
相關(guān)推薦

2024-06-06 09:58:13

2025-01-13 13:00:00

Go網(wǎng)絡(luò)框架nbio

2016-12-15 09:58:26

優(yōu)化SQL高性能

2017-07-12 13:04:23

數(shù)據(jù)庫SQL查詢執(zhí)行計(jì)劃

2020-12-19 10:45:08

Python代碼開發(fā)

2020-07-15 08:17:16

代碼

2020-12-04 11:40:53

Linux

2024-08-12 08:43:09

2020-05-11 15:23:58

CQRS代碼命令

2021-09-01 08:55:20

JavaScript代碼開發(fā)

2013-06-07 14:00:23

代碼維護(hù)

2019-04-08 16:50:33

前端性能監(jiān)控

2021-11-30 10:20:24

JavaScript代碼前端

2011-06-30 11:04:05

JTS

2021-01-04 07:57:07

C++工具代碼

2022-02-08 19:33:13

技巧代碼格式

2022-02-17 10:05:21

CSS代碼前端

2019-09-20 15:47:24

代碼JavaScript副作用

2022-03-11 12:14:43

CSS代碼前端

2020-05-19 15:00:26

Bug代碼語言
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號