简介
Redis在5.0版本开始支持以module插件的方式来扩展redis的能力,包括但不限于开发新的数据结构、实现命令监听和过滤、扩展新的网络服务等。可以说,module的出现极大的扩展了redis的灵活性,也大大的降低了redis的开发难度。
目前为止,redis社区已经涌现了很多module,覆盖了不同领域,生态已经丰富起来了。它们之中大多都是使用c语言开发。但是,redis module也支持使用其他语言开发,如c++和 rust等。本文将试着总结Tair用c++开发redis module中遇到的一些问题并沉淀为最佳实践,希望对redis module的使用者和开发者带来一些帮助(部分最佳实践也适用于c和其他语言)。
原理
Redis内核使用c语言开发,因此在c环境下开发类似插件的东西很容易想到动态链接库。redis的确是这么做的,但是有几个地方需要注意:
1.Redis内核会暴露出/导出很多API给module使用(如内存分配接口、redis核心db结构的操作接口),注意这些API是redis自己解析绑定的,而不是靠动态连接器解析的。
2.Redis内核使用dlopen显示的装载module,而不是直接交由动态链接器隐式装载。即module需要实现特定的接口,redis会自动调用module的入口函数,完成一些API初始化、数据结构注册等功能。
加载
Redis内核中关于module加载的逻辑部分代码如下(代码位于module.c中):
int moduleLoad(const char *path, void **module_argv, int module_argc, int is_loadex) { int (*onload)(void *, void **, int); void *handle; struct stat st; if (stat(path, &st) == 0) { /* This check is best effort */ if (!(st.st_mode & (S_IXUSR | S_IXGRP | S_IXOTH))) { serverLog(LL_WARNING, "Module %s failed to load: It does not have execute permissions.", path); return C_ERR; } } // 打开module so handle = dlopen(path,RTLD_NOW|RTLD_LOCAL); if (handle == NULL) { serverLog(LL_WARNING, "Module %s failed to load: %s", path, dlerror()); return C_ERR; } // 获取module中的onload函数符号地址 onload = (int (*)(void *, void **, int))(unsigned long) dlsym(handle,"RedisModule_OnLoad"); if (onload == NULL) { dlclose(handle); serverLog(LL_WARNING, "Module %s does not export RedisModule_OnLoad() " "symbol. Module not loaded.",path); return C_ERR; } RedisModuleCtx ctx; moduleCreateContext(&ctx, NULL, REDISMODULE_CTX_TEMP_CLIENT); /* We pass NULL since we don't have a module yet. */ // 调用onload对module进行初始化 if (onload((void*)&ctx,module_argv,module_argc) == REDISMODULE_ERR) { serverLog(LL_WARNING, "Module %s initialization failed. Module not loaded",path); if (ctx.module) { moduleUnregisterCommands(ctx.module); moduleUnregisterSharedAPI(ctx.module); moduleUnregisterUsedAPI(ctx.module); moduleRemoveConfigs(ctx.module); moduleFreeModuleStructure(ctx.module); } moduleFreeContext(&ctx); dlclose(handle); return C_ERR; } /* Redis module loaded! Register it. */ //... 无关代码省略 ... moduleFreeContext(&ctx); return C_OK; }
API 绑定
在module的初始化函数中,需要显示的调用RedisModule_Init初始化redis内核导出的api。比如:
int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { if (RedisModule_Init(ctx, "helloworld", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) return REDISMODULE_ERR; // ... 无关代码省略 ... }
RedisModule_Init是一个定义在redismodule.h中的函数,其内部会对redis内核暴露的各个api进行一一的导出、绑定。
static int RedisModule_Init(RedisModuleCtx *ctx, const char *name, int ver, int apiver) { void *getapifuncptr = ((void**)ctx)[0]; RedisModule_GetApi = (int (*)(const char *, void *)) (unsigned long)getapifuncptr; // 绑定redis导出的api REDISMODULE_GET_API(Alloc); REDISMODULE_GET_API(TryAlloc); REDISMODULE_GET_API(Calloc); REDISMODULE_GET_API(Free); REDISMODULE_GET_API(Realloc); REDISMODULE_GET_API(Strdup); REDISMODULE_GET_API(CreateCommand); REDISMODULE_GET_API(GetCommand); // ... 无关代码省略 ... }
先看REDISMODULE_GET_API在干什么事情,它就是一个宏,本质是在调用RedisModule_GetApi函数:
#define REDISMODULE_GET_API(name) \ RedisModule_GetApi("RedisModule_" #name, ((void **)&RedisModule_ ## name))
RedisModule_GetApi看上去是一个redis内部暴露的api,但是我们现在就是在做API绑定的事情,在绑定之前是如何拿到RedisModule_GetApi函数地址的呢?答案就是redis内核在调用module的OnLoad函数时,通过RedisModuleCtx传递了RedisModule_GetApi函数地址。可以看上文加载module部分代码,在调用Onload函数之前,redis使用moduleCreateContext初始化了一个RedisModuleCtx并传递给module。
在moduleCreateContext中,会将redis内部定义的RM_GetApi函数地址赋值给RedisModuleCtx的getapifuncptr成员。
void moduleCreateContext(RedisModuleCtx *out_ctx, RedisModule *module, int ctx_flags) { memset(out_ctx, 0 ,sizeof(RedisModuleCtx)); // 这里把GetApi地址传递给module out_ctx->getapifuncptr = (void*)(unsigned long)&RM_GetApi; out_ctx->module = module; out_ctx->flags = ctx_flags; // ... 无关代码省略 ... }
因此,在module中就可以通过RedisModuleCtx来获取GetApi函数了。那么这里为什么不能直接使用ctx->getapifuncptr获取而要使用((void**)ctx)[0]这种“奇怪”的方式呢?原因是,RedisModuleCtx本身是一个定义在redis内核中的数据结构,其内部结构对module而言是不可见的(opaque pointer)。因此,这里只能使用一种hack的方式,巧用getapifuncptr是RedisModuleCtx第一个成员这个特点,直接取第一个指针即可。
void *getapifuncptr = ((void**)ctx)[0]; RedisModule_GetApi = (int (*)(const char *, void *)) (unsigned long)getapifuncptr;
下面的结构展示了getapifuncptr是RedisModuleCtx第一个成员的事实。
struct RedisModuleCtx { // getapifuncptr是第一个成员 void *getapifuncptr; /* NOTE: Must be the first field. */ struct RedisModule *module; /* Module reference. */ client *client; /* Client calling a command. */ // ... 无关代码省略 ... };
搞清楚了RM_GetApi是怎么被导出的原理后,我们来接着看下RM_GetApi内部在做什么:
int RM_GetApi(const char *funcname, void **targetPtrPtr) { /* Lookup the requested module API and store the function pointer into the * target pointer. The function returns REDISMODULE_ERR if there is no such * named API, otherwise REDISMODULE_OK. * * This function is not meant to be used by modules developer, it is only * used implicitly by including redismodule.h. */ dictEntry *he = dictFind(server.moduleapi, funcname); if (!he) return REDISMODULE_ERR; *targetPtrPtr = dictGetVal(he); return REDISMODULE_OK; }
RM_GetApi的内部实现非常简单,就是根据要绑定的函数名,在一个全局哈希表(server.moduleapi)中查找对应的函数地址,找到了就把地址赋值给targetPtrPtr。那么dict中的内容哪里来的?
Redis内核在启动的时候,会通过moduleRegisterCoreAPI函数注册自身暴露的module api。如下:
/* Register all the APIs we export. Keep this function at the end of the * file so that's easy to seek it to add new entries. */ void moduleRegisterCoreAPI(void) { server.moduleapi = dictCreate(&moduleAPIDictType); server.sharedapi = dictCreate(&moduleAPIDictType); // 向全局哈希表中注册函数 REGISTER_API(Alloc); REGISTER_API(TryAlloc); REGISTER_API(Calloc); REGISTER_API(Realloc); REGISTER_API(Free); REGISTER_API(Strdup); REGISTER_API(CreateCommand); // ... 无关代码省略 ... }
其中REGISTER_API本质也是一个宏定义,内部通过moduleRegisterApi函数实现,而moduleRegisterApi函数内部就会把导出的函数名和函数指针添加到server.moduleapi中。
int moduleRegisterApi(const char *funcname, void *funcptr) { return dictAdd(server.moduleapi, (char*)funcname, funcptr); } #define REGISTER_API(name) \ moduleRegisterApi("RedisModule_" #name, (void *)(unsigned long)RM_ ## name)
那么问题来了,为什么redis要费这么大劲自己实现一套api导出绑定机制呢?理论上,直接利用动态连接器的符号解析和重定位机制,module这些动态库中的代码依然可以调用到redis暴露的可见符号的。这样虽然可行,但是会存在符号冲突的问题,比如其他的module也暴露了一个和redis api一样的函数名,那么这个时候就依赖于全局的符号解析机制和顺序了(全局符号介入)。还有一个原因,redis可以通过这个bind机制更好的控制api的不同版本。
一些最佳实践
入口函数禁用c++ mangle
由前面的module加载机制可以看出,module内部的必须严格保证入口函数名和redis要求的一致。因此,当我们使用c++编写module代码时,首先必须禁用c++ mangle,否则将报“Module does not export RedisModule_OnLoad()”错误。
实例代码如下:
#include "redismodule.h" extern "C" __attribute__((visibility("default"))) int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) { // Init code and command register return REDISMODULE_OK; }
接管内存统计
Redis在运行时需要精确的统计数据结构使用的内存(内部使用原子变量used_memory加加减减),这就要求module内部必须和redis核心内部使用相同的内存分配接口,否则就可能会导致module内的内存分配无法被统计到的问题。
REDISMODULE_API void * (*RedisModule_Alloc)(size_t bytes) REDISMODULE_ATTR; REDISMODULE_API void * (*RedisModule_Realloc)(void *ptr, size_t bytes) REDISMODULE_ATTR; REDISMODULE_API void (*RedisModule_Free)(void *ptr) REDISMODULE_ATTR; REDISMODULE_API void * (*RedisModule_Calloc)(size_t nmemb, size_t size) REDISMODULE_ATTR;
对于一些简单的module而言,显示的调用这些api没有什么问题。但是对于一些稍微复杂的,特别是会依赖一些第三方库的module而言,要想把库里面所有的内存分配全部替换为module接口,就比较困难了。更甚者,如果我们使用c++来开发redis module,那么如何让c++中随处可见的new/delete/make_shared/各种容器分配器也被统一内存分配接管,就显得更为重要了。
new/operator new/placement new
首先阐述一下他们的区别:new是一个关键字,和sizeof一样,我们无法修改其具体功能。new主要做三件事:
1.分配空间(使用operator new)
2.初始化对象(使用placement new或者类型强转),即调用对象的构造函数
3.返回对象指针
operator new是一个操作符,和 +/- 操作符一样,作用是分配空间。我们可以重写它们,修改分配空间的方式。
placement new是operator new的一种重载形式(即参数形式不同)。比如:
void * operator new(size_t, void *location) { return location; }
可见,要想实现修改new默认使用的内存分配,我们可以使用两种方式。
placement new
无非就是手动模拟关键字new的行为,先使用module api分配好一块内存,然后在这个内存上调用对象的构造函数。
Object *p=(Object*)RedisModule_Alloc(sizeof(Object)); new (p)Object();
同时注意析构时也需要特殊处理:
p->~Object(); RedisModule_Free(p);
因为placement new不具有全局行为,需要手动处理每个对象的分配,因此对于复杂的c++ module而言依然不能彻底解决内存分配的问题。
operator new
c++内置了operator new的实现,默认使用glibc malloc分配内存。c++给我们提供了重载机制,即我们可以实现自己的operator new,将内部的malloc替换为RedisModule_Alloc即可。
其实说operator new是重载(同层级函数名相同参数不同)或重写(派生层级函数名和参数必须相同,返回值除了类型协变之外也必须相同)都不太合适,我感觉这里使用覆盖更贴切。因为c++编译器内置的operator new被实现为一个弱(weak)符号,以gcc为例:
_GLIBCXX_WEAK_DEFINITION void * operator new (std::size_t sz) _GLIBCXX_THROW (std::bad_alloc) { void *p; /* malloc (0) is unpredictable; avoid it. */ if (sz == 0) sz = 1; while (__builtin_expect ((p = malloc (sz)) == 0, false)) { new_handler handler = std::get_new_handler (); if (! handler) _GLIBCXX_THROW_OR_ABORT(bad_alloc()); handler (); } return p; }
这样当我们自己实现了一个强符号版本时,就会覆盖编译器自己的实现。
以最基本的operator new/operator delete为例:
void *operator new(std::size_t size) { return RedisModule_Alloc(size); } void operator delete(void *ptr) noexcept { RedisModule_Free(ptr); }
因为operator new具有全局行为,因此这样可以“一劳永逸”的解决所有使用new/delete(make_shared内部也是使用new)分配内存的问题。
operator new在多个module之间的可见性
因为operator new具有全局可见性(编译器也不允许将operator new放入一个namespace下隐藏),因此如果redis加载不止一个c++编写的module,那么就需要小心这种行为的影响。
现在假设有两个module分别为module1何module2,其中module1自己重载了operator new, 由于operator new本质就是一个特殊的函数,当module1被redis加载时(使用dlopen),动态连接器会把module1实现的operator new函数加入到全局符号表里,因此后续在加载module2并进行符号重定位时,module2也会将自己的operator new链接到module1实现的operator new上。
如果module1和module2都是我们自己开发的,这一般不会有什么问题。但是如果module1和module2分数不同的开发者,更甚者它们都提供了不同的operator new实现,那么只有先加载的module的实现会生效(全局符合介入),后加载的module的行为将可能出现异常。
静态链接/动态链接c++标准库
静态链接
有时候,我们的module可能使用较高的c++版本编写和编译,为了防止module在分发时目标平台上没有对应的c++环境支持,我们通常会将c++标准库以静态链接的方式编译进module中。以linux平台为例,我们想将libstdc++和ibgcc_s静态链接到mdoule中。通常,如果redis只加载一个c++ module这一搬不会有什么问题。但是如果同时有两个c++ moudle并同时采用了静态链接c++标准库的方式,那么这可能会导致module异常。具体表现为后加载的moudle内部无法正常的使用c++ stream,进而表现为无法正常的打印信息、使用正则表达式等(怀疑和c++标准库自己定义的一些全局变量被重复初始化导致)。
该问题已经在gcc上存在多年:https://gcc.gnu.org/bugzilla//show_bug.cgi?id=68479
动态链接
因此,在这种场景下(redis会加载一个以上的c++库),还是建议module都使用动态链接的方式。如果还是担心分发时c++版本的兼容问题,那么可以将libstdc++.so和ibgcc_s.so等一起打包,然后使用$ORIGIN修改rpath指定链接自己的版本即可。
使用block机制提高并发处理能力
redis是单线程模型(指worker单线程), 这意味着redis在执行一个命令的时候,不会处理并响应另一个命令。而对于一些比较耗时的module命令,我们还是希望这个命令可以后台运行,这样redis可以继续读取并处理下一个客户端的命令。
如图1所示,cmd1在进入redis中执行,在主线程把cmd1放入队列之后就直接返回了(不会等待cmd1执行结束),此时主线程可以继续处理下一个命令cmd2。当cmd1被执行完毕之后,会重新向主线程中注册一个事件,从而可以在主线程中继续cmd1的后续处理,比如向客户端发送执行结果、写AOF以及向replica复制等操作。
block虽然看上去很美好很强大,但是需要小心处理一些坑,如:
- 命令虽然异步执行了,但是写AOF和向备库复制依然同步做。如果提前写AOF并向备库复制,万一后面命令执行失败了就无法回滚;
- 因为备库是不允许执行block命令的,因此主库需要将block类型的命令rewrite成非block类型的命令复制给备库;
- 异步执行时,在open一个key时不能只看keyname,因为可能在异步线程执行之前,原来的key已经被删除了,然后又有一个同名的key被创建,即当前看到的key已经不是原来的key了;
- 设计好block类型的命令是否支持事务和lua;
- 如果采用线程池,需要注意相同key在线程池中的保序执行问题(即相同key的处理不能乱序);
避免和其他Module符号冲突
因为redis可以同时加载多个module,这些module可能来自不同的团队和个人,因此存在一定的概率,不同的module会定义相同的函数名。为了避免符号冲突导致的未定义行为,建议每个module都把除了Onload和Unload函数之外的符号都隐藏掉,可以在给编译器传递一些flag实现。如gcc:
-fvisibility=hidden
小心Fork陷阱
处理inflight状态的命令
如果module采用异步执行模型(参看前文block一节),那么当redis做aofrewrite或bgsave时,在redis fork子进程的瞬间,如果还有一些命令处于inflight状态,那么此时新产生的base aof或者rdb可能并不会包含这些inflight时的数据,虽然这个看上去也没有太大问题,因为inflight的命令最终完成时也会把命令写入增量的aof中。但是,为了和redis原来的行为兼容(即fork时一定没有处于inflight状态的命令,是一个静止的状态),module最好还是保证所有的inflight状态的命令都执行完了再执行fork。
在module中可以通过redis暴露的RedisModuleEvent_ForkChild事件,在fork执行之前执行一个我们传入的回调函数。
RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_ForkChild, waitAllInflightTaskFinish);
比如在waitAllInflightTaskFinish中等待队列为空(即所有task都执行结束):
static void waitAllInflightTaskFinish() { while (!thread_pool->idle()) ; }
或者,直接使用glibc暴露的pthread_atfork也能实现同样的效果。
int pthread_atfork(void (*prepare)(void), void (*parent)void(), void (*child)(void));
避免死锁
我们知道通过fork创建的一个子进程几乎但不完全与父进程相同。子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝,包括文本、数据和bss段、堆以及用户栈等。子进程还获得与父进程任何打开文件描述符相同的拷贝,这就意味着子进程可以读写父进程中任何打开的文件,父进程和子进程之间最大的区别在于它们有着不同的PID。
但是有一点需要注意的是,在Linux中,fork的时候只复制当前线程到子进程,在fork(2)-Linux Man Page中有着这样一段相关的描述:
The child process is created with a single thread--the one that called fork(). The entire virtual address space of the parent is replicated in the child, including the states of mutexes, condition variables, and other pthreads objects; the use of pthread_atfork(3) may be helpful for dealing with problems that this can cause.
也就是说除了调用fork的线程外,其他线程在子进程中“蒸发”了。
因此,如果在一些异步线程中持有了一些资源的锁,那么在子进程中,因为这些线程消失了,那么子进程可能会发生死锁的问题。
解决方法和解决inflight一样,保证在fork之前所有的锁都释放掉即可。(其实只要所有inflight状态的命令都执行完了,一般锁也就都释放了)
确保向备库复制的AOF保持语义幂等
Redis的主备复制首要目标就是保证主备的一致性。因此备库要做的就是无条件接收来自主库的复制内容,并严格保持一致。但是对于一些比较特殊的命令而言,需要小心处理。
以Tair暴露的Tair String为例,支持给数据设置版本号,比如用户写入:
EXSET key value VER 10
那么主库在执行这条命令之后,最好在向备库复制时将命令改写为:
EXSET key value ABS 11
即使用绝对值版本号强行让备库和主库一致。类似的案例还有很多,比如和时间相关、和浮点计算相关等场景。
支持graceful shutdown
Module内部可能会启动一些异步线程或者管理一些异步资源,这些资源需要在redis shutdown时被处理(如停止、析构、写磁盘等),否则redis在退出时可能发生coredump。
在redis中,可以注册RedisModuleEvent_Shutdown事件实现,当redis关机时会回调我们传入的ShutdownCallback。
当然,在较新的redis版本中,module也可以通过暴露unload函数来实现类似的功能。
RedisModule_SubscribeToServerEvent(ctx, RedisModuleEvent_Shutdown, ShutdownCallback);
避免过大的AOF
- 实现aof文件压缩功能,如将一个hash的所有写操作重写为一条hmset命令(也可能是多条);
- 避免重写后的一条aof过大(如超过500MB),如果超过,则需要rewrite成多条cmd,同时需要确保这些多条cmd是否需要以事务的方式执行(即需要操作命令执行的隔离性);
- 对于一些复杂结构,无法简单重写为已有命令的module,可以单独实现一个“内部”命令,如xxxload/xxxdump等,用于实现对该module数据结构的序列化和反序列化,该命令不会对外暴露给客户端;
- RedisModule_EmitAOF中如果包含array类型的参数(即使用'v' flag传递的参数),则array的长度一定要使用size_t类型,否则可能会遇到诡异的错误;
RDB编码具有向后兼容能力
RDB是二进制格式的序列化和反序列化,因此相对而言比较简单。但是需要注意的是,如果数据结构以后的序列化方式可能会改变,则最好加上编解码的版本,这样在升级的时候可以保证兼容性,如下:
void *xxx_RdbLoad(RedisModuleIO *rdb, int encver) { if (encver == version1 ) { /* version1 format */ } else if (encver == version2 ){ /* version2 format */ } }
一些命令实现的建议
- 参数检验:尽量在命令开始处对参数合法性(如参数个数是否正确、参数类型是否正确等)进行校验,尽量避免命令没有成功执行的情况下提前污染了keyspace(如提前使用了RedisModule_ModuleTypeSetValue修改主数据库)
- 错误信息:返回的错误信息应尽可能简单明了,阐明错误类型是什么
- 响应类型保持统一:注意命令在各种情况下的返回类型要统一,如key不存在、key类型错误、执行成功以及一些参数错误时的响应类型。通常情况下,除了返回错误类型之外,其他的所有情况都应该返回相同类型,如都返回一个简单字符串、或者都返回一个数组(哪怕是一个空数组)。这样客户端在解析命令返回值时比较方便
- 确认读写类型:命令应严格区分读写类型,这涉及到该命令能否在replica上执行、以及该命令是否需要进行同步、写aof等
- 复制幂等性和AOF:对于写命令,需要自行使用RedisModule_ReplicateVerbatim或者RedisModule_Replicate进行主备复制和写AOF(必要的时候需要对原命令进程重写)。其中,使用RedisModule_Replicate产生的AOF,前后都会被自动加上multi/exec(保证module内产生的命令具有隔离性)。因此,推荐优先使用RedisModule_ReplicateVerbatim进行复制和写AOF。但是,如果命令中存在诸如版本号等参数,则必须使用RedisModule_Replicate将版本号重写为绝对版本号,将过期时间重写为绝对过期时间。另外,如果一个命令最终RedisModule_Replicate对命令进行重写,则需要保证重写后的命令不会再次发生重写。
- 复用argv参数:命令传入的argv中的参数类型为RedisModuleString ** ,这些RedisModuleString在命令返回后会被自动Free掉,因此命令中不应该直接引用这些RedisModuleString指针,如果非要这么做(如避免内存拷贝),可以使用RedisModule_RetainString/RedisModule_HoldString增加该RedisModuleString的引用计数,但是之后一定要记得自己手动Free
- key打开方式:在使用RedisModule_OpenKey打开一个key的时候,要严格区分打开的类型:REDISMODULE_READ、REDISMODULE_WRITE,因为这影响着是否更新内部的stat_keyspace_misses和stat_keyspace_hits信息,还影响的了过期再写入的问题。同时,使用REDISMODULE_READ方式打开的key不能被删除,否则报错
- key类型处理:目前只有string的set命令可以强行覆盖其他类型的key,其他的命令在遇到key存在但类型不匹配时需要返回""WRONGTYPE Operation against a key holding the wrong kind of value"错误
- 多key命令的cluster支持:对于多key的命令,一定要处理好firstkey、lastkey、keystep这三个值,因为只有这三个值对了,在cluster模式下,redis才会去检查这些key是否存在CROSS SLOTS的问题
- 全局索引、结构:module中如果有自己维护的全局索引,需要谨慎索引中是否包含dbid、key等信息,因为redis的move、rename、swapdb等命令会“偷梁换柱”式的更换key的名字、交换两个dbid,因此此时如果索引没有同步更新,将得到意想不到的错误
- 根据角色来确定动作:module本身运行的redis可能是一个主也可能是一个备,module内部可以使用RedisModule_GetContextFlags来判断当前redis的角色,并根据不同的角色来采取不同的行为(如是否进行主动过期处理等)
总结
Tair当前支持了非常多的扩展数据结构(其中redis 5.x企业版使用module方式,Tair自研企业版 6.x使用builtin方式),基本涵盖了各种应用场景(具体见介绍文档),其中既有像TairString和TairHash等小而美的数据结构(已经开源),也有像Tair Search和Vector等更为复杂和强大的计算型数据结构,充分满足AIGC背景下各种业务场景,欢迎使用。
fork(2)-Linux Man Page:http://linux.die.net/man/2/fork
作者 | 驱动
来源 | 阿里云开发者公众号