理解go的并发和并行

举报
码乐 发表于 2024/07/30 07:14:26 2024/07/30
【摘要】 1 简介总是有理由了解更多关于Go 的并发模型。并发似乎是围绕该语言的一大流行词。正是 Rob Pike 的 Go 并发模式视频让我认为需要这门语言。这些不同似乎让人凌乱,我们从几个例子来找出线头,深入认识go的并发方式。要理解 Go 如何使编写并发程序变得更容易并且不易出错,我们首先需要了解什么是并发程序以及由此类程序产生的问题。在这篇文章中,我不会谈论 CSP(通信顺序流程),虽然它是...

1 简介

总是有理由了解更多关于Go 的并发模型。并发似乎是围绕该语言的一大流行词。正是 Rob Pike 的 Go 并发模式视频让我认为需要这门语言。这些不同似乎让人凌乱,我们从几个例子来找出线头,深入认识go的并发方式。

要理解 Go 如何使编写并发程序变得更容易并且不易出错,我们首先需要了解什么是并发程序以及由此类程序产生的问题。

在这篇文章中,我不会谈论 CSP(通信顺序流程),虽然它是 Go 实现通道的基础。这里将重点介绍什么是并发程序,goroutines所扮演的角色,以及GOMAXPROCS环境变量和运行时函数如何影响Go运行时的行为和我们编写的程序。

2 go协程和系统线程

  • 进程和线程

操作系统将线程安排在可用处理器上运行,而不管该线程属于哪个进程。

当我们运行一个应用程序时,就像我们使用浏览器一样,操作系统会为应用程序创建一个进程。

该过程的工作是充当应用程序在运行时使用和维护的所有资源的容器。这些资源包括内存地址空间、文件句柄、设备和线程等内容。

线程是由操作系统调度的执行路径,用于针对处理器执行我们在函数中编写的代码。

进程从一个线程(主线程)开始,当该主要线程终止时,进程将终止。
这是因为主线程是应用程序的源。
然后,主线程可以反过来启动更多线程,而这些线程可以启动更多线程。

操作系统线程是被内核所调度,所以从一个线程向另一个“移动”需要完整的上下文切换,也就是说,保存一个用户线程的状态到内存,恢复另一个线程的到寄存器,然后更新调度器的数据结构。

这几步操作很慢,因为其局部性很差需要几次内存访问,并且会增加运行的cpu周期.
每个操作系统都有自己的算法来做出这些决策,我们最好编写不特定于一种算法或另一种算法的并发程序。此外,这些算法会随着操作系统的每个新版本而变化,因此很危险。

3 一个并发例子

Go的运行时包含了其自己的调度器,这个调度器使用了一些技术手段,比如m:n调度,因为其会在n个操作系统线程上多工(调度)m个goroutine。Go调度器的工作和内核的调度是相似的,但是这个调度器只关注单独的Go程序中的goroutine(译注:按程序独立)。

而Go的调度器使用了一个叫做GOMAXPROCS的变量来决定会有多少个操作系统的线程同时执行Go的代码。
其默认的值是运行机器上的CPU的核心数,所以在一个有8个核心的机器上时,调度器一次会在8个OS线程上去调度GO代码。(GOMAXPROCS是前面说的m:n调度中的n)。

在休眠中的或者在通信中被阻塞的goroutine是不需要一个对应的线程来做调度的。
在I/O中或系统调用中或调用非Go语言函数时,是需要一个对应的操作系统线程的,但是GOMAXPROCS并不需要将这几种情况计算在内。

你可以用GOMAXPROCS的环境变量来显式地控制这个参数,或者也可以在运行时用runtime.GOMAXPROCS函数来修改它。

我们在下面的小程序中会看到GOMAXPROCS的效果,这个程序有两个go协程,主协程打印1,go启动的协程打印0,它们会无限打印0和1。

示例:

	import (
		"fmt"
		"runtime"
	)

	func main() { 
		maxs := runtime.GOMAXPROCS(1)
		fmt.Printf("runtime reset gomaxprocs:%v \n", maxs)

		for {
			go fmt.Print(0)
			fmt.Print(1)
		}
	}

运行效果:

111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111
   000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

修改逻辑处理器为2,并行执行

	import (
		"fmt"
		"runtime"
	)

	func main() { 
		maxs := runtime.GOMAXPROCS(2)
		fmt.Printf("runtime reset gomaxprocs:%v \n", maxs)

		for {
			go fmt.Print(0)
			fmt.Print(1)
		}
	}

运行效果
1100101111100000111111100000001111111100000000111111111000000000101100111111100000001110001111111000000011111111111100000000000011111111100000000010111111000000111111111000000000101111110000001111111000000010111111111000000000111100001111100000111111111111111110000000000000000010110011111111

  • 解析

在第一次执行时,最多同时只能有一个goroutine被执行。初始情况下只有main goroutine被执行,所以会打印很多1。过了一段时间后,GO调度器会将其置为休眠,并唤醒另一个goroutine,这时候就开始打印很多0了,在打印的时候,goroutine是被调度到操作系统线程上的。

在第二次执行时,我们使用了两个操作系统线程,所以两个goroutine可以一起被执行,以同样的频率交替打印0和1。

我们必须强调的是goroutine的调度是受很多因素影响的,而runtime也是在不断地发展演进的,所以这里的你实际得到的结果可能会因为版本的不同而与这里显示的运行结果有所不同。

4 理解并发 Goroutines 和 并行 Parallelism

Go 中的任何函数或方法都可以创建为 goroutine。我们可以认为 main 函数作为 goroutine 执行,但是 Go 运行时不会启动该 goroutine。

Goroutines 被认为是轻量级的,因为它们使用很少的内存和资源,而且它们的初始堆栈大小很小。在版本 1.2 之前,堆栈大小从 4K 开始,从版本 1.4 开始,它从 8K 开始,最大1GB。堆栈能够根据需要进行扩展。

操作系统计划线程针对可用处理器运行,并 Go 运行时调度 goroutines 在绑定到 单个操作系统线程。默认情况下,Go 运行时会分配单个 逻辑处理器来执行为我们的程序创建的所有 goroutines。

即使这个是单一的逻辑处理器和操作系统线程,也可以有数十万个goroutine。 这使得计划以惊人的效率和性能同时运行。虽然不建议添加多个逻辑处理器,但如果你想并行运行 goroutines,Go 提供了能够通过 GOMAXPROCS 环境变量或运行时函数添加更多内容。

  • 并发和并行

并发性不是并行性。并行性是指两个或多个线程 针对不同的处理器同时执行代码。
如果将 运行时 要使用多个逻辑处理器,调度器将分发 goroutines 给这些逻辑处理器,这将使得goroutines 在不同的操作系统线程上运行。

但是,要实现真正的并行性,您需要 多个物理处理器。如果没有,那么 goroutines 将同时运行在单个物理处理器,即使 Go 运行时使用多个逻辑处理器。

5 一些的并发和并行实例

  • 并发示例:

让我们构建一个小程序,显示 Go 同时运行 goroutines。在此示例中,我们使用一个逻辑处理器运行代码。

		package main

		import (
		    "fmt"
		    "runtime"
		    "sync"
		)

		func main() {
		    runtime.GOMAXPROCS(1)

		    var wg sync.WaitGroup
		    wg.Add(2)

		    fmt.Println("Starting Go Routines")
		    go func() {
		        defer wg.Done()

		        for char := 'a'; char < 'a'+26; char++ {
		            fmt.Printf("%c ", char)
		        }
		    }()

		    go func() {
		        defer wg.Done()

		        for number := 1; number < 27; number++ {
		            fmt.Printf("%d ", number)
		        }
		    }()

		    fmt.Println("Waiting To Finish")
		    wg.Wait()

		    fmt.Println("\nTerminating Program")
		}

该程序通过使用关键字 go 并声明两个匿名函数来启动两个 goroutine以并发执行。

第一个 goroutine 使用小写字母显示英文字母,第二个 goroutine 显示数字 1 到 26。当我们运行这个程序时,我们得到以下输出:

开始 Go 例程

    等待完成
    a b c d e f g h i j k l m n o p q r s t u v w x y z 1 2 3 4 5 6 7 8 9 10 11
    12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
    终止程序

当我们查看输出时,我们可以看到代码是并发运行的。一旦启动了两个 goroutine,主要的 goroutine 就会等待 goroutines 完成。

我们需要这样做,因为一旦主 goroutine 终止,程序就会终止。使用 WaitGroup 是 goroutines 在完成后进行通信的好方法。

我们可以看到第一个 goroutine 完成显示所有 26 个字母,然后第二个 goroutine 轮到显示所有 26 个数字。

因为第一个 goroutine 完成其工作只需要不到一微秒的时间,所以我们看不到调度器在完成工作之前中断第一个 goroutine。我们可以给调度器一个理由来交换 goroutines,方法是在第一个 goroutine 中加入一个睡眠:

	package main

	import (
	    "fmt"
	    "runtime"
	    "sync"
	    "time"
	)

	func main() {
	    runtime.GOMAXPROCS(1)

	    var wg sync.WaitGroup
	    wg.Add(2)

	    fmt.Println("Starting Go Routines")
	    go func() {
	        defer wg.Done()

	        time.Sleep(1 * time.Microsecond)
	        for char := 'a'; char < 'a'+26; char++ {
	            fmt.Printf("%c ", char)
	        }
	    }()

	    go func() {
	        defer wg.Done()

	        for number := 1; number < 27; number++ {
	            fmt.Printf("%d ", number)
	        }
	    }()

	    fmt.Println("Waiting To Finish")
	    wg.Wait()

	    fmt.Println("\nTerminating Program")
	}

这一次,我们在第一个 goroutine 开始时就添加了一个睡眠。调用 sleep 会导致调度器交换两个 goroutines。

开始 Go 例程

	等待完成

	1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 a
	b c d e f g h i j k l m n o p q r s t u v w x y z

	终止程序

这一次,首先显示数字,然后显示字母。休眠会导致调度器停止运行第一个 goroutine,并让第二个 goroutine 执行其操作。

  • 并行示例

在我们的两个示例中,goroutines 是并发运行的,但不是并行的。

让我们对代码进行更改,以允许 goroutines 并行运行。我们需要做的就是向调度器添加第二个逻辑处理器以使用两个线程:

package main

import (
    "fmt"
    "runtime"
    "sync"
)

func main() {
    runtime.GOMAXPROCS(2)

    var wg sync.WaitGroup
    wg.Add(2)

    fmt.Println("Starting Go Routines")
    go func() {
        defer wg.Done()

        for char := 'a'; char < 'a'+26; char++ {
            fmt.Printf("%c ", char)
        }
    }()

    go func() {
        defer wg.Done()

        for number := 1; number < 27; number++ {
            fmt.Printf("%d ", number)
        }
    }()

    fmt.Println("Waiting To Finish")
    wg.Wait()

    fmt.Println("\nTerminating Program")
}

以下是该程序的输出:

开始 Go 例程

	等待完成
	a b 1 2 3 4 c d e f 5 g h 6 i 7 j 8 k 9 10 11 12 l m n o p q 13 r s 14
	t 15 u v 16 w 17 x y 18 z 19 20 21 22 23 24 25 26
	终止程序

每次我们运行程序时,我们都会得到不同的结果。对于每次运行,调度程序的行为并不完全相同。我们可以看到 goroutines 确实是并行运行的。两个 goroutine 都立即开始运行,你可以看到它们都在争夺标准输出以显示他们的结果。

6 结论

仅仅因为我们可以添加多个逻辑处理器供调度器使用,并不意味着我们应该这样做。
Go 团队按照他们的方式为运行时设置默认值是有原因的。尤其是仅使用单个逻辑处理器的默认设置。

要知道,任意添加逻辑处理器并行运行 goroutines 并不一定能为您的程序提供更好的性能。
始终对您的程序进行分析和基准测试,并确保仅在绝对需要时更改 Go 运行时配置。

在我们的应用程序中构建并发性的问题是,最终我们的 goroutines 可能在同一时间将尝试访问相同的资源.

    对共享资源的读取和写入操作必须始终是原子的。
    
    换句话说,读取和写入必须一次由一个 goroutine 发生,否则我们会在程序中创建竞争条件。
    而通道是 Go 中我们编写安全优雅的并发程序的方式,
    这些使得程序消除了竞争条件,使编写并发程序再次变得有趣。

要了解有关竞争条件的更多信息,我们以后将继续了解。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。