粉丝朋友们,不知道你们看故事看腻了没(要是没腻可必定留言告诉我^_^),今天这篇文章换换口味,正经的来写写技术文。言归正传,我们开始吧!java
- 需求背景
- 进击的 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 这门语言现在的流行程度。编程
说回我们的需求上来,现在在很多的企业中,同时存在 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 代码转换成 Java 能够直接本地调用的模块
对于 Java 来讲,可以本地调用的有两种:
其实咱们一般所说的 Python 指的是 CPython,也就是由 C 语言开发的解释器来解释执行。而除此以外,除了 C 语言,很多其余编程语言也可以按照 Python 的语言规范开发出虚拟机来解释执行 Python 脚本:
若是可以在 JVM 中直接执行 Python 脚本,与 Java 业务代码的交互天然是最简单不过。但随后的调研发现,这条路很快就被堵死了:
这条路行不通,那还有一条:把 Python 代码转换成 Native 代码块,Java 经过 JNI 的接口形式调用。
先将 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,长这个样子:
# 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 惯有的下划线命名法,而要使用驼峰命名法,注意这不是建议,而是要求,缘由后续会提到。
这个文件的做用是对 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个函数:
根据 JNI 接口规范,native 层面的 C 函数命名须要符合以下的形式:
// QualifiedClassName: 全类名
// MethodName: JNI接口函数名
void JNICALL Java_QualifiedClassName_MethodName(JNIEnv*, jobject);
复制代码
因此在main.c文件中对定义须要向上面这样命名,这也是为何前面强调python接口函数命名不能用下划线,这会致使JNI接口找不到对应的native函数。
补充作一个小小的准备工做:把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
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代码!
作到上面这样还不能知足,为了更好的使用体验,咱们再往前一步,封装成为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包
限于篇幅缘由,这里仅仅提一下自动化过程的关键:
上面演示的案例只是一个单独的 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
...
复制代码
除此以外,在代码中还可能存在直接经过模块进行引用的写法。
展开成为平坦结构的代价就是要处理上面全部的状况!轩辕君无奈之下只有出此下策,若是各位大佬有更好的解决方案还望不吝赐教。
Python 转换后的 jar 包开始用于实际生产中了,但随后发现了一个问题:
每当 Java 并发数一上去以后,JVM 老是不定时出现 Crash
随后分析崩溃信息发现,崩溃的地方正是在 Native 代码中的 Python 转换后的代码中。
崩溃的乌云笼罩在头上许久,冷静下来思考: 为何测试的时候正常没有发现问题,上线以后才会崩溃?
再次翻看崩溃日志,发如今 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:
限于笔者水平有限,文中若有错误,欢迎各位不吝赐教,以避免误导读者,多谢。