Go中的goroutine和channel

553 阅读16分钟

本文对A Tour of Go中的例子进行学习,以此了解Go语言中的goroutine和channel。

goroutine翻译为协程,channel翻译为通道或管道。虽然Go开发者在交流中直接称goroutine为协程了,但在goroutine出现之前已经有另一个东西被翻译为了协程(coroutine),所以goroutine准确地说应该是Go语言特有的协程。

这篇文章只是讲goroutine的基本使用,更多相关内容可以查看:

goroutine

goroutine是由Go运行时管理的轻量级线程。goroutine与操作系统线程相似,但不是同一个东西。

go f(x, y, z)在一个新的goroutine中开始执行f(x, y,z)

同一个进程下的goroutines运行自在相同的地址空间中,所以对共享内存访问必须同步。

有两种方式使用共享内存:

  1. 使用channel
  2. 使用sync包的同步原语,比如互斥锁。

简单说一下为什么对共享内存的访问必须同步。同一个进程下的goroutines运行在相同的地址空间中,没有内存隔离,不同的goroutines可以访问同一个内存地址,这样对共享内存的访问就有可能出现问题,比如有一个全局变量A,goroutine 1开始修改A的数据,还没有修改完,goroutine 2 就开始读取A的数据了,这样读到的数据是不准确的,如果goroutine 2也要修改变量A,那么A的数据就处于一种更不确定的状态了,所以需要某种方式,确保同一时间内只能有一个goroutine对变量A进行修改。

下面例子中go say("world") 在一个新的goroutine中开始执行say("world")函数:

import (
  "fmt"
  "time"
)
​
func init() {
  go say("world")
  say("hello")
}
​
func say(s string) {
  for i := 0; i < 3; i++ {
    time.Sleep(100 * time.Millisecond)
    fmt.Printf("%v %v %v\n", s, i, time.Now().Format("15:04:05.000000"))
  }
}
​

打印结果为:

world 0 11:20:29.569787
hello 0 11:20:29.569871
hello 1 11:20:29.671178
world 1 11:20:29.671209
world 2 11:20:29.772683
hello 2 11:20:29.772795

可以看到helloworld的顺序是不确定的,不是先打印完所有hello再打印所有world,也不是先打印完所有world再打印所有hello;不是先打印一个hello再打印一个world,也不是先打印一个world再打印一个hello,说明了goroutine的并发性。

channel

channel是类型化的管道(conduit),可以通过<-(通道运算符)来使用channel,对值进行发送和接收。

可选的<-操作符指定了通道的方向,如果给出了一个方向,通道就是定向的,否则就是双向的。

chan T // 可以被用来发送和接收类型为T的值
chan <- float64 // 只能被用来发送float64类型的值
<-chan int // 只能被用来接收int类型的值

如果有<-操作符,数据按照箭头的方向流动。

刚开始学习的时候我对箭头表示接收/发送有点迷惑,chan <- float64表示只能发送float64类型的值,看箭头的方向数据是流向通道的,站在通道的角度上应该是接收而不是发送,但这里就是发送,所以这个<-符号表示的主体应该不是通道,不是说通道的发送接收,而是向通道发送,或从通道接收。不是通道自己发送/接收,而是别的对象使用通道,向通道发数据,从通道接收数据。

通道在使用前必须被创建:

make(chan int, 100)

通过内置的make函数创建一个新的、初始化的通道,接收的参数是通道类型和一个可选的容量。容量设置缓存区的大小,如果容量是0或者省略了,通道就是非缓存的,只在发送方和接收方都准备好的时候才能通信成功。否则通道就是缓存的,发送方的缓存区没有满,或者接收方的缓存区不为空,就能不阻塞地进行通信。

go中的goroutine.drawio.svg

“发送方的缓存区没有满,或者接收方的缓存区不为空,就能不阻塞地进行通信。“这句话直白一点说,就是如果缓存区满了,就不能再往通道中发送数据了(chan <- 数据 ),如果缓存区是空的,就不能从通道中接收数据了(<-chan)。

1.无缓存通道的例子

import (
  "fmt"
)
​
func init() {
  s := []int{7, 2, 8, -9, 4, 0}
  c := make(chan int)
  mid := len(s) / 2
  go sum(s[:mid], c)
  go sum(s[mid:], c)
  x, y := <-c, <-c
  fmt.Println(x, y, x+y)
}
​
func sum(s []int, c chan int) {
  sum := 0
  for _, v := range s {
    sum += v
  }
  c <- sum
}

x拿到的是先计算完的和,y拿到的是后计算完的和,xy的值是不确定的,x y可能是-5 17或者 17 -5,就看哪个子goroutine中的计算先完成。

go中的goroutine-第 2 页.drawio.svg

这里使用goroutine并发执行代码,把求和的时间复杂度从O(n)O(n)降低到O(n/2)O(n/2),虽然两者的时间复杂度都能说成O(n)O(n),但显然后者的效率是较高的。

2.有缓存通道的例子

import "fmt"func init() {
  ch := make(chan int, 2)
  ch <- 1
  ch <- 2
  fmt.Println(<-ch, <-ch)
}

这个例子创建了一个容量为2的有缓存通道,依次向通道中发送数据并接收,能正常打印出1 2

但是当向通道中发送的元素超过容量大小的时候:

import "fmt"func init() {
  ch := make(chan int, 2)
  ch <- 1
  ch <- 2
  ch <- 3 // 第9行代码
  fmt.Println(<-ch, <-ch)
}

会报死锁错误:

fatal error: all goroutines are asleep - deadlock!
​
goroutine 1 [chan send, locked to thread]:
gopractice/channel.init.0()
  /somePath/2022-05-31-goroutine/channel/bufferedChannel.go:9 +0x6a

错误的原因打印出的信息也说得很清楚了,第9行代码的向通道中发送数据的代码ch <- 3锁住了线程,为什么会锁住呢?因为ch <-3执行前,缓存通道中已经有两个元素了,发送操作会阻塞直到通道中缓存的元素个数小于2,但是因为接收操作<-chch <- 3之后,所以在ch <- 3执行结束之前,通道中的元素个数不会减少,通道中的元素不减少发送操作ch <- 3就会阻塞,因此造成了死锁,按照代码执行逻辑,ch <- 3永远不会等到通道中元素个数小于2的时候。

go中的goroutine-第 3 页.drawio.svg

range 和 close

发送方可以close一个通道来表明没有更多的值会被发送。接收方可以通过赋值第二个参数给接收表达式,测试一个通道是否已经关闭。

执行如下语句:

v, ok := <-ch

如果没有更多的值要接收,并且通道已经关闭了,ok的值就为false

for i := range c循环,从通道中重复地接收值,直到通道关闭。

注意:

  • 只有发送方可以关闭一个通道,接收方不可以。在一个已经关闭的通道上进行发送会导致一个错误(panic)。
  • 通道不像文件,不需要总是关闭它们。关闭只有必须告诉接收方不会再来更多值时,才是必须的,比如终止一个range循环。

斐波那契数列是这样的一个公式:

斐波那契数列: f(n)={f(0)=0f(1)=1f(n)=f(n1)+f(n2)其中 n>1f(n) = \begin{cases} f(0) = 0\\ f(1) = 1\\ f(n) = f(n - 1) + f(n - 2)& 其中~n > 1\\ \end{cases}

下面的代码从f(0)计算到f(9):

import (
	"fmt"
)

func init() {
	c := make(chan int, 10)
	go fibonacci(cap(c), c)
	for i := range c {
		fmt.Println(i)
	}
}

func fibonacci(n int, c chan int) {
	x, y := 0, 1
	for i := 0; i < n; i++ {
		c <- x
		x, y = y, x+y
	}
	close(c)
}

如果把close(c) 语句注释掉,运行代码,就会报错:fatal error: all goroutines are asleep - deadlock!。因为for i := range c一直在等通道关闭,但是整个执行过程中并没有关闭通道,造成了死锁。

select

select语句让一个goroutine等待多个通信操作。

一个select会阻塞,直到它的cases中的一个可以运行,然后它就会执行该case。如果多个通信都准备好了,就会随机选择一个。


import "fmt"

func init() {
	c := make(chan int)
	quit := make(chan int)
	go func() {
		for i := 0; i < 10; i++ {
			fmt.Println(<-c)
		}
		quit <- 0
	}()
	fibonacci(c, quit)
}

func fibonacci(c, quit chan int) {
	x, y := 0, 1
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}

这段代码同样计算从f(0)f(0)f(9)f(9)的斐波那契数列。但是与之前使用rangeclose以及有缓存通道的实现方式不同,这里用的是select和无缓存通道.

在子goroutine中(称这个goroutine 为 goroutine 1)循环10次,依次从通道c中接收数据,循环结束后,将数字0发送到通道quit表示结束计算。

在主goroutine中,调用fibonacci函数:

  • c <- x是向通道中发送数据,只要有地方从通道中接收数据,向通道中发送数据就能继续运行。每次在goroutine 1的循环中执行<-c,主goroutine中的select语句中的case c <- x中的语句就会执行。
  • <-quit是从通道中接收数据,只要有地方向通道发送数据,从通道中接收数据节能继续执行。当goroutine 1中的循环结束之后执行quit <- 0时,case <- quit中的语句就会执行,其中的代码会执行return结束fibonacci函数的执行。

default选项

一个select中的default case,在没有其他case准备好的时候就会运行。

import (
	"fmt"
	"time"
)

func init() {
	tick := time.Tick(100 * time.Millisecond)
	boom := time.After(500 * time.Millisecond)
	for {
		select {
		case <-tick:
			fmt.Println("tick.")
		case <-boom:
			fmt.Println("BOOM!")
			return
		default:
			fmt.Println("    .")
			time.Sleep(50 * time.Millisecond)
		}
	}
}

打印的结果为:

    .
    .
tick.
    .
    .
tick.
    .
    .
tick.
    .
    .
tick.
    .
    .
BOOM!

time.Tick(100 * time.Millisecond)创建一个通道并返回该通道,每隔100毫秒就向通道中发送一次数据。

time.After(500 * time.Millisecond)创建一个通道并返回该通道,500毫秒后向通道发送一次数据。

每隔100毫秒,通道tick就会收到一次数据,case <-tick中的语句会执行,打印一次tick.;500毫秒之后,通道boom会收到数据,case <-boom中的语句会执行,打印BOOM!,并且使用return结束程序的执行。在这期间,由于for语句是一直在循环的,当通道tick和通道boom中都没收到数据时,就会执行default中的语句:打印一个点并且等待50毫秒。

之前说过有两种方式使用共享内存:

  1. 使用channel
  2. 使用sync包的同步原语,比如互斥锁。

到目前的练习中,直接使用channel就不需要特别注意共享内存的问题,接下来使用sync包来使用共享内存。

sync.Mutex

如果我们想要避免冲突,确保一次只有一个goroutine可以访问一个变量(这个概念称为互斥),则可以使用互斥锁。

Go的标准库中提供了互斥的使用,需要用到sync.Mutex和它的两个方法LockUnlock

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func init() {
	counter := Counter{
		val: make(map[string]int),
	}

	wg.Add(100)
	for i := 0; i < 100; i++ {
		go counter.Increase("somekey")
	}

	wg.Wait() // 等待所有goroutines执行完成
	fmt.Println(counter.Value("somekey"))
}

type Counter struct {
	mu  sync.Mutex
	val map[string]int
}

func (c *Counter) Increase(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	defer wg.Done()

	c.val[key]++
}

func (c *Counter) Value(key string) int {
	// 这里也可以换成用RWMutex,只锁写,不锁读
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.val[key]
}

练习题

官方留了两道练习题,没有给出完整的代码。可以作为了解了以上知识之后的练手。

等价的二叉树

有很多不同的二叉树,存储着相同的值的序列。实现一个方法来判断两棵二叉树是否存储相同的序列。例如,下图两棵二叉树存储的序列是1, 1, 2, 3, 5, 8, 13。虽然这两棵树明显是不同的,但是按照中序遍历的顺序,它们存储的序列的值都是1, 1, 2, 3, 5, 8, 13。

image.png

最主要就是实现2个方法,一个方法遍历树中的节点,一个方法根据遍历的结果判断两棵树存储的序列的值是否是一样的。

  1. 实现Walk函数。Walk函数遍历树中的节点。

  2. 测试Walk函数。

    函数tree.New(k)构造了一个随机结构(但总是排序的)的二叉树来存储值k2k3k,...,10k

    创建一个新的通道ch并开始遍历:

    go Walk(tree.New(1), ch)
    

    然后打印树中包含的10个值,应该是数字1,2,3,...,10。

  3. 实现Same函数,使用Walk来确定两棵树是否存储相同的值。

  4. 测试Same函数:

    Same(tree.New(1), tree.New(1)) 应该返回true

    Same(tree.New(1), tree.New(2)) 应该返回false

树的实现官方已经给出了代码,这里New()方法构造出的树是形状随机的二叉搜索树,虽然形状可能不一样,但是中序遍历这些树的节点,得到的序列一定是一样的。

package tree

import (
	"fmt"
	"math/rand"
)

// Tree 是一个包含整数值的二叉树
type Tree struct {
	Left  *Tree
	Value int
	Right *Tree
}

// New 会返回一个新的,包含值为k, 2k, ..., 10k的随机二叉搜索树
func New(k int) *Tree {
	var t *Tree

	// Perm 返回半开区间[0, n)中整数的伪随机排列
	for _, v := range rand.Perm(10) {
		item := (1 + v) * k
		t = insert(t, item)
	}

	return t
}

// 将值插入树中,节点的左子树中存的值小于节点的值,右子树村的值大于等于节点的值
func insert(t *Tree, v int) *Tree {
	if t == nil {
		return &Tree{nil, v, nil}
	}

	if v < t.Value {
		t.Left = insert(t.Left, v)
	} else {
		t.Right = insert(t.Right, v)
	}
	return t
}

// 遍历树的节点,打印出树的内容
func (t *Tree) String() string {
	if t == nil {
		return "()"
	}
	s := ""
	if t.Left != nil {
		s += t.Left.String() + " "
	}
	s += fmt.Sprint(t.Value)
	if t.Right != nil {
		s += " " + t.Right.String()
	}
	return "(" + s + ")"
}

判断两棵树是否等价的代码如下:

package equivalentbinarytrees

import (
	"fmt"
	"gopractice/equivalentBinaryTrees/tree"
)

func init() {
	tree1 := tree.New(1)
	tree2 := tree.New(1)
	fmt.Println(Same(tree1, tree2))
	fmt.Println(tree1, "tree1")
	fmt.Println(tree2, "tree2")
}

// 中序遍历树 t,将树中的所有值依次发送到通道中
func Walk(t *tree.Tree, ch chan int) {
	if t == nil {
		return
	}
	if t.Left != nil {
		Walk(t.Left, ch)
	}
	ch <- t.Value
	if t.Right != nil {
		Walk(t.Right, ch)
	}
}

// 判断两棵树是否包含相同的值
func Same(t1, t2 *tree.Tree) bool {
	ch1 := make(chan int, 10)
	ch2 := make(chan int, 10)

	go Walk(t1, ch1)
	go Walk(t2, ch2)

	count := 0

	for {
		if <-ch1 == <-ch2 {
			count++
			// 这里的count等于10,是因为题目要求里面随机生成的树的节点个数就是10个
			if count == 10 {
				return true
			}
		} else {
			return false
		}
	}
}

执行代码打印的结果如下:

true
(((1) 2 ((3 (4)) 5 (((6) 7) 8))) 9 (10)) tree1
(((1 (2)) 3 (4)) 5 (6 (((7) 8 (9)) 10))) tree2

可以看到两棵树的形状不是一样的,但是它们中序遍历得到的序列是一样的。

网络爬虫

使用Go的并发功能来并发网络爬虫。

修改Crawl函数来并发获取URLs,并且相同的URL不会获取2次。

提示:你可以使用映射缓存已经获取到的URL,但是只使用映射对于并发使用来说是不安全的。

这部分的代码实现看了大佬写的文《learning golang some rough notes s01e10 concurrency web crawler》

主要思路就是递归地请求路径,获取路径对应的数据。并发的部分使用sync.WaitGroup,用Add方法添加WaitGroup计数,用wg.Wait()等待所有的goroutines执行结束。

主要代码是:

func init() {
	wg := &sync.WaitGroup{}
	wg.Add(1)
	go Crawl("https://golang.org/", 5, fetcher, wg)
	wg.Wait()
}

func Crawl(url string, depth int, fetcher Fetcher, wg *sync.WaitGroup) {
	defer wg.Done()
  
  ...

	for _, z := range urls {
		wg.Add(1)
		go Crawl(z, depth-1, fetcher, wg)
	}
}

完整代码:

package webcrawler

import (
	"fmt"
	"math/rand"
	"sync"
	"time"
)

func init() {
	wg := &sync.WaitGroup{}
	wg.Add(1)
	go Crawl("https://golang.org/", 5, fetcher, wg)
	wg.Wait()
}

type URLs struct {
	c  map[string]bool // 用于存放表示一个链接是否被抓取过的映射
	mu sync.Mutex      // 使用互斥锁在并行的执行中进行安全的读写
}

var u URLs = URLs{
	c: make(map[string]bool),
}

// 检查链接是否已经被抓取过
func (u *URLs) IsCrawled(url string) bool {
	fmt.Printf("\n检查 %v 是否被抓取过...", url)
	u.mu.Lock()
	defer u.mu.Unlock()
	if _, ok := u.c[url]; !ok {
		fmt.Printf("...未被抓取\t")
		return false
	}
	fmt.Printf("...已被抓取\t")
	return true
}

// 将链接标记为被抓取过
func (u *URLs) Crawled(url string) {
	u.mu.Lock()
	defer u.mu.Unlock()
	u.c[url] = true
}

// 递归地请求url,直到一个最大的深度
func Crawl(url string, depth int, fetcher Fetcher, wg *sync.WaitGroup) {
	defer wg.Done()

	if depth <= 0 {
		return
	}

	if u.IsCrawled(url) {
		return
	}

	fmt.Printf("\n %v 抓取中", url)
	body, urls, err := fetcher.Fetch(url)
	u.Crawled(url)

	if err != nil {
		fmt.Println(err)
		return
	}
	fmt.Printf("\n\t %s %q\n", url, body)

	for _, z := range urls {
		wg.Add(1)
		go Crawl(z, depth-1, fetcher, wg)
	}
}

// 模拟了网络请求,没有抓取真实的网页
type Fetcher interface {
	// Fetch返回URL的body,以及页面中找到的URLs
	Fetch(url string) (body string, urls []string, err error)
}

type fakeFetcher map[string]*fakeResult

type fakeResult struct {
	body string
	urls []string
}

func (f fakeFetcher) Fetch(url string) (string, []string, error) {
	if res, ok := f[url]; ok {
		// 随机一个时间间隔,模拟请求时耗费的时间
		timeDuration := time.Millisecond * time.Duration(rand.Intn(100))
		time.Sleep(timeDuration)
		return res.body, res.urls, nil
	}
	return "", nil, fmt.Errorf("没有找到: %s", url)
}

// fetcher 是一个填充好数据的fakeFetcher
var fetcher = fakeFetcher{
	"https://golang.org/": &fakeResult{
		"The Go Programming Language",
		[]string{
			"https://golang.org/pkg/",
			"https://golang.org/cmd/",
		},
	},
	"https://golang.org/pkg/": &fakeResult{
		"Packages",
		[]string{
			"https://golang.org/",
			"https://golang.org/cmd/",
			"https://golang.org/pkg/fmt/",
			"https://golang.org/pkg/os/",
		},
	},
	"https://golang.org/pkg/fmt/": &fakeResult{
		"Package fmt",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
	"https://golang.org/pkg/os/": &fakeResult{
		"Package os",
		[]string{
			"https://golang.org/",
			"https://golang.org/pkg/",
		},
	},
}

源码地址

github.com/renmo/myBlo…

名词解释

  • 同步原语,Synchronization Primitives,用于同步的基本原语。Primitives,原语,指基本操作或指令,它是计算机指令的最小单元,是一种最基本的计算机指令,无法被其他更简单的指令表示或模拟。

    原语的主要特点有以下几点:

    1. 原语是一种最基本的计算机指令,不能被分解成更小的指令。
    2. 原语的执行是原子性的,不会被其他指令中断或中止。
    3. 原语执行的时间是确定的,不受其他因素的影响。
    4. 原语的执行可以改变系统状态或返回某些值。
    5. 原语是操作系统或编程语言所提供的一组基本操作,是构成更高级别操作的基础。

    引用自《什么是原语,原语的主要特点是什么》

  • 互斥锁,Mutual Exclusion

    互斥锁是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。 该目的通过将代码切片成一个一个的临界区域(critical section)达成。 临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。

    引用自《互斥锁》

  • 二叉搜索树,Binary Search Tree

    二叉查找树(英语:Binary Search Tree),也称为二叉搜索树有序二叉树(ordered binary tree)或排序二叉树(sorted binary tree),是指一棵空树或者具有下列性质的二叉树

    1. 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
    2. 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
    3. 任意节点的左、右子树也分别为二叉查找树;

    引用自《二叉搜索树》