1、进程、线程、协程
进程:
process
进程是资源分配的基本单位,包括内存、文件描述符等。是计算机运行程序的一个独立单位。每个进程具有自己独立的内存空间,进程之间不能直接访问彼此的数据。
线程:
thread
cpu调度的基本单位,一个进程可以包含多个线程,线程除了有一些自己的必要的堆栈空间之外,其他资源都是共享线程中的,共享的资源包括:
- 所有线程共享相同的虚拟地址空间,即它们可以访问同样的代码段、数据段和堆栈段
- 文件描述符:进程打开的文件描述符进程级别的资源,所以同一个进程中的线程可以共享打开的文件描述符,意味着它们可以同时读写同一个文件
- 全局变量:全局变量是进程级别的变量,可被同一个进程中的所有线程访问和修改
- 静态变量:静态变量也是进程级别的变量,在同一个进程中的线程之间共享内存空间
- 进程id、进程组id
独占的资源:
- 线程id
- 寄存器组的值
- 线程堆栈
- 错误返回码
- 信号屏蔽码
- 线程的优先级
协程:
用户态的线程,可以通过用户程序创建、删除。协程切换时不需要切换内核态 ,上下文切换的开销比线程和进程更小。协程是用户态的轻量级线程,允许在程序中执行非阻塞操作。
协程与线程的区别
- 线程是操作系统的概念,而协程是程序级的概念。线程由操作系统调度执行,每个线程都有自己的执行上下文,包括程序计数器、寄存器等。而协程由程序自身控制。
- 多个线程之间通过切换执行的方式实现并发。线程切换时需要保存和恢复上下文,涉及到上下文切换的开销。而协程切换时不需要操作系统的介入,只需保存和恢复自身的上下文,切换开销较小。
- 线程是抢占式的并发,即操作系统可以随时剥夺一个线程的执行权。而协程是合作式的并发,协程的执行权由程序自身决定,只有当协程主动让出执行权时,其他协程才会得到执行机会。
线程的优点:
- 创建一个新线程的代价要比创建一个新进程小的多
- 线程之间的切换相较于进程之间的切换需要操作系统做的工作很少
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
线程的缺点
共享内存带来的安全性问题、缺乏隔离性、复杂的同步机制、调试困难等
有栈协程和无栈协程
- 有栈协程为每个协程分配独立的堆栈。同一协程的局部变量存储在这个堆栈上。切换协程时,需要保存当前协程的堆栈上下文,恢复目标协程的堆栈上下文。这意味着在切换时需要一定的开销(保存和恢复堆栈信息),但不涉及操作系统内核。Go语言中的有栈协程:goroutines
- 无栈协程不为每个协程单独分配堆栈,而是将局部变量和协程状态保存在共享的堆中。切换时通过某种状态机制进行,而无需显式的上下文切换。JavaScript中的无栈协程async/await,用于异步编程,状态机来管理,await中断当前执行,把上下文存储在公共内存中,后续某个点时继续。
2、Go语言–垃圾回收
GC-垃圾回收-Garbage Collection
(1)Go v1.3之前的标记-清除:
1、暂停业务逻辑,找到不可达的对象,和可达对象
2、开始标记,程序找出它所有可达的对象,并做上标记
3、标记完了之后,然后开始清除未标记的对象
4、停止暂停,让程序继续跑。然后循环重复这个过程,直到process程序生命周期结束
标记-清除的缺点:
1、STW(stop the world):在标记和清除过程中,程序会暂停所有的正常执行操作,对于实时系统会出现短暂的应用卡顿
2、标记需要扫描整个heap(堆,内存空间),查看每一个对象是否仍可被程序引用,在大型应用程序中,这个过程可能会耗费大量时间资源
3、清除数据会产生heap碎片:在清除阶段,已标记为无需保留的对象会被去掉,留下的不连续的内存空间称为碎片。如果堆中碎片过多,可能造成内存分配效率低,尤其是下一次需要分配大块内存时。
为了减少STW的时间,对“标记-清除”中的第三步和第四步进行了替换。
(2)Go v1.5 三色标记法
- **白色:**未被访问的对象【待回收】
- 灰色:已被访问但其引用尚未被完全扫描的对象【正在检查】
- 黑色:已被访问且其所有引用都已被扫描的对象【不可回收】
1、把新创建的对象,默认的颜色都标记为“白色”
2、每次GC回收开始,然后从根节点开始遍历所有对象,把遍历到的对象从白色集合放入“灰色”集合
3、遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入到黑色集合
4、重复第三步,直到灰色无任何对象
5、回收所有的白色标记的对象,回收垃圾
三色标记法不采用STW时可能出现的问题:
需同时满足如下条件:
1、白色对象被黑色对象引用:
如果在标记期间,程序动态地创建了指向白色对象的新引用,而白色对象被黑色对象引用,可能没有被标记为灰色,这个白色对象就不被视为可达对象,从而可能错误地被回收
2、灰色对象对白色对象的引用丢失
一个仍然存在的对象但因引用关系错变而被错误地标记为白色,进而被回收的错误
这两种情况同时满足,会出现对象丢失
强三色不变式
- 定义:黑色对象不能直接引用白色对象,只能通过灰色对象间接引用白色对象
- 本质:保证了黑色对象和白色对象之间必须有灰色对象作为“中间人”
弱三色不变式
- 定义:所有被黑色对象引用的白色对象,必须直接或间接地被某个灰色对象引用
- 本质:保证了从根对象出发,能够访问所有活跃的对象
屏障
-
插入屏障
工作原理:当程序中一个已经确认保留的对象(黑色)引用了一个可能被回收的对象(白色)时,系统会立即将那个白色对象标记为“需要再检查”(灰色),这样就不会错误地回收它。
适用范围:只在内存的“堆”区域自动工作,但在“栈”区域不会自动触发。
额外措施:在真正开始回收前,系统会专门再检查一次栈空间中的所有对象,并暂停程序运行(STW),确保没有遗漏任何不该回收的对象
-
删除屏障:当程序中删除了某个对象的引用时,如果失去引用的对象是灰色或白色的,系统会将它标记为灰色。这确保了这个对象会被重新检查,不会因为失去某个引用而被错误回收。
不足:即使某个对象失去了所有引用(实际上已成为垃圾),但它仍然会被标记为灰色,而灰色对象在当前这轮GC中是不会被回收的。
(3)Go v1.8的三色标记法+混合写屏障机制
初始状态:所有对象的初始状态都是白色(包括栈上和堆上的所有对象)
[1] GC开始将栈上的可达对象全部扫描并标记为黑色(之后不再进行第二次重复扫描,无需STW)
[2] GC期间,任何在栈上创建的新对象,均为黑色
[3] 堆上被删除对象标记为灰色
[4] 堆上被添加的对象标记为灰色
GC垃圾收集的多个阶段
第一阶段:准备打扫(标记准备阶段)
- 安排一位打扫人员准备工作(启动后台标记任务)
- 短暂地请所有人停止活动,暂时"冻结现场"(STW暂停程序)
-如果这次是特别要求的大扫除,先处理上次没清理完的区域(如果当前垃圾收集循环是强制触发的,我们还需要处理还未被清理的内存管理单元;) - 记下所有明显应该保留的物品,比如你手中的、桌上正在用的东西(将根对象入队)
- 安装监控系统,记录接下来物品的挪动情况(开启写屏障)
第二阶段:边用边打扫(标记阶段)
- 让大家继续正常活动(恢复用户协程)
- 打扫人员开始工作,用三种标签标记物品(使用三色标记法开始标记,此时用户协程和标记协程并发执行):
白色:可能是垃圾
灰色:正在检查
黑色:确定要保留
重要的是,在这个阶段你可以继续使用房间,而打扫人员会同时工作
第三阶段:确认标记完成(标记终止阶段)
- 再次短暂请大家停止活动(暂停用户协程)
- 计算下次什么时候需要再次打扫(计算下一次GC触发时机)
- 叫醒负责清理的工作人员(唤醒后台清扫协程)
第四阶段:实际清理(清理阶段)
- 关闭监控系统(关闭写屏障)
- 让大家继续正常活动(恢复用户协程)
- 清理人员悄悄地、渐进地把所有没标记保留的物品(白色物品)清理掉,这个过程不影响大家正常使用房间(异步清理回收)
根对象
**根对象(root object)**是指那些能够从全局可达的地方访问到的对象。垃圾回收器会从根对象开始,再遍历根对象的引用关系,逐步追踪并标记所有可达的对象。任何未被标记的对象都会被认为是垃圾,最终被回收释放。
[1] 全局变量
// 全局变量作为根对象的例子
var globalCache = make(map[string]*UserData)
type UserData struct {
Name string
Age int
// 其他字段...
}
func main() {
// 即使没有其他引用,这个对象也不会被回收
// 因为它可以从全局变量访问到
globalCache["user1"] = &UserData{Name: "张三", Age: 30}
// 这里即使调用了GC也不会回收globalCache中的对象
runtime.GC()
}
[2]当前正在执行的函数的局部变量
func processUser() {
// 局部变量作为当前函数的根对象
userData := &UserData{Name: "李四", Age: 25}
// userData在函数内可访问,是当前函数的根对象
fmt.Println(userData.Name)
// 函数结束后,userData不再是根对象
// 如果没有其他引用,它将被回收
}
[3]当前正在执行的 goroutine 的栈中的变量
func main() {
// 启动一个goroutine
go func() {
// 这个userData对象在goroutine栈中
// 是这个goroutine的根对象
userData := &UserData{Name: "王五", Age: 40}
// 只要goroutine还在运行,这个对象就不会被回收
time.Sleep(10 * time.Second)
fmt.Println(userData.Name)
}()
// 主程序继续执行
time.Sleep(20 * time.Second)
}
[4]其他和运行时系统相关的数据结构和变量
三色标记法的缺点:会让程序偶尔卡顿,消耗额外内存,在大量垃圾时效率下降,并可能导致内存变得“七零八落”不好利用。
3、GC的触发条件
主动触发(手动触发):通过调用runtime.GC来触发GC,此调用阻塞式地等待当前GC运行完毕
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
// 创建一些垃圾数据
for i := 0; i < 10000; i++ {
_ = make([]byte, 1024)
}
fmt.Println("手动触发GC前的内存统计:")
printMemStats()
// 手动触发GC
fmt.Println("手动触发GC...")
runtime.GC()
fmt.Println("手动触发GC后的内存统计:")
printMemStats()
}
func printMemStats() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("系统分配的内存: %v KB\n", m.Sys/1024)
fmt.Printf("已使用的内存: %v KB\n", m.Alloc/1024)
fmt.Printf("累计分配的内存: %v KB\n", m.TotalAlloc/1024)
fmt.Printf("GC执行次数: %v\n", m.NumGC)
fmt.Println()
}
被动触发
[1]步调(pacing)算法
控制内存增长的比例,每次内存分配时检查当前内存分配量是否已经达到阈值(环境变量GOGC):默认100%,即当内存扩大一倍时启动GC
[2]系统监控
当超过两分钟没有产生任何GC时,强制触发GC
4、GC调优
- 控制内存分配速度和Goroutine 数量
- 少量使用“+”连接字符串。每次"+"连接,go都会创建一个全新的字符串,而不是在原有基础上添加。使用strings.Builder代替“+”连接
package main
import (
"fmt"
"strings"
"time"
)
func main() {
// 低效方式:使用+连接
start := time.Now()
result := ""
for i := 0; i < 10000; i++ {
result += "a"
}
fmt.Printf("使用+连接耗时: %v\n", time.Since(start))
// 高效方式:使用strings.Builder
start = time.Now()
var builder strings.Builder
for i := 0; i < 10000; i++ {
builder.WriteString("a")
}
result = builder.String()
fmt.Printf("使用Builder耗时: %v\n", time.Since(start))
}
使用+连接耗时: 10.500625ms
使用Builder耗时: 73.667µs
3.slice提前分配内存
4.避免map key对象过多
5.变量复用,减少对象分配
6.增大GOGC的值
5、GMP调度和CSP模型
- CSP模型是”以通信的方式来共享内存“,不同于传统的多线程通过共享内存来通信。用于描述两个独立的并发实体通过共享的通讯channel来进行通信的并发模型。用“传纸条”代替“共用笔记本”
- GMP调度
G:goroutine,go的协程,每个go关键字都会创建一个协程,”工人“
M:machine,工作线程,数量对应真实的cpu数,”机器“
P:process,包含运行go代码所需的必要资源,用来调度G和M之间的关联关系,其数量可以通过GOMAXPROCS来设置,默认为核心数,”工作台“
线程想运行任务就得获取 P,从 P 的本地队列获取 G,当 P 的本地队列为空时,M 也会尝试从全局队列或其他 P 的本地队列获取 G。M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。如下图:
go语言运行时(runtime)系统中GMP调度模型的核心数据结构
[1]G 结构体 - Goroutine(工人)
type g struct {
goid int64 // 唯一的goroutine的ID
sched gobuf // goroutine切换时,用于保存g的上下文
stack stack // 栈
gopc // 创建这个goroutine的go语句的程序计数器位置
startpc uintptr // goroutine函数的程序计数器位置
// ...其他字段省略...
}
[2] P 结构体 - Processor(工作台)
type p struct {
lock mutex
id int32
status uint32 // 工作台状态:空闲/运行中/...
runqhead uint32 // 本地队列队头
runqtail uint32 // 本地队列队尾
runq [256]guintptr // 本地队列,固定大小为256的数组
runnext guintptr // 下一个优先执行的goroutine
// ...其他字段省略...
}
[3] M 结构体 - Machine(机器)
type m struct {
g0 *g // 每个M都有一个自己的G0
curg *g // 当前正在执行的G
// ...其他字段省略...
}
[4] 全局调度器结构体
type schedt struct {
runq gQueue // 全局队列,链表(长度无限制)
runqsize int32 // 全局队列长度
// ...其他字段省略...
}
[5] Goroutine上下文结构体
type gobuf struct {
sp uintptr // 保存CPU的rsp寄存器的值
pc uintptr // 保存CPU的rip寄存器的值
g guintptr // 记录当前这个gobuf对象属于哪个Goroutine
ret sys.Uintreg // 保存系统调用的返回值
// ...其他字段省略...
}
Goroutine调度策略
- 队列轮转:P会周期性的将G调度到M中执行,执行一段时间后,保存上下文,将G放到队列尾部,然后从队列中再取出一个G进行调度,P还会周期性的查看全局队列是否有G等待调度到M中执行
- 系统调用:当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。M1的来源有可能是M的缓冲池,也可能是新建的。“当某个工人因为外部原因(如I/O操作需要等待时,不会浪费宝贵的工作台和机器资源,其他工人可以继续工作 ”
- 当G0系统调用结束后,如果有空闲的P,则获取一个P,M0与G0重新绑定,继续执行G0;如果没有,则将G0放入全局队列,等待被其他的P调度,然后M0将进入缓存池睡眠。
6、Goroutine切换时机
- select操作阻塞时
func worker() {
for {
select {
case data := <-channelA:
// 处理来自通道A的数据
fmt.Println("收到A的数据:", data)
case data := <-channelB:
// 处理来自通道B的数据
fmt.Println("收到B的数据:", data)
case <-time.After(2 * time.Second):
// 如果2秒内没有数据,做其他事情
fmt.Println("等待超时,先做点别的")
}
// 这里,如果所有case都阻塞,这个goroutine会让出CPU
}
}
- IO阻塞
func readFileWorker(filename string) {
// 打开文件操作可能阻塞,会触发goroutine切换
file, err := os.Open(filename)
if err != nil {
fmt.Println("无法打开文件:", err)
return
}
defer file.Close()
// 读取文件也可能阻塞,再次触发切换
data := make([]byte, 1024)
_, err = file.Read(data)
if err != nil {
fmt.Println("读取失败:", err)
return
}
fmt.Println("读取成功:", string(data))
}
// 调用
go readFileWorker("大文件.txt")
fmt.Println("不会等待文件读完,会继续执行")
- 阻塞在channel
func producer(ch chan int) {
for i := 0; i < 10; i++ {
// 如果通道已满,这里会阻塞并切换goroutine
ch <- i
fmt.Println("生产者生产了:", i)
time.Sleep(100 * time.Millisecond)
}
close(ch)
}
func consumer(ch chan int) {
for {
// 如果通道为空,这里会阻塞并切换goroutine
value, ok := <-ch
if !ok {
break // 通道关闭
}
fmt.Println("消费者消费了:", value)
time.Sleep(200 * time.Millisecond)
}
}
func main() {
ch := make(chan int, 3) // 容量为3的缓冲通道
go producer(ch)
go consumer(ch)
time.Sleep(3 * time.Second)
}
- 程序员显式编码操作
func cooperativeWorker(id int) {
for i := 0; i < 3; i++ {
fmt.Printf("工人%d正在工作...\n", id)
// 做一些工作
time.Sleep(100 * time.Millisecond)
// 显式让出CPU,让其他goroutine运行
runtime.Gosched()
fmt.Printf("工人%d回来继续工作\n", id)
}
}
func main() {
// 启动3个工人
for i := 1; i <= 3; i++ {
go cooperativeWorker(i)
}
// 等待所有工人完成
time.Sleep(2 * time.Second)
}
- 等待锁
var (
counter = 0
mutex = &sync.Mutex{}
)
func lockedWorker(id int) {
for i := 0; i < 3; i++ {
fmt.Printf("工人%d准备获取锁...\n", id)
// 尝试获取锁,如果锁被占用,会阻塞并切换
mutex.Lock()
fmt.Printf("工人%d获得了锁\n", id)
// 临界区 - 操作共享资源
counter++
fmt.Printf("工人%d修改counter为%d\n", id, counter)
time.Sleep(100 * time.Millisecond) // 模拟工作
// 释放锁
mutex.Unlock()
fmt.Printf("工人%d释放了锁\n", id)
// 不操作共享资源的其他工作
time.Sleep(50 * time.Millisecond)
}
}
func main() {
// 启动3个工人
for i := 1; i <= 3; i++ {
go lockedWorker(i)
}
// 等待工人完成
time.Sleep(2 * time.Second)
fmt.Printf("最终counter值: %d\n", counter)
}
- 程序调用:CPU密集任务的强制调度,在Go 1.14之前,函数调用点(如调用heavyCalculation)可能是调度点;在Go 1.14及之后,无论如何,运行过久的goroutine会被强制调度让出CPU
func cpuIntensiveWorker(id int) {
start := time.Now()
count := 0
for time.Since(start) < 3*time.Second {
// 非常消耗CPU的操作
for i := 0; i < 1000000; i++ {
count++
}
// 即使这个工人不主动休息,Go运行时也会在某些点强制它休息
// 例如,函数调用可能是一个调度点
heavyCalculation(id)
fmt.Printf("工人%d已工作: %v, 计数: %d\n",
id, time.Since(start), count)
}
}
func heavyCalculation(id int) int {
// 这个函数调用可能是一个调度点
result := 0
for i := 0; i < 100000; i++ {
result += i
}
return result
}
func main() {
// 启动两个CPU密集型工人
for i := 1; i <= 2; i++ {
go cpuIntensiveWorker(i)
}
// 等待工人完成
time.Sleep(4 * time.Second)
fmt.Println("所有工人完成工作")
}