跳到主要内容
版本:master

只读和并行

1. 概述

本章将系统性地讲解 GS 语言中 readonly(只读)parallel(并行) 两种变量修饰符的使用方法和应用场景。聚焦于数据层面的安全性和性能优化。

本章节面向已经了解 GS 基础语法,需要掌握如何通过变量修饰符来保证数据安全性和提升并发性能的开发者。

通过本章学习,您将理解 readonly 和 parallel 修饰符的语义差异,掌握两种修饰符的正确使用规范,学会在不同场景下选择合适的变量修饰策略,了解如何通过修饰符优化程序性能并保证数据安全。

2 并发和并行概念

后续会多次提到并发与并行,需要了解其基础概念,想象一下你正在吃饭,这时电话响了:

  • 顺序执行:吃完饭才去接电话。程序一次只做一件事,做完一件再做下一件。
  • 并发:停下吃饭,去接电话,接完继续吃。*程序能够在多个任务间快速切换,看起来像是“同时”在推进多个任务。*这是逻辑上的同时
  • 并行:一边吃饭,一边打电话。*程序利用多个CPU核心,真正在同一时刻执行多个任务。*这是物理上的同时

​ 简单来说,并发是“会切换”,主要解决一个任务等待时CPU闲置的问题,是在单核心上交替执行任务的能力。并行是“一起上”,解决如何让任务完成得更快完成的问题,是在多核心上的同时执行能力。这两个概念随着计算机越来越多的利用多处理器的优势而显得愈发重要。

3. 只读和并行

3.1 读写变量

在了解GS中只读和并行前,先回顾下普通的object及其变量和方法。object 绑定域的主要目的是保证 object 中的读写(ReadWrite) object 变量的安全性。当 object 变量声明前没有readonlyparallel关键字时该 object 变量即为 RW 变量,如以下示例中的变量num

// example.gs
int num = 0;
public void hello()
{
printf(HIG "coroutine domain is %O\n" NOR, this_domain());
printf(HIG "object domain is %O\n" NOR, this_object().get_domain());
printf(HIG "num is %d\n" NOR, num);
}

示例3-1:简单的读写变量示例

若要调用函数访问或修改 example.gs 中 RW 的 object变量 num 的函数,则必须占用 example.gs object 创建时所绑定的域。同时这也意味着若有多个的 coroutine ,它们无法在 RW object变量上真正的进行并行数据处理。

3.1 只读变量

3.1.1 使用规范

readonly仅可修饰object变量,不可以修饰函数内部局部变量。该变量的声明、赋值及使用遵循如下规则。

  • 只能用 := 整体赋值,赋值时深拷贝。
    • 可以调用make_readonly函数处理引用类型RW变量以避免深拷贝。
  • 跨域传递时不拷贝。
  • 无法修改变量中的局部内容。
  • 需自行解决数据竞争。

一个正确的的readonly使用示例如下:

// reaonly 变量不能被部分修改,只能被整体赋值
readonly map config := {
"host": "127.0.0.1",
"port": 8080,
"timeout": 30,
"max_connections": 1000
};

// 正确的修改方式:通过整体赋值
void try_modify_config()
{
// config["port"] := 9090; // 错误:不能部分修改readonly变量
// config["new_key"] := "value"; // 错误:不能添加新键

// 正确做法:创建副本,修改副本,然后整体赋值
map m = config.deep_dup(); // 使用深拷贝
m["port"] = 9090; // 修改副本
m["new_key"] = "value"; // 添加新键
config := m; // 正确:readonly变量只能被整体赋值
}

try_modify_config();
printf("After modify config: %O\n", config);

示例3-2:正确readonly使用示例

3.1.2 安全保证

GS 中多个并行的协程执行流在访问readonly变量时有如下安全性保证:

  • 并行读取:多个协程同时读取readonly 变量,无需任何同步机制。
  • 安全更新:更新协程可以安全修改readonly 变量,不会导致读取协程看到部分更新的不一致状态。
  • 数据一致性保证:读取协程要么看到旧版本完整数据,要么看到新版本完整数据,不会看到中间状态。

​ 并行处理的情况下不需要占域即可安全读取或整体赋值readonly变量,可以提升并行数据处理的性能。一个并行访问及修改readonly变量的示例如下:

#pragma parallel
readonly map data := {"count": 0};
void read_func(int co_idx)
{
int value = 0;
for(int i = 0 upto 10000000)
{
// 并行安全读取 10M次,无域竞争
value = data["count"];
}
printf("Thread %d read: %d\n", co_idx, value);
}

void update_func()
{
// 更新数据无需域竞争
map new_data = {"count": data["count"] + 1};
data := new_data;
printf("update to : %d\n", new_data["count"]);
}

void simple_demo()
{
int start_time = time.time_ms();
printf("init num: %d\n", data["count"]);
// 创建多个读取线程
array readers = [];
for (int i = 1 upto 32)
{
readers.push_back(coroutine.create_with_domain(nil, domain.create(), (:read_func, i:)));
}
// 更新线程
coroutine updater = coroutine.create_with_domain(nil, domain.create(), (:update_func:));
// 等待完成
updater.wait();
for(coroutine co : readers)
{
co.wait();
}
int end_time = time.time_ms();
printf("final value: %d\n", data["count"]);
printf("Demo all cost time is %d ms\n", end_time - start_time);
}

simple_demo();

示例3-3:readonly 变量的并行访问与修改

最终示例的输出中总耗时为Demo all cost time is 308 ms而同样的功能让我们尝试使用域保护的RW变量实现,会发现整体处理完成的速度明显变慢,示例如下:

map data = {"count": 0};
void read_func(int co_idx)
{
int value = 0;
for(int i = 0 upto 10000000)
{
// 并发读取 10M次,竞争当前域
value = data["count"];
}
printf("Thread %d read: %d\n", co_idx, value);
}

void update_func()
{
// 更新数据,竞争当前域
map new_data = {"count": data["count"] + 1};
data = new_data;
printf("update to : %d\n", new_data["count"]);
}

void simple_demo()
{
int start_time = time.time_ms();
printf("init num: %d\n", data["count"]);
// 创建多个读取线程
array readers = [];
for (int i = 1 upto 32)
{
readers.push_back(coroutine.create_with_domain(nil, this_domain(), (:read_func, i:)));
}
// 更新线程
coroutine updater = coroutine.create_with_domain(nil, this_domain(), (:update_func:));
// 等待完成
updater.wait();
for(coroutine co : readers)
{
co.wait();
}
int end_time = time.time_ms();
printf("final value: %d\n", data["count"]);
printf("Demo all cost time is %d ms\n", end_time - start_time);
}

simple_demo();

示例3-4:RW域竞争示例

​ 示例的输出结果中总的处理耗时变为Demo all cost time is 856 ms,采用域保护的RW变量方案,由于各个协程都需要竞争当前域以访问或修改当前域保护下的RW变量data,导致协程任务无法并行,耗时显著上升。

3.1.3 赋值深拷贝

​ 如果向readonly变量的赋值很频繁,开销会很大,出现过很多次RO变量频繁赋值导致的性能问题。举一个只读变量频繁读写导致性能问题的例子:

map m = get_system_info();

void run()
{
map s = get_system_info();
int t = time.time_ms();
for (int i = 1 upto 1000000)
m = s;
t = time.time_ms() - t;
printf("t = %dms\n", t);
}

run();

示例3-5:正常变量赋值示例

输出结果如下:

t = 12ms

可以看到向正常变量赋值还是较快的,1M次赋值仅需12ms,现在让我们将object变量变为readonly的。

readonly map m := get_system_info();
void run()
{
map s = get_system_info();
int t = time.time_ms();
for (int i = 1 upto 1000000)
m := make_readonly(s);
t = time.time_ms() - t;
printf("t = %dms\n", t);
printf("s is readonly?: %O\n", s.is_readonly_value());
}
run();

示例3-6:readonly 变量赋值示例

示例的输出结果如下:

t = 1790ms
s is readonly?: false

​ 从示例可以看到每次向readonly变量赋值,赋值的变量均会被深拷贝一次,导致程序的总耗时大幅上升。通过手动调用make_readonly()函数处理RW数据可以避免不必要的深拷贝。示例如下:

readonly map m := get_system_info();
void run()
{
map s = get_system_info();
int t = time.time_ms();
for (int i = 1 upto 1000000)
m := make_readonly(s);
t = time.time_ms() - t;
printf("t = %dms\n", t);
printf("s is readonly?: %O\n", s.is_readonly_value());
}
run();

示例3-7:使用 make_readonly 避免深拷贝示例

示例输出如下:

t = 22ms
s is readonly?: true

可以看到由于没有深拷贝,耗时明显减小,但代价是直接修改了原地 map 的 readonly属性。

3.1.4 跨域传递不拷贝

只读变量作为函数参数在跨域传递是不会拷贝的,利用该特性可以避免因函数参数跨域传递深拷贝造成的性能损失。示例如下:

xxx.gs
//xxx.gs
#pragma parallel
public void func_with_map_arg(map m)
{

}
test.gs
// test.gs
import .xxx;
readonly map ro_m := get_system_info();
map rw_m = get_system_info();

void test()
{
object xxx_ob = new_object(xxx, domain.create("xxx"));
int start_time = time.time_ms();
for(int i = 0 upto 1000000)
{
xxx_ob=>func_with_map_arg(ro_m);
}
int end_time = time.time_ms();
printf("Crosss domain function call(1M) with RO variable cost time %d ms\n", end_time - start_time);

start_time = time.time_ms();
for(int i = 0 upto 1000000)
{
xxx_ob=>func_with_map_arg(rw_m);
}
end_time = time.time_ms();
printf("Crosss domain function call(1M) with RW variable cost time %d ms\n", end_time - start_time);
}
test();

示例3-8:RO变量跨域传递特点

示例的输出结果如下:

Crosss domain function call(1M) with RO variable cost time 41 ms
Crosss domain function call(1M) with RW variable cost time 1233 ms

从示例的输出结果可以看到,RO变量在跨域传递时节省掉了因为深复制导致的内存申请,拷贝的大量实践消耗,极大减小了函数调用的耗时。

3.2 并行变量

3.2.1 使用规范

parallel关键字可以修饰 object 变量、函数或对象本身,同样不能修饰函数内部的局部变量。这里主要讲修饰 object 变量时的使用规范,如下:

  • 只能用 := 赋值。
    • 引用类型的RW变量赋值给parallel变量前必须调用 make_parallel函数处理
  • 跨域传递时不拷贝。
  • parallel变量,如果是个容器(map或者array),可以并行读写容器中的某个元素,但是如果容器本身要改变(添加或减少元素)就需要重新类似RO变量那样做一次深拷贝
  • 需自行解决数据竞争序。

使用示例如下:

// parallel 变量不能被部分修改,只能被整体赋值
parallel map config := make_parallel({
"host": "127.0.0.1",
"port": 8080,
"timeout": 30,
"max_connections": 1000,
"extra_info": nil,
});

// 正确的修改方式:通过整体赋值
void try_modify_config()
{
config["port"] := 9090; // 正确:能够修改 parallel 变量的局部内容
config["extra_info"] := make_parallel({"arch": "arm"}); // 正确: 自RW引用类型赋值需调用make_parallel处理
//config["new_key"] := "value"; // 错误:不能为 parallel 的 map 表添加新的键值对
}

try_modify_config();
printf("After modify config: %O\n", config);

示例3-9:正确 parallel 变量使用示例

从示例中可以看到,parallel变量对比readonly变量有两点不同,

  • RW引用类型的值赋值给parallel变量时必需调用make_parallel 函数处理
  • parallel变量可以修改局部内容,但不能新增局部内容。

3.2.3 局部修改

parallel变量实现的目的就是改进 readonly 变量的性能,在很多场景下,我们容器中元素的数量是稳定的,只是元素要频繁变动,这种情况没必要每次写都深拷贝。可以使用share_value,但是使用起来麻烦,并且也有读写加锁的开销,即使有读写锁,还是比直接访问慢不少。

关于性能方面,有些情况下使用parallel会比readonly变量好非常多,示例如下:

readonly map m := get_system_info();

void run()
{
int t = time.time_ms();
for (int i = 1 upto 1000000)
{
m := m + {"jit_type" : i};
}
t = time.time_ms() - t;
printf("t = %dms\n", t);
}

run();

示例3-10:RO性能问题示例

运行结果是:

t = 2540ms

为了修改readonly的map表,我们每次都要先读取readonly并创建一个全新的表,修改新表,之后后通过深复制赋值给readonly变量,这造成了极大的性能消耗。

parallel map m := make_parallel(get_system_info());

void run()
{
int t = time.time_ms();
for (int i = 1 upto 1000000)
{
m["jit_type"] := i;
}
t = time.time_ms() - t;
printf("t = %dms\n", t);
}

run();

示例3-11:parallel 性能改进

运行结果是:

t = 25ms

由于parallel可以直接修改表中的局部成员,循环体中直接减少了两次深复制,性能提升了两个量级。

3.2.4 其他特性

除了允许局部修改以及由RW引用类型对象赋值必须通过make_parallel()处理外,其他特性,如跨域传递不会拷贝及并行安全特性与readonly只读变量相同,此处不在赘述。

3.3 并行方法

在RO对象中(有#pragma parallel声明的对象)中的方法,或者方法声明时带了parallel修饰的方式均是并行方法。调用并行方法不需要跨域,不同的协程可以进行并行调用。

  • 限制:在普通RW对象中的parallel方法不能访问普通(非 readonly 或者 parallel)的成员变量(Object 变量)
  • 目的:利用多线程并行计算进行优化
  • 难点:函数的参数域,具体参考:函数指针 章节参数域的内容。
    • 设计初衷:底层增加限制,确保不会因为跨域拷贝,导致行为和直觉不符,比如一个函数会改变栈上的变量,那么我们希望不管怎么传,传到哪个域,有没有拷贝,这个函数都被调用的时候都能改变这个栈上的变量,否则行为和直觉就会违背。

非跨域调用并行方法示例如下:

string src = """P
public void foo() {}
public parallel void foo_parallel() {}
"""P;

void run()
{
compile_program("/xx.gs", src);
object ob = new_object("/xx.gs", domain.create());
int t = time.time_ms();
for (int i = 1 upto 5000000)
ob.foo_parallel(); // 非跨域调用并行方法
t = time.time_ms() - t;
printf("t = %dms\n", t);
}

run();

运行结果是:

t = 142ms

跨域调用普通方法示例如下:

string src = """P
public void foo() {}
public parallel void foo_parallel() {}
"""P;

void run()
{
compile_program("/xx.gs", src);
object ob = new_object("/xx.gs", domain.create());
int t = time.time_ms();
for (int i = 1 upto 5000000)
ob=>foo(); // 跨域调用普通方法
t = time.time_ms() - t;
printf("t = %dms\n", t);
}

run();

运行结果是:

t = 384ms

示例3-12:并行与普通方法对比示例

对比并行与普通方法的示例,可以看到简单调用的情况下并行方法确实快不少,但是快得也有限。换一种情况,如果我们有个map要作为参数呢?

非跨域调用并行方法示例如下:

string src = """P
public void foo(mixed m) {}
public parallel void foo_parallel(mixed m) {}
"""P;

void run()
{
compile_program("/xx.gs", src);
object ob = new_object("/xx.gs", domain.create());
int t = time.time_ms();
map m = get_system_info();
for (int i = 1 upto 5000000)
ob.foo_parallel(m);
t = time.time_ms() - t;
printf("t = %dms\n", t);
}

run();

运行结果是:

t = 196ms

跨域的调用普通方法如下:

string src = """P
public void foo(mixed m) {}
public parallel void foo_parallel(mixed m) {}
"""P;

void run()
{
compile_program("/xx.gs", src);
object ob = new_object("/xx.gs", domain.create());
int t = time.time_ms();
map m = get_system_info();
for (int i = 1 upto 5000000)
ob=>foo(m);
t = time.time_ms() - t;
printf("t = %dms\n", t);
}

run();
t = 4746ms

示例3-13:传参跨域dup情况下并行与普通方法对比示例

运行结果是,两者的性能有较大的差别,主要就在于跨域需要深拷贝参数至目标域,内存申请与复制造成了极大的性能消耗,

3.4 并行对象

在文件头添加#pragma parallel预处理指令以将对象文件声明为并行的,该声明会协助我们进行一些检查确保我们所写出的对象的 object 变量及object 方法均为并行的,它会做两种操作:

  • 检查所有 object 变量必须是 readonlyparallel
  • 所有 object 函数声明均隐式的添加parallel前缀

示例如下:

#pragma parallel
// int counter = 0; // 错误,并行对象下的所有 object 变量均必须是 `readonly` 或 `parallel` 的
parallel int counter := 0; // 正确

void test() // 并行对象下的所有函数声明都隐式的带有 parallel 前缀,其构造的函数指针默认参数域为 `nil`,可以被非跨域调用
{
}

void call_without_crosss_domain()
{
const string PARALLEL_OB_SRC = """P
#pragma parallel
public void hello()
{
writeln("Hello GS. I call fuction without cross domain success.");
}
"""P;
// 构造一个带有#pragma parallel预处理指令的对象
compile_program("xxx.gs", PARALLEL_OB_SRC);
// 在另一个域创建对象
object new_ob = new_object("xxx.gs", domain.create("xxx"));
// 不跨域调用函数
new_ob.hello();
}

printlnf("Function arg domain in parallel object's fcuntion is '%O'", (:test:).get_arg_domain());
call_without_crosss_domain();

示例3-14:并行对象示例

示例的输出结果如下:

Function arg domain in parallel object's fcuntion is 'nil'
Hello GS. I call fuction without cross domain success.

4. 数据竞争

只读变量和并行变量的使用规范中都有一条需自行解决数据竞争,拿一个简单的并行加法处理的示例来说,如下:

#pragma parallel
readonly int ro_num := 0;
parallel int pa_num := 0;

void add_ro()
{
for(int i = 0; i < 10000; i++)
{
ro_num := ro_num + 1;
}
}

void add_pa()
{
for(int i = 0; i < 10000; i++)
{
pa_num := pa_num + 1;
}
}

void test()
{
array co_list = [];
for(int i = 0 upto 7)
{
// 开启8个并行协程为 readonly 变量做加法
co_list.push_back(coroutine.create_with_domain(nil, domain.create(), (:add_ro:)));
}

for(int i = 0 upto 7)
{
// 开启8个并行协程为 parallel 变量做加法
co_list.push_back(coroutine.create_with_domain(nil, domain.create(), (:add_pa:)));
}

// 等待所有协程结束
for(coroutine co:co_list)
{
co.wait();
}
printf("RO numer after add is %d\n", ro_num);
printf("Parallel number after add is %d\n", pa_num);
}
test();

示例4-1:数据竞争示例

示例的输出结果如下:

RO numer after add is 23124
Parallel number after add is 21460

示例可见,我们分别创建了8个不同域的可并行协程,每个携程入口函数中将parallelreadonly变量整型自增10000次。最终的输出结果中,数组变量的的值却远小于80000。这是由于并行协整对parallelreadonly变量的数据竞争。parallelreadonly变量只保证在读取变量时不会读取到变量修改的中间状态,要么是更新前的旧值,要么是更新后的新值。

所以在并行自增的过程中,大量加法操作实际读到了更新前的旧值,导致实际自增数值远小于预期。为了解决此类并发编程中的竞态条件与数据竞争问题,GS 提供了sync_object同步机制,queue队列,share_value共享变量等内容。会在后续章节中进行学习。

5. 拓展阅读

6. 总结

通过本章的学习,我们了解并掌握了 GS 语言中 readonlyparallel两种变量修饰符的核心特性和应用场景。

readonly 变量的核心优势在于提供并行安全的数据保护。它通过强制整体赋值机制,确保数据更新的完整性,使得多个协程可以无锁安全地并行读取数据。虽然深拷贝特性在某些频繁赋值的场景下会带来性能开销,但这种设计恰恰保证了数据的完整性和一致性。parallel 变量的核心价值是在 readonly 基础上的性能优化。它允许对容器元素的局部修改,避免了不必要的深拷贝操作,特别适用于容器结构稳定但内容频繁更新的高性能场景。通过parallelreadonly关键字避免跨域调用的开销,消除参数深拷贝和域切换的成本,相较于跨域调用普通函数,调用并行方法在并行的场景下往往能获得更好的性能。

数据竞争问题需要特别关注:虽然 readonly 和 parallel 变量提供了数据访问的安全性,但它们并不保证复合操作(如自增运算)的原子性。在多个协程同时进行"读取-修改-写入"操作时,仍然会出现数据竞争问题,需要通过额外的同步机制来解决。

readonly 和 parallel 修饰符是 GS 并发编程中的重要工具,正确使用它们能够帮助开发者编写出既安全又高效的并发程序,为构建复杂的并发应用奠定坚实基础。