Java、Go 和 Python 的多線程性能對比
今天分享多線程下這三門語言的表現(xiàn)。
簡介
在計算機中,線程是可以由處理器獨立執(zhí)行的小指令序列。多線程在一個進程中是可能的,其中它們共享資源,例如指令和上下文。
發(fā)現(xiàn)在運行多線程進程時效率最高的編程語言非常重要,因為它可以幫助軟件開發(fā)人員同時選擇最有利的語言來實現(xiàn)他們的系統(tǒng)。
本文的目的是分析和比較 Java、Go 和 Python 使用它們的并行工具解決幾種算法的性能,例如:Java 和 Python 的線程,以及 Go 的 goroutine。為了評估性能,我們編寫了經(jīng)典矩陣乘法算法、快速排序算法和康威生存游戲(Conway’s game of life)的并行實現(xiàn)。
背景
Java :它是一種面向?qū)ο蟮木幊陶Z言,通過Thread類、Runnable接口和java.util.concurrent包中包含的其他功能內(nèi)置支持并發(fā)。
Go:它是谷歌在 2012 年發(fā)布的一種編程語言。它是一種類似于 C 的語言,并且具有垃圾收集器和內(nèi)置的并發(fā)支持。對于并發(fā)性和并行性,Go 使用稱為 goroutines 的線程實現(xiàn)。它使用內(nèi)置的調(diào)度程序在后臺處理線程,并試圖向用戶隱藏通常線程管理的大部分復(fù)雜性,以支持簡單地指定要運行的函數(shù)并使用 go 命令為它們開頭。Go 在幕后使用操作系統(tǒng) (OS) 線程。
Python:標(biāo)準(zhǔn)庫 threading,這個模塊封裝了線程,提供了一個干凈的接口來處理它們。在這種方法中,操作系統(tǒng)實際上知道每個線程,并且可以隨時中斷它以開始運行不同的線程。使用 Python 的其他標(biāo)準(zhǔn)庫(稱為 multiprocessing)可以在 Python 中實現(xiàn)并行性,而 multiprocessing 會創(chuàng)建新的進程。threading 模塊使用線程,multiprocessing 模塊使用進程。不同之處之一是線程在相同的內(nèi)存空間中運行,而進程具有單獨的內(nèi)存。
矩陣乘法:它是線性代數(shù)中一個重要而簡單的數(shù)學(xué)運算,經(jīng)典的矩陣乘法算法(CMMA)可以很容易地用任何編程語言實現(xiàn)。CMMA 時間復(fù)雜度 *O(N^3)*,其中 N 是矩陣的維度。
快速排序:它是一種分而治之的算法。最初,它將數(shù)組拆分為兩個子數(shù)組:分別是較低的項和較高的項。然后它遞歸地對這些子數(shù)組進行排序。其算法可以描述為:
從數(shù)組中選擇一個樞軸元素。 對數(shù)組進行分區(qū),使得樞軸左側(cè)的所有元素的值都小于樞軸的值,而樞軸右側(cè)的所有元素的值都大于樞軸的值。 對生成的子數(shù)組遞歸執(zhí)行上述步驟,直到得到完全排序的子數(shù)組。
它的平均排序復(fù)雜度為*O(nlog(n)),但在極少數(shù)情況下會降級為O(n^2)*。
康威的生存游戲:它是由劍橋大學(xué)岡維爾和凱斯學(xué)院的數(shù)學(xué)家約翰霍頓康威在 60 年代開發(fā)的。規(guī)則是:
規(guī)則 1 生存:如果一個活細胞有兩個或三個活的鄰居,它就可以生存。 規(guī)則 2 死亡:如果一個活細胞的存活鄰居少于兩個或多于三個,它就會死亡。 規(guī)則 3 出生:如果一個死細胞恰好有三個活著的鄰居,它就會出生。
下圖片顯示了康威生存游戲從時間 T 到時間 T + 2 的幾個場景。該算法的一個簡單實現(xiàn)將檢查每一步的每個單元格,其時間復(fù)雜度為 O(M × N ),其中 M 表示行數(shù),N 表示網(wǎng)格的列數(shù)。

解決方案
所有基準(zhǔn)測試都在同一臺計算機和同一環(huán)境中執(zhí)行。表 I 顯示了我們進行實驗的硬件規(guī)格,表 II 顯示了每種語言編程編譯器的版本

對于每個基準(zhǔn),我們計算其各自的加速和效率。加速定義為最佳順序時間與p個處理器的并行時間之比:S = T1/Tp。那么效率定義為:并行系統(tǒng)中每個處理器的總體利用率:E = S/p。
矩陣乘法:在本實驗中,我們使用由C = AB計算的簡單矩陣乘法。矩陣的大小為 512 × 512。我們正在運行一個順序場景,然后是幾個線程池大小為:2、4、8、16 和 32 個線程的多線程場景。相同的矩陣A和B用于運行 Java、Go 和 Python 中的代碼。這些矩陣將具有從 0 到 1000 的整數(shù)值(包括 0 到 1000)。線程池將接收 n 個工作,表示矩陣 A 中的行數(shù),然后每個線程將執(zhí)行該特定行的乘法,并更新C中的相應(yīng)行與得到的結(jié)果。每次線程完成其工作時,它將從線程池接收新的分配,直到所有工作都完成。下圖說明了矩陣C的計算。

快速排序:我們從 0 到 10^7(包括 0 和 10^7)對 10^7 個整數(shù)值的數(shù)組進行排序。我們使用 ForkJoinPool 方法來解決這個實驗。執(zhí)行順序運行,然后多線程測試以 2 的冪從 2 到 32 個線程。這 3 個實現(xiàn)對同一個數(shù)組進行排序。在快速排序的并行實現(xiàn)中,分區(qū)后新子序列的排序可以并行執(zhí)行,因為沒有沖突。
康威生存游戲:康威生存游戲中有幾個初始已知模式會產(chǎn)生預(yù)期的結(jié)果,有一個會無限增長,下一張圖像的左側(cè)顯示其初始設(shè)置。在這個基準(zhǔn)測試中,我們定義了一個 28 × 28 的 2D 網(wǎng)格,其中 4 個先前的模式彼此相鄰,并在 20,000 代期間執(zhí)行游戲,該網(wǎng)格看起來像下圖的右側(cè)。

對于這個實驗,我們重復(fù)線程池方法,它將處理 2、4、8、16 和 32 個線程。此外,我們運行順序?qū)崿F(xiàn)。我們并行應(yīng)用了決定細胞在每一代中是否繼續(xù)生存、死亡或重生的規(guī)則。此外,為了獲取單元格的鄰居,我們不會將網(wǎng)格視為無限網(wǎng)格,如果單元格的鄰居在世界范圍之外,則認為它已死。
結(jié)果
矩陣乘法:表 III 顯示了運行矩陣乘法基準(zhǔn)后獲得的結(jié)果。Java 是順序執(zhí)行中最好的,它在 316 毫秒內(nèi)運行了 512 × 512 矩陣乘法,而 Go 消耗了 453 毫秒,這代表順序運行的時間增加了 43.35%。性能最差的是 Python,它使用了 93,870 毫秒(93.87 秒),相差約 29,600%。
此外,在表 III 中可以看出,當(dāng)使用 2 個線程執(zhí)行基準(zhǔn)測試時,Java 性能更好,但是當(dāng)實驗在 4、8 和 16 個線程中運行時,Go 優(yōu)于 Java 和 Python。我們可以在 32 個線程的閾值處檢測到性能下降,這可能是由于創(chuàng)建過多線程的開銷超過了 CPU 中的物理內(nèi)核;即使在這種惡化的情況下,Go 的性能也優(yōu)于其他兩種語言。

下圖的左側(cè)反映了對于 Go,從 2 到 4 個線程的性能差異是顯著的(大約 92% 的改進),而從 8 到 16 個線程它是微不足道的,因為圖中的曲線幾乎保持不變,這說明我們認為 8 個線程對于這個特定場景來說是一個很好的數(shù)字。另一方面,Java 的性能從 2 個線程穩(wěn)步增加到 16 個線程;從這個圖中,我們可以選擇 16 個線程作為大量線程,以便在 Java 中獲得不錯的性能。從下圖右側(cè)可以確定,資源利用效率最差的是Python,最好的是Go。

矩陣乘法的加速和效率。
快速排序:在表 IV 中,我們可以看到 Go 在測試的順序運行中獲得最佳性能,而 Python 最差。Python 實現(xiàn)的一般行為非常糟糕,在 16 和 32 線程的情況下,應(yīng)用程序無法完成執(zhí)行(DNF 代表沒有完成)卡住并阻塞計算機。Go 提供了有趣的結(jié)果,因為多線程運行從糟糕到最差的性能。對于這個度量,Java 獲得了最好的多線程結(jié)果,但是在 16 和 32 線程的運行中,我們可以發(fā)現(xiàn)性能下降,其中 16 線程的性能最差。
下圖左側(cè)的加速表明,當(dāng)使用 Java 實現(xiàn)時,使用超過 8 個線程運行快速排序算法是沒有意義的。此外,我們可以看到 32 線程的運行比 16 線程的運行好一點,但比 8 線程的運行差。該圖反映了 Go 多線程實現(xiàn)不如順序?qū)崿F(xiàn)。即使 Python 的性能很差,使用 2、4 和 8 個線程的運行也比順序運行要好。
下圖右側(cè)反映的效率證實了我們在表 IV 中看到的最好的,但不是最差的,因為這個圖可以讓我們認為 Python 比 Go 更好,但是時間的消耗告訴我們一段不同的歷史:Go 的表現(xiàn)優(yōu)于 Python。

康威的生存游戲:Python 再次獲得了所有 3 種實現(xiàn)中最差的結(jié)果,但是正如我們在表 V 中看到的,它是唯一一個從順序?qū)崿F(xiàn)到多線程實現(xiàn)有一些改進的版本;盡管如此,隨著線程數(shù)量的增加,時間的消耗也會增加。在 Java 和 Go 的情況下,它們的最佳性能來自順序?qū)崿F(xiàn)。在 Java 場景中,從順序版本到具有 32 個線程的版本的時間差異為 11,455%,這代表了當(dāng)時的巨大差距。在 Java 實現(xiàn)中,每一代之后都會釋放線程池并創(chuàng)建一個新的線程池,這會產(chǎn)生相當(dāng)大的開銷。Go 行為不穩(wěn)定,時間利用率從順序到 2 線程版本增加,然后在 4、8 和 16 個線程的版本中,它會稍微減少一點,最后在 32 個線程時,它會再次增加。

下圖的加速圖證實了康威生存游戲的多線程實現(xiàn)的一般性能很差。圍繞加速曲線的期望是它應(yīng)該遵循增加的行為,而這并沒有發(fā)生。報告的效率表明資源沒有得到適當(dāng)?shù)睦谩ava 的效率非常接近于零,Go 和 Java 之間只有一點點差異。

結(jié)論
創(chuàng)建線程的開銷直接影響應(yīng)用程序的性能。在表 III 中可以看出,在達到某個閾值后繼續(xù)創(chuàng)建線程是沒有意義的,因為性能會下降。
如表 V 所示,使用多線程解決問題并不能保證我們會獲得更好的性能,有時順序?qū)崿F(xiàn)是最好的選擇。
處理并發(fā)性和并行性的 Python 標(biāo)準(zhǔn)庫不如 Java 和 Go 等其他編程語言中實現(xiàn)的標(biāo)準(zhǔn)庫。
很難說 Java 和 Go 之間哪個更好,因為 Java 在矩陣乘法基準(zhǔn)測試中優(yōu)于 Go,但 Go 在快速排序?qū)嶒炛谐^了 Java。
未來的工作
這項工作僅限于每個算法的基本實現(xiàn),下一步將是復(fù)制本研究引入的場景,但使用每個基準(zhǔn)測試的最知名實現(xiàn)。
在 Python 中實現(xiàn)并行代碼有多種選擇,例如:MPI for Python、CharmPy、Numba 等。一個好的實驗是使用這個庫用更有效的實現(xiàn)來替換書面代碼。
擴展這項研究的一種方法是考慮其他技術(shù)方面,例如編譯時間、文件大小和編寫的代碼行數(shù)。
作者:Jose Pablo,原文鏈接:https://levelup.gitconnected.com/java-go-and-python-a-multi-thread-performance-comparison-28e942cb73e6。
推薦閱讀
