Go 协程
Go 程(goroutine)是由 Go 运行时管理的轻量级线程:go f(x, y, z),会启动一个新的 Go 协程并执行 f(x, y, z)。
f, x, y 和 z 的求值发生在当前的 Go 协程中,而 f 的执行发生在新的 Go 协程中。
Go 程在相同的地址空间中运行,因此在访问共享的内存时必须进行同步。sync 包提供了这种能力,不过在 Go 中并不经常用到,因为还有其它的办法(见下一页)。
1package main
2
3import (
4 "fmt"
5 "time"
6)
7
8func say(s string) {
9 for i := 0; i < 5; i++ {
10 time.Sleep(100 * time.Millisecond)
11 fmt.Println(s)
12 }
13}
14
15func main() {
16 go say("world")
17 say("hello")
18}
world
world
hello
hello
world
world
hello
hello
主程序完成 say("hello")(打印 5 次后)后,程序退出。 此时,say("world") 例行程序只来得及打印 4 次。
信道
信道(Channel)是带有类型的(通信)管道,你可以通过它用信道操作符 <- 来发送或者接收值,“箭头”就是数据流的方向,允许数据在不同的 Go 协程(goroutine)之间流动。信道是 Go 语言并发编程的核心工具之一,使得不同的并发任务能够安全地交换数据。
1ch <- v // 将 v 发送至信道 ch。
2v := <-ch // 从 ch 接收值并赋予 v。
和映射与切片一样,信道在使用前必须创建:ch := make(chan int)。默认情况下,发送和接收操作在另一端准备好之前都会阻塞。这使得 Go 程可以在没有显式的锁或竞态变量的情况下进行同步。以下示例对切片中的数进行求和,将任务分配给两个 Go 程。一旦两个 Go 程完成了它们的计算,它就能算出最终的结果。
1package main
2
3import "fmt"
4
5func sum(s []int, c chan int) {
6 sum := 0
7 for _, v := range s {
8 sum += v
9 }
10 c <- sum // 发送 sum 到 c
11}
12
13func main() {
14 s := []int{7, 2, 8, -9, 4, 0}
15
16 c := make(chan int)
17 go sum(s[:len(s)/2], c)
18 go sum(s[len(s)/2:], c)
19 x, y := <-c, <-c // 从 c 接收
20
21 fmt.Println(x, y, x+y)
22}
- 有一组数字
{7, 2, 8, -9, 4, 0},将它分成两半。第一个计算{7, 2, 8}的和,第二个计算{-9, 4, 0}的和; - 创建一个信道
c; - 两个通道(用
go关键字启动)同时独立工作,计算各自负责部分的总和; - main 函数在
x, y := <-c, <-c处等待,直到信道c传回各自的结果。
带缓冲的信道
信道可以是带缓冲的。将缓冲长度作为第二个参数提供给 make 来初始化一个带缓冲的信道。
ch := make(chan int, 100) 创建了一个可以存储100个整数的缓冲区。
仅当信道的缓冲区填满后,向其发送数据时才会阻塞。当缓冲区为空时,接受方会阻塞。
1package main
2
3import "fmt"
4
5func main() {
6 ch := make(chan int, 2)
7 ch <- 1
8 ch <- 2
9 fmt.Println(<-ch)
10 fmt.Println(<-ch)
11}
- 信道有2个空位,所以前两次发送操作都不会阻塞
- 如果尝试发送第三个值
ch <- 3(当缓冲区已满),程序会阻塞直到有人从信道接收值 - 当缓冲区有值时,接收操作不会阻塞
- 如果缓冲区为空时尝试接收,程序会阻塞直到有新值发送到信道
带缓冲的信道提供了一种异步通信机制,让发送方和接收方不必同时准备好,提高了程序的并发效率。
range 和 close
发送者可通过 close 关闭一个信道来表示没有需要发送的值了。接收者可以通过为接收表达式分配第二个参数来测试信道是否被关闭:若没有值可以接收且信道已被关闭,那么在执行完 v, ok := <-ch,此时 ok 会被设置为 false。
当从 channel 接收数据时,可以使用两个返回值的形式来检查 channel 是否已关闭:
1v, ok := <-ch
- 如果
ok是true:成功接收到值,v是接收到的值 - 如果
ok是false:channel 已关闭且没有更多值可接收,v是该类型的零值
panic。range 循环。 1package main
2
3import (
4 "fmt"
5)
6
7func fibonacci(n int, c chan int) {
8 x, y := 0, 1
9 for i := 0; i < n; i++ {
10 c <- x // 将斐波那契数列的值发送到 channel
11 x, y = y, x+y // 计算下一个斐波那契数
12 }
13 close(c) // 发送完毕后关闭 channel
14}
15
16func main() {
17 c := make(chan int, 10) // 创建一个容量为 10 的缓冲 channel
18 `Go` fibonacci(cap(c), c) // 启动一个协程,生成斐波那契数并发送到 channel
19 for i := range c { // 不断从 channel 接收值,直到它被关闭
20 fmt.Println(i)
21 }
22}
1
1
2
3
5
8
13
21
34
- 创建一个缓冲容量为 10 的
channel - 启动一个新的
goroutine来计算斐波那契数列 fibonacci函数向channel中发送 10 个斐波那契数- 发送完后,
fibonacci函数关闭channel - main 函数中的
range循环接收channel中的所有值并打印 - 当
channel关闭且没有更多值时,range循环结束
range 与 channel
for i := range c 循环提供了一种更简洁的方式来不断从 channel 接收值,直到它被关闭:
1for i := range c {
2 fmt.Println(i)
3}
这相当于:
1for {
2 i, ok := <-c
3 if !ok {
4 break // channel 已关闭,退出循环
5 }
6 fmt.Println(i)
7}
select 语句
select 语句使一个 Go 程可以等待多个通信操作。它会阻塞到某个分支可以继续执行为止,这时就会执行该分支。当多个分支都准备好时会随机选择一个执行。
1package main
2
3import "fmt"
4
5func fibonacci(c, quit chan int) {
6 x, y := 0, 1
7 for {
8 select {
9 case c <- x:
10 x, y = y, x+y
11 case <-quit:
12 fmt.Println("quit")
13 return
14 }
15 }
16}
17
18func main() {
19 c := make(chan int)
20 quit := make(chan int)
21 go func() {
22 for i := 0; i < 10; i++ {
23 fmt.Println(<-c)
24 }
25 quit <- 0
26 }()
27 fibonacci(c, quit)
28}
1
1
2
3
5
8
13
21
34
quit
- 有两个通道:
c(用于发送斐波那契数)和quit(用于结束程序) - 无限循环中的
select语句在等待两种可能的操作:- 发送操作:将当前的斐波那契数发送到
c通道 - 接收操作:从
quit通道接收退出信号
- 发送操作:将当前的斐波那契数发送到
1func main() {
2 c := make(chan int)
3 quit := make(chan int)
4 `Go` func() {
5 for i := 0; i < 10; i++ {
6 fmt.Println(<-c)
7 }
8 quit <- 0
9 }()
10 fibonacci(c, quit)
11}
主函数中:
- 创建了两个通道:
c和quit; - 启动了一个匿名
goroutine,接收并打印 10 个斐波那契数,发送退出信号到quit通道; - 调用
fibonacci函数。
select 的特殊情况
- 多个通道就绪:如果多个
case同时准备好,select会随机选择一个执行 没有通道就绪:select 会阻塞,直到某个通道可以操作。 - default 分支:如果包含
default分支,当没有其他通道操作可以进行时会执行default,使select变成非阻塞的。
默认选择
当 select 中的其它分支都没有准备好时,default 分支就会执行。为了在尝试发送或者接收时不发生阻塞,可使用 default 分支:
1select {
2 case i := <-c:
3 // 使用 i
4 default:
5 // 从 c 中接收会阻塞时执行
6}
1package main
2
3import (
4 "fmt"
5 "time"
6)
7
8func main() {
9 tick := time.Tick(100 * time.Millisecond)
10 boom := time.After(500 * time.Millisecond)
11 for {
12 select {
13 case <-tick:
14 fmt.Println("tick.")
15 case <-boom:
16 fmt.Println("BOOM!")
17 return
18 default:
19 fmt.Println(" .")
20 time.Sleep(50 * time.Millisecond)
21 }
22 }
23}
1 .
2 .
3tick.
4 .
5 .
6tick.
7 .
8 .
9tick.
10 .
11 .
12tick.
13 .
14 .
15BOOM!
在 Go 语言中,select 是一种处理多个通道(channel)操作的机制。它类似于 switch 语句,但专门用于通道操作。select 语句会监听多个通道,并在某个通道准备好时执行相应的代码块。
default 分支的核心作用是:当 select 中的其他条件分支都没有准备好时,default 分支会被执行。这是实现非阻塞操作的关键。
- 如果没有
default:select会一直等待,直到某个通道可以操作 - 有
default:如果所有通道都不能立即操作,就执行default分支代码
1func main() {
2 tick := time.Tick(100 * time.Millisecond)
3 boom := time.After(500 * time.Millisecond)
4 for {
5 select {
6 case <-tick:
7 fmt.Println("tick.")
8 case <-boom:
9 fmt.Println("BOOM!")
10 return
11 default:
12 fmt.Println(" .")
13 time.Sleep(50 * time.Millisecond)
14 }
15 }
16}
这个例子生动展示了 select 和 default 的使用场景:
tick通道每 100 毫秒产生一个信号boom通道在 500 毫秒后产生一个信号- 程序在无限循环中:
- 如果
tick通道有数据,打印 “tick.” - 如果
boom通道有数据,打印 “BOOM!” 并结束程序 - 如果两个通道都没准备好,执行
default:打印 " ." 并等待 50 毫秒
程序运行效果大致是:
- 刚开始,两个通道都没数据,所以执行
default(约每 50ms 打印一个点) - 每 100ms,
tick通道会准备好,此时打印 “tick.” - 500ms 后,
boom通道准备好,打印 “BOOM!” 并退出
select 的一个重要特点是:整个 select 代码段只执行一次,如果没有可用的通道操作,就会执行 default 分支。这里因为有 for 循环,所以 select 会被反复执行,使我们能看到不同时间点的行为。
练习:等价二叉查找树
不同二叉树的叶节点上可以保存相同的值序列。例如,以下两个二叉树都保存了序列 1,1,2,3,5,8,13。
在大多数语言中,检查两个二叉树是否保存了相同序列的函数都相当复杂。 我们将使用 Go 的并发和信道来编写一个简单的解法。
本例使用了 tree 包,它定义了类型:
1type Tree struct {
2 Left *Tree
3 Value int
4 Right *Tree
5}
1. 实现 Walk 函数。
2. 测试 Walk 函数。
函数 tree.New(k) 用于构造一个随机结构的已排序二叉查找树,它保存了值 k, 2k, 3k, …, 10k。
创建一个新的信道 ch 并且对其进行步进:go Walk(tree.New(1), ch);然后从信道中读取并打印 10 个值。应当是数字 1, 2, 3, …, 10.
3. 用 Walk 实现 Same 函数来检测 t1 和 t2 是否存储了相同的值。
4. 测试 Same 函数。
Same(tree.New(1), tree.New(1)) 应当返回 true,而 Same(tree.New(1), tree.New(2)) 应当返回 false。
Tree 的文档可在这里找到。
Walk 函数实现
Walk 函数需要遍历二叉树并将所有值发送到信道。我们可以使用中序遍历(左子树-节点-右子树)实现这一点:
1// Walk 遍历树 t,将树中所有的值发送到信道 ch
2func Walk(t *tree.Tree, ch chan int) {
3 // 定义一个递归的辅助函数进行树的遍历
4 var walkTree func(t *tree.Tree)
5 walkTree = func(t *tree.Tree) {
6 if t == nil {
7 return
8 }
9 // 先遍历左子树
10 walkTree(t.Left)
11 // 发送当前节点的值
12 ch <- t.Value
13 // 再遍历右子树
14 walkTree(t.Right)
15 }
16
17 walkTree(t)
18 close(ch) // 遍历结束后关闭信道
19}
Same 函数实现
Same 函数用于检查两个二叉树是否包含相同的值序列:
1// Same 判断 t1 和 t2 是否包含相同的值
2func Same(t1, t2 *tree.Tree) bool {
3 ch1 := make(chan int)
4 ch2 := make(chan int)
5
6 `Go` Walk(t1, ch1)
7 `Go` Walk(t2, ch2)
8
9 // 比较两个树的遍历结果
10 for {
11 v1, ok1 := <-ch1
12 v2, ok2 := <-ch2
13
14 // 如果通道状态不同或值不同,则树不相等
15 if ok1 != ok2 || v1 != v2 {
16 return false
17 }
18
19 // 如果两个通道都已关闭,并且所有值都匹配,则树相等
20 if !ok1 {
21 break
22 }
23 }
24
25 return true
26}
主函数(测试)
1func main() {
2 // 测试 Walk 函数
3 fmt.Println("测试 Walk 函数:")
4 ch := make(chan int)
5 `Go` Walk(tree.New(1), ch)
6
7 // 应该打印出 1 到 10 的数字
8 for i := 0; i < 10; i++ {
9 fmt.Printf("%d ", <-ch)
10 }
11 fmt.Println()
12
13 // 测试 Same 函数
14 fmt.Println("测试 Same 函数:")
15 fmt.Println("Same(tree.New(1), tree.New(1)):", Same(tree.New(1), tree.New(1)))
16 fmt.Println("Same(tree.New(1), tree.New(2)):", Same(tree.New(1), tree.New(2)))
17}
完整代码
1package main
2
3import (
4 "fmt"
5 "golang.org/x/tour/tree"
6)
7
8// Walk 遍历树 t,将树中所有的值发送到信道 ch
9func Walk(t *tree.Tree, ch chan int) {
10 var walkTree func(t *tree.Tree)
11 walkTree = func(t *tree.Tree) {
12 if t == nil {
13 return
14 }
15 walkTree(t.Left)
16 ch <- t.Value
17 walkTree(t.Right)
18 }
19
20 walkTree(t)
21 close(ch)
22}
23
24// Same 判断 t1 和 t2 是否包含相同的值
25func Same(t1, t2 *tree.Tree) bool {
26 ch1 := make(chan int)
27 ch2 := make(chan int)
28
29 `Go` Walk(t1, ch1)
30 `Go` Walk(t2, ch2)
31
32 for {
33 v1, ok1 := <-ch1
34 v2, ok2 := <-ch2
35
36 if ok1 != ok2 || v1 != v2 {
37 return false
38 }
39
40 if !ok1 {
41 break
42 }
43 }
44
45 return true
46}
47
48func main() {
49 // 测试 Walk 函数
50 fmt.Println("测试 Walk 函数:")
51 ch := make(chan int)
52 `Go` Walk(tree.New(1), ch)
53
54 for i := 0; i < 10; i++ {
55 fmt.Printf("%d ", <-ch)
56 }
57 fmt.Println()
58
59 // 测试 Same 函数
60 fmt.Println("测试 Same 函数:")
61 fmt.Println("Same(tree.New(1), tree.New(1)):", Same(tree.New(1), tree.New(1)))
62 fmt.Println("Same(tree.New(1), tree.New(2)):", Same(tree.New(1), tree.New(2)))
63}
1 2 3 4 5 6 7 8 9 10
测试 Same 函数:
Same(tree.New(1), tree.New(1)): true
Same(tree.New(1), tree.New(2)): false
注意:这个实现使用了中序遍历,确保了我们从二叉查找树中获取的值是按顺序排列的,这对于比较两棵树是否包含相同的值序列非常重要。
sync.Mutex
我们已经看到信道非常适合在各个 Go 程间进行通信。但是如果我们并不需要通信呢?比如说,若我们只是想保证每次只有一个 Go 程能够访问一个共享的变量,从而避免冲突?
这里涉及的概念叫做 互斥(mutualexclusion)* ,我们通常使用 互斥锁(Mutex) 这一数据结构来提供这种机制。Go 标准库中提供了 sync.Mutex 互斥锁类型及其两个方法:
LockUnlock
我们可以通过在代码前调用 Lock 方法,在代码后调用 Unlock 方法来保证一段代码的互斥执行。参见 Inc 方法。我们也可以用 defer 语句来保证互斥锁一定会被解锁。参见 Value 方法。
1package main
2
3import (
4 "fmt"
5 "sync"
6 "time"
7)
8
9// SafeCounter 是并发安全的
10type SafeCounter struct {
11 mu sync.Mutex
12 v map[string]int
13}
14
15// Inc 对给定键的计数加一
16func (c *SafeCounter) Inc(key string) {
17 c.mu.Lock()
18 // 锁定使得一次只有一个 `Go` 协程可以访问映射 c.v。
19 c.v[key]++
20 c.mu.Unlock()
21}
22
23// Value 返回给定键的计数的当前值。
24func (c *SafeCounter) Value(key string) int {
25 c.mu.Lock()
26 // 锁定使得一次只有一个 `Go` 协程可以访问映射 c.v。
27 defer c.mu.Unlock()
28 return c.v[key]
29}
30
31func main() {
32 c := SafeCounter{v: make(map[string]int)}
33 for i := 0; i < 1000; i++ {
34 go c.Inc("somekey")
35 }
36
37 time.Sleep(time.Second)
38 fmt.Println(c.Value("somekey"))
39}
- SafeCounter 结构体:包含一个互斥锁和一个映射(Go 中的 map 不是并发安全的)
- Inc 方法:
- 在增加计数器前锁定互斥锁
- 操作完成后解锁
- Value 方法:
- 在读取前锁定互斥锁
- 使用
defer确保函数返回时互斥锁始终会被解锁
互斥锁(Mutex)
互斥锁(Mutex,“mutual exclusion"的缩写)是并发编程中的一种同步机制,用于防止多个 goroutine 同时访问共享资源。在 Go 语言中,sync.Mutex 是最常用的同步原语之一 为什么我们需要互斥锁?
Go 的 goroutine 让并发编程变得简单——只需在函数调用前添加 go 关键字即可启动并发执行。然而,当多个 goroutine 访问相同数据时:
- 没有保护:会发生数据竞争,导致不可预测的行为
- 使用互斥锁保护:goroutine 会轮流安全地访问共享资源
sync.Mutex 的工作原理
Go 中的互斥锁提供两个基本方法:
Lock():获取独占访问权(如果已被锁定则阻塞)Unlock():释放互斥锁的独占访问权就像是一把房间的钥匙——只有持有钥匙的 goroutine 才能进入,其他 goroutine 必须等待钥匙被归还。
main() 函数启动了 1000 个 goroutine,它们都试图增加同一个计数器。如果没有互斥锁,这会导致竞态条件。
练习:Web 爬虫
在这个练习中,我们将会使用 Go 的并发特性来并行化一个 Web 爬虫。修改 Crawl 函数来并行地抓取 URL,使爬虫能够并行地爬取多个网页,同时确保不重复爬取同一个页面。
提示: 你可以用一个 map 来缓存已经获取的 URL,但是要注意 map 本身并不是并发安全的!
1package main
2
3import (
4 "fmt"
5)
6
7type Fetcher interface {
8 // Fetch 返回 URL 所指向页面的 body 内容,
9 // 并将该页面上找到的所有 URL 放到一个切片中。
10 Fetch(url string) (body string, urls []string, err error)
11}
12
13// Crawl 用 fetcher 从某个 URL 开始递归的爬取页面,直到达到最大深度。
14func Crawl(url string, depth int, fetcher Fetcher) {
15 // TODO: 并行地爬取 URL。
16 // TODO: 不重复爬取页面。
17 // 下面并没有实现上面两种情况:
18 if depth <= 0 {
19 return
20 }
21 body, urls, err := fetcher.Fetch(url)
22 if err != nil {
23 fmt.Println(err)
24 return
25 }
26 fmt.Printf("found: %s %q\n", url, body)
27 for _, u := range urls {
28 Crawl(u, depth-1, fetcher)
29 }
30 return
31}
32
33func main() {
34 Crawl("https://golang.org/", 4, fetcher)
35}
36
37// fakeFetcher 是待填充结果的 Fetcher。
38type fakeFetcher map[string]*fakeResult
39
40type fakeResult struct {
41 body string
42 urls []string
43}
44
45func (f fakeFetcher) Fetch(url string) (string, []string, error) {
46 if res, ok := f[url]; ok {
47 return res.body, res.urls, nil
48 }
49 return "", nil, fmt.Errorf("not found: %s", url)
50}
51
52// fetcher 是填充后的 fakeFetcher。
53var fetcher = fakeFetcher{
54 "https://golang.org/": &fakeResult{
55 "The `Go` Programming Language",
56 []string{
57 "https://golang.org/pkg/",
58 "https://golang.org/cmd/",
59 },
60 },
61 "https://golang.org/pkg/": &fakeResult{
62 "Packages",
63 []string{
64 "https://golang.org/",
65 "https://golang.org/cmd/",
66 "https://golang.org/pkg/fmt/",
67 "https://golang.org/pkg/os/",
68 },
69 },
70 "https://golang.org/pkg/fmt/": &fakeResult{
71 "Package fmt",
72 []string{
73 "https://golang.org/",
74 "https://golang.org/pkg/",
75 },
76 },
77 "https://golang.org/pkg/os/": &fakeResult{
78 "Package os",
79 []string{
80 "https://golang.org/",
81 "https://golang.org/pkg/",
82 },
83 },
84}
思路:
- 使用 goroutines 实现并行爬取:为每个URL创建一个goroutine来并行爬取
- 使用 map 跟踪已访问的URL:创建一个映射表来记录已经爬取过的URL
- 使用 mutex 保护共享数据:因为多个goroutine会同时访问映射表,需要使用互斥锁保护它
- 使用 WaitGroup 等待所有爬取完成:确保所有并行的爬取任务都完成后再结束程序 实现示例
- Fetcher 接口:定义了如何获取网页内容的方法
1type Fetcher interface {
2 // 获取URL内容并返回页面上的所有链接
3 Fetch(url string) (body string, urls []string, err error)
4}
- Crawl 函数:目前是按顺序爬取网页的函数,需要使其并行工作
1func Crawl(url string, depth int, fetcher Fetcher) {
2 // 需要修改的部分
3}
- fakeFetcher:一个模拟的网页获取器,用于测试我们的爬虫程序
1package main
2
3import (
4 "fmt"
5 "sync"
6)
7
8type Fetcher interface {
9 Fetch(url string) (body string, urls []string, err error)
10}
11
12// SafeCache provides thread-safe access to the visited URLs map
13type SafeCache struct {
14 visited map[string]bool
15 mux sync.Mutex
16}
17
18// CheckAndMark checks if URL was visited and marks it as visited
19func (c *SafeCache) CheckAndMark(url string) bool {
20 c.mux.Lock()
21 defer c.mux.Unlock()
22 if c.visited[url] {
23 return true // Already visited
24 }
25 c.visited[url] = true
26 return false // First time visiting
27}
28
29// Crawl uses fetcher to recursively crawl pages starting with url, to a maximum of depth.
30func Crawl(url string, depth int, fetcher Fetcher) {
31 // Create a cache for tracking visited URLs
32 cache := &SafeCache{visited: make(map[string]bool)}
33
34 // WaitGroup to track all goroutines
35 var wg sync.WaitGroup
36
37 // Define crawl function to use in goroutines
38 var crawler func(string, int)
39 crawler = func(url string, depth int) {
40 defer wg.Done()
41
42 // Stop if we've reached max depth
43 if depth <= 0 {
44 return
45 }
46
47 // Check if we've already visited this URL
48 if cache.CheckAndMark(url) {
49 return
50 }
51
52 body, urls, err := fetcher.Fetch(url)
53 if err != nil {
54 fmt.Println(err)
55 return
56 }
57 fmt.Printf("found: %s %q\n", url, body)
58
59 // Create a goroutine for each URL
60 for _, u := range urls {
61 wg.Add(1)
62 go crawler(u, depth-1)
63 }
64 }
65
66 // Start the first crawl
67 wg.Add(1)
68 go crawler(url, depth)
69
70 // Wait for all crawling to complete
71 wg.Wait()
72}
73
74func main() {
75 Crawl("https://golang.org/", 4, fetcher)
76}
77
78// fakeFetcher is Fetcher that returns canned results.
79type fakeFetcher map[string]*fakeResult
80
81type fakeResult struct {
82 body string
83 urls []string
84}
85
86func (f fakeFetcher) Fetch(url string) (string, []string, error) {
87 if res, ok := f[url]; ok {
88 return res.body, res.urls, nil
89 }
90 return "", nil, fmt.Errorf("not found: %s", url)
91}
92
93// fetcher is a populated fakeFetcher.
94var fetcher = fakeFetcher{
95 "https://golang.org/": &fakeResult{
96 "The Go Programming Language",
97 []string{
98 "https://golang.org/pkg/",
99 "https://golang.org/cmd/",
100 },
101 },
102 "https://golang.org/pkg/": &fakeResult{
103 "Packages",
104 []string{
105 "https://golang.org/",
106 "https://golang.org/cmd/",
107 "https://golang.org/pkg/fmt/",
108 "https://golang.org/pkg/os/",
109 },
110 },
111 "https://golang.org/pkg/fmt/": &fakeResult{
112 "Package fmt",
113 []string{
114 "https://golang.org/",
115 "https://golang.org/pkg/",
116 },
117 },
118 "https://golang.org/pkg/os/": &fakeResult{
119 "Package os",
120 []string{
121 "https://golang.org/",
122 "https://golang.org/pkg/",
123 },
124 },
125}
1found: https://golang.org/ "The Go Programming Language"
2not found: https://golang.org/cmd/
3found: https://golang.org/pkg/ "Packages"
4found: https://golang.org/pkg/os/ "Package os"
5found: https://golang.org/pkg/fmt/ "Package fmt"
小记
没有小记
进阶教程: