go语言并发-01goroutine

并发概念

并发意味着程序在运行时有多个执行上下文,对应着有多个调用栈,我们知道每一个进程在运行时都有自己的调用栈和堆,有一个完整的上下文,而操作系统在调度进程的时候,会保存调度进程的上下文环境,等该进程获取到时间片后,在恢复该进程的上下文到系统中。

go语言轻量级线程

  1. 使用go关键字创建goroutine时,被调用函数的返回值会被忽略
    如果需要在goroutine中返回数据,请使用channel,通过通道把数据从goroutine中传出
  2. 终止goroutine的最好方法就是返回goroutine对应的函数或者使用context包

go语言并发通信

  1. 在工程上,有两种最常见的并发通信模型:共享数据和消息
    共享数据是指多个并发单元分别保持对同一个数据的引用,实现对该数据的共享。被共享的数据可能有多种形式:内存数据块,磁盘文件,网络数据等,在实际工作中应用最多的无疑是内存了,也就是常说的共享内存。
var counter = 0
func Count(lock *sync.Mutex) {
	lock.Lock()
	counter++
	lock.Unlock()
}

func main() {
	lock := &sync.Mutex{}
	for i := 0; i < 10; i++ {
		go Count(lock)
	}
	for {
		lock.Lock()
		c := counter
		//runtime.Gosched()
		lock.Unlock()
		if c >= 10 {
			break
		}
	}
	fmt.Println(counter)  // 10
}

在上面的例子中,我们在 10 个 goroutine 中共享了变量 counter。每个 goroutine 执行完成后,会将 counter 的值加 1。因为 10 个 goroutine 是并发执行的,所以我们还引入了锁,也就是代码中的 lock 变量。每次对 n 的操作,都要先将锁锁住,操作完成后,再将锁打开。

在 main 函数中,使用 for 循环来不断检查 counter 的值(同样需要加锁)。当其值达到 10 时,说明所有 goroutine 都执行完毕了,这时主函数返回,程序退出。

事情好像开始变得糟糕了。实现一个如此简单的功能,却写出如此臃肿而且难以理解的代码。想象一下,在一个大的系统中具有无数的锁、无数的共享变量、无数的业务逻辑与错误处理分支,那将是一场噩梦。这噩梦就是众多 C/C++ 开发者正在经历的,其实 Java 和 C# 开发者也好不到哪里去。

Go语言既然以并发编程作为语言的最核心优势,当然不至于将这样的问题用这么无奈的方式来解决。Go语言提供的是另一种通信模型,即以消息机制而非共享内存作为通信方式。

消息机制认为每个并发单元是自包含的、独立的个体,并且都有自己的变量,但在不同并发单元间这些变量不共享。每个并发单元的输入和输出只有一种,那就是消息。这有点类似于进程的概念,每个进程不会被其他进程打扰,它只做好自己的工作就可以了。不同进程间靠消息来通信,它们不会共享内存。

Go语言提供的消息通信机制被称为 channel,关于 channel 的介绍将在后续的学习中为大家讲解。

runtime.Gosched()案例

func say(s string) {
	for i := 0; i < 2; i++ {
		// runtime.Gosched()用于让出CPU时间片
		runtime.Gosched()
		fmt.Println(s)
	}
}

func main() {
	go say("world")
	say("hello")
}

输出结果:

hello
world
hello

go语言竞争状态

有并发,就又资源竞争,如果两个或者多个goroutine在没有同步的情况下,访问某个共享的资源,比如同时对该资源进行读写时,就会处于相互竞争的状态,这就是并发中的资源竞争。
并发本身并不复杂, 但是因为有了资源竞争的问题,就使得我们开发出好的并发程序变得复杂起来,因为会引起很多莫名其妙的问题。
下面的代码中就会出现竞争状态:

func main() {
	wg.Add(2)
	go incCount()
	go incCount()
	wg.Wait()
	fmt.Println(count)
}
func incCount() {
	defer wg.Done()
	for i := 0; i < 2; i++ {
		value := count
		// 让出CPU时间片,去执行其它goroutine任务
		runtime.Gosched()
		value++
		count = value
	}
}

这是一个资源竞争的例子,大家可以将程序多运行几次,会发现结果可能是 2,也可以是 3,还可能是 4。这是因为 count 变量没有任何同步保护,所以两个 goroutine 都会对其进行读写,会导致对已经计算好的结果被覆盖,以至于产生错误结果。

代码中的 runtime.Gosched() 是让当前 goroutine 暂停的意思,退回执行队列,让其他等待的 goroutine 运行,目的是为了使资源竞争的结果更明显。
下面我们来分析一下程序的运行过程,将两个 goroutine 分别假设为 g1 和 g2:

g1 读取到 count 的值为 0;
然后 g1 暂停了,切换到 g2 运行,g2 读取到 count 的值也为 0;
g2 暂停,切换到 g1,g1 对 count+1,count 的值变为 1;
g1 暂停,切换到 g2,g2 刚刚已经获取到值 0,对其 +1,最后赋值给 count,其结果还是 1;
可以看出 g1 对 count+1 的结果被 g2 给覆盖了,两个 goroutine 都 +1 而结果还是 1。

通过上面的分析可以看出,之所以出现上面的问题,是因为两个 goroutine 相互覆盖结果。
2. 所以我们对同一个资源的读写必须是原子化的,也就是说同一个时间只允许一个goroutine对共享资源进行读写。
共享资源竞争的问题,非常复杂,并且难以察觉,好在 Go 为我们提供了一个工具帮助我们检查,这个就是go build -race命令。在项目目录下执行这个命令,生成一个可以执行文件,然后再运行这个可执行文件,就可以看到打印出的检测信息。
在go build命令中多加了一个-race 标志,这样生成的可执行程序就自带了检测资源竞争的功能,运行生成的可执行文件,效果如下所示:

  main.incCount()
      C:/Users/mayanan/Desktop/pro_go/test_go/test.go:28 +0xa4

Goroutine 8 (running) created at:
  main.main()
      C:/Users/mayanan/Desktop/pro_go/test_go/test.go:17 +0x50

Goroutine 7 (finished) created at:
  main.main()
      C:/Users/mayanan/Desktop/pro_go/test_go/test.go:16 +0x44
==================
4
Found 1 data race(s)

通过运行结果可以看出 goroutine 8 在代码 25 行读取共享资源value := count,而这时 goroutine 7 在代码 28 行修改共享资源count = value,而这两个 goroutine 都是从 main 函数的 16、17 行通过 go 关键字启动的。

  1. 锁住共享资源
    go语言提供了传统的同步goroutine的机制,就是对共享资源加锁。atomic和sync包里面的一些函数就可以对共享资源进行加锁。
    3.1 原子函数
    原子函数能够以很底层的加锁机制来同步访问整型变量和指针:
var (
	count int32
	wg sync.WaitGroup
)
func main() {
	wg.Add(2)
	go incCount()
	go incCount()
	wg.Wait()
	fmt.Println(count)
}
func incCount() {
	defer wg.Done()
	for i := 0; i < 2; i++ {
		// 原子函数能够以很底层的加锁机制来同步访问整型变量和指针
		atomic.AddInt32(&count, 1)
		runtime.Gosched()
	}
}

上述代码中使用了 atmoic 包的 AddInt64 函数,这个函数会同步整型值的加法,方法是强制同一时刻只能有一个 gorountie 运行并完成这个加法操作。当 goroutine 试图去调用任何原子函数时,这些 goroutine 都会自动根据所引用的变量做同步处理。

另外两个有用的原子函数是 LoadInt64 和 StoreInt64。这两个函数提供了一种安全地读和写一个整型值的方式。下面是代码就使用了 LoadInt64 和 StoreInt64 函数来创建一个同步标志,这个标志可以向程序里多个 goroutine 通知某个特殊状态。

var (
	count int32
	wg       sync.WaitGroup
)
func main() {
	wg.Add(2)
	go doWork("A")
	go doWork("B")
	time.Sleep(time.Second)
	fmt.Println("main 写入完成")
	atomic.StoreInt32(&count, 1)
	wg.Wait()
}
func doWork(name string) {
	defer wg.Done()
	for {
		fmt.Println("do working", name)
		time.Sleep(time.Millisecond * 250)
		if atomic.LoadInt32(&count) == 1 {
			fmt.Println(name, "读取完成")
			break
		}
	}
}

上面代码中 main 函数使用 StoreInt64 函数来安全地修改 shutdown 变量的值。如果哪个 doWork goroutine 试图在 main 函数调用 StoreInt64 的同时调用 LoadInt64 函数,那么原子函数会将这些调用互相同步,保证这些操作都是安全的,不会进入竞争状态。

  1. 互斥锁
    另一种同步访问共享资源的方式是使用互斥锁,互斥锁这个名字来自互斥的概念。互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界代码。
var (
	count int32
	wg       sync.WaitGroup
	lock sync.Mutex
)
func main() {
	wg.Add(2)
	go Count()
	go Count()
	wg.Wait()
	fmt.Println(count)
}
func Count() {
	defer wg.Done()
	for i := 0; i < 2; i++ {
		// 同一时刻只允许一个goroutine进入这个临界区
		lock.Lock()
		{
			value := count
			runtime.Gosched()
			value++
			count = value
		}
		// 释放锁,允许其它goroutine进入这个临界区
		lock.Unlock()
	}
}

同一时刻只有一个 goroutine 可以进入临界区。之后直到调用 Unlock 函数之后,其他 goroutine 才能进去临界区。当调用 runtime.Gosched 函数强制将当前 goroutine 退出当前线程后,调度器会再次分配这个 goroutine 继续运行。

go语言调整并发的运行性能

在 Go语言程序运行时(runtime)实现了一个小型的任务调度器。这套调度器的工作原理类似于操作系统调度线程,Go 程序调度器可以高效地将 CPU 资源分配给每一个任务。传统逻辑中,开发者需要维护线程池中线程与 CPU 核心数量的对应关系。同样的,Go 地中也可以通过 runtime.GOMAXPROCS() 函数做到,格式为:
runtime.GOMAXPROCS(逻辑CPU数量)
这里的逻辑CPU数量可以有如下几种数值:

<1:不修改任何数值。
=1:单核心执行。
>1:多核并发执行。

一般情况下,可以使用 runtime.NumCPU() 查询 CPU 数量,并使用 runtime.GOMAXPROCS() 函数进行设置,例如:
runtime.GOMAXPROCS(runtime.NumCPU())
Go 1.5 版本之前,默认使用的是单核心执行。从 Go 1.5 版本开始,默认执行上面语句以便让代码并发执行,最大效率地利用 CPU。
GOMAXPROCS 同时也是一个环境变量,在应用程序启动前设置环境变量也可以起到相同的作用。

并发和并行的区别

  • 并发(concurrency):把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。
  • 并行(parallelism):把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。
    go语言在GOMAXPROCS于任务数量相等时,可以做到并行执行,但一般情况下都是并发执行。

goroutine和coroutine的区别

  • goroutine可能发生并行执行
  • coroutine始终顺序执行
    goroutine间使用channnel通信,coroutine使用yield和resume操作。
  1. coroutine 的运行机制属于协作式任务处理,早期的操作系统要求每一个应用必须遵守操作系统的任务处理规则,应用程序在不需要使用 CPU 时,会主动交出 CPU 使用权。如果开发者无意间或者故意让应用程序长时间占用 CPU,操作系统也无能为力,表现出来的效果就是计算机很容易失去响应或者死机。
  2. goroutine 属于抢占式任务处理,已经和现有的多线程和多进程任务处理非常类似。应用程序对 CPU 的控制最终还需要由操作系统来管理,操作系统如果发现一个应用程序长时间大量地占用 CPU,那么用户有权终止这个任务。
posted @ 2022-08-16 15:39  专职  阅读(42)  评论(0编辑  收藏  举报