当谈到 V 时,并发是一个至关重要的主题。V 的主要精髓在于它为程序员提供的并发能力。在本章中,您将通过详细的代码示例学习并发概念。本章将从解释关于执行日常早晨例行事务的简单现实场景开始。在我选择用来解释并发的现实场景中,您将面临这样一种情况,在不知不觉中执行任务而不知道这些任务是同时进行的。您还将比较顺序执行任务和并发执行类似任务时的结果,并看到并发执行类似任务的好处。
除了本章提供的更直观的并发解释之外,我们还将提供基础知识,例如时间模块和线程类型,以帮助您开始使用 V 编写并发代码。
本章将涵盖以下主题:
-
介绍并发
-
理解并行性
-
学习基本术语
-
入门并发编程
-
在V中实现并发
-
生成
void
函数以并发运行 -
通过编程实现真实的并发场景
-
学习实现并发程序的不同方法
-
在主线程和并发任务之间共享数据
通过本章,您将有信心使用 go
关键字编写 V 中的并发代码,并使用线程类型处理并发函数。您将能够了解编写并发代码相对于顺序代码的好处。本章还将帮助您了解如何启动函数和空函数,以及如何使用返回值的函数。
您还将学习如何在主线程和并行运行的任务之间共享数据。
本章的完整源代码可在 https://github.com/ToughStyle/V-programming-book-cn/tree/main/codes/Chapter10 上获得。
建议您在每个部分的代码示例中使用具有 .v
扩展名的新控制台或文件运行代码,以避免变量名称之间的冲突。
并发意味着同时运行多个任务。虽然这可能看起来像一个非常抽象的定义,但让我们考虑以下现实世界的例子。在冬天的早晨,您醒来,需要热水淋浴。只有当水足够热时,您才能洗澡。但是,在水变热的同时,您需要完成其他早晨的任务。因此,您打开热水器,然后,比如说,您刷牙一段时间,而热水器指示水已经热了。然后,您关闭热水器,享受热水淋浴,准备开始新的一天。
并发的优点在于,您可以同时完成多项不需要遵循特定顺序的任务。因此,在这种情况下,您不必闲置等待水变热;您可以并行完成刷牙的任务。因此,完成任务的顺序并不那么重要。
我们之前使用的"并行"一词在此处是在一般的交谈意义上使用的。但在编程世界中,并发和并行是两个不同的概念。我将在本章稍后更详细地解释并行性。
假设您已经刷完牙了,而加热器仍在加热并且尚未表明热水已经准备好了。在此期间,您可以准备好一套您想要穿的衣服。有时,您可能会同时刷牙和从衣橱中找到一件漂亮的衣服。因此,这些任务不需要按顺序完成。
此外,请注意,并发任务的结果可能不一定影响其他任务。加热水和刷牙的任务是同时或并行完成的。还可以明显地看出,这些任务彼此独立,但是作为主要执行者的您依赖于这些任务的完成,以便您可以完成您每日的早晨例行事务。 在计算机编程世界中, 并发基于I/O 驱动。根据前面的解释,这意味着您(主程序)提供输入或执行任务(打开加热器)。该任务在不同的线程上运行(开始加热水)或通过第三方API在外部运行(使用加热器和电来加热水)。该任务需要自己处理您的输入请求,然后将结果返回给您(以指示灯的形式显示加热器的热水状态)。
我们可以应用并发的一些最常见的I/O驱动操作示例如下:
-
从第三方API获取响应
-
向第三方API发送数据
-
从磁盘读取数据或写入数据
-
从Web服务器下载文件或上传文件
现在我们已经对并发的概念有了牢固的理解,我们将继续下一节,在那里我们将通过涵盖一些真实的场景来理解并行性。
虽然通常可以互换使用"并发"和"并行"这两个词,但在计算机世界中它们有些微的区别。它们都用于加速事情。但是, 并发专注于在尽可能短的时间内完成多个独立任务。与并发相比, 并行专注于将单个任务拆分为多个资源以加速完成特定工作。并行性的概念是巨大的,需要一个完整的章节来解释它。对于本章,我们将在本节中保持事情简单明了,通过查看一个简单而直观的现实世界的例子来理解并行的用例。
假设你有一个容量为1000升的水箱,储存灌溉农田所需的水。您有一台电动泵,每分钟可以以10升/分钟的速度从地下抽取水并将水填充到水箱中。使用单个电动泵填满空水箱需要100分钟,即1小时40分钟。
在夏天,您需要至少4000升的水来维护农场。因此,您购买了三个相似容量的水箱,并有一根连接管道,使用您的电动泵将所有四个水箱都加满水。以前,一个水箱需要1小时40分钟。但是,使用一个电动泵和四个水箱时,您需要等待6小时40分钟才能确保所有水箱都加满水,然后就可以回家了。
考虑到这浪费时间,让电动泵运行更长的时间不是有效的,您计划购买三个类似的电动泵。
现在,您将每个电动泵连接到一个水箱,因此每个电动泵并行地填充一个水箱。您打开所有四个电动泵,它们在1小时40分钟内完成了填充所有四个水箱的工作。您感到高兴,从农场回家了。
值得注意的是,将每个水箱的填充任务分配到多个电动泵中而不是让一个电动泵填满所有四个水箱。因此,适合并行处理的任务必须足够适合拆分到进程中,并确保结果不会受到影响。
此外,由每个电动泵执行的任务实现了一个共同的主要任务,即将所有四个水箱装满水。这意味着最终任务依赖于并行任务以及它们的执行方式。如果其中一个电动泵每分钟注入水的速度比另一台快或慢,那么它会影响完成填充所有四个水箱所需的总时间,这是本场景的最终目标。
简单来说,这个例子类比于计算机世界中的并行,其中每个电动泵可以被认为是四核处理器芯片的核心。将四个空水箱装满的任务可以与任何可以拆分为多个部分并以并行运行的任务相关联,以确保并行任务的集体进展最终导致主任务的完成。
这个例子突出了功能并行性。在一些涉及数据并行性的情况下,需要按特定顺序将并行作业执行的结果组合起来,以便任务被视为成功完成。 一个示例是从报表中导出数据以生成PDF文件。在这个过程中,每个并行作业都会获取要排版成页面的一部分数据。最后的过程是将每个并行作业呈现的单个页面块按特定顺序合并以制成单个PDF文件。如果其中一个并行作业失败,生成的PDF文件被视为包含缺少数据或页面列表的文件。因此,在数据并行性中,所有并行作业必须成功完成它们的各自部分,最终导致将要分割以并行运行完成的任务完成。
了解了并发性和并行性的概念后,让我们学习下面这章节中经常使用的基本术语。
在开始了解如何在V中实现并发性之前,我们将先学习一些在处理并发性时常用的基本术语:
-
程序
: 程序是一组以函数和语句形式表示的指令,帮助我们完成特定的工作。 -
进程
: 当一个具有一个或多个函数和语句的程序开始运行时,它与进程相关联。一个进程可以拥有一个或多个子进程,每个子进程在不同的线程上运行。 -
线程
: 线程允许一个或多个任务按顺序运行。 -
任务
: 任务是在线程上运行的工作单位。它可以表示为V中的函数。
现在我们理解了这些基本术语,让我们开始学习并发概念,以帮助我们在V中实现并发程序。
在深入探讨并发编程之前,我们应该了解时间的一些基础知识、V标准库和线程类型。在本节中,我将简要介绍时间模块和线程类型。理解这些概念将有助于我们继续学习本章。
V附带了一套优雅的库,其中之一是时间库。我将使用时间模块来模拟在并发运行的函数中长时间运行的活动。要使用时间模块,您需要导入它,如下所示:
import time
V中的时间模块具有大量的功能,包括使用time.now()
表达式告知系统当前时间。如果我们只对执行时的小时、分钟和秒钟部分感兴趣,可以将相应表达式写成time.now().hhmmss()
。这些是时间模块中众多可用功能中的一部分。
在接下来的章节中,我们将着重介绍sleep
和new_stopwatch
函数。在V中编写并发代码时,我们将使用这些功能。
sleep
函数像其名称一样,将程序的执行暂停指定的时间。时间模块的sleep
函数只接受时间段类型的一个输入参数,包括纳秒、微秒、毫秒、秒、分钟和小时。这些单位都是time.Duration
类型,其基础数据类型为i64
。
例如,如果您想要将程序的执行暂停半秒钟,只需编写以下语句:
import time
time.sleep(0.5 * time.second)
或者,您也可以编写time.sleep(500 * time.millisecond)
,因为1000毫秒等于1秒。
时间模块还具有计时器功能。new_stopwatch()
方法用于启动计时器。在程序的任何时刻,您都可以使用以下语法检查自启动计时器以来经过的时间:
sw.elapsed().nanoseconds()
在上述语法中,sw
变量保存了使用time.new_stopwatch()
进行初始化的计时器实例。经过的时间长度以纳秒为单位,是i64
类型的。除了调用nanoseconds()
外,您还可以以其他单位获得耗时的时间,例如microseconds()
和milliseconds()
,它们返回经过的时间的i64
值。而seconds()
、minutes()
和hours()
方法返回f64
值。
此功能对于测量执行一系列指令所经过的时间非常有用。例如,以下代码演示了从程序开始时间到完成打印每个迭代变量i
的值所经过的时间:
module main
import time
fn main() {
sw := time.new_stopwatch()
for i in 1 .. 5 {
println('$i')
}
println('Total time took to finish:
$sw.elapsed().seconds() seconds')
}
输出结果如下:
1
2
3
4
Total time took to finish: 0.0141559 seconds
上述代码演示了使用new_stopwatch()
方法,该方法在打印范围内的四个数字后将经过的时间打印到控制台上。这需要我的系统花费了0.0141559秒的时间,但在不同的系统上可能有所不同。
使用go
关键字生成任何函数时获得的句柄是线程类型。使用线程类型,您可以等待线程完成其工作的执行。
您可以使用以下语法创建线程数组:
mut t1 := []thread OPTIONAL_TYPE{}
上面的语法向您展示了如何声明一个线程数组。
OPTIONAL_TYPE
表示包含在线程数组中句柄中的函数的返回类型。线程数组的所有元素必须是具有相似返回类型的函数的句柄。例如,如果正在将元素添加到线程数组中,并且每个元素都生成具有void
类型的并发函数,则数组定义将如下所示:
mut t1 := []thread{}
在上述代码中,t1
变量的类型是[]thread
。为了确保由t1
持有的所有任务完全执行,可以在线程数组上调用阻塞函数t1.wait()
表达式。在t1
上调用wait()
函数将确保t1
持有的所有并发任务都完全执行。本章的" 并行运行多个任务"部分提供了[]thread
的详细演示。
或者,如果要创建一个线程数组,其中包含具有字符串返回类型的所有并发任务,则数组定义将如下所示:
mut t2 := []thread string{}
在上述代码中,t2
类型是[]thread string
。在t2
上调用wait()
函数将确保所有产生返回值为字符串的并发任务都完成并将值存储在t2
中。
以下代码展示了如何访问在其线程中并发运行的字符串函数返回的值:
t2_res := t2.wait()
变量t2_res
将获得[]string
类型。本章的"并行运行具有返回值的函数"
和"接受输入参数的匿名函数并行运行"
部分中提供了[]thread
类型的数组的详细演示。
关于V中线程类型的wait()
函数,以下是您需要知道的几点内容:
-
此函数阻塞主进程的执行,直到在另一个线程中并发运行的任务完成执行。
-
这个函数不需要传入参数。
-
该函数可用于单个线程的句柄上,也可用于分配了使用并发任务处理的线程数组句柄的变量上。
-
wait()
函数的返回类型类似于正在并发运行的函数的返回类型。 -
当在线程数组上调用
wait()
函数时,线程数组的所有元素必须处理具有相似返回类型的函数句柄。
了解了时间模块、线程类型和wait()
函数的基本概念后,我们将开始学习如何在V中编写和处理并发编程。
使用V,您可以编写一个程序,使用go
关键字并发运行函数。go
关键字是V中的内置关键字。无需任何显式的导入语句,您可以在程序的任何地方使用go
关键字。在下一部分中,我们将了解go
关键字的基本语法。
您可以使用go
关键字并行运行任何函数,只需要在函数名后面写上go
关键字即可,如下所示:
go FUNCTION_NAME_1(OPTIONAL_ARGUMENTS)
在上述语法中,演示了在V中使用go
关键字的用法。我们看到的是一个名为FUNCTION_NAME_1
的简单函数,并且它在并发运行。您不需要对要并发运行的函数进行任何特殊的语法更改。
使用上述语法的方法中,活动程序生成一个新线程并让函数并行运行。如果活动程序希望知道FUNCTION_NAME_1
函数的完成状态,则可以等待线程完成执行FUNCTION_NAME_1
。语法如下所示:
h := go FUNCTION_NAME_1(OPTIONAL_ARGUMENTS)
h.wait()
在上述语法中,我们可以看到,FUNCTION_NAME_1(OPTIONAL_ARGUMENTS)
的结果为我们提供了访问执行FUNCTION_NAME_1
函数的线程的句柄。这里,变量h将是线程类型。在此语法中,变量h通常被称为另一个线程中运行的并发任务的句柄。然后,我们在该句柄上调用wait()
函数。wait()
函数会阻塞主程序的执行,直到并发任务完成执行。
上述语法展示了使用go
关键字并行运行函数的方法。它还显示了等待线程完成执行的语法。掌握了这个之后,让我们了解如何生成可以并行运行的void
函数。
在本节中,我们将编写一个简单的没有任何返回类型的函数。该函数只是向控制台打印一条消息。然后,我们将使用go
关键字将该函数并发运行,如下所示:
module main
fn greet() {
println('Hello from other side!')
}
fn main() {
h := go greet()
println(typeof(h).name) // thread
}
在上述代码中,变量h提供了访问并发任务的句柄。在上述代码中,变量h是线程类型。上述代码的输出可能是以下任一输出:
以下是您可能会收到的第一次输出:
thread
以下是您可能会收到的第二个输出:
Hello from other side!
您将看到其中一个输出,这些输出似乎是程序的不一致结果。请注意,greet()
函数旨在打印一条好消息,但在第一个输出中缺少消息。让我们了解如何使用wait()
函数帮助我们将消息打印到标准输出。
在前面的示例中,我们使用go greet()
语句生成greet()
函数在并发线程上运行。但是,生成函数的主应用程序并不关心它已经生成的线程运行情况。因此,前面的示例程序毫不犹豫地退出了执行,而没有真正等待它生成的并发线程。
如果要使主程序等待并发线程完成,则可以通过调用wait()
函数等待从go greet()
语句获得的句柄来实现。
让我们更新示例程序并调用wait()
函数,如下所示:
module main
fn greet() {
println('Hello from other side!')
}
fn main() {
h := go greet()
println(typeof(h).name)
h.wait()
}
输出结果如下:
thread
Hello from other side!
从上述代码中,我们可以看到,主进程正在等待其通过h.wait()
语句在并发线程中生成的子进程。简单地说,当程序开始时,执行控制进入主函数,然后将greet()
函数生成在并发线程上运行。通过h句柄变量,主程序可以访问并发任务。
如果在执行流程中遇到其他语句,则主进程继续执行这些语句。因此,它跳转到序列语句中的下一行,其中我们正在打印h
。随着语句在顺序中得到评估,主程序将h的类型打印到控制台,并将执行控制流到h.wait()
语句。这是主进程等待h线程实例完成执行greet()
函数并将消息打印到标准控制台的地方。
现在,我们了解了如何使用go
关键字并行运行void
函数,接下来让我们学习如何以编程方式实现真实的并发场景。
不仅要理解并发的概念,还要通过使用基本的真实示例编写并发代码来体验它。在本节中,我们将使用三项任务模拟解释本章开始时我们解释的并发示例。这些是我们提到的早晨日常任务; 即加热水,刷牙和从衣柜中选择一对衣服。
由于我认为添加每个任务所需的真实时间是微不足道的,因此我提供了一些虚拟值,以秒为单位模拟这些任务。因此,我选择以秒为单位表示执行这些任务所需的时间,只为简洁起见。这使我们能够更快地运行代码并以整洁有序的方式理解结果。以下表格显示我为我们将并发生成的三项任务模拟的时间:
任务 | 模拟时间(秒) | 实际时间(分钟) |
---|---|---|
1 热水 | 5秒 | 10至15分钟 |
2 刷牙 | 3秒 | 3至5分钟 |
3 选择衣服 | 3秒 | 3至5分钟 |
表10.1-任务的模拟时间和实际时间
现在,我们已经设置好这些时间,让我们开始了解按顺序执行这些任务所面临的挑战。稍后,我们将优化代码以最小化运行这些任务所需的时间,并生成并发程序,以并发方式生成这三个任务。
本节中,我们将了解顺序执行长时间和耗时任务的影响。考虑以下代码,它简单地调用了 hot_water
、brush_teeth
和 select_clothes
任务,在程序上以顺序方式表示:
module main
import time
fn hot_water() {
println('Started Switch on Water heater:
$time.now().hhmmss()')
time.sleep(5 * time.second)
println('Water heater indicates hot water ready!:
$time.now().hhmmss()')
}
fn brush_teeth() {
println('Started brushing: $time.now().hhmmss()')
time.sleep(3 * time.second)
println('End Brushing: $time.now().hhmmss()')
}
fn select_clothes() {
println('Started choosing pair of clothes : $time.now().hhmmss()')
time.sleep(3 * time.second)
println('End choosing pair of
clothes: $time.now().hhmmss()')
}
fn main() {
sw := time.new_stopwatch()
hot_water()
brush_teeth()
select_clothes()
println('Your pre bath morning chores took:
$sw.elapsed().seconds() seconds')
}
上述代码按顺序调用了三个无返回值函数:hot_water()
、brush_teeth()
和select_clothes()
。无返回值函数是在方法签名中没有返回值的函数。
为了模拟每个任务运行所需的时间,我们调用了time.sleep
,并按表格中各自任务的秒数进行了指定。我们还将各自任务的状态消息打印到控制台上,以指示其启动和完成时间。这个状态消息将帮助我们理解它们在开始和结束每个任务时所遵循的顺序。
我们还有一个秒表(由sw
变量表示),它在主函数中的任务按顺序执行之前启动。最后,在这三个任务执行完成后,我们计算了经过的时间(以秒为单位)。
上述代码的输出如下:
Started Switch on Water heater: 07:15:02
Water heater indicates hot water ready!: 07:15:07
Started brushing: 07:15:07: time.now().hhmmss()
End Brushing: 07:15:10
Started choosing pair of clothes : 07:15:10
End choosing pair of clothes: 07:15:13
Your pre bath morning chores took: 11.0601946 seconds
当程序调用这些任务时(如上述输出所示),每个任务都是按顺序执行的。这意味着只有在水加热器指示热水已准备好后,你才会开始刷牙。此外,完成所有三个任务花费了略微超过11秒的时间。
在现实世界中,除非你昏昏欲睡,否则你会继续做其他家务,比如刷牙或选择衣服,如本程序所示,以完成你的早晨日常。
在编程世界中,您可以使用并发来实现这一点。在下一节中,我们将编写一些并发代码,以便快速高效地执行上述任务。
在前一节中,我们了解到顺序执行早晨任务大约需要11秒钟。由于这些任务彼此独立,因此可以更快地完成这些任务。本节中,我们将重点介绍并发运行任务,然后我们将了解并发运行这些任务所需的时间和结果。
然后,我们将分析并发运行任务与按顺序运行任务的优势对比。
在以下代码中,我将使用 V 中并发编程的例子来说明日常早晨例程。要并发运行 hot_water()
、brush_teeth()
和 select_clothes()
函数,不需要更改它们的函数签名或函数体。我们将只是使用 go
关键字调用这些函数。V 提供了在不需要任何其他语法的情况下运行任何函数并发的优势。
正如我们从前面的章节中知道的那样,这三个任务都是无返回值函数。它们只是打印其开始和完成状态。这些任务还有time.sleep
语句,以模拟完成每个任务所需的时间。
图10.2——同时运行多个任务
正如我们在之前的章节中学到的那样,我们可以使用 void
函数创建一个并发进程数组。在本节中,我们将学习如何将并发任务添加到类型为 []thread
的线程数组 t
中。我们将定义一个线程数组 t
,然后向其中添加三个并发任务。在此之后,我们将等待线程数组中的所有任务完成执行。
以下代码演示了同时生成这三个任务的过程:
module main
import time
fn hot_water() {
println('Started Switch on Water heater:
$time.now().hhmmss()')
time.sleep(5 * time.second)
println('Water heater indicates hot water ready! :
$time.now().hhmmss()')
}
fn brush_teeth() {
println('Started brushing: $time.now().hhmmss()')
time.sleep(3 * time.second)
println('End Brushing: $time.now().hhmmss()')
}
fn select_clothes() {
println('Started choosing pair of
clothes: $time.now().hhmmss()')
time.sleep(3 * time.second)
println('End choosing pair of
clothes: $time.now().hhmmss()')
}
fn main() {
mut t := []thread{}
sw := time.new_stopwatch()
t << go hot_water()
t << go brush_teeth()
t << go select_clothes()
t.wait()
println('Your pre bath morning chores took:
$sw.elapsed().seconds() seconds')
}
在上述代码中,我们有三个代表 void
函数的任务。我们还有一个由变量 sw
表示的秒表,它在任务执行之前开始。最后,在三个任务完成执行后,我们计算了以秒为单位经过的时间。
我们还创建了一个类型为 []thread
的可变数组 t
。然后,我们使用线程句柄将线程数组添加上并发任务。我们让每个任务在与主程序运行的线程不同的线程上执行。我们可以看到所有函数 - hot_water()
,brush_teeth()
和select_clothes()
- 都是 void
函数,因此它们适合于 []thread
类型的线程数组中。
在启动这三个任务以便同时运行它们之后,程序等待所有这些任务完成执行。它通过调用 t.wait()
语句来实现。在数组中的线程上调用 wait()
函数会阻止主程序,直到该数组中保存的所有并发任务已经完成。
hot_water()
是耗时最长的任务,因为加热水需要约5秒钟。所以,一大早起床后,你首先要做的就是打开热水器。而不是浪费时间无所事事地等待水变热,你决定刷牙。假设你平均刷牙3秒钟(为了简洁起见,模拟时间)。当你刷牙时,你可以迅速走动并选择你想穿的衣服。
在这里,我们假设选出一件衣服并把它放在一边,以便你完成淋浴/洗澡后穿上它。
您可以通过将代码复制到文件中并使用 v run filename.v
命令从命令提示符运行该代码来运行此示例中的代码。多次运行代码将给出不同的结果。
在前面的代码中,用编程方式表示每个任务所需的时间,输出的结果如下一个中的输出类似。
以下是您可能会得到的第一个输出:
Started Switch on Water heater: 07:15:05
Started choosing pair of clothes: 07:15:05
Started brushing: 07:15:05
End choosing pair of clothes: 07:15:08
End Brushing: 07:15:08
Water heater indicates hot water ready! : 07:15:10
Your pre bath morning chores took: 5.0044861 seconds
以下是您可能会得到的第二个输出:
Started Switch on Water heater: 07:15:11
Started choosing pair of clothes: 07:15:11
Started brushing: 07:15:11
End Brushing: 07:15:14
End choosing pair of clothes: 07:15:14
Water heater indicates hot water ready! : 07:15:16
Your pre bath morning chores took: 5.0028225 seconds
对于每次运行,您将看到输出1或输出2。从每个输出中,我们可以看到三个任务同时开始执行,这表明它们正被并发执行。这两个输出的不同之处在于 brush_teeth()
和 select_clothes()
任务的完成顺序不同。这是因为它们都有一个执行时间为3秒钟,这意味着对于这些任务的执行没有预先确定的顺序。
与连续任务相比,它们需要约11秒的时间来完成,而使用并发编程方法,我们只需要用5秒钟就完成了所有三个任务的运行。
从这个例子中,我们可以看到程序完成所有并发任务所需的最大时间大约等于其他并发任务中运行时间最长的任务被程序产生的时间。这可以从输出结果中看出,例如5.0044861
和5.0029784
秒等。这几乎是加热水所需时间的时间。
并发产生耗时任务的优点在于实现更快的运行时间。在下一节中,我们将比较顺序和并发程序运行时间并理解结果。
正如我们在前面部分的顺序和并发代码的输出中注意到的那样,顺序代码所花费的时间大约为11秒。但是,当使用关键字 go
并等待它们同时运行所有任务时,同样的任务集合只需大约5秒钟即可完成。
顺序编程方法的结果强调了需要更快速度和更少的等待时间,这可以通过并发编程实现。
从 同时运行多个任务 部分的示例中可以看出,与在顺序运行任务时相比,我们将三个任务完成得快两倍。使用并发编程方法,速度几乎快了2.2倍(11/5 = 2.2)。
通过比较这些结果,我们了解到了许多好处,例如更快的运行时间和更少的等待时间。在下一部分中,我们将了解实现并发编程的各种方法。
到目前为止,我们已经了解了并发编程与顺序方法相比的优点。了解在 V 中处理生成并发任务的几种其他方法是有益的。迄今为止,我们所看到的编程示例都涉及 void
函数。在接下来的章节中,我们将学习如何为匿名函数编写并发代码,以及如何检索具有返回值的函数的结果。
到目前为止,我们已经产生了不返回任何值,使主线程并发运行的任务。我们访问了这些并发任务的句柄,并等待这些任务完成。如果并发任务具有返回值怎么办?在本节中,我们将更改上一节中我们运行 void
函数的早晨常规示例,以并发地运行返回值函数。
让我们考虑以下代码:
module main
import time
fn hot_water() string {
println('Started Switch on Water heater:
$time.now().hhmmss()')
time.sleep(5 * time.second)
println('Water heater indicates hot water ready! :
$time.now().hhmmss()')
return 'Hot water ready!'
}
fn brush_teeth() string {
println('Started brushing: $time.now().hhmmss()')
time.sleep(3 * time.second)
println('End Brushing: $time.now().hhmmss()')
return 'Sparkling Teeth ready!'
}
fn select_clothes() string {
println('Started choosing pair of
clothes: $time.now().hhmmss()')
time.sleep(3 * time.second)
println('End choosing pair of
clothes: $time.now().hhmmss()')
return 'Pair of clothes ready!'
}
fn main() {
mut t := []thread string{}
sw := time.new_stopwatch()
t << go hot_water()
t << go brush_teeth()
t << go select_clothes()
res := t.wait()
println('Your pre bath morning chores took:
$sw.elapsed().seconds() seconds')
println('*** Type Check ***')
println('Type of thread array of strings t:
${typeof(t).name}')
println('Type of res: ${typeof(res).name}')
println('*** Values returned by concurrently executed
tasks ***')
println(res)
}
我们可以修改任务,使每个任务返回消息。三个任务-热水,刷牙和选择衣服-现在将具有更新的方法签名,指示它们的返回值为字符串类型。线程数组的一部分的句柄返回的值被取到一个名为res
的变量中。我们还打印了变量t
的类型,这种情况下是由[]thread string
类型表示的字符串数组线程。这可以在以下输出中看到。res
变量是类型[]string
,因为所有三个任务都具有指示字符串返回类型的签名。
前面代码的输出如下所示:
Started Switch on Water heater: 07:15:40
Started choosing pair of clothes: 07:15:40
Started brushing: 07:15:40
End choosing pair of clothes: 07:15:43
End Brushing: 07:15:43
Water heater indicates hot water ready! : 07:15:45
Your pre bath morning chores took: 5.0048506 seconds
*** Type Check ***
Type of thread array of strings t: []thread string
Type of res: []string
*** Values returned by concurrently executed tasks ***
['Hot water ready!', 'Sparkling Teeth ready!', 'Pair of clothes ready!']
在这里,我们可以看到虽然并发生成的任务的执行顺序基于每个任务所需时间而不同,但res
数组是[]string
类型,按照与我们将句柄推入字符串数组线程t
相匹配的顺序保存值。
匿名函数是可以适应任何其他方法的函数,并且它们没有名称。它们的范围限于包含方法。我们在第7章函数的匿名函数部分中了解了这些函数。V简化了生成匿名函数以便它们同时运行。
在下一节中,我们将学习如何生成不带任何输入参数的匿名函数。然后,我们将查看生成具有输入参数的匿名函数的代码示例。
要生成没有输入参数的匿名函数,需要在go
关键字后定义匿名函数。由于匿名函数只需要在定义后立即启动,因此当与go
关键字结合使用时,我们需要在匿名函数的主体之后调用empty()
。以下语法显示如何生成匿名函数:
module main
fn main() {
t := go fn () string {
return 'hi'
}()
x := t.wait()
println(typeof(x).name) // string
println(x) // hi
}
从前面的代码中,我们可以看到我们正在生成一个匿名函数,该函数返回一个字符串消息表明"hi"。在这里,使用go
关键字同时生成运行的匿名函数在其主体之后有一个空()
。这个空()
表示函数签名的输入参数部分。在这种情况下,匿名函数不接受任何输入参数,因此我们用空()
关闭函数主体。
要生成接受输入参数的匿名函数,需要在go关键字之后定义匿名函数。由于匿名函数只需要在定义后立即启动,因此与go关键字结合使用时,我们需要在匿名函数主体之后提供一个列表,其中包含与匿名函数的输入参数类型列表中指定的类型匹配的参数。在圆括号中,它们表示为(i,'hello')
,以表明这些值作为输入参数传递给匿名函数。在这种情况下,匿名函数有(i int, msg string)
的签名,这是输入参数,所以我们必须用(i, 'hello')
关闭函数体,表示通过对应参数的值调用匿名函数。
以下是示例如下:
module main
fn main() {
mut t := []thread string{}
for i in 1 .. 3 {
t << go fn (i int, msg string) string {
return 'iteration: $i, message: $msg'
}(i, 'hello') // <- arguments must match list in
// the anonymous function definition
}
res := t.wait()
println('Type of t: ${typeof(t).name}')
println('Type of res: ${typeof(res).name}')
println(res)
}
从前面的代码中,我们可以看到我们正在生成一个返回字符串的匿名函数。在这里,被使用go
关键字同时生成运行的匿名函数已提供了范围为1..3
中每个迭代值作为第一个参数,以及相同消息hello的字符串类型作为第二个参数。它们一起表示在圆括号中;(i,'hello')
。其中(i,'hello')
表示作为输入参数传递给匿名函数的值。在这种场景中,匿名函数的定义有(i int, msg string)
签名作为输入参数,因此我们必须使用(i, 'hello')
来关闭函数体,表示通过对应参数的值调用匿名函数。
以下是输出:
Type of t: []thread string
Type of res: []string
['iteration: 1, message: hello', 'iteration: 2, message: hello']
从前面的输出中,我们可以看到t变量的类型是[]thread string
。这意味着等待t
将产生一个字符串数组。因此,在这种情况下,t.wait()
的结果被分配给res
变量,它是[]string
类型。在输出的最后一行,我们打印了res
数组的值,其中每个元素都被格式化为包含1..3
范围内的迭代数和返回由匿名函数提供的字符串消息hello
的字符串表示形式。
到目前为止,我们只学习了如何同时生成函数以便它们并发运行。在下一节中,我们将学习如何在主线程和并发任务之间共享数据。
您可以在主线程和被生成以并发运行的任务之间共享或交换数据。V允许您在主线程和生成任务之间共享数据,但只能使用结构体、映射或数组类型的变量。在这种情况下,需要使用shared
关键字来指定这些变量。使用shared
关键字标记的变量需要在读取时使用rlock
进行访问,或者在想要读取/写入/修改这些变量时使用lock
。
让我们考虑一个筹款者为高尚目的筹款的场景。如果捐赠者或多个捐赠者希望为该基金做出贡献,则可以向基金经理(代码中的主要功能)捐款。当捐款达到基金会设定的目标金额时,基金经理将停止筹集资金。假设这是并发发生的,一直到收到的金额大于或等于目标金额,此后基金经理将停止进一步筹资。
由于只能使用结构体、映射或数组类型的变量进行数据共享,因此我们将定义一个表示基金的结构体,如下所示:
struct Fund {
name string
target f32
mut:
total f32
num_donors int
}
上述代码包含四个字段。其中两个是name
和target
,其中name
表示基金名称,target
表示必须达到的目标金额。name
和target
将由基金经理(主程序)设置。另外两个字段是total
和num_donors
:total
用于表示通过捐赠积累在基金中的金额,而num_donors
字段用于表示到目前为止为该基金作出贡献的捐赠者总数。
现在,我们定义一个名为collect
的Fund
结构体方法,该方法接受一个名为amt
的输入参数,该参数表示从任何慷慨的捐赠者筹集的金额,如下所示:
fn (shared f Fund) collect(amt f32) {
lock f { // 读写锁
if f.total < f.target {
f.num_donors += 1
println('$f.num_donors \t before: $f.total \t
funds received: $amt \t total: ${amt +
f.total}')
f.total += amt
}
}
}
collect
方法具有一个接收器参数f
,用于标记Fund
结构体,并使用共享关键字进行标记。我们将接收器参数标记为shared
,因为collect
方法将被多个线程同时访问。在这种情况下,需要避免冲突,并使用rlock
或lock
来获得对由并发任务访问的实例f的锁定。在donate
方法中,我们正在读取和更新total
和num_donors
可变字段,因此需要使用lock
来获取读写锁,如上述代码所示。
现在,让我们继续看看基金经理(主函数)将如何从并发来源筹集资金。我们必须做的第一件事是创建一个Fund
的共享变量,其名称是为筹集资金而募集的原因,并设置达到基金所需目标金额。这可以以以下方式在程序中表示:
shared fund := Fund{
name: '高尚目的'
target: 1000.00
}
在这里,我们可以看到,高尚目的需要最少1000.00美元的目标金额。已定义了一种高尚的筹款基金,假设捐赠者可以捐赠100到250美元的最低和最高金额。可以在以下代码中看到:
import rand
fn donation() f32 {
return rand.f32_in_range(100.00, 250.00)
}
注意使用rand
模块的f32_in_range(100.00,250.00)
函数的用法。donation
函数返回随机金额,该金额代表以美元计算的慷慨捐赠者捐款的金额。rand
模块提供可用的f32_in_range
函数,该函数返回在指定范围内大于或等于起始值但小于结束值的均匀分布32位浮点值。
接下来,基金经理通过调用Fund
结构体的collect
方法不断寻求捐款并更新已筹集的总金额,如下所示:
for {
if fund.total >= fund.target {
break
} else {
h := go donation()
amt := h.wait()
go fund.collect(amt)
}
}
从上面的代码片段中,用于收集捐赠的fund.collect(amt)
进程被衍生到各个线程中。同时,基金经理(主程序)具有对基金数据的共享访问权限,因此基金经理将不断收集捐款,直到总金额fund.total
大于或等于目标金额fund.target
。
将我们所看到的所有代码片段放在一起,完整的源代码如下所示:
module main
import rand
struct Fund {
name string
target f32
mut:
total f32
num_donors int
}
fn (shared f Fund) collect(amt f32) {
lock f { // read - write lock
if f.total < f.target {
f.num_donors += 1
f.total += amt
println('$f.num_donors \t before: ${f.total – amt}
\t funds received: $amt \t total: $f.total')
}
}
}
fn donation() f32 {
return rand.f32_in_range(100.00, 250.00)
}
fn main() {
shared fund := Fund{
name: 'A noble cause'
target: 1000.00
}
for {
if fund.total >= fund.target {
break
} else {
h := go donation()
go fund.collect(h.wait())
}
}
rlock fund { // acquire read lock
println('$fund.num_donors donors donated for
$fund.name')
println('$fund.name raised total fund amount: \$
$fund.total')
}
}
在上面的代码中,我们可以看到主线程(我们假设为基金经理)创建了一个共享对象作为基金,并在获取资金的阅读锁定之后汇集捐款。上面代码的输出如下所示:
Figure 10.3 – Console output
从上述输出中,我们可以看到,基金经理(在我们的例子中为主方法)为一个高尚的目的发起了基金,并从8个捐赠者那里收集了捐款。8个捐赠者筹集的总金额为1039.664美元。在达到fund.total >= fund.target
的条件之后,主方法停止进一步收集捐款。
请注意,您可能会看到不同数量的捐赠者和总募集基金金额,因为我们使用rand.f32_in_range()
函数生成随机金额。
在本章中,我们首先通过简要示例解释并发性和并行性,从而提供了并发性和并行性的简要介绍。然后我们了解了与并发编程相关的基本术语,如程序、进程、线程和任务。然后我们了解了用于在V中编写并发程序的语法,包括go
关键字。我们还了解了V中的基本编程模块,如时间模块和线程类型。
接下来,我们根据我在本章开头介绍的现实生活场景来学习如何在V中以编程方式实现此场景的顺序和并发版本。我们随后比较了顺序和并发程序,并了解了并发编程相对于顺序执行任务的优势。在解释我们以编程方式实现的场景时,我们还学习了如何使用[]thread
生成多个任务。这个演示帮助我们学习如何确保主程序在所有并发任务完成执行之前等待[]thread
处理器。
然后,我们继续看到在V中处理并发的一些编程方法,包括生成具有返回值的函数以及使用wait()
函数并发运行时在函数上获得返回值。我们还学习了如何生成匿名函数,以使它们并发运行,并处理各种情况,包括生成具有和不具有输入参数的匿名函数。最后,我们学习了如何使用共享关键字在主线程和并发任务之间共享数据,该关键字只能用于数组、映射或结构类型的对象。
在获得V中并发编程的基础知识之后,在下一章中,我们将学习有关通道的内容。