《Go 语言第一课》课程学习笔记(十五)

并发

Go 的并发方案:goroutine

  • 并行(parallelism),指的就是在同一时刻,有两个或两个以上的任务(这里指进程)的代码在处理器上执行。
    • 并发不是并行,并发关乎结构,并行关乎执行。
    • 将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计。
  • Go 并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了 goroutine 这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
  • 相比传统操作系统线程来说,goroutine 的优势主要是:
    • 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
    • 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
    • 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
    • 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。
  • 和传统编程语言不同的是,Go 语言是面向并发而生的,所以,在程序的结构设计阶段,Go 的惯例是优先考虑并发设计。这样做的目的更多是考虑随着外界环境的变化,通过并发设计的 Go 应用可以更好地、更自然地适应规模化(scale)。

goroutine 的基本用法

  • 并发是一种能力,它让你的程序可以由若干个代码片段组合而成,并且每个片段都是独立运行的。
  • goroutine 恰恰就是 Go 原生支持并发的一个具体实现。
    • 无论是 Go 自身运行时代码还是用户层 Go 代码,都无一例外地运行在 goroutine 中。
    • Go 语言通过 go 关键字+函数/方法的方式创建一个 goroutine。
    • 创建后,新 goroutine 将拥有独立的代码执行流,并与创建它的 goroutine 一起被 Go 运行时调度。
      go fmt.Println("I am a goroutine")
      var c = make(chan int)
      go func(a, b int) {
      	c <- a + b
      }(3,4)
      // $GOROOT/src/net/http/server.go
      c := srv.newConn(rw)
      go c.serve(connCtx)
      
    • 通过 go 关键字,我们可以基于已有的具名函数 / 方法创建 goroutine,也可以基于匿名函数 / 闭包创建 goroutine。
      • 创建 goroutine 后,go 关键字不会返回 goroutine id 之类的唯一标识 goroutine 的 id,你也不要尝试去得到这样的 id 并依赖它。
      • 另外,和线程一样,一个应用内部启动的所有 goroutine 共享进程空间的资源,如果多个 goroutine 访问同一块内存数据,将会存在竞争,我们需要进行 goroutine 间的同步。
    • goroutine 的执行函数的返回,就意味着 goroutine 退出。
      • goroutine 执行的函数或方法即便有返回值,Go 也会忽略这些返回值。
      • 所以,如果获取 goroutine 执行后的返回值需要另行考虑其他方法,比如通过 goroutine 间的通信来实现。

goroutine 间的通信

  • 传统的编程语言(比如:C++、Java、Python 等)并非面向并发而生的,所以他们面对并发的逻辑多是基于操作系统的线程。
  • 并发的执行单元(线程)之间的通信,利用的也是操作系统提供的线程或进程间通信的原语,比如:共享内存、信号(signal)、管道(pipe)、消息队列、套接字(socket)等。
  • 在这些通信原语中,使用最多、最广泛的(也是最高效的)是结合了线程同步原语(比如:锁以及更为低级的原子操作)的共享内存方式,因此,我们可以说传统语言的并发模型是基于对内存的共享的。
  • 一个符合 CSP(Communicationing Sequential Processes,通信顺序进程)并发模型的并发程序应该是一组通过输入输出原语连接起来的 P 的集合。
    • 从这个角度来看,CSP 理论不仅是一个并发参考模型,也是一种并发程序的程序组织方法。它的组合思想与 Go 的设计哲学不谋而合。
    • 在 Go 中,与“Process”对应的是 goroutine。
    • 为了实现 CSP 并发模型中的输入和输出原语,Go 还引入了 goroutine(P)之间的通信原语 channel。
    • goroutine 可以从 channel 获取输入数据,再将处理后得到的结果数据通过 channel 输出。
    • 通过 channel 将 goroutine(P)组合连接在一起,让设计和编写大型并发系统变得更加简单和清晰,我们再也不用为那些传统共享内存并发模型中的问题而伤脑筋了。
  • 虽然 CSP 模型已经成为 Go 语言支持的主流并发模型,但 Go 也支持传统的、基于共享内存的并发模型,并提供了基本的低级别同步原语(主要是 sync 包中的互斥锁、条件变量、读写锁、原子操作等)。
  • Go 始终推荐以 CSP 并发模型风格构建并发程序,尤其是在复杂的业务层面,这能提升程序的逻辑清晰度,大大降低并发设计的复杂性,并让程序更具可读性和可维护性。
  • 不过,对于局部情况,比如涉及性能敏感的区域或需要保护的结构体数据时,我们可以使用更为高效的低级同步原语(如 mutex),保证 goroutine 对数据的同步访问。

Goroutine 调度器

  • 一个 Go 程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,它甚至不知道有一种叫 Goroutine 的事物存在。所以,Goroutine 的调度全要靠 Go 自己完成。那么,实现 Go 程序内 Goroutine 之间“公平”竞争“CPU”资源的任务,就落到了 Go 运行时(runtime)头上了。
  • Goroutine 竞争的资源就是操作系统线程。Goroutine调度器的任务就是将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。

深入 G-P-M 模型

  • G、P 和 M
    • G: 代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等,而且 G 对象是可以重用的;
    • P: 代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;
    • M: M 代表着真正的执行计算资源。
      • 在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。
      • M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
    • Goroutine 调度器的目标,就是公平合理地将各个 G 调度到 P 上“运行”。
  • G 被抢占调度
    • 除非极端的无限循环,否则只要 G 调用函数,Go 运行时就有了抢占 G 的机会。
    • Go 程序启动时,运行时会去启动一个名为 sysmon 的 M(一般称为监控线程),这个 M 的特殊之处在于它不需要绑定 P 就可以运行(以 g0 这个 G 的形式),这个 M 在整个 Go 程序的运行过程中至关重要。
    • sysmon 每 20us~10ms 启动一次,sysmon 主要完成了这些工作:
      • 释放闲置超过 5 分钟的 span 内存;
      • 如果超过 2 分钟没有垃圾回收,强制执行;
      • 将长时间未处理的 netpoll 结果添加到任务队列;
      • 向长时间运行的 G 任务发出抢占调度,这个事情由函数 retake 实施;
      • 收回因 syscall 长时间阻塞的 P;
    • 如果一个 G 任务运行 10ms,sysmon 就会认为它的运行时间太久而发出抢占式调度的请求。一旦 G 的抢占标志位被设为 true,那么等到这个 G 下一次调用函数或方法时,运行时就可以将 G 抢占并移出运行状态,放入队列中,等待下一次被调度。
  • 两个特殊情况下 G 的调度方法
    • 第一种:channel 阻塞或网络 I/O 情况下的调度。
      • 如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上时,G 会被放置到某个等待(wait)队列中,而 M 会尝试运行 P 的下一个可运行的 G。
      • 如果这个时候 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态。
      • 当 I/O 操作完成或 channel 操作完成,在等待队列中的 G 会被唤醒,标记为可运行(runnable),并被放入到某 P 的队列中,绑定一个 M 后继续执行。
    • 第二种:系统调用阻塞情况下的调度。
      • 如果 G 被阻塞在某个系统调用(system call)上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态。
      • 如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。
      • 当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 runnable,之前的那个挂起的 M 将再次进入挂起状态。

作为一等公民的 channel

  • channel 作为一等公民意味着我们可以像使用普通变量那样使用 channel,比如,定义 channel 类型变量、给 channel 变量赋值、将 channel 作为参数传递给函数 / 方法、将 channel 作为返回值从函数 / 方法中返回,甚至将 channel 发送到其他 channel 中。
  • 创建 channel
    • 和切片、结构体、map 等一样,channel 也是一种复合数据类型。也就是说,我们在声明一个 channel 类型变量时,必须给出其具体的元素类型:var ch chan int。这里,我们声明了一个元素为 int 类型的 channel 类型变量 ch。
    • 如果 channel 类型变量在声明时没有被赋予初值,那么它的默认值为 nil。
      • 并且,和其他复合数据类型支持使用复合类型字面值作为变量初始值不同,为 channel 类型变量赋初值的唯一方法就是使用 make 这个 Go 预定义的函数:
        ch1 := make(chan int)
        ch2 := make(chan int, 5)
        
      • 这里,我们声明了两个元素类型为 int 的 channel 类型变量 ch1 和 ch2,并给这两个变量赋了初值。
      • 第一行我们通过make(chan T)创建的、元素类型为 T 的 channel 类型,是无缓冲 channel,而第二行中通过带有 capacity 参数的make(chan T, capacity)创建的元素类型为 T、缓冲区长度为 capacity 的 channel 类型,是带缓冲 channel。
  • 发送与接收
    • Go 提供了<-操作符用于对 channel 类型变量进行发送与接收操作:
      ch1 <- 13 // 将整型字面值 13 发送到无缓冲 channel 类型变量 ch1 中
      n := <- ch1 // 从无缓冲 channel 类型变量 ch1 中接收一个整型值存储到整型变量 n 中
      ch2 <- 17 // 将整型字面值 17 发送到带缓冲 channel 类型变量 ch2 中
      m := <- ch2 // 从带缓冲 channel 类型变量 ch2 中接收一个整型值存储到整型变量 m 中
      
    • channel 是用于 Goroutine 间通信的,所以绝大多数对 channel 的读写都被分别放在了不同的 Goroutine 中。
    • 由于无缓冲 channel 的运行时层实现不带有缓冲区,所以 Goroutine 对无缓冲 channel 的接收和发送操作是同步的。
      • 也就是说,对同一个无缓冲 channel,只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进行,否则单方面的操作会让对应的 Goroutine 陷入挂起状态。
      • 对无缓冲 channel 类型的发送与接收操作,一定要放在两个不同的 Goroutine 中进行,否则会导致 deadlock。
    • 和无缓冲 channel 相反,带缓冲 channel 的运行时层实现带有缓冲区,因此,对带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。
      • 也就是说,对一个带缓冲 channel 来说,在缓冲区未满的情况下,对它进行发送操作的 Goroutine 并不会阻塞挂起;
      • 在缓冲区有数据的情况下,对它进行接收操作的 Goroutine 也不会阻塞挂起。
      • 但当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起;
      • 当缓冲区为空的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起。
    • 使用操作符 <-,我们还可以声明只发送 channel 类型(send-only)和只接收 channel 类型(recv-only):
      ch1 := make(chan<- int, 1) // 只发送channel类型
      ch2 := make(<-chan int, 1) // 只接收channel类型
      <-ch1 // invalid operation: <-ch1 (receive from send-only type chan<- int
      ch2 <- 13 // invalid operation: ch2 <- 13 (send to receive-only type <-chan int
      
    • 通常只发送 channel 类型和只接收 channel 类型,会被用作函数的参数类型或返回值,用于限制对 channel 内的操作,或者是明确可对 channel 进行的操作的类型。
      • channel 的一个使用惯例是发送端负责关闭 channel。
      • 这是因为发送端没有像接受端那样的、可以安全判断 channel 是否被关闭了的方法。
      • 同时,一旦向一个已经关闭的 channel 执行发送操作,这个操作就会引发 panic。
  • select
    • 当涉及同时对多个 channel 进行操作时,我们会结合 Go 为 CSP 并发模型提供的另外一个原语 select,一起使用。
    • 通过 select,我们可以同时在多个 channel 上进行发送 / 接收操作:
      select {
      case x := <-ch1: // 从 channel ch1 接收数据
      ... ...
      case y, ok := <-ch2: // 从 channel ch2 接收数据,并根据 ok 值判断 ch2 是否已经关闭
      ... ...
      case ch3 <- z: // 将 z 值发送到 channel ch3 中:
      ... ...
      default: // 当上面 case 中的 channel 通信均无法实施时,执行该默认分支
      }
      
    • 当 select 语句中没有 default 分支,而且所有 case 中的 channel 操作都阻塞了的时候,整个 select 语句都将被阻塞,直到某一个 case 上的 channel 变成可发送,或者某个 case 上的 channel 变成可接收,select 语句才可以继续进行下去。
  • 无缓冲 channel 的惯用法
    • 第一种用法:用作信号传递
      • 无缓冲 channel 用作信号传递的时候,有两种情况,分别是 1 对 1 通知信号和 1 对 n 通知信号。
      • 关闭一个无缓冲 channel 会让所有阻塞在这个 channel 上的接收操作返回,从而实现一种 1 对 n 的“广播”机制。
    • 第二种用法:用于替代锁机制
      • 无缓冲 channel 具有同步特性,这让它在某些场合可以替代锁,让我们的程序更加清晰,可读性也更好。
  • 带缓冲 channel 的惯用法
    • 带缓冲的 channel 与无缓冲的 channel 的最大不同之处,就在于它的异步性。
    • 第一种用法:用作消息队列
      • 无论是 1 收 1 发还是多收多发,带缓冲 channel 的收发性能都要好于无缓冲 channel;
      • 对于带缓冲 channel 而言,发送与接收的 Goroutine 数量越多,收发性能会有所下降;
      • 对于带缓冲 channel 而言,选择适当容量会在一定程度上提升收发性能。
    • 第二种用法:用作计数信号量(counting semaphore)
      • Go 并发设计的一个惯用法,就是将带缓冲 channel 用作计数信号量(counting semaphore)。
      • 带缓冲 channel 中的当前数据个数代表的是,当前同时处于活动状态(处理业务)的 Goroutine 的数量,而带缓冲 channel 的容量(capacity),就代表了允许同时处于活动状态的 Goroutine 的最大数量。
      • 向带缓冲 channel 的一个发送操作表示获取一个信号量,而从 channel 的一个接收操作则表示释放一个信号量。
  • len(channel) 的应用
    • 针对 channel ch 的类型不同,len(ch) 有如下两种语义:
      • 当 ch 为无缓冲 channel 时,len(ch) 总是返回 0;
      • 当 ch 为带缓冲 channel 时,len(ch) 返回当前 channel ch 中尚未被读取的元素个数。
    • channel 原语用于多个 Goroutine 间的通信,一旦多个 Goroutine 共同对 channel 进行收发操作,len(channel) 就会在多个 Goroutine 间形成“竞态”。
      • 单纯地依靠 len(channel) 来判断 channel 中元素状态,是不能保证在后续对 channel 的收发时 channel 状态是不变的。
      • 为了不阻塞在 channel 上,常见的方法是将“判空与读取”放在一个“事务”中,将“判满与写入”放在一个“事务”中,而这类“事务”我们可以通过 select 实现。
        func producer(c chan<- int) {
        	var i int = 1
        	for {
        		time.Sleep(2 * time.Second)
        		ok := trySend(c, i)
        		if ok {
        			fmt.Printf("[producer]: send [%d] to channel\n", i)
        			i++
        			continue
        		}
        		fmt.Printf("[producer]: try send [%d], but channel is full\n", i)
        	}
        } 
        
        func tryRecv(c <-chan int) (int, bool) {
        	select {
        	case i := <-c:
        		return i, true
        	default:
        		return 0, false
        	}
        }
        
        func trySend(c chan<- int, i int) bool {
        	select {
        	case c <- i:
        		return true
        	default:
        		return false
        	}
        }
        
        func consumer(c <-chan int) {
        	for {
        		i, ok := tryRecv(c)
        		if !ok {
        			fmt.Println("[consumer]: try to recv from channel, but the channel
        			time.Sleep(1 * time.Second)
        			continue
        		}
        		fmt.Printf("[consumer]: recv [%d] from channel\n", i)
        		if i >= 3 {
        			fmt.Println("[consumer]: exit")
        			return
        		}
        	}
        }
        
        func main() {
        	var wg sync.WaitGroup
        	c := make(chan int, 3)
        	wg.Add(2)
        	go func() {
        		producer(c)
        		wg.Done()
        	}()
        	go func() {
        		consumer(c)
        		wg.Done()
        	}()
        	wg.Wait()
        }
        
      • 这种方法适用于大多数场合,但是这种方法有一个“问题”,那就是它改变了 channel 的状态,会让 channel 接收了一个元素或发送一个元素到 channel。
      • 有些时候我们不想这么做,我们想在不改变 channel 状态的前提下,单纯地侦测 channel 的状态,而又不会因 channel 满或空阻塞在 channel 上。但很遗憾,目前没有一种方法可以在实现这样的功能的同时,适用于所有场合。
      • 但是在特定的场景下,我们可以用 len(channel) 来实现。
        • 多发送单接收
          • 也就是有多个发送者,但有且只有一个接收者。
          • 在这样的场景下,我们可以在接收 goroutine 中使用 len(channel) 是否大于 0 来判断是否 channel 中有数据需要接收。
        • 多接收单发送
          • 也就是有多个接收者,但有且只有一个发送者。在这样的场景下,我们可以在发送 Goroutine 中使用 len(channel) 是否小于 cap(channel) 来判断是否可以执行向 channel 的发送操作。
    • 如果一个 channel 类型变量的值为 nil,我们称它为 nil channel。nil channel 有一个特性,那就是对 nil channel 的读写都会发生阻塞。
  • channel 与 select 结合使用的一些惯用法
    • 第一种用法:利用 default 分支避免阻塞
      • select 语句的 default 分支的语义,就是在其他非 default 分支因通信未就绪,而无法被选择的时候执行的,这就给 default 分支赋予了一种“避免阻塞”的特性。
    • 第二种用法:实现超时机制
      • 带超时机制的 select,是 Go 中常见的一种 select 和 channel 的组合用法。
      • 通过超时事件,我们既可以避免长期陷入某种操作的等待中,也可以做一些异常处理工作。
        func worker() {
        	select {
        	case <-c:
        		// ... do some stuff
        	case <-time.After(30 *time.Second):
        		return
        	}
        }
        
      • 我们要尽量减少在使用 Timer 时给 Go 运行时和 Go 垃圾回收带来的压力,要及时调用 timer 的 Stop 方法回收 Timer 资源。
    • 第三种用法:实现心跳机制
      • 结合 time 包的 Ticker,我们可以实现带有心跳机制的 select。
      • 这种机制让我们可以在监听 channel 的同时,执行一些周期性的任务:
        func worker() {
        	heartbeat := time.NewTicker(30 * time.Second)
        	defer heartbeat.Stop()
        	for {
        		select {
        		case <-c:
        			// ... do some stuff
        		case <- heartbeat.C:
        			//... do heartbeat stuff
        		}
        	}
        }
        

如何使用共享变量?

sync 包低级同步原语可以用在哪?

  • 首先是需要高性能的临界区(critical section)同步机制场景。
    • 在 Go 中,channel 并发原语也可以用于对数据对象访问的同步,我们可以把 channel 看成是一种高级的同步原语,它自身的实现也是建构在低级同步原语之上的。
    • 也正因为如此,channel 自身的性能与低级同步原语相比要略微逊色,开销要更大。
  • 第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。
    • 基于 channel 的并发设计,有一个特点:在 Goroutine 间通过 channel 转移数据对象的所有权。所以,只有拥有数据对象所有权(从 channel 接收到该数据)的 Goroutine 才可以对该数据对象进行状态变更。
    • 如果设计中没有转移结构体对象所有权,但又要保证结构体内部状态数据在多个 Goroutine 之间同步访问,那么可以使用 sync 包提供的低级同步原语来实现,比如最常用的 sync.Mutex。

sync 包中同步原语使用的注意事项

  • Go 标准库中 sync.Mutex 的定义是这样的:
    // $GOROOT/src/sync/mutex.go
    type Mutex struct {
    	state int32
    	sema uint32
    }
    
    • Mutex 的定义非常简单,由两个整型字段 state 和 sema 组成:
      state:表示当前互斥锁的状态;
      sema:用于控制锁状态的信号量。
      
    • 初始情况下,Mutex 的实例处于 Unlocked 状态(state 和 sema 均为 0)。
    • 对 Mutex 实例的复制也就是两个整型字段的复制。一旦发生复制,原变量与副本就是两个单独的内存块,各自发挥同步作用,互相就没有了关联。如果发生复制后,仍然认为原变量与副本保护的是同一个数据对象,那可就大错特错了。
    • 一旦 Mutex 类型变量被拷贝,原变量与副本就各自发挥作用,互相没有关联了。甚至,如果拷贝的时机不对,比如在一个 mutex 处于 locked 的状态时对它进行了拷贝,就会对副本进行加锁操作,将导致加锁的 Goroutine 永远阻塞下去。
    • 如果对使用过的、sync 包中的类型的示例进行复制,并使用了复制后得到的副本,将导致不可预期的结果。所以,在使用 sync 包中的类型的时候,我们推荐通过闭包方式,或者是传递类型实例(或包裹该类型的类型实例)的地址(指针)的方式进行。这就是使用 sync 包时最值得我们注意的事项。
  • sync 包提供了两种用于临界区同步的原语:互斥锁(Mutex)和读写锁(RWMutex)。
    • 它们都是零值可用的数据类型,也就是不需要显式初始化就可以使用,并且使用方法都比较简单。
      var mu sync.Mutex
      mu.Lock() // 加锁
      doSomething()
      mu.Unlock() // 解锁
      
      • 一旦某个 Goroutine 调用的 Mutex 执行 Lock 操作成功,它将成功持有这把互斥锁。
      • 这个时候,如果有其他 Goroutine 执行 Lock 操作,就会阻塞在这把互斥锁上,直到持有这把锁的 Goroutine 调用 Unlock 释放掉这把锁后,才会抢到这把锁的持有权并进入临界区。
      • 由此,我们也可以得到使用互斥锁的两个原则:
        • 尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。
        • 一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。
    • 读写锁与互斥锁用法大致相同,只不过多了一组加读锁和解读锁的方法:
      var rwmu sync.RWMutex
      rwmu.RLock() //加读锁
      readSomething()
      rwmu.RUnlock() //解读锁
      rwmu.Lock() //加写锁
      changeSomething()
      rwmu.Unlock() //解写锁
      
      • 写锁与 Mutex 的行为十分类似,一旦某 Goroutine 持有写锁,其他 Goroutine 无论是尝试加读锁,还是加写锁,都会被阻塞在写锁上。
      • 读锁就宽松多了,一旦某个 Goroutine 持有读锁,它不会阻塞其他尝试加读锁的 Goroutine,但加写锁的 Goroutine 依然会被阻塞住。
      • 通常,互斥锁(Mutex)是临时区同步原语的首选,它常被用来对结构体对象的内部状态、缓存等进行保护,是使用最为广泛的临界区同步原语。相比之下,读写锁的应用就没那么广泛了,只活跃于它擅长的场景下。
      • 读写锁适合应用在具有一定并发量且读多写少的场合。在大量并发读的情况下,多个 Goroutine 可以同时持有读锁,从而减少在锁竞争中等待的时间。
  • 条件变量
    • sync.Cond是传统的条件变量原语概念在 Go 语言中的实现。
      • 我们可以把一个条件变量理解为一个容器,这个容器中存放着一个或一组等待着某个条件成立的 Goroutine。
      • 当条件成立后,这些处于等待状态的 Goroutine 将得到通知,并被唤醒继续进行后续的工作。
    • 条件变量是同步原语的一种,如果没有条件变量,开发人员可能需要在 Goroutine 中通过连续轮询的方式,检查某条件是否为真,这种连续轮询非常消耗资源,因为 Goroutine 在这个过程中是处于活动状态的,但它的工作又没有进展。

原子操作(atomic operations)

  • atomic 包是 Go 语言给用户提供的原子操作原语的相关接口。原子操作(atomic operations)是相对于普通指令操作而言的。
  • 原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,因此相对于操作系统层面和 Go 运行时层面提供的同步技术而言,它更为原始。
  • atomic 包封装了 CPU 实现的部分原子操作指令,为用户层提供体验良好的原子操作函数,因此 atomic 包中提供的原语更接近硬件底层,也更为低级,它也常被用于实现更为高级的并发同步技术,比如 channel 和 sync 包中的同步原语。
  • atomic 包提供了两大类原子操作接口,一类是针对整型变量的,包括有符号整型、无符号整型以及对应的指针类型;另外一类是针对自定义类型的。因此,第一类原子操作接口的存在让 atomic 包天然适合去实现某一个共享整型变量的并发同步。
  • atomic 原子操作的特性:随着并发量提升,使用 atomic 实现的共享变量的并发读写性能表现更为稳定,尤其是原子读操作,和 sync 包中的读写锁原语比起来,atomic 表现出了更好的伸缩性和高性能。
  • atomic 包更适合一些对性能十分敏感、并发量较大且读多写少的场合。不过,atomic 原子操作可用来同步的范围有比较大限制,只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步,那么首选的依旧是 sync 包中的原语。

你可能感兴趣的:(云原生,golang,学习,笔记)