学习路线

教材对应:《Go程序设计语言》第 1 章 入门
环境:Go 1.22.2 / windows-amd64
0. 先建立一个核心认知
Go 是编译型语言,但它自带一个"迷你操作系统"住进你的程序里。
这句话是你后面理解一切"为什么"的钥匙。
- Python 写的
print("hello")需要你机器上装了 Python 解释器才能跑; - Go 写的
fmt.Println("hello")编译完是一个独立的 .exe,丢到任何一台同架构的 Windows 上就能跑,什么都不用装。
误区
a. 错误理解:直接在powershell中运行
b. 正确理解:先在开发机写 .go 源码,用 Go 编译出 .exe;然后把 .exe 放到另一台同架构的 Windows 上直接运行,目标机不用装 Go。开发机仍然需要装 Go 才能编译。
代价是什么?往下看第 4 节
1. Go 是什么:三股血缘(书 p.5--7)
书的前言部分讲了 Go 的"家谱",这不是闲聊,它直接解释了 Go 为什么长这样:
| 血缘 | 继承了什么 | 你现在能看到的地方 |
|---|---|---|
| C 语言 | 表达式语法、控制流、基础类型、指针、传值调用、追求机器码效率 | if / for / int / * 和 & |
| Pascal → Modula-2 → Oberon | 包的概念、导入/声明语法、方法声明语法 | import "fmt"、package main、方法写在类型外面 |
| CSP(Tony Hoare, 1978) | "用通信来共享内存,而不是用共享内存来通信" | goroutine + channel(第 8 章,全书灵魂) |
这张表的意思是:
a. C 语言:给了 Go 表达式、控制流、基础类型、指针、传值、追求效率
b. Pascal/Modula-2/Oberon:给了 Go 包、导入、声明语法、方法写在类型外面、极简模块化。
c. CSP:给了 Go goroutine + channel 的并发哲学
C 给了 Go 身体,Oberon 给了 Go 骨架,CSP 给了 Go 灵魂。
还有两个 Go 自己发明的东西,别的语言抄不走的:
- slice(切片):动态数组 + 底层共享内存,兼顾性能和灵活
- defer 语句:延迟执行,Go 里资源释放的标准姿势
补充:书上这句「用通信来共享内存」是整本书的题眼。现在记住就行,后续拆开讲透。面试被问"Go 并发和其他语言有什么本质区别",答这一句就对了。
2. 环境:建你的第一个 Go 项目
2.1 四个环境变量,别再混淆
# 真实地址隐藏,用x/X代替
GOROOT = D:\xxxxxxxxx Go 自己装在哪(编译器、标准库源码在这)
GOPATH = F:\XXXXXXXXXXX 你的工作区(下载的第三方包缓存在这)
GOMODCACHE = XXXXXXXXX 模块缓存的真正位置
GOPROXY = https://goproxy.cn 下载第三方包走哪个源
把 GOPROXY 改成了 goproxy.cn(原本是 proxy.golang.org,国内会卡)。
配置文件在 C:\peizhiwenjian,你自己改的话用:
bash
go env -w GOPROXY=https://goproxy.cn,direct
a. 配置文件里的值:可以是网址、本地路径、数字、开关
b. Go 的用户级配置文件在这个本地文件里:C:\peizhiwenjian
c. 你自己改,意思是往这个本地文件里写一条配置:GOPROXY=https://goproxy.cn,direct,相当于在本地配置文件里,给 GOPROXY 这个格子填了一个网址
d. GOPROXY 作用:Go 在下载第三方模块时,需要知道去哪里下载。
2.2 go.mod:现代 Go 项目的身份证
bash
# 创建(mkdir )一个名为 go-study 的目录
# 执行成功后,进入这个目录
mkdir go-study && cd go-study
# 把当前目录初始化成一个 Go 模块,模块名是: go-study
go mod init go-study
会生成一个 go.mod:
# go.mod 的内容
module go-study
go 1.22
module go-study
这是模块路径 / 模块名。
它表示:这个模块的导入路径前缀是 go-study。

⚠️ 书与现实的第 1 处代差
书上讲的是 GOPATH 模式:所有代码必须放在
$GOPATH/src/你的项目/下面,否则编译不过。**这套东西在 Go 1.16 之后已经被废弃了。**现在是 Go Modules 时代:项目可以放在磁盘任何位置,只要有
go.mod就是一个独立项目。
3. Hello, World 逐行拆解(书 p.13)
go
package main
import "fmt"
func main() {
fmt.Println("Hello, 世界")
}
四行代码,每一行都不能改,我逐行讲:
第 1 行 package main
package是包声明,必须写在文件第一行(注释除外)。- Go 里没有"类"和"命名空间",包就是唯一的组织单位。
main这个包名是特殊的:它告诉编译器"这玩意儿要编译成可执行文件,不是库"。- 你写成
package myapp,go build会成功但不产生 .exe------它编出来的是个库。
- 你写成
第 2 行 import "fmt"
- 导入标准库的
fmt包(format 的缩写,负责格式化输入输出)。 - **import 了就必须用,不用就编译报错。**这是 Go 的硬性规定,不是警告。
- 为什么这么狠?因为 Go 的设计哲学是"编译快",不用的依赖会被立刻清掉,避免像 C++ 那样 include 一堆头文件导致编译半小时。
- 多个包的标准写法:
go
import (
"fmt"
"os"
)
第 3 行 func main() {
func声明函数;main是入口函数名。main函数必须满足三个条件 :在main包里、名字叫main、无参数无返回值。少一个都编译不过。- 开头的
{必须和func在同一行------原因见第 6 节,这是新手第一大坑。
第 4 行 fmt.Println("Hello, 世界")
fmt是包名,Println是该包导出的函数(大写字母开头 = 对外可见,小写则是包内私有)。- Go 的源文件默认是 UTF-8,所以字符串里直接写中文完全没问题。
- 注意:Go 的字符串不是"一堆字符",而是一段不可变的字节序列 。所以
"Hello, 世界"的字节长度是 13 而不是 9("世界"每个汉字 3 个字节,UTF-8 编码)。- 这个点在第 3 章(p.77)会展开,现在先埋个种子。
没有分号?
Go 语句末尾不写分号。但不是没有,是编译器帮你插了------见第 6 节。
4. 底层:从 hello.go 到 hello.exe 发生了什么
4.1 编译的五个阶段
| 阶段 | 做什么 | 产物 |
|---|---|---|
| 1. 词法分析 | 把源码切成 token(标识符、关键字、字面量) | token 流 |
| 2. 语法分析 | 按语法规则组装成树 | 抽象语法树 AST |
| 3. 类型检查 | 检查类型是否匹配,做逃逸分析 | 带类型的 AST |
| 4. 中间代码 + 优化 | 转成 SSA 形式,做内联、死代码消除、边界检查消除 | 优化后的中间码 |
| 5. 生成机器码 + 链接 | 编译成 amd64 机器码,和 runtime 静态链接 | .exe |
想看第 4 阶段编译器做了哪些优化,可以加参数:
bash
go build -gcflags="-m" hello.go # 打印内联和逃逸分析决策
bash
acer@DESKTOP-SDPT4C3 MINGW64 /f/go/clara-pra
$ go build -gcflags="-m" hello.go
# command-line-arguments
./hello.go:5:6: can inline main
./hello.go:6:13: inlining call to fmt.Println
./hello.go:6:13: ... argument does not escape
./hello.go:6:14: "Hello, World!" escapes to heap
4.2 实测:为什么打印一行字要 1.9 MB?
| 构建方式 | 文件大小 |
|---|---|
默认 go build |
1,988,608 字节(1.90 MB) |
去掉符号表和调试信息 -ldflags="-s -w" |
1,332,224 字节(1.27 MB) |
再关闭内联 -gcflags="-l" |
1,332,736 字节 |
1 MB = 1024 KB
1942 KB ÷ 1024 ≈ 1.8965 MB ≈ 1.90 MB
go build -ldflags="-s -w" -o hello.exe hello.go
go build -ldflags="-s -w" -gcflags="-l" -o hello.exe hello.go
结论拆开看:
- 符号表 + DWARF 调试信息占了 656 KB(33%) 。这部分只是给调试器用的,生产环境用
-s -w砍掉,体积立减三分之一。这是 Go 服务发布的标准操作。 - 剩下的 1.27 MB 几乎全是 Go runtime。你的代码只有几行,但 runtime 被静态链接进来了。
4.3 用符号表证明这件事
-ldflags="-s -w"
由于之前执行上述命令,去掉了符号表的版本,所以结果是这样的
重新编一个默认版本go build -o hello_default.exe hello.go
bash
go tool nm hello.exe | wc -l # 总共 2063 个符号

按包统计符号数量,排前几名的是:
| 包 | 符号数 | 说明 |
|---|---|---|
| runtime. | 1413 | 调度器、GC、内存分配器、栈管理 |
| reflect. | 86 | 反射(因为 fmt 用了反射) |
| syscall. | 81 | 系统调用封装 |
| sync. | 47 | 锁和原子操作 |
| fmt. | 44 | 你真正 import 的那个包 |
| main. | 1 | 你写的代码 |
你写的全部代码,在这个 1.9 MB 的 exe 里只占 1 个符号。
那 1413 个 runtime 符号里包括:
T runtime.mallocgc 内存分配器(你每次 make/map/切片扩容 都走它)
T runtime.gcStart 垃圾回收的启动入口
T runtime.newproc 你写 go func(){} 时真正调用的东西
T runtime.schedule goroutine 调度器的主循环
这就是 Go 的取舍 :用体积换"零依赖部署 + 内置并发 + 自动 GC"。
一个 Go 的 Docker 镜像可以做到只有几 MB,因为里面除了这个静态二进制什么都不用装。
5. 底层:执行go tool nm hello.exe之后,第一个执行的代码不是你的 main
在符号表里能看到两个"main":
437100 T runtime.main ← 真正的入口
489520 T main.main ← 你写的
程序启动的真实顺序是:
操作系统加载 PE 文件
↓
_rt0_amd64_windows (汇编,设置栈)
↓
runtime·rt0_go (初始化 m0/g0,即主线程和第一个 goroutine 的栈)
↓
runtime·schedinit (初始化调度器、内存分配器、GC、创建 P)
↓
runtime·newproc (创建一个 goroutine 去跑 runtime.main)
↓
runtime·mstart (启动调度循环,让出 CPU)
↓
runtime.main (Go 层面的入口)
├─ 启动 GC 后台清扫 goroutine
├─ 执行所有包的 init() 函数
└─ **调用 main.main** ← 你的代码在这里才第一次被执行
↓
main.main 返回 → runtime.exit(0)
关键认知 :你的 main.main 是跑在一个 goroutine 里的。Go 程序从启动那一刻起就是并发的,哪怕你一行 go 都没写。
这点书上完全没讲,但理解了它,第 8、9 章的很多东西会突然变得自然。
6. Go 的隐藏规则:自动分号插入(新手第一大坑)
规则 :Go 编译器在扫描时,如果一行的末尾是下面这些东西之一,就自动插入一个分号:
标识符(变量名、函数名)、字面量(数字、字符串)
break continue fallthrough return
++ -- ) ] }
所以 func main() 这一行末尾是 ),编译器实际看到的是 func main();。
推论:{ 绝对不能另起一行。
我实测了一下,把大括号换行:
go
func main()
{
fmt.Println("brace on new line")
}
编译器报:
demo\bad_brace.go:6:1: syntax error: unexpected semicolon or newline before {
因为编译器看到的是:
go
func main(); // ← 分号被自动插在这里,函数体没了
{ // ← 这个 { 成了孤魂野鬼
这个坑影响所有带大括号的语句 :if、for、switch、func 全都要遵守。
好消息是 gofmt 会自动帮你修正,IDE 保存时自动格式化就永远不会遇到。
7. go run / go build / go install 到底差在哪
| 命令 | 做什么 | 产物 | 什么时候用 |
|---|---|---|---|
go run hello.go |
编译到临时目录再立刻执行,跑完删掉 | 无(看不到文件) | 写两行代码快速验证 |
go build |
在当前目录生成可执行文件 | hello.exe |
本地调试、要拿产物 |
go install |
编译并放到 $GOPATH/bin |
$GOPATH/bin/hello.exe |
想把工具装成全局命令 |
注意 :go run 每次都要重新编译,所以启动比 go build 出来的 exe 慢。感觉"go run 好慢"是正常的。
8. gofmt:Go 不讨论代码风格
Go 官方直接把代码格式统一了,gofmt 是标准:
bash
gofmt -l . # 列出格式不合规的文件
gofmt -w . # 直接改写成规范格式
特点:
- 用 tab 缩进,不是空格。Go 官方就是这么定的。
- 没有"要不要换行""空格几个"这种争论------
gofmt说了算。 - 这是 Go 工程文化的一部分:所有 Go 项目代码看起来都像一个人写的。
配好 IDE 的 "Format on Save",这个习惯从第一课就养成。
8.1 vscode中配Format on Save


配好后,你写:
go
package main
import "fmt"
func main(){
fmt.Println("hello")
}
按 Ctrl+S,自动变成:
go
package main
import "fmt"
func main() {
fmt.Println("hello")
}
9. 本课小结
| 问题 | 答案 |
|---|---|
package main 为什么特殊 |
它决定编译出可执行文件而不是库 |
| import 了不用会怎样 | 编译报错(不是警告) |
| 大写字母开头的标识符意味着什么 | 对外可见(exported) |
| 为什么 hello world 有 1.9 MB | 静态链接了 Go runtime(1413 个符号) |
| 发布时怎么瘦身 | go build -ldflags="-s -w",减 33% |
{ 能不能换行 |
不能,自动分号插入会导致语法错误 |
| 你的 main 是程序第一个跑的代码吗 | 不是,是 runtime.main 调用它的 |
10. 练习题
要求:先自己写,写完再对答案。卡住超过 15 分钟就看提示,别死磕。
练习 1.1(基础)
写一个程序,输出三行:你的名字、你公司的名字、一句你想说的话。
要求:用且只用一个 fmt.Println 调用完成。
提示:查一下
Println接受几个参数。
练习 1.2(体积验证)
在你自己机器上重现第 4.2 节的实验:
bash
go build -o a.exe hello.go
go build -ldflags="-s -w" -o b.exe hello.go
对比两者大小,算出符号表占了多少百分比。
然后思考:如果你的服务有 100 个 Go 微服务要发布,砍掉这 33% 意味着什么?
练习 1.3(符号表探查)
运行 go tool nm a.exe,回答:
- 你的程序里有
runtime.gcStart这个符号吗?它说明什么? - 找找有没有
net/http相关的符号?为什么?(提示:你 import 了吗) - 把
import "fmt"换成import "os"然后用os.Exit(0)(不输出任何东西),重新编译看看体积变化。
练习 1.4(自动分号,必做)
故意写一段大括号换行的错误代码,亲眼看一下那个报错。
然后想一想:下面这段代码能编译过吗?为什么?
go
x := 1 +
2
提示:第一行末尾是
+,不在自动插入分号的列表里。
练习 1.5(思考题,不难但要动脑)
Go 的 import 不用就报错。你觉得这个设计:
- 解决了什么问题?
- 带来了什么麻烦?(提示:想想调试时临时注释掉一段代码)
练习 1.6(进阶 · 为下节课铺垫)
fmt.Println("Hello, 世界") 输出的这个字符串,它的字节长度 和字符个数分别是多少?
先自己猜,然后写程序验证:
go
s := "Hello, 世界"
fmt.Println(len(s)) // 这个是字节长度
怎么拿到"字符个数"?自己查一下 utf8 包或者 range 遍历。
这题答不出来没关系,它是第 3 章(p.77)的核心内容,下下节课我会讲。
下一课:第 1 章剩下的部分------命令行参数(p.16)、查找重复的行(p.20),以及 Go 里最重要的两个数据结构:slice 和 map。






