文章目录
在 上一篇文章中,我探讨了一种通过进程交换的合作框架,随后突如其来的一场重病拖慢了实现过程。尽管如此,经过同学们半年多的讨论、实验,目前实现了这个想法。感谢在我病休近6个月期间,徐老师、杜同学的坚持,其中的UI基础性编码工作由他们完成。在本篇,我首先介绍基本原理,后续将详细讲解开发过程。由于身体仍旧没有完全恢复,博客、论坛的回答可能滞后,见谅!
本文的GitHub链接为 这里
一个 发布包见这里
演示视频 在这里
1. 什么是Taskbus
Taskbus 是一种面向非专业开发者的跨平台多进程合作框架,具有进程切割、语言无关、编译器无关、架构无关四个特点。
非专业开发者是一个泛泛的概念,可以理解为没有受过专业化的软件工程化训练的开发者。诸如需要频繁自行开发小工具进行算法验证的高校教研团队,以及深入某一领域(化工、机械、通信、电子等)进行数据分析,需要长期从事非消费类工具软件开发的工程师团队。
Taskbus 从感官上提供一种类似Simulink或GNU-Radio的模块化拖拽界面,可用于在通用计算机上实现准实时的处理逻辑。但是,从结构上,其与二者完全不同。Taskbus 对编译器、运行平台、开发语言不做要求。它通过定义一种功能发布与数据交换标准,提供一套进程管理平台,以便把不同语言开发的进程组合起来。在例子中,您可以看到Python2/3,NodeJS,C#,Matlab、Qt、C++、MFC等工具链生成的模块,它们在平台统一调度下完成功能。
2. 关键特性
taskBus 的核心理念是 “定IO标准不定具体工具,定连接结构不定架构算法”
- taskBus 仅定义数据交换的方法与格式,对具体实现语言、运行环境没有要求。这带来了非常高的灵活性。
- taskBus 仅定义进程间的逻辑连接结构,并不定义用户搭建的具体功能所采用的架构、所需要的方法。
它的关键特性:
- 简单: 进程通过标准输入输出(stdin,stdout,stderr)吞吐数据、命令行参数接受初始化参数。没有对数据库、COM、CORBA、动态链接、SOAP或网络协议的知识要求。基于平台提供的调试功能,经过录制、回放操作,可以脱离平台独立调试各个模块的逻辑。
- 灵活: 通过专题(Subject)、通路(Path),通过标准输入输出管道可以分时处理多个逻辑流。它们之间的关系完全由模块设计者确定。基于网络模块、数据库模块提供的功能,可以在不同的操作系统上构建分布式的处理系统。您甚至可以用封装器把Matlab、Octave、Python程序包装进来。
- 稳定: 错误被控制在一个模块进程内部,易于发现问题。 可为各个模块设置独立的优先级(nice)。
- 高效: 多进程并行、分布式计算与优先级控制,使得通用PC计算环境也可达到实时处理/准实时处理的能力。当您的模块加入GPU加速等特性后,可以使整个系统性能得到大幅度提升。
- 推送而非请求:与GNU-Radio不同,taskBus的前序模块主动向后端推送数据,而负荷控制由模块通过简单的方法实现(参考下文“负荷控制”部分)。
3. 基本原理
很多经典的编程语言课本都是从控制台开始的. 控制台程序通过键盘接收用户输入,向屏幕打印运算结果。事实上,无论在Linux还是Windows中,进程启动时便有三个特殊的文件句柄可用了。他们是标准输出(stdout),标准输入(stdin),标准错误(stderr)。默认情况下,stdin与键盘关联,stdout、stderr与屏幕关联。
大多数现代语言支持创建子进程, 并且可以通过 “管道重定向” 技术接管子进程的标准管道。Taskbus 技术即基于此特性,从各个子进程的stdout读取数据,并转发给需要的子进程(stdin)。
3.1 输入输出
一个实现XOR操作的教科书C程序,一般类似这样:
#include <stdio.h>
int main(int argc, char *argv[])
{
unsigned int a[4];
scanf("%u,%u,%u,%u",a,a+1,a+2,a+3);
for(int i = 0; i < 4; ++i)
a[i] = 0xFFFFFFFF ^ a[i];
printf("%u,%u,%u,%u\n",a[0],a[1],a[2],a[3]);
return 0;
}
上面的程序里,从键盘输入四个数字,取反后输出。由于键盘与stdin关联,屏幕与stdout关联,上述程序实质上与下面的程序等效:
fscanf(stdin,"%u,%u,%u,%u",a,a+1,a+2,a+3);
fprintf(stdout,"%u,%u,%u,%u\n",a[0],a[1],a[2],a[3]);
Taskbus 模块的输入输出与上述程序非常类似,唯一的区别是使用了二进制读写函数:
unsigned int a[4];
fread(a,sizeof(int),4,stdin);
for(int i = 0; i < 4; ++i)
a[i] = 0xFFFFFFFF ^ a[i];
fwrite(a,sizeof(int),4,stdout);
3.2 专题与通路
一个子进程只有一对输入输出管道。通过设置专题与通路,可以仅用一路管道分享多路内容。
3.2.1 专题
专题(Subject)指示一类数据。如声卡采集的波形,以及从文件中读取的字节流。
在图形界面上,专题显示为管脚。每个专题有一个便于记忆的名字;在运行时,taskBus平台会根据连线关系,为每个专题设置一个整数ID。ID相同的输入、输出管脚会被连接起来。
一个专题的生产者生成数据,交给平台。平台把数据交给所有连接了此专题的消费者。
注意: 不同的管脚可以生产同一ID的专题,也可以监听一个相同的专题。对生产者,这样做的行为是一致的。对消费者,如何处理专题ID相同的情况,取决于模块实现者。
3.2.2 通路
通路(PATH)在一类专题中,区分一条独立的自然时序。下面的例子中,两路声卡采集的数据汇入同一个FFT变换器。对于变换器来说,需要区分出两条自然时序,才能不导致混淆。
上图中的声卡模块采用自身的进程ID(2、6)作为通路号,这样非常便捷地标定了数据的来源。
3.3 带有专题和通路的IO
鉴于上面的介绍,我们在前文代码中稍加修改,即可实现基于stdio的通信。
void record( char a[], int data_len, int path_id)
{
int out_subject_id, out_path_id;
int out_data_len;
char b[MAX_LEN];
deal(a, datalen,path_id,
&out_subject_id,
&out_path_id,
&out_data_len,
b
);
fwrite (&out_subject_id,sizeof(int),1,stdin);
fwrite (&out_path_id,sizeof(int),1,stdin);
fwrite (&out_data_len,sizeof(int),1,stdin);
fwrite (b,sizeof(char),out_data_len,stdin);
}
int main(int argc, char *argv[])
{
int subject_id, path_id;
int data_len;
char a[MAX_LEN];
while (!finished())
{
fread (&subject_id,sizeof(int),1,stdin);
fread (&path_id,sizeof(int),1,stdin);
fread (&data_len,sizeof(int),1,stdin);
fread (a,sizeof(char),data_len,stdin);
switch (subject_id)
{
case ID_WAV:
record(a,data_len,path_id);
break;
case ID_DAT:
deal(a,data_len,path_id);
break;
default:
break;
}
}
return 0;
}
上述代码缺少上下文,但清晰的示意了taskBus最基本的通信原理。不同模块之间,正是通过这样的方法进行沟通的。
3.4 模块功能发布
由开发者独立开发的模块,需要使用JSON文件发布自己的功能。这样,平台就知道模块支持的专题类型、参数选项。
一个典型的功能描述文件必须包括三部分,分别是:
- 参数表
- 输入专题表
- 输出专题表
其他用户自定义部分依旧可以读入并显示在平台,但没有实际的意义。 平台提供的helloworld例子包含两个功能,一个是比特抑或,一个是顺序取反。JSON文件的结构如下:
{
"example_bitxor":{
"name":"bitxor",
"parameters":{
"mask":{
"type":"unsigned char",
"tooltip":"bytemask",
"default":255,
"range":{
"min":0,
"max":255
}
}
},
"input_subjects":
{
"data_in":{
"type":"byte",
"tooltip":"input"
}
},
"output_subjects":{
"data_out":{
"type":"byte",
"tooltip":"output"
}
},
"info":{
"auther":"kelly",
"version":[1,0,0],
"mail":"[email protected]"
}
},
"example_reverse":{
"name":"reverse",
"parameters":{
},
"input_subjects":
{
"data_in":{
"type":"byte",
"tooltip":"input"
}
},
"output_subjects":{
"data_out":{
"type":"byte",
"tooltip":"output"
}
},
"info":{
"auther":"kelly",
"version":[1,1,0],
"mail":"[email protected]"
}
}
}
可以看到,文件由两大块组成。第一块为 example_bitxor部分,第二块为example_reverse,对应了两个功能。
在各个功能内部,又分name、parameters、input_subject、output_subject四个子项目,分别对应友好名称、静态属性、输入专题、输出专题。
**注意:**尽管各个属性含有“type”类型指示以及range取值范围指示,但平台把所有输入输出看做字节流,这些取值仅仅为了提醒用户。一个成熟的模块应该有详细的接口文档描述输入输出类型、字节序、大小端。对文本类型,要明确或者可以设施字符集。上面的模块,在平台上显示为:
3.5 命令行参数
taskBus 平台根据JSON文件启动进程。在启动各个功能模块时,taskBus 通过命令行参数送入所有的信息。命令行参数有如下几类。
类别 | 参数 | 意义 | 解释 |
---|---|---|---|
进程 | ----instance= | 向模块送入进程ID值 | 整形。用于区分独立的进程,这个措施避免了模块自己来生成唯一ID |
进程 | ----function= | 向模块指定当前实例开启的功能。 | 一个模块可以支持很多功能。 |
进程 | ----information | 平台请求模块输出JSON描述并退出。 | 模块既可以附带JSON文件,也可以在本参数中printf JSON。 |
专题 | ----<sub_name>= | 向模块指定专题名对应的ID | 专题名由各个模块确定,可以出现多条。 |
用户属性 | ----= | 用户自定的初始化属性 | 可在平台“属性”栏设置。 |
以上述模块JSON为例子,下图中的模块,在启动时,命令行如下:
user@local$ example_helloworld.exe --instance=6 --function=example_bitxor --mask=255 --data_in=6 --data_out=1
3.6 第一个Hello-world 模块的代码
我们把HelloWold模块的代码粘贴到这里,使用C++,可以非常方便的实现上述功能。
- 该代码没有使用任何标准C++之外的特性
- 事实上,在第四章您可以看到,我们已经为接入平台提前做好了不少简化工作。但简化工作会掩盖细节,下面的代码仍旧是最体现模块运行原理的好例子。
#include <cstdio>
#include <string>
#include <cstring>
#include <cstdlib>
#ifdef WINNT
#include <io.h>
#include <fcntl.h>
#endif
using namespace std;
#define MAX_DTALEN 65536
int main(int argc, char * argv[])
{
//In windows, stdio must be set to BINARY mode, to
//prevent linebreak \\n\\r replace.
#ifdef WINNT
setmode(fileno(stdout),O_BINARY);
setmode(fileno(stdin),O_BINARY);
#endif
bool bInfo = false, finished = false;
int instance = 0;
int sub_input = 0, sub_output = 0;
char mask = 0;
string function;
//1. parse cmdline
for (int i=1;i<argc;++i)
{
string arg_key = argv[i], arg_value = argv[i];
int idx = arg_key.find('=');
if (idx>=0 && idx<arg_key.size())
{
arg_key = arg_key.substr(0,idx);
arg_value = arg_value.substr(idx+1);
}
if (arg_key=="--function")
function = arg_value;
else if (arg_key=="--information")
bInfo = true;
else if (arg_key=="--instance")
instance = atoi(arg_value.c_str());
else if (arg_key=="--data_in")
sub_input = atoi(arg_value.c_str());
else if (arg_key=="--data_out")
sub_output = atoi(arg_value.c_str());
else if (arg_key=="--mask")
mask = atoi(arg_value.c_str());
fprintf(stderr,"%s:%s\n",arg_key.c_str(),arg_value.c_str());
fflush(stderr);
}
//2. function case
if (bInfo)
{
//In this example, json file will be published with exe file.
//We will return directly. Or, you can output json here to stdout,
//If you do not want to publish your json file.
return 0;
}
else if (instance<=0 || function.length()==0)
return -1;
else
{
char header[4], data[MAX_DTALEN+1];
memset(data,0,MAX_DTALEN+1);
int n_sub = 0, n_path = 0, n_len = 0;
while(false==finished)
{
fread(header,1,4,stdin); //2.1 read header
if (header[0]!=0x3C || header[1]!=0x5A || header[2]!=0x7E || header[3]!=0x69)
{
fprintf(stderr,"Bad header\n");
break;
}
fread(&n_sub,sizeof(int),1,stdin);
fread(&n_path,sizeof(int),1,stdin);
fread(&n_len,sizeof(int),1,stdin);
if (n_len<0 || n_len >MAX_DTALEN)
{
fprintf(stderr,"Bad length %d\n",n_len);
break;
}
fread(data,sizeof(char),n_len,stdin);
if (n_sub<=0)
{
if (strstr(data, "quit")!=nullptr)
{
finished = true;
continue;
}
}
else if (n_sub != sub_input)
continue;
if (function=="example_bitxor")
{
for (int i=0;i<n_len;++i)
data[i] ^= mask;
}
else if (function=="example_reverse")
{
for (int i=0;i<n_len/2;++i)
{
char t = data[i];
data[i] = data[n_len-1-i];
data[n_len-1-i] = t;
}
}
else
{
fprintf(stderr,"Unknown function %s\n",function.c_str());
break;
}
fwrite(header,1,4,stdout);
fwrite(&sub_output,sizeof(int),1,stdout);
fwrite(&n_path,sizeof(int),1,stdout);
fwrite(&n_len,sizeof(int),1,stdout);
fwrite(data,sizeof(char),n_len,stdout);
fflush(stdout);
}
}
//3.exit
return 0;
}
下一篇文章,我们通过一个示例来叙述客户端模块的开发过程。