Go语言并发机制

本文介绍了Go语言并发机制的核心概念和技术细节,包括goroutine、channel及select等关键组件的作用和使用方法。通过实例展示了如何利用这些机制进行高效并发编程。

Go语言并发机制是Go语言得以在后端开发中大显身手的核心机制。
Go语言的并发机制在相当大的程度上比Java简单,比Python略微强大。这篇博文是简介,后面还会接着出详解。
Go语言并发底层理论基础基于图灵奖得主C.A.R Hoare的主要贡献之一CSP(通信顺序过程)。大部分介绍Go语言并发机制的文章都以简述草草了事,因为这部分比较困难,它在我们学校里面是面向学硕的一门课程,而对工程师而言只要会用就可以了。之后我可能出相关文章。

Go语言并发机制

并发是编程语言中一个重要的概念,当前主要的编程语言都在语言层面支持并发,Go语言中的并发编程比其他语言都要简单,这是Go语言在后端开发逐渐流行起来的原因。

并行与并发
  • 并行:同一时刻执行多个任务。
  • 并发:同一个时间段内执行多个任务

Go语言的并发通过goroutine(直译应该是Go程)实现。goroutine是用户态的轻量级线程,因此上下文切换要比线程的上下文切换开销要小很多。

一谈到并发,自然就会有通信调度共享资源竞争这些需要解决的问题。

Go语言提供channel在多个goroutine中进行通信。goroutinechannel是Go语言秉承CSP(Communicating Sequence Process)实现并发模式的基础。Go语言的并发机制主要基于CSP,小部分实现也采用了CSP的竞品CCS(Calculus of Communicating System)

在实际的项目中,你可以创建成千上万个goroutine,这些goroutine的调度将由Go语言运行时调度自动完成,因而开销更小且更加灵活,而线程调度则需要操作系统代为完成,用户态到核心态的切换将耗费可观的时间。

共享资源竞争问题,需要使用锁以及相关机制保证并发安全,Go语言的sync包中提供了并发实现的一系列工具,其中包括了用于原子操作的sync/atomic包。

goroutine

在Java/C++中实现并发编程的时候,程序员需要自己维护一个线程池,并且需要手动去包装一个又一个的任务然后自己去调度线程执行任务并维护上下文切换。这一切将会耗费程序员大量的时间和心智,而且很容易发生错误。因此如果编程语言能在语言层面上提供一种机制自动分配定义好的任务到CPU上实现并发执行,将能大大减少程序员的工作量,而goroutine就是这样一种机制。

goroutine的概念类似于coroutine协程而不是线程(参考文献第二篇说是线程,这是一个广泛存在的错误)。也就是说,它由Go程序自身来完成调度,而不需要交由操作系统切换到核心态处理。goroutine的调度由Go运行时调度器使用m:n调度技术进行调度,比线程调度成本低很多。在Go语言中,你不需要自己去写进程、线程、协程,你只需要使用go关键字发起一个goroutine就可以了,简单如此,因而称Go语言为现代化的编程语言。

go func_name(para_list)
goroutine和coroutine的区别

C#、Lua、Python都支持coroutinecoroutinegoroutine在名称上类似,都属于轻量级用户态线程,由程序逻辑调度,可以在函数或者语句在独立环境中运行,但它们有一些区别:

  • goroutine可能发生并行执行,但coroutine始终顺序执行。
  • goroutine间使用channel进行通信,coroutine使用yieldresume操作进行通信
  • coroutine的运行机制属于协作式任务处理,goroutine则属于抢占式任务处理。

特别注明:Go语言中main函数也是作为goroutine运行的,下面有实例。

package main

import (
	"fmt"
)

func print10(i int) {
	fmt.Println("goroutine", i)
}

func main() {
	for i:=0; i<10; i++ {
		go print10(i)
		fmt.Println("goroutine", i, "Done")
	}
}

结果1:

goroutine 0 Done
goroutine 0
goroutine 1 Done
goroutine 1
goroutine 2
goroutine 2 Done
goroutine 3 Done
goroutine 4 Done
goroutine 3
goroutine 4
goroutine 5 Done
goroutine 6 Done
goroutine 7 Done
goroutine 8 Done
goroutine 9 Done

结果2:

goroutine 0
goroutine 0 Done
goroutine 1 Done
goroutine 2 Done
goroutine 3 Done
goroutine 1
goroutine 4 Done
goroutine 2
goroutine 3
goroutine 4
goroutine 5 Done
goroutine 6 Done
goroutine 6
goroutine 5
goroutine 7 Done
goroutine 8 Done
goroutine 7
goroutine 9 Done
goroutine 9
goroutine 8

结果说明什么呢?

Go语言中main函数也是作为goroutine运行的,而且如果main函数退出了整个程序将退出当前没有获得调度的goroutine也不会再运行了main函数作为goroutine时,它的优先级也不会比其他goroutine高。

但是这显然不符合我们的期望,我们希望所有的协程都完成自己的工作之后程序再退出。这时候我们可以使用WaitGroup

sync.WaitGroup可以用来实现简单的goroutine同步,实例如下:

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func print10(i int) {
	defer wg.Done()
	fmt.Println("goroutine", i)
}

func main() {
	for i:=0; i<10; i++ {
		wg.Add(1)
		go print10(i)
		fmt.Println("goroutine", i, "Done")
	}
	wg.Wait()
}

结果

goroutine 0 Done
goroutine 1 Done
goroutine 2 Done
goroutine 0
goroutine 3
goroutine 1
goroutine 3 Done
goroutine 4 Done
goroutine 5 Done
goroutine 6 Done
goroutine 2
goroutine 7 Done
goroutine 7
goroutine 4
goroutine 5
goroutine 6
goroutine 8
goroutine 8 Done
goroutine 9 Done
goroutine 9

打印的顺序不是按序的,这10个goroutine都被加入到了一个WaitGroup中,它们的调度是随机的。

wg.Wait()运行后,它会调度goroutine运行,等待组内所有的goroutine运行结束后返回。

channel

通道(channel)是Go语言中定义的用来传递数据的一种新的数据结构。它的概念和其他地方的通道一样,只不过Go在语言层面就提供了变量级别的实现。

通道可用于两个goroutine之间传递一个指定类型的值来进行同步运行和通信。操作符<-用于指定通道的方向。如果没有指定方向,默认为双向通道。

单纯地将函数并发执行是没有意义的。函数与函数之间需要交换数据才能体现并发执行函数的意义。虽然可以使用共享内存进行数据交换(其他语言中一种最高效的线程通信方式),但这种方式在不同的goroutine中容易发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,加锁势必会造成性能问题。

Go语言的并发模型基于CSP,CSP提倡通过通信共享内存,而不是通过共享内次你实现通信。如果说goroutine是Go程序并发的执行体,channel是它们之间的连接。channel是可以让一个goroutine发送特定类型值(类型在通道定义的时候指定)到另一个goroutine的通信机制。

Go语言的通道(channel)是一种特殊的类型。通道像是一个传送带或者队列,总是遵循先入先出(First In First Out, FIFO)规则,保证数据收发的顺序。

channel声明语法如下
var channel_name chan elem_type

实例

var ch1 chan int   // 声明传递整型数据的通道
var ch2 chan bool  // 声明传递布尔类型的通道
var ch3 chan []int // 声明传递int形切片的通道

注:channel本身属于引用类型,空值对应<nil>

package main

import "fmt"

func main() {
	var ch1 chan int
	fmt.Println(ch1)
}

// <nil>
channel初始化

声明的channel需要使用make函数初始化,语法如下:

make(chan elem_type, [缓冲区大小])
var ch1 chan int
ch1 = make(chan int)

等价于下面:

ch1 := make(chan int)

注:没有定义缓冲区大小的channel没有缓冲区的,无缓冲区的channel又被称为阻塞通道,在没有变量接收值的情况下向channel发送数据会引起死锁(deadlock)。

无缓冲的channel上的发送操作会被阻塞,直到另一个goroutine在该通道上执行接收操作,这时值才能发送成功,两个goroutine将继续执行。如果接收方先执行接收操作,那么接收方被阻塞,直到另一个goroutinechannel上发送一个值。

使用无缓冲通道进行通信将导致发送和接收的goroutine同步化。因此,无缓冲通道又被称为同步通道

channel操作

channel有发送、接收、关闭三种操作。发送和接收都是使用<-符号。

// 定义通道
ch := make(chan int)

// 发送
//一个值到通道中 需要满足通道所接收的数据类型 这里是整型
ch <- 10

// 接收
// 从通道中接收一个值并赋值给一个变量
x := <- ch

// 关闭
close(ch)
通道关闭需要注意的事情
单向通道

将通道作为函数参数在多个函数间传递时,我们可能需要限定不同函数使用通道的方式,比如只能发送或者接收。Go语言中提供了单向通道来处理这种情况。它体现在函数参数中指明了该函数使用通道的方式,使用<-标识数据传输的方向。

// 只能向通道发送数据
func func_name(ch chan<- int)
// 只能从通道中取数据
func func_name(ch <-chan int)

实例:

package main

import "fmt"

func counter(ch chan<- int) {
	for i:=0; i<10; i++ {
		ch <- i
	}
	close(ch)
 }

func square(ch1 chan<- int, ch2 <-chan int) {
	for i := range ch2 {
		ch1 <- i*i
	}
	close(ch1)
}

func printer(ch <- chan int) {
	for i := range ch {
		fmt.Println(i)
	}
}

func main() {
	ch1 := make(chan int)
	ch2 := make(chan int)
	go counter(ch1)
	go square(ch2, ch1)
	printer(ch2)
}

结果如下:

0
1
4
9
16
25
36
49
64
81

注:因为通道先进先出的特性,即使goroutine是随机执行的,但输出结果仍是顺序的。

select多路复用

在某些场景下我们需要从多个通道接收数据。通道在接收数据时,如果没有数据接收会阻塞等待。

// Go语言中for语句替代了while语句
// for {} 相当于 while (true) {}
for {
	data, ok := <- ch1
	data, ok := <- ch2
	...
	data, ok := <- chn
}

这种实现方式逻辑是对的,但轮询操作在很多情况下都得尝试很多次才能取得成功,因而性能不高。为了应对这种场景,Go语言内置了select来实现类似于swich的分支选择,它和switch写法类似,不同在于case语句是某种通信操作。

语法如下:

select {
	case communication_clause1:
		statement1
	case communication_clause2:
		statement2
	...
	[default]:
		[statementd]
}
  • 每一个case必须是一个通信语句
  • 所有通道表达式都会被求值
  • 所有被发送的表达式都会被求值
  • 如果任意一个case通信可以进行,它就执行,其他被忽略
  • 如果有多个case都可以运行,select会随机公平地选出一个执行,其他case则不会被执行。
  • 在没有任何case可以运行的情况下;如果存在default语句,则执行该语句;如果没有default语句,则阻塞,直到某个case可以通信,Go不会重新对通道进行求值。

实例:

package main

import (
	"fmt"
)

func main() {
	var c1, c2 chan int

	var a = 1

	c1 = make(chan int, 10)
	c2 = make(chan int, 10)

	select {
	case c1 <- a:
		fmt.Println("Send", a, "to c1")
	case c2 <- a:
		fmt.Println("Send", a, "to c2")
	case b := <- c1:
		fmt.Println("Received", b, "from c1")
	case b := <- c2:
		fmt.Println("Received", b, "from c2")
	default:
		fmt.Println("No communication")
	}
}

// 程序执行的结果有以下两种:
// Send 1 to c1
// Send 2 to c2
// 以上两种随机出现
// 不会有其他种类 因为前两个case一定可以执行 后两个case一定不能执行(通道为空,不能发送) default不会执行

实例:双goroutine通道循环取值

package main

import "fmt"

func main() {

	ch1 := make(chan int)
	ch2 := make(chan int)

	// 创建一个匿名函数 直接开启goroutine
	go func() {
		for i:=0; i<10; i++ {
			ch1 <- i
		}
		close(ch1) // 通道使用完 记得关闭
	} ()

	// 创建一个匿名函数 直接开启goroutine
	go func() {
		for i:=0; i<10; i++ {
			i, ok := <- ch1

			if !ok {
				break
			}
			ch2 <- i * i
		}
		close(ch2) // 通道使用完 记得关闭
	} ()
	// 遍历通道取值
	for i := range ch2 {
		fmt.Println(i)
	}
}

结果如下:

0
1
4
9
16
25
36
49
64
81

参考文献

Go并发

Go语言基础之并发

goroutine和coroutine的区别

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值