本文构造:

- 需要背景  - 进击的 Python  - Java 和 Python- 给 Python 减速  - 寻找方向  - Jython?- Python->Native 代码  - 整体思路  - 理论入手  - 自动化- 关键问题  - import 的问题  - Python GIL 问题- 测试成果- 总结

需要背景

进击的 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 开发生态曾经建设的绝对欠缺,所以造成了在很多公司中算法团队和工程化团队不得不应用不同的语言。

当初该抛出本文的重要问题: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 编写的解释器
    Ir
  • onPython: .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.pydef 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 __cplusplusextern "C" {#endif#if PY_MAJOR_VERSION < 3# define MODINIT(name)  init ## name#else# define MODINIT(name)  PyInit_ ## name#endifPyMODINIT_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接口函数名voidJNICALLJava_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 setupfrom Cython.Build import cythonizefrom distutils.extension import Extensionsourcefiles = ['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.javapublic 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_functionthis is a logic functionparam is [this is called from java]leave JNI_API_test_functionTHIS IS CALLED FROM JAVA!

胜利实现了在Java中调用Python代码!
5.封装为 Jar 包
做到下面这样还不能满足,为了更好的应用体验,咱们再往前一步,封装成为Jar包。
首先原来的JNI接口文件须要再裁减一下,退出一个静态方法loadLibrary,主动实现so文件的开释和加载。

// FileName: Test.javapublic 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.pyB.pym_C.py

单是这样还不够,还须要对 A、B 中援用到 C 的中央全副进行修改为对 m_C 的援用。
这看起来很简略,但理论状况远比这简单,在 Python 中,import 可不只有 import 这么简略,有各种各样简单的模式:

import packageimport moduleimport package.moduleimport module.class / functionimport package.module.class / functionimport package.*import module.*from module import *from module import modulefrom package import *from package import modulefrom 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~500s,而 CPython 解释执行的工夫则在 2000~4000s,同样也是相差悬殊。

总结
本文提供了一种 Java 调用 Python 代码的新思路,仅供参考,其成熟度和稳定性还有待商讨,通过 HTTP Restful 接口拜访依然是跨语言对接的首选。
至于文中的办法,感兴趣的敌人欢送留言交换。