泛型
需要预先了解: 接口 包与模块:代码组织 泛型 Go 1.18 引入了泛型(generics):函数和类型可以带类型参数,在调用或使用时才确定具体类型。它解决的是"同一套逻辑要写很多遍,只有类型不同"的问题——比复制粘贴好维护,比 any + 类型断言更安全,也比接口更直接(不需要为每种类型实现方法)。 从重复代码出发 先看一个没有泛型的场景:为 int 和 float64 各写一遍求最小值的函数。 func MinInt(a, b int) int { if a < b { return a } return b } func MinFloat(a, b float64) float64 { if a < b { return a } return b } 两个函数除了类型以外完全相同。用泛型可以合成一个: func Min[T Ordered](a, b T) T { if a < b { ...
goroutine 与并发初探
需要预先了解: 函数 包与模块:代码组织 goroutine 与并发初探 Go 的并发能力从语言层面就开始了:一个 go 关键字就能启动一个 goroutine。这篇先讲清楚"并发"与"并行"的区别、goroutine 的启动与开销,再引出 sync.WaitGroup、sync.Once 这两个基础同步工具,最后给出 GMP 调度模型的概览,为后面几篇铺路。 并发与并行 先区分两个概念: 并发(concurrency):多个任务在重叠的时间段内推进,宏观上"同时"发生,微观上可能是交替执行; 并行(parallelism):多个任务在同一个时刻真正同时执行,需要多核支持。 并发是一种程序结构,并行是一种执行状态。一个程序可以并发但不并行(比如只给一个 CPU 核心)。 几个系统层面的执行单元: 概念 说明 进程 拥有独立地址空间的执行单元,进程之间默认不共享内存 线程 进程内的执行单元,共享进程的地址空间,由操作系统调度 协程 用户态的轻量执行单元,由语言运行时或库调度...
channel
需要预先了解: goroutine 与并发初探 channel channel 是 goroutine 之间传递数据的管道,也是 Go 并发模型的核心。它同时承担两件事:传递数据和同步。这一篇讲清楚无缓冲与有缓冲的差别、close 的规则、select 的多路复用,以及几个会让程序直接崩溃的操作。 设计哲学 Go 的并发信条是: 不要通过共享内存来通信,而要通过通信来共享内存。 意思是:与其让多个 goroutine 抢着读写同一块内存(然后用锁保护),不如让数据在 goroutine 之间传过来,同一时刻只有一方持有它。channel 就是传递的载体。 无缓冲 channel:同步语义 make(chan T) 创建的是无缓冲 channel。它的发送和接收必须同时就位才能完成——发送方会一直阻塞,直到有接收方来取: ch := make(chan int) go func() { fmt.Println("发送前") ch <- 1 fmt.Println("发送完成") }() time.Sleep(100 * ti...
sync 包与原子操作
需要预先了解: goroutine 与并发初探 channel sync 包与原子操作 多个 goroutine 同时读写同一份数据,如果不加保护,就会产生竞态条件(race condition)。这一篇讲清楚竞态是怎么发生的、怎么用 -race 检测它,以及 sync.Mutex、sync.RWMutex、sync/atomic、sync.Map、sync.Pool 各自的适用场景。 竞态条件 var counter int func main() { var wg sync.WaitGroup for i := 0; i < 1000; i++ { wg.Add(1) go func() { defer wg.Done() counter++ }() } wg.Wait() fmt.Println("counter =", counter) } 期望值是 1000,实际运行可能得到: counter = 968 原因在于 counter++ ...
context
需要预先了解: channel sync 包与原子操作 context 一个请求往往要派生多个 goroutine:查数据库、调下游服务、写日志。如果其中一步超时或客户端断开,其他步骤需要尽快停下。context 就是用来统一传递取消信号、截止时间和请求范围内的元数据的标准机制。 需求引入 假设一个函数要并发做三件事,任何一件失败或超时就整体放弃。如果每个 goroutine 各自用 done channel,代码会变成一堆手写的通知逻辑: done1 := make(chan struct{}) done2 := make(chan struct{}) // 谁来关闭?超时怎么办?取消要不要级联? context 把这些问题标准化:它是一棵可级联取消的树,父节点取消时所有子节点都会收到通知。 Context 接口 type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{...
并发设计模式与陷阱总结
需要预先了解: goroutine 与并发初探 channel sync 包与原子操作 context 并发设计模式与陷阱总结 前四篇讲了 goroutine、channel、同步原语和 context 的用法,这一篇把它们组织成可复用的模式,并把并发编程里最容易踩的坑集中列出来。 pipeline **pipeline(流水线)**把任务拆成若干阶段,每个阶段的输入输出都是 channel,数据像流水一样流过: // 生成器:产生数据 func gen(nums ...int) <-chan int { out := make(chan int) go func() { defer close(out) for _, n := range nums { out <- n } }() return out } // 阶段:处理数据 func square(in <-chan int) <-chan int { out ...
内存分配与逃逸分析
需要预先了解: 数组与切片 结构体与指针 方法 内存分配与逃逸分析 一个变量分配在栈上还是堆上,由编译器通过**逃逸分析(escape analysis)**决定。这个决定直接影响性能:栈分配只是移动一下栈指针,几乎零成本;堆分配要走分配器、还要被垃圾回收扫描。这一篇讲清楚逃逸分析怎么看、哪些写法会让变量逃逸,以及内存对齐和分配大小分级这两个容易忽略的细节。 栈与堆 维度 栈 堆 分配成本 极低(移动栈指针) 较高(分配器 + 可能的锁) 回收方式 函数返回时自动释放 由垃圾回收器回收 生命周期 与函数调用绑定 由可达性决定 访问速度 更快(缓存友好) 稍慢 Go 的 goroutine 栈是动态增长的:初始约 8KB,不够时自动扩容(最大到 1GB,64 位平台)。栈上变量的地址在扩容时会被修正,所以程序里取到的指针在逻辑上始终有效。 Go 的分配器 Go 运行时自带内存分配器,按对象大小走不同的路径: 小对象(≤ 32KB): mcache(每个 P 私有,无锁) | mcent...
垃圾回收
需要预先了解: 内存分配与逃逸分析 垃圾回收 Go 的垃圾回收器(GC,Garbage Collector)负责回收不再被引用的堆对象。它的设计目标很明确:把停顿时间压到亚毫秒级,让服务端程序不必为 GC 停顿做特殊设计。这一篇讲清楚三色标记、写屏障、触发时机和调优手段。 为什么需要 GC 堆上的内存生命周期无法在编译期确定,只能运行时判断。手动管理会带来两类问题: 忘记释放:内存越用越多,最终耗尽; 释放过早:对象还在被引用就被回收,后续访问到已失效的内存。 垃圾回收器把"什么时候释放"这件事交给运行时:它周期性地从根对象(栈上的变量、全局变量、寄存器)出发,找出所有还能访问到的对象,其余的就是垃圾。 GC 演进时间线 Go 的 GC 从"全程暂停"演进到"几乎不停顿",每一步都是被上一步的问题推动的: 阶段 做法 问题 STW 标记清扫 暂停所有 goroutine,标记垃圾并清扫 停顿与堆大小成正比,堆大了没法用 并发标记清扫 标记阶段与用户代码并发执行 并发时对象引用会变化...
调度器:GMP 模型深入
需要预先了解: goroutine 与并发初探 channel 调度器:GMP 模型深入 goroutine 与并发初探 里给出了 GMP 的概览。这一篇展开内部结构:G 里存了什么、调度器怎么选下一个 G、work stealing 和 hand-off 如何工作、抢占是怎么实现的,以及为什么网络 IO 不会占住线程。 G、M、P 的职责 G G G G G = goroutine(任务:栈 + 状态 + 调度信息) | | | | v v v v +---------------+ P = 逻辑处理器(本地队列 + 资源配额) | P runq | +---------------+ | v +---------------+ M = 内核线程(真正执行指令) | M | +---------------+ 结构 内容 G 栈指...
切片、map 与字符串的底层实现
需要预先了解: 数组与切片 map 与字符串 内存分配与逃逸分析 切片、map 与字符串的底层实现 这一篇把三种最常用的类型拆到运行时层面:切片头到底存了什么、append 扩容的精确规则、map 的桶结构与渐进式搬迁、字符串为什么不可变。搞懂这些,前面几篇里"看起来反直觉"的行为就都有解释了。 切片头 切片在运行时是一个三元组: type slice struct { array unsafe.Pointer // 指向底层数组 len int // 长度 cap int // 容量 } 用 unsafe 观察: s := make([]int, 3, 5) fmt.Printf("切片: len=%d cap=%d 底层数组指针=%p 首元素地址=%p\n", len(s), cap(s), unsafe.SliceData(s), &s[0]) fmt.Println("切片头大小:", unsafe.Sizeof(s)) 切片: le...



