快速学习GO语言总结
干货分享,感谢您的阅读!备注:本博客将自己初步学习GO的总结进行分享,希望大家通过本博客可以在短时间内快速掌握GO的基本程序编码能力,如有错误请留言指正,谢谢!
一、初步了解Go语言
(一)Go语言诞生的主要问题和目标
-
多核硬件架构: 随着计算机硬件的发展,多核处理器成为主流,使得并行计算变得普遍。然而,传统的编程语言在处理多核并行性时可能面临困难,因为它们缺乏合适的原生支持。Go语言通过引入轻量级的协程(goroutine)和通道(channel)机制,使得并发编程变得更加容易。开发者可以轻松地创建数千个并发执行的协程,而无需担心线程管理的复杂性。
-
超大规模分布式计算集群: 随着云计算和分布式系统的崛起,构建和维护超大规模的分布式计算集群变得越来越常见。这些集群需要能够高效处理大量的请求、数据共享和协调。Go语言的并发特性和通道机制使得编写分布式系统变得更加容易,开发者可以使用协程和通道来处理并发任务、消息传递和协调工作。
-
Web模式导致的开发规模和更新速度增加: Web应用的兴起带来了前所未有的开发规模和持续更新的需求。传统的编程语言在开发大型Web应用时可能会面临可维护性、性能和开发效率等问题。Go语言通过其简洁的语法、高效的编译速度以及并发支持,使得开发者能够更快速地迭代和部署Web应用,同时也能够更好地处理高并发的网络请求。
综合来看,Go语言在诞生时确实着重解决了多核硬件架构、超大规模分布式计算集群和Web模式下的开发规模与速度等技术挑战,它的设计目标之一是提供一种适应现代软件开发需求的编程语言,使开发者能够更好地应对这些挑战。
(二)Go语言应用典型代表
Go语言在当下应用开发中已经得到广泛应用,许多知名公司和项目都使用Go语言来构建各种类型的应用。以下是一些代表性的产品和项目,它们使用了Go语言作为核心开发语言:
这些仅仅是Go语言应用的一小部分示例,实际上还有许多其他的项目和产品也在使用Go语言来构建高性能、可靠且易于维护的应用程序。这表明Go语言在现代应用开发中发挥了重要作用,特别是在分布式系统、云计算和高性能应用领域。
(三)Java、C++、C程序员在学习编写Go时存在的误区
当Java、C++、C等编程语言的程序员开始学习编写Go语言时,可能会遇到一些误区,因为Go在某些方面与这些传统语言有所不同。以下是一些常见的误区:
-
过度使用传统的并发模型: 传统的编程语言如Java、C++、C在处理并发时通常使用线程和锁来实现,但在Go中,使用协程(goroutine)和通道(channel)是更好的方式。新学习Go的程序员可能会继续使用传统的并发模型,而不充分利用Go的轻量级协程和通道,从而失去了Go的并发优势。
-
过度使用指针: C和C++等语言强调指针的使用,但Go语言在设计时避免了过多的指针操作。新学习Go的程序员可能会过度使用指针,导致代码变得复杂。在Go中,尽量避免使用指针,除非真正需要对值进行修改。
-
忽视错误处理: Go鼓励显式地处理错误,而不是简单地忽略它们。这与一些其他语言的习惯不同,其中错误往往被忽略或简单地抛出。新学习Go的程序员可能会忽视错误处理,导致潜在的问题未被检测到。
-
过度使用全局变量: 在C和C++等语言中,全局变量可能是常见的做法。然而,在Go中,全局变量的使用被视为不良实践。Go鼓励使用局部变量和传递参数的方式来传递数据,以避免引入不必要的耦合和副作用。
-
不熟悉切片和映射: Go中的切片和映射是强大的数据结构,但对于其他语言的程序员来说可能不太熟悉。学习如何正确使用切片和映射是很重要的,因为它们在Go中广泛用于集合和数据处理。
-
错误的Go风格: 每种语言都有其独特的编码风格和惯例。新学习Go的程序员可能会在Go代码中应用其他语言的编码风格,这可能会使代码难以阅读和理解。
为了避免这些误区,学习Go的程序员应该投入时间去理解Go语言的核心概念,包括并发模型、错误处理、数据结构等,同时积极参与Go社区,阅读Go的官方文档和示例代码,以便更好地适应Go的设计理念和最佳实践。
二、环境准备(以Mac说明)
(一)环境设置
在macOS上设置Go语言开发环境非常简单,可以按照以下步骤进行操作:
-
使用Homebrew安装: 如果您使用Homebrew包管理器,这是最方便的方法。打开终端,并运行以下命令来安装Go语言:
brew install go
-
手动安装: 如果想手动安装Go语言,可以按照以下步骤操作:
a. 访问官方网站下载安装包`goX.X.X.darwin-amd64.pkg
b. 双击下载的安装包,按照指示运行安装程序。按照默认设置即可,安装路径通常是/usr/local/go。
-
设置环境变量: 一旦安装完成,需要将Go语言的二进制路径添加到自己的终端配置文件中的PATH环境变量中。这样就可以在终端中直接运行Go命令。
a. 打开终端,并使用文本编辑器(如nano、vim或任何您喜欢的编辑器)编辑终端配置文件。例如:
nano ~/.bash_profile
b. 在文件中添加以下行(根据安装路径进行调整),然后保存并退出编辑器:
export PATH=$PATH:/usr/local/go/bin
c. 使配置生效,可以运行以下命令或者重启终端:
source ~/.bash_profile
-
验证安装: 打开终端,输入以下命令来验证Go是否已正确安装:
go version
如果看到了Go的版本号,表示安装成功。
(二)IDE选择说明
我个人使用的GoLand,直接官网下载后,上网购买破解版即可,这里不在多说!
三、Go语言程序学习
创建自己的工程目录/Users/zyf/zyfcodes/go/go-learning,新建src目录。
(一)第一个Go语言编写
src目录下创建chapter1/hello目录,新建hello.go文件,编写代码如下:
package main import ( "fmt" "os" ) /** * @author zhangyanfeng * @description 第一个godaima * @date 2023/8/20 23:45 * @param * @return **/ func main() { if len(os.Args) > 1 { fmt.Println("Hello World", os.Args[1]) } }
这段代码是一个简单的Go语言程序,它接受命令行参数并打印出一条带参数的 "Hello World" 消息。下面是对代码的逐行分析:
-
package main: 声明这个文件属于名为 "main" 的包,这是一个Go程序的入口包名。
-
import ("fmt" "os"): 引入了两个标准库包,分别是 "fmt" 用于格式化输出,和 "os" 用于与操作系统交互。
-
func main() { ... }: 这是程序的入口函数,它会在程序运行时首先被调用。
-
if len(os.Args) > 1 { ... }: 这个条件语句检查命令行参数的数量是否大于1,也就是判断是否有参数传递给程序。os.Args 是一个字符串切片,它包含了所有的命令行参数,第一个参数是程序的名称。
-
fmt.Println("Hello World", os.Args[1]): 如果有参数传递给程序,就会执行这行代码。它使用 fmt.Println 函数打印一条消息,消息由字符串 "Hello World" 和 os.Args[1] 组成,os.Args[1] 表示传递给程序的第一个参数。
综上所述,这段代码涵盖了以下知识点:
-
包导入和使用标准库:通过 import 关键字导入 "fmt" 和 "os" 包,然后在代码中使用这些包提供的函数和类型。
-
命令行参数获取:使用 os.Args 获取命令行参数。
-
条件语句:使用 if 条件语句来判断是否有命令行参数传递给程序。
-
字符串操作:使用字符串连接操作将 "Hello World" 与命令行参数拼接在一起。
-
格式化输出:使用 fmt.Println 函数将消息输出到标准输出。
注意:如果没有传递参数给程序,那么这段代码不会打印任何消息。如果传递了多个参数,代码只会使用第一个参数并忽略其他参数。
在该目录下执行“go run hello.go ZYF”,运行结果为“Hello World ZYF”。
(二)基本程序结构编写学习
src目录下创建chapter2
1.变量
前提:chapter2目录下创建variables,学习总结如下:
- 变量声明: 使用var关键字声明一个变量,例如:var x int。
- 类型推断: 可以使用:=操作符进行变量声明和赋值,Go会根据右侧的值自动推断变量类型,例如:y := 5。
- 变量赋值: 使用赋值操作符=给变量赋值,例如:x = 10。
- 多变量声明: 可以同时声明多个变量,例如:var a, b, c int。
- 变量初始化: 变量可以在声明时进行初始化,例如:var name string = "John"。
- 零值: 未初始化的变量会被赋予零值,数字类型为0,布尔类型为false,字符串类型为空字符串等。
- 短变量声明: 在函数内部,可以使用短变量声明方式,例如:count := 10。
新建fib_test.go,背景:简单实用斐波那契数列进行练习
package variables import "testing" func TestFibList(t *testing.T) { a := 1 b := 1 t.Log(a) for i := 0; i
下面逐个解释代码中涉及的知识点:
-
package variables: 声明了一个名为 "variables" 的包,这是一个用于测试的包名。
-
import "testing": 导入了Go语言的测试框架 "testing" 包,用于编写和运行测试函数。
-
func TestFibList(t *testing.T) { ... }: 定义了一个测试函数 "TestFibList",该函数用于测试斐波那契数列生成逻辑。这是一个测试函数的标准命名,以 "Test" 开头,接着是被测试的函数名。
在测试函数内部,声明了两个整数变量 a 和 b,并将它们初始化为 1,这是斐波那契数列的前两个数。使用 t.Log(a) 打印变量 a 的值到测试日志中。使用循环来生成斐波那契数列的前 5 个数,每次迭代都会将 b 的值打印到测试日志,并更新 a 和 b 的值以生成下一个数。 -
func TestExchange(t *testing.T) { ... }: 定义了另一个测试函数 "TestExchange",该函数用于测试变量交换的逻辑。
在测试函数内部,声明了两个整数变量 a 和 b,并分别将它们初始化为 1 和 2。使用注释的方式展示了一种变量交换的写法(通过中间变量),但实际上被注释掉了。然后使用 a, b = b, a 这一行代码来实现 a 和 b 的交换,这是Go语言中的一种特有的交换方式,不需要额外的中间变量。使用 t.Log(a, b) 打印交换后的变量值到测试日志中。
2.常量
前提:chapter2目录下创建constant,学习总结如下:
- 常量声明: 使用const关键字声明一个常量,例如:const pi = 3.14159。
- 常量赋值: 常量的值在声明时必须被赋值,一旦赋值后不可修改。
- 枚举常量: 可以使用一组常量来模拟枚举,例如:
const ( Monday = 1 Tuesday = 2 // ... )
- 类型指定: 常量的类型也可以被指定,例如:const speed int = 300000。
- 常量表达式: 常量可使用表达式计算,例如:const secondsInHour = 60 * 60。
- 无类型常量: 常量可以是无类型的,根据上下文自动推断类型。例如,const x = 5会被推断为整数类型。
新建constant_test.go,写代码如下:
package constant import "testing" const ( Monday = 1 + iota Tuesday Wednesday ) const ( Readable = 1 100 { return nil, LargerThenHundredError } fibList := []int{1, 1} for i := 2; /*短变量声明 := */ i
2.错误链
创建chain目录,编写error_chain_test.go
在某些情况下,错误可以包含附加信息,以便更好地理解错误的原因。可以通过 fmt.Errorf() 函数来创建包含附加信息的错误。
假设我们正在构建一个文件操作的库,其中包含文件读取和写入功能。有时,在文件读取或写入过程中可能会出现各种错误,例如文件不存在、权限问题等。我们希望能够提供有关错误的更多上下文信息。
package chain import ( "errors" "fmt" "testing" ) // 自定义文件操作错误类型 type FileError struct { Op string // 操作类型("read" 或 "write") Path string // 文件路径 Err error // 原始错误 } // 实现 error 接口的 Error() 方法 func (e *FileError) Error() string { return fmt.Sprintf("%s %s: %v", e.Op, e.Path, e.Err) } // 模拟文件读取操作 func ReadFile(path string) ([]byte, error) { // 模拟文件不存在的情况 return nil, &FileError{Op: "read", Path: path, Err: errors.New("file not found")} } func TestChain(t *testing.T) { filePath := "/path/to/nonexistent/file.txt" _, err := ReadFile(filePath) if err != nil { fmt.Println("Error:", err) // 在这里,我们可以检查错误类型,提取上下文信息 if fileErr, ok := err.(*FileError); ok { fmt.Printf("Operation: %s\n", fileErr.Op) fmt.Printf("File Path: %s\n", fileErr.Path) fmt.Printf("Original Error: %v\n", fileErr.Err) } } }
下面是代码的解释:
-
FileError 结构体:定义了一个自定义错误类型 FileError,包含以下字段:
Op:操作类型,表示是读取("read")还是写入("write")操作;Path:文件路径,表示涉及哪个文件;Err:原始错误,包含底层的错误信息。 -
Error() 方法:为 FileError 结构体实现了 error 接口的 Error() 方法,用于生成错误的文本描述。
-
ReadFile() 函数:模拟文件读取操作。在这个示例中,该函数返回一个 FileError 类型的错误,模拟了文件不存在的情况。
-
TestChain() 测试函数:演示如何在错误处理中使用自定义错误类型。
定义了一个文件路径 filePath,并调用 ReadFile(filePath) 函数来模拟文件读取操作;检查错误,如果发生错误,输出错误信息;在错误处理中,通过类型断言检查错误是否为 *FileError 类型,如果是,则可以提取更多上下文信息,如操作类型、文件路径和原始错误信息。
3.Panic 和 Recover
在Go语言中,panic 和 recover 是用于处理异常情况的机制,但它们应该谨慎使用,仅用于特定的情况,而不是替代正常的错误处理机制。以下是对 panic 和 recover 的详细解释,并给出一个具体用例:
panic
创建panic目录,编写panic_test.go。panic 是一个内置函数,用于引发运行时恐慌。当程序遇到无法继续执行的致命错误时,可以使用 panic 来中断程序的正常流程。但应该避免滥用 panic,因为它会导致程序崩溃,不会提供友好的错误信息。典型情况下,panic 用于表示程序中的不可恢复错误,例如切片索引越界。
package panic import ( "fmt" "testing" ) func TestPanic(t *testing.T) { arr := []int{1, 2, 3} index := 4 if index >= len(arr) { panic("Index out of range") } element := arr[index] fmt.Println("Element:", element) }
在上述示例中,如果索引 index 超出了切片 arr 的范围,会触发 panic,导致程序崩溃。这种情况下,panic 用于表示程序的不可恢复错误。
recover
创建recover目录,编写recover_test.go。recover 也是一个内置函数,用于恢复 panic 引发的运行时恐慌。它只能在延迟函数(defer)内部使用,并且用于恢复程序的控制流,而不是用于处理错误。通常,在发生 panic 后,recover 可以在延迟函数中捕获 panic,并执行一些清理工作,然后程序会继续执行。
package recover import ( "fmt" "testing" ) func cleanup() { if r := recover(); r != nil { fmt.Println("Recovered from panic:", r) } } func TestRecover(t *testing.T) { defer cleanup() panic("Something went wrong") fmt.Println("This line will not be executed") }
在上述示例中,panic 触发后,cleanup 函数中的 recover 捕获了 panic,并打印了错误消息。然后程序会继续执行,但需要注意的是,控制流不会回到触发 panic 的地方,因此 fmt.Println 不会被执行。
总之,panic 和 recover 应该谨慎使用,只用于特殊情况,如不可恢复的错误或在延迟函数中进行清理操作。在大多数情况下,应该优先使用错误返回值来处理异常情况,因为这种方式更安全、可控,能够提供更好的错误信息和错误处理。只有在特定的情况下,例如遇到不可恢复的错误时,才应该考虑使用 panic 和 recover。
4.自定义错误类型
创建define目录,编写error_define_test.go。
在Go中,你可以根据需要定义自己的错误类型,只需满足 error 接口的要求即可。这允许你创建更具描述性和上下文的错误类型。
在Go中,自定义错误类型是一种强大的方式,可以创建更具描述性和上下文的错误,以提供更好的错误信息。自定义错误类型必须满足 error 接口的要求,即实现 Error() string 方法。以下是一个示例,展示如何自定义错误类型和验证其用例:
package define import ( "fmt" "testing" "time" ) // 自定义错误类型 type TimeoutError struct { Operation string // 操作名称 Timeout time.Time // 超时时间 } // 实现 error 接口的 Error() 方法 func (e TimeoutError) Error() string { return fmt.Sprintf("Timeout error during %s operation. Timeout at %s", e.Operation, e.Timeout.Format("2006-01-02 15:04:05")) } // 模拟执行某个操作,可能会超时 func PerformOperation() error { // 模拟操作超时 timeout := time.Now().Add(5 * time.Second) if time.Now().After(timeout) { return TimeoutError{Operation: "PerformOperation", Timeout: timeout} } // 模拟操作成功 return nil } func TestDefineError(t *testing.T) { err := PerformOperation() if err != nil { // 检查错误类型并打印错误信息 if timeoutErr, ok := err.(TimeoutError); ok { fmt.Println("Error Type:", timeoutErr.Operation) fmt.Println("Timeout At:", timeoutErr.Timeout) } fmt.Println("Error:", err) } else { fmt.Println("Operation completed successfully.") } }
下面是代码的解释:
-
TimeoutError 结构体:定义了一个自定义错误类型 TimeoutError,包含以下字段:
Operation:操作名称,表示哪个操作超时;Timeout:超时时间,表示操作发生超时的时间点。 -
Error() 方法:为 TimeoutError 结构体实现了 error 接口的 Error() 方法,用于生成错误的文本描述。
-
PerformOperation() 函数:模拟执行某个操作,可能会超时。在这个示例中,如果当前时间超过了超时时间,则返回一个 TimeoutError 类型的错误。
-
TestDefineError() 测试函数:演示如何在错误处理中使用自定义错误类型。
调用 PerformOperation() 函数来模拟操作,并检查是否发生了错误;如果发生错误,首先检查错误类型是否为 TimeoutError,如果是,则提取超时操作和超时时间,并输出相关信息;最后,无论是否发生错误,都会输出错误信息或成功完成的消息。
这个示例展示了如何自定义错误类型以及如何在错误处理中利用这些自定义错误类型来提供更多的上下文信息,使错误处理更加有信息和灵活。在这里,TimeoutError 提供了有关超时操作和超时时间的额外信息。
(七)包和依赖管理
src目录下创建chapter7,Go 语言的包和依赖管理主要通过其内置的模块系统(Go Modules)来实现。Go Modules 于 Go 1.11 版本首次引入,并在 Go 1.13 版本中成为默认的依赖管理方式。
1.package(包)的基本知识点
基本复用模块单元
在 Go 语言中,package 是代码复用的基本单元。一个 package 可以包含多个 Go 源文件,这些文件可以共享同一个包中的代码,并通过包的导入机制被其他包使用。
包的可见性:在 Go 语言中,通过首字母大写来表明一个标识符(如变量、函数、类型等)可以被包外的代码访问。反之,首字母小写的标识符只能在包内使用。
// mypackage.go package mypackage // 公有函数,其他包可以访问 func PublicFunction() { // 实现细节 } // 私有函数,仅在当前包内可访问 func privateFunction() { // 实现细节 }
代码的 package 可以和所在的目录不一致
Go 语言的文件组织结构鼓励但不强制 package 名称与其所在目录名称一致。通常情况下,开发者会遵循这种约定以保持代码的一致性和可读性,但 Go 并不强制执行这一规则。
实际应用:你可以在 chapter7 目录下创建多个文件,并在这些文件中定义相同的包名 mypackage,也可以选择一个不同于目录名的包名。
// src目录下的代码 // src/chapter7/utility.go package utility // 包名与所在目录名不同 func UtilityFunction() { // 实现细节 }
同一目录里的 Go 代码的 package 要保持一致
在同一目录中的所有 Go 文件必须声明相同的 package 名称。这是 Go 语言的一个基本规则,确保同一目录下的所有文件都属于同一个包,从而能够互相访问这些文件中声明的标识符。
违例情况:如果你在同一目录下使用不同的 package 名称,Go 编译器将会报错,提示包声明不一致。这个在上面的案例中也可以直接看到。
2.构建一个自身可复用的package
src目录下创建chapter7后,再次新建series,编写my_series.go如下:
package series import "fmt" func init() { fmt.Println("init1") } func init() { fmt.Println("init2") } func Square(n int) int { return n * n } func GetFibonacciSerie(n int) []int { ret := []int{1, 1} for i := 2; i
然后在chapter7中新建client,编写package_test.go将上面的内容引入:
package client import ( "go-learning/src/chapter7/series" "testing" ) func TestPackage(t *testing.T) { t.Log(series.GetFibonacciSerie(5)) t.Log(series.Square(5)) }
通过在 chapter7 目录下创建一个名为 series 的包,把与数学相关的函数(如求平方和斐波那契数列)集中在一起。这样在其他地方需要使用这些功能时,只需引入这个包即可,不必重复编写相同的代码。
知识点:包的初始化
- 利用 Go 语言中的 init() 函数机制进行包的初始化操作。在 Go 中,每个包可以有多个 init() 函数,这些函数会在包第一次被加载时自动执行,且执行顺序按照代码顺序。
- 在 series 包中编写了两个 init() 函数,它们会在包被引入时自动执行。这种机制可以用于在包加载时执行一些必要的初始化工作(如设置默认值、加载配置等),或者用来调试包的加载过程。
3.导入和应用远程依赖(即外部包)
获取和更新远程依赖
- 使用 go get 命令来下载并添加远程依赖到项目中。Go Modules 会自动管理这些依赖,并更新 go.mod 和 go.sum 文件。
- 如果需要强制从网络获取最新版本的依赖,可以使用 -u 参数:
- 示例:go get -u github.com/user/repo
这将更新指定包及其依赖项到最新的次要版本或修订版本。
代码在 GitHub 上的组织形式
- 确保代码库的目录结构直接反映包的导入路径,而不要使用 src 目录作为根目录。这使得项目更容易与 Go 的依赖管理工具兼容,确保导入路径的简洁和一致性。
-
github.com/username/project/ ├── mypackage/ │ └── mypackage.go └── anotherpackage/ └── anotherpackage.go
最佳实践:在 GitHub 上组织代码时,目录结构应与包名匹配,例如: - 这样可以避免导入路径中的多余层级,并确保使用 go get 时能正确定位包。
按照该思路我们进行验证,在在 chapter7 目录下创建一个名为 remote_package 的包,我们先进行下载“go get github.com/easierway/concurrent_map”的下载,然后创建remote_package_test.go进行验证:
package remote import ( "fmt" "testing" cm "github.com/easierway/concurrent_map" ) func TestConcurrentMap(t *testing.T) { m := cm.CreateConcurrentMap(99) m.Set(cm.StrKey("key"), 10) value, ok := m.Get(cm.StrKey("key")) if ok { fmt.Println("Key found:", value) t.Log(m.Get(cm.StrKey("key"))) } }
concurrent_map的介绍:concurrent_map 是一个由 GitHub 用户 easierway 创建的 Go 包,主要用于实现线程安全的并发 map 数据结构。这个包提供了一种简单且高效的方式来处理并发环境下的 map 操作,避免了传统 map 在多 goroutine 访问时出现的竞争问题。
功能/特点 说明 线程安全 通过分段锁机制(分片锁)确保 map 在多 goroutine 并发访问时的数据安全。 高效的读写操作 将 map 分成多个子 map,减少锁的粒度,提高并发访问的效率。 简单易用的 API 提供类似标准 map 的接口,如 Set、Get、Remove,使用方式简单。 动态扩展 根据使用需求动态扩展或收缩分段,提高资源利用率。 4.包的依赖管理
Go 语言在早期的依赖管理中(使用 GOPATH)确实存在一些未解决的问题:
同一环境下,不同项目使用同一包的不同版本
在 Go Modules 引入之前,Go 的依赖管理依赖于 GOPATH 目录。所有的项目共享同一个 GOPATH,这就导致了一个问题:如果两个项目需要使用同一包的不同版本,由于 GOPATH 中同一个包只能有一个版本,无法同时满足这两个项目的需求。这种情况下,开发者往往需要手动管理和切换包版本,带来了很大的麻烦和不确定性。
无法管理对包的特定版本的依赖
在没有 Go Modules 之前,Go 的依赖管理缺乏对包版本的精确控制。通常情况下,开发者只能获取最新版本的包,这就导致了以下问题:
- 当某个包发布了不兼容的新版本时,项目可能会因自动升级到新版本而导致编译或运行错误。
- 难以重现历史版本的构建,因为无法确定项目依赖的具体版本。
Go Modules 如何解决这些问题
为了解决这些问题,Go 从 1.11 版本开始引入了 Go Modules,从根本上改变了 Go 的依赖管理方式。Go Modules 提供了版本控制和模块隔离的机制,避免了上述问题。
不同项目使用同一包的不同版本
- 独立的模块空间:每个 Go 项目通过 go.mod 文件独立管理其依赖关系。go.mod 文件定义了项目所依赖的所有包及其版本,这些包会被下载到 $GOPATH/pkg/mod 下,并且是根据模块名和版本号来隔离的。因此,不同项目可以使用同一包的不同版本,而不会相互干扰。
- 无需全局 GOPATH:Go Modules 摆脱了对全局 GOPATH 的依赖,转而使用模块级的依赖管理。每个项目的依赖包版本在项目目录下独立管理,避免了版本冲突。
管理对包的特定版本的依赖
- 精确的版本控制:在 go.mod 文件中,你可以指定依赖包的具体版本。Go Modules 支持语义化版本控制(Semantic Versioning),你可以通过 @ 符号指定某个依赖包的版本号(如 v1.2.3),或者使用 go get @ 命令来更新某个依赖的版本。这样,你可以明确指定和锁定项目依赖的版本,确保项目的可重现性。
- 版本兼容性和依赖解析:Go Modules 通过 go.mod 和 go.sum 文件管理版本依赖,确保项目构建过程中使用的依赖版本是可预测且稳定的。即使某个依赖包发布了新版本,你的项目仍会使用 go.mod 中指定的版本,除非你主动升级。
虽然 Go Modules 解决了许多依赖管理问题,但它也带来了一些新的挑战:
- 多模块项目的管理:在一些大型项目中,可能会有多个模块,这些模块之间的依赖管理需要谨慎处理,特别是当这些模块之间存在依赖关系时。
- 依赖冲突:如果不同的依赖项依赖于同一个包的不同版本,Go Modules 会尝试找到一个可用的共同版本,但这可能并不总是理想的解决方案。
Go Modules 通过模块化和版本控制,基本解决了 Go 语言早期依赖管理中的主要问题,如同一环境下不同项目使用同一包的不同版本,以及对包的特定版本的依赖管理问题。然而,尽管如此,随着项目规模的扩大和依赖关系的复杂化,依赖管理仍然需要开发者谨慎对待。
(八)并发编程
src目录下创建chapter8,展开后续的学习。
1.协程机制
Thread vs Goroutine
Java 的线程(Thread)与 Go 语言的协程(Goroutine)在设计哲学和实现细节上有很大的不同,主要表现在栈大小及与内核空间实体(KSE)的对应关系方面:
比较项 Java Thread Goroutine 栈的初始大小 1MB(JDK5 及以后版本) 2KB 栈的增长方式 固定大小,超出时抛出 StackOverflowError 动态增长,最大可扩展到 1GB 与内核线程的对应关系 1:1 模型,每个 Java 线程对应一个内核线程 M模型,多个 Goroutine 对应少量内核线程 调度方式 由操作系统调度 由 Go 运行时调度 创建和调度的开销 较大,创建和切换线程的开销较高 较小,创建和调度 Goroutine 的开销非常低 并发处理能力 创建大量线程时可能影响系统性能 可以高效创建和管理大量 Goroutine Goroutine 的调度原理
左侧图示展示了 Goroutine 在正常调度情况下的工作原理:
-
M(System Thread):代表操作系统的线程,图中有两个系统线程,M0 和 M1。
M0 正在执行某个 Goroutine。 -
P(Processor):代表处理器,这里是 Go 的调度器中的一个抽象概念,而不是实际的 CPU 核心。P 负责执行 Goroutine 的队列,并将它们映射到系统线程(M)上。
图中有一个 P,它将 Goroutine 分配给 M 进行执行。 -
G(Goroutine):代表 Goroutine,图中 G0, G1, G2 等分别表示不同的 Goroutine。
G0 正在被 M0 执行。G1, G2 仍在等待被调度执行。
右侧图示展示了 Goroutine 发生系统调用(Syscall)时的工作原理:
-
Syscall:当 Goroutine 需要执行系统调用时,执行该 Goroutine 的系统线程会被阻塞。
这里 M0 在处理 G0 的系统调用,因此 M0 被阻塞在系统调用中。 -
M1:系统线程 M1 被调度来继续处理 P 中的其他 Goroutine。
P 调度了其他的 Goroutine(如 G1, G2)到新的系统线程 M1 上继续执行,从而避免了因为一个 Goroutine 阻塞而导致整个线程阻塞的情况。
调度机制总结
- Go 运行时调度器通过 M:P:G 模型实现了 Goroutine 的高效调度。
- M(系统线程)可以执行多个 G(Goroutine),而 P(Processor)则决定哪些 Goroutine 应该运行在 M 上。
- 当一个 Goroutine 被阻塞时(如执行系统调用),Go 运行时会将该系统线程从调度队列中移除,并将剩余的 Goroutine 调度到其他空闲的系统线程上继续执行。
- 这样可以有效地利用系统资源,避免线程阻塞导致的资源浪费,体现了 Goroutine 的轻量化和高效性。
这张图很直观地展示了 Go 语言中 Goroutine 的 M 模型,如何通过 M, P, G 之间的协作,实现高效的并发调度。
直接的代码展示
直接在chapter8下新建groutine,编写groutine_test.go代码如下:
package groutine import ( "fmt" "testing" "time" ) func sayHello() { fmt.Println("Hello, Goroutine!") } func TestGroutine(t *testing.T) { for i := 0; i
- TestGroutine 展示了如何在循环中创建多个 Goroutine 并并发执行任务,同时说明了 Goroutine 的变量捕获问题。
- TestSayHello 展示了如何使用 Goroutine 并发执行一个简单的函数,并突出 Goroutine 的非阻塞特性。
通过这两个函数,可以更好地理解 Go 语言中 Goroutine 的基本使用方式以及它们在并发编程中的作用。
2.共享内存并发机制
在chapter8下新建share_mem,我们可以先写下面的代码share_mem_test.go来体验共享内存并发机制的控制:
未引入同步处理情况
package share_mem import ( "testing" "time" ) func TestCounter(t *testing.T) { counter := 0 for i := 0; i
运行结果为:
=== RUN TestCounter share_mem_test.go:17: counter = 4426 --- PASS: TestCounter (1.01s) PASS
在代码中,5000 个 goroutine 同时对 counter 变量进行递增操作(counter++),但 counter++ 并不是原子操作,它实际上包含了三步:
- 读取 counter 的当前值。
- 对 counter 的值加 1。
- 将新的值写回 counter。
在并发环境下,不同的 goroutine 可能在同一时间读取 counter,并且在写入时产生冲突。举个例子,两个 goroutine 可能在几乎同一时刻读取到 counter 的值为 100,然后都试图将 counter 更新为 101。由于没有同步机制,其中一个更新可能会被覆盖,从而导致 counter 的实际值小于预期的 5000。
注意,虽然程序使用了 time.Sleep(1 * time.Second) 来等待 goroutine 执行完毕,但这并不能解决数据竞争的问题。即使所有 goroutine 都在一秒内完成,counter 变量的递增操作依然存在竞争。
sync.Mutex
要解决这个问题,需要引入同步机制来保护对共享变量的访问。常见的同步方法包括:
- 使用 sync.Mutex 来确保每次只有一个 goroutine 能够修改 counter。
- 使用 sync/atomic 包中的原子操作,如 atomic.AddInt32 或 atomic.AddInt64。
我们使用sync.Mutex修改代码实现如下:
func TestCounterThreadSafe(t *testing.T) { var mut sync.Mutex counter := 0 for i := 0; i
这个时候运行结果符合我们的预期:
=== RUN TestCounterThreadSafe share_mem_test.go:32: counter = 5000 --- PASS: TestCounterThreadSafe (1.00s) PASS
sync.WaitGroup
上面的代码中去掉 time.Sleep(1 * time.Second) 后,虽然代码本身有加锁保护 counter 的访问,但依然出现错误的运行结果,这与并发 goroutine 的执行时机有关:
- goroutine 的非阻塞执行:在 Go 语言中,go 关键字启动的 goroutine 是并发运行的,而不是同步运行的。启动 goroutine 后,主程序并不会等待它们执行完毕就继续执行。也就是说,go func() 语句启动的 5000 个 goroutine 是异步执行的。而 t.Logf("counter = %d", counter) 是在主函数中执行的。当主函数到达 t.Logf 时,主程序并没有等待这些 goroutine 执行完毕,而是直接打印了 counter 的值。
- 主程序过早结束:由于去掉了 time.Sleep(1 * time.Second),主程序并不会等到所有 goroutine 执行完成,而是可能在 goroutine 尚未执行或部分执行完时就已经输出了 counter 的值。因此,counter 的值通常会小于 5000,因为并不是所有的 goroutine 都有机会执行 counter++ 操作。
我们需要一种机制来确保主程序等待所有 goroutine 完成后再输出 counter。可以使用 sync.WaitGroup 来实现这个目标,具体代码如下:
func TestCounterWaitGroup(t *testing.T) { var mut sync.Mutex counter := 0 var wg sync.WaitGroup // 声明 WaitGroup for i := 0; i
此时运行结果正常。WaitGroup 是 Go 语言中的一种用于并发控制的同步机制,主要用于等待一组 Goroutines 完成执行。当你有多个 Goroutines 需要同时执行并等待它们全部完成时,WaitGroup 提供了一种简单的方式来实现这一需求。
WaitGroup 主要有三个方法:
- Add(delta int):添加或减少等待的 Goroutines 计数。参数 delta 表示增加或减少的 Goroutines 数量,通常是正数增加等待数量,负数减少等待数量。
- Done():表示一个 Goroutine 完成了工作,通常在 Goroutine 结束时调用,等价于 Add(-1)。
- Wait():阻塞当前 Goroutine,直到 WaitGroup 计数为零,也就是所有添加的 Goroutines 都完成了工作。
WaitGroup 非常适合用于多个 Goroutines 并发执行并且需要在主程序中等待它们全部完成的场景。例如:
- 并发下载文件后统一处理。
- 并行处理多个任务,最终汇总结果。
这种机制在 Go 语言中非常常用,结合 Goroutines,可以极大地提高程序的并发能力。
3.CSP 并发机制
Go 语言的并发机制是基于 CSP(Communicating Sequential Processes,通信顺序进程) 模型。CSP 是一种并发模型,允许多个独立的进程通过消息传递进行通信,而不是通过共享内存来交换数据。Go 通过 Goroutines 和 Channels 实现了这种并发机制,使得并发编程变得更加简单和安全。
基本原理
Goroutine在上面已经了解到了,现在理解一下Channels,Channels 是 Go 中用于 Goroutines 之间通信的机制,可以通过 chan 关键字定义,它们允许一个 Goroutine 发送数据,另一个 Goroutine 接收数据。Channels 可以是无缓冲的(阻塞式)或带缓冲的(非阻塞式):
-
左边的图片展示了 Goroutines(标记为“GR”)使用 无缓冲通道 进行通信的过程。在无缓冲通道中,发送和接收操作是阻塞的,意味着发送方必须等待接收方准备好接收,反之亦然。
在第一张图中,Goroutine 1 和 Goroutine 2 之间的通道是空的,表示发送或接收操作的阻塞状态。在后面的图中,Goroutines 通过 Channel 成功交换了数据。 -
右边的图片展示了 有缓冲通道 的情况。与无缓冲通道不同,有缓冲通道允许在不阻塞的情况下存储一定数量的数据。
每个缓冲通道都有一定数量的存储槽位(图中绿色块代表缓冲区)。发送者可以连续发送多个数据,直到缓冲区被填满。例如,在第一张图中,通道缓冲区未满,Goroutine 能继续向缓冲区发送数据。当缓冲区满时,发送方将阻塞,直到接收方消费数据。 - 无缓冲通道:发送和接收必须同步,发送方和接收方必须同时准备好进行通信。
- 有缓冲通道:发送方可以发送多条消息,直到缓冲区满后阻塞,接收方可以在缓冲区不为空时接收消息。
Goroutines 使用 channel 进行通信时,可以通过
-
-
- 示例:go get -u github.com/user/repo
-
-