使用eBPF观测Go程序的goroutine状态变化

eBPF号称能动态地对内核进行编程,可以实现高效的网络处理、观测、追踪和安全功能。 Dynamically program the kernel for efficient networking, observability, tracing, and security 这篇博客就来体验一下用eBPF观测Go的goroutine调度,会涉及到uprobe原理、eBPF开发框架选择、eBPF程序的编写、用户态程序加载eBPF程序以及用户态程序和eBPF程序通过map交互等内容。 uprobe原理 uprobe(user-space probe)是 Linux 内核提供的一种机制,用来在 用户态程序的特定指令处 插入动态探针(probe)。uprobe 通过在用户态程序代码段指定位置写入一个 1 字节的断点指令(INT3, 0xCC),触发内核陷入(trap)进入 uprobe handler,再运行hook的代码。由于eBPF具有很好的安全性,所以可以用eBPF来编写hook代码,保证安全。 eBPF开发框架选择 最近几年eBPF发展的非常迅速,出现了优秀的开源eBPF开发框架,使得eBPF的开发难度大大降低。下面选取一些我知道的eBPF开发框架做介绍: 项目 特点 语言 bcc eBPF开发工具箱,适合教学&运维,依赖LLVM,较重。 C、Python libbpf-bootstrap 用C开发eBPF的模版/框架,官方推荐。 C cilium/ebpf 用Go开发eBPF的首选,eilium开源,能用于生产环境,无LLVM依赖 C、Go bpftrace 用类似awk的脚本语言开发eBPF,适合快速调试、验证。 专用语法 eunomia-bpf 面向云原生的eBPF WASM化 Rust 经过对比最终选择了使用cilium/ebpf来实现观测goroutine状态变化,原因有:bcc是一个很重的项目,有很多依赖环境。libbpf-bootstrap开发用户态程序相对麻烦一些。使用bpftrace开发需要了解他的脚本语言的语法(并不是通用的脚本语言),有一定的学习成本,而且开发复杂的eBPF程序也不太灵活。eunomia-bpf还没仔细研究过,以后会尝试。经过对比,我最终选择了对我来说最简单的cilium/ebpf,而且这个项目里有现成的examples,可以照猫画虎,很快就能实现一个简单uprobe功能。 用eBPF观测goroutine状态变化 eBPF程序 Go支持高并发的利器就是goroutine,而goroutine之所以能轻而易举的实现上万的并发,要归功于Go的调度策略,Go使用GMP调度模型。核心就是在用户态实现调度逻辑,避免系统级线程调度时用户态和内核态之间的切换开销。 Go的调度逻辑在源码的src/runtime/proc.go中,当scheduler切换goroutine状态时会调用runtime.casgstatus,runtime.casgstatus的原型如下,它的作用是:检查当前goroutine的状态是否old,如果是就把状态原子的更新为new,如果不是,不做更新。 func casgstatus(gp *g, oldval, newval uint32) 要观测goroutine的状态切换,只需要把eBPF函数hook到这个函数的入口处,拿到这三个参数即可。 首先把cilium/ebpf拉到本地,进入examples目录,在创建一个uprobe_sched目录,创建一个uprobe_sched.c文件。 $ cd ebpf/examples $ mkdir uprobe_sched $ cd uprobe_sched $ touch uprobe_sched.c 然后参考隔壁的examples/uretprobe/uretprobe.c,编写eBPF程序,主要逻辑就是,获取三个参数,第一个参数是指向g结构体的指针,第二个参数是old,第三个参数是new。其中goid在g结构体偏移0xA0(这个偏移和Go的版本相关,具体计算方法后面介绍),需要借助bpf_probe_read_user函数读取,读到goid之后将data_t结构体写入map中。 ...

十一月 27, 2025 · 5 分钟 · rand0m

XDP 挂载模式剖析

XDP挂载模式对比 了解XDP的读者应该知道:XDP是基于eBPF的一个高性能网络路径技术,它的原理就是在数据包处理的早期阶段(在内核网络协议栈之前)挂载eBPF程序对数据包进行处理,从而实现高效的网络数据包处理。如果你写过XDP程序,那么一定知道挂载XDP的时候有多种模式可选,不同模式之间的效率不同。这篇文章我们就来深入剖析一下XDP的集中模式之间到底有哪些区别。 首先看看XDP挂载模式有哪几种?不同的挂载模式有和区别? XDP挂载模式可以以三种方式挂在到网卡上: Generic Native Offloaded 兼容性 兼容所有网络设备 需要网卡驱动显示支持XDP 特定的可编程网卡 执行阶段 在网络核心代码中执行(此时已经分配了SKB) 在网卡驱动中执行(还未分配SKB) 网卡执行,CPU零开销 性能 较低 高 最高 XDP 挂载原理 XDP程序是挂载在网络数据包的处理路径上的,所以我们有必要先对网络数据包的处理路径有一个整体的掌握(这里插播一条小广告,我之前写过一篇分析数据包从网卡到内核协议栈的博客)。 数据包从网卡到内核网络协议栈的流程可以分为以下几个步骤: 数据包到达网卡 网卡硬件接收以太帧,做基本校验(如 CRC)。 DMA 写入内存 网卡通过 DMA 将数据包写入驱动预先分配好的接收缓冲区(Descriptor Ring)。 中断通知 CPU 网卡通过 IRQ 告诉 CPU:“我收到了新数据包”。 驱动中断处理函数(ISR) 驱动快速处理中断,通常只是调用 __napi_schedule(),把 NAPI poll 加入调度队列。 软中断调度 NAPI poll CPU 执行 do_softirq() → net_rx_action() → 调用 网卡的的 poll 函数。 poll 函数提取数据包并构造 skb 驱动在 poll 中读取 DMA ring 的描述符,把数据包封装进 sk_buff 结构,交给网络核心层。 网络核心层处理 网络核心层根据数据包格式选择对应的协议栈,然后交给协议栈处理。 XDP就是挂载在上面的某个阶段,从而实现高效网络数据包处理的。具体来说Native模式的XDP是在网卡的驱动程序中执行的(对应步骤6),而Generic模式的XDP是在网络核心层中执行的(对应步骤7)。这也说明了Native模式的性能比Generic模式高。 ...

九月 25, 2025 · 3 分钟 · rand0m

Linux 网络编程——socket 系统调用实现剖析

Linux 下多语言网络编程对比 还记得Linux网络编程姿势吗?如果不记得了,这里有一个用C语言写的tcp_echo服务,用这段代码能帮我们回忆Linux的网络编程套路: 调用socket创建一个网络套接字socket; 调用bind给socket绑定地址; listen设置 调用accept接收网络请求; #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> int main(void) { int sd = socket(AF_INET, SOCK_STREAM, 0); struct sockaddr_in addr = { .sin_family = AF_INET, .sin_port = htons(12345), .sin_addr.s_addr = INADDR_ANY, }; bind(sd, (struct sockaddr*)&addr, sizeof(addr)); listen(sd, 5); while (1) { int client = accept(sd, NULL, NULL); char buf[1024]; ssize_t n; while ((n = recv(client, buf, sizeof(buf), 0)) > 0) { send(client, buf, n, 0); // 把读到的内容发送回去 } close(client); } close(sd); return 0; } 如果你用的是Go、Python、Rust等高级编程语言,可能会对这段代码嗤之以鼻,这么简单一个功能,要创建一个可以通信的TCP连接完全不必这么复杂。 这是用Go写的,如果不考虑错误处理,只需要调用Listen和Accept。 package main import ( "io" "net" ) func main() { ln, err := net.Listen("tcp", "127.0.0.1:12345") if err != nil { panic("listen err") } for { conn, err := ln.Accept() if err != nil { panic("accept error") } go func(conn net.Conn) { defer conn.Close() io.Copy(conn, conn) }(conn) } } 使用Rust标准库std::net的同步版本如下。 ...

九月 24, 2025 · 5 分钟 · rand0m

网络数据包接受过程分析——从网卡到内核协议栈(以Intel e1000 + Linux 4.4为例)

引言 网络数据包从网卡到应用程序,需要经历一段复杂的旅程。作为开发者,我们平时调用 socket()、recv() 就能轻松拿到数据,却很少思考内核背后究竟发生了什么。 本系列文章尝试结合 理论流程 + 内核源码分析,逐步剖析 Linux 内核中网络数据包的接收过程。这里选择 Linux 4.4 内核作为例子(代码相对稳定,资料丰富,逻辑上没有过多新特性干扰),并结合 Intel e1000 驱动来具体展示数据包是如何从网卡到达内核网络协议栈的。 网络数据包接收的总体流程 先给出一个全局视角:数据包从网卡到达内存,再到协议栈的路径,大致如下: 数据包到达网卡 网卡硬件接收以太帧,做基本校验(如 CRC)。 DMA 写入内存 网卡通过 DMA 将数据包写入驱动预先分配好的接收缓冲区(Descriptor Ring)。 中断通知 CPU 网卡通过 IRQ 告诉 CPU:“我收到了新数据包”。 驱动中断处理函数(ISR) 驱动快速处理中断,通常只是调用 __napi_schedule(),把 NAPI poll 加入调度队列。 软中断调度 NAPI poll CPU 执行 do_softirq() → net_rx_action() → 调用 e1000 的 poll 函数。 poll 函数提取数据包并构造 skb 驱动在 poll 中读取 DMA ring 的描述符,把数据包封装进 sk_buff 结构,交给内核网络子系统。 网络核心层处理 skb 被送到网络核心层,核心层通过数据包的协议类型选择对应的网络协议栈,如IP协议栈,至此网络数据包从网卡到达了Linux内核的网络协议栈。 在开始具体分析之前,先说以下相关源码的位置。和网卡相关的代码在驱动目录下(/drivers/net/ethernet),由于我们分析的是Intel的e1000网卡,所以具体位置就是/drivers/net/ethernet/intel/e1000,内核网络协议栈位于网络子系统目录下/net,主要是/net/core/目录,我们主要分析IPv4,所以还会涉及/net/ipv4/中的少量代码。 接下来就以Intel e1000网卡为例,来一起探究网卡收包过程吧😀 ...

九月 22, 2025 · 9 分钟 · rand0m