本文对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运行自在相同的地址空间中,所以对共享内存访问必须同步。
有两种方式使用共享内存:
- 使用
channel。 - 使用
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
可以看到hello和world的顺序是不确定的,不是先打印完所有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或者省略了,通道就是非缓存的,只在发送方和接收方都准备好的时候才能通信成功。否则通道就是缓存的,发送方的缓存区没有满,或者接收方的缓存区不为空,就能不阻塞地进行通信。
“发送方的缓存区没有满,或者接收方的缓存区不为空,就能不阻塞地进行通信。“这句话直白一点说,就是如果缓存区满了,就不能再往通道中发送数据了(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拿到的是后计算完的和,x,y的值是不确定的,x y可能是-5 17或者 17 -5,就看哪个子goroutine中的计算先完成。
这里使用goroutine并发执行代码,把求和的时间复杂度从降低到,虽然两者的时间复杂度都能说成,但显然后者的效率是较高的。
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,但是因为接收操作<-ch在ch <- 3之后,所以在ch <- 3执行结束之前,通道中的元素个数不会减少,通道中的元素不减少发送操作ch <- 3就会阻塞,因此造成了死锁,按照代码执行逻辑,ch <- 3永远不会等到通道中元素个数小于2的时候。
range 和 close
发送方可以close一个通道来表明没有更多的值会被发送。接收方可以通过赋值第二个参数给接收表达式,测试一个通道是否已经关闭。
执行如下语句:
v, ok := <-ch
如果没有更多的值要接收,并且通道已经关闭了,ok的值就为false。
for i := range c循环,从通道中重复地接收值,直到通道关闭。
注意:
- 只有发送方可以关闭一个通道,接收方不可以。在一个已经关闭的通道上进行发送会导致一个错误(panic)。
- 通道不像文件,不需要总是关闭它们。关闭只有必须告诉接收方不会再来更多值时,才是必须的,比如终止一个
range循环。
斐波那契数列是这样的一个公式:
斐波那契数列:
下面的代码从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
}
}
}
这段代码同样计算从到的斐波那契数列。但是与之前使用range和close以及有缓存通道的实现方式不同,这里用的是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毫秒。
之前说过有两种方式使用共享内存:
- 使用
channel。 - 使用
sync包的同步原语,比如互斥锁。
到目前的练习中,直接使用channel就不需要特别注意共享内存的问题,接下来使用sync包来使用共享内存。
sync.Mutex
如果我们想要避免冲突,确保一次只有一个goroutine可以访问一个变量(这个概念称为互斥),则可以使用互斥锁。
Go的标准库中提供了互斥的使用,需要用到sync.Mutex和它的两个方法Lock和Unlock。
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。
最主要就是实现2个方法,一个方法遍历树中的节点,一个方法根据遍历的结果判断两棵树存储的序列的值是否是一样的。
-
实现
Walk函数。Walk函数遍历树中的节点。 -
测试
Walk函数。函数
tree.New(k)构造了一个随机结构(但总是排序的)的二叉树来存储值k,2k,3k,...,10k。创建一个新的通道
ch并开始遍历:go Walk(tree.New(1), ch)然后打印树中包含的10个值,应该是数字1,2,3,...,10。
-
实现
Same函数,使用Walk来确定两棵树是否存储相同的值。 -
测试
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/",
},
},
}
源码地址
名词解释
-
同步原语,Synchronization Primitives,用于同步的基本原语。Primitives,原语,指基本操作或指令,它是计算机指令的最小单元,是一种最基本的计算机指令,无法被其他更简单的指令表示或模拟。
原语的主要特点有以下几点:
- 原语是一种最基本的计算机指令,不能被分解成更小的指令。
- 原语的执行是原子性的,不会被其他指令中断或中止。
- 原语执行的时间是确定的,不受其他因素的影响。
- 原语的执行可以改变系统状态或返回某些值。
- 原语是操作系统或编程语言所提供的一组基本操作,是构成更高级别操作的基础。
-
互斥锁,Mutual Exclusion
互斥锁是一种用于多线程编程中,防止两条线程同时对同一公共资源(比如全局变量)进行读写的机制。 该目的通过将代码切片成一个一个的临界区域(critical section)达成。 临界区域指的是一块对公共资源进行访问的代码,并非一种机制或是算法。
引用自《互斥锁》
-
二叉搜索树,Binary Search Tree
二叉查找树(英语:Binary Search Tree),也称为二叉搜索树、有序二叉树(ordered binary tree)或排序二叉树(sorted binary tree),是指一棵空树或者具有下列性质的二叉树:
- 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
- 任意节点的左、右子树也分别为二叉查找树;
引用自《二叉搜索树》