Engee 文档

Julia代码实现

章中已经提到过 调用C和Fortran代码,在Julia中有一种简单而有效的方法来调用用C编写的函数.但是有些情况下需要相反的东西:从C代码中调用Julia函数。 此功能用于将Julia代码集成到更大的C或C++项目中。++ 而不必重写C或C中的所有内容++. 为了实现它,Julia有一个用于C的API.由于几乎所有编程语言都可以调用C中的函数,因此Julia API for C也可用于与其他语言形成交互(例如,从Python,Rust或C#调用Julia)。 虽然在Rust和C++ 您可以直接使用C代码注入API,并且有两种语言的软件包可以简化此任务。 对于C++ 包将是有用的。 https://github.com/Clemapfel/jluna [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 *argv[])
{
    /* required: setup the Julia context */
    jl_init();

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

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

要创建此程序,您需要将Julia头路径添加到包含文件的路径中,并与`libjulia`链接。 例如,如果Julia代码设置为'$JULIA_DIR`,则可以编译上述测试程序’test。c’与’gcc’使用以下行:

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

或者,看看嵌入。test/embed/文件夹下Julia源代码树中的c程序。 程序文件’cli/loader_exe。c’是在与`libjulia`链接时设置参数`jl_options’的另一个简单示例。

在调用Julia中的任何其他C函数之前要做的第一件事是初始化Julia。 为此,请调用’jl_init’方法,该方法试图自动确定Julia安装的位置。 如果需要指定自定义位置或指定要下载的系统映像,请使用’jl_init_with_image'。

测试程序中的第二个运算符通过调用`jl_eval_string’来计算Julia运算符。

强烈建议在终止程序之前调用’jl_atexit_hook'。 在上面的命令示例中,调用在从`main’返回之前立即执行。

目前,与共享库’libjulia’的动态链接需要传递参数’RTLD_GLOBAL'。 在Python中,它看起来像这样。

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

如果Julia程序需要访问主可执行文件中的符号,除了使用脚本"julia-config"创建的符号外,还可能需要在Linux上编译期间添加链接器标志"-Wl,--export-dynamic"。jl’如下所述。 这是编译共享库时的可选操作。

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

脚本’julia-config.jl’允许您使用嵌入式Julia代码确定程序需要哪些构建参数。 它应用特定Julia发行版的构建参数和系统配置,调用它为嵌入式程序导出必要的编译器标志,以便与该发行版交互。 脚本位于Julia共享数据目录中。

例子:

#include <julia.h>

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

从命令行使用它

最简单的方法是从命令行使用此脚本。 如果我们假设脚本’julia-config。jl’位于文件夹`/usr/local/julia/share/julia`中,它可以直接从命令行调用,它接受三个标志的任意组合:

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

如果上面示例中的源代码保存在文件’embed_example中。c`,下面的命令将其编译成linux和Windows(MSYS2环境)上的可执行程序。 在macOS中,将`clang’替换为’gcc'。

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

Makefile中的用法

一般来说,与上面的例子相比,项目的实现将是一个更复杂的过程,所以当使用GNU make实用程序与宏*shell*扩展相关时,也支持一个通用的makefile。 此外,虽然’julia-config.jl’文件通常位于'/usr/local`目录中,如果它不在那里,您可以使用Julia环境本身搜索`julia-config。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

现在build命令只是’make'。

使用Visual Studio在Windows中的高级实现

如果缺少环境变量`JULIA_DIR`,请在启动Visual Studio之前使用系统面板添加它。 JULIA_DIR中的`bin’文件夹应该位于PATH系统变量中。

让我们首先打开Visual Studio并创建一个控制台应用程序项目。 打开stdafx头文件。h并在其末尾添加以下行:

#include <julia.h>

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

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

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

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

下一步是设置一个项目来搜索包含的Julia文件和库。 了解Julia安装版本很重要:32位或64位。 在继续之前,请删除与Julia安装不匹配的所有平台配置。

使用"项目属性"对话框,转到"传递:c[C/C++]"部分

'General’并在"包含文件的附加目录"属性中添加`JUL(JULIA_DIR)\include\julia\`。 然后转到"链接器"部分

'General’并将`JUL(JULIA_DIR)\lib`添加到"包含文件的附加目录"属性中。 最后,在"链接器"部分

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

在这个阶段,项目必须组装和启动。

类型转换

真正的应用程序不仅要执行表达式,还要将它们的值返回给主程序。 'jl_eval_string’返回值’jl_value_t’,它是指向堆上分配的Julia对象的指针。 存储简单的数据类型,如 Float64称为’装箱`,提取存储的基元数据称为’拆箱'。 在Julia中计算2的平方根并在C中返回结果的程序的改进示例在其主体中包含以下代码:

jl_value_t *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");
}

要检查’ret’是否属于特定的Julia类型,可以使用函数`jl_isa`,jl_typeis`或+jl_is_。..+`. 在Julia shell中输入’typeof(sqrt(2.0))`时,可以看到返回类型为 'Float64'(c中的`double')。 上面的代码片段使用函数’jl_unbox_float64’将打包的Julia值转换为c double类型。

对应的函数'jl_box_。..'用于反向转换。:

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

我们将在后面看到,要使用某些参数调用Julia函数,需要进行打包转换。

调用Julia函数

尽管`jl_eval_string’脚本允许您在C中获取Julia表达式的结果,但它禁止将在C中计算的参数传递给Julia。 为此,您需要使用`jl_call`直接调用Julia函数:

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

在第一步中,通过调用`jl_get_function`来提取Julia函数`sqrt’的描述符。 传递给’jl_get_function’函数的第一个参数是指向定义’sqrt’函数的’Base’模块的指针。 然后使用`jl_box_float64’打包双精度十进制值。 在最后一步中,使用`jl_call1’调用函数。 还有函数`jl_call0`,`jl_call2`和’jl_call3',允许有效地处理任意数量的参数。 要传递大量参数,请使用’jl_call’函数。:

jl_value_t *jl_call(jl_function_t *f, jl_value_t **args, int32_t nargs)

它的第二个参数’args’是一个由`jl_value_t*`参数组成的数组,`nargs’表示参数的数量。

还有一种替代的,可能更简单的方法来调用Julia函数-通过 '@cfunction'。 使用'@cfunction’允许在Julia端进行类型转换,这通常比在C端更容易。 上面带有’sqrt’函数的示例可以使用'@cfunction’重写,如下所示:

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

在这里,我们首先在Julia中定义被调用的C函数,然后从中提取函数指针,最后调用它。 除了通过在更高级语言中执行来简化类型转换之外,通过`@cfunction`指针调用Julia函数消除了`jl_call`指针所需的动态调度成本(所有参数都是"打包"的),并且应该具有

内存管理

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

Julia对象通常由垃圾回收器释放,但它最初不知道存储的对来自C的Julia值的引用。这意味着垃圾回收器可以释放对象,使指针无效。

只有当新的Julia对象放入内存时,垃圾回收器才会工作。 使用像`jl_box_float64’这样的调用来执行分配,但它也可以在Julia代码执行期间的任何时间发生。

使用嵌入式Julia函数编写代码时,在调用`jl_之间使用`jl_value_t*`的值通常是安全的。..'(因为垃圾回收器仅由这些调用激活)。 但是,这样可以保证值在调用`jl_之后持续存在。..',有必要通知Julia,有一个对Julia值的引用需要https://www.cs.purdue.edu/homes/hosking/690M/p611-fenichel.pdf [超级用户权限]。 此过程称为向垃圾收集器授予超级用户权限。 获取值的超级用户权限可确保垃圾回收器不会意外地将其标识为未使用,并释放存储该值的内存。 这可以使用宏`JL_GC_PUSH’来完成:

jl_value_t *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堆栈中,因此在退出作用域之前必须与宏`JL_GC_POP`精确配对。 也就是说,在函数返回值或控制流离开调用’JL_GC_PUSH’宏的块之前。

使用宏’JL_GC_PUSH2'’JL_GC_PUSH6`,您可以同时发送多个Julia值:

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

要发送Julia值的数组,可以使用宏`JL_GC_PUSHARGS`,如下所示。

jl_value_t **args;
JL_GC_PUSHARGS(args, 2); // args can now hold 2 `jl_value_t*` 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*'同时发送所有必要的变量,或者需要发送六个以上的变量,并且使用参数数组不是合适的解决方案,则可以使用内部块。:

jl_value_t *ret1 = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret1;);
jl_value_t *ret2 = 0;
{
    jl_function_t *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_GC_PUSH*`之前,具有`jl_value_t*'的有效值是可选的。 用值`NULL’初始化一些变量,将它们传递给’JL_GC_PUSH*',然后创建有效的Julia值是可以接受的。 例如:

jl_value_t *ret1 = NULL, *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',它将存储引用,禁止垃圾收集器释放它们。 但是,此方法仅适用于可变数据。

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

...

// `var` is the variable we want to protect between function calls.
jl_value_t* 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);

如果变量是不可变的,它必须包含在等效的可变容器中,或者最好是包含在`RefValue中{Any}'发送到’IdDict’之前。 使用这种方法,必须使用C代码创建或填充容器,例如使用’jl_new_struct’函数。 如果容器是使用`jl_call*`创建的,则必须重新加载将在C代码中使用的指针。

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

...

// `var` is the variable we want to protect between function calls.
jl_value_t*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*rvar=jl_new_struct(reft,var);
JL_GC_POP();

jl_call3(setindex,refs,rvar,rvar);

要允许垃圾回收器释放一个变量,请使用"删除"从"refs"中删除对它的引用!'功能。'前提是没有其他对变量的引用存储在任何其他位置:

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

或者,对于非常简单的情况,您可以简单地创建`Vector'类型的全局容器。{Any}'并在必要时从中提取元素。 或者您甚至可以使用下面的示例为每个指针创建一个全局变量。

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

更新由垃圾回收器管理的对象的字段

当垃圾回收器运行时,还假定它知道指向新生代的对象的老一代的每个对象。 每次指针被更新时,假设被消除,这应该使用`jl_gc_wb`(写屏障)函数向收集器指示如下。

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

一般来说,无法预测哪些值在运行时将被认为是旧的,因此必须在所有显式指定的存储之后插入写入屏障。 一个显着的例外是当`parent’对象刚刚被分配并且此后没有执行垃圾回收时。 请记住,大多数函数都是'jl_。..'有时可以触发垃圾收集过程。

在直接更新指针数组的数据时,写入障碍也是必要的。 调用’jl_array_ptr_set’通常更可取。 但也可以执行直接更新。 例如:

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

垃圾收集器管理

管理垃圾收集器有几个功能。 它们在正常使用中不需要。

功能 资料描述

jl_gc_collect()

强制启动垃圾收集器

'jl_gc_enable(0)`

禁用垃圾回收器,将以前的状态返回为初始状态

'jl_gc_enable(1)`

启用垃圾回收器,将以前的状态返回为初始状态

jl_gc_is_enabled()

将当前状态返回为初始状态

使用数组

Julia和C可以共享数组数据而无需复制。 下面的例子将展示它是如何工作的。

Julia数组用c表示,数据类型为’jl_array_t’。 实际上,'jl_array_t’是一个包含:

  • 有关数据类型的信息;

  • 指向数据块的指针;

  • 阵的大小的信息。

为了简单起见,让我们从一维数组开始。 您可以创建一个包含长度为10的Float64元素的数组,如下所示。

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

或者,如果您已经分配了一个数组,则可以围绕其数据创建一个薄壳。:

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

最后一个参数是合乎逻辑的,并指示数据是否应该由Julia接管。 如果此参数非零,则当不再引用数组时,垃圾回收器将为数据指针调用`free'。

要访问’x’数据,您可以使用’jl_array_data`:

double *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 *func = jl_get_function(jl_base_module, "reverse!");
jl_call1(func, (jl_value_t*)x);

在输出数组时,您可以确保`x`的元素现在以相反的顺序排列。

访问返回的数组

如果Julia函数返回数组,则`jl_eval_string`和`jl_call’的返回值可以转换为’jl_array_t*`:

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

现在可以像以前一样使用`jl_array_data`访问`y’的内容。 不要忘记在使用数组时保留对数组的引用。

多维数组

Julia多维数组按列存储在内存中。 下面是创建二维数组并访问其属性的代码。

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

// Get array pointer
double *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*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()));

如果您从支持异常的语言(例如,Python,C#,C)使用Julia API for C++),将每个对`libjulia`的调用包含在一个函数中是有意义的,该函数检查是否引发了异常,然后在主语言中重新创建异常。

在Julia中调用异常

在编写调用Julia函数时,您可能需要检查参数并引发异常以指示错误。 标准类型检查如下。

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

使用函数可能会导致常见异常:

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

'jl_error’接受字符串C,jl_error’被称为’printf:

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

在这个例子中,'x’被认为是一个整数。

螺纹安全

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

  • 'jl_init()在应用程序的生存期内只能调用一次。 这同样适用于函数`jl_atexit_hook(),它只能在`jl_init()'之后调用。

  • API函数'jl_。..()`只能从调用`jl_init()'函数的线程中调用,_或从Jl_运行时环境启动的线程中调用。 不支持从用户启动的线程调用Julia API函数,并可能导致未定义的行为和崩溃。

从第二个条件可以看出,不可能安全地调用'jl_函数。..()`来自不在Julia环境中运行的线程(例外是调用`jl_init()`的线程)。 例如,以下调用选项不受支持,很可能会崩溃:

void *func(void*)
{
    // 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 *func(void*)
{
    // 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 main()
{
    pthread_t t;
    // Create a new thread, which runs func()
    pthread_create(&t;, NULL, func, NULL);
    pthread_join(t, NULL);
}

来自Julia环境本身启动的线程的Julia 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 *sqrt = jl_get_function(jl_base_module, "sqrt");
    jl_value_t* 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);
}

如果您使用两个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

如您所见,流Julia1对应于标识符pthread3bfd9c00,而流Julia2对应于标识符23938640。 这表明实际上在C级别使用了几个线程,并且可以安全地从中调用Julia API例程。