Skip to content

Latest commit

 

History

History
529 lines (391 loc) · 18.7 KB

File metadata and controls

529 lines (391 loc) · 18.7 KB

eBPF 跟踪 goroutine 状态的变化

引出问题


  • 如何跟踪用户态程序中的函数调用,寄存器参数的读取 ?

  • 如何使用 eBPFuprobe 探针 ?

  • 如何设置 eBPF 程序的常量 ?

  • 如何使用 ringbuf 接收 eBPF 内核态程序传递的事件 ?

  • 如何使用 cilium/ebpf 库加载和接收 eBPF 内核态程序传递的事件 ?

  • 如何追踪 Go 程序中 goroutine 的状态变化,但不想修改源代码或重新编译二进制文件 ?

解决方案


使用 eBPFuprobes 来检测 Go 运行时中的 runtime.casgstatus 函数. 这允许你在运行时捕获 goroutine 的状态转换.

goroutine.h 文件内容如下, 它定义了 goroutine 的状态,以及 goroutine_execute_data 结构体,用于存储 goroutine 状态变化的数据. goroutine 的状态必须 Go 运行时版本一致,否则可能出现不匹配的情况(用例版本是:go version go1.22.4 linux/amd64).

#ifndef EBPF_EXAMPLE_GOROUTINE_H
#define EBPF_EXAMPLE_GOROUTINE_H

enum goroutine_state
{
    IDLE,
    RUNNABLE,
    RUNNING,
    SYSCALL,
    WAITING,
    MORIBUND_UNUSED,
    DEAD,
    ENQUEUE_UNUSED,
    COPYSTACK,
    PREEMPTED,
};

struct goroutine_execute_data
{
    enum goroutine_state old_state;
    enum goroutine_state new_state;
    u64 goid;
    u32 pid;
    u32 tgid;
};

#endif

需要重点要跟踪的是 runtime.casgstatus 函数,这个函数在 runtime/proc.go 中定义:

// If asked to move to or from a Gscanstatus this will throw. Use the castogscanstatus
// and casfrom_Gscanstatus instead.
// casgstatus will loop if the g->atomicstatus is in a Gscan status until the routine that
// put it in the Gscan state is finished.
//
//go:nosplit
func casgstatus(gp *g, oldval, newval uint32) {
	if (oldval&_Gscan != 0) || (newval&_Gscan != 0) || oldval == newval {
		systemstack(func() {
			// Call on the systemstack to prevent print and throw from counting
			// against the nosplit stack reservation.
			print("runtime: casgstatus: oldval=", hex(oldval), " newval=", hex(newval), "\n")
			throw("casgstatus: bad incoming values")
		})
	}

	lockWithRankMayAcquire(nil, lockRankGscan)

	// See https://golang.org/cl/21503 for justification of the yield delay.
	const yieldDelay = 5 * 1000
	var nextYield int64
......

goroutine 需要改变状态时,就会调用这个函数. 可以看到这个函数有三个参数,分别表示:

  • gp:指向 goroutine 的指针.

  • old:旧的状态值. uint32 类型.

  • new:新的状态值. uint32 类型.

goroutine 的状态值定义在 runtime2.go#L37中,枚举类型,在头文件中也做了相应的定义. (这里没有处理_Gscan状态,这个状态是给垃圾回收器使用的)

eBPF 内核态程序如下:

#include "vmlinux.h"
#include "goroutine.h"
#include <bpf/bpf_core_read.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

const volatile int pid_target = 0;

#define GOID_OFFSET 0x98

struct {
  __uint(type, BPF_MAP_TYPE_RINGBUF);
  __uint(max_entries, 256 * 1024);
} rb SEC(".maps");

const struct goroutine_execute_data *unused __attribute__((unused));

SEC("uprobe//home/fangyuan/code/go/src/github.com/767829413/advanced-go/eBPF/uprobes/demo/main:runtime.casgstatus")
int uprobe_runtime_casgstatus(struct pt_regs *ctx) {
    void *gp = (void *)ctx->ax;
    u32 oldval = (u32)ctx->bx;
    u32 newval = (u32)ctx->cx;

    u64 tgid_pid = bpf_get_current_pid_tgid();
    u32 pgid = tgid_pid >> 32;
    u32 pid = tgid_pid;

    if (pid_target && pid_target != pgid)
      return false;


    struct goroutine_execute_data *data;
    u64 goid;
    if (bpf_probe_read_user(&goid, sizeof(goid), gp + GOID_OFFSET) == 0) {
      data = bpf_ringbuf_reserve(&rb, sizeof(*data), 0);
      if (data) {
        data->pid = pid;
        data->tgid = pgid;
        data->goid = goid;
        data->new_state = newval;
        data->old_state = oldval; // Assuming you have added this field to the struct
        bpf_printk("pgid:%d, pid:%d, goid: %lu, old state: %d, new state: %d", data->tgid, data->pid, goid, oldval, newval);
        bpf_ringbuf_submit(data, 0);
      }
    }
    return 0;
}

char LICENSE[] SEC("license") = "GPL";

这段代码是一个使用 eBPF (Extended Berkeley Packet Filter) 技术的程序,用于跟踪 Go 语言程序中 goroutine 的状态变化. 让我们逐步解析这段代码:

  1. 头文件和包含:代码开始包含了必要的头文件,如 vmlinux.h(用于 eBPF 程序)、bpf 相关的头文件,以及自定义的 goroutine.h. goroutine.h 文件中定义了 goroutine_execute_data 结构体,用于存储 goroutine 状态变化的数据.

  2. 全局变量:pid_target 是一个可以在运行时修改的变量,用于过滤特定进程 ID.

  3. 常量定义:GOID_OFFSET 定义了 goroutine IDgoroutine 结构体中的偏移量. 避免 Go 版本升级后,结构体偏移量变化.

  4. 环形缓冲区映射:定义了一个名为 rbBPF 环形缓冲区映射,用于将数据从 eBPF 程序传递到用户空间.

  5. eBPF 程序:uprobe_runtime_casgstatus 是主要的 eBPF 程序,它被附加到 Go 运行时的 runtime.casgstatus 函数上.

  6. 程序逻辑:

  • CPU 寄存器中提取函数参数(goroutine 指针、旧状态值、新状态值).

  • 获取当前进程的 PIDTGID(线程组 ID).

  • 如果设置了 pid_target,则只处理匹配的进程.

  • 读取 goroutine ID.

  • 创建一个 goroutine_execute_data 结构体,填充相关信息.

  • 使用 bpf_printk 打印调试信息.

  • 将数据提交到环形缓冲区.

  1. 许可声明:声明使用 GPL 许可证,这是使用某些 BPF 辅助函数所必需的.

这个程序允许你监控 Go 程序中 goroutine 的状态变化,而无需修改原始程序. 它可以用于调试、性能分析或了解 Go 程序的并发行为.

这里在启动一个程序作为观测对象(github.com/767829413/advanced-go/eBPF/uprobes/demo/main)

./eBPF/uprobes/demo/main 
Main goroutine ID: 1
Goroutine 0: Iteration 0
Goroutine 1: Iteration 0

还要获取 pid

ps -ef | grep './eBPF/uprobes/demo/main'
fangyuan  374855  335569  0 15:02 pts/8    00:00:00 ./eBPF/uprobes/demo/main
fangyuan  379190  153170  0 15:05 pts/10   00:00:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn ./eBPF/uprobes/demo/main

现在可以使用 ecc 编译,ecli 运行就可以了.

./ecc ./eBPF/uprobes/uprobes_goroutine.c ./eBPF/uprobes/goroutine.h
INFO [ecc_rs::bpf_compiler] Compiling bpf object...
INFO [ecc_rs::bpf_compiler] Generating export types...
INFO [ecc_rs::bpf_compiler] Generating package json..
INFO [ecc_rs::bpf_compiler] Packing ebpf object and config into ./eBPF/uprobes/package.json...

sudo ./ecli run ./eBPF/uprobes/package.json --pid_target=374855
INFO [faerie::elf] strtab: 0xa47 symtab 0xa80 relocs 0xac8 sh_offset 0xac8
INFO [bpf_loader_lib::skeleton::preload::section_loader] load runtime arg (user specified the value through cli, or predefined in the skeleton) for pid_target: Number(336281), real_type=<INT> 'int' bits:32 off:0 enc:signed, btf_type=BtfVar { name: "pid_target", type_id: 14, kind: GlobalAlloc }
INFO [bpf_loader_lib::skeleton::preload::section_loader] received bytes [153, 33, 5, 0]
INFO [bpf_loader_lib::skeleton::preload::section_loader] User didn't specify custom value for variable uprobe_runtime_casgstatus.___fmt, use the default one in ELF
INFO [bpf_loader_lib::skeleton::preload::section_loader] User didn't specify custom value for variable unused, use the default one in ELF
INFO [bpf_loader_lib::skeleton::preload::section_loader] User didn't specify custom value for variable __eunomia_dummy_goroutine_execute_data_ptr, use the default one in ELF
INFO [bpf_loader_lib::skeleton] Running ebpf program...
TIME     OLD_STATE NEW_STATE GOID   PID    TGID
14:32:46  WAITING(4) RUNNABLE(1) 7  336284 336281
14:32:46  WAITING(4) RUNNABLE(1) 10 336284 336281
14:32:46  WAITING(4) RUNNABLE(1) 8  336284 336281
14:32:46  WAITING(4) RUNNABLE(1) 17 336284 336281
14:32:46  WAITING(4) RUNNABLE(1) 9  336284 336281
14:32:46  RUNNABLE(1) RUNNING(2) 9  336284 336281
14:32:46  RUNNING(2) SYSCALL(3) 9   336284 336281

如果使用cilium/ebpf库, 套路还是一样的, 我们先产生“桩代码” : //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" -target bpf --go-package tool -output-dir tool Tool uprobes_goroutine.c -- -I/usr/include/bpf -I/usr/include/linux,然后实现 main.go 文件中具体的加载和 attach 等逻辑.

当然一开始我们先定义 GoroutineState 类型,与 C 语言的 enum goroutine_state 对应:

package goroutineState

import "fmt"

type GoroutineState uint32

const (
	IDLE GoroutineState = iota
	RUNNABLE
	RUNNING
	SYSCALL
	WAITING
	MORIBUND_UNUSED
	DEAD
	ENQUEUE_UNUSED
	COPYSTACK
	PREEMPTED
)

func (s GoroutineState) String() string {
	switch s {
	case IDLE:
		return "IDLE"
	case RUNNABLE:
		return "RUNNABLE"
	case RUNNING:
		return "RUNNING"
	case SYSCALL:
		return "SYSCALL"
	case WAITING:
		return "WAITING"
	case MORIBUND_UNUSED:
		return "MORIBUND_UNUSED"
	case DEAD:
		return "DEAD"
	case ENQUEUE_UNUSED:
		return "ENQUEUE_UNUSED"
	case COPYSTACK:
		return "COPYSTACK"
	case PREEMPTED:
		return "PREEMPTED"
	default:
		return fmt.Sprintf("UNKNOWN(%d)", s)
	}
}

// GoroutineExecuteData 与 C 语言的 struct goroutine_execute_data 对应
type GoroutineExecuteData struct {
	OldState GoroutineState
	NewState GoroutineState
	Goid     uint64
	Pid      uint32
	Tgid     uint32
}

接下来就是正常套路,加载编译的 eBPF 程序,attach 到目标函数,然后读取 ringbuf 中的数据. 注意数据的解析,使用 binary.Read 从字节流中读取数据,使用小端序解码,然后转换为 GoroutineExecuteData 类型.

//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -Werror" -target bpf --go-package tool -output-dir tool Tool uprobes_goroutine.c -- -I/usr/include/bpf -I/usr/include/linux

package main

import (
	"bytes"
	"encoding/binary"
	"flag"
	"fmt"
	"log"
	"os"
	"os/signal"
	"syscall"

	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/ringbuf"
	"github.com/cilium/ebpf/rlimit"

	"github.com/767829413/advanced-go/eBPF/uprobes/goroutineState"
	"github.com/767829413/advanced-go/eBPF/uprobes/tool"
)

var pidTarget int

func main() {
	// 解析命令行参数
	flag.IntVar(&pidTarget, "pid", 0, "Target PID for filtering")
	flag.Parse()

	// Allow the current process to lock memory for eBPF resources.
	if err := rlimit.RemoveMemlock(); err != nil {
		log.Fatal(err)
	}

	// 加载编译的 eBPF 程序
	spec, err := tool.LoadTool()
	if err != nil {
		log.Fatalf("loading BPF program: %v", err)
	}

	// Load pre-compiled programs and maps into the kernel.
	objs := tool.ToolObjects{}
	if err := spec.LoadAndAssign(&objs, nil); err != nil {
		log.Fatalf("loading objects: %v", err)
	}
	defer objs.Close()

	// Open a uprobe for the runtime.casgstatus function.
	ex, err := link.OpenExecutable("/opt/ddns-go/ddns-go")
	if err != nil {
		log.Fatalf("opening executable: %v", err)
	}

	err = spec.RewriteConstants(map[string]interface{}{
		"pid_target": uint32(pidTarget),
	})
	if err != nil {
		log.Fatalf("rewriting constants: %v", err)
	}

	// up, err := ex.Uretprobe("runtime.casgstatus", objs.UprobeRuntimeCasgstatus, nil)
	up, err := ex.Uprobe("runtime.casgstatus", objs.UprobeRuntimeCasgstatus, nil)
	if err != nil {
		log.Fatalf("creating uretprobe: %v", err)
	}
	defer up.Close()

	// Open a ring buffer to receive events from the eBPF program.
	rb, err := ringbuf.NewReader(objs.Rb)
	if err != nil {
		log.Fatalf("opening ringbuf reader: %v", err)
	}
	defer rb.Close()

	// 捕获 SIGINT 和 SIGTERM 信号以正确退出
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)

	fmt.Println("Listening for events...")

	// 读取 ring buffer 中的数据
	go func() {
		for {
			// 读取事件
			record, err := rb.Read()
			if err != nil {
				log.Printf("reading from ringbuf: %v", err)
				return
			}

			// 将事件解析为 GoroutineExecuteData 结构

			var data goroutineState.GoroutineExecuteData
			err = binary.Read(bytes.NewReader(record.RawSample), binary.LittleEndian, &data)
			if err != nil {
				log.Printf("parsing event: %v", err)
				return
			}

			// 输出 goroutine 信息
			fmt.Printf("TGID: %d, PID: %d, GoID: %d, OldState: %s, NewState: %s\n",
				data.Tgid, data.Pid, data.Goid, data.OldState.String(), data.NewState.String())
		}
	}()

	// 等待退出信号
	<-sig
	fmt.Println("Exiting...")
}

然后执行这个 main.go

sudo go run main.go --pid=374855
Listening for events...
TGID: 374855, PID: 374862, GoID: 36, OldState: WAITING, NewState: RUNNABLE
TGID: 374855, PID: 374862, GoID: 36, OldState: RUNNABLE, NewState: RUNNING
TGID: 374855, PID: 374862, GoID: 36, OldState: RUNNING, NewState: SYSCALL
TGID: 374855, PID: 374864, GoID: 33, OldState: WAITING, NewState: RUNNABLE

......

讨论


Uprobes(用户级探针)是一种强大的追踪机制,允许你动态地检测用户空间应用程序. 与内核中预定义的静态跟踪点(内核跟踪点)不同,uprobes 可以附加到用户空间程序的任何指令上.

在这个例子中,我们使用 uprobe 来拦截 Go 程序中 runtime.casgstatus 函数的调用. 这个函数负责改变 goroutine 的状态,使其成为追踪 goroutine 状态转换的理想目标.

以下是该解决方案的工作原理:

  1. 设置 uprobe:在 Go 代码中,我们使用 cilium/ebpf 库打开目标可执行文件,并将 uprobe 附加到 runtime.casgstatus 函数上. 这个 uprobe 链接到我们的 eBPF 程序 UprobeRuntimeCasgstatus.

  2. eBPF 程序:每次调用 runtime.casgstatus 时,eBPF 程序 uprobe_runtime_casgstatus 都会被触发. 它接收一个 struct pt_regs *ctx 参数,其中包含函数调用时的 CPU 寄存器值.

  3. 访问函数参数:eBPF 程序从 CPU 寄存器中提取函数参数. 在这个例子中,ctx->ax 包含 goroutine 指针,ctx->bx 包含旧状态,ctx->cx 包含新状态.

  4. 处理和提交数据:程序然后从内存中读取 goroutine ID,创建一个包含相关信息的 goroutine_execute_data 结构体,并将其提交到环形缓冲区,供用户空间程序消费.

  5. 过滤:程序包含一个 PID 过滤器(pid_target),可以选择只追踪特定的进程.

这种方法允许你在不修改 Go 程序或其运行时的情况下,深入了解 goroutine 的行为. 它对于调试、性能分析和理解复杂的并发 Go 应用程序的行为特别有用.

然而,需要注意的是,这种方法依赖于 Go 运行时的内部细节,这些细节可能在不同的 Go 版本之间发生变化. 始终要针对你所针对的特定 Go 版本测试你的 eBPF 程序.

扩展


怎么知道参数在调用的时候,参数存放在哪个寄存器中?

这里可以写一个简单的程序,模拟 runtime.casgstatus 的调用,然后在反汇编看看:

package main

import (
    "runtime"
    "sync"
)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)

    go func() {
        defer wg.Done()
        runtime.Gosched() // 让出 CPU,触发 goroutine 状态变化
    }()

    wg.Wait()
}

编译这个程序:

## 禁止编译器优化
go build -gcflags="-N -l" -o casgstatus_example ./github.com/767829413/advanced-go/eBPF/uprobes/casgstatus_example/casgstatus_example.go

然后使用 go tool objdump 来反编译并查看 runtime.casgstatus 的调用:

go tool objdump casgstatus_example | grep -C 1 'runtime.casgstatus'

可以看到三个参数分别放入了AXBXCX寄存器中.

根据Go ABI 规范amd64 架构下的寄存器使用规则如下:

amd64 架构使用以下序列的 9 个寄存器来表示整数参数和结果:

RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11

它使用 X0 – X14 作为浮点参数和结果.

基本原理:这些序列是从可用的寄存器中选择的,以便相对容易记住.

寄存器 R12R13 是永久暂存寄存器. R15 是一个暂存寄存器,但动态链接的二进制文件除外.

基本原理:某些操作(如堆栈增长和反射调用)需要专用的暂存寄存器,以便在不损坏参数或结果的情况下操作调用帧.

寄存器 Call meaning Body meaning
RSP Stack pointer Fixed
RBP Frame pointer Fixed
RDX Closure context pointer Scratch
R12 None Scratch
R13 None Scratch
R14 当前 goroutine Scratch
R15 GOT reference temporary Fixed if dynlink
X15 Zero value Fixed

所以呢,这三个参数按照寄存器使用规则,也是要分别放在AXBXCX寄存器中.

pid vs pgid

进程 ID (PID)

  • PID 是操作系统分配给每个进程的唯一标识符. 每个进程在系统中都有一个独立的 PID,用来标识和管理进程. 父进程创建子进程时,子进程会继承父进程的环境,但会获得一个新的 PID.

  • 你可以通过 getpid() 系统调用来获取当前进程的 PID.

  • 线程在 Linux 中也被分配了一个 PID,但它实际上是该线程的 TID(线程 ID).

进程组 ID (PGID)

  • PGID 用于将一组相关的进程归类到一个进程组中. 多个进程可以共享同一个 PGID,通常它们是由同一个父进程派生出来的. 一个进程组中的所有进程都可以由相同的控制终端来管理,尤其是在作业控制(如 shell 中的前台和后台任务)中.

  • 进程组的创建由一个进程启动新进程时完成. 最开始的进程会成为组长,并且它的 PGID 等于它的 PID.

  • 你可以通过 getpgid()getpgrp() 来获取进程的 PGID.

区别与关系

  • PID 是进程的唯一标识,而 PGID 用来标识一组相关的进程. 一个进程的 PID 可能会和它的 PGID 相同,特别是当这个进程是进程组组长时.

  • 通过进程组,可以实现对多个进程的统一管理,比如在终端中可以使用 kill -pgid 来杀掉整个进程组中的所有进程.

  • 所有线程与它们的父进程共享相同的 PGID.

因此代码中的 pid_target 是用来过滤运行 pgid 的.