【后端开发】Go语言编程实践,Goroutines和Channels,基于共享变量的并发,反射与底层编程
【后端开发】Go语言高级编程,CGO、Go汇编语言、RPC实现、Web框架实现、分布式系统
文章目录
-
-
- [1、并发基础, Goroutines和Channels](#1、并发基础, Goroutines和Channels)
- [2、基于共享变量的并发, sync.WaitGroup和sync.Mutex](#2、基于共享变量的并发, sync.WaitGroup和sync.Mutex)
- 3、反射与底层编程
-
参考资料:
-
1、框架
go语言源码-124k,
go精选框架-133k,
go-gin-HTTP Web框架-80k,
go-Llama 3.2框架-90k,
rclone-云存储挂载-50k,
go-zero-原生微服务框架-20k -
2、教程
The Go programming language - 124k
Go语言圣经 《The Go Programming Language》 中文版-4.5k
Go语言高级编程-20k,
go成长路线-6k,
go学习指南-3k
go基础语法-菜鸟
1、并发基础, Goroutines和Channels
Goroutine 是 Go 中实现并发的基本单位。它是一种轻量级线程,使用 go
关键字启动。
- 如果你使用过操作系统或者其它语言提供的线程,那么你可以简单地把goroutine类比作一个线程,这样你就可以写出一些正确的程序了。
- 当一个程序启动时,其主函数即在一个单独的goroutine中运行,我们叫它main goroutine。新的goroutine会用go语句来创建。
- 在语法上,go语句是一个普通的函数或方法调用前加上关键字go。go语句会使其语句中的函数在一个新创建的goroutine中运行。而go语句本身会迅速地完成。
go
go func() {
// 这里是并发运行的代码
}()
func aaa(str string){}
go aaa("aa")
f() // call f(); wait for it to return
go f() // create a new goroutine that calls f(); don't wait
Channels
- 如果说goroutine是Go语言程序的并发体的话,那么channels则是它们之间的通信机制。一个channel是一个通信机制,它可以让一个goroutine通过它给另一个goroutine发送值信息。
- 每个channel都有一个特殊的类型,也就是channels可发送数据的类型。一个可以发送int类型数据的channel一般写为chan int。
- 和map类似,channel也对应一个make创建的底层数据结构的引用。当我们复制一个channel或用于函数参数传递时,我们只是拷贝了一个channel引用,因此调用者和被调用者将引用同一个channel对象。和其它的引用类型一样,channel的零值也是nil。
go
ch := make(chan int)
ch <- x // a send statement
x = <-ch // a receive expression in an assignment statement
<-ch // a receive statement; result is discarded
close(ch) // 随后对基于该channel的任何发送操作都将导致panic异常。
ch = make(chan int) // unbuffered channel
ch = make(chan int, 0) // unbuffered channel
ch = make(chan int, 3) // buffered channel with capacity 3
操作不同状态的chan会引发三种行为:
panic
- 向已经关闭的通道写数据
- 重复关闭通道
阻塞
- 向未初始化的通道写/读数据
- 向缓冲区已满的通道写入数据
- 通道中没有数据,读取该通道
非阻塞
- 读取已经关闭的通道,这个操作会返回通道元素类型的零值(可用comma, ok语法)
- 向有缓冲且没有满的通道读/写
go
package main
import (
"testing"
)
func TestChanOperateStatus(t *testing.T) {
t.Run("向已经关闭的通道写数据", func(t *testing.T) {
ch := make(chan int)
close(ch) // 关闭通道
ch <- 1 // 这里会引发panic,因为向已关闭的通道发送数据
// panic: send on closed channel [recovered]
})
t.Run("重复关闭通道", func(t *testing.T) {
ch := make(chan int)
close(ch) // 第一次关闭通道
close(ch) // 再次关闭通道会引发panic
// panic: close of closed channel [recovered]
})
t.Run("向未初始化的通道写/读数据", func(t *testing.T) {
var ch chan int
go func() {
ch <- 1
// x := <-ch
}()
_ = <-ch
// fatal error: all goroutines are asleep - deadlock!
})
t.Run("向缓冲区已满的通道写入数据", func(t *testing.T) {
ch := make(chan int, 1)
ch <- 1 // 第一次写入,缓冲区未满
ch <- 2 // 这里会阻塞,因为缓冲区已满,没有goroutine读取数据
// fatal error: all goroutines are asleep - deadlock!
})
t.Run("通道中没有数据,读取该通道", func(t *testing.T) {
ch := make(chan int)
_ = <-ch // 这里会阻塞,因为没有goroutine发送数据到通道
// fatal error: all goroutines are asleep - deadlock!
})
t.Run("读取已经关闭的通道,这个操作会返回通道元素类型的零值(可用comma, ok语法)", func(t *testing.T) {
ch := make(chan int)
close(ch) // 关闭通道
x, ok := <-ch // x 将会是int类型的零值,ok 将会是false
expectx, expectok := 0, false
if ok != expectok && x != expectx {
t.Errorf("expect 0, false, get %d, %t\n", x, ok)
}
})
t.Run("向有缓冲且没有满的通道写,向有缓冲且不为空的通道读", func(t *testing.T) {
ch := make(chan int, 2) // 1 也不会堵塞
ch <- 1 // 写入数据,不会阻塞
_ = <-ch // 读取数据,不会阻塞
})
}
2、基于共享变量的并发, sync.WaitGroup和sync.Mutex
sync.WaitGroup 计数器,等待并发完成
sync.Mutex 互斥锁,保护共享资源
闭包,捕获外部变量的值
sync.WaitGroup
是一个计数器,用于等待一组 goroutine 完成。使用它的步骤如下:
- 添加计数 :使用
Add(n int)
方法增加计数,通常在启动 goroutine 之前调用。 - 完成计数 :在 goroutine 内部,使用
Done()
方法来减少计数。 - 等待完成 :使用
Wait()
方法阻塞当前 goroutine,直到计数器变为零。
go
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1) // 增加计数
go func(i int) {
defer wg.Done() // 在完成时减少计数
// 某些操作
}(i)
}
wg.Wait() // 等待所有 goroutine 完成
sync.Mutex
是一种互斥锁,用于保护共享资源,确保同一时间只有一个 goroutine 访问它。使用方法如下:
- 锁定 :使用
Lock()
方法加锁,确保线程安全。 - 解锁 :使用
Unlock()
方法解锁。通常推荐使用defer
来确保在函数退出时解锁。
go
var mtx sync.Mutex
mtx.Lock() // 上锁
// 对共享资源的访问
mtx.Unlock() // 解锁
在并发操作中,通过收集错误并处理它们也是很常见的做法。可以使用切片来存储可能发生的错误,并且在访问这个切片时,需要使用 Mutex 来保证线程安全。
go
var errs []error
var mtx sync.Mutex
if err != nil {
mtx.Lock()
errs = append(errs, err)
mtx.Unlock()
}
闭包
在 goroutine 内部,可以使用闭包来捕获外部变量的值。这对于确保在并发执行时每个 goroutine 使用到的是正确的变量非常重要。
go
for _, value := range values {
go func(v string) {
// 使用 v,确保 v 是当前循环中的值
}(value)
}
结合以上组件,可以实现并发的操作,例如:
go
var wg sync.WaitGroup
var mtx sync.Mutex
var errs []error
for _, id := range ids {
wg.Add(1)
go func(id string) {
defer wg.Done()
// 假设这里是某个并发操作
if err := doSomething(id); err != nil {
mtx.Lock()
errs = append(errs, err)
mtx.Unlock()
}
}(id)
}
wg.Wait()
// 处理错误
if len(errs) > 0 {
// 处理错误逻辑
}
3、反射与底层编程
反射:遍历不确定的结构体的每个字段,可以用反射来获取结构体的字段的值 。以及判断字段数据类型,在调用适当的函数,做神奇操作。
底层编程:cgo
反射是由 reflect 包提供的。它定义了两个重要的类型,Type 和 Value。一个 Type 表示一个Go类型。
- 反射是程序在运行时能够"观察"并且修改自己的行为的能力。在Go语言中,反射是通过reflect包实现的,它提供了两个核心功能:Type和Value
- 获取Type和Value:使用reflect.TypeOf()和reflect.ValueOf()可以获取变量的动态类型和值。TypeOf返回的是Type接口,而ValueOf返回的是Value接口
- 类型和值的查询:通过Type和Value接口的方法,可以查询变量的类型信息和值。例如,Kind()方法可以返回一个常量,表示底层数据类型,如Uint、Float64、Slice等
- 修改值:使用Value的Set()方法可以修改值,但需要注意的是,只有当值是可设置的(settable)时才能修改。可设置性意味着值必须通过指针传递,并且使用Elem()方法获取指针指向的值进行修改
- 动态方法调用和字段访问:反射不仅可以用于基础类型和结构体,还可以用于动态地调用方法和访问字段。例如,可以通过MethodByName方法动态调用对象的方法
go
package main
import (
"fmt"
"reflect"
)
// 定义一个示例结构体
type Employee struct {
Name string
Age int
Salary float64
Active bool
}
// 一个通用函数,使用反射打印并修改结构体字段
func inspectAndModify(v interface{}, newValue interface{}) {
// 获取传入值的反射值
val := reflect.ValueOf(v)
// 检查是否是指针类型,如果是,获取其元素
if val.Kind() == reflect.Ptr {
val = val.Elem()
}
// 打印结构体的字段
fmt.Printf("结构体类型: %s\n", val.Type())
fmt.Println("字段:")
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := val.Type().Field(i)
// 打印字段名称和类型
fmt.Printf(" %s (%s): %v\n", fieldType.Name, fieldType.Type, field)
// 这里我们简单示例,用传入的值替换第一个可设置的字段
if i == 0 && field.CanSet() {
value := reflect.ValueOf(newValue)
if value.Type().AssignableTo(field.Type()) {
field.Set(value)
fmt.Printf(" %s字段已更新为: %v\n", fieldType.Name, field)
} else {
fmt.Printf(" 无法将值%v赋给字段%s,类型不匹配。\n", newValue, fieldType.Name)
}
}
}
}
func main() {
// 创建一个 Employee 实例
emp := &Employee{Name: "Alice", Age: 30, Salary: 65000.0, Active: true}
// 使用 inspectAndModify 函数打印结构体信息并修改字段
inspectAndModify(emp, "Bob")
// 打印修改后的结构体
fmt.Println("修改后的 Employee:", emp)
}
// 结构体类型: main.Employee
// 字段:
// Name (string): Alice
// Name字段已更新为: Bob
// Age (int): 30
// Salary (float64): 65000
// Active (bool): true
// 修改后的 Employee: &{Bob 30 65000 true}
go
package main
import (
"fmt"
"reflect"
)
type Person struct {
Name string
Age int
Address string
}
func main() {
// 创建一个Person实例
p := Person{
Name: "John Doe",
Age: 30,
Address: "123 Main St",
}
// 获取Person实例的反射值
v := reflect.ValueOf(p)
// 确保v是一个结构体
if v.Kind() == reflect.Struct {
// 遍历结构体的所有字段
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
// 获取字段的名称
fieldName := v.Type().Field(i).Name
// 获取字段的值
fieldValue := field.Interface()
fmt.Printf("Field: %s, Value: %v\n", fieldName, fieldValue)
}
}
}
Go的底层编程
- Go的底层编程涉及到更接近硬件和操作系统的细节,包括内存管理、指针操作等。
- unsafe包:Go提供了unsafe包,它允许程序员绕过Go的类型系统,进行指针操作和内存对齐等操作。unsafe包中的Sizeof、Alignof和Offsetof可以用于获取类型的存储大小、内存对齐和字段偏移量
- unsafe.Pointer:unsafe.Pointer是一个特殊的类型,它可以存储任何类型的指针,并允许进行指针转换和算术操作
- 调用C代码:通过cgo,Go程序可以调用C语言代码,这涉及到底层的内存管理和类型转换
- 性能考虑:底层编程和反射操作通常比直接的Go操作要慢,因为它们涉及到额外的动态查询和类型转换。因此,在性能敏感的应用中需要谨慎使用
- 要编译和运行这个示例,你需要确保你的Go环境可以使用cgo。通常,对于大多数操作系统,cgo是默认启用的。
调用c语言代码
go
// main.go
package main
/*
#include <stdio.h>
#include <stdlib.h>
// 定义转置函数
void transpose(int* src, int* dest, int rows, int cols) {
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
dest[j * rows + i] = src[i * cols + j];
}
}
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
// 定义一个3x3的矩阵
rows, cols := 3, 3
src := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
dest := make([]int, rows*cols)
// 打印原始矩阵
fmt.Println("原始矩阵:")
printMatrix(src, rows, cols)
// 调用C代码进行转置
C.transpose((*C.int)(unsafe.Pointer(&src[0])), (*C.int)(unsafe.Pointer(&dest[0])), C.int(rows), C.int(cols))
// 打印转置后的矩阵
fmt.Println("转置后的矩阵:")
printMatrix(dest, cols, rows)
}
func printMatrix(matrix []int, rows, cols int) {
for i := 0; i < rows; i++ {
for j := 0; j < cols; j++ {
fmt.Printf("%d ", matrix[i*cols+j])
}
fmt.Println()
}
}