龙空技术网

Go语言并发编程:3 使用内存共享的线程通信

启辰8 15

前言:

目前同学们对“c语言500行代码项目”可能比较看重,小伙伴们都想要分析一些“c语言500行代码项目”的相关资讯。那么小编也在网摘上网罗了一些有关“c语言500行代码项目””的相关文章,希望你们能喜欢,看官们快快来学习一下吧!

本章涵盖利用我们的硬件架构的线程间通信使用内存共享进行通信识别竞争条件

为解决常见问题而协同工作的执行线程需要某种形式的通信。这就是所谓的线程间通信(ITC)或进程间通信(IPC)来指代进程。通信类型分为两个主要类:内存共享和消息传递。在本章中,我们将重点介绍前者。内存共享类似于让所有执行共享一个大的空画布(进程的内存),每个执行都可以在该画布上写入自己的计算结果。我们可以协调执行,以便他们使用这个空画布进行协作。另一方面,消息传递正是它听起来的样子。就像人一样,线程可以通过相互发送消息来进行通信。在第 8 章中,我们研究了使用通道在 Go 中传递消息。

我们在应用程序中使用的线程通信类型将取决于我们尝试解决的问题类型。内存共享是ITC的常用方法,但是,正如我们将在本章中看到的,它带来了一系列挑战。

3.1 共享内存

通过记忆共享进行交流就好像我们试图与朋友交谈,但我们不是交换信息,而是使用白板(或一张大纸),我们正在交换想法、符号和抽象(见图 3.1)。

图3.1 通过内存共享进行通信

这类似于使用内存共享的并发编程中发生的情况。我们分配进程内存的一部分(例如,共享数据结构或变量),并让不同的 goroutines 同时处理此内存。在我们的类比中,白板是各种goroutine使用的共享内存。

在 Go 中,我们的 goroutines 可能存在于几个内核级线程下。因此,我们运行多线程应用程序的硬件和操作系统体系结构需要在属于同一进程的线程之间启用这种类型的内存共享。如果我们的系统只有一个处理器,架构可以很简单。我们可以为同一进程上的所有内核级线程提供相同的内存访问权限,并在线程之间切换上下文,让每个线程随心所欲地读取和写入内存。

但是,当我们的系统具有多个处理器(或多核系统)时,情况变得更加复杂。之所以出现这种复杂性,是因为计算机体系结构通常涉及CPU和主内存之间的各种缓存层。

图3.2显示了典型总线架构的简化示例。在这种架构中,处理器在需要从主内存读取或写入时使用系统总线。在处理器使用总线之前,它会侦听以确保总线处于空闲状态,并且未被其他处理器使用。一旦总线空闲,处理器就会发出内存位置请求,并返回侦听并等待总线上的回复。

图 3.2 具有两个 CPU 和一个缓存层的总线架构。

随着我们扩展系统中处理器的数量,这条总线变得越来越繁忙,并成为我们添加更多处理器的能力的瓶颈。为了减少总线上的负载,我们可以使用缓存。这些缓存的目的是使内存内容更接近需要的位置,从而提高性能。缓存还减少了系统总线上的负载,因为 CPU 现在可以从缓存中读取大部分所需数据,而不是查询内存。通过这种方式,总线不会成为瓶颈。我们在图 3.2 中显示的示例是一个简化的体系结构,具有两个 CPU 和一个缓存层。通常,现代体系结构包含更多的处理器和多层缓存。在此图所示的场景中,我们有两个并行运行的线程,它们希望通过内存共享进行通信。

在我们的场景中,假设线程 1 尝试从主内存中读取变量。系统将包含变量的内存块的内容放入更靠近 CPU 的缓存中(通过总线)。这样,当线程 1 需要再次读取或更新该变量时,它将能够使用缓存更快地执行该操作。它不需要通过尝试再次从主内存读取变量来使系统总线过载。我们在图3.3中显示了这一点。

图 3.3 从主存储器读取内存块并将其存储在处理器的缓存中,以便更快地检索

让我们继续我们的示例。现在线程 1 决定更新此变量的值。这会导致缓存的内容使用此更改进行更新。如果我们不做任何其他事情,线程 2 可能想要读取这个相同的变量,当它从主内存中获取它时,它将有一个过时的值,而不会更改线程 1。

一种解决方案是执行所谓的缓存直写:当线程 1 更新缓存内容时,我们将更新镜像回主内存。但是,这并不能解决线程 2 在另一个本地 CPU 缓存中具有相同内存块的过时副本的情况。为了解决这个问题,我们可以让缓存侦听总线内存更新消息。当缓存注意到它在其缓存空间中复制的内存更新时,它会应用更新或使包含更新内存的缓存行失效。如果我们使缓存行无效,则下次线程需要该变量时,它将不得不从内存中获取它,以获取更新的副本。我们在图3.4中显示了这个系统。

图 3.4 使用缓存更新架构中的共享变量

如何处理多处理器系统中内存和缓存上的读写的机制就是所谓的缓存一致性协议。带有失效的写回是一个此类协议的概述。值得注意的是,现代架构通常混合使用这些协议。

连贯性墙

微芯片工程师担心,在扩展处理器内核数量时,缓存一致性将成为限制因素。随着处理器的增加,实现缓存一致性将变得更加复杂和昂贵,并可能最终限制性能。这就是所谓的一致性墙。

3.2 实践中的内存共享

现在让我们选择几个例子来展示我们如何在 Go 中的并发程序中利用 goroutines 之间的共享内存。在本节中,我们将探讨两个示例。首先,我们看到两个goroutine之间的简单变量共享,向我们展示了内存逃逸分析的概念。稍后我们看到一个更复杂的应用程序,其中多个 goroutines 协同工作以并行下载和处理多个网页。

3.2.1 在 goroutines 之间共享变量

我们如何让两个 goroutines 共享内存?在第一个示例中,我们创建了一个goroutine,它将与主goroutine(执行main函数)共享内存中的变量。该变量的作用就像倒数计时器一样。我们将有一个 goroutine每秒减少这个变量的值,另一个 goroutine 更频繁地读取变量并将其输出到控制台上。图 3.5 显示了两个执行此操作的 goroutine。

图 3.5 两个共享倒数计时器变量的 goroutines 的应用

我们在下面的清单中实现了这一点。在此代码中,主线程为一个名为 count 的整数变量分配空间,然后与新创建的 goroutine共享对该变量的内存指针引用,调用函数 countdown()。此函数每 1 秒更新一次此变量,方法是将其值减少 1 直到变为 0。主 goroutine 每半秒读取一次这个共享变量并输出它。这样,两个 goroutines 在指针位置共享内存。

示例 3.1 在内存中共享变量的 Goroutines

package main import (    "fmt"    "time") func main() {    count := 5    #A    go countdown(&count)    #B    for count > 0 {    #C        time.Sleep(500 * time.Millisecond)    #C        fmt.Println(count)    #C    }} func countdown(seconds *int) {    for *seconds > 0 {        time.Sleep(1 * time.Second)        *seconds -= 1    #D    }}
注意

您可以访问 以查看本书中的任何列表。

由于我们读取共享变量的值的频率高于更新它的频率,因此相同的值在我们的输出中被多次输出。以下是控制台输出:

$ go run countdown.go5443322110

这里发生的事情是,我们有一个非常简单的内存共享,并发程序。一个 goroutine 正在更新特定内存位置的内容,而另一个线程只是读取其内容。

如果从清单 3.1 中删除 go 关键字,程序将变为顺序程序。它将在主堆栈上创建变量计数,并将对它的引用传递给倒计时函数。倒计时函数将需要 5 秒才能返回,在此期间它将通过减去 1 每秒更新主函数堆栈上的值。当函数返回时,count 变量的值为 0,main 函数不会进入循环,而是终止,因为计数的值为 0。

3.2.2 逃逸分析

我们应该在哪里为变量计数分配内存空间?这是 Go 编译器必须为我们创建的每个新变量做出的决定。它有两个选择:在函数的堆栈或主进程的内存中分配空间,我们称之为堆空间。

在上一章中,当我们讨论共享相同内存空间的线程时,我们看到了每个线程如何拥有自己的堆栈空间,但共享进程的主内存。当我们在单独的 goroutine中执行 countdown() 函数时,count 变量不能存在于主函数的堆栈中。对于 Go 的运行时来说,允许一个 goroutine 读取或修改另一个 goroutine 堆栈的内存内容是没有意义的。这是因为 goroutines 可能具有完全不同的生命周期。当另一个 goroutine 需要修改它时,一个 goroutines 的堆栈可能不再可用。Go 的编译器足够聪明,可以意识到我们何时在 goroutines 之间共享内存。当它注意到这一点时,它会在堆而不是堆栈上分配内存,即使我们的变量看起来像是局部变量,属于堆栈。

定义

用技术术语来说,当我们声明一个看起来属于局部函数堆栈但被分配到堆内存中的变量时,我们说该变量已转义到堆中。转义分析是确定是否应在堆而不是堆栈上分配变量的编译器算法。

在许多情况下,变量会转义到堆中。每当变量在函数的堆栈帧范围之外共享时,该变量就会在堆上分配。在 goroutines 之间共享变量的引用就是这样一个例子。图 3.6 向我们展示了一个图形表示。

图 3.6 在堆内存上共享变量的 Go例程

在 Go 中,在堆上使用内存而不是堆栈会产生额外的小成本。这是因为当我们使用完内存时,需要通过 Go 的垃圾回收清理堆。垃圾回收器遍历堆中不再被任何 goroutine 引用的对象,并将空间标记为可用,以便可以重用。当我们使用堆栈上的空间时,当函数完成时,此内存将被回收。

我们可以通过要求编译器显示优化决策来判断变量转义到堆内存。我们可以通过使用编译时选项 -m 来做到这一点:

$ go tool compile -m countdown.gocountdown.go:7:6: can inline countdowncountdown.go:7:16: seconds does not escapecountdown.go:15:5: moved to heap: count

在这里,编译器告诉我们哪些变量正在转义到堆内存中,哪些变量保留在堆栈上。在第 7 行,秒指针变量不会转义到堆中,因此停留在我们的倒计时函数的堆栈上。但是,编译器将变量计数放在堆上,因为我们与另一个 goroutine共享变量。

如果我们必须从代码中删除 go 调用,将其转换为顺序程序,编译器不会将变量计数移动到堆中。以下是我们删除 go 关键字后的输出:

$ go tool compile -m countdown.gocountdown.go:7:6: can inline countdowncountdown.go:16:14: inlining call to countdowncountdown.go:7:16: seconds does not escape

请注意,现在,我们没有收到变量计数的消息“移动到堆”。另一个变化是,现在我们还收到一条消息,指出编译器正在内联函数调用以倒计时。内联是一种优化,在某些情况下,编译器将函数调用替换为函数的内容。编译器这样做是为了提高性能,因为调用函数会产生轻微的开销。开销来自准备新的函数堆栈,将输入参数传递到新堆栈上,并使程序跳转到函数上的新指令。当我们并行执行函数时,内联函数是没有意义的,因为函数是使用单独的堆栈执行的,可能在另一个内核级线程上。

使用 goroutines,我们将失去一些编译器优化,例如内联,并且通过将共享变量放在堆上来增加开销。权衡是,通过以并发方式执行我们的代码,我们有可能实现加速。

3.2.3 网页字母频率

现在让我们看一个涉及两个以上 goroutines 的示例。对于使用内存共享的线程通信的第二个示例,我们想找出英文字母在公共文本中出现的频率。为此,我们将编写一个程序来处理网页,方法是下载网页,然后计算字母表中每个字母在页面上出现的频率。当程序完成时,它应该给我们一个频率表,其中包含每个字符出现的频率。

让我们首先以正常的顺序方式开发它,然后更改我们的代码,使其以并发方式运行。我们在图 3.7 中显示了开发此类程序所需的步骤和数据结构。我们使用切片整数数据结构作为包含每个字母计数结果的字母表。我们的程序将检查网页列表,一次一个,下载并扫描网页的内容,并阅读和更新页面上遇到的每个英文字母的数量。

图3.7 单例程计算各种网页上的字母

我们可以从编写一个简单的函数开始,该函数从URL下载所有文本,然后迭代下载文本中的每个字符。在执行此操作时,我们会更新任何英文字母字符(不包括标点符号、空格等)的字母频率计数表。下面的清单显示了它的实现。

清单 3.2 为 URL 生成字母频率计数的函数

package main import (    "fmt"    "io "    "net/http"    "strings") const allLetters = "abcdefghijklmnopqrstuvwxyz" func countLetters(url string, frequency []int) {    resp, _ := http.Get(url)    #A    defer resp.Body.Close()    body, _ := io.ReadAll(resp.Body)    for _, b := range body {    #B        c := strings.ToLower(string(b))        cIndex := strings.Index(allLetters, c)    #C        if cIndex >= 0 {            frequency[cIndex] += 1    #D        }    }    fmt.Println("Completed:", url)}
注意

为了简洁起见,我们忽略了列表中的错误处理。

该函数首先在其输入参数中下载 URL 的内容。然后,它使用 for 循环遍历每个字符,并将每个字符转换为小写。我们这样做是为了将大写和小写字符视为等效字符。如果我们在包含英文字母的字符串中找到该字符,则我们在该字符表示的 Go 切片条目中增加该字符的计数。在这里,我们使用 Go 切片作为我们的字符频率表。在此表中,元素 0 表示字母 a 的计数,元素 1 表示 b,2 表示 c 等。在函数结束时,在我们处理整个下载的文档后,我们输出一条消息,显示函数完成的 URL。

现在让我们尝试使用一些网页运行此函数。理想情况下,我们想要一些永远不会改变的静态东西。如果网页的内容只是没有文档格式/图像/链接等的文本,那也很好。例如,新闻网页是不够的,因为内容经常更改并且格式丰富。

该网站 rfc-editor.org 包含一个有关互联网的技术文档数据库。文档(称为征求意见或 RFC)包括规范、标准、策略和备忘录。这是本练习的一个很好的来源,因为文档不会更改,我们可以下载没有格式的纯文本文档。另一个优点是 URL 具有增量文档 ID,这使得它们可预测。我们可以使用 rfc-editor.org/rfc/rfc {ID} 的 URL 格式 .txt .例如,如果我们使用 URL rfc-editor.org/rfc/rfc1001.txt,我们可以获得文档 ID 1001 .

现在我们只需要一个 main 函数,它多次运行我们的 countLetters() 函数,每次使用不同的 URL,传入相同的频率表并让它更新字符数。下面的清单显示了这个主要功能。

示例 3.3 主函数调用具有不同 URL 的 countLetters()

func main() {    var frequency = make([]int, 26)    #A    for i := 1000; i <= 1200; i++ {    #B        url := fmt.Sprintf(";, i)        countLetters(url, frequency)    #C    }    for i, c := range allLetters {        fmt.Printf("%c-%d ", c, frequency[i])    #D    }}

在 main 函数中,我们创建一个新切片,该切片将存储包含字母频率表的结果。然后我们指定将 201 个文档从 rfc1000.txt 下载到 rfc1200.txt .该程序按顺序(即一个接一个)调用我们的countLetters()函数来下载和处理每个网页。根据我们的互联网连接速度,该程序可能需要几秒钟到几分钟的时间。完成后,main 函数将输出频率切片变量的内容。程序的输出如下所示:

$ time go run charcountersequential.goCompleted: : : : :  b-112402 c-302799 d-272588 e-913705 f-165931 g-130191 h-249050 i-539694 j-16454 k-45356 l-257370 m-216499 n-533842 o-518742 p-206760 q-14651 r-531048 s-514870 t-695999 u-187716 v-68991 w-83091 x-27722 y-95821 z-7647real   1m35.822suser   0m1.183ssys    0m0.819s

程序输出的最后一行(在计时之前)包含所有 201 个文档中每个字母的实际计数。列表中的第一个条目表示字母 a 的计数,第二个条目表示字母 b 的计数,依此类推。快速浏览一下就会发现,字母 e 是我们文档中最常用的字母。该程序还需要 1 分 35 秒才能完成。

现在让我们尝试通过使用并发编程来提高程序的速度。在图 3.8 中,我们展示了如何使用多个 goroutines 并发下载和处理每个网页,而不是一个接一个地下载和处理。这里的诀窍只是通过使用 go 关键字并发运行我们的 countLetters() 函数。

图 3.8 Goroutines 协同工作以计算字符数

为了实现这一点,在下面的列表中,我们对 main 函数进行了两项更改。首先,我们将 go 添加到我们的函数调用 countLetters() 中。这只是意味着我们正在创建 201 个 goroutine,每个网页一个。然后,每个goroutine将同时下载和处理其文档(即一起下载和处理,而不是一个接一个)。第二个更改是等待几秒钟,直到所有 go 例程完成。我们需要这个;否则,当主 goroutine 完成时,进程在我们完成处理完所有下载之前终止。这是因为在 Go 中,当主 goroutine 完成时,整个过程就会终止。即使其他 goroutines 仍在执行,也会发生这种情况。

警告

使用来自多个 goroutines 的 countLetters() 函数将产生错误的结果。我们在这里这样做只是为了演示目的。

清单 3.4 创建goroutines和共享频率片的主函数

func main() {    var frequency = make([]int, 26)    for i := 1000; i <= 1200; i++ {        url := fmt.Sprintf(";, i)        go countLetters(fmt.Sprintf(url), frequency)    #A    }    time.Sleep(10 * time.Second)    #B    for i, c := range allLetters {        fmt.Printf("%c-%d ", c, frequency[i])    #C    }}
注意

使用 Sleep() 不是等待另一个 goroutine 完成的好方法。事实上,如果你的互联网连接速度很慢,你可能需要增加清单 3.4 的等待时间。在第5章中,我们将讨论如何使用条件变量和信号量来完成此任务。此外,在第 6 章中,我们将介绍等待组的概念,它允许我们阻止执行 goroutine,直到某些任务完成。

请注意,在此示例中,goroutines 在内存中共享相同的数据结构。当我们在 main 函数中初始化 Go 切片时,我们在堆上为它分配空间。当我们创建 goroutines 时,我们将对包含 Go 切片的内存位置的相同引用传递给所有 goroutine。然后,201 个 goroutines 将同时读取和写入相同的频率片。通过这种方式,线程可以合作并协同工作以更新相同的内存空间。这就是线程内存共享的全部内容。您有一个要与其他线程共享的数据结构或变量。与顺序编程的区别在于,goroutine可能会将值写入变量,但是当它读回它时,它可能会有所不同,因为另一个goroutine可能已经改变了它。

如果您运行该程序,您可能已经注意到它的问题。下面是运行后输出的外观:

$ time go run charcounterconcurrent.goCompleted: : : : :  b-112034 c-299303 d-269461 e-885298 f-164939 g-129492 h-246896 i-529325 j-16452 k-45262 l-254625 m-214661 n-524692 o-510210 p-205281 q-14622 r-516746 s-500445 t-671512 u-185652 v-68621 w-82657 x-27681 y-95368 z-7642real   0m11.485suser   0m0.940ssys    0m0.430s

首先,我们注意到下载的完成速度比顺序下载快得多。我们期待这一点。一次性完成所有下载应该比一个接一个地下载更快。其次,输出消息不再有序。由于我们同时开始下载所有文档,因此有些文档会比其他文档更早完成,因为它们的大小都不同。较早完成的那些首先输出其已完成的消息。我们处理页面的顺序对于此应用程序来说并不重要。

问题出在结果上。当我们将顺序运行的字符数与并发运行的字符数进行比较时,我们注意到了这种差异:大多数字符在并发版本中的计数较低。例如,字母 e 在顺序运行中的计数为 913705,在并发运行中的计数为 885298(您的并发结果可能与我的不同)。

我们还可以尝试多次运行顺序和并发程序。结果将根据我们的设置而有所不同,例如互联网连接和处理器速度。但是,当我们比较它们时,我们看到顺序版本每次都给我们相同的结果,但并行版本在每次运行时给我们的值略有不同。这是怎么回事?

这就是所谓的争用条件 - 当我们有多个线程(或进程)共享一个资源并且它们相互跳过时,会给我们带来意想不到的结果。现在让我们更详细地了解竞争条件发生的原因。在下一章中,我们将看到如何解决并发字母频率程序的问题。

3.3 比赛条件

争用条件是指程序尝试同时执行许多操作时发生的情况,其行为取决于独立不可预测事件的确切时间。正如我们在字母频率节目中看到的那样,我们的程序最终给出了意想不到的结果。有时结果甚至更具戏剧性。我们的并发代码可能会在很长一段时间内运行良好,然后有一天它崩溃导致更严重的数据损坏。发生这种情况是因为并发执行缺乏适当的同步,并且相互单步跳过。

系统范围中断

在大型国际投资银行特纳贝尔福特(Turner Belfort)24楼举行的会议上,气氛黯淡得如此黯淡。该公司的软件开发人员开会讨论关键核心应用程序失败并导致系统范围中断后的最佳前进方向。系统故障导致客户账户报告其持有的金额有误。

“伙计们,我们这里有一个严重的问题。我发现中断是由我们代码中的竞争条件引起的,该条件是不久前引入的,并于昨晚触发,“高级开发人员 Mark Adams 说。

房间里一片寂静。落地窗外的小车在繁忙的城市交通中缓慢而无声地匍匐前进。高级开发人员立即了解情况的严重性,意识到他们现在将全天候工作以解决问题并整理数据存储中的混乱。经验不足的开发人员知道竞争条件很严重,但不知道究竟是什么原因造成的,因此闭嘴。

最终,交付经理 David Holmes 用这个问题打破了沉默:“应用程序已经运行了几个月,没有任何问题,我们最近没有发布任何代码,那么软件到底怎么可能崩溃?!

每个人都摇了摇头,回到了自己的办公桌前,留下大卫一个人在房间里,困惑不解。他拿出手机,搜索“竞争条件”一词。

当并发参与者进行交互时,这种类型的错误也会在现实生活中发生。例如,一对夫妇可能共享一个家庭购物清单,例如在冰箱门上用白板记号笔书写的杂货清单。在两人去办公室之前的早晨,他们独立决定下班后购买杂货。两人拍下了清单的照片,然后去商店购买了所有物品。每个人都不知道对方是否决定做同样的事情。这就是他们最终得到他们需要的东西中的两个的方式(见图3.9)。

图 3.9 竞争条件有时也会在现实生活中发生。

其他比赛条件

软件争用条件是发生在并发程序中的条件。竞争条件也发生在其他环境中,例如分布式系统、电子电路,有时甚至在真实的人类交互中。

在网页字母频率应用程序中,我们有一个竞争条件,导致程序少报字母计数。让我们尝试编写一个更简单的并发程序来突出显示竞争条件,以便我们可以更好地理解这个问题。稍后,在接下来的章节中,我们将讨论避免竞争条件的不同方法。在下一章中,我们将使用互斥体修复字母计数器程序。

3.3.1 吝啬和花钱:创造竞争条件

Stingy 和 Spendy 是两个独立的 goroutine。Stingy努力工作并赚取现金,但从不花一美元。斯潘迪则相反,花钱却一无所获。两个goroutine共享一个共同的银行账户。为了演示竞争条件,我们将使用 Stingy 和 Spendy,让他们每次赚取和花费 10 美元,1 万次。由于 Stingy 花费的金额与 Spendy 赚取的金额完全相同,如果我们的编程是正确的,那么我们应该以与我们开始时相同的金额完成(见图 3.10)。

图 3.10 使用两个 goroutine创建争用条件的方案

在下面的列表中,我们首先创建函数 Stingy 和 Spendy。 stingy() 和 spendy() 函数都迭代 1 万次,每次调整一个共享货币变量——stingy() 函数每次加 10 美元,spendy() 函数每次减去它。

警告

使用来自多个 goroutines 的以下 stingy() 和 spendy() 函数将产生竞争条件。我们在这里这样做只是为了演示目的。

清单 3.5 吝啬和花钱函数

func stingy(money *int) {    #A    for i := 0; i < 1000000; i++ {        *money += 10    #B    }    fmt.Println("Stingy Done")} func spendy(money *int) {    #A    for i := 0; i < 1000000; i++ {        *money -= 10    #C    }    fmt.Println("Spendy Done")}

我们现在需要使用单独的 goroutine调用这两个函数。我们可以编写一个 main 函数来初始化共享货币变量,创建 goroutine,并将变量引用传递给新创建的 goroutine。在下面的列表中,我们将普通银行账户初始化为 100 美元。在创建 goroutines 后,我们也有主 goroutine 休眠 2 秒钟,以等待它们终止。在第 6 章中,我们将讨论等待组,这些组将允许我们阻塞直到任务完成,而不必睡眠几秒钟。主线程重新唤醒后,它会打印 money 变量中的剩余货币。

3.6 吝啬和花钱的主函数

package main import (    "fmt"    "time")... func main() {    money := 100    #A    go stingy(&money)    #B    go spendy(&money)    #B    time.Sleep(2 * time.Second)    #C    println("Money in bank account: ", money)}

在此列表中,我们预计它将因此产出 100 美元。毕竟,我们只是在变量 10 万次上加减 1。这是模拟 Stingy 赚了 10 万,而 Spendy 花费了相同的金额,给我们留下了 100 的初始值。但是,这是程序的输出:

$ go run stingyspendy.goSpendy DoneStingy DoneMoney in bank account: 4203750

账户中还剩下4多万美元!Stingy会对这个结果感到非常满意。然而,这纯属偶然。事实上,如果我们再次运行它,我们的帐户最终可能会低于零:

$ go run stingyspendy.goStingy DoneSpendy DoneMoney in bank account: -1127120

让我们试着通过演练一个场景来理解为什么我们会得到这些奇怪的结果。为了简单起见,让我们假设我们只有一个处理器,因此不会并行进行任何处理。图 3.11 显示了在我们吝啬和花费的程序中发生的一个此类竞争条件的场景。

图3.11 Stiny和Spendy之间的竞争条件,解释

在时间戳 01 到 03 处,Spendy 正在执行。线程从共享内存中读取值 100,并将其放入处理器的寄存器中。然后它减去 10 并将 90 美元写回共享内存。在时间戳 04 到 06 时,轮到吝啬了。它读取值 90,将值加 10 并将 100 写回堆上的共享变量。时间戳 07 到 11 是事情开始变坏的时候。在时间戳 07 处,Spendy 从主内存读取值 100,并将该值写入其处理器寄存器。在时间戳 08 处,发生了上下文切换,Stingy 的 goroutine 现在开始在处理器上执行。它从共享变量中读取 100 的值,因为 Stingy 的线程还没有机会更新它。在时间戳 09 和 10 处,goroutines 减去并加 10。然后 Spendy 写回值 90,在时间 11,Stingy 的线程通过将 110 写入共享变量来覆盖它。我们总共花了 20 并赚回了 20,但最终我们的帐户中多了 10 个。

定义

原子来自希腊语atomos,意思是不可分割的。在计算机科学中,当我们提到原子操作时,我们指的是不能中断的操作。

我们遇到了这个问题,因为操作 *money += 10 和 *money -= 10 不是原子的,编译后它们转换为多个指令。因此,指令可能会受到另一个goroutine的干扰并导致竞争条件。当这种越界发生时,我们会得到不可预测的结果。

定义

代码中的一个关键部分是一组指令,应该在不受其他执行干扰的情况下执行这些指令,从而影响该部分中使用的状态。当允许这种干扰发生时,可能会出现争用条件。

即使指令是原子的,我们仍然可能遇到问题。还记得在本章开头我们是如何讨论处理器缓存和寄存器的吗?每个处理器内核都有一个本地缓存和寄存器,用于存储经常使用的变量。当我们编译代码时,编译器有时会应用优化以将变量保留在 CPU 寄存器/缓存上,然后再发出将它们刷新回内存的指令。这意味着在单独的 CPU 上运行的两个 goroutine 在完成定期刷新到内存之前,可能无法看到彼此的更改。

当我们在并行环境中执行编写不佳的并发程序时,更有可能出现这些类型的错误。并行运行的 Goroutines 增加了这些类型的竞争条件发生的机会,因为现在我们将同时执行一些步骤。在我们的 Stingy 和 Spendy 程序中,两个 goroutines 更有可能同时读取 money 变量,然后在并行运行时将其写回。

需要注意的是,在这种情况下,当我们使用 goroutines(或任何用户级线程)并且仅在单个处理器上运行时,运行时不太可能在这些指令中间中断执行。这是因为用户级调度通常是非抢占式的,即它只会在特定情况下(例如 I/O 或应用程序调用线程产量(go中的 Goshec() )时执行上下文切换。这与操作系统调度不同,后者通常是抢占式的,可以随时中断执行。任何goroutine也不太可能看到该变量的过时版本,因为它们都将使用相同的缓存在同一个处理器上运行。事实上,如果你尝试使用运行时列出清单 3.6 。GOMAXPROCS(1) 您很可能不会看到相同的问题。显然,这不是一个好的解决方案,主要是因为我们将放弃拥有多个处理器的优势,但也因为不能保证它会完全解决问题。不同版本或未来版本的 Go 可能会以不同的方式进行调度,然后破坏我们的程序。无论我们使用什么调度系统,我们都应该防范竞争条件。这样,无论程序将在什么环境中运行,我们都不会出现问题。

3.3.2 屈服执行对竞争条件没有帮助

如果我们确切地告诉 Go 的运行时它应该在什么时候运行调度器呢?在上一章中,我们看到了如何使用调用运行时。Gosched() 来调用调度程序,以便我们可以将执行权交给另一个 goroutine。下面的清单显示了我们如何修改我们的两个函数并进行此调用。

清单 3.7 吝啬和花钱函数

func stingy(money *int) {    for i := 0; i < 1000000; i++ {        *money += 10        runtime.Gosched()    #A    }    fmt.Println("Stingy Done")} func spendy(money *int) {    for i := 0; i < 1000000; i++ {        *money -= 10        runtime.Gosched()    #B    }    fmt.Println("Spendy Done")}

不幸的是,这并不能解决我们的问题。此列表的输出将根据系统差异(处理器数量、Go 的版本和实现、类型操作系统等)而有所不同;但是,在我们的多核系统上,这是输出:

$ go run stingyspendysched.goStingy DoneSpendy DoneMoney in bank account: 170

再运行一次,我们得到:

$ go run stingyspendysched.goSpendy DoneStingy DoneMoney in bank account: -190

看起来竞争条件发生的频率较低,但仍在发生。对于此代码段,两个 goroutines 在不同的处理器上并行运行。争用条件的出现次数较少有多种,但不太可能是因为我们正在指示调度程序何时运行。让我们首先通过查看 的 Go 文档来提醒自己这个调用的作用:

func Gosched()

Gosched 产生处理器,允许其他 goroutines 运行。它不会挂起当前的 goroutine,因此执行会自动恢复。

我们的程序现在在关键部分(加法和减法)上花费的时间比例较小。它现在花费了大量时间来调用 Go 调度程序;因此,两个 GoRoutines 同时读取/写入共享变量的可能性要小得多。

另一个原因可能是编译器在循环中优化代码的选项较少,因为我们现在调用运行时。戈舍德() .

警告

永远不要依赖于告诉运行时何时生成处理器来解决争用条件。不能保证另一个并行线程不会干扰。此外,即使我们的系统有一个处理器,如果我们使用多个内核级线程,例如,通过使用运行时设置不同的值。GOMAXPROCS( n ) — 操作系统可以随时中断执行。

3.3.3 适当的同步和通信消除了竞争条件

我们可以做些什么来解决竞争条件?这里没有灵丹妙药。没有一种技术最适合解决所有案例。第一步是确保我们为作业使用正确的工具。您的问题真的需要内存共享吗?我们可以使用另一种方式在goroutine之间进行通信吗?在本书的第7章中,我们将看到一种不同的沟通方式,即使用渠道和沟通顺序过程。这种建模并发方式消除了许多此类错误。

编写良好的并发编程的第二步是识别何时可能发生争用条件。当我们与其他goroutines共享资源时,我们必须注意。一旦我们知道这些关键代码部分的位置,我们就可以想到要采用的最佳实践,以便以安全的方式共享资源。

在本节的开头,我们讨论了当两个人在厨房冰箱上共享购物清单时发生的现实生活中的竞争条件。这导致了两次购买杂货的情况,因为他们不知道另一个合作伙伴也决定照顾购物。我们可以通过更好的同步和通信方式来消除这种情况再次发生,如图 3.12 所示。

图 3.12 适当的同步和通信消除了争用条件。

例如,我们可以在购物清单上留下另一个便条或标记,表明有人已经在购物了。这将向其他人表明没有必要再次购物。在我们的编程中,为了避免竞争条件,我们需要与其他 goroutines 进行良好的同步和通信,以确保它们不会相互超越。并发编程是关于如何有效地同步并发执行,以消除竞争条件,同时提高性能和吞吐量。在本书后面的章节中,我们使用不同的技术和工具来帮助我们同步和协调程序中的线程。通过这种方式,我们可以解决这些竞争条件和同步问题,有时甚至完全避免它们。

3.3.4 围棋比赛检测器

Go 为我们提供了一个工具来检测代码中的竞争条件:我们可以使用 -race 命令行标志运行 Go 编译器。使用此标志,编译器将特殊代码添加到所有内存访问。此代码跟踪不同的 goroutine 何时在内存中读取和写入。如果检测到争用条件,则使用此标志时,它会在控制台上输出警告消息。如果我们尝试在我们的 Stegy 和 Spendy 程序上运行此标志(请参阅列表 3.5 和 3.6),我们会得到以下结果:

$ go run -race stingyspendy.go==================WARNING: DATA RACERead at 0x00c00001a0f8 by goroutine 7:  main.spendy()      /home/james/go/stingyspendy.go:16 +0x3b  main.main.func2()      /home/james/go/stingyspendy.go:24 +0x39 Previous write at 0x00c00001a0f8 by goroutine 6:  main.stingy()      /home/james/go/stingyspendy.go:9 +0x4d  main.main.func1()      /home/james/go/stingyspendy.go:23 +0x39 Goroutine 7 (running) created at:  main.main()      /home/james/go/stingyspendy.go:24 +0x116 Goroutine 6 (running) created at:  main.main()      /home/james/go/stingyspendy.go:23 +0xae==================Stingy DoneSpendy DoneMoney in bank account:  -808630Found 1 data race(s)exit status 66

在这个例子中,Go 的比赛检测器找到了我们的 1 比赛条件。它指向代码中第 16 行和第 9 行的关键部分,我们在 stingy() 和 spendy() 函数中对 money 变量进行加减。它还为我们提供了有关读取和写入内存的信息。在前面的代码片段中,我们可以看到内存位置0x00c00001a0f8首先由goroutine 6(运行stingy())编写,然后由goroutine 7(运行spendy())读取。

警告

Go 的比赛检测器仅在触发特定比赛条件时查找比赛条件。因此,探测器并非万无一失。使用种族检测器时,应使用类似生产方案的场景测试代码。在生产环境中启用它通常是不可取的,因为它会降低性能并使用更多内存。

随着经验的积累,识别争用条件变得更加容易,因为您编写了更多的并发代码。请务必记住,每当在代码的关键部分中与其他 goroutines 共享资源(例如内存)时,除非您同步对共享资源的访问,否则可能会出现争用条件。

3.4 小结内存共享是多个 goroutines 可以通信以完成任务的一种方式。多处理器和多核系统为我们提供了硬件支持和系统,以便在线程之间共享内存。争用条件是指由于在 goroutines 之间共享资源(如内存)而出现意外结果。关键部分是一组指令,应在不受其他并发执行干扰的情况下执行。当允许发生干扰时,可能会出现争用条件。在关键部分之外调用 Go 调度程序不是避免竞争条件的解决方案。使用适当的同步和通信可消除争用条件。Go 为我们提供了比赛检测器工具,可以帮助我们在代码中发现这些比赛条件。3.5 练习注意

访问 以查看所有代码解决方案。

修改我们的顺序字母频率程序以生成单词频率列表而不是字母频率。RFC 网页的 URL 可以与前面在清单 3.3 中使用的 URL 相同。完成后,程序应输出一个单词列表,其中包含每个单词在网页中出现的频率。下面是一些示例输出:

$ go run wordfrequency.gothe -> 5a -> 8car -> 1       program -> 3

当您尝试将顺序程序转换为并发程序并发程序,为每个页面创建一个goroutine时会发生什么?我们将在下一章中修复这些错误。

运行清单 3.1 中的 Go 竞赛检测器。结果是否包含争用条件?如果是这样,你能解释一下为什么会这样吗?请考虑以下清单。您可以尝试在不运行比赛检测器的情况下找出该程序中的比赛条件吗?提示:尝试多次运行该程序,以查看它是否会导致争用条件。练习 3.3

package main import (    "fmt"    "time") func addNextNumber(nextNum *[101]int) {    i := 0    for nextNum[i] != 0 { i++ }    nextNum[i] = nextNum[i-1] + 1} func main() {    nextNum := [101]int{1}    for i := 0; i < 100; i++ {        go addNextNumber(&nextNum)    }    for nextNum[100] == 0 {        println("Waiting for goroutines to complete")        time.Sleep(10 * time.Millisecond)    }    fmt.Println(nextNum)}

标签: #c语言500行代码项目