当前位置: 首页 > news >正文

GO的启动流程(GMP模型/内存)

目录

    • 第一部分:程序编译
    • 第二部分:函数解读
      • 1)Golang 核心初始化过程
      • 2)创建第一个协程
      • 3)启动系统调度
      • 4)跳转main函数
      • 5)总结
    • 第三部分:GMP模型
      • Goroutine
      • 流程解读
    • 第四部分:内存分配与管理机制
    • 第五部分:知识拓展
        • 1)MMP
        • 2)Channel解读
    • 第六部分:参考资料

第一部分:程序编译

有如下代码:

package main
import "fmt"
func main() {fmt.Println("Hello World!")
}#编译运行
go build main.go
./main
Hello World!

我们通过readelf来读main,结果如下:

readelf --file-header main 这个命令的意思是:
它使用 readelf 工具读取并显示 ELF(Executable and Linkable Format
可执行与可链接文件格式)文件 main 的 文件头(ELF header) 信息。
# readelf命令解释:
1)一个专门用来查看 ELF 格式文件信息的工具(属于 binutils 工具集)。
2)可以读取目标文件(object file)、可执行文件(executable)、共享库(shared library)等。
3)它与 objdump 类似,但 readelf 只处理 ELF 文件,不依赖系统的运行时环境,因此输出更精确和稳定。
# 参数--file-header 解释
表示只显示 ELF 文件头(不是全部内容,比如节表、符号表等就不显示)。
ELF 文件头位于文件最开始,描述了文件的整体结构,比如文件类型、架构、入口地址、节表偏移量等。

在这里插入图片描述
上图的一些参数解释:
在这里插入图片描述
主要看到这个main函数的程序入口地址是:0x45e9a0

nm:这是一个用于列出二进制文件(如可执行文件、目标文件、库文件等)中符号表的命令,通常用于程序调试和分析。
-n:nm 命令的选项,指定按符号的地址(数值)从小到大排序输出,而不是默认按符号名称排序。[root@node3 test]# nm -n main | grep 45e9a0
000000000045e9a0 T _rt0_amd64_linux结果解释:
T 表示这个符号位于 .text 段(代码段)并且是全局符号(全局可见的函数)。
这个地址 0x45e9a0 是程序加载到内存后的虚拟地址,不是文件的物理偏移。

我们需要进一步查看这个_rt0_amd64_linux符号的内容:

用gdb直接调试查看:
gdb main
(gdb) disassemble _rt0_amd64_linux
主要结果如下:
Dump of assembler code for function _rt0_amd64_linux:0x000000000045e9a0 <+0>:     jmpq   0x45af80 <_rt0_amd64>
End of assembler dump._rt0_amd64_linux 只是一个跳板,最终跳转到_rt0_amd64。我们进一步查看_rt0_amd64内容(通过地址直接跳转):
(gdb) disassemble /r _rt0_amd64
Dump of assembler code for function _rt0_amd64:0x000000000045af80 <+0>:     48 8b 3c 24     mov    (%rsp),%rdi0x000000000045af84 <+4>:     48 8d 74 24 08  lea    0x8(%rsp),%rsi0x000000000045af89 <+9>:     e9 12 00 00 00  jmpq   0x45afa0 <runtime.rt0_go.abi0>
End of assembler dump.rt0_amd64 只是将参数简单地保存一下后就 JMP 到 runtime·rt0_go 中了。继续查看runtime.rt0_go的主要内容:
(gdb) disassemble /r runtime.rt0_go
---Type <return> to continue, or q <return> to quit---0x45b0b2 <runtime.rt0_go.abi0+274>:  callq  0x45f660 <runtime.osinit.abi0>0x45b0b7 <runtime.rt0_go.abi0+279>:  callq  0x45f7a0 <runtime.schedinit.abi0>0x45b0bc <runtime.rt0_go.abi0+284>:  lea    0x5cb9d(%rip),%rax        # 0x4b7c60 <runtime.mainPC>0x45b0c3 <runtime.rt0_go.abi0+291>:  push   %rax0x45b0c4 <runtime.rt0_go.abi0+292>:  callq  0x45f800 <runtime.newproc.abi0>0x45b0c9 <runtime.rt0_go.abi0+297>:  pop    %rax0x45b0ca <runtime.rt0_go.abi0+298>:  callq  0x45b140 <runtime.mstart.abi0>0x45b0cf <runtime.rt0_go.abi0+303>:  callq  0x45d140 <runtime.abort.abi0>0x45b0d4 <runtime.rt0_go.abi0+308>:  retq   
解释:
1)Golang 核心初始化过程(对 golang 运行时进行关键的初始化如GMP的初始化,与调度逻辑)
callq  0x45f660 <runtime.osinit.abi0>
callq  0x45f7a0 <runtime.schedinit.abi0>
2)调用 runtime·newproc 创建第一个协程(创建一个主协程,并指明 runtime.main 函数是其入口函数)
callq  0x45f800 <runtime.newproc.abi0>
3)启动线程,启动调度系统(真正开启运行)callq  0x45b140 <runtime.mstart.abi0>
4)指定主函数的入口地址
runtime.mainPC 就是一个保存了 runtime.main 函数地址的全局变量,
它的主要用途就是——在 runtime.rt0_go 里作为参数传给 runtime.newproc,
从而让调度器创建一个 goroutine 去执行 runtime.main,也就是最终执行用户的 main.main()

第二部分:函数解读

1)Golang 核心初始化过程

  • runtime.osinit
#主要是获取CPU数量,页大小和操作系统初始化工作
func osinit() {ncpu = getproccount()physHugePageSize = getHugePageSize()osArchInit()
}
  • runtime.schedinit
// file:runtime/proc.go
// The bootstrap sequence is:
//
// call osinit
// call schedinit
// make & queue new G
// call runtime·mstart
//
// The new G calls runtime·main.
func schedinit() {......// 默认情况下 procs 等于 cpu 个数// 如果设置了 GOMAXPROCS 则以这个为准procs := ncpuif n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {procs = n}// 分配 procs 个 Pif procresize(procs) != nil {throw("unknown runnable goroutine during bootstrap")}......
} 注:
golang 的 bootstrap(启动)流程步骤分别是 
call osinit、call schedinit、make & queue new G 和 call runtime·mstart 四个步骤。
runtime.GOMAXPROCS 真正制约的是 GMP 中的 P,而不是 M

2)创建第一个协程

  • runtime.newproc
//file:runtime/proc.go
func newproc(fn *funcval) {...systemstack(func() {newg := newproc1(fn, gp, pc)_p_ := getg().m.p.ptr()runqput(_p_, newg, true)if mainStarted {wakep()}})
}
[解释]
1)runtime 代码经常通过调用 systemstack 临时性的切换到系统栈去执行一些特殊的任务
2)newproc1 创建一个协程
3)runqput 将协程添加到运行队列
4)akep 唤醒一个线程去执行运行队列中的协程
  • newproc1
// file:runtime/proc.go
func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {...//从缓存中获取或者创建 G 对象newg := gfget(_p_)if newg == nil {newg = malg(_StackMin)...}newg.sched.sp = spnewg.stktopsp = sp...newg.startpc = fn.fn...return newg
}
[解释]
1)gfget 中是尝试从缓存中获取一个 G 对象出来
2)在调用 malg 时传入了一个 _StackMin,这表示默认的栈大小,在 Golang 中的默认值是 2048
3)在 malg 创建完后,对新的 gorutine 对象进行一些设置后就返回了
  • malg
// file:runtime/proc.go
func malg(stacksize int32) *g {newg := new(g)if stacksize >= 0 {//这里会在 stacksize 的基础上为每个栈预留系统调用所需的内存大小 \_StackSystemstacksize = round2(_StackSystem + stacksize)}// 切换到 G0 为 newg 初始化栈内存systemstack(func() {newg.stack = stackalloc(uint32(stacksize))})// 设置 stackguard0 ,用来判断是否要进行栈扩容 newg.stackguard0 = newg.stack.lo + _StackGuardnewg.stackguard1 = ^uintptr(0)
} //round2 函数会将传入的值舍入为 2 的指数
  • stackalloc
//file:runtime/stack.go
func stackalloc(n uint32) stack {thisg := getg()...//对齐到整数页n = uint32(alignUp(uintptr(n), physPageSize))v := sysAlloc(uintptr(n), &memstats.stacks_sys)return stack{uintptr(v), uintptr(v) + uintptr(n)}
}
  • sysAlloc
// file:runtime/mem_darwin.go
func sysAlloc(n uintptr, sysStat *sysMemStat) unsafe.Pointer {v, err := mmap(nil, n, _PROT_READ|_PROT_WRITE, _MAP_ANON|_MAP_PRIVATE, -1, 0)if err != 0 {return nil}sysStat.add(int64(n))return v
}
  • runqput
// file:runtime/proc.go
// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the _p_.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(_p_ *p, gp *g, next bool) {...//将新 goroutine 添加到 P 的 runnext 中if next {retryNext:oldnext := _p_.runnextif !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {goto retryNext}if oldnext == 0 {return}// 将原来的 runnext 添加到运行队列中gp = oldnext.ptr()}//将新协程或者被从 runnext 上踢下来的协程添加到运行队列中
retry:h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumerst := _p_.runqtail//如果 P 的运行队列没满,那就添加到尾部if t-h < uint32(len(_p_.runq)) {_p_.runq[t%uint32(len(_p_.runq))].set(gp)atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumptionreturn}//如果满了,就添加到全局运行队列中if runqputslow(_p_, gp, h, t) {return}
}[解释]
在 runqput 中首先尝试将新协程放到 runnext 中,这个有优先执行权。
然后会将新协程,或者被新协程从 runnext 上踢下来的协程加入到当前 P(运行队列)的尾部去。
但还有可能当前这个运行队列已经任务过多了,那就需要调用 runqputslow 分一部分运行队列中的协程到全局队列中去。
  • wakep
通过上面的函数已经创建了GMP模型当中的 G和P了。还剩一个M。// file:runtime/proc.go
func wakep() {...startm(nil, true)
}
  • startm
// file:runtime/proc.go
// Schedules some M to run the p (creates an M if necessary).
func startm(_p_ *p, spinning bool) {mp := acquirem()//如果没有传入 p,就获取一个 idel pif _p_ == nil {_p_ = pidleget()}//再获取一个空闲的 mnmp := mget()if nmp == nil {//如果获取不到,就创建一个出来newm(fn, _p_, id)...return}...
}

3)启动系统调度

  • runtime.mstart
// file:runtime/proc.go
func mstart0() {...mstart1()
}// file:runtime/proc.go
func mstart1() {...// 进入调度循环schedule()
}
  • schedule
// file:runtime/proc.go
func schedule() {_g_ := getg()...
top:pp := _g_.m.p.ptr()//每 61 次从全局运行队列中获取可运行的协程if gp == nil {if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {lock(&sched.lock)gp = globrunqget(_g_.m.p.ptr(), 1)unlock(&sched.lock)}}if gp == nil {//从当前 P 的运行队列中获取可运行gp, inheritTime = runqget(_g_.m.p.ptr())}if gp == nil {//当前P或者全局队列中获取可运行协程//尝试从其它P中steal任务来处理//如果获取不到,就阻塞gp, inheritTime = findrunnable() // blocks until work is available}//执行协程execute(gp, inheritTime) 
}

4)跳转main函数

在第一部分有如下的代码:

   0x45b0bc <runtime.rt0_go.abi0+284>:  lea    0x5cb9d(%rip),%rax        # 0x4b7c60 <runtime.mainPC>0x45b0c3 <runtime.rt0_go.abi0+291>:  push   %rax0x45b0c4 <runtime.rt0_go.abi0+292>:  callq  0x45f800 <runtime.newproc.abi0>[解释]1)runtime.mainPC 变量在编译时被赋值为 runtime.main 的代码地址2)push 的值就是要作为新 goroutine 入口的函数地址
  • Main
// file:runtime/proc.go
// The main goroutine.
func main() {g := getg()// 在系统栈上运行 sysmonsystemstack(func() {newm(sysmon, nil, -1)})// runtime 内部 init 函数的执行,编译器动态生成的。doInit(&runtime_inittask) // Must be before defer.// gc 启动一个goroutine进行gc清扫gcenable()// 执行main initdoInit(&main_inittask)// 执行用户mainfn := main_main // make an indirect call, as the linker doesn't know the address of the main package when laying down the runtimefn()// 退出程序exit(0)
}

5)总结

OS 加载程序 → _rt0_amd64_linux → _rt0_amd64 → runtime.rt0_go├─ osinit()    # 初始化 OS 层信息├─ schedinit() # 初始化调度器和内存分配├─ newproc(mainPC=runtime.main) # 创建主协程└─ mstart()    # 启动调度循环└─ 调度器调度主协程 → runtime.main├─ 启动 sysmon├─ 启动 GC 清扫├─ 执行 runtime init├─ 执行用户 init└─ 执行用户 main.main()

第三部分:GMP模型

  • 问题: 为什么需要协程?
    在这里插入图片描述
    • CPU 上有个 Memory Management Unit(MMU) 单元
    • CPU 把虚拟地址给 MMU,MMU 去物理内存中查询页表,得到实际的物理地址
    • CPU 维护一份缓存 Translation Lookaside Buffer(TLB),缓存虚拟地址和物理地址的映射关系
    进程/线程切换的开销?
    1)直接开销
    切换页表全局目录(PGD)
    切换内核态堆栈
    切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)
    刷新 TLB
    系统调度器的代码执行
    2)间接开销
    CPU 缓存失效导致的进程需要到内存直接访问的 IO 操作变多
    3)进程 vs 线程
    线程切换相比进程切换,主要节省了虚拟地址空间的切换
  • 协程
    无需内核帮助,应用程序在用户空间创建的可执行单元,创建销毁完全在用户态完成
    在这里插入图片描述

Goroutine

  • G:表示 goroutine,每个 goroutine 都有自己的栈空间,定时器, 初始化的栈空间在 2k 左右,空间会随着需求增长。
  • M:抽象化代表内核线程,记录内核线程栈信息,当 goroutine 调度 到线程时,使用该 goroutine 自己的栈信息。
  • P:代表调度器,负责调度 goroutine,维护一个本地 goroutine 队 列,M 从 P 上获得 goroutine 并执行,同时还负责部分内存的管理。
    在这里插入图片描述

流程解读

在这里插入图片描述

新建 G → 本地队列↓ 队列满?——是——→ 搬一半到全局队列↓ 否
调度循环:本地有任务?——是——→ 执行↓ 否全局有任务?——是——→ 批量取到本地↓ 否窃取其他 P 的一半任务↓ 全无释放 P,M 休眠等待唤醒
  • 创建 G(newproc)
runtime 会创建一个新的 G 结构,包含执行函数、初始栈、调度状态等。
这个 G 会被加入当前 P 的本地队列(runq)。
设计原因:
本地队列无锁访问,速度快。
先放本地队列可以提高 CPU cache 命中率(新创建的 goroutine 往往与当前 G 相关联)。
  • 本地队列满了 → 把一半 goroutine 放到全局队列
每个 P 的本地队列有固定长度(默认 256 个 G)。
如果队列已满:
从本地队列中取一半 goroutine 移动到 全局队列(gqueue)。
把新 G 放回剩下的一半。
设计原因:
全局队列是所有 P 共享的,必须加锁;减少访问全局队列的频率。
把一半搬走而不是全部,保证本地队列还有任务继续跑。
  • 调度循环:本地队列有 G → 直接取来运行
M 绑定 P 后,会进入调度循环:
从 P 的本地队列取一个 G。
切换到该 G 的栈,执行其函数。
执行完一个时间片(或被阻塞、主动让出),调度器切回 M/P,继续取下一个 G。
设计原因:
本地队列是 M 绑定的 P 专用,取任务无需加锁 → 非常快。
  • 本地队列为空 → 从全局队列获取 G
本地队列为空 → 从全局队列获取 G
如果本地队列没任务:
尝试从全局队列取一批 G(最多取本地队列一半的空间)。
设计原因:
让长时间空闲的 P 能尽快参与调度其他 goroutine。
批量取可以减少锁竞争。
  • 全局队列也空 → 从其他 P 偷(Work Stealing)
如果全局队列也没任务:
随机选择一个邻居 P,从它的本地队列偷一半 goroutine。
设计原因:
Work Stealing 保证了负载均衡。
随机偷而不是按顺序,减少多个 P 同时争抢同一个 P 的概率。
  • 仍然没有任务 → M 进入休眠
如果本地队列、全局队列、邻居队列都没任务:
M 会解除与 P 的绑定,把 P 放回空闲队列。
M 进入休眠状态(阻塞在 park)。
等待新任务到来时,P 被唤醒并绑定空闲的 M(或新建 M)。
设计原因:
避免空转浪费 CPU。
节省线程资源,降低系统调用负担。
  • 总结
    Go 调度器优先用 P 的本地队列 运行 G,本地没任务就去全局队列或邻居 P 偷任务,全都没任务就让 M 休眠;这种 本地优先 + 全局补充 + 工作窃取 + 休眠唤醒 的模式,让 goroutine 在多核上高效、均衡地运行。

【注】更详细的流程解读,可以看参考资料部分的文章!!!

第四部分:内存分配与管理机制

在这里插入图片描述
流程解读:

  • 三层架构
[OS]  <-- 系统调用 mmap/brk -->[mheap]   # 全局堆管理(大对象 / 跨 P/M 的共享资源)[mcentral]# 按 size class 组织的 span 中央池(每个 size class 两个链表)[mcache]  # 每个 P 的本地缓存(小对象快速分配)
  • mcache:P 本地缓存
位置:每个 P(Processor)都有一个 mcache,即 goroutine 调度中的本地内存分配器。
作用:负责小对象(< 32KB)的快速分配。
原理:
mcache 维护 66 个 size class(从 8B 到 32KB,每个 class 对应一种对象大小)。
每个 size class 在 mcache 中有两个 span(分别用于不同 GC 标记颜色的分配,避免 GC 混淆)。
分配时:直接从当前 size class 对应 span 的空闲 slot 中取一个,不加锁。
特点:
减少全局锁竞争。
缺点是可能短期浪费一些内存(因为每个 P 都有自己的缓存)。

这里的size class 如下:
在这里插入图片描述

  • mcentral:全局按 size class 管理的 span 池
位置:每个 size class 有一个 mcentral。
作用:管理所有属于该 size class 的 span。
原理:
mcentral 有两个链表:
非空链表:有空闲 slot 的 span
已满链表:所有 slot 已分配出去的 span
当某个 P 的 mcache 发现本地 span 用完:
从 mcentral 的非空链表取一个 span。
如果非空链表也没有可用 span,就向 mheap 申请一个新的 span。

在这里插入图片描述

  • mheap:全局堆管理
位置:所有 P/M 共享的全局内存管理器。
作用:负责大对象分配(>= 32KB)和 span 的全局管理。
原理:
mheap 管理一组 free list + treap 树结构(按 span 数量和地址组织),比单纯链表查找更高效。
当 mcentral 要新的 span 时,mheap 会提供指定大小的 span。
如果 mheap 自己也没有空闲内存:
通过 mmap 或 sbrk 向操作系统申请大块虚拟内存(通常 64KB 或更大)。
这些内存被划分成 heapArena 区块(管理虚拟地址范围、指针位图等)。
  • heapArena:虚拟地址映射与 GC 辅助
作用:heapArena 是 Go 堆的元数据管理单元。
内容:
地址映射表(从虚拟地址到 span 的映射)
指针位图(标记 span 内哪些位置存的是指针,用于 GC 根扫描)
  • 总结
    以小对象分配为例(< 32KB):
  1. goroutine 在某个 P 上运行,调用 new 或 make 触发分配。
  2. mcache 直接在本地 span 分配 slot(O(1),无锁)。
  3. 如果当前 span 用完 → 向 mcentral 请求一个新 span(需加锁)。
  4. mcentral 没有可用 span → 向 mheap 申请一个新的 span。
  5. mheap 没有可用内存 → 向 OS 申请一大块内存(mmap)。
  6. OS 返回内存 → mheap 划分成 span → mcentral 提供给 mcache → mcache 返回 slot。

第四部分:GC

用户代码 ──────────────────────────────────────────────▶┌─ STW ──┐              ┌─ STW ─┐▼        ▼              ▼       ▼Mark Prepare → 并行 Mark → Mark Term → 并行 Sweep → Sweep Term

1)初识

Go 运行时的垃圾回收(GC)是:
三色标记法(Tri-color marking):对象分为白、灰、黑三类:
白色:未标记,可回收。
灰色:已标记但其子对象还未处理。
黑色:已标记且子对象已全部处理。
并发标记:绝大多数标记和清扫工作与用户代码(mutator)并行进行,减少停顿。
写屏障(write barrier):并行标记时,为了不漏标新产生或修改的引用,需要在赋值语句中记录变化。
STW(Stop The World):只有少数关键阶段需要全局暂停用户代码,其他阶段后台执行。

2)GC 的工作阶段

  • Mark Prepare(标记准备阶段)
STW 阶段(短暂停顿)
初始化本轮 GC 的各种数据结构。
开启 写屏障(write barrier),保证在并行标记过程中,新产生或被修改的引用不会漏掉。
启用 Mutator Assist:让用户代码在分配内存时,分摊一部分标记工作(避免 GC 线程跟不上分配速度)。
统计 root 对象(GC 从这些地方开始扫描),包括:
全局变量(全局指针)
所有正在运行的 goroutine 栈上的指针
为什么要 STW:
要一次性切换到 GC 模式(打开屏障、记录 root 集)必须在一致的世界状态下做。
  • GC Drains(并行标记阶段)
并行执行(GC goroutine 与用户 goroutine 同时运行)
扫描 root 对象:
遍历全局指针和所有 goroutine 栈,找到可达对象,把它们放到灰色队列。
如果要扫描某个 goroutine 栈,必须短暂停该 goroutine。
处理灰色队列:
循环取出灰色对象,扫描它引用的其他对象:
如果引用的对象是白色 → 变灰并加入灰色队列。
当前对象处理完 → 变黑。
持续处理直到灰色队列为空。
期间写屏障会不断把新创建或新引用的对象加到灰色队列,防止漏标。
  • Mark Termination(标记终止阶段)
STW 阶段(短暂停顿)
重新扫描(re-scan):
再次扫描全局指针和 goroutine 栈。
因为标记阶段与用户代码是并行的,可能在标记期间分配了新对象或产生了新引用,这些变化通过写屏障记录下来,在这一步统一处理。
标记阶段彻底完成,所有存活对象都已变黑,其他都是白色(可回收)。
为什么要 STW:
确保标记结束时没有漏标的存活对象。
  • Sweep(并行清扫阶段)
并行执行
遍历所有 span(Go 堆内存的基本分配单元,8KB):
把其中白色对象回收(释放 slot 给下一次分配)。
保留黑色对象(活对象)。
这个过程是增量式的,不会一次性清理全部,而是分批清扫。
注意:
清扫是按需触发的(懒清扫),即下一次分配时发现 span 未清扫才清扫,减少一次性延迟。
  • Sweep Termination(清扫终止阶段)
确保上一次 GC 的清扫任务完全结束。
只有当上一轮 Sweep 完全结束,才能开始新一轮 GC(保证数据一致性)。
这个阶段不一定 STW,但会等待清扫 goroutine 完成。

在这里插入图片描述

  • GC的触发机制
【1】自动触发机制1)基于内存增长比例(GOGC)当堆内存(heap)分配量比上一次 GC 结束时的堆大小增加了 GOGC% 时,触发下一次 GC2)基于定时触发(最大暂停间隔)如果距离上次 GC 时间太长,runtime 也会强制启动 GC3)辅助 GC(Mutator Assist)如果分配速度过快,GC 跟不上,runtime 会在分配路径上让分配者帮忙做一部分 GC 标记工作
【2】手动触发(显示调用)runtime.GC()

第五部分:知识拓展

1)MMP

mmap(memory map)是 内存映射机制:

  • 它可以把 文件 或 匿名内存 的一部分映射到进程的虚拟地址空间。
  • 映射之后,文件的内容 就好像是进程的一段内存,你可以用普通的指针来读写它。
  • 所有读写操作都是直接作用于内存,由操作系统负责把修改同步到文件(或从文件加载到内存)。
  • 这就是为什么可以不用 read/write 系统调用:因为访问内存本身就会触发底层的页面加载(缺页中断)。
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>
int main() {int fd = open("test.txt", O_RDWR);if (fd < 0) { perror("open"); return 1; }// 获取文件大小off_t size = lseek(fd, 0, SEEK_END);// 映射整个文件到内存char *data = mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);if (data == MAP_FAILED) { perror("mmap"); return 1; }// 直接修改内存,相当于修改文件内容strcpy(data, "Hello mmap!\n");// 解除映射munmap(data, size);close(fd);
}
[原理解释]
调用 mmap:
1)内核在进程虚拟地址空间中划出一段连续的地址范围。
2)把这段虚拟内存的页表映射到文件对应的物理页(通过磁盘缓存页)。这些物理页数据并不会马上全部加载,而是按需加载(缺页中断)。
3)访问映射区的内存时:如果该页不在内存,触发缺页中断,内核从文件中读取该页数据。如果修改数据,内核会标记该页为脏页,稍后回写到文件(MAP_SHARED)
2)Channel解读
  1. 数据结构
type hchan struct {qcount   uint           // 队列中当前元素个数dataqsiz uint           // 循环队列的容量(缓冲区大小)buf      unsafe.Pointer // 缓冲区指针(环形队列)elemsize uint16         // 每个元素的大小(字节数)closed   uint32         // 是否已关闭(0/1)elemtype *_type         // 元素类型信息(用于内存分配/GC)sendx    uint           // 下一个发送位置索引(环形队列下标)recvx    uint           // 下一个接收位置索引(环形队列下标)recvq    waitq          // 等待接收的 goroutine 队列sendq    waitq          // 等待发送的 goroutine 队列lock     mutex          // 互斥锁,保护上述字段
}

在这里插入图片描述
2. 写流程
在这里插入图片描述

1)nil 通道检查
若 ch == nil:当前 goroutine 永久阻塞(park),只有当整个程序再无可运行 G 时,
runtime 才判定 死锁 并崩溃。否则,继续。
2)加锁
获取 hchan.lock,保护后续对队列与等待队列的访问。
3)关闭检查
若 ch.closed != 0:解锁后立刻 panic("send on closed channel")4)是否有等待接收者(recvq 非空)
有:走 直接配对传递(handoff) 路径:
从 recvq 取出一个阻塞接收者 sudog;
将待发送元素 直接拷贝 到接收者的目标地址;
将该接收者 唤醒(goready 进入可运行队列);解锁并返回。
5)缓冲区是否有空间(qcount < dataqsiz)
有:走 入环形缓冲 路径:
将元素拷贝到 buf[sendx];sendx = (sendx + 1) % dataqsiz;qcount++;解锁并返回。
否则:缓冲已满且无等待读者 → 阻塞发送者
构造当前 goroutine 的 sudog,记录:
关联的通道指针、待发送元素地址、元素大小/类型信息等;
将 sudog 挂入 sendq;
调用 goparkunlock;释放锁并 park 当前 goroutine;
6)被唤醒后(可能因为接收者到来完成配对,或通道被关闭):重新加锁并重新检查状态:
若通道已关闭:按规则 panic;否则配对已完成,解锁并返回。
  1. 读流程

在这里插入图片描述

1)nil 通道检查
若 ch == nil:当前 goroutine 永久阻塞(park)。
仅当进程内所有 goroutine 都不可运行时,runtime 判定 死锁 并崩溃。否则继续。
2)加锁 lock(&c.lock)3)缓冲区是否有数据(qcount > 0)?
有:从环形缓冲取出一个元素:
从 buf[recvx] 复制到接收方目标;
recvx = (recvx+1) % dataqsiz,qcount--;
若此时存在等待发送者(sendq 非空):
可立即将一个发送者的数据写入刚空出的缓冲格(出一个、补一个的优化),并唤醒该发送者;解锁并返回(ok=true)。
否则缓冲区为空:是否存在等待发送者(sendq 非空)?
有:走直接配对(handoff)路径:
从 sendq 取一个阻塞发送者 sudog;
将其待发送元素直接拷贝到接收方目标(无缓冲通道必经此路;有缓冲通道在空时也优先直配以降低延迟);
唤醒该发送者(goready);解锁并返回(ok=true)。
否则:无数据且无等待发送者
4)通道已关闭(closed != 0)?
是:接收元素类型的零值并返回(ok=false)。
否:当前接收者阻塞:
构造当前 goroutine 的 sudog,挂入 recvq;
goparkunlock释放锁并 park;
被唤醒后(由发送者直配或 close 触发)重新加锁。
  1. 关闭
    在这里插入图片描述
0)入口与判错
通道是否为 nil?
是:panic("close of nil channel")(不能关闭 nil 通道)。
否:继续。
加锁 lock(&c.lock),保护 hchan 的内部状态。
是否已关闭?
c.closed != 0:解锁后 panic("close of closed channel")(重复关闭会 panic)。
否:c.closed = 1,标记已关闭,继续。1)处理等待的接收者(recvq)——“先满足读者”
关闭时,优先处理接收方队列;因为缓冲里可能还有未读数据:
循环从 recvq 取出阻塞的接收者 r:
如果缓冲里还有数据(qcount > 0):
从环形缓冲 buf[recvx] 取出一条写给该接收者,更新 recvx/qcount;
该接收者被唤醒后会拿到正常元素,ok=true。
如果缓冲已空:
直接给该接收者元素零值,并标记 ok=false;
这就是关闭后“读到零值并且 ok=false”的来源。
无论哪种,先把要唤醒的 G 加到一个临时 glist,暂不立即唤醒(避免持锁唤醒)。2)处理等待的发送者(sendq)——“全部唤醒并让其失败”
关闭后不允许再发送;对已经阻塞在 sendq 的发送者,需要统一处理:
循环从 sendq 取出阻塞的发送者 s:
把它加入 glist,稍后统一唤醒。
它被唤醒后会回到发送路径继续执行,看到通道已关闭从而触发 panic("send on closed channel")。
也就是说:close 会把所有正在阻塞的发送者全部唤醒并最终让它们 panic。3)解锁并统一唤醒
解锁 unlock(&c.lock)。
统一唤醒 glist 里的所有 goroutine(接收者与发送者都在里面):
接收者要么得到缓冲里的元素(ok=true),要么得到零值(ok=false);
发送者恢复后立即走到“通道已关闭”的分支并 panic。

第六部分:参考资料

  1. https://learnku.com/articles/68142
  2. https://mp.weixin.qq.com/s/QgNndPgN1kqxWh-ijSofkw
  3. https://mp.weixin.qq.com/s/0EZCmABsMEV3TFVmDZmzZA
http://www.dtcms.com/a/321741.html

相关文章:

  • 要写新项目了,运行老Django项目找找记忆先
  • Redis(②-持久化)
  • 写一个redis客户端软件,参考 Another Redis Desktop Manager 的设计风格。
  • 【沉浸式解决问题】pycharm关闭科学模式
  • Docker Compose 实战指南:从配置到多容器联动的全流程解析
  • Linux系统编程Day9 -- 理解计算机的软硬件管理
  • Dijkstra?spfa?SPstra?
  • 01Vue3
  • 增长强势 成果丰硕 | Fortinet发布2025年第二季度财报
  • GPT-5正式发布:与Claude 4、Gemini 2.5等主流大模型谁更胜一筹?
  • Java中重写和重载有哪些区别
  • 大模型——部署体验gpt-oss-20b
  • 写论文助手Zotero 的使用
  • Scrapy返回200但无数据?可能是Cookies或Session问题
  • electron 静默安装同时安装完成后自动启动(nsis)
  • 【vLLM 学习】Load Sharded State
  • VB网际探针:零依赖轻量爬虫实战
  • GPT-5 is here
  • STM32 输入捕获,串口打印,定时器,中断综合运用
  • centos系统配置防火墙
  • DDR-怎么计算存储空间-什么是预取(Pre-fetch)
  • 【世纪龙科技】汽车车身测量虚拟实训软件-虚境精测全维赋能
  • 应急响应流程
  • vue2-scoped关键字、组件通信
  • Qwen-Image擅长文字渲染的创作利器
  • 用 Go 写个极简反向代理,把 CC 攻击挡在业务容器之外
  • 深入浅出:掌握银河麒麟桌面操作系统的防火墙管理艺术
  • 3- Python 网络爬虫 — 如何抓取动态加载数据?Ajax 原理与实战全解析
  • Redis:集群(Cluster)
  • eNSP 模拟器安装教程