|
|
[{"id":0,"href":"/memory/","title":"Memory","section":"简介","content":" 内存管理 # 一张一弛,文武之道。\n内存分配器 GC "},{"id":1,"href":"/memory/allocator/","title":"Allocator","section":"Memory","content":" 内存分配器 # 概述 # Golang内存分配管理策略是按照不同大小的对象和不同的内存层级来分配管理内存。通过这种多层级分配策略,形成无锁化或者降低锁的粒度,以及尽量减少内存碎片,来提高内存分配效率。\nGolang中内存分配管理的对象按照大小可以分为:\n类别 大小 微对象 tiny object (0, 16B) 小对象 small object [16B, 32KB] 大对象 large object (32KB, +∞) Golang中内存管理的层级从最下到最上可以分为:mspan -\u0026gt; mcache -\u0026gt; mcentral -\u0026gt; mheap -\u0026gt; heapArena。golang中对象的内存分配流程如下:\n小于16个字节的对象使用mcache的微对象分配器进行分配内存 大小在16个字节到32k字节之间的对象,首先计算出需要使用的span大小规格,然后使用mcache中相同大小规格的mspan分配 如果对应的大小规格在mcache中没有可用的mspan,则向mcentral申请 如果mcentral中没有可用的mspan,则向mheap申请,并根据BestFit算法找到最合适的mspan。如果申请到的mspan超出申请大小,将会根据需求进行切分,以返回用户所需的页数,剩余的页构成一个新的mspan放回mheap的空闲列表 如果mheap中没有可用span,则向操作系统申请一系列新的页(最小 1MB) 对于大于32K的大对象直接从mheap分配 mspan # mspan是一个双向链表结构。mspan是golang中内存分配管理的基本单位。\n// file: mheap.go type mspan struct { next *mspan // 指向下一个mspan prev *mspan // 指向上一个mspan startAddr uintptr // 该span在arena区域起始地址 npages uintptr // 该span在arena区域中占用page个数 manualFreeList gclinkptr // 空闲对象列表 freeindex uintptr // 下一个空闲span的索引,freeindex大小介于0到nelems,当freeindex == nelem,表明该span中没有空余对象空间了 // freeindex之前的元素均是已经被使用的,freeindex之后的元素可能被使用,也可能没被使用 // freeindex 和 allocCache配合使用来定位出可用span的位置 nelems uintptr // span链表中元素个数 allocCache uint64 // 初始值为2^64-1,位值置为1(假定该位的位置是pos)的表明该span链表中对应的freeindex+pos位置的span未使用 allocBits *gcBits // 标识该span中所有元素的使用分配情况,位值置为1则标识span链表中对应位置的span已被分配 gcmarkBits *gcBits // 用来sweep过程进行标记垃圾对象的,用于后续gc。 allocCount uint16 // 已分配的对象个数 spanclass spanClass // span类别 state mSpanState // mspaninuse etc needzero uint8 // needs to be zeroed before allocation elemsize uintptr // 能存储的对象大小 } // file: mheap.go type spanClass uint8 // span规格类型 span大小一共有67个规格。规格列表如下, 其中class = 0 是特殊的span,用于大于32kb对象分配,是直接从mheap上分配的:\n# file: sizeclasses.go // class bytes/obj bytes/span objects tail waste max waste // 1 8 8192 1024 0 87.50% // 2 16 8192 512 0 43.75% // 3 32 8192 256 0 46.88% // 4 48 8192 170 32 31.52% // 5 64 8192 128 0 23.44% ... // 64 27264 81920 3 128 10.00% // 65 28672 57344 2 0 4.91% // 66 32768 32768 1 0 12.50% class - 规格id,即spanClass bytes/obj - 能够存储的对象大小,对应的是mspan的elemsize字段 bytes/span - 每个span的大小,大小等于页数*页大小,即8k * npages object - 每个span能够存储的objects个数,即nelems,也等于(bytes/span)/(bytes/obj) tail waste - 每个span产生的内存碎片,即(bytes/span)%(bytes/obj) max waste - 最大浪费比例,(bytes/obj-span最小使用量)*objects/(bytes/span)*100,比如class =2时,span运行的最小使用量是9bytes,则max waste=(16-9)512/8192100=43.75% mcache # mcache持有一系列不同大小的mspan。mcache属于per-P cache,由于M运行G时候,必须绑定一个P,这样当G中申请从mcache分配对象内存时候,无需加锁处理。\n// file: mcache.go type mcache struct { next_sample uintptr // trigger heap sample after allocating this many bytes local_scan uintptr // bytes of scannable heap allocated // 微对象分配器,对象大小需要小于16byte tiny uintptr // 微对象起始地址 tinyoffset uintptr // 从tiny开始的偏移值 local_tinyallocs uintptr // tiny对象的个数 // 大小为134的指针数组,数组元素指向mspan,SpanClasses一共有67种,为了满足指针对象和非指针对象,这里为每种规格的span同时准备scan和noscan两个,分别用于存储指针对象和非指针对象 alloc [numSpanClasses]*mspan stackcache [_NumStackOrders]stackfreelist // 栈缓存 // Local allocator stats, flushed during GC. local_largefree uintptr // 大对象释放的字节数 local_nlargefree uintptr // 释放的大对象个数 local_nsmallfree [_NumSizeClasses]uintptr // 大小为64的数组,每种规格span是否的小对象个数 flushGen uint32 // 扫描计数 } // file: malloc.go if size \u0026lt;= maxSmallSize { // 如果size \u0026lt;= 32k if noscan \u0026amp;\u0026amp; size \u0026lt; maxTinySize { // 不需要扫描,且size\u0026lt;16 if size\u0026amp;7 == 0 { off = round(off, 8) } else if size\u0026amp;3 == 0 { off = round(off, 4) } else if size\u0026amp;1 == 0 { off = round(off, 2) } if off+size \u0026lt;= maxTinySize \u0026amp;\u0026amp; c.tiny != 0 { // The object fits into existing tiny block. x = unsafe.Pointer(c.tiny + off) c.tinyoffset = off + size c.local_tinyallocs++ mp.mallocing = 0 releasem(mp) return x } // Allocate a new maxTinySize block. span := c.alloc[tinySpanClass] v := nextFreeFast(span) if v == 0 { v, _, shouldhelpgc = c.nextFree(tinySpanClass) } x = unsafe.Pointer(v) (*[2]uint64)(x)[0] = 0 (*[2]uint64)(x)[1] = 0 // See if we need to replace the existing tiny block with the new one // based on amount of remaining free space. if size \u0026lt; c.tinyoffset || c.tiny == 0 { c.tiny = uintptr(x) c.tinyoffset = size } size = maxTinySize } else { // 16b ~ 32kb var sizeclass uint8 if size \u0026lt;= smallSizeMax-8 { sizeclass = size_to_class8[(size+smallSizeDiv-1)/smallSizeDiv] } else { sizeclass = size_to_class128[(size-smallSizeMax+largeSizeDiv-1)/largeSizeDiv] } size = uintptr(class_to_size[sizeclass]) spc := makeSpanClass(sizeclass, noscan) span := c.alloc[spc] v := nextFreeFast(span) if v == 0 { v, span, shouldhelpgc = c.nextFree(spc) } x = unsafe.Pointer(v) if needzero \u0026amp;\u0026amp; span.needzero != 0 { memclrNoHeapPointers(unsafe.Pointer(v), size) } } } else {// \u0026gt; 32kb var s *mspan shouldhelpgc = true systemstack(func() { s = largeAlloc(size, needzero, noscan) }) s.freeindex = 1 s.allocCount = 1 x = unsafe.Pointer(s.base()) size = s.elemsize } mcentral # 当mcache的中没有可用的span时候,会向mcentral申请,mcetral结构如下:\ntype mcentral struct { lock mutex // 锁,由于每个p关联的mcache都可能会向mcentral申请空闲的span,所以需要加锁 spanclass spanClass // mcentral负责的span规格 nonempty mSpanList // 空闲span列表 empty mSpanList // 已经使用的span列表 nmalloc uint64 // mcentral已分配的span计数 } 一个mecentral只负责一个规格span,规格类型记录在mcentral的spanClass字段中。mcentral维护着两个双向链表,nonempty表示链表里还有空闲的mspan待分配。empty表示这条链表里的mspan都被分配了object。mcache从mcentrl中获取和归还span流程如下:\n获取时候先加锁,先从nonempty中获取一个没有分配使用的span,将其从nonempty中删除,并将span加入empty链表,mcache获取之后释放锁。 归还时候先加锁,先将span加入nonempty链表中,并从empty链表中删除,最后释放锁。 mheap # 当mecentral没有可用的span时候,会向mheap申请。\ntype mheap struct { // lock must only be acquired on the system stack, otherwise a g // could self-deadlock if its stack grows with the lock held. lock mutex free mTreap // 空闲的并且没有被os收回的二叉树堆,大对象用 sweepgen uint32 // 扫描计数值,每次gc后自增2 sweepdone uint32 // all spans are swept sweepers uint32 // number of active sweepone calls allspans []*mspan // 所有的span sweepSpans [2]gcSweepBuf _ uint32 // align uint64 fields on 32-bit for atomics pagesInUse uint64 // pages of spans in stats mSpanInUse; R/W with mheap.lock pagesSwept uint64 // pages swept this cycle; updated atomically pagesSweptBasis uint64 // pagesSwept to use as the origin of the sweep ratio; updated atomically sweepHeapLiveBasis uint64 // value of heap_live to use as the origin of sweep ratio; written with lock, read without sweepPagesPerByte float64 // proportional sweep ratio; written with lock, read without // TODO(austin): pagesInUse should be a uintptr, but the 386 // compiler can\u0026#39;t 8-byte align fields. scavengeTimeBasis int64 scavengeRetainedBasis uint64 scavengeBytesPerNS float64 scavengeRetainedGoal uint64 scavengeGen uint64 // incremented on each pacing update reclaimIndex uint64 reclaimCredit uintptr // Malloc stats. largealloc uint64 // bytes allocated for large objects nlargealloc uint64 // number of large object allocations largefree uint64 // bytes freed for large objects (\u0026gt;maxsmallsize) nlargefree uint64 // number of frees for large objects (\u0026gt;maxsmallsize) nsmallfree [_NumSizeClasses]uint64 // number of frees for small objects (\u0026lt;=maxsmallsize) arenas [1 \u0026lt;\u0026lt; arenaL1Bits]*[1 \u0026lt;\u0026lt; arenaL2Bits]*heapArena heapArenaAlloc linearAlloc arenaHints *arenaHint arena linearAlloc allArenas []arenaIdx sweepArenas []arenaIdx curArena struct { base, end uintptr } _ uint32 // ensure 64-bit alignment of central // 各个尺寸的central central [numSpanClasses]struct { mcentral mcentral pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte } spanalloc fixalloc // allocator for span* cachealloc fixalloc // allocator for mcache* treapalloc fixalloc // allocator for treapNodes* specialfinalizeralloc fixalloc // allocator for specialfinalizer* specialprofilealloc fixalloc // allocator for specialprofile* speciallock mutex // lock for special record allocators. arenaHintAlloc fixalloc // allocator for arenaHints unused *specialfinalizer // never set, just here to force the specialfinalizer type into DWARF } heapArena # heapArenaBytes = 1 \u0026lt;\u0026lt; logHeapArenaBytes logHeapArenaBytes = (6+20)*(_64bit*(1-sys.GoosWindows)*(1-sys.GoosAix)*(1-sys.GoarchWasm)) + (2+20)*(_64bit*sys.GoosWindows) + (2+20)*(1-_64bit) + (8+20)*sys.GoosAix + (2+20)*sys.GoarchWasm // heapArenaBitmapBytes is the size of each heap arena\u0026#39;s bitmap. heapArenaBitmapBytes = heapArenaBytes / (sys.PtrSize * 8 / 2) pagesPerArena = heapArenaBytes / pageSize type heapArena struct { bitmap [heapArenaBitmapBytes]byte spans [pagesPerArena]*mspan pageInUse [pagesPerArena / 8]uint8 pageMarks [pagesPerArena / 8]uint8 } heapArena中arena区域是真正的堆区,所有分配的span都是从这个地方分配。arena区域管理的单元大小是page,page页数为pagesPerArena。\n在64位linux系统,runtime.mheap会持有 4,194,304 runtime.heapArena,每个 runtime.heapArena 都会管理 64MB 的内存,所有golang的内存上限是256TB。\n"},{"id":2,"href":"/memory/gc/","title":"Gc","section":"Memory","content":" GC # 三色标记清除算法 # Golang中采用 三色标记清除算法(tricolor mark-and-sweep algorithm) 进行GC。由于支持写屏障(write barrier)了,GC过程和程序可以并发运行。\n三色标记清除算核心原则就是根据每个对象的颜色,分到不同的颜色集合中,对象的颜色是在标记阶段完成的。三色是黑白灰三种颜色,每种颜色的集合都有特别的含义:\n黑色集合\n该集合下的对象没有引用任何白色对象(即该对象没有指针指向白色对象)\n白色集合\n扫描标记结束之后,白色集合里面的对象就是要进行垃圾回收的,该对象允许有指针指向黑色对象。\n灰色集合\n可能有指针指向白色对象。它是一个中间状态,只有该集合下不在存在任何对象时候,才能进行最终的清除操作。\n过程 # 标记清除算法核心不变要素是没有黑色的对象能够指向白色集合对象。当垃圾回收开始,全部对象标记为白色,然后垃圾回收器会遍历所有根对象并把它们标记为灰色。根对象就是程序能直接访问到的对象,包括全局变量以及栈、寄存器上的里面的变量。在这之后,垃圾回收器选取一个灰色的对象,首先把它变为黑色,然后开始寻找去确定这个对象是否有指针指向白色集合的对象,若找到则把找到的对象由标记为灰色,并将其白色集合中移入到灰色集合中。就这样持续下去,直到灰色集合中没有任何对象为止。\n为了支持能够并发进行垃圾回收,Golang在垃圾回收过程中采用写屏障,每次堆中的指针被修改时候写屏障都会执行,写屏障会将该指针指向的对象标记为灰色,然后放入灰色集合(因为才对象现在是可触达的了),然后继续扫描该对象。\n举个例子说明写屏障的重要性:\n假定标记完成的瞬间,A对象是黑色,B是白色,然后A的对象指针字段f由空指针改成指向B,若没有写屏障的话,清除阶段B就会被清除掉,那边A的f字段就变成了悬浮指针,这是有问题的。若存在写屏障那么f字段改变的时候,f指向的B就会放入到灰色集合中,然后继续扫描,B最终也会变成黑色的,那么清除阶段它也就不会被清除了。\n"},{"id":3,"href":"/analysis-tools/dlv/","title":"Delve is a debugger for the Go programming language","section":"Go语言分析工具","content":" Delve # Delve1 是使用Go语言实现的,专门用来调试Go程序的工具。它跟 GDB 工具类似,相比 GDB,它简单易用,能更好的理解Go语言数据结构和语言特性。它支持打印 goroutine 以及 defer 函数等Go特有语法特性。Delve 简称 dlv,后文将以 dlv 代称 Delve.\n安装 # go get -u github.com/go-delve/delve/cmd/dlv 使用 # 开始调试 # dlv 使用 debug 命令进入调试界面:\ndlv debug main.go 如果当前目录是 main 包所在目录时候,可以不用指定 main.go 文件这个参数的。假定项目结构如下:\n. ├── github.com/me/foo ├── cmd │ └── foo │ └── main.go ├── pkg │ └── baz │ ├── bar.go │ └── bar_test.go 如果当前已在 cmd/foo 目录下,我们可以直接执行 dlv debug 命令开始调试。在任何目录下我们可以使用 dlv debug github.com/me/foo/cmd/foo 开始调试。\n如果已构建成二进制可执行文件,我们可以使用 dlv exec 命令开始调试:\ndlv exec /youpath/go_binary_file 对于需要命令行参数才能启动的程序,我们可以通过--来传递命令行参数,比如如下:\ndlv debug github.com/me/foo/cmd/foo -- -arg1 value dlv exec /mypath/binary -- --config=config.toml 对于已经运行的程序,可以使用 attach 命令,进行跟踪调试指定 pid 的Go应用:\ndlv attach pid 除了上面调试 main 包外,dlv 通过 test 子命令还支持调试 test 文件:\ndlv test github.com/me/foo/pkg/baz 接下来我们可以使用 help 命令查看 dlv 支持的命令有哪些:\n(dlv) help The following commands are available: Running the program: call ------------------------ Resumes process, injecting a function call (EXPERIMENTAL!!!) continue (alias: c) --------- Run until breakpoint or program termination. next (alias: n) ------------- Step over to next source line. rebuild --------------------- Rebuild the target executable and restarts it. It does not work if the executable was not built by delve. restart (alias: r) ---------- Restart process. step (alias: s) ------------- Single step through program. step-instruction (alias: si) Single step a single cpu instruction. stepout (alias: so) --------- Step out of the current function. Manipulating breakpoints: break (alias: b) ------- Sets a breakpoint. breakpoints (alias: bp) Print out info for active breakpoints. clear ------------------ Deletes breakpoint. clearall --------------- Deletes multiple breakpoints. condition (alias: cond) Set breakpoint condition. on --------------------- Executes a command when a breakpoint is hit. trace (alias: t) ------- Set tracepoint. Viewing program variables and memory: args ----------------- Print function arguments. display -------------- Print value of an expression every time the program stops. examinemem (alias: x) Examine memory: locals --------------- Print local variables. print (alias: p) ----- Evaluate an expression. regs ----------------- Print contents of CPU registers. set ------------------ Changes the value of a variable. vars ----------------- Print package variables. whatis --------------- Prints type of an expression. Listing and switching between threads and goroutines: goroutine (alias: gr) -- Shows or changes current goroutine goroutines (alias: grs) List program goroutines. thread (alias: tr) ----- Switch to the specified thread. threads ---------------- Print out info for every traced thread. Viewing the call stack and selecting frames: deferred --------- Executes command in the context of a deferred call. down ------------- Move the current frame down. frame ------------ Set the current frame, or execute command on a different frame. stack (alias: bt) Print stack trace. up --------------- Move the current frame up. Other commands: config --------------------- Changes configuration parameters. disassemble (alias: disass) Disassembler. edit (alias: ed) ----------- Open where you are in $DELVE_EDITOR or $EDITOR exit (alias: quit | q) ----- Exit the debugger. funcs ---------------------- Print list of functions. help (alias: h) ------------ Prints the help message. libraries ------------------ List loaded dynamic libraries list (alias: ls | l) ------- Show source code. source --------------------- Executes a file containing a list of delve commands sources -------------------- Print list of source files. types ---------------------- Print list of types Type help followed by a command for full documentation. 接下来我们将以下面代码作为示例演示如何dlv进行调试。\npackage main import \u0026#34;fmt\u0026#34; func main() { fmt.Println(\u0026#34;go\u0026#34;) } 设置断点 # 当我们使用 dlv debug main.go 命令进行 dlv 调试之后,我们可以设置断点。\n(dlv) b main.main # 在main函数处设置断点 Breakpoint 1 set at 0x4adf8f for main.main() ./main.go:5 继续执行 # 设置断点之后,我们可以通过 continue 命令,可以简写成 c ,继续执行到我们设置的断点处。\n(dlv) c \u0026gt; main.main() ./main.go:5 (hits goroutine(1):1 total:1) (PC: 0x4adf8f) 1:\tpackage main 2: 3:\timport \u0026#34;fmt\u0026#34; 4: =\u0026gt; 5:\tfunc main() { 6:\tfmt.Println(\u0026#34;go\u0026#34;) 7:\t} 注意不同于 GDB 需要执行 run 命令启动应用之后,才能执行 continue 命令。而 dlv 在进入调试界面之后,已经指向程序的入口地址处,可以直接执行 continue 命令\n执行下一条指令 # 我们可以通过next命令,可以简写成n,来执行下一行源码。同 GDB 一样,next 命令是 Step over 操作,遇到函数时不会进入函数内部一行行代码执行,而是直接执行函数,然后跳过到函数下面的一行代码。\n(dlv) n go \u0026gt; main.main() ./main.go:7 (PC: 0x4adfff) 2: 3:\timport \u0026#34;fmt\u0026#34; 4: 5:\tfunc main() { 6:\tfmt.Println(\u0026#34;go\u0026#34;) =\u0026gt; 7:\t} 打印栈信息 # 通过 stack 命令,我们可以查看函数栈信息:\n(dlv) stack 0 0x00000000004adfff in main.main at ./main.go:7 1 0x0000000000436be8 in runtime.main at /usr/lib/go/src/runtime/proc.go:203 2 0x0000000000464621 in runtime.goexit at /usr/lib/go/src/runtime/asm_amd64.s:1373 打印gorountine信息 # 通过goroutines命令,可以简写成grs,我们可以查看所有 goroutine:\n(dlv) goroutines * Goroutine 1 - User: ./main.go:7 main.main (0x4adfff) (thread 14358) Goroutine 2 - User: /usr/lib/go/src/runtime/proc.go:305 runtime.gopark (0x436f9b) Goroutine 3 - User: /usr/lib/go/src/runtime/proc.go:305 runtime.gopark (0x436f9b) Goroutine 4 - User: /usr/lib/go/src/runtime/proc.go:305 runtime.gopark (0x436f9b) Goroutine 5 - User: /usr/lib/go/src/runtime/mfinal.go:161 runtime.runfinq (0x418f80) [5 goroutines] goroutine 命令,可以简写成 gr,用来显示当前 goroutine 信息:\n(dlv) goroutine Thread 14358 at ./main.go:7 Goroutine 1: Runtime: ./main.go:7 main.main (0x4adfff) User: ./main.go:7 main.main (0x4adfff) Go: /usr/lib/go/src/runtime/asm_amd64.s:220 runtime.rt0_go (0x462594) Start: /usr/lib/go/src/runtime/proc.go:113 runtime.main (0x436a20) 查看汇编代码 # 通过 disassemble 命令,可以简写成 disass ,我们可以查看汇编代码:\n(dlv) disass TEXT main.main(SB) /tmp/dlv/main.go main.go:5\t0x4adf80\t64488b0c25f8ffffff\tmov rcx, qword ptr fs:[0xfffffff8] main.go:5\t0x4adf89\t483b6110\tcmp rsp, qword ptr [rcx+0x10] main.go:5\t0x4adf8d\t767a\tjbe 0x4ae009 main.go:5\t0x4adf8f*\t4883ec68\tsub rsp, 0x68 main.go:5\t0x4adf93\t48896c2460\tmov qword ptr [rsp+0x60], rbp main.go:5\t0x4adf98\t488d6c2460\tlea rbp, ptr [rsp+0x60] main.go:6\t0x4adf9d\t0f57c0\txorps xmm0, xmm0 main.go:6\t0x4adfa0\t0f11442438\tmovups xmmword ptr [rsp+0x38], xmm0 main.go:6\t0x4adfa5\t488d442438\tlea rax, ptr [rsp+0x38] main.go:6\t0x4adfaa\t4889442430\tmov qword ptr [rsp+0x30], rax main.go:6\t0x4adfaf\t8400\ttest byte ptr [rax], al main.go:6\t0x4adfb1\t488d0d28ed0000\tlea rcx, ptr [rip+0xed28] main.go:6\t0x4adfb8\t48894c2438\tmov qword ptr [rsp+0x38], rcx main.go:6\t0x4adfbd\t488d0dcce10300\tlea rcx, ptr [rip+0x3e1cc] main.go:6\t0x4adfc4\t48894c2440\tmov qword ptr [rsp+0x40], rcx main.go:6\t0x4adfc9\t8400\ttest byte ptr [rax], al main.go:6\t0x4adfcb\teb00\tjmp 0x4adfcd main.go:6\t0x4adfcd\t4889442448\tmov qword ptr [rsp+0x48], rax main.go:6\t0x4adfd2\t48c744245001000000\tmov qword ptr [rsp+0x50], 0x1 main.go:6\t0x4adfdb\t48c744245801000000\tmov qword ptr [rsp+0x58], 0x1 main.go:6\t0x4adfe4\t48890424\tmov qword ptr [rsp], rax main.go:6\t0x4adfe8\t48c744240801000000\tmov qword ptr [rsp+0x8], 0x1 main.go:6\t0x4adff1\t48c744241001000000\tmov qword ptr [rsp+0x10], 0x1 main.go:6\t0x4adffa\te811a1ffff\tcall $fmt.Println =\u0026gt;\tmain.go:7\t0x4adfff\t488b6c2460\tmov rbp, qword ptr [rsp+0x60] main.go:7\t0x4ae004\t4883c468\tadd rsp, 0x68 main.go:7\t0x4ae008\tc3\tret main.go:5\t0x4ae009\te8e247fbff\tcall $runtime.morestack_noctxt \u0026lt;autogenerated\u0026gt;:1\t0x4ae00e\te96dffffff\tjmp $main.main dlv 默认显示的是 intel 风格汇编代码,我们可以通过 config 命令设置 gnu 或者 go 风格代码:\n(dlv) config disassemble-flavor go 这种方式更改的配置只会对此次调试有效,若保证下次调试一样有效,我们需要将其配置到配置文件中。dlv 默认配置文件是 HOME/.config/dlv/config.yml。我们只需要在配置文件加入以下内容:\ndisassemble-flavor: go https://github.com/go-delve/delve\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n"},{"id":4,"href":"/feature/for-range/","title":"for-range语法","section":"语言特性","content":" 遍历 - for-range语法 # for-range语法可以用来遍历数组、指向数组的指针,切片、字符串、映射和通道。\n遍历数组 # 当遍历一个数组a时候,循环范围会从0到len(a) -1:\nfunc main() { var a [3]int for i, v := range a { fmt.Println(i, v) } for i, v := range \u0026amp;a { fmt.Println(i, v) } } 遍历切片 # 当遍历一个切片s时候,循环范围会从0到len(s) -1,若切片是nil,则迭代次数是0次:\nfunc main() { a := make([]int, 3) for i, v := range a { fmt.Println(i, v) } a = nil for i, v := range a { fmt.Println(i, v) } } for-range切片时候可以边遍历边append吗? # 当遍历切片时候,可以边遍历边append操作,这并不会造成死循环。因为遍历之前已经确定了循环范围,遍历操作相当如下伪代码:\nlen_temp := len(range) // 循环上界 range_temp := range for index_temp = 0; index_temp \u0026lt; len_temp; index_temp++ { value_temp = range_temp[index_temp] index = index_temp value = value_temp original body } for-range切片时候,返回的是值拷贝 # 无论遍历数组还是切片,返回都是数组或切片中的值拷贝:\nfunc main() { users := []User{ { Name: \u0026#34;a1\u0026#34;, Age: 100, }, { Name: \u0026#34;a2\u0026#34;, Age: 101, }, { Name: \u0026#34;a2\u0026#34;, Age: 102, }, } fmt.Println(\u0026#34;before: \u0026#34;, users) for _, v := range users { v.Age = v.Age + 10 // 想给users中所有用户年龄增加10岁 } fmt.Println(\u0026#34;after: \u0026#34;, users) } 执行上面代码,输入以下内容:\nbefore: [{a1 100} {a2 101} {a2 102}] after: [{a1 100} {a2 101} {a2 102}] 解决办法可以通过索引访问原切片或数组:\nfunc main() { users := []User{ { Name: \u0026#34;a1\u0026#34;, Age: 100, }, { Name: \u0026#34;a2\u0026#34;, Age: 101, }, { Name: \u0026#34;a2\u0026#34;, Age: 102, }, } fmt.Println(\u0026#34;before: \u0026#34;, users) for i := range users { users[i].Age = users[i].Age + 10 } fmt.Println(\u0026#34;after: \u0026#34;, users) } 遍历字符串 # 当遍历字符串时候,返回的是rune类型,rune类型是int32类型的别名,一个rune就是一个码点(code point):\n// rune is an alias for int32 and is equivalent to int32 in all ways. It is // used, by convention, to distinguish character values from integer values. type rune = int32 由于遍历字符串时候,返回的是码点,所以索引并不总是依次增加1的:\nfunc main() { var str = \u0026#34;hello,你好\u0026#34; var buf [100]byte for i, v := range str { vl := utf8.RuneLen(v) si := i + vl copy(buf[:], str[i:si]) fmt.Printf(\u0026#34;索引%2d: %q,\\t 码点: %#6x,\\t 码点转换成字节: %#v\\n\u0026#34;, i, v, v, buf[:vl]) } } 执行上面代码将输出以下内容:\n索引 0: \u0026#39;h\u0026#39;,\t码点: 0x68,\t码点转换成字节: []byte{0x68} 索引 1: \u0026#39;e\u0026#39;,\t码点: 0x65,\t码点转换成字节: []byte{0x65} 索引 2: \u0026#39;l\u0026#39;,\t码点: 0x6c,\t码点转换成字节: []byte{0x6c} 索引 3: \u0026#39;l\u0026#39;,\t码点: 0x6c,\t码点转换成字节: []byte{0x6c} 索引 4: \u0026#39;o\u0026#39;,\t码点: 0x6f,\t码点转换成字节: []byte{0x6f} 索引 5: \u0026#39;,\u0026#39;,\t码点: 0xff0c,\t码点转换成字节: []byte{0xef, 0xbc, 0x8c} 索引 8: \u0026#39;你\u0026#39;,\t码点: 0x4f60,\t码点转换成字节: []byte{0xe4, 0xbd, 0xa0} 索引11: \u0026#39;好\u0026#39;,\t码点: 0x597d,\t码点转换成字节: []byte{0xe5, 0xa5, 0xbd} 遍历映射 # 当遍历切片时候,Go语言是不会保证遍历顺序的,为了明确强调这一点,Go语言在实现的时候,故意随机地选择一个桶开始遍历。当映射通道为nil时候,遍历次数为0次。\nfunc main() { m := map[int]int{ 1: 10, 2: 20, 3: 30, } for i, v := range m { fmt.Println(i, v) } m = nil for i, v := range m { fmt.Println(i, v) } } for-range映射时候可以边遍历,边新增或删除吗? # 若在一个Goroutine里面边遍历边新增、删除,理论上是可以的,不会触发写检测的,新增的key-value可能会被访问到,也可能不会。\n若多个Goroutine中进行遍历、新增、删除操作的话,是不可以的,是可能触发写检测的,然后直接panic。\n遍历通道 # 当遍历通道时,直到通道关闭才会终止,若通道是nil,则会永远阻塞。遍历通道源码分析请见 从通道中读取数据。\n进一步阅读 # Go Range Loop Internals For statements "},{"id":5,"href":"/gmp/","title":"G-M-P调度机制","section":"简介","content":" G-M-P调度机制 # 其曲弥高,其和弥寡。\n调度机制概述 调度器 "},{"id":6,"href":"/analysis-tools/gdb/","title":"GDB","section":"Go语言分析工具","content":" GDB # GDB(GNU symbolic Debugger)是Linux系统下的强大的调试工具,可以用来调试ada, c, c++, asm, minimal, d, fortran, objective-c, go, java,pascal 等多种语言。\n我们以调试 go 代码为示例来介绍GDB的使用。源码内容如下:\npackage main import \u0026#34;fmt\u0026#34; func add(a, b int) int { sum := 0 sum = a + b return sum } func main() { sum := add(10, 20) fmt.Println(sum) } 构建二进制应用:\ngo build -gcflags=\u0026#34;-N -l\u0026#34; -o test main.go 启动调试 # gdb ./test # 启动调试 gdb --args ./test arg1 arg2 # 指定参数启动调试 进入gdb调试界面之后,执行 run 命令运行程序。若程序已经运行,我们可以 attach 该程序的进程id进行调试:\n$ gdb (gdb) attach 1785 当执行 attach 命令的时候,GDB首先会在当前工作目录下查找进程的可执行程序,如果没有找到,接着会用源代码文件搜索路径。我们也可以用file命令来加载可执行文件。\n或者通过命令设置进程id:\ngdb test 1785 gdb test --pid 1785 若已运行的进程不含调试信息,我们可以使用同样代码编译出一个带调试信息的版本,然后使用 file 和 attach 命令进行运行调试。\n$ gdb (gdb) file test Reading symbols from test...done. (gdb) attach 1785 可视化窗口 # GDB也支持多窗口图形启动运行,一个窗口显示源码信息,一个窗口显示调试信息:\ngdb test -tui GDB支持在运行过程中使用 Crtl+X+A 组合键进入多窗口图形界面, GDB支持的快捷操作有:\nCrtl+X+A // 多窗口与单窗口界面切换 Ctrl + X + 2 // 显示两个窗口 Ctrl + X + 1 // 显示一个窗口 运行程序 # 通过 run 命令运行程序, run 命令可以简写成 r:\n(gdb) run 除了启动GDB时候,设置程序的命令行参数外,我们也可以在启动GDB后,再指定程序的命令行参数:\n(gdb) run arg1 arg2 或者通过 set 命令设置命令行参数:\n(gdb) set args arg1 arg2 (gdb) run 除了 run 命令外,我们也可以使用 start 命令运行程序。start 命令会在在 main 函数的第一条语句前面停下来。\n(gdb) start start 命令相当于在Go程序的入口函数 main.main (main.main 代表 main 包的 main 函数)处设置断点,然后运行 run 命令:\n(gdb) b main.main (gdb) run 断点的设置、查看、删除、禁用 # 设置断点 # GDB中是通过 break 命令来设置断点(BreakPoint),break 可以简写成 b。\nbreak function\n在指定函数出设置断点,设置断点后程序会在进入指定函数时停住\nbreak linenum\n在指定行号处设置断点\nbreak +offset/-offset\n在当前行号的前面或后面的offset行处设置断点。offset为自然数\nbreak filename:linenum\n在源文件filename的linenum行处设置断点\nbreak filename:function\n在源文件filename的function函数的入口处设置断点\nbreak *address\n在程序运行的内存地址处设置断点\nbreak\nbreak命令没有参数时,表示在下一条指令处停住。\nbreak \u0026hellip; if \u0026hellip;可以是上述的参数,condition表示条件,在条件成立时停住。比如在循环境体中,可以设置break if i=100,表示当i为100时停住程序\n查看断点 # 我们可以通过 info 命令查看断点:\n(gdb) info breakpoint # 查看所有断点 (gdb) info breakpoint 3 # 查看3号断点 删除断点 # 删除断点是通过 delete 命令删除的,delete 命令可以简写成 d:\n(gdb) delete 3 # 删除3号断点 断点启用与禁用 # (gdb) disable 3 # 禁用3号断点 (gdb) enable 3 # 启用3号断点 调试 # 单步执行 # next 用于单步执行,会一行行执行代码,运到函数时候,不会进入到函数内部,跳过该函数,但会执行该函数,即 step over。可以简写成 n。\n(gdb) next 单步进入 # step 用于单步进入执行,跟 next 命令类似,但是遇到函数时候,会进入到函数内部一步步执行,即 step into。可以简写成 s。\n(gdb) step 与 step 相关的命令 stepi,用于每次执行每次执行一条机器指令。可以简写成 si。\n继续执行到下一个断点 # continue 命令会继续执行程序,直到再次遇到断点处。可以简写成 c:\n(gdb) continue (gdb) continue 3 # 跳过3个断点 继续运行到指定位置 # until 命令可以帮助我们实现运行到某一行停住,可以简写成 u:\n(gdb) until 5 跳过执行 # skip 命令可以在step时跳过一些不想关注的函数或者某个文件的代码:\n(gdb) skip function add # step时跳过add函数 (gdb) info skip # 查看skip列表 其他相关的命令:\nskip delete [num] 删除skip skip enable [num] 启动skip skip disable [num] 关闭skip 注意: 当不带skip号时候,是针对所有skip进行设置。\n执行完成当前函数 # finish 命令用来将当前函数执行完成,并打印函数返回时的堆栈地址、返回值、参数值等信息,即step out 。\n(gdb) finish 查看源码 # GDB中的 list 命令用来显示源码信息。list 命令可以简写成 l。\nlist\n从第一行开始显示源码,继续输入list,可列出后面的源码\nlist linenum\n列出linenum行附近的源码\nlist function\n列出函数function的代码\nlist filename:linenum\n列出文件filename文件中,linenum行出的代码\nlist filename:function\n列出文件filename中,函数function的代码\nlist +offset/-offset\n列出在当前行号的前面或后面的offset行附近的代码。offset为自然数。\nlist +/-\n列出当前行后面或者前面的代码\nlist linenum1, linenum2\n列出行linenum1和linenum2之间的代码\n查看信息 # info 命令用来显示信息,可以简写成 i。\ninfo files\n显示当前的调试的文件,包含程序入口地址,内存分段布局位置信息等\ninfo breakpoints\n显示当前设置的断点列表\ninfo registers\n显示当前寄存器的值,可以简写成 i r。指定寄存器名称,可以查看具体寄存器信息:i r rsp\ninfo all-registers\n显示所有寄存器的值。GDB提供四个标准寄存器:pc 是程序计数器寄存器,sp 是堆栈指针。fp 用于记录当前堆栈帧的指针,ps 用于记录处理器状态的寄存器。GDB会处理好不同架构系统寄存器不一致问题,比如对于 amd64 架构,pc 对应就是 rip 寄存器。\n引用寄存器内容是将寄存器名前置 $ 符作为变量来用。比如 $pc 就是程序计数器寄存器值。\ninfo args\n显示当前函数参数\ninfo locals\n显示当前局部变量\ninfo frame\n查看当前栈帧的详细信息,包括 rip 信息,正在运行的指令所在文件位置\ninfo variables\n查看程序中的变量符号\ninfo functions\n查看程序中的函数符号\ninfo functions regexp\n通过正则匹配来查看程序中的函数符号\ninfo goroutines\n显示当前执行的 goroutine 列表,带 * 的表示当前执行的。注意需要加载 go runtime 支持。\ninfo stack\n查看栈信息\ninfo proc mappings\n可以简写成 i proc m。用来查看应用内存映射\ninfo proc [procid]\n显示进程信息\ninfo proc status\n显示进程相关信息:包括user id和group id;进程内有多少线程;虚拟内存的使用情况;挂起的信号,阻塞的信号,忽略的信号;TTY;消耗的系统和用户时间;堆栈大小;nice值\ninfo display\ninfo watchpoints\n列出当前所设置了的所有观察点\ninfo line [linenum]\n查看第 linenum 的代码指令地址信息,不带 linenum 时,显示的是当前位置的指令地址信息\ninfo source\n显示此源代码的源代码语言\ninfo sources\n显示程序中所有有调试信息的源文件名,一共显示两个列表:一个是其符号信息已经读过的,一个是还未读取过的\ninfo types\n显示程序中所有类型符号\ninfo types regexp\n通过正则匹配来查看程序中的类型符号\n其他类似命令有:\nshow args\n查看命令行参数\nshow environment [envname]\n查看环境变量信息\nshow paths\n查看程序的运行路径\nwhatis var1\n显示变量var1类型\nptype var1\n显示变量 var1 类型,若是 var1 结构体类型,会显示该结构体定义信息。\n查看调用栈 # 通过 where 可以查看调用栈信息:\n(gdb) where #0 _rt0_amd64 () at /usr/lib/go/src/runtime/asm_amd64.s:15 #1 0x0000000000000001 in ?? () #2 0x00007fffffffdd2c in ?? () #3 0x0000000000000000 in ?? () 设置观察点 # 通过 watch 命令,可以设置观察点。当观察点的变量发生变化时,程序会停下来。可以简写成 wa\n(gdb) watch sum 查看汇编代码 # 我们可以通过开启 disassemble-next-line 自动显示汇编代码。\n(gdb) set disassemble-next-line on 当面我们可以查看指定函数的汇编代码:\n(gdb) disassemble main.main disassemble 可以简写成 disas。我们也可以将源代码和汇编代码一一映射起来后进行查看\n(gdb) disas /m main.main GDB默认显示汇编指令格式是 AT\u0026amp;T 格式,我们可以改成 intel 格式:\n(gdb) set disassembly-flavor intel 自动显示变量值 # display 命令支持自动显示变量值功能。当进行 next 或者 step 等调试操作时候,GDB会自动显示 display 所设置的变量或者地址的值信息。\ndisplay 命令格式:\ndisplay \u0026lt;expr\u0026gt; display /\u0026lt;fmt\u0026gt; \u0026lt;expr\u0026gt; display /\u0026lt;fmt\u0026gt; \u0026lt;addr\u0026gt; expr是一个表达式 fmt表示显示的格式 addr表示内存地址 其他相关命令:\nundisplay [num]: 不显示 delete display [num]: 删除 disable display [num]: 关闭自动显示 enable display [num]: 开启自动显示 info display: 查看display信息 注意: 当不带display号时候,是针对所有display进行设置。\n显示将要执行的汇编指令 # 我们可以通过 display 命令可以实现当程序停止时,查看将要执行的汇编指令:\n(gdb) display /i $pc (gdb) display /3i $pc # 一次性显示3条指令 取消显示可以用 undisplay 命令进行操作。\n查看backtrace信息 # backtrace 命令用来查看栈帧信息。可以简写成 bt。\n(gdb) backtrace # 显示当前函数的栈帧以及局部变量信息 (gdb) backtrace full # 显示各个函数的栈帧以及局部变量值 (gdb) backtrace full n # 从内向外显示n个栈桢,及其局部变量 (gdb) backtrace full -n # 从外向内显示n个栈桢,及其局部变量 切换栈帧信息 # frame 命令可以切换栈帧信息:\n(gdb) frame n # 其中n是层数,最内层的函数帧为第0帧 其他相关命令:\ninfo frame: 查看栈帧列表 调试多线程 # GDB中有一组命令能够辅助多线程的调试:\ninfo threads\n显示当前可调式的所有线程,线程 ID 前有 “*” 表示当前被调试的线程。\nthread threadid\n切换线程到线程threadid\nset scheduler-locking [on|off|step]\n多线程环境下,会存在多个线程运行,这会影响调试某个线程的结果,这个命令可以设置调试的时候多个线程的运行情况,on 表示只有当前调试的线程会继续执行,off 表示不屏蔽任何线程,所有线程都可以执行,step 表示在单步执行时,只有当前线程会执行。\nthread apply [threadid] [all] args\n对线程列表执行命令。比如通过 thread apply all bt full 可以查看所有线程的局部变量信息。\n查看运行时变量 # print 命令可以用来查看变量的值。print 命令可以简写成 p。print 命令格式如下:\nprint [\u0026lt;/format\u0026gt;] \u0026lt;expr\u0026gt; format 用来设置显示变量的格式,是可选的选项。其可用值如下所示:\nx 按十六进制格式显示变量 d 按十进制格式显示变量 u 按十六进制格式显示无符号整型 o 按八进制格式显示变量 t 按二进制格式显示变量 a 按十六进制格式显示变量 c 按字符格式显示变量 f 按浮点数格式显示变量 z 按十六进制格式显示变量,左侧填充零 expr 可以是一个变量,也可以是表达式,也可以是寄存器:\n(gdb) p var1 # 打印变量var1 (gdb) p \u0026amp;var1 # 打印变量var1地址 (gdb) p $rsp # 打印rsp寄存器地址 (gdb) p $rsp + 8 # 打印rsp加8后的地址信息 (gdb) p 0xc000068fd0 # 打印0xc000068fd0转换成10进制格式 (gdb) p /x 824634150864 # 打印824634150864转换成16进制格式 print 也支持查看连续内存,@ 操作符用于查看连续内存,@ 的左边是第一个内存的地址的值,@ 的右边则想查看内存的长度。\n例如对于如下代码:int arr[] = {2, 4, 6, 8, 10};,可以通过如下命令查看 arr 前三个单元的数据:\n(gdb) p *arr@3 $2 = {2, 4, 6} 查看内存中的值 # examine 命令用来查看内存地址中的值,可以简写成 x。examine 命令的语法如下所示:\nexamine /\u0026lt;n/f/u\u0026gt; \u0026lt;addr\u0026gt; n 表示显示字段的长度,也就是说从当前地址向后显示几个地址的内容。\nf 表示显示的格式\nd 数字 decimal u 无符号数字 unsigned decimal s 字符串 string c 字符 char u 无符号整数 unsigned integer t 二进制 binary o 八进制格式 octal x 十六进制格式 hex f 浮点数格式 float i 指令 instruction a 地址 address z 十六进制格式,左侧填充零 hex, zero padded on the left u 表示从当前地址往后请求的字节数,默认是4个bytes\nb 一个字节 byte h 两个字节 halfword w 四个字节 word g 八个字节 giantword 示例:\n(gdb) x/10c 0x4005d4 # 打印前10个字符 (gdb) x/16xb a # 以16进制格式打印数组前a16个byte的值 (gdb) x/16ub a # 以无符号10进制格式打印数组a前16个byte的值 (gdb) x/16tb a # 以2进制格式打印数组前16个abyte的值 (gdb) x/16xw a # 以16进制格式打印数组a前16个word(4个byte)的值 (gdb) x $rsp # 打印rsp寄存器执行的地址的值 (gdb) x $rsp + 8 # 打印rsp加8后的地址指向的值 (gdb) x 0xc000068fd0 # 打印内存0xc000068fd0指向的值 (gdb) x/5i schedule # 打印函数schedule前5条指令 修改变量或寄存器值 # set 命令支持修改变量以及寄存器的值:\n(gdb) set var var1=123 # 设置变量var1值为123 (gdb) set var $rax=123 # 设置寄存器值为123 (gdb) set environment envname1=123 # 设置环境变量envname1值为123 查看命令帮助信息 # help 命令支持查看GDB命令帮助信息。\n(gdb) help status # 查看所有命令使用示例 (gdb) help x # 查看x命令使用帮助 搜索源文件 # search 命令支持在当前文件中使用正则表达式搜索内容。search 等效于 forward-search 命令,是从当前位置向前搜索,可以简写成 fo。reverse-search 命令功能跟 forward-search 恰好相反,其可以简写成 rev。\n(gdb) search func add # 从当前位置向前搜索add方法 (gdb) rev func add # 从当前为向后搜索add方法 执行shell命令 # 我们可以通过 shell 指令来执行shell命令。\n(gdb) shell cat /proc/27889/maps # 查看进程27889的内存映射。若想查看当前进程id,可以使用info proc命令获取 (gdb) shell ls -alh GDB对go runtime支持 # runtime.Breakpoint():触发调试器断点。 runtime/debug.PrintStack():显示调试堆栈。 log:适合替代 print显示调试信息 为系统调用设置捕获点 # GDB支持为系统调用设置捕获点(catchpoint),我们可以通过 catch 指令,后面加上 系统调用号(syscall numbers)1 或者系统调用助记符(syscall mnemonic names,也称为系统调用名称) 来设置捕获点。如果不指定系统调用的话,默认是捕获所有系统调用。\n(gdb) catch syscall 231 Catchpoint 1 (syscall \u0026#39;exit_group\u0026#39; [231]) (gdb) catch syscall exit_group Catchpoint 2 (syscall \u0026#39;exit_group\u0026#39; [231]) (gdb) catch syscall Catchpoint 3 (any syscall) 设置源文件查找路径 # 在程序调试过程中,构建程序的源文件位置更改之后,gdb不能找到源文件位置,我们可以使用 directory命令设置查找源文件的路径。\ndirectory ~/www/go/src/github.com/go-delve/ directory 命令只使用相对路径下的源文件,若绝对路径下源文件找不到,我们可以使用 set substitute-path 设置路径替换。\nset substitute-path ~/www/go/src/github.com/go-delve/ ~/www/go/src/github.com/go-delve2/ 批量执行命令 # GDB支持以脚本形式运行命令,我们可以使用下面的选项:\n-ex选项可以用来指定执行命令 -iex选来用来指定加载应用程序之前需执行的命令 -x 选项用来从指定文件中加载命令 -batch类似-q,支持安静模式,会指示GDB在所有命令执行完成之后,退出 # 1. 打印提示语 2. 在main.main出设置断点 3. 运行程序 4. 执行完成程序退出gdb gdb -iex \u0026#39;echo 开始执行:\\n\u0026#39; -ex \u0026#34;b main.main\u0026#34; -ex \u0026#34;run\u0026#34; -batch ./main # 设置exit/exit_group系统调用追踪点,然后运行程序,最后打印backtrace信息 gdb -ex \u0026#34;catch syscall exit exit_group\u0026#34; -ex \u0026#34;run\u0026#34; -ex \u0026#34;bt\u0026#34; -batch ./main # 从文件中加载命令 gdb -batch -x /tmp/cmds --args executablename arg1 arg2 arg3 GDB增强插件 # gdbinit gdb-dashboard pwndbg peda 进一步阅读 # Beej\u0026rsquo;s Quick Guide to GDB 100个gdb小技巧 https://x64.syscall.sh/\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n"},{"id":7,"href":"/function/first-class/","title":"Go函数是一等公民","section":"函数","content":" 一等公民 # Go语言中函数是一等公民(first class),因为它既可以作为变量,也可作为函数参数,函数返回值。Go语言还支持匿名函数,闭包,函数返回多个值。\n一等公民特征 # 函数赋值给一个变量 # func add(a, b int) int { return a + b } func main() { fn := add fmt.Println(fn(1, 2)) // 3 } 函数作为返回值 # func pow(a int) func(int) int { return func(b int) int { result := 1 for i := 0; i \u0026lt; b; i++ { result *= a } return result } } func main() { powOfTwo := pow(2) // 2的x次幂 fmt.Println(powOfTwo(3)) // 8 fmt.Println(powOfTwo(4)) // 16 powOfThree := pow(3) // 3的x次幂 fmt.Println(powOfThree(3)) // 27 fmt.Println(powOfThree(4)) // 81 } 函数作为函数参数传递 # 下面示例中使用匿名函数作为函数参数传递另外一个函数。\nfunc filter(a []int, fn func(int) bool) (result []int) { for _, v := range a { if fn(v) { result = append(result, v) } } return result } func main() { data := []int{1, 2, 3, 4, 5} // 传递奇数过滤器函子,过滤出奇数 fmt.Println(filter(data, func(a int) bool { return a\u0026amp;1 == 1 })) // 1, 3, 5 // 过滤出偶数 fmt.Println(filter(data, func(a int) bool { return a\u0026amp;1 == 0 })) // 2, 4 } 使用闭包函数构建一个生成器 # 生成器指的是每次调用时候总是返回下一序列值。下面演示一个整数的生成器:\nfunc generateInteger() func() int { ch := make(chan int) count := 0 go func() { for { ch \u0026lt;- count count++ } }() return func() int { return \u0026lt;-ch } } func main() { generate := generateInteger() fmt.Println(generate()) // 0 fmt.Println(generate()) // 1 fmt.Println(generate()) // 2 } 函数式编程 # 函数式编程(functional programming)是一种编程范式,其核心思想是将复杂的操作采用函数嵌套、组合调用方式来处理。函数式编程一大特征是函数是一等公民,Go语言中函数是一等公民,但是由于其不支持泛型,Go语言中采用函数式编程有时候是无法通用性的。比如上面的过滤器示例,当想要支持过滤int64类型的,就需要重写一遍或者传递interface{}参数。\n高阶函数 # 高阶函数(Higher-order function)指的是至少满足下列一个条件的函数:\n接受一个或多个函数作为输入 输出一个函数 高阶函数是函数式编程中常用范式,常见使用案例有:\n过滤器 apply函数 排序函数 回调函数 函数柯里化 合成函数 进一步阅读 # 7 Easy functional programming techniques in Go "},{"id":8,"href":"/function/call-stack/","title":"Go函数调用栈","section":"函数","content":" 调用栈 # 这一章节延续前面《 准备篇-Go汇编 》那一章节。这一章节将从一个实例出发详细分析Go 语言中函数调用栈。这一章节会涉及caller,callee,寄存器相关概念,如果还不太了解可以去《 准备篇-Go汇编 》查看了解。\n在详细分析函数栈之前,我们先复习以下几个概念。\ncaller 与 callee # 如果一个函数调用另外一个函数,那么该函数被称为调用者函数,也叫做caller,而被调用的函数称为被调用者函数,也叫做callee。比如函数main中调用sum函数,那么main就是caller,而sum函数就是callee。\n栈帧 # 栈帧(stack frame)指的是未完成函数所持有的,独立连续的栈区域,用来保存其局部变量,返回地址等信息。\n函数调用约定 # 函数调用约定(Calling Conventions)是 ABI(Application Binary Interface) 的组成部分,它描述了:\n如何将执行控制权交给callee,以及返还给caller 如何保存和恢复caller的状态 如何将参数传递个callee 如何从callee获取返回值 简而言之,一句话就是函数调用约定指的是约定了函数调用时候,函数参数如何传递,函数栈由谁完成平衡,以及函数返回值如何返回的。\n在Go语言中,函数的参数和返回值的存储空间是由其caller的栈帧提供。这也为Go语言为啥支持多返回值以及总是值传递的原因。从Go汇编层面看,在callee中访问其参数和返回值,是通过FP寄存器来操作的(在实现层面是通过SP寄存器访问的)。Go语言中函数参数入栈顺序是从右到左入栈的。\n函数调用时候,会为其分配栈空间用来存放临时变量,返回值等信息,当完成调用后,这些栈空间应该进行回收,以恢复调用以前的状态。这个过程就是栈平衡。栈平衡工作可以由被调用者本身(callee)完成,也可以由其调用者(caller)完成。在Go语言中是由callee来完成栈平衡的。\n函数栈 # 当前函数作为caller,其本身拥有的栈帧以及其所有callee的栈帧,可以称为该函数的函数栈,也称函数调用栈。C语言中函数栈大小是固定的,如果超出栈空间,就会栈溢出异常。比如递归求斐波拉契,这时候可以使用尾调用来优化。由于Go 语言栈可以自动进行分裂扩容,栈空间不够时候,可以自动进行扩容。当用火焰图分析性能时候,火焰越高,说明栈越深。\nGo 语言中函数栈全景图如下:\nGo语言函数调用栈 接下来的函数调用栈分析,都是基于函数栈的全景图出发。知道该全景图每一部分含义也就了解函数调用栈。\n实例分析 # 我们将分析如下代码。\npackage main func sum(a, b int) int { sum := 0 sum = a + b return sum } func main() { a := 3 b := 5 print(sum(a, b)) } 参照前面的函数栈全景图,我们画出main函数调用sum函数时的函数调用栈图:\nmain函数调用栈 从栈底往栈顶,我们依次可以看到:\nmain函数的caller的基址(Base Pointer)。这部分是黄色区域。 main函数局部变量a,b。我们看到a,b变量按照他们出现的顺序依次入栈,在实际指令中可能出现指令重排,a,b变量入栈顺序可能相反,但这个不影响最终结果。这部分是蓝色区域。 接下来是绿色区域,这部分是用来存放sum函数返回值的。这部分空间是提前分配好了。由于sum函数返回值只有一个,且是int类型,那么绿色区域大小是8字节(64位系统下int占用8字节)。在sum函数内部是通过FP寄存器访问这个栈空间的。 在下来就是浅黄色区域,这个是存放sum函数实参的。从上面介绍中我们知道Go语言中函数参数是从右到左入栈的,sum函数的签名是func sum(a, b int) int,那么b=5会先入栈,a=3接着入栈。 接下来是粉红色区域,这部分存放的是return address。main函数调用sum函数时候,会将sum函数后面的一条指令入栈。从main函数caller的基址空间到此处都属于main的函数栈帧。 接下来就是sum函数栈帧空间部分。首先同main函数栈帧空间一样,其存放的sum函数caller的基址,由于sum函数的caller就是main函数,所以这个地方存放就是main栈帧的栈底地址。 \u0026hellip;. 从汇编的角度观察 # 接下来我们从Go 汇编角度查看main函数调用sum函数时的函数调用栈。\nGo语言中函数的栈帧空间是提前分配好的,分配的空间用来存放函数局部变量,被调用函数参数,被调用函数返回值,返回地址等信息。我们来看下main函数和sum函数的汇编定义:\nTEXT\t\u0026#34;\u0026#34;.main(SB), ABIInternal, $56-0 // main函数定义 TEXT\t\u0026#34;\u0026#34;.sum(SB), NOSPLIT|ABIInternal, $16-24 // sum函数定义 从上面函数定义可以看出来给main函数分配的栈帧空间大小是56字节大小(这里面的56字节大小,是不包括返回地址空间的,实际上main函数的栈帧大小是56+8(返回地址占用8字节空间大小) = 64字节大小),由于main函数没有参数和返回值,所以参数和返回值这部分大小是0。给sum函数分配的栈帧空间大小是16字节大小,sum函数参数有2个,且都是int类型,返回值是int类型,所以参数和返回值大小是24字节。\n关于函数声明时每个字段的含义可以去《 准备篇-Go汇编-函数声明 》 查看:\n需要注意的有两点:\n函数分配的栈空间足以放下所有被调用者信息,如果一个函数会调用很多其他函数,那么它的栈空间是按照其调用函数中最大空间要求来分配的。 函数栈空间是可以split。当栈空间不足时候,会进行split,重新找一块2倍当前栈空间的内存空间,将当前栈帧信息拷贝过去,这个叫栈分裂。Go语言在栈分裂基础上实现了抢占式调度,这个我们会在后续篇章详细探讨。我们可以使用 //go:nosplit 这个编译指示,强制函数不进行栈分裂。从sum函数定义可以看出来,其没有进行栈分裂处理。 接下来我们分析main函数的汇编代码:\n0x0000 00000 (main.go:9)\tTEXT\t\u0026#34;\u0026#34;.main(SB), ABIInternal, $56-0 # main函数定义 0x0000 00000 (main.go:9)\tMOVQ\t(TLS), CX # 将本地线程存储信息保存到CX寄存器中 0x0009 00009 (main.go:9)\tCMPQ\tSP, 16(CX) # 比较当前栈顶地址(SP寄存器存放的)与本地线程存储的栈顶地址 0x000d 00013 (main.go:9)\tPCDATA\t$0, $-2 # PCDATA,FUNCDATA用于Go汇编额外信息,不必关注 0x000d 00013 (main.go:9)\tJLS\t114 # 如果当前栈顶地址(SP寄存器存放的)小于本地线程存储的栈顶地址,则跳到114处代码处进行栈分裂扩容操作 0x000f 00015 (main.go:9)\tPCDATA\t$0, $-1 0x000f 00015 (main.go:9)\tSUBQ\t$56, SP # 提前分配好56字节空间,作为main函数的栈帧,注意此时的SP寄存器指向,会往下移动了56个字节 0x0013 00019 (main.go:9)\tMOVQ\tBP, 48(SP) # BP寄存器存放的是main函数caller的基址,movq这条指令是将main函数caller的基址入栈。对应就是上图中我们看到的main函数栈帧的黄色区域。 0x0018 00024 (main.go:9)\tLEAQ\t48(SP), BP # 将main函数的基址存放到到BP寄存器 0x001d 00029 (main.go:9)\tPCDATA\t$0, $-2 0x001d 00029 (main.go:9)\tPCDATA\t$1, $-2 0x001d 00029 (main.go:9)\tFUNCDATA\t$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (main.go:9)\tFUNCDATA\t$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (main.go:9)\tFUNCDATA\t$2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d 00029 (main.go:10)\tPCDATA\t$0, $0 0x001d 00029 (main.go:10)\tPCDATA\t$1, $0 0x001d 00029 (main.go:10)\tMOVQ\t$3, \u0026#34;\u0026#34;.a+32(SP) # main函数局部变量a入栈 0x0026 00038 (main.go:11)\tMOVQ\t$5, \u0026#34;\u0026#34;.b+24(SP) # main函数局部变量b入栈 0x002f 00047 (main.go:12)\tMOVQ\t\u0026#34;\u0026#34;.a+32(SP), AX # 将局部变量a保存到AX寄存中 0x0034 00052 (main.go:12)\tMOVQ\tAX, (SP) # sum函数第二个参数 0x0038 00056 (main.go:12)\tMOVQ\t$5, 8(SP) # sum函数第一个参数 0x0041 00065 (main.go:12)\tCALL\t\u0026#34;\u0026#34;.sum(SB) # 通过call指令调用sum函数。此时会隐式进行两个操作:1. 将当前指令的下一条指令的地址入栈。当前指令下一条指令就是MOVQ 16(SP), AX,其相对地址是0x0046。2. IP指令寄存器指向了sum函数指令入库地址。 0x0046 00070 (main.go:12)\tMOVQ\t16(SP), AX #将sum函数值保存AX寄存中。16(SP) 存放的是sum函数的返回值 0x004b 00075 (main.go:12)\tMOVQ\tAX, \u0026#34;\u0026#34;..autotmp_2+40(SP) 0x0050 00080 (main.go:12)\tCALL\truntime.printlock(SB) 0x0055 00085 (main.go:12)\tMOVQ\t\u0026#34;\u0026#34;..autotmp_2+40(SP), AX 0x005a 00090 (main.go:12)\tMOVQ\tAX, (SP) 0x005e 00094 (main.go:12)\tCALL\truntime.printint(SB) 0x0063 00099 (main.go:12)\tCALL\truntime.printunlock(SB) 0x0068 00104 (main.go:13)\tMOVQ\t48(SP), BP 0x006d 00109 (main.go:13)\tADDQ\t$56, SP 0x0071 00113 (main.go:13)\tRET 0x0072 00114 (main.go:13)\tNOP 0x0072 00114 (main.go:9)\tPCDATA\t$1, $-1 0x0072 00114 (main.go:9)\tPCDATA\t$0, $-2 0x0072 00114 (main.go:9)\tCALL\truntime.morestack_noctxt(SB) # 调用栈分裂处理函数 0x0077 00119 (main.go:9)\tPCDATA\t$0, $-1 0x0077 00119 (main.go:9)\tJMP\t0 结合汇编,我们最终画出 main 函数调用栈图:\nmain函数调用栈 "},{"id":9,"href":"/go-assembly/","title":"Go汇编语法","section":"简介","content":" Go汇编 # 本节将介绍Go语言所使用到的汇编知识。在介绍Go汇编之前,我们先了解一些汇编语言,寄存器, AT\u0026amp;T 汇编语法,内存布局等前置知识点。这些知识点与Go汇编或多或少有关系,了解这些才能更好的帮助我们去看懂Go汇编代码。\n前置知识 # 机器语言 # 机器语言是机器指令的集合。计算机的机器指令是一系列二进制数字。计算机将之转换为一系列高低电平脉冲信号来驱动硬件工作的。\n汇编语言 # 机器指令是由0和1组成的二进制指令,难以编写与记忆。汇编语言是二进制指令的文本形式,与机器指令一一对应,相当于机器指令的助记码。比如,加法的机器指令是00000011写成汇编语言就是ADD。汇编的指令格式由操作码和操作数组成。\n将助记码标准化后称为assembly language,缩写为asm,中文译为汇编语言。\n汇编语言大致可以分为两类:\n基于x86架构处理器的汇编语言\nIntel 汇编 DOS(8086处理器), Windows Windows 派系 -\u0026gt; VC 编译器 AT\u0026amp;T 汇编 Linux, Unix, Mac OS, iOS(模拟器) Unix派系 -\u0026gt; GCC编译器 基于ARM 架构处理器的汇编语言\nARM 汇编 数据单元大小 # 汇编中数据单元大小可分为:\n位 bit 半字节 Nibble 字节 Byte 字 Word 相当于两个字节 双字 Double Word 相当于2个字,4个字节 四字 Quadword 相当于4个字,8个字节 寄存器 # 寄存器是CPU中存储数据的器件,起到数据缓存作用。内存按照内存层级(memory hierarchy)依次分为寄存器,L1 Cache, L2 Cache, L3 Cache,其读写延迟依次增加,实现成本依次降低。\n内存层级结构 寄存器分类 # 一个CPU中有多个寄存器。每一个寄存器都有自己的名称。寄存器按照种类分为通用寄存器和控制寄存器。其中通用寄存器有可细分为数据寄存器,指针寄存器,以及变址寄存器。\n1979年因特尔推出8086架构的CPU,开始支持16位。为了兼容之前8008架构的8位CPU,8086架构中AX寄存器高8位称为AH,低8位称为AL,用来对应8008架构的8位的A寄存器。后来随着x86,以及x86-64 架构的CPU推出,开始支持32位以及64位,为了兼容并保留了旧名称,16位处理器的AX寄存器拓展成EAX(E代表拓展Extended的意思)。对于64位处理器的寄存器相应的RAX(R代表寄存器Register的意思)。其他指令也类似。\n各个寄存器功能介绍:\n寄存器 功能 AX A代表累加器Accumulator,X是八位寄存器AH和AL的中H和L的占位符,表示AX由AH和AL组成。AX一般用于算术与逻辑运算,以及作为函数返回值 BX B代表Base,BX一般用于保存中间地址(hold indirect addresses) CX C代表Count,CX一般用于计数,比如使用它来计算循环中的迭代次数或指定字符串中的字符数 DX D代表Data,DX一般用于保存某些算术运算的溢出,并且在访问80x86 I/O总线上的数据时保存I/O地址 DI DI代表Destination Index,DI一般用于指针 SI SI代表Source Index,SI用途同DI一样 SP SP代表Stack Pointer,是栈指针寄存器,存放着执行函数对应栈帧的栈顶地址,且始终指向栈顶 BP BP代表Base Pointer,是栈帧基址指针寄存器,存放这执行函数对应栈帧的栈底地址,一般用于访问栈中的局部变量和参数 IP IP代表Instruction Pointer,是指令寄存器,指向处理器下条等待执行的指令地址(代码段内的偏移量),每次执行完相应汇编指令IP值就会增加;IP是个特殊寄存器,不能像访问通用寄存器那样访问它。IP可被jmp、call和ret等指令隐含地改变 进程在虚拟内存中布局 # 32位系统下,虚拟内存空间大小为4G,每一个进程独立的运行在该虚拟内存空间上。从0x00000000开始的3G空间属于用户空间,剩下1G空间属于内核空间。\n用户空间还可以进一步细分,每一部分叫做段(section),大致可以分为以下几段:\nStack 栈空间:用于函数调用中存储局部变量、返回地址、返回值等,向下增长,变量存储和使用过程叫做入栈和出栈过程 Heap 堆空间:用于动态申请的内存,比如c语言通过malloc函数调用分配内存,其向上增长。指针型变量指向的一般就是这里面的空间。存储此空间的数据需要GC的。栈上变量scope是函数级的,而堆上变量属于进程级的 Bss段:未初始化数据区,存储未初始化的全局变量或静态变量 Data段:初始化数据区,存储已经初始化的全局变量或静态变量 Text段:代码区,存储的是源码编译后二进制指令 在32位系统中进程空间(即用户空间)范围为0x00000000 ~ 0xbfffffff,内核空间范围为0xc0000000 ~ 0xffffffff, 实际上分配的进程空间并不是从0x00000000开始的,而是从0x08048000开始,到0xbfffffff结束。另外进程实际的esp指向的地址并不是从0xbfffffff开始的,因为linux系统会在程序初始化前,将一些命令行参数及环境变量以及ELF辅助向量(ELF Auxiliary Vectors)等信息放到栈上。进程启动时,其空间布局如下所示(注意图示中地址是从低地址到高地址的):\nstack pointer -\u0026gt; [ argc = number of args ] 4 [ argv[0] (pointer) ] 4 (program name) [ argv[1] (pointer) ] 4 [ argv[..] (pointer) ] 4 * x [ argv[n - 1] (pointer) ] 4 [ argv[n] (pointer) ] 4 (= NULL) [ envp[0] (pointer) ] 4 [ envp[1] (pointer) ] 4 [ envp[..] (pointer) ] 4 [ envp[term] (pointer) ] 4 (= NULL) [ auxv[0] (Elf32_auxv_t) ] 8 [ auxv[1] (Elf32_auxv_t) ] 8 [ auxv[..] (Elf32_auxv_t) ] 8 [ auxv[term] (Elf32_auxv_t) ] 8 (= AT_NULL vector) [ padding ] 0 - 16 [ argument ASCIIZ strings ] \u0026gt;= 0 [ environment ASCIIZ strings ] \u0026gt;= 0 [ program name ASCIIZ strings ] \u0026gt;= 0 (0xbffffffc) [ end marker ] 4 (= NULL) (0xc0000000) \u0026lt; bottom of stack \u0026gt; 0 (virtual) 进程空间起始位置处存放命令行参数个数与参数信息,我们将在后面章节有讨论到。\ncaller 与 callee # 如果一个函数调用另外一个函数,那么该函数被称为调用者函数,也叫做caller,而被调用的函数称为被调用者函数,也叫做callee。比如函数main中调用sum函数,那么main就是caller,而sum函数就是callee。\n栈帧 # 栈帧即stack frame,即未完成函数所持有的,独立连续的栈区域,用来保存其局部变量,返回地址等信息。\n函数栈 # 当前函数作为caller,其本身拥有的栈帧以及其所有callee的栈帧,可以称为该函数的函数栈。一般情况下函数栈大小是固定的,如果超出栈空间,就会栈溢出异常。比如递归求斐波拉契,这时候可以使用尾调用来优化。用火焰图分析性能时候,火焰越高,说明栈越深。\nAT\u0026amp;T 汇编语法 # AT&T汇编语法是类Unix的系统上的标准汇编语法,比如gcc、gdb中默认都是使用AT\u0026amp;T汇编语法。AT\u0026amp;T汇编的指令格式如下:\ninstruction src dst 其中instruction是指令助记符,也叫操作码,比如mov就是一个指令助记符,src是源操作数,dst是目的操作。\n当引用寄存器时候,应在寄存器名称加前缀%,对于常数,则应加前缀 $。\n指令分类 # 数据传输指令 # 汇编指令 逻辑表达式 含义 mov $0x05, %ax R[ax] = 0x05 将数值5存储到寄存器ax中 mov %ax, -4(%bp) mem[R[bp] -4] = R[ax] 将ax寄存器中存储的数据存储到bp寄存器存的地址减去4之后的内存地址中, mov -4(%bp), %ax R[ax] = mem[R[bp] -4] bp寄存器存储的地址减去4值,然后改地址对应的内存存储的信息存储到ax寄存器中 mov $0x10, (%sp) mem[R[sp]] = 0x10 将16存储到sp寄存器存储的地址对应的内存 push $0x03 mem[R[sp]] = 0x03 R[sp] = R[sp] - 4 将数值03入栈,然后sp寄存器存储的地址减去4 pop R[sp] = R[sp] + 4 将当前sp寄存器指向的地址的变量出栈,并将sp寄存器存储的地址加4 call func1 \u0026mdash; 调用函数func1 ret \u0026mdash; 函数返回,将返回值存储到寄存器中或caller栈中,并将return address弹出到ip寄存器中 当使用mov指令传递数据时,数据的大小由mov指令的后缀决定。\nmovb $123, %eax // 1 byte movw $123, %eax // 2 byte movl $123, %eax // 4 byte movq $123, %eax // 8 byte 算术运算指令 # 指令 含义 subl $0x05, %eax R[eax] = R[eax] - 0x05 subl %eax, -4(%ebp) mem[R[ebp] -4] = mem[R[ebp] -4] - R[eax] subl -4(%ebp), %eax R[eax] = R[eax] - mem[R[ebp] -4] 跳转指令 # 指令 含义 cmpl %eax %ebx 计算 R[eax] - R[ebx], 然后设置flags寄存器 jmp location 无条件跳转到location je location 如果flags寄存器设置了相等标志,则跳转到location jg, jge, jl, gle, jnz, \u0026hellip; location 如果flags寄存器设置了\u0026gt;, \u0026gt;=, \u0026lt;, \u0026lt;=, != 0等标志,则跳转到location 栈与地址管理指令 # 指令 含义 等同操作 pushl %eax 将R[eax]入栈 subl $4, %esp; movl %eax, (%esp) popl %eax 将栈顶数据弹出,然后存储到R[eax] movl (%esp), %eax addl $4, %esp leave Restore the callers stack pointer movl %ebp, %esp pop %ebp lea\t8(%esp), %esi 将R[esp]存放的地址加8,然后存储到R[esi] R[esi] = R[esp] + 8 lea 是load effective address的缩写,用于将一个内存地址直接赋给目的操作数。\n函数调用指令 # 指令 含义 call label 调用函数,并将返回地址入栈 ret 从栈中弹出返回地址,并跳转至该返回地址 leave 恢复调用者者栈指针 注意:\n以上指令分类并不规范和完整,比如 call , ret 都可以算作无条件跳转指令,这里面是按照功能放在函数调用这一分类了。\nGo 汇编 # Go语言汇编器采用Plan9 汇编语法,该汇编语言是由贝尔实验推出来的。下面说的Go汇编也就是Plan9 汇编。 不同于C语言汇编中汇编指令的寄存器都是代表硬件寄存器,Go汇编中的寄存器使用的是伪寄存器,可以把Go汇编考虑成是底层硬件汇编之上的抽象。\n伪寄存器 # Go汇编一共有4个伪寄存器:\nFP: Frame pointer: arguments and locals.\n使用形如 symbol+offset(FP) 的方式,引用函数的输入参数。例如 arg0+0(FP),arg1+8(FP) offset是正值 PC: Program counter: jumps and branches.\nPC寄存器,在 x86 平台下对应 ip 寄存器,amd64 上则是 rip SB: Static base pointer: global symbols.\n全局静态基指针,一般用来声明函数或全局变量 SP: Stack pointer: top of stack.\nSP寄存器指向当前栈帧的局部变量的开始位置,使用形如 symbol+offset(SP) 的方式,引用函数的局部变量。 offset是负值,offset 的合法取值是 [-framesize, 0)。 手写汇编代码时,如果是 symbol+offset(SP) 形式,则表示伪寄存器 SP。如果是 offset(SP) 则表示硬件寄存器 SP。对于编译输出(go tool compile -S / go tool objdump)的代码来讲,所有的 SP 都是硬件寄存器 SP,无论是否带 symbol。 函数声明 # 参数大小+返回值大小 | TEXT pkgname·add(SB),NOSPLIT,$32-16 | | | 包名 函数名 栈帧大小 TEXT指令声明了pagname.add是在.text段\npkgname·add中的·,是一个 unicode 的中点。在程序被链接之后,所有的中点·都会被替换为点号.,所以通过 GDB 调试打断点时候,应该是 b pagname.add\n(SB): SB 是一个虚拟寄存器,保存了静态基地址(static-base) 指针,即我们程序地址空间的开始地址。 \u0026quot;\u0026quot;.add(SB) 表明我们的符号add位于某个固定的相对地址空间起始处的偏移位置\nobjdump -j .text -t test | grep \u0026#39;main.add\u0026#39; # 可获得main.add的绝对地址 NOSPLIT: 表明该函数内部不进行栈分裂逻辑处理,可以避免CPU资源浪费。关于栈分裂会在调度器章节介绍\n$32-16: $32代表即将分配的栈帧大小;而$16指定了传入的参数与返回值的大小\n函数调用栈 # Go汇编中函数调用的参数以及返回值都是由栈传递和保存的,这部分空间由caller在其栈帧(stack frame)上提供。Go汇编中没有使用PUSH/POP指令进行栈的伸缩处理,所有栈的增长和收缩是通过在栈指针寄存器SP上分别执行加减指令来实现的。\ncaller +------------------+ | | +----------------------\u0026gt; |------------------| | | caller parent BP | | |------------------| \u0026lt;--------- BP(pseudo SP) | | local Var0 | | |------------------| | | ......... | | |------------------| | | local VarN | | |------------------| | | temporarily | | unused space | caller stack frame |------------------| | callee retN | | |------------------| | | ......... | | |------------------| | | callee ret0 | | |------------------| | | callee argN | | |------------------| | | ......... | | |------------------| | | callee arg0 | | |------------------| \u0026lt;--------- FP(virtual register) | | return addr | +----------------------\u0026gt; |------------------| \u0026lt;----------------------+ | caller BP | | BP(pseudo SP) ------\u0026gt; |------------------| | | local Var0 | | |------------------| | | local Var1 | |------------------| callee stack frame | ......... | |------------------| | | local VarN | | SP(Real Register) ------\u0026gt; |------------------| | | | | | | | +------------------+ \u0026lt;----------------------+ callee 关于Go汇编进一步知识,我们将在 《 基础篇-函数-函数调用栈 》 章节详细探讨说明,此处我们只需要大致了解下函数声明、调用栈概念即可。\n获取Go汇编代码 # go代码示例:\npackage main import \u0026#34;fmt\u0026#34; //go:noinline func add(a, b int) int { return a + b } func main() { c := add(3, 5) fmt.Println(c) } go tool compile # go tool compile -N -l -S main.go GOOS=linux GOARCH=amd64 go tool compile -N -l -S main.go # 指定系统和架构 -N选项指示禁止优化 -l选项指示禁止内联 -S选项指示打印出汇编代码 若要禁止指定函数内联优化,也可以在函数定义处加上noinline编译指示:\n//go:noinline func add(a, b int) int { return a + b } go tool objdump # 方法1: 根据目标文件反编译出汇编代码\ngo tool compile -N -l main.go # 生成main.o go tool objdump main.o go tool objdump -s \u0026#34;main.(main|add)\u0026#34; ./test # objdump支持搜索特定字符串 方法2: 根据可执行文件反编译出汇编代码\ngo build -gcflags=\u0026#34;-N -l\u0026#34; main.go -o test go tool objdump main.o go build -gcflags -S # go build -gcflags=\u0026#34;-N -l -S\u0026#34; main.go 其他方法 # objdump命令 go编译器:gccgo 在线转换汇编代码:godbolt 进一步阅读 # Go官方:A Quick Guide to Go\u0026rsquo;s Assembler plan9 assembly 完全解析 x86 Assembly book What is an ABI? "},{"id":10,"href":"/compiler/","title":"Go编译流程","section":"简介","content":" 编译流程 # Go语言是一门编译型语言,程序运行时需要先编译成相应平台的可执行文件。在介绍Go语言编译流程之前,我们来了解下编译器编译整个流程。\n编译六阶段 # 编译器工作目标是完成从高级语言(high-level langue)到机器码(machine code)的输出。整个编译流程可分为两部分,每个部分又可以细分为三个阶段,也就是说整个编译流程分为六个阶段。编译流程的两部分别是分析部分(Analysis part)以及合成部分(Synthesis part),也称为编译前端和编译后端。编译六阶段如下:\n词法分析(Lexical analysis) 语法分析(Syntax analysis) 语义分析(Semantic analysis) 中间码生成(Intermediate code generator) 代码优化(Code optimizer) 机器代码生成(Code generator) 词法分析 # 词法分析最终生成的是Tokens。词法分析时编译器扫描源代码,从当前行最左端开始到最右端,然后将扫描到的字符进行分组标记。编译器会将扫描到的词法单位(Lexemes)归类到常量、保留字、运算符等标记(Tokens)中。我们以c = a+b*5为例:\nLexemes Tokens c identifier = assignment symbol a identifier + + (addition symbol) b identifier * * (multiplication symbol) 5 5 (number) 语法分析 # 词法分析阶段接收上一阶段生成的Tokens序列,基于特定编程语言的规则生成抽象语法树(Abstract Syntax Tree)。\n抽象语法树 # 抽象语法树(Abstract Syntax Tree),简称AST,是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。\n以(a+b)*c为例,最终生成的抽象语法树如下:\n语义分析阶段 # 语义分析阶段用来检查代码的语义一致性。它使用前一阶段的语法树以及符号表来验证给定的源代码在语义上是一致的。它还检查代码是否传达了适当的含义。\n中间码生成阶段 # 中间代码介是于高级语言和机器语言之间,具有跨平台特性。使用中间代码可以易于跨平台转换为特定类型目标机器代码。\n代码优化阶段 # 代码优化阶段主要是改进中间代码,删除不必要的代码,以调整代码序列以生成速度更快和空间更少的中间代码。\n机器码生成阶段 # 机器码生成阶段是编译器工作的最后阶段。此阶段会基于中间码生成汇编代码,汇编器根据汇编代码生成目标文件,目标文件经过链接器处理最终生成可执行文件。\nGo语言编译流程 # 上面介绍了编译器工作整个流程,Go语言编译器编译也符合上面流程:\nGo语言编译流程 我们执行go build命令时候,带上-n选项可以观察编译流程所执行所有的命令:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 # # command-line-arguments # mkdir -p $WORK/b001/ cat \u0026gt;$WORK/b001/importcfg \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal # import config packagefile runtime=/usr/lib/go/pkg/linux_amd64/runtime.a EOF cd /home/vagrant/dive-into-go /usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath \u0026#34;$WORK/b001=\u0026gt;\u0026#34; -p main -complete -buildid aJhlsTb17ElgWQeF76b5/aJhlsTb17ElgWQeF76b5 -goversion go1.14.13 -D _/home/vagrant/dive-into-go -importcfg $WORK/b001/importcfg -pack ./empty_string.go /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/_pkg_.a # internal cat \u0026gt;$WORK/b001/importcfg.link \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal packagefile command-line-arguments=$WORK/b001/_pkg_.a packagefile runtime=/usr/lib/go/pkg/linux_amd64/runtime.a packagefile internal/bytealg=/usr/lib/go/pkg/linux_amd64/internal/bytealg.a packagefile internal/cpu=/usr/lib/go/pkg/linux_amd64/internal/cpu.a packagefile runtime/internal/atomic=/usr/lib/go/pkg/linux_amd64/runtime/internal/atomic.a packagefile runtime/internal/math=/usr/lib/go/pkg/linux_amd64/runtime/internal/math.a packagefile runtime/internal/sys=/usr/lib/go/pkg/linux_amd64/runtime/internal/sys.a EOF mkdir -p $WORK/b001/exe/ cd . /usr/lib/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=FoylCipvV-SPkhyi2PJs/aJhlsTb17ElgWQeF76b5/aJhlsTb17ElgWQeF76b5/FoylCipvV-SPkhyi2PJs -extld=gcc $WORK/b001/_pkg_.a /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal mv $WORK/b001/exe/a.out empty_string 从上面命令输出的内容可以看到:\nGo编译器首先会创建一个任务输出临时目录(mkdir -p $WORK/b001/)。b001是root task的工作目录,每次构建都是由一系列task完成,它们构成 action graph\n接着将empty_string.go中依赖的包: /usr/lib/go/pkg/linux_amd64/runtime.a 写入到importcfg中\n接着会使用compile命令,并指定importcfg文件,将主程序empty_string.go编译成_pkg.a文件(/usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b001/pkg.a -trimpath \u0026ldquo;$WORK/b001=\u0026gt;\u0026rdquo; -p main -complete -buildid aJhlsTb17ElgWQeF76b5/aJhlsTb17ElgWQeF76b5 -goversion go1.14.13 -D _/home/vagrant/dive-into-go -importcfg $WORK/b001/importcfg -pack ./empty_string.go)。\n程序依赖的包都写到importcfg.link这个文件中,Go编译器连接阶段中链接器会使用该文件,找到所有依赖的包文件,将其连接到程序中(/usr/lib/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=FoylCipvV-SPkhyi2PJs/aJhlsTb17ElgWQeF76b5/aJhlsTb17ElgWQeF76b5/FoylCipvV-SPkhyi2PJs -extld=gcc $WORK/b001/pkg.a /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal)。\n将编译成功的二进制文件移动到输出目录中(mv $WORK/b001/exe/a.out empty_string)。\n为了详细查看go build整个详细过程,我们可以使用go build -work -a -p 1 -x empty_string.go命令来观察整个过程,它比go build -n提供了更详细的信息:\n-work选项指示编译器编译完成后保留编译临时工作目录 -a选项强制编译所有包。我们使用go build -n时候,只看到main包编译过程,这是因为其他包已经编译过了,不会再编译。我们可以使用这个选项强制编译所有包。 -p选项用来指定编译过程中线程数,这里指定为1,是为观察编译的顺序性 -x选项可以指定编译参数 输出内容摘要如下:\nvagrant@vagrant:~/dive-into-go$ go build -work -a -p 1 -x empty_string.go WORK=/tmp/go-build871888098 mkdir -p $WORK/b004/ cat \u0026gt;$WORK/b004/go_asm.h \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal EOF cd /usr/lib/go/src/internal/cpu /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b004=\u0026gt;\u0026#34; -I $WORK/b004/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -gensymabis -o $WORK/b004/symabis ./cpu_x86.s cat \u0026gt;$WORK/b004/importcfg \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal # import config EOF /usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b004/_pkg_.a -trimpath \u0026#34;$WORK/b004=\u0026gt;\u0026#34; -p internal/cpu -std -+ -buildid 8F_1bll3rU7d1mo74DFt/8F_1bll3rU7d1mo74DFt -goversion go1.14.13 -symabis $WORK/b004/symabis -D \u0026#34;\u0026#34; -importcfg $WORK/b004/importcfg -pack -asmhdr $WORK/b004/go_asm.h ./cpu.go ./cpu_amd64.go ./cpu_x86.go /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b004=\u0026gt;\u0026#34; -I $WORK/b004/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -o $WORK/b004/cpu_x86.o ./cpu_x86.s /usr/lib/go/pkg/tool/linux_amd64/pack r $WORK/b004/_pkg_.a $WORK/b004/cpu_x86.o # internal /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b004/_pkg_.a # internal cp $WORK/b004/_pkg_.a /home/vagrant/.cache/go-build/e2/e20b6a590621cff911735ea491492b992b429df9b0b579155aecbfdffdf7ec74-d # internal mkdir -p $WORK/b003/ cat \u0026gt;$WORK/b003/go_asm.h \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal EOF cd /usr/lib/go/src/internal/bytealg /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b003=\u0026gt;\u0026#34; -I $WORK/b003/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -gensymabis -o $WORK/b003/symabis ./compare_amd64.s ./count_amd64.s ./equal_amd64.s ./index_amd64.s ./indexbyte_amd64.s cat \u0026gt;$WORK/b003/importcfg \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal # import config packagefile internal/cpu=$WORK/b004/_pkg_.a EOF /usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b003/_pkg_.a -trimpath \u0026#34;$WORK/b003=\u0026gt;\u0026#34; -p internal/bytealg -std -+ -buildid I0-Z7SEGCaTIz2BZXZCm/I0-Z7SEGCaTIz2BZXZCm -goversion go1.14.13 -symabis $WORK/b003/symabis -D \u0026#34;\u0026#34; -importcfg $WORK/b003/importcfg -pack -asmhdr $WORK/b003/go_asm.h ./bytealg.go ./compare_native.go ./count_native.go ./equal_generic.go ./equal_native.go ./index_amd64.go ./index_native.go ./indexbyte_native.go /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b003=\u0026gt;\u0026#34; -I $WORK/b003/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -o $WORK/b003/compare_amd64.o ./compare_amd64.s /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b003=\u0026gt;\u0026#34; -I $WORK/b003/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -o $WORK/b003/count_amd64.o ./count_amd64.s /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b003=\u0026gt;\u0026#34; -I $WORK/b003/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -o $WORK/b003/equal_amd64.o ./equal_amd64.s /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b003=\u0026gt;\u0026#34; -I $WORK/b003/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -o $WORK/b003/index_amd64.o ./index_amd64.s /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b003=\u0026gt;\u0026#34; -I $WORK/b003/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -o $WORK/b003/indexbyte_amd64.o ./indexbyte_amd64.s /usr/lib/go/pkg/tool/linux_amd64/pack r $WORK/b003/_pkg_.a $WORK/b003/compare_amd64.o $WORK/b003/count_amd64.o $WORK/b003/equal_amd64.o $WORK/b003/index_amd64.o $WORK/b003/indexbyte_amd64.o # internal /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b003/_pkg_.a # internal cp $WORK/b003/_pkg_.a /home/vagrant/.cache/go-build/42/42c362e050cb454a893b15620b72fbb75879ac0a1fdd13762323eec247798a43-d # internal mkdir -p $WORK/b006/ cat \u0026gt;$WORK/b006/go_asm.h \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal EOF cd /usr/lib/go/src/runtime/internal/atomic /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b006=\u0026gt;\u0026#34; -I $WORK/b006/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -gensymabis -o $WORK/b006/symabis ./asm_amd64.s cat \u0026gt;$WORK/b006/importcfg \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal # import config EOF /usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b006/_pkg_.a -trimpath \u0026#34;$WORK/b006=\u0026gt;\u0026#34; -p runtime/internal/atomic -std -+ -buildid uI0THQvFtr7yRsGPOXDw/uI0THQvFtr7yRsGPOXDw -goversion go1.14.13 -symabis $WORK/b006/symabis -D \u0026#34;\u0026#34; -importcfg $WORK/b006/importcfg -pack -asmhdr $WORK/b006/go_asm.h ./atomic_amd64.go ./stubs.go /usr/lib/go/pkg/tool/linux_amd64/asm -trimpath \u0026#34;$WORK/b006=\u0026gt;\u0026#34; -I $WORK/b006/ -I /usr/lib/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -o $WORK/b006/asm_amd64.o ./asm_amd64.s /usr/lib/go/pkg/tool/linux_amd64/pack r $WORK/b006/_pkg_.a $WORK/b006/asm_amd64.o # internal /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b006/_pkg_.a # internal cp $WORK/b006/_pkg_.a /home/vagrant/.cache/go-build/6b/6b2c5449e17d9b0e34bfe37a77dc16b9675ffb657fbe9277a1067fa8ca5179ab-d # internal mkdir -p $WORK/b008/ cat \u0026gt;$WORK/b008/importcfg \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal # import config EOF cd /usr/lib/go/src/runtime/internal/sys /usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b008/_pkg_.a -trimpath \u0026#34;$WORK/b008=\u0026gt;\u0026#34; -p runtime/internal/sys -std -+ -complete -buildid AZJ761JYi_ToDiYI_5UA/AZJ761JYi_ToDiYI_5UA -goversion go1.14.13 -D \u0026#34;\u0026#34; -importcfg $WORK/b008/importcfg -pack ./arch.go ./arch_amd64.go ./intrinsics.go ./intrinsics_common.go ./stubs.go ./sys.go ./zgoarch_amd64.go ./zgoos_linux.go ./zversion.go /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b008/_pkg_.a # internal cp $WORK/b008/_pkg_.a /home/vagrant/.cache/go-build/f7/f706a1321f01a45857a441e80fd50709a700a9d304543d534a953827021222c1-d # internal mkdir -p $WORK/b007/ cat \u0026gt;$WORK/b007/importcfg \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal # import config packagefile runtime/internal/sys=$WORK/b008/_pkg_.a EOF cd /usr/lib/go/src/runtime/internal/math /usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b007/_pkg_.a -trimpath \u0026#34;$WORK/b007=\u0026gt;\u0026#34; -p runtime/internal/math -std -+ -complete -buildid NxqylyDav-hCzDju1Kr1/NxqylyDav-hCzDju1Kr1 -goversion go1.14.13 -D \u0026#34;\u0026#34; -importcfg $WORK/b007/importcfg -pack ./math.go /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b007/_pkg_.a # internal cp $WORK/b007/_pkg_.a /home/vagrant/.cache/go-build/f6/f6dcba7ea64d64182a26bcda498c1888786213b0b5560d9bde92cfff323be7df-d # internal ... 从上面可以看到编译器工作目录是/tmp/go-build871888098,cd进去之后,我们可以看到多个子目录,每个子目录都是用编译子task使用,存放的都是编译后的包:\nvagrant@vagrant:/tmp/go-build871888098$ ls b001 b002 b003 b004 b006 b007 b008 其中b001目录用于main包编译,是任务图的root节点。b001目录下面的importcfg.link文件存放都是程序所有依赖的包地址,它们指向的都是b002,b003\u0026hellip;这些目录下的_pkg_.a文件。\nGo 编译器 # Go 编译器,英文名称是Go compiler,简称gc。gc是Go命令的一部分,包含在每次Go发行版本中。Go命令是由Go语言编写的,而Go 语言编写的程序需要Go命令来编译,也就是自己编译自己,这就出现了“先有鸡还是先有蛋”的问题。Go gc如何做到自己编译自己呢,要解答这个问题,我们先来了解下自举概念。\n自举 # 自举,英文名称是Bootstrapping。自举指的是用要编译的程序的编程语言来编写其编译器。自举步骤一般如下,假定要编译的程序语言是A:\n先使用程序语言B实现A的编译器,假定为compiler0 接着使用A语言实现A的编译器,之后使用步骤1中的compiler0编译器编译,得到编译器compiler1 最后我们就可以使用compiler1来编译A语言写的程序,这样实现了自己编译自己 通过自举方式,解决了上面说的“先有鸡还是先有蛋”的问题,实现了自己编译自己。Go语言最开始是使用C语言实现的编译器,go1.4是最后一个C语言实现的编译器版本。自go1.5开始,Go实现了自举功能,go1.5的gc是由go语言实现的,它是由go1.4版本的C语言实现编译器编译出来的,详细内容可以参见Go 自举的设计文档: Go 1.3+ Compiler Overhaul。\n除了 Go 语言实现的 gc 外,Go 官方还维护了一个基于 gcc 实现的 Go 编译器 gccgo。与 gc 相比,gccgo 编译代码较慢,但支持更强大的优化,因此由 gccgo 构建的 CPU 密集型(CPU-bound)程序通常会运行得更快。此外 gccgo 比 gc 支持更多的操作系统,如果交叉编译gc不支持的操作系统,可以考虑使用gccgo。\n源码安装 # Go 源码安装需要系统先有一个bootstrap toolchain,该toolchain可以从下面三种方式获取:\n从官网下载Go二进制发行包 使用gccgo工具编译 基于Go1.4版本的工具链 从官网下载发行包 # 第一种方式是从Go发行包中获取Go二进制应用,比如要源码编译go1.14.13,我们可以去 官网下载已经编译好的go1.13,设置好GOROOT_BOOTSTRAP环境变量,就可以源码编译了。\nwget https://golang.org/dl/go1.13.15.linux-amd64.tar.gz tar xzvf go1.13.15.linux-amd64.tar.gz mv go go1.13.15 export GOROOT_BOOTSTRAP=/tmp/go1.13.15 # 设置GOROOT_BOOTSTRAP环境变量指向bootstrap toolchain的目录 cd /tmp git clone -b go1.14.13 https://go.googlesource.com/go go1.14.13 cd go1.14.13/src ./make.bash 使用gccgo工具编译 # 第二种方式是使用gccgo来编译:\nsudo apt-get install gccgo-5 sudo update-alternatives --set go /usr/bin/go-5 export GOROOT_BOOTSTRAP=/usr cd /tmp git clone -b go1.14.13 https://go.googlesource.com/go go1.14.13 cd go1.14.13/src ./make.bash 基于go1.14版本工具链编译 # 第三种方式是先编译出go1.4版本,然后使用go1.4版本去编译其他版本。\ncd /tmp git clone -b go1.4.3 https://go.googlesource.com/go go1.4 cd go1.4/src ./all.bash # go1.4版本是c语言实现的编译器 export GOROOT_BOOTSTRAP=/tmp/go1.4 git clone -b go1.14.13 https://go.googlesource.com/go go1.14.13 cd go1.14.13/src ./all.bash 进一步阅读 # Go: Overview of the Compiler How a Go Program Compiles down to Machine Code 编译原理 Compiler appearance - Phases of Compiler Introduction to the Go compiler How “go build” Works Go 1.3+ Compiler Overhaul Installing Go from source GcToolchainTricks Bootstrapping Go Gccgo in GCC 4.7.1 "},{"id":11,"href":"/analysis-tools/go-buildin-tools/","title":"Go语言内置分析工具","section":"Go语言分析工具","content":" Go 内置分析工具 # 这一章节将介绍Go 内置分析工具。通过这些工具我们可以分析、诊断、跟踪竞态,GMP调度,CPU耗用等问题。\ngo build # go build命令用来编译Go 程序。go build重要的命令行选项有以下几个:\ngo build -n # -n选项用来显示编译过程中所有执行的命令,不会真正执行。通过该选项我们可以查看编译器,连接器如何工作的:\n# # _/home/vagrant/dive-into-go # mkdir -p $WORK/b001/ cat \u0026gt;$WORK/b001/importcfg \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal # import config packagefile fmt=/usr/lib/go/pkg/linux_amd64/fmt.a packagefile runtime=/usr/lib/go/pkg/linux_amd64/runtime.a EOF cd /home/vagrant/dive-into-go /usr/lib/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath \u0026#34;$WORK/b001=\u0026gt;\u0026#34; -p main -complete -buildid RcHLBQbXBa2gQVsMR6P0/RcHLBQbXBa2gQVsMR6P0 -goversion go1.14.13 -D _/home/vagrant/dive-into-go -importcfg $WORK/b001/importcfg -pack ./empty_string.go ./string.go /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/_pkg_.a # internal cat \u0026gt;$WORK/b001/importcfg.link \u0026lt;\u0026lt; \u0026#39;EOF\u0026#39; # internal packagefile _/home/vagrant/dive-into-go=$WORK/b001/_pkg_.a packagefile fmt=/usr/lib/go/pkg/linux_amd64/fmt.a packagefile runtime=/usr/lib/go/pkg/linux_amd64/runtime.a packagefile errors=/usr/lib/go/pkg/linux_amd64/errors.a packagefile internal/fmtsort=/usr/lib/go/pkg/linux_amd64/internal/fmtsort.a packagefile io=/usr/lib/go/pkg/linux_amd64/io.a packagefile math=/usr/lib/go/pkg/linux_amd64/math.a packagefile os=/usr/lib/go/pkg/linux_amd64/os.a packagefile reflect=/usr/lib/go/pkg/linux_amd64/reflect.a packagefile strconv=/usr/lib/go/pkg/linux_amd64/strconv.a packagefile sync=/usr/lib/go/pkg/linux_amd64/sync.a packagefile unicode/utf8=/usr/lib/go/pkg/linux_amd64/unicode/utf8.a packagefile internal/bytealg=/usr/lib/go/pkg/linux_amd64/internal/bytealg.a packagefile internal/cpu=/usr/lib/go/pkg/linux_amd64/internal/cpu.a packagefile runtime/internal/atomic=/usr/lib/go/pkg/linux_amd64/runtime/internal/atomic.a packagefile runtime/internal/math=/usr/lib/go/pkg/linux_amd64/runtime/internal/math.a packagefile runtime/internal/sys=/usr/lib/go/pkg/linux_amd64/runtime/internal/sys.a packagefile internal/reflectlite=/usr/lib/go/pkg/linux_amd64/internal/reflectlite.a packagefile sort=/usr/lib/go/pkg/linux_amd64/sort.a packagefile math/bits=/usr/lib/go/pkg/linux_amd64/math/bits.a packagefile internal/oserror=/usr/lib/go/pkg/linux_amd64/internal/oserror.a packagefile internal/poll=/usr/lib/go/pkg/linux_amd64/internal/poll.a packagefile internal/syscall/execenv=/usr/lib/go/pkg/linux_amd64/internal/syscall/execenv.a packagefile internal/syscall/unix=/usr/lib/go/pkg/linux_amd64/internal/syscall/unix.a packagefile internal/testlog=/usr/lib/go/pkg/linux_amd64/internal/testlog.a packagefile sync/atomic=/usr/lib/go/pkg/linux_amd64/sync/atomic.a packagefile syscall=/usr/lib/go/pkg/linux_amd64/syscall.a packagefile time=/usr/lib/go/pkg/linux_amd64/time.a packagefile unicode=/usr/lib/go/pkg/linux_amd64/unicode.a packagefile internal/race=/usr/lib/go/pkg/linux_amd64/internal/race.a EOF mkdir -p $WORK/b001/exe/ cd . /usr/lib/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=nR64Q3qx-0ZdNI4_-qJS/RcHLBQbXBa2gQVsMR6P0/RcHLBQbXBa2gQVsMR6P0/nR64Q3qx-0ZdNI4_-qJS -extld=gcc $WORK/b001/_pkg_.a /usr/lib/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal mv $WORK/b001/exe/a.out dive-into-go go build -race # -race选项用来检查代码中是否存在竞态问题。-race可以用在多个子命令中:\ngo test -race mypkg go run -race mysrc.go go build -race mycmd go install -race mypkg 下面是来自Go语言官方博客的一个示例1,在该示例中演示了使用-race选项检查代码中的竞态问题:\nfunc main() { start := time.Now() var t *time.Timer t = time.AfterFunc(randomDuration(), func() { fmt.Println(time.Now().Sub(start)) t.Reset(randomDuration()) }) time.Sleep(5 * time.Second) } func randomDuration() time.Duration { return time.Duration(rand.Int63n(1e9)) } 上面代码完成的功能是通过time.AfterFunc创建定时器,该定时器会在randomDuration()时候打印消息,此外还会通过Rest()方法重置该定时器,以达到重复利用该定时器目的。\n当我们使用-race选项执行检查时候,可以发现上面代码是存在竞态问题的:\n$ go run -race main.go ================== WARNING: DATA RACE Read by goroutine 5: main.func·001() race.go:14 +0x169 Previous write by goroutine 1: main.main() race.go:15 +0x174 Goroutine 5 (running) created at: time.goFunc() src/pkg/time/sleep.go:122 +0x56 timerproc() src/pkg/runtime/ztime_linux_amd64.c:181 +0x189 ================== go build -gcflags # -gcflags选项用来设置编译器编译时参数,支持的参数有:\n-N选项指示禁止优化 -l选项指示禁止内联 -S选项指示打印出汇编代码 -m选项指示打印出变量变量逃逸信息,-m -m可以打印出更丰富的变量逃逸信息 -gcflags支持只在编译特定包时候才传递编译参数,此时的-gcflags格式为包名=参数列表。\ngo build -gcflags=\u0026#34;-N -l -S\u0026#34; main.go // 打印出main.go对应的汇编代码 go build -gcflags=\u0026#34;log=-N -l\u0026#34; main.go // 只对log包进行禁止优化,禁止内联操作 go tool compile # go tool compile命令用于汇编处理Go 程序文件。go tool compile支持常见选项有:\n-N选项指示禁止优化 -l选项指示禁止内联 -S选项指示打印出汇编代码 -m选项指示打印出变量内存逃逸信息 go tool compile -N -l -S main.go # 打印出main.go对应的汇编代码 GOOS=linux GOARCH=amd64 go tool compile -N -l -S main.go # 打印出针对特定系统和CPU架构的汇编代码 go tool nm # go tool nm命令用来查看Go 二进制文件中符号表信息。\ngo tool nm ./main | grep \u0026#34;runtime.zerobase\u0026#34; go tool objdump # go tool objdump命令用来根据目标文件或二进制文件反编译出汇编代码。该命令支持两个选项:\n-S选项指示打印汇编代码 -s选项指示搜索相关的汇编代码 go tool compile -N -l main.go # 生成main.o go tool objdump main.o # 打印所有汇编代码 go tool objdump -s \u0026#34;main.(main|add)\u0026#34; ./test # objdump支持搜索特定字符串 go tool trace # GODEBUG环境变量 # GODEBUG是控制运行时调试的变量,其参数以逗号分隔,格式为:name=val。GODEBUG可以用来观察GMP调度和GC过程。\nGMP调度 # 与GMP调度相关的两个参数:\nschedtrace:设置 schedtrace=X 参数可以使运行时在每 X 毫秒输出一行调度器的摘要信息到标准 err 输出中。\nscheddetail:设置 schedtrace=X 和 scheddetail=1 可以使运行时在每 X 毫秒输出一次详细的多行信息,信息内容主要包括调度程序、处理器、OS 线程 和 Goroutine 的状态。\n我们以下面代码为例:\npackage main import ( \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; ) func main() { var wg sync.WaitGroup for i := 0; i \u0026lt; 2000; i++ { wg.Add(1) go func() { a := 0 for i := 0; i \u0026lt; 1e6; i++ { a += 1 } wg.Done() }() time.Sleep(100 * time.Millisecond) } wg.Wait() } 执行以下代码获取GMP调度信息:\nGODEBUG=schedtrace=1000 go run ./test.go 笔者本人电脑输出以下内容:\nSCHED 0ms: gomaxprocs=8 idleprocs=6 threads=4 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=3 spinningthreads=1 idlethreads=0 runqueue=0 [1 0 0 0 0 0 0 0] SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=2 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 1007ms: gomaxprocs=8 idleprocs=8 threads=16 spinningthreads=0 idlethreads=9 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 1000ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0] SCHED 2018ms: gomaxprocs=8 idleprocs=8 threads=16 spinningthreads=0 idlethreads=9 runqueue=0 [0 0 0 0 0 0 0 0] 上面输出内容解释说明:\nSCHED XXms: SCHED是调度日志输出标志符。XXms是自程序启动之后到输出当前行时间 gomaxprocs: P的数量,等于当前的 CPU 核心数,或者GOMAXPROCS环境变量的值 idleprocs: 空闲P的数量,与gomaxprocs的差值即运行中P的数量 threads: 线程数量,即M的数量 spinningthreads:自旋状态线程的数量。当M没有找到可供其调度执行的 Goroutine 时,该线程并不会销毁,而是出于自旋状态 idlethreads:空闲线程的数量 runqueue:全局队列中G的数量 [0 0 0 0 0 0 0 0]:表示P本地队列下G的数量,有几个P中括号里面就会有几个数字 GC # 与GC相关的参数是gctrace,当设置为1时候,会输出gc信息到标准err输出中。使用方式示例如下:\nGODEBUG=gctrace=1 godoc -http=:8080 GC时候输出的内容格式如下:\ngc# @#s #%: #+#+# ms clock, #+#/#/#+# ms cpu, #-\u0026gt;#-\u0026gt;# MB, # MB goal, #P\n格式解释说明如下:\ngc#:GC 执行次数的编号,每次叠加。 @#s:自程序启动后到当前的具体秒数。 #%:自程序启动以来在GC中花费的时间百分比。 #+\u0026hellip;+#:GC 的标记工作共使用的 CPU 时间占总 CPU 时间的百分比。 #-\u0026gt;#-\u0026gt;# MB:分别表示 GC 启动时, GC 结束时, GC 活动时的堆大小. #MB goal:下一次触发 GC 的内存占用阈值。 #P:当前使用的处理器 P 的数量。 比如对于以下输出内容:\ngc 100 @0.904s 11%: 0.043+2.8+0.029 ms clock, 0.34+3.4/5.4/0+0.23 ms cpu, 10-\u0026gt;11-\u0026gt;6 MB, 12 MB goal, 8 P\ngc 100:第 100 次 GC\n@0.904s:当前时间是程序启动后的0.904s\n11%:程序启动后到现在共花费 11% 的时间在 GC 上\n0.043+2.8+0.029 ms clock\n0.043:表示单个 P 在 mark 阶段的 STW 时间 2.8:表示所有 P 的 mark concurrent(并发标记)所使用的时间 0.029:表示单个 P 的 markTermination 阶段的 STW 时间 0.34+3.4/5.4/0+0.23 ms cpu:\n0.34:表示整个进程在 mark 阶段 STW 停顿的时间,一共0.34秒 3.4/5.4/0:3.4 表示 mutator assist 占用的时间,5.4 表示 dedicated + fractional 占用的时间,0 表示 idle 占用的时间 0.23 ms:0.23 表示整个进程在 markTermination 阶段 STW 时间 10-\u0026gt;11-\u0026gt;6 MB:\n10:表示开始 mark 阶段前的 heap_live 大小 11:表示开始 markTermination 阶段前的 heap_live 大小 6:表示被标记对象的大小 12 MB goal:表示下一次触发 GC 回收的阈值是 12 MB\n8 P:本次 GC 一共涉及8 P\nGOGC参数 # Go语言GC相关的另外一个参数是GOGC。GOGC 用于控制GC的处发频率, 其值默认为100, 这意味着直到自上次垃圾回收后heap size已经增长了100%时GC才触发运行,live heap size每增长一倍,GC触发运行一次。若设定GOGC=200, 则live heap size 自上次垃圾回收后,增长2倍时,GC触发运行, 总之,其值越大则GC触发运行频率越低, 反之则越高。如果GOGC=off 则关闭GC。\n# 表示当前应用占用的内存是上次GC时占用内存的两倍时,触发GC export GOGC=100 进一步阅读 # Go 大杀器之跟踪剖析 trace go runtime Environment Variables Introducing the Go Race Detector\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n"},{"id":12,"href":"/analysis-tools/","title":"Go语言分析工具","section":"简介","content":" 分析工具 # 工欲善其事,必先利其器。\nGDB Delve Go 内置工具 "},{"id":13,"href":"/type/nil/","title":"nil类型","section":"数据类型与数据结构","content":" nil # 在探究 nil 之前,我们先看看零值的概念。\n零值 # 零值(zero value)1 指的是当声明变量且未显示初始化时,Go语言会自动给变量赋予一个默认初始值。对于值类型变量来说不同值类型,有不同的零值,比如整数型零值是 0,字符串类型是 \u0026quot;\u0026quot;,布尔类型是 false。对于引用类型变量其零值都是 nil。\n类型 零值 数值类型 0 字符串 \u0026quot;\u0026quot; 布尔类型 false 指针类型 nil 通道 nil 函数 nil 接口 nil 映射 nil 切片 nil 结构体 每个结构体字段对应类型的零值 自定义类型 其底层类型的对应的零值 从零值的定义,可以看出Go语言引入 nil 概念,是为了将其作为引用类型变量的零值而存在。\nnil # nil 是Go语言中的一个变量,是预先声明的标识符,用来作为引用类型变量的零值。\n// nil is a predeclared identifier representing the zero value for a // pointer, channel, func, interface, map, or slice type. var nil Type // Type must be a pointer, channel, func, interface, map, or slice type nil 不能通过:=方式赋值给一个变量,下面代码是编译不通过的:\na := nil 上面代码编译不通过是因为Go语言是无法通过 nil 自动推断出a的类型,而Go语言是强类型的,每个变量都必须明确其类型。将 nil 赋值一个变量是可以的:\nvar a chan int a = nil b := make([]int, 5) b = nil 与nil进行比较 # nil 与 nil比较 # nil 是不能和 nil 比较的:\nfunc main() { fmt.Println(nil == nil) // 报错:invalid operation: nil == nil (operator == not defined on nil) } nil 与 指针类型变量、通道、切片、函数、映射比较 # nil 是可以和指针类型变量,通道、切片、函数、映射比较的。\n对于指针类型变量,只有其未指向任何对象时候,才能等于 nil: func main() { var p *int println(p == nil) // true a := 100 p = \u0026amp;a println(p == nil) // false } 对于通道、切片、映射只有 var t T 或者手动赋值为nil时候(t = nil),才能等于nil: func main() { // 通道 var ch chan int println(ch == nil) // true ch = make(chan int, 0) println(ch == nil) // false ch1 := make(chan int, 0) println(ch1 == nil) // false ch1 = nil println(ch1 == nil) // true // 切片 var s []int // 此时s是nil slice println(s == nil) // true s = make([]int, 0, 0) // 此时s是empty slice println(s == nil) // false // 映射 var m map[int]int // 此时m是nil map println(m == nil) // true m = make(map[int]int, 0) println(m == nil) // false // 函数 var fn func() println(fn == nil) fn = func() { } println(fn == nil) } 从上面可以看到,通过make函数初始化的变量都不等于 nil。\nnil 与 接口比较 # 接口类型变量包含两个基础属性:Type 和 Value,Type 指的是接口类型变量的底层类型,Value 指的是接口类型变量的底层值。接口类型变量是可以比较的。当它们具有相同的底层类型,且相等的底层值时候,或者两者都为nil时候,这两个接口值是相等的。\n当 nil 与接口比较时候,需要接口的 Type 和 Value都是 nil 时候,两者才相等:\nfunc main() { var p *int var i interface{} // (T=nil, V=nil) println(p == nil) // true println(i == nil) // true var pi interface{} = interface{}(p) // (T=*int, V= nil) println(pi == nil) // false println(pi == i) // fasle println(p == i) // false。跟上面强制转换p一样。当变量和接口比较时候,会隐式将其转换成接口 var a interface{} = nil // (T=nil, V=nil) println(a == nil) // true var a2 interface{} = (*interface{})(nil) // (T=*interface{}, V=nil) println(a2 == nil) // false var a3 interface{} = (interface{})(nil) // (T=nil, V=nil) println(a3 == nil) // true } nil 和接口比较最容易出错的场景是使用error接口时候。Go官方文档举了一个例子 Why is my nil error value not equal to nil?:\ntype MyError int func (e *MyError) Error() string { return \u0026#34;errCode \u0026#34; + string(int) } func returnError() error { var p *MyError = nil if bad() { // 出现错误时候,返回MyError p = \u0026amp;MyError(401) } // println(p == nil) // 输出true return p } func checkError(err error) { if err == nil { println(\u0026#34;nil\u0026#34;) return } println(\u0026#34;not nil\u0026#34;) } err := returnError() // 假定returnsError函数中bad()返回false println(err == nil) // false checkError(err) // 输出not nil 我们可以看到上面代码中 checkError 函数输出的并不是 nil,而是 not nil。这是因为接口类型变量 err 的底层类型是 (T=*MyError, V=nil),不再是 (T=nil, V=nil)。解决办法是当需返回 nil 时候,直接返回 nil。\nfunc returnError() error { if bad() { // 出现错误时候,返回MyError return \u0026amp;MyError(401) } return p } 几个值为nil的特别变量 # nil通道 # 通道类型变量的零值是 nil,对于等于 nil 的通道称为 nil通道。当从 nil通道 读取或写入数据时候,会发生永久性阻塞,若关闭则会发生恐慌。nil通道 存在的意义可以参考 Why are there nil channels in Go?\nnil切片 # 对 nil切片 进行读写操作时候会发生恐慌。但对 nil切片 进行 append 操作时候是可以的,这是因为Go语言对append操作做了特殊处理。\nvar s []int s[0] = 1 // panic: runtime error: index out of range [0] with length 0 println(s[0]) // panic: runtime error: index out of range [0] with length 0 s = append(s, 100) // ok nil映射 # 我们可以对 nil映射 进行读取和删除操作,当进行读取操作时候会返回映射的零值。当进行写操作时候会发生恐慌。\nfunc main() { var m map[int]int println(m[100]) // print 0 delete(m, 1) m[100] = 100 // panic: assignment to entry in nil map } nil接收者 # 值为 nil 的变量可以作为函数的接收者:\nconst defaultPath = \u0026#34;/usr/bin/\u0026#34; type Config struct { path string } func (c *Config) Path() string { if c == nil { return defaultPath } return c.path } func main() { var c1 *Config var c2 = \u0026amp;Config{ path: \u0026#34;/usr/local/bin/\u0026#34;, } fmt.Println(c1.Path(), c2.Path()) } nil函数 # nil函数 可以用来处理默认值情况:\nfunc NewServer(logger function) { if logger == nil { logger = log.Printf // default } logger.DoSomething... } 参考资料 # Golang 零值、空值与空结构 Why are there nil channels in Go? Go官方语法指南:零值的定义\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n"},{"id":14,"href":"/feature/panic-recover/","title":"panic与recover机制","section":"语言特性","content":" 恐慌与恢复 - panic/recover # 我们知道Go语言中许多错误会在编译时暴露出来,直接编译不通过,但对于空指针访问元素,切片/数组越界访问之类的运行时错误,只会在运行时引发 panic 异常暴露出来。这种由Go语言自动的触发的 panic 异常属于运行时panic(Run-time panics)1。当发生 panic 时候,Go会运行所有已经注册的延迟函数,若延迟函数中未进行panic异常捕获处理,那么最终Go进程会终止,并打印堆栈信息。此外Go中还内置了 panic 函数,可以用于用户手动触发panic。\nGo语言中内置的 recover 函数可以用来捕获 panic异常,但 recover 函数只能放在延迟函数调用中,才能起作用。我们从之前的章节《 基础篇-语言特性-defer函数 》了解到,多个延迟函数,会组成一个链表。Go在发生panic过程中,会依次遍历该链表,并检查链表中的延迟函数是否调用了 recover 函数调用,若调用了则 panic 异常会被捕获而不会继续向上抛出,否则会继续向上抛出异常和执行延迟函数,直到该 panic 没有被捕获,进程异常终止,这个过程叫做panicking。我们需要知道的是即使panic被延迟函数链表中某个延迟函数捕获处理了,但其他的延迟函数还是会继续执行的,只是panic异常不在继续抛出。\n接下来我们来将深入了解下panic和recover底层的实现机制。在开始之前,我们来看下下面的测试题。\n测试题:下面哪些panic异常将会捕获? # case 1:\nfunc main() { recover() panic(\u0026#34;it is panic\u0026#34;) // not recover } case 2:\nfunc main() { defer func() { recover() }() panic(\u0026#34;it is panic\u0026#34;) // recover } case 3:\nfunc main() { defer recover() panic(\u0026#34;it is panic\u0026#34;) // not recover } case 4:\nfunc main() { defer func() { defer recover() }() panic(\u0026#34;it is panic\u0026#34;) // recover } case 5:\nfunc main() { defer func() { defer func() { recover() }() }() panic(\u0026#34;it is panic\u0026#34;) // not recover } case 6:\nfunc main() { defer doRecover() panic(\u0026#34;it is panic\u0026#34;) // recover } func doRecover() { recover() fmt.Println(\u0026#34;hello\u0026#34;) } case 7:\nfunc main() { defer doRecover() panic(\u0026#34;it is panic\u0026#34;) // recover } func doRecover() { defer recover() } 简单说明下上面几个案例运行结果:\ncase 1中recover函数调用不是在defer延迟函数里面,肯定不会捕获panic异常。 case 2中是panic异常捕获的标准操作,是可以捕获panic异常的,case 6跟case 2是一样的,只不过一个是匿名延迟函数,一个是具名延迟函数,同样可以捕获panic异常。 case 3中recover函数作为延迟函数,没有在其他延迟函数中调用,它也是不起作用的。 case 4中recover函数被一个延迟函数调用,且recover函数本身作为一个延迟函数,这个情况下也是可以正常捕获panic异常的,case 7跟case 4是一样的,只不过一个是匿名延迟函数,一个是具名延迟函数,同样可以捕获panic异常。 case 5中尽管recover函数被延迟函数调用,但它却无法捕获panic异常。 从上面案例中可以看出来,使用recover函数进行panic异常捕获,也要使用正确才能起作用。下面会分析源码,探讨panic-recover实现机制,也能更好帮助你理解为什么case 2,case 4可以起作用,而case 3和case 5为啥没有起作用。\n源码分析 # 我们先分析case 2案例,我们可以通过go tool compile -N -l -S case2.go获取 汇编代码,来查看panic和recover在底层真正的实现:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 main_pc0: TEXT \u0026#34;\u0026#34;.main(SB), ABIInternal, $104-0 MOVQ (TLS), CX CMPQ SP, 16(CX) JLS main_pc113 SUBQ $104, SP MOVQ BP, 96(SP) LEAQ 96(SP), BP MOVL $0, \u0026#34;\u0026#34;..autotmp_1+16(SP) LEAQ \u0026#34;\u0026#34;.main.func1·f(SB), AX MOVQ AX, \u0026#34;\u0026#34;..autotmp_1+40(SP) LEAQ \u0026#34;\u0026#34;..autotmp_1+16(SP), AX MOVQ AX, (SP) CALL runtime.deferprocStack(SB) TESTL AX, AX JNE main_pc97 JMP main_pc69 main_pc69: LEAQ type.string(SB), AX MOVQ AX, (SP) LEAQ \u0026#34;\u0026#34;..stmp_0(SB), AX MOVQ AX, 8(SP) CALL runtime.gopanic(SB) main_pc97: XCHGL AX, AX CALL runtime.deferreturn(SB) MOVQ 96(SP), BP ADDQ $104, SP RET main_pc113: NOP CALL runtime.morestack_noctxt(SB) JMP main_pc0 main_func1_pc0: TEXT \u0026#34;\u0026#34;.main.func1(SB), ABIInternal, $32-0 MOVQ (TLS), CX CMPQ SP, 16(CX) JLS main_func1_pc53 SUBQ $32, SP MOVQ BP, 24(SP) LEAQ 24(SP), BP LEAQ \u0026#34;\u0026#34;..fp+40(SP), AX MOVQ AX, (SP) CALL runtime.gorecover(SB) MOVQ 24(SP), BP ADDQ $32, SP RET main_func1_pc53: NOP CALL runtime.morestack_noctxt(SB) JMP main_func1_pc0 从上面汇编代码中,可以看出 panic 函数底层实现 runtime.gopanic,recover 函数底层实现是 runtime.gorecover。\npanic函数底层实现的 runtime.gopanic 源码如下:\nfunc gopanic(e interface{}) { gp := getg() ... // 一些判断当前g是否允许在用户栈,是否正在内存分配的代码,略 var p _panic // panic底层数据结构是_panic p.arg = e // e是panic函数的参数,对应case2中的: it is panic p.link = gp._panic gp._panic = (*_panic)(noescape(unsafe.Pointer(\u0026amp;p))) // 将当前panic挂到g上面 atomic.Xadd(\u0026amp;runningPanicDefers, 1) // 记录正在执行panic的goroutine数量,防止main groutine返回时候, // 其他goroutine的panic栈信息未打印出来。@see https://github.com/golang/go/blob/go1.14.13/src/runtime/proc.go#L208-L220 // 对于open-coded defer实现的延迟函数,需要扫描FUNCDATA_OpenCodedDeferInfo信息, // 获取延迟函数的sp/pc信息,并创建_defer结构,将其插入gp._defer链表中 // 这是也是在defer函数章节中,提到的为啥open-coded defer提升了延迟函数的性能,而panic性能却降低的原因 addOneOpenDeferFrame(gp, getcallerpc(), unsafe.Pointer(getcallersp())) for { // 开始遍历defer链表 d := gp._defer if d == nil { break } // 当延迟函数里面再次抛出panic或者调用runtime.Goexit时候, // 会再次进入同一个延迟函数,此时d.started已经设置为true状态 if d.started { if d._panic != nil { // 标记上一个_panic状态为aborted d._panic.aborted = true } d._panic = nil if !d.openDefer { // 对于非open-coded defer函数,我们需要将_defer从gp._defer链表中溢出去,防止继续重复执行 d.fn = nil gp._defer = d.link freedefer(d) continue } } // 标记当前defer开始执行,这样当g栈增长时候或者垃圾回收时候,可以更新defer的参数栈帧 d.started = true // 记录当前的_panic信息到_defer结构中,这样当该defer函数再次发生panic时候,可以标记d._panic为aborted状态 d._panic = (*_panic)(noescape(unsafe.Pointer(\u0026amp;p))) done := true if d.openDefer { // 如果该延迟函数是open-coded defer函数 done = runOpenDeferFrame(gp, d) // 运行open-coded defer函数,如果当前栈下面没有其他延迟函数,则返回true if done \u0026amp;\u0026amp; !d._panic.recovered { // 如果当前栈下面没有其他open-coded defer函数了,且panic也未recover, // 那么继续当前的open-coded defer函数的sp作为基址,继续扫描funcdata,获取open-coded defer函数。 // 之所以这么做是因为open-coded defer里面也存在defer函数的情况,例如case4 addOneOpenDeferFrame(gp, 0, nil) } } else {// 非open-coded defer实现的defer函数 // getargp返回其caller的保存callee参数的地址。 // 之前介绍过了Go语言中函数调用约定,callee的参数存储,是由caller的栈空间提供。 p.argp = unsafe.Pointer(getargp(0)) // 这里面p.argp保存的gopanic函数作为caller时候,保存callee参数的地址。 // 之所以要_panic.argp保存gopanic的callee参数地址, // 这是因为调用gorecover会通过此检查其caller的caller是不是gopanic。 // 这也是case5等不能捕获panic异常的原因。 // 调用defer函数 reflectcall(nil, unsafe.Pointer(d.fn), deferArgs(d), uint32(d.siz), uint32(d.siz)) } p.argp = nil // reflectcall did not panic. Remove d. if gp._defer != d { throw(\u0026#34;bad defer entry in panic\u0026#34;) } d._panic = nil pc := d.pc sp := unsafe.Pointer(d.sp) if done { // 从gp._defer链表清除掉当前defer函数 d.fn = nil gp._defer = d.link freedefer(d) } if p.recovered { gp._panic = p.link if gp._panic != nil \u0026amp;\u0026amp; gp._panic.goexit \u0026amp;\u0026amp; gp._panic.aborted { // A normal recover would bypass/abort the Goexit. Instead, // we return to the processing loop of the Goexit. gp.sigcode0 = uintptr(gp._panic.sp) gp.sigcode1 = uintptr(gp._panic.pc) mcall(recovery) throw(\u0026#34;bypassed recovery failed\u0026#34;) // mcall should not return } atomic.Xadd(\u0026amp;runningPanicDefers, -1) if done { // panic已经被recover处理掉了,那么移除掉上面通过addOneOpenDeferFrame添加到gp._defer中的open-coded defer函数。 // 因为这些open-coded defer是通过inline方式执行的,从gp._defer链表中移除掉,不影响它们继续的执行 d := gp._defer var prev *_defer for d != nil { if d.openDefer { if d.started { break } if prev == nil { gp._defer = d.link } else { prev.link = d.link } newd := d.link freedefer(d) d = newd } else { prev = d d = d.link } } } gp._panic = p.link // 无用代码,上面已经操作过了 // Aborted panics are marked but remain on the g.panic list. // Remove them from the list. for gp._panic != nil \u0026amp;\u0026amp; gp._panic.aborted { gp._panic = gp._panic.link } if gp._panic == nil { // must be done with signal gp.sig = 0 } // Pass information about recovering frame to recovery. gp.sigcode0 = uintptr(sp) gp.sigcode1 = pc mcall(recovery) throw(\u0026#34;recovery failed\u0026#34;) // mcall should not return } } preprintpanics(gp._panic) fatalpanic(gp._panic) // should not return *(*int)(nil) = 0 // not reached } 对于基于open-coded defer方式实现的延迟函数中处理panic recover逻辑,比如addOneOpenDeferFrame,runOpenDeferFrame等函数,这里不再深究。这里主要分析通过链表实现的延迟函数中处理panic recover逻辑。\n接下来我们看下recover函数底层实现runtime.gorecover源码\nfunc gorecover(argp uintptr) interface{} { gp := getg() p := gp._panic if p != nil \u0026amp;\u0026amp; !p.goexit \u0026amp;\u0026amp; !p.recovered \u0026amp;\u0026amp; argp == uintptr(p.argp) { p.recovered = true return p.arg } return nil } Go官方语法指南:运行时恐慌\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n"},{"id":15,"href":"/concurrency/sync-once/","title":"一次性操作 - sync.Once","section":"并发编程","content":" 一次性操作 - sync.Once # sync.Once用来完成一次性操作,比如配置加载,单例对象初始化等。\n源码分析 # sync.Once定义如下:\ntype Once struct { done uint32 // 用来标志操作是否操作 m Mutex // 锁,用来第一操作时候,加锁处理 } 接下来看剩下的全部代码:\nfunc (o *Once) Do(f func()) { if atomic.LoadUint32(\u0026amp;o.done) == 0 {// 原子性加载o.done,若值为1,说明已完成操作,若为0,说明未完成操作 o.doSlow(f) } } func (o *Once) doSlow(f func()) { o.m.Lock() // 加锁 defer o.m.Unlock() if o.done == 0 { // 再次进行o.done是否等于0判断,因为存在并发调用doSlow的情况 defer atomic.StoreUint32(\u0026amp;o.done, 1) // 将o.done值设置为1,用来标志操作完成 f() // 执行操作 } } "},{"id":16,"href":"/concurrency/context/","title":"上下文 - context","section":"并发编程","content":" 上下文 - context # Context是由Golang官方开发的并发控制包,一方面可以用于当请求超时或者取消时候,相关的goroutine马上退出释放资源,另一方面Context本身含义就是上下文,其可以在多个goroutine或者多个处理函数之间传递共享的信息。\n创建一个新的context,必须基于一个父context,新的context又可以作为其他context的父context。所有context在一起构造成一个context树。\nContext使用示例 # Context一大用处就是超时控制。我们先看一个简单用法。\nfunc main() { ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second) defer cancel() go SlowOperation(ctx) go func() { for { time.Sleep(300 * time.Millisecond) fmt.Println(\u0026#34;goroutine:\u0026#34;, runtime.NumGoroutine()) } }() time.Sleep(4 * time.Second) } func SlowOperation(ctx context.Context) { done := make(chan int, 1) go func() { // 模拟慢操作 dur := time.Duration(rand.Intn(5)+1) * time.Second time.Sleep(dur) done \u0026lt;- 1 }() select { case \u0026lt;-ctx.Done(): fmt.Println(\u0026#34;SlowOperation timeout:\u0026#34;, ctx.Err()) case \u0026lt;-done: fmt.Println(\u0026#34;Complete work\u0026#34;) } } 上面代码会不停打印当前groutine数量,可以观察到SlowOperation函数执行超时之后,goroutine数量由4个变成2个,相关goroutetine退出了。源码可以去 go playground查看。\n再看一个关于超时处理的例子, 源码可以去 go playground查看:\n// // 根据github仓库统计信息接口查询某个仓库信息 func QueryFrameworkStats(ctx context.Context, framework string) \u0026lt;-chan string { stats := make(chan string) go func() { repos := \u0026#34;https://api.github.com/repos/\u0026#34; + framework req, err := http.NewRequest(\u0026#34;GET\u0026#34;, repos, nil) if err != nil { return } req = req.WithContext(ctx) client := \u0026amp;http.Client{} resp, err := client.Do(req) if err != nil { return } data, err := ioutil.ReadAll(resp.Body) if err != nil { return } defer resp.Body.Close() stats \u0026lt;- string(data) }() return stats } func main() { ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() framework := \u0026#34;gin-gonic/gin\u0026#34; select { case \u0026lt;-ctx.Done(): fmt.Println(ctx.Err()) case statsInfo := \u0026lt;-QueryFrameworkStats(ctx, framework): fmt.Println(framework, \u0026#34; fork and start info : \u0026#34;, statsInfo) } } Context另外一个用途就是传递上下文信息。从WithValue方法我们可以创建一个可以储存键值的context\nContext源码分析 # Context接口 # 首先我们来看下Context接口\ntype Context interface { Deadline() (deadline time.Time, ok bool) Done() \u0026lt;-chan struct{} Err() error Value(key interface{}) interface{} } Context接口一共包含四个方法:\nDeadline:返回绑定该context任务的执行超时时间,若未设置,则ok等于false Done:返回一个只读通道,当绑定该context的任务执行完成并调用cancel方法或者任务执行超时时候,该通道会被关闭 Err:返回一个错误,如果Done返回的通道未关闭则返回nil,如果context如果被取消,返回Canceled错误,如果超时则会返回DeadlineExceeded错误 Value:根据key返回,存储在context中k-v数据 实现Context接口的类型 # Context一共有4个类型实现了Context接口, 分别是emptyCtx, cancelCtx,timerCtx,valueCtx。每个类型都关联一个创建方法。\nemptyCtx # emptyCtx是int类型,emptyCtx实现了Context接口,是一个空context,只能作为根context。\ntype emptyCtx int // func (*emptyCtx) Deadline() (deadline time.Time, ok bool) { return } func (*emptyCtx) Done() \u0026lt;-chan struct{} { return nil } func (*emptyCtx) Err() error { return nil } func (*emptyCtx) Value(key interface{}) interface{} { return nil } func (e *emptyCtx) String() string { switch e { case background: return \u0026#34;context.Background\u0026#34; case todo: return \u0026#34;context.TODO\u0026#34; } return \u0026#34;unknown empty Context\u0026#34; } Background/TODO\ncontext包还提供两个函数返回emptyCtx类型。\nvar ( background = new(emptyCtx) todo = new(emptyCtx) ) func Background() Context { return background } func TODO() Context { return todo } Background用于创建根context,一般用于主函数、初始化和测试中,我们创建的context一般都是基于Bacground创建的。TODO用于当我们不确定使用什么样的context的时候使用。\ncancelCtx # cancelCtx支持取消操作,取消同时也会对实现了canceler接口的子代进行取消操作。我们来看下cancelCtx结构体和cancelceler接口:\ntype cancelCtx struct { Context mu sync.Mutex done chan struct{} children map[canceler]struct{} err error } type canceler interface { cancel(removeFromParent bool, err error) Done() \u0026lt;-chan struct{} } cancelCtx:\nContext变量存储其父context done变量定义了一个通道,并且只在第一次取消调用才关闭此通道。该通道是惰性创建的 children是一个映射类型,用来存储其子代context中实现的canceler,当该context取消时候,会遍历该映射来让子代context进行取消操作 err记录错误信息,默认是nil,仅当第一次cancel调用时候,才会设置。 我们分别来看下cancelCtx实现的Done,Err,cancel方法。\nfunc (c *cancelCtx) Done() \u0026lt;-chan struct{} { c.mu.Lock() // 加锁 if c.done == nil { // done通道惰性创建,只有调用Done方法时候才会创建 c.done = make(chan struct{}) } d := c.done c.mu.Unlock() return d } func (c *cancelCtx) Err() error { c.mu.Lock() err := c.err c.mu.Unlock() return err } func (c *cancelCtx) cancel(removeFromParent bool, err error) { if err == nil { // 取消操作时候一定要传递err信息 panic(\u0026#34;context: internal error: missing cancel error\u0026#34;) } c.mu.Lock() if c.err != nil { // 只允许第一次cancel调用操作,下一次进来直接返回 c.mu.Unlock() return } c.err = err if c.done == nil { // 未先进行Done调用,而先行调用Cancel, 此时done是nil, // 这时候复用全局已关闭的通道 c.done = closedchan } else { // 关闭Done返回的通道,发送关闭信号 close(c.done) } // 子级context依次进行取消操作 for child := range c.children { child.cancel(false, err) } c.children = nil c.mu.Unlock() if removeFromParent { // 将当前context从其父级context中children map中移除掉,父级Context与该Context脱钩。 // 这样当父级Context进行Cancel操作时候,不会再改Context进行取消操作了。因为再取消也没有意义了,因为该Context已经取消过了 removeChild(c.Context, c) } } func removeChild(parent Context, child canceler) { p, ok := parentCancelCtx(parent) if !ok { return } p.mu.Lock() if p.children != nil { delete(p.children, child) } p.mu.Unlock() } func parentCancelCtx(parent Context) (*cancelCtx, bool) { for { switch c := parent.(type) { case *cancelCtx: return c, true case *timerCtx: return \u0026amp;c.cancelCtx, true case *valueCtx: // 当父级context是不支持cancel操作的ValueCtx类型时候,向上一直查找 parent = c.Context default: return nil, false } } } 注意parentCancelCtx找到的节点不一定是就是父context,有可能是其父辈的context。可以参考下面这种图:\nWithCancel\n接下来看cancelCtx类型Context的创建。WithCancel会创一个cancelCtx,以及它关联的取消函数。\ntype CancelFunc func() func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { // 根据父context创建新的cancelCtx类型的context c := newCancelCtx(parent) // 向上递归找到父辈,并将新context的canceler添加到父辈的映射中 propagateCancel(parent, \u0026amp;c) return \u0026amp;c, func() { c.cancel(true, Canceled) } } func newCancelCtx(parent Context) cancelCtx { return cancelCtx{Context: parent} } func propagateCancel(parent Context, child canceler) { if parent.Done() == nil { // parent.Done()返回nil表明父Context不支持取消操作 // 大部分情况下,该父context已是根context, // 该父context是通过context.Background(),或者context.ToDo()创建的 return } if p, ok := parentCancelCtx(parent); ok { p.mu.Lock() if p.err != nil { // 父conext已经取消操作过, // 子context立即进行取消操作,并传递父级的错误信息 child.cancel(false, p.err) } else { if p.children == nil { p.children = make(map[canceler]struct{}) } p.children[child] = struct{}{} // 将当前context的取消添加到父context中 } p.mu.Unlock() } else { // 如果parent是不可取消的,则监控parent和child的Done()通道 go func() { select { case \u0026lt;-parent.Done(): child.cancel(false, parent.Err()) case \u0026lt;-child.Done(): } }() } } timerCtx # timerCtx是基于cancelCtx的context类型,它支持过期取消。\ntype timerCtx struct { cancelCtx timer *time.Timer deadline time.Time } func (c *timerCtx) Deadline() (deadline time.Time, ok bool) { return c.deadline, true } func (c *timerCtx) String() string { return contextName(c.cancelCtx.Context) + \u0026#34;.WithDeadline(\u0026#34; + c.deadline.String() + \u0026#34; [\u0026#34; + time.Until(c.deadline).String() + \u0026#34;])\u0026#34; } func (c *timerCtx) cancel(removeFromParent bool, err error) { c.cancelCtx.cancel(false, err) if removeFromParent { // 删除与父辈context的关联 removeChild(c.cancelCtx.Context, c) } c.mu.Lock() if c.timer != nil { // 停止timer并回收 c.timer.Stop() c.timer = nil } c.mu.Unlock() } WithDeadline\nWithDeadline会创建一个timerCtx,以及它关联的取消函数\nfunc WithDeadline(parent Context, d time.Time) (Context, CancelFunc) { if cur, ok := parent.Deadline(); ok \u0026amp;\u0026amp; cur.Before(d) { // 如果父context过期时间早于当前context过期时间,则创建cancelCtx return WithCancel(parent) } c := \u0026amp;timerCtx{ cancelCtx: newCancelCtx(parent), deadline: d, } propagateCancel(parent, c) dur := time.Until(d) if dur \u0026lt;= 0 { // 如果新创建的timerCtx正好过期了,则取消操作并传递DeadlineExceeded c.cancel(true, DeadlineExceeded) return c, func() { c.cancel(false, Canceled) } } c.mu.Lock() defer c.mu.Unlock() if c.err == nil { // 创建定时器,时间一到执行context取消操作 c.timer = time.AfterFunc(dur, func() { c.cancel(true, DeadlineExceeded) }) } return c, func() { c.cancel(true, Canceled) } } WithTimeout\nWithTimeout用来创建超时就会取消的context,内部实现就是WithDealine,传递给WithDealine的过期时间就是当前时间加上timeout时间\nfunc WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { return WithDeadline(parent, time.Now().Add(timeout)) } valueCtx # valueCtx是可以传递共享信息的context。\ntype valueCtx struct { Context key, val interface{} } func (c *valueCtx) Value(key interface{}) interface{} { if c.key == key { // 当前context存在当前的key return c.val } // 当前context不存在,则会沿着context树,向上递归查找,直到根context,如果一直未找到,则会返回nil return c.Context.Value(key) } 如果当前context不存在该key,则会沿着context树,向上递归查找,直到查找到根context,最后返回nil WithValue\nWithValue用来创建valueCtx。如果key是不可以比较的时候,则会发生恐慌。可以比较类型,可以参考 Comparison_operators。key应该是不导出变量,防止冲突。\nfunc WithValue(parent Context, key, val interface{}) Context { if key == nil { panic(\u0026#34;nil key\u0026#34;) } if !reflectlite.TypeOf(key).Comparable() { panic(\u0026#34;key is not comparable\u0026#34;) } return \u0026amp;valueCtx{parent, key, val} } 总结 # 实现Context接口的类型 # Context一共有4个类型实现了Context接口, 分别是emptyCtx,\tcancelCtx,timerCtx,valueCtx。\n它们的功能与创建方法如下:\n类型 创建方法 功能 emptyCtx Background()/TODO() 用做context树的根节点 cancelCtx WithCancel() 可取消的context timerCtx WithDeadline()/WithTimeout() 可取消的context,过期或超时会自动取消 valueCtx WithValue() 可存储共享信息的context Context实现两种递归 # Context实现两种方向的递归操作。\n递归方向 目的 向下递归 当对父Context进去手动取消操作,或超时取消时候,向下递归处理对实现了canceler接口的后代进行取消操作 向上队规 当对Context查询Key信息时候,若当前Context没有当前K-V信息时候,则向父辈递归查询,一直到查询到跟节点的emptyCtx,返回nil为止 Context使用规范 # 使用Context的是应该准守以下原则来保证在不同包中使用时候的接口一致性,以及能让静态分析工具可以检查context的传播:\n不要将Context作为结构体的一个字段存储,相反而应该显示传递Context给每一个需要它的函数,Context应该作为函数的第一个参数,并命名为ctx 不要传递一个nil Context给一个函数,即使该函数能够接受它。如果你不确定使用哪一个Context,那你就传递context.TODO context是并发安全的,相同的Context能够传递给运行在不同goroutine的函数 参考资料 # 深入理解Golang之context Go Concurrency Patterns: Context Using Goroutines, Channels, Contexts, Timers, WaitGroups and Errgroups "},{"id":17,"href":"/concurrency/sync-mutex/","title":"互斥锁 - sync.Mutex","section":"并发编程","content":" 互斥锁 - sync.Mutex # "},{"id":18,"href":"/function/pass-by-value/","title":"值传递","section":"函数","content":" 值传递 # 函数传参有三种方式,分别是值传递(pass by value)、引用传递(pass by reference),以及指针传递(pass by pointer)。指针传递也称为地址传递,本质上也属于值传递,它只不过传递的值是地址而已。所以按照广义的函数传递来分,分为值传递和引用传递。Go语言中函数传参值传递,不支持引用传递。但是由于切片,通道,映射等具有引用传递的某些特性,往往令人疑惑其应该是引用传递。这个章节我们就来探究下Go语言中函数传递的问题。\n在探究Go语言中函数传递的问题,我们先研究C++语言下的引用传递和指针传递是怎么回事。\nC++中指针传递 # #include \u0026lt;stdio.h\u0026gt; void swap(int* a,int *b){ printf(\u0026#34;交换中:变量a值:%d, 地址:%p; 变量b值:%d,地址:%p\\n\u0026#34;, *a, \u0026amp;a, *b, \u0026amp;b); int temp = *a; *a = *b; *b = temp; } int main() { int a = 1; int b = 2; printf(\u0026#34;交换前:变量a值:%d, 地址:%p; 变量b值:%d,地址:%p\\n\u0026#34;, a, \u0026amp;a, b, \u0026amp;b); swap(\u0026amp;a,\u0026amp;b); printf(\u0026#34;交换后:变量a值:%d, 地址:%p; 变量b值:%d,地址:%p\\n\u0026#34;, a, \u0026amp;a, b, \u0026amp;b); return 0; } C++中引用传递 # #include \u0026lt;stdio.h\u0026gt; void swap(int \u0026amp;a, int \u0026amp;b){ printf(\u0026#34;交换中:变量a值:%d, 地址:%p; 变量b值:%d,地址:%p\\n\u0026#34;, a, \u0026amp;a, b, \u0026amp;b); int temp = a; a = b; b = temp; } int main() { int a = 1; int b = 2; printf(\u0026#34;交换前:变量a值:%d, 地址:%p; 变量b值:%d,地址:%p\\n\u0026#34;, a, \u0026amp;a, b, \u0026amp;b); swap(a,b); printf(\u0026#34;交换后:变量a值:%d, 地址:%p; 变量b值:%d,地址:%p\\n\u0026#34;, a, \u0026amp;a, b, \u0026amp;b); return 0; } 进一步阅读 # When are function parameters passed by value? "},{"id":19,"href":"/concurrency/memory-model/","title":"内存模型 - memroy model","section":"并发编程","content":" 内存模型 # Go语言中的内存模型规定了多个goroutine读取变量时候,变量的可见性情况。注意本章节的内存模型并不是内存对象分配、管理、回收的模型,准确的说这里面的内存模型是内存一致性模型。\nHappens Before原则 # Happens Before原则的定义是如果一个操作e1先于操作e2发生,那么我们就说e1 happens before e2,也可以描述成e2 happens after e2,此时e1操作的变量结果对e2都是可见的。如果e1操作既不先于e2发生又不晚于e2发生,我们说e1操作与e2操作并发发生。\nHappens Before具有传导性:如果操作e1 happens before 操作e2,e3 happends before e1,那么e3一定也 happends before e2。\n由于存在指令重排和多核CPU并发访问情况,我们代码中变量顺序和实际方法顺序并不总是一致的。考虑下面一种情况:\na := 1 b := 2 c := a + 1 上面代码中是先给变量a赋值,然后给变量b赋值,最后给编程c赋值。但是在底层实现指令时候,可能发生指令重排:变量b赋值在前,变量a赋值在后,最后变量c赋值。对于依赖于a变量的c变量的赋值,不管怎样指令重排,Go语言都会保证变量a赋值操作 happends before c变量赋值操作。\n上面代码运行是运行在同一goroutine中,Go语言时能够保证happends before原则的,实现正确的变量可见性。但对于多个goroutine共享数据时候,Go语言是无法保证Happens Before原则的,这时候就需要我们采用锁、通道等同步手段来保证数据一致性。考虑下面场景:\nvar a, b int // goroutine A go func() { a = 1 b = 2 }() // goroutine B go func() { if b == 2 { print(a) } }() 当执行goroutine B打印变量a时并不一定打印出来1,有可能打印出来的是0。这是因为goroutine A中可能存在指令重排,先将b变量赋值2,若这时候接着执行goroutine B那么就会打印出来0\nGo语言中保证的 happens-before 场景 # Go语言提供了某些场景下面的happens-before原则保证。详细内容可以阅读文章末尾进一步阅读中提供的Go官方资料。\n初始化 # 当进行包初始化或程序初始化时候,会保证下面的happens-before:\n如果包p导入了包q,则q的init函数的happens before在任何p的开始之前。 所有init函数happens before 入口函数main.main goroutine # 与goroutine有关的happens-before保证场景有:\ngoroutine的创建happens before其执行 goroutine的完成不保证happens-before任何代码 对于第一条场景,考虑下面代码:\nvar a string func f() { print(a) // 3 } func hello() { a = \u0026#34;hello, world\u0026#34; // 1 go f() // 2 } 根据goroutine的创建happens before其执行,我们知道操作2 happens before 操作3。又因为在同一goroutine中,先书写的代码一定会happens before后面代码(注意:即使发生了执行重排,其并不会影响happends before),操作1 happends before 操作3,那么操作1 happends before 操作3,所以最终一定会打印出hello, world,不可能出现打印空字符串情况。\n注意goroutine f()的执行完成,并不能保证hello()返回之前,其有可能是在hello返回之后执行完成。\n对于第二条场景,考虑下面代码:\nvar a string func hello() { go func() { a = \u0026#34;hello\u0026#34; }() // 1 print(a) // 2 } 由于goroutine的完成不保证happens-before任何代码,那么操作1和操作2无法确定谁先执行,谁后执行,那么最终可能打印出hello,也有可能打印出空字符串。\n通道通信 # 对于缓冲通道,向通道发送数据happens-before从通道接收到数据 var c = make(chan int, 10) var a string func f() { a = \u0026#34;hello, world\u0026#34; // 4 c \u0026lt;- 0 // 5 } func main() { go f() // 1 \u0026lt;-c // 2 print(a) // 3 } c是一个缓存通道,操作5 happens before 操作2,所以最终会打印hello, world\n对于无缓冲通道,从通道接收数据happens-before向通道发送数据 var c = make(chan int) var a string func f() { a = \u0026#34;hello, world\u0026#34; // 4 \u0026lt;-c // 5 } func main() { go f() // 1 c \u0026lt;- 0 // 2 print(a) // 3 } c是无缓存通道,操作5 happens before 操作2,所以最终会打印hello, world。\n对于上面通道的两种happens before场景下打印数据结果,我们都可以通过通道特性得出相关结果。\n锁 # 对于任意的sync.Mutex或者sync.RWMutex,n次Unlock()调用happens before m次Lock()调用,其中n\u0026lt;m var l sync.Mutex var a string func f() { a = \u0026#34;hello, world\u0026#34; l.Unlock() // 2 } func main() { l.Lock() // 1 go f() l.Lock() // 3 print(a) } 操作2 happends before 操作3,所以最终一定会打印出来hello,world。\n对于这种情况,我们可以从锁的机制方面理解,操作3一定会阻塞到操作为2完成释放锁,那么最终一定会打印hello, world。\n进一步阅读 # The Go Memory Model "},{"id":20,"href":"/function/","title":"函数","section":"简介","content":" 函数 # 博观而约取,厚积而薄发。\n一等公民 函数调用栈 值传递 闭包 方法 "},{"id":21,"href":"/type/slice/","title":"切片","section":"数据类型与数据结构","content":" 切片 # 切片是Go语言中最常用的数据类型之一,它类似数组,但相比数组它更加灵活,高效,由于它本身的特性,往往也更容易用错。\n不同于数组是值类型,而切片是引用类型。虽然两者作为函数参数传递时候都是值传递(pass by value),但是切片传递的包含数据指针(可以细分为pass by pointer),如果切片使用不当,会产生意想不到的副作用。\n初始化 # 切片的初始化方式可以分为三种:\n使用make函数创建切片\nmake函数语法格式为:make([]T, length, capacity),capacity可以省略,默认等于length\n使用字面量创建切片\n从数组或者切片派生(reslice)出新切片\nGo支持从数组、指向数组的指针、切片类型变量再reslice一个新切片。\nreslice操作语法可以是[]T[low : high],也可以是[]T[low : high : max]。其中low,high,max都可以省略,low默认值是0,high默认值cap([]T),max默认值cap([]T)。low,hight,max取值范围是0 \u0026lt;= low \u0026lt;= high \u0026lt;= max \u0026lt;= cap([]T),其中high-low是新切片的长度,max-low是新切片的容量。\n对于[]T[low : high],其包含的元素是[]T中下标low开始,到high结束(不含high所在位置的,相当于左闭右开[low, high))的元素,元素个数是high - low个,容量是cap([]T) - low。\nfunc main() { slice1 := make([]int, 0) slice2 := make([]int, 1, 3) slice3 := []int{} slice4 := []int{1: 2, 3} arr := []int{1, 2, 3} slice5 := arr[1:2] slice6 := arr[1:2:2] slice7 := arr[1:] slice8 := arr[:1] slice9 := arr[3:] slice10 := slice2[1:2] fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice1\u0026#34;, slice1, len(slice1), cap(slice1)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice2\u0026#34;, slice2, len(slice2), cap(slice2)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice3\u0026#34;, slice3, len(slice3), cap(slice3)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice4\u0026#34;, slice4, len(slice4), cap(slice4)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice5\u0026#34;, slice5, len(slice5), cap(slice5)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice6\u0026#34;, slice6, len(slice6), cap(slice6)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice7\u0026#34;, slice7, len(slice7), cap(slice7)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice8\u0026#34;, slice8, len(slice8), cap(slice8)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice9\u0026#34;, slice9, len(slice9), cap(slice9)) fmt.Printf(\u0026#34;%s = %v,\\t len = %d, cap = %d\\n\u0026#34;, \u0026#34;slice10\u0026#34;, slice10, len(slice10), cap(slice10)) } 上面代码输出一下内容:\nslice1 = [],\tlen = 0, cap = 0 slice2 = [0],\tlen = 1, cap = 3 slice3 = [],\tlen = 0, cap = 0 slice4 = [0 2 3],\tlen = 3, cap = 3 slice5 = [2],\tlen = 1, cap = 2 slice6 = [2],\tlen = 1, cap = 1 slice7 = [2 3],\tlen = 2, cap = 2 slice8 = [1],\tlen = 1, cap = 3 slice9 = [],\tlen = 0, cap = 0 slice10 = [0],\tlen = 1, cap = 2 注意:\n我们使用arr[3]访问切片元素时候会报 index out of range [3] with length 错误,而使用arr[3:]来初始化slice9却是可以的。因为这是Go语言故意为之的。具体原因可以参见 Why slice not painc 这个issue。\n接下来我们来看看切片的底层数据结构。\n数据结构 # Go语言中切片的底层数据结构是 runtime.slice( runtime/slice.go),其中包含了指向数据数组的指针,切片长度以及切片容量:\ntype slice struct { array unsafe.Pointer // 底层数据数组的指针 len int // 切片长度 cap int // 切片容量 } 注意:\n切片底层数据结构也可以说成是 reflect.SliceHeader,两者没有冲突。reflect.SliceHeader 是暴露出来的类型,可以被用户程序代码直接使用。\n我们来看看下面切片如何共用同一个底层数组的:\nfunc main() { a := []byte{\u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;} b := a[2:3] c := a[2:3:3] fmt.Println(string(a), string(b), string(c)) // 输出 hello l l } Go语言切片底层结构示意图 在前面 《 基础篇-字符串 》 章节,我们使用了 GDB 工具验证了字符串的数据结构,这一次我们使用另外一种方式验证切片的数据结构。我们通过打印切片的底层结构信息来验证:\nfunc main() { type sliceHeader struct { array unsafe.Pointer // 底层数据数组的指针 len int // 切片长度 cap int // 切片容量 } a := []byte{\u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;} b := a[2:3] c := a[2:3:3] ptrA := (*sliceHeader)(unsafe.Pointer(\u0026amp;a)) ptrB := (*sliceHeader)(unsafe.Pointer(\u0026amp;b)) ptrC := (*sliceHeader)(unsafe.Pointer(\u0026amp;c)) fmt.Printf(\u0026#34;切片%s: 底层数组地址=0x%x, 长度=%d, 容量=%d\\n\u0026#34;, \u0026#34;a\u0026#34;, ptrA.array, ptrA.len, ptrA.cap) fmt.Printf(\u0026#34;切片%s: 底层数组地址=0x%x, 长度=%d, 容量=%d\\n\u0026#34;, \u0026#34;b\u0026#34;, ptrB.array, ptrB.len, ptrB.cap) fmt.Printf(\u0026#34;切片%s: 底层数组地址=0x%x, 长度=%d, 容量=%d\\n\u0026#34;, \u0026#34;c\u0026#34;, ptrC.array, ptrC.len, ptrC.cap) } 上面代码输出以下内容:\n切片a: 底层数组地址=0xc00009400b, 长度=5, 容量=5 切片b: 底层数组地址=0xc00009400d, 长度=1, 容量=3 切片c: 底层数组地址=0xc00009400d, 长度=1, 容量=1 从输出内容可以看到切片变量 b和 c 都指向同一个底层数组地址 0xc00009400d,它们与切片变量 a 指向的底层数组地址 0xc00009400b 恰好相差2个字节,这两个字节大小的内存空间存在的是 h 和 e 字符。\n副作用 # 由于切片底层结构的特殊性,当我们使用切片的时候需要特别留心,防止产生副作用(side effect)。\n示例1:append操作产生副作用 # func main() { slice1 := []byte{\u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;} slice2 := slice1[2:3] slice2 = append(slice2, \u0026#39;g\u0026#39;) fmt.Println(string(slice2)) // lg fmt.Println(string(slice1)) // 输出helge,slice1的值也变了。 } 上面代码本意是将切片slice2追加g字符,却产生副作用,即也修改了slice1的值:\nGo语言append切片时产生副作用 解决append产生的副作用 # 解决由于append产生的副作用,有两种解决办法:\nreslice时候指定max边界 使用copy函数拷贝出一个副本 reslice时候指定max边界 # func main() { slice1 := []byte{\u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;} slice2 := slice1[2:3:3] slice2 = append(slice2, \u0026#39;g\u0026#39;) // 此时slice2容量扩大到8 fmt.Println(string(slice2)) // 输出lg fmt.Println(string(slice1)) // 输出hello } 通过slice2 := slice1[2:3:3] 方式进行reslice之后,slice2的长度和容量一样,若对slice2再进行append操作其一定会发送扩容操作,此后slice2和slice1之间就没有任何关系了。\nreslice时候指定max边界 使用copy函数拷贝出一个副本 # func main() { slice1 := []byte{\u0026#39;h\u0026#39;, \u0026#39;e\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;l\u0026#39;, \u0026#39;o\u0026#39;} slice2 := make([]byte, 1) copy(slice2, slice1[2:3]) slice2 = append(slice2, \u0026#39;g\u0026#39;) fmt.Println(string(slice2)) // 输出lg fmt.Println(string(slice1)) // 输出hello } 示例2:指针类型变量引用切片产生副作用 # type User struct { Likes int } func main() { users := make([]User, 1) pFirstUser := \u0026amp;users[0] pFirstUser.Likes++ fmt.Println(\u0026#34;所有用户:\u0026#34;) for i := range users { fmt.Printf(\u0026#34;User: %d Likes: %d\\n\\n\u0026#34;, i, users[i].Likes) } users = append(users, User{}) // 添加一个新用户到集合中 pFirstUser.Likes++ // 第一个用户的Likes次数加一 fmt.Println(\u0026#34;所有用户:\u0026#34;) for i := range users { fmt.Printf(\u0026#34;User: %d Likes: %d\\n\u0026#34;, i, users[i].Likes) } } 指向上面代码输出以下内容:\n所有用户: User: 0 Likes: 1 所有用户: User: 0 Likes: 1 User: 1 Likes: 0 代码本意是通过User类型指针变量pUsers进行第一个用户Likes更新操作,没想到切片进行append之后,产生了副作用:pUsers指向切片已经与切片变量users不一样了。\n引用切片变量产生副作用 避免切片副作用黄金法则 # 在边界处拷贝切片,这里面的边界指的是函数接受切片参数或返回切片的时候。 永远不要使用一个变量来引用切片数据 扩容策略 # 当对切片进行append操作时候,若切片容量不够时候,会进行扩容处理。当切片进行扩容时候会先调用runtime.growslice函数,该函数返回一个新的slice底层结构体,该结构体array字段指向新的底层数组地址,cap字段是新切片的容量,len字段是旧切片的长度,旧切片的内容会拷贝到新切片中,最后再把要追加的数据复制到新切片中,并更新切片len长度。\n// et是slice元素类型 // old是旧的slice // cap是新slice最低要求容量大小。是旧的slice的长度加上append函数中追加的元素的个数 // 比如s := []int{1, 2, 3};s = append(s, 4, 5); 此时growslice中的cap参数值为5 func growslice(et *_type, old slice, cap int) slice { if cap \u0026lt; old.cap { panic(errorString(\u0026#34;growslice: cap out of range\u0026#34;)) } if et.size == 0 { return slice{unsafe.Pointer(\u0026amp;zerobase), old.len, cap} } newcap := old.cap doublecap := newcap + newcap if cap \u0026gt; doublecap { // 最小cap要求大于旧slice的cap两倍大小 newcap = cap } else { if old.len \u0026lt; 1024 { // 当旧slice的len小于1024, 扩容一倍 newcap = doublecap } else { // 否则每次扩容25% for 0 \u0026lt; newcap \u0026amp;\u0026amp; newcap \u0026lt; cap { newcap += newcap / 4 } if newcap \u0026lt;= 0 { newcap = cap } } } var overflow bool var lenmem, newlenmem, capmem uintptr switch { case et.size == 1: // 元素大小 lenmem = uintptr(old.len) newlenmem = uintptr(cap) capmem = roundupsize(uintptr(newcap)) overflow = uintptr(newcap) \u0026gt; maxAlloc newcap = int(capmem) // 调整newcap大小 case et.size == sys.PtrSize: lenmem = uintptr(old.len) * sys.PtrSize newlenmem = uintptr(cap) * sys.PtrSize capmem = roundupsize(uintptr(newcap) * sys.PtrSize) overflow = uintptr(newcap) \u0026gt; maxAlloc/sys.PtrSize newcap = int(capmem / sys.PtrSize) case isPowerOfTwo(et.size): var shift uintptr if sys.PtrSize == 8 { // Mask shift for better code generation. shift = uintptr(sys.Ctz64(uint64(et.size))) \u0026amp; 63 } else { shift = uintptr(sys.Ctz32(uint32(et.size))) \u0026amp; 31 } lenmem = uintptr(old.len) \u0026lt;\u0026lt; shift newlenmem = uintptr(cap) \u0026lt;\u0026lt; shift capmem = roundupsize(uintptr(newcap) \u0026lt;\u0026lt; shift) overflow = uintptr(newcap) \u0026gt; (maxAlloc \u0026gt;\u0026gt; shift) newcap = int(capmem \u0026gt;\u0026gt; shift) default: lenmem = uintptr(old.len) * et.size newlenmem = uintptr(cap) * et.size capmem, overflow = math.MulUintptr(et.size, uintptr(newcap)) capmem = roundupsize(capmem) newcap = int(capmem / et.size) } if overflow || capmem \u0026gt; maxAlloc { panic(errorString(\u0026#34;growslice: cap out of range\u0026#34;)) } var p unsafe.Pointer if et.ptrdata == 0 { // 切片元素中没有指针类型数据,不用考虑写屏障问题 p = mallocgc(capmem, nil, false) memclrNoHeapPointers(add(p, newlenmem), capmem-newlenmem) } else { p = mallocgc(capmem, et, true) if lenmem \u0026gt; 0 \u0026amp;\u0026amp; writeBarrier.enabled { bulkBarrierPreWriteSrcOnly(uintptr(p), uintptr(old.array), lenmem) } } // 涉及到slice扩容都会有内存移动操作 memmove(p, old.array, lenmem) return slice{p, old.len, newcap} } 从上面代码中可以总结出切片扩容的策略是:\n若切片容量小于1024,会扩容一倍 若切片容量大于等于1024,会扩容1/4大小,由于考虑内存对齐,最终实际扩容大小可能会大于1/4 从上面代码中可以看到,切片进行扩容时一定会进行内存拷贝,这是成本较大操作。所以切片一大优化点就是在使用之前尽量指定好切片所需容量,避免出现扩容情况。\nstring类型与[]byte类型如何实现zero-copy互相转换? # 什么是零拷贝(zero-copy) # 零拷贝(zero-copy) 指的是CPU不需要先将数据从某处内存复制到另一个特定区域。当应用程序读取文件,需要从磁盘中加载内核区域,然后将内核区域内容复制到应用内存区域,这就涉及到内存拷贝。若采用mmap技术可以文件映射到特定内存中,只需加载一次,应用程序和内核都可以共享内存中文件数据,这就实现了zero-copy。或者当应用程序需要发送文件给远程时候,可以采用sendfile技术实现零拷贝,若未实现零拷贝,则需要进行四次拷贝过程:\n磁盘\u0026mdash;(DMA copy)\u0026ndash;\u0026gt; 系统内核 \u0026ndash;\u0026gt; 应用程序区域 \u0026ndash;\u0026gt; 系统内核(socket) \u0026mdash;(DMA copy)\u0026mdash;\u0026gt; 网卡\n使用[]byte(string) 和 string([]byte)方式进行字符串和字节切片互转时候会不会发生内存拷贝? # package main func byteArrayToString(b []byte) string { return string(b) } func stringToByteArray(s string) []byte { return []byte(s) } func main() { } 我们来看下上面代码中的底层实现\ngo tool compile -N -l -S main.go 执行上面命名,输出以下内容:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 \u0026#34;\u0026#34;.byteArrayToString STEXT size=117 args=0x28 locals=0x38 0x0000 00000 (main.go:3)\tTEXT\t\u0026#34;\u0026#34;.byteArrayToString(SB), ABIInternal, $56-40 0x0000 00000 (main.go:3)\tMOVQ\t(TLS), CX 0x0009 00009 (main.go:3)\tCMPQ\tSP, 16(CX) 0x000d 00013 (main.go:3)\tPCDATA\t$0, $-2 0x000d 00013 (main.go:3)\tJLS\t110 0x000f 00015 (main.go:3)\tPCDATA\t$0, $-1 0x000f 00015 (main.go:3)\tSUBQ\t$56, SP 0x0013 00019 (main.go:3)\tMOVQ\tBP, 48(SP) 0x0018 00024 (main.go:3)\tLEAQ\t48(SP), BP ... 0x003c 00060 (main.go:4)\tMOVQ\tAX, 8(SP) 0x0041 00065 (main.go:4)\tMOVQ\tCX, 16(SP) 0x0046 00070 (main.go:4)\tMOVQ\tDX, 24(SP) 0x004b 00075 (main.go:4)\tCALL\truntime.slicebytetostring(SB) 0x0050 00080 (main.go:4)\tMOVQ\t40(SP), AX .... \u0026#34;\u0026#34;.stringToByteArray STEXT size=144 args=0x28 locals=0x50 0x0000 00000 (main.go:7)\tTEXT\t\u0026#34;\u0026#34;.stringToByteArray(SB), ABIInternal, $80-40 0x0000 00000 (main.go:7)\tMOVQ\t(TLS), CX 0x0009 00009 (main.go:7)\tCMPQ\tSP, 16(CX) ... 0x0040 00064 (main.go:8)\tMOVQ\tAX, 8(SP) 0x0045 00069 (main.go:8)\tMOVQ\tCX, 16(SP) 0x004a 00074 (main.go:8)\tCALL\truntime.stringtoslicebyte(SB) 0x004f 00079 (main.go:8)\tMOVQ\t32(SP), AX 0x0054 00084 (main.go:8)\tMOVQ\t40(SP), CX .... 从上面汇编代码可以看到 string([]byte) 底层调用的是 runtime.slicebytetostring,[]byte(string) 底层调用的是 runtime.stringtoslicebyte。查看这两个底层函数实现可以看到两者都是先创建一段内存空间,然后使用 memmove 函数拷贝内存,将数据拷贝到新内存空间。这也就是说 []byte(string) 和 string([]byte) 进行转换时候需要内存拷贝。\nstring类型与[]byte类型 zero-copy转换实现 # 那么能不能实现不需要内存拷贝的字符串和字节切片的转换呢?答案是可以的。\n根据前面 《 基础篇-字符串 》 章节和本章节,我们可以看到字符串和字节切片底层结构很相似,它们相同部分都有指向底层数据指针和记录底层数据长度len字段,而字节切片额外多了一个字段cap,记录底层数据的容量。我们只要转换时候让它们共享底层数据就能实现zero-copy。让我们再看看字符串和切片的数组结构:\ntype StringHeader struct { Data uintptr Len int } type SliceHeader struct { Data uintptr Len int Cap int } 我们来看下网上比较常见zero-copy的实现方式,它是有bug的:\nfunc string2bytes(s string) []byte { return *(*[]byte)(unsafe.Pointer(\u0026amp;s)) } func bytes2string(b []byte) string{ return *(*string)(unsafe.Pointer(\u0026amp;b)) } 我们来测试一下:\nfunc main() { a := \u0026#34;hello\u0026#34; b := string2bytes(a) fmt.Println(string(b), len(b), cap(b)) } 上面代码输出以下内容:\nhello 5 824634122328 从上面输入内容,我们可以看到字符串转换成字节切片后的容量明显是有问题的。让我们来分析下具体原因。\n上面两个函数借助 非安全指针类型 强制转换类型实现的。对于字节切片转换字符串使用这种方式是可以的,字节切片多余的cap字段会自动溢出掉;而反过来由于字符串没有记录容量字段,那么将其强制转换成字节切片时候,字节切片的cap字段是未知的,这有可能导致非常严重问题。所以将字符串转换成字节切片时候需要保证字节切片的cap设置正确。\n正确的字符串转字节切片实现如下:\nfunc StringToBytes(s string) (b []byte) { sh := *(*reflect.StringHeader)(unsafe.Pointer(\u0026amp;s)) bh := (*reflect.SliceHeader)(unsafe.Pointer(\u0026amp;b)) bh.Data, bh.Len, bh.Cap = sh.Data, sh.Len, sh.Len return b } 或者\nfunc StringToBytes(s string) []byte { return *(*[]byte)(unsafe.Pointer( \u0026amp;struct { string Cap int }{s, len(s)}, )) } 进一步阅读 # The Go Programming Language Specification: Slice expressions Uber Go Style Guide: Copy Slices and Maps at Boundaries "},{"id":22,"href":"/concurrency/atomic/","title":"原子操作-atomic","section":"并发编程","content":" 原子操作 - atomic # atomic是Go内置原子操作包。下面是官方说明:\nPackage atomic provides low-level atomic memory primitives useful for implementing synchronization algorithms. atomic包提供了用于实现同步机制的底层原子内存原语。\nThese functions require great care to be used correctly. Except for special, low-level applications, synchronization is better done with channels or the facilities of the sync package. Share memory by communicating; don\u0026rsquo;t communicate by sharing memory. 使用这些功能需要非常小心。除了特殊的底层应用程序外,最好使用通道或sync包来进行同步。通过通信来共享内存;不要通过共享内存来通信。\natomic包提供的操作可以分为三类:\n对整数类型T的操作 # T类型是int32、int64、uint32、uint64、uintptr其中一种。\nfunc AddT(addr *T, delta T) (new T) func CompareAndSwapT(addr *T, old, new T) (swapped bool) func LoadT(addr *T) (val T) func StoreT(addr *T, val T) func SwapT(addr *T, new T) (old T) 对于unsafe.Pointer类型的操作 # func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool) func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer) func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer) func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer) atomic.Value类型提供Load/Store操作 # atomic提供了atomic.Value类型,用来原子性加载和存储类型一致的值(consistently typed value)。atomic.Value提供了对任何类型的原则性操作。\nfunc (v *Value) Load() (x interface{}) // 原子性返回刚刚存储的值,若没有值返回nil func (v *Value) Store(x interface{}) // 原子性存储值x,x可以是nil,但需要每次存的值都必须是同一个具体类型。 用法 # 用法示例1:原子性增加值 # package main import ( \u0026#34;fmt\u0026#34; \u0026#34;sync\u0026#34; \u0026#34;sync/atomic\u0026#34; ) func main() { var count int32 var wg sync.WaitGroup for i := 0; i \u0026lt; 10; i++ { wg.Add(1) go func() { atomic.AddInt32(\u0026amp;count, 1) // 原子性增加值 wg.Done() }() go func() { fmt.Println(atomic.LoadInt32(\u0026amp;count)) // 原子性加载 }() } wg.Wait() fmt.Println(\u0026#34;count: \u0026#34;, count) } 用法示例2:简易自旋锁实现 # package main import ( \u0026#34;sync/atomic\u0026#34; ) type spin int64 func (l *spin) lock() bool { for { if atomic.CompareAndSwapInt64((*int64)(l), 0, 1) { return true } continue } } func (l *spin) unlock() bool { for { if atomic.CompareAndSwapInt64((*int64)(l), 1, 0) { return true } continue } } func main() { s := new(spin) for i := 0; i \u0026lt; 5; i++ { s.lock() go func(i int) { println(i) s.unlock() }(i) } for { } } 用法示例3: 无符号整数减法操作 # 对于Uint32和Uint64类型Add方法第二个参数只能接受相应的无符号整数,atomic包没有提供减法SubstractT操作:\nfunc AddUint32(addr *uint32, delta uint32) (new uint32) func AddUint64(addr *uint64, delta uint64) (new uint64) 对于无符号整数V,我们可以传递-V给AddT方法第二个参数就可以实现减法操作。\npackage main import ( \u0026#34;sync/atomic\u0026#34; ) func main() { var i uint64 = 100 var j uint64 = 10 var k = 5 atomic.AddUint64(\u0026amp;i, -j) println(i) atomic.AddUint64(\u0026amp;i, -uint64(k)) println(i) // 下面这种操作是不可以的,会发生恐慌:constant -5 overflows uint64 // atomic.AddUint64(\u0026amp;i, -uint64(5)) } 源码分析 # atomic包提供的三类操作的前两种都是直接通过汇编源码实现的( sync/atomic/asm.s):\n#include \u0026#34;textflag.h\u0026#34; TEXT ·SwapInt32(SB),NOSPLIT,$0 JMP\truntime∕internal∕atomic·Xchg(SB) TEXT ·SwapUint32(SB),NOSPLIT,$0 JMP\truntime∕internal∕atomic·Xchg(SB) ... TEXT ·StoreUintptr(SB),NOSPLIT,$0 JMP\truntime∕internal∕atomic·Storeuintptr(SB) 从上面汇编代码可以看出来atomic操作通过JMP操作跳到runtime/internal/atomic目录下面的汇编实现。我们把目标转移到runtime/internal/atomic目录下面。\n该目录包含针对不同平台的atomic汇编实现asm_xxx.s。这里面我们只关注amd64平台asm_amd64.s( runtime/internal/atomic/asm_amd64.s)和atomic_amd64.go( runtime/internal/atomic/atomic_amd64.go)。\n函数 底层实现 SwapInt32 / SwapUint32 runtime∕internal∕atomic·Xchg SwapInt64 / SwapUint64 / SwapUintptr runtime∕internal∕atomic·Xchg64 CompareAndSwapInt32 / CompareAndSwapUint32 runtime∕internal∕atomic·Cas CompareAndSwapUintptr / CompareAndSwapInt64 / CompareAndSwapUint64 runtime∕internal∕atomic·Cas64 AddInt32 / AddUint32 runtime∕internal∕atomic·Xadd AddUintptr / AddInt64 / AddUint64 runtime∕internal∕atomic·Xadd64 LoadInt32 / LoadUint32 runtime∕internal∕atomic·Load LoadInt64 / LoadUint64 / LoadUint64/ LoadUintptr runtime∕internal∕atomic·Load64 LoadPointer runtime∕internal∕atomic·Loadp StoreInt32 / StoreUint32 runtime∕internal∕atomic·Store StoreInt64 / StoreUint64 / StoreUintptr runtime∕internal∕atomic·Store64 Add操作 # AddUintptr 、 AddInt64 以及 AddUint64都是由方法runtime∕internal∕atomic·Xadd64实现:\nTEXT runtime∕internal∕atomic·Xadd64(SB), NOSPLIT, $0-24 MOVQ\tptr+0(FP), BX // 第一个参数保存到BX MOVQ\tdelta+8(FP), AX // 第二个参数保存到AX MOVQ\tAX, CX // 将第二个参数临时存到CX寄存器中 LOCK\t// LOCK指令进行锁住操作,实现对共享内存独占访问 XADDQ\tAX, 0(BX) // xaddq指令,实现寄存器AX的值与BX指向的内存存的值互换, // 并将这两个值的和存在BX指向的内存中,此时AX寄存器存的是第一个参数指向的值 ADDQ\tCX, AX // 此时AX寄存器的值是Add操作之后的值,和0(BX)值一样 MOVQ\tAX, ret+16(FP) # 返回值 RET LOCK指令是一个指令前缀,其后是读-写性质的指令,在多处理器环境中,LOCK指令能够确保在执行LOCK随后的指令时,处理器拥有对数据的独占使用。若对应数据已经在cache line里,也就不用锁定总线,仅锁住缓存行即可,否则需要锁住总线来保证独占性。\nXADDQ指令用于交换加操作,会将源操作数与目的操作数互换,并将两者的和保存到源操作数中。\nAddInt32 、 AddUint32 都是由方法runtime∕internal∕atomic·Xadd实现,实现逻辑和runtime∕internal∕atomic·Xadd64一样,只是Xadd中相关数据操作指令后缀是L:\nTEXT runtime∕internal∕atomic·Xadd(SB), NOSPLIT, $0-20 MOVQ\tptr+0(FP), BX // 注意第一个参数是一个指针类型,是64位,所以还是MOVQ指令 MOVL\tdelta+8(FP), AX // 第二个参数32位的,所以是MOVL指令 MOVL\tAX, CX LOCK XADDL\tAX, 0(BX) ADDL\tCX, AX MOVL\tAX, ret+16(FP) RET Store操作 # StoreInt64、StoreUint64、StoreUintptr三个是runtime∕internal∕atomic·Store64方法实现:\nTEXT runtime∕internal∕atomic·Store64(SB), NOSPLIT, $0-16 MOVQ\tptr+0(FP), BX // 第一个参数保存到BX MOVQ\tval+8(FP), AX // 第二个参数保存到AX XCHGQ\tAX, 0(BX) // 将AX寄存器与BX寄存指向内存的值互换, // 那么第一个参数指向的内存存的值为第二个参数 RET XCHGQ指令是交换指令,用于交换源操作数和目的操作数。\nStoreInt32、StoreUint32是由runtime∕internal∕atomic·Store方法实现,与runtime∕internal∕atomic·Store64逻辑一样,这里不在赘述。\nCompareAndSwap操作 # CompareAndSwapUintptr、CompareAndSwapInt64和CompareAndSwapUint64都是由runtime∕internal∕atomic·Cas64实现:\nTEXT runtime∕internal∕atomic·Cas64(SB), NOSPLIT, $0-25 MOVQ\tptr+0(FP), BX // 将第一个参数保存到BX MOVQ\told+8(FP), AX // 将第二个参数保存到AX MOVQ\tnew+16(FP), CX // 将第三个参数保存CX LOCK\t// LOCK指令进行上锁操作 CMPXCHGQ\tCX, 0(BX) // BX寄存器指向的内存的值与AX寄存器值进行比较,若相等则把CX寄存器值存储到BX寄存器指向的内存中 SETEQ\tret+24(FP) RET CMPXCHGQ指令是比较并交换指令,它的用法是将目的操作数和累加寄存器AX进行比较,若相等,则将源操作数复制到目的操作数中,否则将目的操作复制到累加寄存器中。\nSwap操作 # SwapInt64、SwapUint64、SwapUintptr实现的方法是runtime∕internal∕atomic·Xchg64,SwapInt32和SwapUint32底层实现是runtime∕internal∕atomic·Xchg,这里面只分析64的操作:\nTEXT runtime∕internal∕atomic·Xchg64(SB), NOSPLIT, $0-24 MOVQ\tptr+0(FP), BX // 第一个参数保存到BX MOVQ\tnew+8(FP), AX // 第一个参数保存到AX中 XCHGQ\tAX, 0(BX) // XCHGQ指令交互AX值到0(BX)中 MOVQ\tAX, ret+16(FP) // 将旧值返回 RET Load操作 # LoadInt32、LoadUint32、LoadInt64 、 LoadUint64 、 LoadUint64、 LoadUintptr、LoadPointer实现都是Go实现的:\n//go:linkname Load //go:linkname Loadp //go:linkname Load64 //go:nosplit //go:noinline func Load(ptr *uint32) uint32 { return *ptr } //go:nosplit //go:noinline func Loadp(ptr unsafe.Pointer) unsafe.Pointer { return *(*unsafe.Pointer)(ptr) } //go:nosplit //go:noinline func Load64(ptr *uint64) uint64 { return *ptr } 最后我们来分析atomic.Value类型提供Load/Store操作。\natomic.Value类型的Load/Store操作 # atomic.Value类型定义如下:\ntype Value struct { v interface{} } // ifaceWords是空接口底层表示 type ifaceWords struct { typ unsafe.Pointer data unsafe.Pointer } atomic.Value底层存储的是空接口类型,空接口底层结构如下:\ntype eface struct { _type *_type // 空接口持有的类型 data unsafe.Pointer // 指向空接口持有类型变量的指针 } atomic.Value内存布局如下所示:\n从上图可以看出来atomic.Value内部分为两部分,第一个部分是_type类型指针,第二个部分是unsafe.Pointer类型,两个部分大小都是8字节(64系统下)。我们可以通过以下代码进行测试:\ntype Value struct { v interface{} } type ifaceWords struct { typ unsafe.Pointer data unsafe.Pointer } func main() { func main() { val := Value{v: 123456} t := (*ifaceWords)(unsafe.Pointer(\u0026amp;val)) dp := (*t).data // dp是非安全指针类型变量 fmt.Println(*((*int)(dp))) // 输出123456 var val2 Value t = (*ifaceWords)(unsafe.Pointer(\u0026amp;val2)) fmt.Println(t.typ) // 输出nil } 接下来我们看下Store方法:\nfunc (v *Value) Store(x interface{}) { if x == nil { // atomic.Value类型变量不能是nil panic(\u0026#34;sync/atomic: store of nil value into Value\u0026#34;) } vp := (*ifaceWords)(unsafe.Pointer(v)) // 将指向atomic.Value类型指针转换成*ifaceWords类型 xp := (*ifaceWords)(unsafe.Pointer(\u0026amp;x)) // xp是*faceWords类型指针,指向传入参数x for { typ := LoadPointer(\u0026amp;vp.typ) // 原子性返回vp.typ if typ == nil { // 第一次调用Store时候,atomic.Value底层结构体第一部分是nil, // 我们可以从上面测试代码可以看出来 runtime_procPin() // pin process处理,防止M被抢占 if !CompareAndSwapPointer(\u0026amp;vp.typ, nil, unsafe.Pointer(^uintptr(0))) { // 通过cas操作,将atomic.Value的第一部分存储为unsafe.Pointer(^uintptr(0)),若没操作成功,继续操作 runtime_procUnpin() // unpin process处理,释放对当前M的锁定 continue } // vp.data == xp.data // vp.typ == xp.typ StorePointer(\u0026amp;vp.data, xp.data) StorePointer(\u0026amp;vp.typ, xp.typ) runtime_procUnpin() return } if uintptr(typ) == ^uintptr(0) { // 此时说明第一次的Store操作未完成,正在处理中,此时其他的Store等待第一次操作完成 continue } if typ != xp.typ { // 再次Store操作时进行typ类型校验,确保每次Store数据对象都必须是同一类型 panic(\u0026#34;sync/atomic: store of inconsistently typed value into Value\u0026#34;) } StorePointer(\u0026amp;vp.data, xp.data) // vp.data == xp.data return } } 总结上面Store流程:\n每次调用Store方法时候,会将传入参数转换成interface{}类型。当第一次调用Store方法时候,分两部分操作,分别将传入参数空接口类型的_typ和data,存储到Value类型中。 当再次调用Store类型时候,进行传入参数空接口类型的_type和Value的_type比较,若不一致直接panic,若一致则将data存储到Value类型中 从流程2可以看出来,每次调用Store方法时传入参数都必须是同一类型的变量。当Store完成之后,实现了“鸠占鹊巢”,atomic.Value底层存储的实际上是(interface{})x。\n最后我们看看atomic.Value的Load操作:\nfunc (v *Value) Load() (x interface{}) { vp := (*ifaceWords)(unsafe.Pointer(v)) // 将指向v指针转换成*ifaceWords类型 typ := LoadPointer(\u0026amp;vp.typ) if typ == nil || uintptr(typ) == ^uintptr(0) { // typ == nil 说明Store方法未调用过 // uintptr(typ) == ^uintptr(0) 说明第一Store方法调用正在进行中 return nil } data := LoadPointer(\u0026amp;vp.data) xp := (*ifaceWords)(unsafe.Pointer(\u0026amp;x)) xp.typ = typ xp.data = data return } "},{"id":23,"href":"/type-system/reflect/","title":"反射","section":"类型系统","content":" 反射 # "},{"id":24,"href":"/type/string/","title":"字符串","section":"数据类型与数据结构","content":" 字符串 # 我们知道C语言中的字符串是使用字符数组 char[] 表示,字符数组的最后一位元素是 \\0,用来标记字符串的结束。C语言中字符串的结构简单,但获取字符串长度时候,需要遍历字符数组才能完成。\nGo语言中字符串的底层结构中也包含了字符数组,该字符数组是完整的字符串内容,它不同于C语言,字符数组中没有标记字符串结束的标记。为了记录底层字符数组的大小,Go语言使用了额外的一个长度字段来记录该字符数组的大小,字符数组的大小也就是字符串的长度。\n数据结构 # Go语言字符串的底层数据结构是 reflect.StringHeader( reflect/value.go),它包含了指向字节数组的指针,以及该指针指向的字符数组的大小:\ntype StringHeader struct { Data uintptr Len int } 字符串复制 # 当将一个字符串变量赋值给另外一个变量时候,他们 StringHeader.Data 都指向同一个内存地址,不会发生字符串拷贝:\na := \u0026#34;hello\u0026#34; b := a 从上图中我们可以看到a变量和b变量的Data字段存储的都是0x1234,而0x1234是字符数组的起始地址。\n接来下我们借助 GDB 工具来验证Go语言中字符串数据结构是不是按照上面说的那样。\npackage main import ( \u0026#34;fmt\u0026#34; ) func main() { a := \u0026#34;hello\u0026#34; b := a fmt.Printf(\u0026#34;a变量地址:%p\\n\u0026#34;, \u0026amp;a) fmt.Printf(\u0026#34;b变量地址:%p\\n\u0026#34;, \u0026amp;b) print(\u0026#34;断点打在这里\u0026#34;) } 将上面代码构建二进制应用, 然后使用 GDB 调试一下:\ngo build -o string string.go # 构建二进制应用 gdb ./string # GDB调试 调试流程如下:\nlen(str) == 0 和 str == \u0026quot;\u0026quot;有区别吗? # 判断一个字符串是否是空字符串,我们既可以使用len判断其长度是0,也可以判断其是否等于空字符串 \u0026quot;\u0026quot;。那么它们有什么区别吗?这个问题的答案是二者没有区别。因为他们底层实现是一样的。\n让我们来探究一下。源代码如下:\npackage main func isEmptyStr(str string) bool { return len(str) == 0 } func isEmtpyStr2(str string) bool { return str == \u0026#34;\u0026#34; } func main() { } 接下来我们来查看下上面代码的底层汇编:\ngo tool compile -S empty_string.go # 查看底层汇编代码 从下图中,我们可以发现两种方式的实现是一样的:\n注意:\n当我们编译时候开启了禁止内联,禁止优化时候,可以发现 len(str) == 0 和 str == \u0026quot;\u0026quot; 的实现是不同的,前者的执行效率是不如后者的。在默认情况下,Go编译器是开启了优化选项的,len(str) == 0 会优化成跟 str == \u0026quot;\u0026quot; 的实现一样。 [3]string类型的变量占用多大空间? # 对于这个问题,直觉上觉得[3]string类型变量,由3个字符串组成,而字符串长度是不确定的,所以对于类似[n]string类型变量占用多大的空间是不确定。\n首先明确的是Go语言中提供了 unsafe.Sizeof 函数来确定一个类型变量占用空间大小,这个大小是不含它引用的内存大小。比如某结构体中一个字段是个指针类型,这个字段指向的内存是不计算进去的,只会计算该字段本身的大小。\n字符串底层结构是 reflect.StringHeader ,一共占用16个字节空间,所以我们对于[n]string的大小,计算伪代码如下:\nunsafe.Sizeof([n]string) == n * 16 那么问题[3]string类型的变量占用多大空间?的答案是48。\n如何高效的进行字符串拼接? # 字符串进行拼接有多种方法:\n使用拼接字符 + 拼接字符串\n效率低,每次拼接会产生临时字符串,适合少量字符串拼接。使用起来最简单。\n使用 fmt.Printf() 来拼接字符\n由于需要将字符串转换成空接口类型,效率差,这里面不再讨论\n使用 strings.Join() 来拼接字符串\n其底层其实使用的是 strings.Builder ,效率高,适合字符串数组。\n使用 bytes.Buffer 来拼接字符串\n效率高,可以复用\n使用 strings.Builder 来拼接字符串\n效率高,每次Reset()之后,其底层缓冲会被清除,不适合复用。\n使用拼接符 + 进行拼接 # package main import ( \u0026#34;fmt\u0026#34; \u0026#34;reflect\u0026#34; \u0026#34;unsafe\u0026#34; ) func main() { strSlices := []string{\u0026#34;h\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;o\u0026#34;} var all string for _, str := range strSlices { all += str sh := (*reflect.StringHeader)(unsafe.Pointer(\u0026amp;all)) fmt.Printf(\u0026#34;str地址:%p,all地址:%p,all底层字节数组地址=0x%x\\n\u0026#34;, \u0026amp;str, \u0026amp;all, sh.Data) } } 上面代码输出一下内容:\nstr地址:0xc000010250,all地址:0xc000010240,all底层字节数组地址=0x4bc8f7 str地址:0xc000010250,all地址:0xc000010240,all底层字节数组地址=0xc000018048 str地址:0xc000010250,all地址:0xc000010240,all底层字节数组地址=0xc000018068 str地址:0xc000010250,all地址:0xc000010240,all底层字节数组地址=0xc000018078 str地址:0xc000010250,all地址:0xc000010240,all底层字节数组地址=0xc000018088 从上面输出中可以发现str和all地址一直没有变,但是all的底层字节数组地址一直在变化,这说明拼接符 + 在拼接字符串时候,会创建许多临时字符串,临时字符串意味着内存分配,指向效率不会太高。\n使用 bytes.Buffer 拼接字符串 # package main import \u0026#34;bytes\u0026#34; func main() { strSlices := []string{\u0026#34;h\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;o\u0026#34;} var bf bytes.Buffer for _, str := range strSlices { bf.WriteString(str) } print(bf.String()) } bytes.Buffer 底层结构包含内存缓冲,最少缓冲大小是64个字节,当进行字符串拼接时候,由于利用到了缓冲,拼接效率相比拼接符 + 大大提升:\ntype Buffer struct { buf []byte // 内存缓冲是字节切片类型 off int // buf已读索引,下次读取从buf[off]开始 lastRead readOp } func (b *Buffer) String() string { if b == nil { // Special case, useful in debugging. return \u0026#34;\u0026lt;nil\u0026gt;\u0026#34; } return string(b.buf[b.off:]) } 注意:\nbytes.Buffer是可以复用的。当进行reset时候,并不会销毁内存缓冲。\n使用 strings.Builder 拼接字符串 # package main import \u0026#34;strings\u0026#34; func main() { strSlices := []string{\u0026#34;h\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;o\u0026#34;} var strb strings.Builder for _, str := range strSlices { strb.WriteString(str) } print(strb.String()) } strings.Builder 同 bytes.Buffer 一样都是用内存缓冲,最大限度地减少了内存复制:\ntype Builder struct { addr *Builder // 用来运行时检测是否违背nocopy机制 buf []byte // 内存缓冲,类型是字节数组 } func (b *Builder) String() string { return *(*string)(unsafe.Pointer(\u0026amp;b.buf)) } 从上面可以看到 string.Builder 的 String 方法使用 unsafe.Pointer 将字节数组转换成字符串。而bytes.Buffer的 String 方法使用的 string([]byte)将字节数组转换成字符串,后者由于涉及内存分配和拷贝,相比之下它的执行效率低。\n为什么bytes.Buffer的 String 方法的效率比较低,可以查看《 基础篇-切片-string类型与[]byte类型如何实现zero-copy互相转换?》。\n字符串拼接基准测试 # 下面我们进行基准测试下:\n// 使用拼接符拼接字符串 func BenchmarkJoinStringUsePlus(b *testing.B) { strSlices := []string{\u0026#34;h\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;o\u0026#34;} for i := 0; i \u0026lt; b.N; i++ { for j := 0; j \u0026lt; 10000; j++ { var all string for _, str := range strSlices { all += str } _ = all } } } // 复用bytes.Buffer结构 func BenchmarkJoinStringUseBytesBufWithReuse(b *testing.B) { strSlices := []string{\u0026#34;h\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;o\u0026#34;} var bf bytes.Buffer for i := 0; i \u0026lt; b.N; i++ { for j := 0; j \u0026lt; 10000; j++ { var all string for _, str := range strSlices { bf.WriteString(str) } all = bf.String() _ = all bf.Reset() } } } // 使用bytes.Buffer,未进行复用 func BenchmarkJoinStringUseBytesBufWithoutReuse(b *testing.B) { strSlices := []string{\u0026#34;h\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;o\u0026#34;} for i := 0; i \u0026lt; b.N; i++ { for j := 0; j \u0026lt; 10000; j++ { var all string var bf bytes.Buffer for _, str := range strSlices { bf.WriteString(str) } all = bf.String() _ = all bf.Reset() } } } // 使用strings.Builder func BenchmarkJoinStringUseStringBuilder(b *testing.B) { strSlices := []string{\u0026#34;h\u0026#34;, \u0026#34;e\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;l\u0026#34;, \u0026#34;o\u0026#34;} for i := 0; i \u0026lt; b.N; i++ { for j := 0; j \u0026lt; 10000; j++ { all := \u0026#34;\u0026#34; var strb strings.Builder for _, str := range strSlices { strb.WriteString(str) } all = strb.String() _ = all strb.Reset() } } } 基准测试结果如下:\nBenchmarkJoinStringUsePlus 703\t1633439 ns/op\t160000 B/op\t40000 allocs/op BenchmarkJoinStringUseBytesBufWithReuse 2130\t471368 ns/op\t0 B/op\t0 allocs/op BenchmarkJoinStringUseBytesBufWithoutReuse 1209\t883053 ns/op\t640000 B/op\t10000 allocs/op BenchmarkJoinStringUseStringBuilder 1830\t548350 ns/op\t80000 B/op\t10000 allocs/op 字符串拼接效率总结 # 从上面结果可以分析得到字符串拼接效率,其中strings.Builder的效率最高,拼接字符+效率最低:\nstrings.Builder \u0026gt; bytes.Buffer \u0026gt; 拼接字符+ 但是由于bytes.Buffer可以复用,若在需要多此执行字符串拼接的场景下,推荐使用它。\n"},{"id":25,"href":"/concurrency/sync-map/","title":"并发Map - sync.Map","section":"并发编程","content":" 并发Map - sync.Map # 源码分析 # sync.Map的结构:\ntype Map struct { mu Mutex // 排他锁,用于对dirty map操作时候加锁处理 read atomic.Value // read map // dirty map。新增key时候,只写入dirty map中,需要使用mu dirty map[interface{}]*entry // 用来记录从read map中读取key时miss的次数 misses int } sync.Map结构体中read字段是atomic.Value类型,底层是readOnly结构体:\ntype readOnly struct { m map[interface{}]*entry amended bool // 当amended为true时候,表示sync.Map中的key也存在dirty map中 } read map和dirty map的value类型是*entry, entry结构体定义如下:\n// expunged用来标记从dirty map删除掉了 var expunged = unsafe.Pointer(new(interface{})) type entry struct { // 如果p == nil 说明对应的entry已经被删除掉了, 且m.dirty == nil // 如果 p == expunged 说明对应的entry已经被删除了,但m.dirty != nil,且该entry不存在m.dirty中 // 上述两种情况外,entry则是合法的值并且在m.read.m[key]中存在 // 如果m.dirty != nil,entry也会在m.dirty[key]中 // p指针指向sync.Map中key对应的Value p unsafe.Pointer // *interface{} } 对Map的操作可以分为四类:\nAdd key-value 新增key-value Update key-value 更新key对应的value值 Get Key-value 获取Key对应的Value值 Delete Key 删除key 我们来看看新增和更新操作:\n// Store用来新增和更新操作 func (m *Map) Store(key, value interface{}) { read, _ := m.read.Load().(readOnly) // 如果read map存在该key,且该key对应的value不是expunged时(准确的说key对应的value, value是*entry类型,entry的p字段指向不是expunged时), // 则使用cas更新value,此操作是原子性的 if e, ok := read.m[key]; ok \u0026amp;\u0026amp; e.tryStore(\u0026amp;value) { return } m.mu.Lock() // 先加锁,然后重新读取一次read map,目的是防止dirty map升级到read map(并发Load操作时候),read map更改了。 read, _ = m.read.Load().(readOnly) if e, ok := read.m[key]; ok { // 若read map存在此key,此时就是map的更新操作 if e.unexpungeLocked() { // 将value由expunged更改成nil, // 若成功则表明dirty map中不存在此key,把key-value添加到dirty map中 m.dirty[key] = e } e.storeLocked(\u0026amp;value) // 更改value。value是指针类型(*entry),read map和dirty map的value都指向该值。 } else if e, ok := m.dirty[key]; ok {// 若dirty map存在该key,则直接更改value e.storeLocked(\u0026amp;value) } else { // 若read map和dirty map中都不存在该key,其实就是map的新增key-value操作 if !read.amended {// amended为true时表示sync.Map部分key存在dirty map中 // dirtyLocked()做两件事情: // 1. 若dirty map等于nil,则初始化dirty map。 // 2. 遍历read map,将read map中的key-value复制到dirty map中,从read map中复制的key-value时,value是nil或expunged的(因为nil和expunged是key删除了的)不进行复制。 // 同时若value值为nil,则顺便更改成expunged(用来标记dirty map不包含此key) // 思考🤔:为啥dirtyLocked()要干事情2,即将read map的key-value复制到dirty map中? m.dirtyLocked() // 该新增key-value将添加dirty map中,所以将read map的amended设置为true。当amended为true时候,从sync.Map读取key时候,优先从read map中读取,若read map读取时候不到时候,会从dirty map中读取 m.read.Store(readOnly{m: read.m, amended: true}) } // 添加key-value到dirty map中 m.dirty[key] = newEntry(value) } // 释放锁 m.mu.Unlock() } func (e *entry) tryStore(i *interface{}) bool { for { p := atomic.LoadPointer(\u0026amp;e.p) if p == expunged { return false } if atomic.CompareAndSwapPointer(\u0026amp;e.p, p, unsafe.Pointer(i)) { return true } } } func (e *entry) unexpungeLocked() (wasExpunged bool) { return atomic.CompareAndSwapPointer(\u0026amp;e.p, expunged, nil) } func (e *entry) storeLocked(i *interface{}) { atomic.StorePointer(\u0026amp;e.p, unsafe.Pointer(i)) } func (m *Map) dirtyLocked() { if m.dirty != nil { return } read, _ := m.read.Load().(readOnly) m.dirty = make(map[interface{}]*entry, len(read.m)) for k, e := range read.m { if !e.tryExpungeLocked() { m.dirty[k] = e } } } func (e *entry) tryExpungeLocked() (isExpunged bool) { p := atomic.LoadPointer(\u0026amp;e.p) for p == nil { if atomic.CompareAndSwapPointer(\u0026amp;e.p, nil, expunged) { return true } p = atomic.LoadPointer(\u0026amp;e.p) } return p == expunged } 接下来看看Map的Get操作:\n// Load方法用来获取key对应的value值,返回的ok表名key是否存在sync.Map中 func (m *Map) Load(key interface{}) (value interface{}, ok bool) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] if !ok \u0026amp;\u0026amp; read.amended { // 若key不存在read map中,且dirty map包含sync.Map中key情况下 m.mu.Lock() // 加锁 read, _ = m.read.Load().(readOnly) // 再次从read map读取key e, ok = read.m[key] if !ok \u0026amp;\u0026amp; read.amended { e, ok = m.dirty[key] // 从dirty map中读取key // missLocked() 首先将misses计数加1,misses用来表明read map读取key没有命中的次数。 // 若misses次数多于dirty map中元素个数时候,则将dirty map升级为read map,dirty map设置为nil, amended置为false m.missLocked() } m.mu.Unlock() } if !ok { // read map 和 dirty map中都不存在该key return nil, false } // 加载value值 return e.load() } func (e *entry) load() (value interface{}, ok bool) { p := atomic.LoadPointer(\u0026amp;e.p) if p == nil || p == expunged { // 若value值是nil或expunged,返回nil, false,表示key不存在 return nil, false } return *(*interface{})(p), true } func (m *Map) missLocked() { m.misses++ if m.misses \u0026lt; len(m.dirty) { return } // 新创建一个readOnly对象,其中amended为false, 并将m.dirty直接赋值给该对象的m字段, // 这也是上面思考中的dirtyLocked为什么要干事情2的原因,因为通过2操作之后,m.dirty已包含read map中的所有key,可以直接拿来创建readOnly。 m.read.Store(readOnly{m: m.dirty}) m.dirty = nil m.misses = 0 } 在接着看看Map的删除操作:\n// Delete用于删除key func (m *Map) Delete(key interface{}) { read, _ := m.read.Load().(readOnly) e, ok := read.m[key] if !ok \u0026amp;\u0026amp; read.amended { m.mu.Lock() read, _ = m.read.Load().(readOnly) e, ok = read.m[key] // 若read map不存在该key,但dirty map中存在该key。则直接调用delete,删除dirty map中该key if !ok \u0026amp;\u0026amp; read.amended { delete(m.dirty, key) } m.mu.Unlock() } if ok { e.delete() } } func (e *entry) delete() (hadValue bool) { for { p := atomic.LoadPointer(\u0026amp;e.p) if p == nil || p == expunged { // 若entry中p已经是nil或者expunged则直接返回 return false } if atomic.CompareAndSwapPointer(\u0026amp;e.p, p, nil) { // 将entry中的p设置为nil return true } } } sync.Map还提供遍历key-value功能:\n// Range方法接受一个迭代回调函数,用来处理遍历的key和value func (m *Map) Range(f func(key, value interface{}) bool) { read, _ := m.read.Load().(readOnly) if read.amended { // 若dirty map中包含sync.Map中key时候 m.mu.Lock() read, _ = m.read.Load().(readOnly) if read.amended {// 加锁之后,再次判断,是为了防止并发调用Load方法时候,dirty map升级为read map时候,amended为false情况 // read.amended为true的时候,m.dirty包含sync.Map中所有的key read = readOnly{m: m.dirty} m.read.Store(read) m.dirty = nil m.misses = 0 } m.mu.Unlock() } for k, e := range read.m { v, ok := e.load() if !ok { continue } if !f(k, v) { //执行迭代回调函数,当返回false时候,停止迭代 break } } } 为什么不使用sync.Mutex+map实现并发的map呢? # 这个问题可以换个问法就是sync.Map相比sync.Mutex+map实现并发map有哪些优势?\nsync.Map优势在于当key存在read map时候,如果进行Store操作,可以使用原子性操作更新,而sync.Mutex+map形式每次写操作都要加锁,这个成本更高。\n另外并发读写两个不同的key时候,写操作需要加锁,而读操作是不需要加锁的。\n读少写多情况下并发map,应该怎么设计? # 这种情况下,可以使用分片锁,跟据key进行hash处理后,找到其对应读写锁,然后进行锁定处理。通过分片锁机制,可以降低锁的粒度来实现读少写多情况下高并发。可以参见 orcaman/concurrent-map实现。\n总结 # sync.Map是不能值传递(狭义的)的 sync.Map采用空间换时间策略。其底层结构存在两个map,分别是read map和dirty map。当读取操作时候,优先从read map中读取,是不需要加锁的,若key不存在read map中时候,再从dirty map中读取,这个过程是加锁的。当新增key操作时候,只会将新增key添加到dirty map中,此操作是加锁的,但不会影响read map的读操作。当更新key操作时候,如果key已存在read map中时候,只需无锁更新更新read map就行,负责加锁处理在dirty map中情况了。总之sync.Map会优先从read map中读取、更新、删除,因为对read map的读取不需要锁 当sync.Map读取key操作时候,若从read map中一直未读到,若dirty map中存在read map中不存在的keys时,则会把dirty map升级为read map,这个过程是加锁的。这样下次读取时候只需要考虑从read map读取,且读取过程是无锁的 延迟删除机制,删除一个键值时只是打上删除标记,只有在提升dirty map为read map的时候才清理删除的数据 sync.Map中的dirty map要么是nil,要么包含read map中所有未删除的key-value。 sync.Map适用于读多写少场景。根据 包官方文档介绍,它特别适合这两个场景:1. 一个key只写入一次但读取多次时,比如在只会增长的缓存中;2. 当多个goroutine读取、写入和更新不相交的键值对时。 "},{"id":26,"href":"/concurrency/","title":"并发编程","section":"简介","content":" 并发编程 # 学然后知不足,教然后知困。\n内存模型 上下文 - context 通道 - channel 原子操作 - atomic 并发Map - sync.Map 等待组 - sync.WaitGroup 一次性操作 - sync.Once 缓冲池 - sync.Pool 条件变量 - sync.Cond 互斥锁 - sync.Mutex 读写锁 - sync.RWMutex "},{"id":27,"href":"/feature/defer/","title":"延迟执行 - defer语法","section":"语言特性","content":" 延迟执行 - defer语法 # defer 语法支持是Go 语言中一大特性,通过 defer 关键字,我们可以声明一个延迟执行函数,当调用者返回之前开始执行该函数,一般用来完成资源、锁、连接等释放工作,或者 recover 可能发生的panic。\n三大特性 # defer延迟执行语法有三大特性:\ndefer函数的传入参数在定义时就已经明确 # func main() { i := 1 defer fmt.Println(i) i++ return } 上面代码输出1,而不是2。\ndefer函数是按照后进先出的顺序执行 # func main() { for i := 1; i \u0026lt;= 5; i++ { defer fmt.Print(i) } } 上面代码输出54321,而不是12345。\ndefer函数可以读取和修改函数的命名返回值 # func main() { fmt.Println(test()) } func test() (i int) { defer func() { i++ }() return 100 } 上面代码输出输出101,而不是100或者1。\n白话defer原理 # defer函数底层数据结构是_defer结构体,多个defer函数会构建成一个_defer链表,后面加入的defer函数会插入链表的头部,该链表链表头部会链接到G上。当函数执行完成返回的时候,会从_defer链表头部开始依次执行defer函数。这也就是defer函数执行时会LIFO的原因。_defer链接结构示意图如下:\ndefer原理示意图 创建_defer结构体是需要进行内存分配的,为了减少分配_defer结构体时资源消耗,Go底层使用了defer缓冲池(defer pool),用来缓存上次使用完的_defer结构体,这样下次可以直接使用,不必再重新分配内存了。defer缓冲池一共有两级:per-P级defer缓冲池和全局defer缓冲池。当创建_defer结构体时候,优先从当前M关联的P的缓冲池中取得_defer结构体,即从per-P缓冲池中获取,这个过程是无锁操作。如果per-P缓冲池中没有,则在尝试从全局defer缓冲池获取,若也没有获取到,则重新分配一个新的_defer结构体。\n当defer函数执行完成之后,Go底层会将分配的_defer结构体进行回收,先存放在per-P级defer缓冲池中,若已存满,则存放在全局defer缓冲池中。\n源码分析 # 我们以下代码作为示例,分析defer实现机制:\npackage main func main() { defer greet(\u0026#34;friend\u0026#34;) println(\u0026#34;welcome\u0026#34;) } func greet(text string) { print(\u0026#34;hello \u0026#34; + text) } 在分析之前,我们先来看下defer结构体:\ntype _defer struct { siz int32 // 参数和返回值共占用空间大小,这段空间会在_defer结构体后面,用于defer注册时候保存参数,并在执行时候拷贝到调用者参数与返回值空间。 started bool // 标记defer是否已经执行 heap bool // 标记该_defer结构体是否分配在堆上 openDefer bool // 标志是否使用open coded defer方式处理defer sp uintptr // 调用者栈指针,执行时会根据sp判断该defer是否是当前执行调用者注册的 pc uintptr // deferprocStack或deferproc的返回地址 fn *funcval // defer函数,是funcval类型 _panic *_panic // panic链表,用于panic处理 link *_defer // 链接到下一个_defer结构体,即该在_defer之前注册的_defer结构体 fd unsafe.Pointer // funcdata for the function associated with the frame varp uintptr // value of varp for the stack frame framepc uintptr } _defer结构体中siz字段记录着defer函数参数和返回值大小,如果defer函数拥有参数,则Go会把其参数拷贝到该defer函数对应的_defer结构体后面的内存块中。\n_defer结构体中fn字段是指向一个funcval类型的指针,funcval结构体的fn字段字段指向defer函数的入口地址。对应上面示例代码中就是greet函数的入口地址\n上面示例代码中编译后的Go汇编代码如下, 点击在线查看汇编代码:\nmain_pc0: TEXT \u0026#34;\u0026#34;.main(SB), ABIInternal, $40-0 MOVQ (TLS), CX CMPQ SP, 16(CX) JLS main_pc151 SUBQ $40, SP MOVQ BP, 32(SP) LEAQ 32(SP), BP FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) FUNCDATA $3, gclocals·9fb7f0986f647f17cb53dda1484e0f7a(SB) PCDATA $2, $0 PCDATA $0, $0 MOVL $16, (SP) PCDATA $2, $1 LEAQ \u0026#34;\u0026#34;.greet·f(SB), AX PCDATA $2, $0 MOVQ AX, 8(SP) PCDATA $2, $1 LEAQ go.string.\u0026#34;friend\u0026#34;(SB), AX PCDATA $2, $0 MOVQ AX, 16(SP) MOVQ $6, 24(SP) CALL runtime.deferproc(SB) TESTL AX, AX JNE main_pc135 JMP main_pc84 main_pc84: CALL runtime.printlock(SB) PCDATA $2, $1 LEAQ go.string.\u0026#34;welcome\\n\u0026#34;(SB), AX PCDATA $2, $0 MOVQ AX, (SP) MOVQ $8, 8(SP) CALL runtime.printstring(SB) CALL runtime.printunlock(SB) XCHGL AX, AX CALL runtime.deferreturn(SB) MOVQ 32(SP), BP ADDQ $40, SP RET main_pc135: XCHGL AX, AX CALL runtime.deferreturn(SB) MOVQ 32(SP), BP ADDQ $40, SP RET 需要注意的是上面汇编代码是go1.12版本的汇编代码。\n从上面汇编代码我们可以发现defer实现有两个阶段,第一个阶段使用runtime.deferproc函数进行defer注册阶段。这一阶段主要工作是创建defer结构,然后将其注册到defer链表中。在注册完成之后,会根据runtime.deferproc函数返回结果进行下一步处理,若是1则说明,defer函数有panic处理,则直接跳过defer后面的代码,直接去执行runtime.deferreturn(对应就是上面汇编代码JNE main_pc135逻辑),若是0则是正常流程,则继续后面的代码(对应上面汇编代码就是 JMP main_pc84)。\n第二个阶段是调用runtime.deferreturn函数执行defer执行阶段。这个阶段遍历defer链表,获取defer结构,然后执行defer结构中存放的defer函数信息。\ndefer注册阶段 # defer注册阶段是调用deferproc函数将创建defer结构体,并将其注册到defer链表中。\nfunc deferproc(siz int32, fn *funcval) { if getg().m.curg != getg() { // 判断当前G是否处在用户栈空间上,若不是则抛出异常 throw(\u0026#34;defer on system stack\u0026#34;) } sp := getcallersp() argp := uintptr(unsafe.Pointer(\u0026amp;fn)) + unsafe.Sizeof(fn) // 获取defer函数参数起始地址 callerpc := getcallerpc() d := newdefer(siz) if d._panic != nil { throw(\u0026#34;deferproc: d.panic != nil after newdefer\u0026#34;) } d.fn = fn d.pc = callerpc d.sp = sp switch siz { case 0: // Do nothing. case sys.PtrSize: // defer函数等于8字节大小(64位系统下),则直接将_defer结构体后面8字节空间 *(*uintptr)(deferArgs(d)) = *(*uintptr)(unsafe.Pointer(argp)) default: memmove(deferArgs(d), unsafe.Pointer(argp), uintptr(siz)) } return0() } 上面代码中getcallersp()返回调用者SP地址。deferproc的调用者是main函数,getcallersp()返回的SP地址指向的deferproc的return address。\ngetcallerpc()返回调用者PC,此时PC指向的CALL runtime.deferproc(SB)指令的下一条指令,即TESTL AX, AX。\n结合汇编和deferproc代码,我们画出defer注册时状态图:\n接下来,我们来看下newdefer函数是如何分配defer结构体的。\nfunc newdefer(siz int32) *_defer { var d *_defer sc := deferclass(uintptr(siz)) // 根据defer函数参数大小,计算出应该使用上面规格的defer缓冲池 gp := getg() if sc \u0026lt; uintptr(len(p{}.deferpool)) { // defer缓冲池只支持5种缓冲池,从0到4,若sc规格不小于5(说明defer参数大小大于64字节), // 则无法使用缓冲池,则需从内存中分配 pp := gp.m.p.ptr() // pp指向当前M关联的P if len(pp.deferpool[sc]) == 0 \u0026amp;\u0026amp; sched.deferpool[sc] != nil { // 若当前P的defer缓冲池为空,且全局缓冲池有可用的defer,那么先从全局缓冲拿一点过来存放在P的缓冲池中 systemstack(func() { lock(\u0026amp;sched.deferlock) for len(pp.deferpool[sc]) \u0026lt; cap(pp.deferpool[sc])/2 \u0026amp;\u0026amp; sched.deferpool[sc] != nil { d := sched.deferpool[sc] sched.deferpool[sc] = d.link d.link = nil pp.deferpool[sc] = append(pp.deferpool[sc], d) } unlock(\u0026amp;sched.deferlock) }) } if n := len(pp.deferpool[sc]); n \u0026gt; 0 { d = pp.deferpool[sc][n-1] pp.deferpool[sc][n-1] = nil pp.deferpool[sc] = pp.deferpool[sc][:n-1] } } if d == nil { // 若果需要的defer缓冲池不满足所需的规格,或者缓冲池中没有可用的时候,切换到系统栈上,进行defer结构内存分配。 systemstack(func() { total := roundupsize(totaldefersize(uintptr(siz))) d = (*_defer)(mallocgc(total, deferType, true)) }) } d.siz = siz d.heap = true // 标记分配到堆上 d.link = gp._defer // 插入到链表头部 gp._defer = d return d } 总结下newdefer函数逻辑:\n首先根据defer函数的参数大小,使用deferclass计算出相应所需要的defer规格,如果defer缓冲池支持该规格,则尝试从defer缓冲池取出对应的defer结构体。 从defer缓冲池中取可用defer结构体时候,会首先从per-P defer缓冲池中取,若per-P defer缓冲池为空,则尝试从全局缓冲池中取一些可用defer结构体,然后放在per-P缓冲池,然后再从per-P缓冲池中取。 若defer缓冲池不支持该规格,或者缓冲池无可用缓冲,则切换到系统栈上进行defer结构分配。 defer缓冲池规格 # defer缓冲池,是按照defer函数参数大小范围分为五种规格,若不在五种规格之类,则不提供缓冲池功能,那么每次defer注册时候时候都必须进行内存分配创建defer结构体:\n缓冲池规格 defer函数参数大小范围 对应per-P缓冲池位置 对应全局缓冲池位置 class0 0 p.deferpool[0] sched.deferpool[0] class1 [1, 16] p.deferpool[1] sched.deferpool[1] class2 [17, 32] p.deferpool[2] sched.deferpool[2] class3 [33, 48] p.deferpool[3] sched.deferpool[3] class4 [49, 64] p.deferpool[4] sched.deferpool[4] defer函数参数大小与缓冲池规格转换是通过deferclass函数转换的:\nfunc deferclass(siz uintptr) uintptr { if siz \u0026lt;= minDeferArgs { // minDeferArgs是个常量,值是0 return 0 } return (siz - minDeferArgs + 15) / 16 } per-P级defer缓冲池与全局级defer缓冲池结构 # per-P级defer缓冲池结构使用两个字段deferpool和deferpoolbuf构成缓冲池:\ntype p struct { ... deferpool [5][]*_defer // pool of available defer structs of different sizes (see panic.go) deferpoolbuf [5][32]*_defer ... } p结构体中deferpool数组的元素是_defer指针类型的切片,该切片的底层数组是deferpoolbuf数组的元素:\nfunc (pp *p) init(id int32) { ... for i := range pp.deferpool { pp.deferpool[i] = pp.deferpoolbuf[i][:0] } ... } 全局级defer缓冲池保存在全局sched的deferpool字段中,sched是schedt类型变量,deferpool是由5个_defer类型指针构成链表组成的数组:\ntype schedt struct { ... deferlock mutex // 由于存在多个P并发的从全局缓冲池中获取defer结构体,所以需要一个锁 deferpool [5]*_defer ... } defer执行阶段 # 当函数返回之前,Go会调用deferreturn函数,开始执行defer函数。总之defer流程可以简单概括为:Go语言通过先注册(通过调用deferproc函数),然后函数返回之前执行defer函数(通过调用deferreturn函数),实现了defer延迟执行功能。\nfunc deferreturn(arg0 uintptr) { gp := getg() d := gp._defer if d == nil { // defer链表为空,直接返回。deferreturn是一个递归调用,每次调用都会从defer链表弹出一个defer进行执行,当defer链表为空时候,说明所有defer都已经执行完成 return } sp := getcallersp() if d.sp != sp { // defer保存的sp与当前调用deferreturn的调用者栈顶sp不一致,则直接返回 return } switch d.siz { case 0: case sys.PtrSize: // 若defer参数大小是8字节,则直接将defer参数复制给arg0 *(*uintptr)(unsafe.Pointer(\u0026amp;arg0)) = *(*uintptr)(deferArgs(d)) default: // 否则进行内存移动,将defer的参数复制到arg0中,此后arg0存放的是延迟函数的参数 memmove(unsafe.Pointer(\u0026amp;arg0), deferArgs(d), uintptr(d.siz)) } fn := d.fn d.fn = nil gp._defer = d.link freedefer(d) jmpdefer(fn, uintptr(unsafe.Pointer(\u0026amp;arg0))) } deferreturn函数通过jmpdefer实现递归调用,jmpdefer是通过汇编实现的,jmpdefer函数完成两个功能:调用defer函数和deferreturn再次调用。deferreturn递归调用时候,递归终止条件有两个:1. defer链表为空。2. defer保存的sp与当前调用deferreturn调用者栈顶sp不一致。第一个条件很好了解,第二个循环终止条件存在原因,我们稍后探究。\n我们需要理解arg0这个变量用途。arg0看似是deferreturn的参数,实际上是用来存储延迟函数的参数。\n在调用jmpdefer之前,会先调用freedefer将当前defer结构释放回收:\nfunc freedefer(d *_defer) { if d._panic != nil { // freedefer调用时_panic一定是nil freedeferpanic() // freedeferpanic作用是抛出异常:freedefer with d._panic != nil } if d.fn != nil { // freedefer调用时fn一定已经置为nil freedeferfn() // freedeferfn作用是抛出异常:freedefer with d.fn != nil } if !d.heap { // defer结构不是在堆上分配,则无需进行回收 return } sc := deferclass(uintptr(d.siz)) // 根据defer参数和返回值大小,判断规格,以便决定放在哪种规格defer缓冲池中 if sc \u0026gt;= uintptr(len(p{}.deferpool)) { return } pp := getg().m.p.ptr() if len(pp.deferpool[sc]) == cap(pp.deferpool[sc]) { // 当前P的defer缓冲池已满,则将P的defer缓冲池defer取出一般放在全局defer缓冲池中 systemstack(func() { var first, last *_defer for len(pp.deferpool[sc]) \u0026gt; cap(pp.deferpool[sc])/2 { n := len(pp.deferpool[sc]) d := pp.deferpool[sc][n-1] pp.deferpool[sc][n-1] = nil pp.deferpool[sc] = pp.deferpool[sc][:n-1] if first == nil { first = d } else { last.link = d } last = d } lock(\u0026amp;sched.deferlock) last.link = sched.deferpool[sc] sched.deferpool[sc] = first unlock(\u0026amp;sched.deferlock) }) } // 重置defer参数 d.siz = 0 d.started = false d.sp = 0 d.pc = 0 d.link = nil pp.deferpool[sc] = append(pp.deferpool[sc], d) // 将当前defer放入P的defer缓冲池中 } 我们来看下jmpdefer实现:\nTEXT runtime·jmpdefer(SB), NOSPLIT, $0-16 MOVQ\tfv+0(FP), DX\t# DX寄存器存储jmpdefer第一个参数fn,fn是funcval类型指针 MOVQ\targp+8(FP), BX\t# BX寄存器存储jmpdefer第二个参数,该参数是个指针类型,指向arg0 LEAQ\t-8(BX), SP\t# 将BX存放的arg0的地址减少8,获取得到调用deferreturn时栈顶地址(此时栈顶存放的是deferreturn的return address),最后将该地址存放在SP寄存器中 MOVQ\t-8(SP), BP\t# 重置BP寄存器 SUBQ\t$5, (SP)\t# 此时SP寄存器指向的是deferreturn的return address。该指令是将调用deferreturn的return address减少5, # 而减少5之后,return adderss恰好指向了`CALL runtime.deferreturn(SB)`,这就实现了deferreturn递归调用 MOVQ 0(DX), BX # DX存储的是fn,其是funcval类型指针,所以获取真正函数入口地址需要0(DX),该指令等效于BX = Mem[R[DX] + 0]。 # 寄存器逻辑操作不了解的话,可以参看前面Go汇编章节 JMP\tBX\t# 通过JMP指令调用延迟函数 从上面代码可以看出来,jmpdefer通过汇编更改了延迟函数调用的return address,使return address指向deferreturn入口地址,这样当延迟函数执行完成之后,会继续调用deferreturn函数,从而实现了deferreturn递归调用。deferreturn和jmpdefer最后实现的逻辑的伪代码如下:\nfunction deferreturn() { var arg int for _, d := range deferLinkList { arg = d.arg d.fn(arg) deferreturn() } } 画出deferreturn调用内存和栈的状态图,帮助理解:\n最后我们来探究一下deferreturn第二个终止条件,考虑下面的场景:\nfunc A() { defer B() defer C() } func C() { defer D() } 将上面代码转换成成底层实现的伪代码如下:\nfunc A() { deferproc(B) // 注册延迟函数B deferproc(C) // 注册延迟函数C deferreturn() // 开始执行延迟函数 } func C() { deferproc(D) // 注册延迟函数C deferreturn() // 开始执行延迟函数 } 当调用A函数的deferreturn函数时,会从defer链表中取出延迟函数C进行执行,当执行C函数时,其内部也有一个defer函数,C函数最后也会调用deferreturn函数,当C函数中调用deferreturn函数时,defer链表结构如下:\nsp指向C的栈顶 sp指向A的栈顶 | | | | v v g._defer ---------\u0026gt; D --------\u0026gt; B 当C中的deferreturn执行完defer链表中延迟函数D之后,开始执行B的时候,由于B的sp指向的是A的栈顶,不等于C的栈顶,此时满足终止条件2,C中的deferreturn会退出执行,此时A的deferreturn开始继续执行(A的deferreturn调用其C的deferreturn函数,相当于一个大循环里面套一个小循环,现在是小循环退出了,大循环还是会继续的),此时由于B的sp指向就是A的栈顶,B函数会执行。\ndeferreturn循环终止第二个条件就是为了解决诸于此类的场景。\n优化历程 # 上面我们分析的代码中defer结构是分配到堆上,其实为了优化defer语法性能,Go在实现过程可能会将defer结构分配在栈上。我们来看看Go各个版本对defer都做了哪些优化?\npackage main func main() { defer greet() } func greet() { print(\u0026#34;hello\u0026#34;) } 我们以上面代码为例,看看其在go1.12、go1.13、go1.14这几个版本下的核心汇编代码:\ngo1.12版本:\n1 2 3 4 5 6 7 8 9 leaq \u0026#34;\u0026#34;.greet·f(SB), AX pcdata $2, $0 movq AX, 8(SP) call runtime.deferproc(SB) testl AX, AX jne main_pc73 .loc 1 5 0 xchgl AX, AX call runtime.deferreturn(SB) go1.12版本中通过调用 runtime.deferproc 函数,将defer函数包装成 _defer 结构并注册到defer链表中,该 _defer 结构体是分配在堆内存中,需要进行垃圾回收的。\ngo1.13版本:\n1 2 3 4 5 6 7 8 9 10 11 12 13 leaq \u0026#34;\u0026#34;.greet·f(SB), AX pcdata $0, $0 movq AX, \u0026#34;\u0026#34;..autotmp_0+32(SP) pcdata $0, $1 leaq \u0026#34;\u0026#34;..autotmp_0+8(SP), AX pcdata $0, $0 movq AX, (SP) call runtime.deferprocStack(SB) testl AX, AX jne main_pc83 .loc 1 5 0 xchgl AX, AX call runtime.deferreturn(SB) go1.13版本中通过调用 runtime.deferprocStack 函数,将defer函数包装成 _defer 结构并注册到defer链表中,该 _defer 结构体是分配在栈上,不需要进行垃圾回收处理,这个地方就是go1.13相比go1.12所做的优化点。\ngo1.14版本:\n1 2 3 4 5 6 7 8 9 10 11 leaq \u0026#34;\u0026#34;.greet·f(SB), AX pcdata $0, $0 pcdata $1, $1 movq AX, \u0026#34;\u0026#34;..autotmp_1+8(SP) .loc 1 5 0 movb $0, \u0026#34;\u0026#34;..autotmp_0+7(SP) call \u0026#34;\u0026#34;.greet(SB) movq 16(SP), BP addq $24, SP ret call runtime.deferreturn(SB) go1.14版本不再调用deferproc/deferprocStack 函数来处理,而是在 return 返回之前直接调用该 defer函数(即inline方式),性能相比go1.13又得到进一步提升,go官方把这种处理方式称为open-coded defer。实际上go1.14中禁止优化和内联之后,defer函数其底层实现方式就和go1.13一样了。\n需要注意的是 open-coded defer 使用是有限制的,它不能用于for循环中的defer函数,还有就是defer的数量也是有限制的, 最多支持8个defer函数,对于for循环或者数量过的defer,将使用deferproc/deferprocStack方式实现。关于 open-coded defer 设计细节可以参见官方设计文档: Proposal: Low-cost defers through inline code, and extra funcdata to manage the panic case\n此外 open-coded defer 虽大大提高了 defer 函数执行的性能,但 panic 的 recover 的执行性能会大大变慢,这是因为 panic 处理过程中会扫描 open-coded defer 的栈帧。具体参见open-coded defer的 代码提交记录。open-coded defer带来的好处的是明显,毕竟panic是比较少发生的。\ngo1.14也增加了 -d defer 编译选项,可以查看defer实现时候使用哪一种方式:\ngo build -gcflags=\u0026#34;-d defer\u0026#34; main.go 总结一下defer优化历程:\n版本 优化内容 Go1.12及以前 defer分配到堆上,是heap-allocated defer Go1.13 支持在栈上分配defer结构,减少堆上分配和GC的开销,是stack-allocated defer G01.14 支持开放式编码defer,不再使用defer结构,直接在函数尾部调用延迟函数,是open-coded defer 进一步阅读 # What is a defer? And how many can you run? "},{"id":28,"href":"/type/pointer/","title":"指针","section":"数据类型与数据结构","content":" 指针 # Golang支持指针,但是不能像C语言中那样进行算术运算。对于任意类型T,其对应的的指针类型是*T,类型T称为指针类型*T的基类型。\n引用与解引用 # 一个指针类型*T变量B存储的是类型T变量A的内存地址,我们称该指针类型变量B引用(reference)了A。从指针类型变量B获取(或者称为访问)A变量的值的过程,叫解引用。解引用是通过解引用操作符*操作的。\nfunc main() { var A int = 100 var B *int = \u0026amp;A fmt.Println(A == *B) } 转换和可比较性 # 对于指针类型变量能不能够比较和显示转换需要满足以下规则:\n指针类型*T1和*T2相应的基类型T1和T2的底层类型必须一致。 type MyInt int type PInt *int type PMyInt *MyInt func main() { p1 := new(int) var p2 PInt = p1 // p2底层类型是*int p3 := new(MyInt) var p4 PMyInt = p3 // p4底层类型是*MyInt fmt.Println(p1, p2, p3, p4) } uintptr # uintptr是一个足够大的整数类型,能够存放任何指针。不同C语言,Go语言中普通类型指针不能进行算术运算,我们可以将普通类型指针转换成uintptr然后进行运算,但普通类型指针不能直接转换成uintptr,必须先转换成unsafe.Pointer类型之后,再转换成uintptr。\n// uintptr is an integer type that is large enough to hold the bit pattern of // any pointer. type uintptr uintptr unsafe.Pointer # unsafe标准库包提供了unsafe.Pointer类型,unsafe.Pointer类型称为非安全指针类型。\ntype ArbitraryType int type Pointer *ArbitraryType unsafe标准库包中也提供了三个函数:\nfunc Alignof(variable ArbitraryType) uintptr // 用来获取变量variable的对齐保证 func Offsetof(selector ArbitraryType) uintptr // 用来获取结构体值中的某个字段的地址相对于此结构体值地址的偏移 func Sizeof(variable ArbitraryType) uintptr // 用来获取变量variable变量的大小尺寸 任何指针类型都可以转换成unsafe.Pointer类型,即unsafe.Pointer可以指向任何类型(arbitrary type),但是该类型值是不能够解引用(dereferenced)的。unsafe.Pointer类型的零值是nil。反过来,unsafe.Pointer也可以转换成任何指针类型。\nunsafe.Pointer类型变量可以显示转换成内置的uintptr类型变量,uintptr变量是整数,可以进行算术运算,也可以反向转换成unsafe.Pointer。\n安全类型指针(普通类型指针) \u0026lt;\u0026mdash;-\u0026gt; unsafe.Pointer \u0026lt;\u0026mdash;\u0026ndash;\u0026gt; uintptr\n如何正确地使用非类型安全指针? # unsafe包中列出 6种正确使用unsafe.Pointer的模式。\nCode not using these patterns is likely to be invalid today or to become invalid in the future 在代码中不使用这些模式可能现在无效,或者将来也会变成无效的。\n通过非安全类型指针,将T1转换成T2 # func Float64bits(f float64) uint64 { return *(*uint64)(unsafe.Pointer(\u0026amp;f)) } 此时unsafe.Pointer充当桥梁,注意T2类型的尺寸不应该大于T1,否则会出现溢出异常\n将非安全类型指针转换成uintptr类型 # type MyInt int func main() { a := 100 fmt.Printf(\u0026#34;%p\\n\u0026#34;, \u0026amp;a) fmt.Printf(\u0026#34;%x\\n\u0026#34;, uintptr(unsafe.Pointer(\u0026amp;a))) } 将非安全类型指针转换成uintptr类型,并进行算术运算 # 这种模式常用来访问结构体字段或者数组的地址。\ntype MyType struct { f1 uint8 f2 int f3 uint64 } func main() { s := MyType{f1: 10, f2: 20, f3: 30} f2UintPtr := uintptr(unsafe.Pointer(uintptr(unsafe.Pointer(\u0026amp;s)) + unsafe.Offsetof(s.f2))) fmt.Printf(\u0026#34;%p\\n\u0026#34;, \u0026amp;s) fmt.Printf(\u0026#34;%x\\n\u0026#34;, f2UintPtr) // f2UintPtr = s地址 + 8 arr := [3]int{} fmt.Printf(\u0026#34;%p\\n\u0026#34;, \u0026amp;arr) for i := 0; i \u0026lt; 3; i++ { addr := uintptr(unsafe.Pointer(uintptr(unsafe.Pointer(\u0026amp;arr[0])) + uintptr(i)*unsafe.Sizeof(arr[0]))) fmt.Printf(\u0026#34;%x\\n\u0026#34;, addr) } } 通过指针移动到变量内存地址的末尾是无效的:\n// INVALID: end points outside allocated space. var s thing end = unsafe.Pointer(uintptr(unsafe.Pointer(\u0026amp;s)) + unsafe.Sizeof(s)) // INVALID: end points outside allocated space. b := make([]byte, n) end = unsafe.Pointer(uintptr(unsafe.Pointer(\u0026amp;b[0])) + uintptr(n)) .. warning:: 当将uintptr转换回unsafe.Pointer时,其不能赋值给一个变量进行中转。 我们来看看下面这个例子:\ntype MyType struct { f1 uint8 f2 int f3 uint64 } func main() { // 方式1 s := MyType{f1: 10, f2: 20, f3: 30} ptr := uintptr(unsafe.Pointer(\u0026amp;s)) + unsafe.Offsetof(s.f2) f2Ptr := (*int)(unsafe.Pointer(ptr)) fmt.Println(*f2Ptr) // 方式2 f2Ptr2 := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(\u0026amp;s)) + unsafe.Offsetof(s.f2))) fmt.Println(*f2Ptr2) } 上面代码中方式1是不安全的,尽管大多数情况结果是符合我们期望的,但是由于将uintptr赋值给ptr时,变量s已不再被引用,这时候若恰好进行GC,变量s会被回收处理。这会造成此后的操作都是非法访问内存地址。所以对于uintptr转换成unsafe.Pointer的场景,我们应该采用方式2将其写在一行里面。\n将非类型安全指针值转换为uintptr值,然后传递给syscall.Syscall函数 # 如果unsafe.Pointer参数必须转换为uintptr才能作为参数使用,这个转换必须出现在调用表达式中:\nsyscall.Syscall(SYS_READ, uintptr(fd), uintptr(unsafe.Pointer(p)), uintptr(n)) 将unsafe.Pointer转换成uintptr后传参时,无法保证执行函数时其执行的内存未回收。只有将这个转换放在函数调用表达时候,才能保证函数能够安全的访问该内存,这个是编译器进行安全保障实现的。\n将reflect.Value.Pointer或reflect.Value.UnsafeAddr方法的uintptr返回值转换为非类型安全指针 # reflect标准库包中的Value类型的Pointer和UnsafeAddr方法都返回uintptr类型值,而不是unsafe.Pointer类型值,是为了避免用户在不引用unsafe包情况下就可以将这两个方法的返回值转换为任何类型安全指针类型。\n调用reflect.Value.Pointer或reflect.Value.UnsafeAddr方法获取uintptr,并转换unsafe.Pointer必须放在一行表达式中:\np := (*int)(unsafe.Pointer(reflect.ValueOf(new(int)).Pointer())) 下面这种形式是非法:\n// INVALID: uintptr cannot be stored in variable // before conversion back to Pointer. u := reflect.ValueOf(new(int)).Pointer() p := (*int)(unsafe.Pointer(u)) 将reflect.SliceHeader或reflect.StringHeader的Data字段转换成非安全类型,或反之操作 # 正确的转换操作如下:\nvar s string hdr := (*reflect.StringHeader)(unsafe.Pointer(\u0026amp;s)) // 模式1 hdr.Data = uintptr(unsafe.Pointer(p)) // 模式6 hdr.Len = n 下面操作是存在bug的:\nvar hdr reflect.StringHeader hdr.Data = uintptr(unsafe.Pointer(p)) // 当执行下面代码时候,hdr.Data指向的内存可以已经被回收了 hdr.Len = n s := *(*string)(unsafe.Pointer(\u0026amp;hdr)) "},{"id":29,"href":"/type-system/interface/","title":"接口","section":"类型系统","content":" 接口 # "},{"id":30,"href":"/type/","title":"数据类型与数据结构","section":"简介","content":" 数据类型与数据结构 # 绳锯木断,水滴石穿。\n字符串 数组 切片 nil 空结构体 指针 映射 "},{"id":31,"href":"/type/array/","title":"数组","section":"数据类型与数据结构","content":" 数组 # 数组是Go语言中常见的数据结构,相比切片,数组我们使用的比较少。\n初始化 # Go语言数组有两个声明初始化方式,一种需要显示指明数组大小,另一种使用 ... 保留字, 数组的长度将由编译器在编译阶段推断出来:\narr1 := [3]int{1, 2, 3} // 使用[n]T方式 arr2 := [...]int{1, 2, 3} // 使用[...]T方式 arr3 := [3]int{2: 3} // 使用[n]T方式 arr4 := [...]int{2: 3} // 使用[...]T方式 注意:\n上面代码中 arr3 和 arr4 的初始化方式是指定数组索引对应的值。实际使用中这种方式并不常见。\n可比较性 # 数组大小是数组类型的一部分,只有数组大小和数组元素类型一样的数组才能够进行比较。\nfunc main() { var a1 [3]int var a2 [3]int var a3 [5]int fmt.Println(a1 == a2) // 输出true fmt.Println(a1 == a3) // 不能够比较,会报编译错误: invalid operation: a1 == a3 (mismatched types [3]int and [5]int) } 值类型 # Go语言中数组是一个值类型变量,将一个数组作为函数参数传递是拷贝原数组形成一个新数组传递,在函数里面对数组做任何更改都不会影响原数组:\nfunc passArr(arr [3]int) { arr[0] = arr[0] * 100 } func main() { myArr := [3]int{1, 3, 5} passArr(myArr) fmt.Println(myArr[0]) // 输出1 } 空间局部性与时间局部性 # CPU访问数据时候,趋于访问同一片内存区域的数据,这个称为 局部性原理(principle of locality)。局部性原理可以为细分为 空间局部性(Spatial Locality) 和 时间局部性(Temporal Locality)。\n空间局部性\n指的是如果一个位置的数据被访问,那么它周围的数据也有可能被访问到。\n时间局部性\n指的是如果一个位置的数据被访问到,那么它下一次还是很有可能被访问到。所以我们可以把最近访问的数据缓存起来,内存淘汰算法LRU就是基于这个原理。\n我们知道数组内存空间是连续分配的,比如对于[3][5]int类型数组其内存空间分配使用如下图所示:\n二维数组内存布局 观察上面的二维数组的内存布局,我们可以得出对于 [m][n]T 类型的数组中任一个元素内存地址的计算公式是:\n数组元素的内存地址 = 第一个数组元素的内存地址 + 该元素跨过了多少行 * 元素类型大小 + 该元素在当前行的位置 * 元素类型大小 转换成伪码的实现如下:\naddress(arr[x][y]) = address(arr[0][0]) + x * n * sizeof(T) + y * sizeof(T) = address(arr[0][0]) + (x * n + y) * sizeof(T) 下面我们根据上面公式来访问数组中元素,下面代码中使用到了 uintptr 和 unsafe.Pointer,如果不太了解的话可以看本书的 《 基础篇-指针》 那一章节:\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;unsafe\u0026#34; ) func main() { arr := [2][3]int{{1, 2, 3}, {4, 5, 6}} for i := 0; i \u0026lt; 2; i++ { for j := 0; j \u0026lt; 3; j++ { addr := uintptr(unsafe.Pointer(\u0026amp;arr[0][0])) + uintptr(i*3*8) + uintptr(j*8) // 地址 fmt.Printf(\u0026#34;arr[%d][%d]: 地址 = 0x%x,值 = %d\\n\u0026#34;, i, j, addr, *(*int)(unsafe.Pointer(uintptr(unsafe.Pointer(\u0026amp;arr[0][0])) + uintptr(i*3*8) + uintptr(j*8)))) } } } 上面代码运行结果如下:\narr[0][0]: 地址 = 0xc000068ef0,值 = 1 arr[0][1]: 地址 = 0xc000068ef8,值 = 2 arr[0][2]: 地址 = 0xc000068f00,值 = 3 arr[1][0]: 地址 = 0xc000068f08,值 = 4 arr[1][1]: 地址 = 0xc000068f10,值 = 5 arr[1][2]: 地址 = 0xc000068f18,值 = 6 空间局部性示例 # 对于数组的访问,我们可以一行行访问,也可以一列列访问,根据上面分析我们可以得出一行行访问可以有很好的空间局部性,有更好的执行效率的结论。因为一行行访问时,下一次访问的就是当前元素挨着的元素,而一列列访问则是需要跨过数组列数个元素:\n二位数组的访问 最后我们来进行下基准测试验证一下:\nfunc BenchmarkAccessArrayByRow(b *testing.B) { var myArr [3][5]int b.ReportAllocs() b.ResetTimer() for k := 0; k \u0026lt; b.N; k++ { for i := 0; i \u0026lt; 3; i++ { for j := 0; j \u0026lt; 5; j++ { myArr[i][j] = i*i + j*j } } } } func BenchmarkAccessArrayByCol(b *testing.B) { var myArr [3][5]int b.ReportAllocs() b.ResetTimer() for k := 0; k \u0026lt; b.N; k++ { for i := 0; i \u0026lt; 5; i++ { for j := 0; j \u0026lt; 3; j++ { myArr[j][i] = i*i + j*j } } } } 本人电脑中基准测试结果如下:\ngoos: linux goarch: amd64 BenchmarkAccessArrayByRow 121336255\t10.3 ns/op\t0 B/op\t0 allocs/op BenchmarkAccessArrayByCol 82772149\t13.2 ns/op\t0 B/op\t0 allocs/op PASS 从上面结果可以看出来,我们可以发现按行访问(10.3 ns/op)快于按列访问(13.2 ns/op),符合我们预测的结论。\n如何实现随机访问数组的全部元素? # 这里将介绍两种实现方法。这两种实现方法都是Go语言底层使用到的算法。\n第一种方法用在Go调度器部分。G-M-P调度模型中,当M关联的P的本地队列中没有可以执行的G时候,M会从其他P的本地可运行G队列中偷取G,所有P存储一个全局切片中,为了随机性选择P来偷取,这就需要随机的访问数组。该算法具体叫什么,未找到相关文档。由于该算法实现上使用到素数和取模运算,姑且称之素数取模随机法。\n第二种方法使用算法Fisher–Yates shuffle,Go语言用它来随机性处理通道选择器select中case语句。\n素数取模随机法 # 该算法实现逻辑是:对于一个数组[n]T,随机的从小于n的素数集合中,选择一个素数,假定是p,接着从数组0到n-1位置中随机选择一个位置开始,假定是m,那么此时(m + p)%n = i位置处的数组元素就是我们要访问的第一个元素。第二次要访问的元素是(上一次位置+p)%n处元素,这里面就是(i+p)%n,以此类推,访问n次就可以访问完全部数组元素。\n举个具体例子来说明,比如对于[8]int数组a,其素数集合是{1, 3, 5, 7}。假定选择的素数是5,从位置1开始。\n第一次访问元素是 (1 + 5)%8 = 6处元素,即a[6] 第二次访问元素是 (6 + 5)%8 = 3处元素,即a[3] 第三次访问元素是 (3 + 5)%8 = 0处元素,即a[0] 第四次访问元素是 (0 + 5)%8 = 5处元素,即a[5] 第五次访问元素是 (5 + 5)%8 = 2处元素,即a[2] 第六次访问元素是 (2 + 5)%8 = 7处元素,即a[7] 第七次访问元素是 (7 + 5)%8 = 4处元素,即a[4] 第八次访问元素是 (4 + 5)%8 = 1处元素,即a[1] 从上面例子可以看出来访问8次即可遍历完所有数组元素,由于素数和开始位置是随机的,那么访问也能做到随机性。\n该算法实现如下,代码来自Go源码 runtime/proc.go:\npackage main import ( \u0026#34;fmt\u0026#34; \u0026#34;math/rand\u0026#34; ) type randomOrder struct { count uint32 coprimes []uint32 } type randomEnum struct { i uint32 count uint32 pos uint32 inc uint32 } func (ord *randomOrder) reset(count uint32) { ord.count = count ord.coprimes = ord.coprimes[:0] for i := uint32(1); i \u0026lt;= count; i++ { // 初始化素数集合 if gcd(i, count) == 1 { ord.coprimes = append(ord.coprimes, i) } } } func (ord *randomOrder) start(i uint32) randomEnum { return randomEnum{ count: ord.count, pos: i % ord.count, inc: ord.coprimes[i%uint32(len(ord.coprimes))], } } func (enum *randomEnum) done() bool { return enum.i == enum.count } func (enum *randomEnum) next() { enum.i++ enum.pos = (enum.pos + enum.inc) % enum.count } func (enum *randomEnum) position() uint32 { return enum.pos } func gcd(a, b uint32) uint32 { // 辗转相除法取最大公约数 for b != 0 { a, b = b, a%b } return a } func main() { arr := [8]int{1, 2, 3, 4, 5, 6, 7, 8} var order randomOrder order.reset(uint32(len(arr))) fmt.Println(\u0026#34;====第一次随机遍历====\u0026#34;) for enum := order.start(rand.Uint32()); !enum.done(); enum.next() { fmt.Println(arr[enum.position()]) } fmt.Println(\u0026#34;====第二次随机遍历====\u0026#34;) for enum := order.start(rand.Uint32()); !enum.done(); enum.next() { fmt.Println(arr[enum.position()]) } } Fisher–Yates shuffle # 进一步阅读 # Locality of reference Fisher–Yates_shuffle "},{"id":32,"href":"/function/method/","title":"方法","section":"函数","content":" 方法 # Go 语言中具有接收者的函数,即为方法。若函数的接收者类型是T,那么我们可以说该函数是类型T的方法。那么方法底层实现是怎么样的,和函数有什么区别呢?这一章节我们将探讨这个。\n方法的本质就是普通函数 # 我们来看下如下的代码:\ntype A struct { name string } func (a A) Name() string { a.name = \u0026#34;Hi \u0026#34; + a.name return a.name } func main() { a := A{name: \u0026#34;new world\u0026#34;} println(a.Name()) println(A.Name(a)) } func NameofA(a A) string { a.name = \u0026#34;Hi \u0026#34; + a.name return a.name } 上面代码中,a.Name()表示的是调用对象a的Name方法。它实际上是一个语法糖,等效于A.Name(a),其中a就是方法接收者。我们可以通过以下代码证明两者是相等的:\nt1 := reflect.TypeOf(A.Name) t2 := relect.TypeOf(NameOfA) fmt.Println(t1 == t2) // true 我们在看下a.Name()底层实现是怎么样的,点击 在线查看:\nLEAQ go.string.\u0026#34;new world\u0026#34;(SB), AX MOVQ AX, \u0026#34;\u0026#34;.a+32(SP) MOVQ $9, \u0026#34;\u0026#34;.a+40(SP) PCDATA $0, $0 MOVQ AX, (SP) MOVQ $9, 8(SP) CALL \u0026#34;\u0026#34;.A.Name(SB) a.Name()底层其实调用的就是A.Name函数,只不过传递的第一参数就是对象a。\n综上所述,方法本质就是普通的函数,方法的接收者就是隐含的第一个参数。对于其他面向对象的语言来说,类对象就是相应的函数的第一个参数。\n值接收者和指针接收者混合的方法 # 比如以下代码中,展示的值接收者和指针接收者混合的方法\ntype A struct { name string } func (a A) GetName() string { return a.name } func (pa *A) SetName() string { pa.name = \u0026#34;Hi \u0026#34; + p.name return pa.name } func main() { a := A{name: \u0026#34;new world\u0026#34;} pa := \u0026amp;a println(pa.GetName()) // 通过指针调用定义的值接收者方法 println(a.SetName()) // 通过值调用定义的指针接收者方法 } 上面代码中通过指针调用值接收者方法和通过值调用指针接收者方法,都能够正常运行。这是因为两者都是语法糖,Go 语言会在编译阶段会将两者转换如下形式:\nprintln((*pa).GetName()) println((\u0026amp;a).SetName()) 方法表达式与方法变量 # type A struct { name string } func (a A) GetName() string { return a.name } func main() { a := A{name: \u0026#34;new world\u0026#34;} f1 := A.GetName // 方法表达式 f1(a) f2 := a.GetName // 方法变量 f2() } 方法表达式(Method Expression) 与方法变量(Method Value)本质上都是 Function Value ,区别在于方法变量会捕获方法接收者形成闭包,此方法变量的生命周期与方法接收者一样,编译器会将其进行优化转换成对类型T的方法调用,并传入接收者作为参数。 根据上面描述我们可以将上面代码中f2理解成如下代码:\nfunc GetFunc() (func()) string { a := A{name: \u0026#34;new world\u0026#34;} return func() string { return A.GetName(a) } } f2 = GetFunc() "},{"id":33,"href":"/type/map/","title":"映射类型","section":"数据类型与数据结构","content":" 映射 # 映射也被称为哈希表(hash table)、字典。它是一种由key-value组成的抽象数据结构。大多数情况下,它都能在O(1)的时间复杂度下实现增删改查功能。若在极端情况下出现所有key都发生哈希碰撞时则退回成链表形式,此时复杂度为O(N)。\n映射底层一般都是由数组组成,该数组每个元素称为桶,它使用hash函数将key分配到不同桶中,若出现碰撞冲突时候,则采用链地址法(也称为拉链法)或者开放寻址法解决冲突。下图就是一个由姓名-号码构成的哈希表的结构图:\nGo语言中映射中key若出现冲突碰撞时候,则采用链地址法解决,Go语言中映射具有以下特点:\n引用类型变量 读写并发不安全 遍历结果是随机的 数据结构 # Go语言映射的数据结构 Go语言中映射的数据结构是 runtime.hmap( runtime/map.go):\n// A header for a Go map. type hmap struct { count int // 元素个数,用于len函数返回map元素数量 flags uint8 // 标志位,标志当前map正在写等状态 B uint8 // buckets个数的对数,即桶数量 = 2 ^ B noverflow uint16 // overflow桶数量的近似值,overflow桶即溢出桶,即链表法中存在链表上的桶的个数 hash0 uint32 // 随机数种子,用于计算key的哈希值 buckets unsafe.Pointer // 指向buckets数组,如果元素个数为0时,该值为nil oldbuckets unsafe.Pointer // 扩容时指向旧的buckets nevacuate uintptr // 用于指示迁移进度,小于此值的桶已经迁移完成 extra *mapextra // 额外记录overflow桶信息 } 映射中每一个桶的结构是 runtime.bmap( runtime/map.go):\n// A bucket for a Go map. type bmap struct { tophash [bucketCnt]uint8 } 上面bmap结构是静态结构,在编译过程中 runtime.bmap 会拓展成以下结构体:\ntype bmap struct{ tophash [8]uint8 keys [8]keytype // keytype 由编译器编译时候确定 values [8]elemtype // elemtype 由编译器编译时候确定 overflow uintptr // overflow指向下一个bmap,overflow是uintptr而不是*bmap类型,是为了减少gc } bmap结构示意图:\nbmap底层结构 每个桶bmap中可以装载8个key-value键值对。当一个key确定存储在哪个桶之后,还需要确定具体存储在桶的哪个位置(这个位置也称为桶单元,一个bmap装载8个key-value键值对,那么一个bmap共8个桶单元),bmap中tophash就是用于实现快速定位key的位置。在实现过程中会使用key的hash值的高八位作为tophash值,存放在bmap的tophash字段中。tophash计算公式如下:\nfunc tophash(hash uintptr) uint8 { top := uint8(hash \u0026gt;\u0026gt; (sys.PtrSize*8 - 8)) if top \u0026lt; minTopHash { top += minTopHash } return top } 上面函数中hash是64位的,sys.PtrSize值是8,所以top := uint8(hash \u0026gt;\u0026gt; (sys.PtrSize*8 - 8))等效top = uint8(hash \u0026gt;\u0026gt; 56),最后top取出来的值就是hash的最高8位值。bmap的tophash字段不光存储key哈希值的高八位,还会存储一些状态值,用来表明当前桶单元状态,这些状态值都是小于minTopHash的。\n为了避免key哈希值的高八位值出现这些状态值相等产生混淆情况,所以当key哈希值高八位若小于minTopHash时候,自动将其值加上minTopHash作为该key的tophash。桶单元的状态值如下:\nemptyRest = 0 // 表明此桶单元为空,且更高索引的单元也是空 emptyOne = 1 // 表明此桶单元为空 evacuatedX = 2 // 用于表示扩容迁移到新桶前半段区间 evacuatedY = 3 // 用于表示扩容迁移到新桶后半段区间 evacuatedEmpty = 4 // 用于表示此单元已迁移 minTopHash = 5 // key的tophash值与桶状态值分割线值,小于此值的一定代表着桶单元的状态,大于此值的一定是key对应的tophash值 emptyRest和emptyOne状态都表示此桶单元为空,都可以用来插入数据。但是emptyRest还代表着更高单元也为空,那么遍历寻找key的时候,当遇到当前单元值为emptyRest时候,那么更高单元无需继续遍历。\n下图中桶单元1的tophash值是emptyOne,桶单元3的tophash值是emptyRest,那么我们一定可以推断出桶单元3以上都是emptyRest状态。\nbmap的tophash底层结构 bmap中可以装载8个key-value,这8个key-value并不是按照key1/value1/key2/value2/key3/value3\u0026hellip;这样形式存储,而采用key1/key2../key8/value1/../value8形式存储,因为第二种形式可以减少padding,源码中以map[int64]int8举例说明。\nhmap中extra字段是 runtime.mapextra 类型,用来记录额外信息:\n// mapextra holds fields that are not present on all maps. type mapextra struct { overflow *[]*bmap // 指向overflow桶指针组成的切片,防止这些溢出桶被gc了 oldoverflow *[]*bmap // 扩容时候,指向旧的溢出桶组成的切片,防止这些溢出桶被gc了 //指向下一个可用的overflow 桶 nextOverflow *bmap } 当映射的key和value都不是指针类型时候,bmap将完全不包含指针,那么gc时候就不用扫描bmap。bmap指向溢出桶的字段overflow是uintptr类型,为了防止这些overflow桶被gc掉,所以需要mapextra.overflow将它保存起来。如果bmap的overflow是*bmap类型,那么gc扫描的是一个个拉链表,效率明显不如直接扫描一段内存(hmap.mapextra.overflow)\n映射的创建 # 当使用make函数创建映射时候,若不指定map元素数量时候,底层将使用是make_small函数创建hmap结构,此时只产生哈希种子,不初始化桶:\nfunc makemap_small() *hmap { h := new(hmap) h.hash0 = fastrand() return h } 若指定map元素数量时候,底层会使用 makemap 函数创建hmap结构:\nfunc makemap(t *maptype, hint int, h *hmap) *hmap { mem, overflow := math.MulUintptr(uintptr(hint), t.bucket.size) if overflow || mem \u0026gt; maxAlloc { // 检查所有桶占用的内存是否大于内存限制 hint = 0 } // h不nil,说明map结构已经创建在栈上了,这个操作由编译器处理的 if h == nil { // h为nil,则需要创建一个hmap类型 h = new(hmap) } h.hash0 = fastrand() // 设置map的随机数种子 B := uint8(0) for overLoadFactor(hint, B) { // 设置合适B的值 B++ } h.B = B // 如果B == 0,那么map的buckets,将会惰性分配(allocated lazily),使用时候再分配 // 如果B != 0时,初始化桶 if h.B != 0 { var nextOverflow *bmap h.buckets, nextOverflow = makeBucketArray(t, h.B, nil) if nextOverflow != nil { h.extra = new(mapextra) h.extra.nextOverflow = nextOverflow // extra.nextOverflow指向下一个可用溢出桶位置 } } return h } makemap函数的第一个参数是maptype类指针,它描述了创建的map中key和value元素的类型信息以及其他map信息,第二个参数hint,对应是make([Type]Type, len)中len参数,第三个参数h,如果不为nil,说明当前map的结构已经有编译器在栈上创建了,makemap只需要完成设置随机数种子等操作。\noverLoadFactor函数用来判断当前映射的加载因子是否超过加载因子阈值。makemap使用overLoadFactor函数来调整B值。\n加载因子描述了哈希表中元素填满程度,加载因子越大,表明哈希表中元素越多,空间利用率高,但是这也意味着冲突的机会就会加大。当哈希表中所有桶已写满情况下,加载因子就是1,此时再写入新key一定会产生冲突碰撞。为了提高哈希表写入效率就必须在加载因子超过一定值时(这个值称为加载因子阈值),进行rehash操作,将桶容量进行扩容,来尽量避免出现冲突情况。\nJava中hashmap的默认加载因子阈值是0.75,Go语言中映射的加载因子阈值是6.5。为什么Go映射的加载因子阈值不是0.75,而且超过了1?这是因为Java中哈希表的桶存放的是一个key-value,其满载因子是1,Go映射中每个桶可以存8个key-value,满载因子是8,当加载因子阈值为6.5时候空间利用率和写入性能达到最佳平衡。\nfunc overLoadFactor(count int, B uint8) bool { // count \u0026gt; bucketCnt,bucketCnt值是8,每一个桶可以存放8个key-value,如果map中元素个数count小于8那么一定不会超过加载因子 // loadFactorNum和loadFactorDen的值分别是13和2,bucketShift(B)等效于1\u0026lt;\u0026lt;B // 所以 uintptr(count) \u0026gt; loadFactorNum*(bucketShift(B)/loadFactorDen) 等于 uintptr(count) \u0026gt; 6.5 * 2^ B return count \u0026gt; bucketCnt \u0026amp;\u0026amp; uintptr(count) \u0026gt; loadFactorNum*(bucketShift(B)/loadFactorDen) } // bucketShift returns 1\u0026lt;\u0026lt;b, optimized for code generation. func bucketShift(b uint8) uintptr { return uintptr(1) \u0026lt;\u0026lt; (b \u0026amp; (sys.PtrSize*8 - 1)) } makeBucketArray函数是用来创建bmap array,来用作为map的buckets。对于创建时指定元素大小超过(2^4) * 8时候,除了创建map的buckets,也会提前分配好一些桶作为溢出桶。buckets和溢出桶,在内存上是连续的。为啥提前分配好溢出桶,而不是在溢出时候,再分配,这是因为现在分配是直接申请一大片内存,效率更高。\nhamp.extra.nextOverflow指向该溢出桶,溢出桶的除了最后一个桶的overflow指向map的buckets,其他桶的overflow指向nil,这是用来判断溢出桶最后边界,后面代码有涉及此处逻辑。\nfunc makeBucketArray(t *maptype, b uint8, dirtyalloc unsafe.Pointer) (buckets unsafe.Pointer, nextOverflow *bmap) { base := bucketShift(b) // 等效于 base := 1 \u0026lt;\u0026lt; b nbuckets := base if b \u0026gt;= 4 { // 对于小b,不太可能出现溢出桶,所以B超过4时候,才考虑提前分配写溢出桶 nbuckets += bucketShift(b - 4) sz := t.bucket.size * nbuckets up := roundupsize(sz) if up != sz { nbuckets = up / t.bucket.size } } if dirtyalloc == nil { buckets = newarray(t.bucket, int(nbuckets)) } else { // 若dirtyalloc不为nil时, // dirtyalloc指向的之前已经使用完的map的buckets,之前已使用完的map和当前map具有相同类型的t和b,这样它buckets可以拿来复用 // 此时只需对dirtyalloc进行清除操作就可以作为当前map的buckets buckets = dirtyalloc size := t.bucket.size * nbuckets // 下面是清空dirtyalloc操作 if t.bucket.ptrdata != 0 { // map中key或value是指针类型 memclrHasPointers(buckets, size) } else { memclrNoHeapPointers(buckets, size) } } if base != nbuckets { // 多创建一些溢出桶 nextOverflow = (*bmap)(add(buckets, base*uintptr(t.bucketsize))) // 溢出桶的最后一个的overflow字段指向buckets last := (*bmap)(add(buckets, (nbuckets-1)*uintptr(t.bucketsize))) last.setoverflow(t, (*bmap)(buckets)) } return buckets, nextOverflow } 我们画出桶初始化时候的分配示意图:\n映射中桶定位 通过上面分析整个映射创建过程,可以看到使用make创建map时候,返回都是hmap类型指针,这也就说明Go语言中映射时引用类型的。\n访问映射操作 # 访问映射涉及到key定位的问题,首先需要确定从哪个桶找,确定桶之后,还需要确定key-value具体存放在哪个单元里面(每个桶里面有8个坑位)。key定位详细流程如下:\n首先需根据hash函数计算出key的hash值 该key的hash值的低hmap.B位的值是该key所在的桶 该key的hash值的高8位,用来快速定位其在桶具体位置。一个桶中存放8个key,遍历所有key,找到等于该key的位置,此位置对应的就是值所在位置 根据步骤3取到的值,计算该值的hash,再次比较,若相等则定位成功。否则重复步骤3去bmap.overflow中继续查找。 若bmap.overflow链表都找个遍都没有找到,则返回nil。 映射中桶定位 当m为2的x幂时候,n对m取余数存在以下等式:\nn % m = n \u0026amp; (m -1) 举个例子比如:n为15,m为8,n%m等7, n\u0026amp;(m-1)也等于7,取余应尽量使用第二种方式,因为效率更高。\n那么对于映射中key定位计算就是:\nkey对应value所在桶位置 = hash(key)%(hmap.B \u0026lt;\u0026lt; 1) = hash(key) \u0026amp; (hmap.B \u0026lt;\u0026lt;1 - 1) 那么为什么上面key定位流程步骤2中说的却是根据该key的hash值的低hmap.B位的值是该key所在的桶。两者是没有区别的,只是一种意思不同说法。\n直接访问与逗号ok模式访问 # 访问映射操作方式有两种:\n第一种直接访问,若key不存在,则返回value类型的零值,其底层实现mapaccess1函数:\nv := a[\u0026#34;x\u0026#34;] 第二种是逗号ok模式,如果key不存在,除了返回value类型的零值,ok变量也会设置为false,其底层实现mapaccess2:\nv, ok := a[\u0026#34;x\u0026#34;] 为了优化性能,Go编译器会根据key类型采用不同底层函数,比如对于key类型是int的,底层实现是mapaccess1_fast64。具体文件可以查看runtime/map_fastxxx.go。优化版本函数有:\nkey 类型 方法 uint64 func mapaccess1_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer uint64 func mapaccess2_fast64(t *maptype, h *hmap, key uint64) (unsafe.Pointer, bool) uint32 func mapaccess1_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer uint32 func mapaccess2_fast32(t *maptype, h *hmap, key uint32) (unsafe.Pointer, bool) string func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer string func mapaccess2_faststr(t *maptype, h *hmap, ky string) (unsafe.Pointer, bool) 这里面我们这分析通用的mapaccess1函数。\nfunc mapaccess1(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { if h == nil || h.count == 0 { // map为nil或者map中元素个数为0,则直接返回零值 if t.hashMightPanic() { t.hasher(key, 0) // see issue 23734 } return unsafe.Pointer(\u0026amp;zeroVal[0]) } if h.flags\u0026amp;hashWriting != 0 { // 有其他Goroutine正在写map,则直接panic throw(\u0026#34;concurrent map read and map write\u0026#34;) } hash := t.hasher(key, uintptr(h.hash0)) // 计算出key的hash值 m := bucketMask(h.B) // m = 2^h.B - 1 b := (*bmap)(add(h.buckets, (hash\u0026amp;m)*uintptr(t.bucketsize))) // 根据上面介绍的取余操作转换成位与操作来获取key所在的桶 if c := h.oldbuckets; c != nil { // 如果oldbuckets不为0,说明该map正在处于扩容过程中 if !h.sameSizeGrow() { // 如果不是等容量扩容,此时buckets大小是oldbuckets的两倍,那么m需减半,然后用来定位key在旧桶中位置 m \u0026gt;\u0026gt;= 1 } oldb := (*bmap)(add(c, (hash\u0026amp;m)*uintptr(t.bucketsize))) // 获取key在旧桶的桶 if !evacuated(oldb) { // 如果旧桶数据没有迁移新桶里面,那就在旧桶里面找 b = oldb } } top := tophash(hash) // 计算出key的tophash bucketloop: for ; b != nil; b = b.overflow(t) { // for循环实现功能是先从当前桶找,若未找到则当前桶的溢出桶b.overfolw(t)查找,直到溢出桶为nil for i := uintptr(0); i \u0026lt; bucketCnt; i++ { // 每个桶有8个单元,循环这8个单元,一个个找 if b.tophash[i] != top { // 如果当前单元的tophash与key的tophash不一致, if b.tophash[i] == emptyRest { // 若单元tophash值是emptyRest,则直接跳出整个大循环,emptyRest表明当前单元和更高单元存储都为空,所以无需在继续查找下去了 break bucketloop } continue // 继续查找桶其他的单元 } // 此时已找到tophash等于key的tophash的桶单元,此时i记录这桶单元编号 k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) // dataOffset是bmap.keys相对于bmap的偏移,k记录key存在bmap的位置 if t.indirectkey() { // 若key是指针类型 k = *((*unsafe.Pointer)(k)) } if t.key.equal(key, k) {// 如果key和存放bmap里面的key相等则获取对应value值返回 // value在bmap中的位置 = bmap.keys相对于bmap的偏移 + 8个key占用的空间(8 * keysize) + 该value在bmap.values中偏移(i * t.elemsize) e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize)) if t.indirectelem() { e = *((*unsafe.Pointer)(e)) } return e } } } return unsafe.Pointer(\u0026amp;zeroVal[0]) } 赋值映射操作 # 在map中增加和更新key-value时候,都会调用runtime.mapassign方法,同访问操作一样,Go编译器针对不同类型的key,会采用优化版本函数:\nkey 类型 方法 uint64 func mapassign_fast64(t *maptype, h *hmap, key uint64) unsafe.Pointer unsafe.Pointer func mapassign_fast64ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer uint32 func mapassign_fast32(t *maptype, h *hmap, key uint32) unsafe.Pointer unsafe.Pointer func mapassign_fast32ptr(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer string func mapassign_faststr(t *maptype, h *hmap, s string) unsafe.Pointer 这里面我们只分析通用的方法mapassign:\nfunc mapassign(t *maptype, h *hmap, key unsafe.Pointer) unsafe.Pointer { if h == nil { // 对于nil map赋值操作直接panic。需要注意的是访问nil map返回的是value类型的零值 panic(plainError(\u0026#34;assignment to entry in nil map\u0026#34;)) } if h.flags\u0026amp;hashWriting != 0 { // 有其他Goroutine正在写操作,则直接panic throw(\u0026#34;concurrent map writes\u0026#34;) } hash := t.hasher(key, uintptr(h.hash0)) // 计算出key的hash值 h.flags ^= hashWriting // 将写标志位置为1 if h.buckets == nil { // 惰性创建buckets,make创建map时候,并未初始buckets,等到mapassign时候在创建初始化 h.buckets = newobject(t.bucket) // newarray(t.bucket, 1) } again: bucket := hash \u0026amp; bucketMask(h.B) // bucket := hash \u0026amp; (2^h.B - 1) if h.growing() { // 如果当前map处于扩容过程中,则先进行扩容,将key所对应的旧桶先迁移过来 growWork(t, h, bucket) } b := (*bmap)(unsafe.Pointer(uintptr(h.buckets) + bucket*uintptr(t.bucketsize))) // 获取key所在的桶 top := tophash(hash) // 计算出key的tophash var inserti *uint8 // 指向key的tophash应该存放的位置,即bmap.tophash这个数组中某个位置 var insertk unsafe.Pointer // 指向key应该存放的位置,即bmap.keys这个数组中某个位置 var elem unsafe.Pointer // 指向value应该存放的位置,即bmap.values这个数组中某个位置 bucketloop: for { for i := uintptr(0); i \u0026lt; bucketCnt; i++ { if b.tophash[i] != top { if isEmpty(b.tophash[i]) \u0026amp;\u0026amp; inserti == nil { // 当i单元的tophash值为空,那么说明该单元可以用来存放key-value。 // 再加上inserti == nil条件就是inserti只找到第一个空闲的单元即可 inserti = \u0026amp;b.tophash[i] insertk = add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize)) } if b.tophash[i] == emptyRest { // 如果i单元的tophash值为emptyRest,那么剩下单元也不用继续找了,剩下单元一定都是空的 break bucketloop } continue } // 上面代码是先找到第一个为空的桶单元,然后把该桶单元相关的tophash、key、value等位置信息记录在inserti,insertk,elem临时变量上。 // 这样当key没有在map中情况下,可以拿inserti,insertk,elem这变量,将该key的信息写入到桶单元中,这种情况下key是一个新key,这种赋值操作属于新增操作。 // 下面代码部分就是处理中map已存在key的情况,这时候,我们只需要找到key所在桶单元中value的位置,然后把value新值写入即可。 // 这种情况下key是一个旧key,这种赋值操作属于更新操作。 k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) if t.indirectkey() { k = *((*unsafe.Pointer)(k)) } if !t.key.equal(key, k) { continue } if t.needkeyupdate() { typedmemmove(t.key, k, key) } elem = add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize)) goto done } ovf := b.overflow(t) // 当前桶没有找到,继续在其溢出桶里面找, if ovf == nil { // 直到都没有找到,那么跳出循环,不在找了。 break } b = ovf } // 当map未扩容中,那么就判断当前map是否需要扩容,扩容条件是以下两个条件符合任意之一即可: // 1. 是否达到负载因子的阈值6.5 // 2. 溢出桶是否过多 if !h.growing() \u0026amp;\u0026amp; (overLoadFactor(h.count+1, h.B) || tooManyOverflowBuckets(h.noverflow, h.B)) { hashGrow(t, h) goto again // 跳到again标签处,再来一遍 } // 如果上面两层for循环都没有找到空的桶单元,那说明所有桶单元都写满了,那么就得创建一个溢出桶了。 // 然后将数据存放到该溢出桶的第一个单元上 if inserti == nil { newb := h.newoverflow(t, b) // 创建一个溢出桶 inserti = \u0026amp;newb.tophash[0] insertk = add(unsafe.Pointer(newb), dataOffset) elem = add(insertk, bucketCnt*uintptr(t.keysize)) } // store new key/elem at insert position if t.indirectkey() { kmem := newobject(t.key) *(*unsafe.Pointer)(insertk) = kmem insertk = kmem } if t.indirectelem() { vmem := newobject(t.elem) *(*unsafe.Pointer)(elem) = vmem } typedmemmove(t.key, insertk, key) *inserti = top // 写入key的tophash值 h.count++ // 更新map的元素计数 done: if h.flags\u0026amp;hashWriting == 0 { throw(\u0026#34;concurrent map writes\u0026#34;) } h.flags \u0026amp;^= hashWriting // 将map的写标志置为0,那么其他Gorountine可以进行写入操作了 if t.indirectelem() { elem = *((*unsafe.Pointer)(elem)) } return elem } 我们梳理总结下mapassign函数执行流程:\n首先进行写标志检查和桶初始化检查。如果当前map写标志位已经置为1,那么肯定有它Gorountine正在进行写操作,那么直接panic。桶初始化检查是当map的桶未创建情况下,则在桶初始化检查阶段创建一个桶。\n接下来判断桶是否处在扩容过程中,如果处在扩容过程中,那么先将当前key所在旧桶全部迁移到新桶中,然后再接着迁移一个旧桶,也就是说每次mapasssign最多只迁移两个旧桶。为什么一定要先迁移key所在的旧桶数据呢?如果key是新key,那么旧桶中一定没有这个key信息,这种情况迁不迁移旧桶无关紧要,但若key之前在旧桶已存在,那么一定要先迁移,如果不这样的话,当key的新value写入新桶中之后再迁移,那么旧桶中的旧数据就会覆盖掉新桶中key的value值,为了应对这种情况,所以一定要先迁移key所在旧桶数据。\n接下就是两层for循环。第一层for循环就是遍历当前key所在桶,以及桶的溢出桶,直到桶的所有溢出桶都遍历一遍后,终止该层循环。第二层for循环遍历的是第一层for循环每次得到的桶中的8个桶单元。两层for循环是为了在map中找到key,如果找到key,那只需更新key对应value值就可。在循环过程中,会记录下第一个为空的桶单元,这样在未找到key的情况时候,就把key-value信息写入这个桶单元中。如果map中未找到key,且也未找到空的桶单元,那么没有办法了,只能创建一个溢出桶来存放该key-value。\n接下里判断当前map是否需要扩容,如果需要扩容,则调用hashGrow函数,将旧的buckets挂到hmap.oldbuckets字段上,再接着通过goto语法跳转标签形式跳到流程2继续执行下去\n最后就是将key的tophash,key值写入到找到的桶单元中,并返回桶单元的value地址。value的写入是拿到mapassign返回的地址,再写入的。\n接下来我们看下溢出桶创建操作:\n首先会从预分配的溢出桶列表中取,如果未取到,则会现场创建一个溢出桶 若map的key和value都不是指针类型,那么会将溢出桶记录到hmap.extra.overflow中 func (h *hmap) newoverflow(t *maptype, b *bmap) *bmap { var ovf *bmap if h.extra != nil \u0026amp;\u0026amp; h.extra.nextOverflow != nil { ovf = h.extra.nextOverflow // 从上面分析映射的创建过程代码中,我们知道创建map的buckets时候,有时候会顺便创建一些溢出桶, // h.extra.nextOverflow就是指向这些溢出桶 if ovf.overflow(t) == nil { // ovf不是最后一个溢出桶 h.extra.nextOverflow = (*bmap)(add(unsafe.Pointer(ovf), uintptr(t.bucketsize))) // extra.nextOverflow指向下一个溢出桶 } else { // ovf是最后一个溢出桶 ovf.setoverflow(t, nil) // 将ovf.overflow设置nil h.extra.nextOverflow = nil } } else { // 没有可用的预分配的溢出桶,则创建一个溢出桶 ovf = (*bmap)(newobject(t.bucket)) } h.incrnoverflow() // 更新溢出桶计数,这个溢出桶计数可用来是否进行rehash的依据 if t.bucket.ptrdata == 0 { // 如果map中的key和value都不是指针类型,那么将溢出桶指针添加到extra.overflow这个切片中 h.createOverflow() *h.extra.overflow = append(*h.extra.overflow, ovf) } b.setoverflow(t, ovf) return ovf } func (h *hmap) createOverflow() { if h.extra == nil { h.extra = new(mapextra) } if h.extra.overflow == nil { h.extra.overflow = new([]*bmap) } } func (b *bmap) overflow(t *maptype) *bmap { return *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize)) } func (b *bmap) setoverflow(t *maptype, ovf *bmap) { *(**bmap)(add(unsafe.Pointer(b), uintptr(t.bucketsize)-sys.PtrSize)) = ovf } func (h *hmap) incrnoverflow() { if h.B \u0026lt; 16 { h.noverflow++ return } // 当h.B大于等于16时候,有1/(1\u0026lt;\u0026lt;(h.B-15))的概率会更新h.noverflow // 比如h.B == 18时,mask==7,那么fastrand \u0026amp; 7 == 0的概率就是1/8 mask := uint32(1)\u0026lt;\u0026lt;(h.B-15) - 1 if fastrand()\u0026amp;mask == 0 { h.noverflow++ } } 映射的删除操作 # 在map中删除key-value时候,都会调用runtime.mapdelete方法,同访问操作一样,Go编译器针对不同类型的key,会采用优化版本函数:\nkey 类型 方法 uint64 func mapdelete_fast64(t *maptype, h *hmap, key uint64) uint32 func mapdelete_fast32(t *maptype, h *hmap, key uint32) string func mapdelete_faststr(t *maptype, h *hmap, ky string) 这里面我们只大概分析通用删除操作mapdelete函数:\n删除map中元素时候并不会释放内存。删除时候,会清空映射中相应位置的key和value数据,并将对应的tophash置为emptyOne。此外会检查当前单元旁边单元的状态是否也是空状态,如果也是空状态,那么会将当前单元和旁边空单元状态都改成emptyRest。\nfunc mapdelete(t *maptype, h *hmap, key unsafe.Pointer) { if h == nil || h.count == 0 { // 对nil map或者数量为0的map进行删除 if t.hashMightPanic() { t.hasher(key, 0) // see issue 23734 } return } if h.flags\u0026amp;hashWriting != 0 { // 有其他Goroutine正在写操作,则直接panic throw(\u0026#34;concurrent map writes\u0026#34;) } hash := t.hasher(key, uintptr(h.hash0)) h.flags ^= hashWriting // 将写标志置为1,删除操作也是一种写操作 bucket := hash \u0026amp; bucketMask(h.B) if h.growing() { growWork(t, h, bucket) } b := (*bmap)(add(h.buckets, bucket*uintptr(t.bucketsize))) bOrig := b top := tophash(hash) search: for ; b != nil; b = b.overflow(t) { for i := uintptr(0); i \u0026lt; bucketCnt; i++ { if b.tophash[i] != top { if b.tophash[i] == emptyRest { break search } continue } k := add(unsafe.Pointer(b), dataOffset+i*uintptr(t.keysize)) k2 := k if t.indirectkey() { k2 = *((*unsafe.Pointer)(k2)) } if !t.key.equal(key, k2) { continue } // 清空key if t.indirectkey() { *(*unsafe.Pointer)(k) = nil } else if t.key.ptrdata != 0 { memclrHasPointers(k, t.key.size) } // 清空value e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+i*uintptr(t.elemsize)) if t.indirectelem() { *(*unsafe.Pointer)(e) = nil } else if t.elem.ptrdata != 0 { memclrHasPointers(e, t.elem.size) } else { memclrNoHeapPointers(e, t.elem.size) } b.tophash[i] = emptyOne // 将tophash置为emptyOne // 下面代码是将当前单元附近的emptyOne状态的单元都改成emptyRest状态 if i == bucketCnt-1 { if b.overflow(t) != nil \u0026amp;\u0026amp; b.overflow(t).tophash[0] != emptyRest { goto notLast } } else { if b.tophash[i+1] != emptyRest { goto notLast } } for { b.tophash[i] = emptyRest if i == 0 { if b == bOrig { break } c := b for b = bOrig; b.overflow(t) != c; b = b.overflow(t) { } i = bucketCnt - 1 } else { i-- } if b.tophash[i] != emptyOne { break } } notLast: h.count-- break search } } if h.flags\u0026amp;hashWriting == 0 { throw(\u0026#34;concurrent map writes\u0026#34;) } h.flags \u0026amp;^= hashWriting } 扩容方式 # Go语言中映射扩容采用渐进式扩容,避免一次性迁移数据过多造成性能问题。当对映射进行新增、更新时候会触发扩容操作然后进行扩容操作(删除操作只会进行扩容操作,不会进行触发扩容操作),每次最多迁移2个bucket。扩容方式有两种类型:\n等容量扩容 双倍容量扩容 等容量扩容 # 当对一个map不停进行新增和删除操作时候,会创建了很多溢出桶,而加载因子没有超过阈值不会发生双倍容量扩容,这些桶利用率很低,就会导致查询效率变慢。这时候就需要采用等容量扩容,使用桶中数据更紧凑,减少溢出桶数量,从而提高查询效率。等容量扩容的条件是在未达到加载因子阈值情况下,如果B小于15时,溢出桶的数量大于2^B,B大于等于15时候,溢出桶数量大于2^15时候会进行等容量扩容操作:\nfunc tooManyOverflowBuckets(noverflow uint16, B uint8) bool { if B \u0026gt; 15 { B = 15 } return noverflow \u0026gt;= uint16(1)\u0026lt;\u0026lt;(B\u0026amp;15) } 双倍容量扩容 # 双倍容量扩容指的是桶的数量变成旧桶数量的2倍。当映射的负载因子超过阈值时候,会触发双倍容量扩容。\nfunc overLoadFactor(count int, B uint8) bool { return count \u0026gt; bucketCnt \u0026amp;\u0026amp; uintptr(count) \u0026gt; loadFactorNum*(bucketShift(B)/loadFactorDen) } 不论是等容量扩容,还是双倍容量扩容,都会新创建一个buckets,然后将hmap.buckets指向这个新的buckets,hmap.oldbuckets指向旧的buckets。\n进一步阅读 # 深度解密Go语言之map "},{"id":34,"href":"/concurrency/sync-cond/","title":"条件变量 - sync.Cond","section":"并发编程","content":" 条件变量 - sync.Cond # "},{"id":35,"href":"/type/empty_struct/","title":"空结构体","section":"数据类型与数据结构","content":" 空结构体 # 空结构体指的是没有任何字段的结构体。\n大小与内存地址 # 空结构体占用的内存空间大小为零字节,并且它们的地址可能相等也可能不等。当发生内存逃逸时候,它们的地址是相等的,都指向了 runtime.zerobase。\n// empty_struct.go type Empty struct{} //go:linkname zerobase runtime.zerobase var zerobase uintptr // 使用go:linkname编译指令,将zerobase变量指向runtime.zerobase func main() { a := Empty{} b := struct{}{} fmt.Println(unsafe.Sizeof(a) == 0) // true fmt.Println(unsafe.Sizeof(b) == 0) // true fmt.Printf(\u0026#34;%p\\n\u0026#34;, \u0026amp;a) // 0x590d00 fmt.Printf(\u0026#34;%p\\n\u0026#34;, \u0026amp;b) // 0x590d00 fmt.Printf(\u0026#34;%p\\n\u0026#34;, \u0026amp;zerobase) // 0x590d00 c := new(Empty) d := new(Empty) fmt.Sprint(c, d) // 目的是让变量c和d发生逃逸 println(c) // 0x590d00 println(d) // 0x590d00 fmt.Println(c == d) // true e := new(Empty) f := new(Empty) println(e) // 0xc00008ef47 println(f) // 0xc00008ef47 fmt.Println(e == f) // flase } 从上面代码输出可以看到 a, b, zerobase 这三个变量的地址都是一样的,最终指向的都是全局变量runtime.zerobase( runtime/malloc.go)。\n// base address for all 0-byte allocations var zerobase uintptr 我们可以通过下面方法再次来验证一下 runtime.zerobase 变量的地址是不是也是0x590d00:\ngo build -o empty_struct empty_struct.go go tool nm ./empty_struct | grep 590d00 # 或者 objdump -t empty_struct | grep 590d00 执行上面命令输出以下的内容:\n590d00 D runtime.zerobase # 或者 0000000000590d00 g O .noptrbss\t0000000000000008 runtime.zerobase 从上面输出的内容可以看到 runtime.zerobase 的地址也是 0x590d00。\n接下来我们看看变量逃逸的情况:\ngo run -gcflags=\u0026#34;-m -l\u0026#34; empty_struct.go # command-line-arguments ./empty_struct.go:15:2: moved to heap: a ./empty_struct.go:16:2: moved to heap: b ./empty_struct.go:18:13: ... argument does not escape ./empty_struct.go:18:31: unsafe.Sizeof(a) == 0 escapes to heap ./empty_struct.go:19:13: ... argument does not escape ./empty_struct.go:19:31: unsafe.Sizeof(b) == 0 escapes to heap ./empty_struct.go:20:12: ... argument does not escape ./empty_struct.go:21:12: ... argument does not escape ./empty_struct.go:22:12: ... argument does not escape ./empty_struct.go:24:10: new(Empty) escapes to heap ./empty_struct.go:25:10: new(Empty) escapes to heap ./empty_struct.go:26:12: ... argument does not escape ./empty_struct.go:29:13: ... argument does not escape ./empty_struct.go:29:16: c == d escapes to heap ./empty_struct.go:31:10: new(Empty) does not escape ./empty_struct.go:32:10: new(Empty) does not escape ./empty_struct.go:35:13: ... argument does not escape ./empty_struct.go:35:16: e == f escapes to heap 可以看到变量 c 和 d 逃逸到堆上,它们打印出来的都是 0x591d00,且两者进行相等比较时候返回 true。而变量 e 和 f 打印出来的都是0xc00008ef47,但两者进行相等比较时候却返回false。这因为Go有意为之的,当空结构体变量未发生逃逸时候,指向该变量的指针是不等的,当空结构体变量发生逃逸之后,指向该变量是相等的。这也就是 Go官方语法指南 所说的:\nPointers to distinct zero-size variables may or may not be equal\nGo语言比较操作符比较规则 注意:\n不论逃逸还是未逃逸,我们都不应该对空结构体类型变量指向的内存地址是否一样,做任何预期。 当一个结构体嵌入空结构体时,占用空间怎么计算? # 空结构体本身不占用空间,但是作为某结构体内嵌字段时候,有可能是占用空间的。具体计算规则如下:\n当空结构体是该结构体唯一的字段时,该结构体是不占用空间的,空结构体自然也不占用空间 当空结构体作为第一个字段或者中间字段时候,是不占用空间的 当空结构体作为最后一个字段时候,是占用空间的,大小跟其前一个字段保持一致 type s1 struct { a struct{} } type s2 struct { _ struct{} } type s3 struct { a struct{} b byte } type s4 struct { a struct{} b int64 } type s5 struct { a byte b struct{} c int64 } type s6 struct { a byte b struct{} } type s7 struct { a int64 b struct{} } type s8 struct { a struct{} b struct{} } func main() { fmt.Println(unsafe.Sizeof(s1{})) // 0 fmt.Println(unsafe.Sizeof(s2{})) // 0 fmt.Println(unsafe.Sizeof(s3{})) // 1 fmt.Println(unsafe.Sizeof(s4{})) // 8 fmt.Println(unsafe.Sizeof(s5{})) // 16 fmt.Println(unsafe.Sizeof(s6{})) // 2 fmt.Println(unsafe.Sizeof(s7{})) // 16 fmt.Println(unsafe.Sizeof(s8{})) // 0 } 当空结构体作为数组、切片的元素时候:\nvar a [10]int fmt.Println(unsafe.Sizeof(a)) // 80 var b [10]struct{} fmt.Println(unsafe.Sizeof(b)) // 0 var c = make([]struct{}, 10) fmt.Println(unsafe.Sizeof(c)) // 24,即slice header的大小 用途 # 由于空结构体占用的空间大小为零,我们可以利用这个特性,完成一些功能,却不需要占用额外空间。\n阻止unkeyed方式初始化结构体 # type MustKeydStruct struct { Name string Age int _ struct{} } func main() { persion := MustKeydStruct{Name: \u0026#34;hello\u0026#34;, Age: 10} fmt.Println(persion) persion2 := MustKeydStruct{\u0026#34;hello\u0026#34;, 10} //编译失败,提示: too few values in MustKeydStruct{...} fmt.Println(persion2) } 实现集合数据结构 # 集合数据结构我们可以使用map来实现:只关心key,不必关心value,我们就可以值设置为空结构体类型变量(或者底层类型是空结构体的变量)。\npackage main import ( \u0026#34;fmt\u0026#34; ) type Set struct { items map[interface{}]emptyItem } type emptyItem struct{} var itemExists = emptyItem{} func NewSet() *Set { set := \u0026amp;Set{items: make(map[interface{}]emptyItem)} return set } // 添加元素到集合 func (set *Set) Add(item interface{}) { set.items[item] = itemExists } // 从集合中删除元素 func (set *Set) Remove(item interface{}) { delete(set.items, item) } // 判断元素是否存在集合中 func (set *Set) Contains(item interface{}) bool { _, contains := set.items[item] return contains } // 返回集合大小 func (set *Set) Size() int { return len(set.items) } func main() { set := NewSet() set.Add(\u0026#34;hello\u0026#34;) set.Add(\u0026#34;world\u0026#34;) fmt.Println(set.Contains(\u0026#34;hello\u0026#34;)) fmt.Println(set.Contains(\u0026#34;Hello\u0026#34;)) fmt.Println(set.Size()) } 作为通道的信号传输 # 使用通道时候,有时候我们只关心是否有数据从通道内传输出来,而不关心数据内容,这时候通道数据相当于一个信号,比如我们实现退出时候。下面例子是基于通道实现的信号量。\n// empty struct var empty = struct{}{} // Semaphore is empty type chan type Semaphore chan struct{} // P used to acquire n resources func (s Semaphore) P(n int) { for i := 0; i \u0026lt; n; i++ { s \u0026lt;- empty } } // V used to release n resouces func (s Semaphore) V(n int) { for i := 0; i \u0026lt; n; i++ { \u0026lt;-s } } // Lock used to lock resource func (s Semaphore) Lock() { s.P(1) } // Unlock used to unlock resource func (s Semaphore) Unlock() { s.V(1) } // NewSemaphore return semaphore func NewSemaphore(N int) Semaphore { return make(Semaphore, N) } 进一步阅读 # The empty struct "},{"id":36,"href":"/concurrency/sync-waitgroup/","title":"等待组 - sync.WaitGroup","section":"并发编程","content":" 等待组 - sync.WaitGroup # 源码分析 # type WaitGroup struct { noCopy noCopy // waitgroup是不能够拷贝复制的,是通过go vet来检测实现 /* waitgroup使用一个int64来计数:高32位,用来add计数,低32位用来记录waiter数量。 若要原子性更新int64就必须保证该int64对齐系数是8,即64位对齐。 对于64位系统,直接使用一个int64类型字段就能保证原子性要求,但对32位系统就不行了。 所以实现的时候并没有直接一个int64, 而是使用[3]int32数组,若[0]int32地址恰好是8对齐的,那就waitgroup int64 = [0]int32 + [1]int32, 否则一定是4对齐的, 故[0]int32不用,恰好错开了4字节,此时[1]int32一定是8对齐的。此时waitgroup int64 = [1]int32 + [2]int32 通过这个技巧恰好满足32位和64位系统下int64都能原子性操作 */ state1 [3]uint32 // waitgroup对齐系数是4 } func (wg *WaitGroup) state() (statep *uint64, semap *uint32) { // 当state1是8对齐的,则返回低8字节(statep)用来计数,即state1[0]是add计数,state1[1]是waiter计数 if uintptr(unsafe.Pointer(\u0026amp;wg.state1))%8 == 0 { return (*uint64)(unsafe.Pointer(\u0026amp;wg.state1)), \u0026amp;wg.state1[2] } else { // 反之,则返回高8字节用来计数,即state1[1]是add计数,state1[2]是waiter计数 return (*uint64)(unsafe.Pointer(\u0026amp;wg.state1[1])), \u0026amp;wg.state1[0] } } // Add方法用来更新add计数器。即将原来计数值加上delta,delta可以为负值 // waitgroup的Done方法本质上就是Add(-1) // Add更新之后的计数器值不能小于0。当计数器值等于0时候,会释放信号,所有调用Wait方法而阻塞的Goroutine不再阻塞(释放的信号量=waiter计数) func (wg *WaitGroup) Add(delta int) { statep, semap := wg.state() if race.Enabled { // 竞态检查,忽略不看 _ = *statep // trigger nil deref early if delta \u0026lt; 0 { // Synchronize decrements with Wait. race.ReleaseMerge(unsafe.Pointer(wg)) } race.Disable() defer race.Enable() } state := atomic.AddUint64(statep, uint64(delta)\u0026lt;\u0026lt;32) // delta左移32位,然后原子性更新statep值并返回更新后的statep值 v := int32(state \u0026gt;\u0026gt; 32) // state高位的4字节是add计数,赋值给v w := uint32(state) // state低位的4字节是waiter计数,赋值给w if v \u0026lt; 0 { // add计数不能为负值。 panic(\u0026#34;sync: negative WaitGroup counter\u0026#34;) } // Add方法与Wait方法不能并发调用 if w != 0 \u0026amp;\u0026amp; delta \u0026gt; 0 \u0026amp;\u0026amp; v == int32(delta) { panic(\u0026#34;sync: WaitGroup misuse: Add called concurrently with Wait\u0026#34;) } if v \u0026gt; 0 || w == 0 { // add计数大于0,或者waiter计数等于0,直接返回不执行后面逻辑。 return } // statep指向state1字段,其指向的值和state进行比较,如果不一样,说明存在并发调用了Add和Wait方法 // 此时v = 0, w \u0026gt; 0,这个时候waitgroup的add计数和waiter计数不能再更改了。 // *statep != state情况举例:假定当前groutine是g1,执行到此处时, // 恰好另外一个groutine g2并发调用了Wait方法, // 那么waitgroup的state1字段会更新,而g1中w的值还是g2调用Wait方法之前的waiter数, // 这会导致总有一个g永远得不到释放信号,从而造成g泄漏。所以此处要进行panic判断 if *statep != state { panic(\u0026#34;sync: WaitGroup misuse: Add called concurrently with Wait\u0026#34;) } *statep = 0 // 重置计数器为0 for ; w != 0; w-- { // 有w个waiter,则释放出w个信号 runtime_Semrelease(semap, false, 0) } } // Done() == Add(-1) func (wg *WaitGroup) Done() { wg.Add(-1) } // Wait会阻塞当前goroutine,直到add计数器值为0 func (wg *WaitGroup) Wait() { statep, semap := wg.state() for { state := atomic.LoadUint64(statep) v := int32(state \u0026gt;\u0026gt; 32) w := uint32(state) // 使用for + cas进制,原子性更新waiter计数 if atomic.CompareAndSwapUint64(statep, state, state+1) { // 更新成功后,开始获取信号,未获取到信号的话则当前g一直阻塞 runtime_Semacquire(semap) if *statep != 0 { panic(\u0026#34;sync: WaitGroup is reused before previous Wait has returned\u0026#34;) } return } } } 总结 # waitgroup是不能值传递的 Add方法的传值可以是负数,但加上该传值之后的waitgroup计数器值不能是负值 Done方法实际上调用的是Add(-1) Add方法和Wait方法不能并发调用 Wait方法可以多次调用,调用此方法的goroutine会阻塞,一直阻塞到waitgroup计数器值变为0。 "},{"id":37,"href":"/type-system/","title":"类型系统","section":"简介","content":" 类型系统 # 知者不惑,仁者不忧,勇者不惧。\n类型系统 接口 反射 "},{"id":38,"href":"/type-system/type/","title":"类型系统","section":"类型系统","content":" 类型系统 # "},{"id":39,"href":"/concurrency/sync-pool/","title":"缓冲池 - sync.Pool","section":"并发编程","content":" 缓冲池 - sync.Pool # A Pool is a set of temporary objects that may be individually saved and retrieved.\nAny item stored in the Pool may be removed automatically at any time without notification. If the Pool holds the only reference when this happens, the item might be deallocated.\nA Pool is safe for use by multiple goroutines simultaneously.\nPool\u0026rsquo;s purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector. That is, it makes it easy to build efficient, thread-safe free lists. However, it is not suitable for all free lists\nsync.Pool提供了临时对象缓存池,存在池子的对象可能在任何时刻被自动移除,我们对此不能做任何预期。sync.Pool可以并发使用,它通过复用对象来减少对象内存分配和GC的压力。当负载大的时候,临时对象缓存池会扩大,缓存池中的对象会在每2个GC循环中清除。\nsync.Pool拥有两个对象存储容器:local pool和victim cache。local pool与victim cache相似,相当于primary cache。当获取对象时,优先从local pool中查找,若未找到则再从victim cache中查找,若也未获取到,则调用New方法创建一个对象返回。当对象放回sync.Pool时候,会放在local pool中。当GC开始时候,首先将victim cache中所有对象清除,然后将local pool容器中所有对象都会移动到victim cache中,所以说缓存池中的对象会在每2个GC循环中清除。\nvictim cache是从CPU缓存中借鉴的概念。下面是维基百科中关于victim cache的定义:\n所谓受害者缓存(Victim Cache),是一个与直接匹配或低相联缓存并用的、容量很小的全相联缓存。当一个数据块被逐出缓存时,并不直接丢弃,而是暂先进入受害者缓存。如果受害者缓存已满,就替换掉其中一项。当进行缓存标签匹配时,在与索引指向标签匹配的同时,并行查看受害者缓存,如果在受害者缓存发现匹配,就将其此数据块与缓存中的不匹配数据块做交换,同时返回给处理器。\n受害者缓存的意图是弥补因为低相联度造成的频繁替换所损失的时间局部性。\n用法 # sync.Pool提供两个接口,Get和Put分别用于从缓存池中获取临时对象,和将临时对象放回到缓存池中:\nfunc (p *Pool) Get() interface{} func (p *Pool) Put(x interface{}) 示例1 # type A struct { Name string } func (a *A) Reset() { a.Name = \u0026#34;\u0026#34; } var pool = sync.Pool{ New: func() interface{} { return new(A) }, } func main() { objA := pool.Get().(*A) objA.Reset() // 重置一下对象数据,防止脏数据 defer pool.Put(objA) objA.Name = \u0026#34;test123\u0026#34; fmt.Println(objA) } 接下来我们进行基准测试下未使用和使用sync.Pool情况:\ntype A struct { Name string } func (a *A) Reset() { a.Name = \u0026#34;\u0026#34; } var pool = sync.Pool{ New: func() interface{} { return new(A) }, } func BenchmarkWithoutPool(b *testing.B) { var a *A b.ReportAllocs() b.ResetTimer() for i := 0; i \u0026lt; b.N; i++ { for j := 0; j \u0026lt; 10000; j++ { a = new(A) a.Name = \u0026#34;tink\u0026#34; } } } func BenchmarkWithPool(b *testing.B) { var a *A b.ReportAllocs() b.ResetTimer() for i := 0; i \u0026lt; b.N; i++ { for j := 0; j \u0026lt; 10000; j++ { a = pool.Get().(*A) a.Reset() a.Name = \u0026#34;tink\u0026#34; pool.Put(a) // 一定要记得放回操作,否则退化到每次都需要New操作 } } } 基准测试结果如下:\n# go test -benchmem -run=^$ -bench . goos: darwin goarch: amd64 BenchmarkWithoutPool-8 3404 314232 ns/op 160001 B/op 10000 allocs/op BenchmarkWithPool-8 5870 220399 ns/op 0 B/op 0 allocs/op 从上面基准测试中,我们可以看到使用sync.Pool之后,每次执行的耗时由314232ns降到220399ns,降低了29.8%,每次执行的内存分配降到0(注意这是平均值,并不是没进行过内存分配,只不过是绝大数操作没有进行过内存分配,最终平均下来,四舍五入之后为0)。\n示例2 # go-redis/redis项目中实现连接池时候,使用到sync.Pool来创建定时器:\n// 创建timer Pool var timers = sync.Pool{ New: func() interface{} { // 定义创建临时对象创建方法 t := time.NewTimer(time.Hour) t.Stop() return t }, } func (p *ConnPool) waitTurn(ctx context.Context) error { select { case \u0026lt;-ctx.Done(): return ctx.Err() default: } ... timer := timers.Get().(*time.Timer) // 从缓存池中取出对象 timer.Reset(p.opt.PoolTimeout) select { ... case \u0026lt;-timer.C: timers.Put(timer) // 将对象放回到缓存池中,以便下次使用 atomic.AddUint32(\u0026amp;p.stats.Timeouts, 1) return ErrPoolTimeout } 数据结构 # sync.Pool底层数据结构体是Pool结构体( sync/pool.go):\ntype Pool struct { noCopy noCopy // nocopy机制,用于go vet命令检查是否复制后使用 local unsafe.Pointer // 指向[P]poolLocal数组,P等于runtime.GOMAXPROCS(0) localSize uintptr // local数组大小,即[P]poolLocal大小 victim unsafe.Pointer // 指向上一个gc循环前的local victimSize uintptr // victim数组大小 New func() interface{} // 创建临时对象的方法,当从local数组和victim数组中都没有找到临时对象缓存,那么会调用此方法现场创建一个 } Pool.local指向大小为runtime.GOMAXPROCS(0)的poolLocal数组,相当于大小为runtime.GOMAXPROCS(0)的缓存槽(solt)。每一个P都会通过其ID关联一个槽位上的poolLocal,比如对于ID=1的P关联的poolLocal就是[1]poolLocal,这个poolLocal属于per-P级别的poolLocal,与P关联的M和G可以无锁的操作此poolLocal。\npoolLocal结构如下:\ntype poolLocal struct { poolLocalInternal // 内嵌poolLocalInternal结构体 // 进行一些padding,阻止false share pad [128 - unsafe.Sizeof(poolLocalInternal{})%128]byte } type poolLocalInternal struct { private interface{} // 私有属性,快速存取临时对象 shared poolChain // shared是一个双端链表 } 为啥不直接把所有poolLocalInternal字段都写到poolLocal里面,而是采用内嵌形式?这是为了好计算出poolLocal的padding大小。\npoolChain结构如下:\ntype poolChain struct { // 指向双向链表头 head *poolChainElt // 指向双向链表尾 tail *poolChainElt } type poolChainElt struct { poolDequeue next, prev *poolChainElt } type poolDequeue struct { // headTail高32位是环形队列的head // headTail低32位是环形队列的tail // [tail, head)范围是队列所有元素 headTail uint64 vals []eface // 用于存放临时对象,大小是2的倍数,最小尺寸是8,最大尺寸是dequeueLimit } type eface struct { typ, val unsafe.Pointer } poolLocalInternal的shared字段指向是一个双向链表(doubly-linked list),链表每一个元素都是poolChainElt类型,poolChainElt是一个双端队列(Double-ended Queue,简写deque),并且链表中每一个元素的队列大小是2的倍数,且是前一个元素队列大小的2倍。poolChainElt是基于环形队列(Circular Queue)实现的双端队列。\n若poolLocal属于当前P,那么可以对shared进行pushHead和popHead操作,而其他P只能进行popTail操作。当前其他P进行popTail操作时候,会检查链表中节点的poolChainElt是否为空,若是空,则会drop掉该节点,这样当popHead操作时候避免去查一个空的poolChainElt。\npoolDequeue中的headTail字段的高32位记录的是环形队列的head,其低32位是环形队列的tail。vals是环形队列的底层数组。\nGet操作 # 我们来看下如何从sync.Pool中取出临时对象。下面代码已去掉竞态检测相关代码。\nfunc (p *Pool) Get() interface{} { l, pid := p.pin() // 返回当前per-P级poolLocal和P的id x := l.private l.private = nil if x == nil { x, _ = l.shared.popHead() if x == nil { x = p.getSlow(pid) } } runtime_procUnpin() if x == nil \u0026amp;\u0026amp; p.New != nil { x = p.New() } return x } 上面代码执行流程如下:\n首先通过调用pin方法,获取当前G关联的P对应的poolLocal和该P的id 接着查看poolLocal的private字段是否存放了对象,如果有的话,那么该字段存放的对象可直接返回,这属于最快路径。 若poolLocal的private字段未存放对象,那么就尝试从poolLocal的双端队列中取出对象,这个操作是lock-free的。 若G关联的per-P级poolLocal的双端队列中没有取出来对象,那么就尝试从其他P关联的poolLocal中偷一个。若从其他P关联的poolLocal没有偷到一个,那么就尝试从victim cache中取。 若步骤4中也没没有取到缓存对象,那么只能调用pool.New方法新创建一个对象。 我们来看下pin方法:\nfunc (p *Pool) pin() (*poolLocal, int) { pid := runtime_procPin() // 禁止M被抢占 s := atomic.LoadUintptr(\u0026amp;p.localSize) // 原子性加载local pool的大小 l := p.local if uintptr(pid) \u0026lt; s { // 如果local pool大小大于P的id,那么从local pool取出来P关联的poolLocal return indexLocal(l, pid), pid } /* * 当p.local指向[P]poolLocal数组还没有创建 * 或者通过runtime.GOMAXPROCS()调大P数量时候都可能会走到此处逻辑 */ return p.pinSlow() } func (p *Pool) pinSlow() (*poolLocal, int) { runtime_procUnpin() allPoolsMu.Lock() // 加锁 defer allPoolsMu.Unlock() pid := runtime_procPin() s := p.localSize l := p.local if uintptr(pid) \u0026lt; s { // 加锁后再次判断一下P关联的poolLocal是否存在 return indexLocal(l, pid), pid } if p.local == nil { // 将p记录到全局变量allPools中,执行GC钩子时候,会使用到 allPools = append(allPools, p) } size := runtime.GOMAXPROCS(0) // 根据P数量创建p.local local := make([]poolLocal, size) atomic.StorePointer(\u0026amp;p.local, unsafe.Pointer(\u0026amp;local[0])) atomic.StoreUintptr(\u0026amp;p.localSize, uintptr(size)) return \u0026amp;local[pid], pid } func indexLocal(l unsafe.Pointer, i int) *poolLocal { // 通过uintptr和unsafe.Pointer取出[P]poolLocal数组中,索引i对应的poolLocal lp := unsafe.Pointer(uintptr(l) + uintptr(i)*unsafe.Sizeof(poolLocal{})) return (*poolLocal)(lp) } pin方法中会首先调用runtime_procPin来设置M禁止被抢占。GMP调度模型中,M必须绑定到P之后才能执行G,禁止M被抢占就是禁止M绑定的P被剥夺走,相当于pin processor。\npin方法中为啥要首先禁止M被抢占?这是因为我们需要找到per-P级的poolLocal,如果在此过程中发生M绑定的P被剥夺,那么我们找到的就可能是其他M的per-P级poolLocal,没有局部性可言了。\nruntime_procPin方法是通过给M加锁实现禁止被抢占的,即m.locks++。当m.locks==0时候m是可以被抢占的:\n//go:linkname sync_runtime_procPin sync.runtime_procPin //go:nosplit func sync_runtime_procPin() int { return procPin() } //go:linkname sync_runtime_procUnpin sync.runtime_procUnpin //go:nosplit func sync_runtime_procUnpin() { procUnpin() } //go:nosplit func procPin() int { _g_ := getg() mp := _g_.m mp.locks++ // 给m加锁 return int(mp.p.ptr().id) } //go:nosplit func procUnpin() { _g_ := getg() _g_.m.locks-- } go:linkname是编译指令用于将私有函数或者变量在编译阶段链接到指定位置。从上面代码中我们可以看到sync.runtime_procPin和sync.runtime_procUnpin最终实现方法是sync_runtime_procPin和sync_runtime_procUnpin。\npinSlow方法用到的allPoolsMu和allPools是全局变量:\nvar ( allPoolsMu Mutex // allPools is the set of pools that have non-empty primary // caches. Protected by either 1) allPoolsMu and pinning or 2) // STW. allPools []*Pool // oldPools is the set of pools that may have non-empty victim // caches. Protected by STW. oldPools []*Pool ) 接下我们来看Get流程中步骤3的实现:\nfunc (c *poolChain) popHead() (interface{}, bool) { d := c.head // 从双向链表的头部开始 for d != nil { if val, ok := d.popHead(); ok { // 从双端队列头部取对象缓存,若取到则返回 return val, ok } // 若未取到,则尝试从上一个节点开始取 d = loadPoolChainElt(\u0026amp;d.prev) } return nil, false } func loadPoolChainElt(pp **poolChainElt) *poolChainElt { return (*poolChainElt)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(pp)))) } 最后我们看下Get流程中步骤4的实现:\nfunc (p *Pool) getSlow(pid int) interface{} { size := atomic.LoadUintptr(\u0026amp;p.localSize) locals := p.local for i := 0; i \u0026lt; int(size); i++ { // 尝试从其他P关联的poolLocal取一个, // 类似GMP调度模型从其他P的runable G队列中偷一个 // 偷的时候是双向链表尾部开始偷,这个和从本地P的poolLocal取恰好是反向的 l := indexLocal(locals, (pid+i+1)%int(size)) if x, _ := l.shared.popTail(); x != nil { return x } } // 若从其他P的poolLocal没有偷到,则尝试从victim cache取 size = atomic.LoadUintptr(\u0026amp;p.victimSize) if uintptr(pid) \u0026gt;= size { return nil } locals = p.victim l := indexLocal(locals, pid) if x := l.private; x != nil { l.private = nil return x } for i := 0; i \u0026lt; int(size); i++ { l := indexLocal(locals, (pid+i)%int(size)) if x, _ := l.shared.popTail(); x != nil { return x } } atomic.StoreUintptr(\u0026amp;p.victimSize, 0) return nil } func (c *poolChain) popTail() (interface{}, bool) { d := loadPoolChainElt(\u0026amp;c.tail) if d == nil { return nil, false } for { d2 := loadPoolChainElt(\u0026amp;d.next) if val, ok := d.popTail(); ok { // 从双端队列的尾部出队 return val, ok } if d2 == nil { // 若下一个节点为空,则返回。说明链表已经遍历完了 return nil, false } // 下面代码会将当前节点从链表中删除掉。 // 为什么要删掉它,因为该节点的队列里面有没有对象缓存了, // 删掉之后,下次本地P取的时候,不必遍历此空节点了 if atomic.CompareAndSwapPointer((*unsafe.Pointer)(unsafe.Pointer(\u0026amp;c.tail)), unsafe.Pointer(d), unsafe.Pointer(d2)) { storePoolChainElt(\u0026amp;d2.prev, nil) } d = d2 } } func storePoolChainElt(pp **poolChainElt, v *poolChainElt) { atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(pp)), unsafe.Pointer(v)) } 我们画出Get流程中步骤3和4的中从local pool取对象示意图:\n总结下从local pool流程是:\n首先从当前P的localPool的私有属性private上取 若未取到,则从localPool中由队列组成的双向链表上取,方向是从头部节点队列开始,依次往上查找 如果当前P的localPool中没有取到,则尝试从其他P的localPool偷一个,方向是从尾部节点队列开始,依次向下查找,若当前节点为空,会把当前节点从链表中删掉。 Put操作 # 接下来我们还看下对象归还操作:\nfunc (p *Pool) Put(x interface{}) { if x == nil { return } l, _ := p.pin() // 返回当前P的localPool if l.private == nil { // 若localPool的private没有存放对象,那就存放在private上,这是最快路径。取的时候优先从private上面取 l.private = x x = nil } if x != nil { // 入队 l.shared.pushHead(x) } runtime_procUnpin() } 流程步骤如下:\n调用pin方法,返回当前P的localPool 若当前P的localPool的private属性没有存放对象,那就存放其上面,这是最快路径,取的时候优先从private上面取 若当前P的localPool的private属性已经存放了归还的对象,那么就将对象入队存储。 我们接着看步骤3中代码:\nfunc (c *poolChain) pushHead(val interface{}) { d := c.head if d == nil { // 双向链表头部节点为空,则创建 // 头部节点的队列长度为8 const initSize = 8 d = new(poolChainElt) d.vals = make([]eface, initSize) c.head = d storePoolChainElt(\u0026amp;c.tail, d) } // 将归还对象入队 if d.pushHead(val) { return } // 若归还对象入队失败,说明当前头部节点的队列已满,会走后面的逻辑: // 创建新的队列节点,新的队列长度是当前节点队列的2倍,最大不超过dequeueLimit, // 然后将新的队列节点设置为双向链表的头部 newSize := len(d.vals) * 2 if newSize \u0026gt;= dequeueLimit { newSize = dequeueLimit } d2 := \u0026amp;poolChainElt{prev: d} // 新节点的prev指针指向旧的头部节点 d2.vals = make([]eface, newSize) c.head = d2 // 新节点成为双向链表的头部节点 storePoolChainElt(\u0026amp;d.next, d2) // 旧的头部节点next指针指向新节点 d2.pushHead(val) // 归还的临时对象入队新节点的队列中 } 从上面代码可以看到,创建的双向链表第一个节点队列的大小为8,第二个节点队列大小为16,第三个节点队列大小为32,依次类推,最大为dequeueLimit。每个节点队列的大小都是2的n次幂,这是因为队列使用环形队列结构实现的,底层是数组,同前面介绍的映射一样,定位位置时候取余运算可以改成与运算,更高效。\n我们画出双向链表中头部节点队列未满和已满两种情况下示意图:\n双端队列 - poolDequeue # 从上面Get操作和Put操作中,我们可以看到都是对poolChain操作,poolChain操作最终都是对双端队列poolDequeue的操作,Get操作对应poolDequeue的popHead和popTail, Put操作对应poolDequeue的pushHead。\n再看一下poolDequeue结构体定义:\ntype poolDequeue struct { headTail uint64 vals []eface } type eface struct { typ, val unsafe.Pointer } type dequeueNil *struct{} poolDequeue是一个无锁的(lock-free)、固定大小的(fixed-size) 单一生产者(single-producer),多消费者(multi-consumer)队列。单一生产者可以从队列头部push和pop元素,消费者可以从队列尾部pop元素。poolDequeue是基于环形队列实现的双端队列。所谓双端队列(double-ended queue,双端队列,简写deque)是一种具有队列和栈的性质的数据结构。双端队列中的元素可以从两端弹出,其限定插入和删除操作在表的两端进行。poolDequeue支持在两端删除操作,只支持在head端插入。\npoolDequeue的headTail字段是由环形队列的head索引(即rear索引)和tail索引(即front索引)打包而来,headTail是64位无符号整形,其高32位是head索引,低32位是tail索引:\nconst dequeueBits = 32 func (d *poolDequeue) unpack(ptrs uint64) (head, tail uint32) { const mask = 1\u0026lt;\u0026lt;dequeueBits - 1 head = uint32((ptrs \u0026gt;\u0026gt; dequeueBits) \u0026amp; mask) tail = uint32(ptrs \u0026amp; mask) return } func (d *poolDequeue) pack(head, tail uint32) uint64 { const mask = 1\u0026lt;\u0026lt;dequeueBits - 1 return (uint64(head) \u0026lt;\u0026lt; dequeueBits) | uint64(tail\u0026amp;mask) } head索引指向的是环形队列中下一个需要填充的槽位,即新入队元素将会写入的位置,tail索引指向的是环形队列中最早入队元素位置。环形队列中元素位置范围是[tail, head)。\n我们知道环形队列中,为了解决head == tail即可能是队列为空,也可能是队列空间全部占满的二义性,有两种解决办法:1. 空余单元法, 2. 记录队列元素个数法。\n采用空余单元法时,队列中永远有一个元素空间不使用,即队列中元素个数最多有QueueSize -1个。此时队列为空和占满的判断条件如下:\nhead == tail // 队列为空 (head + 1)%QueueSize == tail // 队列已满 而poolDequeue采用的是记录队列中元素个数法,相比空余单元法好处就是不会浪费一个队列元素空间。后面章节讲到的有缓存通道使用到的环形队列也是采用的这种方案。这种方案队列为空和占满的判断条件如下:\nhead == tail // 队列为空 tail + nums_of_elment_in_queue == head 删除操作 # 删除操作即出队操作。\nfunc (d *poolDequeue) popHead() (interface{}, bool) { var slot *eface for { ptrs := atomic.LoadUint64(\u0026amp;d.headTail) head, tail := d.unpack(ptrs) if tail == head { // 队列为空情况 return nil, false } head-- ptrs2 := d.pack(head, tail) // 先原子性更新head索引信息,更新成功,则取出队列最新的元素所在槽位地址 if atomic.CompareAndSwapUint64(\u0026amp;d.headTail, ptrs, ptrs2) { slot = \u0026amp;d.vals[head\u0026amp;uint32(len(d.vals)-1)] break } } val := *(*interface{})(unsafe.Pointer(slot)) // 取出槽位对应存储的值 if val == dequeueNil(nil) { val = nil } // 不同与popTail,popHead是没有竞态问题,所以可以直接将其复制为eface{} *slot = eface{} return val, true } func (d *poolDequeue) popTail() (interface{}, bool) { var slot *eface for { ptrs := atomic.LoadUint64(\u0026amp;d.headTail) head, tail := d.unpack(ptrs) if tail == head { // 队列为空情况 return nil, false } ptrs2 := d.pack(head, tail+1) // 先原子性更新tail索引信息,更新成功,则取出队列最后一个元素所在槽位地址 if atomic.CompareAndSwapUint64(\u0026amp;d.headTail, ptrs, ptrs2) { slot = \u0026amp;d.vals[tail\u0026amp;uint32(len(d.vals)-1)] break } } val := *(*interface{})(unsafe.Pointer(slot)) if val == dequeueNil(nil) { val = nil } /** 理解后面代码,我们需意识到*slot = eface{}或slot = *eface(nil)不是一个原子操作。 这是因为每个槽位存放2个8字节的unsafe.Pointer。而Go atomic包是不支持16字节原子操作,只能原子性操作solt中的其中一个字段。 后面代码中先将solt.val置为nil,然后原子操作solt.typ,那么pushHead操作时候,只需要判断solt.typ是否nil,既可以判断这个槽位完全被清空了(当solt.typ==nil时候,solt.val一定是nil)。 */ slot.val = nil atomic.StorePointer(\u0026amp;slot.typ, nil) return val, true } 插入操作 # 插入操作即入队操作。\nfunc (d *poolDequeue) pushHead(val interface{}) bool { ptrs := atomic.LoadUint64(\u0026amp;d.headTail) head, tail := d.unpack(ptrs) if (tail+uint32(len(d.vals)))\u0026amp;(1\u0026lt;\u0026lt;dequeueBits-1) == head { // 队列已写满情况 return false } slot := \u0026amp;d.vals[head\u0026amp;uint32(len(d.vals)-1)] typ := atomic.LoadPointer(\u0026amp;slot.typ) if typ != nil { // 说明有其他Goroutine正在pop此槽位,当pop完成之后会drop掉此槽位,队列还是保持写满状态 return false } if val == nil { val = dequeueNil(nil) } *(*interface{})(unsafe.Pointer(slot)) = val atomic.AddUint64(\u0026amp;d.headTail, 1\u0026lt;\u0026lt;dequeueBits) return true } pool回收 # 文章开头介绍sync.Pool时候,我们提到缓存池中的对象会在每2个GC循环中清除。我们现在看看这块逻辑:\nfunc poolCleanup() { for _, p := range oldPools { // 清空victim cache p.victim = nil p.victimSize = 0 } // 将primary cache(local pool)移动到victim cache for _, p := range allPools { p.victim = p.local p.victimSize = p.localSize p.local = nil p.localSize = 0 } oldPools, allPools = allPools, nil } func init() { runtime_registerPoolCleanup(poolCleanup) } sync.Pool通过在包初始化时候使用runtime_registerPoolCleanup注册GC的钩子poolCleanup来进行pool回收处理。runtime_registerPoolCleanup函数通过编译指令go:linkname链接到 runtime/mgc.go 文件中 sync_runtime_registerPoolCleanup 函数:\nvar poolcleanup func() //go:linkname sync_runtime_registerPoolCleanup sync.runtime_registerPoolCleanup func sync_runtime_registerPoolCleanup(f func()) { poolcleanup = f } func clearpools() { // clear sync.Pools if poolcleanup != nil { poolcleanup() } ... } // gc入口 func gcStart(trigger gcTrigger) { ... clearpools() ... } poolCleanup函数会在一次GC时候,会将local pool中缓存对象移动到victim cache中,然后在下一次GC时候,清空victim cache对象。\n进一步阅读 # Go: Understand the Design of Sync.Pool CPU缓存-受害者缓存 "},{"id":40,"href":"/feature/","title":"语言特性","section":"简介","content":" 语言特性 # 仰之弥高,钻之弥坚。\n逗号ok模式 遍历 - for-range语法 延迟执行 - defer语法 通道选择器 - select语法 恐慌与恢复 - panic/recover "},{"id":41,"href":"/concurrency/sync-rwmutex/","title":"读写锁 - sync.RWMutex","section":"并发编程","content":" 读写锁 - sync.RWMutex # RWMutex是Go语言中内置的一个reader/writer锁,用来解决读者-写者问题(Readers–writers problem)。在任意一时刻,一个RWMutex只能由任意数量的reader持有,或者只能由一个writer持有。\n读者-写者问题 # 读者-写者问题(Readers–writers problem)描述了计算机并发处理读写数据遇到的问题,如何保证数据完整性、一致性。解决读者-写者问题需保证对于一份资源操作满足以下下条件:\n读写互斥 写写互斥 允许多个读者同时读取 解决读者-写者问题,可以采用读者优先(readers-preference)方案或者写者优先(writers-preference)方案。\n读者优先(readers-preference):读者优先是读操作优先于写操作,即使写操作提出申请资源,但只要还有读者在读取操作,就还允许其他读者继续读取操作,直到所有读者结束读取,才开始写。读优先可以提供很高的并发处理性能,但是在频繁读取的系统中,会长时间写阻塞,导致写饥饿。\n写者优先(writers-preference):写者优先是写操作优先于读操作,如果有写者提出申请资源,在申请之前已经开始读取操作的可以继续执行读取,但是如果再有读者申请读取操作,则不能够读取,只有在所有的写者写完之后才可以读取。写者优先解决了读者优先造成写饥饿的问题。但是若在频繁写入的系统中,会长时间读阻塞,导致读饥饿。\nRWMutex设计采用写者优先方法,保证写操作优先处理。\n源码分析 # 下面分析的源码进行精简处理,去掉了race检查功能的代码。\nRWMutex的定义 # type RWMutex struct { w Mutex // 互斥锁 writerSem uint32 // writers信号量 readerSem uint32 // readers信号量 readerCount int32 // reader数量 readerWait int32 // writer申请锁时候,已经申请到锁的reader的数量 } const rwmutexMaxReaders = 1 \u0026lt;\u0026lt; 30 // 最大reader数,用于反转readerCount RLock/RUnlock的实现 # func (rw *RWMutex) RLock() { if atomic.AddInt32(\u0026amp;rw.readerCount, 1) \u0026lt; 0 { // 如果rw.readerCount为负数,说明此时已有一个writer持有锁或者正在申请锁。 runtime_SemacquireMutex(\u0026amp;rw.readerSem, false, 0) // 此时reader休眠阻塞在readerSem信号上,等待唤醒 } } func (rw *RWMutex) RUnlock() { if r := atomic.AddInt32(\u0026amp;rw.readerCount, -1); r \u0026lt; 0 { // r小于0说明此时有等待请求锁的writer rw.rUnlockSlow(r) } } func (rw *RWMutex) rUnlockSlow(r int32) { if r+1 == 0 || r+1 == -rwmutexMaxReaders { // RLock之前已经进行了RUnlock操作 throw(\u0026#34;sync: RUnlock of unlocked RWMutex\u0026#34;) } if atomic.AddInt32(\u0026amp;rw.readerWait, -1) == 0 { // 此时是最后一个获取到锁的reader进行RUnlock操作,那么释放writerSem信号,唤醒等待的writer来获取锁。 runtime_Semrelease(\u0026amp;rw.writerSem, false, 1) } } Lock/Unlock的实现 # func (rw *RWMutex) Lock() { rw.w.Lock() // 加互斥锁,阻塞其他writer进行Lock操作,保证写-写互斥。 // 将rw.readerCount 更改为rw.readerCount - rwmutexMaxReaders, // 此时rw.readerCount由一个正数转变成一个负数,这种方式既能保持记录reader数量,又能表明有writer正在请求锁 r := atomic.AddInt32(\u0026amp;rw.readerCount, -rwmutexMaxReaders) + rwmutexMaxReaders if r != 0 \u0026amp;\u0026amp; atomic.AddInt32(\u0026amp;rw.readerWait, r) != 0 { // r!=0表明此时有reader持有锁,则当前writer只能阻塞等待,但为了保证写优先,需要readerWait记录当前已获取到锁的读者数量 runtime_SemacquireMutex(\u0026amp;rw.writerSem, false, 0) } } func (rw *RWMutex) Unlock() { r := atomic.AddInt32(\u0026amp;rw.readerCount, rwmutexMaxReaders) if r \u0026gt;= rwmutexMaxReaders { // Lock之前先进行了Unlock操作 throw(\u0026#34;sync: Unlock of unlocked RWMutex\u0026#34;) } for i := 0; i \u0026lt; int(r); i++ { // 释放信号,唤醒阻塞的reader们 runtime_Semrelease(\u0026amp;rw.readerSem, false, 0) } rw.w.Unlock() // 是否互锁锁,允许其他writer进行获取锁操作了 } 对于读者优先(readers-preference)的读写锁,只需要一个readerCount记录所有读者,就可以轻易实现。Go中的RWMutex实现的是写者优先(writers-preference)的读写锁,那就需要用到readerWait来记录写者申请锁时候,已经获取到锁的读者数量。\n这样当后续有其他读者继续申请锁时候,可以读取readerWait是否大于0,大于0则说明有写者已经申请锁了,按照写者优先(writers-preference)原则,该读者需要排到写者之后,但是我们还需要记录这些排在写者后面读者的数量呀,毕竟写着将来释放锁的时候,还得唤醒一个个这些读者。这种情况下既要读取readerWait,又要更新排队的读者数量,这是两个操作,无法原子化。RWMutex在实现时候,通过将readerCount转换成负数,一方面表明有写者申请了锁,另一方面readerCount还可以继续记录排队的读者数量,解决刚描述的无法原子化的问题,真是巧妙!\n对于读者优先(readers-preference)的读写锁,我们可以借助Mutex实现。示例代码如下:\ntype rwlock struct { reader_cnt int reader_lock sync.Mutex writer_lock sync.Mutex } func NewRWLock() *rwlock { return \u0026amp;rwlock{} } func (l *rwlock) RLock() { l.reader_lock.Lock() defer l.reader_lock.Unlock() l.reader_cnt++ if l.reader_cnt == 1 { // first reader l.writer_lock.Lock() } } func (l *rwlock) RUnlock() { l.reader_lock.Lock() defer l.reader_lock.Unlock() l.reader_cnt-- if l.reader_cnt == 0 { // latest reader l.writer_lock.Unlock() } } func (l *rwlock) Lock() { l.writer_lock.Lock() } func (l *rwlock) Unlock() { l.writer_lock.Unlock() } 上面示例代码中,尽管读者操作的实现上用到互斥锁,但由于它是用完立马就是释放掉,性能不会差太多。\n三大错误使用场景 # RLock/RUnlock、Lock/Unlock未成对出现 # 同互斥锁一样,sync.RWMutex的RLock/RUnlock,以及Lock/Unlock总是成对出现的。Lock或RLock多余调用会导致锁没有释放,可能出现死锁,Unlock或RUnlock多余的调用会大导致panic.\nfunc main() { var l sync.RWMutex l.Lock() l.Unlock() l.Unlock() // fatal error: sync: Unlock of unlocked RWMutex } 对于Lock/Unlock未成对出现所有可能情况如下:\n如果只有Lock情况\n如果有一个 goroutine 只执行 Lock 操作而不执行 Unlock 操作,那么其他的 goroutine 就会一直被阻塞(拿不到锁),随着越来越多的阻塞的 goroutine 越来越多,整个系统最终会崩溃。\n如果只有Unlock情况\n如果其他 goroutine 持有锁,锁将被释放。 如果锁处于空闲状态(unoccupied state),它会panic。 复制sync.RWMutex作为函数值传递 # 同Mutex一样,RWMutex也是不能复制使用的,考虑下面场景代码:\nfunc main() { var l sync.RWMutex l.Lock() foo(l) l.Lock() l.Unlock() } func foo(l sync.RWMutex) { l.Unlock() } 上面场景代码中本意先使用l.Lock()进行上锁操作,然后调用foo(l)释放该锁,最后再次上锁和释放锁。但这种操作是错误的,会导致死锁。foo()函数接收的参数是变量l的一个副本,该副本把之前l变量的锁状态(锁状态指的是writerSem,readerCount等字段信息)也复制了一遍,此时副本的锁状态是上锁状态的,所以foo函数中是可以进行释放锁操作的,但释放的并不是最开始的那个锁。\n我们可以使用go vet命令检测复制锁情况:\nvagrant@vagrant:~$ go vet main.go # command-line-arguments ./main.go:8:6: call of foo copies lock value: sync.RWMutex ./main.go:13:12: foo passes lock by value: sync.RWMutex 解决上面问题可以使用指针传递:\nfunc foo(l *sync.RWMutex) { l.Unlock() } 不可重入导致死锁 # 可重入锁(ReentrantLock)指的一个线程中可以多次获取同一把锁,换到Go语言场景就是一个Goroutine中,Mutex和RWMutex可以连续Lock操作,而不会导致死锁。同互斥体Mutex一样,RWMutex也是不可重入锁,不支持重入。\nfunc main() { var l sync.RWMutex l.Lock() foo(\u0026amp;l) // foo中尝试重入锁,会导致死锁 l.Unlock() } func foo(l *sync.RWMutex) { l.Lock() l.Unlock() } 下面是读锁和写锁重入时候导致的死锁:\nfunc main() { var l sync.RWMutex l.RLock() foo(\u0026amp;l) l.RUnlock() } func foo(l *sync.RWMutex) { l.Lock() l.Unlock() } 上面代码中写锁重入时候,需要读锁先释放,而读锁释放又依赖写锁,这样就形成了死循环,导致死锁。\n进一步阅读 # Readers–writers problem sync.RWMutex: Solving readers-writers problems Use sync.Mutex, sync.RWMutex to lock share data for race condition "},{"id":42,"href":"/gmp/scheduler/","title":"调度器","section":"G-M-P调度机制","content":" 调度器 # "},{"id":43,"href":"/gmp/gmp-model/","title":"调度机制概述","section":"G-M-P调度机制","content":" GMP模型 # Golang的一大特色就是Goroutine。Goroutine是Golang支持高并发的重要保障。Golang可以创建成千上万个Goroutine来处理任务,将这些Goroutine分配、负载、调度到处理器上采用的是G-M-P模型。\n什么是Goroutine # Goroutine = Golang + Coroutine。Goroutine是golang实现的协程,是用户级线程。Goroutine具有以下特点:\n相比线程,其启动的代价很小,以很小栈空间启动(2Kb左右) 能够动态地伸缩栈的大小,最大可以支持到Gb级别 工作在用户态,切换成很小 与线程关系是n:m,即可以在n个系统线程上多工调度m个Goroutine 进程、线程、Goroutine # 在仅支持进程的操作系统中,进程是拥有资源和独立调度的基本单位。在引入线程的操作系统中,线程是独立调度的基本单位,进程是资源拥有的基本单位。在同一进程中,线程的切换不会引起进程切换。在不同进程中进行线程切换,如从一个进程内的线程切换到另一个进程中的线程时,会引起进程切换\n线程创建、管理、调度等采用的方式称为线程模型。线程模型一般分为以下三种:\n内核级线程(Kernel Level Thread)模型 用户级线程(User Level Thread)模型 两级线程模型,也称混合型线程模型 三大线程模型最大差异就在于用户级线程与内核调度实体KSE(KSE,Kernel Scheduling Entity)之间的对应关系。KSE是Kernel Scheduling Entity的缩写,其是可被操作系统内核调度器调度的对象实体,是操作系统内核的最小调度单元,可以简单理解为内核级线程。\n用户级线程即协程,由应用程序创建与管理,协程必须与内核级线程绑定之后才能执行。线程由 CPU 调度是抢占式的,协程由用户态调度是协作式的,一个协程让出 CPU 后,才执行下一个协程。\n用户级线程(ULT)与内核级线程(KLT)比较:\n特性 用户级线程 内核级线程 创建者 应用程序 内核 操作系统是否感知存在 否 是 开销成本 创建成本低,上下文切换成本低,上下文切换不需要硬件支持 创建成本高,上下文切换成本高,上下文切换需要硬件支持 如果线程阻塞 整个进程将被阻塞。即不能利用多处理来发挥并发优势 其他线程可以继续执行,进程不会阻塞 案例 Java thread, POSIX threads Window Solaris 内核级线程模型 # 内核级线程模型中用户线程与内核线程是一对一关系(1 : 1)。线程的创建、销毁、切换工作都是有内核完成的。应用程序不参与线程的管理工作,只能调用内核级线程编程接口(应用程序创建一个新线程或撤销一个已有线程时,都会进行一个系统调用)。每个用户线程都会被绑定到一个内核线程。用户线程在其生命期内都会绑定到该内核线程。一旦用户线程终止,两个线程都将离开系统。\n操作系统调度器管理、调度并分派这些线程。运行时库为每个用户级线程请求一个内核级线程。操作系统的内存管理和调度子系统必须要考虑到数量巨大的用户级线程。操作系统为每个线程创建上下文。进程的每个线程在资源可用时都可以被指派到处理器内核。\n内核级线程模型有如下优点:\n在多处理器系统中,内核能够并行执行同一进程内的多个线程 如果进程中的一个线程被阻塞,不会阻塞其他线程,是能够切换同一进程内的其他线程继续执行 当一个线程阻塞时,内核根据选择可以运行另一个进程的线程,而用户空间实现的线程中,运行时系统始终运行自己进程中的线程 缺点:\n线程的创建与删除都需要CPU参与,成本大 用户级线程模型 # 用户线程模型中的用户线程与内核线程KSE是多对一关系(N : 1)。线程的创建、销毁以及线程之间的协调、同步等工作都是在用户态完成,具体来说就是由应用程序的线程库来完成。内核对这些是无感知的,内核此时的调度都是基于进程的。线程的并发处理从宏观来看,任意时刻每个进程只能够有一个线程在运行,且只有一个处理器内核会被分配给该进程。\n从上图中可以看出来:库调度器从进程的多个线程中选择一个线程,然后该线程和该进程允许的一个内核线程关联起来。内核线程将被操作系统调度器指派到处理器内核。用户级线程是一种”多对一”的线程映射\n用户级线程有如下优点:\n创建和销毁线程、线程切换代价等线程管理的代价比内核线程少得多, 因为保存线程状态的过程和调用程序都只是本地过程 线程能够利用的表空间和堆栈空间比内核级线程多 缺点:\n线程发生I/O或页面故障引起的阻塞时,如果调用阻塞系统调用则内核由于不知道有多线程的存在,而会阻塞整个进程从而阻塞所有线程, 因此同一进程中只能同时有一个线程在运行 资源调度按照进程进行,多个处理机下,同一个进程中的线程只能在同一个处理机下分时复用 两级线程模型 # 两级线程模型中用户线程与内核线程是一对一关系(N : M)。两级线程模型充分吸收上面两种模型的优点,尽量规避缺点。其线程创建在用户空间中完成,线程的调度和同步也在应用程序中进行。一个应用程序中的多个用户级线程被绑定到一些(小于或等于用户级线程的数目)内核级线程上。\nGolang的线程模型 # Golang在底层实现了混合型线程模型。M即系统线程,由系统调用产生,一个M关联一个KSE,即两级线程模型中的系统线程。G为Groutine,即两级线程模型的的应用及线程。M与G的关系是N:M。\nG-M-P模型概览 # G-M-P分别代表:\nG - Goroutine,Go协程,是参与调度与执行的最小单位 M - Machine,指的是系统级线程 P - Processor,指的是逻辑处理器,P关联了的本地可运行G的队列(也称为LRQ),最多可存放256个G。 GMP调度流程大致如下:\n线程M想运行任务就需得获取 P,即与P关联。 然从 P 的本地队列(LRQ)获取 G 若LRQ中没有可运行的G,M 会尝试从全局队列(GRQ)拿一批G放到P的本地队列, 若全局队列也未找到可运行的G时候,M会随机从其他 P 的本地队列偷一半放到自己 P 的本地队列。 拿到可运行的G之后,M 运行 G,G 执行之后,M 会从 P 获取下一个 G,不断重复下去。 调度的生命周期 # M0 是启动程序后的编号为 0 的主线程,这个 M 对应的实例会在全局变量 runtime.m0 中,不需要在 heap 上分配,M0 负责执行初始化操作和启动第一个 G, 在之后 M0 就和其他的 M 一样了 G0 是每次启动一个 M 都会第一个创建的 gourtine,G0 仅用于负责调度的 G,G0 不指向任何可执行的函数,每个 M 都会有一个自己的 G0。在调度或系统调用时会使用 G0 的栈空间,全局变量的 G0 是 M0 的 G0 上面生命周期流程说明:\nruntime 创建最初的线程 m0 和 goroutine g0,并把两者进行关联(g0.m = m0) 调度器初始化:设置M最大数量,P个数,栈和内存出事,以及创建 GOMAXPROCS个P 示例代码中的 main 函数是 main.main,runtime 中也有 1 个 main 函数 ——runtime.main,代码经过编译后,runtime.main 会调用 main.main,程序启动时会为 runtime.main 创建 goroutine,称它为 main goroutine 吧,然后把 main goroutine 加入到 P 的本地队列。 启动 m0,m0 已经绑定了 P,会从 P 的本地队列获取 G,获取到 main goroutine。 G 拥有栈,M 根据 G 中的栈信息和调度信息设置运行环境 M 运行 G G 退出,再次回到 M 获取可运行的 G,这样重复下去,直到 main.main 退出,runtime.main 执行 Defer 和 Panic 处理,或调用 runtime.exit 退出程序。 G-M-P的数量 # G 的数量:\n理论上没有数量上限限制的。查看当前G的数量可以使用runtime. NumGoroutine()\nP 的数量:\n由启动时环境变量 $GOMAXPROCS 或者是由runtime.GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 $GOMAXPROCS 个 goroutine 在同时运行。\nM 的数量:\ngo 语言本身的限制:go 程序启动时,会设置 M 的最大数量,默认 10000. 但是内核很难支持这么多的线程数,所以这个限制可以忽略。 runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量 一个 M 阻塞了,会创建新的 M。M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。\n调度的流程状态 # 从上图我们可以看出来:\n每个P有个局部队列,局部队列保存待执行的goroutine(流程2),当M绑定的P的的局部队列已经满了之后就会把goroutine放到全局队列(流程2-1) 每个P和一个M绑定,M是真正的执行P中goroutine的实体(流程3),M从绑定的P中的局部队列获取G来执行 当M绑定的P的局部队列为空时,M会从全局队列获取到本地队列来执行G(流程3.1),当从全局队列中没有获取到可执行的G时候,M会从其他P的局部队列中偷取G来执行(流程3.2),这种从其他P偷的方式称为work stealing 当G因系统调用(syscall)阻塞时会阻塞M,此时P会和M解绑即hand off,并寻找新的idle的M,若没有idle的M就会新建一个M(流程5.1)。 当G因channel或者network I/O阻塞时,不会阻塞M,M会寻找其他runnable的G;当阻塞的G恢复后会重新进入runnable进入P队列等待执行(流程5.3) 调度过程中阻塞 # GMP模型的阻塞可能发生在下面几种情况:\nI/O,select block on syscall channel 等待锁 runtime.Gosched() 用户态阻塞 # 当goroutine因为channel操作或者network I/O而阻塞时(实际上golang已经用netpoller实现了goroutine网络I/O阻塞不会导致M被阻塞,仅阻塞G),对应的G会被放置到某个wait队列(如channel的waitq),该G的状态由_Gruning变为_Gwaitting,而M会跳过该G尝试获取并执行下一个G,如果此时没有runnable的G供M运行,那么M将解绑P,并进入sleep状态;当阻塞的G被另一端的G2唤醒时(比如channel的可读/写通知),G被标记为runnable,尝试加入G2所在P的runnext,然后再是P的Local队列和Global队列。\n系统调用阻塞 # 当G被阻塞在某个系统调用上时,此时G会阻塞在_Gsyscall状态,M也处于 block on syscall 状态,此时的M可被抢占调度:执行该G的M会与P解绑,而P则尝试与其它idle的M绑定,继续执行其它G。如果没有其它idle的M,但P的Local队列中仍然有G需要执行,则创建一个新的M;当系统调用完成后,G会重新尝试获取一个idle的P进入它的Local队列恢复执行,如果没有idle的P,G会被标记为runnable加入到Global队列。\nG-M-P内部结构 # G的内部结构 # G的内部结构中重要字段如下,完全结构参见 源码\ntype g struct { stack stack // g自己的栈 m *m // 隶属于哪个M sched gobuf // 保存了g的现场,goroutine切换时通过它来恢复 atomicstatus uint32 // G的运行状态 goid int64 schedlink guintptr // 下一个g, g链表 preempt bool //抢占标记 lockedm muintptr // 锁定的M,g中断恢复指定M执行 gopc uintptr // 创建该goroutine的指令地址 startpc uintptr // goroutine 函数的指令地址 } G的状态有以下9种,可以参见 代码:\n状态 值 含义 _Gidle 0 刚刚被分配,还没有进行初始化。 _Grunnable 1 已经在运行队列中,还没有执行用户代码。 _Grunning 2 不在运行队列里中,已经可以执行用户代码,此时已经分配了 M 和 P。 _Gsyscall 3 正在执行系统调用,此时分配了 M。 _Gwaiting 4 在运行时被阻止,没有执行用户代码,也不在运行队列中,此时它正在某处阻塞等待中。Groutine wait的原因有哪些参加 代码 _Gmoribund_unused 5 尚未使用,但是在 gdb 中进行了硬编码。 _Gdead 6 尚未使用,这个状态可能是刚退出或是刚被初始化,此时它并没有执行用户代码,有可能有也有可能没有分配堆栈。 _Genqueue_unused 7 尚未使用。 _Gcopystack 8 正在复制堆栈,并没有执行用户代码,也不在运行队列中。 M的结构 # M的内部结构,完整结构参见 源码\ntype m struct { g0 *g // g0, 每个M都有自己独有的g0 curg *g // 当前正在运行的g p puintptr // 隶属于哪个P nextp puintptr // 当m被唤醒时,首先拥有这个p id int64 spinning bool // 是否处于自旋 park note alllink *m // on allm schedlink muintptr // 下一个m, m链表 mcache *mcache // 内存分配 lockedg guintptr // 和 G 的lockedm对应 freelink *m // on sched.freem } P的内部结构 # P的内部结构,完全结构参见 源码\ntype p struct { id int32 status uint32 // P的状态 link puintptr // 下一个P, P链表 m muintptr // 拥有这个P的M mcache *mcache // P本地runnable状态的G队列,无锁访问 runqhead uint32 runqtail uint32 runq [256]guintptr runnext guintptr // 一个比runq优先级更高的runnable G // 状态为dead的G链表,在获取G时会从这里面获取 gFree struct { gList n int32 } gcBgMarkWorker guintptr // (atomic) gcw gcWork } P有以下几种状态,参加 源码\n状态 值 含义 _Pidle 0 刚刚被分配,还没有进行进行初始化。 _Prunning 1 当 M 与 P 绑定调用 acquirep 时,P 的状态会改变为 _Prunning。 _Psyscall 2 正在执行系统调用。 _Pgcstop 3 暂停运行,此时系统正在进行 GC,直至 GC 结束后才会转变到下一个状态阶段。 _Pdead 4 废弃,不再使用。 调度器的内部结构 # 调度器内部结构,完全结构参见 源码\ntype schedt struct { lock mutex midle muintptr // 空闲M链表 nmidle int32 // 空闲M数量 nmidlelocked int32 // 被锁住的M的数量 mnext int64 // 已创建M的数量,以及下一个M ID maxmcount int32 // 允许创建最大的M数量 nmsys int32 // 不计入死锁的M数量 nmfreed int64 // 累计释放M的数量 pidle puintptr // 空闲的P链表 npidle uint32 // 空闲的P数量 runq gQueue // 全局runnable的G队列 runqsize int32 // 全局runnable的G数量 // Global cache of dead G\u0026#39;s. gFree struct { lock mutex stack gList // Gs with stacks noStack gList // Gs without stacks n int32 } // freem is the list of m\u0026#39;s waiting to be freed when their // m.exited is set. Linked through m.freelink. freem *m } 观察调度流程 # GODEBUG trace方式 # GODEBUG 变量可以控制运行时内的调试变量,参数以逗号分隔,格式为:name=val。观察GMP可以使用下面两个参数:\nschedtrace:设置 schedtrace=X 参数可以使运行时在每 X 毫秒输出一行调度器的摘要信息到标准 err 输出中。\nscheddetail:设置 schedtrace=X 和 scheddetail=1 可以使运行时在每 X 毫秒输出一次详细的多行信息,信息内容主要包括调度程序、处理器、OS 线程 和 Goroutine 的状态。\npackage main import ( \u0026#34;sync\u0026#34; \u0026#34;time\u0026#34; ) func main() { var wg sync.WaitGroup for i := 0; i \u0026lt; 2000; i++ { wg.Add(1) go func() { a := 0 for i := 0; i \u0026lt; 1e6; i++ { a += 1 } wg.Done() }() time.Sleep(100 * time.Millisecond) } wg.Wait() } 执行一下命令:\nGODEBUG=schedtrace=1000 go run ./test.go\n输出内容如下:\nSCHED 0ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=1 runqueue=0 [0] SCHED 1001ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0] SCHED 2002ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0] SCHED 3002ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0] SCHED 4003ms: gomaxprocs=1 idleprocs=1 threads=4 spinningthreads=0 idlethreads=2 runqueue=0 [0] 输出内容解释说明:\nSCHED XXms: SCHED是调度日志输出标志符。XXms是自程序启动之后到输出当前行时间 gomaxprocs: P的数量,等于当前的 CPU 核心数,或者GOMAXPROCS环境变量的值 idleprocs: 空闲P的数量,与gomaxprocs的差值即运行中P的数量 threads: 线程数量,即M的数量 spinningthreads:自旋状态线程的数量。当M没有找到可供其调度执行的 Goroutine 时,该线程并不会销毁,而是出于自旋状态 idlethreads:空闲线程的数量 runqueue:全局队列中G的数量 [0]:表示P本地队列下G的数量,有几个P中括号里面就会有几个数字 Go tool trace方式 # func main() { // 创建trace文件 f, err := os.Create(\u0026#34;trace.out\u0026#34;) if err != nil { panic(err) } defer f.Close() // 启动trace goroutine err = trace.Start(f) if err != nil { panic(err) } defer trace.Stop() // main fmt.Println(\u0026#34;Hello trace\u0026#34;) } 执行下面命令产生trace文件trace.out:\ngo run test.go\n执行下面命令,打开浏览器,打开控制台查看。\ngo tool trace trace.out\n总结 # Golang的线程模型采用的是混合型线程模型,线程与协程关系是N:M。 Golang混合型线程模型实现采用GMP模型进行调度,G是goroutine,是golang实现的协程,M是OS线程,P是逻辑处理器。 每一个M都需要与一个P绑定,P拥有本地可运行G队列,M是执行G的单元,M获取可运行G流程是先从P的本地队列获取,若未获取到,则从其他P偷取过来(即work steal),若其他的P也没有则从全局G队列获取,若都未获取到,则M将处于自旋状态,并不会销毁。 当执行G时候,发生通道阻塞等用户级别阻塞时候,此时M不会阻塞,M会继续寻找其他可运行的G,当阻塞的G恢复之后,重新进入P的队列等待执行,若G进行系统调用时候,会阻塞M,此时P会和M解绑(即hand off),并寻找新的空闲的M。若没有空闲的就会创建一个新的M。 Work Steal和Hand Off保证了线程的高效利用。 G-M-P高效的保证策略有:\nM是可以复用的,不需要反复创建与销毁,当没有可执行的Goroutine时候就处于自旋状态,等待唤醒 Work Stealing和Hand Off策略保证了M的高效利用 内存分配状态(mcache)位于P,G可以跨M调度,不再存在跨M调度局部性差的问题 M从关联的P中获取G,不需要使用锁,是lock free的 参考资料 # Golang 调度器 GMP 原理与调度全分析 Go: Work-Stealing in Go Scheduler 线程的3种实现方式\u0026ndash;内核级线程, 用户级线程和混合型线程 "},{"id":44,"href":"/feature/comma-ok/","title":"逗号ok模式","section":"语言特性","content":" 逗号ok模式 # 通过逗号ok模式(comma ok idiom),我们可以进行类型断言,判断映射中是否存在某个key以及通道是否关闭。\n类型断言 # // 方式1 var ( v T ok bool ) v, ok = x.(T) // 方式2 v, ok := x.(T) // x是接口类型的变量,T是要断言的类型 // 方式3 var v, ok = x.(T) // 方式4 v := x.(T) // 当心此种方式断言,若断言失败会发生恐慌 判断key是否存在映射中 # // 方式1 v, ok := a[x] // 方式2 var v, ok = a[x] 判断通道是否关闭 # // 方式1 var ( x T ok bool ) x, ok = \u0026lt;-ch // 方式2 x, ok := \u0026lt;-ch // 方式3 var x, ok = \u0026lt;-ch "},{"id":45,"href":"/concurrency/channel/","title":"通道 - channel","section":"并发编程","content":" 通道 - channel # Golang中Channel是goroutine间重要通信的方式,是并发安全的,通道内的数据First In First Out,我们可以把通道想象成队列。\nchannel数据结构 # Channel底层数据结构是一个结构体。\ntype hchan struct { qcount uint // 队列中元素个数 dataqsiz uint // 循环队列的大小 buf unsafe.Pointer // 指向循环队列 elemsize uint16 // 通道里面的元素大小 closed uint32 // 通道关闭的标志 elemtype *_type // 通道元素的类型 sendx uint // 待发送的索引,即循环队列中的队尾指针rear recvx uint // 待读取的索引,即循环队列中的队头指针front recvq waitq // 接收等待队列 sendq waitq // 发送等待队列 lock mutex // 互斥锁 } hchan结构体中的buf指向一个数组,用来实现循环队列,sendx是循环队列的队尾指针,recvx是循环队列的队头指针。dataqsize是缓存型通道的大小,qcount记录着通道内数据个数。\n循环队列一般使用空余单元法来解决队空和队满时候都存在font=rear带来的二义性问题,但这样会浪费一个单元。golang的channel中是通过增加qcount字段记录队列长度来解决二义性,一方面不会浪费一个存储单元,另一方面当使用len函数查看通道长度时候,可以直接返回qcount字段,一举两得。\nhchan结构体中另一重要部分是recvq,sendq,分别存储了等待从通道中接收数据的goroutine,和等待发送数据到通道的goroutine。两者都是waitq类型。\nwaitq是一个结构体类型,waitq和sudog构成双向链表,其中sudog是链表元素的类型,waitq中first和last字段分别指向链表头部的sudog,链表尾部的sudog。\ntype waitq struct { first *sudog last *sudog } type sudog struct { ... g *g // 当前阻塞的G ... next *sudog prev *sudog elem unsafe.Pointer ... } hchan结构图如下:\nchannel的创建 # 在分析channel的创建代码之前,我们看下源码文件中最开始定义的两个常量;\nconst ( maxAlign = 8 hchanSize = unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))\u0026amp;(maxAlign-1)) ... ) maxAlgin用来设置内存最大对齐值,对应就是64位系统下cache line的大小。当结构体是8字节对齐时候,能够避免false share,提高读写速度 hchanSize用来设置chan大小,unsafe.Sizeof(hchan{}) + uintptr(-int(unsafe.Sizeof(hchan{}))\u0026amp;(maxAlign-1)),这个复杂公式用来计算离unsafe.Sizeof(hchan{})最近的8的倍数。假设hchan{}大小是13,hchanSize是16。 假设n代表unsafe.Sizeof(hchan{}),a代表maxAlign,c代表hchanSize,则上面hchanSize的计算公式可以抽象为:\nc = n + ((-n) \u0026amp; (a - 1))\n计算离8最近的倍数,只需将n补足与到8倍数的差值就可,c也可以用下面公式计算\nc = n + (a - n%a)\n感兴趣的可以证明在a为2的n的次幂时候,上面两个公式是相等的。\nfunc makechan(t *chantype, size int) *hchan { elem := t.elem // 通道元素的大小不能超过64K if elem.size \u0026gt;= 1\u0026lt;\u0026lt;16 { throw(\u0026#34;makechan: invalid channel element type\u0026#34;) } // hchanSize大小不是maxAlign倍数,或者通道数据元素的对齐保证大于maxAlign if hchanSize%maxAlign != 0 || elem.align \u0026gt; maxAlign { throw(\u0026#34;makechan: bad alignment\u0026#34;) } // 判断通道数据是否超过内存限制 mem, overflow := math.MulUintptr(elem.size, uintptr(size)) if overflow || mem \u0026gt; maxAlloc-hchanSize || size \u0026lt; 0 { panic(plainError(\u0026#34;makechan: size out of range\u0026#34;)) } var c *hchan switch { case mem == 0: // 无缓冲通道 c = (*hchan)(mallocgc(hchanSize, nil, true)) c.buf = c.raceaddr() case elem.ptrdata == 0: // 当通道数据元素不含指针,hchan和buf内存空间调用mallocgc一次性分配完成 c = (*hchan)(mallocgc(hchanSize+mem, nil, true)) // hchan和buf内存上布局是紧挨着的 c.buf = add(unsafe.Pointer(c), hchanSize) default: // 当通道数据元素含指针时候,先创建hchan,然后给buf分配内存空间 c = new(hchan) c.buf = mallocgc(mem, elem, true) } c.elemsize = uint16(elem.size) c.elemtype = elem c.dataqsiz = uint(size) ... return c } 发送数据到channel # func chansend(c *hchan, ep unsafe.Pointer, block bool, callerpc uintptr) bool { // 当通道为nil时候 if c == nil { // 非阻塞模式下,直接返回false if !block { return false } // 调用gopark将当前Goroutine休眠,调用gopark时候,将传入unlockf设置为nil,当前Goroutine会一直休眠 gopark(nil, nil, waitReasonChanSendNilChan, traceEvGoStop, 2) throw(\u0026#34;unreachable\u0026#34;) } // 调试,不必关注 if debugChan { print(\u0026#34;chansend: chan=\u0026#34;, c, \u0026#34;\\n\u0026#34;) } // 竞态检测,不必关注 if raceenabled { racereadpc(c.raceaddr(), callerpc, funcPC(chansend)) } // 非阻塞模式下,不使用锁快速检查send操作 if !block \u0026amp;\u0026amp; c.closed == 0 \u0026amp;\u0026amp; ((c.dataqsiz == 0 \u0026amp;\u0026amp; c.recvq.first == nil) || (c.dataqsiz \u0026gt; 0 \u0026amp;\u0026amp; c.qcount == c.dataqsiz)) { return false } var t0 int64 if blockprofilerate \u0026gt; 0 { t0 = cputicks() } // 加锁 lock(\u0026amp;c.lock) // 如果通道已关闭,再发送数据,发生恐慌 if c.closed != 0 { unlock(\u0026amp;c.lock) panic(plainError(\u0026#34;send on closed channel\u0026#34;)) } // 从接收者队列recvq中取出一个接收者,接收者不为空情况下,直接将数据传递给该接收者 if sg := c.recvq.dequeue(); sg != nil { send(c, sg, ep, func() { unlock(\u0026amp;c.lock) }, 3) return true } // 缓冲队列中的元素个数小于队列的大小 // 说明缓冲队列还有空间 if c.qcount \u0026lt; c.dataqsiz { qp := chanbuf(c, c.sendx) // qp指向循环数组中未使用的位置 if raceenabled { raceacquire(qp) racerelease(qp) } // 将发送的数据写入到qp指向的循环数组中的位置 typedmemmove(c.elemtype, qp, ep) c.sendx++ // 将send加一,相当于循环队列的front指针向前进1 if c.sendx == c.dataqsiz { //当循环队列最后一个元素已使用,此时循环队列将再次从0开始 c.sendx = 0 } c.qcount++ // 队列中元素计数加1 unlock(\u0026amp;c.lock) // 释放锁 return true } if !block { unlock(\u0026amp;c.lock) return false } gp := getg() // 获取当前的G mysg := acquireSudog() // 返回一个sudog mysg.releasetime = 0 if t0 != 0 { mysg.releasetime = -1 } mysg.elem = ep // 发送的数据 mysg.waitlink = nil mysg.g = gp // 当前G,即发送者 mysg.isSelect = false mysg.c = c gp.waiting = mysg gp.param = nil c.sendq.enqueue(mysg) // 将当前发送者入队sendq中 goparkunlock(\u0026amp;c.lock, waitReasonChanSend, traceEvGoBlockSend, 3) // 将当前goroutine放入waiting状态,并释放c.lock锁 // Ensure the value being sent is kept alive until the // receiver copies it out. The sudog has a pointer to the // stack object, but sudogs aren\u0026#39;t considered as roots of the // stack tracer KeepAlive(ep) // someone woke us up. if mysg != gp.waiting { throw(\u0026#34;G waiting list is corrupted\u0026#34;) } gp.waiting = nil if gp.param == nil { if c.closed == 0 { throw(\u0026#34;chansend: spurious wakeup\u0026#34;) } panic(plainError(\u0026#34;send on closed channel\u0026#34;)) } gp.param = nil if mysg.releasetime \u0026gt; 0 { blockevent(mysg.releasetime-t0, 2) } mysg.c = nil releaseSudog(mysg) return true } func send(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { if raceenabled { if c.dataqsiz == 0 { // 无缓冲通道 racesync(c, sg) } else { qp := chanbuf(c, c.recvx) raceacquire(qp) racerelease(qp) raceacquireg(sg.g, qp) racereleaseg(sg.g, qp) c.recvx++ // 相当于循环队列的rear指针向前进1 if c.recvx == c.dataqsiz { // 队列数组中最后一个元素已读取,则再次从头开始读取 c.recvx = 0 } c.sendx = c.recvx } } if sg.elem != nil { // 复制数据到sg中 sendDirect(c.elemtype, sg, ep) sg.elem = nil } gp := sg.g unlockf() gp.param = unsafe.Pointer(sg) if sg.releasetime != 0 { sg.releasetime = cputicks() } goready(gp, skip+1) // 使goroutine变成runnable状态,唤醒goroutine } func sendDirect(t *_type, sg *sudog, src unsafe.Pointer) { dst := sg.elem typeBitsBulkBarrier(t, uintptr(dst), uintptr(src), t.size) memmove(dst, src, t.size) } // 返回缓存槽i位置的对应的指针 func chanbuf(c *hchan, i uint) unsafe.Pointer { return add(c.buf, uintptr(i)*uintptr(c.elemsize)) } // 将src值复制到dst // 源码https://github.com/golang/go/blob/2bc8d90fa21e9547aeb0f0ae775107dc8e05dc0a/src/runtime/mbarrier.go#L156 func typedmemmove(typ *_type, dst, src unsafe.Pointer) { if dst == src { return } ... memmove(dst, src, typ.size) ... } 从channel中读取数据 # func chanrecv(c *hchan, ep unsafe.Pointer, block bool) (selected, received bool) { // 当通道为nil时候 if c == nil { if !block { // 当非阻塞模式直接返回 return } // 一直阻塞 gopark(nil, nil, waitReasonChanReceiveNilChan, traceEvGoStop, 2) throw(\u0026#34;unreachable\u0026#34;) } ... // 加锁锁 lock(\u0026amp;c.lock) // 当通道已关闭,且通道缓冲没有元素时候,直接返回 if c.closed != 0 \u0026amp;\u0026amp; c.qcount == 0 { if raceenabled { raceacquire(c.raceaddr()) } unlock(\u0026amp;c.lock) // 释放锁 if ep != nil { typedmemclr(c.elemtype, ep) // 清空ep指向的内存 } return true, false } // 从发送者队列中取出一个发送者,发送者不为空时候,将发送者数据传递给接收者 if sg := c.sendq.dequeue(); sg != nil { recv(c, sg, ep, func() { unlock(\u0026amp;c.lock) }, 3) return true, true } // 缓冲队列中有数据情况下,从缓存队列取出数据,传递给接收者 if c.qcount \u0026gt; 0 { // qp指向循环队列数组中元素 qp := chanbuf(c, c.recvx) if raceenabled { raceacquire(qp) racerelease(qp) } if ep != nil { // 直接qp指向的数据复制到ep指向的地址 typedmemmove(c.elemtype, ep, qp) } // 清空qp指向内存的数据 typedmemclr(c.elemtype, qp) c.recvx++ // 相当于循环队列中的rear加1 if c.recvx == c.dataqsiz { // 队列最后一个元素已读取出来,recvx指向0 c.recvx = 0 } c.qcount-- // 队列中元素个数减1 unlock(\u0026amp;c.lock) // 释放锁 return true, true } if !block { unlock(\u0026amp;c.lock) return false, false } gp := getg() mysg := acquireSudog() mysg.releasetime = 0 if t0 != 0 { mysg.releasetime = -1 } mysg.elem = ep mysg.waitlink = nil gp.waiting = mysg mysg.g = gp mysg.isSelect = false mysg.c = c gp.param = nil c.recvq.enqueue(mysg) goparkunlock(\u0026amp;c.lock, waitReasonChanReceive, traceEvGoBlockRecv, 3) if mysg != gp.waiting { throw(\u0026#34;G waiting list is corrupted\u0026#34;) } gp.waiting = nil if mysg.releasetime \u0026gt; 0 { blockevent(mysg.releasetime-t0, 2) } closed := gp.param == nil gp.param = nil mysg.c = nil releaseSudog(mysg) return true, !closed } func recv(c *hchan, sg *sudog, ep unsafe.Pointer, unlockf func(), skip int) { if c.dataqsiz == 0 { if raceenabled { racesync(c, sg) } if ep != nil { recvDirect(c.elemtype, sg, ep) } } else { qp := chanbuf(c, c.recvx) if raceenabled { raceacquire(qp) racerelease(qp) raceacquireg(sg.g, qp) racereleaseg(sg.g, qp) } // 复制队列中数据到接收者 if ep != nil { typedmemmove(c.elemtype, ep, qp) } typedmemmove(c.elemtype, qp, sg.elem) c.recvx++ if c.recvx == c.dataqsiz { c.recvx = 0 } c.sendx = c.recvx } sg.elem = nil gp := sg.g unlockf() gp.param = unsafe.Pointer(sg) if sg.releasetime != 0 { sg.releasetime = cputicks() } goready(gp, skip+1) // 唤醒G } 关闭channel # func closechan(c *hchan) { // 当关闭的通道是nil时候,直接恐慌 if c == nil { panic(plainError(\u0026#34;close of nil channel\u0026#34;)) } // 加锁 lock(\u0026amp;c.lock) // 通道已关闭,再次关闭直接恐慌 if c.closed != 0 { unlock(\u0026amp;c.lock) panic(plainError(\u0026#34;close of closed channel\u0026#34;)) } ... c.closed = 1 // 关闭标志closed置为1 var glist gList // 将接收者添加到glist中 for { sg := c.recvq.dequeue() if sg == nil { break } if sg.elem != nil { typedmemclr(c.elemtype, sg.elem) sg.elem = nil } if sg.releasetime != 0 { sg.releasetime = cputicks() } gp := sg.g gp.param = nil if raceenabled { raceacquireg(gp, c.raceaddr()) } glist.push(gp) } // 将发送者添加到glist中 for { sg := c.sendq.dequeue() if sg == nil { break } sg.elem = nil if sg.releasetime != 0 { sg.releasetime = cputicks() } gp := sg.g gp.param = nil if raceenabled { raceacquireg(gp, c.raceaddr()) } glist.push(gp) // } unlock(\u0026amp;c.lock) // 循环glist,调用goready唤醒所有接收者和发送者 for !glist.empty() { gp := glist.pop() gp.schedlink = 0 goready(gp, 3) } } 总结 # channel规则: 操作 空Channel 已关闭Channel 活跃Channel close(ch) panic panic 成功关闭 ch \u0026lt;-v 永远阻塞 panic 成功发送或阻塞 v,ok = \u0026lt;-ch 永远阻塞 不阻塞 成功接收或阻塞 注意: 从空通道中写入或读取数据会永远阻塞,这会造成goroutine泄漏。\n发送、接收数据以及关闭通道流程图: "},{"id":46,"href":"/feature/select/","title":"通道选择器-select","section":"语言特性","content":" 通道选择器-select # Go 语言中select关键字结构跟switch结构类似,但是select结构的case语句都是跟通道操作相关的。Go 语言会从select结构中已经可读取或可以写入通道对应的case语句中随机选择一个执行,如果所有case语句中的通道都不能可读取或可写入且存在default语句的话,那么会执行default语句。\n根据Go 官方语法指南指出select语句执行分为以下几个步骤:\nFor all the cases in the statement, the channel operands of receive operations and the channel and right-hand-side expressions of send statements are evaluated exactly once, in source order, upon entering the \u0026ldquo;select\u0026rdquo; statement. The result is a set of channels to receive from or send to, and the corresponding values to send. Any side effects in that evaluation will occur irrespective of which (if any) communication operation is selected to proceed. Expressions on the left-hand side of a RecvStmt with a short variable declaration or assignment are not yet evaluated.\n对于case分支语句中写入通道的右侧表达式都会先执行,执行顺序是按照代码中case分支顺序,由上到下执行。case分支语句中读取通道的左右表达式不会先执行的。\nIf one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection. Otherwise, if there is a default case, that case is chosen. If there is no default case, the \u0026ldquo;select\u0026rdquo; statement blocks until at least one of the communications can proceed.\n如果有一个或者多个case分支的通道可以通信(读取或写入),那么会随机选择一个case分支执行。否则如果存在default分支,那么执行default分支,若没有default分支,那么select语句会阻塞,直到某一个case分支的通道可以通信。\nUnless the selected case is the default case, the respective communication operation is executed.\n除非选择的case分支是default分支,否则将执行相应case分支的通道读写操作。\nIf the selected case is a RecvStmt with a short variable declaration or an assignment, the left-hand side expressions are evaluated and the received value (or values) are assigned.\nThe statement list of the selected case is executed.\n执行所选case中的语句。\n上面介绍的执行顺序第一步骤,我们可以从下面代码输出结果可以看出来:\nfunc main() { ch := make(chan int, 1) select { case ch \u0026lt;- getVal(1): println(\u0026#34;recv: \u0026#34;, \u0026lt;-ch) case ch \u0026lt;- getVal(2): println(\u0026#34;recv: \u0026#34;, \u0026lt;-ch) } } func getVal(n int) int { println(\u0026#34;getVal: \u0026#34;, n) return n } 上面代码输出结果可能如下:\ngetVal: 1 getVal: 2 recv: 2 可以看到通道写入的右侧表达式getVal(1)和getVal(2)都会立马执行,执行顺序跟case语句顺序一样。\n接下来我们来看看第二步骤:\nfunc main() { ch := make(chan int, 1) ch \u0026lt;- 100 select { case i := \u0026lt;-ch: println(\u0026#34;case1 recv: \u0026#34;, i) case i := \u0026lt;-ch: println(\u0026#34;case2 recv: \u0026#34;, i) } } 上面代码中case1 和case2分支的通道都是可以通信状态,那么Go会随机选择一个分支执行,我们执行代码后打印出来的结果可以证明这一点。\n我们接下来再看看下面的代码:\nfunc main() { ch := make(chan int, 1) go func() { time.Sleep(time.Second) ch \u0026lt;- 100 }() select { case i := \u0026lt;-ch: println(\u0026#34;case1 recv: \u0026#34;, i) case i := \u0026lt;-ch: println(\u0026#34;case2 recv: \u0026#34;, i) default: println(\u0026#34;default case\u0026#34;) } } 上面代码中case1 和case2语句中的ch是未可以通信状态,由于存在default分支,那么Go会执行default分支,进而打印出default case。\n如果我们注释掉default分支,我们可以发现select会阻塞,直到1秒之后ch通道是可以通信状态,此时case1或case2中某个分支会执行。\n"},{"id":47,"href":"/function/closure/","title":"闭包","section":"函数","content":" 闭包 # C语言中函数名称就是函数的首地址。Go语言中函数名称跟C语言一样,函数名指向函数的首地址,即函数的入口地址。从前面《 基础篇-函数-一等公民》那一章节我们知道Go 语言中函数是一等公民,它可以绑定变量,作函数参数,做函数返回值,那么它底层是怎么实现的呢?\n我们先来了解下 Function Value 这个概念。\nFunction Value # Go 语言中函数是一等公民,函数可以绑定到变量,也可以做参数传递以及做函数返回值。Golang把这样的参数、返回值、变量称为Function value。\nGo 语言中Function value本质上是一个指针,但是其并不直接指向函数的入口地址,而是指向的runtime.funcval( runtime/runtime2.go)这个结构体。该结构体中的fn字段存储的是函数的入口地址:\ntype funcval struct { fn uintptr // variable-size, fn-specific data here } 我们以下面这段代码为例来看下Function value是如何使用的:\nfunc A(i int) { i++ fmt.Println(i) } func B() { f1 := A f1(1) } func C() { f2 := A f2(2) } 上面代码中,函数A被赋值给变量f1和f2,这种情况下编译器会做出优化,让f1和f2共用一个funcval结构体,该结构体是在编译阶段分配到数据段的只读区域(.rodata)。如下图所示那样,f1和f2都指向了该结构体的地址addr2,该结构体的fn字段存储了函数A的入口地址addr1:\n为什么f1和f2需要通过了一个二级指针来获取到真正的函数入口地址,而不是直接将f1,f2指向函数入口地址addr1。关于这个原因就涉及到Golang中闭包设计与实现了。\n闭包 # 闭包(Closure) 通俗点讲就是能够访问外部函数内部变量的函数。像这样能被访问的变量通常被称为捕获变量。\n闭包函数指令在编译阶段生成,但因为每个闭包对象都要保存自己捕获的变量,所以要等到执行阶段才创建对应的闭包对象。我们来看下下面闭包的例子:\npackage main func A() func() int { i := 3 return func() int { return i } } func main() { f1 := A() f2 := A() print(f1()) pirnt(f2()) } 上面代码中当执行main函数时,会在其栈帧区间内为局部变量f1和f2分配栈空间,当执行第一个A函数时候,会在其栈帧空间分配栈空间来存放局部变量i,然后在堆上分配一个funcval结构体(其地址假定addr2),该结构体的fn字段存储的是A函数内那个闭包函数的入口地址(其地址假定为addr1)。A函数除了分配一个funcval结构体外,还会挨着该结构体分配闭包函数的变量捕获列表,该捕获列表里面只有一个变量i。由于捕获列表的存在,所以说闭包函数是一个有状态函数。\n当A函数执行完毕后,其返回值赋值给f1,此时f1指向的就是地址addr2。同理下来f2指向地址addr3。f1和f2都能通过funcval取到了闭包函数入口地址,但拥有不同的捕获列表。\n当执行f1()时候,Go 语言会将其对应funcval地址存储到特定寄存器(比如amd64平台中使用rax寄存器),这样在闭包函数中就可以通过该寄存器取出funcval地址,然后通过偏移找到每一个捕获的变量。由此可以看出来Golang中闭包就是有捕获列表的Function value。\n根据上面描述,我们画出内存布局图:\n若闭包捕获的变量会发生改变,编译器会智能的将该变量逃逸到堆上,这样外部函数和闭包引用的是同一个变量,此时不再是变量值的拷贝。这也是为什么下面代码总是打印循环的最后面一个值。\npackage main func main() { fns := make([]func(), 0, 5) for i := 0; i \u0026lt; 5; i++ { fns = append(fns, func() { println(i) }) } for _, fn := range fns { // 最后输出5个5,而不是0,1,2,3,4 fn() } } 感兴趣的可以仿造上图,画出上面代码的内存布局图。重点关注闭包函数捕获的不是值拷贝,而是引用一个堆变量。\n"}] |