项目

现代go项目,使用go mod init <project_module_name> 初始化,类似于npm init,go.mod就等同于package.json。

关于<project_module_name>

这个name是项目名称,如果你要对外发布,那必须是一个可以下载的地址,比如xxx.com/xxx/xxx

NOTE

这个name在import时,本质就是表示项目根目录

模块

类似java的package模式

但是不同于java的package必须和文件系统完全一致,go的package可以不同于父目录, 虽然并不建议这么做

比如:

package com.example.demo;
 
public class Demo {
    ...
}

那目录结构就必须是src/com/example/demo,demo目录下的所有文件的开头必须都是package com.example.demo,这些文件里的Class都属于这个package。

然后可以使用import com.example.demo.Demo来引用这个类。

而go则不同:

// 假设目录为example/demo
package demo
 
func main() {
    ...
}

那么就可以使用import "<project_module_name>/example/demo"来引用这个目录下的包

注意这里的import是目录结构,不是包结构,引用过来的是demo目录下的包,一句话解释:”导入了这个目录下的包“

调用时,必须是<package_name>.<function_name>,这和java不同。

Import

ImportSpec = [ "." | PackageName ] ImportPath .
  • ”.”表示不用包名,直接用原始名称,此时被导入包的package block中被导出的定义都会在导入的file block中定义
  • PackageName包使用的本地标识符(别名)
  • _作为PackageName,一般是仅仅想使用导入包的side-effects,也就是init方法

包的初始化

核心:

  1. 先声明先初始化
  2. 被依赖的先初始化(依赖关系取决于词法分析)
  3. 变量不允许循环依赖
  4. 同时声明的,同时初始化
  5. 多源文件时,传给编译器的顺序(通常按文件名排序)决定变量声明顺序
  6. 包的所有变量都初始化完了,再按文件顺序执行init函数

RUNTIME PATH

和其他语言一样,模块的寻找都是通过RUNTIMEPATH完成,go使用GOPATH,默认值 ~/go(奇怪的go而不是.go设计,一般不建议改),而且不同于python,nodejs,在项目中使用.venv,node_modules管理项目级依赖,go的依赖源文件全部统一下载在GOPATH,项目通过go.mod持有元信息,有点类似pnpm的管理模式。

import "example.com/hello"时,会先去$GOPATH/pkg/mod中寻找,没有则通过模块名直接下载(所以模块名要标识地址)到GOPATH.

如果要求本地解析,需要进行目录映射:

# 将远程路径重定向到本地相对路径
go mod edit -replace example.com/hello=../hello

程序初始化

核心特点

静态初始化:所有包在 main.main 执行前一次性完成初始化,不存在延迟加载。

执行流程

启动流程

flowchart TD
    A["runtime.main() 启动"] --> B["doInit(runtime_inittasks)"]
    B --> B1["初始化 runtime 包<br/>隐式根依赖"]
    B1 --> C["doInit(m.inittasks)"]
    C --> C1["按链接器预计算顺序<br/>初始化所有用户包"]
    C1 --> D["main.main()<br/>用户代码开始执行"]

    style A fill:#f9f
    style D fill:#9f9
    style B fill:#bbf
    style C fill:#bbf

两类不同的初始化

类型初始化方式说明
隐式根runtime显式调用 doInit(runtime_inittasks)由 Go 运行时自身初始化,无需 import
用户依赖树main, fmt, 等遍历 m.inittasks 列表从 main 包递归收集,按依赖拓扑排序

关键区别

  • runtime 包:不是通过 import 引入的,它是 Go 程序的基础运行时,在程序启动时由汇编代码设置好环境后,由 runtime.main() 显式初始化
  • 用户包:从你的 main 包开始,根据 import 语句递归构建依赖树,链接器计算拓扑顺序

用户包初始化底层实现(完整四阶段)

Go 语言规范 (Program Initialization) 定义:

Package initialization—variable initialization and the invocation of init functions—happens in a single goroutine, sequentially, one package at a time. A package is initialized only once, even if imported from multiple packages. Given the list of all packages, sorted by import path, in each step the first uninitialized package in the list for which all imported packages (if any) are already initialized is initialized. Initialization proceeds until all packages are initialized.

该规范描述的动态选择算法在实现上被优化为预计算顺序+直接遍历,分为四个阶段:

flowchart TB
    subgraph Load["阶段1: 包加载"]
        L1["从 main 包开始"] --> L2["DFS 后序遍历依赖<br/>cmd/go/internal/load"]
    end

    subgraph Compile["阶段2: 编译"]
        C1["解析 AST"] --> C2["生成 init 函数<br/>cmd/compile/internal/pkginit"]
        C2 --> C3["生成 pkg..inittask<br/>含 R_INITORDER 重定位"]
    end

    subgraph Link["阶段3: 链接"]
        K1["收集所有 inittask"] --> K2["拓扑排序+字典序<br/>cmd/link/internal/ld"]
        K2 --> K3["输出 go:main.inittasks"]
    end

    subgraph Run["阶段4: 运行"]
        R1["遍历 []*initTask"] --> R2["执行 init 函数<br/>runtime/proc.go"]
    end

    Load --> Compile --> Link --> Run

阶段说明

  • 包加载go build 使用 DFS 后序遍历构建依赖图,确定编译顺序
  • 编译:每个包独立编译,生成 pkg..inittask(含 statenfns、函数指针数组)和 R_INITORDER 重定位信息
  • 链接:沿 R_INITORDER 边收集所有 inittask,优先队列按字典序拓扑排序,输出有序数组
  • 运行runtime.main() 直接遍历预计算数组,state 字段仅作防御性检查(plugin 场景有效)

编译链接过程

  1. 编译阶段:每个包生成 pkg..inittask 符号,包含 init 函数列表和依赖关系(R_INITORDER)
  2. 链接阶段:拓扑排序所有 inittask,生成 go:main.inittasks 列表
  3. 运行阶段:直接遍历预计算列表执行,无决策开销

关键数据结构

initTask 定义在 runtime/proc.go(搜索 “type initTask”):

// runtime/proc.go
type initTask struct {
    state uint32  // 0=未初始化, 1=进行中, 2=完成
    nfns  uint32  // init 函数数量
    // 后跟 nfns 个函数指针
}

state 字段的作用

状态值含义用途
0未初始化初始状态,等待初始化
1进行中防御性检查,检测循环依赖/重复初始化
2已完成标记完成,后续重复调用直接返回

使用场景

  • 静态程序:链接器已保证顺序,state 主要作为防御性检查
  • Plugin 动态加载:检测重复加载和循环依赖的关键机制

工作原理(doInit1 函数):

switch t.state {
case 2: return                    // 已完成,跳过
case 1: panic("循环依赖")        // 正在进行中,说明有循环
default: t.state = 1; /* 初始化 */ t.state = 2  // 开始→完成
}

import 语句的本质

  • Go:编译期声明,运行时不执行
  • Python/Lua:运行期语句,执行时才初始化
import "pkg"  // 只是告诉编译器依赖关系,运行时无动作

特殊包说明

包名类型说明
runtime隐式根包无需 import,程序启动时自动存在,由运行时显式初始化
unsafe编译器内置包需要 import,但函数由编译器直接处理(编译期内联),不生成实际函数调用,因此无 inittask

注意

  • runtime 不通过 import 参与依赖树,是独立初始化的隐式根

  • unsafe 虽然是真实存在的包,但它的函数(Sizeof, Offsetof, Alignof, Pointer 等)是编译器内置函数(intrinsics)

    // 源码中写的
    unsafe.Sizeof(x)
     
    // 编译器直接内联处理,不生成函数调用
    // 比如 int 的大小就是常量 8,直接替换

    因此编译后的程序不会调用 unsafe 包的任何函数,也就不需要初始化 unsafe 包

    查看汇编验证:go build -gcflags="-S" 可以看到没有 unsafe.Sizeof 的 CALL 指令。

init 函数执行特性

关键约束

约束说明
单 goroutine 顺序执行整个初始化过程在一个 goroutine 中依次完成
变量先于 init 函数包内所有变量先初始化(按依赖顺序),再执行 init 函数
多 init 按文件顺序一个包多个 init 函数时,按源文件名的字典序执行
init 内 goroutine 不等待init 可以启动 goroutine,但主流程不等待其完成
阻塞执行必须等当前 init 返回后,才执行下一个 init

示例:init 函数中启动 goroutine

func init() {
    go func() {
        time.Sleep(100 * time.Millisecond)
        println("goroutine done")
    }()
    println("init returns immediately")  // 先打印
}

循环依赖检测

循环导入在编译阶段就会被检测并报错,而不是链接阶段:

package test-cycle
    imports test-cycle/cycle_a from main.go
    imports test-cycle/cycle_b from cycle_a.go
    imports test-cycle/cycle_a from cycle_b.go: import cycle not allowed

编译错误:import cycle not allowed

包初始化失败处理

如果某个包的 init 函数 panic,程序会立即崩溃,没有重试机制

func init() {
    panic("init failed")  // 程序直接崩溃
}

验证方法

GODEBUG=inittrace=1 go run main.go

Plugin 动态加载

plugin 是 Go 的受限动态加载机制,类似 Python/Lua 但更严格。

平台限制

Plugin 仅支持以下平台

  • Linux(需 cgo)
  • macOS(需 cgo)
  • FreeBSD(需 cgo)

Windows 不支持 plugin 机制。

版本要求

主程序和 plugin 必须使用完全相同的 Go 版本和依赖版本编译,否则会报错:

plugin was built with a different version of package xxx

加载流程

flowchart TD
    A["plugin.Open(myplugin.so)"] --> B{"plugins[filepath]<br/>已存在?"}
    B -->|是| C["返回已加载 plugin"]
    B -->|否| D["dlopen() 加载 .so"]
    D --> E["lastmoduleinit()<br/>获取 moduledata"]
    E --> F{"检查版本<br/>typemap/pluginpath/pkghash"}
    F -->|失败| G["返回错误"]
    F -->|通过| H["doInit(initTasks)"]
    H --> H1{"state == 2?"}
    H1 -->|是| H2["跳过(已初始化)"]
    H1 -->|否| H3["state = 1 → 执行 init → state = 2"]
    H2 --> I["close(p.loaded)<br/>标记完成"]
    H3 --> I
    I --> J["返回 plugin"]
    G --> K["plugins[filepath] = error<br/>记录失败"]
    
    style A fill:#f9f
    style J fill:#9f9
    style G fill:#faa

保证一次性加载的机制

检查点位置作用
filepathplugins map同路径不重复加载
typemapmoduledata同模块不重复初始化
pluginpathactiveModules同包名不重复
pkghash版本校验主程序与 plugin 版本必须一致

共享已初始化包

主程序和 plugin 引用同一个 initTask 地址

主程序                      Plugin
   │                          │
   │  pkgc..inittask          │ 引用同一地址
   │  @0x579560               │
   │  state = 2               │
   │                          │ doInit(pkgc..inittask)
   │                          │ → 发现 state=2,跳过

initTask 不是专为 plugin 设计

initTask 是 Go 基础链接模型的产物:

  • 每个包编译时都生成 pkg..inittask 符号
  • 所有符号(函数、变量、initTask)都是全局的
  • plugin 只是”搭便车”受益于这个设计
# 最简单的程序也有 initTask
go tool nm simple | grep "inittask"
  errors..inittask      @0x578560
  math..inittask        @0x578580
  runtime..inittask     @0x578900
  ...

命名风格

  1. 驼峰
  2. 首字母大写 public,小写 private
  3. 尽可能短(反面例子java)
  4. 包命名,全小写,单数
  5. 测试文件,_test.go结尾
  6. 接口,方法名 + er

最后,建议上linter,比如golangci-lint

基本概念

字符集

源码使用UTF-8

数字和字母:

letter        = unicode_letter | "_" .
decimal_digit = "0" … "9" .
binary_digit  = "0" | "1" .
octal_digit   = "0" … "7" .
hex_digit     = "0" … "9" | "A" … "F" | "a" … "f" .

注释

同C

  • //
  • /* */

字面量

字面量都是无类型的,可以把他们理解为单纯的符号性质,比如10,3.14,它在使用时(分配给变量)才有类型,比如:

var a int64 = 10 // 编译时转换为int64格式
var f float32 = 3.14 // 编译时转换为float32格式
字面量示例种类默认类型
10无类型整数int
3.14无类型浮点数float64
true / false无类型布尔bool
'a'无类型 runerune
"hello"无类型字符串string
nil无类型 nil

整数

除了常规的0b,0x,0o,支持在前缀和连续的数字中间使用_,增加可阅读性

浮点数

可以用16进制表达,p表示幂, _的使用同整数。

0.
72.40
072.40       // == 72.40
2.71828
1.e+0
6.67428e-11
1E6
.25
.12345E+5
1_5.         // == 15.0
0.15e+0_2    // == 15.0

0x1p-2       // == 0.25
0x2.p10      // == 2048.0
0x1.Fp+0     // == 1.9375

虚字面

虚数的概念

虚数是实数概念的延伸,用于解决实数范围内无法求解的问题(如 x² = -1)。 引入一个虚数单位 i,满足: i² = -1 即 i 是 -1 的平方根,记作 i = √-1 在几何意义上i 代表逆时针旋转 90°,乘以 i 就是将数旋转到虚轴 直观理解

  • 实数:数轴上的点(一维)
  • 虚数:与实数轴垂直的另一条轴(y 轴)
  • 复数:平面上的点(二维),坐标 (a, b) 对应 a + bi

举个例子

3 + 4i // 实部3,虚部4,对应复平面上的点(3,4) (3+4i)*i // = -4 + 3i,相当于将点(3,4)逆时针旋转90°到(-4,3),注意这里不是点的旋转,而是矩阵的旋转,所以实数的3被旋转到了上虚轴为3i,虚轴的4,旋转到了左实轴,变成了-4

用法:

0123i         // == 123i for backward-compatibility
0o123i        // == 0o123 * 1i == 83i
0xabci        // == 0xabc * 1i == 2748i
0.i
2.71828i

字符

有多种表示方法:

  • 单引号,‘a’,‘中’
  • \x 后跟两个十六进制数字; 
  • \u 后跟四个十六进制数字; 
  • \U 后跟八个十六进制数字;
  • 纯反斜杠 \ 后跟三个八进制数字。 虽然是3个8进制数字,但是实际表示范围为\000-\377,也即是0-255,1个字节表示

这些本质都是码点的不同表示,go使用rune类型表示字符,它是int32的别名。

Tip

字符是单独的类型,它不是存储为utf-8字符了。

字符串

  • raw字符串,用``包裹,不转义,但是忽略换行符。 比如\n \n 等价于\n\n

  • 解释型字符串(普通字符串),会自动转义,比如\n就是换行符,而不是\n2个字符 另外,它也接受和字符一样的表示形式,比如\x,\u等等,语义上同样表示码点,不同的是编译时会转换为UTF-8字符形式。

常数

const,比如true,false,不可修改。

可以有类型,也可以无类型。

无类型的在使用时会隐士的转换类型。

const Untyped = 123       // 无类型,可根据上下文自动转换
const Typed int64 = 123   // 有类型 int64,只能用于 int64 上下文

由于不可变特性,所以编译期间就确定了,有点类似C的宏,在使用的地方进行了替换,并使用替换处的上下文类型进行表示。

变量

var message string
message = "hello world"
var x interface{}  // x is nil and has static type interface{}
var v *T           // v has value nil, static type *T
x = 42             // x has value 42 and dynamic type int
x = v              // x has value (*T)(nil) and dynamic type *T

Note

go是静态类型语言,但是接口有一定的动态类型特征,比如interface{}等价于any,但是它依然是静态类型,运行时会有符合接口的实际类型以及对应的值。

简洁写法:

message := "hello world"  // 声明并初始化,类型自动推断

Tip

初始化后会自动赋空值(对应类型的0值),比如int 0, bool false, string "", 复杂类型对应nil C,C++这种不会赋空值,根据分配的地址,可能有残留的值,所以一般要求必须初始化。

类型

Type     = TypeName [ TypeArgs ] | TypeLit | "(" Type ")" .
TypeName = identifier | QualifiedIdent .
TypeArgs = "[" TypeList [ "," ] "]" .
TypeList = Type { "," Type } .
TypeLit  = ArrayType | StructType | PointerType | FunctionType | InterfaceType |
           SliceType | MapType | ChannelType .           

 Composite types—array, struct, pointer, function, interface, slice, map, and channel types—may be constructed using type literals. 即:通过类型字面量构造/定义的类型.

类型分类

类型 ├── 命名类型(named type) │ ├── 预声明类型 │ ├── 定义类型 │ └── 类型参数 └── 类型字面量

预声明类型: 内置类型 定义类型: 使用type定义的新类型 类型参数: 泛型

bool

true ,false, 这2个值是预定义的常量

数值类型

uint8       the set of all unsigned  8-bit integers (0 to 255)
uint16      the set of all unsigned 16-bit integers (0 to 65535)
uint32      the set of all unsigned 32-bit integers (0 to 4294967295)
uint64      the set of all unsigned 64-bit integers (0 to 18446744073709551615)
 
int8        the set of all signed  8-bit integers (-128 to 127)
int16       the set of all signed 16-bit integers (-32768 to 32767)
int32       the set of all signed 32-bit integers (-2147483648 to 2147483647)
int64       the set of all signed 64-bit integers (-9223372036854775808 to 9223372036854775807)
 
float32     the set of all IEEE 754 32-bit floating-point numbers
float64     the set of all IEEE 754 64-bit floating-point numbers
 
complex64   the set of all complex numbers with float32 real and imaginary parts
complex128  the set of all complex numbers with float64 real and imaginary parts
 
byte        alias for uint8
rune        alias for int32

简洁直观。

和C一样,还有些类型和具体的实现有关:

uint     either 32 or 64 bits
int      same size as uint
uintptr  an unsigned integer large enough to store the uninterpreted bits of a pointer value

complex

有2种类型: complex64, complex128

除了可以使用字面量,也可以通过2个float构建复数。

complex(realPart, imaginaryPart floatT) complexT // create
real(complexT) floatT // get real
imag(complexT) floatT // get imaginary part

构建和解构类型对应关系: complex64 float32 complex128 float64

如果构建的2个参数都是无类型的,那么返回的复数也是无类型的,意味着会使用相关上下文决定的类型,或默认的complex128 realimag同样,如果参数无类型,返回的float也是无类型的。

字符串类型

string

字符串不可变。

注意:len返回的是字节数,不是字符数!

数组

和C的数组基本相同,同样的初始化后不能修改长度。同样的如果直接在栈上分配一个大数组,可能栈溢出,当然go好一些,它的栈大小不是固定的,一般最大可以到1G。

len(<array>)计算长度。

[32]byte
[2*N] struct { x, y int32 }
[1000]*float64
[3][5]int
[2][2][2]float64  // same as [2]([2]([2]float64))

注意: 纯数组/结构体的无限套娃不允许,但只要有指针、切片、map、接口等其他类型作为”中间层”,递归定义就合法。

下列不合法:

// 1. 直接包含自身
type T [3]T          // 错误:数组包含自己
 
// 2. 结构体包含自身
type S struct {
    x S             // 错误:结构体包含自己
}
 
// 3. 间接递归(只有数组/结构体)
type A [3]struct {
    x A             // 错误:数组→结构体→数组,只有数组和结构体
}

下列合法:

// 1. 指针介入
type Node struct {
    next *Node      // 允许:指针不是数组/结构体
}
 
// 2. 切片介入
type Tree struct {
    children []Tree // 允许:切片不是数组/结构体
}
 
// 3. 接口介入
type List struct {
    head interface{ Next() *List }  // 允许
}
 
// 4. Map 介入
type Graph struct {
    edges map[string]*Graph  // 允许
}

slice类型

切片是对数组的视图,并且可以增加,缩小长度,底层会自动管理原始数组。

一个数组,可以有多个slice,他们共享这个数组的存储空间。

一个slice的容量,表示从切片的开始到数组的末尾的长度,用cap(<slice>)计算,len(<slice>)计算的是长度。

创建slice:make([]T, length, [capacity]),可选容量,为底层数组的长度。使用make创造的切片,总是会分配一个新的、隐藏的数组。所以下面的行为等价:

make([]int, 50, 100) // 创建隐藏数组,长度为100,然后进行[0:50]切片
new([100]int)[0:50] // 创建新的长度为100的数组,然后[0:50]切片

数组和切片都可以多维,但是内部切片的长度并不一定完全一样,因为切片的长度可以是动态的。

struct

类似c中的struct,但是并不完全一样,比如这里可以有函数。

语法规范:

StructType    = "struct" "{" { FieldDecl ";" } "}" .
FieldDecl     = (IdentifierList Type | EmbeddedField) [ Tag ] .
EmbeddedField = [ "*" ] TypeName [ TypeArgs ] .
Tag           = string_lit .

例子

// An empty struct.
struct {}
 
// A struct with 6 fields.
struct {
	x, y int
	u float32
	_ float32  // padding
	A *[]int
	F func()
}

根据规范可知, field名称并不是必须的,比如EmbeddedField,这种”内嵌”字段,它的TypeName不能是指针和类型参数,此时它的字段名就是类型名。

// A struct with four embedded fields of types T1, *T2, P.T3 and *P.T4
struct {
	T1        // field name is T1
	*T2       // field name is T2
	P.T3      // field name is T3
	*P.T4     // field name is T4
	x, y int  // field names are x and y
}

内嵌字段的方法”提升“,比如:

type Inner struct {}
func (i Inner) Method() {}
 
type Outer struct {
    Inner       // 嵌入字段(匿名)
}
 
var o Outer
o.Method()   // 合法!Inner.Method 被"提升"到 Outer

tag

就是一个纯字符串,但是可以有约定的格式,比如json,可以提供一些元信息,主要用于:

  • 序列化
  • 反序列化
  • 反射

Tip

go的反射类似java的反射,可以运行时获取类型信息,修改字段值,调用方法等等

方法接收者

go中可以给任意方法定义方法接收者,即method recevier,本质这个接收者就是调用者,常见的有2种形式:

func (t T)f1(){
	...
	t.xx
	...
}
func (t *T)f1(){
	...
	t.xx
	...
}

此时T类型的对象可以调用这2个方法,一个是传值,一个是传指针,函数体中.应用时,会自动解引用。

Tip

  • 方法接收者并不是一定是struct,本包内的对象都可以
  • 概念接近this, self
  • 实际上, 方法在编译时会转换为普通函数,即T.f1(t T)(*T).f1(t *T)这种形式(这里的(*T).f1*T不是解引用,它表示类型的方法)

方法集

methodSets

一个对象拥有的方法,这个和调用不同,不拥有也可以调用。

比如上面的方法接收者:

type I interface {
	f1()
	f2()
}
 
var t T
var _ I = t // 编译错误,t作为T类型,它的方法集中只有f1,f2接收的是*T,但是调用f2是没有问题的
var pt *T
var _ I = pt // 正确,pt作为*T指针,可以解引用获取具体的对象

另外一种更复杂的方法”提升”情况:

package main
 
import "fmt"
 
type T struct {
	name string
}
 
func (t T) f1() {
	fmt.Println(t.name + ": f1")
}
 
func (t *T) f2() {
	fmt.Println(t.name + ": f2")
}
 
type I interface {
	f1()
	f2()
}
 
type (
	S1 struct{ T }
	S2 struct{ *T }
)
 
func main() {
	t := T{name: "t"}
	var _ I = t // 报错:T 没有 f2
	t.f1()
	t.f2() // 可以运行,虽然方法集中没有
 
	pt := &T{name: "pt"}
	var _ I = pt // 成功:*T 有 f1 和 f2
	pt.f1()
	pt.f2()
 
	s1 := S1{T{name: "s1"}}
	var _ I = s1 // 报错:T 没有 f2
	s1.f1()
	s1.f2() // 可以运行,虽然没有再方法集中
 
	s2 := S2{&T{name: "s2"}}
	var _ I = s2 // 成功:S2 有 f1 和 f2
	s2.f1()
	s2.f2()
 
	ps1 := &s1
	var _ I = ps1 // 成功:ps1 有 f1 和 f2
 
	ps12 := &s2
	var _ I = ps12 // 成功:ps12 有 f1 和 f2
}

总的来说:

  • T类型的方法集只有接收类型为T的方法
  • *T 类型的方法集中有接收类型为T*T的方法
  • S{T}类型的方法集中,提升的方法,也只包括接受类型T
  • *S{T}类型的方法集中,提升的方法,就包括接受类型T*T
  • [*]S{*T}*T一样,都包括

Note

至于为什么这么设计: 猜测是因为指针类型意味着对原对象可以修改,所以可以包括接收类型为指针的方法,这个逻辑适用于方法提升,因为获取对象的地址意味着可以获取里面结构的地址。

指针

同C的指针, 引用类型的实现 但是要注意,在go中引用类型在操作时总是自动解引用,而指针不一定

*Point
*[4]int

函数

关于返回值:

和lua一样,可以有多个返回值,最常见的是和error一起,比如(string, error)(类似元组的概念)

甚至可以具名返回值:(result int),本质和形参一样的,先进行了声明(以及默认的初始化),此时return可以裸写,当然如果你还是写了return <value>,本质上就是用这个value给具名的返回赋了一次值。

定义类型时可以省略<name>

接口

interface表达,可以包括方法,或类型元素。用来定义和描述类型。

总是可以被归纳为方法集和类型集。

Basic接口

表达了行为

方法集为直接定义的方法并集。 类型集为实现任一方法集中方法的类型交集。

// A simple File interface.
type File interface {
	Read([]byte) (int, error)
	Write([]byte) (int, error)
	Close() error
}

完全由方法签名组成,类似java的Interface

但是go中没有所谓的implements显示过程,一个类型有这些方法,只要签名一致那就自动实现了这个接口, 所谓”鸭子”类型。 至于怎么定义”有”,go只有一种形式: 方法接收者

Note

通过方法接收者的形式定义,是非常松散和解耦的,好处坏处都很明显

空接口,别名any

interface {}

它只有空方法,所有类型都可以看作接收了一个空方法,即实现了这个接口,除了非空接口,它不能看作实现了空接口。

type S1 struct{}         // 具体类型
func (s S1) Read([]byte) (int, error) { return 0, nil }
var e interface{} = S1{} // S1实现了空接口
type Reader interface {
    Read([]byte) (int, error)
}
var r Reader = S1{}
var e3 interface{} = r

Embedded接口

接口嵌套:

type Reader interface {
    Read(p []byte) (n int, err error)
}
 
type Writer interface {
    Write(p []byte) (n int, err error)
}
 
// ReadWriter 嵌入 Reader 和 Writer
type ReadWriter interface {
    Reader      // 嵌入接口
    Writer      // 嵌入接口
    Close()     // 显式声明的方法
}

方法集: 嵌套接口声明的方法 + 直接声明的方法。(这里假设嵌套的都是basic接口) 简单说就是嵌套接口的扁平化展开了。

类型集:

接口类型集描述
Reader所有实现了 Read 方法的类型
Writer所有实现了 Write 方法的类型
ReadWriter
必须同时满足:实现了 Read + 实现了 Write + 实现了 Close

注意: 不直接或间接的嵌套自己。

Note

注意这里说的是类型集合,不是方法集合,对于类型集合来说要同时满足所有实现,则是取类型交集,而对于方法集合来说是并集,需要所有都被实现。

General接口

接口中有类型元素(也就是不全是方法元素)都称为通用接口, 通用接口只能用于泛型约束,不能用于变量的类型声明。 通用接口的含义为: 同时满足类型约束和行为约束。

注意:Basic嵌套General后,会自动变成General

Tip

嵌套类型是basic还是general取决于是否包括类型元素。

官方规范给了非常重要的总结:

  • 空接口的类型集是所有非接口类型的集合。
  • 非空接口的类型集是接口元素类型集的交集。
  • 方法规范的类型集是其方法集包含该方法的所有非接口类型的集合。
  • 非接口类型术语的类型集是由该类型组成的集合。
  • 形式为 ~T 的术语的类型集是其底层类型为 T 的所有类型的集合。
  • 术语联合的类型集 t1|t2|…|tn 是术语类型集的联合。

其中: ~T,表示底层类型是T的,类型家族,比如:

type MyInt int        // MyInt 的底层类型是 int
type YourInt int      // YourInt 的底层类型也是 int
 
type Integer interface {
    ~int              // 匹配 int, MyInt, YourInt 等
}

注意: ~int并不包括int16,int32之类的。

接口中同时包括类型描述和方法签名:

// 表示同时满足类型和方法的约束,即该类型是int或float,且实现了Do方法
type Foo interface {
	int | float
	Do() string
}

此时这个接口的

  • 方法集是 {类型的方法集} {直接声明的方法集},也就是直接定义的方法,就像basic那样。
  • 类型集就是直接声明的类型表示的集合。

map

语义:

MapType = "map" "[" KeyType "]" ElementType .
KeyType = Type .

例子:

map[string]int
map[*T]struct{ x, y float64 }
map[string]interface{}

和其他语言一样,map类型最特殊的就是key,它必须实现==!=操作符

使用make 创建:

make(map[string]int)
make(map[string]int, 100)  // 可选的容量,仅影响性能,可靠的预估,可避免扩容带来的性能开销

nil map不能添加任何元素:

var m map[string]int
m = nil
m["a"] = 1 // panic: assignment to entry in nil map

delete

delete(m, k)  // remove element m[k] from map m

删除nil或不存在的key,不会报错。

清空操作: clear

channel

专门为并发而设计的通道类型,用于多线程数据共享

ChannelType = ( "chan" | "chan" "<-" | "<-" "chan" ) [ElementType] .

<- 操作符指定通道方向,发送或接收。如果指定了方向,通道就是定向的,否则就是双向的。

比如:

chan T          // can be used to send and receive values of type T
chan<- float64  // can only be used to send float64s
<-chan int      // can only be used to receive ints

复杂例子:

chan<- chan int    // same as chan<- (chan int)
chan<- <-chan int  // same as chan<- (<-chan int)
<-chan <-chan int  // same as <-chan (<-chan int)
chan (<-chan int)

即: <-只与最左边的chan相关联。

channel核心是为了简化并发编程,核心是协调, channel本身通信过程是线程安全的,即多个goroutine同时读写时,每个读写都是独立有序的原子操作,不会出现交错。但是它不保证通信内容的线程安全,比如chan map[string]int类型,传递的是map,它是引用类型,map的线程安全需要额外的实现,比如锁。

Note

Do not communicate by sharing memory; instead, share memory by communicating.

创建channel:

make(chan int, 100) 
make(cha-> int, 100) // 虽然正确,但是没有意义,创建一个单向的chan没有用
// 单向chan一般作为参数,用来进行权限控制,以达到谁能发(写),谁能收(读)的目的

容量(以元素个数为单位)设定了信道缓冲区的大小。如果容量为零或不存在,通道就是无缓冲的,只有当发送方和接收方都准备好时,通信才会成功。否则,如果缓冲区未满(发送方)或不为空(接收方),则通道处于缓冲状态,通信成功而不会阻塞。一个 nil 通道永远不会为通信做好准备。

channel是严格的FIFO

close

close(ch) // 关闭发送通道

说明:

  • close 只能接收的channel,panic
  • 已关闭的不能再send,否则panic
  • 已关闭的不能再close,否则panic
  • close nil通道,同样panic
  • 已关闭的通道,可以读,如果没有任何可读,将不阻塞返回对应类型的0值

类型和值的本质

相关描述:类型

值的表达

值本身或引用

predeclared类型,以及array, struct是self-contained,简单说他们是值类型,包括完整的数据,他们不会为nil,默认是0值

Non-nil pointer, function, slice, map, and channel,是引用类型:

  • A pointer value is a reference to the variable holding the pointer base type value.
  • A function value contains references to the (possibly anonymous) function and enclosed variables.
  • A slice value contains the slice length, capacity, and a reference to its underlying array.
  • A map or channel value is a reference to the implementation-specific data structure of the map or channel.

Tip

严格来说指针也应该算值类型。

引用类型的默认值为nil。这里有个误区,nil本质是指向的是nil,但是他们本身是分配了内存并初始化了的,比如: var a []int此时a就是nil,因为没有指向有效的底层数组,而a所在的地址内存区域是有值的,为[]int类型的slice结构的0值,效果等同于new([]int)分配的内存。

interface,本身用来定义类型集,它本身包含2个指针,一个指向类型,一个指向数据,虽然表现得像引用类型,但可以当作值类型来看待。 必须2个指针都指向nilinterface才是nil

底层类型

任何类型要么就是底层类型,要么就是由底层类型组成。

predeclared类型boolean, numeric, string,以及类型字面量本身就是底层类型(包括类型字面量,也就是array,function,map,channel,pointer,slice,struct)

但是其他比如通过type MyInt int定义的新类型,它的底层类型需要递归追溯。

Tip

type MyInt int // 定义新类型 type MyInt = int // 类型别名,此时MyInt就是int

相同的类型

命名类型(named type)之间总是不同的。 类型字面量之间,“结构”相同时表示相同的类型。 比如:

type Myint int
var a int
var b Myint
b = a // cannot use a (variable of type int) as Myint value in assignment.

结构相同的类型字面量,他们是相同的

var c struct{ x int }
var d struct{ x int } = c

官方规范

  • Two array types are identical if they have identical element types and the same array length.
  • Two slice types are identical if they have identical element types.
  • Two struct types are identical if they have the same sequence of fields, and if corresponding pairs of fields have the same names, identical types, and identical tags, and are either both embedded or both not embedded. Non-exported field names from different packages are always different.
  • Two pointer types are identical if they have identical base types.
  • Two function types are identical if they have the same number of parameters and result values, corresponding parameter and result types are identical, and either both functions are variadic or neither is. Parameter and result names are not required to match.
  • Two interface types are identical if they define the same type set.
  • Two map types are identical if they have identical key and element types.
  • Two channel types are identical if they have identical element types and the same direction.
  • Two instantiated types are identical if their defined types and all type arguments are identical.

类型和值的关系

类型相同或者实现了你才能赋值。

我在你能表示的范围内。 比如:

var a int8 = 300 // 超出了

方法集

每个类型都有一个与它相关的方法集(可能为空)。

  • 已定义类型 T 的方法集由所有以接收器类型 T 声明的方法组成。
  • 指向已定义类型 T 的指针(其中 T 既不是指针也不是接口)的方法集,是以接收器 *T 或 T 声明的所有方法的集合。
  • 接口类型的方法集是接口类型集中每个类型的方法集的交集(得到的方法集通常只是接口中已声明的方法集),接口作为类型代表只能代表公共部分。

blocks

不是{}包裹的才是块,有很多隐含的块:

  • 全局块
  • package 块
  • file 块
  • if, for, switch有隐藏的块
  • switch,select中每个clause都是一个隐藏的块

块决定了作用域。

声明和作用域

原则: 先声明后使用。

Tip

在file block,package block中声明,function block中使用。

2个特殊的: _,空白标识符,它被声明后,没有进行任何绑定,也就是运行时不会分配地址。

init标识符: 只能用于initfunction,并且不进行绑定,它是被go自动调用。

不绑定:意味着不能当作变量使用,且能声明多个。

作用域:

  1. 内置的属于全局作用域, universe block
  2. top level(函数外)属于package block
  3. import的包名,属于file block
  4. 函数参数、返回结果变量,函数体,属于function block
  5. 类型参数(泛型),作用域为函数名之后,函数体结束之前
  6. 类型参数,在类型定义时,作用域为类型名之后,Typespec(类型定义block)结束之前
  7. 函数的内部作用域,可以有子{}block

label scopes

标签没有块作用域,它不和同名的非label冲突

内置标识符

Types:
	any bool byte comparable
	complex64 complex128 error float32 float64
	int int8 int16 int32 int64 rune string
	uint uint8 uint16 uint32 uint64 uintptr

Constants:
	true false iota

Zero value:
	nil

Functions:
	append cap clear close complex copy delete imag len
	make max min new panic print println real recover

exported 标识符

约定: package block 并且首字母大写,视为已导出,可以包外导入访问。

iota

用于常量声明的表达式,值是常量索引连续且从0开始。

const (
	c0 = iota  // c0 == 0
	c1 = iota  // c1 == 1
	c2 = iota  // c2 == 2
	c3 // 可省略,默认使用前一个非空表达式
)

注意,它是常量索引,所以一个常量的表达式多次使用,值还是这个索引值。

Note

有点像python枚举类的auto()

类型声明

别名声明(go1.9)

type nodelist = []*Node
type set[P comparable] = map[P]bool

类型定义

type (
	Point struct{ x, y float64 }  // Point and struct{ x, y float64 } are different types
	polar Point                   // polar and Point denote different types
)

类型定义的意义是可以赋予一些方法。

类型参数

泛型,应用于函数和类型声明相关。 可以对类型参数进行约束,又称为”元”类型 通用接口用来表示约束,当只有一个类型元素时,比如 [T interface{E}],可以简写为[T E]:

[T []P]                      // = [T interface{[]P}]
[T ~int]                     // = [T interface{~int}]
[T int|string]               // = [T interface{int|string}]

comparable

这是一个内置类型,表示可以对比的类型集合,可以当作general接口看待,所以只能用于泛型约束。

func compare[T comparable](x, y T) bool {
	return x != y // 对比,不是比大小,所以不能使用<,>之类的操作符
}

又比如:

[T interface{comparable; E}] // 表示可比较,并实现了E(假设E为basic接口)

变量

声明:

var i int
var U, V, W float64
var k = 0
var x, y float32 = -1, -2
var (
	i       int
	u, v, s = 2.0, 3.0, "bar"
)
var re, im = complexSqrt(-1)
var _, found = entries[name]  // map lookup; only interested in "found"

简化版:

i, j := 0, 10 // 等同于先声明后赋值

简化版可以重新声明,其实就是重新赋值,但是有很多限制,常规的用法:

field1, offset := nextField(str, 0)
field2, offset := nextField(str, offset)

函数声明

可以没有body

方法声明

带有方法接收者的函数。

即: 普通方法和对象方法的区别

func (p *Point) Length() float64 {
	return math.Sqrt(p.x * p.x + p.y * p.y)
}
 
func (p *Point) Scale(factor float64) {
	p.x *= factor
	p.y *= factor
}

带泛型参数的接收者:

type Pair[A, B any] struct {
	a A
	b B
}
 
func (p Pair[A, B]) Swap() Pair[B, A]  { … }  // receiver declares A, B
func (p Pair[First, _]) First() First  { … }  // receiver declares First, corresponds to A in Pair

Tip

方法名并不是一个有效的变量,不可以直接赋值,它必须依附于类型,而函数就可以

Expressions

Composite字面量

struct字面量

type Point3D struct { x, y, z float64 }
 
// 都是合法的
origin := Point3D{}
p1 := Point3D{1.1,1.2,1.3} // 省略key时,必须所有字段按顺序赋值
p2 := Point3D{y: -4, z: 12.3} // 写key时,可以省略field,可以不按key顺序

array,slice字面量

和python一样,可以使用索引作为key,可以创建”稀疏”数组:

c := []int{
    10,     // 无key,索引0
    3: 40,  // key=3
    50,     // 无key,索引=前一个索引+1=4
}
//  [10, 0, 0, 40, 50]

由于go中char实际是int32,所以还可以:

// 字符会转换ASCII索引
vowels := [128]bool{'a': true, 'e': true, 'i': true, 'o': true, 'u': true, 'y': true}

array字面量的长度:

buffer := [10]string{}             // len(buffer) == 10
intSet := [6]int{1, 2, 3, 5}       // len(intSet) == 6
// ...表示最大的元素索引+1,就是array的长度
days := [...]string{"Sat", "Sun"}  // len(days) == 2

关于nil和empty

nil本质是没有初始化,empty表示空值,但是初始化了。

go中:

  • new: 创建了一个指针,但是并没有创建指向的内容,所以此时*p就是nil
  • make: 创建了具体的内容,如果是引用类型,还会同时创建对应的header/index。具体参考make

对于引用类型,实际上*p得到的是header的内存空间,但是在操作时,自动解引用到他们指向的位置。

p1 := new([]int) // new返回的是指针
*p1 == nil // true
 
s := make([]int, 3, 10) // make对比new,是创建了真实的底层数组
&s == nil // false
 
&s == &s[0] // false,&s是header的地址,&s[0]是底层数组的地址
 
p := &s
(*p)[0] == s[0] // true, (*p)[0], 操作[0]时自动解引用到底层数组上

嵌套字面量的省略写法

[...]Point{{1.5, -3.5}, {0, 0}}     // same as [...]Point{Point{1.5, -3.5}, Point{0, 0}}
[][]int{{1, 2, 3}, {4, 5}}          // same as [][]int{[]int{1, 2, 3}, []int{4, 5}}
[][]Point{{{0, 1}, {1, 2}}}         // same as [][]Point{[]Point{Point{0, 1}, Point{1, 2}}}
map[string]Point{"orig": {0, 0}}    // same as map[string]Point{"orig": Point{0, 0}}
map[Point]string{{0, 0}: "orig"}    // same as map[Point]string{Point{0, 0}: "orig"}
 
type PPoint *Point
[2]*Point{{1.5, -3.5}, {}}          // same as [2]*Point{&Point{1.5, -3.5}, &Point{}}
[2]PPoint{{1.5, -3.5}, {}}          // same as [2]PPoint{PPoint(&Point{1.5, -3.5}), PPoint(&Point{})}

Selectors

如果(*p).f是个有效的选择器,那p.f就是它的简写。

对于多重嵌套的情况也是适用,可以理解为自动解引用了。

方法表达式

注意不是函数,方法无法脱离类型/对象存在。

方法的转换

func (tv T) Mv(a int) int { return tv.a + 1 } // value receiver
func (tp *T) Mp(f float32) float32 { return 1 }  // pointer receiver

实际上上面的方法,在编译时会变成普通函数, 即<ReceiverType>.<MethodName>(<RecevierNameArg>, <OtherArgs>)

T.Mv(tv T, a int)
(*T).Mp(tp *T, f float32)

当然也可以显示使用T.Mv,(*T).Mp, 效果一样,都指向编译后的普通函数,这种显示模式,又称为”method expressions” 方法表达式

而对象调用:

//
var t T
var o T
i := 1
f := t.Mv
g := o.Mv
t.Mv(i)

上面2种典型的情况:

  1. t.Mv赋值给变量,此时Mv被包装函数包装(函数签名没有变,还是Mv的签名),包装函数的核心就是持有t的值,然后调用T.Mv 而且一个类型的所有包装函数都是同一个(函数的入口地址一样)。

    reflect.ValueOf(f).Pointer() == reflect.ValueOf(g).Pointer() // true
  2. t.Mv(i),直接调用,此时会被编译为T.Mv(t, i)

<obj>.<MethodName这种形式,又被称为”Method values”

总结: 方法 函数, 包装函数 调用函数, 直接调用函数

对象调用

不管哪种语言,面向对象编程时,都有一个类似this的概念,本质上就是一个隐藏的形参,调用者将自己赋值给它。

go中同样,不管是直接调用,还是包装函数,同样有这个赋值的过程, 只是不是this, 而是定义的方法接收者名称。

同样遵循常规的值类型和引用类型的赋值逻辑,也就是值修改不会影响已赋值,但是引用类型的指向修改会影响。

Index expressions

a[x] 用于:

  • 数组
  • slice
  • string
  • map

Caution

同样的数组越界问题

对于类型参数,包括泛型,索引必须对类型集 中的所有类型都有效.

string

字符串的索引位置是byte,不是char, 字符串索引不能被赋值。

map

不包括的key或者map是nil时,返回的值就是对应类型的0值。

Note

nil也返回值,虽然简化了操作,但是够奇葩。

m := make(map[string]int)
mp := new(map[string]int)
 
m["a"] // int的0值,0
(*mp)["a"] // int的0值,0
 

为了区分返回的0值是fallback的,还是真0值,可以指定一个可选的bool返回值,key存在时true,否则false

v, ok = m["a"]

slice operator

和python差不多,非常方便的操作类list数据结构,返回的结果一定是stringslice a[[star] : [end] [:max]] 索引从start开始,不包括endmax为容量边界索引(不包括),所以cap=max - start start默认为0,end默认为len(a)

比如:

a := [10]int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
s := a[1:5]
len(s) // 4
cap(s) // 9

s的长度是5-1, 不包括end s的容量为9-1+1, s从索引1开始,底层的a从索引1到最后,有9个元素,所以容量是9,意味着s可以append扩展到长度为9,而不需要分配新的底层数组。

为什么需要容量: append在没有超过容量时,修改的是同一个底层数组,超过容量后会copy出一个新的底层数组,所以可以指定容量来强制分配新的底层数组。

slice虽然不能读取超过长度的元素,但是可以在容量范围slice,本质还是slice原始数组,只是索引位置是参照当前slice,而不是原始数组:

s := a[1:5:7] // 长度为4,容量为6
s[4] // panic,越界了
s[:6] // 结果为{2,3,4,5,6,7}

Type assert

断言为更具体的类型,不会改变内存表达。 比如:

var x interface{} = 1
i, ok := x.(int) // if ok, i断言为int
var i, ok any = x.(int) // 左边的这个any不影响实际的类型,i依然int,ok为bool,不过静态编译时还是会被当作any :(
reflect.TypeOf(i) // int

断言为具体类型 vs 断言为接口类型

type I interface { m() }
 
func f(y I) {
	s := y.(string)        // illegal: string does not implement I (missing method m)
	r := y.(io.Reader)     // r has type io.Reader and the dynamic type of y must implement both I and io.Reader

}

string没有实现I,所以这个断言不可能成立 但是y却可能是一个同时实现Iio.Reader接口的类型,所以这里可以成立

本质上就是这个类型要同时满足I和断言类型。

Calls

最后一个参数可以是...T, 有2种调用传值方式:

  1. func(n1,n2,n3),会创建一个新的[]T
  2. func(s...),会复用s,所以修改会影响s

操作符

  • &^ 按位清除(AND NOT),0101 &^ 0011 = 0100 将左边操作数中对应右边操作数为1的位置置0
  • <<, 逻辑左移,补0
  • >>, 无符号,逻辑右移,有符号,算术右移,补1
  • +, 可用于字符串拼接

虽然浮点数不支持shift,但是如果能转换位整数,且有整数的上下文,会被当作整数:

var s uint = 33
var m int = 1.0<<s             // 1.0 has type int; m == 1<<33
var n = 1.0<<s == j            // 1.0 has type int32; n == true

Tip

所有的位运算都是基于存储的字节,不是语义上的字节

优先级:

Precedence    Operator
    5             *  /  %  <<  >>  &  &^
    4             +  -  |  ^
    3             ==  !=  <  <=  >  >=
    2             &&
    1             ||

求模

 x     y     x / y     x % y
 5     3       1         2
-5     3      -1        -2
 5    -3      -1         2
-5    -3       1        -2

go的求模是针对被求的那个数

浮点数操作

常量 / 0直接编译错误。(编译器行为)

非常量浮点数 / 0,根据IEEE 754返回+Inf-InfNaN(0.0/0)

f := 0.1
r := f / 0 // +Inf
z := 0.0
n := z / 0 // NaN 

比较运算

注意比较和有序

  • 比较: ==,!=
  • 有序: <,<=,>,>=
类型== !=< >说明
boolYN值相同即相等
int/uintYY值比较
floatYY值比较
complexYN实部虚部分别相等
stringYY字典序
指针YN指向同一变量
channelYN同一个make
接口YN动态类型加值相等
数组YN元素逐个比较
结构体YN字段逐个比较
slice仅nilN不可比较
map仅nilN不可比较
函数仅nilN不可比较
约束运算符类型范围
comparable== !=所有可比较类型
Ordered< > >=int float string

逻辑运算

&&, ||, ! 操作数必须是bool,返回值也一定是bool,支持短路。

Tip

js, python可以对非bool类型进行逻辑运算,且返回的结果为操作数。

地址运算

*, & 同C

接收操作

r := <-ch
r, ok := <-ch // ok表达通信是否成功

接收操作会阻塞,直到有可用值。从nil channel中接收,会一直阻塞。 对于已close的channel,可以立即获得结果,不会阻塞,如果channel中没有值了,返回该类型的0值。

类型转换

  • 类型断言: 视图上的改变,且只针对interface,当然运行时也会被当作被断言成功的类型。
  • 类型转换: 类型编译期的改变,完全的转变。非常量的数字和数字之间,数字和字符串之间的转换,有可能改变内存表达,产生运行时代价。其他类型只是改变视图,仅仅是进行类型标记的改变。
x := T(x)

类型转换时struct的tag会被忽略。

Note

从内存的角度看,很多不能转换的貌似都能转换,比如指针和整数,如果这个整数恰好是某个地址呢。 unsafe包就是干这个的,某种意义上的后门,绕过编译器的检查,当然你自己得承担这个后果。

整数之间的转换
  • 大转小,直接截断高位
  • 小转大,高位填充符号位
字符串转换

和C不同,""不是表示\0,它就是什么都没有

[]byte("") // len=0 等同于[]byte{}
string([]byte(nil)) // ""

字符串转换为字符rune,得到的是码点slice

切片到数组

数组的长度不能大于切片的长度(不是容量)

s := make([]byte, 2, 4)
a := [4]byte(s) // panic: runtime error: cannot convert slice with length 2 to array or pointer to array with length 4

常量表达式

使用const的表达式。

const a = 2 + 3.0
const b float32 = 2 + 3.0

字面量都是无类型的,在编译期间进行了计算。 计算过程中类型会按优先级进行统一: 整数 < rune < 浮点数 < 复数

如果const是有类型的,字面量计算的结果会转换为指定类型,不是一定要const变量指定类型了才是有类型,类型推断一样生效。 无类型const的好处是,使用时才转换为具体类型,更灵活。

const变量在编译后是不存在的,没有内存地址存他们,这点类似C的宏,但是它比宏又更强大和安全,它是参与类型检查的。

const和字面量的区别在于通过变量进行重复使用,如果有类型则参与类型检查。

Note

go的编译器在计算常量时不是使用cpu层面的比如int64,float64来计算常量,它内部实现了一套仿真逻辑(也是因为此时为无类型),拥有更高的精度,当然也需要更多的开销,只是这是编译期,可以接受。

执行顺序

规范中提到了一个例子:

y[f()], ok = g(z || h(), i()+x[j()], <-c), k()

Go 强制要求“函数调用、通道操作和逻辑运算”这类具有副作用的操作必须按代码文字顺序从左到右执行,但“定位变量地址/获取变量值”这一动作与这些操作之间的先后顺序是不确定的。

也就是确定y,x的地址,和f(),j()之间的执行顺序并不确定,如果f()改变了y的地址,将引发不可控后果。所以一定不要这么做。

同时变量和变量的计算顺序还要看依赖关系。

例子:

a := 1
f := func() int { a++; return a }
x := []int{a, f()}            // x may be [1, 2] or [2, 2]: evaluation order between a and f() is not specified
m := map[int]int{a: 1, a: 2}  // m may be {2: 1} or {2: 2}: evaluation order between the two map assignments is not specified
n := map[int]int{a: f()}      // n may be {2: 3} or {3: 3}: evaluation order between the key and the value is not specified

值得注意的是,map计算entry的顺序不是确定的,计算key和value的顺序也不是确定的。

Statements

Terminating statements

执行到这里后,不会继续执行下去的语句。 死循环也算!

Tip

这个应该是用于编译器优化,以确保有return

Empty statements

空语句。 常用来占位,或满足编译器语法要求。

for ; ; {}

Labeled statements

配合Goto statements使用。

LabeledStmt = Label ":" Statement .
Close:
	...

IncDec statements

IncDecStmt = Expression ( "++" | "--" ) .

不像C,go对自增自减做了彻底的简化,以及歧义消除。所以它变成语句了,不再是表达式,你不能f(i++)来使用了。

它就是+/-= 1的简写。

Nice

所以,不用再纠结i = i++ + ++i这种玩意了!

Assignment statements

  1. <op>= 用法

    a[i] <<= 2
    i &^= 1<<n
  2. tuple赋值 基本和lua相同,包括_忽略的写法

    x, y = f() // 多值表达式
    one, two, three = '', '', '' // 表达式一定是单值

执行顺序中阐述了一些表达式计算的不确定性,主要是索引和变量地址之间的计算顺序, 比如a[f()] = ...这种,如果f()改变了a的地址,那计算结果将不可预估。 但是go在赋值过程也引入了一些确定性的顺序,以避免赋值冲突,比如:

  1. 首先,计算左边的索引、指针和右边的表达式,他们按执行顺序表达式的顺序执行
  2. 然后赋值,按从左到右的顺序

比如:

a, b = b, a  // exchange a and b
 
x := []int{1, 2, 3}
i := 0
i, x[i] = 1, 2  // set i = 1, x[0] = 2, 先计算后赋值,所以i为0,赋值的是x[0]

If statements

可以在if后面接一个简单的语句,做一些简单的赋值计算等等。

if x := f(); x < y {
	return x
} else if x > z {
	return z
} else {
	return y
}

Fallthrough statements

只能用于表达式switch

fallthrough用于将控制权转移到下一个case的第一个statement,它只能作为case/default的最后一个语句。

不同于c/java/js等,在没有break下,自动”贯穿”,执行下一个case,go必须显示fallthrough,相当于默认使用了break,当然你也可以手动break,比如中途跳出的情况。

Note

go反其道而行之,对于实际中基本都是break的情况,这种形式更友好。

Switch statements

表达式switches

就是常规的 switch

需要注意:

  1. switchcase表达式,必须是comparable
  2. 无类型常量switch表达式,使用默认类型;对于无类型的case表达式,它会使用switch表达式的类型
  3. 没有switch表达式意味着true
  4. case可以使用表达式列表,逐个短路匹配
  5. default没有位置要求
switch tag {
default: s3()
case 0, 1, 2, 3: s1()
case 4, 5, 6, 7: s2()
}
 
switch x := f(); {  // missing switch expression means "true"
case x < 0: return -x
default: return x
}

Type switches

使用TypeSwitchGuard描述switch语句, 它的表达式必须是类型断言:

TypeSwitchGuard = [ identifier ":=" ] PrimaryExpr "." "(" "type" ")" .

例子:

i := x.(type)

如果赋值给了变量比如i,这个i可以被每一个case/default访问,但是它的类型,如果case明确指定了一个(case后面可以是TypeList),在这个block中会被当作这个类型,否则使用TypeSwitchGuard描述的类型。

case后还可以有最多一个nil表达式,用于nil interface value.

switch i := x.(type) {
case nil:
	printString("x is nil")                // type of i is type of x (interface{})
case int:
	printInt(i)                            // type of i is int
case float64:
	printFloat64(i)                        // type of i is float64
case func(int) float64:
	printFunction(i)                       // type of i is func(int) float64
case bool, string:                         // type list, i has the type of the expression in the TypeSwitchGuard
	printString("type is bool or string")  // type of i is type of x (interface{})
default:
	printString("don't know the type")     // type of i is type of x (interface{})
}

case的类型不能重复,但是如果是泛型类型的情况,可能无法避免,此时前面的优先生效。

fallthrough语句不允许在这里出现。

For statements

ForStmt   = "for" [ Condition | ForClause | RangeClause ] Block .
Condition = Expression .

Condition

就是while

ForClause

对于ForClause的情况,go 1.22开始,init部分定义的变量,在每次迭代时都重新声明了,只是会赋予上一次迭代后的值,这是一个全新的变量了,不是一个变量在重新赋值了。

这解决了什么问题呢?,规范中的例子:

var prints []func()
for i := 0; i < 5; i++ {
	prints = append(prints, func() { println(i) })
	i++ // 再次改变这个block中的i,函数执行的时候,i是这里+1后的值
}
for _, p := range prints {
	p()
}
 
// 1 3 5

如果不是重新定义,那么所有的函数都引用了同一个变量,最后结果就是6 6 6

Note

js中也有个类似的陷阱,关于varlet的:

for (var i = 0; i < 3; i++) { setTimeout(function() { console.log(i); // 输出:3, 3, 3 }, 100); }

使用let即可解决,同样的block作用域,同样的每次赋上一次迭代的值

RangeClause

RangeClause = [ ExpressionList "=" | IdentifierList ":=" ] "range" Expression .

包括: array, slice, string, map, channel, int values(0 - upper), yield function 可以理解为通用迭代器,应用于所有可迭代的结构。

通常range表达式,会在循环开始前计算一次,除了一种情况: 只有一个迭代变量,表达式为常量或者len是常量,这个时候编译器会进行优化,生成固定次数的循环,所以不用计算了。

range左侧的函数,每次迭代都会执行:

func getSlice() []int {
	fmt.Println("getSlice() called")
	return []int{1, 2, 3}
}
 
func getPointer() *int {
	fmt.Println("getPointer() called")
	return new(int)
}
 
func LeftSide() {
	fmt.Println("=== 左侧有函数调用 ===")
	for *getPointer() = range getSlice() {
		// ...
	}
}

具体说明:

  1. 对于array,slicerange会迭代出索引和值,但是如果你只有一个迭代变量时,本质上和range(len(a))没有区别,编译器会优化,根本不会真的去遍历这个arrayslice

  2. 对于string,迭代出索引和unicode码点,索引不是码点的索引,而是码点对应的utf-8原始字节的第一个字节的索引,本质是字节的位置。

  3. 对于map,前面执行顺序中也提到map的计算顺序,entry, kv都不是固定的,实际上迭代顺序也无法保证,所以不要依赖这个,它不像python的dict。 这个不固定还体现在迭代过程中进行add,写入的entry可能迭代出来也可能不会;但是remove没有迭代到的entry,这个entry一定不会迭代出来。

  4. 对于channel,阻塞读,直至close,如果channel为nil,则一直阻塞,这和接收的行为一致。

  5. 对于integer,小于等于0时,不迭代。迭代变量的类型取决于定义的类型,否则使用integer的类型(常量就是默认类型)

  6. 对于yield function: go的yield实现和python,js等的完全不同,python,js本质是暂停并保存调用栈,next时恢复,但是go的yield本质是对循环体的包装,就是一个普通的高阶函数使用,是一个语法糖,例:

    编译后本质是执行BadIterator,这个yield参数,由编译器构建,它本质是对range循环体的封装 ,执行一次yield就是执行一次循环体,如果循环体终止,比如breakyield返回false;当yield返回false后,再次调用yield就会panic: runtime error: range function continued iteration after function for loop body returned false

    所以这完全没有任何魔法。

    yield的函数签名:

    • yield func() bool
    • yield func(V) bool
    • yield func(K, V) bool
    func BadIterator(yield func(int) bool) {
       for i := 1; i <= 5; i++ {
       	fmt.Printf("--- 迭代器内部:尝试产生数字 %d ---\n", i)
     
       	res := yield(i)
     
       	fmt.Printf("--- 迭代器内部:yield(%d) 的返回值是 %v ---\n", i, res)
     
       	if !res {
       		fmt.Println("!!! 检测到外部 break,但我拒绝退出,我要再次尝试 yield !!!")
       		// 这里不 return,强行继续循环进入下一次 yield
       		// return
       	}
       }
    }
     
    func RunBadIterator() {
       for v := range BadIterator {
       	fmt.Printf("外部循环:接收到 %d\n", v)
       	if v == 1 {
       		fmt.Println("外部循环:执行 break")
       		break
       	}
       }
    }

Go statements

GoStmt = "go" Expression .

启用一个新的goroutine,并不会等待函数执行完,可以理解为去异步执行了。 只能对函数或方法使用,且返回值会被丢弃。

go Server()
go func(ch chan<- bool) { for { sleep(10); ch <- true }} (c)

Select statements

专门用于通道的选择模式,行为类似jsPromise.race(),本质是竞速。由于channel默认阻塞,需要select形式来实现,而不能简单的if判断。

要点:

  1. 准备阶段,右边的表达式会按代码顺序先执行,等所有的表达式执行完后,再开始”竞速”,但是左边的表达式并不计算,拿到数据才执行,因为有些可能竞赛失败,永远不会执行。
  2. 如果有default,将立即执行,它相当于已经准备好了。没有则阻塞直到有可用的
  3. 如果有多个同时准备好,将使用伪随机使用其中任何一个
  4. 接收和发送可以在一个select中,但是一个case只能有一个接收或发送channel
  5. 可以循环select,这和Promise一锤子买卖不同
var a []int
var c, c1, c2, c3, c4 chan int
var i1, i2 int
select {
case i1 = <-c1:
	print("received ", i1, " from c1\n")
case c2 <- i2:
	print("sent ", i2, " to c2\n")
case i3, ok := (<-c3):  // same as: i3, ok := <-c3
	if ok {
		print("received ", i3, " from c3\n")
	} else {
		print("c3 is closed\n")
	}
case a[f()] = <-c4:
	// same as:
	// case t := <-c4
	//	a[f()] = t
default:
	print("no communication\n")
}
 
for {  // send random sequence of bits to c
	select {
	case c <- 0:  // note: no statement, no fallthrough, no folding of cases
	case c <- 1:
	}
}
 
select {}  // block forever

Return statements

可以返回多个,可以具名。

func complexF1() (re float64, im float64) {
	return -7.0, -4.0
}
 
func complexF3() (re float64, im float64) {
	re = 7.0
	im = 4.0
	return // 不需要指定了,返回的变量赋值了,但是这个return不能省略,必须有显示的terminating语句
}

不管是形参,还是返回参数,本质都是函数体作用域的本地变量。

Defer

defer函数,类似finally

函数在出栈前,一定会调用defer,除非没有找到声明的defer(还没执行到就return了):

  1. return返回之前,return不是一个原子操作,分为赋值(分配),返回,返回才会出栈
  2. 出现panic,会出栈
  3. 没有return,函数体执行完时

defer是有可能会修改返回值的,特别是具名的情况下:

func Anonymous() int {
	x := 5
	defer func() {
		x = x + 10 // 这里改的是局部变量 x,不是 return 已经存好的备份
	}()
	return x
}
 
func Named() (x int) {
	defer func() {
		fmt.Println("defer看到的结果是:", x) // 输出 5
		x = 10                         // 10赋值给x, 修改结果
	}()
	return 5 // 5赋值给x
}

Break statements

BreakStmt = "break" [ Label ] .

用于终止for,switchselectbreak和他们必须处于同一个函数中,闭包函数也不行。

标签的意义在于跳出多重嵌套,标签必须定义在包含此 breakforswitchselect 语句上。

OuterLoop:
	for i = 0; i < n; i++ {
		for j = 0; j < m; j++ {
			switch a[i][j] {
			case nil:
				state = Error
				break OuterLoop
			case item:
				state = Found
				break OuterLoop
			}
		}
	}

Continue statements

ContinueStmt = "continue" [ Label ] .

除了针对for,直接下一次迭代而不是终止,其他和break相同。

Goto statements

GotoStmt = "goto" Label .

为了防止滥用,有很多限制:

  1. goto必须在同一个function
  2. 不能跨block,更严格的限定
  3. goto下面不能申明新的变量,因为这样会跳过变量的初始化,导致可能的错误调用
goto L  // BAD
	v := 3
L:
 
// -----------
 
if n%2 == 1 {
	goto L1  // BAD
}
for n > 0 {
	f()
	n--
L1:
	f()
	n--
}

Defer statements

DeferStmt = "defer" Expression .

表达式必须是函数或方法的call

调用时机见Defer

defer函数在执行到声明的地方时,会固化变量,但是并不执行,等出栈前开始执行,就算后面改变了变量的值也不影响之前的赋值(不考虑引用类型的问题)。 如果defer的函数是nil,声明时也不会报错,而是延迟到执行时才报错。

defer函数的返回值会直接丢弃。

GMP模型

GMP是一种调度逻辑,用来实现海量goroutine在少量OS线程上的高效调度。

所有的用户代码都是跑在goroutine上的,没有任何意外,也不需要主动开启!

  • Goroutine(G) = Go runtime 对并发执行单元的抽象,类似java的虚线程
  • Machine(M) = OS 线程的 runtime 抽象,即内核线程
  • Processor(P) = 资源容器和调度上下文

三者的关系: M必须在绑定P的情况下,才能执行G,P提供了资源比如内存和一些上下文,但是G并不依赖特定的P。

结构关系

classDiagram
    class G {
        +stack stack
        +sched gobuf
        +status uint32
        +m *m
        +goid uint64
    }
    
    class M {
        +g0 *g
        +curg *g
        +p *p
        +id int64
    }
    
    class P {
        +status uint32
        +m *m
        +runq[256] guintptr
        +mcache *mcache
        +id int32
    }
    
    class schedt {
        +runq gQueue
        +runqsize int32
        +pidle pList
        +midle mList
    }
    
    G "n" --> "0..1" M : curg
    M "n" --> "0..1" P : 绑定
    P "n" <-- "1" schedt : 全局调度器
    P "1" *-- "1" mcache : 拥有
    P "1" o-- "n" G : runq持有引用

G状态流转图

展示 Goroutine 的完整生命周期。

stateDiagram-v2
    [*] --> _Gidle : 创建
    
    _Gidle --> _Grunnable : 放入队列
    
    _Grunnable --> _Grunning : M取出执行
    
    _Grunning --> _Grunnable : 抢占或Gosched
    
    _Grunning --> _Gwaiting : channel或网络阻塞
    
    _Grunning --> _Gsyscall : syscall进入
    
    _Gsyscall --> _Grunning : syscall返回获P
    
    _Gsyscall --> _Grunnable : syscall返回无P
    
    _Gwaiting --> _Grunnable : 唤醒
    
    _Grunning --> _Gdead : 执行完毕
    
    _Gdead --> [*] : 回收或复用
    
    note right of _Gwaiting
        存放位置
        channel.recvq/sendq
        netpoller
        timer heap
    end note
    
    note right of _Gsyscall
		触发syscall的情况
        文件I/O: open/read/write
        系统调用: time/getpid/exec
        CGO调用
        特点: G跟着M
        P可能被handoff
    end note
    
    note right of _Grunnable
        存放位置
        P.runq本地队列
        sched.runq全局队列
    end note
    
    note left of _Grunning
        让出CPU方式
        Gosched: G主动让出
        抢占: sysmon强制
        超过10ms触发
    end note
状态含义G的位置
_Gidle刚创建,未初始化
_Grunnable可执行,等待调度P.runq 或 sched.runq
_Grunning正在执行M.curg
_Gsyscallsyscall 中跟着 M
_Gwaiting等待(channel/网络)channel.recvq/netpoller
_Gdead执行完毕回收池

关于 syscall: 进入 _Gsyscall 状态的触发条件:文件 I/O(open/read/write)、系统调用(time/exec/进程操作)、CGO 调用。

网络 I/O 不进入 syscall:Go 使用 netpoller 异步处理网络 I/O,通过 epoll/kqueue 等机制,网络读写不会阻塞 M,而是让 G 进入 _Gwaiting 状态放入 netpoller 等待队列,M 继续执行其他 G。这是 Go 高并发的关键设计。

P 状态流转图

展示 Processor 在不同场景的状态变化。

stateDiagram-v2
    [*] --> _Pidle : 创建

    _Pidle --> _Prunning : M绑定

    _Prunning --> _Pidle : 无G可执行

    _Prunning --> _Pgcstop : GC开始

    _Pgcstop --> _Pidle : GC结束

    note right of _Pidle
        P空闲等待:
        sched.pidle列表
        等待M绑定
    end note

    note right of _Prunning
        P绑定M执行G
        runq有待执行G
        syscall时P仍为_Prunning
        但可通过M.curg.status
        判断M是否在syscall
    end note
状态含义
_Pidle空闲,等待 M 绑定
_Prunning正在执行,绑定了 M
_PgcstopGC 期间停止
_Pdead不再使用(GOMAXPROCS 缩减时)

调度循环流程图

展示 M 获取 G 并执行的完整调度循环。

flowchart TD
    A[schedule循环开始] --> B{schedtick%61==0<br/>全局队列有G?}

    B -->|是| C[从全局队列取G<br/>确保公平性]
    B -->|否| D{P.runnext有G?}

    D -->|是| E[取出runnext的G<br/>继承时间片]
    D -->|否| F{P.runq有G?}

    F -->|是| G[从P.runq取出G]
    F -->|否| H{全局队列有G?}

    H -->|是| I[加锁取一批G<br/>放入P.runq<br/>取一个执行]
    H -->|否| J{netpoller有就绪G?}

    J -->|是| K[取出就绪的G]
    J -->|否| L{其他P.runq有G?}

    L -->|是| M[Work Stealing<br/>偷一半放入P.runq]
    L -->|否| N[M进入休眠/idle]

    C --> O[execute执行G]
    E --> O
    G --> O
    I --> O
    K --> O
    M --> A

    O --> P{G需要阻塞?}

    P -->|channel阻塞| Q[G→_Gwaiting<br/>放入channel.recvq<br/>M继续调度]
    P -->|网络I/O| R[G→_Gwaiting<br/>放入netpoller<br/>M继续调度]
    P -->|syscall| S[G→_Gsyscall<br/>M进入syscall阻塞]
    P -->|执行完毕| T[G→_Gdead<br/>回收]
    P -->|继续执行| O

    Q --> A
    R --> A
    T --> A

    S --> U{syscall>10ms?}
    U -->|是| V[sysmon强制handoff<br/>P解绑交给其他M]
    U -->|否| W[syscall返回<br/>快速获取原P]

    V --> X[syscall返回<br/>尝试获取空闲P<br/>或G入全局队列]
    W --> O
    X --> Y{获取P成功?}
    Y -->|是| O
    Y -->|否| Z[G入全局队列<br/>M进入idle]
    Z --> A

调度优先级顺序(基于源码 findRunnable):

  1. 每 61 个 schedtick 检查全局队列(确保公平性,防止饥饿)
  2. P.runnext(优先插队位,继承时间片,最高优先)
  3. P.runq 本地队列(无锁,FIFO)
  4. sched.runq 全局队列批量获取(有锁,取 len(runq)/2 个)
  5. netpoller(网络 I/O 就绪的 G)
  6. Work Stealing(从其他 P 偷一半)
  7. 找不到 → M 休眠

Channel 阻塞时序图

展示 channel 接收阻塞和发送唤醒的完整过程。

sequenceDiagram
    participant G1栈 as G1栈(用户代码)
    participant g0栈 as g0栈(runtime代码)
    participant M as M(执行引擎)
    participant ch as channel.recvq
    participant G2栈 as G2栈(用户代码)

    Note over M: M.curg = G1

    rect rgb(230, 240, 255)
        Note right of M: 在G1栈上执行
        M->>G1栈: 执行用户代码
        G1栈->>G1栈: x := <-ch
    end

    rect rgb(255, 245, 230)
        Note right of M: 切到g0栈执行runtime
        M->>g0栈: 切换栈
        g0栈->>g0栈: chanrecv()
        g0栈->>ch: enqueue(sudog)
        g0栈->>g0栈: gopark()
        Note over M: G1.status = _Gwaiting
    end

    rect rgb(240, 255, 240)
        Note right of M: 调度循环
        g0栈->>g0栈: schedule()
        g0栈->>g0栈: 从P.runq取G2
        M->>M: M.curg = G2
        M->>G2栈: execute(G2)
    end

    Note over M: G1在recvq等待...

    rect rgb(230, 240, 255)
        Note right of M: 在G2栈上执行
        M->>G2栈: 执行用户代码
        G2栈->>G2栈: ch <- data
    end

    rect rgb(255, 245, 230)
        Note right of M: 切到g0栈
        M->>g0栈: 切换栈
        g0栈->>g0栈: chansend()
        g0栈->>ch: dequeue(sudog=G1)
        g0栈->>g0栈: goready(G1)
        Note over M: G1.status = _Grunnable
        g0栈->>g0栈: G1放入P.runq
    end

    rect rgb(230, 240, 255)
        Note right of M: G2返回继续执行
        M->>G2栈: 返回用户栈继续
        Note over M: G2执行到调度点<br/>阻塞/完成/抢占
    end

    rect rgb(255, 245, 230)
        Note right of M: 调度点触发schedule
        M->>g0栈: 切到g0栈
        g0栈->>g0栈: schedule()
        g0栈->>g0栈: 从P.runnext取G1
        M->>M: M.curg = G1
        M->>G1栈: execute(G1)
    end

    Note over M: G1拿到数据继续

执行栈切换说明

代码类型执行内容
用户栈Go 用户代码x := <-ch、业务逻辑
g0栈runtime 代码chanrecvgoparkschedule

关键点

  • M 是执行引擎,在 g0栈 和用户栈之间切换
  • Channel 操作触发栈切换:用户栈 → g0栈 → runtime 处理
  • M 和 P 不解绑,阻塞的是 G,M 继续调度
  • goready 只入队,不立即切换:G2 发送后继续执行,直到调度点才切换到 G1
  • enqueue 放入的是 sudog(G的代理结构),不是 G 本身

Syscall 阻塞时序图

展示 syscall 阻塞的 handoff 机制和恢复过程。

核心理解:syscall 时 G 跟着 M,不像 channel 那样脱离。sysmon 监控并可能触发 handoff。

sequenceDiagram
    participant G栈 as G栈(用户代码)
    participant g0栈 as g0栈(runtime代码)
    participant M1 as M(原线程)
    participant P as P
    participant sysmon as sysmon(独立M)
    participant M2 as M(新线程)
    participant 全局队列 as sched.runq

    Note over M1: M.curg = G

    rect rgb(230, 240, 255)
        Note right of M1: 在G栈执行
        M1->>G栈: 执行用户代码
        G栈->>G栈: syscall.Read()
    end

    rect rgb(255, 245, 230)
        Note right of M1: 切到g0栈
        M1->>g0栈: 切换栈
        g0栈->>g0栈: entersyscall()
        Note over M1: G.status = _Gsyscall
        g0栈->>g0栈: 保存现场, P.syscalltick++
    end

    rect rgb(255, 230, 230)
        Note right of M1: M进入OS阻塞
        M1->>M1: OS线程阻塞在syscall
        Note over P: P仍绑定M1,状态为_Prunning
    end

    rect rgb(245, 230, 255)
        Note right of sysmon: sysmon监控(不绑定P)
        Note over sysmon: delay: 20μs→10ms
        loop 监控循环
            sysmon->>sysmon: retake()
            sysmon->>P: 检查syscalltick+时间
            alt syscall > 10ms 且有其他工作
                sysmon->>P: handoffp()
                Note over P: P解绑M1
                sysmon->>M2: 找空闲M或创建新M
                M2->>P: 绑定P
                Note over P: P.runq中的G可执行
            else 短时间 或 无工作
                Note over sysmon: 不介入
            end
        end
    end

    rect rgb(255, 230, 230)
        Note right of M1: syscall返回
        M1->>M1: OS线程恢复
        Note over M1: G仍跟着M1
    end

    rect rgb(255, 245, 230)
        Note right of M1: 切到g0栈
        M1->>g0栈: 切换栈
        g0栈->>g0栈: exitsyscall()
        g0栈->>P: 尝试快速获取原P
    end

    alt 获取原P成功
        rect rgb(240, 255, 240)
            Note right of M1: 快速路径
            M1->>P: 重新绑定原P
            g0栈->>G栈: 返回G栈继续
            Note over M1: G继续执行
        end
    else 原P被handoff
        rect rgb(255, 240, 230)
            Note right of M1: 慢路径
            g0栈->>g0栈: 尝试获取空闲P
            alt 找到空闲P
                M1->>P: 绑定空闲P
                g0栈->>G栈: 返回G栈继续
            else 无空闲P
                g0栈->>全局队列: G放入全局队列
                Note over M1: M进入idle
                g0栈->>g0栈: stopm()
            end
        end
    end

执行阶段说明

阶段颜色执行位置说明
用户代码蓝色G栈syscall.Read()
runtime 处理橙色g0栈entersyscall/exitsyscall
OS 阻塞红色M(OS线程)内态态阻塞
sysmon 监控紫色sysmon.g0栈独立M,不绑定P
快速恢复绿色原绑定syscall返回快速获P
慢路径黄色调度获取失败入全局队列

关键点

  • syscall 阻塞时,G 跟着 M(不像 channel 那样脱离)
  • sysmon 监控,超过 10ms 且有其他工作时强制 handoff
  • P 解绑后交给其他 M 或进入 _Pidle
  • syscall 返回后 M 自己尝试获取 P(sysmon 不参与)
  • 获取失败则 G 入全局队列

注意:P 没有 _Psyscall 状态。syscall 时 P 仍为 _Prunning。sysmon 通过检查 M.curg.status 判断这个 M 是否在执行 syscall。

Work Stealing 流程图

展示 P 本地队列空时的偷任务过程。

flowchart TD
    A[P.runq 空] --> B[选择目标P<br/>随机或轮询]
    B --> C{目标P.runq有G?}

    C -->|否| D[选择下一个P]
    D --> C

    C -->|是| E[加锁目标P.runq]
    E --> F[计算偷取数量<br/>n = runqsize/2]
    F --> G[从尾部偷取n个G]
    G --> H[放入自己的P.runq]
    H --> I[解锁目标P.runq]
    I --> J[取出一个G执行]

    J --> K{执行成功?}
    K -->|是| L[完成]
    K -->|否| A

偷一半的设计原因

原因说明
减少偷取频率偷一批而非单个,减少后续偷取次数
平衡负载均衡各 P 的工作量
避免竞争每个偷取操作需要加锁,减少加锁次数

整体架构图

展示 GMP 模型的整体架构。

flowchart TB
    subgraph Global["全局共享数据 schedt"]
        GRQ["sched.runq<br/>全局G队列(有锁)"]
        PList["pidle<br/>空闲P列表"]
        MList["midle<br/>空闲M列表"]
    end

    subgraph P1["P0"]
        LRQ1["P.runq<br/>本地队列(256,无锁)"]
        MC1["mcache<br/>内存缓存"]
    end

    subgraph P2["P1"]
        LRQ2["P.runq"]
        MC2["mcache"]
    end

    subgraph P3["P2"]
        LRQ3["P.runq"]
        MC3["mcache"]
    end

    subgraph M1["M0"]
        G0_1["g0<br/>调度栈"]
        CurG1["curg<br/>当前G"]
    end

    subgraph M2["M1"]
        G0_2["g0"]
        CurG2["curg"]
    end

    subgraph M3["M2"]
        G0_3["g0"]
        CurG3["curg"]
    end

    subgraph Special["特殊线程"]
        SYS["sysmon<br/>系统监控(不绑定P)"]
    end

    Global <--> P1
    Global <--> P2
    Global <--> P3

    P1 <--> M1
    P2 <--> M2
    P3 <--> M3

    M1 --> LRQ1
    M2 --> LRQ2
    M3 --> LRQ3

    LRQ1 <-.-> LRQ2
    LRQ2 <-.-> LRQ3

    SYS -.-> P1
    SYS -.-> P2
    SYS -.-> P3

架构说明

组件说明
schedt全局共享数据,用锁保护
P.runq本地队列,256容量,无锁访问
g0M 的调度栈,执行 runtime 代码
curgM 当前执行的 G
sysmon独立 M,不绑定 P,监控 syscall 和抢占

Built-in functions

内置函数没有标准的Go types,只能直接调用,不能作为function values.

append and copy

针对slice的操作。除了bytestring外,要求类型严格一直,必须有相同的底层类型。

另外:

  • slice不支持协变,所以any[]不能接收int[]
  • 有些情况会进行自动装箱,比如append([]any, int)时,int会装箱为any

Tip

接口类型支持协变

内存拷贝重叠问题

问题产生的原因:

  1. 寄存器大小限制无法一次复制完
  2. 写入操作改写了 src 还没来得及读取的部分

情况1: src < dist

位置图

src A B C D dst

错误拷贝

src A B C D dst A B A B 1 2 3 4 3、4读取时src已被1、2覆盖

正确拷贝

src A B C D dst A B C D 1 2 3 4 从高地址开始写,不影响后续读取

情况2: src > dist

位置图

src A B C D dst

错误拷贝

src A B C D dst C D C D 1 2 3 4 3、4读取时src已被1、2覆盖

正确拷贝

src A B C D dst A B C D 1 2 3 4 从低地址开始写,不影响后续读取

go的拷贝

不同于c需要手动使用memmove来处理重叠的问题,go静默的做了优化,所以不用管重叠的情况。

另外不管怎么拷贝,目标是保证按期望生成dst,至于src,并不保证不会被修改,实际上重叠时,src肯定会被修改。

append

append(s S, x ...E) S  // E is the element type of S
s0 := []int{0, 0}
s1 := append(s0, 2)
s3 := append(s2, s0...) // slice...参数表示解构

对于byte[],第2个参数可以传string

var b []byte
b = append(b, "bar"...)            // append string contents      b is []byte{'b', 'a', 'r' }

超出容量会自动扩容,即使用新的底层数组。

copy

copy(dst, src []T) int
copy(dst []byte, src string) int

这里返回的是赋值的元素个数,为min(len(dst), len(src))

var a = [...]int{0, 1, 2, 3, 4, 5, 6, 7}
var s = make([]int, 6)
var b = make([]byte, 5)
n1 := copy(s, a[0:])            // n1 == 6, s is []int{0, 1, 2, 3, 4, 5}
n2 := copy(s, s[2:])            // n2 == 4, s is []int{2, 3, 4, 5, 4, 5}
n3 := copy(b, "Hello, World!")  // n3 == 5, b is []byte("Hello")

clear

清空mapslice

var m = make(map[string]string, 16)
clear(m) // delete all entries
len(m) // 0

如果值是nilclear并不会报错,相当于什么都没有做

len and cap

Call      Argument type    Result

len(s)    string type      string length in bytes
          [n]T, *[n]T      array length (== n)
          []T              slice length
          map[K]T          map length (number of defined keys)
          chan T           number of elements queued in channel buffer
          type parameter   see below

cap(s)    [n]T, *[n]T      array length (== n)
          []T              slice capacity
          chan T           channel buffer capacity
          type parameter   see below

类型参数的类型集都必须支持lencap

0 <= len(s) <= cap(s)永远成立, 0 对应emptynil

对于字符串(len)和数组,这个结果是个常量,编译期就被替换了。

make

针对slice,map,channel,返回对应类型的值,且进行了初始化。

Call             Type T            Result

make(T, n)       slice             slice of type T with length n and capacity n
make(T, n, m)    slice             slice of type T with length n and capacity m

make(T)          map               map of type T
make(T, n)       map               map of type T with initial space for approximately n elements

make(T)          channel           unbuffered channel of type T
make(T, n)       channel           buffered channel of type T, buffer size n

make(T, n)       type parameter    see below
make(T, n, m)    type parameter    see below

n不能大于m

min and max

针对ordered types 结果的类型等同于x+y结果的类型,比如一些类型提升的情况。

var x, y int
m := min(x)                 // m == x
m := min(x, y)              // m is the smaller of x and y
m := max(x, y, 10)          // m is the larger of x and y but at least 10
c := max(1, 2.0, 10)        // c == 10.0 (floating-point kind)
f := max(0, float32(x))     // type of f is float32
var s []string
_ = min(s...)               // invalid: slice arguments are not permitted
t := max("", "foo", "bar")  // t == "foo" (string kind) 直接按物理字节来比较

特殊的浮点数值:

   x        y    min(x, y)    max(x, y)

  -0.0    0.0         -0.0          0.0    // negative zero is smaller than (non-negative) zero
  -Inf      y         -Inf            y    // negative infinity is smaller than any other number
  +Inf      y            y         +Inf    // positive infinity is larger than any other number
   NaN      y          NaN          NaN    // if any argument is a NaN, the result is a NaN

allocation

使用new函数来进行内存分配,并初始化这个内存块,和make不一样,并不做额外的内部数据结构初始化,比如slice这种,它实际有2块内存区域,new并不会分配底层数组。

Note

本质new只关心值类型,或复杂类型的值类型部分。

2种用法:

  1. new(T): 参数为类型,初始化这个类型的一块内存区域,并初始化,返回地址(指针)
  2. new(exp): 分配一个exp同类型的内存块,并初始化为exp对应的值,返回地址(指针)

Bootstrapping

printprintln,输出到stderr,主要用于调试,生产环境建议使用fmt包。

异常

通常go的异常,可以当作返回值,所以有很多这种模板代码:

if err != nil {
	log.Fatal(err) // print and exit
}

Note

这就是go的设计哲学,显示处理err

Error

通用error类型(内置):

type error interface {
	Error() string
}

runtime.Error扩展了它:

type Error interface {
	error
 
	// RuntimeError is a no-op function but
	// serves to distinguish types that are runtime
	// errors from ordinary errors: a type is a
	// runtime error if it has a RuntimeError method.
	RuntimeError()
}

内置函数

panic

显示抛出异常。

panic(42)
panic("unreachable")
panic(Error("cannot parse"))

异常类型为runtime.Error

recover

捕获同一个goroutine中的panic,需要在defer中运行,否则返回nil

try catch

那么常规的try catch呢,go确实有一套组合逻辑,可以模拟这个过程,但是这并不是他的本意,所以不要这么做:

func protectMe() {
    // 3. 这里的 defer 像 finally,内部的 recover 像 catch。注意如果代码没有执行到defer就panic了,defer是不会执行的
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("捕获到了异常:", r)
        }
    }()
 
    fmt.Println("准备出发...")
    
    // 1. 触发异常 (throw)
    panic("糟糕,出大问题了!")
 
    // 2. 这行永远不会执行
    fmt.Println("这句话看不见")
}

本质上:

  • defer 类似clean,用于清理,”优雅的离开“
  • recover 以防程序崩溃,局部的问题不至于影响整个进程
  • panicruntime exception,也就是无法编译时就能确定的问题,这是程序员应该处理的错误,但是你没有处理,所以“痛”

数据结构

除了内置类型中的基本数据结构外,还有些常用的数据结构。

sync.Map

并发场景专用。

核心原理

  • 空间换时间:内部是 read map + dirty map 双缓冲
  • 读多写少场景性能极佳,写多读少用 map+Mutex 更好
var m sync.Map
 
m.Store(key, val)           // 写
val, ok := m.Load(key)      // 读
val, loaded := m.LoadOrStore(key, val)  // 不存在才写入
m.Delete(key)
m.Range(func(k, v any) bool { ... })  // 遍历(会阻塞写)

container/heap

优先队列。

核心

  • 底层一般是数组实现的完全二叉树(数组存储,二叉树逻辑由heap提供)
  • 必须实现5个方法
type IntHeap []int
 
func (h IntHeap) Len() int           { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] }  // < 最小堆, > 最大堆
func (h IntHeap) Swap(i, j int)      { h[i], h[j] = h[j], h[i] }
 
func (h *IntHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
    old := *h
    n := len(old)
    x := old[n-1]
    *h = old[:n-1]
    return x
}
 
// 使用
h := &IntHeap{2, 1, 5}
heap.Init(h)
heap.Push(h, 3)
min := heap.Pop(h)  // 返回 any,需类型断言

container/list

双向链表

核心

  • 每个元素是 list.Element 节点,包含前后指针
  • O(1) 头尾插入删除,但随机访问是O(n)
l := list.New()
 
// 队列操作
l.PushBack(val)        // 入队
elem := l.Front()      // 查看队首
l.Remove(l.Front())    // 出队
 
// 栈操作
l.PushBack(val)        // 入栈
l.Remove(l.Back())     // 出栈
 
// 移动元素(LRU核心操作)
l.MoveToBack(elem)     // 元素移到尾部(标记最近使用)
 
// 遍历
for e := l.Front(); e != nil; e = e.Next() {
    v := e.Value.(YourType)
}

并发

go程序默认就跑在goroutine上,使用GMP模型,天生并发友好。

Communication

package selectp
 
import "fmt"
 
func fibonacci(c, quit chan int) {
	x, y := 0, 1
	defer func() {
		fmt.Println("fibonacci done")
	}()
	for {
		select {
		case c <- x:
			x, y = y, x+y
		case <-quit:
			fmt.Println("quit")
			return
		}
	}
}
 
func Run() {
	c := make(chan int) // 无缓冲,意味着接受和发送方要同时准备就绪才能执行,接收和写入此时是个原子操作
	quit := make(chan int)
	go fibonacci(c, quit)
	for range 10 {
		fmt.Println(<-c)
	}
	quit <- 0
	// 发送者关闭
	close(c)
	close(quit)
}

Thread safe

channel只是让goroutine之间的沟通更加方便,心智负担更小,但是它无法保证对象的线程安全。 线程安全还是老办法: 加锁

锁的本质就是一个同步屏障,阻塞直至获取锁,执行,释放锁,这保证了锁中间的代码一次只有一个线程在执行。

Tip

根据go的组合优于继承的哲学,最佳实践就是需要锁的对象持有锁,比如封装为struct,这样更方便合理。

package lock
 
import (
	"fmt"
	"sync"
	"time"
)
 
type Counter struct {
	mu sync.Mutex
	n  int
}
 
func (c *Counter) Inc() {
	c.mu.Lock()
	c.n++
	c.mu.Unlock() // 直接unlock
}
 
func (c *Counter) Dec() {
	c.mu.Lock()
	c.n--
	defer c.mu.Unlock() // 更规范
}
 
func Run() {
	var c Counter // 不需要显示初始化,sync.Mutex的0值是可用的
	for range 10 {
		go func() {
			for range 10 {
				c.Inc()
				time.Sleep(time.Millisecond)
				c.Dec()
			}
		}()
	}
	time.Sleep(time.Second)
	fmt.Println(c.n) // 0
}

Caution

不要随便复制锁,会造成状态混乱。参考javathis作为锁,就是用来确保锁的唯一性。

unsafe

provides facilities for low-level programming including operations that violate the type system

更底层,所以才不安全,无法使用go本身的类型约束机制,你需要自行完成类型检查。

而且这个包是由编译器直接实现的,特殊包说明

相关方法:

package unsafe
 
type ArbitraryType int  // shorthand for an arbitrary Go type; it is not a real type
type Pointer *ArbitraryType
 
func Alignof(variable ArbitraryType) uintptr
func Offsetof(selector ArbitraryType) uintptr
func Sizeof(variable ArbitraryType) uintptr
 
type IntegerType int  // shorthand for an integer type; it is not a real type
func Add(ptr Pointer, len IntegerType) Pointer
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
func SliceData(slice []ArbitraryType) *ArbitraryType
func String(ptr *byte, len IntegerType) string
func StringData(str string) *byte

它定义了一个类型ArbitraryType,表示任意类型,它的底层类型int,没有意义,仅仅是个占位类型: it is not a real type,你也无法使用var i unsafe.ArbitraryType这种声明;同样的IntegerType表示任意整数类型。

Tip

unsafe本质就是绕过一系列限制,直接操作内存。 它的unsafe是认真的,尽量不要用。

Pointer

  • 万能指针,但是这个类型的值无法解引用。
  • 任何指针或者unitptr都可以转换为Pointer,反之亦可。

uintptr

  • 严格来说,这不是指针类型,应该叫地址类型,所以当然可以uint。指针本质就是地址,所以指针最终可以转换为这个类型。
  • uintptr可以存储当前系统下任意地址值,类似uint, int,大小和系统相关的。

它暴露了指针最真实的一面。常规的指针一定是和类型绑定的,它的操作也和类型强关联,但是uintptr你基本可以把它当作uint,自然的可以进行数字运算,比如进行地址计算。

ptr := uintptr(unsafe.Pointer(&someArray[0])) // 使用unsafe.Pointer作为桥梁
// 可以像普通整数一样运算
nextPtr := ptr + unsafe.Sizeof(element)

这意味着我可以从内存的角度来读写任意内存区域(根据入口地址和类型计算内部地址),毕竟所有的操作最终的实现就是内存读写。

内存布局

使用Sizeof, Alignof, Offsetof可以准确的定位对象内存地址,在编写高性能代码或与 C 代码交互时非常有用。

Sizeof

所见即所得,获取这个地址的内存区域的字节数量,不会进行额外的比如解引用。

unsafe.Sizeof(int(0))        // 8 (64位系统)
unsafe.Sizeof(int32(0))      // 4
unsafe.Sizeof(int64(0))      // 8
unsafe.Sizeof("hello")       // 16 (字符串头部:指针+长度)
unsafe.Sizeof([]int{1,2,3})  // 24 (切片头部:指针+长度+容量)

注意因为Alignof的存在,Sizeof并不一定是直观可计算的,特别是针对struct,不是简单的field相加。

Alignof

获取内存对齐要求(和系统有关)。

Tip

内存对齐是指该类型的变量在内存中起始地址必须是 Alignof 返回值的倍数。

为什么要对齐? 从物理设计上,使用这个地址,而不是连续的顺序地址,能让数据尽可能的落在少的物理读取单元,避免多次读取,虽然浪费了空间,但是提高了效率。

关于内存对齐:第8点

对于struct:

type Example struct {
    a int8    // alignof = 1
    b int64   // alignof = 8
    c int32   // alignof = 4
}
unsafe.Alignof(Example{})  // 8 (取字段中最大的对齐值)

此时struct本身起始地址要是8的倍数地址,同时里面的field也会遵守各自的alignof,这也造成struct的实际size不是简单的求和:

unsafe.Sizeof(Example{}) // 24
// 如果filed是b,c,a的顺序,此时size又变成了16

Offsetof

计算field相对于起始地址的偏移量

func Offsetof(selector ArbitraryType) uintptr // 比如是selector,比如struct.field
 
// 效果
uintptr(unsafe.Pointer(&s)) + unsafe.Offsetof(s.f) == uintptr(unsafe.Pointer(&s.f))

高级工具

  1. Add 作用:将指针向前偏移 len 个字节(类似 C 的 ptr + len)

    arr := []int{10, 20, 30, 40, 50}
     
    // 获取第3个元素的指针 (偏移 2 * 8 = 16 字节)
    ptr := unsafe.Pointer(&arr[0])
    elem3Ptr := unsafe.Add(ptr, unsafe.Sizeof(arr[0])*2)
     
    val := *(*int)(elem3Ptr)  // 30
  2. Slice 作用:从指针和长度创建一个切片。注意不要越界,这里没有编译器来约束了。

    arr := [5]int{10, 20, 30, 40, 50}
     
    // 从第3个元素开始,创建长度为3的切片
    ptr := unsafe.Pointer(&arr[2])
    slice := unsafe.Slice((*int)(ptr), 3)  // [30, 40, 50]
     
    // 可以修改底层数组
    slice[0] = 999
    fmt.Println(arr)  // [10, 20, 999, 40, 50]	
  3. SliceData 作用:获取切片视角内底层数组的第一个元素的指针,不是原始底层数组的起始地址,这个地址slice本身并不知道

    s := []int{10, 20, 30}
     
    // 获取底层数组指针
    ptr := unsafe.SliceData(s)
     
    // ptr 指向 &s[0]
    fmt.Println(*ptr)  // 10
     
    // 可以像数组一样遍历
    for i := 0; i < len(s); i++ {
        val := *(*int)(unsafe.Add(unsafe.Pointer(ptr), unsafe.Sizeof(int(0))*uintptr(i)))
        fmt.Println(val)
    }
  4. String 作用:从字节指针和长度创建一个字符串

    data := []byte{'H', 'e', 'l', 'l', 'o'}
     
    // 从字节切片创建字符串(零拷贝)
    str := unsafe.String(&data[0], len(data))
    fmt.Println(str)  // "Hello"
     
    // 注意:如果修改了 data,str 也会"看到"变化
    data[0] = 'X'
    fmt.Println(str)  // "Xello" (行为未定义,可能崩溃)
  5. StringData 作用:获取字符串底层字节数组的指针。 警告: 千万不要通过这种形式改变字符串,go约定字符串不可修改是有原因的,比如字符串共享、map的key hash等等,改了很可能造成程序崩溃,除非你对它的内存生命周期完全掌握。

    s := "Hello"
     
    // 获取字符串数据的指针(只读!)
    ptr := unsafe.StringData(s)
     
    // 读取第一个字符
    fmt.Println(*ptr)  // 72 ('H')
     
    // 遍历所有字符
    for i := 0; i < len(s); i++ {
        b := *(*byte)(unsafe.Add(unsafe.Pointer(ptr), uintptr(i)))
        fmt.Printf("%c ", b)  // H e l l o
    }

zerobase

对于没有字段的struct[0]TYPE这种类型的对象,他们的size为0,为了节省内存,会将他们指向zerobase表示的特殊地址上。

var a struct{}
var b [0]int
 
fmt.Printf("%p\n", &a) // 0x5a5c80
fmt.Printf("%p\n", &b) // 0x5a5c80

单元测试

go直接了内置的单元测试。

只要以测试文件_test结尾即可。

测试方法,需要定义一个testing.T的指针形参,运行时会被自动注入,它提供了一些方法,比如ErrorfLogfError, Log, Fail, `FailNow等,用于控制测试函数的一些行为。

典型的测试方法如下:

// testing.T类型的指针,自动注入
func TestHello(t *testing.T) {
	name := "Gladys"
	want := regexp.MustCompile(`\b` + name + `\b`)
	got, err := Hello(name)
	if !want.MatchString(got) || err != nil {
		t.Errorf(`Hello(%q) = %q, %v, want match for %#q, nil`, name, got, err, want)
	}
}

可以使用go test [-v] [./...]来运行,查看输出

Tip

./...表示当前目录及其所有子目录(递归)

run && build && install

运行:

package main下,且有main函数

go run .
# 或者
go run <path>

编译:

  • 对于package main,且有main函数,编译生成可执行二进制文件
  • 对于普通的packagebuild仅仅做编译检查,不会生成二进制文件,但会生成编译缓存,供后续构建使用
go build [./...]
go build <path>

安装

默认安装在$GOPATH/bin,也可以指定GOBIN:

go env -w GOBIN=/path/to/your/bin

安装命令:

对于普通package,也就是lib包,install本质和build没有区别

go install [./...]