全栈编程

Balance $ 2,317
Item Sold 1230
文章作者: 全栈编程@luboke.com
版权声明: 本文章为go语言体系课视频教程配套电子书,版权归 全栈编程@luboke.com所有,欢迎免费学习,转载必须注明出处!但禁止任何商业用途,否则将受到法律制裁!

数据访问概述

不要使用全局变量或者共享内存,它们会给你的代码在并发运算的时候带来危险。

解决数据访问的方法之一在于同步不同的线程,对数据加锁,这样同时就只有一个线程可以变更数据。

众所周知,使用多线程的应用,最难的部分就是数据的准备性,最主要的问题是内存中的数据共享,它们会被多线程以无法预知的方式进行操作,导致一些无法重现或者随机的结果(称作 竞争状态,即竞态)。

在 Go 的标准库 sync 中有一些工具用来在低级别的代码中实现加锁;

传统的软件开发经验告诉我们,使用加锁的方式会带来更高的复杂度,更容易使代码出错以及更低的性能,因此加锁这种方法不再适合现代多核/多处理器编程,因为现在的多处理器或者多核已经不是受资金所制约,相对价格较便宜。thread-per-connection 模型不够有效。

我们的程序被编写为顺序执行并完成独立任务的代码。这类程序的好处是很容易写,也很容易维护。

但是有好处也有坏处,如果程序并行执行多个任务会有更大的好处。

比如Web 服务需要在各自独立的套接字(socket)上同时接收多个数据请求。每个套接字请求都是独立的,可以完全独立于其他套接字进行处理。具有并行执行多个请求的能力可以显著提高这类系统的性能。考虑到这一点,Go 语言的语法和运
行时直接内置了对并发的支持。


Go 语言里的并发一般指的是能让某个函数独立于其他函数运行的能力。当一个函数创建为 goroutine时,Go 会将其视为一个独立的工作单元。这个单元会被调度到可用的逻辑处理器上执行。Go 语言运行时的调度器是一个复杂的软件,能管理被创建的所有 goroutine 并为其分配执行时间。

goroutine是通过Go的runtime管理的一个线程管理器。goroutine通过go关键字实现了,其实就是一个普通的函数。

这个调度器在操作系统之上,将操作系统的线程与语言运行时的逻辑处理器绑定,并在逻辑处理器上运行goroutine。调度器在任何给定的时间,都会全面控制哪个 goroutine 要在哪个逻辑处理器上运行。

操作系统线程、逻辑处理器和本地运行队列之间的关系。如果创建一个 goroutine 并准备运行,这个 goroutine 就会被放到调度器的全局运行队列中。之后,调度器就将这些队列中的 goroutine 分配给一个逻辑处理器,并放到这个逻辑处理器对应的本地运行队列中。本地运行队列中的 goroutine 会一直等待直到自己被分配的逻辑处理器执行。

 

    有时,正在运行的 goroutine 需要执行一个阻塞的系统调用,如打开一个文件。当这类调用发生时,线程和 goroutine 会从逻辑处理器上分离,该线程会继续阻塞,等待系统调用的返回。

    与此同时,这个逻辑处理器就失去了用来运行的线程。所以,调度器会创建一个新线程,并将其绑定到该逻辑处理器上。之后,调度器会从本地运行队列里选择另一个 goroutine 来运行。一旦被阻塞的系统调用执行完成并返回,对应的 goroutine 会放回到本地运行队列,而之前的线程会保存好,以便之后可以继续使用。

Go 语言的并发同步模型来源于通信顺序进程(Communicating Sequential Processes,CSP)的范型(paradigm)。CSP 是一种消息传递模型,通过在 goroutine 之间传递数据来传递消息而不是对数据进行加锁来实现同步访问。用于在 goroutine 之间同步和传递数据的关键数据类型叫作通道(channel)。  

在 Go 中,应用程序并发处理的部分被称作 goroutines(协程),协程可以运行在多个操作系统线程之间,也可以运行在线程之内,它可以进行更有效的并发运算。在协程和操作系统的线程之间并不是一对一的关系:协程是根据一个或多个线程的可用性,映射(多路复用,执行于)在他们之上的;协程调度器在 Go 运行时很好的完成了这个工作。协程占用很小的内存就可以处理大量的任务。由于操作系统线程上的协程时间片,你可以使用少量的操作系统线程就能拥有任意多个提供服务的协程,而且 Go 运行时可以聪明的意识到哪些协程被阻塞了,暂时搁置它们并处理其他协程。

由于协程工作在相同的地址空间中,所以共享内存的方式一定是同步的;这个可以使用 sync 包来实现,但并不鼓励这样做:Go 使用 channels 来同步协程 。

当系统调用(比如等待 I/O)阻塞协程时,其他协程会继续在其他线程上工作。协程的设计隐藏了许多线程创建和管理方面的复杂工作。 

两种并发方式:确定性的(明确定义排序)和非确定性的(加锁/互斥从而未定义排序)。

Go 的协程和通道理所当然的支持确定性的并发方式(例如通道具有一个 sender 和一个 receiver)。 

协程是通过使用关键字 go 调用(执行)一个函数或者方法来实现的(也可以是匿名或者 lambda 函数)。这样会在当前的计算过程中开始一个同时进行的函数,在相同的地址空间中并且分配了独立的栈。执行goroutine只需极少的栈内存(大概是4~5KB),当然会根据相应的数据伸缩。也正因为如此,可同时运行成千上万个并发任务。goroutine比thread更易用、更高效、更轻便。

goroutine说到底其实就是线程,但是它比线程更小,十几个goroutine可能体现在底层就是五六个线程,Go语言内部会处理协程与物理线程的映射。

协程的栈会根据需要进行伸缩,不出现栈溢出;开发者不需要关心栈的大小。当协程结束的时候,它会静默退出:用来启动这个协程的函数不会得到任何的返回值。

任何 Go 程序都必须有的 main() 函数也可以看做是一个协程,尽管它并没有通过 go 来启动。协程可以在程序初始化的过程中运行(在 init() 函数中)。 

如果希望让 goroutine 并行,必须使用多于一个逻辑处理器。当有多个逻辑处理器时,调度器 会将 goroutine 平等分配到每个逻辑处理器上。这会让 goroutine 在不同的线程上运行。不过要想真 的实现并行的效果,用户需要让自己的程序运行在有多个物理处理器的机器上。 

go语言中协程的waitgroup

正常情况下 goroutine的结束过程是不可控制的,唯一可以保证终止goroutine的行为是main goroutine的终止。也就是说,我们并不知道哪个goroutine什么时候结束。

通过WaitGroup提供的三个函数:Add,Done,Wait,可以轻松实现等待某个协程或协程组完成的同步操作。

但在使用时要注意:

Add的数量和Done的调用数量必须相等。
另外,就是WaitGroup结构一旦定义就不能复制的原因
WaitGroup在需要等待多个任务结束再返回的业务来说还是很有用的,但现实中用的更多的可能是,先等待一个协程组,若所有协程组都正确完成,则一直等到所有协程组结束;若其中有一个协程发生错误,则告诉协程组的其他协程,全部停止运行(本次任务失败)以免浪费系统资源。
该场景WaitGroup是无法实现的,那么该场景该如何实现呢,就需要用到通知机制,其实也可以用channel来实现,具体的解决办法,请看后续的文章。
这样说来,WaitGroup的使用场景是有限的。

但很多情况下,我们需要知道goroutine是否完成。这需要借助sync包的WaitGroup来实现。

WatiGroup是sync包中的一个struct类型,用来收集需要等待执行完成的goroutine。下面是它的定义:

type WaitGroup struct {
        // Has unexported fields.
}
    A WaitGroup waits for a collection of goroutines to finish. The main
    goroutine calls Add to set the number of goroutines to wait for. Then each
    of the goroutines runs and calls Done when finished. At the same time, Wait
    can be used to block until all goroutines have finished.

    A WaitGroup must not be copied after first use.


func (wg *WaitGroup) Add(delta int)
func (wg *WaitGroup) Done()
func (wg *WaitGroup) Wait()

它有3个方法:

Add():每次激活想要被等待完成的goroutine之前,先调用Add(),用来设置或添加要等待完成的goroutine数量
例如Add(2)或者两次调用Add(1)都会设置等待计数器的值为2,表示要等待2个goroutine完成
Done():每次需要等待的goroutine在真正完成之前,应该调用该方法来人为表示goroutine完成了,该方法会对等待计数器减1
Wait():在等待计数器减为0之前,Wait()会一直阻塞当前的goroutine
也就是说,Add()用来增加要等待的goroutine的数量,Done()用来表示goroutine已经完成了,减少一次计数器,Wait()用来等待所有需要等待的goroutine完成。

WaitGroup 是一个计数信号量,可以用来记录并维护运行的 goroutine。如果 WaitGroup的值大于 0,Wait 方法就会阻塞。
在第 18 行,创建了一个 WaitGroup 类型的变量,之后在第 19 行,将这个 WaitGroup 的值设置为 2,表示有两个正在运行的 goroutine。
为了减小WaitGroup 的值并最终释放 main 函数,要在第 26 和 39 行,使用 defer 声明在函数退出时调用 Done 方法。

关键字 defer 会修改函数调用时机,在正在执行的函数返回时才真正调用 defer 声明的函数。对这里的示例程序来说,我们使用关键字 defer 保证,每个 goroutine 一旦完成其工作就调用 Done 方法。

package main

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

func main() {
	// 分配一个逻辑处理器给调度器使用
	//调用了 runtime 包的 GOMAXPROCS 函数。这个函数允许程序更改调度器可以使用的逻辑处理器的数量。
	//给这个函数传入 1,是通知调度器只能为该程序使用一个逻辑处理器。
	//第一个 goroutine 完成所有显示需要花时间太短了,以至于在调度器切换到第二个 goroutine
	//之前,就完成了所有任务。这也是为什么会看到先输出了所有的大写字母,之后才输出小写字母。
	//我们创建的两个 goroutine 一个接一个地并发运行,独立完成显示字母表的任务。
	runtime.GOMAXPROCS(1)

	//会看到 goroutine 是并行运行的。两个 goroutine 几乎
	//是同时开始运行的,大小写字母是混合在一起显示的。这是在一台 8 核的电脑上运行程序的输出,
	//所以每个 goroutine 独自运行在自己的核上。记住,只有在有多个逻辑处理器且可以同时让每个
	//goroutine 运行在一个可用的物理处理器上的时候,goroutine 才会并行运行。
	runtime.GOMAXPROCS(2)

	// wg 用来等待程序完成
	// 计数加 2,表示要等待两个 goroutine
	var wg sync.WaitGroup
	wg.Add(2)

	fmt.Println("Start Goroutines")

	// 声明一个匿名函数,并通过关键字 go创建一个 goroutine
	go func() {
		// 在函数退出时调用 Done 来通知 main 函数工作已经完成
		defer wg.Done()

		// 显示字母表 2 次
		for count := 0; count < 2; count++ {
			for char := 'a'; char < 'a'+26; char++ {
				fmt.Printf("%c ", char)
			}
		}
	}()

	// 声明一个匿名函数,并通过关键字 go创建一个 goroutine
	go func() {
		// 在函数退出时调用 Done 来通知 main 函数工作已经完成
		defer wg.Done()

		// 显示字母表 2 次
		for count := 0; count < 2; count++ {
			for char := 'A'; char < 'A'+26; char++ {
				fmt.Printf("%c ", char)
			}
		}
	}()

	// 等待 goroutine 结束
	fmt.Println("Waiting To Finish")

	//一旦两个匿名函数创建 goroutine 来执行,main 中的代码会继续运行。
	//这意味着 main 函数会在 goroutine 完成工作前返回。如果真的返回了,程序就会在 goroutine 有
	//机会运行前终止。因此,在 main 函数通过 WaitGroup,等待两个 goroutine 完成它们 的工作。
	wg.Wait()

	fmt.Println("\nTerminating Program")
}
文章作者: 全栈编程@luboke.com
版权声明: 本文章为go语言体系课视频教程配套电子书,版权归 全栈编程@luboke.com所有,欢迎免费学习,转载必须注明出处!但禁止任何商业用途,否则将受到法律制裁!
copyright © 2020 全栈编程@luboke.com