0%

聊聊 Python 互操作(混合编程)

之前负责的大数据项目里,由于客户提供的云环境过于特殊,Spark 的版本太老,用不上 Apache Livy ,迫于无奈只能自己实现一个较为直接(菜鸟)的 Spark Restful 交互,当时有一个难点就是需要将 PySpark 运行的某些信息额外存一份到 HDFS 上。当然 RDD 存 HDFS 上对于 Spark 来说是非常方便,但是却怎么也找不到 PySpark 直接操作 HDFS 的方法,当时是参考了 Raven’s Blog 的一篇文章解决了,也了解到了 PySpark 用于操作 Java 对象的库 Py4J ,然后心血来潮,稍微归纳一下目前自己用到或者摸过的 Python 和其他语言的互操作库,纯粹分享(水一篇 blog)。

和 C/C++ 交互

大部分人用的都是 CPython ,因此和 C 进行交互是最为直接的,而 ctypes 本身也包含在了 Python 发行版的标准库里,可见其重要性。Python 和 C/C++ 交互无法是为了速度,或者保密需要,再者就是纯粹的胶水脚本。虽说是与 C/C++ 进行交互,实际上 Python 本身只支持 C API ,因此本质上还是和 C 交互。ctypes 的资料确实不算多,但对比起其他环境来已经是好太多,本质上还是这个交互过程实际上也较为依赖系统编程的知识点,因此即使文档再详细,没有相关的知识储备读起来还是晕头转向。

对于 C++ 的函数接口,需要通过 extern 声明转换为 C 的函数接口,这样编译出来的 dll 才能被 Python 调用得到。

ctypes 在不同的操作系统上会使用对应的动态加载动态链接库的方法,并通过一套映射方法将 Python 和二进制形式的动态链接库连接起来。在 Windows 下最终调用的是 Windows API 的 LoadLibraryGetProcAddress ,而在 Linux 和 Mac OSX 下则是 Posix 标准的 dlopendlsym。其中 ctypes 实现了一系列的类型转换,将 Python 类型转为 C 类型作为调用参数,然后将返回结果从 C type 转回为 Python type 。

下面这个例子纯粹是演示用途(实际上并不能跑) 😂 ,同时也是提醒 ctypes 的跨平台性较差,使用的时候需注意。

ctypes_example.py
1
2
3
4
5
6
7
8
9
10
11
# 1. 引入 ctypes 库
from ctypes import *

# 2. 加载 dll,这里要区分 Windows 和 Posix
stdcall_dll = ctypes.windll.LoadLibrary("dll路径") # 仅限 Windows ,dll 中的函数使用 stdcall 约定并再默认情况下返回 int (注意在 Windows CE 是只支持 standard 标准约定)
cdecl_dll = ctypes.cdll.LoadLibrary("dll路径") # dll 中的函数使用 cdecl 或称作 C 调用约定,并以 int 返回
oledll_dll = ctypes.oledll.LoadLibrary("dll路径") # 仅限 Windows,dll 中的函数使用 stdcall 约定并假定返回 Windows 特定的 HRESULT 代码,调用失败时会抛出 `OSError` 异常。
pydll_dll = ctypes.pydll.LoadLibrary("dll路径") # 行为类似于 CDLL 实例,但不同之处在于执行期间不会释放 GIL 同时执行完之后会检查 Python error flag ,如果设置了 flag 则会抛出 Python 异常,因此这个仅对直接调用 Python C api 有用~

# 3. 调用 dll ,`dll 对象.函数名(函数参数)`
cdecl_dll.function_a(123)

至于其他的类型映射,如数组、指针、结构体、联合体等请查阅 ctypes 文档。

和 .NET 交互

.NET 平台上的语言基本上都要在 CLR 上运行(这里不包括新的 CoreCLR),当然了最直接肯定是用 IronPython ,让 Python 在 CLR 运行不就把问题解决了吗 😀 ?但是 Python 中 C 依赖较多的库是没有办法在 IronPython 上使用,同时目前看来 IronPython3 的步伐既跟不上 Python 也跟不上 .NET (这也很无奈,两边都是高速往前,而 IronPython 组的核心成员也不够稳定,希望喜欢 IronPython 的能多支持一下),所以折衷的方案就是使用 Python for .NET 了。

Python for .NET 的安装很简单:pip install pythonnet

下面摘自 PyPI 的例子,咋一看和 IronPython 引入第三方 .NET DLL 也非常类似:

dotnet_exmple.py
1
2
3
4
5
6
7
8
9
# Python for .NET 对于 CLR 的命名空间将视为 Python 的 Package
import clr
from System import String
from System.Collections import *

# 需要加载具体的程序集,需要使用 `clr` 的 `AddReference`

clr.AddReference("System.Windows.Forms")
from System.Windows.Forms import Form

更多的教程可以取查看 Python for .NET 的 github page (详见参考资料),因为支持 .NET Framework 和 mono 的平台,所以 Python For .NET 的跨平台性还算可以,也支持内嵌 Python 到 .NET 中(PyPI 上同样由 C# 引用 Python 的例子),这个可以说是很惊喜了。

和 Java 交互

类似于 IronPython ,同样也有在 JVM 上运行的 Python 版本:Jython 。对于 Jython 我对它的了解确实不够多,对于 JVM 的语言的了解除了 Java 和浅尝则止的 Scala 以及 Kotlin 就很有限了。也是在做 PySpark 这边的项目才知道 PySpark 是通过 Py4J 的手段连接 Python 和 Java 。

Py4J 可以使运行在 Python Interpreter 的 Python 程序能够动态访问 Java 虚拟机的对象,同样 Java 也可以回调 Python 的对象。
使用 Py4J 的过程有点曲折,详细可以查看它的官网介绍(见「参考资料」一节),按它的说法是 an hybrid between a glorified Remote Procedure Call and using the Java Virtual Machine to run a Python program.,所以说本质上是基于 socket 的 RPC 调用,因此对比 Jython 在 JVM 上执行 Python 代码和 JPype 这种通过 JNI 通信的来说开销会更大,但是也更独立(也就是 Python 可以使用原先 Cpython 能使用的库而不受影响),对性能有格外要求的可以忽略了(不过我寻思都用这两了在性能上就不要强求了吧😂)。

注意:官网的 Installing 环节没有直接提供 py4j 的 jar 包下载,可以下载 py4j-java 的源码然后通过 gradle 和 maven 来构建,这里我用的 maven ,然后将 py4j-0.10.9.jar 包的依赖加到 maven 项目中。

这里直接上了一下 Welcome 页的例子

Python 代码部分:

py4j_example.py
1
2
3
4
5
6
7
8
9
10
11
from py4j.java_gateway import JavaGateway

if __name__ == '__main__':
gateway = JavaGateway() # 连结 JVM
random = gateway.jvm.java.util.Random() # 创建一个 java.util.Random 实例
number1 = random.nextInt(10)
number2 = random.nextInt(10)
print(number1, number2)
addition_app = gateway.entry_point # 获取 AdditionApplication 实例
value = addition_app.addition(number1, number2) # 调用 addition fangfa
print(value)

Java 代码部分:

py4j_example.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import py4j.GatewayServer;

public class AdditionApplication
{
public int addition(int first, int second) {
return first + second;
}

public static void main( String[] args )
{
App app = new App();
// app 现在是 gateway.entry_point
GatewayServer server = new GatewayServer(app);
server.start();
}
}

注意由于是 RPC 调用的关系 Java 部分代码要求先于 Python 代码部分运行,且两者需同时运行才能正常运行,换言之, Py4J 是不会启动 JVM 的

和 Lua 交互

多年前为了探讨一下解决 CPython GIL 锁的限制,考虑过在并发上引入 Lua 的方案,然后就发现了 Lupa 这个东西。其实 Lupa 还有个前身 lunatic-python ,这个东西主要提供了 Python 和 Lua 之间的桥接,既能让 Python 运行 Lua ,又能让 Lua 运行 Python ,机制就是在各自的 interpreter 创建对方的 interpreter 。Lupa 就是将 LuaLuaJIT2 的运行时整合进 CPython 中,同时重写了部分 lunatic-python 内容以更好地提供 coroutine 的支持。

这里 Lua 实际上是 Python 的一种补充,由于 Lua 的运行时很小(700K左右便于嵌入)而 LuaJIT 可以将动态的 Lua 编译成极快的机器代码,所以可以将 Lua 作为 Python 的一种加速且资源友好的 bakcup language ,而 Lupa 就是其中的包装器包装了两个 runtime 交互的细节。

至于其他的用途在 PyPI 的页面也提到了:

  • 使用 Python 协程装饰的 Lua 协程
  • 正确的处理字符串的编码和解码(对于 Python2 比较有用)
  • 调用 lua 时会释放 GIL 并支持在单独的运行时中进行线程化
  • 面向 LuaJIT 编写,不过 lua 的 interpreter 可以替换为标准的 Lua 5.1 和 Lua 5.2
  • 因为使用 Cython 编写而不是 C 所以便于扩展(相对而言吧)

PyPI 中 “Installing lupa” 一节中介绍了使用 LuaJIT2 和 Lua5.1 的 lupa 在各个平台的源码编译安装过程,pip install 的默认是基于 LuaJIT2 运行是。

这里演示的是多线程生成 Mandelbrot 集合的图象并用 PIL 库展示的代码(来自 PyPI)

lupa_example.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
lua_code = '''\
function(N, i, total)
local char, unpack = string.char, table.unpack
local result = ""
local M, ba, bb, buf = 2/N, 2^(N%8+1)-1, 2^(8-N%8), {}
local start_line, end_line = N/total * (i-1), N/total * i - 1
for y=start_line,end_line do
local Ci, b, p = y*M-1, 1, 0
for x=0,N-1 do
local Cr = x*M-1.5
local Zr, Zi, Zrq, Ziq = Cr, Ci, Cr*Cr, Ci*Ci
b = b + b
for i=1,49 do
Zi = Zr*Zi*2 + Ci
Zr = Zrq-Ziq + Cr
Ziq = Zi*Zi
Zrq = Zr*Zr
if Zrq+Ziq > 4.0 then b = b + 1; break; end
end
if b >= 256 then p = p + 1; buf[p] = 511 - b; b = 1; end
end
if b ~= 1 then p = p + 1; buf[p] = (ba-b)*bb; end
result = result .. char(unpack(buf, 1, p))
end
return result
end
'''

image_size = 1280 # == 1280 x 1280
thread_count = 8

from lupa import LuaRuntime
lua_funcs = [LuaRuntime(encoding=None).eval(lua_code) for _ in range(thread_count)]

results = [None] * thread_count


def mandelbrot(i, lua_func):
results[i] = lua_func(image_size, i+1, thread_count)

import threading
threads = [threading.Thread(target=mandelbrot, args=(i, lua_func)) for i, lua_func in enumerate(lua_funcs)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

result_buffer = b''.join(results)

# use Pillow to display the image
from PIL import Image
image = Image.frombytes('1', (image_size, image_size), result_buffer)
image.show()

注意这里实际上为每个线程都创建一个单独的 LuaRuntime 并行执行,每个 LuaRuntime 都有一个全局锁保护,防止并发访问。因为 Lua 内存占用低,因此可以使用多个运行时,但这种设置也意味着不能轻易在 Lua 内部的线程之间交换值,必须通过 Python 的空间复制来传递。

和 JavaScript 交互

Python 和 JS 交互最常见的场面就是在爬虫开发上了,越来越的网页对 JavaScript 进行了混淆,如果懒得去破的话直接运行 JavaScript 反倒是最快的。

当然这种情况要用到 client-side 对象的话 selenium 之类的可能更直接,如果只是用到了 js 环境,那么比如 PyExecJS 和 PyV8都可以用一下。然而实际情况是,PyExecJS 已经 EOL 了,PyV8 也已经咸鱼好久了 ……

单纯运行 JavaScript 方面其实也没有什么大的进展,反倒是像 Js2Py 这一种将 JavaScript 翻译为 Python 的还是很活跃的,而且号称无依赖且完全支持 ECMAScript 5.1 (ECMA 6 支持尚在试验阶段),不过对于混淆很深的 JS 代码或者有外部 node 依赖的性能也不会好到哪里去。

比方说提供对 ECMA6 支持是通过 Babel 将 ECMA6 转为 ECMA5.1 再通过 Js2Py 将 ECMA5.1 转为 Python ,其中导入 babel.js (4M 大小) 转译这过程就要 15 秒 ……

最后看一下 Js2Py 的代码(这个基本和交互就没关系了)

js2py_example.py
1
2
3
import js2py
print(js2py.eval_js('console.log("Hello World!")'))
# 'Hello World!'

参考资料