使用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

Go Channel源码解读

“Don’t communicate by sharing memory, share memory by communicating.” 不要通过共享内存来通信,而要通过通信来共享内存。 channel 介绍 channel是Go语言内置的一个非常重要的feature,相比其他语言,channel为Go提供了goroutine之间通信的独特方式。它和Linux的管道很像,goroutine可以向可写的channel写入数据,也可以从channel中读取数据,还可以关闭channel。这篇文章结合Go的源码来分析channel的实现原理,包括channel的创建、读、写和关闭。 channel实现原理 hchan结构体 Go语言的channel本质上是一个带锁的等待队列的循环缓冲区队列,它的源码在runtime/chan.go中,其实就是一个hchan结构体: type hchan struct { qcount uint // total data in the queue dataqsiz uint // size of the circular queue buf unsafe.Pointer // points to an array of dataqsiz elements elemsize uint16 closed uint32 timer *timer // timer feeding this chan elemtype *_type // element type sendx uint // send index recvx uint // receive index recvq waitq // list of recv waiters sendq waitq // list of send waiters // lock protects all fields in hchan, as well as several // fields in sudogs blocked on this channel. // // Do not change another G's status while holding this lock // (in particular, do not ready a G), as this can deadlock // with stack shrinking. lock mutex } hchan结构体各成员的含义如下: ...

十月 14, 2025 · 6 分钟 · rand0m

Go语言常见错误分析

Hello,欢迎来到我的Go语言学习笔记,这一篇我们一起来看看Go语言中一些常见错误,以及一些容易被人误解的地方。(另外,也欢大家迎浏览我的上一篇笔记:详解Go Testing😀) 为了突出问题,下面举的某些例子可能比较极端,还望大家不要纠结例子的使用场景,明白其中的错误原因即可。另外,欢迎大家补充示例🫵。 Bugs🐞 变量遮蔽(shadowing)🥷🏿 在 Go 中,变量遮蔽(variable shadowing)指的是在内部作用域中声明一个与外部作用域中同名的变量,从而覆盖了外部作用域中的变量。这种bug比较低级,但是可能会出现在比较负载的函数里,而且比较隐蔽,不容易被发现。 下面是一个简单的例子,findTarget从给定strs中查找第一个target,返回索引。但是这个简单的函数存在变量遮蔽的bug(到Go Playground运行),返回值永远是-1。 func findTarget(strs []string, target string) (i int) { i = -1 for i := 0; i < len(strs); i++ { if strs[i] == target { break } } return i } 危险的map😈 未初始化 Go语言中未初始化的变量的值为此变量类型的默认值,map的默认值为nil,读值为nil的map是安全的,但是对nil的map进行写操作会引发Panic。(到Go Playround运行) func main() { var m map[string]int // read from nil map, should not panic println(m["a"]) // write to nil map, panic!!! m["a"] = 1 println(m["a"]) } 并行读写map Go语言的禁止多个goroutine同时读和写map(会引发Panic),因此如果有并行操作map的需求,必须使用锁。下面是一个在不加锁的情况下并行读写map的示例,程序会Panic(到Go Playground运行)。 ...

十月 14, 2025 · 8 分钟 · rand0m

详解Go Testing

TL; DR 本文介绍了Go原生支持的testing的两种测试方法(包内测试和包外测试)和Go支持的四种测试类型(TestXxxx、FuzzXxxx、BenchmarkXxxx和ExampleXxxx)以及使用IDE提示的run test/debug test和手动执行go test命令的区别。和实际项目开发中的被测对象相比,本文的示例比较简单,只是用于说明如何编写四种Go原生支持的测试函数。 Testing 测试的意义 测试是软件生命周期中一个重要部分,测试能带来很多好处: 减少代码缺陷,提升软件质量; 起到文档说明的作用,降低使用门槛; 加深开发人员对代码的理解,提高开发人员的自信; 提高软件开发效率; …… Go Testing Go对test有很好的支持,go专门提供了用于测试的test子命令,测试代码需要写在以go项目中以_test.go结尾的文件中。Go提供了包内测试和包外测试,测试类型又可分为四种:TestXxxx、BenchmarkXxxx、FuzzXxx、ExampleXxx。 包内测试 vs 包外测试 包内测试 包内测试面向实现。包内测试可以访问包内的所有符号(包括未导出的符号);测试代码的测试数据构造和测试逻辑通常与被测包的数据结构以及具体实现逻辑紧密结合。因此,如果修改了被测包的数据结构/实现逻辑,一般需要同步调整包内测试代码。 包外测试 包外测试面向接口。包外测试只能访问被测包导出的API;被测包的API是与外部交互的契约,契约一旦确定就应该长期保持稳定和向前兼容。因此一般修改被测包内部的数据结构和具体实现逻辑不影响包外测试代码。 四种测试类型 目前Go支持4种测试类型:四种测试方法的命名分别为:TestXxxx、BenchmarkXxxx、FuzzXxx和ExampleXxx。 TestXxxx **用途:**用来检查被测代码的输出是否符合预期,最常用的一种测试类型。 **示例:**实现一个查找最长无重复子串的函数;(直接运行) // mytest/mytest.go // leetcode problem_3: https://leetcode.cn/problems/longest-substring-without-repeating-characters/description/ func LengthOfLongestSubstring(s string) int { if len(s) == 0 { return 0 } // 哈希集合,记录每个字符是否出现过 m := map[byte]int{} n := len(s) // 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动 rk, ans := -1, 0 for i := 0; i < n; i++ { if i != 0 { // 左指针向右移动一格,移除一个字符 delete(m, s[i-1]) } for rk+1 < n && m[s[rk+1]] == 0 { // 不断地移动右指针 m[s[rk+1]]++ rk++ } // 第 i 到 rk 个字符是一个极长的无重复字符子串 ans = max(ans, rk-i+1) } return ans } func max(x, y int) int { if x < y { return y } return x } 如何验证这个函数是否符合预期?可以写一个简单的main函数,构造一些测试用例,然后调用这个函数。但是Go有自己的测试方法,只需要在被测代码的目录下创建一个以_test.go结尾的文件,并编写一个测试函数即可: ...

十月 14, 2025 · 9 分钟 · 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