并行任务和通信
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 上。

这里涉及将任务分配给特定的硬件元素(系统的 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展示了一些任务之间的连接示例。

通道和接口
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是接口的类型。
连接的客户端端点可以作为参数传递给任务。具有客户端端点 访问权限的任务可以使用类似函数调用的语法来启动事务:
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);
};
在这个接口中,有两个普通函数(f和get_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所示)。

图10:一个任务连接到其他多个任务
通过使用接口数组,一个任务可以连接到多个其他任务。一个任务可以处理整个数组的端点,而数组的各个元素可以传递给其他任务。例如,下面的代码将task3连接到task1和task2:
int main() {
interface if1 a[2];
par {
task1(a[0]);
task2(a[1]);
task3(a, 2);
}
return 0;
}
task1和task2被分配了数组的一个元素,并且可以像往常一样使用接口端点:
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;
}
}
}