Skip to main content
欢迎来到PAWPAW技术文档网站了解更多信息

并行任务和通信

xC编程与C语言最基本的区别在于将并行性和任务管理集成到语言中。

并行与任务

xC程序由并行运行的任务组成。关于任务,xC没有任何特殊的语法 - 任何xC函数都表示可以运行的任务:

void task1(int x) {
printf("Hello world - %d\n", x);
}

您可以使用par构造来并行运行任务。下面是一个par语句的示例:

par {
task1(5);
task2();
}

这个语句将同时运行task1和task2,直到它们都完成。它会等待两个任务都完成后再继续执行。

尽管任何函数都可以表示为一个任务(即可以与其他任务并行运行的代码块),但任务通常具有一种常见的形式,即一个永不结束的循环:

void task1(args) {
//... 初始化 ...
while (1) {
//... 主循环 ...
}
}

虽然代码可以在程序的任何地方并行运行,但主函数main()在这方面是特殊的,因为它可以将任务分配在不同的硬件实体,即 tile 与 core 上。

image-20230705171500739
图7:任务分配

这里涉及将任务分配给特定的硬件元素(系统的 tile 和核心)。图7展示了一组任务的可能放置方式。

请注意:

  • 多个任务可以在同一个逻辑核心上运行。这是通过协作式多任务处理实现的,如可组合函数所述。
  • 一些任务跨多个逻辑核心运行。这些可分配任务在分布式函数中进行了描述。

如前所述,任务分配仅发生在主函数中,并且使用on构造在par内进行。下面是一个将多个任务分配在硬件上的示例:

#include <platform.h>
...
int main() {
par {
on tile[0]: task1();
on tile[1].core[0]: task2();
on tile[1].core[0]: task3();
}
}

在这个示例中,task2和task3被放置在同一个核心上。只有当这些任务可以参与协作式多任务处理时才有效(即它们是可组合的函数 - 参见可组合函数)。如果在放置中未指定核心,则任务会自动分配给指定 tile 上的空闲逻辑核心。

复制par语句

复制的par语句可以并行运行多个相同任务的实例。其语法类似于C语言的for循环:

par (size_t i = 0; i < 4; i++)
task(i);

这等效于以下语句:

par {
task(0);
task(1);
task(2);
task(3);
}

复制的par语句中的迭代器范围(如上例中的i)必须在编译时常量边界之间。

通信

任务通过显式的事务进行通信。任何任务都可以与任何其他任务进行通信,无论这些任务在哪个 tile 和核心上运行。

编译器会使用底层通信硬件以最高效的方式实现这些事务。

所有的通信都是通过任务之间的点对点连接完成的。这些连接在程序中是明确指定的。图8展示了一些任务之间的连接示例。

image-20230705172633371
图8:任务之间的通信示例

通道和接口

xC提供了三种任务间通信的方法:接口(interfaces)、通道(channels)和流式通道(streaming channels)。

通道提供了任务之间最简单的通信方法;它们允许任务之间同步传递无类型的数据。

流式通道允许任务之间的异步通信;它们利用硬件通信结构中的任何缓冲区,在任务之间提供了一个简单而短的FIFO。缓冲区的大小取决于硬件,但通常为一个或两个Word。

通道(包括流式通道和普通通道)是xC中可用的最底层的抽象层次,可以用来实现相当高效的核间通信,但没有类型检查,并且不能在同一个核心上的任务之间使用

接口提供了一种在任务之间执行带有类型的事务的方法。这允许程序在任务之间具有多个事务函数(类似远程函数调用)。接口允许在同一个逻辑核心上运行的任务之间进行通信。此外,接口还允许在其他同步通信期间通过异步方式发出通知

接口连接

接口提供了最具结构化和最灵活的任务间的连接方法。接口定义了任务之间可以发生的事务类型以及传递的数据。例如,下面的接口声明定义了两种事务类型:

interface my_interface {
void fA(int x, int y);
void fB(float x);
};

事务类型的定义类似于C函数。接口函数可以接受任何C函数可以接受的参数。这些参数定义了在任务之间发生事务时传递的数据。由于函数可以具有按引用传递的参数(参见引用)或返回值,因此在单个事务中可以双向传输数据。

两个任务之间的单个接口连接由三部分组成:接口连接本身、客户端端点和服务器端点。图9显示了这些部分以及与每个部分相关的xC类型。在语言的类型系统中:

  • 接口连接的类型为“interface T

  • 客户端端点的类型为“client interface T

  • 服务器端点的类型为“server interface T” 其中T是接口的类型。

image-20230705173459757
图9:一个接口连接

连接的客户端端点可以作为参数传递给任务。具有客户端端点访问权限的任务可以使用类似函数调用的语法来启动事务:

void task1(client interface my_interface i) {
// 'i' 是连接的客户端端点,我们与另一端进行通信。
// fA() 是接口中定义的函数,将一个客户端接口端点作为参数传入可以使用它
i.fA(5, 10);
}

服务器端点可以作为参数传递给任务,该任务可以使用select构造等待事务发生。select会等待直到另一端启动事务:

void task2(server interface my_interface i) {
// 等待通过连接'i'的fA或fB。
select {
case i.fA(int x, int y):
printf("Received fA: %d, %d\n", x, y);
break;
case i.fB(float x):
printf("Received fB: %f\n", x);
break;
}
}

请注意,select允许您处理多种不同类型的事务。代码可以等待来自多个不同来源(使用不同接口类型)的多种不同类型的事务。一旦其中一个事务被启动并且select处理了事件,代码将继续执行。select单次只处理一个事件。

当任务运行时,您可以通过声明一个接口的实例并将其作为参数传递给两个任务来将它们连接在一起:

int main(void) {
interface my_interface i;
par {
task1(i);
task2(i);
}
return 0;
}

在一个连接中,只能有一个任务使用服务器端点,也只能有一个任务使用客户端端点。如果在par中有多个任务使用任一端点,将会导致编译时错误。

任务可以连接到多个接口连接,例如:

int main(void) {
interface my_interface i1;
interface my_interface i2;
par {
task1(i1);
task3(i2);
task4(i1, i2);
}
return 0;
}

这段代码对应于图8中显示的连接。一个任务可以在一个select中等待来自多个连接的事件:

void task4(interface my_interface server i1, interface my_interface server i2) {
while (1) {
// 等待通过任一连接的fA或fB。
select {
case i1.fA(int x, int y):
printf("Received fA on interface end i1: %d, %d\n", x, y);
break;
case i1.fB(float x):
printf("Received fB on interface end i1: %f\n", x);
break;
case i2.fA(int x, int y):
printf("Received fA on interface end i2: %d, %d\n", x, y);
break;
case i2.fB(float x):
printf("Received fB on interface end i2: %f\n", x);
break;
}
}
}

在接口连接中,客户端端点发起通信。然而,有时服务器端点需要独立地向客户端端点发送信息。通知(notifications)提供了一种让服务器在客户端调用之外与客户端联系的方式。这种方式是异步且非阻塞的,即服务器端可以发出一个信号然后继续处理其他任务。

以下代码在接口中声明了一个带有通知函数的接口:

interface if1 {
void f(int x);

[[clears_notification]]
int get_data();

[[notification]] slave void data_ready(void);
};

在这个接口中,有两个普通函数(fget_data)。然而,它还有一个通知函数:data_ready。在接口声明内部,可以使用[[notification]]属性声明通知函数。该函数必须被声明为slave,以指示通信的方向是从服务器端到客户端。换句话说,服务器将调用该函数,而客户端将响应。通知函数不能带有参数,并且返回类型必须为void

备注

在函数上同时指定slave[[notification]]似乎是多余的。同时需要这两者是为了使语言对未来的扩展具备兼容性,其中slave函数不一定需要成为通知函数。

一旦服务器发出通知,它会在接口的客户端端点触发一个事件。然而,重复发出通知对客户端没有影响,直到客户端清除通知。可以通过将接口中的一个或多个函数标记为[[clears_notification]]来实现。当客户端调用这些函数时,它将清除通知。

接口的服务器端点可以调用通知函数来通知客户端端点,即可以执行以下代码:

void task (server interface if1 i)
{
...
i. data_ready();
}

正如之前提到的,通知是非阻塞的,它会客户端发出信号。该信号只能被触发一次 - 在调用data_ready后再次调用它不会产生任何效果。

接口的客户端端点可以像调用普通函数一样,对通知函数进行调用,但也可以在select中选择从接口的服务器端点接收通知。例如:

void task2(client interface if1 i) {
i.f(5);
select {
case i.data_ready():
int x = i.get_data();
printf("task2: Got data %d\n", x);
break;
}
}

在接收到通知后,任务调用data_ready。除了执行事务外,这还会清除通知,以便服务器可以在以后重新通知。

通道

通道提供了一种基本的任务间通信方法。它们将任务连接在一起,并提供阻塞式通信,但不定义任何事务类型。使用chan声明可以将两个任务连接到一个通道上:

chan c;
par {
task1(c);
task2(c);
}

在通道中,特殊的操作符<:和:>分别用于发送和接收数据。这些操作符在通道上发送一个值。例如,以下代码将值5发送到通道上:

void task1(chanend c) {
c <: 5;
}

另一端可以在select中接收数据:

void task2(chanend c) {
select {
case c :> int i:
printintln(i);
break;
}
}

您也可以在select之外直接使用输入操作符接收数据:

void task1(chanend c) {
int x;
//...
// 从通道接收一个值到x
c :> x;
}

流式通道

默认情况下,通道的输入/输出是同步的。这意味着对于通过通道发送的每个字节/字,以及用于执行输出的任务都会被阻塞,直到通道另一端的输入任务接收到数据。同步的时间以及阻塞的时间可能会导致性能降低。流式通道提供了解决此问题的方法。它们在两个任务之间建立了一个永久的路径,可以在该路径上高效地进行数据通信,而无需同步。

您可以将通道的每个端点传递给每个逻辑核心,从而在两个核心之间打开一个永久路径:

streaming chan c;
par {
f1(c);
f2(c);
}

注意:这里使用了streaming关键字来声明流式通道。

通过接口函数调用来传递数据

一个接口函数调用,通过其参数将数据从客户端端点传递到服务器端点。这种方式允许返回值。例如,以下接口声明包含一个返回整数的函数:

interface my_interface {
int get_value(void);
};

在接口的客户端端点,可以使用从服务器端返回的接口函数调用的结果。这意味着客户端可以利用该结果进行后续操作或处理:

void task1(client interface my_interface i) {
int x;
x = i.get_value();
printintln(x);
}

在处理服务器端的函数时,可以在select case中声明一个变量来保存返回值。这个变量可以在case体内赋值,并在case结束时将值返回给客户端:

void task2(server interface my_interface i) {
int data = 33;
select {
case i.get_value() -> int return_val:
// 设置返回值
return_val = data;
break;
}
}

数据也可以通过按引用传递的参数(参见引用)和数组参数进行双向传递:

interface my_interface {
void f(int a[]);
}

客户端端点可以将数组传递给这个函数:

void task1(client interface my_interface i) {
int a[5] = {0, 1, 2, 3, 4};
i.f(a);
}

在传递数组时,传递的是数组的引用。下面一个允许服务器在处理该事务的select case中访问该数组元素的句柄:

...
select {
case i.f(int a[]): //server接收client发送的数组a[]作为参数
x = a[2]; //通过索引访问数组元素,即读取数据
a[3] = 7; //通过索引为数组元素赋值,即写入数据
break;
}

请注意,服务器既可以从数组中读取数据,也可以将数据写入数组。即使接口跨越了 tile ,这种方式也可以正常工作。在这种情况下,数组访问会转换为对硬件通信基础设施的高效操作。

对数组的访问还包括使用memcpy。例如,一个接口可能包含一个用于填充缓冲区的函数:

interface my_interface {
...
void fill_buffer(int buf[n], unsigned n);
}

在接口的服务器端,可以使用string.h中的memcpy将本地数据复制到远程数组。这会被转换成高效的任务间复制操作:

int data[5];
...
select {
case i.fill_buffer(int a[n], unsigned n):
// 将数据从本地数组复制到远程数组
memcpy(a, data, n * sizeof(int));
break;
}

接口和通道数组

能够将一个任务连接到多个其他任务是很有用的(如图10所示)。

image-20230708141656836

图10:一个任务连接到其他多个任务

通过使用接口数组,一个任务可以连接到多个其他任务。一个任务可以处理整个数组的端点,而数组的各个元素可以传递给其他任务。例如,下面的代码将task3连接到task1task2

int main() {
interface if1 a[2];
par {
task1(a[0]);
task2(a[1]);
task3(a, 2);
}
return 0;
}

task1task2被分配了数组的一个元素,并且可以像往常一样使用接口端点:

void task1(client interface if1 i) {
i.f(5);
}

task3拥有整个数组的服务器端点。select结构可以等待任何连接上的事务。这是通过在select语句的case中使用模式变量来实现的。语法是在select语句的数组索引内部声明变量:

case a[int i].msg(int x):
// 处理该情况
...
break;

在这里,变量i被声明为数组a的下标,这意味着该case将选择整个数组,并等待来自其中一个元素的事务事件。

当发生事务时,i被设置为事务发生的数组元素的索引。下面是一个完整的处理接口数组的任务示例:

void task3(server interface if1 a[n], unsigned n) {
while (1) {
select {
case a[int i].f(int x):
printf("从连接 %d 接收到值 %d\n", i, x);
break;
}
}
}

对于通道数组,同样的方法也适用,例如:

int main() {
chan c[2];
par {
task1(c[0]);
task2(c[1]);
task3(c, 2);
}
return 0;
}

task3可以选择通道端点数组:

void task3(chanend c[n], unsigned n) {
while (1) {
select {
case c[int i] :> int x:
printf("从连接 %d 接收到值 %d\n", i, x);
break;
}
}
}

在客户端接口端扩展功能

接口可以为系统的组件提供API。客户端接口扩展提供了一种在基本接口之上添加额外功能的方式。以一个UART组件的接口为例:

interface uart_tx_if {
void output_char(uint8_t data);
}

要扩展客户端接口,可以声明一个行为类似于新接口函数的新函数。语法如下:

extends client interface T {
function-declarations
}

以下示例向uart_tx_if接口添加了一个新函数:

extends client interface uart_tx_if: {
void output_string(client interface uart_tx_if self, uint8_t data[n], unsigned n) {
for (size_t i = 0; i < n; i++) {
self.output_char(data[i]);
}
}
}

在这里,output_string扩展了客户端接口uart_tx_if。它的第一个参数必须是该客户端接口类型(在此示例中使用了约定俗成的self作为变量名,但可以是任何变量名)。在函数内部,它可以使用这个第一个参数与接口的另一端进行事务交互。对函数定义的唯一限制是它不能访问全局变量。

扩展可以像接口函数一样由拥有客户端接口端的任务使用:

void f(client interface uart_tx_if i) {
uint8_t data[8];
...
i.output_string(data, 8);
}

在这里,i被隐式地作为output_string函数的第一个参数传递。

创建灵活放置的任务

xC程序由多个并行运行的任务构建而成。这些任务可以是几种不同类型的任务,可以以不同的方式使用。下表显示了不同的任务类型:

任务类型用途
普通任务在逻辑核上独立运行,与其他任务无关。任务具有可预测的运行时间,并且可以对外部事件做出非常高效的响应。
可组合任务可以将可组合任务组合在同一个逻辑核上运行。核心根据编译器驱动的协作式多任务切换上下文。
分布式任务可分布式任务可以在多个核心上运行,根据连接到它们的任务的需求进行运行。

使用这些不同的任务类型,您可以根据任务的形式和时序要求最大化设备的资源利用率。

可组合函数

如果一个任务以包含select语句的无限循环结束,它表示一个持续响应事件的任务:

void task1(args) {
// 初始化...
while (1) {
select {
case ...:
break;
case ...:
break;
...
}
}
}

在这里,任务task1不断地根据事件做出反应。

如果一个函数符合这种格式,可以通过添加combinable属性来标记为可组合:

[[combinable]]
void counter_task(const char* taskId) {
int count = 0;
timer tmr;
unsigned time;
tmr :> time;

// This task performs a timed count a certain number of times, then exits
while (1) {
select {
case tmr when timerafter(time) :> int now:
printf("Counter tick at time %x on task %s\n", now, taskId);
count++;
time += 1000;
break;
}
}
}

该函数使用了后面第3节中描述的计时器事件。

可组合函数必须遵守以下限制:

  • 函数的返回类型必须为void
  • 函数的最后一条语句必须是一个包含单个select语句的while(1)循环。

多个可组合函数可以在一个逻辑核上运行。这样做的效果是将这些函数“组合”起来,如图11所示。

image-20230708142402564

图11:组合多个任务

当任务被组合时,编译器会创建代码,首先按照未指定的顺序运行每个函数的初始序列,然后进入一个主循环。该循环启用每个任务的主选择中的case,并等待其中之一的事件发生。当事件发生时,将调用一个函数来执行该任务中该case的主体,然后返回到主循环。

main函数中,可以使用on构造将可组合函数运行在同一个逻辑核上:

int main() {
par {
on tile[0].core[0]: counter_task("task1");
on tile[0].core[0]: counter_task("task2");
}
return 0;
}

如果在同一个核上放置了非可组合函数,编译器会报错。或者,可以在程序的任何地方将par语句标记为组合任务:

void f() {
[[combine]]
par {
counter_task("task1");
counter_task("task2");
}
}

在同一个逻辑核上运行的任务可以相互通信,但有一个限制:不能在组合任务之间使用通道,必须使用接口连接。

可组合函数可以由较小的可组合函数构建而成。例如,下面的代码从两个较小的函数task1task2构建了可组合函数combined_task

[[combinable]]
void task1(server interface ping_if i);

[[combinable]]
void task2(server interface pong_if i_pong, client interface ping_if i_ping);

[[combinable]]
void combined_task(server interface pong_if i_pong) {
interface ping_if i_ping;
[[combine]]
par {
task1(i_ping);
task2(i_pong, i_ping);
}
}

注意,在combined_task内部将task1task2连接在一起。

分布式函数

有时候,任务包含状态并为其他任务提供服务,但不需要自己对任何外部事件做出反应。这种类型的任务只在与其他任务通信时运行任何代码。因此,它们不需要自己的核心,而是可以共享与其通信的任务的逻辑核心(如图12所示)。

image-20230708142827191

图12:一个任务在其他任务之间分布

更正式地说,如果满足以下条件,任务可以被标记为可分布式:

  • 它满足可组合的条件(即以一个永不结束的循环和一个select语句结束)
  • select语句中的case只响应接口事务

下面的示例展示了一个分布式任务,它响应于通过接口连接i对端口p进行访问的事务:

[[distributable]]
void port_wiggler(server interface wiggle_if i, port p) {
// This task waits for a transaction on the interface i and
// wiggles the port p the required number of times.
while (1) {
select {
case i.wiggle(int n):
printstrln("Wiggling port.");
for (int j = 0; j < n; j++) {
p <: 1;
p <: 0;
}
break;
case i.finish():
return;
}
}
}

如果分布式任务连接的所有任务都在同一个tile上,那么它可以被非常高效地实现。在这种情况下,编译器不会为其分配独立的逻辑核心。例如,假设port_wiggler任务在以下方式中被使用:

int main() {
interface wiggle_if i;
par {
on tile[0]: task1(i);
on tile[0]: port_wiggler(i, p);
}
return 0;
}

在这种情况下,task1将被分配一个核心,而port_wiggler则不会。当task1port_wiggler进行事务交互时,其核心上的上下文将被切换以执行port_wiggler中的case;完成后,上下文将切换回task1。图13显示了这样一个事务的进程。

该实现要求客户端任务的核心直接访问分布式任务的状态,因此仅在两者位于同一个tile上时才有效。如果任务跨越多个tile连接,则分布式任务将作为普通任务运行(尽管它仍然是可组合函数,因此可以与其他任务共享一个核心)。

如果一个分布式任务连接到多个任务,它们不能同时更改其状态。在这种情况下,编译器会隐式使用锁来保护任务的状态。

image-20230708143047647

图13:分布式任务中的事务