问题从哪来:Flink 用 Java 写,UDF 用 Python 写
Flink 的运行时是 Java 写的,但很多人更愿意用 Python 写业务逻辑:清洗数据有熟悉的库,模型推理离不开 NumPy、scikit-learn、PyTorch。PyFlink 要做的,就是让这两边配合起来,其中有两个方向的调用:
写作业时,Python 调 Java:在 Python 里调用的 Table API / DataStream API,背后是通过 Py4J 调用 Java 的 API,把作业翻译成 Java 那边的执行计划。这只在提交作业时发生,慢一点也无所谓。
跑作业时,Java 调 Python:作业跑起来以后,每来一条数据,Java 算子都要把它交给 Python UDF 算一下,再拿回结果。每条数据都要来回一次,这里的开销决定了 Python UDF 能跑多快。
所以问题变成了:Java 怎样调用 Python,才能又快又稳?
三种思路:打电话、请替身、坐在一起
把 Java 和 Python 想成两位同事,让他们合作大致有三种办法:
思路 | 代表 | 好处 | 代价 |
|---|---|---|---|
打电话(进程间通信) | Py4J、gRPC;PyFlink 的进程模式 | 两边各跑各的,Python 崩了不会拖垮 Java | 每条数据都要序列化、跨进程传输、再反序列化 |
请替身(在 JVM 上实现 Python) | 没有跨进程的开销 | 稳定版只支持 Python 2.7,用不了 NumPy 这类 C 扩展 | |
坐在一起(FFI,外部函数接口) | 同一个进程里直接调用,省掉序列化和通信 | 要和 C、GIL、内存管理打交道;Python 出了致命错误会拖垮整个进程 |
PyFlink 两种都用了:默认的进程模式是「打电话」,Flink 1.15 加入的线程模式是「坐在一起」。要理解线程模式,就得先弄明白第三种思路到底怎么「坐在一起」。
中间人是 C:JNI 和 Python/C API
Java 和 Python 彼此不认识,但它们都会说 C:
JNI(Java Native Interface):JVM 提供的接口。Java 里声明一个
native方法,就能调用一个用 C 写、编译进动态库的函数;C 代码也能通过JNIEnv反过来读写 Java 的对象。Python/C API:CPython 本身就是 C 写的,它把解释器做成了一个可以链接的库(
libpython)。C 程序链接它以后,可以启动解释器、导入模块、调用 Python 函数,这叫嵌入(embedding)。
把这两头接起来,C 就成了中间人:
关键在于,这三样东西最后都装进了同一个进程:
既然在同一个进程里,一次调用就只是几次函数跳转,外加一些类型转换:不用把数据编码成字节流、经过网络或管道送到另一个进程,再在那边解码回来。
动手:Java 传日期,Python 返回星期几
代码在 QFlink 的 qflink-python/ffi-demo 下,三个源文件加一个脚本:
qflink-python/ffi-demo/
├── src/main/java/cn/syntomic/ffi/FFIDemo.java # Java:声明 native 方法
├── src/main/python/day_of_week.py # Python:真正干活的函数
├── src/main/c/ffi_demo.c # C:JNI + Python/C API 的桥
└── run.sh # 编译 Java 和 C,然后运行第零步:先跑起来
在 QFlink 仓库根目录执行,第二次传一个自己的日期:
qflink-python/ffi-demo/run.sh
qflink-python/ffi-demo/run.sh 2026-10-244
61994 年 5 月 5 日是星期四,2026 年 10 月 24 日是星期六(星期一是 1)。这两个数字从 Python 算出来,经 C 交回 Java 打印。run.sh 做了三件事:生成头文件、编译 C 桥、运行 Java。下面按这个顺序一步步拆开。
第一步:Java 声明一个 native 方法
native 的意思是:这个方法 Java 这边只有声明,实现写在别的语言里。加载动态库的时候,JVM 会把它和 C 里对应的函数对上。
package cn.syntomic.ffi;
/** Java 传一个日期给 Python,Python 告诉我们这天星期几。 */
public class FFIDemo {
static {
// 加载用 C 写的“桥”。加载时 JVM 会调用其中的 JNI_OnLoad,Python 解释器就在那里启动
System.load(System.getProperty("user.dir") + "/" + System.mapLibraryName("ffidemo"));
}
/** native 方法:Java 这里只有声明,实现写在 C 里 */
native int dayOfWeek(String date);
public static void main(String[] args) throws InterruptedException {
FFIDemo demo = new FFIDemo();
System.out.println(demo.dayOfWeek(args.length > 0 ? args[0] : "1994-05-05"));
}
}System.mapLibraryName("ffidemo") 在 macOS 上是 libffidemo.dylib,在 Linux 上是 libffidemo.so,同一份代码两边都能跑。仓库里的 main 还多一个 bench 分支,后面测 GIL 时用。
第二步:生成 C 头文件
javac -h src/main/c -d target/classes src/main/java/cn/syntomic/ffi/FFIDemo.javajavac -h 在编译 Java 的同时,会在 src/main/c 下生成 cn_syntomic_ffi_FFIDemo.h,里面就是 C 这边要实现的函数:
JNIEXPORT jint JNICALL Java_cn_syntomic_ffi_FFIDemo_dayOfWeek
(JNIEnv *, jobject, jstring);函数名:
Java_加上包名、类名、方法名,点换成下划线。JVM 就是靠这个名字找到实现的。JNIEnv *:JNI 的「工具箱」,读 Java 字符串、抛 Java 异常都要通过它。jobject:调用这个方法的 Java 对象,相当于this。jstring:Java 的String在 C 里的样子。其他类型的对应关系见 JNI Types。
第三步:写 Python 函数
from datetime import datetime
def day_of_week(date: str) -> int:
"""'1994-05-05' -> 4(星期一是 1,星期日是 7)"""
return datetime.strptime(date, "%Y-%m-%d").isoweekday()一点数学:不用日期库,怎么手算星期几
如果不想依赖日期库,可以用数论里的蔡勒(Zeller)公式直接算公历日期是星期几:
其中 c 是年份的前两位,y 是年份的后两位,m 是月份,d 是日期,方括号表示向下取整。1 月和 2 月要当成上一年的 13 月和 14 月来算。结果 w 为 0 表示星期日,1 到 6 依次是星期一到星期六;算出负数时加 7 再取余。
代入 1994-05-05,也就是 c = 19、y = 94、m = 5、d = 5:
余数是 4,星期四,和 Python 算的一样。
第四步:用 C 把两边接起来
这是整篇文章的核心。C 代码只做两件事:加载时启动解释器;每次调用时,把参数送进 Python,再把结果拿回来。
#include <Python.h>
#include "cn_syntomic_ffi_FFIDemo.h"
static PyObject *day_of_week; // 导入一次、反复使用的 Python 函数
/* System.load 加载这个库时,JVM 会先调用 JNI_OnLoad:解释器在这里启动,只启动一次 */
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
Py_Initialize();
PyRun_SimpleString("import sys; sys.path.insert(0, 'src/main/python')");
PyObject *module = PyImport_ImportModule("day_of_week");
day_of_week = module ? PyObject_GetAttrString(module, "day_of_week") : NULL;
Py_XDECREF(module);
if (day_of_week == NULL) {
PyErr_Print();
return JNI_ERR;
}
// Py_Initialize 之后,当前线程一直拿着 GIL;交出来,其他线程才能调用 Python
PyEval_SaveThread();
return JNI_VERSION_1_8;
}
JNIEXPORT jint JNICALL Java_cn_syntomic_ffi_FFIDemo_dayOfWeek(JNIEnv *env, jobject self,
jstring date) {
// ① Java 的 String -> C 的字符串
const char *text = (*env)->GetStringUTFChars(env, date, NULL);
// ② 拿到 GIL:同一时刻,只有拿着它的线程能执行 Python
PyGILState_STATE gil = PyGILState_Ensure();
// ③ C 字符串 -> Python 的 str,调用 day_of_week(text)
PyObject *result = PyObject_CallFunction(day_of_week, "s", text);
// ④ Python 的 int -> C 的 long;Python 抛了异常就转成 Java 异常
long weekday = -1;
if (result != NULL) {
weekday = PyLong_AsLong(result);
Py_DECREF(result);
} else {
PyErr_Print();
(*env)->ThrowNew(env, (*env)->FindClass(env, "java/lang/RuntimeException"),
"Python 函数执行失败");
}
// ⑤ 归还 GIL,释放字符串
PyGILState_Release(gil);
(*env)->ReleaseStringUTFChars(env, date, text);
return (jint)weekday;
}几个要点:
JNI_OnLoad只跑一次:JVM 加载动态库时自动调用它。我们在这里启动解释器、导入模块,把函数对象存起来。启动解释器很贵,要初始化一大堆内置模块;反复启动、关闭也不安全,NumPy 等很多 C 扩展不支持在同一个进程里重新初始化。类型转换:①③④ 都是在换「表示法」。Java 的
String变成 C 的char *,再变成 Python 的str;Python 的int变成 C 的long,再变成 Java 的int。GIL:② 和 ⑤ 一拿一还,下一节细说为什么必须这样。
引用计数:Python 对象靠引用计数回收。C 代码拿到的新对象(比如
result)用完要Py_DECREF,不然就会内存泄漏。异常:Python 抛的异常不会自己变成 Java 异常,要在 C 里检查,再用
ThrowNew抛给 Java。
第五步:编译、运行
编译 C 桥要三样东西:JDK 的 JNI 头文件、Python 的头文件、libpython。Python 这两样的位置因安装方式而异,run.sh 不依赖 python3-config,直接问 Python 自己(sysconfig):
py() { "$PYTHON" -c "import sysconfig; print(sysconfig.$1)"; }
py_include="$(py 'get_paths()["include"]')" # Python.h 所在的目录
py_libdir="$(py 'get_config_var("LIBDIR")')" # libpython 所在的目录
py_version="$(py 'get_config_var("LDVERSION")')" # 比如 3.12
# 1. Java:编译,并为 native 方法生成 C 头文件
javac -h src/main/c -d target/classes src/main/java/cn/syntomic/ffi/FFIDemo.java
# 2. C:头文件来自 JDK 和 Python,链接 libpython,产出动态库
cc -shared -fPIC \
-I"$JAVA_HOME/include" -I"$JAVA_HOME/include/$platform" \
-I"$py_include" \
src/main/c/ffi_demo.c \
-L"$py_libdir" -lpython"$py_version" -Wl,-rpath,"$py_libdir" \
-o "$library"
# 3. 运行:Java -> JNI -> C -> Python/C API -> Python
java -cp target/classes cn.syntomic.ffi.FFIDemo "$@"-Wl,-rpath 把 libpython 的目录写进动态库,运行时不用再设 DYLD_LIBRARY_PATH 或 LD_LIBRARY_PATH。$platform 在 macOS 上是 darwin,在 Linux 上是 linux,脚本会自己判断。
几种常见的报错:
no Python.h under …, point PYTHON to a python with its headers:选中的 Python 没有头文件,比如 macOS 自带的那个。用PYTHON=/path/to/python qflink-python/ffi-demo/run.sh换一个,uv、Homebrew、python.org 装的都可以。No module named 'encodings':嵌入的解释器没找到标准库,先export PYTHONHOME=$(python3 -c 'import sys; print(sys.base_prefix)')再运行。Python 函数本身出错:传一个不合法的日期试试,
run.sh 2026-13-01。Python 的ValueError先被 C 代码打印出来,再由ThrowNew转成 Java 的RuntimeException: Python 函数执行失败,这就是上面要点里「异常」那一条。
这份代码最早在 ffi_demo 仓库里,搬进 QFlink 时改进了 C 部分:解释器只启动一次,并补上了 GIL、引用计数和异常的处理。
一次调用里发生了什么
把上面的代码按时间顺序摊开,就是这张图:
和进程间通信比,这里没有网络、没有序列化协议,也没有另一个进程要调度。剩下的成本主要是类型转换,以及拿、还 GIL。
绕不开的 GIL
CPython 有一把全局解释器锁(GIL):同一个解释器里,同一时刻只有一个线程能执行 Python 代码。可以把它想成会议室里唯一的话筒,谁拿着话筒谁说话,其他人排队。
嵌进 JVM 以后,这条规矩照样有效。Java 是多线程的,几个线程可能同时调用 dayOfWeek,所以 C 桥在调用 Python 之前必须先拿到 GIL,用完马上归还。不拿就调用,解释器的内部状态会被几个线程同时改乱,进程直接崩溃。
排队是有代价的。动手测一下:同样调用 40 万次 dayOfWeek,平分给 1、2、4 个 Java 线程去跑。仓库里的 run 方法就是做这件事的:
static void run(FFIDemo demo, int threads, int total) throws InterruptedException {
List<Thread> workers = new ArrayList<>();
for (int i = 0; i < threads; i++) {
Thread worker =
new Thread(
() -> {
for (int n = 0; n < total / threads; n++) {
demo.dayOfWeek("1994-05-05");
}
});
workers.add(worker);
worker.start();
}
for (Thread worker : workers) {
worker.join();
}
}qflink-python/ffi-demo/run.sh bench400000 次调用
1 个线程:1.49 秒
2 个线程:2.14 秒
4 个线程:2.36 秒Java 线程数 | 40 万次调用的总耗时(三次实测) |
|---|---|
1 | 1.47~1.56 秒 |
2 | 2.14~2.25 秒 |
4 | 2.29~2.52 秒 |
线程越多反而越慢:Python 那一段本来就只能排队,线程多了,抢锁和交接的开销还会叠加上去。加线程只能让 Java 那部分并行,Python 那部分始终是一个接一个。
回到 PyFlink:两种模式差在哪
进程模式:Java 算子给 Python 进程打电话
默认的进程模式下,每个 Python 算子的并行实例都配有一个独立的 Python worker 进程,它基于 Apache Beam 的可移植框架。Java 算子和它之间通过几组 gRPC 服务通信:控制、数据、状态、日志、指标各走一条。
一条数据要先在 Java 这边编码,攒成一批(bundle)后经 gRPC 发给 Python 进程;Python 解码、执行 UDF、再编码结果,经 gRPC 发回,Java 再解码。攒批是为了摊薄通信开销,对吞吐友好,但单条数据要等一批凑齐或超时才会被处理,延迟会变长。
线程模式:把 Python 请进 JVM
线程模式把中间的 gRPC 换成了 PEMJA:Python 解释器直接嵌进 TaskManager 的 JVM 进程,UDF 就在 Java 算子的线程里被调用。
PEMJA 做的,正是上面那座手写的桥的「工业版」:
手写的桥 | PEMJA |
|---|---|
在 | 专门起一个守护线程( |
一个全局的函数对象 | 每个 |
| 每次 |
手写 | 内置常见类型的双向转换:基本类型、字符串、字节数组、List、Map、日期时间等 |
只能 Java 调 Python | Python 也能用 |
用起来是这样(改写自 PEMJA 的 README):
PythonInterpreterConfig config = PythonInterpreterConfig.newBuilder()
.setPythonExec("python3")
.addPythonPaths(path)
.build();
PythonInterpreter interpreter = new PythonInterpreter(config);
interpreter.exec("import str_upper");
Object result = interpreter.invoke("str_upper.upper", "abcd"); // "ABCD"
interpreter.close();PEMJA 有两种执行方式:MULTI_THREAD 让所有 PythonInterpreter 共用一个主解释器,各自只是一个线程状态;SUB_INTERPRETER 给每个 PythonInterpreter 建一个子解释器,模块和全局变量也彼此隔离,但不少 C 扩展不支持子解释器。PyFlink 的线程模式用的是前者。
一条数据在线程模式里怎么走
以标量函数(ScalarFunction)为例,Flink 的 EmbeddedPythonScalarFunctionOperator 做的事可以压缩成两段:
// open():每个算子实例创建自己的 PythonInterpreter,把 UDF 准备好
interpreter = new PythonInterpreter(config);
interpreter.exec("from pyflink.fn_execution.embedded.operation_utils"
+ " import create_scalar_operation_from_proto");
interpreter.set("proto", udfProto); // 描述 UDF 的 protobuf 字节,只传一次
interpreter.exec("scalar_operation = create_scalar_operation_from_proto(proto, ...)");
interpreter.invokeMethod("scalar_operation", "open");
// processElement():每来一条数据,在算子线程里同步调用一次
Object args = inputConverter.toExternal(row); // Flink 内部格式 -> Java 对象
Object result = interpreter.invokeMethod("scalar_operation", "process_element", args);
output.collect(outputConverter.toInternal(result)); // Java 对象 -> Flink 内部格式这段代码精简自 Flink 源码里的 AbstractEmbeddedPythonFunctionOperator 和 EmbeddedPythonScalarFunctionOperator。和我们的 dayOfWeek 几乎一模一样:没有攒批,没有 gRPC,每条数据就是一次 invokeMethod,只不过参数从一个字符串换成了一行数据。
同一个 JVM,同一把 GIL
线程模式省掉了通信,也把 Python 和 TaskManager 绑在了一起。一个 TaskManager 上的多个 slot、多个 Python 算子实例,用的是同一个嵌入的解释器:
这带来三个后果:
GIL 争用:Flink 文档也提醒,同一个 JVM 里的多个 Python UDF 仍然受 GIL 影响。UDF 很轻时影响不大;UDF 本身很吃 CPU、一个 TaskManager 上的并行度又高时,就会出现上面实验里的现象。
没有隔离:Python 或某个 C 扩展出了段错误,倒下的是整个 TaskManager;Python 占用的内存也直接算在 TaskManager 进程头上。
进程级的状态是共享的:比如工作目录,整个进程只有一个。Flink 为此用一把静态锁加计数来管:同一个作业里第一个打开的 Python 算子切换过去,最后一个关闭的再切回来。
怎么选:线程模式还是进程模式
线程模式从 Flink 1.15 开始提供,用 python.execution-mode 打开:
SET 'python.execution-mode' = 'thread';t_env.get_config().set("python.execution-mode", "thread")进程模式(默认) | 线程模式 | |
|---|---|---|
Python 在哪里跑 | 独立的 Python worker 进程 | TaskManager 的 JVM 进程里 |
数据怎么过去 | 序列化、攒批,经 gRPC 传输 | 经 JNI 直接调用,做类型转换 |
延迟 | 要等一批凑齐或超时 | 每条数据同步调用一次 |
隔离 | Python 崩了不影响 JVM | Python 崩了 TaskManager 跟着崩 |
GIL | 每个 worker 进程各有一把 | 同一个 TaskManager 共用一把 |
支持范围 | 全部 Python UDF 类型 | Python UDF、UDTF 和常用的 DataStream 算子;UDAF、Pandas UDF / UDAF 只能用进程模式,遇到时自动回落 |
经验上,UDF 逻辑轻、对延迟敏感,比如解析 JSON、简单的规则判断、查一张小字典,优先试试线程模式;UDF 很重、需要隔离,或者用到了线程模式不支持的函数,就留在进程模式。SQL Is All Your Need: Flink SQL UDF 里调用 Python 模型的例子,用的就是线程模式。
在 QFlink 里可以直接跑这个线程模式的例子(准备好 uv 环境之后),每秒造一条鸢尾花数据交给 Python 模型分类:
tutorials/run.sh --python sql_is_all_your_need/flink_sql_udf/python_udf.sql--python 做的两件事正好对应本文:把 uv 环境里的 python 放进 PATH,线程模式靠它找到 libpython;把环境的 site-packages 放进 PYTHONPATH,嵌进 JVM 的解释器从这里导入 pemja 和模型依赖。少了任何一个,作业都会在启动后失败。详见 QFlink 教程第 02 章。
总结
回到开头的问题:PyFlink 的线程模式之所以能让 Java 算子直接调用 Python 函数,靠的就是本文手写的那座桥。
用 JNI 从 Java 进入 C;
用 Python/C API 把 CPython 解释器嵌进同一个进程,在 C 里调用 Python 函数;
解释器只启动一次,每次调用都是转换类型、拿 GIL、调用、还 GIL。
PEMJA 把这座桥做成了通用的库,PyFlink 在算子线程里一条条地调用 invokeMethod。省掉的是序列化和跨进程通信,代价是和 TaskManager 共用一个进程、一把 GIL。弄清楚这一点,什么时候该用线程模式,也就有了判断的依据。
参考资料
QFlink:qflink-python/ffi-demo,本文的全部代码;教程第 07 章。
ffi_demo:本文示例的最初版本。
全文评论
0正在加载评论…