Python一鍵轉Jar包,Java調用Python新姿式!

粉絲朋友們,不知道你們看故事看膩了沒(要是沒膩可必定留言告訴我^_^),今天這篇文章換換口味,正經的來寫寫技術文。言歸正傳,我們開始吧!java

本文結構:

- 需求背景
  - 進擊的 Python
  - Java 和 Python
- 給 Python 加速
  - 尋找方向
  - Jython?
- Python->Native 代碼
  - 總體思路
  - 實際動手
  - 自動化
- 關鍵問題
  - import 的問題
  - Python GIL 問題
- 測試效果
- 總結
複製代碼

需求背景

進擊的 Python

隨着人工智能的興起,Python 這門曾經小衆的編程語言可謂是煥發了第二春。 python

以 tensorflow、pytorch 等爲主的機器學習/深度學習的開發框架大行其道,助推了 python 這門曾經以爬蟲見長(python 粉別生氣)的編程語言在 TIOBE 編程語言排行榜上一路披荊斬棘,坐上前三甲的寶座,僅次於 Java 和 C,將 C++、JavaScript、PHP、C#等一衆勁敵斬落馬下。 算法

固然,軒轅君向來是不提倡編程語言之間的競爭對比,每一門語言都有本身的優點和劣勢,有本身應用的領域。 另外一方面,TIOBE 統計的數據也不能表明國內的實際狀況,上面的例子只是側面反映了 Python 這門語言現在的流行程度。編程

Java 仍是 Python

說回我們的需求上來,現在在很多的企業中,同時存在 Python 研發團隊和 Java 研發團隊,Python 團隊負責人工智能算法開發,而 Java 團隊負責算法工程化,將算法能力經過工程化包裝提供接口給更上層的應用使用。安全

可能你們要問了,爲何不直接用 Java 作 AI 開發呢?要弄兩個團隊。其實,如今包括 TensorFlow 在內的框架都逐漸開始支持 Java 平臺,用 Java 作 AI 開發也不是不行(其實已經有很多團隊在這樣作了),但限於歷史緣由,作 AI 開發的人本就很少,而這一些人絕大部分都是 Python 技術棧入坑,Python 的 AI 開發生態已經建設的相對完善,因此形成了在不少公司中算法團隊和工程化團隊不得不使用不一樣的語言。bash

如今該拋出本文的重要問題: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 即可以直接調用。

流程並不複雜,但要完整實現這個目標,有一個關鍵問題須要解決:

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 代碼

來看一個實驗:

# FileName: test.py
def TestFunction():
  print("this is print from python script")
複製代碼

將上述代碼經過 Cython 轉化,生成 test.c,長這個樣子:

代碼很是長,並且不易讀,這裏僅截圖示意。

實際動手

1.準備 Python 源代碼

# FileName: Test.py
# 示例代碼:將輸入的字符串轉變爲大寫
def logic(param):
  print('this is a logic function')
  print('param is [%s]' % param)
  return param.upper()

# 接口函數,導出給Java Native的接口
def JNI_API_TestFunction(param):
  print("enter JNI_API_test_function")
  result = logic(param)
  print("leave JNI_API_test_function")
  return result
複製代碼

注意1:這裏在 python 源碼中使用一種約定:以JNI_API_爲前綴開頭的函數表示爲Python代碼模塊要導出對外調用的接口函數,這樣作的目的是爲了讓咱們的 Python 一鍵轉 Jar 包系統能自動化識別提取哪些接口做爲導出函數。

注意2:這一類接口函數的輸入是一個 python 的 str 類型字符串,輸出亦然,如此可便於移植以往經過JSON形式做爲參數的 RESTful 接口。使用JSON的好處是能夠對參數進行封裝,支持多種複雜的參數形式,而不用重載出不一樣的接口函數對外調用。

注意3:還有一點須要說明的是,在接口函數前綴JNI_API_的後面,函數命名不能以 python 慣有的下劃線命名法,而要使用駝峯命名法,注意這不是建議,而是要求,緣由後續會提到。

2.準備一個 main.c 文件

這個文件的做用是對 Cython 轉換生成的代碼進行一次封裝,封裝成 Java JNI 接口形式的風格,以備下一步 Java 的使用。

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
#include <Python.h>
#include <stdio.h>

#ifndef _Included_main
#define _Included_main
#ifdef __cplusplus
extern "C" {
#endif

#if PY_MAJOR_VERSION < 3
# define MODINIT(name) init ## name
#else
# define MODINIT(name) PyInit_ ## name
#endif
PyMODINIT_FUNC MODINIT(Test)(void);

JNIEXPORT void JNICALL Java_Test_initModule (JNIEnv *env, jobject obj) {
  PyImport_AppendInittab("Test", MODINIT(Test));
  Py_Initialize();

  PyRun_SimpleString("import os");
  PyRun_SimpleString("__name__ = \"__main__\"");
  PyRun_SimpleString("import sys");
  PyRun_SimpleString("sys.path.append('./')");

  PyObject* m = PyInit_Test_Test();
  if (!PyModule_Check(m)) {
  	PyModuleDef *mdef = (PyModuleDef *) m;
  	PyObject *modname = PyUnicode_FromString("__main__");
  	m = NULL;
  	if (modname) {
  	  m = PyModule_NewObject(modname);
  	  Py_DECREF(modname);
  	  if (m) PyModule_ExecDef(m, mdef);
  	}
  }
  PyEval_InitThreads();
}


JNIEXPORT void JNICALL Java_Test_uninitModule (JNIEnv *env, jobject obj) {
  Py_Finalize();
}

JNIEXPORT jstring JNICALL Java_Test_testFunction (JNIEnv *env, jobject obj, jstring string) {
  const char* param = (char*)(*env)->GetStringUTFChars(env, string, NULL);
  static PyObject *s_pmodule = NULL;
  static PyObject *s_pfunc = NULL;
  if (!s_pmodule || !s_pfunc) {
    s_pmodule = PyImport_ImportModule("Test");
    s_pfunc = PyObject_GetAttrString(s_pmodule, "JNI_API_testFunction");
  }
  PyObject *pyRet = PyObject_CallFunction(s_pfunc, "s", param);
  (*env)->ReleaseStringUTFChars(env, string, param);
  if (pyRet) {
    jstring retJstring = (*env)->NewStringUTF(env, PyUnicode_AsUTF8(pyRet));
    Py_DECREF(pyRet);
    return retJstring;
  } else {
    PyErr_Print();
    return (*env)->NewStringUTF(env, "error");
  }
}
#ifdef __cplusplus
}
#endif
#endif
複製代碼

這個文件中一共有3個函數:

  • Java_Test_initModule: python初始化工做
  • Java_Test_uninitModule: python反初始化工做
  • Java_Test_testFunction: 真正的業務接口,封裝了對原來Python中定義對JNI_API_testFuncion函數的調用,同時要負責JNI層面的參數jstring類型的轉換。

根據 JNI 接口規範,native 層面的 C 函數命名須要符合以下的形式:

// QualifiedClassName: 全類名
// MethodName: JNI接口函數名
void JNICALL Java_QualifiedClassName_MethodName(JNIEnv*, jobject);
複製代碼

因此在main.c文件中對定義須要向上面這樣命名,這也是爲何前面強調python接口函數命名不能用下劃線,這會致使JNI接口找不到對應的native函數。

3.使用 Cython 工具編譯生成動態庫

補充作一個小小的準備工做:把Python源碼文件的後綴從.py改爲.pyx

python源代碼Test.pyx和main.c文件都準備就緒,接下來即是Cython登場的時候了,它將會將全部pyx的文件自動轉換成.c文件,並結合咱們本身的main.c文件,內部調用gcc生成一個動態二進制庫文件。

Cython 的工做須要準備一個 setup.py 文件,配置好轉換的編譯信息,包括輸入文件、輸出文件、編譯參數、包含目錄、連接目錄,以下所示:

from distutils.core import setup
from Cython.Build import cythonize
from distutils.extension import Extension

sourcefiles = ['Test.pyx', 'main.c']

extensions = [Extension("libTest", sourcefiles, 
  include_dirs=['/Library/Java/JavaVirtualMachines/jdk1.8.0_191.jdk/Contents/Home/include',
    '/Library/Java/JavaVirtualMachines/jdk1.8.0_191.jdk/Contents/Home/include/darwin/',
    '/Library/Frameworks/Python.framework/Versions/3.6/include/python3.6m'],
  library_dirs=['/Library/Frameworks/Python.framework/Versions/3.6/lib/'],
  libraries=['python3.6m'])]

setup(ext_modules=cythonize(extensions, language_level = 3))
複製代碼

注意:這裏涉及Python二進制代碼的編譯,須要連接Python的庫

注意:這裏涉及JNI相關數據結構定義,須要包含Java JNI目錄

setup.py文件準備就緒後,便執行以下命令,啓動轉換+編譯工做:

python3.6 setup.py build_ext --inplace
複製代碼

生成咱們須要的動態庫文件:libTest.so

4.準備Java JNI調用的接口文件

Java業務代碼使用須要定義一個接口,以下所示:

// FileName: Test.java
public class Test {
  public native void initModule();
  public native void uninitModule();
  public native String testFunction(String param);
}
複製代碼

到這一步,其實已經實現了在Java中調用的目的了,注意調用業務接口以前,須要先調用initModule進行native層面的Python初始化工做。

import Test;
public class Demo {
    public void main(String[] args) {
        System.load("libTest.so");
        Test tester = new Test();
        tester.initModule();
        String result = tester.testFunction("this is called from java");
        tester.uninitModule();

        System.out.println(result);
    }
}
複製代碼

輸出:

enter JNI_API_test_function
this is a logic function
param is [this is called from java]
leave JNI_API_test_function
THIS IS CALLED FROM JAVA!
複製代碼

成功實現了在Java中調用Python代碼!

5.封裝爲 Jar 包

作到上面這樣還不能知足,爲了更好的使用體驗,咱們再往前一步,封裝成爲Jar包。

首先原來的JNI接口文件須要再擴充一下,加入一個靜態方法loadLibrary,自動實現so文件的釋放和加載。

// FileName: Test.java
public class Test {
  public native void initModule();
  public native void uninitModule();
  public native String testFunction(String param);
  public synchronized static void loadLibrary() throws IOException {
    // 實現略...
  }
}
複製代碼

接着將上面的接口文件轉換成java class文件:

javac Test.java
複製代碼

最後,準備將class文件和so文件放置於Test目錄下,打包:

jar -cvf Test.jar ./Test
複製代碼

自動化

上面5個步驟若是每次都要手動來作着實是麻煩!好在,咱們能夠編寫Python腳本將這個過程徹底的自動化,真正作到Python一鍵轉換Jar包

限於篇幅緣由,這裏僅僅提一下自動化過程的關鍵:

  • 自動掃描提取python源代碼中須要導出的接口函數
  • main.c、setup.py和JNI接口java文件都須要自動化生成(能夠定義模板+參數形式快速構建),須要處理好各模塊名、函數名對應關係

關鍵問題

1.import 問題

上面演示的案例只是一個單獨的 py 文件,而實際工做中,咱們的項目一般是具備多個 py 文件,而且這些文件一般是構成了複雜的目錄層級,互相之間各類 import 關係,錯綜複雜。

Cython 這個工具備一個最大的坑在於:通過其處理的文件代碼中會丟失代碼文件的目錄層級信息,以下圖所示,C.py 轉換後的代碼和 m/C.py 生成的代碼沒有任何區別。

這就帶來一個很是大的問題: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 這麼簡單,有各類各樣複雜的形式:

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 修正工做有問題?

崩潰的烏雲籠罩在頭上許久,冷靜下來思考: 爲何測試的時候正常沒有發現問題,上線以後纔會崩潰?

再次翻看崩潰日誌,發如今 native 代碼中,發生異常的地方老是在 malloc 分配內存的地方,難不成內存被破壞了? 又發現測試的時候只是完成了功能性測試,並無進行併發壓力測試,而發生崩潰的場景老是在多併發環境中。多線程訪問 JNI 接口,那 Native 代碼將在多個線程上下文中執行。

猛地一個警覺:99%跟 Python 的 GIL 鎖有關係!

衆所周知,限於歷史緣由,Python 誕生於上世紀九十年代,彼時多線程的概念還遠遠沒有像今天這樣深刻人心過,Python 做爲這個時代的產物一誕生就是一個單線程的產品。

雖然 Python 也有多線程庫,容許建立多個線程,但因爲 C 語言版本的解釋器在內存管理上並不是線程安全,因此在解釋器內部有一個很是重要的鎖在制約着 Python 的多線程,因此所謂多線程實際上也只是你們輪流來佔坑。

原來 GIL 是由解釋器在進行調度管理,現在被轉成了 C 代碼後,誰來負責管理多線程的安全呢?

因爲 Python 提供了一套供 C 語言調用的接口,容許在 C 程序中執行 Python 腳本,因而翻看這套 API 的文檔,看看可否找到答案。

幸運的是,還真被我找到了:

獲取 GIL 鎖:

釋放 GIL 鎖:

在 JNI 調用入口須要得到 GIL 鎖,接口退出時須要釋放 GIL 鎖。

加入 GIL 鎖的控制後,煩人的 Crash 問題終於得以解決!

測試效果

準備兩份如出一轍的 py 文件,一樣的一個算法函數,一個經過 Flask Web 接口訪問,(Web 服務部署於本地 127.0.0.1,儘量減小網絡延時),另外一個經過上述過程轉換成 Jar 包。

在 Java 服務中,分別調用兩個接口 100 次,整個測試工做進行 10 次,統計執行耗時:

上述測試中,爲進一步區分網絡帶來的延遲和代碼執行自己的延遲,在算法函數的入口和出口作了計時,在 Java 執行接口調用前和得到結果的地方也作了計時,這樣能夠計算出算法執行自己的時間在整個接口調用過程當中的佔比。

  • 從結果能夠看出,經過 Web API 執行的接口訪問,算法自己執行的時間只佔到了 30%+,大部分的時間用在了網絡開銷(數據包的收發、Flask 框架的調度處理等等)。

  • 而經過 JNI 接口本地調用,算法的執行時間佔到了整個接口執行時間的 80%以上,而 Java JNI 的接口轉換過程只佔用 10%+的時間,有效提高了效率,減小額外時間的浪費。

  • 除此以外,單看算法自己的執行部分,同一份代碼,轉換成 Native 代碼後的執行時間在 300~500μs,而 CPython 解釋執行的時間則在 2000~4000μs,一樣也是相差懸殊。

總結

本文提供了一種 Java 調用 Python 代碼的新思路,僅供參考,其成熟度和穩定性還有待商榷,經過 HTTP Restful 接口訪問仍然是跨語言對接的首選。

至於文中的方法,感興趣的朋友歡迎留言交流。

PS:限於筆者水平有限,文中若有錯誤,歡迎各位不吝賜教,以避免誤導讀者,多謝。

往期熱門回顧

一個Java對象的回憶錄:垃圾回收

內核地址空間大冒險3:權限管理

誰動了你的HTTPS流量?

路由器裏的廣告祕密

內核地址空間大冒險2:中斷與異常

DDoS攻擊:無限戰爭

一條SQL注入引出的驚天大案

內核地址空間大冒險:系統調用

一個HTTP數據包的奇幻之旅

一個DNS數據包的驚險之旅

我是一個流氓軟件線程

掃碼關注,更多精彩


相關文章
相關標籤/搜索