Python一鍵轉Jar包,Java呼叫Python新姿勢!
阿新 • • 發佈:2020-03-10
粉絲朋友們,不知道大家看故事看膩了沒(要是沒膩可一定留言告訴我^_^),今天這篇文章換換口味,正經的來寫寫技術文。言歸正傳,咱們開始吧!
## 本文結構:
- 需求背景
- 進擊的Python
- Java和Python
- 給Python加速
- 尋找方向
- Jython?
- Python->Native程式碼
- 整體思路
- 實際動手
- 關鍵問題
- import的問題
- Python GIL問題
- 測試效果
- 總結
# 需求背景
## 進擊的Python
隨著人工智慧的興起,Python這門曾經小眾的程式語言可謂是煥發了第二春。
![](https://imgkr.cn-bj.ufileos.com/ffc42dca-7633-42ba-8416-e0e591d5b228.png)
以tensorflow、pytorch等為主的機器學習/深度學習的開發框架大行其道,助推了python這門曾經以爬蟲見長(python粉別生氣)的程式語言在TIOBE程式語言排行榜上一路披荊斬棘,坐上前三甲的寶座,僅次於Java和C,將C++、JavaScript、PHP、C#等一眾勁敵斬落馬下。
![](https://imgkr.cn-bj.ufileos.com/8cbdb113-3cb5-4c70-9b03-8b71e173bcd9.png)
![](https://imgkr.cn-bj.ufileos.com/25687a4a-a001-4a5c-abc0-92488adf0a3e.png)
當然,軒轅君向來是不提倡程式語言之間的競爭對比,每一門語言都有自己的優勢和劣勢,有自己應用的領域。
另一方面,TIOBE統計的資料也不能代表國內的實際情況,上面的例子只是側面反映了Python這門語言如今的流行程度。
## Java 還是 Python
說回咱們的需求上來,如今在不少的企業中,同時存在Python研發團隊和Java研發團隊,Python團隊負責人工智慧演算法開發,而Java團隊負責演算法工程化,將演算法能力通過工程化包裝提供介面給更上層的應用使用。
可能大家要問了,為什麼不直接用Java做AI開發呢?要弄兩個團隊。其實,現在包括TensorFlow在內的框架都逐漸開始支援Java平臺,用Java做AI開發也不是不行(軒轅君的前同事就已經在這樣做了),但限於歷史原因,做AI開發的人本就不多,而這一些人絕大部分都是Python技術棧入坑,Python的AI開發生態已經建設的相對完善,所以造成了在很多公司中演算法團隊和工程化團隊使用不同的語言。
現在該丟擲本文的重要問題:**Java工程化團隊如何呼叫Python的演算法能力?**
答案基本上只有一個:**Python通過Django/Flask等框架啟動一個Web服務,Java中通過Restful API與之進行互動**
上面的方式的確可以解決問題,但隨之而來的就是效能問題。尤其是在使用者量上升後,大量併發介面訪問下,通過網路訪問和Python的程式碼執行速度將成為拖累整個專案的瓶頸。
當然,不差錢的公司可以用硬體堆出效能,一個不行,那就多部署幾個Python Web服務。
那除此之外,有沒有更實惠的解決方案呢?這就是這篇文章要討論的問題。
# 給Python加速
## 尋找方向
上面的效能瓶頸中,拖累執行速度的原因主要有兩個:
- 通過網路訪問,不如直接呼叫內部模組快
- Python是解釋執行,快不起來
眾所周知,Python是一門解釋型指令碼語言,一般來說,在執行速度上:
**解釋型語言 < 中間位元組碼語言 < 本地編譯型語言**
自然而然,我們要努力的方向也就有兩個:
- 能否不通過網路訪問,直接本地呼叫
- Python不要解釋執行
結合上面的兩個點,我們的目標也清晰起來:
**將Python程式碼轉換成Java可以直接本地呼叫的模組**
對於Java來說,能夠本地呼叫的有兩種:
- **Java程式碼包**
- **Native程式碼模組**
其實我們通常所說的Python指的是CPython,也就是由C語言開發的直譯器來解釋執行。而除此之外,除了C語言,不少其他程式語言也能夠按照Python的語言規範開發出虛擬機器來解釋執行Python指令碼:
- CPython: C語言編寫的直譯器
- Jython: Java編寫的直譯器
- IronPython: .NET平臺的直譯器
- PyPy: Python自己編寫的直譯器(雞生蛋,蛋生雞)
## Jython?
如果能夠在JVM中直接執行Python指令碼,與Java業務程式碼的互動自然是最簡單不過。但隨後的調研發現,這條路很快就被堵死了:
- 不支援Python3.0以上的語法
- python原始碼中若引用的第三方庫包含C語言擴充套件,將無法提供支援,如numpy等
這條路行不通,那還有一條:把Python程式碼轉換成Native程式碼塊,Java通過JNI的介面形式呼叫。
# Python -> Native程式碼
## 整體思路
先將Python原始碼轉換成C程式碼,之後用GCC編譯C程式碼為二進位制模組so/dll,接著進行一次Java Native介面封裝,使用Jar打包命令轉換成Jar包,然後Java便可以直接呼叫。
![](https://imgkr.cn-bj.ufileos.com/adcdf61d-ea4e-4e3e-8c6a-069cbbf55227.png)
流程並不複雜,但要完整實現這個目標,有兩個關鍵問題需要解決:
### 1.Python程式碼如何轉換成C程式碼?
終於要輪到本文的主角登場了,將要用到的一個核心工具叫:**Cython**
請注意,這裡的**Cython**和前面提到的**CPython**不是一回事。CPython狹義上是指C語言編寫的Python直譯器,是Windows、Linux下我們預設的Python指令碼直譯器。
而Cython是Python的一個第三方庫,你可以通過`pip install Cython`進行安裝。
官方介紹Cython是一個Python語言規範的超集,它可以將Python+C混合編碼的.pyx指令碼轉換為C程式碼,主要用於優化Python指令碼效能或Python呼叫C函式庫。
聽上去有點複雜,也有點繞,不過沒關係,get一個核心點即可:**Cython能夠把Python指令碼轉換成C程式碼**
來看一個實驗:
```python
# FileName: test.py
def test_function():
print("this is print from python script")
```
將上述程式碼通過Cython轉化,生成test.c,長這個樣子:
另外新增一個main.c,在其中實現C語言的main函式,並呼叫原python中的函式:
```cpp
extern void test_function();
int main() {
test_function();
return 0;
}
```
輸出結果:
可以正常工作!
### 2.轉換後的C程式碼如何包裝成JNI介面使用
## 實際動手
### 1.Python原始碼
```python
def logic(param):
print('this is a logic function')
# 介面函式,匯出給Java Native的介面
def JNI_API_TestFunction(param):
print("enter JNI_API_test_function")
logic(param)
print("leave JNI_API_test_function")
```
### 2.使用Cython工具轉換成C程式碼
### 3.編譯生成動態庫
### 4.封裝為Jar包
準備一個JNI呼叫的Interface:JNITest.java
```java
public class JNITest {
native boolean Java_PkgName_module_initModule( );
native void Java_PkgName_module_uninitModule( );
native String Java_PkgName_module_TestFunction(String param);
}
```
這裡有3個native方法:
- initModule: 對應C程式碼中Java_JNITest_initModule(),主要完成Python初始化
- uninitModule: 對應C程式碼中Java_JNITest_uninitModule(),主要完成Python反初始化
- TestFunction: 對應C程式碼中的Java_JNITest_TestFunction(),為核心業務介面
介面宣告檔案+二進位制動態庫檔案準備就緒,開始打包:
`jar -cvf JNITest.jar ./JNITest`
### 5.Java呼叫
# 關鍵問題
## 1.import問題
上面演示的案例只是一個單獨的py檔案,而實際工作中,我們的專案通常是具有多個py檔案,並且這些檔案通常是構成了複雜的目錄層級,互相之間各種import關係,錯綜複雜。
Cython這個工具有一個最大的坑在於:**經過其處理的檔案程式碼中會丟失程式碼檔案的目錄層級資訊,如下圖所示,C.py轉換後的程式碼和m/C.py生成的程式碼沒有任何區別。**
![](https://imgkr.cn-bj.ufileos.com/3cd337fa-e885-49f7-9b33-5c5cccddebe8.png)
這就帶來一個非常大的問題:A.py或B.py程式碼中如果有引用m目錄下的C.py模組,目錄資訊的丟失將導致二者在執行import m.C時報錯,找不到對應的模組!
幸運的是,經過實驗表明,在上面的圖中,如果A、B、C三個模組處於同一級目錄下時,import能夠正確執行。
軒轅君曾經嘗試閱讀Cython的原始碼,並進行修改,將目錄資訊進行保留,使得生成後的C程式碼仍然能夠正常import,但限於時間倉促,對Python直譯器機理了解不足,在一番嘗試之後選擇了放棄。
在這個問題上卡了很久,最終選擇了一種笨辦法:**將樹形的程式碼層級目錄展開成為平坦的目錄結構**,就上圖中的例子而言,展開後的目錄結構變成了
```
A.py
B.py
m_C.py
```
單是這樣還不夠,還需要對A、B中引用到C的地方全部進行修正為對m_C的引用。
這看起來很簡單,但實際情況遠比這複雜,在Python中,import可不只有import這麼簡單,有各種各樣複雜的形式:
```python
import package
import module
import package.module
import module.class / function
import package.module.class / function
import package.*
import module.*
from module import *
from module import module
from package import *
from package import module
from package.module import class / function
...
```
除此之外,在程式碼中還可能存在直接通過模組進行引用的寫法。
展開成為平坦結構的代價就是要處理上面所有的情況!軒轅君無奈之下只有出此下策,如果各位大佬有更好的解決方案還望不吝賜教。
## 2.Python GIL問題
Python轉換後的jar包開始用於實際生產中了,但隨後發現了一個問題:
**每當Java併發數一上去之後,JVM總是不定時出現Crash**
隨後分析崩潰資訊發現,崩潰的地方正是在Native程式碼中的Python轉換後的程式碼中。
- 難道是Cython的bug?
- 轉換後的程式碼有坑?
- 還是說上面的import修正工作有問題?
![](https://imgkr.cn-bj.ufileos.com/609d2a99-41f6-41eb-81b6-091088fe899c.png)
崩潰的烏雲籠罩在頭上許久,冷靜下來思考:
為什麼測試的時候正常沒有發現問題,上線之後才會崩潰?
再次翻看崩潰日誌,發現在native程式碼中,發生異常的地方總是在malloc分配記憶體的地方,難不成記憶體被破壞了?
又敏銳的發現測試的時候只是完成了功能性測試,並沒有進行併發壓力測試,而發生崩潰的場景總是在多併發環境中。多執行緒訪問JNI介面,那Native程式碼將在多個執行緒上下文中執行。
猛地一個警覺:**99%跟Python的GIL鎖有關係!**
![](https://imgkr.cn-bj.ufileos.com/09c6a8e9-c96f-486a-b35b-d406e99a03ab.png)
眾所周知,限於歷史原因,Python誕生於上世紀九十年代,彼時多執行緒的概念還遠遠沒有像今天這樣深入人心過,Python作為這個時代的產物一誕生就是一個單執行緒的產品。
雖然Python也有多執行緒庫,允許建立多個執行緒,但由於C語言版本的直譯器在記憶體管理上並非執行緒安全,所以在直譯器內部有一個非常重要的鎖在制約著Python的多執行緒,所以所謂多執行緒實際上也只是大家輪流來佔坑。
原來GIL是由直譯器在進行排程管理,如今被轉成了C程式碼後,誰來負責管理多執行緒的安全呢?
由於Python提供了一套供C語言呼叫的介面,允許在C程式中執行Python指令碼,於是翻看這套API的文件,看看能否找到答案。
幸運的是,還真被我找到了:
獲取GIL鎖:
![](https://imgkr.cn-bj.ufileos.com/717c53e9-5c93-411b-8794-ed2e39db23d4.png)
釋放GIL鎖:
![](https://imgkr.cn-bj.ufileos.com/81f95a5d-fd00-49fb-8c52-ffdf6e7d57e7.png)
在JNI呼叫入口需要獲得GIL鎖,介面退出時需要釋放GIL鎖。
加入GIL鎖的控制後,煩人的Crash問題終於得以解決!
# 測試效果
準備兩份一模一樣的py檔案,同樣的一個演算法函式,一個通過Flask Web介面訪問,(Web服務部署於本地127.0.0.1,儘可能減少網路延時),另一個通過上述過程轉換成Jar包。
在Java服務中,分別呼叫兩個介面100次,整個測試工作進行10次,統計執行耗時:
![](https://imgkr.cn-bj.ufileos.com/ecb4fc05-541d-4be5-8365-26eb2c6d4b49.png)
上述測試中,為進一步區分網路帶來的延遲和程式碼執行本身的延遲,在演算法函式的入口和出口做了計時,在Java執行介面呼叫前和獲得結果的地方也做了計時,這樣可以計算出演算法執行本身的時間在整個介面呼叫過程中的佔比。
- 從結果可以看出,通過Web API執行的介面訪問,演算法本身執行的時間只佔到了30%+,大部分的時間用在了網路開銷(資料包的收發、Flask框架的排程處理等等)。
- 而通過JNI介面本地呼叫,演算法的執行時間佔到了整個介面執行時間的80%以上,而Java JNI的介面轉換過程只佔用10%+的時間,有效提升了效率,減少額外時間的浪費。
- **除此之外,單看演算法本身的執行部分,同一份程式碼,轉換成Native程式碼後的執行時間在300~500μs,而CPython解釋執行的時間則在2000~4000μs,同樣也是相差懸殊。**
# 總結
本文提供了一種Java呼叫Python功能的新思路,僅供參考,其成熟度和穩定性還有待商榷,通過HTTP Restful介面訪問仍然是跨語言對接的首選。
至於文中的方法,感興趣的朋友歡迎留言交流。
![](https://img2020.cnblogs.com/blog/659280/202003/659280-20200310094004127-5180106