Skip to main content

go/0010-调度器

· One min read

GPM 模型

n:m

调度器核心函数 - runtime.gopark

runtime.gopark是Go调度器的核心函数之一,其源码实现位于src/runtime/proc.go中,主要用于将当前goroutine挂起(暂停执行)并切换到其他任务

func gopark(
unlockf func(*g, unsafe.Pointer) bool, // 解锁回调函数
lock unsafe.Pointer, // 关联的锁对象
reason waitReason, // 挂起原因(如等待通道、网络I/O等)
traceEv byte, // 事件跟踪标记
traceskip int, // 堆栈跟踪跳过的层级
)

1、网络 IO 阻塞:netpool 将 G 挂起,直到 fd 就绪后通过 goready 唤醒。 2、通道操作:读写 channel 时若条件不满足(如无缓冲区),会调用 gopark 并设置 waitReasonChanSend/Receive。 3、定时器:time.Sleep 最终通过 gopark 挂起 G,到期后由定时器堆触发唤醒。

go/0011-内存分配

· 9 min read

Go 语言的内存管理是建立在操作系统的内存管理之上的,最大程度上发挥了操作系统内存管理层面的优势,避开了低效的情况。

Go 实现了主动申请与主动释放管理,增加了逃逸分析GC(垃圾回收),将开发者从内存管理中释放出来,让开发者有更多的精力关注软件设计而不是底层的内存问题。

核心考量:

1、每次从操作系统申请一大块儿的内存(内存大小见 mheap),由Go来对这块儿内存做分配,减少系统调用。 2、内存分配算法采用 GoogleTCMalloc 算法。核心思想就是把内存切分的非常细小,分为多级管理,以降低锁的粒度。 3、回收对象内存时,并没有将其真正释放掉,只是放回了预先分配的大块内存中,以便复用。只有内存闲置过多的时候,才会尝试归还部分内存给操作系统,降低整体开销。

操作系统内存管理

1、虚拟内存

CPU访问速度远大于存储设备的访问速度,弥补速率差异,引入了 cache。除了速率还有多任务的需求,也就是多进程。如果解决多进程的内存分配问题,引入了虚拟内存的概念,这样所有进程都以为自己独享了一整块内存。

进程间的虚拟内存都是隔离开的。每个进程都拥有自己独立的虚拟地址空间(如32位系统通常有4GB虚拟地址范围),操作系统通过内存管理单元(MMU)和页表机制,将进程的虚拟地址映射到物理内存的不同区域。因此,进程A的地址 0x4000 与进程B的同地址指向完全不同的物理内存位置。

操作系统将同一块物理内存映射到多个进程的虚拟地址空间中。

CPU 访问内存基本过程:

  1. CPU 使用虚拟地址访问数据,执行 MOV 指令(数据传送指令)加载数据到寄存器,把地址传递给 MMU (内存管理单元);
  2. MMU 生成 PTE (页表)地址,并从主存(或自己的cache)中得到它。
  3. 如果 MMU 根据 PTE 得到真实的物理地址,正常读取数据。流程到此结束。
  4. 如果 PTE 信息表示没有关联的物理地址,MMU 则触发一个缺页异常
  5. 操作系统捕获到这个异常,开始执行缺页处理程序。在物理内存上创建一页内存,并更新页表。
  6. 缺页处理程序在物理内存中确定一个物理页,如果这个物理页上有数据,则把数据保存到磁盘上,然后将虚拟内存中的内容拷贝到这个物理页上。
  7. 缺页处理程序更新 PTE。
  8. 缺页处理程序结束,再回去执行上一条指令(导致缺页异常的那个指令,也就是 MOV指令),这次肯定命中了。

内存访问中的第三步,结束了,就是页命中了,反之就是未命中。假设 n 次内存访问中,出现命中的次数是 m,那么 m/n * 100% 表示命中率。这个指标可以衡量内存管理程序的好坏。

命中率低,性能下降。换页算法有 LRU 算法。

程序的内存布局

每个程序都有自己一套独立的地址空间可以使用,但在高级语言中,很少直接使用地址,而是通过变量名来访问数据的,编译器会自动将我们的变量名转换成真正的虚拟地址。

进程绕不开的堆和栈概念:

  • 栈:在高地址,从高地址向低地址增长;ESP EBP 两个寄存器指针,分别指向栈帧(一次函数调用)栈顶和栈底。这俩指针是会随着程序的执行进行移动。
  • 堆:在低地址,从低地址向高地址增长;

栈和堆相比的好处:

  • 栈的内存管理简单,分配比堆上快,栈通过压栈出栈方式自动分配释放的。由系统管理,使用起来高效无感知,而堆用以动态分配的,由程序自己管理分配和释放;Go 里函数调用就会自动压栈(局部变量,参数,地址)和出栈。
  • 栈上的内存不需要回收,而堆需要回收♻️。这是由栈的性质决定的。
  • 栈上的内存有更好的局部性,堆上的内存访问就不怎么友好。写程序时尽量将变量分配到栈上

2、TCMalloc

Thread Cache Malloc 的简称,是 Go 内存管理的起源(现在已经优化了很多)。引入虚拟内存后,让内存的并发访问问题的粒度由多进程级别,降低到多线程级别(线程栈)。然而同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

TCMalloc 的做法是,为每一个线程预分配一块缓存,线程申请小内存时,可以从缓存分配内存。这样2个好处:

  • 为线程预分配缓存需要进行1次系统调用,后续线程申请小内存时直接从缓存分配,都是在用户态执行,没有了系统调用,缩短了内存总体的分配和释放时间。
  • 多个线程同时申请小内存时,从各自的缓存分配,访问的是不同的地址空间,从而无需加锁,把内存并发访问的粒度进一步降低了。

2.1 内存池

内存池是一种内存管理方式,通常应用在服务器端后台开发中,主要是为了避免进程在运行过程中频繁的从堆上申请内存,使用以空间换取时间的方式来提高运行效率。比如 Linux 中常常直接调用 mmap 申请内存,但这会带来一些代价:

  • 系统调用,需要程序从用户态切换到内核态,在调用结束后,又需要返回用户态;
  • 频繁申请很小的内存空间,容易出现大量的内存碎片🧩,增大了操作系统整理碎片的压力;
  • 为了保证内存访问具有很好的局限性,开发中需要投入大量的精力去做优化,这是个很重的负担;

解决的方式就是实现一个内存池,先向操作系统申请大块内存交由其管理。当程序释放内存时不实际归还操作系统,而是放回池子中重复利用。这样就不需要频繁申请内存,对象池将会被频繁利用,不会出现碎片,且程序访问的是同一块内存空间,具有很好的 局部性(内存页命中率高)

2.2 多线程内存管理

多线程方面,每条线程都有自己的本地内存,然后有一个 全局的分配链,当某个线程中内存不足就向全局分配链中申请内存,避免了多线程同时访问共享变量时的加锁。

避免内存碎片方面大块内存直接按页为单位分配,小块内存会切分成各种不同的固定大小的块, 申请做任意字节内存时会向上取整到最接近的块,将整块分配给申请者以避免随意切割。

系统线程的内存分配方面,使用了一个本地的 mcache,少量地址分配就直接从 mcache 中分配,并且定期做垃圾回收,将线程的 mcache 中的空闲内存返回给全局堆。小于 32k 为小对象,大对象直接从全局堆上以页(4k)为单位进行分配,也就是大对象总是以页对齐的,一个页可以存入一些相同大小的对象,小对象从本地内存链表中分配,大对象从中心内存堆中分配。

https://www.cnblogs.com/binlovetech/p/17571929.html

https://blog.csdn.net/weixin_50941083/article/details/125782118

3、Go 语言内存分配器

Go 语言的内存分配器包含内存管理单元(runtime.mspan)、线程缓存(runtime.mcache)、中心缓存(runtime.mcentral)和页堆(runtime.mheap)几个重要组件。

![[Pasted image 20250704151239.png]]

所有的 Go 语言程序都会在启动时初始化如上图所示的内存布局。在 amd64 的 Linux 操作系统上,runtime.mheap 会持有 4,194,304 runtime.heapArena,每个 runtime.heapArena 都会管理 64MB 的内存,单个 Go 语言程序的内存上限也就是 256TB。

Go 的内存管理本质上就是一个内存池,内部做了很多优化,比如:自动伸缩内存池大小,合理的分割内存块等。

3.3.1 mheap

程序启动初始,将会一次性从系统申请大块内存作为内存池,这个内存空间将会被放在 mheap 中进行管理。

3.3.2 mcentral
3.3.3 mcache
3.3.4 mspan

结论:

go 的内存分配采用了 TCMalloc 算法,采用多级存储策略,好处是,能够避免锁竞争。提供系统性能

之前的 c/c++ 程序员需要自己去管理内存的申请和释放,这很麻烦,稍有不慎就会导致悬挂指针(dangling pointer)。而 Go 采用逃逸分析来决定内存的分配,是分配到栈上还是堆上,无需程序员操心。

内存对齐

主要是对于结构体字段来说,会存在填充(padding) 情况。在编译时按照一定规则自动进行内存对齐,是为了减少CPU访问内存的次数加大CPU访问内存的吞吐量。不进行对齐,很可能增加CPU访问内存的次数。

使用到的函数:

unsafe.Sizeof()

unsafe.Alignof()

CPU 访问内存时,并不是逐个字节访问,而是以字(word)为单位访问。比如 64位CPU的字长(word size)为8bytes,那么CPU访问内存的单位也是8字节,每次加载的内存数据也是固定的若干字长,如8words(64bytes)、16words(128bytes)等

规则:字段size大的尽量放在前面。

栈内存来源?

go 栈都是 goroutine 申请,起始栈大小为 2kb,可以对比线程栈大小都是 MB 级别。go 栈的开销很小,主要用来保存一些函数变量、参数、栈帧和返回地址等。

go/0012-逃逸分析

· 5 min read

内存管理

内存管理主要包括两个动作:分配与释放。逃逸分析就是服务于内存分配。

栈:

在Go中,栈的内存是由编译器自动进行分配和释放,栈区往往存储着函数参数、局部变量和调用函数帧,它们随着函数的创建而分配,函数的退出而销毁

一个goroutine对应一个栈,栈是调用栈(call stack)的简称。一个栈通常又包含了许多栈帧(stack frame),它描述的是函数之间的调用关系,每一帧对应一个尚未返回的函数调用,它本身也是以栈形式存放数据。

堆:

与栈不同的是,应用程序在运行时只会存在一个堆。 我们可以简单理解为:我们用GO语言开发过程中,要考虑的内存管理只是针对堆内存而言的。

程序在运行期间可以主动从堆上申请内存,这些内存通过Go的内存分配器分配,并由垃圾收集器回收。

堆和栈的对比

加锁

  • 栈不需要加锁:栈是每个goroutine独有的,这就意味着栈上的内存操作是不需要加锁的。
  • 堆有时需要加锁:堆上的内存,有时需要加锁防止多线程冲突

延伸知识点:为什么堆上的内存有时需要加锁?而不是一直需要加锁呢?

因为Go的内存分配策略学习了TCMalloc的线程缓存思想,他为每个处理器P分配了一个mcache,从mcache分配内存也是无锁的

性能

  • 栈内存管理 性能好:栈上的内存,它的分配与释放非常高效的。简单地说,它只需要两个CPU指令:一个是分配入栈,另外一个是栈内释放。只需要借助于栈相关寄存器即可完成。
  • 堆内存管理 性能差:对于程序堆上的内存回收,还需要通过标记清除阶段,例如Go采用的三色标记法。

缓存策略

  • 栈缓存性能更好
  • 堆缓存性能较差

原因是:栈内存能更好地利用CPU的缓存策略,因为栈空间相较于堆来说是更连续的。

逃逸分析

相比于把内存分配到堆中,分配到栈中优势更明显。 Go语言也是这么做的:Go编译器会尽可能将变量分配到到栈上。 但是,当编译器无法证明函数返回的变量有没有被引用时,编译器就必须在堆上分配该变量,以此避免悬挂指针(dangling pointer)的问题。

另外,如果局部变量占用内存非常大,也会将其分配在堆上。

Go是如何确定内存是分配到栈上还是堆上的呢?

答案就是:逃逸分析。

编译器通过逃逸分析技术去选择堆或者栈,逃逸分析的基本思想如下:检查变量的生命周期是否是完全可知的,如果通过检查,则在栈上分配。否则,就是所谓的逃逸,必须在堆上进行分配。

逃逸分析原则

Go语言虽然没有明确说明逃逸分析原则,但是有以下几点准则,是可以参考的。

  • 不同于JAVA JVM的运行时逃逸分析,Go的逃逸分析是在编译期完成的:编译期无法确定的参数类型必定放到堆中;
  • 如果变量在函数外部存在引用,则必定放在堆中;
  • 如果变量占用内存较大时,则优先放到堆中;
  • 如果变量在函数外部没有引用,则优先放到栈中;

逃逸分析举例

我们使用这个命令来查看逃逸分析的结果: go build -gcflags '-m -m -l'

1、参数是 interface 类型:参数为 interface 类型,编译期无法确定其具体的参数类型,所以内存分配到堆中。 2、变量在函数外部又引用:当函数执行完毕,对应的栈帧就被销毁,但是引用已经被返回到函数之外。如果这时外部通过引用地址取值,虽然地址还在,但是这块内存已经被释放回收了,这就是非法内存。为了避免非法内存情况,这种情况下变量的内存分配必须分配到堆上。 3、变量内存占用较大:占用较大的变量(64 KB 以上)也会发生逃逸,内存分配到堆上。 4、变量大小不确定时:编译期间并不能确定变量的值,所以会发生逃逸。

l := 1  
a := make([]int, l, l)
for i := 0; i < l; i++ {
a[i] = i
}

变量 a 逃逸(escape)到堆上了。

5、闭包💼:一个函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包(closure)。也就是说,闭包让你可以在一个内层函数中访问到其外层函数的作用域。闭包访问了外部变量时,这个变量将会一直存在,直到闭包消亡。

日常开发中

函数传参时,有些场景下,应该传递结构体,而不应该传递结构体指针。

  • 如果结构体较大,传递结构体指针更合适,指针类型相比值类型能节省大量的内存空间。
  • 如果结构体较小,传递结构体更合适,栈上分配内存,可以有效减少 GC 压力。

go/0013-GC

· 3 min read

标记清除、三色并发标记清除、混合写屏障、强弱三色不变式

黑色:根对象 灰色:被引用的对象 白色:需要被清除的对象

问题:

并发标记会引来:漏标、多标问题。

为了解决漏标问题,引出了强弱三色不变式。

漏标:黑色对象引用了一个白色对象。影响很大,这个白色对象会被清除。 多标:一个灰色对象本应被标记为白色,而在这一次 GC 过程中没被标记的。影响不大,下一轮 GC 会清除。

强三色不变式: 黑色对象不可以引用白色对象。 弱三色不变式: 黑色对象可以引用白色对象,但是这个白色对象必须存在其他灰色对象对它的引用,或者可达它的链路上游存在灰色对象。

为了遵循上述的两个方式,GC算法演进到两种屏障方式,他们是插入屏障和删除屏障。

插入屏障:

插入屏障的具体操作是,在A对象引用B对象的时候,B对象被标记为灰色(将B挂在A下游,B必须被标记为灰色)。

伪代码:

添加下游对象(当前下游对象slot,新下游对象ptr) {  
// 第一步
标记灰色(新下游对象ptr)
// 第二步
当前下游对象slot =新下游对象ptr
}

插入屏障实际上是满足强三色不变式(不存在黑色对象引用白色对象的情况了, 因为白色会强制变成灰色)。

黑色对象的内存槽有两种位置:栈和堆。栈空间容量小,响应速度快,因为函数调用弹出频繁使用,所以“插入屏障”机制在栈空间的对象操作中不使用,而仅仅使用在堆空间对象的操作中。

当这一次全部三色标记扫描之后,栈上有可能依然存在白色对象被引用的情况(如上图的对象9)。 所以要对栈重新进行三色标记扫描,但这次为了对象不丢失。但这次扫描要启动STW暂停,直到栈空间的三色标记结束。

对这些白色对象启动STW暂定保护起来,那么任何并行对以上被保护的对象进行任何读写操作均会被拦截且阻塞,防止外界干扰。

堆空间的对象将不会触发STW,这样也是为了保证堆空间的GC回收性能

删除屏障:

删除屏障的具体操作是,被删除的对象,如果自身为灰色或者白色,那么被标记为灰色。

删除屏障实际上是满足弱三色不变式,目的是保护灰色对象到白色对象的路径不会断

GC 的阶段与混合写屏障的配合

Go GC 分为四个阶段:

  1. Mark Setup(STW):开启写屏障,准备标记。
  2. Marking(并发):GC 与用户程序并发运行,使用混合写屏障跟踪对象变化。
  3. Mark Termination(STW):关闭写屏障,完成标记,统计垃圾对象。
  4. Sweeping(并发):并发清除垃圾对象,用户程序可同时分配新内存。

go/0014-网络IO

· 8 min read

epoll

  • 红黑树管理 FD:通过 epoll_ctl 将 FD 注册到内核的红黑树中。
  • 就绪链表优化:当事件发生时,内核将 FD 加入就绪链表,epoll_wait 直接从链表获取就绪 FD。
  • 事件驱动机制:使用 mmap 避免用户态与内核态之间的数据复制。
# 三个函数
epoll_create
epoll_ctl
epoll_wait

fd
listen_fd
client_fd

处理 TCP 数据

  1. epoll 通过 epoll_ctl 管理两类 FD:listen fd(监听新连接)和 client fd(监听数据)。
  2. 事件驱动:epoll_wait 阻塞等待,仅在事件发生时唤醒,避免轮询消耗 CPU。
  3. 分工明确:
    • listen fd 的 EPOLLIN → 用非阻塞 accept 接收新连接,注册 client fd 到 epoll
    • client fd 的 EPOLLIN → 用非阻塞 recv 读取数据,处理后可回显或转发。
  4. 非阻塞 + 事件通知结合:既避免了阻塞导致的效率问题,又解决了非阻塞单独使用时的 “忙等” 问题。

epoll 处理监听的 TCP 套接字(listen fd)数据到来的过程,本质是通过事件驱动机制检测客户端连接请求,并触发应用程序处理连接的流程。

前提:监听套接字的准备

首先需要创建一个监听的 TCP 套接字(listen_fd),并完成初始化:

  1. 调用 socket() 创建 TCP 套接字(SOCK_STREAM 类型)。
  2. 调用 bind() 将套接字绑定到指定的 IP 和端口。
  3. 调用 listen() 将套接字转为监听状态(此时 listen_fd 开始接收客户端的连接请求)。
  4. (可选但推荐)将 listen_fd 设置为非阻塞模式(通过 fcntl 设置 O_NONBLOCK),避免后续 accept 操作阻塞程序。

步骤 1:将监听套接字注册到 epoll

要让 epoll 监控 listen_fd 的连接请求,需先将其加入 epoll 的监控列表:

  1. 调用 epoll_create 创建一个 epoll 实例(返回 epoll_fd)。

  2. 调用 epoll_ctl 向 epoll 实例添加 listen_fd,并指定要监控的事件:

    • 核心关注事件:EPOLLIN(表示 “可读”)。因为当客户端发起连接请求(三次握手完成后),内核会将连接请求放入 listen_fd 的接收队列,此时 listen_fd 处于 “可读” 状态。
    • 可选设置:EPOLLET(边缘触发模式),相比默认的水平触发模式(LT),ET 模式更高效(仅在事件状态变化时通知一次)。
int epoll_fd = epoll_create1(0); // 创建epoll实例 
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLET; // 监控"可读"事件,边缘触发
ev.data.fd = listen_fd; // 关联监听fd
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev); // 添加到epoll

步骤 2:epoll 等待连接请求事件

应用程序通过 epoll_wait 阻塞等待事件(可设置超时时间):

struct epoll_event events[1024];  // 存储就绪事件的数组
int nfds = epoll_wait(epoll_fd, events, 1024, -1); // 阻塞等待事件
  • 当没有客户端连接请求时,epoll_wait 会阻塞,不占用 CPU 资源。
  • 当有客户端发起连接(三次握手完成),内核会标记 listen_fd 为 “可读”,并将其加入 epoll 的就绪队列,此时 epoll_wait 会返回,nfds 为就绪事件的数量。

步骤 3:处理监听套接字的 “可读” 事件

epoll_wait 返回后,遍历就绪事件数组,判断是否是监听套接字的事件:

for (int i = 0; i < nfds; i++) {
if (events[i].data.fd == listen_fd) { // 监听fd有事件
// 处理连接请求
}
}

此时 “可读” 事件的含义是:listen_fd 的接收队列中存在未处理的客户端连接请求,需要调用 accept 获取新连接。

步骤 4:通过 accept 获取客户端连接

当确认是监听套接字的 “可读” 事件后,需调用 accept 从 listen_fd 的接收队列中取出客户端连接:

  • accept 会返回一个新的套接字(client_fd),用于与客户端通信。
  • 由于 listen_fd 已设为非阻塞,且可能有多个连接请求(如高并发场景),需要循环调用 accept,直到返回 EAGAIN 或 EWOULDBLOCK(表示接收队列已空),避免漏接连接。

总结流程

  1. 初始化监听套接字(listen_fd)并设置为非阻塞。
  2. 创建 epoll 实例,将 listen_fd 加入 epoll 并监控 EPOLLIN 事件(可选 ET 模式)。
  3. 循环调用 epoll_wait 等待事件。
  4. 当 listen_fd 触发 EPOLLIN 事件时,循环调用 accept 获取所有客户端连接(client_fd)。
  5. 将 client_fd 加入 epoll 监控(后续处理数据读写),完成连接建立。

epoll事件有哪些?

1. EPOLLIN

可读事件,表示文件描述符可读。触发场景包括:

  • 普通数据或优先数据(如 TCP 常规数据)到达;
  • 对方发送 FIN 包(连接关闭请求),此时 read() 会返回 0;
  • 监听 socket(listen() 后的 socket)收到新的连接请求(此时可调用 accept() 接收连接)。

2. EPOLLOUT

可写事件,表示文件描述符可写。触发场景包括:

  • 内核发送缓冲区有空闲空间(可写入数据,不会阻塞);
  • 非阻塞连接(connect())完成时(无论成功或失败,都会先触发 EPOLLOUT,需通过 getsockopt() 检查连接结果)。

注意:若持续注册 EPOLLOUT,可能会频繁触发(因为发送缓冲区通常有空间),建议仅在发送数据阻塞后注册,等待可写后再发送。

3. EPOLLERR

错误事件,表示文件描述符发生错误。

  • 无需显式注册,当 fd 发生错误时(如连接失败、socket 异常),epoll 会自动触发该事件;
  • 常见场景:非阻塞连接失败、socket 被关闭后操作等。

4. EPOLLHUP

挂起事件,表示文件描述符的连接被 “挂断”。

  • 触发场景:管道的写端关闭后,读端会收到 EPOLLHUP;socket 连接被对端强制关闭(如对端进程退出)等;
  • 通常与 EPOLLIN 同时触发(如对端关闭连接时,既有 FIN 包触发 EPOLLIN,也可能伴随 EPOLLHUP)。

5. EPOLLRDHUP

TCP 对端关闭 / 半关闭事件(Linux 2.6.17 后新增),专门用于 TCP 连接。

  • 触发场景:TCP 对端调用 close() 关闭连接(发送 FIN 包),或对端关闭写半连接(shutdown(SHUT_WR));
  • 相比 EPOLLIN(可能因普通数据触发),EPOLLRDHUP 更明确地标识 “对端关闭”,避免误判。

6. EPOLLPRI

紧急数据可读事件,表示有 “优先数据”(如 TCP 带外数据 OOB)到达。

  • 触发场景:对方发送 TCP 带外数据(通过 sendmsg() 或 send() 带 MSG_OOB 标志),此时 read() 可读取带外数据。

TCP 事件如何处理

1、注册 TCP 连接到 epoll

应用程序在建立 TCP 连接后(通过 accept() 获得新的 socket fd),需要通过 epoll_ctl 将该 fd 注册到 epoll 实例中,并指定监听 EPOLLIN 事件(表示关注 “数据到来”)。

注册时,通常会将该 fd 与对应的连接上下文(如连接的结构体指针、用户信息等)关联起来,存储在 epoll_event 结构体的 data 字段中(例如 data.ptr = 连接结构体指针)。

2、epoll 等待事件就绪

应用程序通过 epoll_wait 阻塞等待事件(或非阻塞轮询)。当某个 TCP 连接有常规数据到来时,内核会标记该 fd 为 “就绪”,并将其加入 epoll 的就绪列表。

epoll_wait 会返回就绪的事件列表(数组),每个元素是 epoll_event 结构体,包含两个关键信息:

  • events:事件类型(此处为 EPOLLIN,表示有常规数据);
  • data:注册时关联的连接上下文(如连接结构体指针)。

3. 应用程序识别并处理对应连接

应用程序遍历 epoll_wait 返回的就绪事件列表,通过每个事件的 data 字段(或 data.fd)找到对应的 TCP 连接,然后调用 read() 或 recv() 读取数据。

select vs poll vs epoll

特性selectpollepoll
数据结构位图(bitmap)数组(struct pollfd红黑树 + 就绪链表
FD 数量限制通常限制为 1024(FD_SETSIZE无限制(取决于系统资源)无限制(仅受内存限制)
事件传递方式每次调用需重新设置 FD 集合每次调用需重新设置 pollfd 数组仅需通过 epoll_ctl 注册一次
就绪事件获取返回后需遍历所有 FD 检查状态返回后需遍历所有 pollfd 检查状态直接通过 epoll_wait 获取就绪 FD
触发模式仅支持水平触发(LT)仅支持水平触发(LT)支持水平触发(LT)和边缘触发(ET)
系统调用开销O(n)O(n)O(1)
应用场景少量 FD 的场景中等数量 FD 的场景大量 FD 且活跃度低的场景

网络 IO多路复用,Linux 是 epoll ,Mac 是 kqueue

go 高性能编程/002-更高效的写法

· 3 min read

字符串高效拼接

常见字符串拼接方式:5 种

1、使用 + 2、使用 fmt.Sprintf 3、使用 strings.Builder

func builderConcat(n int, str string) string {  
var builder strings.Builder
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}

4、使用 bytes.Buffer

func bufferConcat(n int, s string) string {  
buf := new(bytes.Buffer)
for i := 0; i < n; i++ {
buf.WriteString(s)
}
return buf.String()
}

5、使用 []byte

func byteConcat(n int, str string) string {  
buf := make([]byte, 0)
for i := 0; i < n; i++ {
buf = append(buf, str...)
}
return string(buf)
}

strings.Builderbytes.Buffer 和 []byte 的性能差距不大,而且消耗的内存也十分接近。

最后建议:一般推荐使用 strings.Builder 来大量拼接字符串。

string.Builder 也提供了预分配内存的方式 Grow

func builderConcat(n int, str string) string {  
var builder strings.Builder
builder.Grow(n * len(str))
for i := 0; i < n; i++ {
builder.WriteString(str)
}
return builder.String()
}

切片陷阱

在已有切片的基础上进行切片,不会创建新的底层数组。因为原来的底层数组没有发生变化,内存会一直占用,直到没有变量引用该数组。因此很可能出现这么一种情况,原切片由大量的元素构成,但是我们在原切片的基础上切片,虽然只使用了很小一段,但底层数组在内存中仍然占据了大量空间,得不到释放。比较推荐的做法,使用 copy 替代 re-slice

如果函数直接在原切片基础上进行切片,会导致内存得不到释放。

for vs for range

for range 是一个语法糖,range 对每个迭代值都创建了一个拷贝,如果每次迭代的值内存占用很小的情况下,for 和 range 的性能几乎没有差异,但是如果每个迭代值内存占用很大。那性能差距就很明显了。

这个时候建议用指针,指针指向这个迭代的对象。

避免用反射 reflect

反射效率很低,特别是 go 自带的 json 的 Marshal 和 Unmarshal 方法。推荐用高性能的 json 工具。

用空结构体节省内存

Go 语言中空结构体不占据内存,因此被广泛作为各种场景下的占位符使用。节省资源 + 本身具备很强语义。

fmt.Println(unsafe.Sizeof(struct{}{}))

0

空结构体不需要进行内存对齐,所以不占用空间。

注意:struct 作为结构体最后一个字段时,需要内存对齐。因为如果有指针指向该字段, 返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)。

高并发/0005-中间件

· One min read

一个高并发系统需要很多中间件

消息通信、数据存储、服务治理

数据库:

mysql postgreSQL

mongodb clickhouse

消息队列:异步通信、解耦。

kafka redis stream rabbitMQ rocketMQ

缓存:加速访问、减压数据库

redis valkey dragonfly 本地内存缓存

数据库中间件:分库分表、读写分离

shardingSphere

web服务器/负载均衡:流量调度与高可用

nginx traefik kong

微服务治理:服务发现与熔断

go-zero

监控:指标(Metrics)/日志(Logs)/链路追踪(Traces)

搜索:es

链路追踪(Traces)

tractID: UUID或Snowflake ID,Snowflake ID → Base64 编码(26字符 → 18字符),减少网络开销‌

func CompactTraceID(id uint64) string { 
return base64.RawURLEncoding.EncodeToString(binary.BigEndian.AppendUint64(nil, id))
}

span:spanID

Opentelemetry

初始化导出器(exporter)、资源(resource)、追踪器提供者(tracer provider),最后初始化一个 tracer 实例。

Elasticsearch

OpenTelemetry SDK + Jaeger 将是分布式追踪的理想方案

分布式挑战

数据一致性 负载均衡