Golang简约指南

Go 语言的设计目的是作为 C/C++ 的替代工具,是谷歌内部为了解决 C/C++ 开发中的一些痛点:编译慢、不受控制的依赖、三方库混乱、编程范式繁多(每个人都选择其中一部分特性)、并发编程麻烦、跨平台代码兼容、内存安全、工程开发效率低等等

Go的优势

  1. 静态编译,编译速度快
  2. 云原生+跨平台
  3. 独特的异步编程机制
  4. 垃圾回收

接下来我们从以下几方面来入门Go语言,帮助大家开始尽早进入开发流程:

  1. 语言基础
  2. 工程化
  3. 进阶知识

Go是一门非常简约时尚的语言,希望我的排版也可以带给大家简约的美感

语言基础

Go是一门支持函数式编程的强类型语言

常用的基本类型:bool,int,float,struct,stringslicemapchannelarrayinterface(接口),function(函数)

Go存在基本类型与引用类型的划分,string为基本类型,其默认值为“”

Hello World

1
2
3
4
5
6
7
package main

import ("fmt")

func main() {
fmt.Println("Hello World!")
}

var

声明变量,类型后置

1
2
3
4
5
6
var a,b bool
var p *int
var arr []int
var mp map[string] int
var ch chan int
var fn func(string) int
1
2
3
4
5
var b = true
var f,s,t = "a",false,322
b2 := true
str := "hello world"
f2,s2,t3 := "a",false,322

Tip: 相同的代码块中,不可以再次对于相同名称的变量使用初始化声明

const

声明常量并立即赋值,只可以是布尔型、数字型和字符串型

1
2
3
const d = 2525
const d2 int = 1324
const a,b,c = "a",false,322

常量用作枚举,可以使用内置函数赋值

iota常量生成器,在 const 关键字出现时将被重置为 0,const 中每新增一行常量声明将使 iota 计数一次

1
2
3
4
5
6
7
const (
Unknown = 0
Female = 1
Male = 2
b = "abc"
a = len(b)
)
1
2
3
4
5
const (
a = iota
b
c
)

运算符

与c语言类似,添加**用作乘方运算符

结构化编程

if

不推荐使用括号包裹条件,支持在if内执行初始化语句

1
2
3
4
5
6
7
8
9
10
11
12
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}

if a := 3; a>12 {
fmt.Println("a>12")
}else if a<=2 {
fmt.Println("a<=2")
}else{
fmt.Println("2<a<=12")
}

switch

支持对确定类型的值进行选择,无switch穿透,使用fallthrough进入下一分支

1
2
3
4
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
...
}
1
2
3
4
5
6
7
8
9
10
var t interface{}
t = function()
switch t := t.(type) {
default:
fmt.Printf("unexpected type %T\n", t)
case bool:
fmt.Printf("boolean %t\n", t)
case *int:
fmt.Printf("pointer to integer %d\n", *t)
}

select

通道选择,执行select时,会同时监听多个通道的操作

支持三种表达式:即通道接收表达式,通道发送表达式和default,所有发送表达式在select前被并行求值(即使其中某些表达式可能永远不会被执行)

1
2
3
4
5
6
7
8
9
10
select {
case <- channel1:
// do something...
case value := <- channel2:
// do something...
case channel3 <- value:
// do something...
default:
// do something...
}

若有通道准备完毕,则执行对应代码块并结束。若已有多个通道准备完毕,则随机选择一个执行。若所有通道未准备好,则执行default代码或被阻塞

for

循环语句,支持使用break暂停并跳出循环,支持使用continue跳转到下一次循环,支持标记特定循环

1
2
3
4
5
6
7
8
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
for {
if a<10 {
break
}
}

range

支持自动获取遍历迭代器

1
2
3
4
5
6
7
8
for key, value := range oldMap {
newMap[key] = value
}

sum := 0
for _, value := range array {
sum += value
}

类型系统(一)

func

函数,参数存在值传递与引用传递,并支持返回多个值,返回值可以命名

1
2
3
4
5
6
7
8
9
func ReadFull(r Reader, buf []byte) (n int, err error) {
for len(buf) > 0 && err == nil {
var nr int
nr, err = r.Read(buf)
n += nr
buf = buf[nr:]
}
return
}

为了实现多值返回,Go是使用栈空间来返回值的,实际上是在传入的参数之上留了两个空位,被调者直接将返回值放在这两空位。而C语言是通过寄存器来返回值的。

支持匿名函数和闭包,函数可以在其他函数内部定义,所有函数内可以访问其外部函数的变量,并且在不同的作用域中保持对这些变量的引用

func是一个值,可以作为参数传递

1
2
3
4
5
6
7
8
9
func main() {
outer := func(param int32) (inner func() int32) {
return func() int32 {
return param
}
}
inner := outer(1);
println(inner())
}

struct

结构体,由各类字段组成的复合结构,共有字段名称首字母大写,私有小写

对结构体变量或指针使用.操作符访问其字段,支持为其字段赋值或对其字段进行取址(&)操作

1
2
3
4
type SyncedBuffer struct {
lock sync.Mutex
buffer bytes.Buffer
}

初始化支持自定义构造函数,使用时可以按照顺序的全参构造,也可使用键值对样式的部分参数构造

除使用构造函数外,也可以使用Go内置函数new()分配内存,初始化结构体并返回分配的内存指针

1
2
3
buffer :=  SyncedBuffer{nil,nil}
buffer2 := SyncedBuffer{lock:nil}
bp := new(SyncedBuffer)

array

有序序列,长度固定,与C不同的是将 [] 前置。

数组支持栈分配和值传递,效率更高。不支持引用传递,通常使用切片作为参数传递

1
2
arr := [5]int{}
array := [5]int{1}//[1, 0, 0, 0, 0]

需要注意海象符的语义:带类型推导的赋值,而非声明某类型的变量;因此 arr := [5]inti := int 一样不合法

通过 [] 来访问元素,不支持数组指针

1
2
array[0] = 3
element := array[2]

slide

有序序列,长度可变,与数组类似,但为引用传递,堆上分配

切片支持单独构造,也可以作为数组的引用,用来间接操作数组

1
2
s := []int{}
slide := []int{1,2,3,4}
1
2
array := [5]int{1, 2, 3, 4, 5}
subSlide := arr[0:5]

通过 [] 来访问元素,使用 append() 函数添加元素,使用切片操作进行截取

1
2
3
sub := slice[1:3]//[2,3]
slide = append(slide, 5)
element := slide[4]

map

映射是一个无序的键值对集合,可以根据键来快速检索对应的值

1
m := map[string]int{"key":1}

map 的底层实现是一个哈希表数据结构,使用一个哈希函数来计算键的哈希值,然后将键值对存储在一个称为桶(bucket)的数组中。每个桶包含一个链表或者红黑树,用于解决哈希冲突

支持添加值,获取值并判断是否存在,range获取迭代器,delete删除键值对

1
2
3
4
5
6
m["key"] = 2
value, ok := m["key"]
for _, val := range m {
fmt.Print(val)
}
delete(m, "key")

如果从一个未初始化的 map 中获取值,将会返回该值类型的零值

channel

channel是一种可以用来在协程之间传递数据的类型,channel是线程安全的,多个协程可以同时读写一个channel,而不会发生数据竞争的问题

使用make创建channel,使用close关闭channel

1
2
ch := make(chan string)
close(ch)

使用通道符号 <- 接收或发送信息,分为通道接收表达式 <-channel 和通道发送表达式 channel <- value

1
2
ch <- "Hello"
str := <- ch

channel可以带有一个缓冲区,用于存储一定量的数据。如果缓冲区已经满了,发送操作会被阻塞,直到有其他协程从channel中取走了数据;如果缓冲区已经空了,接收操作会被阻塞,直到有其他协程向channel中发送了数据。

1
ch := make(chan int, 10)

支持配合select在多个goroutine间传递数据

类型系统(二)

method

方法是与特定类型相关联的函数,可以将函数与特定类型绑定在一起,使得该类型的对象可以调用该方法

方法可以在任何自定义类型上定义,包括结构体、基本类型的别名类型以及接口,只需在函数名前面加上接收者,

1
2
3
func (receiver) method(params) (results) {
// do something...
}

接收者可以为一个值或一个指针,也可以只有类型

1
2
3
4
5
6
7
8
9
10
11
12
13
type Person struct{}

func (Person) SayHello() {
fmt.Println("Hello")
}

func (p Person) DoSomething() {
// do something...
}

func (p *Person) DoSomethingWithPointer() {
// do something...
}

interface

接口是一种抽象类型,它定义了一组方法签名,方法签名包括方法的名称、参数列表和返回值列表

1
2
3
type Writer interface {
Write(data []byte) (int, error)
}

类型只需要实现了接口中定义的所有方法,就被视为实现了该接口

1
2
3
4
type ConsoleWriter struct{}
func (cw ConsoleWriter) Write(data []byte) (int, error) {
// do something...
}

匿名字段方法

若存在匿名字段,其的方法可以被外部直接调用,通过组合与特殊语法实现了类似继承的机制

注意,只有方法被继承,且支持一个类型拥有多个匿名字段,所有匿名字段的方法都被继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Person struct {}

func (p Person) SayHello() {
fmt.Println("Hello")
}

type Employee struct {
Person
}

func main() {
emp := Employee{
Person: Person{},
}
emp.SayHello()
}

类型断言

类型断言用于检查变量是否为某种类型,并返回基础接口值。类型断言仅适用于接口

1
2
value, ok := variable.(Type)
value, ok := variable.(Interface)

类型转换

类型转换用于将一个类型的值转换为另一个类型,只有在两种类型之间存在兼容关系时,才能进行类型转换

1
value := Type(variable)

空接口

interface{} 可以代表动态类型,从逻辑上看,所有类型都实现了空接口

空结构体

struct{} 是一个特殊的类型,一个不包含任何字段的空结构体类型,其值没有字段需要存储,不占用任何内存空间。可以创建空结构体的实例,并将其传递给函数或通过通道进行发送,来表示某种特定的事件或触发某些操作,而不需要传递具体的数据

杂项

defer

defer函数被延迟到函数运行结束后,主要用来释放资源。若有多条defer函数则按LIFO顺序(后进先出)执行

1
2
3
4
5
6
7
8
func trace(s string)   { fmt.Println("entering:", s) }
func untrace(s string) { fmt.Println("leaving:", s) }

func a() {
trace("a")
defer untrace("a")
// do something...
}

tag

结构体字段结尾使用 “tag” 为字段附加元数据,可以在运行时通过反射机制读取,常用于实现对象的序列化、数据校验等

1
2
3
4
type Person struct {
Name string `json:"name" validate:"required"`
Age int `json:"age" validate:"gte=0"`
}

可变长参数

与其他语言类似的语法糖,可变长参数会被作为切片(slice)传递给函数

1
2
3
4
5
6
7
func sum(nums ...int) int {
total := 0
for _, num := range nums {
total += num
}
return total
}
1
result := sum(1, 2, 3, 4)

原始字符串字面量

使用反引号(`)来定义。在原始字符串字面量中,特殊字符(如反斜杠和换行符)将被直接包含,无需进行转义

1
2
3
str := `This is a raw string literal.
It can contain special characters like \n and \t.
It extends over multiple lines without the need for escape sequences.`

A Tour of Go

Go 的官方教程非常全面,建议大家有空可以读读,可以在A Tour of Go敲一敲代码,Or 直接官方文档Documentation - The Go Programming Language

我是一只可爱的留白喵


工程化是构建一门强大的开发语言和完整生态系统的重要组成部分

除语言设计本身外,代码规范和风格,依赖管理,构建和部署等对实现工程化也起着至关重要的作用

工程化

代码规范

命名规范

需要遵守特定的命名规范,使代码更易读并符合 Go 社区的惯例:

  1. 包名小写,使用单词或单词的缩写,并且不应该包含下划线或混合大小写。
  2. 常量应该使用全大写字母,并且单词之间使用下划线分隔。
  3. 变量名应该是小写的,使用驼峰命名法。命名应该具有描述性,以便更好地理解变量的含义。
  4. 函数和方法名应该是小写的,使用驼峰命名法。如果函数或方法是公共的,则应该以大写字母开头。
  5. 结构体名应该使用驼峰命名法,并且应该以大写字母开头。结构体的字段名应该是小写的,使用驼峰命名法。
  6. 接口名应该使用驼峰命名法,并且应该以大写字母开头。接口名通常应以 er 结尾,以表示它是一个接口。
  7. 错误变量应以 err 作为前缀,这样可以清晰地表示该变量是用来存储错误信息的。

导入规范

按照字母顺序对导入包进行分组,并在每个分组中按照标准库包、第三方库包和本地包的顺序排序。这种组织方式有助于提高代码的可读性,并且便于查找和维护导入的包。

注释规范

使用 // 进行单行注释,使用 /* */ 进行块注释。对于函数、方法和类型,使用注释来解释其功能和参数含义。对于公共的函数和方法,使用注释来描述其作用、返回值和可能的错误。注释对于代码的理解和维护非常重要。

错误处理

遵循 Go 语言的错误处理机制,使用多返回值来返回函数的结果和错误信息。对于可能出现错误的地方,及时检查和处理错误。这样可以提高代码的健壮性和可靠性。

包的组织

将相关的类型、函数和常量放在同一个包中,并将它们放在一个逻辑上相关的文件中。这种组织方式有助于代码的模块化和可维护性。对外提供的公共函数和类型应以大写字母开头,非公共的函数和类型应以小写字母开头。

避免全局变量

尽量避免使用全局变量,而是使用函数的参数和返回值来传递数据。这种做法有助于避免不必要的依赖和副作用,使代码更易于测试和维护。

依赖管理-模块

版本号

Go对于依赖包的版本管理基于语义化,即版本号需要按照以规定 v<major>.<minor>.<patch>

  • major(主版本号): 当做了不兼容的API修改时,一般是重大架构、技术、功能升级,API已经不兼容原来的版本
  • minor(次版本): 当做了向下兼容的功能性新新增,一般是正常的版本、功能迭代,要求API向后兼容
  • patch(修订版本号):当做了向下兼容的问题修正,要求API向后兼容

使用GoPath进行本地依赖管理

在Go中存在两个重要目录:GOROOT、GOPATH,分别为Go 语言安装目录和用户工作空间目录

Go 项目由一个或者多个 package 组成,package 按照来源可能分为:标准库、第三方库、项目私有库。标准库全部位于 GOROOT 目录中,而第三方库和私有库,都位于 GOPATH 目录

但是GoPath有一个非常严重的问题,就是所有的依赖的包必须由程序员手动管理,项目之间的版本管理没有分离,非常混乱,所以Go官方推出了依赖管理工具GoModule

使用GoModule进行项目依赖管理

使用命令 go mod init [module name] 初始化项目

GoModule主要为了解决依赖管理的问题,自动管理解决和管理依赖冲突,并实现了构建功能:

  1. 记录项目依赖了哪些包,以及包的精确版本。同时可以导出全局依赖树,方便管理和升级
  2. 可重复构建:在任何环境、平台的构建产物一致

在使用 Go Modules 进行依赖管理时,下载的依赖包会被存储在项目下的 pkg/mod 缓存目录。这样,每个项目都有其独立的依赖包目录,不会与其他项目共享依赖

通常我们直接将依赖声明在go.mod中,由以下几部分组成:

  1. module:项目声明,一般采用repo + module name的方式定义
  2. go版本声明:代码所需要的Go的最低版本
  3. require:声明了依赖包的路径和名字、版本
  4. exclude:项目中排除某个依赖库的某个版本
  5. replace:强制替换某些依赖库的版本
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
module github.com/feellmoose/gotest

go 1.22.0

require (
...
)

exclude (
...
)

replace (
...
)

除了直接修改 go.mod 外,我们还可以使用以下命令更加方便的管理:

  • go get [package] 添加新的依赖包到项目中,自动下载并将其添加到go.mod文件中
  • go get -u [package] 更新项目中的依赖包,自动下载最新版本的依赖并更新go.mod文件
  • go mod tidy 移除不再使用的依赖包,根据项目代码的实际依赖情况自动更新go.mod文件
  • go list -m all 查看项目的所有依赖及其版本,列出项目直接和间接依赖的所有模块
  • go clean -modcache 命令清理缓存,以释放磁盘空间或解决依赖包下载问题

构建

go build

使用 go build 通过GoModule简单构建可执行文件,第一次构建时会自动生成 go.sum 文件,其中有多行记录,每行记录由包名、版本号、哈希值组成,使用空格分开,第一次构建时生成的哈希值需要经过校验

当之后构建项目时,Go 会先从缓存中获取依赖包,然后计算本地依赖包的哈希值,和 go.sum 中的哈希值对比,如果不一致,就会拒绝构建

除了简单构建可执行文件以外,Go通过交叉编译支持跨平台构建,通过参数GOOSGOARCH,可以指定构建的目标平台,例如GOOS=windows GOARCH=amd64 go build

Go支持静态编译,将全部依赖包编译入可执行文件,便于直接部署。使用-ldflags标志来指定静态编译的参数,例如 go build -ldflags "-linkmode external -extldflags '-static'"

打包好的可执行文件可以直接部署

我是一只可爱的留白喵


在进阶知识一章中,我将为大家继续补充其他有用的知识

进阶知识

冷冷的语法在我身上胡乱的拍

分号的隐式使用:分号是可选的,编译器会根据换行符自动插入分号,这使得多行调用产生了歧义,于是以下两条看起来不合理的做法合理了起来

点号的位置限制:多行调用的点号 . 必须在前一行的末尾,而不能单独占据一行,否则会产生歧义

大括号的位置限制:左大括号 { 必须与控制结构声明在同一行,且右大括号 } 必须在下一行的开头,或者与前一行的代码在同一列,否则会产生歧义

虽然写Go的程序员们都说这些语法上的限制和约定是为了确保代码的可读性、一致性和语法的简洁性。

遍历一棵二叉树

非常的简单,非常的美味,下列代码提供了中序遍历,其他遍历规则只需调整最后三行代码的顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
type Node struct {
Value int
Left, Right *Node
}

func inorderTraversal(node *Node, slide *[]int) {
if node == nil {
return
}
inorderTraversal(node.Left, slide)
*slide = append(*slide, node.Value)
inorderTraversal(node.Right, slide)
}

我把代码推到仓库了,你们引一下我的包

GoModule有一个非常棒的特性,就是其支持直接将代码托管平台作为仓库,从这些仓库直接获取依赖的包,所以当我们想要发布一个包时,可以直接使用GitHub发布和分享

使用 github.com/username/projectname 命名包,然后将带有go.mod的源代码推送到username下对应的projectname仓库,此时一个最新版本的包就可以被其他人获取了

其他开发者可以通过在他们的项目中运行go get github.com/username/projectname 获取包,Go工具链会自动下载包及其依赖,并将其放在GOPATH目录下的合适位置,以供其他项目引用

但是只有一个最新版本是不够的,为了不断更新我们必须产生多个版本并迭代开发,一个解决方案是我们可以使用Git的标签(Tags)来管理版本,每发布一个新的版本时,使用Git标签来标记这个版本,go mod会识别每个tags内对应go.mod的版本并获取对应版本的包

利用几种结构实现异步

Go具有精简的并发模型,基于CSP,即通信顺序模型。在CSP模型中,独立运行的并发实体通过通信进行交互,而不是通过共享内存。Go中,独立运行的并发实体就是Goroutine,Goroutine间使用channel进行通信

CSP(Communicating Sequential Processes)理论,由计算机科学家Tony Hoare在1977年的论文中提出。

Goroutine

Goroutine是Go语言并发的基本单位。它是一种轻量级线程,由Go运行时(runtime)管理。与传统的操作系统线程相比,Goroutine的启动和销毁的代价非常低,并且可以高效地运行成千上万个Goroutine

channel

通道是Goroutine之间进行通信和同步的机制。它提供了一种安全的方式来传递数据和同步操作,避免了显式的锁和竞态条件。通过通道,Goroutine可以发送和接收数据,以进行协作和共享数据

select

select语句用于在多个通道操作中进行选择。它允许Goroutine同时等待多个通道操作的就绪状态,并执行相应的操作。select语句是Go语言处理并发多路选择的关键机制之一

sync program tools

  1. Mutex,互斥锁是用于保护共享资源的同步原语。Mutex提供了对临界区的互斥访问,而RWMutex允许多个读操作并发进行,但只允许独占的写操作。Mutex和RWMutex是实现互斥和共享资源保护的常用工具
  2. WaitGroup,用于等待一组Goroutine完成任务。它可以阻塞主Goroutine,直到所有的Goroutine都完成了任务。WaitGroup提供了一种简单的方式来协调和同步多个Goroutine的执行
  3. Atomic,提供了一组原子操作,用于对共享变量进行原子操作。它们可以保证在多个Goroutine并发访问共享变量时的原子性操作,避免了竞态条件

示例

在一个Goroutine向另一个Goroutine发送消息时,另一个Goroutine在等待过程中会阻塞(底层表现是协程被挂起),这代表着我们可以基于这一机制完成全部基于同步和锁的异步并发模型,至此结束,一切又回到了起点。(实际上我们也可以使用互斥锁模型完成这一操作)

1
2
3
4
5
6
7
8
9
10
11
12
13
go func doAsync(done chan struct{}) {
// do something...
done <- struct{}{}
}

func task() {
// do something...
done = make(chan struct{})
go doAsync(done)
// await before done...
<-done
// do something...
}

Go语言的sync.Mutexsync.RWMutex是使用底层的操作系统原语(如互斥量和条件变量)来实现的,提供了临界区保护和简单资源保护的轻量级选择。在选择合适的同步机制时,需要根据具体的需求、场景和性能考虑做出决策

我们也可以使用WaitGroup来等待某些任务完成,在派发多个任务的时候非常有效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"sync"
"time"
)

func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()

fmt.Printf("Worker %d started\n", id)
time.Sleep(time.Second)
fmt.Printf("Worker %d finished\n", id)
}

func main() {
var wg sync.WaitGroup

for i := 1; i <= 5; i++ {
wg.Add(1)
go worker(i, &wg)
}

wg.Wait()
fmt.Println("All workers have finished")
}

除了与其他语言类似的同步编程模型,我们还可以利用锁和 channel 的特殊性质,简单实现基于 topic 的发布订阅模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
type PubSub struct {
subscribers map[string][]chan string
mutex sync.RWMutex
}

func NewPubSub() *PubSub {
return &PubSub{
subscribers: make(map[string][]chan string),
}
}

func (ps *PubSub) Subscribe(topic string) <-chan string {
ps.mutex.Lock()
defer ps.mutex.Unlock()

ch := make(chan string)
ps.subscribers[topic] = append(ps.subscribers[topic], ch)
return ch
}

func (ps *PubSub) Publish(topic string, message string) {
ps.mutex.RLock()
defer ps.mutex.RUnlock()

subscribers, ok := ps.subscribers[topic]
if !ok {
return
}
for _, ch := range subscribers {
go func(c chan string) {
c <- message
}(ch)
}
}

我们利用map存储channel和对应的topic,subscriber可以从对应的channel中获取message

1
2
3
4
5
6
7
8
9
10
11
12
func main() {
pubsub := NewPubSub()
newsCh := pubsub.Subscribe("news")
go func() {
for msg := range newsCh {
fmt.Println("Received news:", msg)
}
}()
pubsub.Publish("news", "Breaking news: Go is awesome!")
time.Sleep(time.Second)
close(newsCh)
}

除了go和channel以外,谈及Go并发设计还有另外一个关键字select也经常出现,select主要用于处理多个通信操作的就绪状态,例如处理多个通信操作、超时控制和操作系统发出的退出程序指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
"fmt"
"time"
)

func main() {
ch1 := make(chan int)
ch2 := make(chan string)

go func() {
time.Sleep(2 * time.Second)
ch1 <- 100
}()

go func() {
time.Sleep(3 * time.Second)
ch2 <- "Hello, World!"
}()

select {
case num := <-ch1:
fmt.Println("Received from ch1:", num)
case msg := <-ch2:
fmt.Println("Received from ch2:", msg)
case <-time.After(4 * time.Second):
fmt.Println("Timeout!")
}
}

我是一只可爱的留白喵