AnyMath 文档

嵌入Julia

正如我们在 调用C和Fortran代码,Julia有一个简单而有效的方法来调用用C编写的函数.但是有些情况需要相反:从C代码调用Julia函数。 这可以用于将Julia代码集成到更大的C/C中++ 项目,而不需要在C/C中重写所有内容++. Julia有一个C API来实现这一点。 由于几乎所有的编程语言都有某种方式来调用C函数,因此Julia C API也可以用于构建进一步的语言桥梁(例如从Python,Rust或C#调用Julia)。 即使生锈和C++ 可以直接使用c嵌入API,两者都有包帮助它,用于C++ Jluna是有用的。

高级嵌入

注意:本节介绍在类Unix操作系统上的c语言中嵌入Julia代码。 要在Windows上执行此操作,请参阅以下部分, 使用Visual Studio在Windows上的高级嵌入

我们从一个简单的c程序开始,该程序初始化Julia并调用一些Julia代码:

#include <julia.h>
JULIA_DEFINE_FAST_TLS // only define this once, in an executable (not in a shared library) if you want fast code.

int main(int argc, char &ast;argv[])
{
    /&ast; required: setup the Julia context &ast;/
    jl_init();

    /&ast; run Julia commands &ast;/
    jl_eval_string("print(sqrt(2.0))");

    /&ast; strongly recommended: notify Julia that the
         program is about to terminate. this allows
         Julia time to cleanup pending write requests
         and run all finalizers
    &ast;/
    jl_atexit_hook(0);
    return 0;
}

为了构建此程序,您必须将Julia头的路径添加到include路径并链接到 [医]利朱利亚. 例如,当Julia安装到 $JULIA_DIR,可以编译上述测试程序 测试。c海湾合作委员会 使用:

gcc -o test -fPIC -I$JULIA_DIR/include/julia -L$JULIA_DIR/lib -Wl,-rpath,$JULIA_DIR/lib test.c -ljulia

或者,看看 嵌入。c 在Julia源代码树中的程序 测试/嵌入/ 文件夹。 文件 cli/loader_exe。c 程序是如何设置的另一个简单的例子 jl_选项 链接时的选项 [医]利朱利亚.

在调用任何其他Julia c函数之前必须做的第一件事是初始化Julia。 这是通过调用 jl_init,它试图自动确定Julia的安装位置。 如果需要指定自定义位置,或指定要加载的系统映像,请使用 jl_init_with_image_filejl_init_with_image_handle 相反。

测试程序中的第二个语句使用调用Julia语句来计算Julia语句。 jl_eval_string.

在程序终止之前,强烈建议 jl_atexit_hook 被调用。 上面的示例程序在从 主要.

注意目前,动态链接到 [医]利朱利亚 共享库需要通过 RTLD_全球 选择。 在Python中,这看起来像:

```
>>>julia=CDLL('。/libjulia.dylib',RTLD_GLOBAL)
朱莉娅。jl_init。argtypes=[]
朱莉娅。jl_init()
250593296
```

注意如果julia程序需要从主可执行文件访问符号,则可能需要添加 -Wl,--导出-动态 linux编译时的链接器标志,除了由 朱莉娅-配置。jl 如下所述。 在编译共享库时,这不是必需的。

使用julia-config自动确定构建参数

剧本 朱莉娅-配置。jl 是为了帮助确定使用嵌入式Julia的程序需要哪些构建参数而创建的。 此脚本使用它所调用的特定Julia发行版的构建参数和系统配置来导出嵌入程序与该发行版交互所需的编译器标志。 此脚本位于Julia共享数据目录中。

例子:

#include <julia.h>

int main(int argc, char &ast;argv[])
{
    jl_init();
    (void)jl_eval_string("println(sqrt(2.0))");
    jl_atexit_hook(0);
    return 0;
}

在命令行上

这个脚本的一个简单用法是从命令行。 假设 朱莉娅-配置。jl 位于 usr/本地/朱莉娅/共享/朱莉娅,它可以直接在命令行上调用,并采用三个标志的任意组合:

/usr/local/julia/share/julia/julia-config.jl
Usage: julia-config [--cflags|--ldflags|--ldlibs]

如果上面的示例源保存在文件中 embed_example。c,那么下面的命令将其编译成linux和Windows(MSYS2环境)上的可执行程序。 在macOS上,替代 叮当海湾合作委员会.:

/usr/local/julia/share/julia/julia-config.jl --cflags --ldflags --ldlibs | xargs gcc embed_example.c

Makefile中的使用

一般来说,嵌入项目会比上面的例子更复杂,所以下面的例子也允许通用的makefile支持—​假设GNU make是因为使用了*shell*宏扩展。 此外,虽然 朱莉娅-配置。jl 通常在 /usr/本地 目录,如果不是,那么Julia本身可以用来查找 朱莉娅-配置。jl,而makefile可以利用这一点。 上面的示例扩展为使用makefile:

JL_SHARE = $(shell julia -e 'print(joinpath(Sys.BINDIR, Base.DATAROOTDIR, "julia"))')
CFLAGS   += $(shell $(JL_SHARE)/julia-config.jl --cflags)
CXXFLAGS += $(shell $(JL_SHARE)/julia-config.jl --cflags)
LDFLAGS  += $(shell $(JL_SHARE)/julia-config.jl --ldflags)
LDLIBS   += $(shell $(JL_SHARE)/julia-config.jl --ldlibs)

all: embed_example

现在构建命令很简单 使.

使用Visual Studio在Windows上的高级嵌入

如果 朱莉亚*迪尔 环境变量尚未设置,在启动Visual Studio之前使用系统面板添加它。 该 JULIA_DIR下的文件夹应该在系统路径上。

我们首先打开Visual Studio并创建一个新的控制台应用程序项目。 打开’stdafx。h’头文件,并在最后添加以下几行:

#include <julia.h>

然后,将项目中的main()函数替换为以下代码:

int main(int argc, char &ast;argv[])
{
    /&ast; required: setup the Julia context &ast;/
    jl_init();

    /&ast; run Julia commands &ast;/
    jl_eval_string("print(sqrt(2.0))");

    /&ast; strongly recommended: notify Julia that the
         program is about to terminate. this allows
         Julia time to cleanup pending write requests
         and run all finalizers
    &ast;/
    jl_atexit_hook(0);
    return 0;
}

下一步是设置项目以查找Julia包含文件和库。 重要的是要知道Julia安装是32位还是64位。 在继续之前,请删除与Julia安装不对应的任何平台配置。

使用"项目属性"对话框,转到 通过:c[C/C++]

一般事务 并添加 $(JULIA_DIR)\include\julia\ 到附加的Include Directories属性。 然后,转到 链接器

一般事务 节和添加 $(JULIA_DIR)\lib 到附加库目录属性。 最后,在 链接器

输入,添加 libjulia。dll的。a;libopenlibm。dll的。a; 到库列表。

此时,项目应该构建并运行。

转换类型

真正的应用程序不仅需要执行表达式,还需要将它们的值返回给宿主程序。 jl_eval_string 返回a jl_value_t*,它是一个指向堆分配的Julia对象的指针。 存储简单的数据类型,如 漂浮64以这种方式被称为 拳击,并提取存储的基元数据被调用 拆箱. 我们改进的示例程序在Julia中计算2的平方根并在C中读回结果,它有一个现在包含此代码的主体:

jl_value_t &ast;ret = jl_eval_string("sqrt(2.0)");

if (jl_typeis(ret, jl_float64_type)) {
    double ret_unboxed = jl_unbox_float64(ret);
    printf("sqrt(2.0) in C: %e \n", ret_unboxed);
}
else {
    printf("ERROR: unexpected return type from sqrt(::Float64)\n");
}

为了检查是否 [医]雷特 是一个特定的Julia类型,我们可以使用 jl_isa, jl_typeis,或 jl_is_... 函数。 通过打字 打字(sqrt(2.0)) 进入Julia shell我们可以看到返回类型是 漂浮64 (双倍 在C)。 要将盒装Julia值转换为c double jl_unbox_float64 函数在上面的代码片段中使用。

相应的 jl_box_。.. 函数用于转换其他方式:

jl_value_t &ast;a = jl_box_float64(3.0);
jl_value_t &ast;b = jl_box_float32(3.0f);
jl_value_t &ast;c = jl_box_int32(3);

正如我们接下来将看到的,需要装箱来调用带有特定参数的Julia函数。

调用Julia函数

jl_eval_string 允许C获取Julia表达式的结果,它不允许将C中计算的参数传递给Julia。 为此,您需要直接调用Julia函数,使用 jl_call:

jl_function_t &ast;func = jl_get_function(jl_base_module, "sqrt");
jl_value_t &ast;argument = jl_box_float64(2.0);
jl_value_t &ast;ret = jl_call1(func, argument);

第一步,Julia函数的句柄 sqrt,sqrt 通过调用检索 jl_get_function. 第一个论点传递给 jl_get_function 是指向 基地 其中的模块 sqrt,sqrt 被定义。 然后,使用以下方法将double值装箱 jl_box_float64. 最后,在最后一步中,使用以下方法调用函数 jl_call1. jl_call0, jl_call2,而 jl_call3 函数也存在,以方便地处理不同数量的参数。 要传递更多参数,请使用 jl_call:

jl_value_t &ast;jl_call(jl_function_t &ast;f, jl_value_t &ast;&ast;args, int32_t nargs)

它的第二个论点 阿格斯 是一个数组 jl_value_t* 争论和 纳格斯 是参数的数量。

还有一种调用Julia函数的替代方法,可能更简单,那就是通过 @cfunction. 使用 @cfunction 允许您在Julia端进行类型转换,这通常比在C端进行更容易。 该 sqrt,sqrt 上面的例子将与 @cfunction 被写成:

double (&ast;sqrt_jl)(double) = jl_unbox_voidpointer(jl_eval_string("@cfunction(sqrt, Float64, (Float64,))"));
double ret = sqrt_jl(2.0);

其中我们首先在Julia中定义一个c可调用函数,从中提取函数指针,最后调用它。 除了通过在更高级语言中执行类型转换来简化类型转换之外,还通过以下方式调用Julia函数 @cfunction 指针消除了由 jl_call (所有参数都是"盒装"的),并且应该具有与本机C函数指针等效的性能。

内存管理

正如我们所看到的,Julia对象在C中表示为类型的指针 jl_value_t*. 这就提出了谁负责释放这些对象的问题。

通常情况下,Julia对象被垃圾回收器(GC)释放,但是GC不会自动知道我们正在从C中持有对Julia值的引用。这意味着GC可以将对象从你下面释放出来,使指针无效。

GC只会在分配新的Julia对象时运行。 像这样的电话 jl_box_float64 执行分配,但分配也可能发生在运行Julia代码的任何时候。

在编写嵌入Julia的代码时,通常可以安全使用 jl_value_t* 介于两者之间的值 jl_... 调用(因为GC只会被这些调用触发)。 但为了确保价值观能够生存下去 jl_... 电话,我们必须告诉茱莉亚,我们仍然对茱莉亚有一个参考https://www.cs.purdue.edu/homes/hosking/690M/p611-fenichel.pdf[root]值,一个称为"GC生根"的过程。 生根值将确保垃圾回收器不会意外地将此值标识为未使用,并释放支持该值的内存。 这可以使用 JL_GC_PUSH 宏:

jl_value_t &ast;ret = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret;);
// Do something with ret
JL_GC_POP();

JL_GC_POP 调用释放先前建立的引用 JL_GC_PUSH. 请注意 JL_GC_PUSH 将引用存储在C堆栈上,因此它必须与a完全配对 JL_GC_POP 范围退出之前。 也就是说,在函数返回之前,或控制流以其他方式离开其中的块 JL_GC_PUSH 被调用。

几个Julia值可以使用 JL_GC_PUSH2JL_GC_PUSH6 宏:

JL_GC_PUSH2(&ret1;, &ret2;);
// ...
JL_GC_PUSH6(&ret1;, &ret2;, &ret3;, &ret4;, &ret5;, &ret6;);

要推送Julia值的数组,可以使用 JL_GC_PUSHARGS 宏,可以使用如下:

jl_value_t &ast;&ast;args;
JL_GC_PUSHARGS(args, 2); // args can now hold 2 `jl_value_t&ast;` objects
args[0] = some_value;
args[1] = some_other_value;
// Do something with args (e.g. call jl_... functions)
JL_GC_POP();

每个作用域必须只有一个调用 JL_GC_PUSH*,并且应该只与单个配对 JL_GC_POP 打电话。 如果您想要根的所有必要变量不能通过一次调用推送到 JL_GC_PUSH*,或者如果有超过6个变量要推送并且使用参数数组不是一个选项,那么可以使用内部块:

jl_value_t &ast;ret1 = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret1;);
jl_value_t &ast;ret2 = 0;
{
    jl_function_t &ast;func = jl_get_function(jl_base_module, "exp");
    ret2 = jl_call1(func, ret1);
    JL_GC_PUSH1(&ret2;);
    // Do something with ret2.
    JL_GC_POP();    // This pops ret2.
}
JL_GC_POP();    // This pops ret1.

请注意,没有必要有有效的 jl_value_t* 调用前的值 JL_GC_PUSH*. 将它们中的一些初始化为 NULL,把那些递给 JL_GC_PUSH* 然后创建实际的Julia值。 例如:

jl_value_t &ast;ret1 = NULL, &ast;ret2 = NULL;
JL_GC_PUSH2(&ret1;, &ret2;);
ret1 = jl_eval_string("sqrt(2.0)");
ret2 = jl_eval_string("sqrt(3.0)");
// Use ret1 and ret2
JL_GC_POP();

如果需要在函数(或块作用域)之间保存指向变量的指针,则无法使用 JL_GC_PUSH*. 在这种情况下,有必要在Julia全局作用域中创建并保留对变量的引用。 实现这一目标的一个简单方法是使用全局 IdDict 这将保存引用,避免GC取消分配。 但是,此方法仅适用于可变类型。

// This functions shall be executed only once, during the initialization.
jl_value_t&ast; refs = jl_eval_string("refs = IdDict()");
jl_function_t&ast; setindex = jl_get_function(jl_base_module, "setindex!");

...

// `var` is the variable we want to protect between function calls.
jl_value_t&ast; var = 0;

...

// `var` is a `Vector{Float64}`, which is mutable.
var = jl_eval_string("[sqrt(2.0); sqrt(4.0); sqrt(6.0)]");

// To protect `var`, add its reference to `refs`.
jl_call3(setindex, refs, var, var);

如果变量是不可变的,那么它需要被包装在一个等价的可变容器中,或者最好是在一个 重新值{Any} 在它被推到 IdDict. 在这种方法中,容器必须通过C代码创建或填充,例如使用函数 jl_new_结构. 如果容器是由 jl_call*,然后您将需要重新加载要在C代码中使用的指针。

// This functions shall be executed only once, during the initialization.
jl_value_t&ast; refs = jl_eval_string("refs = IdDict()");
jl_function_t&ast; setindex = jl_get_function(jl_base_module, "setindex!");
jl_datatype_t&ast; reft = (jl_datatype_t&ast;)jl_eval_string("Base.RefValue{Any}");

...

// `var` is the variable we want to protect between function calls.
jl_value_t&ast; var = 0;

...

// `var` is a `Float64`, which is immutable.
var = jl_eval_string("sqrt(2.0)");

// Protect `var` until we add its reference to `refs`.
JL_GC_PUSH1(&var;);

// Wrap `var` in `RefValue{Any}` and push to `refs` to protect it.
jl_value_t&ast; rvar = jl_new_struct(reft, var);
JL_GC_POP();

jl_call3(setindex, refs, rvar, rvar);

可以允许GC通过删除对变量的引用来释放变量 裁判 使用函数 删除!,前提是没有其他对变量的引用保留在任何地方:

jl_function_t&ast; delete = jl_get_function(jl_base_module, "delete!");
jl_call2(delete, refs, rvar);

作为非常简单的情况的替代方案,可以只创建一个类型的全局容器 向量{Any} 并在必要时从中获取元素,甚至使用以下方法为每个指针创建一个全局变量

jl_module_t &ast;mod = jl_main_module;
jl_sym_t &ast;var = jl_symbol("var");
jl_binding_t &ast;bp = jl_get_binding_wr(mod, var, 1);
jl_checked_assignment(bp, mod, var, val);

更新GC管理对象的字段

垃圾回收器还在假设它知道指向年轻一代的每个老一代对象的情况下运行。 任何时候一个指针被更新打破了这个假设,它必须用 jl_gc_wb (写屏障)这样的功能:

jl_value_t &ast;parent = some_old_value, &ast;child = some_young_value;
((some_specific_type&ast;)parent)->field = child;
jl_gc_wb(parent, child);

通常无法预测哪些值在运行时将是旧的,因此必须在所有显式存储之后插入写入屏障。 一个值得注意的例外是,如果 家长/家长 对象刚刚被分配,此后没有垃圾回收运行。 请注意,大多数 jl_... 函数有时可以调用垃圾回收。

在直接更新指针数组的数据时,写入屏障也是必要的。 打电话来 jl_array_ptr_set 通常是非常优选的。 但可以直接更新。 例如:

jl_array_t &ast;some_array = ...; // e.g. a Vector{Any}
void &ast;&ast;data = jl_array_data(some_array, void&ast;);
jl_value_t &ast;some_value = ...;
data[0] = some_value;
jl_gc_wb(jl_array_owner(some_array), some_value);

控制垃圾收集器

有一些函数可以控制GC。 在正常用例中,这些不应该是必要的。

功能 资料描述

jl_gc_collect()

强制GC运行

jl_gc_enable(0)

禁用GC,返回以前的状态为int

jl_gc_enable(1)

启用GC,返回以前的状态为int

jl_gc_is_enabled()

返回当前状态为int

使用数组

Julia和C可以共享数组数据而无需复制。 下一个示例将显示这是如何工作的。

Julia数组在c中由数据类型表示 jl_array_t*. 基本上, jl_array_t 是一个包含:

*有关数据类型的信息 *指向数据块的指针 *有关数组大小的信息

为了简单起见,我们从一维数组开始。 创建一个包含长度为10的Float64元素的数组可以这样完成:

jl_value_t&ast; array_type = jl_apply_array_type((jl_value_t&ast;)jl_float64_type, 1);
jl_array_t&ast; x          = jl_alloc_array_1d(array_type, 10);

或者,如果您已经分配了数组,则可以在其数据周围生成一个瘦包装器:

double &ast;existingArray = (double&ast;)malloc(sizeof(double)&ast;10);
jl_array_t &ast;x = jl_ptr_to_array_1d(array_type, existingArray, 10, 0);

最后一个参数是一个布尔值,指示Julia是否应该拥有数据的所有权。 如果此参数非零,GC将调用 免费 当数组不再被引用时,在数据指针上。

为了访问的数据 x,我们可以使用 jl_array_数据:

double &ast;xData = jl_array_data(x, double);

现在我们可以填充数组了:

for (size_t i = 0; i < jl_array_nrows(x); i++)
    xData[i] = i;

现在让我们调用一个Julia函数,该函数在 x:

jl_function_t &ast;func = jl_get_function(jl_base_module, "reverse!");
jl_call1(func, (jl_value_t&ast;)x);

通过打印数组,可以验证 x 现在倒过来了。

访问返回的数组

如果Julia函数返回一个数组,则返回值为 jl_eval_stringjl_call 可以转换为 jl_array_t*:

jl_function_t &ast;func  = jl_get_function(jl_base_module, "reverse");
jl_array_t &ast;y = (jl_array_t&ast;)jl_call1(func, (jl_value_t&ast;)x);

现在的内容 y 可以像使用之前一样访问 jl_array_数据. 与往常一样,请务必在使用数组时保留对数组的引用。

多维数组

Julia的多维数组以列主顺序存储在内存中。 下面是一些创建2D数组并访问其属性的代码:

// Create 2D array of float64 type
jl_value_t &ast;array_type = jl_apply_array_type((jl_value_t&ast;)jl_float64_type, 2);
int dims[] = {10,5};
jl_array_t &ast;x  = jl_alloc_array_nd(array_type, dims, 2);

// Get array pointer
double &ast;p = jl_array_data(x, double);
// Get number of dimensions
int ndims = jl_array_ndims(x);
// Get the size of the i-th dim
size_t size0 = jl_array_dim(x,0);
size_t size1 = jl_array_dim(x,1);

// Fill array with data
for(size_t i=0; i<size1; i++)
    for(size_t j=0; j<size0; j++)
        p[j + size0&ast;i] = i + j;

请注意,虽然Julia数组使用基于1的索引,但C API使用基于0的索引(例如在调用 jl_array_dim),以便阅读为惯用的C代码。

例外情况

Julia代码可以抛出异常。 例如,考虑:

jl_eval_string("this_function_does_not_exist()");

这个电话似乎什么也不做。 但是,可以检查是否引发了异常:

if (jl_exception_occurred())
    printf("%s \n", jl_typeof_str(jl_exception_occurred()));

如果您使用的是来自支持异常的语言的Julia C API(例如Python,C#,C++),将每个调用包装成 [医]利朱利亚 具有检查是否引发异常的函数,然后在宿主语言中重新引发异常。

抛出Julia异常

在编写Julia可调用函数时,可能需要验证参数并抛出异常以指示错误。 典型的类型检查看起来像:

if (!jl_typeis(val, jl_float64_type)) {
    jl_type_error(function_name, (jl_value_t&ast;)jl_float64_type, val);
}

可以使用函数引发一般异常:

void jl_error(const char &ast;str);
void jl_errorf(const char &ast;fmt, ...);

jl_error 取一个C字符串, jl_errorf 被称为像 printf打印:

jl_errorf("argument x = %d is too large", x);

在这个例子中 x 被假定为整数。

螺纹安全

一般来说,Julia C API不是完全线程安全的。 在多线程应用程序中嵌入Julia时,需要注意不要违反以下限制:

* jl_init() 在应用程序生命周期中只能调用一次。 这同样适用于 jl_atexit_hook(),并且只能在之后调用 jl_init(). * jl_...() API函数只能从其中的线程调用 jl_init() 从Julia runtime_启动的线程中调用_or。 不支持从用户启动的线程调用Julia API函数,并且可能导致未定义的行为和崩溃。

上面的第二个条件意味着你不能安全地打电话 jl_...() 来自未由Julia启动的线程的函数(线程调用 jl_init() 是例外)。 例如,不支持以下内容,并且很可能会出现segfault:

void &ast;func(void&ast;)
{
    // Wrong, jl_eval_string() called from thread that was not started by Julia
    jl_eval_string("println(Threads.threadid())");
    return NULL;
}

int main()
{
    pthread_t t;

    jl_init();

    // Start a new thread
    pthread_create(&t;, NULL, func, NULL);
    pthread_join(t, NULL);

    jl_atexit_hook(0);
}

相反,从同一个用户创建的线程执行所有Julia调用将起作用:

void &ast;func(void&ast;)
{
    // Okay, all jl_...() calls from the same thread,
    // even though it is not the main application thread
    jl_init();
    jl_eval_string("println(Threads.threadid())");
    jl_atexit_hook(0);
    return NULL;
}

int主()
{
    pthread_t t;
    //创建一个运行func()的新线程
    pthread_create(&t;,NULL,func,NULL);
    pthread_join(t,NULL);
}

从Julia自己启动的线程调用Julia C API的示例:

#include <julia/julia.h>
JULIA_DEFINE_FAST_TLS

double c_func(int i)
{
    printf("[C %08x] i = %d\n", pthread_self(), i);

    // Call the Julia sqrt() function to compute the square root of i, and return it
    jl_function_t &ast;sqrt = jl_get_function(jl_base_module, "sqrt");
    jl_value_t&ast; arg = jl_box_int32(i);
    double ret = jl_unbox_float64(jl_call1(sqrt, arg));

    return ret;
}

int main()
{
    jl_init();

    // Define a Julia function func() that calls our c_func() defined in C above
    jl_eval_string("func(i) = ccall(:c_func, Float64, (Int32,), i)");

    // Call func() multiple times, using multiple threads to do so
    jl_eval_string("println(Threads.threadpoolsize())");
    jl_eval_string("use(i) = println(\"[J $(Threads.threadid())] i = $(i) -> $(func(i))\")");
    jl_eval_string("Threads.@threads for i in 1:5 use(i) end");

    jl_atexit_hook(0);
}

如果我们使用2个Julia线程运行此代码,我们会得到以下输出(注意:每个运行和系统的输出会有所不同):

$ JULIA_NUM_THREADS=2 ./thread_example
2
[C 3bfd9c00] i = 1
[C 23938640] i = 4
[J 1] i = 1 -> 1.0
[C 3bfd9c00] i = 2
[J 1] i = 2 -> 1.4142135623730951
[C 3bfd9c00] i = 3
[J 2] i = 4 -> 2.0
[C 23938640] i = 5
[J 1] i = 3 -> 1.7320508075688772
[J 2] i = 5 -> 2.23606797749979

可以看出,Julia线程1对应于pthread ID3bfd9c00,Julia线程2对应于ID23938640,表明确实在C级别使用了多个线程,并且我们可以安全地从这些线程中调用Julia C API例程。