问题从哪来: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 想成两位同事,让他们合作大致有三种办法:

三种思路:跨进程通信、在 JVM 里重新实现 Python、在同一个进程里直接调用

思路

代表

好处

代价

打电话(进程间通信)

Py4J、gRPC;PyFlink 的进程模式

两边各跑各的,Python 崩了不会拖垮 Java

每条数据都要序列化、跨进程传输、再反序列化

请替身(在 JVM 上实现 Python)

Jython

没有跨进程的开销

稳定版只支持 Python 2.7,用不了 NumPy 这类 C 扩展

坐在一起(FFI,外部函数接口)

JEP、PEMJA;PyFlink 的线程模式

同一个进程里直接调用,省掉序列化和通信

要和 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 经 JNI 调到 C,C 再经 Python/C API 调到 Python

关键在于,这三样东西最后都装进了同一个进程:

JVM、C 桥和 Python 解释器在同一个进程里,共享同一块内存

既然在同一个进程里,一次调用就只是几次函数跳转,外加一些类型转换:不用把数据编码成字节流、经过网络或管道送到另一个进程,再在那边解码回来。

动手:Java 传日期,Python 返回星期几

代码在 QFlink 的 qflink-python/ffi-demo 下,三个源文件加一个脚本:

text
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 仓库根目录执行,第二次传一个自己的日期:

shell
qflink-python/ffi-demo/run.sh
qflink-python/ffi-demo/run.sh 2026-10-24
text
4
6

1994 年 5 月 5 日是星期四,2026 年 10 月 24 日是星期六(星期一是 1)。这两个数字从 Python 算出来,经 C 交回 Java 打印。run.sh 做了三件事:生成头文件、编译 C 桥、运行 Java。下面按这个顺序一步步拆开。

第一步:Java 声明一个 native 方法

native 的意思是:这个方法 Java 这边只有声明,实现写在别的语言里。加载动态库的时候,JVM 会把它和 C 里对应的函数对上。

java
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 头文件

shell
javac -h src/main/c -d target/classes src/main/java/cn/syntomic/ffi/FFIDemo.java

javac -h 在编译 Java 的同时,会在 src/main/c 下生成 cn_syntomic_ffi_FFIDemo.h,里面就是 C 这边要实现的函数:

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 函数

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)公式直接算公历日期是星期几:

w=(y+[y4]+[c4]−2c+[26(m+1)10]+d−1) mod 7w=\left(y+\left[\frac{y}{4}\right]+\left[\frac{c}{4}\right]-2c+\left[\frac{26(m+1)}{10}\right]+d-1\right) \bmod 7

其中 c 是年份的前两位,y 是年份的后两位,m 是月份,d 是日期,方括号表示向下取整。1 月和 2 月要当成上一年的 13 月和 14 月来算。结果 w 为 0 表示星期日,1 到 6 依次是星期一到星期六;算出负数时加 7 再取余。

代入 1994-05-05,也就是 c = 19、y = 94、m = 5、d = 5:

(94+23+4−38+15+5−1) mod 7=102 mod 7=4(94+23+4-38+15+5-1) \bmod 7 = 102 \bmod 7 = 4

余数是 4,星期四,和 Python 算的一样。

第四步:用 C 把两边接起来

这是整篇文章的核心。C 代码只做两件事:加载时启动解释器;每次调用时,把参数送进 Python,再把结果拿回来。

c
#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):

shell
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、转换结果

和进程间通信比,这里没有网络、没有序列化协议,也没有另一个进程要调度。剩下的成本主要是类型转换,以及拿、还 GIL。

绕不开的 GIL

CPython 有一把全局解释器锁(GIL):同一个解释器里,同一时刻只有一个线程能执行 Python 代码。可以把它想成会议室里唯一的话筒,谁拿着话筒谁说话,其他人排队。

嵌进 JVM 以后,这条规矩照样有效。Java 是多线程的,几个线程可能同时调用 dayOfWeek,所以 C 桥在调用 Python 之前必须先拿到 GIL,用完马上归还。不拿就调用,解释器的内部状态会被几个线程同时改乱,进程直接崩溃。

同一个解释器里,Python 代码只能轮流执行

排队是有代价的。动手测一下:同样调用 40 万次 dayOfWeek,平分给 1、2、4 个 Java 线程去跑。仓库里的 run 方法就是做这件事的:

java
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();
    }
}
shell
qflink-python/ffi-demo/run.sh bench
text
400000 次调用
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 算子与 Python Worker 通过 gRPC 的控制、数据、状态、日志、指标服务通信

一条数据要先在 Java 这边编码,攒成一批(bundle)后经 gRPC 发给 Python 进程;Python 解码、执行 UDF、再编码结果,经 gRPC 发回,Java 再解码。攒批是为了摊薄通信开销,对吞吐友好,但单条数据要等一批凑齐或超时才会被处理,延迟会变长。

线程模式:把 Python 请进 JVM

线程模式把中间的 gRPC 换成了 PEMJA:Python 解释器直接嵌进 TaskManager 的 JVM 进程,UDF 就在 Java 算子的线程里被调用。

线程模式:Java 算子通过 PEMJA 直接调用同一进程里的 Python UDF

PEMJA 做的,正是上面那座手写的桥的「工业版」:

手写的桥

PEMJA

在 JNI_OnLoad 里调用一次 Py_Initialize()

专门起一个守护线程(PemJaMainInterpreter)初始化解释器,并一直留着它

一个全局的函数对象

每个 PythonInterpreter 对象各有一个线程状态(PyThreadState)和自己的全局变量

PyGILState_Ensure / PyGILState_Release

每次 invoke、exec 前后调用 PyEval_AcquireThread / PyEval_ReleaseThread

手写 String 和 str、int 和 long 的转换

内置常见类型的双向转换:基本类型、字符串、字节数组、List、Map、日期时间等

只能 Java 调 Python

Python 也能用 findClass 调回 Java

用起来是这样(改写自 PEMJA 的 README):

java
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 做的事可以压缩成两段:

java
// 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

这带来三个后果:

  • GIL 争用:Flink 文档也提醒,同一个 JVM 里的多个 Python UDF 仍然受 GIL 影响。UDF 很轻时影响不大;UDF 本身很吃 CPU、一个 TaskManager 上的并行度又高时,就会出现上面实验里的现象。

  • 没有隔离:Python 或某个 C 扩展出了段错误,倒下的是整个 TaskManager;Python 占用的内存也直接算在 TaskManager 进程头上。

  • 进程级的状态是共享的:比如工作目录,整个进程只有一个。Flink 为此用一把静态锁加计数来管:同一个作业里第一个打开的 Python 算子切换过去,最后一个关闭的再切回来。

怎么选:线程模式还是进程模式

线程模式从 Flink 1.15 开始提供,用 python.execution-mode 打开:

sql
SET 'python.execution-mode' = 'thread';
python
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 模型分类:

shell
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 函数,靠的就是本文手写的那座桥。

  1. 用 JNI 从 Java 进入 C;

  2. 用 Python/C API 把 CPython 解释器嵌进同一个进程,在 C 里调用 Python 函数;

  3. 解释器只启动一次,每次调用都是转换类型、拿 GIL、调用、还 GIL。

PEMJA 把这座桥做成了通用的库,PyFlink 在算子线程里一条条地调用 invokeMethod。省掉的是序列化和跨进程通信,代价是和 TaskManager 共用一个进程、一把 GIL。弄清楚这一点,什么时候该用线程模式,也就有了判断的依据。

参考资料


全文评论

0

正在加载评论…