GIL是/Global Interpreter Lock/的簡(jiǎn)稱,翻譯為中文是/全局解釋器鎖/,維基百科的解釋為:
全局解釋器鎖是計(jì)算機(jī)程序設(shè)計(jì)語(yǔ)言解釋器用于同步線程的一種機(jī)制,它使得任何時(shí)刻僅有一個(gè)線程在執(zhí)行。即便在多核心處理器上,使用 GIL 的解釋器也只允許同一時(shí)間執(zhí)行一個(gè)線程。
關(guān)于Python多線程與GIL的思考
問(wèn)題的提出
學(xué)過(guò)Python的人大都知道這個(gè)解釋性語(yǔ)言最通用的實(shí)現(xiàn)(CPython)采用了GIL的方式,因此在網(wǎng)上可以看到一些言論說(shuō)“Python因?yàn)橛蠫IL存在,多線程就算了,還是多進(jìn)程吧”。
可這并不符合使用Python編程的實(shí)際體驗(yàn),的確會(huì)讓人產(chǎn)生一些疑惑。
Python有其自帶的多線程模塊,而且著名的爬蟲(chóng)框架scrapy可以同時(shí)爬多個(gè)網(wǎng)站,感覺(jué)上其并沒(méi)有受到GIL的限制。
與Java對(duì)比的話,Java也支持多線程也可以寫爬蟲(chóng),而Java并沒(méi)有GIL,這與Python看起來(lái)好像沒(méi)有什么區(qū)別,那么GIL到底有沒(méi)有發(fā)揮作用呢?
能否使用Java和Python分別寫一段語(yǔ)義上一樣的代碼,通過(guò)兩段程序的output有著明顯的不同來(lái)證明GIL的確存在并且起了一定的作用呢?
要做這個(gè)事情首先要進(jìn)行理論上的更進(jìn)一步探索,才能進(jìn)行代碼的實(shí)現(xiàn)與output的設(shè)計(jì)。
關(guān)于并發(fā)的知識(shí)鋪墊
<CSAPP>上提到了三種不同層面的 *并發(fā)編程技術(shù)*,分別為:
- 進(jìn)程級(jí)別的并發(fā);
- I/O多路復(fù)用;
- 線程級(jí)別的并發(fā)。
顯然此篇的討論應(yīng)該歸到第三種類型。
接下來(lái),還要明確另一對(duì)容易搞錯(cuò)的概念, 并發(fā) 與 并行 。
并發(fā) 指的是邏輯控制流在時(shí)間上的重疊,而 并行 則是指對(duì)多核CPU的利用。
并行只是并發(fā)的一個(gè)真子集,有種說(shuō)法是“并發(fā)是基于邏輯上的同時(shí)發(fā)生,而并行是基于物理上的同時(shí)發(fā)生”。
所以,在只有一個(gè)CPU的機(jī)器上也可以運(yùn)行并發(fā)程序,卻不能運(yùn)行并行程序。
使用加速比證明GIL存在的假設(shè)
根據(jù)以上關(guān)于并發(fā)與并行的基本知識(shí),Python與Java在并發(fā)程序上的本質(zhì)區(qū)別便可以得知。
即,因?yàn)橛蠫IL的存在,Python無(wú)法利用到多核處理器的并行性,但依然可以編寫除此之外的并發(fā)程序,并獲得效率提升。而Java則無(wú)此限制。
CSAPP中提到了對(duì)于并行程序性能的衡量標(biāo)準(zhǔn)– 加速比 。

上述公式中,Sp稱為加速比,其中p是處理器核的數(shù)量,Tp是指在p個(gè)核上程序的執(zhí)行時(shí)間,當(dāng)T1是程序順序執(zhí)行版本的執(zhí)行時(shí)間時(shí),Sp稱為絕對(duì)加速比,而當(dāng)Sp為程序并行版本在一個(gè)核上的執(zhí)行時(shí)間時(shí),Sp稱為相對(duì)加速比。
所以,可以使用絕對(duì)加速比來(lái)證明GIL的存在。
預(yù)期是,寫一段無(wú)IO的計(jì)算密集性任務(wù),分別交給Python與Java的一個(gè)(順序執(zhí)行)、多個(gè)線程(并行版本)去運(yùn)行,算出各自的加速比,如果Python版本加速比小于1,而Java版本的加速比在計(jì)算機(jī)核心數(shù)左右,則說(shuō)明是GIL起了作用,導(dǎo)致Python程序無(wú)法發(fā)揮多核的并行性。
證明過(guò)程
依然使用書中的例子: 做一個(gè)加法任務(wù),從0加到0x7fffffff求和,通過(guò)設(shè)置線程數(shù)n,將數(shù)字加和任務(wù)平均拆分為n份,給到各線程做自己的一份,最后將子任務(wù)的和再加和求得最后的結(jié)果。
那么當(dāng)n等于1時(shí),即為順序版本,n大于1時(shí)則為并行版本。
書中代碼使用C語(yǔ)言實(shí)現(xiàn),此處分別改寫為Python與Java兩個(gè)版本。
入口為:
def main():
thread_num1 = 1
thread_num2 = 2
thread_num4 = 4
thread_num8 = 8
print ("sum_task with thread_num1 cost time: " + str(measure_time_cost(thread_num1)) + "s in Python version.")
print ("sum_task with thread_num2 cost time: " + str(measure_time_cost(thread_num2)) + "s in Python version.")
print ("sum_task with thread_num4 cost time: " + str(measure_time_cost(thread_num4)) + "s in Python version.")
print ("sum_task with thread_num8 cost time: " + str(measure_time_cost(thread_num4)) + "s in Python version.")
分別用嘗試1,2,4,8個(gè)線程下運(yùn)行結(jié)果,measuretimecost 主要用來(lái)創(chuàng)建目標(biāo)數(shù)量的線程,給各線程分配自己的計(jì)算任務(wù),然后等待各線程全部返回,再加和,同時(shí)返回耗時(shí),該函數(shù)實(shí)現(xiàn)為:
def measure_time_cost(thread_nums):
nums = 99999999
num_per_thread = int((nums + 1) / thread_nums)
thread_list = [None] * thread_nums
task_list = [None] * thread_nums
start_at = time.time()
for i in range(thread_nums):
ct = SumTask()
thread_list[i] = threading.Thread(target=ct.run, args=(i, num_per_thread))
thread_list[i].start()
task_list[i] = ct
for i in range(thread_nums):
thread_list[i].join()
end_at = time.time()
result = 0
for i in range(thread_nums):
result += task_list[i].get_result()
print (result)
return end_at - start_at
用到的SumTask就是一個(gè)簡(jiǎn)單的類用來(lái)處理返回值,不想去用queue,全局變量什么的。
由于筆者的mac只有兩核,無(wú)法看到4核、8核等更明顯的效果,Python版本的程序跑下來(lái)結(jié)果為:

而Java版本的相同實(shí)現(xiàn),跑下來(lái)的結(jié)果為:

由于電腦核少,故主要看2核情況的對(duì)比,Python版本使用2核并沒(méi)有得到明顯的增速,加速比小于1。而Java版則差不多為2,發(fā)揮到了多核的效用,提高了計(jì)算密集性任務(wù)的效率。
隨著線程數(shù)的增加,由于沒(méi)有那么多核,線程切換的副作用體現(xiàn)了出來(lái),后面時(shí)間會(huì)增加到比單線程還多。
之后,在知乎上有網(wǎng)友利用8核電腦做了驗(yàn)證,依然與預(yù)期相符,Java的最大加速比為0.701/0.168=4.17,而Python的加速比均小于0.5。

Java代碼就是Executor提交任務(wù),然后通過(guò)繼承Callable利用Future得到結(jié)果。
完整版代碼在這里,直接復(fù)制進(jìn)code runner跑就可以看到結(jié)果,很方便。
這,可能是很多人第一次感受到GIL的存在吧~