[{"content":"一句话概括：零拷贝技术让数据在 磁盘 → 内核缓冲区 → 网卡 之间直接传输，避免用户态与内核态之间的冗余数据拷贝，核心是消除 CPU 参与的数据搬移。\n传统 IO 路径（4 次拷贝，2 次 CPU 参与） 1 2 磁盘 → [DMA] → Page Cache (内核) → [CPU] → 用户 Buffer → [CPU] → Socket Buffer (内核) → [DMA] → NIC ① ② ③ ④ ① DMA 读磁盘到 page cache ② CPU 把 page cache 拷到用户态 buffer（read） ③ CPU 把用户态 buffer 拷到 socket buffer（send） ④ DMA 从 socket buffer 发送到网卡 零拷贝的目标：去掉 ② 和 ③ 这两步 CPU 拷贝。\n1. sendfile（最常用，静态文件服务） 1 2 sendfile(out_fd, in_fd, \u0026amp;offset, count); // out_fd= socket, in_fd= 文件 fd 路径：\n1 2 磁盘 → [DMA] → Page Cache → [SG-DMA 直接描述] → NIC ① ② (无 CPU 数据拷贝) 底层： Page Cache 中的页面直接通过 scatter-gather DMA 发送到网卡。内核只需要把 page 指针链入 skb 的 frag 数组，DMA 引擎直接读取。CPU 不碰数据，只传元数据（指针、长度）。\n限制：\n数据必须从文件到 socket（不支持 socket→socket 或文件→文件） out_fd 必须支持 DMA 发送（通常只有 TCP socket） in_fd 必须是支持 splice_read 的文件（普通文件、块设备） 2. splice（通用 pipe 搬运，任意 fd 之间） 1 2 3 4 int pipefd[2]; pipe(pipefd); splice(fd_in, off_in, pipefd[1], NULL, len, SPLICE_F_MOVE); splice(pipefd[0], NULL, fd_out, off_out, len, SPLICE_F_MOVE); 路径：\n1 文件 → pipe buffer (只传指针，不拷数据) → socket 底层： splice 将 fd_in 的 page 引用直接挂到 pipe 的 struct pipe_buffer 上，再从 pipe 挂到 fd_out 的 skb 上。数据始终在内核 page cache 里不动，只移动了引用计数。\nsendfile vs splice：\nsendfile 是 splice 的特化（splice 内部实现就是 pipe + 两个 splice 调用） splice 可以连接任意两个 fd（文件 → socket、socket → socket、pipe → 任何），而 sendfile 只支持文件 → socket 3. mmap + write（用户态直接操作 page cache） 1 2 void *addr = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); write(sockfd, addr, len); 路径：\n1 2 3 4 磁盘 → [DMA] → Page Cache ↕ (mmap 共用同一物理页，不拷贝) 用户地址空间 ↓ write 时内核直接从 page cache 构建 skb 底层： mmap 将 page cache 的物理页直接映射到用户地址空间，用户 write 时内核直接从映射页构造 skb-\u0026gt;frags，无需 copy_from_user。\n问题：\n多线程并发写同一个 mmap 区域需加锁 文件长度变化、截断时的 SIGBUS 处理复杂 脏页回写时机不可控 4. SO_ZEROCOPY（Linux 4.14+，用户态直接发零拷贝） 1 2 3 int one = 1; setsockopt(sockfd, SOL_SOCKET, SO_ZEROCOPY, \u0026amp;one, sizeof(one)); send(sockfd, buf, len, MSG_ZEROCOPY); 路径：\n1 2 3 4 用户 Buffer → [DMA 直接读取] → NIC ① 内核锁定用户页，记录其物理地址 ② DMA 直接从用户页读取数据发送 ③ 发送完成后通知用户页可释放 底层：\n内核调用 pin_user_pages 固定用户缓冲区物理页（防止被 swap 出去） 将用户页地址填入 skb frags，DMA 引擎直接读取用户态内存 发送完成通过 recvmsg 返回 SO_EE_ORIGIN_ZEROCOPY 通知用户释放 去掉了一次内核态到用户态的拷贝（copy_from_user），但需要 pin/unpin 开销。\n适用场景： 大块数据发送（\u0026gt;10KB），小包反而更慢（pin/unpin 开销 \u0026gt; 直接拷贝）\n5. io_uring + 注册缓冲区（Linux 5.1+） 1 2 3 4 5 6 7 8 // 注册固定缓冲区（一次 pin，多次复用） struct iovec iov = { .iov_base = buf, .iov_len = len }; io_uring_register_buf_ring(ring, \u0026amp;iov, 1); // 提交 IO，直接引用已 pin 的 buffer struct io_uring_sqe *sqe = io_uring_get_sqe(ring); io_uring_prep_read_fixed(sqe, fd, buf, len, 0, buf_index); io_uring_prep_write_fixed(sqe, fd, buf, len, 0, buf_index); 路径：\n1 磁盘 → [DMA] → 用户注册 Buffer（直接） 底层： io_uring_register_buf_ring 一次 pin_user_pages 后返回 buffer index，后续所有 IO 请求直接通过 index 引用 buffer，无需再 pin/unpin。避免了每次 IO 的页锁定开销，同时去掉 CPU 拷贝。\n6. RDMA（InfiniBand / RoCE / iWARP，硬件级零拷贝） 1 2 3 4 5 6 7 8 // 注册 MR (Memory Region) struct ibv_mr *mr = ibv_reg_mr(pd, buf, len, IBV_ACCESS_LOCAL_WRITE | IBV_ACCESS_REMOTE_WRITE); // 远端直接写本端内存（不需要本端 CPU 参与） struct ibv_sge sge = { .addr = (uintptr_t)buf, .length = len, .lkey = mr-\u0026gt;lkey }; struct ibv_send_wr wr = { .wr_id = 1, .sg_list = \u0026amp;sge, .num_sge = 1, .opcode = IBV_WR_RDMA_WRITE }; ibv_post_send(qp, \u0026amp;wr, \u0026amp;bad_wr); 路径：\n1 2 远端内存 → [网卡硬件 DMA] → 本端用户内存 全程不需要本端 CPU 参与 底层： 网卡通过 ibv_reg_mr 获取用户虚拟地址 → 物理页的映射表，远端网卡直接写入本端物理页，旁路内核，零 CPU。本端应用直接看到数据，不经过内核 socket buffer。\n代价：\n需要 RDMA 硬件（InfiniBand 交换机/网卡或支持 RoCE 的以太网卡） 内存注册（ibv_reg_mr）开销大（页表遍历 + pin pages），需池化复用 7. DPDK（用户态驱动，完全绕过内核） 1 2 3 应用 → DPDK PMD → NIC ↑ 全程用户态操作 网卡直接把数据 DMA 到用户态预先分配的 hugepage 里。没有系统调用，没有上下文切换，没有内核数据结构。\n但代价是应用层必须实现 TCP 协议栈（如 mTCP、F-Stack）。\n各场景推荐 场景 推荐技术 Web 服务器静态文件 sendfile 反向代理/网关转发 splice 大块数据发送（视频服务） SO_ZEROCOPY 高性能存储 + 网络 io_uring + registered buffers 超低延迟（\u0026lt; 10us） RDMA 纯包转发（\u0026gt; 100Gbps） DPDK 注意： 零拷贝不是银弹。小包场景下，CPU 拷贝的延迟远小于 pin/unpin 或上下文切换的开销，传统路径反而更快。Nginx 对 \u0026lt; 16KB 的文件直接用 read + write，超过才用 sendfile——这也是一种工程权衡。\n","date":"2026-05-18T00:00:00Z","permalink":"/p/%E9%9B%B6%E6%8B%B7%E8%B4%9D%E6%8A%80%E6%9C%AF/","title":"零拷贝技术"},{"content":" 本文是对 Build a Container from Scratch in Go (Modern Namespaces + cgroup v2) 这篇文章的翻译，以下是正文\n作者：Faizan Firdousi 发布时间：2026 年 2 月 18 日 我用 Go 从零构建了一个容器，代码行数尽可能少，并在过程中了解了容器内部通常发生的各种事情——也就是 Docker 抽象掉的那些细节。\n虽然网上也有类似的文章，但我写这篇是因为 Linux 内核的一些变化，导致很多博客在理解和代码实现上已经有些过时了。\n什么是容器？ 首先理解一下容器是什么。你可能已经知道，从根本上说，容器就是“将依赖打包起来，以便以可重复、安全的方式交付代码”。\n但让我们深入理解背后的概念：命名空间（Namespaces）、控制组（Cgroups）和文件系统隔离，这些才是容器的基石。\n命名空间 Linux 命名空间是内核的一个基础特性，它提供资源隔离，让不同的进程集合看到不同的资源视图。\n命名空间非常重要，稍后我们会看到它的使用频率，因为它们是容器构建的核心技术。当你用 Docker 或 Podman 创建容器时，它会自动为你创建命名空间。\n命名空间主要有 6 种类型（注意，我们会在编写代码时更详细地介绍它们，因为我喜欢边做边学，不会一开始就塞给你太多信息）：\nPID —— 分配独立的进程 ID。新命名空间中的第一个进程获得 PID 1。 Network —— 通过虚拟以太网对提供独立的网络栈。 MNT —— 维护独立的挂载点列表，允许在不影响宿主机的情况下挂载/卸载文件系统。 USER —— 拥有自己的用户 ID 和组 ID，允许一个进程在自己的命名空间内拥有 root 权限，但在别处没有。 IPC —— 隔离进程间通信资源，如 POSIX 消息队列。 UTS —— 允许同一系统上的不同进程拥有不同的主机名和域名。 Cgroups 理解 cgroups 的表面含义很容易——它代表控制组（control groups），是 Linux 内核的一个特性，用于限制、统计和隔离进程集合的资源使用，包括 CPU、内存、磁盘 I/O 和网络。\n关于 cgroups 可以讲很多，但这里我只介绍实现所需的基础知识，后面还会再提到。\n为了方便一步步深入理解，我们把代码分成几个阶段。\n这里是完整代码的 GitHub 链接（请给个 star）：\nhttps://github.com/faizanfirdousi/container-from-scratch\n阶段 0 首先，明确一下我们要做什么：主要目标是创建一个与宿主机隔离的容器。换句话说，我们要构建一个沙盒环境，运行一个拥有自己文件系统、进程命名空间和资源限制的 shell，从而与宿主机实现强隔离。\n容器可以运行任何东西。为了简单起见，我们将使用 Alpine Linux 容器，因为它体积小，同时也拥有完整的组件，能提供完整的系统感觉。最终，我们将创建一个容器——或者说一个进程——它认为 Alpine 目录就是整个计算机。\n为此，我们需要 Alpine 的根文件系统。在开始编码前先准备好：\n1 2 3 4 # 下载并解压 Alpine Linux rootfs docker export $(docker create alpine) -o alpine.tar mkdir -p /home/faizan/alpine-rootfs tar -xf alpine.tar -C /home/faizan/alpine-rootfs 如果你没有 Docker，可以通过 curl 安装。这里用它只是为了简单。\n阶段 1 首先，我们创建一个简单的程序来运行命令，但它还没有任何隔离。这似乎有点反直觉，但有一个很好的理由：通过观察没有隔离时会发生什么，当我们后续添加每个隔离原语时，你才能真正理解它们的作用。\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 package main import ( \u0026#34;fmt\u0026#34; \u0026#34;os\u0026#34; \u0026#34;os/exec\u0026#34; ) func main() { if len(os.Args) \u0026lt; 2 { fmt.Fprintf(os.Stderr, \u0026#34;Usage: %s run \u0026lt;cmd\u0026gt; [args...]\\n\u0026#34;, os.Args[0]) os.Exit(1) } switch os.Args[1] { case \u0026#34;run\u0026#34;: run() default: panic(\u0026#34;Unknown command\u0026#34;) } } func run() { fmt.Printf(\u0026#34;Running %v\\n\u0026#34;, os.Args[2:]) cmd := exec.Command(os.Args[2], os.Args[3:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr must(cmd.Run()) } func must(err error) { if err != nil { panic(err) } } 这段代码做了什么 程序首先检查传递给它的参数。第一个参数必须是 run，告诉我们的程序要执行一个命令。run 之后的所有内容都成为要执行的命令及其参数——例如 /bin/bash 或 /bin/sh。\n当 run 函数被调用时，它使用 Go 的 exec.Command 创建一个新进程。工作原理很简单：os.Args[2] 包含命令本身（如 /bin/bash），os.Args[3:] 包含要传递给该命令的任何附加参数。... 语法将这些参数展开，逐个传递给命令。\n然后我们将标准输入、输出和错误流连接起来。这很重要，因为它允许命令与你的终端自然交互。你可以像任何普通命令一样输入内容并看到输出。最后，cmd.Run() 实际执行命令并等待它完成。\n测试 1 go run main.go run /bin/bash 你应该会看到类似这样的输出：\n1 2 Running [/bin/bash] [root@archlinux cfs]# 测试一下是否隔离：运行 ps 和 hostname 等命令。\n现在让我展示一下测试结果。在 shell 中运行 ps，仔细观察输出。\n注意到什么有趣的事情了吗？PID 并不是从 1 开始（就像在真正的容器中那样）。相反，你看到的 PID 是 46628、46629、46669 等——这些正是宿主机看到的进程 ID。如果你在宿主机上打开另一个终端窗口并运行 ps aux，你会看到完全相同的 PID 值。这证明我们与宿主机共享着同一个进程命名空间。\n现在检查主机名。\n运行 hostname，你会看到你机器的真实主机名。试着用 sudo hostname test-container 修改它，然后再次运行 hostname 确认修改成功。退出这个 shell，再检查你宿主机的 hostname——它也被修改了！我们直接修改了宿主系统的 hostname。我们的“容器”内部没有任何隔离屏障来保护宿主机免受修改的影响。\n文件系统也是一样。当你运行 ls / 时，你看到的是实际宿主机的根目录。你在 /tmp 中创建的任何文件都会出现在宿主机上。我们完全在宿主机系统上操作，没有任何隔离。\n为什么这很重要 我们现在构建的东西基本上只是一个执行命令的包装器——根本没有发生任何容器化。但这个基线很重要。在接下来的步骤中，当我们添加 Linux 命名空间时，你会看到情况会发生多么戏剧性的变化。PID 将从 1 开始，修改 hostname 不会影响宿主机，我们还将拥有自己隔离的文件系统。\n阶段 2 现在把 run() 函数更新为如下所示：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 func run() { fmt.Printf(\u0026#34;Running %v\\n\u0026#34;, os.Args[2:]) // 重新执行我们自己作为 \u0026#34;child\u0026#34;，并进入新的命名空间 cmd := exec.Command(\u0026#34;/proc/self/exe\u0026#34;, append([]string{\u0026#34;child\u0026#34;}, os.Args[2:]...)...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.SysProcAttr = \u0026amp;syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, Unshareflags: syscall.CLONE_NEWNS, } must(cmd.Run()) } 并添加这个新的 child() 函数：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 func child() { fmt.Printf(\u0026#34;Running %v as child\\n\u0026#34;, os.Args[2:]) cmd := exec.Command(os.Args[2], os.Args[3:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr must(syscall.Sethostname([]byte(\u0026#34;container\u0026#34;))) if err := cmd.Run(); err != nil { fmt.Println(\u0026#34;Process exited:\u0026#34;, err) } } 让我们理解一下这里在做什么。首先，我们需要创建命名空间。在 run() 中我们写下：\n1 cmd := exec.Command(\u0026#34;/proc/self/exe\u0026#34;, append([]string{\u0026#34;child\u0026#34;}, os.Args[2:]...)...) 这是关键洞察：我们不是直接运行 /bin/bash。相反，我们重新执行我们自己的 Go 程序（/proc/self/exe 是当前运行二进制文件的路径），但使用了一个新的参数列表：[\u0026quot;child\u0026quot;, \u0026quot;/bin/bash\u0026quot;]。\n为什么需要这种奇怪的自我重新执行？因为 Linux 命名空间只能在创建新进程时建立，你不能为一个已经运行的进程创建命名空间。所以我们需要让我们的代码再次运行，但这一次处于全新的隔离命名空间内部。\n然后我们使用以下代码实际创建命名空间：\n1 2 3 4 cmd.SysProcAttr = \u0026amp;syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWUTS | syscall.CLONE_NEWPID | syscall.CLONE_NEWNS, Unshareflags: syscall.CLONE_NEWNS, } 正如我在博客开头所说，不同的命名空间隔离不同的东西。这里我们使用 Linux 系统调用来创建命名空间。具体来说，我们使用带有特殊标志的 clone() 系统调用。\n我们使用的系统调用属于 CLONE_* 家族。每个 CLONE_NEW* 标志告诉 Linux 内核：“当你创建这个子进程时，把它放到一个独立的、针对该资源的命名空间中。”让我分解一下每个标志的作用：\nCLONE_NEWUTS 创建一个新的 UTS（Unix Time-Sharing）命名空间。这隔离了主机名和域名。有了这个标志，当子进程将其主机名改为 \u0026ldquo;container\u0026rdquo; 时，宿主机的主机名完全保持不变。如果没有这个标志，内部修改 hostname 会影响到你的实际宿主机——这正是我们在阶段 1 中看到的情况。\nCLONE_NEWPID 创建一个新的 PID（进程 ID）命名空间。这非常重要。这意味着子进程获得了一个完全独立的进程 ID 编号系统。在容器内部，shell 会认为自己是 PID 1——第一个进程，就像真正的 Linux 启动中的 init 系统。但从宿主机的角度看，同一个进程可能只是 PID 15234。这就是为什么在真正的容器中运行 ps aux 时，你只能看到容器内的进程，而不是宿主机的所有进程。\nCLONE_NEWNS 创建一个新的 Mount 命名空间。这隔离了文件系统挂载点。我们在容器内部做的任何挂载（如挂载 /proc 或 /tmp）都不会出现在宿主机上，反之亦然。这对文件系统隔离至关重要。\n这些标志之间的 |（竖线）是位或操作——它表示同时启用多个命名空间。本质上我们在说：“为子进程同时创建这三类命名空间。”\nUnshareflags 配合 CLONE_NEWNS 是一个额外的安全措施。它使挂载命名空间“不可共享”，以防止挂载传播，基本上确保容器内的任何文件系统变更绝对不会泄露到宿主机。\n测试 现在测试一下我们的命名空间隔离是否真的有效。运行你更新后的代码：\n你应该会看到：\n1 2 3 Running [/bin/bash] Running [/bin/bash] as child [root@container cfs]# 你会注意到提示符中的主机名已经显示为 container——这正是 Sethostname 调用生效的结果。\n现在检查修改 hostname 是否保持隔离：修改容器内的 hostname，然后检查宿主机的 hostname 是否也被改变了。\n接下来检查 PID 命名空间隔离——这个很有趣也很重要。\n在容器内部运行：\n1 echo $$ 在你的宿主机上（另一个终端）：\n1 ps aux | grep bash 同一个进程，两个不同的 PID！ 在命名空间内部它是 PID 7（对你来说可能是其他较小的数字），从宿主机看它是 PID 44999。这证明了 PID 隔离正在工作。\n测试 /proc 问题 在容器内部运行 ps：\n1 ps aux 你会看到宿主机的所有进程——systemd、kthreadd、Chrome，等等。为什么？因为即使我们有 PID 命名空间隔离，我们仍然在读取宿主机的 /proc 文件系统。ps 命令从 /proc 获取信息，而我们还没有隔离 /proc。\n阶段 3 更新你的 child() 函数，添加文件系统隔离。首先，在 cmd 设置之后立即添加环境变量：\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 func child() { fmt.Printf(\u0026#34;Running %v \\n\u0026#34;, os.Args[2:]) cmd := exec.Command(os.Args[2], os.Args[3:]...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // 添加这些环境变量 cmd.Env = []string{ \u0026#34;PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\u0026#34;, \u0026#34;HOME=/root\u0026#34;, \u0026#34;TERM=xterm\u0026#34;, } must(syscall.Sethostname([]byte(\u0026#34;container\u0026#34;))) // 添加文件系统隔离 must(syscall.Chroot(\u0026#34;/home/faizan/alpine-rootfs\u0026#34;)) must(os.Chdir(\u0026#34;/\u0026#34;)) // 挂载 /proc 文件系统 must(syscall.Mount(\u0026#34;proc\u0026#34;, \u0026#34;proc\u0026#34;, \u0026#34;proc\u0026#34;, 0, \u0026#34;\u0026#34;)) // 在 /tmp 挂载 tmpfs must(os.MkdirAll(\u0026#34;tmp\u0026#34;, 0755)) must(syscall.Mount(\u0026#34;tmpfs\u0026#34;, \u0026#34;tmp\u0026#34;, \u0026#34;tmpfs\u0026#34;, 0, \u0026#34;\u0026#34;)) if err := cmd.Run(); err != nil { fmt.Println(\u0026#34;Process exited:\u0026#34;, err) } // 退出时清理挂载 syscall.Unmount(\u0026#34;proc\u0026#34;, 0) syscall.Unmount(\u0026#34;tmp\u0026#34;, 0) } 环境变量 1 2 3 4 5 cmd.Env = []string{ \u0026#34;PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\u0026#34;, \u0026#34;HOME=/root\u0026#34;, \u0026#34;TERM=xterm\u0026#34;, } 这是一个虽小但重要的细节。当子进程被生成时，默认会继承父进程的环境变量，这意味着它会继承你宿主机的 PATH、宿主机的 HOME 等等。这是一个问题，因为在我们下一步进行文件系统隔离之后，那些宿主机路径在容器内部将根本不存在。\n所以我们显式设置了一个干净、最小的环境。PATH 告诉 shell 去哪里找可执行文件。HOME 设置家目录。TERM=xterm 确保终端行为正常工作，比如清屏、箭头键、彩色输出。没有 TERM，很多终端程序会表现异常或拒绝运行。\nChroot（关键步骤） 1 2 must(syscall.Chroot(\u0026#34;/home/faizan/alpine-rootfs\u0026#34;)) must(os.Chdir(\u0026#34;/\u0026#34;)) chroot 为这个进程及其所有子进程重新定义了 / 的含义。在这个调用之后，当 shell 打开 /etc/passwd 时，内核将其解析为 /home/faizan/alpine-rootfs/etc/passwd。宿主机的文件系统变得完全不可见。运行 ls /，你会看到 Alpine 的 bin、etc、usr，就像一台真正的 Alpine 机器。\nchroot 改变了 / 指向的位置，但你的当前工作目录并不会移动。所以如果你在运行代码的目录下，chroot 之后你仍然停留在那个目录——也就是在“监狱”外面。从那里，一个进程可以简单地执行 cd ../../.. 然后直接走出去。os.Chdir(\u0026quot;/\u0026quot;) 立即将你移入监狱内部，这样就没有地方可以逃逸了——非常完美。\n挂载 /proc —— 修复 ps 读取的问题 1 must(syscall.Mount(\u0026#34;proc\u0026#34;, \u0026#34;proc\u0026#34;, \u0026#34;proc\u0026#34;, 0, \u0026#34;\u0026#34;)) 在 chroot 之后，Alpine 的 /proc 是一个空目录。ps 命令从 /proc/\u0026lt;pid\u0026gt;/status 读取所有信息，如果不挂载东西，ps 就会失败。\n因此我们挂载一个新的 procfs。关键细节：这不是宿主机 /proc 的副本。内核根据当前 PID 命名空间能看到什么来填充它。由于我们处于阶段 2 的 CLONE_NEWPID 内部，所以只有容器的进程会出现在这里。现在运行 ps aux，你只会看到容器的 shell。宿主机进程的洪流消失了。这是 PID 隔离和文件系统隔离终于协同工作。\n在 /tmp 挂载 tmpfs 1 2 must(os.MkdirAll(\u0026#34;tmp\u0026#34;, 0755)) must(syscall.Mount(\u0026#34;tmpfs\u0026#34;, \u0026#34;tmp\u0026#34;, \u0026#34;tmpfs\u0026#34;, 0, \u0026#34;\u0026#34;)) tmpfs 是基于内存的文件系统。容器内部写入 /tmp 的任何内容都存在于 RAM 中，永远不会触及宿主机的 /tmp，并且在容器退出的那一刻就会消失。这也是为什么阶段 2 中的 CLONE_NEWNS 很重要。如果没有挂载命名空间，这些挂载会传播到宿主机的挂载表。挂载命名空间是前提条件，而这些挂载是实际负载。\n测试 现在检查一切：是否显示容器自身的文件系统和进程，而不是宿主机的。\n阶段 4 —— Cgroups 现在我们有了一个真正隔离的容器：自己的文件系统、自己的进程树、自己的主机名。但仍然存在一个问题：容器内的进程没有任何限制来阻止它消耗宿主机的所有 CPU、生成数千个进程或吃掉所有 RAM。容器内部一个简单的 fork bomb 就会让整台机器崩溃。\n这就是 cgroups（控制组）要解决的问题。命名空间控制进程能看到什么，而 cgroups 控制进程能使用多少资源。\n因此，用这个函数更新你的代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 func cg() { cgroupRoot := \u0026#34;/sys/fs/cgroup/\u0026#34; cgroupName := \u0026#34;container-cgroup\u0026#34; containerCgroup := filepath.Join(cgroupRoot, cgroupName) os.WriteFile( filepath.Join(cgroupRoot, \u0026#34;cgroup.subtree_control\u0026#34;), []byte(\u0026#34;+pids +memory +cpu\u0026#34;), 0644, ) must(os.MkdirAll(containerCgroup, 0755)) must(os.WriteFile(filepath.Join(containerCgroup, \u0026#34;pids.max\u0026#34;), []byte(\u0026#34;20\u0026#34;), 0700)) must(os.WriteFile(filepath.Join(containerCgroup, \u0026#34;memory.max\u0026#34;), []byte(\u0026#34;52428800\u0026#34;), 0700)) must(os.WriteFile(filepath.Join(containerCgroup, \u0026#34;cpu.max\u0026#34;), []byte(\u0026#34;50000 100000\u0026#34;), 0700)) must(os.WriteFile( filepath.Join(containerCgroup, \u0026#34;cgroup.procs\u0026#34;), []byte(strconv.Itoa(os.Getpid())), 0700, )) } Cgroup v2 这正是我写这篇博客的主要原因。你看，大多数博客都是基于 cgroups v1 的，但很早以前内核就已经切换到 cgroup v2 了，所以代码也得相应地改变。\nCgroup v2 使用统一层级结构。每个控制器——pids、memory、cpu——都位于 /sys/fs/cgroup/ 下的单棵树中。这与 v1 不同，v1 为每个控制器使用单独的树，例如 /sys/fs/cgroup/pids/、/sys/fs/cgroup/memory/ 等等。如果你看过使用 v1 路径的老容器博客，那就是为什么它们在现代内核上无法工作的原因。\n操作 cgroups 完全通过文件系统进行——你创建目录，向文件写入内容。不需要特殊的系统调用。\n1 2 3 4 5 os.WriteFile( filepath.Join(cgroupRoot, \u0026#34;cgroup.subtree_control\u0026#34;), []byte(\u0026#34;+pids +memory +cpu\u0026#34;), 0644, ) 首先，我们在根 cgroup 上启用控制器。向 subtree_control 写入 +pids +memory +cpu 告诉内核让这些控制器可用于我们后续创建的子 cgroup。这就像在使用功能之前先解锁它。\n创建 Cgroup 1 must(os.MkdirAll(containerCgroup, 0755)) 这就是创建 cgroup 的全部操作。你在 /sys/fs/cgroup/ 下创建一个目录，内核会识别它，将其视为一个新的 cgroup，并自动在其中填充控制文件：pids.max、memory.max、cpu.max、cgroup.procs 等等。不需要其他任何操作。\n设置限制 1 2 3 must(os.WriteFile(filepath.Join(containerCgroup, \u0026#34;pids.max\u0026#34;), []byte(\u0026#34;20\u0026#34;), 0700)) must(os.WriteFile(filepath.Join(containerCgroup, \u0026#34;memory.max\u0026#34;), []byte(\u0026#34;52428800\u0026#34;), 0700)) must(os.WriteFile(filepath.Join(containerCgroup, \u0026#34;cpu.max\u0026#34;), []byte(\u0026#34;50000 100000\u0026#34;), 0700)) pids.max 是最直接重要的一个。将其设置为 20 意味着一旦进程数达到 20，内核就会开始拒绝 fork() 和 clone() 调用。容器内部的 fork bomb 只会自己崩溃，宿主机不受影响。\nmemory.max 是 52428800 字节 —— 50 MiB。如果容器超过这个限制，内核的 OOM killer 会介入并杀死超限的进程。宿主机的内存永远不会受到威胁。\ncpu.max 的格式是 $MAX $PERIOD，单位是微秒。50000 100000 的意思是：在每 100ms 的时间窗口内，这个 cgroup 最多获得 50ms 的 CPU 时间，即单核的 50%。容器内部失控的 CPU 循环会在内核调度器层面被限流。你的机器保持响应。\n将进程移入 1 2 3 4 5 must(os.WriteFile( filepath.Join(containerCgroup, \u0026#34;cgroup.procs\u0026#34;), []byte(strconv.Itoa(os.Getpid())), 0700, )) 之前的所有操作都只是配置。这行代码才是真正执行限制的。将我们的 PID 写入 cgroup.procs 会将当前进程移入 cgroup。从这一刻起，这个进程及其产生的所有子进程——包括 shell 和用户运行的所有东西——都将受到上述三个限制的约束。\n测试 在容器内部尝试一个 fork bomb：\n1 :(){ :|:\u0026amp; };: 由于 pids.max = 20，内核会在超过限制后拒绝每一个 fork() 调用。容器 shell 会死掉。在宿主机上打开另一个终端，一切正常运行。如果没有 cgroups，那个 fork bomb 会需要硬重启才能恢复。\n好了！你已经从零构建了一个真正的容器，具备了：使用命名空间的隔离、使用 chroot 的文件系统虚拟化、以及使用 cgroup v2 的资源限制。这本质上就是 Docker 在底层做的事情，只不过 Docker 有更多的功能、更好的用户体验和生产级工具。\n","date":"2026-05-13T00:00:00Z","permalink":"/p/%E8%AF%91%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%94%A8-go-%E6%9E%84%E5%BB%BA%E5%AE%B9%E5%99%A8%E7%8E%B0%E4%BB%A3%E5%91%BD%E5%90%8D%E7%A9%BA%E9%97%B4--cgroup-v2/","title":"【译】从零开始用 Go 构建容器：现代命名空间 + cgroup v2"},{"content":"1. 为什么 Go 需要汇编 Go 在以下场景允许直接嵌入汇编：\n性能热点：手写 SIMD 指令、避免编译器优化不足 系统调用：部分 runtime 代码（如 runtime·sys_linux_amd64.s）直接发起 syscall 引导/启动：rt0_linux_amd64.s 等启动代码必须在汇编层面完成栈和寄存器初始化 密码学：crypto/aes, crypto/sha256 等标准库大量使用汇编加速 Go 使用 Plan 9 汇编语法，与 AT\u0026amp;T / Intel 语法都不同。\n2. Plan 9 汇编基础 2.1 操作数顺序 Plan 9 汇编是源操作数在前，目标操作数在后（类似 AT\u0026amp;T，但无 % / $ 前缀）：\n1 2 ADDQ BX, AX // AX = AX + BX (Intel: add rax, rbx) MOVQ x+0(FP), AX // AX = *(FP + x_offset) 2.2 寄存器命名 通用寄存器 Plan 9 名 Intel 名 AX / EAX / AX:BX AX RAX BX BX RBX CX CX RCX DX DX RDX Stack Pointer SP RSP Base Pointer BP RBP R8–R15 R8–R15 R8–R15 X0–X15 (SSE) X0–X15 XMM0–XMM15 2.3 伪寄存器（核心概念） Go 汇编定义了四个伪寄存器，没有物理硬件对应：\n伪寄存器 含义 示例 SB Static Base — 全局符号基址 TEXT ·Sum(SB) 声明全局函数 FP Frame Pointer — 指向参数和返回值区域的起始 x+0(FP) 访问第一个参数 SP 栈指针 — 指向当前 goroutine 栈顶 MOVQ AX, local+0(SP) 局部变量 PC Program Counter — 指令地址 用于跳转表和 CALL 重点：FP 是函数参数/返回值的入口地址，硬件寄存器 SP 是 RSP，而汇编中的 SP 指的是局部变量区的基址。两者是不同的。\n2.4 指令后缀 后缀 操作数大小 对应 C 类型 B 1 字节 byte / uint8 W 2 字节 uint16 L 4 字节 uint32 Q 8 字节 uint64 / int (64-bit) 3. 函数声明与栈帧 3.1 TEXT 指令 1 TEXT ·函数名(SB), $帧大小-参数大小 ·函数名：·（U+00B7）是包分隔符，等价于 Go 的 . SB：使该符号全局可见，Go 链接器可找到它 $N-M： N：局部变量所需的栈空间（0 表示不需要） M：参数 + 返回值占用的总字节数 3.2 栈布局（amd64） 1 2 3 4 5 6 7 8 9 10 11 12 调用者视角: +------------------+ 高地址 | caller frame | +------------------+ | return addr | (8 bytes, 由 CALL 压栈) +------------------+ ← 被调函数的硬件 SP (RSP) | 参数1 x | x+0(FP) | 参数2 y | y+8(FP) | 返回值 | ret+16(FP) +------------------+ ← FP (伪寄存器) | 局部变量区 | local+0(SP) ... local+N(SP) +------------------+ ← 硬件 SP 最终位置 关键：FP 指向参数区的起始，SP（伪寄存器）指向局部变量区的起始。返回值存放在参数区的最末尾，紧接在参数之后。\n3.3 参数与返回值的偏移计算 对于 func Sum(x, y int) int（amd64，int = 8 字节）：\n1 2 3 4 FP + 0: x (8 bytes) FP + 8: y (8 bytes) FP + 16: ret (8 bytes) 总大小: 24 bytes 因此 TEXT 指令的帧大小应为 $0-24（0 字节局部变量 + 24 字节参数/返回值空间）。\n4. 实例解析 4.1 Go 侧 (main.go) 1 2 3 4 5 6 7 8 9 10 11 12 package main import \u0026#34;fmt\u0026#34; func main() { x := 10 y := 20 sum := Sum(x, y) fmt.Println(\u0026#34;Sum:\u0026#34;, sum) } func Sum(x, y int) int // 只声明，无函数体 Sum 只有签名没有实现体 Go 编译器看到这样的声明会自动查找同名汇编符号 注意：汇编函数名大小写敏感，Sum 大写表示导出（exported） 4.2 汇编侧 (add.s) 1 2 3 4 5 6 TEXT ·Sum(SB), $0-24 MOVQ x+0(FP), AX // AX = x MOVQ y+8(FP), BX // BX = y ADDQ BX, AX // AX = AX + BX MOVQ AX, ret+16(FP) // 将结果写入返回值位置 RET 逐行解释：\nMOVQ x+0(FP), AX — 从栈上偏移 0 处取出参数 x，放入 AX MOVQ y+8(FP), BX — 从栈上偏移 8 处取出参数 y，放入 BX ADDQ BX, AX — AX += BX MOVQ AX, ret+16(FP) — 将 AX 的值写入返回值槽位 RET — 函数返回，调用者从 ret+16(FP) 处读取结果 4.3 编译与运行 1 2 3 4 5 6 7 8 9 10 11 12 13 # 直接 go build，汇编文件 (.s) 会被自动识别 go build -o go-asm ./go-asm # 输出: Sum: 30 # 查看 Go 生成的汇编（对比编译器生成版本） go tool compile -S main.go # 只编译汇编文件 go tool asm add.s # 反汇编已编译的二进制 go tool objdump -s Sum go-asm 5. 调试与工具 5.1 验证栈帧大小 编译时设置 -asan 或者直接检查：\n1 go build -gcflags=\u0026#34;-live\u0026#34; 2\u0026gt;\u0026amp;1 | head 5.2 查看调用约定兼容性 1 2 # 确认寄存器传参是否被使用（Go 1.17+ ABI 内部变化） go build -gcflags=\u0026#34;-S\u0026#34; 2\u0026gt;\u0026amp;1 | grep Sum Go 1.17 起引入了基于寄存器的调用约定（ABIInternal），参数和返回值优先通过寄存器传递而非栈。但手写的汇编 .s 文件默认仍使用栈传参（ABI0）。如果需要兼容 ABIInternal，需要在 TEXT 指令中指定。\n6. 常见陷阱 6.1 帧大小标注不匹配 如果 TEXT 中声明的 $N-M 与实际使用的偏移量不一致，链接时可能不会报错但运行时会产生数据竞争或栈损坏。\n当前仓库中 add.s 的 $0-8 应修正为 $0-24（amd64 下 int 为 8 字节，参数 + 返回值共 24 字节）。\n6.2 符号命名 Go 中使用 ·（middle dot）分隔包名和函数名，不可用普通点 . 导出函数名（首字母大写）在汇编中同样需要大写 使用 \u0026quot;\u0026quot;.Sum 或 ·Sum 均可（后者是缩写形式） 6.3 栈对齐 x86-64 ABI 要求栈在 CALL 前 16 字节对齐。如果手动调整栈指针，需要确保对齐。\n6.4 寄存器保存 Go 汇编中，调用者需要保存 callee-saved 寄存器（BP、BX、R12–R15）。AX、CX、DX 等是调用者保存的 scratch 寄存器，可以随意使用。\n6.5 Go 版本差异 版本 变化 Go 1.17 引入 ABIInternal（寄存器传参），汇编默认仍使用 ABI0 Go 1.21+ 部分标准库汇编适配寄存器 ABI Go 1.22+ GOAMD64=v3 支持 AVX2 等新指令 7. 参考资源 A Quick Guide to Go\u0026rsquo;s Assembler — Go 官方汇编快速指南 go tool compile -S xxx.go — 查看编译器生成的汇编 go doc cmd/asm — 汇编器文档 标准库 crypto/ 目录下大量 .s 文件可供学习 ","date":"2026-05-11T00:00:00Z","permalink":"/p/go-%E4%B8%8E%E6%B1%87%E7%BC%96/","title":"Go 与汇编"},{"content":" 既然都 panic 了，不就是想让程序崩溃吗？为什么还要“恢复”它？\npanic 不一定是“程序该死”，更多时候是“当前请求/任务该死”。\n我们可以把程序分为两种崩溃模式：\n1. 致命错误 —— 不应该 recover 例如：配置文件缺失、数据库连接失败、关键初始化逻辑出错。\n这种错误发生后，程序已无法继续正常运行，panic 后不应该 recover，让程序崩溃退出是正确的。\n2. 局部错误 —— 应该 recover 很多 HTTP/RPC 服务、goroutine 子任务中，panic 只代表当前请求或子任务出错，而不代表整个进程该死。\n例如：\n一个 HTTP 请求的处理函数里数组越界 panic 一个 goroutine 里发生了未预期的空指针 如果不用 recover：\n整个进程崩溃 → 服务停止 → 影响所有用户 如果用 recover：\n该请求返回 500 错误 主进程继续运行，其他请求不受影响 示例对比 没有 recover（错误） 1 2 3 4 5 func handler(w http.ResponseWriter, r *http.Request) { var arr []int _ = arr[1] // panic } // 一个请求出错 → 整个程序崩溃 有 recover（正确） 1 2 3 4 5 6 7 8 9 10 11 12 func handler(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { log.Printf(\u0026#34;panic: %v\u0026#34;, err) w.WriteHeader(http.StatusInternalServerError) w.Write([]byte(\u0026#34;internal error\u0026#34;)) } }() var arr []int _ = arr[1] // panic 被捕获，该请求返回 500，进程继续 } 总结：什么时候 recover？ 场景 是否 recover 原因 初始化失败 ❌ 程序无法运行 HTTP 请求内 panic ✅ 只影响单个请求 goroutine 任务 ✅ 防止进程崩溃 明确不该出现的 bug 看情况 开发时可崩溃，生产环境建议 recover + 记录日志 panic 代表“当前逻辑无法继续执行”，但不代表“整个进程必须死”。 recover 是在进程层面保护边界（请求边界、任务边界），让错误可控、服务保持可用。 ","date":"2026-05-08T00:00:00Z","permalink":"/p/deferpanic%E4%B8%8Erecover/","title":"defer、panic与recover"},{"content":"一、常用输出函数（按输出目标分类） 函数 说明 示例 Print / Println / Printf 输出到标准输出 fmt.Println(\u0026quot;Hello\u0026quot;) Fprint / Fprintln / Fprintf 输出到任意 io.Writer fmt.Fprintf(os.Stderr, \u0026quot;err: %v\u0026quot;, err) Sprint / Sprintln / Sprintf 格式化结果返回字符串（不打印） s := fmt.Sprintf(\u0026quot;age=%d\u0026quot;, 20) Errorf 格式化后返回 error 类型 return fmt.Errorf(\u0026quot;invalid: %v\u0026quot;, val) Println/Sprintln/Fprintln 会自动在末尾加换行，Print 不加；Printf 需要显式写 \\n。\n二、常用格式占位符（格式化动词） 动词 用于 示例输出 %v 任意类型的默认格式 123 \u0026quot;abc\u0026quot; [1 2] %#v Go 语法表示（带类型/引号） \u0026quot;abc\u0026quot; []int{1,2} %T 值的类型 int string []int %% 百分号字面量 % 整数 %d 十进制 123 %b 二进制 1111011 %o 八进制 173 %x / %X 十六进制（小写/大写） 7b / 7B %c Unicode 字符 { (ASCII 123) 浮点数 %f 十进制小数 3.141593 %.2f 指定小数位数 3.14 %e / %E 科学计数法 3.141593e+00 %g 自动选择 %f 或 %e 更紧凑 字符串/字节切片 %s 原始字符串 hello %q 带双引号的字符串 \u0026quot;hello\u0026quot; %x 十六进制字节 68656c6c6f 指针 %p 十六进制地址 0xc0000140f0 布尔 %t true 或 false true 三、宽度与精度 通用格式：%[flags][width][.precision]verb\n示例 含义 %5d 宽度 5，右对齐，不足补空格 %-5d 宽度 5，左对齐 %05d 宽度 5，右对齐，前补零 %5.2f 总宽度 5（含小数点），小数 2 位 %5s 最小宽度 5 的字符串 %q 或 %x 也可跟宽度/精度 四、常用辅助函数 函数 作用 fmt.Scan / Scanf / Scanln 从标准输入读取格式化数据 fmt.Fscan / Fscanf 从 io.Reader 读取 fmt.Sscan / Sscanf 从字符串解析 五、自定义格式化（Stringer 接口） 任何类型实现 String() string 方法后，使用 %v、%s、Print 等都会自动调用该方法。\n1 2 3 4 type Person struct { Name string; Age int } func (p Person) String() string { return fmt.Sprintf(\u0026#34;%s (%d)\u0026#34;, p.Name, p.Age) } 六、性能提示 Sprintf 有内存分配开销；频繁调用可考虑 strings.Builder + fmt.Fprintf。 类型断言比 %v 反射更快，但对通用代码可接受。 ","date":"2026-05-08T00:00:00Z","permalink":"/p/go%E4%B8%ADfmt%E7%9A%84%E5%B8%B8%E8%A7%81%E7%94%A8%E6%B3%95%E6%80%BB%E7%BB%93/","title":"Go中fmt的常见用法总结"},{"content":"在我的 linux 服务器上，执行：\n1 wget -O /tmp/easytier.sh \u0026#34;https://raw.githubusercontent.com/EasyTier/EasyTier/main/script/install.sh\u0026#34; \u0026amp;\u0026amp; sudo bash /tmp/easytier.sh install --gh-proxy https://ghfast.top/ 如果上述命令无法安装成功，基本上是网络问题。\n接着启动 systemd 服务：\n1 systemctl start easytier@default 之后会默认创建 /opt/easytier/config/default.conf，通常，我们只需要修改其中的 peer:uri、network_name 和 network_secret：\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 instance_name = \u0026#34;default\u0026#34; dhcp = true listeners = [ \u0026#34;tcp://0.0.0.0:11010\u0026#34;, \u0026#34;udp://0.0.0.0:11010\u0026#34;, \u0026#34;wg://0.0.0.0:11011\u0026#34;, \u0026#34;ws://0.0.0.0:11011/\u0026#34;, \u0026#34;wss://0.0.0.0:11012/\u0026#34;, ] exit_nodes = [] rpc_portal = \u0026#34;0.0.0.0:0\u0026#34; [[peer]] uri = \u0026#34;tcp://public.easytier.top:11010\u0026#34; [network_identity] network_name = \u0026#34;xxx\u0026#34; network_secret = \u0026#34;xxx\u0026#34; [flags] default_protocol = \u0026#34;udp\u0026#34; dev_name = \u0026#34;\u0026#34; enable_encryption = true enable_ipv6 = true mtu = 1380 latency_first = false enable_exit_node = false no_tun = false use_smoltcp = false foreign_network_whitelist = \u0026#34;*\u0026#34; disable_p2p = false p2p_only = false relay_all_peer_rpc = false disable_tcp_hole_punching = false disable_udp_hole_punching = false 在另一台主机上进行配置时（无论是通过 cli 还是 gui），确保刚刚提到的几个参数配置是一样的即可。\n","date":"2026-05-01T00:00:00Z","permalink":"/p/easytier%E4%BD%BF%E7%94%A8/","title":"EasyTier使用"},{"content":" 本文档围绕你的 profiler-cpp 项目，从面试官视角梳理所有可能被问到的问题，涵盖 eBPF 性能采样、perf_event 子系统、栈回溯与符号化、C++23 工程实践、基准测试方法、系统设计等方向，每个问题附带详细答案。\n1. 项目概览 Q1: 一句话介绍这个项目 答： Profiler 是一个基于 eBPF + perf_event 的 Linux 采样分析器——它将 eBPF 程序挂载到硬件/软件 perf 事件上，在内核态以可配置频率采集进程的 kernel+user 栈回溯，通过 ring buffer 推送到用户态，最终用 blazesym 库符号化为函数名并输出火焰图兼容格式。\nQ2: 这个项目的架构是什么？数据流是怎样的？ 答：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 内核态 (eBPF): perf_event 触发 (每秒freq次，每CPU独立) | v SEC(\u0026#34;perf_event\u0026#34;) int profile(void* ctx) ├─ bpf_get_current_pid_tgid() → 获取 TGID ├─ match_target() → 过滤 (tgid/pgrp/session/cgroup) ├─ bpf_ringbuf_reserve() → 分配事件缓冲区 ├─ bpf_get_current_comm() → 进程名 ├─ bpf_get_stack(ctx, kstack, ..., 0) → 内核栈 ├─ bpf_get_stack(ctx, ustack, ..., BPF_F_USER_STACK) └─ bpf_ringbuf_submit() → 提交到 ring buffer | 用户态 (C++23): | ring_buffer__poll() v handle_event_wrapper() → EventHandler::handle() ├─ handle_standard() → 逐条输出 (时间戳+栈帧) └─ handle_fold_extend() → 聚合去重, 火焰图格式 | v blaze::Symbolizer → blazesym C API └─ 地址 → 函数名 + 源文件位置 + 内联信息 关键设计点：\n每 CPU 一个 perf_event fd，BPF 程序在多 CPU 上并行执行，无锁 内核态过滤（match_target）避免无效事件浪费 ring buffer 空间 用户态懒符号化——--no-symbolize 模式下只计数，用于低开销基准测试 Q3: 这个项目和 Linux 自带的 perf 工具有什么区别？ 答：\n维度 linux perf Profiler 采样机制 perf_event + perf buffer（共享内存 mmap） perf_event + BPF ring buffer 栈收集 内核采样帧（frame pointer/ORC/DWARF） bpf_get_stack() 内核辅助函数 符号化 perf script 离线后处理 在线符号化（blazesym）或关闭符号化（RawCount） 过滤 perf record -p/-t kernel 态实时过滤（tgid/pgrp/session/cgroup） 输出 perf.data 二进制 + perf script/report 直接输出标准/折叠文本格式 端到端 CPU 开销 baseline 较 perf record + perf script/report 低约 30% 纯采集 CPU 开销 较低 较 perf record 高（更多采集内容: comm + kernel+user stack） 我的基准测试结果：\ncollect-only（仅采集）：profiler CPU 开销更高（约 -48%），因为 profiler 采集了更多数据（进程名、完整内核栈 + 用户栈） end-to-end（采集 + 后处理）：profiler 总开销更低（约 +30%），因为省去了 perf 的二次处理（perf script + perf report） 2. eBPF 采样架构 Q4: 为什么选择 perf_event 类型的 BPF 程序，而不是 kprobe/tracepoint？ 答：\nBPF 程序类型 触发源 是否适合采样 原因 SEC(\u0026ldquo;perf_event\u0026rdquo;) 硬件/软件性能计数器溢出 ✓ 最合适 基于频率/周期的采样，是 profiling 的标准方法 kprobe 内核函数调用 ✗ 需要指定具体函数，不能做通用采样；频繁函数会产生大量事件 tracepoint 内核静态跟踪点 ✗ 同上，不是周期性采样 XDP / TC 网络包到达 ✗ 完全不同用途 perf_event BPF 程序的核心价值：\n周期性触发——硬件 PMC（Performance Monitoring Counter）每次溢出触发一次 BPF 程序执行 统计性采样——不需要 hook 每个函数调用，低开销 与内核 perf 子系统深度集成——复用了 perf_event_open() 的过滤、多路复用等能力 Q5: eBPF 程序中如何读取内核数据结构（如 task_struct）？为什么不会崩溃？ 答： 使用 BPF CO-RE（Compile Once, Run Everywhere）机制：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // profiler.bpf.c 中读取进程组 ID 的例子 static __always_inline int current_pid_nr(enum pid_type type) { struct task_struct* task = bpf_get_current_task_btf(); struct signal_struct* signal = BPF_CORE_READ(task, signal); if (!signal) { return 0; } struct pid* pid = BPF_CORE_READ(signal, pids[type]); if (!pid) { return 0; } unsigned int level = BPF_CORE_READ(pid, level); int nr = 0; bpf_core_read(\u0026amp;nr, sizeof(nr), \u0026amp;pid-\u0026gt;numbers[level].nr); return nr; } 为什么安全（不崩溃）：\nBTF (BPF Type Format)： 内核编译时生成 BTF 信息，描述了所有内核结构体的字段偏移和类型 BPF_CORE_READ 宏： 编译时记录偏移意图，加载时 verifier + libbpf 根据 BTF 动态修正偏移量 Verifier 检查： 每个 BPF_CORE_READ 后都做了 NULL 检查（if (!signal) / if (!pid)） bpf_core_read() 有错误处理： 即使是指针链中的最后一个节点，无法读取会返回错误而非崩溃 没有 CO-RE 的旧方式： 需要为每个内核版本重新编译 BPF 程序（因为结构体偏移在不同编译下可能不同）。CO-RE 解决了这个问题，编译一次，到处运行。\n3. perf_event 子系统 Q6: perf_event_open() 系统调用做了什么？你的项目如何配置它？ 答： perf_event_open() 是 Linux 性能计数器子系统的入口，核心逻辑：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // src/perf.cpp perf_event_attr attr = { .type = sw_event ? PERF_TYPE_SOFTWARE : PERF_TYPE_HARDWARE, .size = sizeof(perf_event_attr), .config = sw_event ? PERF_COUNT_SW_CPU_CLOCK : PERF_COUNT_HW_CPU_CYCLES, .sample_freq = freq, // 每秒采样频次 .freq = 1, // 使用频率模式（非周期模式） }; for (int cpu = 0; cpu \u0026lt; cpus; ++cpu) { int fd = perf_event_open(\u0026amp;attr, pid, cpu, -1, 0); fds.push_back(fd); } 关键参数解析：\n参数 硬件事件 软件事件 .type PERF_TYPE_HARDWARE PERF_TYPE_SOFTWARE .config PERF_COUNT_HW_CPU_CYCLES PERF_COUNT_SW_CPU_CLOCK 触发机制 CPU 周期计数器溢出（精确，但有 skid） 高精度定时器中断 适用场景 现代 x86/ARM CPU，需要真实 PMC 虚拟机、不支持硬件 PMU 的环境 开销 极低（硬件计数器，几乎无开销） 略高（软件中断） 为什么每 CPU 打开一个 fd？\nperf 事件的计数和采样是 per-CPU 的物理特性——CPU0 的周期计数器只计数 CPU0 上执行的事件 每个 fd 绑定到 [pid, cpu]，表示 \u0026quot; 监视某个 pid 在某个 CPU 上的 perf 事件 \u0026quot; 如果 pid=-1，表示监听所有进程（system-wide） Q7: sample_freq 和 period 有什么区别？频率采样是怎样工作的？ 答：\nperiod（周期模式）： 每 N 个事件触发一次采样。例如 period=1000000 表示每 100 万个 CPU 周期采样一次——采样频率随 CPU 频率变化 freq（频率模式）： 内核动态调整 period 使采样频率稳定在指定值。.freq=1 + .sample_freq=99 → 每秒约 99 次采样 频率模式的内部机制：\n1 2 3 内核每秒重新计算 period 值： period = (last_period * avg_sample_rate + allowed_err) / target_freq 这样系统可以对抗 CPU 频率变化（DVFS/P-state），维持稳定的采样频率。 我们为什么使用 freq 模式？\n用户界面友好——\u0026quot; 每秒采样 99 次 \u0026quot; 比 \u0026quot; 每 1000 万周期采样一次 \u0026quot; 更直观 可比较性——不同 CPU 节点间采样频率一致，基准测试有意义 稳定性——不因 CPU 降频/P-state 变化而波动 Q8: 硬件事件和软件事件在采样中有何不同？什么时候应该用软件事件？ 答：\n特性 硬件事件 (HW_CPU_CYCLES) 软件事件 (SW_CPU_CLOCK) 触发源 CPU PMC（Performance Monitoring Counter） 内核高精度定时器 (hrtimer) 精确度 采样存在 skid（从溢出到 PC 记录有几条指令延迟） 定时器中断精度（微秒级） 计数含义 CPU 实际执行的周期数（排除 sleep/stolen） 墙钟时间（包括被 swap out 的时间） 虚拟化支持 需要 vPMU（VM 中可能不可用） 始终可用 idle 时采样 不在 idle 时采样 会采样到 idle 进程 何时使用软件事件（项目中的 --sw-event）：\n虚拟化环境——VM 没有暴露 vPMU（如某些云主机） 异构 CPU——大小核架构（Intel P-core/E-core）中，硬件事件在不同核上行为差异大 调试/兼容性——硬件 PMU 某些情况下不可用 明确测量 CPU 时间而非 CPU 周期——软事件测量的是 \u0026quot; 占用 CPU 的时间比例 \u0026quot; 4. 栈回溯与收集 Q9: bpf_get_stack() 是如何工作的？内核栈和用户栈有什么区别？ 答： bpf_get_stack() 是 eBPF 辅助函数，在 BPF 程序上下文中获取当前进程的调用栈。\n1 2 3 4 // 内核栈 (flag=0) event-\u0026gt;kstack_sz = bpf_get_stack(ctx, event-\u0026gt;kstack, sizeof(event-\u0026gt;kstack), 0); // 用户栈 (flag=BPF_F_USER_STACK) event-\u0026gt;ustack_sz = bpf_get_stack(ctx, event-\u0026gt;ustack, sizeof(event-\u0026gt;ustack), BPF_F_USER_STACK); 内核栈回溯：\n直接遍历内核栈帧链表（通过 frame pointer 或 ORC unwinder） flag=0 表示内核栈 返回写入的字节数（不是帧数）——stack_sz / sizeof(__u64) = 帧数 内核态执行的代码，符号化时 PID=0（使用 kallsyms/System.map） 用户栈回溯：\nBPF_F_USER_STACK 标志指示读取用户空间栈 读取的是用户空间的栈内存地址 符号化时使用目标进程的 PID（通过 /proc/pid/maps + ELF 调试信息） 结果与 frame pointer/DWARF unwind 信息有关 栈深度限制：\n1 #define MAX_STACK_DEPTH 128 // 最多 128 层栈帧 bpf_get_stack() 的实际返回值可能是负数（special codes），项目中使用 stack_sz \u0026gt; 0 判断有效性。\nQ10: 为什么 bpf_get_stack() 有时返回 -1（或很小）的用户栈？怎么解决？ 答：\n原因：\n没有 frame pointer（-fomit-frame-pointer）： 默认情况下，现代编译器（gcc -O2, clang）会省略 frame pointer 以提升性能，导致栈无法回溯 尾调用优化： 编译器将函数调用替换为 jmp，导致栈帧消失 JIT 代码： Java/JIT/解释器生成的代码没有 frame pointer 交织 syscall 入口无用户栈帧： 在系统调用入口处，用户栈寄存器尚未保存 解决方案：\n编译时保留 frame pointer： CFLAGS=\u0026quot;-fno-omit-frame-pointer\u0026quot; 重新编译目标程序 使用 ORC unwinder（内核 5.12+）： 内核 ORC（Oops Rewind Capability）数据可以替代 frame pointer 进行栈回溯 DWARF unwind： 更精确但开销更高，需要 userspace 完成 告知用户： 项目 README 中应说明被 profile 的程序需要保留 frame pointer 本项目的现状： bpf_get_stack() 依赖内核的栈回溯能力，如果被采样进程编译时省略了 frame pointer，用户栈帧将缺失。这是一个已知限制。\nQ11: 什么是采样偏差（Skid）？对你的项目有什么影响？ 答：\nSkid（滑动）： 从性能计数器溢出（触发中断）到程序计数器（PC）被记录之间，已经执行了额外的指令，导致采样的 PC 指向的不是真正触发溢出的指令。\n1 2 3 真实情况： CPU 执行到指令 X → PMC 溢出触发中断 → 中断延迟 → 记录 PC 时指向 X+N 中间的 N 条指令 = skid 对 profiling 的影响：\n热点函数识别通常不精确到具体指令，skid 影响有限 但会使函数级别的统计出现偏差——特别是短函数可能被错误归因到邻近函数 硬件 PEBS（Precise Event-Based Sampling）可以消除或减少 skid 本项目中：\n使用了简单的硬件事件（PERF_COUNT_HW_CPU_CYCLES）而非 PEBS 对宏观性能分析（找出哪个函数花时间最多）来说，skid 的统计误差可接受 面试官追问 \u0026quot; 如何改进？\u0026quot;——回答：perf_event_attr 中设置 .precise_ip 可以启用 PEBS 5. BPF CO-RE 与 BTF Q12: 什么是 BTF？为什么它对 eBPF 程序很重要？ 答：\nBTF (BPF Type Format)： 内核编译时生成的元数据格式，描述了内核中所有类型（结构体、联合体、枚举、typedef）的布局，包括每个字段的名称、类型、偏移、大小。\n在 Linux 中的位置： /sys/kernel/btf/vmlinux（一个 ELF 格式文件，包含 .BTF section）\n两种用法：\nvmlinux.h（编译时）： 1 2 # cmake/FindBpfObject.cmake 中生成 bpftool btf dump file /sys/kernel/btf/vmlinux format c \u0026gt; vmlinux.h 将所有内核类型展开为 C 头文件，BPF 程序可以直接 include 使用。\nCO-RE 重定位（加载时）： 1 BPF_CORE_READ(task, signal, pids[type], numbers[level].nr); libbpf 在加载 eBPF 程序时，根据当前运行内核的 BTF 修正结构体字段偏移——这就是 \u0026quot; 一次编译，到处运行 \u0026quot; 的秘诀。\n为什么重要：\n内核内部结构体在不同发行版/版本间布局可能不同 没有 CO-RE 的话，需要为每个内核版本编译独立的 eBPF 程序 没有 BTF 的话，BPF_CORE_READ 无法确定字段偏移 Q13: BPF_CORE_READ 宏是如何实现安全的指针链遍历的？ 答： BPF_CORE_READ 展开后实际做了：\n1 2 3 4 // BPF_CORE_READ(task, signal, pids[type], numbers[level].nr) // 展开为多个 bpf_core_read 调用，每个调用会用 bpf_probe_read_kernel() // 在内核地址空间做 safe memory access，偏移由 BTF 修正 // 每层都有 NULL 检查，避免 crash Verifier 安全保证：\n每次指针解引用前做 NULL 检查 所有内存访问都有边界检查 使用 bpf_probe_read_kernel() 代替直接内存解引用（可处理 page fault） 6. 符号化与 blazesym Q14: 什么是符号化（Symbolization）？为什么不能直接用地址？ 答： 符号化（Address-to-Symbol Resolution）将栈回溯中的内存地址转换为人类可读的函数名 + 代码位置。\n1 2 3 符号化前: 符号化后: 0xffffffff81234567 do_syscall_64+0x27 (arch/x86/entry/common.c:79) 0x7f8a3c0010a0 main+0x50 (src/main.cpp:34) 为什么不能直接用地址：\nASLR（地址空间布局随机化）导致每次运行地址不同 不同进程的同一函数地址不同 人类理解需要函数名而非十六进制 blazesym 如何获取符号信息：\n来源 内核符号 用户空间符号 数据源 /proc/kallsyms 或 kallsyms 内建 /proc/pid/maps + ELF 文件 符号信息 函数名 + 偏移 函数名 + 源文件: 行号: 列号 + 内联函数 API blaze_symbolize_kernel_abs_addrs() blaze_symbolize_process_abs_addrs() 项目中 PID=0 表示内核符号源：\n1 2 3 4 5 6 7 // src/blaze.h inline auto get_symbolize_source(uint32_t pid) -\u0026gt; blaze::Source { if (pid == 0) { return blaze::Source{blaze_symbolize_src_kernel{...}}; } return blaze::Source{blaze_symbolize_src_process{.pid = pid, ...}}; } Q15: 为什么选择 blazesym 而不是其他符号化库？ 答：\n方案 优点 缺点 blazesym (本项目) Rust 实现高性能、支持 DWARF inline、活跃维护、C API 易集成 构建依赖 Rust 工具链 + Corrosion addr2line (binutils) 随系统自带 每个地址 fork 进程，慢 libunwind 完整 unwinding 栈 C 库，无内联函数支持 libdw (elfutils) 支持 DWARF 全部特性 API 复杂，体积大 blazesym 的核心优势：\n批量符号化： 一次调用符号化整个栈数组，减少 IPC 开销 内联函数支持： 源码中 inlined_cnt + inlined[] 字段揭示函数是否被编译器内联 性能： Rust 实现，无 GC，零抽象开销 代码信息： 提供源文件路径、行号、列号 Q16: blazesym（Rust）是如何集成到 C++ 项目中的？ 答： 使用 Corrosion（Rust-CMake 桥接工具）：\n1 2 3 4 5 # cmake/SetupBlazesym.cmake FetchContent_Declare(corrosion ...) corrosion_import_crate(MANIFEST_PATH third_party/blazesym/Cargo.toml) # 最终生成静态库 libblazesym_c.a add_library(blazesym STATIC IMPORTED) 完整链接链路：\n1 2 3 4 5 Rust (blazesym/capi) → cargo build → libblazesym_c.a (C ABI) ↓ C++ (src/blaze.h) → 包装 C API → blaze::Symbolizer → event.cpp ↓ C++ (profiler) → linkage: blazesym + profiler_skel + CLI11 + spdlog 构建前提：\ncargo build --manifest-path third_party/blazesym/Cargo.toml（需要 Rust 工具链） 生成的 C 头文件：third_party/blazesym/capi/include/blazesym.h 生成的静态库：libblazesym_c.a 面试小加分点： 你也熟悉 Rust 生态工具的集成，说明你有跨语言工程实践能力。\n7. 过滤模式设计 Q17: 四种过滤模式（tgid/pgrp/session/cgroup）的区别和使用场景？ 答：\n过滤模式 匹配的是什么 典型用途 TGID（线程组 ID） 单个进程（传统 PID） Profile 单个已知的进程 PGRP（进程组 ID） 属于同一进程组的所有进程 make -j16 这种 fork 多子进程的场景，shell pipeline Session（会话 ID） 同一会话的所有进程（默认） bash 启动的工作负载，覆盖其所有子进程和后台进程 Cgroup（控制组 ID） 同一 cgroup v2 的所有进程 容器中所有进程（如 Docker container 的 cgroup） 默认使用 Session 的原因：\n基准测试脚本 benchmark.py 使用 bash -lc exec cmd 启动负载——它在新的 session 中运行，profiler 用 session 模式可以自动 profile 整个工作负载进程树。\nQ18: kernel 态如何实现进程组/会话过滤？（核心实现细节） 答： 通过 BTF 直接访问 task_struct 内核结构体：\n1 2 3 4 5 6 7 8 9 static __always_inline int current_pid_nr(enum pid_type type) { struct task_struct* task = bpf_get_current_task_btf(); struct signal_struct* signal = BPF_CORE_READ(task, signal); struct pid* pid = BPF_CORE_READ(signal, pids[type]); unsigned int level = BPF_CORE_READ(pid, level); int nr = 0; bpf_core_read(\u0026amp;nr, sizeof(nr), \u0026amp;pid-\u0026gt;numbers[level].nr); return nr; } 数据结构穿透路径：\n1 2 3 task_struct → signal_struct (包含所有 pid 类型) └→ pids[type] → struct pid └→ numbers[level].nr ← 实际进程组/会话 ID 面试官可能追问：\u0026quot; 为什么不直接用 getpgid() 系统调用？\u0026quot;\n答：在内核态 BPF 中没有系统调用的概念——你无法 call syscall，只能通过 BTF 直接读取内核数据结构。而且直接读取远比系统调用快（没有上下文切换）。\nQ19: cgroup 过滤模式下，用户态如何解析 cgroup ID？ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // src/common.h - cgroup_path_for_pid() auto cgroup_path_for_pid(pid_t pid) -\u0026gt; std::string { // 读取 /proc/pid/cgroup，找到 cgroup v2 路径（格式 \u0026#34;0::/path\u0026#34;） while (std::getline(file, line)) { auto pos = line.find(\u0026#34;::\u0026#34;); // cgroup v2 格式标记 if (pos != std::string::npos) { return \u0026#34;/sys/fs/cgroup\u0026#34; + line.substr(pos + 2); } } } // cgroup_id_for_pid() - 通过 stat() 获取 inode 作为唯一标识 auto cgroup_id_for_pid(pid_t pid) -\u0026gt; uint64_t { struct stat st{}; stat(cgroup_path_for_pid(pid).c_str(), \u0026amp;st); return st.st_ino; // cgroup 目录的 inode 就是 cgroup ID } 而在内核态： 直接使用 bpf_get_current_cgroup_id() 辅助函数。两端需要匹配同一个 cgroup 标识。\n8. Ring Buffer 原理 Q20: BPF Ring Buffer 和传统 Perf Buffer 有什么区别？为什么选择它？ 答： 项目使用 BPF_MAP_TYPE_RINGBUF（内核 5.8+）：\n1 2 3 4 5 // bpf/profiler.bpf.c struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); // 256KB 共享缓冲区 } events SEC(\u0026#34;.maps\u0026#34;); 特性 BPF Ring Buffer Perf Buffer (BPF_MAP_TYPE_PERF_EVENT_ARRAY) 数据结构 单共享环形缓冲区（multi-producer, single-consumer） 每 CPU 独立 ring buffer 内存开销 固定大小（256KB） 每 CPU 拷贝一份，内存 = 256KB × Ncpu 事件顺序 全局顺序（写入时按 timestamp 排序） 每 CPU 内部有序，跨 CPU 无序 API ringbuf_reserve/ringbuf_submit perf_event_output 动态大小消息 原生支持（reserve 时指定大小） 需要固定消息大小 丢数据检测 可通过返回值检测并处理 静默丢弃 为什么选择 Ring Buffer：\n内存效率： 64 核机器上，256KB vs 16MB 全局顺序： 按时间排序有利于火焰图构建 显式丢数据检测： bpf_ringbuf_reserve 返回 NULL 时可以记录 lost events Q21: ring buffer 的 poll 模型是怎样的？为什么用 100ms 超时？ 1 2 3 4 5 6 7 8 9 10 // src/main.cpp - 主循环 while (!exiting) { int err = rb.poll(100); // 最多阻塞 100ms if (err == -EINTR) { continue; // 被信号中断，检查退出标志 } if (err \u0026lt; 0) { break; // 其他错误，退出 } } ring_buffer__poll(100) 调用 epoll_wait() 等待事件，超时 100ms 超时返回后重新检查 exiting 标志——信号响应延迟 ≤ 100ms 事件处理在 poll 返回时完成（回调函数中同步符号化） 设计权衡： 100ms 超时使得 Ctrl+C 响应较慢但 CPU 空转不过度 9. 输出格式与火焰图 Q22: 两种输出格式（Standard vs FoldExtend）的区别和设计意图？ 答：\nStandard 格式： 逐条输出每个采样事件\n1 2 3 4 5 [1714567890.123456789 COMM: stress-ng-matri (pid=12345) @ CPU 3] Kernel: 0xffffffff81234567: do_syscall_64 @ 0xffffffff81234500 + 0x67 (arch/x86/entry/common.c:79) Userspace: 0x7f8a3c0010a0: main @ 0x7f8a3c000000 + 0x10a0 (src/main.cpp:34) 用于单条分析——查看某次采样的完整调用栈 时间戳 + CPU 号 → 可以分析 \u0026quot; 某个 CPU 上发生了什么 \u0026quot; 包含 inline 函数展开 + 源文件行列信息 FoldExtend 格式（火焰图兼容）： 等符号化完整个会话后，flush 时输出\n1 stress-ng-matri-12345;main;intensive_calculation;matrix_multiply_[k];handle_mm_fault_[k] 42 用于统计分析——\u0026quot; 哪些函数组合占了最多 CPU 时间 \u0026quot; 分号分隔的栈帧（栈底到栈顶），末尾计数 _[k] 后缀标识内核栈帧 管道到 FlameGraph 脚本生成 SVG 火焰图 FoldExtend 的去重优化：\n1 2 3 4 5 6 7 // event.cpp - 使用 raw bytes 作为 key 判断是否见过相同栈 auto key = folded_key(event); // 拼接 pid+comm+kstack+ustack 的二进制 auto cached = folded_stacks_.find(key); if (cached != folded_stacks_.end()) { cached-\u0026gt;second.count++; // 去重，只增加计数 return; // 跳过后面的符号化！ } 相同栈只符号化一次，后续直接计数——大幅减少 CPU 开销 等 flush 时统一输出 Q23: 火焰图是什么？怎么从你的输出生成火焰图？ 答：火焰图（FlameGraph） 是 Brendan Gregg 提出的性能可视化方法。\n生成流程：\n1 2 3 4 5 # 1. 用 profiler 采集折叠格式数据 sudo ./build/Release/profiler -f 99 -E -p 12345 \u0026gt; output.folded # 2. 使用 FlameGraph 脚本生成 SVG ./third_party/FlameGraph/flamegraph.pl output.folded \u0026gt; flamegraph.svg 火焰图的阅读规则：\nX 轴宽度 = 函数占总采样数的比例（越宽 = 越热点） Y 轴 = 调用栈深度（越往上 = 越深） 颜色随机（无特殊含义），由 flamegraph.pl 控制 10. C++23 与工程实践 Q24: 项目中用了哪些 C++23 特性？为什么选择这些特性？ 答：\nC++23 特性 使用位置 用途 std::expected\u0026lt;T,E\u0026gt; utils.h 全局 Rust 风格的 Result 错误处理 std::format / std::println event.cpp 类型安全的格式化输出 std::ranges / std::views event.cpp 管道式数据转换（join_with / to\u0026lt;string\u0026gt;） auto 返回类型推导 全项目 现代 C++ 风格 [[nodiscard]] event.h, common.h 编译时警告未使用的返回值 std::expected（Rust Result 模式的 C++ 版）：\n1 2 3 4 5 6 7 8 9 // 成功时返回 Result\u0026lt;T\u0026gt; auto init_perf_monitor(...) -\u0026gt; Result\u0026lt;std::vector\u0026lt;int\u0026gt;, libbpf_errno\u0026gt;; // 使用时 auto perf_fds = init_perf_monitor(freq, args.sw_event, perf_pid); if (!perf_fds) { spdlog::error(\u0026#34;Failed to initialize perf monitor\u0026#34;); return 1; } // 成功路径: perf_fds.value() 为什么不用异常？\nBPF/perf 操作失败是预期内行为（权限不足、内核不支持等），不应算 \u0026quot; 异常 \u0026quot; std::expected 强迫调用者处理错误，避免忽略 性能——exception unwind 在热路径中开销大 Q25: RAII（Resource Acquisition Is Initialization）在项目中的体现？ 答： 项目使用多个 RAII 包装器管理内核资源：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // ProfilerSkel - 管理 BPF 对象生命周期 struct ProfilerSkel { profiler_bpf* obj{nullptr}; ProfilerSkel(...) { obj = profiler_bpf::open(); profiler_bpf::load(obj); } ~ProfilerSkel() { if (obj) profiler_bpf__destroy(obj); } }; // RingBuffer - 管理 ring buffer 生命周期 struct RingBuffer { ring_buffer* rb; RingBuffer(int map_fd, ...) { rb = ring_buffer__new(map_fd, ...); } ~RingBuffer() { if (rb) ring_buffer__free(rb); } }; // blaze::Syms - 管理符号化结果 struct Syms { const blaze_syms* syms_; ~Syms() { if (syms_) blaze_syms_free(syms_); } Syms(const Syms\u0026amp;) = delete; // 禁止拷贝 }; 为什么重要：\n即使在异常/早期返回/信号处理路径中，资源也能被正确释放 BPF 程序不释放会导致内核内存泄漏和 zombie BPF 程序 Q26: std::variant + Overloaded 的 visitor 模式用在哪？ 答： 用于区分内核符号化和进程符号化：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // src/blaze.h using Source = std::variant\u0026lt;blaze_symbolize_src_process, blaze_symbolize_src_kernel\u0026gt;; auto symbolize(Source src, const Input\u0026amp; input) const -\u0026gt; Result\u0026lt;Syms\u0026gt; { const blaze_syms* syms = nullptr; std::visit( Overloaded{ [\u0026amp;](blaze_symbolize_src_kernel\u0026amp; kern_src) -\u0026gt; void { syms = blaze_symbolize_kernel_abs_addrs(symbolizer_, \u0026amp;kern_src, input.addrs_, input.cnt_); }, [\u0026amp;](blaze_symbolize_src_process\u0026amp; proc_src) -\u0026gt; void { syms = blaze_symbolize_process_abs_addrs(symbolizer_, \u0026amp;proc_src, input.addrs_, input.cnt_); }, }, src); return syms ? Result\u0026lt;Syms\u0026gt;{syms} : Err\u0026lt;\u0026gt;(...); } 巧妙之处： Overloaded 结构体通过多重继承把多个 lambda 的 operator() 合并在一起，实现了编译期类型派发，无虚函数开销。\n11. 构建系统 Q27: 项目的构建流程是怎样的？（CMake + Conan + Corrosion + bpftool） 答：\n1 2 3 4 5 6 7 8 9 10 11 12 conan install . -s build_type=Release \u0026lt;- 下载 CLI11 + spdlog 到本地 cmake --preset release \u0026lt;- 配置 CMake (gcc + C++23) |- FindBpfObject.cmake \u0026lt;- 找到 bpftool + clang + libbpf | |- bpftool btf dump -\u0026gt; vmlinux.h \u0026lt;- 生成内核类型头文件 | |- 提供 bpf_object() 宏 |- SetupBlazesym.cmake \u0026lt;- Fetch Corrosion -\u0026gt; cargo build blazesym |- CMakeLists.txt \u0026lt;- 定义目标 |- bpf_object(profiler bpf/profiler.bpf.c) \u0026lt;- 编译 eBPF + 生成 skeleton |- add_executable(profiler src/main.cpp ...) \u0026lt;- 编译 C++ 可执行文件 cmake --build build/Release --target _cargo-build_blazesym_c \u0026lt;- 构建 Rust 库 cmake --build build/Release --target profiler \u0026lt;- 构建 profiler bpf_object 宏做了什么：\n1 2 3 4 # cmake/FindBpfObject.cmake 1. clang -target bpf -O2 -g -D__TARGET_ARCH_x86 -c bpf/profiler.bpf.c -o profiler.bpf.o 2. bpftool gen skeleton profiler.bpf.o \u0026gt; profiler.skel.h \u0026lt;- 生成骨架 C 头文件 3. 创建 INTERFACE 库 profiler_skel，链接 libbpf + libelf + libz Q28: 为什么要生成 vmlinux.h？为什么不用 kernel headers？ 答：\nvmlinux.h： 通过 bpftool btf dump file /sys/kernel/btf/vmlinux format c 从运行内核的 BTF 信息自动生成的单个头文件。\n对比 kernel headers：\n维度 vmlinux.h (BTF 生成) kernel headers (linux/*.h) 来源 当前运行内核的 BTF 元数据 编译时指定的 kernel-devel 包 一致性 100% 与运行内核一致 可能版本不匹配 大小 单文件，约 1MB+ 数百个头文件 CO-RE 支持 v 天然支持 x 需要手动处理 核心原因： BPF CO-RE 依赖运行内核的 BTF 进行字段重定位。vmlinux.h 确保编译时类型定义和运行时 BTF完全一致。\nQ29: 为什么用 Conan 管理 C++ 依赖而不是 vcpkg 或 FetchContent？ 答：\n方案 优点 缺点 Conan (本项目) CMake-presets 深度集成，二进制包缓存 需要安装 conan vcpkg 微软维护，Windows/Linux 都好用 CMake 集成稍弱 FetchContent 无需外部包管理 每次都从源码编译 Conan 适合本项目因为 CLI11 + spdlog 都是纯头文件/小库，Conan 能快速拉取预编译版本。\nQ30: build/ 目录下的编译产物有哪些关键文件？ 答：\nbuild/vmlinux.h —— 由 bpftool 生成的完整内核类型定义 build/profiler.bpf.o —— eBPF 字节码目标文件（clang -target bpf 编译产物） build/profiler.skel.h —— bpftool gen skeleton 生成的一体化头文件，包含加载/attach/Map 访问的 C++ 包装 build/profiler —— 最终可执行文件 build/compile_commands.json —— clangd/LSP 用的编译数据库 12. 基准测试方法论 Q31: 你的基准测试为什么分 collect-only 和 end-to-end 两组？ 答： 因为两组回答不同的问题：\ncollect-only（纯采集开销）：\n1 2 3 profiler --no-symbolize -f 99 vs perf record -F 99 -\u0026gt; 比较 BPF ring buffer + eBPF 程序开销 vs perf buffer 开销 -\u0026gt; 纯数据面开销对比 end-to-end（端到端开销）：\n1 2 3 profiler -E -f 99 vs perf record + perf script + perf report -\u0026gt; 比较整个分析工作流的总开销 -\u0026gt; 包含符号化 / 输出格式化 为什么 profiler 纯采集开销更高？\n采集了更多数据：comm（进程名）、完整的 kernel + user stack（perf 可能压缩栈） BPF ring buffer 的 reserve/submit 机制有锁开销 但 end-to-end 更低——因为省去了 perf 的后处理步骤 Q32: benchmark.py 的多轮统计是怎么做的？ 答：\n1 2 3 4 5 6 7 8 9 10 11 12 # 每轮： # 1. start_workload -\u0026gt; run_profiler -\u0026gt; stop_workload # 2. start_workload -\u0026gt; run_perf -\u0026gt; stop_workload # 对每种工具采集: user_sec, sys_sec, elapsed_sec, cpu_pct, max_rss_kb, samples # 多轮聚合: mean = sum(values) / len(values) std = sqrt(sum(x^2)/n - mean^2) p95 = sorted(values)[ceil(0.95 * n) - 1] # 最终输出: cpu_overhead_improvement_vs_perf(%) = (perf_cpu_mean - profiler_cpu_mean) / perf_cpu_mean * 100 关键设计选择：\n两轮使用相同 workload 但分别启动——避免 profiling 互相干扰 同等条件下对比——相同的采样频率、时长、过滤参数 多轮消除偶然因素——均值/std/p95 有统计意义 可复现性——所有参数记录到 benchmark_config.json Q33: 基准测试中样本数（samples）不一致怎么办？能比较吗？ 答： 样本数差异是正常的，原因：\n硬件事件采样数依赖实际 CPU 周期消耗——perf record 和 profiler 不可能精确收集相同数量的样本（同一 workload 但两次运行有波动） 软件事件采样数更稳定，基于墙钟时间 benchmark.py 的对比不是比较样本数，而是比较 CPU 时间（user_sec + sys_sec）——这才是真正的开销 统计手段： 多轮运行的 mean/std/p95 会告诉我们差异是否显著。\n13. 与 Linux perf 工具对比 Q34: perf record 的整个工作流程是怎样的？和你的项目有何异同？ 答：\n1 2 3 4 5 6 7 8 9 10 11 12 perf record 工作流程: perf record -F 99 -g -p \u0026lt;pid\u0026gt; |- perf_event_open() x 每CPU \u0026lt;- 与你相同 |- 内核采样: 记录 PC + callchain \u0026lt;- 内核完成 |- mmap 的 perf buffer -\u0026gt; 写入 perf.data \u0026lt;- 与你不同 (perf buffer vs ring buffer) |- 采集完成后，perf.data 是二进制格式 perf script: |- 读取 perf.data -\u0026gt; 后处理 -\u0026gt; 符号化输出 perf report: |- 读取 perf.data -\u0026gt; 统计聚合 -\u0026gt; 交互式 TUI 关键差异：\n阶段 perf Profiler 栈回溯 内核采样帧（有 skid，但有 PEBS 精确模式） bpf_get_stack()（BPF 辅助函数） 事件缓冲区 Perf buffer（每 CPU 独立 mmap） BPF ring buffer（共享 256KB） 数据格式 二进制 perf.data 直接文本输出 符号化时机 后处理（perf script） 在线（或关闭） 过滤能力 pid 过滤 tgid/pgrp/session/cgroup 灵活过滤 典型用途 通用性能分析 轻量级、可定制分析 Q35: 为什么 perf 工具仍然更常用？你的项目有什么独特价值？ 答：\nperf 的优势：\n随内核发布——零依赖，每台 Linux 都有 功能全面——除了采样还有 stat/top/annotate/ftrace 等 PEBS 精确事件——消除 skid 丰富的后处理——perf report TUI、perf diff 比较、perf annotate 汇编 我的项目的独特价值：\n学习价值——深入理解 eBPF + perf_event + 符号化的完整链路 定制化——filter 比 perf 更灵活（特别是 session/cgroup） 在线符号化——采样结束即刻有结果，无需后处理 低端到端开销——对于只关心火焰图的场景比 perf 更快 火焰图原生支持——FoldExtend 格式直出 Q36: perf_event 的 mmap ring buffer 和 BPF ring buffer 在实现层面的根本差异？ 答：\nPerf buffer (mmap)：\n1 2 3 内核写入: 通过 perf_output_begin/end 写到 mmap 共享内存页 用户态读取: 通过 poll/epoll 得知有数据，然后直接读 mmap 区域（零拷贝） 问题: 每 CPU 一个独立的 mmap 环形缓冲区 -\u0026gt; Ncpu 个 buffer BPF ring buffer：\n1 2 3 内核写入: bpf_ringbuf_reserve 分配记录 -\u0026gt; 填数据 -\u0026gt; bpf_ringbuf_submit 提交 用户态读取: ring_buffer__poll -\u0026gt; epoll -\u0026gt; 回调函数 特点: 单个共享 buffer，按时间戳全局有序 核心差异：BPF ring buffer 提供多生产者单消费者（multi-producer single-consumer）的原子性保证，不需要 per-CPU 隔离。\n14. SRE 发散题 Q37: 如果要部署到生产环境做持续 profiling，需要考虑什么？ 答： 从 SRE 视角：\n1. 资源开销控制：\n采样频率：生产环境通常 10-49Hz（而非默认 99Hz），项目默认 10Hz 是合理的 CPU 预算：约 1-3% 单核开销（需根据实际测量确认） 内存限制：RLIMIT_MEMLOCK 设为 infinity 不合适——生产环境应该根据 Map 大小计算精确值（ring buffer 256KB + BPF 程序本身约 50KB = 约 300KB） BPF 指令数限制：程序只有 137 行，远在 1M 指令限制之内 2. 数据持久化与聚合：\n当前所有数据存在进程内存中，需要添加定时 flush 或 Pushgateway 支持 生产环境需要将折叠数据推送到 Prometheus / Loki 而非 stdout 考虑采样率自适应——高负载时自动降低频率 3. 安全：\n需要 CAP_BPF + CAP_PERFMON（内核 5.8+ 的细粒度权限），不应以 root 运行 Perf 事件可能泄露其他进程的敏感信息——需要 namespace 隔离 /proc/kallsyms 读取限制（kernel.kptr_restrict=1 时非 root 无法读取内核符号） 4. 稳定性：\neBPF 程序 crash → perf 事件探测器会返回错误，用户态应处理 Ring buffer 满 → bpf_ringbuf_reserve 返回 NULL → 只能丢事件，应暴露 lost_events 指标 长时间运行下的内存泄漏：RingBuffer, EventHandler, Symbolizer 应定期重置 符号化可能非常慢（大 ELF/DWARF），需要考虑反压——丢事件 vs 阻塞 5. 可观测性：\n当前只输出 sample_count（--no-symbolize 模式） 应增加：lost_events 计数、ring buffer 水位、符号化延迟 P50/P99 Q38: 如果采样频率调到 1000Hz，系统会出现什么问题？ 答：\n1. Perf 事件风暴：\n每秒 1000 次 x Ncpu 个事件 = 大量中断 每个事件触发 BPF 程序执行（约 1-5us），可能导致中断风暴 在 64 核机器上：64 x 1000 x 2us = 128ms 的 CPU 时间每秒被 profiler 吃掉（~13%） 2. Ring buffer 溢出：\n256KB 的 ring buffer 在高频下可能瞬间填满 触发大量 bpf_ringbuf_reserve 返回 NULL（丢事件） StacktraceEvent 大小 = 4 + 4 + 8 + 16 + 4 + 4 + 1024 + 1024 = 2088 字节 256KB / 2KB ≈ 128 个事件就满了 —— 远不够 1000Hz x 64 CPU 3. 符号化线程跟不上：\n用户态 poll 回来 128 个事件，每个都需要符号化（比较慢） 形成反压——内核在填，用户态在慢速消费 4. 替代方案：\n超大 ring buffer（如 64MB） 只采样、不符号化（--no-symbolize） 或采样后 dump 原始数据、离线符号化 Q39: 你的 profiler 应该暴露哪些 SLI（Service Level Indicators）？ 答：\nSLI 指标 用途 采样覆盖率 samples_received / expected_samples 判断 ring buffer 是否溢出 符号化成功率 symbolized_stacks / total_stacks blazesym 健康检查 采样延迟 ring buffer 从写入到 poll 取出的延迟 数据新鲜度 CPU 自开销 profiler 自身消耗的 CPU 秒数 是否影响业务 内存使用 RSS / BPF Map 使用量 内存泄漏检测 错误率 perf_event_open 失败次数 系统兼容性 Q40: 如果用这个工具做分布式 profiling（多节点），需要怎么扩展？ 答：\n时间对齐： 多节点间需要精确时间同步（PTP/NTP），否则合并火焰图时时间轴不对齐 中央聚合： 每节点 push folded 数据到 Kafka/ClickHouse → 中央查询/渲染 按节点 key 区分： FoldExtend 格式中增加 hostname/node_id —— node1;comm-pid;func1;func2 count 采样策略协调： 避免所有节点同时高频采样冲击网络 去重策略： 如果集群中同构节点很多，按节点去重和跨节点去重的粒度需要权衡 15. 陷阱题 陷阱题 1：\u0026quot; 你的 profiler 采样时，如果目标进程正在做系统调用，采到的是内核栈还是用户栈？\u0026quot; 答： bpf_get_stack() 根据 flag 不同可以同时获取两者。我的项目同时获取了内核栈（flag=0）和用户栈（flag=BPF_F_USER_STACK）。\n系统调用路径中：内核栈会显示 syscall_entry -\u0026gt; do_syscall_64 -\u0026gt; 具体的syscall -\u0026gt; ... 用户栈会显示 syscall 之前的用户调用链 但存在一种情况： 如果进程正在内核空间执行（如内核线程或纯内核路径），bpf_get_stack(ctx, ustack, ..., BPF_F_USER_STACK) 可能返回空（因为当前上下文不在用户空间）。\n陷阱题 2：\u0026quot; 如果被 profiled 程序的 ELF 被删除了（如滚动升级），符号化还能工作吗？\u0026quot; 答：\n进程仍在运行： ELF 文件的 inode 仍在内核的 page cache 中，/proc/pid/maps 仍然有效 blazesym 通过 /proc/pid/maps 找到每个内存映射对应的 ELF 文件路径，再从中读取符号表 但如果磁盘上的 ELF 文件真的被删除了（而非被替换），blazesym 将无法打开文件，符号化会失败 解决方案： blazesym 支持读取 /proc/pid/mem（进程内存）获取符号，但这更复杂 陷阱题 3：\u0026quot; 如果采样频率设置得很高，但目标进程在采样瞬间被调度出去了，会怎样？\u0026quot; 答：\nperf_event 的 pid 参数决定监控哪个进程 如果目标进程不在 CPU 上运行，perf 事件不会触发（硬件周期计数器只计在 CPU 上执行的周期） 这意味着采样到的数据天然就是 \u0026quot; 进程实际运行时 \u0026quot; 的分布——这是一种隐式的有效工作时间过滤 对比 SW_CPU_CLOCK 模式，它会采样到 idle 进程——需要过滤掉 陷阱题 4：\u0026quot;bpf_get_current_comm() 返回的进程名最多 15 字符，如果进程名更长会怎样？\u0026quot; 答：\nLinux 内核的 task_struct::comm 字段固定为 16 字节（TASK_COMM_LEN=16，含末尾 \\0） bpf_get_current_comm() 最多拷贝 16 字节，更长的进程名会被截断 prctl(PR_SET_NAME) 可以设置但即使设置更长也只会存 15 个字符 对 profiling 的影响：相同进程名聚合后可能混入不同线程——所以 FoldExtend 格式中我们将 comm-pid 作为栈底，保证了唯一性 陷阱题 5：\u0026quot; 你的 match_target 函数中为什么先取 tgid 再判断？为什么不直接用 bpf_get_current_pid_tgid() 和 target_id 比较？\u0026quot; 答： 确实在 TGID 模式下直接比较了 tgid。但对于 PGRP/SESSION/CGROUP 模式，tgid 不参与比较——需要额外的内核数据结构遍历来获取这些 ID。\n1 2 3 4 // 优化后的逻辑：先检查 target_id == 0（无过滤），快速返回 if (target_id == 0) { return true; // 大多数情况下第一个判断就通过了！ } 巧妙之处： target_id == 0 的判断放在最前面——默认无过滤模式下，这是最高频路径，单指令就完成判断。\n陷阱题 6：\u0026quot; 如果 perf_event_open 失败（返回 -1），你的代码怎么处理？\u0026quot; 答：\n1 2 3 4 5 // src/perf.cpp int fd = static_cast\u0026lt;int\u0026gt;(perf_event_open(\u0026amp;attr, pid, cpu, -1, 0)); if (fd \u0026lt; 0) { return Err(libbpf_errno::LIBBPF_ERRNO__INTERNAL); } 任何 CPU 的 perf_event_open 失败都会导致整个 init 失败 init_perf_monitor 返回 Result，调用方（main.cpp）会打印错误并退出 缺失的容错： 如果只是个别 CPU 打开失败（如 offline CPU），理想情况应该跳过该 CPU 继续，但当前实现会整个失败 陷阱题 7：\u0026quot; 你的 benchmark.py 为什么先跑 profiler 再跑 perf？反过来会有什么不同？\u0026quot; 答： 顺序会影响结果！\n先跑的工具可能更有优势——系统更冷（cache 未污染，CPU 频率未降），但也可能更劣势（首次加载 page fault） benchmark.py 通过多轮交替（每轮内 fixed 顺序）来暴露这个问题 更严格的设计： 应该随机顺序，或 ABBA 模式（A-B-B-A），消除顺序偏差 当前设计已经比较公平——每个工具在每轮中各自启动独立的 workload 陷阱题 8：\u0026quot; 用户态符号化时，blazesym 读取了目标进程的内存映射（/proc/pid/maps），如果目标进程在符号化过程中结束了呢？\u0026quot; 答： blazesym 在符号化开始时打开 /proc/pid/maps 和对应的 ELF 文件。如果随后目标进程死亡：\nELF 文件本身不受影响（只是进程结束，文件仍在磁盘） /proc/pid/maps 内容可能不可用——但 blazesym 已经读取了 如果进程被另一个同名进程替换（PID 复用），maps 内容可能是错的——PID 复用是 profiling 工具的经典问题 解决方案：记录进程的 start_time（/proc/pid/stat 第 22 字段）作为唯一标识，检测 PID 复用 面试速查卡片 核心数字：\n采样方式：perf_event（硬件/软件） + eBPF 频率采样 默认采样频率：10 Hz BPF ring buffer 大小：256 KB 最大栈深度：128 帧 进程名长度：16 字节（TASK_COMM_LEN） 单事件大小：约 2088 字节（StacktraceEvent） 每 CPU 一个 perf_event fd Ring buffer Poll 超时：100 ms 符号化库：blazesym (Rust, via Corrosion) C++ 标准：C++23 BPF 程序行数：137 行 构建时长：约 2-3 分钟（含 Rust 编译） 核心数据流：\n1 2 perf_event trigger → profile() [eBPF] → ring buffer → poll → EventHandler → (Standard: 逐条符号化输出) / (FoldExtend: 批量去重输出) 核心设计模式：\nRAII 管理内核资源（ProfilerSkel, RingBuffer, blaze::Syms） std::expected 替代异常（Result\u0026lt;T,E\u0026gt;） 内核态过滤 → 减少无效数据传输 二进制 key 去重 → 相同栈只符号化一次 Overloaded + std::visit → 编译期多态 多轮统计基准测试 → 消除偶然因素 核心系统调用链：\n1 2 3 4 perf_event_open() x Ncpu → bpf_program__attach_perf_event() → poll loop (ring_buffer__poll) → EventHandler::handle() → blaze::Symbolizer::symbolize() → blazesym C API → handle_standard() / handle_fold_extend() 四个过滤模式对比：\n模式 内核实现 用户态解析 TGID pid_tgid \u0026gt;\u0026gt; 32 pid 直接作为 target_id PGRP BPF_CORE_READ(task, signal, pids[PIDTYPE_PGID], ...) getpgid(pid) Session BPF_CORE_READ(task, signal, pids[PIDTYPE_SID], ...) getsid(pid) Cgroup bpf_get_current_cgroup_id() stat(/proc/pid/cgroup path).st_ino 与 perf 对比一图流：\n1 2 3 4 5 6 7 8 采集 后处理 perf: [perf record] → [perf script + perf report] 开销: 低 开销: 高 输出: perf.data 输出: 文本/TUI profiler: [profiler -E] ────────────────────────┐ 开销: 中高 在线符号化 输出: 文本/Folded 输出: 可直接消费 ←──────────────────────┘ ","date":"2026-04-26T00:00:00Z","permalink":"/p/sre-%E5%AE%9E%E4%B9%A0%E9%9D%A2%E8%AF%95%E9%9D%A2%E8%AF%95%E8%A6%81%E7%82%B9/","title":"【SRE 实习面试】面试要点"},{"content":" 本文档结合 traffic-manager 项目实战经验，覆盖 SRE 实习面试可能涉及的全部知识点，包括项目深度挖掘、SRE 核心概念、Linux/Kubernetes 系统知识、故障排查、可观测性等方向。\n1. 项目深度挖掘 Q1: 请详细描述 traffic-manager 的核心工作原理 答： Traffic Manager 是一个基于 eBPF 的 Kubernetes Service L4 透明负载均衡器，核心工作流程：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 1. 用户态启动： - 加载 eBPF 程序（bpf2go 生成）→ JIT 编译 → 验证器检查 - 挂载到 cgroup/connect4、sendmsg4、recvmsg4 钩子 - 启动 Kubernetes 控制器（Informer + Workqueue） 2. 数据面流程（以 TCP 为例）： 应用 connect(Service_VIP:Port) → 内核 socket 层触发 cgroup/connect4 钩子 → eBPF 程序 sock4_connect() 执行 → 查找 service_meta_map（O(1) Hash） → 根据策略选择后端槽位（随机 O(1) / 加权 O(log N)） → 查找 service_slot_map → backend_map 获取 Pod IP:Port → 修改 ctx-\u0026gt;user_ip4 和 ctx-\u0026gt;user_port → 内核继续完成 TCP 握手（目标是 Pod IP） 3. 控制面流程： - Service/EndpointSlice Informer 监听 K8s 资源变化 - 事件入队到 Workqueue（去重、限流） - Worker 消费：构建 serviceState → 对比旧状态 → 更新 BPF Maps - 每 10 秒全量对账（syncLoop）兜底 关键数字：\neBPF 查找复杂度：O(1) Hash + O(log N) 二分搜索 对比 iptables：O(n) 规则遍历 性能提升：正常场景 +19.47%，500 个干扰 Service +27.74% Q2: 代码中如何处理 TCP 和 UDP 的差异？ 答： TCP 和 UDP 在 socket 层的行为差异决定了 eBPF 钩子的使用：\nTCP 处理（cgroup/connect4）：\n1 2 3 4 5 6 7 8 SEC(\u0026#34;cgroup/connect4\u0026#34;) int sock4_connect(struct bpf_sock_addr* ctx) { if (ctx-\u0026gt;protocol != IPPROTO_TCP) { return SYS_PROCEED; // 非 TCP 直接放行 } sock4_forward_entry(ctx, IPPROTO_TCP); return SYS_PROCEED; // 始终返回 1，内核继续连接 } TCP 是面向连接的，connect() 建立后方向固定 只需在连接建立时重写一次目标地址 后续数据传输不再需要 eBPF 干预 UDP 处理（sendmsg4 + recvmsg4）：\n1 2 3 4 5 6 7 8 9 10 11 SEC(\u0026#34;cgroup/sendmsg4\u0026#34;) int sock4_sendmsg(struct bpf_sock_addr* ctx) { sock4_udp_sendmsg_entry(ctx); // 每次发送都选后端 + 记录亲和性 return SYS_PROCEED; } SEC(\u0026#34;cgroup/recvmsg4\u0026#34;) int sock4_recvmsg(struct bpf_sock_addr* ctx) { sock4_udp_recvmsg_entry(ctx); // 恢复 VIP 源地址 return SYS_PROCEED; } UDP 无连接，每次 sendmsg()/recvmsg() 都需要处理 需要亲和性表（udp_affinity_map）保证同一客户端路由到同一后端 需要反向 NAT 表（nat_sk_map）恢复源地址（后端回复时源是 Pod IP，需改为 VIP） Q3: 加权负载均衡的具体实现细节是什么？ 答： 加权负载均衡分为用户态权重计算和内核态权重选择两部分：\n用户态权重计算（metrics + controller）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // metrics/metrics.go func (nm *NodeExporterNodeMetric) AvailableRate() float64 { if nm.load1 \u0026gt; 10 { return 0 // 负载 \u0026gt;= 10 认为节点不可用 } return (10 - nm.load1) / 10 // load1=2 → 0.8, load1=5 → 0.5 } // controller 中归一化权重 func (c *Controller) nodeMetricWeight(nodeName string) float64 { if weight, ok := c.queryNodeMetricWeight(nodeName); ok { return weight } return 1.0 // Prometheus 不可达时降级为等权重 } 内核态二分搜索（bpf/cgroup_sock.c）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static __always_inline int sock_fast_select_weighted_slot(__u16 sbc, __u16 total_weight, struct svc_slot_key key) { int random_point = bpf_get_prandom_u32() % total_weight; int l = 1, r = sbc; for (int i = 0; i \u0026lt; 10; i++) { // log2(1024) ≈ 10 int mid = (l + r) \u0026gt;\u0026gt; 1; key.backend_slot = mid; backend_slot = lookup_backend_slot(\u0026amp;key); if (!backend_slot) return -ENOENT; if (backend_slot-\u0026gt;weight_range_upper \u0026lt;= random_point) l = mid + 1; else r = mid; } return l; } 权重前缀和示例：\n1 2 3 4 5 后端 A: weight=0.5 → weight_range_upper = 512 (0.5 * 1024) 后端 B: weight=0.3 → weight_range_upper = 512 + 307 = 819 后端 C: weight=0.2 → weight_range_upper = 819 + 205 = 1024 随机点 600 → 落在 (512, 819] → 选中后端 B Q4: 为什么用 LRU_HASH 而不是普通 HASH？ 答： UDP 亲和性表和 NAT 表需要自动淘汰机制：\nMap 类型 淘汰策略 适用场景 BPF_MAP_TYPE_HASH 无，满了返回错误 静态数据（Service、Backend） BPF_MAP_TYPE_LRU_HASH 满时淘汰最久未使用 动态会话数据（UDP 亲和、NAT） 为什么需要 LRU：\n避免内存泄漏： UDP 客户端可能崩溃或断开，无法主动清理映射表 自动过期： 60 秒超时（LB_UDP_STATE_TIMEOUT_NS）通过 bpf_ktime_get_ns() 检查，但 LRU 提供了额外的兜底 条目限制： udp_affinity_map 最大 262,144 条目，满时自动淘汰最老的会话 代码中的超时检查 + LRU 双重保障：\n1 2 3 4 5 6 __u64 now = bpf_ktime_get_ns(); if (now - existing_affinity-\u0026gt;updated_at_ns \u0026gt; LB_UDP_STATE_TIMEOUT_NS) { bpf_map_delete_elem(\u0026amp;udp_affinity_map, \u0026amp;affinity_key); // 主动删除过期 goto select_backend; } // LRU 会在 map 满时自动淘汰最久未使用的条目 Q5: 控制器如何保证与 K8s 状态的最终一致性？ 答： 三层保障机制：\n1. 事件驱动（实时同步）：\n1 2 3 4 5 // 任何 Service/EndpointSlice 变化都触发入队 func (c *Controller) handleServiceAdd(obj interface{}) { key := ... // 生成 namespace/name c.queue.Add(key) } 2. Workqueue 特性（去重 + 限流）：\n去重： 同一 key 在队列中存在时不会重复添加 限流： 失败时 AddRateLimited 指数退避重试 有序： FIFO 保证公平性 3. 定期全量对账（兜底）：\n1 2 3 4 5 6 7 8 9 10 func (c *Controller) syncLoop() { ticker := time.NewTicker(10 * time.Second) for range ticker.C { c.mutex.Lock() for key := range c.svcStates { c.queue.Add(key) // 所有已知 Service 重新入队 } c.mutex.Unlock() } } 4. 状态变更检测（避免无效更新）：\n1 2 3 if serviceStateEqual(oldState, newState) { return nil // 状态未变，不写 BPF Map } 这就是 Kubernetes 控制器模式的核心：声明式 + 最终一致性。\n2. 项目架构与设计决策 Q6: 为什么选择二级映射（Service → Slot → Backend）而不是一级映射？ 答： 二级映射是经典的间接引用设计，带来以下好处：\n一级映射（反例）：\n1 2 // 如果直接 service_ip → backend_ip，权重如何实现？ // 需要为每个后端复制多个条目，浪费内存且更新复杂 二级映射（实际实现）：\n1 2 3 4 5 6 ServiceMeta (VIP:Port → Meta) ↓ 业务维度：count, action, total_weight ServiceSlot (VIP:Port:SlotIndex → BackendID) ↓ 调度维度：weight_range_upper（前缀和） Backend (BackendID → IP:Port:Proto) ↓ 物理维度 优势：\n权重灵活表示： 一个后端可以有多个槽位（权重 0.5 = 512 个槽位指向同一 BackendID） 减少内存： Backend 的完整信息（IP、Port、Flags）只存一份，Slot 只存 BackendID（u32） 原子更新： Pod 重建后 IP 变化，只需更新 backend_map，无需改动 service_slot_map 二分搜索支持： weight_range_upper 前缀和使得 O(log N) 查找成为可能 Q7: 为什么选择 cgroup/connect4 而不是 XDP 或 TC？ 答： 对比分析：\n钩子 挂载位置 网络栈路径 本项目为何选择/不选择 XDP 网卡驱动层（最早） 包未进入内核协议栈 需要改网卡驱动，且处理的是路由后的包，已过 netfilter TC 协议栈入口/出口 经过 netfilter 同样在 iptables 规则链路径上 cgroup/connect4 socket 层 connect() 最早的用户态触发点 完全绕过 netfilter，直接改写目标地址 核心原因：\n时机最早： 在 connect() 系统调用时就完成重写，内核后续完全感知不到 VIP 无需理解包结构： 直接使用 bpf_sock_addr 上下文修改 user_ip4/user_port 天然隔离： cgroup v2 可以按进程组控制哪些流量被重定向 绕过 netfilter： XDP/TC 处理的包仍会经过 iptables 链 Q8: bpf2go 工具链的作用是什么？ 答： bpf2go 是 Cilium 提供的工具，用于简化 eBPF 开发与 Go 的集成：\n工作流程：\n1 2 3 4 5 6 7 8 # 1. 编写 eBPF C 代码（bpf/cgroup_sock.c） # 2. 运行 go generate（触发 bpf2go） cd pkg/bpf \u0026amp;\u0026amp; go generate # 生成文件： # - connect_bpfel.go（Go 绑定，包含 connectObjects 结构体） # - connect_bpfel.o（eBPF 字节码） # 3. 编译主程序 go build -o bin/traffic-manager main.go bpf2go 做了什么：\n用 clang 将 C 代码编译为 eBPF 字节码（.o 文件） 将字节码嵌入 Go 源码（作为 []byte 常量） 生成类型安全的 Go 绑定（Maps、Programs 的访问封装） 支持 CO-RE（Compile Once, Run Everywhere）特性 生成代码示例：\n1 2 3 4 5 6 7 8 9 10 11 // connect_bpfel.go（自动生成） type connectObjects struct { connectPrograms connectMaps } type connectMaps struct { ServiceMetaMap *ebpf.Map `ebpf:\u0026#34;service_meta_map\u0026#34;` ServiceSlotMap *ebpf.Map `ebpf:\u0026#34;service_slot_map\u0026#34;` // ... } 3. eBPF 技术深度 Q9: eBPF Verifier 如何保证内核安全？ 答： Verifier 是 eBPF 的安全守门员，在程序加载到内核前执行静态分析：\n主要检查项：\nDAG 检测（无死循环）：\n传统 BPF 完全不允许循环 内核 5.3+ 支持有界循环（循环次数在编译期可确定） 我的代码中二分搜索：for (int i = 0; i \u0026lt; 10; i++) 上限固定为 10 内存安全：\n所有指针访问前必须检查非空 不允许越界访问（通过指针类型追踪） 1 2 backend_slot = lookup_backend_slot(\u0026amp;key); if (!backend_slot) return -ENOENT; // 必须检查 NULL 寄存器状态追踪：\n每个寄存器有类型（PTR_TO_MAP_KEY、PTR_TO_STACK 等） 禁止未初始化变量的使用 指令数限制：\n内核 5.2+：100 万条指令 之前：4096 条 我的代码如何通过验证：\n使用 static __always_inline 内联函数，减少栈深度 二分搜索循环上限硬编码（verifier 可完全展开） 所有 bpf_map_lookup_elem 返回值都做 NULL 检查 使用 volatile 防止编译器优化掉边界检查 Q10: eBPF Map 的 Pin 机制有什么用？ 答： Pin（固化）将 BPF Map 实例化为文件系统对象（通常在 /sys/fs/bpf/），使其生命周期独立于创建进程：\n为什么需要：\n进程重启恢复： traffic-manager crash 后重启，通过 ebpf.LoadPinnedMap() 重新打开已有 Map，恢复之前的状态 多进程共享： 调试工具 bpftool map dump 可以读取 pinned Map 优雅升级： 新版本程序可以直接接管旧的 Map 代码演示：\n1 2 3 4 5 6 7 8 9 10 11 12 // 加载时设置 Pin 路径 options.Maps.PinPath = \u0026#34;/sys/fs/bpf/sock_ops_map\u0026#34; loadConnectObjects(\u0026amp;program.connectObj, \u0026amp;options) // 检查命令通过 LoadPinnedMap 读取 func LookupPinnedService(ip, port) (exists bool, ...) { mapObj, err := ebpf.LoadPinnedMap(\u0026#34;/sys/fs/bpf/sock_ops_map/service_meta_map\u0026#34;, nil) // ... } // 关闭时 Unpin（从文件系统移除引用） program.connectObj.connectMaps.ServiceMetaMap.Unpin() Q11: 为什么需要 rlimit.RemoveMemlock()？ 答： eBPF Maps 和 Programs 的内存被锁定在 RAM 中（不能被 swap），因为内核处理网络包时不能承受 page fault。\n历史限制：\nLinux 默认限制每个进程的可锁定内存量（通常 64KB） eBPF Map 通常需要 MB 级内存（如 udp_affinity_map 有 262,144 条目） rlimit.RemoveMemlock() 移除这个限制 现代内核的解决方案：\n内核 5.11+ 引入了 CAP_BPF 能力 有此能力的进程不再需要 RLIMIT_MEMLOCK 设置 但在旧内核上仍需要此调用 4. Kubernetes 控制器模式 Q12: Informer + Workqueue 的工作原理是什么？ 答： 这是 Kubernetes 自定义控制器的标准模式：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ┌─────────────┐ │ API Server │ └──────┬───────┘ │ List \u0026amp; Watch (HTTP/2 长连接) ┌──────▼───────┐ │ Informer │ │ (Reflector) │ ← 全量 List + 增量 Watch │ DeltaFIFO │ ← 存储未处理的事件 └──────┬───────┘ │ Add/Update/Delete Event ┌──────▼───────┐ │ WorkQueue │ ← 去重、限流、顺序保证 │ (RateLimiting)│ └──────┬───────┘ │ 消费者取 key ┌──────▼───────┐ │ Worker │ ← 获取最新状态，协调到 eBPF Maps └──────────────┘ Informer 的关键特性：\n本地缓存（Store）： svcLister.Services(namespace).Get(name) 是纯内存操作，不访问 API Server List + Watch： 先全量 List，然后增量 Watch 变更事件 事件去重： DeltaFIFO 保证同一对象不会被重复处理 Workqueue 的关键特性：\n去重： 同一 key 在队列中存在时不会重复添加 限流： AddRateLimited 指数退避（5s → 10s → 20s\u0026hellip;） 失败重试： 处理失败时重新入队，成功后 Forget 清空限流计数 Q13: EndpointSlice 相比 Endpoints 有什么优势？ 答： EndpointSlice 是 Kubernetes 1.21+ 引入的 Endpoints 替代方案：\n维度 Endpoints EndpointSlice 对象结构 一个 Service 的所有后端在一个对象中 自动分片，每片最多 100 个端点 更新机制 全量更新（即使只变一个后端） 增量更新（只传输变化的切片） 大规模场景 单个对象可能数 MB 分片后每片小，传输快 拓扑感知 不支持 支持 Topology 字段（zone、node 等） 我的项目使用 EndpointSlice：\n1 2 3 4 5 6 epInformer := factory.Discovery().V1().EndpointSlices() // 通过 LabelServiceName 标签关联到 Service selector := labels.SelectorFromSet(labels.Set{ discoveryv1.LabelServiceName: name, }) endpointSlices, _ := c.epLister.EndpointSlices(namespace).List(selector) 优势：\n单个 Service 有数百个后端 Pod 时，EndpointSlice 避免每次传输数 MB 的完整对象 增量更新减少 API Server 和网络的负载 Q14: 控制器如何处理并发？ 答： 并发控制通过 sync.Mutex 保护共享状态：\n1 2 3 4 5 type Controller struct { svcStates map[string]*serviceState // 被保护的共享状态 mutex sync.Mutex queue workqueue.TypedRateLimitingInterface[string] } 临界区分析：\n操作 位置 是否需要锁 读 svcStates refreshState() → 获取旧状态 ✓ 写 svcStates commitServiceState() → 更新状态 ✓ 遍历 svcStates syncLoop() → 全量入队 ✓ 删除 svcStates Service 被删除时 ✓ Workqueue 的并发保证：\nclient-go 的 Workqueue 保证：同一时间只有一个 worker 处理同一个 key 即使多个 worker 并行，相同的 namespace/name 不会同时被处理 因此 refreshState 中对同一 key 的操作是串行的 Worker 数量： = NumCPU，通过 runtime.NumCPU() 获取\n5. 可观测性实践 Q15: 项目中有哪些可观测性手段？ 答： 三层可观测性：\n1. 数据面统计（eBPF 内嵌）：\n1 2 3 4 5 6 7 // stats_map 是 BPF_MAP_TYPE_ARRAY，6 个计数器 incr_stat(STAT_CONNECT_ATTEMPTS); // 总连接尝试 incr_stat(STAT_SERVICE_MISS); // 服务未找到 incr_stat(STAT_BACKEND_SLOT_MISS); // 槽位未找到 incr_stat(STAT_BACKEND_MISS); // 后端未找到 incr_stat(STAT_REWRITE_SUCCESS); // 重写成功 incr_stat(STAT_UNSUPPORTED_ACTION); // 不支持的动作 通过 --dump-stats 读取：\n1 2 3 ./traffic-manager --dump-stats # 输出： # INFO BPF metrics dump stats.connect_attempts=12345 stats.service_misses=12 ... 2. 控制面指标（Prometheus + node-exporter）：\n1 2 3 // 查询 node_load1 指标 query := `avg_over_time(node_load1{job=\u0026#34;node-exporter\u0026#34;}[1m])` promAPI.QueryRange(ctx, query, v1.Range{...}) 3. 应用日志（slog 结构化日志）：\n1 2 slog.Warn(\u0026#34;Failed to update metrics\u0026#34;, \u0026#34;service\u0026#34;, key, \u0026#34;error\u0026#34;, err) // 可输出为 JSON 格式，便于 Loki/ELK 收集 Q16: node_load1 作为权重指标有什么问题？ 答： node_load1 的局限性：\nload1 是 CPU 队列长度： 不能区分 CPU 密集型和 IO 密集型 1 分钟平均有滞后： 流量尖峰不能立即反映 硬编码阈值 10： 2 核机器 load1=2 已满载，64 核机器 load1=2 还很空闲 不考虑内存/网络压力： 节点可能因内存不足或网络拥塞而不可用 更好的方案：\n指标 公式 优势 劣势 相对负载 (num_cpu - load1) / num_cpu 适配不同规格节点 仍是 CPU 维度 内存可用率 node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes 考虑内存压力 不反映 CPU 综合打分 0.4*CPU + 0.3*Mem + 0.2*Net + 0.1*Latency 多维度 需要调参 应用层延迟 P99/P50 响应时间 最直接的 QoE 需要应用暴露 Q17: 如果 Prometheus 不可达，系统会怎样？ 答： 优雅降级机制：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // controller.go if c.metrics != nil { c.metrics.Update() // 更新失败只打 WARN，不崩溃 } // 权重查询降级链路 func (c *Controller) nodeMetricWeight(nodeName string) float64 { // 1. 尝试用 NodeName 查询 if weight, ok := c.queryNodeMetricWeight(nodeName); ok { return weight } // 2. 通过 Node InternalIP 再试一次 if node, ok := c.nodeLister.Get(nodeName); ok { // 获取 Node InternalIP 再查询... } // 3. 最终降级：返回 1.0（等权重） return 1.0 } 降级链路：\nPrometheus 查询成功 → 使用真实权重 Prometheus 不可达 → 尝试用 Node IP 查询 全部失败 → 返回 1.0（退化为随机负载均衡） 设计评价： 优雅降级做得不错，但没有缓存上一次成功的权重值。如果 Prometheus 短暂不可达，会立即丢失所有节点的权重差异。\n6. 性能优化与基准测试 Q18: 基准测试的设计有什么优缺点？ 答： 测试设计（来自 scripts/benchmark-minikube.py）：\n测试流程：\n1 2 3 4 5 6 7 1. 部署 25 个后端 Pod (sisyphe Deployment) 2. 使用 siege 压测工具发起 HTTP 请求 3. 对比场景： - Baseline: kube-proxy (iptables) - Experiment: Traffic Manager (eBPF) 4. 额外场景：创建 500/1000 个 dummy Service 5. 每个场景：50 并发 5s 预热 → 200 并发 15s 正式测试 优点：\n方面 说明 预热机制 避免冷启动偏差 干扰 Service 验证了 O(1) vs O(n) 的差异 HTTP 短连接 覆盖了新建连接场景（connect() 频繁） 不足：\n方面 改进建议 单节点环境 应补充多节点测试（跨节点网络开销） 只测 QPS 应补充 P99 延迟、CPU 使用率 短连接为主 应测试长连接（gRPC、WebSocket） 25 个后端 应测试数百个后端（验证二分搜索优势） Q19: 为什么 eBPF 在大规模 Service 场景优势更明显？ 答： 核心原因：iptables 是 O(n)，eBPF 是 O(1)。\niptables 处理流程：\n1 2 3 4 5 6 connect() → OUTPUT 链 → KUBE-SERVICES 链 → 遍历规则 1 → 不匹配 → 遍历规则 2 → 不匹配 → ... → 遍历规则 500 → 匹配！（第 500 个 Service） → DNAT → 完成 eBPF 处理流程：\n1 2 3 4 connect() → cgroup/connect4 钩子 → Hash 查找 service_meta_map（O(1)，无论多少 Service） → 二分搜索后端槽位（O(log N)，N 为后端数） → 完成 数据验证：\n正常场景（少量 Service）：eBPF 快 19.47% 500 个干扰 Service：eBPF 快 27.74% 差距扩大：iptables 规则遍历成本随 Service 数线性增长，eBPF Hash 查找时间恒定 7. SRE 核心概念 Q20: 什么是 SRE？SRE 和 DevOps 的关系是什么？ 答： SRE（Site Reliability Engineering）是 Google 提出的一种工程实践，通过软件工程的方法解决运维问题。\n核心理念：\n拥抱风险： 100% 可用性是错误目标，追求合理的可用性（如 99.9%） SLO/SLI/SLA： 用数据驱动可靠性决策 错误预算： 可用性的 \u0026quot; 货币 \u0026ldquo;，用于平衡创新和稳定性 自动化： 消除重复手工劳动（\u0026ldquo;SRE 不手动操作 \u0026ldquo;） 监控： 必须有数据支撑决策 SRE vs DevOps：\n维度 DevOps SRE 定位 文化运动、方法论 具体工程实践 关注点 开发运维协作 系统可靠性工程 实践 CI/CD、自动化 SLO、错误预算、容量规划 关系 SRE 是 DevOps 的具体实现之一 SRE 是 DevOps 文化的工程化落地 Q21: 解释 SLO、SLI、SLA 的区别 答：\nSLI（Service Level Indicator，服务水平指标）：\n测量的具体指标，通常是量化的值 例如：请求延迟、错误率、吞吐量 公式：SLI = (好事件数 / 总事件数) * 100% SLO（Service Level Objective，服务水平目标）：\n对 SLI 的目标值，内部承诺 例如：\u0026ldquo;99% 的请求延迟 \u0026lt; 100ms\u0026rdquo; 不是对外承诺，是团队内部的质量目标 SLA（Service Level Agreement，服务水平协议）：\n对外承诺，通常有经济惩罚条款 例如：\u0026rdquo; 可用性 99.9%，否则赔偿 10% 服务费 \u0026quot; SLA ≤ SLO（对外承诺要低于内部目标） 示例：\n1 2 3 4 SLI: HTTP 请求成功率 SLO: 99.9% 的请求成功（月度） SLA: 99.5% 的可用性承诺（对客户） 错误预算: 0.1% * 月度总请求数 = 允许的失败次数 Q22: 什么是错误预算（Error Budget）？如何使用？ 答： 错误预算是 SLO 的 \u0026quot; 货币 \u0026ldquo;，表示允许的不可用时间。\n计算：\n1 2 SLO: 99.9% 可用 错误预算: 0.1% * 月度总分钟数 = 0.1% * 43200 = 43.2 分钟 使用场景：\n发布决策： 如果错误预算剩余 \u0026lt; 10%，暂停非关键发布 权衡创新与稳定性： 预算充足时快速迭代，预算不足时保守运营 事故复盘： 事故消耗了多少预算？如何改进？ 实践建议：\n错误预算不是 \u0026quot; 目标 \u0026ldquo;，而是 \u0026quot; 最大可接受不可用时间 \u0026quot; 不要故意消耗预算（\u0026rdquo; 反正有 43 分钟 \u0026ldquo;） 用错误预算推动文化：团队共同负责可靠性 8. Linux 系统知识 Q23: 解释 cgroup v2 和 v1 的区别 答：\n维度 cgroup v1 cgroup v2 层级结构 每个控制器独立层级（cpu、memory、blkio 各自一棵树） 统一层级（所有控制器在一棵树上） 进程归属 一个进程可以在不同层级的多个 cgroup 中 一个进程只能在一个 cgroup 中 线程支持 不支持线程级控制 支持线程级控制 eBPF 支持 部分钩子支持 所有 cgroup BPF 钩子只支持 v2 为什么项目需要 cgroup v2：\ncgroup/connect4、cgroup/sendmsg4、cgroup/recvmsg4 等 BPF 程序类型只支持 cgroup v2 cgroup v2 支持无特权 BPF 程序挂载（通过 CAP_BPF） 检测 cgroup v2 挂载点：\n1 2 3 4 5 6 7 8 9 10 func DetectCgroupPath() (string, error) { f, _ := os.Open(\u0026#34;/proc/mounts\u0026#34;) for scanner.Scan() { fields := strings.Split(scanner.Text(), \u0026#34; \u0026#34;) if len(fields) \u0026gt;= 3 \u0026amp;\u0026amp; fields[2] == \u0026#34;cgroup2\u0026#34; { return fields[1], nil // 返回挂载点，如 /sys/fs/cgroup } } return \u0026#34;\u0026#34;, fmt.Errorf(\u0026#34;cgroup2 not mounted\u0026#34;) } Q24: 解释 Linux Socket 层的 connect()、sendmsg()、recvmsg() 答：\n系统调用 用途 TCP UDP connect() 建立连接/设置默认目标 三次握手，后续数据面向此连接 只设置默认目标地址，不影响数据面 sendmsg() / sendto() 发送数据 使用 connect() 建立的目标 可以每次指定不同目标 recvmsg() / recvfrom() 接收数据 返回发送方地址 返回数据来源地址 eBPF 钩子对应关系：\ncgroup/connect4 → connect() 系统调用 cgroup/sendmsg4 → sendmsg() / sendto() 系统调用 cgroup/recvmsg4 → recvmsg() / recvfrom() 系统调用 UDP 的特殊性：\nUDP 无连接，每次 sendmsg() 都可能选择不同后端 需要亲和性表（udp_affinity_map）保证同一客户端路由到同一后端 需要反向 NAT 表（nat_sk_map）恢复源地址 Q25: 什么是 CO-RE（Compile Once, Run Everywhere）？ 答： CO-RE 是 BPF 的一项特性，允许 eBPF 程序编译一次，在不同内核版本上运行。\n问题背景：\n传统 BPF：需要读取内核结构体（如 struct task_struct），但不同内核版本结构体布局不同 解决方案 1：在每个节点上安装内核头文件，现场编译 解决方案 2（CO-RE）：在 eBPF 程序中嵌入 BTF（BPF Type Format）信息，加载时根据当前内核的 BTF 重定位 我的项目是否使用 CO-RE？\n1 2 3 # bpf2go 默认生成 CO-RE 兼容的代码 go tool bpf2go -tags linux -target bpfel connect ../../bpf/cgroup_sock.c # 生成的 connect_bpfel.go 包含 BTF 重定位信息 优势：\n无需在目标节点安装内核头文件 一个二进制可以在不同内核版本上运行 简化部署（只需拷贝二进制） 9. Kubernetes 运维实践 Q26: 如何排查 Kubernetes Service 无法访问的问题？ 答： 分层排查：\n1. 应用层：\n1 2 3 4 5 6 # 检查 Pod 是否正常运行 kubectl get pods -n \u0026lt;namespace\u0026gt; kubectl logs \u0026lt;pod-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 检查 Pod 内服务是否监听正确端口 kubectl exec \u0026lt;pod-name\u0026gt; -- netstat -tlnp 2. Service 层：\n1 2 3 4 5 6 7 8 9 # 检查 Service 是否存在 kubectl get svc \u0026lt;service-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 检查 Endpoints/EndpointSlice 是否有后端 kubectl get endpoints \u0026lt;service-name\u0026gt; -n \u0026lt;namespace\u0026gt; kubectl get endpointslice -l kubernetes.io/service-name=\u0026lt;service-name\u0026gt; -n \u0026lt;namespace\u0026gt; # 描述 Service，查看 selector 和 endpoints kubectl describe svc \u0026lt;service-name\u0026gt; -n \u0026lt;namespace\u0026gt; 3. 网络层（以本项目为例）：\n1 2 3 4 5 6 7 8 # 检查 eBPF 程序是否挂载 ls /sys/fs/bpf/sock_ops_map/ # 检查 BPF Maps 是否正确 bpftool map dump pinned /sys/fs/bpf/sock_ops_map/service_meta_map # 检查统计信息 ./traffic-manager --dump-stats 4. 内核层：\n1 2 3 4 5 # 查看 eBPF 程序日志 cat /sys/kernel/debug/tracing/trace_pipe | grep traffic # 检查 cgroup v2 挂载 mount | grep cgroup2 Q27: Kubernetes 控制器开发的最佳实践有哪些？ 答：\n1. 使用 client-go 的 Informer + Workqueue：\n不要自己轮询 API Server Informer 提供本地缓存，减少 API Server 压力 2. 幂等性（Idempotency）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 func (c *Controller) processNextItem() bool { key, _ := c.queue.Get() defer c.queue.Done(key) // 无论执行多少次，结果应该相同 err := c.syncHandler(key.(string)) if err != nil { c.queue.AddRateLimited(key) // 失败重试 return true } c.queue.Forget(key) // 成功，清空限流计数 return true } 3. 最终一致性：\n不要期望立即一致，允许短暂不一致 定期全量对账（syncLoop）兜底 4. 优雅退出：\n1 2 3 4 func (c *Controller) Run(stopCh \u0026lt;-chan struct{}) { defer c.queue.ShutDown() // 关闭队列，不再接受新任务 // ... } Q28: 如何升级 Kubernetes 集群？ 答： 滚动升级策略：\n1. 升级控制平面（Control Plane）：\n1 2 3 4 5 6 7 8 9 10 11 12 # 1. 升级 kubeadm apt-get update \u0026amp;\u0026amp; apt-get install kubeadm=1.29.x # 2. 检查升级计划 kubeadm upgrade plan # 3. 执行升级 kubeadm upgrade apply v1.29.x # 4. 升级 kubelet 和 kubectl apt-get install kubelet=1.29.x kubectl=1.29.x systemctl restart kubelet 2. 升级工作节点（Worker Node）：\n1 2 3 4 5 6 7 8 9 10 11 12 # 1. 标记节点不可调度 kubectl cordon \u0026lt;node-name\u0026gt; # 2. 驱逐 Pod kubectl drain \u0026lt;node-name\u0026gt; --ignore-daemonsets # 3. 升级 kubelet apt-get install kubelet=1.29.x systemctl restart kubelet # 4. 恢复调度 kubectl uncordon \u0026lt;node-name\u0026gt; 3. 注意事项：\n逐个节点升级，避免同时升级多个节点 确保 PodDisruptionBudget（PDB）配置正确 备份 etcd 数据（升级前） 10. 故障排查与应急响应 Q29: 如果发现生产环境某个 Service 流量异常，如何排查？ 答： 按层次从上到下：\n1. 快速定位问题范围：\n1 2 3 # 是所有用户受影响，还是部分用户？ # 是全部 Service，还是单个 Service？ # 是某个节点，还是全部节点？ 2. 检查 Kubernetes 资源：\n1 2 3 4 5 6 7 8 9 # Service 是否存在 kubectl get svc \u0026lt;service-name\u0026gt; # 后端 Pod 是否 Ready kubectl get pods -l app=\u0026lt;app-label\u0026gt; kubectl describe pod \u0026lt;pod-name\u0026gt; # EndpointSlice 是否正确 kubectl get endpointslice -l kubernetes.io/service-name=\u0026lt;service-name\u0026gt; 3. 检查本项目（traffic-manager）状态：\n1 2 3 4 5 6 7 8 9 10 11 # 检查进程是否运行 ps aux | grep traffic-manager # 检查 eBPF 程序是否挂载 bpftool cgroup show /sys/fs/cgroup # 检查 BPF Maps ./traffic-manager --check-service-ip=\u0026lt;VIP\u0026gt; --check-service-port=\u0026lt;port\u0026gt; # 查看统计信息 ./traffic-manager --dump-stats 4. 检查数据面（eBPF）：\n1 2 3 4 5 # 查看 eBPF 日志 cat /sys/kernel/debug/tracing/trace_pipe # dump BPF Maps 查看配置 bpftool map dump pinned /sys/fs/bpf/sock_ops_map/service_meta_map 5. 检查控制面（Prometheus/Controller）：\n1 2 3 4 5 # Prometheus 是否可达 curl http://prometheus:9090/-/healthy # 查看 Controller 日志 journalctl -u traffic-manager -f Q30: 如何设计故障演练（GameDay）？ 答： 故障演练是 SRE 的重要实践，验证系统在故障时的表现。\n常见故障场景：\n故障类型 注入方法 预期行为 Pod 崩溃 kubectl delete pod 流量应路由到其他后端 节点故障 关闭节点 / 断开网络 流量应路由到其他节点 API Server 不可达 断开 API Server 网络 Controller 停止更新，但现有规则继续工作 Prometheus 不可达 停止 Prometheus 降级为等权重负载均衡 eBPF 程序卸载 pkill traffic-manager cgroup 钩子解除，流量走 kube-proxy 演练流程：\n计划： 明确演练目标、范围、时间窗口 通知： 通知相关团队，避免误报告警 注入故障： 逐步注入，观察系统行为 记录： 记录发现的问题、恢复时间 复盘： 总结改进措施 示例（本项目）：\n1 2 3 4 5 6 7 8 # 故障注入：停止 traffic-manager pkill traffic-manager # 观察：新连接是否还能正常工作？ # 预期：cgroup 钩子解除，流量回退到 kube-proxy # 恢复：重启 traffic-manager sudo ./traffic-manager \u0026amp; 11. 监控告警与 SLO/SLI/SLA Q31: 如何为本项目设计 SLO？ 答： 基于项目特性，可以设计以下 SLO：\n1. 可用性 SLO：\n1 2 3 SLI: 成功转发的连接数 / 总连接尝试数 SLO: 99.9% 的连接成功转发 测量方法: stats_map 中的 rewrite_success / connect_attempts 2. 延迟 SLO：\n1 2 3 SLI: eBPF 程序执行时间（可以通过 bpf_ktime_get_ns() 测量） SLO: 99% 的 connect() 重定向 \u0026lt; 100μs 注意: eBPF 程序执行时间需要额外的观测手段（perf event） 3. 数据一致性 SLO：\n1 2 3 SLI: K8s Service 变化到 eBPF Map 更新的延迟 SLO: 99% 的变更在 10 秒内生效 测量方法: 记录 Informer 事件时间戳 vs Map 更新时间戳 错误预算计算：\n1 2 月度可用性 SLO: 99.9% 错误预算: 0.1% * 43200 分钟 = 43.2 分钟不可用时间 Q32: 如何设计告警规则？ 答： 基于项目的可观测性数据：\n1. 数据面告警：\n1 2 3 4 5 6 7 8 9 10 # Prometheus 告警规则 - alert: HighServiceMissRate expr: rate(stats_service_misses[5m]) / rate(stats_connect_attempts[5m]) \u0026gt; 0.05 annotations: description: \u0026#34;Service 未命中率超过 5%\u0026#34; - alert: HighBackendMissRate expr: rate(stats_backend_misses[5m]) \u0026gt; 10 annotations: description: \u0026#34;后端未找到速率超过 10/s\u0026#34; 2. 控制面告警：\n1 2 3 4 5 6 7 8 9 - alert: ControllerSyncFailure expr: increase(controller_sync_errors[5m]) \u0026gt; 0 annotations: description: \u0026#34;控制器同步失败\u0026#34; - alert: PrometheusUnavailable expr: up{job=\u0026#34;prometheus\u0026#34;} == 0 annotations: description: \u0026#34;Prometheus 不可达，权重降级为等权重\u0026#34; 3. 系统级告警：\n1 2 3 4 5 6 7 8 9 - alert: TrafficManagerDown expr: up{job=\u0026#34;traffic-manager\u0026#34;} == 0 annotations: description: \u0026#34;traffic-manager 进程崩溃\u0026#34; - alert: BPFMapNearFull expr: bpf_map_size / bpf_map_max \u0026gt; 0.8 annotations: description: \u0026#34;BPF Map 使用率超过 80%\u0026#34; 12. 容量规划与扩展性 Q33: 如何做容量规划？ 答： 容量规划需要关注各层的瓶颈：\n1. BPF Map 容量：\nMap 类型 最大条目 建议规划 service*meta_map Hash 65536 按 Service 数量 * 1.5 预留 backend*map Hash 65536 按总 Pod 数量 * 2 预留 service*slot_map Hash 65536 按总槽位数（后端数 * 权重因子） udp*affinity_map LRU Hash 262144 按并发 UDP 会话数 * 2 2. Controller 性能：\nWorker 数量 = NumCPU 每个 Service 更新需要 2-3 次 Map 操作（O(1)） 1000 个 Service 全量更新：~1000 * 3 = 3000 次 Map 操作，毫秒级完成 3. API Server 压力：\n1 2 单节点: 2 个 Watch 连接（Service + EndpointSlice） 1000 节点集群: 2000 个 Watch 连接 HTTP/2 多路复用，实际压力可控 大规模集群（\u0026gt;1000 节点）建议评估 API Server 规格 4. 建议的容量规划公式：\n1 2 3 最大 Service 数 = 65536 / 1.5 ≈ 43690 最大后端 Pod 数 = 65536 / 2 ≈ 32768 最大并发 UDP 会话 = 262144 / 2 ≈ 131072 Q34: 如果要在 1000 节点集群部署，需要做什么调整？ 答： 需要评估和调整：\n1. API Server 压力：\n1 2 当前: 每节点 Watch 2 种资源（Service + EndpointSlice） 1000 节点: 2000 个长连接 评估 API Server 的 CPU/内存规格 考虑使用 EndpointSliceHint 减少推送频率 2. BPF Map 大小：\n如果 Service + Pod 总数超过 65536，需要增大 Map 大小 或者分片部署（不同节点组使用不同的 BPF Maps） 3. 控制器架构：\n当前每节点一个控制器实例，Watch 全部 Service 优化方案：使用 Leader Election，只在一个节点运行控制器，其他节点只运行 eBPF 程序 4. 监控和告警：\n增加 API Server 连接数监控 增加 BPF Map 使用率监控 13. 安全与权限管理 Q35: 运行本项目需要哪些 Linux Capabilities？ 答：\nCapability 用途 是否必需 CAP_BPF 加载 eBPF 程序、操作 BPF Maps 必需（内核 5.8+） CAP_NET_ADMIN 挂载 eBPF 程序到 cgroup 网络钩子 必需 CAP_SYS_ADMIN 旧内核中的超级权限（代替 CAP_BPF） 旧内核必需 CAP_SYS_RESOURCE 调整 RLIMIT_MEMLOCK 旧内核必需（\u0026lt; 5.11） 最佳实践：\n1 2 3 4 5 6 # 不要用 root 运行，而是授予最小权限 sudo setcap cap_bpf,cap_net_admin+ep ./bin/traffic-manager # 验证 getcap ./bin/traffic-manager # 输出: ./bin/traffic-manager = cap_bpf,cap_net_admin+ep Q36: eBPF 程序可能引入哪些安全风险？ 答：\n信息泄露：\neBPF 程序可以读取所有经过的连接的 VIP→Pod IP 映射 如果 Map 被非特权进程读取，可能泄露集群拓扑 流量劫持：\n恶意 eBPF 程序可以把流量导向任意地址 这也是为什么 eBPF 加载需要 CAP_BPF 资源耗尽：\n过多的 eBPF Maps 可能耗尽内核内存 复杂的 eBPF 程序可能消耗大量 CPU（verifier 限制指令数） 侧信道攻击：\n理论上，eBPF 程序的执行时间差异可能被利用 项目的防护措施：\neBPF Verifier 保证程序不会越界访问或死循环 Map Pin 在 /sys/fs/bpf/，默认只有 root 可访问 bpf_printk 仅输出 debug 信息，不泄露敏感数据 14. 分布式系统理论 Q37: 解释 CAP 定理 答： CAP 定理指出，分布式系统无法同时满足以下三个特性：\nC（Consistency，一致性）：\n所有节点在同一时间看到相同的数据 强一致性：每次读取都得到最新写入的值 A（Availability，可用性）：\n每个请求都能收到响应（不保证最新数据） 即使部分节点故障，系统仍然可响应 P（Partition tolerance，分区容错性）：\n网络分区（节点间网络断开）时系统仍能继续工作 在分布式系统中，P 通常是必须选择的 CAP 组合：\nCA： 单节点系统（无分区风险），如传统关系型数据库 CP： 优先保证一致性，牺牲可用性，如 ZooKeeper、etcd AP： 优先保证可用性，牺牲强一致性，如 Cassandra、DynamoDB Kubernetes 的选择：\netcd（CP 系统）：优先保证一致性和分区容错 kube-apiserver 无状态，可以水平扩展提高可用性 Q38: 最终一致性是什么？为什么 Kubernetes 使用它？ 答： 最终一致性是指：如果没有新的更新，经过一段时间后，所有节点会看到相同的数据。\nKubernetes 的控制器模式就是最终一致性：\n1 2 3 4 5 6 7 8 // 控制器不断协调，直到实际状态 = 期望状态 for { desired := getDesiredStateFromAPI() // 从 Informer 缓存获取 current := getCurrentState() // 从 eBPF Maps 获取 if desired != current { updateBPFMaps(desired) // 更新到期望状态 } } 为什么选择最终一致性：\n容忍短暂不一致： 网络延迟、事件丢失不会导致系统错误 简化设计： 不需要复杂的分布式事务 高可用： 即使 API Server 短暂不可达，系统仍能工作（使用本地缓存） 代价：\n需要定期全量对账（syncLoop）兜底 用户可能会观察到短暂的不一致 15. 行为面试题与场景题 Q39: 描述一次你解决技术难题的经历 答： （基于项目经历）\n背景：\n在开发 traffic-manager 时，遇到 eBPF 程序加载失败的问题。Verifier 报错：\u0026ldquo;invalid indirect read from stack off -64+0 size 4\u0026rdquo;。\n排查过程：\n理解错误： Verifier 检测到可能的栈越界访问 检查代码： 发现二分搜索函数中 key.backend_slot = mid 这行，Verifier 无法确定 mid 的范围 分析原因： 虽然 l 和 r 都是有界的，但 Verifier 无法追踪跨循环迭代的变量关系 解决方案： 将二分搜索的循环上限硬编码为 10（log2(1024)），让 Verifier 能够完全展开循环 结果：\neBPF 程序成功加载 学到了 Verifier 的限制和绕过方法 在 INTERVIEW_PREP.md 中记录了这个经验 总结：\n遇到技术难题时，先理解错误信息，再逐步缩小问题范围，最后用系统化的方法解决。\nQ40: 如果你发现生产环境流量分配不均，如何排查？ 答： 分层排查：\n1. 确认问题范围：\n1 2 # 是所有 Service 都有问题，还是单个 Service？ # 是加权策略有问题，还是随机策略也有问题？ 2. 检查 Kubernetes 层：\n1 2 3 4 5 # EndpointSlice 的后端列表是否正确 kubectl get endpointslice -l kubernetes.io/service-name=\u0026lt;service\u0026gt; -o yaml # 后端 Pod 是否都 Ready kubectl get pods -l app=\u0026lt;app\u0026gt; 3. 检查用户态（Controller）：\n1 2 3 4 5 # 查看 Service 在 BPF Maps 中的状态 ./traffic-manager --check-service-ip=\u0026lt;VIP\u0026gt; --check-service-port=\u0026lt;port\u0026gt; # 查看 Controller 日志 journalctl -u traffic-manager -f 4. 检查权重计算：\n1 2 3 4 # Prometheus 中的 node_load1 是否正常 curl \u0026#39;http://prometheus:9090/api/v1/query?query=node_load1\u0026#39; # 是否有节点 load1 \u0026gt;= 10（被认为不可用） 5. 检查内核态（eBPF Maps）：\n1 2 3 4 5 6 # dump service_slot_map 查看权重分布 bpftool map dump pinned /sys/fs/bpf/sock_ops_map/service_slot_map # 查看统计信息 ./traffic-manager --dump-stats # 关注 backend_slot_misses 是否异常高 6. 检查 eBPF 日志：\n1 cat /sys/kernel/debug/tracing/trace_pipe | grep traffic 16. 面试官追问与陷阱题 Q41: \u0026quot; 你的项目对 ExternalTrafficPolicy=Local 的 Service 怎么处理？\u0026rdquo; 答： 代码中定义了 scope 字段（LB_LOOKUP_SCOPE_EXT=0 / LB_LOOKUP_SCOPE_INT=1），但当前 Controller 始终传入 scope=0（Cluster 范围）。\n对于 ExternalTrafficPolicy=Local 的 Service，只应将流量路由到本节点的后端。当前实现会把流量路由到任意节点的后端，这是一个已知的功能缺口。\n改进方案：\n1 2 3 4 5 // 在 Controller 中识别 Service 的 ExternalTrafficPolicy if service.Spec.ExternalTrafficPolicy == v1.ServiceExternalTrafficPolicyLocal { scope = LB_LOOKUP_SCOPE_EXT // 只路由到本节点后端 // 需要额外过滤 EndpointSlice，只保留本节点的后端 } Q42: \u0026quot; 为什么不用 Service Mesh（如 Istio）？\u0026rdquo; 答： 对比分析：\n维度 Traffic Manager Istio 工作层 L4（TCP/UDP） L7（HTTP/gRPC） 性能开销 极低（eBPF 在内核态） 较高（Sidecar 代理） 复杂度 约 2000 行代码 数十万行代码 功能 仅负载均衡 流量管理、安全、可观测性等 部署 独立二进制 需要替换 CNI + Sidecar 注入 选择依据：\n如果只需要 L4 负载均衡，Traffic Manager 更轻量 如果需要 L7 路由、熔断、灰度发布等，选择 Istio 两者不冲突，可以共存（Traffic Manager 处理 L4，Istio 处理 L7） Q43: \u0026quot; 如果让你设计一个生产级的 eBPF 负载均衡器，你会怎么做？\u0026rdquo; 答： 基于项目经验，生产级需要考虑：\n1. 可靠性：\n健康检查：主动探测后端健康状态，而不依赖 kubelet 的就绪探针 优雅退出：收到 SIGTERM 后，先停止接受新连接，等待现有连接完成 崩溃恢复：通过 Pinned Maps 恢复状态 2. 可扩展性：\n支持数千个 Service 和数万个后端 考虑使用 LPM Trie Map（最长前缀匹配）支持 CIDR 路由 控制器 Leader Election，避免每节点 Watch 所有资源 3. 可观测性：\n暴露 Prometheus metrics（不只是 eBPF 计数器，还有延迟分布） 集成 OpenTelemetry，提供分布式追踪 日志结构化（已使用 slog） 4. 安全：\n最小权限（CAP_BPF + CAP_NET_ADMIN） 支持 mTLS（如果需要 L7） 审计日志 5. 灰度发布：\n支持按百分比逐步迁移流量 快速回滚机制 17. 项目改进与未来方向 Q44: 你的项目有哪些已知的限制？ 答：\n只支持 IPv4： 当前代码只处理 connect4、sendmsg4、recvmsg4，不支持 IPv6 ExternalTrafficPolicy=Local 未实现： 如前所述，scope 字段未使用 权重指标单一： 只使用 node_load1，不考虑内存、网络等其他指标 没有健康检查： 完全依赖 Kubernetes 的 EndpointSlice，不主动探测后端 没有连接跟踪表： TCP 长连接虽然不需要，但 UDP 的亲和性表可能会在节点重启后丢失 Leader Election 缺失： 每节点运行一个控制器，大规模集群下 API Server 压力大 Q45: 如果给你更多时间，你会如何改进这个项目？ 答：\n短期改进（1-2 周）：\n实现 ExternalTrafficPolicy=Local 支持 添加 IPv6 支持（connect6、sendmsg6、recvmsg6） 支持更多权重指标（内存、网络 IO） 添加 Prometheus metrics 暴露（当前只有 --dump-stats） 中期改进（1-2 月）：\n实现 Leader Election，优化大规模集群部署 添加主动健康检查（TCP connect 探测或 HTTP GET） 支持 L7 路由（基于 HTTP Host/Path） 集成 OpenTelemetry，提供追踪能力 长期愿景（3-6 月）：\n支持 Cilium 式的全功能 CNI 插件 支持 Service Mesh 模式（Sidecar 或 Ambient） 提供 Web UI，可视化流量拓扑 支持多集群服务发现 附录：面试速查卡片 核心数字：\neBPF vs iptables 性能提升：+19% ~ +28% BPF Map 最大条目：65536（Service/Slot/Backend） UDP 亲和超时：60 秒 定期对账周期：10 秒 Worker 数量：NumCPU 核心设计模式：\n控制器模式：Informer + Workqueue + Reconciliation Loop 二级间接引用：ServiceMeta → ServiceSlot → Backend 优雅降级：Prometheus 不可用 → 等权重退化为随机 核心系统调用链：\n1 connect() → cgroup/connect4 → service_meta_map lookup → slot selection → backend_map lookup → IP rewrite 必需的 Linux Capabilities：\nCAP_BPF：加载 eBPF 程序 CAP_NET_ADMIN：挂载到 cgroup 钩子 常见问题快速回答：\n为什么用 cgroup/connect4？→ 最早拦截点，完全绕过 netfilter 为什么用 LRU_HASH？→ UDP 亲和表需要自动淘汰过期条目 如何保证最终一致性？→ Informer + Workqueue + 定期全量对账 文档版本： v2.0 (hy3)\n生成时间： 2026-04-26\n基于项目： traffic-manager (github.com/kerolt/traffic-manager)\n","date":"2026-04-25T00:00:00Z","permalink":"/p/sre-%E5%AE%9E%E4%B9%A0%E9%9D%A2%E8%AF%95trafficmanager-%E9%9D%A2%E8%AF%95%E8%A6%81%E7%82%B9/","title":"【SRE 实习面试】TrafficManager-面试要点"},{"content":"这个项目选择 blazesym，本质上是因为它正好解决了“采样拿到的是地址，怎么把地址稳定、较快地还原成符号”这个核心问题，而且它和当前项目的技术栈很匹配。\n这个项目在 eBPF 里通过 bpf_get_stack 拿到的其实只是地址数组，不是函数名。真正把这些地址变成 main、__GI___write、do_syscall_64 这种可读符号的，是用户态的符号化器。这里在 src/blaze.h 里封装了 blazesym，在 src/event.cpp 里分别对内核地址和进程地址做符号化。\n为何选择 适合按地址批量符号化的场景 这里不是偶尔查一个地址，而是每条样本里有一串用户栈和内核栈，频率还可能比较高。blazesym 提供的接口就是面向“给我一批地址，我返回一批符号”，很贴合 EventHandler::show_stack_trace 和 EventHandler::symbolize_stack_to_vec 这种用法。\n如果自己写，第一步就要先把 ELF、DWARF、内核符号、进程地址空间这些问题都啃下来，工程量会非常大。\n它同时支持用户态和内核态符号化 这个项目既抓用户栈，也抓内核栈，所以需要一个库同时处理：\n某个 PID 的用户态地址 kernel 的绝对地址 在 src/blaze.h 里可以看到，作者就是用一个 variant 区分 process 和 kernel source，再统一走 symbolize。这让用户态代码很整洁。\n如果换一些更通用但更底层的库，通常你要自己拼用户符号解析和内核符号解析两套逻辑，接口层会更碎。\n已经封装好许多细节 符号化不是“读个符号表”这么简单，真正麻烦的是：\n地址归属到哪个映射文件 符号表和调试信息怎么查 进程地址空间和内核地址空间怎么区分 inline frame 怎么处理 失败时怎么降级 在 src/event.cpp 里，作者已经能直接拿到 name、addr、offset，甚至还能打印 inlined 函数信息。\n如果自己写，不但开发成本高，正确性风险也高，尤其是不同二进制格式、优化级别、strip 情况下会出各种边角问题。\n和 eBPF / profiling 生态比较近 这个项目不是通用调试器，而是一个 Linux profiler。blazesym 本身就比较偏 observability / profiling 场景，所以它的接口设计、性能目标、内核支持，都比一些传统符号库更对路。\n也就是说，作者不是在选“最全能的符号库”，而是在选“最适合这个任务的符号库”。\n然后说，为什么不用别的库。\n如果用 libbfd、libdw、libelf 这一类库，也不是不能做，但问题是它们更偏“底层能力库”。\n它们给你零件，不直接给你一个好用的 profiler 级 symbolizer。你需要自己处理更多对象生命周期、地址归一化、模块映射关系，代码复杂度会上去，维护成本也更高。对于这个项目这种“小而完整”的工具来说，不划算。\n如果用 addr2line 这种外部工具，也不合适。\n因为它更像离线单次查询工具，不适合高频、批量、运行时调用。你每次采样都去起进程或者频繁 shell out，开销会很难看，也不利于 benchmark。\n如果完全自己写，那最大问题不是“能不能写出来”，而是投入和收益极不对称。\n这个项目真正想体现的价值在于：\neBPF 采样链路 perf event 挂载 ring buffer 传输 输出与 benchmark 设计 符号化并不是这里最值得重复造轮子的部分。自己写只会把大量时间花在 ELF/DWARF 解析和边界条件上，反而偏离项目重点。\n代价 不过，选 blazesym 也不是没有代价，我会主动补这一点，这样显得不是只会夸。\n它的代价主要有两个：\n引入 Rust 构建链\nREADME 里专门写了要先构建 blazesym C API，这会增加依赖复杂度。\n符号化本身仍然是重操作\n项目专门提供了 --no-symbolize，在 src/main.cpp 里会切到 RawCount 模式，只统计样本数不逐条输出。采样和符号化应该分开看，符号化成本不低。\n这一类库的原理 这类库，也就是 blazesym、addr2line、libdw、libbfd 这类“符号化库”，核心原理可以概括成一句话：\n把运行时采样拿到的地址，映射回它所属的二进制文件，再在这个文件的符号表和调试信息里查出函数名、偏移、源码位置。\n拆开讲就是几步。\n1. 先确定这个地址属于哪里 采样时我们拿到的是一个虚拟地址，比如：\n1 0x7f55ce0921fe 这个地址本身没有语义。符号化库要先判断它属于哪个映射区。对用户态进程来说，通常会读：\n1 /proc/\u0026lt;pid\u0026gt;/maps 里面会告诉我们某段地址范围对应哪个文件，比如 /usr/lib64/libc.so.6、/usr/bin/yes、某个 .so。\n所以第一步是：运行时地址 -\u0026gt; 对应的 ELF 文件和映射偏移。\n2. 把运行时地址转换成文件内地址 因为程序运行时会有 ASLR，动态库加载地址每次可能不同，所以不能直接拿运行时地址去 ELF 文件里查。需要做一次归一化，大概是：\n1 文件内地址 = 运行时地址 - 映射起始地址 + 文件映射偏移 例如某个函数在 libc 里，运行时地址是 0x7f55ce0921fe，但 libc 在这次进程里加载到哪里是不固定的。符号化库要把它还原成 libc 文件内部的相对地址，才能继续查。\n3. 在符号表里查函数名 ELF 文件里可能有 .symtab 或 .dynsym 符号表。符号化库会找最接近这个地址、并且范围覆盖它的函数符号。\n所以它能把：\n1 0x7f55ce0921fe 解析成类似：\n1 __GI___libc_write + 0x1e 这里的 +0x1e 是说采样点落在函数入口之后的偏移位置。\n4. 如果有调试信息，再查源码文件和行号 函数名通常来自符号表，但源码行号来自 DWARF 调试信息，比如 .debug_info、.debug_line 等 section。\n如果二进制没有 debug info，可能只能得到函数名，甚至只能得到地址。\n如果有 debuginfo，库就可以进一步解析成：\n1 write.c:26 这一步比查函数名更复杂，也更慢。\n5. 处理 inline 函数 现代编译器优化会把函数内联掉，采样地址可能实际对应一层或多层 inline 调用关系。\n所以比较完整的符号化库会读取 DWARF 里的 inline 信息，把它还原成类似：\n1 2 outer() inlined inner() 这个项目里 src/event.cpp 的 show_stack_trace 就有打印 inlined 信息的逻辑。\n对于内核符号化，原理类似，但数据来源不太一样。\n用户态一般看 /proc/\u0026lt;pid\u0026gt;/maps 和 ELF 文件；内核态则通常依赖：\n1 2 3 4 /proc/kallsyms /sys/kernel/kallsyms vmlinux kernel debuginfo 还要考虑 KASLR，也就是内核地址随机化。符号化库需要知道内核符号的实际加载地址，才能把采样地址对应到 do_syscall_64、ksys_write 这类内核函数。\n所以这类库真正做的事情不是“简单查表”，而是：\n1 2 3 4 5 6 采样地址 -\u0026gt; 判断属于进程/内核/哪个动态库 -\u0026gt; 根据加载基址做地址归一化 -\u0026gt; 读取 ELF 符号表 -\u0026gt; 可选读取 DWARF 调试信息 -\u0026gt; 返回函数名、偏移、源码位置、inline 信息 放到这个项目里，BPF 端只负责采地址：\n1 2 bpf_get_stack(ctx, event-\u0026gt;kstack, ...) bpf_get_stack(ctx, event-\u0026gt;ustack, ..., BPF_F_USER_STACK) 然后用户态的 blazesym 负责把地址变成人能看懂的调用栈。\n这就是为什么 profiler 通常会把“采样”和“符号化”分成两个阶段：采样要求轻，符号化要求信息完整，两者关注点不一样。\n","date":"2026-04-21T00:00:00Z","permalink":"/p/%E4%B8%BA%E4%BB%80%E4%B9%88%E7%AC%A6%E5%8F%B7%E5%8C%96%E6%97%B6%E9%80%89%E6%8B%A9blazesym%E5%BA%93/","title":"为什么符号化时选择blazesym库"},{"content":"awk 常用命令 基本语法 1 awk \u0026#39;pattern {action}\u0026#39; file 常用内置变量 变量 说明 $0 整行内容 $1, $2... 第 1、2\u0026hellip;列 NF 字段数量 NR 行号 FS 输入字段分隔符 OFS 输出字段分隔符 常见用法示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # 打印指定列 awk \u0026#39;{print $1, $3}\u0026#39; file # 指定分隔符 awk -F: \u0026#39;{print $1}\u0026#39; /etc/passwd # 条件过滤（第3列大于100） awk \u0026#39;$3 \u0026gt; 100 {print $0}\u0026#39; file # 行号范围 awk \u0026#39;NR\u0026gt;=10 \u0026amp;\u0026amp; NR\u0026lt;=20\u0026#39; file # 模式匹配 awk \u0026#39;/error/ {print NR, $0}\u0026#39; log # 统计求和 awk \u0026#39;{sum+=$2} END {print sum}\u0026#39; file # 格式化输出 awk \u0026#39;{printf \u0026#34;%-10s %5d\\n\u0026#34;, $1, $2}\u0026#39; file sed 常用命令 基本语法 1 sed \u0026#39;command\u0026#39; file 常用命令 命令 说明 s/old/new/ 替换 d 删除行 p 打印行 i 插入行 a 追加行 c 替换整行 n 读下一行 q 退出 常见用法示例 1 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 # 替换（只替换每行第一个匹配） sed \u0026#39;s/old/new/\u0026#39; file # 全局替换 sed \u0026#39;s/old/new/g\u0026#39; file # 删除第3行 sed \u0026#39;3d\u0026#39; file # 删除第2-5行 sed \u0026#39;2,5d\u0026#39; file # 删除空行 sed \u0026#39;/^$/d\u0026#39; file # 打印匹配行（类似grep） sed -n \u0026#39;/pattern/p\u0026#39; file # 在第3行后插入 sed \u0026#39;3a\\new line\u0026#39; file # 范围替换（第2-4行） sed \u0026#39;2,4 s/old/new/g\u0026#39; file # 直接修改文件（谨慎使用） sed -i \u0026#39;s/old/new/g\u0026#39; file 快速对比 场景 awk sed 处理列/字段 ✅ 强大 ❌ 较弱 行范围操作 ✅ 支持 ✅ 支持 文本替换 ❌ 较弱 ✅ 强大 数学计算 ✅ 内置 ❌ 不支持 数组/关联数组 ✅ 支持 ❌ 不支持 流式编辑 ❌ 不支持 ✅ 设计目标 选择建议：处理结构化数据（CSV、日志列）用 awk；纯文本替换、删除、插入用 sed。\n","date":"2026-04-20T00:00:00Z","permalink":"/p/awk%E4%B8%8Esed%E7%AE%80%E5%8D%95%E4%BD%BF%E7%94%A8/","title":"awk与sed简单使用"},{"content":"测试结果 执行：\n1 2 3 4 5 6 sudo ./scripts/benchmark.py \\ --freq 99 \\ --duration 30 \\ --runs 5 \\ --workload \u0026#34;yes \u0026gt; /dev/null\u0026#34; \\ --profiler-symbolize 对 profiler 与 perf 的 benchmark 结果如下：\n1 2 3 4 5 6 7 8 9 10 11 === 多轮统计汇总-collect-only (20260420_143315, runs=5) === tool cpu_mean(s) cpu_std(s) cpu_p95(s) samples_mean samples_std samples_p95 profiler 0.450000 0.008944 0.460000 2982.800000 3.969887 2987.000000 perf 0.396000 0.004899 0.400000 2948.400000 3.979950 2954.000000 cpu_overhead_improvement_vs_perf(%): -13.64 === 多轮统计汇总-end-to-end (20260420_143315, runs=5) === tool cpu_mean(s) cpu_std(s) cpu_p95(s) samples_mean samples_std samples_p95 profiler 0.450000 0.008944 0.460000 2982.800000 3.969887 2987.000000 perf 0.880000 0.006325 0.890000 2948.400000 3.979950 2954.000000 cpu_overhead_improvement_vs_perf(%): 48.86 虽然 e2e 的结果不错，但是 collect only 为什么会差一些呢？\n结果分析 这次命令带了：\n1 --profiler-symbolize 那 collect-only 对比会明显偏向 perf，因为：\n1 2 profiler collect-only = 采集 + 符号化/文本化 perf collect-only = 只 record 二进制数据 这种情况下，profiler collect-only 差一些完全正常，甚至可以说不是同一口径。\n要比较纯采集成本，应该确保 profiler 用默认的 --no-symbolize，也就是不要传 --profiler-symbolize。\n建议下一步排查 我会优先确认这几件事：\n1 2 3 4 5 6 7 # 确认没有开启 profiler 符号化 sudo ./scripts/benchmark.py \\ --freq 99 \\ --duration 30 \\ --runs 10 \\ --workload \u0026#34;yes \u0026gt; /dev/null\u0026#34; \\ --outdir ./report/no_symbolize 然后再单独跑符号化版本：\n1 2 3 4 5 6 7 sudo ./scripts/benchmark.py \\ --freq 99 \\ --duration 30 \\ --runs 10 \\ --workload \u0026#34;yes \u0026gt; /dev/null\u0026#34; \\ --outdir ./report/symbolize \\ --profiler-symbolize 如果两者差距很大，说明 collect-only 主要被符号化/输出拖住。\n如果默认 --no-symbolize 仍然是 0.466s，那下一步就该看 profiler 采集路径里是否有这些开销点：\n每次事件都做了较重的用户态处理 输出文本太频繁 BPF map 查询次数太多 栈聚合数据结构有额外成本 ring buffer / perf buffer 消费路径不够轻 sample 结束后仍有清理或汇总逻辑被算进采集时间 ","date":"2026-04-20T00:00:00Z","permalink":"/p/%E6%80%A7%E8%83%BD%E6%B5%8B%E8%AF%95%E5%88%86%E6%9E%90/","title":"性能测试分析"},{"content":"在使用 minikube 的时候碰到了一个问题，minikube start 之后，虽然我开启了主机的代理，但是在 minikube 节点中 pull 镜像时总是失败。\n在 minikube 官方文档里，推荐通过 HTTP_PROXY、HTTPS_PROXY 和 NO_PROXY 环境变量传给 minikube，并且特别强调 NO_PROXY 很重要，否则集群内部地址也可能被错误地送进代理，导致组件通信异常。\n宿主机中执行 ip a 时如下：\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 1: lo: \u0026lt;LOOPBACK,UP,LOWER_UP\u0026gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host noprefixroute valid_lft forever preferred_lft forever 3: enp171s0: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc fq_codel state UP group default qlen 1000 link/ether 78:55:36:06:e4:22 brd ff:ff:ff:ff:ff:ff altname enx78553606e422 inet 10.65.163.66/16 brd 10.65.255.255 scope global dynamic noprefixroute enp171s0 valid_lft 240080sec preferred_lft 240080sec inet6 2001:da8:e021:6565::3:d80e/128 scope global dynamic noprefixroute valid_lft 179550sec preferred_lft 93150sec inet6 fe80::e8be:df6a:d5b3:21ee/64 scope link noprefixroute valid_lft forever preferred_lft forever 6: docker0: \u0026lt;NO-CARRIER,BROADCAST,MULTICAST,UP\u0026gt; mtu 1500 qdisc noqueue state DOWN group default link/ether 3a:b6:7f:07:78:26 brd ff:ff:ff:ff:ff:ff inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 valid_lft forever preferred_lft forever inet6 fe80::38b6:7fff:fe07:7826/64 scope link proto kernel_ll valid_lft forever preferred_lft forever 19: br-ef20fd0aa66e: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc noqueue state UP group default link/ether da:3c:71:a8:5b:17 brd ff:ff:ff:ff:ff:ff inet 192.168.49.1/24 brd 192.168.49.255 scope global br-ef20fd0aa66e valid_lft forever preferred_lft forever inet6 fe80::d83c:71ff:fea8:5b17/64 scope link proto kernel_ll valid_lft forever preferred_lft forever 在 minikube 节点中执行 ip a 如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 1: lo: \u0026lt;LOOPBACK,UP,LOWER_UP\u0026gt; mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: eth0@if24: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc noqueue state UP group default link/ether 42:0c:2d:36:1b:6b brd ff:ff:ff:ff:ff:ff link-netnsid 0 inet 192.168.49.2/24 brd 192.168.49.255 scope global eth0 valid_lft forever preferred_lft forever 3: docker0: \u0026lt;NO-CARRIER,BROADCAST,MULTICAST,UP\u0026gt; mtu 1500 qdisc noqueue state DOWN group default link/ether e2:c0:91:13:8c:f9 brd ff:ff:ff:ff:ff:ff inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0 valid_lft forever preferred_lft forever 4: bridge: \u0026lt;BROADCAST,MULTICAST,UP,LOWER_UP\u0026gt; mtu 1500 qdisc noqueue state UP group default qlen 1000 link/ether 16:a6:f8:bd:fe:f3 brd ff:ff:ff:ff:ff:ff inet 10.244.0.1/16 brd 10.244.255.255 scope global bridge valid_lft forever preferred_lft forever inet6 fe80::14a6:f8ff:febd:fef3/64 scope link valid_lft forever preferred_lft forever 这说明 minikube 节点容器和宿主机就在同一个 192.168.49.0/24 二层网段里，192.168.49.1 实际上就是“minikube 看见的宿主机”。minikube 文档中有说明将 192.168.49.0/24 列为 docker driver 的默认集群网段，并要求放进 NO_PROXY，否则集群内部通信可能异常。\n前提条件是 Clash 必须监听到宿主机可被 minikube 访问的地址，而不只是 127.0.0.1。也就是要开启局域网访问，至少要让代理监听到 0.0.0.0:\u0026lt;port\u0026gt; 或 192.168.49.1:\u0026lt;port\u0026gt;。否则 minikube 容器虽然能看到宿主机 IP，但连不上对应端口。minikube 官方对代理的要求也是把一个可达的 HTTP_PROXY/HTTPS_PROXY 地址传给 minikube 和容器运行时。\n现在在 minikube 中执行：\n1 2 ping -c 1 192.168.49.1 nc -vz 192.168.49.1 7890 输出为：\n1 Connection to 192.168.49.1 7890 port [tcp/*] succeeded! 说明使用 192.168.49.1:7890 来作为代理地址是没问题的。\n如果可以直接删除原节点，可以：\n1 2 3 4 5 6 minikube delete minikube start \\ --docker-env HTTP_PROXY=http://192.168.49.1:7890 \\ --docker-env HTTPS_PROXY=http://192.168.49.1:7890 \\ --docker-env NO_PROXY=localhost,127.0.0.1,::1,192.168.49.0/24,10.96.0.0/12,10.244.0.0/16,.svc,.cluster.local 或者在原节点内部修改：\n如果是 docker driver，可通过 minikube profile list 查看\n1 2 3 4 5 6 7 8 sudo mkdir -p /etc/systemd/system/docker.service.d sudo tee /etc/systemd/system/docker.service.d/http-proxy.conf \u0026lt;\u0026lt;EOF [Service] Environment=\u0026#34;HTTP_PROXY=http://192.168.49.1:7890\u0026#34; Environment=\u0026#34;HTTPS_PROXY=http://192.168.49.1:7890\u0026#34; Environment=\u0026#34;NO_PROXY=localhost,127.0.0.1,192.168.49.0/24,10.96.0.0/12,10.244.0.0/16,.svc,.cluster.local\u0026#34; EOF 然后：\n1 2 3 sudo systemctl daemon-reexec sudo systemctl daemon-reload sudo systemctl restart docker ","date":"2026-04-17T00:00:00Z","permalink":"/p/minikube%E9%85%8D%E7%BD%AE%E4%B8%BB%E6%9C%BA%E4%BB%A3%E7%90%86/","title":"Minikube配置主机代理"},{"content":"压平规则 这里的核心原理是：kube-proxy 的 iptables 模式把 Service 转发规则编译成一串 netfilter 规则链，而 Traffic Manager 把 Service 查找变成一次 eBPF Map 查询。当 Service 数量变大时，这两种查找路径的增长方式不一样。\n在 kube-proxy 的 iptables 模式下，每个 Service、每个端口、每个 Endpoint 都会生成对应的 iptables 规则。客户端访问一个 ClusterIP 时，数据包会进入内核网络协议栈，然后经过 netfilter 的 PREROUTING/OUTPUT 等链，沿着一组规则逐条匹配：这个目标 IP 是不是某个 Service？端口是不是匹配？协议是不是匹配？命中了以后再跳到对应的 Service chain，然后根据概率规则选择某个 Endpoint，最后做 DNAT。\n问题在于，iptables 的匹配本质上更接近“规则链顺序扫描”。集群里的 Service 越多，规则越多，包在找到目标 Service 前可能要经过更多匹配判断。即使每条规则判断很快，数量上来以后，累计成本会变明显。尤其是短连接场景，每个新连接都要重新走这段路径，所以开销会被放大。\nTraffic Manager 的做法不一样。它在 socket 层的 connect4 hook 拦截应用的 connect() 调用，拿到目标地址，也就是 Service 的 ClusterIP 和端口，然后直接用这个 (VIP, Port, Proto) 作为 key 去 eBPF Hash Map 里查 Service 元信息。查到之后，再根据后端数量和调度策略选择一个 backend，把 socket 目标地址直接改成 Pod IP 和 Pod Port。\n所以它绕过了两部分成本：\n绕过 iptables/netfilter 规则链匹配 不再让连接请求在大量 Service 规则里逐条判断，而是直接用 key 查 Map。 更早地改写目标地址 它在应用发起 connect() 的时候就把目标 VIP 改成真实 Pod IP，后续包天然就是发往后端 Pod，不需要每个包都依赖复杂的 DNAT 路径。 简单说，iptables 像是在一本很长的规则表里从头往后找“这个 Service 是谁”；eBPF Map 像是拿着 Service IP 和端口直接查哈希表。Service 少的时候，两者差距可能不大；Service 多的时候，规则链扫描成本增长，而 Hash Map 查找成本基本稳定，所以 eBPF 的相对优势就会变大。\n这也是为什么脚本里有 --rule-bloat 这个测试：它会创建很多 dummy Service，人为把 kube-proxy 的规则数量撑大。这样做不是为了“作弊”，而是在模拟真实大集群里 Service 数量很多时 kube-proxy iptables 规则膨胀的情况。这个场景越明显，Traffic Manager 的设计优势越容易体现出来。\n还有一个关键点是这个项目测的是 短连接压测。短连接里大量请求都会反复触发连接建立路径，connect() 和首次路由/NAT 的成本占比更高。Traffic Manager 正好把 Service 选择前移到 connect() 阶段，并且用 eBPF Map 直接完成后端选择，所以在 Siege 这种大量短连接的 benchmark 里提升会比较明显。\n当然，这里也要诚实地说，性能更高不是无条件成立。它取决于场景：\n如果 Service 数量很少，iptables 规则不多，收益可能有限。 如果业务是长连接，连接建立成本被摊薄，QPS 提升可能没短连接明显。 如果 eBPF 里加了很复杂的权重计算、状态维护或 Map 查询过多，也会吃掉一部分收益。 如果 kube-proxy 使用 IPVS 或 nftables，和 iptables 模式相比差距也会变化。 如果瓶颈在应用本身、Pod 网络、CPU 或后端服务，而不是 Service 转发路径，那么绕过 iptables 也不会带来很大提升。 所以我会把这个结论表述得更准确一点：\n在 kube-proxy iptables 模式、Service 数量较多、短连接较多的场景下，把 Service 规则链匹配替换成 eBPF Hash Map 查找，可以减少连接建立路径上的匹配和 NAT 开销，因此性能更高，而且 Service 规模越大，相对收益越明显。\n为什么能跳过 kube-proxy 的 Service DNAT 规则 这个项目把 eBPF 程序挂到了 socket connect4 钩子上，在应用真正发包、进入 kube-proxy/iptables 转发路径之前，就把目标地址从 Service VIP 改成了 Pod IP，所以后续流量看起来根本不是访问 Service，自然也就不会命中 kube-proxy 的 Service DNAT 规则。\n正常 kube-proxy iptables 模式大概是这样：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 应用 connect(Service ClusterIP:80) | v 内核网络栈 | v netfilter / iptables 规则 | v kube-proxy 维护的 Service 规则命中 | v DNAT: Service VIP:80 -\u0026gt; PodIP:PodPort | v 发往真实 Pod 也就是说，kube-proxy 本身不直接转发每个包，它主要负责把 Kubernetes Service/Endpoint 信息同步成 iptables/IPVS/nftables 规则。真正转发时，包经过内核网络栈，由这些规则做 DNAT。\n而这个项目的路径是：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 应用 connect(Service ClusterIP:80) | v eBPF cgroup/connect4 hook | v 查 eBPF Map: Service VIP:80 -\u0026gt; backend PodIP:PodPort | v 直接改写 socket 目标地址 | v 应用这次连接实际变成 connect(PodIP:PodPort) | v 发往真实 Pod 关键点在于 hook 的位置更早。\ncgroup/connect4 这个 eBPF hook 发生在进程调用 connect() 的时候。你的 eBPF 程序能通过 bpf_sock_addr 看到原始目标地址，也就是应用想连的 ClusterIP:Port。如果它发现这个地址是一个 Kubernetes Service，就直接调用类似逻辑把 ctx-\u0026gt;user_ip4、ctx-\u0026gt;user_port 改成后端 Pod 的 IP 和端口。\n项目里的 eBPF 程序就是在这里做的：\n1 2 ctx_set_dst_ip(ctx, backend.address); ctx_set_dst_port(ctx, backend.port); 所以原本应用以为自己连的是：\n1 10.96.x.x:80 // Service ClusterIP 但经过 eBPF hook 后，内核后续处理的目标已经变成：\n1 10.244.x.x:8080 // 某个后端 Pod 这时候 kube-proxy 的规则通常是匹配 Service ClusterIP 的，比如：\n1 -d 10.96.x.x --dport 80 但目标地址已经不是 10.96.x.x:80 了，所以这些 Service 规则就不会命中。换句话说，不是 eBPF 把 kube-proxy 禁用了，而是它把流量提前改道了，让流量不再走 kube-proxy 管的那条 Service VIP 路径。\n这也是为什么这个项目需要自己维护 eBPF Map。因为跳过 kube-proxy 后，谁来知道 Service 后面有哪些 Pod？答案就是 Traffic Manager 的 Go 控制器：\n1 2 3 4 5 6 7 8 9 10 11 12 13 Kubernetes Service / EndpointSlice | v Informer 监听变化 | v Controller 计算后端列表 | v 写入 eBPF Map | v connect4 程序查 Map 并改写目标地址 所以它本质上是在做一个 kube-proxy 的替代数据面：\nkube-proxy：监听 Service/Endpoint，然后写 iptables/IPVS 规则。 Traffic Manager：监听 Service/EndpointSlice，然后写 eBPF Map。 eBPF 程序：在 socket 层查 Map，直接改写目标地址。 但这里也要注意几个边界：\n只能跳过被这个 eBPF 程序处理到的流量 你的项目挂的是 connect4，主要处理本机进程发起的 IPv4 TCP connect 路径。没有经过这个 hook 的流量，不会被它改写。\nkube-proxy 仍然可以存在 它没有被卸载，也没有失效。只是这部分流量提前被改成 PodIP，所以不再命中 Service VIP 规则。\n如果 eBPF Map 里没有这个 Service，还是会走原路径 你的代码里如果查不到 Service，就返回 miss，不做改写。那目标仍然是 ClusterIP，后面还是可能由 kube-proxy 处理。\n这不是通用意义上跳过所有 Kubernetes 网络路径 它跳过的是 Service VIP 到后端 Pod 的 kube-proxy 负载均衡/DNAT 逻辑。后续 Pod 网络、CNI 路由、veth、bridge/overlay 等路径还是要走。\n一句话总结就是：\n因为 eBPF 挂在 connect4 这个比 kube-proxy Service DNAT 更早的位置，提前把 Service ClusterIP 改成了真实 Pod IP。后续内核看到的目的地址已经不是 Service VIP，所以 kube-proxy 的 Service 规则没有机会命中，于是这条流量就绕过了 kube-proxy。\n","date":"2026-04-17T00:00:00Z","permalink":"/p/%E4%B8%BA%E4%BB%80%E4%B9%88%E9%80%9A%E8%BF%87ebpf%E5%8F%AF%E4%BB%A5%E6%8F%90%E9%AB%98%E6%80%A7%E8%83%BD/","title":"为什么通过eBPF可以提高性能"},{"content":"Minikube 端到端测试 通过 scripts/benchmark-minikube.py 脚本，让它自动完成一套验证流程：\n检查 Minikube 是否运行。 编译 Linux 版本的 traffic-manager。 拷贝二进制到 Minikube 节点。 部署测试用的后端 Deployment 和 Siege 压测 Pod。 先不启动 Traffic Manager，跑一次 kube-proxy baseline。 启动 Traffic Manager，把 eBPF 程序挂到 cgroup 上。 等待 BPF Map pin 成功，并检查目标 Service 已经同步进 Map。 再跑一次 eBPF 路径下的压测。 输出 QPS 对比。 最后清理远端进程、BPF Map 和 dummy service。 我这么做是因为 eBPF 项目高度依赖运行环境：内核版本、cgroup v2、BPF filesystem、权限、Kubernetes 网络环境都会影响结果。单纯在本机跑 go test，只能证明控制逻辑大概没错，不能证明真正的 socket connect hook 生效了。所以我选择用 Minikube 搭一个尽量接近真实 Kubernetes 的小环境，把控制面和数据面一起拉起来测。\n性能对照测试 性能测试里我做了两个关键对照：\n正常 Service 数量下的对照：同一个后端服务、同一个 Siege 客户端、同样的并发数和压测时长，先测 kube-proxy，再测 Traffic Manager。 rule bloat 场景：额外创建几百到上千个 dummy Service，让 kube-proxy 的 iptables 规则膨胀，再对比 eBPF Map 查找路径的表现。 这么测的原因是这个项目的核心卖点就是绕过 kube-proxy/iptables 规则链。iptables 规则数量多的时候，匹配路径会变长；而 eBPF 这里是通过 Hash Map 查 Service，再选 backend，理论上查找复杂度更稳定。所以只测小规模场景不够，必须人为制造 Service 数量膨胀的场景，才能验证这个设计在大规模集群下的优势是否真的存在。\n生命周期和清理测试。 这个项目需要 root 权限加载 eBPF 程序，也会 pin BPF Map。如果退出时不清理，后续测试可能会受到旧 Map、旧进程、旧 cgroup attach 的影响。所以脚本里会做：\n停止 Minikube 里的旧 traffic-manager 进程。 删除旧的 BPF Map pin 路径。 rule bloat 测试后删除 dummy services。 Siege 超时时杀掉残留 siege 进程。 测试方面的不足 性能测试还不够严谨 现在 Siege 测的是短连接 QPS，对比方向是对的，但还不够完整：\n只测了 QPS，没有系统性记录 p50/p95/p99 延迟。 单次结果波动可能比较大，应该多轮运行取均值、标准差。 Minikube 单节点环境和真实多节点集群还有差距。 没有区分 TCP 长连接、短连接、UDP 流量等不同模式。 没有统计 CPU 使用率、内核态开销、上下文切换、BPF Map 命中率等指标。 后续我会把压测结果结构化输出成 JSON/CSV，然后多轮跑，配合 Prometheus 或 perf/bpftool 去观察资源消耗，而不是只看一个最终 QPS。\n故障场景覆盖不够 真实环境里会遇到很多动态变化，比如：\n后端 Pod 滚动更新。 Pod 被 kill。 EndpointSlice 快速变化。 Service 被删除又重建。 Prometheus 暂时不可用。 BPF Map 写入失败。 权重数据异常，比如全部为 0。 controller 重启后需要重新同步已有 Service。 这些目前没有形成自动化 case。后续我会补一组 chaos/e2e 测试，比如在压测过程中持续扩缩容 Deployment，看连接成功率和错误率；或者故意让 Prometheus 不可用，看是否能降级到普通负载均衡。\n","date":"2026-04-17T00:00:00Z","permalink":"/p/%E9%A1%B9%E7%9B%AEbenchmark/","title":"项目Benchmark"},{"content":"介绍 这个项目是一个用 C++ 实现的 Linux 采样性能分析器，底层基于 eBPF 和 perf events。它的目标是像 perf 一样周期性采集程序运行时的内核态和用户态调用栈，然后在用户态做符号化，最后输出可读的调用栈信息，或者输出 FlameGraph 兼容的 folded 格式，用来生成火焰图。\n我主要做的事情可以分成几块：\n第一块是内核侧采集。我写了一个 eBPF perf_event 程序，在采样事件触发时通过 bpf_get_stack 同时抓取 kernel stack 和 user stack，再把 PID、CPU、时间戳、进程名和栈地址通过 ring buffer 传回用户态。\n第二块是用户态控制程序。C++ 侧负责解析命令行参数、加载 BPF skeleton、初始化 perf event、给每个 CPU 绑定采样事件，并持续从 ring buffer 读取采样数据。这里支持设置采样频率、指定 PID、切换硬件事件和软件事件，也支持关闭符号化用于纯采集 benchmark。\n第三块是符号化和输出。我接入了 blazesym，把原始地址解析成函数名、源码位置和内联函数信息。输出上做了两种格式：一种是标准的可读调用栈，方便直接看；另一种是 folded 格式，能直接喂给 FlameGraph 生成火焰图。\n项目亮点 这个项目的亮点我会强调三个：\n它不是简单地调用 perf，而是自己打通了 perf_event -\u0026gt; eBPF -\u0026gt; ring buffer -\u0026gt; C++ 用户态处理 -\u0026gt; blazesym 符号化 -\u0026gt; FlameGraph 输出 这一整条链路。 它同时支持用户态和内核态栈，并且能按 PID 过滤，既可以看整个系统，也可以聚焦某个进程。 我专门做了和 perf 的 benchmark 对比，而且没有简单宣传“比 perf 快”。目前结果是：仅看 collect-only 采集阶段，当前实现开销比 perf record 高；但如果看 end-to-end，也就是 perf record + perf script/report 的完整流程，这个项目的总开销更低。这个结论更真实，也更能说明我理解性能指标的口径差异。 麻烦点解决 实现过程中比较麻烦的点主要有几个：\n一个是 eBPF 和 perf event 的配合。采样不是由 BPF 自己定时触发，而是要通过 perf_event_open 在每个 CPU 上创建事件，再把 BPF 程序 attach 到这些 perf events 上。这里涉及 CPU 数量、硬件事件和软件事件兼容性、权限、文件描述符生命周期管理等问题。\n第二个是用户栈符号化。eBPF 采到的是地址，真正想变成函数名，需要结合目标进程的地址空间、ELF、debug info、内核符号等信息。这里我用了 blazesym 来处理，但还要区分 pid 为 0 的内核符号化和普通进程的用户态符号化。\n第三个是性能对比的口径。最开始很容易只看一个数字，比如“相比 perf 降低多少开销”，但后来发现 collect-only 和 end-to-end 得出的结论不同。所以我补了 benchmark 脚本，用多轮运行统计 mean、std、p95，并把结论改成更准确的表达：不同口径下有不同优势。\nFlameGraph 兼容格式是什么样的 FlameGraph 兼容的 folded 格式，本质上就是“每一行表示一条调用栈 + 这条调用栈出现的次数”。\n格式大概是：\n1 栈底函数;中间函数;栈顶函数 次数 比如：\n1 2 3 main;handle_request;parse_json;malloc 17 main;handle_request;query_db;read 9 main;background_worker;compress;zlib_deflate 4 含义是：\n; 用来分隔调用栈里的每一层函数 左边从栈底到栈顶排列，也就是调用链从外到内 最后的空格后面是采样次数 / 权重 FlameGraph 会把相同前缀的调用路径合并，宽度由次数决定 在你的项目里，扩展 folded 输出大概是这样生成的：\n1 进程名-pid;用户态函数1;用户态函数2;内核态函数_[k] 1 例如可能输出：\n1 yes-12345;main;do_output;write;entry_SYSCALL_64_after_hwframe_[k];do_syscall_64_[k] 1 这里最后的 1 表示这一行代表一次采样。后续 flamegraph.pl 会把完全相同或有共同前缀的调用栈聚合起来。\n你的代码里对应逻辑在 src/event.cpp 的 handle_fold_extend：它先把 comm-pid 放在栈底，然后把用户态栈、内核态栈反转后拼起来，最后用分号连接并输出：\n1 stack;frames 1 所以 folded 格式不是给人精读的，它是给 FlameGraph 这类工具做聚合和可视化用的中间格式。\n","date":"2026-04-17T00:00:00Z","permalink":"/p/%E9%A1%B9%E7%9B%AE%E4%BB%8B%E7%BB%8D/","title":"项目介绍"},{"content":"现在我有一个这样的使用场景：在远程服务器通过 VSCode 来编辑代码，然后需要长时间训练/数据处理。为了确保退出 VSCode 后这个处理进程能继续，通常需要使用 tmux 来管理。这里总结一下常用命令。\n创建会话 创建一个新会话（建议命名）：\n1 tmux new -s msmetrics 在里面直接运行你的任务：\n1 uv run data/prepare_msmetrics_dataset.py ... | tee run.log 退出但不终止任务（detach）：\n1 Ctrl + B，然后按 D 重新连接：\n1 tmux attach -t msmetrics 结束会话（任务结束后）：\n1 exit 会话管理（多实验并行的关键） 查看所有 session：\n1 tmux ls 输出类似：\n1 2 3 msmetrics: 1 windows dafpred: 1 windows informer: 1 windows 连接某个 session：\n1 tmux attach -t dafpred 强制接管（比如之前断线没正常 detach）：\n1 tmux attach -d -t dafpred 删除某个 session：\n1 tmux kill-session -t dafpred 窗口（window）管理（一个 session 多任务） 一个 session 可以开多个“窗口”（类似 tab）。\n创建新窗口：\n1 Ctrl + B，然后按 C 切换窗口：\n1 2 Ctrl + B，然后按 N # 下一个 Ctrl + B，然后按 P # 上一个 查看窗口列表：\n1 Ctrl + B，然后按 W 关闭当前窗口：\n1 exit 面板（pane）分屏（实用） 当需要：\n一边跑训练 一边看日志 / nvidia-smi / htop 就用 pane。\n水平分屏：\n1 Ctrl + B，然后按 \u0026#34; 垂直分屏：\n1 Ctrl + B，然后按 % 切换 pane：\n1 Ctrl + B，然后方向键 关闭 pane：\n1 exit 滚动查看输出 默认鼠标滚轮是不能滚 tmux 的，需要：\n进入滚动模式：\n1 Ctrl + B，然后按 [ 然后：\n↑ ↓ 滚动 PgUp / PgDn q 退出 如果你经常看日志，这个比 tail -f 更直接。\n复制粘贴 进入复制模式：\n1 Ctrl + B，然后按 [ 开始选择：\n1 空格 复制：\n1 Enter 粘贴：\n1 Ctrl + B，然后按 ] ","date":"2026-03-18T00:00:00Z","permalink":"/p/tmux%E9%95%BF%E6%97%B6%E9%97%B4%E8%BF%90%E8%A1%8C%E5%91%BD%E4%BB%A4/","title":"tmux长时间运行命令"},{"content":"/sys/fs/bpf 是 Linux 内核中 BPF 文件系统 的挂载点。简单来说，它是 BPF 对象（如 Maps 和 Programs）在用户空间文件系统中的“家”。\n持久化与命名空间化 在 BPF 文件系统出现之前，BPF 程序和 Map 的生命周期完全依赖于持有其文件描述符的进程。如果创建 BPF Map 的进程退出了，Map 也就随之销毁了。\n/sys/fs/bpf 解决了这个问题，它提供了两个核心能力：\n持久化： 当将一个 BPF Map 或 BPF 程序 pin 到这个文件系统中时，即使创建它的进程结束，只要内核还在，这个对象就会一直存在。其他进程可以随后通过读取该文件来获取对象的句柄，从而实现数据的共享和程序的复用。 命名空间隔离： 它遵循文件系统的权限模型和命名空间规则。不同的容器或用户可以在各自的目录下管理 BPF 对象，避免了冲突，也增加了安全性。 Pin 当用户空间程序加载一个 BPF 程序或创建一个 Map 时，内核会在内存中创建相应的数据结构，并返回一个文件描述符给用户空间。\n默认情况下，当 fd 关闭（进程退出）时，内核引用计数归零，对象被销毁。\n用户空间程序可以调用 bpf_obj_pin() 系统调用，将这个 fd 与 /sys/fs/bpf/ 下的一个路径绑定：\n这会在该路径下创建一个文件（这就是你看到的那些文件）。 内核会增加该对象的引用计数。 只要该文件存在，对象就不会被内核销毁。 目录下的文件是什么？ 在 /sys/fs/bpf 下执行 ls -l 时，看到的文件实际上是 内核 BPF 对象的句柄。\nMap 文件： 代表一个 Key-Value 存储结构。多个 BPF 程序可以共享同一个 Map 文件来交换数据（例如：一个程序统计流量，另一个程序读取统计数据）。 Program 文件： 代表编译好的 BPF 字节码。可以被附加到 Hook 点（如 XDP, Tracepoint, TC 等）上执行。 它们不存储文本或二进制数据流，无法用 cat 读取 Map 里的内容（虽然可以用 bpftool 命令操作）。\n自动挂载机制 在现代 Linux 发行版中，Systemd 通常会自动挂载 bpffs。你可以通过 mount 命令验证：\n1 2 3 mount | grep bpf # 输出通常类似： # bpffs on /sys/fs/bpf type bpf (rw,relatime) 如果没有挂载，你需要手动挂载才能使用 BPF 的文件系统特性：\n1 mount -t bpf none /sys/fs/bpf 实际工作流程示例 假设你编写了一个 BPF 程序来统计网络包：\n加载： 你的用户空间加载器（Loader）编译并加载 BPF 程序到内核。 创建 Map： 程序创建了一个 hash map 用来存储 IP 地址和计数。 Pin（关键步骤）： 加载器调用系统调用，将 Map 钉在 /sys/fs/bpf/my_stats_map。 将程序钉在 /sys/fs/bpf/my_xdp_prog。 进程退出： 加载器完成任务后退出了。 结果： BPF 程序依然在网卡上运行（如果已附加）。 Map 里的数据依然在内存中。 另一个全新的监控程序可以打开 /sys/fs/bpf/my_stats_map，拿到 fd，读取里面的统计数据。 运维和开发人员通常使用 bpftool 来与这个目录交互，而不是直接操作文件。\nbpftool map list：列出系统中所有的 Map。 bpftool map pin id \u0026lt;ID\u0026gt; /sys/fs/bpf/my_map：将一个 Map 钉入文件系统。 bpftool prog load my.bpf.o /sys/fs/bpf/my_prog：直接将编译好的对象文件加载并钉住。 ","date":"2026-03-10T00:00:00Z","permalink":"/p/%E5%A6%82%E4%BD%95%E7%90%86%E8%A7%A3sys-fs-bpf/","title":"如何理解sys-fs-bpf"},{"content":"在局域网中对于一台没有安装图形化界面的服务器（无头 Headless 服务器），如果需要使用校园网来进行上网连接，通常需要通过弹出网页登陆的形式验证，即 Portal 认证。最简单的方法就是通过==SSH Tunnel + 本地浏览器==来解决。\n其原理为建立一个 Dynamic Port Forwarding。让本地浏览器通过 SSH 隧道，以目标服务器的身份去访问网络。\n在你的本地笔记本终端运行： ssh -D 21219 -N -C user@ip\n保持这个终端别关。打开笔记本的浏览器，设置 SOCKS5 代理：\n地址：127.0.0.1 端口：123456 在浏览器输入任一网址（如 http://xxx.com），它会自动跳转到校园网认证页面。此时你输入的账号密码，在服务端看来就是 Mini 主机发出的请求。\n登录成功后，关闭代理即可。\n参考：Portal认证原理\n","date":"2026-03-06T00:00:00Z","permalink":"/p/headless-server%E5%A6%82%E4%BD%95%E8%BF%9B%E8%A1%8C-portal-%E8%AE%A4%E8%AF%81/","title":"Headless Server如何进行 Portal 认证"},{"content":"What 使用 kind 在本地运行集群时，拉取镜像总是有这样的报错：\n1 spec.containers{nginx}: Failed to pull image \u0026#34;nginx:alpine\u0026#34;: failed to pull and unpack image \u0026#34;docker.io/library/nginx:alpine\u0026#34;: failed to resolve reference \u0026#34;docker.io/library/nginx:alpine\u0026#34;: failed to do request: Head \u0026#34;https://registry-1.docker.io/v2/library/nginx/manifests/alpine \u0026#34;: proxyconnect tcp: dial tcp 127.0.0.1:7890: connect: connection refused Why 问题的根源在于 网络隔离层级 搞错了。\n在 kind 的架构中，K8s 的“节点”其实是运行在宿主机上的 Docker 容器。\n宿主机的视角：127.0.0.1:7890 指向宿主机上运行的代理软件。 kind 节点的视角：当容器（Node）尝试访问 127.0.0.1:7890 时，它访问的是 容器内部的 Loopback 接口。 结果：容器内部根本没有监听 7890 端口的服务，所以报 connection refused。 How 因此不能在配置里写 127.0.0.1，必须让 kind 节点访问宿主机的 IP。\n在 Linux 宿主机上，Docker 默认网桥的网关通常是 172.17.0.1。\n修改 /etc/systemd/system/docker.service.d/http-proxy.conf：\n1 2 3 4 [Service] Environment=\u0026#34;HTTP_PROXY=http://172.17.0.1:7890\u0026#34; Environment=\u0026#34;HTTPS_PROXY=http://172.17.0.1:7890\u0026#34; Environment=\u0026#34;NO_PROXY=localhost,127.0.0.1,10.0.0.0/8,172.0.0.0/8,192.168.0.0/16,.svc,.cluster.local\u0026#34; 代理软件（如 Clash）必须开启 \u0026ldquo;Allow LAN\u0026rdquo; (允许局域网连接)，否则它会拒绝来自 172.17.x.x 这种非本地流量。\n重启 Docker：\n1 2 sudo systemctl daemon-reload sudo systemctl restart docker ","date":"2026-03-05T00:00:00Z","permalink":"/p/kind%E9%85%8D%E7%BD%AE%E4%BB%A3%E7%90%86/","title":"Kind配置代理"},{"content":"What 使用 kind 在本地运行集群时，拉取镜像总是有这样的报错：\n1 spec.containers{nginx}: Failed to pull image \u0026#34;nginx:alpine\u0026#34;: failed to pull and unpack image \u0026#34;docker.io/library/nginx:alpine\u0026#34;: failed to resolve reference \u0026#34;docker.io/library/nginx:alpine\u0026#34;: failed to do request: Head \u0026#34;https://registry-1.docker.io/v2/library/nginx/manifests/alpine \u0026#34;: proxyconnect tcp: dial tcp 127.0.0.1:7890: connect: connection refused Why 问题的根源在于 网络隔离层级 搞错了。\n在 kind 的架构中，K8s 的“节点”其实是运行在宿主机上的 Docker 容器。\n宿主机的视角：127.0.0.1:7890 指向宿主机上运行的代理软件。 kind 节点的视角：当容器（Node）尝试访问 127.0.0.1:7890 时，它访问的是 容器内部的 Loopback 接口。 结果：容器内部根本没有监听 7890 端口的服务，所以报 connection refused。 How 对于 Kind 因此不能在配置里写 127.0.0.1，必须让 kind 节点访问宿主机的 IP。\n在 Linux 宿主机上，Docker 默认网桥的网关通常是 172.17.0.1。\n修改 /etc/systemd/system/docker.service.d/http-proxy.conf：\n如果是 containerd，需要修改的文件为 /etc/systemd/system/containerd.service.d/http-proxy.conf\n1 2 3 4 [Service] Environment=\u0026#34;HTTP_PROXY=http://172.17.0.1:7890\u0026#34; Environment=\u0026#34;HTTPS_PROXY=http://172.17.0.1:7890\u0026#34; Environment=\u0026#34;NO_PROXY=localhost,127.0.0.1,10.0.0.0/8,172.0.0.0/8,192.168.0.0/16,.svc,.cluster.local\u0026#34; 代理软件（如 Clash）必须开启 \u0026ldquo;Allow LAN\u0026rdquo; (允许局域网连接)，否则它会拒绝来自 172.17.x.x 这种非本地流量。\n重启 Docker：\n1 2 sudo systemctl daemon-reload sudo systemctl restart docker For Minikube 1 2 3 minikube start --docker-env http_proxy=http://172.17.0.1:7890 \\ --docker-env https_proxy=http://172.17.0.1:7890 \\ --docker-env no_proxy=localhost,127.0.0.1,10.96.0.0/12,192.168.0.0/16 ","date":"2026-03-05T00:00:00Z","permalink":"/p/kind%E4%B8%8Eminikube%E9%85%8D%E7%BD%AE%E4%BB%A3%E7%90%86/","title":"Kind与MiniKube配置代理"},{"content":"在 Kubernetes 中，Service、Endpoint（或 EndpointSlice）和 kube-proxy 是协同实现服务发现与负载均衡的核心组件。它们的关系可以简单理解为：Service 定义了访问规则，Endpoint 维护了实际后端 Pod 的地址列表，而 kube-proxy 负责将规则落地到每个节点，实现流量转发。下面从概念到实例逐一说明。\n1. 核心概念 Service 作用：为一组功能相同的 Pod 提供稳定的网络访问入口（IP 和 DNS），屏蔽后端 Pod 的动态变化（如扩缩容、故障迁移）。 类型：ClusterIP（集群内访问）、NodePort（通过节点端口访问）、LoadBalancer（云负载均衡器）等。 关键属性：标签选择器（selector）决定了哪些 Pod 属于该 Service。 Endpoint（及 EndpointSlice） 作用：记录 Service 背后实际 Pod 的 IP 地址和端口列表。每个 Service 通常对应一个同名的 Endpoint 对象（旧版）或一组 EndpointSlice（新版，用于大规模集群）。 自动维护：当 Service 的标签选择器匹配到 Pod 时，Kubernetes 控制平面会自动创建并更新 Endpoint/EndpointSlice，将匹配的 Pod IP:Port 加入列表；若 Pod 变化，Endpoint 也随之更新。 kube-proxy 作用：运行在每个工作节点上的代理组件，负责将访问 Service 的流量转发到后端的 Pod。 工作模式：常见的有 iptables（默认）、IPVS（高性能）、userspace（旧版）。kube-proxy 通过监听 API Server 中 Service 和 Endpoint 的变化，动态更新节点上的网络规则（如 iptables 规则），实现流量的负载均衡。 2. 三者的关系与协同流程 1 用户/客户端 -\u0026gt; Service（ClusterIP）-\u0026gt; kube-proxy 设置的规则 -\u0026gt; 后端 Pod（通过 Endpoint 获取地址） 控制平面： 用户创建 Service，指定 selector: app=web。 Kubernetes 自动创建同名的 Endpoint 对象，并持续监控所有带 app=web 标签的 Pod，将其 IP:Port 写入 Endpoint 列表。 数据平面（每个节点）： kube-proxy 监听 Service 和 Endpoint 的变化。 有更新时，kube-proxy 在节点上配置相应的转发规则（例如 iptables 规则）： 所有目标为 Service ClusterIP 的流量，随机或轮询转发到 Endpoint 列表中的某个 Pod IP。 当客户端通过 Service IP 访问时，请求到达节点，被节点上的 iptables 规则拦截，直接转发给某个后端 Pod。 流量路径示例： Pod A（客户端） -\u0026gt; Service 10.96.0.1:80 -\u0026gt; 节点 iptables 规则 -\u0026gt; Pod B（后端）10.244.1.2:8080 3. 举例说明 假设我们有一个简单的 Web 应用，由两个 Pod 组成，它们都带有标签 app=web，容器端口为 80。\n步骤 1：创建 Service 1 2 3 4 5 6 7 8 9 10 11 apiVersion: v1 kind: Service metadata: name: web-service spec: selector: app: web # 选择器，匹配标签 app=web 的 Pod ports: - protocol: TCP port: 80 # Service 暴露的端口 targetPort: 80 # Pod 上的容器端口 步骤 2：Kubernetes 自动创建 Endpoint 执行 kubectl get endpoints web-service，可以看到类似输出：\n1 2 NAME ENDPOINTS AGE web-service 10.244.1.2:80,10.244.2.3:80 5s 这里记录了当前两个 Pod 的 IP 和端口。如果某个 Pod 被删除或重建，Endpoint 列表会实时更新，确保只包含健康 Pod。\n步骤 3：kube-proxy 配置规则 每个节点上的 kube-proxy 监听到 Service 和 Endpoint 后，会设置 iptables 规则（以 iptables 模式为例）：\n创建一个虚拟 IP（ClusterIP，如 10.96.0.1）作为 Service 的入口。 编写规则，将发往 10.96.0.1:80 的包 DNAT（目的地址转换）到 Endpoint 列表中的某个 Pod IP（例如 10.244.1.2:80 或 10.244.2.3:80），并做负载均衡（随机或轮询）。 步骤 4：访问验证 集群内的任意 Pod 可以通过 Service 名称（web-service.default.svc.cluster.local）或 ClusterIP（10.96.0.1）访问 Web 应用：\n1 curl http://web-service 请求到达节点后，根据 iptables 规则被转发到其中一个实际 Pod，从而实现负载均衡和服务发现。\n4. 补充说明：EndpointSlice 在较大规模集群中，单个 Endpoint 对象可能因 Pod 过多而变得庞大，影响性能。Kubernetes 引入了 EndpointSlice，将同一 Service 的后端地址切分成多个 slice，每个 slice 默认最多容纳 100 个地址，从而提升可扩展性。kube-proxy 也支持监听 EndpointSlice，原理与 Endpoint 类似。\n总结 Service 提供稳定访问入口，Endpoint 提供动态后端列表，kube-proxy 负责将二者结合并落地为节点上的转发规则。 三者通过 API Server 的监听机制联动，使得 Kubernetes 的服务发现和负载均衡无需手动干预，能够自动适应 Pod 的变化。 ","date":"2026-03-01T00:00:00Z","permalink":"/p/service%E4%B8%8Eendpoint/","title":"Service与Endpoint"},{"content":" 本篇文章由 AI 辅助完成\n在 eBPF（extended Berkeley Packet Filter）生态中，Ring Buffer 和 Perf Buffer 都是用于将内核态采集的数据高效传递到用户态的机制。但从 Linux 5.8 开始引入的 Ring Buffer 被认为在多数场景下优于传统的 Perf Buffer，主要原因包括以下几点：\n1. 内存模型与数据一致性（Memory Model \u0026amp; Data Consistency） Perf Buffer： 基于 per-CPU 的 ring buffer（每个 CPU 一个独立 buffer）。 用户态需轮询所有 CPU 的 buffer，增加了复杂度。 写入时若跨页（跨越单个 perf event buffer 的页面边界），会丢弃整个样本（sample loss）。 不保证写入原子性：如果事件大于 buffer 剩余空间，会被截断或丢弃。 Ring Buffer： 单一共享 buffer（可选 per-CPU 模式，但默认是全局的）。 支持 原子 reserve-commit 语义：先 bpf_ringbuf_reserve() 预留空间，填充后再 bpf_ringbuf_submit() 提交。这确保了即使事件较大，也不会因 buffer 空间不足而被静默丢弃。 若空间不足，reserve 直接失败，用户程序可感知并处理（如降级、计数等），避免 silent data loss。 💡 底层原理：Ring Buffer 使用 memory-mapped shared memory + consumer/producer index（类似 DPDK 或 Linux kernel 的 kfifo），通过 memory barrier 保证 SMP 下的一致性。\n2. Overhead 与性能 Perf Buffer： 每次写入触发 perf_event_output()，涉及较多内核路径（包括 IRQ context 切换、perf subsystem 调度等）。 在高吞吐场景下，容易成为瓶颈，尤其当多个 eBPF 程序同时写入时。 Ring Buffer： 更轻量：直接操作 shared memory，无需经过 perf subsystem。 用户态通过 epoll 或 busy-poll 方式消费，延迟更低。 实测表明，在相同负载下，Ring Buffer 的 CPU overhead 明显更低。 3. API 友好性与错误处理 Perf Buffer： 用户态需使用 libbpf 或 bcc 提供的封装，处理 per-CPU buffer 合并逻辑。 错误（如丢包）难以追踪。 Ring Buffer： 提供清晰的 reserve/submit/discard 接口，便于实现 backpressure 或采样策略。 用户态通过 ring_buffer__poll() 统一消费，代码更简洁。 支持“丢弃通知”（通过 BPF_RB_NO_WAKEUP / BPF_RB_FORCE_WAKEUP 控制唤醒行为）。 4. 功能扩展性 Ring Buffer 支持 带标志位的消息提交（如 BPF_RB_FORCE_WAKEUP），允许精细控制是否唤醒用户态消费者，这对低延迟或批处理场景非常有用。 Perf Buffer 无此类控制机制。 对比总结表 特性 Perf Buffer Ring Buffer 内存模型 Per-CPU 独立 buffer 全局共享 buffer（默认） 原子写入 ❌（大事件可能丢弃） ✅（reserve/commit 保证） 数据丢失处理 静默丢弃，难追踪 可检测、可处理 CPU Overhead 较高（走 perf 子系统） 较低（直接 mmap） API 复杂度 高（需合并多 CPU） 低（统一接口） 唤醒控制 无 支持精细控制 最低内核版本 ~4.4+ 5.8+ ","date":"2026-02-25T00:00:00Z","permalink":"/p/ringbuffer%E4%B8%8Eperfbuffer/","title":"RingBuffer与PerfBuffer"},{"content":" 本篇文章由 AI 辅助完成\nInformer 就是一个带“自动更新”功能的本地缓存（Local Cache）。\n直观理解：订阅模式 vs. 轮询模式 想象你要==监控集群里的 Pod 状态==：\n笨办法（直接调用 Client）： 你每隔 5 秒问一次 API Server：“现在的 Pod 列表是什么？”\n后果：集群里有 1 万个 Pod，你每秒问一次，API Server 的 QPS 瞬间爆炸，你会被限流（Rate Limit）。 术语：这叫 Polling（轮询），效率低，延迟高。 聪明办法（使用 Informer）： 你告诉 API Server：“我要订阅 Pod 的变化”。\n过程： 先全量拿一次列表（List），存到你本地内存里。 建立长连接（Watch），只要有变化，API Server 主动推给你。 你更新本地内存，并触发你的回调函数（Callback）。 后果：大部分查询直接读本地内存，零网络开销。只有变化时才通信。 术语：这叫 Watch 机制 + Client-side Caching。 代码极简版 核心就三步：创建 -\u0026gt; 注册回调 -\u0026gt; 启动。\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 52 53 54 55 56 57 58 package main import ( \u0026#34;context\u0026#34; \u0026#34;time\u0026#34; corev1 \u0026#34;k8s.io/api/core/v1\u0026#34; \u0026#34;k8s.io/client-go/informers\u0026#34; \u0026#34;k8s.io/client-go/kubernetes\u0026#34; \u0026#34;k8s.io/client-go/tools/cache\u0026#34; \u0026#34;k8s.io/klog/v2\u0026#34; ) func main() { // 假设 clientset 已经初始化好 var clientset *kubernetes.Clientset // 1. 创建 Informer：监听所有 Namespace 的 Pod // 参数 30*time.Minute 是 Resync 周期，意思是即使没变化，每隔这么久也重新同步一次（防丢失） factory := informers.NewSharedInformerFactory(clientset, 30*time.Minute) podInformer := factory.Core().V1().Pods().Informer() // 2. 注册回调：告诉 Informer 有事了叫我 podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { pod := obj.(*corev1.Pod) klog.Infof(\u0026#34;捕获新增 Pod: %s\u0026#34;, pod.Name) // 这里写你的业务逻辑，比如加入工作队列 }, UpdateFunc: func(oldObj, newObj interface{}) { // 只有真正变化了才处理，避免噪音 oldPod := oldObj.(*corev1.Pod) newPod := newObj.(*corev1.Pod) if oldPod.ResourceVersion == newPod.ResourceVersion { return } klog.Infof(\u0026#34;捕获更新 Pod: %s\u0026#34;, newPod.Name) }, DeleteFunc: func(obj interface{}) { klog.Infof(\u0026#34;捕获删除 Pod\u0026#34;) }, }) // 3. 启动 ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 异步启动 Informer go factory.Start(ctx.Done()) // 等待缓存同步完成（这步很重要，否则查不到数据） factory.WaitForCacheSync(ctx.Done()) klog.Info(\u0026#34;Informer 已就绪，正在监听...\u0026#34;) // 阻塞主线程 \u0026lt;-ctx.Done() } 为什么要用 Informer？ 如果你只是写个脚本一次性查个数据，用 clientset.CoreV1().Pods().Get() 没问题。\n但如果在写 Controller 或 长期运行的服务：\n方案 读数据来源 API Server 压力 实时性 推荐场景 直接 Client 远程 API Server 高 (每次请求都打过去) 高 (强一致) 一次性脚本、Admission Webhook Informer 本地内存 Cache 低 (长连接复用) 中 (最终一致) Controller、Operator、后台服务 除非你有强一致性要求（比如必须确保读到的是毫秒前的状态），否则永远优先选 Informer**。保护 API Server 就是保护集群的稳定性。\n初学者最容易踩的坑 在回调里写死循环或耗时操作： AddFunc 是阻塞的。如果你在这里 sleep 10s 或者调外部 API，整个 Informer 就卡住了，后续事件全堆积。正确做法是：回调里只把对象 key 丢进一个 WorkQueue，另起一个 worker 去处理。 忽略 WaitForCacheSync： 启动 Informer 后，本地缓存是空的。如果不等同步完成就直接查缓存，会得到空结果。 下一步思考 你现在明白了 Informer 是“本地缓存 + 事件通知”。\n那么，如果你的程序重启了，重启期间发生的 Pod 变化事件，Informer 是怎么保证不丢失的？是靠 Resync 机制，还是靠 ResourceVersion 的断点续传？\n试着去想一下这个问题，这能帮你理解 K8s 的 最终一致性（Eventually Consistent） 设计哲学。\n","date":"2026-02-22T00:00:00Z","permalink":"/p/k8s%E4%B8%AD%E7%9A%84informer/","title":"k8s中的Informer"},{"content":" 1 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 --- # 1. 扩大匹配范围，确保头文件也能享受 BPF 配置 If: PathMatch: [.*\\.bpf\\.c, .*/bpf/.*\\.c, .*\\.h] # 增加对 .h 的支持 CompileFlags: Add: - \u0026#34;-target\u0026#34; - \u0026#34;bpf\u0026#34; - \u0026#34;-x\u0026#34; - \u0026#34;c\u0026#34; - \u0026#34;-std=gnu11\u0026#34; - \u0026#34;-D__BPF_TRACING__\u0026#34; - \u0026#34;-D__TARGET_ARCH_x86\u0026#34; # 必须指定架构，否则 vmlinux.h 内部的部分条件编译会失效 - \u0026#34;-I.\u0026#34; # 确保当前目录的头文件优先级最高 - \u0026#34;-I/usr/include/bpf\u0026#34; # 指向 libbpf 头文件路径 - \u0026#34;-Wno-unknown-attributes\u0026#34; # 忽略不认识的属性 - \u0026#34;-ferror-limit=0\u0026#34; # 即使错误再多也不要停止解析，这对补全很重要 Remove: - \u0026#34;-std=c++*\u0026#34; - \u0026#34;-fexceptions\u0026#34; Diagnostics: # 2. 针对 vmlinux.h 的重定义问题进行精准打击 Suppress: - \u0026#34;redefinition\u0026#34; # 抑制所有的符号重定义 - \u0026#34;typedef_redefinition\u0026#34; # 特别针对 typedef 的冲突 - \u0026#34;too_many_errors\u0026#34; # 彻底关掉那个烦人的警告 - \u0026#34;err_expected_identifer\u0026#34; # 针对 vmlinux 内部属性解析失败的补救 --- # 3. 针对特定的 vmlinux.h 文件彻底关闭检查 If: PathMatch: .*vmlinux\\.h Diagnostics: Suppress: [\u0026#34;*\u0026#34;] ","date":"2026-02-15T00:00:00Z","permalink":"/p/%E6%9B%B4%E9%80%82%E5%90%88ebpf%E7%A8%8B%E5%BA%8F%E5%AE%9D%E5%AE%9D%E4%BD%93%E8%B4%A8%E7%9A%84clangd%E9%85%8D%E7%BD%AE/","title":"更适合eBPF程序宝宝体质的clangd配置"},{"content":"环境：Fedora 43，NetworkManager 管理网络，基于 nmcli（NetworkManager Command Line Interface）操作。\nWiFi 连接全流程 1. 确认网卡状态 1 nmcli device status 输出示例：\n1 2 3 DEVICE TYPE STATE CONNECTION wlan0 wifi disconnected -- eth0 ethernet connected Wired connection 1 如果 STATE 为 unmanaged，说明网卡未被 NetworkManager 接管：\n1 sudo nmcli device set wlan0 managed yes 2. 扫描可用网络 1 nmcli device wifi list 输出示例：\n1 2 3 4 5 IN-USE SSID MODE CHAN RATE SIGNAL BARS SECURITY MyHome_5G Infra 149 1300 Mbit/s 75 ▂▄▆_ WPA2 MyHome_2.4G Infra 6 1300 Mbit/s 90 ▂▄▆█ WPA3 Neighbor_WiFi Infra 1 195 Mbit/s 30 ▂___ WPA2 * Office_Guest Infra 36 540 Mbit/s 60 ▂▄▆_ WPA2 IN-USE 列带 * 表示当前已连接的网络。\n3. 连接 WiFi 基本连接（自动保存配置） 1 sudo nmcli device wifi connect \u0026#34;MyHome_5G\u0026#34; password \u0026#34;your_password\u0026#34; 成功输出：\n1 Device \u0026#39;wlan0\u0026#39; successfully activated with \u0026#39;xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\u0026#39; 此命令自动完成：生成 connection profile（保存在 /etc/NetworkManager/system-connections/）→ 触发 DHCP → 设置 autoconnect=yes。\n连接隐藏 SSID 1 sudo nmcli device wifi connect \u0026#34;Hidden_SSID\u0026#34; password \u0026#34;your_password\u0026#34; hidden yes 指定加密方式（避免协商不确定性） 1 sudo nmcli device wifi connect \u0026#34;MyHome_5G\u0026#34; password \u0026#34;your_password\u0026#34; wep-key-type key 4. 查看活动连接与 IP 1 2 3 nmcli connection show --active ip addr show wlan0 ping -c 3 8.8.8.8 5. 切换 WiFi（断旧连新一步完成） 1 sudo nmcli device wifi connect \u0026#34;New_SSID\u0026#34; password \u0026#34;new_password\u0026#34; nmcli device wifi connect 会自动断开当前连接，无需手动先行断开。\n6. 断开当前 WiFi 方式一：断开网卡（不会自动重连） 1 sudo nmcli device disconnect wlan0 方式二：禁用连接配置（autoconnect 可能触发重连） 1 sudo nmcli connection down \u0026#34;MyHome_5G\u0026#34; 方式三：彻底删除配置 1 sudo nmcli connection delete \u0026#34;MyHome_5G\u0026#34; nmcli 网卡操作参考 查看设备与连接 命令 用途 nmcli device status 查看所有网卡状态、类型、连接情况 nmcli device show wlan0 查看指定网卡详细信息（驱动、MAC、IP、DNS 等） nmcli connection show 列出所有 connection profile nmcli connection show --active 列出当前活动的连接 nmcli connection show \u0026quot;MyHome_5G\u0026quot; 查看指定连接配置详情 设备管理 命令 用途 nmcli device connect wlan0 让网卡尝试自动连接（根据已有 profile） nmcli device disconnect wlan0 断开网卡，不再自动连接 nmcli device set wlan0 managed yes 将网卡纳入 NetworkManager 管理 nmcli device set wlan0 managed no 将网卡排除出管理（变为 unmanaged） nmcli device reapply wlan0 重新应用当前连接配置 nmcli device wifi list 扫描并列出附近 WiFi nmcli device wifi rescan 强制重新扫描 nmcli device monitor wlan0 实时监控网卡状态变化 Connection Profile 管理 modify 常用示例 1 2 3 4 5 6 7 8 9 10 11 # 设置静态 IP sudo nmcli con modify \u0026#34;MyHome_5G\u0026#34; ipv4.addresses 192.168.1.100/24 sudo nmcli con modify \u0026#34;MyHome_5G\u0026#34; ipv4.gateway 192.168.1.1 sudo nmcli con modify \u0026#34;MyHome_5G\u0026#34; ipv4.dns \u0026#34;8.8.8.8,1.1.1.1\u0026#34; sudo nmcli con modify \u0026#34;MyHome_5G\u0026#34; ipv4.method manual # 关闭自动连接 sudo nmcli con modify \u0026#34;MyHome_5G\u0026#34; connection.autoconnect no # 设置连接优先级（数字越大越优先） sudo nmcli con modify \u0026#34;MyHome_5G\u0026#34; connection.autoconnect-priority 10 全局设置 命令 用途 nmcli general status 查看 NetworkManager 整体状态 nmcli general hostname 查看/设置主机名 nmcli general set wifi.on-demand on 仅在需要时启用 WiFi（省电/安全） nmcli general permissions 查看当前用户权限 配置文件操作 直接编辑后重载：\n1 2 sudo vim /etc/NetworkManager/system-connections/MyHome_5G.nmconnection sudo nmcli connection reload 配置文件为 INI 格式，手动修改后必须 reload 才能生效。注意设置权限：\n1 2 sudo chmod 600 /etc/NetworkManager/system-connections/*.nmconnection sudo chown root:root /etc/NetworkManager/system-connections/*.nmconnection ","date":"2026-02-12T00:00:00Z","permalink":"/p/%E4%BD%BF%E7%94%A8nmcli%E7%AE%A1%E7%90%86wi-fi%E8%BF%9E%E6%8E%A5/","title":"使用nmcli管理Wi-Fi连接"},{"content":" 将 Go 的默认工作 Go 目录从 $HOME/go 改为 $HOME/.go ，这个行为由环境变量 GOPATH 控制\n核心原理：GOPATH 与现代 Go 的关系 自 Go 1.11 引入 Go Modules 后，项目源码不再强制依赖 GOPATH/src，但以下内容仍受 GOPATH 影响：\n路径 用途 是否受 GOPATH 影响 $GOPATH/bin go install 安装的可执行文件 ✅ 是 $GOPATH/pkg/mod Module 缓存（Go 1.16+ 由 GOMODCACHE 控制） ⚠️ 部分 $GOPATH/src 传统 GOPATH 模式源码 ❌ Modules 模式下基本不用 因此，修改 GOPATH 主要影响二进制安装路径和部分缓存位置，不影响已启用 modules 的项目构建。\n迁移步骤（Fedora 环境） 1. 设置环境变量 在 ~/.bashrc 或 ~/.zshrc 中添加：\n1 2 3 4 5 # Go workspace 配置 export GOPATH=\u0026#34;$HOME/.go\u0026#34; export GOBIN=\u0026#34;$GOPATH/bin\u0026#34; # 显式指定二进制安装目录 export GOMODCACHE=\u0026#34;$GOPATH/pkg/mod\u0026#34; # 可选：统一 module 缓存位置 export PATH=\u0026#34;$GOBIN:$PATH\u0026#34; # 确保安装的工具可直接执行 💡 提示：Fedora 默认使用 Bash，建议修改 ~/.bashrc；若使用 Zsh 则改 ~/.zshrc。\n使配置生效：\n1 source ~/.bashrc # 或 source ~/.zshrc 2. 迁移现有数据 1 2 3 4 5 6 7 8 9 10 # 创建新目录结构 mkdir -p ~/.go/{bin,pkg,src} # 迁移已有内容（保留原目录作为备份） mv ~/go/bin/* ~/.go/bin/ 2\u0026gt;/dev/null || true mv ~/go/pkg/* ~/.go/pkg/ 2\u0026gt;/dev/null || true mv ~/go/src/* ~/.go/src/ 2\u0026gt;/dev/null || true # 验证后可删除原目录 # rm -rf ~/go 3. 验证配置 1 2 3 4 5 go env GOPATH GOBIN GOMODCACHE # 应输出： # /home/yourname/.go # /home/yourname/.go/bin # /home/yourname/.go/pkg/mod 测试安装一个工具验证路径：\n1 2 3 go install golang.org/dl/go1.21@latest which go1.21 # 应返回：/home/yourname/.go/bin/go1.21 ","date":"2026-02-11T00:00:00Z","permalink":"/p/%E6%9B%B4%E6%94%B9go%E7%9A%84%E9%BB%98%E8%AE%A4%E5%AE%89%E8%A3%85%E4%BD%8D%E7%BD%AE/","title":"更改Go的默认安装位置"},{"content":"一、生成 SSH 密钥对（在本地机器上） 打开终端（Linux/macOS）或使用 PowerShell/WSL（Windows）。 执行以下命令生成密钥对： 1 ssh-keygen -t rsa -b 4096 -C \u0026#34;your_email@example.com\u0026#34; -t rsa：指定密钥类型为 RSA（也可以使用 -t ed25519 更安全）。 -b 4096：密钥长度（RSA 推荐 4096 位）。 -C：添加注释（通常是邮箱，可选）。 按提示操作： 保存路径：直接回车使用默认路径 ~/.ssh/id_rsa。 设置密码（passphrase）：可选，增强安全性。如果希望完全免密，直接回车留空。 生成后会在 ~/.ssh/ 目录下创建两个文件：\nid_rsa：私钥（不要泄露） id_rsa.pub：公钥（用于上传到服务器） 二、将公钥上传到远程服务器 方法一：使用 ssh-copy-id 1 ssh-copy-id username@server_ip 例如：\n1 ssh-copy-id user@192.168.1.100 系统会提示你输入远程用户的密码，验证通过后，公钥会被自动追加到服务器的 ~/.ssh/authorized_keys 文件中。\n⚠️ 如果提示 command not found，说明 ssh-copy-id 不存在（常见于 macOS 或某些 Linux 发行版），请使用方法二。\n方法二：手动复制公钥 查看本地公钥内容： 1 cat ~/.ssh/id_rsa.pub 复制输出的整段内容（以 ssh-rsa AAAAB3... 开头）。\n登录远程服务器： 1 ssh username@server_ip 在服务器上创建 .ssh 目录（如果不存在）： 1 mkdir -p ~/.ssh 将公钥内容写入 authorized_keys 文件： 1 echo \u0026#34;你复制的公钥内容\u0026#34; \u0026gt;\u0026gt; ~/.ssh/authorized_keys 注意替换“你复制的公钥内容”为实际内容。\n设置正确的权限（非常重要！SSH 要求严格权限）： 1 2 3 chmod 700 ~/.ssh chmod 600 ~/.ssh/authorized_keys chown $USER:$USER ~/.ssh -R 三、配置别名 配置别名：编辑 ~/.ssh/config，简化连接命令： 1 2 3 4 Host myserver HostName 192.168.1.100 User myuser IdentityFile ~/.ssh/id_rsa 之后只需运行：\n1 ssh myserver ","date":"2026-02-10T00:00:00Z","permalink":"/p/linux-ssh%E5%85%8D%E5%AF%86%E7%99%BB%E9%99%86/","title":"Linux SSH免密登陆"},{"content":"引言 在性能分析和系统调优中，了解应用程序在运行时的调用栈信息至关重要。在 【eBPF 入门实践教程十二：使用 eBPF 程序 profile 进行性能分析】 这篇文章中，其利用 Rust + eBPF 构建了一个 profiler 工具，抱着学习的目的，我使用 C++ 实现了相同的功能，并将所学知识总结成本文。\nperf_event_open 机制解析 perf 原理图：https://plantegg.github.io/2021/05/16/Perf_IPC%E4%BB%A5%E5%8F%8ACPU%E5%88%A9%E7%94%A8%E7%8E%87/\nperf_event_open 是 Linux 内核提供的性能监控接口，允许用户空间程序创建性能事件计数器。当用户态程序调用 sys_perf_event_open 系统调用时，内核会创建相应的性能监控实例。以下是基本的使用方式：\n1 2 3 4 5 6 7 8 #include \u0026lt;linux/perf_event.h\u0026gt; #include \u0026lt;sys/syscall.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; int64_t perf_event_open(struct perf_event_attr* hw_event, pid_t pid, int cpu, int group_fd, unsigned long flags) { return syscall(SYS_perf_event_open, hw_event, pid, cpu, group_fd, flags); } 以文件描述符为中心的监控单元 Linux 遵循 \u0026quot; 一切皆文件 \u0026quot; 的设计哲学，perf_event_open 也不例外。每次调用该函数，内核都会创建一个独立的性能监控实例，并返回一个文件描述符。\n核心概念：\n1 fd = 1 Event：每个文件描述符对应一个具体的监控任务，例如 \u0026quot; 监控 CPU 0 的指令执行数 \u0026quot; 事件分组 (Group)：多个文件描述符可以绑定在一起形成事件组 分组的重要性： 假设需要计算 \u0026quot; 每条指令的缓存未命中率 \u0026ldquo;，需要同时监控 \u0026quot; 指令数 \u0026quot; 和 \u0026quot; 缓存未命中数 \u0026ldquo;。将它们放入同一事件组可以确保：\n两个事件同时开始、同时结束计数 硬件计数器同步采样，保证计算比例的准确性 减少系统调用开销 事件状态控制：ioctl 与 prctl 创建事件后，可以通过以下方式控制其运行状态：\nioctl (Input/Output Control)：针对单个文件描述符的操作 ioctl(fd, PERF_EVENT_IOC_ENABLE)：启用事件监控 ioctl(fd, PERF_EVENT_IOC_DISABLE)：暂停事件监控 prctl (Process Control)：针对当前进程所有事件的批量操作 适用于管理多个监控事件的场景，避免频繁的 ioctl 调用 设计优势：事件被禁用时，计数器暂停但数据保留。这种设计允许在程序特定阶段（如关键算法执行期间）精确开启监控，实现细粒度的性能分析。\n两种工作模式：计数 vs 采样 perf_event 支持两种基本工作模式，满足不同的性能分析需求：\n计数模式 (Counting Mode) 内核维护一个简单的计数器（通常是 u64 类型整数）。计数事件的结果通过 read 系统调用收集。\n特点：\n低开销：仅维护计数器，不记录详细上下文 结果简单：返回事件发生的总次数 应用场景：基准测试、性能统计 例如：\u0026rdquo; 这段代码执行消耗了多少 CPU 周期 \u0026quot; \u0026quot; 函数调用发生了多少次缓存未命中 \u0026quot; 采样模式 (Sampling Mode) 设置采样阈值（如每 1000 次缓存未命中采样一次）。当事件计数器溢出时，内核会捕获当前的执行上下文（包括指令指针、进程 ID、调用栈等）。\n工作原理：\n内核分配环形缓冲区用于存储采样数据 采样数据通过内存映射 (mmap) 暴露给用户空间 用户程序从缓冲区 \u0026quot; 消费 \u0026quot; 采样数据 优势：\n记录详细的执行上下文信息 支持调用栈回溯 应用场景：性能热点分析、火焰图生成、函数调用频率统计 结合 eBPF 实现 profiling 传统 perf 工具虽然功能强大，但在某些场景下存在局限性。eBPF 提供了更灵活、更低开销的性能监控方案。通过将 eBPF 程序挂载到 perf_event 上，我们可以在内核空间直接处理性能事件，实现高效的调用栈采集。\n核心思路：\n利用 perf_event 触发采样事件 eBPF 程序在内核中捕获事件并采集调用栈信息 通过 Ring Buffer 高效传输数据到用户空间 用户空间程序解析并展示结果 1 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 52 53 54 55 56 57 58 59 60 61 62 63 64 #include \u0026#34;vmlinux.h\u0026#34; #include \u0026lt;bpf/bpf_helpers.h\u0026gt; char LICENSE[] SEC(\u0026#34;license\u0026#34;) = \u0026#34;Dual BSD/GPL\u0026#34;; #ifndef TASK_COMM_LEN #define TASK_COMM_LEN 16 #endif #ifndef MAX_STACK_DEPTH #define MAX_STACK_DEPTH 128 #endif typedef __u64 stack_trace_t[MAX_STACK_DEPTH]; struct stack_trace_event { __u32 pid; __u32 cpu_id; __u64 timestamp; char comm[TASK_COMM_LEN]; __s32 kstack_sz; __s32 ustack_sz; stack_trace_t kstack; stack_trace_t ustack; }; struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 256 * 1024); } events SEC(\u0026#34;.maps\u0026#34;); SEC(\u0026#34;perf_event\u0026#34;) int profile(void* ctx) { struct stack_trace_event* event = bpf_ringbuf_reserve(\u0026amp;events, sizeof(struct stack_trace_event), 0); if (!event) { return 1; } int pid = bpf_get_current_pid_tgid() \u0026gt;\u0026gt; 32; int cpu_id = bpf_get_smp_processor_id(); event-\u0026gt;pid = pid; event-\u0026gt;cpu_id = cpu_id; event-\u0026gt;timestamp = bpf_ktime_get_ns(); // 获取当前进程名 if (bpf_get_current_comm(event-\u0026gt;comm, sizeof(event-\u0026gt;comm))) { event-\u0026gt;comm[0] = 0; } // 获取内核栈、用户栈信息 event-\u0026gt;kstack_sz = bpf_get_stack(ctx, event-\u0026gt;kstack, sizeof(event-\u0026gt;kstack), 0); event-\u0026gt;ustack_sz = bpf_get_stack( ctx, event-\u0026gt;ustack, sizeof(event-\u0026gt;ustack), BPF_F_USER_STACK); bpf_ringbuf_submit(event, 0); return 0; } eBPF 程序执行流程：\n程序挂载到 perf_event，随性能计数器（CPU 周期、指令数等）或定时器触发 每次触发时，eBPF 程序在内核空间执行一次 采集关键性能数据并通过 Ring Buffer 传输到用户空间 采集的关键信息：\n进程标识：当前运行进程的 PID CPU 信息：执行任务的 CPU 核心编号 时间戳：事件发生的精确时间（纳秒级） 进程名：可执行文件名称 栈回溯：内核栈和用户栈的完整调用链 数据结构设计：\n1 2 3 4 5 6 7 8 9 10 struct stack_trace_event { __u32 pid; // 进程 ID __u32 cpu_id; // CPU 核心编号 __u64 timestamp; // 时间戳（纳秒） char comm[TASK_COMM_LEN]; // 进程名 __s32 kstack_sz; // 内核栈大小 __s32 ustack_sz; // 用户栈大小 stack_trace_t kstack; // 内核栈地址数组 stack_trace_t ustack; // 用户栈地址数组 }; 应用价值： 采集的数据经过处理后，可以生成 火焰图 (Flame Graphs)，直观展示：\nCPU 消耗分布：哪个进程/函数占用最多 CPU 时间 调用链分析：热点函数的完整调用路径 内核/用户态比例：系统调用与业务逻辑的时间分配 性能瓶颈定位：快速识别性能热点和优化机会 解析采集的栈回溯信息 API 设计 定义内核返回的 event 结构体：\n1 2 3 4 5 6 7 8 9 10 struct StacktraceEvent { uint32_t pid; uint32_t cpu_id; uint64_t timestamp; char comm[TASK_COMM_LEN]; int32_t kstack_size; int32_t ustack_size; uint64_t kstack[MAX_STACK_DEPTH]; uint64_t ustack[MAX_STACK_DEPTH]; }; 定义事件处理器：\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 enum class OutputFormat : uint8_t { Standard, FoldExtend }; class EventHandler { public: EventHandler(OutputFormat fmt) : format(fmt) { boot_time_ns = get_boot_time_ns(); } ~EventHandler() = default; auto handle(const uint8_t* data, size_t len) -\u0026gt; int; void show_stack_trace(const uint64_t* stack, uint32_t size, uint32_t pid); private: blaze::Symbolizer symbolizer_; OutputFormat format; uint64_t boot_time_ns; static auto get_boot_time_ns() -\u0026gt; uint64_t; // 符号化堆栈并返回字符串向量 auto symbolize_stack_to_vec(const uint64_t* stack, uint32_t stack_sz, uint32_t pid) -\u0026gt; std::vector\u0026lt;std::string\u0026gt;; void handle_standard(const StacktraceEvent* event); void handle_fold_extend(const StacktraceEvent* event); }; 符号化解析：将地址转换为函数名 eBPF 采集的调用栈信息是内存地址数组，需要转换为可读的函数名和源代码位置。这里使用 blazesym 库进行符号化解析，并封装为易用的 C++ 接口。\n符号化的重要性：\n原始地址（如 0xffffffff81000000）对人类不友好 需要转换为函数名（如 do_syscall_64）和源代码位置 支持内核符号和用户空间符号的差异化处理 1 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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 #ifndef BLAZE_H_ #define BLAZE_H_ #include \u0026lt;cstdint\u0026gt; #include \u0026lt;format\u0026gt; #include \u0026lt;variant\u0026gt; #include \u0026lt;blazesym.h\u0026gt; #include \u0026#34;utils.h\u0026#34; namespace blaze { struct CodeInfo { const blaze_symbolize_code_info* info_; CodeInfo(const blaze_symbolize_code_info* info) : info_(info) {} }; struct Syms { const blaze_syms* syms_; Syms(const blaze_syms* syms) : syms_(syms) {} ~Syms() { if (syms_) { blaze_syms_free(syms_); } } // 禁止拷贝，防止双重释放 Syms(const Syms\u0026amp;) = delete; auto operator=(const Syms\u0026amp;) -\u0026gt; Syms\u0026amp; = delete; }; // 符号源 using Source = std::variant\u0026lt;blaze_symbolize_src_process, blaze_symbolize_src_kernel\u0026gt;; struct Input { const uint64_t* addrs_; size_t cnt_; }; struct Symbolizer { blaze_symbolizer* symbolizer_; Symbolizer() { symbolizer_ = blaze_symbolizer_new(); } ~Symbolizer() { blaze_symbolizer_free(symbolizer_); } [[nodiscard]] auto symbolize(Source src, const Input\u0026amp; input) const -\u0026gt; Result\u0026lt;Syms\u0026gt; { if (!input.addrs_ || input.cnt_ == 0) { return Err\u0026lt;\u0026gt;{\u0026#34;Empty input addresses\u0026#34;}; } const blaze_syms* syms = nullptr; std::visit( Overloaded{ [\u0026amp;](blaze_symbolize_src_kernel\u0026amp; kern_src) -\u0026gt; void { syms = blaze_symbolize_kernel_abs_addrs( symbolizer_, \u0026amp;kern_src, input.addrs_, input.cnt_); }, [\u0026amp;](blaze_symbolize_src_process\u0026amp; proc_src) -\u0026gt; void { syms = blaze_symbolize_process_abs_addrs( symbolizer_, \u0026amp;proc_src, input.addrs_, input.cnt_); }, }, src); return syms ? Result\u0026lt;Syms\u0026gt;{syms} : Err\u0026lt;\u0026gt;{std::format(\u0026#34;Symbolization failed, errno is: {}\u0026#34;, static_cast\u0026lt;int16_t\u0026gt;(blaze_err_last()))}; } }; inline auto get_symbolize_source(uint32_t pid) -\u0026gt; blaze::Source { if (pid == 0) { blaze_symbolize_src_kernel src{ .type_size = sizeof(src), }; return blaze::Source{src}; } else { blaze_symbolize_src_process src{ .type_size = sizeof(src), .pid = pid, }; return blaze::Source{src}; } } } // namespace blaze #endif /* BLAZE_H_ */ 其中的 Result、Err、Overloaded 分别借鉴了 Rust 中的 Result、Err 和 match。\nSymbolizer::symbolize 用于把绝对地址数组符号化（转成符号/调试信息）：\n如果 input.addrs_ 为 null 或 input.cnt_ == 0，返回错误 Err\u0026lt;\u0026gt;{\u0026quot;Empty input addresses\u0026quot;}。 根据参数 src（Source 是 blaze_symbolize_src_process 或 blaze_symbolize_src_kernel 的 std::variant）调用不同的符号化接口： 内核源时调用 blaze_symbolize_kernel_abs_addrs(symbolizer_, \u0026amp;kern_src, input.addrs_, input.cnt_)； 进程源时调用 blaze_symbolize_process_abs_addrs(symbolizer_, \u0026amp;proc_src, input.addrs_, input.cnt_)。 使用 std::visit + Overloaded 选择分支。 结果处理：把返回的 const blaze_syms* syms 封装进 Syms（RAII，析构时会调用 blaze_syms_free）。若 syms 非空则返回 Result\u0026lt;Syms\u0026gt;{syms}；否则返回带有 blaze_err_last()（转换为整数）的错误信息。 事件处理 EventHandler::handle 接收的是一块“原始二进制缓冲区”（raw bytes）而不是已经解析好的结构体，所以用 const uint8_t* data, size_t len 更通用、安全，并且允许在用户态从 ring/payload、socket 或 perf buffer 等来源直接读取数据并检查长度后再解析（在这个项目中，数据从 ringbuf 中获取）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 auto EventHandler::handle(const uint8_t* data, size_t len) -\u0026gt; int { if (len != sizeof(StacktraceEvent)) { std::println(\u0026#34;Data length mismatch: expected {}, got {}\u0026#34;, sizeof(StacktraceEvent), len); return 1; } const auto* const event = reinterpret_cast\u0026lt;const StacktraceEvent*\u0026gt;(data); if (event-\u0026gt;kstack_size \u0026lt;= 0 \u0026amp;\u0026amp; event-\u0026gt;ustack_size \u0026lt;= 0) { return 1; } if (format == OutputFormat::Standard) { handle_standard(event); } else { handle_fold_extend(event); } return 0; } 标准输出格式：可读的逐帧展示 handle_standard 方法生成人类可读的调用栈输出，包含完整的时间戳、进程信息和源代码位置。这种格式适合直接查看和分析：\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 static void print_frame(const char* name, Option\u0026lt;AddrInfo\u0026gt; addr_info, const blaze_symbolize_code_info* code_info) { std::string code_str; if (code_info != nullptr) { // path if ((code_info-\u0026gt;dir != nullptr) \u0026amp;\u0026amp; (code_info-\u0026gt;file != nullptr)) { code_str = std::format(\u0026#34; {}/{})\u0026#34;, code_info-\u0026gt;dir, code_info-\u0026gt;file); } else if (code_info-\u0026gt;file != nullptr) { code_str = code_info-\u0026gt;file; } // line and column if (code_info-\u0026gt;line \u0026gt; 0) { code_str += std::format(\u0026#34;:{}\u0026#34;, code_info-\u0026gt;line); if (code_info-\u0026gt;column \u0026gt; 0) { code_str += std::format(\u0026#34;:{}\u0026#34;, code_info-\u0026gt;column); } } } if (addr_info.has_value()) { auto [input_addr, addr, offset] = *addr_info; std::println(\u0026#34;0x{:0\u0026gt;{}}: {} @ {:#x} + {:#x}{}\u0026#34;, input_addr, ADDR_WIDTH, (name != nullptr) ? name : \u0026#34;\u0026lt;unknown\u0026gt;\u0026#34;, addr, offset, code_str); } else { std::println(\u0026#34;{:\u0026gt;{}} {}{} [inlined]\u0026#34;, \u0026#34;\u0026#34;, ADDR_WIDTH, name, code_str); } } void EventHandler::show_stack_trace(const uint64_t* stack, uint32_t size, uint32_t pid) { blaze::Source src = blaze::get_symbolize_source(pid); size_t count = static_cast\u0026lt;size_t\u0026gt;(size) / sizeof(uint64_t); // 栈帧数量 auto result = symbolizer_.symbolize( src, blaze::Input{.addrs_ = stack, .cnt_ = count}); if (!result) { std::println(stderr, \u0026#34; Failed to symbolize stack trace. err: {}\u0026#34;, result.error()); return; } const auto* syms = result-\u0026gt;syms_; for (size_t i = 0; i \u0026lt; count; ++i) { if (i \u0026lt; syms-\u0026gt;cnt \u0026amp;\u0026amp; (syms-\u0026gt;syms[i].name != nullptr)) { print_frame( syms-\u0026gt;syms[i].name, Option\u0026lt;AddrInfo\u0026gt;(std::make_tuple( stack[i], syms-\u0026gt;syms[i].addr, syms-\u0026gt;syms[i].offset)), \u0026amp;syms-\u0026gt;syms[i].code_info); // 打印内联函数信息 for (size_t j = 0; j \u0026lt; syms-\u0026gt;syms[i].inlined_cnt; ++j) { print_frame(syms-\u0026gt;syms[i].inlined[j].name, std::nullopt, \u0026amp;syms-\u0026gt;syms[i].inlined[j].code_info); } } else { std::println(\u0026#34;{:\u0026gt;0{}}: \u0026lt;no-symbol\u0026gt;\u0026#34;, stack[i], ADDR_WIDTH); } } } void EventHandler::handle_standard(const StacktraceEvent* event) { uint64_t unix_ns = event-\u0026gt;timestamp + boot_time_ns; std::println(\u0026#34;[{}.{:09} COMM: {} (pid={}) @ CPU {}]\u0026#34;, unix_ns / 1\u0026#39;000\u0026#39;000\u0026#39;000, unix_ns % 1\u0026#39;000\u0026#39;000\u0026#39;000, event-\u0026gt;comm, event-\u0026gt;pid, event-\u0026gt;cpu_id); // 打印内核栈 if (event-\u0026gt;kstack_size \u0026gt; 0) { std::println(\u0026#34;Kernel:\u0026#34;); show_stack_trace(event-\u0026gt;kstack, event-\u0026gt;kstack_size, 0); } else { std::println(\u0026#34;Kernel: \u0026lt;no stack\u0026gt;\u0026#34;); } // 打印用户栈 if (event-\u0026gt;ustack_size \u0026gt; 0) { std::println(\u0026#34;Userspace:\u0026#34;); show_stack_trace(event-\u0026gt;ustack, event-\u0026gt;ustack_size, event-\u0026gt;pid); } else { std::println(\u0026#34;Userspace: \u0026lt;no stack\u0026gt;\u0026#34;); } std::println(); } 火焰图格式：机器可读的数据输出 handle_fold_extend 生成折叠行（comm-pid;frame;... 1），用于生成火焰图（FlameGraph）。handle_fold_extend 对每个栈结果做 reverse，把栈从“根”（程序入口）到“叶”（当前帧）排列，符合火焰图期望的父到子顺序。\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 auto EventHandler::symbolize_stack_to_vec(const uint64_t* stack, uint32_t stack_sz, uint32_t pid) -\u0026gt; std::vector\u0026lt;std::string\u0026gt; { if (stack_sz \u0026lt;= 0) { return {}; } blaze::Source src = blaze::get_symbolize_source(pid); size_t count = stack_sz / sizeof(uint64_t); auto result = symbolizer_.symbolize( src, blaze::Input{.addrs_ = stack, .cnt_ = count}); if (!result) { return {}; } const auto* syms = result-\u0026gt;syms_; std::vector\u0026lt;std::string\u0026gt; vec; if (syms == nullptr) { for (size_t i = 0; i \u0026lt; count; ++i) { vec.push_back(std::format(\u0026#34;0x{:x}\u0026#34;, stack[i])); } return vec; } for (size_t i = 0; i \u0026lt; syms-\u0026gt;cnt; ++i) { if (syms-\u0026gt;syms[i].name != nullptr) { vec.emplace_back(syms-\u0026gt;syms[i].name); } else { vec.push_back(std::format(\u0026#34;0x{:x}\u0026#34;, stack[i])); } } return vec; } void EventHandler::handle_fold_extend(const StacktraceEvent* event) { std::vector\u0026lt;std::string\u0026gt; stack_frames; // 为了让火焰图能够按进程聚合，将 \u0026#34;comm-pid\u0026#34; 作为栈底 stack_frames.push_back(std::format(\u0026#34;{}-{}\u0026#34;, event-\u0026gt;comm, event-\u0026gt;pid)); // 处理用户态 if (event-\u0026gt;ustack_size \u0026gt; 0) { auto user_frames = symbolize_stack_to_vec( event-\u0026gt;ustack, event-\u0026gt;ustack_size, event-\u0026gt;pid); for (const auto\u0026amp; frame : user_frames | std::views::reverse) { stack_frames.push_back(frame); } } // 处理内核态 if (event-\u0026gt;kstack_size \u0026gt; 0) { auto kern_frames = symbolize_stack_to_vec(event-\u0026gt;kstack, event-\u0026gt;kstack_size, 0); for (const auto\u0026amp; frame : kern_frames | std::views::reverse) { stack_frames.push_back(frame + \u0026#34;_[k]\u0026#34;); } } auto temp = stack_frames | std::views::join_with(\u0026#39;;\u0026#39;); // 输出格式：stack;frames 1 // FlameGraph 工具期望每行以空格和数字结尾 std::println(\u0026#34;{} 1\u0026#34;, temp | std::ranges::to\u0026lt;std::string\u0026gt;()); } 流程图 flowchart TD A[eBPF 程序（内核） 采集 stacktrace ] --\u003e B[Userspace reader 接收 StacktraceEvent] B --\u003e C[EventHandler::handle 校验 \u0026 选择输出格式] C --\u003e D{有 ustack?} C --\u003e E{有 kstack?} D -- yes --\u003e U1[\"构造 blaze::Source (process) 构造 blaze::Input(addrs,cnt)\"] E -- yes --\u003e K1[\"构造 blaze::Source (kernel) 构造 blaze::Input(addrs,cnt)\"] U1 --\u003e S1[\"Symbolizer::symbolize (std::visit -\u003e process API)\"] K1 --\u003e S2[\"Symbolizer::symbolize (std::visit -\u003e kernel API)\"] S1 --\u003e F{symbolize 成功?} S2 --\u003e F F -- yes --\u003e G[返回 Syms（RAII）] F -- no --\u003e H[\"返回 Err 记录 blaze_err_last() -\u003e fallback: 打印原始地址\"] G --\u003e P1[show_stack_trace 逐帧打印（含内联）] G --\u003e P2[symbolize_stack_to_vec 生成折叠帧向量] P2 --\u003e M[组装折叠行 -\u003e 输出给 FlameGraph] P1 --\u003e O[标准可读输出] H --\u003e O2[打印原始地址 / 记录错误日志] style U1 fill:#e6ffed,stroke:#2d9a3b style K1 fill:#e6f0ff,stroke:#2176d2 style H fill:#fff1f0,stroke:#d9534f启动与事件循环 这一节描述用户态程序如何完成启动准备并进入事件轮询：解析命令行与日志级别、提升必要的资源限制、加载并 attach eBPF 程序、通过 ring buffer 接收内核事件、调用 EventHandler 进行解析输出，以及在接收到信号时优雅退出。\n关键依赖与权限 依赖项：libbpf（加载 BPF 对象与 maps）、spdlog（日志）、CLI11（命令行解析）、可选的内核 BTF / vmlinux（用于内核符号化）。\n运行权限：通常需要 root 或至少 CAP_BPF/CAP_SYS_ADMIN。另外必须提升 RLIMIT_MEMLOCK，否则内核会因为无法锁定内存而拒绝加载 BPF 对象。\n建议在启动前做最基本的环境检查：\n1 2 uname -r which bpftool 命令行与日志初始化 使用 CLI11 解析采样频率（-f）、PID 过滤（-p）、输出格式（-E）等选项，并使用 spdlog 配置默认日志器与日志等级。示例代码：\n1 2 3 4 5 6 7 8 9 10 11 CLI::App app{\u0026#34;A simple profiler using eBPF\u0026#34;}; Args args; app.add_option(\u0026#34;-f,--freq\u0026#34;, args.freq, \u0026#34;Sampling frequency\u0026#34;)-\u0026gt;default_val(10); app.add_flag(\u0026#34;-E,--fold-extend\u0026#34;, args.fold_extend, \u0026#34;Output in extended folded format\u0026#34;); CLI11_PARSE(app, argc, argv); using Level = spdlog::level::level_enum; Level level = (args.verbosity == 0) ? Level::warn : (args.verbosity==1 ? Level::info : Level::debug); auto console = spdlog::stdout_color_mt(\u0026#34;console\u0026#34;); spdlog::set_default_logger(console); spdlog::set_level(level); 将日志级别与命令行绑定，便于在调试时查看详细符号化错误或在生产时降低输出噪音。\n提升资源限制（RLIMIT_MEMLOCK） BPF 对象和 ring buffer 需要被内核锁定在内存。推荐将 RLIMIT_MEMLOCK 设为无限以避免加载失败：\n1 2 rlimit rl = {.rlim_cur = RLIM_INFINITY, .rlim_max = RLIM_INFINITY}; setrlimit(RLIMIT_MEMLOCK, \u0026amp;rl); 在受限环境下（容器、受限用户）应提前确认是否允许修改该限制。\n加载并管理 BPF 对象（ProfilerSkel） 通过 bpf 的 skeleton（例如 profiler_bpf）生成的封装，使用 RAII 管理生命周期：open/load 在构造时完成，资源在析构时释放。若加载失败，应立即记录错误并退出。\n1 2 3 4 5 6 7 8 9 struct ProfilerSkel { profiler_bpf* obj{nullptr}; ProfilerSkel() { obj = profiler_bpf::open_and_load(); if (obj == nullptr) spdlog::error(\u0026#34;Failed to open and load BPF object\u0026#34;); } ~ProfilerSkel() { if (obj) profiler_bpf__destroy(obj); } operator bool() const { return obj != nullptr; } auto operator-\u0026gt;() const -\u0026gt; profiler_bpf* { return obj; } }; 在加载成功后，还可能需要对 map 进行预初始化（例如设置 PID 过滤器）或为 symbolization 提供 vmlinux 路径。\n初始化 perf 事件并 attach 根据配置（采样频率、是否使用软件事件、是否按 PID 过滤等）创建 perf event 文件描述符并返回给调用方；随后将这些 fd attach 到 eBPF 程序的 entry（例如 profile）上。初始化失败时应当记录并退出。\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 52 53 54 // perf.cpp auto init_perf_monitor(uint64_t freq, bool sw_event, pid_t pid = -1) -\u0026gt; Result\u0026lt;std::vector\u0026lt;int\u0026gt;, libbpf_errno\u0026gt; { int cpus = libbpf_num_possible_cpus(); if (cpus \u0026lt; 0) { return Err(libbpf_errno::LIBBPF_ERRNO__INTERNAL); } perf_event_attr attr = { .type = sw_event ? PERF_TYPE_SOFTWARE : PERF_TYPE_HARDWARE, // 采样类型 .size = sizeof(perf_event_attr), .config = (sw_event ? static_cast\u0026lt;uint64_t\u0026gt;(PERF_COUNT_SW_CPU_CLOCK) : static_cast\u0026lt;uint64_t\u0026gt;(PERF_COUNT_HW_CPU_CYCLES)), .sample_freq = freq, // 设置频率，每秒 freq 次采样 .freq = 1, // 标志位：设为 1 表示使用 sample_freq }; std::vector\u0026lt;int\u0026gt; fds; for (int cpu = 0; cpu \u0026lt; cpus; ++cpu) { int fd = static_cast\u0026lt;int\u0026gt;(perf_event_open(\u0026amp;attr, pid, cpu, -1, 0)); if (fd \u0026lt; 0) { return Err(libbpf_errno::LIBBPF_ERRNO__INTERNAL); } fds.push_back(fd); } return fds; } auto attach_perf_events(const std::vector\u0026lt;int\u0026gt;\u0026amp; fds, bpf_program* prog) -\u0026gt; std::vector\u0026lt;Result\u0026lt;bpf_link*, libbpf_errno\u0026gt;\u0026gt; { std::vector\u0026lt;Result\u0026lt;bpf_link*, libbpf_errno\u0026gt;\u0026gt; links; for (int fd : fds) { auto* link = bpf_program__attach_perf_event(prog, fd); if (libbpf_get_error(link) != 0) { links.emplace_back(Err(libbpf_errno::LIBBPF_ERRNO__INTERNAL)); } links.emplace_back(link); } return links; } // --------------------------------------------------------------- // main.cpp auto perf_fds = init_perf_monitor(freq, args.sw_event, args.pid); if (!perf_fds) { spdlog::error(\u0026#34;Failed to initialize perf monitor\u0026#34;); return 1; } attach_perf_events(perf_fds.value(), obj-\u0026gt;progs.profile); attach 成功后，内核采样事件触发时会进入 eBPF 程序，程序会把数据写入之前声明的 ring buffer map。\nRing Buffer 与事件回调 用户态通过 ring buffer 从内核读取事件样本。回调函数负责类型安全地把原始字节传递给 EventHandler::handle，由其完成长度检查、解析与输出。\n1 2 3 4 5 6 7 8 9 10 static auto handle_event_wrapper(void* ctx, void* data, size_t data_sz) -\u0026gt; int { auto* handler = static_cast\u0026lt;EventHandler*\u0026gt;(ctx); return handler-\u0026gt;handle(static_cast\u0026lt;const uint8_t*\u0026gt;(data), data_sz); } RingBuffer rb{bpf_map__fd(obj-\u0026gt;maps.events), handle_event_wrapper, \u0026amp;event_handler, nullptr}; if (!rb) { spdlog::error(\u0026#34;Failed to create ring buffer\u0026#34;); return 1; } 要点：\n回调签名使用 const uint8_t* data, size_t data_sz，有利于在用户态对来自不同来源（ringbuf、perf buffer、socket 等）的原始字节流做统一处理和边界校验； ring buffer 的容量应根据采样频率与处理速率调整，避免丢失样本。 主循环、轮询与优雅退出 主循环负责轮询 ring buffer，并在接收到中断信号时优雅退出：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 static volatile bool exiting = false; static void sig_handler(int sig) { exiting = true; } ... signal(SIGINT, sig_handler); while (!exiting) { int err = rb.poll(100); if (err == -EINTR) { // Interrupted by signal, continue to check exiting flag continue; } if (err \u0026lt; 0) { spdlog::error(\u0026#34;Error polling ring buffer: {}\u0026#34;, err); break; } } auto r = close_perf_events(perf_fds.value()); if (!r) { spdlog::error(\u0026#34;Failed to close perf events, error message is: {}\u0026#34;, static_cast\u0026lt;int\u0026gt;(r.error())); return 1; } 退出流程要点：\nsignal handler 仅设置原子/易变标志，实际清理在主线程完成； 调用 close_perf_events 关闭 perf fds，确保內核计数器停止并释放资源； 依赖 RAII 的 BPF skeleton 在作用域结束时销毁 BPF 对象和 maps。 这样可以保证在收到 SIGINT / SIGTERM 时，程序能够尽量把已经到达的样本处理完并正确释放所有内核资源。\n对 eBPF 程序挂载方式的思考 为什么不用 bpf skeleton 中的 bpf_object__attach_skeleton 来挂载，而是使用 bpf_program__attach_perf_event ？ 在使用 libbpf 开发 BPF 程序时，通常 skeleton 会提供的自动 attach 机制 bpf_object__attach_skeleton，但在实际实现 perf 采样监控（例如 init_perf_monitor 这类逻辑）的过程中，最终选择的是手动创建 perf_event fd，并使用 bpf_program__attach_perf_event 进行挂载。这并不是因为 skeleton 不好，而是两者适用的层级和场景并不相同。\nskeleton attach 是什么，它解决了什么问题？ skeleton attach（本质是 bpf_object__attach_skeleton 或生成代码中的 xxx__attach()）是一种高层抽象：\n在 BPF ELF 中通过 section 描述程序的 attach 类型 libbpf 根据这些元信息，自动把所有 program 挂载到对应的 hook 点 用户态几乎不需要关心 attach 的细节 它非常适合下面这些场景：\nattach 类型是 静态的、编译期就确定的 比如 tracepoint、kprobe、raw_tp 等 希望快速把程序跑起来，而不是写大量样板代码 在这些情况下，skeleton attach 是几乎没有理由拒绝的。但当 attach 对象变成 perf event 时，情况就不太一样了。\nperf 场景下的不同 perf 事件并不是一个“天然存在的 hook 点”，而是需要用户态显式创建的：\n需要调用 perf_event_open 需要构造 perf_event_attr 需要决定： 是按 CPU 还是按 PID 采样频率 / period 是否 per-CPU 创建多个 fd 这些信息天然属于运行时配置，而不是 ELF 元信息的一部分。\n而 skeleton attach 的设计假设是：\nattach 行为已经在 BPF 程序中声明好，用户态只负责“一次性挂上去”。\n因而这里选择了另一条路径：\n在用户态显式创建 perf_event fd 将单个 bpf_program 附加到指定的 perf fd 上 也就是使用：\n1 bpf_program__attach_perf_event(prog, perf_fd); 这种方式带来的好处，主要体现在控制权完全回到用户态。\nprogram attach 挂载的好处 （1）attach 粒度更细\nskeleton attach： 以整个 skeleton 为单位 自动 attach 多个 program / link program attach： 明确地控制“哪个 program → 哪个 perf fd” 这在 perf 场景下非常重要，因为 fd 本身就是策略的一部分。\n（2）perf 参数可以在运行时自由配置\n通过手动创建 perf fd，可以在用户态精确控制：\n采样频率（freq / period） perf event 类型 绑定到哪个 CPU 或 PID 是否为每个 CPU 创建独立 fd 这些配置很难优雅地放进 skeleton 的自动 attach 流程中，但却是 perf 采样中最核心的部分。\n（3）天然支持 per-CPU / 多 fd 模型\n一个很典型的模式是：\n为每个 CPU 创建一个 perf fd 把同一个 BPF 程序附加到所有这些 fd 上 这在 program attach 模型下是顺理成章的事情：\n1 2 3 4 for_each_cpu(cpu) { perf_fd = perf_event_open(..., cpu, ...); link[cpu] = bpf_program__attach_perf_event(prog, perf_fd); } 而 skeleton attach 并不擅长表达这种 “一对多、运行时生成”的 attach 关系。\n（4）错误处理逻辑更清晰\n手动管理 perf fd 和 link，也意味着：\nperf_event_open 失败时可以： 跳过某些 CPU 降级采样策略 做重试或 fallback attach 失败时可以单独处理，而不是整体失败 对比小结 需要强调的一点是，底层其实并没有“谁更高级”：\n无论是 skeleton attach 还是 bpf_program__attach_perf_event，最终内核里做的事情本质上是一样的——建立 bpf_link，把程序绑定到事件上。\n对当前的 perf 采样监控场景来说：\n采样频率、CPU 绑定、fd 数量都是运行时决策 需要 per-CPU 创建 perf fd 需要清晰的错误处理和资源管理路径 因此在用户态创建 perf_event fd，并使用 bpf_program__attach_perf_event 逐个 attach，是更自然、也更可控的做法。\n参考 eBPF 入门实践教程十二：使用 eBPF 程序 profile 进行性能分析 eBPF 性能分析实战 - eBPF 在性能分析中的应用 Perf IPC 与 CPU 利用率 - perf 原理深入解析 perf_event_open 手册页 - 官方文档参考 FlameGraph 工具 - 火焰图生成工具 blazesym 符号化库 - 高性能符号化库 ","date":"2026-01-29T00:00:00Z","permalink":"/p/%E5%88%A9%E7%94%A8ebpf%E4%B8%8Eperf_event%E6%9E%84%E5%BB%BA%E4%B8%80%E4%B8%AAprofiling%E5%B7%A5%E5%85%B7/","title":"利用eBPF与perf_event构建一个profiling工具"},{"content":"首先在 layouts/partials 下添加 mermaid.html：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 \u0026lt;script type=\u0026#34;module\u0026#34;\u0026gt; import mermaid from \u0026#39;https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs\u0026#39;; mermaid.initialize({ startOnLoad: true }); \u0026lt;/script\u0026gt; \u0026lt;script\u0026gt; // Replace mermaid pre.code to div Array.from(document.getElementsByClassName(\u0026#34;language-mermaid\u0026#34;)).forEach( (el) =\u0026gt; { el.parentElement.outerHTML = `\u0026lt;div class=\u0026#34;mermaid\u0026#34;\u0026gt;${el.innerHTML}\u0026lt;/div\u0026gt;`; } ); \u0026lt;/script\u0026gt; \u0026lt;style\u0026gt; /* Set svg to center */ .mermaid svg { display: block; margin: auto; } \u0026lt;/style\u0026gt; 然后在 layouts/partials/_default 下的 single.html 中添加 mermaid 组件的引入：\n1 2 \u0026lt;!-- mermaid --\u0026gt; {{- partial \u0026#34;mermaid.html\u0026#34; . -}} 最后设置配置文件中对于语法高亮中的 guessSyntax 为 false：\n1 2 3 markup: highlight: gussSyntax: false 至此，对于在 markdown 中的 mermaid 代码就可以正确渲染了。\n","date":"2026-01-29T00:00:00Z","permalink":"/p/%E4%B8%BAhugo-papermod%E6%B7%BB%E5%8A%A0mermaid%E6%B8%B2%E6%9F%93/","title":"为Hugo PaperMod添加mermaid渲染"},{"content":"需求：在 Docker 环境下启用 IPv6，使基于 nginx 的容器服务可通过 IPv6 访问。\n为网络启用 IPv6 添加 enable_ipv6: true 并配置 ipam。\n设置子网前缀为 fd00:abcd::/64。\n1 2 3 4 5 6 7 8 networks: my-network: driver: bridge enable_ipv6: true ipam: driver: default config: - subnet: \u0026#34;fd00:abcd::/64\u0026#34; 在项目前端的 nginx 配置中，增加 IPv6 监听：\n1 2 listen 80; listen [::]:80 ipv6only=on; enable_ipv6: true + ipam.subnet：告诉 Docker 网络为容器分配 IPv6 地址并（可选）使用指定前缀。 nginx 增加 listen [::]:80：容器内 nginx 需要监听 IPv6 地址，否则即使容器有 IPv6 也无法响应 IPv6 请求。 配置 Docker Engine 必须在 Docker 引擎启用 IPv6，否则容器不会拿到 IPv6 地址。在 /etc/docker/daemon.json 中加入：\n1 2 3 4 { \u0026#34;ipv6\u0026#34;: true, \u0026#34;fixed-cidr-v6\u0026#34;: \u0026#34;fd00:abcd::/80\u0026#34; } 然后重启 docker：\n1 2 sudo systemctl daemon-reload sudo systemctl restart docker 重新启动容器与测试 重建并启动：\n1 2 docker compose down docker compose up -d --build 查看网络与容器 IPv6：\n1 2 docker network inspect 网络名 docker inspect -f \u0026#34;{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}}{{end}}\u0026#34; 容器名 访问（强制 IPv6）：\n1 curl -6 http://[容器_IPV6]/ ","date":"2026-01-28T00:00:00Z","permalink":"/p/%E5%9C%A8-docker-compose-%E4%B8%AD%E4%B8%BA%E9%A1%B9%E7%9B%AE%E5%90%AF%E7%94%A8-ipv6-%E5%B9%B6%E5%8F%AF%E9%80%9A%E8%BF%87-ipv6-%E5%8F%AF%E8%AE%BF%E9%97%AE/","title":"在 Docker Compose 中为项目启用 IPv6 并可通过 IPv6 可访问"},{"content":"epel-release 是一个 RPM 软件包，用于在基于 Red Hat 的 Linux 发行版（如 RHEL、CentOS、Rocky Linux、AlmaLinux 和 Oracle Linux）上启用 EPEL（Extra Packages for Enterprise Linux） 软件仓库。\nEPEL 是一个由 Fedora 项目维护的社区驱动的额外软件包仓库，提供不在标准 RHEL 或其衍生发行版基础仓库中的高质量开源软件包。\n通常安装方法如下：\n1 2 sudo dnf install epel-release -y sudo dnf makecache ","date":"2026-01-26T00:00:00Z","permalink":"/p/rockylinux%E5%90%AF%E7%94%A8epel%E8%BD%AF%E4%BB%B6%E4%BB%93%E5%BA%93/","title":"RockyLinux启用EPEL软件仓库"},{"content":"背景 最近根据 【eBPF 入门实践教程十二：使用 eBPF 程序 profile 进行性能分析】 这篇文章，写了一个性能分析的小工具。不过文章中的实现是 rust，但我还不怎么熟悉 rust，因而打算参考其代码使用 c++ 来实现相同的功能。项目地址：profiler。\nrust 中使用 cargo 就能非常方便的管理依赖与构建项目，c++ 中我使用 CMake 来构建项目，Conan 来管理依赖。在使用期间，我对于这二者的协同工作原理感到好奇，因此有了这篇笔记。\n文中的出现的“依赖”、“包”、“库”等名词都是指代同一个概念，只是不同的说法而已~\nCMake 查找依赖 在 CMake 中查找和管理项目依赖，主要是通过内置的 find_package 命令完成，其会在系统中寻找指定的库。\n1 find_package(\u0026lt;PackageName\u0026gt; [version] [REQUIRED] [COMPONENTS components...]) PackageName: 库的名称（区分大小写，如 OpenCV、Qt5）。 REQUIRED: 如果找不到该库，CMake 会直接报错并停止配置。 COMPONENTS: 查找库中的特定模块（例如 Qt5 里的 Widgets）。 CMake 有两种查找库的逻辑：\nModule 模式 (查找 Find\u0026lt;PackageName\u0026gt;.cmake)\nCMake 预置了一系列脚本（在 /usr/share/cmake/Modules 下）。它会寻找名为 FindGSL.cmake 这样的文件。\n适用场景: 较老或不直接支持 CMake 的库（如 CURL, ZLIB）。 Config 模式 (查找 \u0026lt;PackageName\u0026gt;Config.cmake 或 \u0026lt;lower-case-pkg\u0026gt;-config.cmake)\n这是现代库推荐的方式。库在安装时会自带一个配置文件。\n适用场景: OpenCV, Qt, Protobuf 等现代库。 查找路径: 库的安装路径、CMAKE_PREFIX_PATH 或环境变量。 CMAKE_PREFIX_PATH 当在 CMake 中使用 find_package、find_library 或 find_path 等指令去寻找一个外部库时，CMake 并不知道这个库安装在哪里。CMAKE_PREFIX_PATH 就是用来告诉 CMake 去这些目录下找找看。\n这里有三种设置的方法：\n命令行设置\n1 cmake -DCMAKE_PREFIX_PATH=\u0026#34;/path/to\u0026#34; CMakeLists.txt\n1 list(APPEND CMAKE_PREFIX_PATH \u0026#34;/path/to\u0026#34;) 环境变量\n1 export CMAKE_PREFIX_PATH=$CMAKE_PREFIX_PATH:/path/to Conan Generator 在一个比较简单的项目里，通常使用 conanfile.txt 来管理依赖即可，例如：\n1 2 3 4 5 6 7 8 9 10 [requires] cli11/2.6.0 spdlog/1.17.0 [generators] CMakeDeps CMakeToolchain [layout] cmake_layout 注意 [generators] 中的 CMakeDeps 和 CMakeToolchain，他们在 Conan 与 CMake 集成使用中起了至关重要的作用。\nCMakeDeps 当我们安装了 spdlog 这样的库后，需要 CMake 能看到它。CMakeDeps 就是这样一个依赖关系生成器，它会为每一个依赖库生成一个标准的 spdlog-config.cmake 文件。\n将 CMakeDeps 生成的 spdlog-config.cmake 目录位置加入 CMAKE_PREFIX_PATH 后，就可以在 CMakeLists.txt 中写 find_package(spdlog REQUIRED) 来查找依赖了。\nCMakeToolchain CMakeToolchain 负责将你在 Conan Profile（配置文件）中定义的设置（如 compiler=gcc, build_type=Release）注入给 CMake。同时，其生成 conan_toolchain.cmake，并自动设置 CMAKE_PREFIX_PATH。\n重要的一点就是：它会自动把 CMakeDeps 生成的配置文件目录加入搜索路径。\n当执行 conan install 后，这两个生成器会接力完成任务：\nConan 下载库到缓存。 CMakeDeps 生成 spdlog-config.cmake 等文件，描述如何链接这些库。 CMakeToolchain 生成 conan_toolchain.cmake，里面有一行代码告诉 CMake：“去 CMakeDeps 生成的那个文件夹里找包”。 例如我的项目里， ~/Workspace/profiler-cpp/build/Debug/generators/conan_toolchain.cmake 中的一部分如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 ########## \u0026#39;find_paths\u0026#39; block ############# # Define paths to find packages, programs, libraries, etc. if(EXISTS \u0026#34;${CMAKE_CURRENT_LIST_DIR}/conan_cmakedeps_paths.cmake\u0026#34;) message(STATUS \u0026#34;Conan toolchain: Including CMakeDeps generated conan_cmakedeps_paths.cmake\u0026#34;) include(\u0026#34;${CMAKE_CURRENT_LIST_DIR}/conan_cmakedeps_paths.cmake\u0026#34;) else() set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ON) # Definition of CMAKE_MODULE_PATH # the generators folder (where conan generates files, like this toolchain) list(PREPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_LIST_DIR}) # Definition of CMAKE_PREFIX_PATH, CMAKE_XXXXX_PATH # The Conan local \u0026#34;generators\u0026#34; folder, where this toolchain is saved. list(PREPEND CMAKE_PREFIX_PATH ${CMAKE_CURRENT_LIST_DIR} ) list(PREPEND CMAKE_LIBRARY_PATH \u0026#34;/home/kerolt/.conan2/p/b/spdlo964bc6492d317/p/lib\u0026#34; \u0026#34;/home/kerolt/.conan2/p/b/fmtc7b4bad61f66e/p/lib\u0026#34;) list(PREPEND CMAKE_INCLUDE_PATH \u0026#34;/home/kerolt/.conan2/p/cli115c29056d7e51f/p/include\u0026#34; \u0026#34;/home/kerolt/.conan2/p/b/spdlo964bc6492d317/p/include\u0026#34; \u0026#34;/home/kerolt/.conan2/p/b/fmtc7b4bad61f66e/p/include\u0026#34;) set(CONAN_RUNTIME_LIB_DIRS \u0026#34;/home/kerolt/.conan2/p/b/spdlo964bc6492d317/p/lib\u0026#34; \u0026#34;/home/kerolt/.conan2/p/b/fmtc7b4bad61f66e/p/lib\u0026#34; ) endif() CMake 工具链文件 工具链文件是一个以 .cmake 结尾的脚本，通过在配置阶段指定变量 CMAKE_TOOLCHAIN_FILE 来加载：\n1 cmake -DCMAKE_TOOLCHAIN_FILE=my_toolchain.cmake .. 它通常在 project() 指令执行 之前 被加载，用于初始化编译器路径、目标平台信息等关键底层变量。\n现在通过 Conan 的 CMakeDeps 和 CMakeToolchain，直接引入 Conan 自动生成的工具链文件，就能无感地使用下载的依赖了。\n例如直接在命令里使用：\n1 cmake -DCMAKE_TOOLCHAIN_FILE=/home/kerolt/Workspace/profiler-cpp/build/Debug/generators/conan_toolchain.cmake ... 或者通过 CMakePresets（CMake \u0026gt;= 3.19）来使用：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { \u0026#34;version\u0026#34;: 8, \u0026#34;configurePresets\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;debug\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Profiler Debug 构建\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;正在使用编译器: C = /usr/bin/gcc, CXX = /usr/bin/g++\u0026#34;, \u0026#34;binaryDir\u0026#34;: \u0026#34;${sourceDir}/build/Debug\u0026#34;, // 就是这个 toolchainFile ~ \u0026#34;toolchainFile\u0026#34;: \u0026#34;${sourceDir}/build/Debug/generators/conan_toolchain.cmake\u0026#34;, \u0026#34;cacheVariables\u0026#34;: { \u0026#34;CMAKE_BUILD_TYPE\u0026#34;: \u0026#34;Debug\u0026#34;, \u0026#34;CMAKE_CXX_STANDARD\u0026#34;: \u0026#34;23\u0026#34;, \u0026#34;CMAKE_EXPORT_COMPILE_COMMANDS\u0026#34;: \u0026#34;ON\u0026#34; } } ] } 总结 理解了 CMake 是如何查找依赖包后，二者的协同工作原理就明了了：Conan 通过自动生成的配置能告诉 CMake 在哪里找依赖包后，就能直接在 CMakeLists.txt 中使用 find_package 来查找对应的依赖了。\n","date":"2026-01-22T00:00:00Z","permalink":"/p/cmake%E4%B8%8Econan%E5%8D%8F%E5%90%8C%E5%B7%A5%E4%BD%9C%E7%90%86%E8%A7%A3/","title":"CMake与Conan协同工作理解"},{"content":"在 【eBPF 入门实践教程十二：使用 eBPF 程序 profile 进行性能分析】 这篇文章中， profiler 工具的实现是使用了 rust 编写的 blazesym 库，而我现在打算使用 C++ 实现，还好这个库提供了 C API，但是需要我们将这个 Rust 库的构建集成到 CMake 中。\nCorrosion 可将 Rust 集成到现有 CMake 项目中，它是一个 CMake 工具链，旨在让 CMake 像对待原生 C++ 子项目一样对待 Rust 的 Cargo 项目。Corrosion 将手动调用 cargo build，处理复杂的跨平台路径、构建配置（Debug/Release）以及繁琐的链接参数这些过程完全自动化了。\nCorrosion 的核心功能 Corrosion 的工作原理是在 CMake 层面为 Cargo 包装了一层“外壳”，其主要功能包括：\n自动处理构建类型：CMake 的 Release 或 Debug 模式会自动映射到 Cargo 的 --release 或默认模式。 目标导入：将 Rust 的 staticlib 或 cdylib 导入为标准的 CMake 目标（Targets），你可以直接对它们使用 target_link_libraries。 跨平台交叉编译：它能自动将 CMake 的交叉编译设置（如 Android, iOS, 嵌入式等）传递给 Rust 的目标三元组（Target Triple）。 多包支持：支持 Cargo 工作区（Workspaces）和单个 Crate。 项目结构 在我的项目 profiler 中使用 Corrosion 时，项目布局如下：\n1 2 3 4 5 6 profiler/ ├── CMakeLists.txt ├── src ├── third_party/ │ └── blazesym └── cmake/ # 存放 Corrosion 脚本 如何在 CMake 中使用 Corrosion （1）通过 FetchContent 自动下载并集成 1 2 3 4 5 6 7 8 include(FetchContent) FetchContent_Declare( Corrosion GIT_REPOSITORY https://github.com/corrosion-rs/corrosion.git GIT_TAG v0.6.0 ) FetchContent_MakeAvailable(Corrosion) （2）导入 Rust 项目 1 2 3 4 5 # 这里的路径指向包含 Cargo.toml 的目录 corrosion_import_crate( MANIFEST_PATH ${CMAKE_CURRENT_SOURCE_DIR}/third_party/blazesym/Cargo.toml NO_AUTO_EXE ) （3）配置静态库 在 blazesym 的 C API 构建时，文档中说明了需要链接的 C 库：\n1 -lrt -ldl -lpthread -lm 为了方便后面的构建目标使用 blazesym 库，我的做法是将其制作的静态库和头文件“打包”，方便构建目标通过 target_link_libraries 使用：\n1 2 3 4 5 6 7 8 9 10 11 12 13 find_package(Threads REQUIRED) find_library(LIB_RT rt) find_library(LIB_DL dl) find_library(LIB_M m) # 定义并配置静态库目标 add_library(blazesym STATIC IMPORTED GLOBAL) set_target_properties(blazesym PROPERTIES IMPORTED_LOCATION \u0026#34;${CMAKE_CURRENT_BINARY_DIR}/libblazesym_c.a\u0026#34; INTERFACE_INCLUDE_DIRECTORIES \u0026#34;${CMAKE_CURRENT_SOURCE_DIR}/third_party/blazesym/capi/include\u0026#34; INTERFACE_LINK_LIBRARIES \u0026#34;Threads::Threads;${LIB_RT};${LIB_DL};${LIB_M}\u0026#34; ) （4）使用静态库 可以将刚刚的三步的代码合并成一个 cmake 文件中放在项目根目录下的 cmake 目录中（例如我这里的 cmake/SetupBlazesym.cmake），然后在项目的 CMakelists.txt 中 通过 include 使用：\n1 2 3 4 5 include(\u0026#34;${CMAKE_CURRENT_SOURCE_DIR}/cmake/SetupBlazesym.cmake\u0026#34;) add_executable(profiler src/...) target_link_libraries(profiler PRIVATE blazesym) ","date":"2026-01-22T00:00:00Z","permalink":"/p/%E5%A6%82%E4%BD%95%E5%9C%A8cmake%E5%B7%A5%E7%A8%8B%E4%B8%AD%E4%BD%BF%E7%94%A8rust%E5%BA%93blazesym/","title":"如何在CMake工程中使用Rust库blazesym"},{"content":" 📝 注意！ 以下代码需要支持 C++23 的编译器\n这里利用 std::expected 和 std::unexpected 进行模仿：\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 52 53 54 55 56 57 58 #include \u0026lt;print\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;expected\u0026gt; template \u0026lt;typename E = std::string\u0026gt; using Err = std::unexpected\u0026lt;E\u0026gt;; template \u0026lt;typename T, typename E = std::string\u0026gt; using Result = std::expected\u0026lt;T, E\u0026gt;; struct Inner { int a, b; }; struct X { Inner* i_; X(Inner* i) { std::println(\u0026#34;X()\u0026#34;); i_ = i; } ~X() { std::println(\u0026#34;~X() delete inner obj\u0026#34;); delete i_; } }; auto foo(bool flag) -\u0026gt; Result\u0026lt;X\u0026gt; { if (!flag) { return Err\u0026lt;\u0026gt;(\u0026#34;Flag is false!\u0026#34;); } Inner* i = new Inner(); i-\u0026gt;a = 1; i-\u0026gt;b = 2; return Result\u0026lt;X\u0026gt;{i}; } int main() { std::println(\u0026#34;main--1\u0026#34;); { std::println(\u0026#34;main--2\u0026#34;); auto x = foo(true); std::println(\u0026#34;main--3\u0026#34;); } { std::println(\u0026#34;main--4\u0026#34;); auto x = foo(false); std::println(\u0026#34;Error message is: {}\u0026#34;, x.error()); std::println(\u0026#34;main--5\u0026#34;); } std::println(\u0026#34;main--6\u0026#34;); } 输出为：\n1 2 3 4 5 6 7 8 9 main--1 main--2 X() main--3 ~X() delete inner obj main--4 Error message is: Flag is false! main--5 main--6 ","date":"2026-01-20T00:00:00Z","permalink":"/p/c-%E5%AF%B9rust%E4%B8%ADresult%E7%9A%84%E7%AE%80%E5%8D%95%E6%A8%A1%E4%BB%BF/","title":"C++对Rust中Result的简单模仿"},{"content":"1、Windhawk 通过一些插件来改善体验。\n其中“Disable grouping on the taskbar”非常有用，可以让一个应用开多个窗口时任务栏图标不会堆叠，而是每个窗口一个图标。\n2、右键扩展 关闭右键扩展：\n1 2 reg add \u0026#34;HKCU\\Software\\Classes\\CLSID\\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\\InprocServer32\u0026#34; /f /ve taskkill /f /im explorer.exe \u0026amp; start explorer.exe 如果想要恢复右键扩展：\n1 2 reg delete \u0026#34;HKCU\\Software\\Classes\\CLSID\\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\u0026#34; /f taskkill /f /im explorer.exe \u0026amp; start explorer.exe 3、ContextMenuManager 链接：https://github.com/BluePointLilac/ContextMenuManager\n右键菜单内容管理工具。\n4、Dism++ Windows 系统清理、配置项调整。\n5、用户名调整为英文 简单来说就是重命名一个目录 + 更新一个对应的注册表项。\n具体看知乎的文章：https://zhuanlan.zhihu.com/p/509804656\n6、修改默认目录名为英文 系统路径内的目录不仅有图标还显示中文名称，但是打开路径的时候显示的却是英文，如何修改呢？\n（1）显示受保护的操作系统文件\n先要显示受保护的操作系统文件，设置完成之后将会看到 desktop.ini 文件（ desktop.ini 在每个目录内都有一个），如果没看到，就要勾选“查看”-\u0026gt;“显示”-\u0026gt;“隐藏的项目”。\n（2）编辑 desktop.ini\n如果要设置哪个目录的别名就找到哪个目录内的 desktop.ini 文件，并编辑下图红框中的内容：\n将 LocalizedResourceName 最后的数字修改为想要的目录名称，设置完成之后保存 刷新就 OK 了，设置完成注意要 隐藏受保护的操作系统文件。\n好像不同版本后面的数字不同，在修改前记得保存。\n7、用户目录下创建数据盘的目录软连接 这样做的目的是我个人想要让用户目录和 Linux 中的 home 目录比较一致，但软件、图片、文档我又不想保存在 C 盘，而是用软连接连接到数据盘（例如 D 盘）。\n操作很简单，就是使用 mklink 命令：\n1 mklink /D \u0026#34;C:\\Users\\\u0026lt;用户名\u0026gt;\\\u0026lt;目录名称\u0026gt;\u0026#34; \u0026#34;D:\\\u0026lt;目录名称\u0026gt;\u0026#34; 其中 /D 表示创建的是目录符号链接（软连接）。 8、无拓展名文件设置默认打开方式 管理员状态下运行 cmd，使用 assoc 查看无拓展的关联类型（assoc 用来关联后缀与文件类型）\n1 2 C:\\Users\\kerolt\u0026gt;assoc . 没有为扩展名 . 找到文件关联 定义无后缀文件对应的文件类型（此处为 No Extension，可以自定义），设置关联：\n1 2 C:\\Users\\kerolt\u0026gt;assoc .=No Extension .=No Extension 而 ftype 用来关联文件类型和打开其的应用程序：\n1 2 C:\\Users\\kerolt\u0026gt;ftype \u0026#34;No Extension\u0026#34;=\u0026#34;D:\\Application\\Notepad3\\Notepad3.exe\u0026#34; \u0026#34;%1\u0026#34; No Extension=\u0026#34;D:\\Application\\Notepad3\\Notepad3.exe\u0026#34;\u0026#34;%1 以上几步设置好后，无后缀文件的默认打开方式就变成了 Notepad3，并且默认图标也不再是一个丑丑的空白图标了，变成了 Notepad3 的相关图标。\n","date":"2026-01-06T00:00:00Z","permalink":"/p/%E4%B8%AA%E4%BA%BA%E8%A3%85%E6%9C%BA%E9%85%8D%E7%BD%AE/","title":"个人装机配置"},{"content":"Nginx 作为反向代理服务器，需要精确匹配客户端请求路径，并将请求转发到后端服务。在这个过程中：\nlocation 指令：决定哪些请求会被当前配置块处理 proxy_pass 指令：决定如何将请求转发到后端 而斜杠在这两个指令中的不同位置，会改变 Nginx 的匹配逻辑和路径传递行为，配置不当可能导致 404 错误、路径污染或安全风险。\n1、Location 配置：/api/vs /api 的核心差异 1.1 语法对比 配置 匹配模式 精确要求 示例匹配结果 location /api/ 前缀匹配（带斜杠） 必须以 /api/ 开头 /api/user✅ /api❌ /api-test❌ location /api 前缀匹配（无斜杠） 以 /api 开头即可 /api/user✅ /api✅ /api-test✅ 1.2 语义解释 /api/：表示一个 \u0026quot; 目录 \u0026quot; 路径，Nginx 会将其视为完整的路径段，只匹配以 /api/ 开头的请求。这种配置更精确，避免了意外匹配。\n/api：表示一个 \u0026quot; 前缀字符串 \u0026ldquo;，匹配所有以 /api 开头的请求，包括 /api 本身、/api/xxx 以及 /api-anything。这种配置更宽泛，可能产生误匹配。\n1.3 实际场景分析 场景 1：API 接口路由（推荐使用 /api/）\n1 2 3 4 5 6 7 8 9 10 11 # 推荐配置：精确匹配API目录 location /api/ { proxy_pass http://backend; # 只处理/api/下的所有请求，不会误匹配/api-test } # 不推荐：可能误匹配其他路径 location /api { # 会匹配/api、/api/、/api-test、/api-v2等 # 如果后端没有/api-test接口，可能返回404或错误数据 } 场景 2：静态资源目录（必须使用 /xxx/）\n1 2 3 4 5 6 7 8 9 10 11 # 静态文件目录，必须带斜杠 location /static/ { alias /path/to/static/; # 确保只匹配/static/目录下的文件 } # 错误配置：可能误匹配静态文件 location /static { # 会匹配/static、/static/、/static-file.txt # 如果存在/static-file.txt文件，可能被错误处理 } 1.4 优先级与冲突处理 当同时存在多个 location 时，Nginx 按最长前缀匹配原则。但需要注意：\nlocation /api/ 和 location /api 可能同时匹配 /api/user，此时 /api/ 优先级更高（更长匹配） 建议避免同时配置，以免产生预期外的匹配结果 2、Proxy_pass 配置：斜杠在 URL 末尾的影响 2.1 核心区别对比 配置方式 后端接收的路径 URI 传递行为 适用场景 proxy_pass http://127.0.0.1:8080 保留原始 URI 传递完整路径 常规代理，需要保留路径上下文 proxy_pass http://127.0.0.1:8080/ 去除 location 匹配部分 只传递 location 后的部分 路径重写，去除前缀 2.2 具体行为演示 假设配置：\n1 2 3 4 5 6 7 location /api/ { # 配置1：不带斜杠 proxy_pass http://127.0.0.1:8080; # 配置2：带斜杠 # proxy_pass http://127.0.0.1:8080/; } 请求路径映射结果：\n客户端请求 配置 1（不带/）→ 后端路径 配置 2（带/）→ 后端路径 /api/user /api/user /user /api/user/list /api/user/list /user/list /api/ /api/ / 关键规则：\nproxy_pass 末尾无斜杠：将 location 匹配的完整路径传递给后端 proxy_pass 末尾有斜杠：将 location 匹配部分去除，只传递剩余部分 2.3 实际应用场景 场景 1：后端服务需要完整路径（推荐配置 1）\n1 2 3 4 5 6 # 后端Spring Boot应用：@RequestMapping(\u0026#34;/api\u0026#34;) location /api/ { proxy_pass http://127.0.0.1:8080; # 请求/api/user → 后端收到/api/user # Controller的@GetMapping(\u0026#34;/user\u0026#34;)可以正常匹配 } 场景 2：后端服务无前缀（使用配置 2）\n1 2 3 4 5 6 # 后端服务直接处理/user路径 location /api/ { proxy_pass http://127.0.0.1:8080/; # 请求/api/user → 后端收到/user # 后端Controller：@GetMapping(\u0026#34;/user\u0026#34;)直接匹配 } 场景 3：路径重写场景\n1 2 3 4 5 # 将/static映射到后端的/static目录 location /static/ { proxy_pass http://127.0.0.1:8080/static/; # 请求/static/css/style.css → 后端收到/static/css/style.css } 2.4 常见误区 误区 1：认为 proxy_pass 的斜杠只是格式问题，实际效果相同\n误区 2：混淆 location 斜杠和 proxy_pass 斜杠的作用\nlocation /api/ 的斜杠：影响路径匹配规则 proxy_pass http://backend/ 的斜杠：影响 URI 传递行为 两者是独立的配置项 3、综合配置：Location 与 Proxy_pass 的协同工作 3.1 推荐的最佳实践组合 1 2 3 4 5 6 7 8 9 10 11 12 13 14 # 场景：API网关，精确匹配API路径并保留完整上下文 location /api/ { proxy_pass http://127.0.0.1:8080; # 匹配规则：只处理/api/开头的请求 # 路径传递：保留完整路径（/api/user → /api/user） # 后端服务可以正常处理带前缀的路径 } # 场景：静态资源代理 location /static/ { proxy_pass http://127.0.0.1:8080/static/; # 匹配规则：只处理/static/目录 # 路径传递：保留相对路径（/static/css/style.css → /static/css/style.css） } 3.2 错误配置示例 1 2 3 4 5 6 7 8 9 10 11 12 13 # 问题配置：可能产生路径污染 location /api { proxy_pass http://127.0.0.1:8080/; # 问题1：会匹配/api-test等非API路径 # 问题2：路径被重写（/api/user → /user） # 如果后端没有/user接口，返回404 } # 正确改进方案 location /api/ { proxy_pass http://127.0.0.1:8080; # 或根据后端需求选择是否带斜杠 } 3.3 调试与验证方法 方法 1：查看 Nginx 访问日志\n1 2 # 在nginx.conf中配置日志格式 log_format main \u0026#39;$remote_addr - $request - $upstream_addr\u0026#39;; 方法 2：后端服务日志查看\n查看后端应用日志，确认实际接收的请求路径\n方法 3：使用 curl 测试\n1 2 3 # 测试不同路径的响应 curl -v http://nginx-host/api/user curl -v http://nginx-host/api 方法 4：添加调试 header\n1 2 3 4 5 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header X-Original-URI $request_uri; # 在header中查看原始URI } 4、总结与最佳实践 4.1 核心要点总结 配置项 关键规则 推荐做法 location /api/vs /api /api/ 是目录匹配，更精确；/api 是前缀匹配，更宽泛 API 路由优先用 /api/，避免误匹配 proxy_pass 末尾斜杠 无斜杠保留完整路径，有斜杠去除前缀 根据后端需求选择，默认建议保留完整路径 组合使用 两者独立作用，需协同考虑 明确业务需求，测试验证路径映射 4.2 配置原则 明确性优先：配置应明确表达意图，避免模糊匹配 测试驱动：部署前用真实请求测试所有路径 文档注释：在配置文件中添加注释说明匹配逻辑和路径传递规则 团队约定：建立统一的配置规范，减少理解成本 4.3 常见问题速查 问题现象 可能原因 解决方案 访问 /api 返回 404 location /api/ 不匹配 /api 添加 location = /api 或重定向 /api-test 被错误代理 使用了 location /api 改为 location /api/ 后端收到错误路径 proxy_pass 斜杠配置错误 根据后端需求调整斜杠 路径污染 无斜杠前缀匹配过宽 使用带斜杠的精确匹配 写在最后 Nginx 配置中的斜杠看似简单，实则体现了“精确配置”的工程哲学。在实际项目中，建议：\n理解原理：不要死记硬背，理解每个斜杠的语义含义 小步验证：每次修改配置后，用真实请求验证效果 监控告警：配置日志监控，及时发现异常匹配 版本控制：将 Nginx 配置纳入版本管理，便于追溯和回滚 ","date":"2026-01-05T00:00:00Z","permalink":"/p/nginxlocation%E4%B8%8Eproxy_pass%E9%85%8D%E7%BD%AE%E4%B8%AD%E7%9A%84%E6%96%9C%E6%9D%A0%E5%B7%AE%E5%BC%82/","title":"【Nginx】location与proxy_pass配置中的斜杠差异"},{"content":" 1 2 3 4 5 6 7 8 9 10 $ uv add numpy pandas torch scikit-learn matplotlib Resolved 45 packages in 21ms × Failed to download `nvidia-cufft-cu12==11.3.3.83` ├─▶ Failed to fetch: │ `https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl` ├─▶ Request failed after 3 retries ├─▶ error sending request for url │ (https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl) ╰─▶ operation timed out help: If you want to add the package regardless of the failed resolution, provide the `--frozen` flag to skip locking and syncing. 这个错误是因为 uv 在尝试从官方源下载 NVIDIA 的 CUDA 组件包（nvidia-cufft-cu12）时网络超时了。\n这些 NVIDIA 的包通常非常大（几百 MB），如果网络环境不够快或者连接 PyPI 官方源不稳定（在国内很常见），就会导致下载失败。\n可以通过设置环境变量 UV_INDEX_URL 来让 uv 使用清华大学或阿里云的镜像源：\nLinux / macOS:\n1 UV_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple uv add numpy pandas torch scikit-learn matplotlib Windows (PowerShell):\n1 2 $env:UV_INDEX_URL=\u0026#34;https://pypi.tuna.tsinghua.edu.cn/simple\u0026#34; uv add numpy pandas torch scikit-learn matplotlib ","date":"2025-12-12T00:00:00Z","permalink":"/p/uv%E5%8C%85%E5%AE%89%E8%A3%85%E7%BD%91%E7%BB%9C%E8%B6%85%E6%97%B6%E8%A7%A3%E5%86%B3/","title":"uv包安装网络超时解决"},{"content":"首先确定网络是用什么工具管理：\n1 2 $ systemctl is-active NetworkManager active 说明 网络由 NetworkManager 管理，这种情况修改 IP 通常使用 nmcli：\n修改静态 IP 看当前连接名 1 nmcli connection show 输出类似：\n1 2 NAME UUID TYPE DEVICE Wired connection 1 2d3e1f6a-4e87-4a76-8f23-2f459c8c112b ethernet enp60s0 这里的 NAME（如 Wired connection 1）就是连接名，后面命令要用到。\n修改为静态 IP 假设你想把 IP 设置为：\nIP：192.168.1.100/24 网关：192.168.1.1 DNS：8.8.8.8, 1.1.1.1 执行：\n1 2 3 4 sudo nmcli con mod \u0026#34;Wired connection 1\u0026#34; ipv4.addresses 192.168.1.100/24 sudo nmcli con mod \u0026#34;Wired connection 1\u0026#34; ipv4.gateway 192.168.1.1 sudo nmcli con mod \u0026#34;Wired connection 1\u0026#34; ipv4.dns \u0026#34;8.8.8.8,1.1.1.1\u0026#34; sudo nmcli con mod \u0026#34;Wired connection 1\u0026#34; ipv4.method manual 注意 \u0026quot;Wired connection 1\u0026quot; 需要替换为你自己的连接名。 如果名字里有空格，必须加引号。\n应用修改（立即生效） 1 2 sudo nmcli con down \u0026#34;Wired connection 1\u0026#34; sudo nmcli con up \u0026#34;Wired connection 1\u0026#34; 验证 1 2 ip addr show enp60s0 ip route 你应该能看到：\n1 2 inet 192.168.1.100/24 ... default via 192.168.1.1 ... 改回 DHCP 自动获取 如果想恢复为 DHCP：\n1 2 sudo nmcli con mod \u0026#34;Wired connection 1\u0026#34; ipv4.method auto sudo nmcli con up \u0026#34;Wired connection 1\u0026#34; DHCP 重新获取 IP 检查是否是固定分配 IP 1 2 3 4 sudo nmcli device disconnect enp60s0 sudo dhclient -r enp60s0 # 释放旧租约（告诉 DHCP 我不要这个 IP 了） sudo pkill dhclient # 确保没有旧 dhclient 残留 sudo nmcli device connect enp60s0 # 重新请求新 IP 如果上面的操作无法获得新的 IP，那就说明 DHCP 服务器端 是按 MAC 地址固定分配。\n获取原本的 MAC 地址 可以临时更改 MAC 来解决，首先获取一下原本的 MAC：\n1 2 3 4 ip link show enp60s0 # 或者 nmcli device show enp60s0 | grep -i hwaddr 修改临时 MAC 修改为临时 MAC：\n1 2 3 sudo nmcli connection modify \u0026#34;Wired connection 1\u0026#34; 802-3-ethernet.cloned-mac-address random sudo nmcli connection down \u0026#34;Wired connection 1\u0026#34; sudo nmcli connection up \u0026#34;Wired connection 1\u0026#34; 这会让 DHCP 服务器以为是一个新设备，从而分配新 IP。 完成后可以恢复原 MAC：\n1 sudo nmcli connection modify \u0026#34;Wired connection 1\u0026#34; 802-3-ethernet.cloned-mac-address \u0026#34;YOUR_MAC\u0026#34; 实测可以获取到新的 IP。\n","date":"2025-11-09T00:00:00Z","permalink":"/p/linux%E4%BF%AE%E6%94%B9ip/","title":"Linux修改IP"},{"content":"容器网络最典型的基础模型 —— 两个不同的命名空间通过 bridge 通信 。\nDocker 默认的 bridge 模式就是基于此原理,创建 docker0 bridge，容器 veth 对一端连接容器，一端连接 docker0，提供 NAT 功能让容器可以访问外网。\n一个简单的结构如下👇：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 +---------------------+ | root NS | (宿主机 / default netns) | bridge: br0(10.0.0.254/24) | | | veth0 ─────────┐ | \u0026lt;- veth0 在 root，master=br0 | │ | | veth2 ─────────┴──┘ \u0026lt;- veth2 在 root，master=br0 +---------------------+ │ ┌─────────────────┴─────────────────┐ │ │ veth0 \u0026lt;-\u0026gt; veth1 veth2 \u0026lt;-\u0026gt; veth3 (root, peer) (in ns1) (root, peer) (in ns2) ns1 namespace ns2 namespace ┌───────────────┐ ┌───────────────┐ │ veth1 │ │ veth3 │ │ 10.0.0.1/24 │ │ 10.0.0.2/24 │ └───────────────┘ └───────────────┘ 📝 veth (Virtual Ethernet Device) 虚拟以太网设备，总是成对出现，类似管道两端\n一端发送的数据会在另一端接收到 主要用途：连接不同网络命名空间，实现网络隔离环境间的通信 📝 bridge 虚拟交换机，工作在数据链路层（二层） 可以连接多个网络接口，在它们之间转发数据帧 Linux bridge 支持 STP、VLAN 等交换机构造特性 📝 网络命名空间 (netns) 提供完全隔离的网络栈，包括接口、路由表、iptables 规则等 每个容器通常运行在独立的网络命名空间中 实现步骤如下：\n（1）创建两个命名空间\n1 2 ip netns add ns1 ip netns add ns2 （2）创建两对 veth\n每个命名空间各分配一个端口。\n1 2 ip link add veth0 type veth peer name veth1 ip link add veth2 type veth peer name veth3 现在有：\n1 2 veth0 \u0026lt;-\u0026gt; veth1 veth2 \u0026lt;-\u0026gt; veth3 （3）veth1 和 veth3 移入命名空间\n1 2 ip link set veth1 netns ns1 ip link set veth3 netns ns2 （4）创建 bridge（虚拟交换机）\n1 2 ip link add name br0 type bridge ip link set br0 up （5）把宿主机侧的 veth（veth0、veth2）加入 bridge\n1 2 3 4 ip link set veth0 master br0 ip link set veth2 master br0 ip link set veth0 up ip link set veth2 up （6）配置命名空间内部 IP\nns1:\n1 2 ip netns exec ns1 ip addr add 10.0.0.1/24 dev veth1 ip netns exec ns1 ip link set veth1 up ns2:\n1 2 ip netns exec ns2 ip addr add 10.0.0.2/24 dev veth3 ip netns exec ns2 ip link set veth3 up 测试一下连通性：\n1 ip netns exec ns1 ping 10.0.0.2 如果一切正常，应该能 ping 通，因为这两个 namespace 的 \u0026quot; 网线 \u0026quot; 都插在同一个虚拟交换机 br0 上。\n（7）让宿主机也能参与通信\n给宿主机也分配 ip 在同一个网段：\n1 ip addr add 10.0.0.254/24 dev br0 然后：\n1 ip netns exec ns1 ping 10.0.0.254 也能通。这样，通过 veth pair + bridge + netns，可以模拟 Docker 那样是如何搭建一个多容器虚拟局域网。\n","date":"2025-11-08T00:00:00Z","permalink":"/p/linux%E4%B8%AD%E7%9A%84veth%E4%B8%8Ebridge/","title":"Linux中的veth与bridge"},{"content":"服务器是用旧笔记本装的 Debian13，然后用 ddns-go 做动态域名解析，但是装好之后貌似由于时钟没有校准因此 ddns-go 无法更新域名解析（使用的 aliyun），以下是解决方法。\n首先我使用 timedatectl 的结果如下：\n1 2 3 4 5 6 7 8 9 ~ $ timedatectl Local time: 六 2025-11-08 00:20:49 CST Universal time: 五 2025-11-07 16:20:49 UTC RTC time: 五 2025-11-07 16:20:49 Time zone: Asia/Shanghai (CST, +0800) System clock synchronized: no NTP service: n/a RTC in local TZ: no 使用 systemd-timesyncd 可以让系统自动校时：\n1 sudo timedatectl set-ntp true 如果显示：\n1 Failed to set ntp: NTP not supported 说明当前系统里没有可用的 NTP 同步服务（比如 systemd-timesyncd、chronyd 或 ntpd）被启用或支持，可以执行：\n1 2 sudo apt install systemd-timesyncd sudo systemctl enable --now systemd-timesyncd.service 之后再执行 timedatectl 可以看到：\n1 2 System clock synchronized: yes NTP service: active 这样就 ok 了。\n","date":"2025-11-07T00:00:00Z","permalink":"/p/linux%E6%A0%A1%E5%87%86%E6%97%B6%E9%97%B4/","title":"Linux校准时间"},{"content":"一、什么是“用户态”和“内核态” CPU 有不同的 特权级（Privilege Level）：\n用户态（User Mode）： 应用程序在这里运行，权限受限，比如不能直接访问硬件、不能修改页表等。\n内核态（Kernel Mode）： 操作系统内核运行在这里，拥有完全的访问权限，可以管理内存、设备、中断等。\n二、什么是“上下文切换（Context Switch）” “上下文”就是 CPU 当前正在执行的任务的所有状态，包括：\n寄存器内容（RIP、RSP、RAX 等） 程序计数器（Program Counter） 栈指针 内存映射（页表） 调度信息（优先级、时间片等） 上下文切换指的是 CPU 从一个执行上下文切换到另一个（比如进程 A → 进程 B）。\n三、内核态与用户态切换 ≠ 进程切换，但都属于“上下文切换” 这两种是不同层次的“切换”：\n类型 示例 是否涉及调度 开销大小 备注 用户态 → 内核态 系统调用、I/O、中断 否 小（几十到几百纳秒） 同一线程，只是 CPU 特权级变化 进程上下文切换 从进程 A → 进程 B 是 大（微秒级） 不仅换栈，还要换虚拟内存上下文 四、为什么内核/用户态切换有“开销” 内核态切换的代价来自几个部分：\n1. CPU 特权级变化 切换时 CPU 会：\n保存当前寄存器状态； 改变特权级（从 ring3 → ring0）； 切换到内核栈（每个线程有独立内核栈）； 执行系统调用入口代码（syscall、sysenter 指令）； 执行完后再反向恢复回用户态。 这些过程虽然不是“线程切换”，但都需要 CPU 做额外操作。\n2. 缓存污染（Cache/TLB flush） 在切换时，可能触发：\n指令缓存（I-Cache）和数据缓存（D-Cache）失效 TLB（页表缓存）失效 这会让下一次访问内存时性能下降。尤其是跨页表切换（进程切换）时，TLB 必须刷新。\n3. 管线冲刷（Pipeline Flush） 现代 CPU 使用深流水线和乱序执行，切换到内核态后，这些指令流需要被中断、清空、重新加载，浪费了几十个周期。\n4. 安全隔离检查（比如 KPTI） 在 Spectre/Meltdown 漏洞后，Linux 内核加了 KPTI（Kernel Page Table Isolation），在用户态和内核态之间切换时需要切换页表来隔离内核地址空间，进一步增加了 TLB flush 和页表切换开销。\n五、开销有多大？ 大致数量级（不同架构差异很大）：\n操作 典型开销 一次函数调用 1~5 ns 一次系统调用（空） 100~500 ns 一次进程上下文切换 1~5 µs 一次磁盘 I/O 系统调用 1 ms 以上 比如：\n1 2 $ strace -c ls # 可以看到每个系统调用耗时几十到几百纳秒 六、举个例子：read() 系统调用 当用户调用：\n1 read(fd, buf, size); 实际发生的事：\n程序在 用户态 调用 read； CPU 执行 syscall 指令，切到 内核态； 内核检查参数、文件描述符合法性； 调用文件系统层 → 设备驱动； 如果是磁盘 I/O，线程可能被阻塞，调度其他任务； 数据准备好后再切回用户态。 这整个过程涉及 多次用户态 ↔ 内核态切换 + 潜在调度切换。\n七、如何减少这种开销 性能优化中，常见的做法是减少切换频率：\n技术 思路 批处理系统调用 一次调用处理多个请求（如 readv, writev） 零拷贝 I/O 减少数据在内核与用户空间之间的复制 IO_uring / eBPF / XDP 通过内核接口减少 syscalls 次数或在内核内直接处理 epoll / io_uring 异步 I/O，减少阻塞导致的频繁切换 用户态网络栈（DPDK） 完全绕过内核态，直接用户态驱动网卡 ","date":"2025-11-06T00:00:00Z","permalink":"/p/%E5%A6%82%E4%BD%95%E7%90%86%E8%A7%A3%E5%86%85%E6%A0%B8%E6%80%81%E4%B8%8E%E7%94%A8%E6%88%B7%E6%80%81%E5%88%87%E6%8D%A2%E7%9A%84%E4%B8%8A%E4%B8%8B%E6%96%87%E5%BC%80%E9%94%80/","title":"如何理解内核态与用户态切换的上下文开销"},{"content":"在开发一个 CNI（Container Network Interface）插件 时，一般需要定义几个关键配置文件，它们共同决定插件的行为、网络配置方式、以及与 Kubernetes 的集成。\n一、CNI 网络配置文件 位置：\n通常位于 /etc/cni/net.d/ 目录下（可以通过 kubelet 参数 --cni-conf-dir 修改）。\n文件名：\n一般以 .conf、.conflist 结尾，例如：\n1 2 /etc/cni/net.d/10-myplugin.conf /etc/cni/net.d/10-myplugin.conflist 内容：\nJSON 格式，描述网络的类型、IPAM 配置、路由等。\n示例：10-simplecni.conf\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 { \u0026#34;cniVersion\u0026#34;: \u0026#34;0.4.0\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;simple-network\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;simple-cni\u0026#34;, \u0026#34;ipam\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;host-local\u0026#34;, \u0026#34;subnet\u0026#34;: \u0026#34;10.10.0.0/16\u0026#34;, \u0026#34;rangeStart\u0026#34;: \u0026#34;10.10.0.10\u0026#34;, \u0026#34;rangeEnd\u0026#34;: \u0026#34;10.10.255.254\u0026#34;, \u0026#34;routes\u0026#34;: [ { \u0026#34;dst\u0026#34;: \u0026#34;0.0.0.0/0\u0026#34; } ], \u0026#34;gateway\u0026#34;: \u0026#34;10.10.0.1\u0026#34; } } 其中：\ntype：对应你编译出的插件二进制名，比如 /opt/cni/bin/simple-cni。 ipam：IP 地址分配方式。可以是 host-local、dhcp 或你自己实现的插件。 .conflist 文件可以包含多个 CNI 插件链，用于组合执行（比如 Flannel + Portmap）。 二、CNI 插件的内部配置文件（插件自定义） 除了标准 CNI 配置外，大多数插件都会定义自己独立的配置文件目录，用来保存运行时信息或持久化状态。\n常见位置：\n1 /var/lib/simple-cni/ 常用来存储分配的 IP、子网、网卡信息等。例如：\n1 2 /var/lib/simple-cni/subnets.json /var/lib/simple-cni/allocations.json 示例：/var/lib/simple-cni/subnets.json\n1 2 3 4 5 6 7 8 9 { \u0026#34;subnets\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;default\u0026#34;, \u0026#34;range\u0026#34;: \u0026#34;10.10.0.0/16\u0026#34;, \u0026#34;allocated\u0026#34;: [\u0026#34;10.10.0.10\u0026#34;, \u0026#34;10.10.0.11\u0026#34;] } ] } 这个文件在 Flannel、Calico、Canal 等插件中都有类似形式（比如 /run/flannel/subnet.env）。\n三、Kubernetes 集成相关配置文件 3.1 CNI 插件二进制路径 路径：\n1 /opt/cni/bin/ kubelet 启动参数 --cni-bin-dir 控制路径。 例如：\n1 2 3 /opt/cni/bin/simple-cni /opt/cni/bin/host-local /opt/cni/bin/loopback 3.2 Kubernetes 节点配置（可选） 某些插件（如 Flannel、Calico）会依赖 Kubernetes 中的 ConfigMap 来传递配置，比如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 apiVersion: v1 kind: ConfigMap metadata: name: simple-cni-config namespace: kube-system data: cni-conf.json: | { \u0026#34;name\u0026#34;: \u0026#34;simple-network\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;simple-cni\u0026#34;, \u0026#34;ipam\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;simple-ipam\u0026#34;, \u0026#34;subnet\u0026#34;: \u0026#34;10.10.0.0/16\u0026#34; } } 插件的安装 DaemonSet 会在容器启动时将该配置写入 /etc/cni/net.d/。\n总结 类型 文件路径 作用 由谁提供/管理 CNI 网络配置 /etc/cni/net.d/*.conf 定义 CNI 网络行为、插件类型 管理员 / DaemonSet 插件二进制 /opt/cni/bin/* 可执行插件程序 插件安装程序 状态文件 /var/lib/\u0026lt;plugin\u0026gt;/... 存储分配信息、缓存 插件自身 动态配置（可选） /run/\u0026lt;plugin\u0026gt;/... 运行时信息（如 Socket、Lock） 插件自身 K8s ConfigMap（可选） - 集群范围配置同步 插件控制器 / DaemonSet 几个配置文件为什么要放不同的目录下：\n启动阶段（配置加载）\n插件从 /etc/cni/net.d/ 读取网络配置（例如网段、路由方式等）。\n运行阶段（状态生成）\n插件运行时，会在 /run/simple-cni/ 写入当前节点或进程级的运行时状态，如：\n当前分配的子网（subnets.json）； 其他节点同步信息。 这些数据是临时的，重启后可重新计算。子网并不是一成不变的，可能节点重启后或者网络分配时会改变，所以不应该放在 /var/lib/ 下持久化。\n分配阶段（状态持久化）\n插件每当分配一个 IP，会在 /var/lib/cni/networks/\u0026lt;netname\u0026gt;/ 中持久化：\n哪个容器用了哪个 IP； 最后一个分配的地址（用于下次递增）。 这些数据必须持久化，否则重启后会出现 IP 重复分配冲突。\n","date":"2025-11-04T00:00:00Z","permalink":"/p/%E5%9C%A8%E5%BC%80%E5%8F%91%E4%B8%80%E4%B8%AAcni%E6%8F%92%E4%BB%B6%E6%97%B6%E9%9C%80%E8%A6%81%E5%AE%9A%E4%B9%89%E7%9A%84%E5%87%A0%E4%B8%AA%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6/","title":"在开发一个CNI插件时需要定义的几个配置文件"},{"content":" Kubernetes 的设计理念是：\n用单一职责的组件，通过对象组合的方式，构建出复杂且可扩展的分布式系统。\n什么是“单一职责”和“对象组合”？ 这两个概念来自软件设计原则（尤其是微服务与面向对象设计），Kubernetes 只是将它们贯彻得非常彻底：\n概念 含义 在 K8s 中的体现 单一职责（Single Responsibility） 每个组件、对象、控制器只做一件事情，并把它做到极致 API Server 只提供 API；Scheduler 只负责调度；Controller 只负责某个资源的控制循环 对象组合（Object Composition） 复杂功能通过多个简单对象的组合实现，而不是通过单个复杂对象 Pod 由多个 Container 组成；Deployment 由多个 ReplicaSet 组成；Service 与 Pod 组合形成负载均衡能力 Kubernetes 的单一职责原则 K8s 的每个核心组件都有明确且边界清晰的职责：\n组件 职责 kube-apiserver 提供 REST API，是所有操作的入口，不做任何业务逻辑，只负责一致性与认证授权 kube-scheduler 只负责选择 Pod 要运行在哪个 Node 上 kube-controller-manager 负责运行各种控制循环（ReplicaSetController、NodeController、ServiceController 等），每个控制器专注一种资源类型 kubelet 只负责本节点 Pod 的生命周期管理 etcd 只负责存储状态，不参与逻辑 👉 这就像一个分布式的微内核系统——每个模块专注于自己的“单一职责”，彼此通过 API 协作。\n对象组合的体现（K8s 的“积木哲学”） Kubernetes 的对象设计体现出非常强的组合思想（composition over inheritance）：\nPod 是容器的组合\n一个 Pod 中可以组合多个容器（共享网络、存储命名空间）。 比如一个主容器 + 一个 sidecar 容器（如日志收集、代理）。 Pod 本身不关心容器内部细节，只定义组合关系。 高层对象是底层对象的组合\nReplicaSet 组合多个 Pod，保证副本数； Deployment 组合 ReplicaSet，实现滚动升级； Service 组合 Pod 提供稳定访问入口； Ingress 组合多个 Service 提供统一的 HTTP 入口。 这种“对象套对象”的层级组合，让系统具有高度的模块化与可替换性。\n控制循环（Controller Loop）是组合逻辑的胶水 控制器是 Kubernetes 实现组合逻辑的“胶水层”：\n控制器通过 Watch API 观察对象状态； 检查“期望状态”（spec）与“实际状态”（status）； 通过创建/删除其他对象来组合出目标效果。 例如：\nDeployment Controller 不直接操作容器； 它只是创建 ReplicaSet → ReplicaSet 再创建 Pod → Pod 由 kubelet 管理； 各层各司其职，却形成一个完整的生命周期管理流程。 这就是组合带来的解耦与扩展性：你可以替换其中任意层（例如自定义控制器），而不破坏整体系统。\n这种设计的优势：\n特性 原因 高扩展性 新对象和控制器可以独立增加，不破坏现有体系 高可维护性 每个对象逻辑简单，易于调试和替换 强复用性 通用对象（Pod、ConfigMap）可以在不同系统中复用 一致性与声明式管理 所有对象都遵循相同的 API 模式（spec/status） ","date":"2025-11-01T00:00:00Z","permalink":"/p/k8s%E4%B8%AD%E7%9A%84%E5%8D%95%E4%B8%80%E8%81%8C%E8%B4%A3%E5%92%8C%E5%AF%B9%E8%B1%A1%E7%BB%84%E5%90%88/","title":"K8s中的单一职责和对象组合"},{"content":" 环境：在 VMWare 上，启动了两个 Ubuntu 24.04.3 LTS，配置都为 2C2G。一台作为 master，一台作为 worker，需要为两台机器设置不同的 hostname。\nPrepare 安装好 Docker 后，设置一下 cgroup 的驱动：\n1 2 3 4 5 6 7 8 cat \u0026lt;\u0026lt;EOF | sudo tee /etc/docker/daemon.json { \u0026#34;exec-opts\u0026#34;: [\u0026#34;native.cgroupdriver=systemd\u0026#34;], \u0026#34;log-driver\u0026#34;: \u0026#34;json-file\u0026#34;, \u0026#34;log-opts\u0026#34;: { \u0026#34;max-size\u0026#34;: \u0026#34;100m\u0026#34; }, \u0026#34;storage-driver\u0026#34;: \u0026#34;overlay2\u0026#34; } EOF 然后重启 docker：\n1 2 3 sudo systemctl enable docker sudo systemctl daemon-reload sudo systemctl restart docker 为了让 Kubernetes 能够检查、转发网络流量，需要修改 iptables 的配置,启 用 br_netfilter 模块:\n1 2 3 4 5 6 7 8 9 10 11 cat \u0026lt;\u0026lt;EOF | sudo tee /etc/modules-load.d/k8s.conf br_netfilter EOF cat \u0026lt;\u0026lt;EOF | sudo tee /etc/sysctl.d/k8s.conf net.bridge.bridge-nf-call-ip6tables = 1 net.bridge.bridge-nf-call-iptables = 1 net.ipv4.ip_forward=1 # better than modify /etc/sysctl.conf EOF sudo sysctl --system 修改“/etc/fstab”，关闭 Linux 的 swap 分区，提升 Kubernetes 的性能：\n1 2 sudo swapoff -a sudo sed -ri \u0026#39;/\\sswap\\s/s/^#?/#/\u0026#39; /etc/fstab 安装 kubeadm 1 2 3 4 5 6 7 8 sudo apt install -y apt-transport-https ca-certificates curl curl https://mirrors.aliyun.com/kubernetes/apt/doc/apt-key.gpg | sudo apt-key add cat \u0026lt;\u0026lt;EOF | sudo tee /etc/apt/sources.list.d/kubernetes.list deb https://mirrors.aliyun.com/kubernetes/apt/ kubernetes-xenial main EOF sudo apt update 然后安装 kubeadm、kubelet、kubectl：\n1 2 3 4 sudo apt install -y kubeadm=1.23.3-00 kubelet=1.23.3-00 kubectl=1.23.3-00 # 最好使用 apt-mark hold 锁定版本 sudo apt-mark hold kubeadm kubelet kubectl kubeadm 把 apiserver、etcd、scheduler 等组件都打包成了镜像,以容器的方式启动 Kubernetes。使用命令 kubeadm config images list 可以查看安装 Kubernetes 所需的镜像列表,参数 --kubernetes-version 可以指定版本号:\n1 kubeadm config images list --kubernetes-version v1.23.3 最好先想办法把列出的镜像下到本地。\n安装 Master 节点 kubeadm 安装 master 节点非常简单，只需要指定一下几个参数：\n--pod-network-cidr：设置集群里 Pod 的 IP 地址段 --apiserver-advertise-address：设置 apiserver 的 IP 地址，可以指定 apiserver 在哪个网卡上对外提供服务 --kubernetes-version：指定 Kubernetes 的版本号 1 2 3 4 sudo kubeadm init \\ --pod-network-cidr=10.10.0.0/16 \\ --apiserver-advertise-address=192.168.10.210 \\ --kubernetes-version=v1.23.3 完成后，它会提示接下来要干什么：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Your Kubernetes control-plane has initialized successfully! To start using your cluster, you need to run the following as a regular user: mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config Alternatively, if you are the root user, you can run: export KUBECONFIG=/etc/kubernetes/admin.conf You should now deploy a pod network to the cluster. Run \u0026#34;kubectl apply -f [podnetwork].yaml\u0026#34; with one of the options listed at: https://kubernetes.io/docs/concepts/cluster-administration/addons/ Then you can join any number of worker nodes by running the following on each as root: kubeadm join 192.168.116.129:6443 --token e7m92u.o3namkgyg9f5n3qw \\ --discovery-token-ca-cert-hash sha256:8d15d3ef5536d14706b8efba99b760212a61eccb3a18c179c307045bcea4e950 首先拷⻉ kubectl 的配置文件：\n1 2 3 mkdir -p $HOME/.kube sudo cp -i /etc/kubernetes/admin.conf $HOME/.kube/config sudo chown $(id -u):$(id -g) $HOME/.kube/config 完成后，运行 kubectl get node 会发现 master 节点的 STATUS 为 NotReady，这是因为我们还没有添加网络插件，集群的内部网络还没有正常运作。\n安装 Fannel 网络插件 保存 https://github.com/flannel-io/flannel/blob/master/Documentation/kube-flannel.yml ，然后运行修改其中的 net-conf.json 字段把 Network 改成之前参数 --pod-network-cidr 设置的地址段：\n1 2 3 4 5 6 7 net-conf.json: | { \u0026#34;Network\u0026#34;: \u0026#34;10.10.0.0/16\u0026#34;, \u0026#34;Backend\u0026#34;: { \u0026#34;Type\u0026#34;: \u0026#34;vxlan\u0026#34; } } 然后\n1 kubectl apply -f kube-flannel.yml 稍等一会儿 master 节点的状态就变为了 Ready。\nFlannel 所处的命名空间在 kube-flannel 中，可以通过 kubectl get pods -n kube-flannel 查看插件是否运行成功\n安装 Worker 节点 安装好了 master 节点后，worker 节点只需要执行：\n1 2 sudo kubeadm join 192.168.116.129:6443 --token e7m92u.o3namkgyg9f5n3qw \\ --discovery-token-ca-cert-hash sha256:8d15d3ef5536d14706b8efba99b760212a61eccb3a18c179c307045bcea4e950 它会连接 master 节点，然后拉取镜像，安装网络插件，最后把节点加入集群。\n如果需要重新显示 join 信息，可以运行：\n1 sudo kubeadm token create --print-join-command 安装完后，执行 kubectl get node，就会看到两个节点都是“Ready”状态。\n碰到的小问题 之前在安装网络插件时，会碰到安装完毕后 master 节点的状态始终还是“NotReady”，然后试着把 $HOME/.kube 目录删掉，然后：\nsudo kubeadm reset 重新 kubeadm init 重新安装 Fannel 插件 就 ok 了~\n","date":"2025-10-31T00:00:00Z","permalink":"/p/%E5%88%A9%E7%94%A8kubeadm%E6%90%AD%E5%BB%BA%E9%9B%86%E7%BE%A4/","title":"利用kubeadm搭建集群"},{"content":" 在 eBPF 中，什么时候应该用 bpf_htons、bpf_htonl、bpf_ntohs、bpf_ntohl？\n这些 bpf_* 函数是 eBPF 程序中用于字节序转换的辅助函数。它们的作用与标准的 C 库中的 htons、htonl、ntohs、ntohl 类似，但针对 BPF 环境进行了优化或封装。它们用于在主机字节序（Host Byte Order）和网络字节序（Network Byte Order）之间进行转换。\n简而言之，当在 BPF 程序中处理网络协议头部（如 IP、TCP、UDP）中的多字节字段时，就需要使用这些函数。\n网络协议标准（例如，IPv4、TCP、UDP）规定所有多字节数值（如端口号、IP 地址、校验和等）都必须以网络字节序（大端序，Big-Endian）传输。\n📝 备注 bpf_ntohs (Network To Host Short) \u0026amp; bpf_ntohl (Network To Host Long)\n其将一个来自网络数据包的 16 位（s - short）或 32 位（l - long）数值从网络字节序转换为主机字节序。\n使用场景：\n读取网络包数据时： 当需要从接收到的网络数据包中提取字段（如 TCP/UDP 端口号、IP 地址、序列号、窗口大小等）并在 BPF 程序内部进行数值比较或计算时。 原因： BPF 程序运行在内核中，默认以主机字节序（通常是小端序，但在大端系统上可能是大端序）进行操作和计算。为了正确解释网络数据包中按大端序存储的值，必须进行转换。 📝 备注 bpf_htons (Host To Network Short) \u0026amp; bpf_htonl (Host To Network Long)\n其将一个 16 位或 32 位数值从主机字节序转换为网络字节序。\n使用场景：\n修改或生成网络包数据时： 当您的 BPF 程序（例如 XDP/TC 程序）需要修改数据包头部中的多字节字段（如修改目标端口号），或者创建新的网络数据包头部时。 原因： 任何写入到数据包中准备发送到网络上的多字节字段，都必须遵循网络标准，以网络字节序（大端序）存储。 函数 作用 字节数 使用场景 bpf_ntohs 网络 \u0026ndash;\u0026gt; 主机 2 字节（short） 读取端口号、校验和等。 bpf_ntohl 网络 \u0026ndash;\u0026gt; 主机 4 字节（long） 读取 IP 地址、序列号等。 bpf_htons 主机 \u0026ndash;\u0026gt; 网络 2 字节（short） 写入/修改端口号等。 bpf_htonl 主机 \u0026ndash;\u0026gt; 网络 4 字节（long） 写入/修改 IP 地址等。 ","date":"2025-10-23T00:00:00Z","permalink":"/p/ebpf%E4%B8%AD%E4%BD%95%E6%97%B6%E4%BD%BF%E7%94%A8%E5%AD%97%E8%8A%82%E5%BA%8F%E8%BD%AC%E6%8D%A2%E5%87%BD%E6%95%B0/","title":"eBPF中何时使用字节序转换函数"},{"content":"Nginx 负载均衡基准测试 1 2 3 4 5 6 7 8 9 / # wrk -c100 \u0026#34;http://172.17.0.5\u0026#34; Running 10s test @ http://172.17.0.5 2 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 12.31ms 12.66ms 84.49ms 82.31% Req/Sec 5.71k 4.96k 12.98k 63.00% 113583 requests in 10.04s, 17.87MB read Requests/sec: 11313.86 Transfer/sec: 1.78MB Latency（延迟）\nAvg 12.31ms：平均每个请求的响应时间为 12.31 毫秒 Stdev 12.66ms：标准差说明响应时间波动较大 Max 84.49ms：最长请求花了 84.49 毫秒 82.31% +/- Stdev：82% 的请求延迟在 ±1 标准差范围内（说明大部分请求分布较广） Req/Sec（每秒请求数）\nAvg 5.71k：平均每个线程每秒发出约 5710 个请求 Stdev 4.96k：标准差说明不同时间点吞吐波动较大 Max 12.98k：单个线程的最高瞬时请求速率为 12.98k/s 63.00% +/- Stdev：约 63% 的样本在这个范围内 接下来我们利用套接字 eBPF 程序优化\n理解 Nginx 负载均衡的本质 Nginx 作为反向代理或负载均衡器，本质上是：\n从客户端接收请求（通过 accept() 建立 TCP 连接） 选择一个上游后端（upstream server） 在用户态发起一个新的 TCP 连接 到该后端 在两个 socket 之间中转数据（前端 \u0026lt;-\u0026gt; 后端） 关键点：Nginx 的负载均衡策略（如 round robin、ip_hash）在用户态决定；而实际的数据流是两个独立的 socket，中间的数据转发也在用户态进行（效率上有损）。\n当我们引入 eBPF（extended Berkeley Packet Filter） 时，目标往往是：\n把负载均衡逻辑下放到内核态 减少 Nginx 用户态转发开销 实现更快的流量分发（零拷贝、低延迟） 例如：\n用 eBPF 程序在 XDP 或 TC ingress hook 阶段直接选择后端； 或者使用 sockmap/sockhash 在内核中直接“转发”两个 socket 之间的数据。 使用 socket 映射转发网络包 socket 映射（socket map）是 eBPF 中的一种 BPF map 类型，它可以在内核中保存 socket 的引用（即 TCP 连接对象）， 从而允许 eBPF 程序直接操作和转发这些连接上的数据。它有两种形式：\n类型 名称 特点 BPF_MAP_TYPE_SOCKMAP sockmap 数组结构，按索引存 socket BPF_MAP_TYPE_SOCKHASH sockhash 哈希结构，可按 key 查找 socket 可以理解为：sockmap/sockhash = 一个“连接路由表”，存放 \u0026lt;key, socket\u0026gt; 映射关系。 让 eBPF 程序在内核中知道“哪个 key 对应哪个 socket”。\n传统用户态转发的性能瓶颈，以 Nginx 为例：\n1 client socket → Nginx 用户态 → upstream socket 数据流需要经过：\n2 次系统调用（recv + send） 2 次上下文切换（内核↔用户态） 1 次数据拷贝（内核缓冲区↔用户缓冲区） 当并发量很高（比如 wrk 10k QPS）时，这个过程就成为瓶颈。\n于是 Linux 引入了 socket 映射 + SK_MSG eBPF 程序可以直接在内核中完成转发：\n把所有连接（client 和 upstream）的 socket 引用注册进 sockmap； eBPF 程序在发送路径中（BPF_PROG_TYPE_SK_MSG）执行； 程序从 sockmap 中查找目标 socket； 内核直接把数据从一个 socket 发送到另一个 socket —— 零拷贝转发。 在 eBPF 优化体系中：\n控制面（Control Plane）：决定怎么转发（谁转发给谁） → 用户态（Nginx） 数据面（Data Plane）：实际执行转发 → 内核态（eBPF） socket 映射就是两者的“桥梁”：Nginx 负责把 socket 注册进 sockmap（告诉内核：client_fd 对应 upstream_fd），eBPF 程序负责从 sockmap 中查找目标并完成转发。\n这种模式的好处是：\neBPF 不需要自己维护连接； 用户态应用可以动态调整映射； 内核可以高性能地完成数据转发。 数据流简化示意图如下：\n传统 Nginx 转发 1 2 3 4 5 [Client Socket] ↓ recv() [用户态缓冲区] ↓ send() [Upstream Socket] eBPF + socket 映射优化后 1 2 3 4 5 [Client Socket] ↓ eBPF 程序 (SK_MSG) ↘︎ 查找 sockmap[key] ↓ [Upstream Socket] 整个转发在内核中完成，无需进入 Nginx 用户态。\n那优化的步骤如下：\n创建套接字映射； 在 BPF_PROG_TYPE_SOCK_OPS 类型的 eBPF 程序中，将新创建的套接字存入套接字映射中； 在流解析类的 eBPF 程序 (如 BPF_PROG_TYPE_SK_SKB 或 BPF_PROG_TYPE_SK_MSG ) 中，从套接字映射中提取套接字信息，并调用 BPF 辅助函数转发网络包; 加载并挂载 eBPF 程序到套接字事件。 socket 映射 这里使用 socket 映射（socket map）中的一种 BPF Map 类型：BPF_MAP_TYPE_SOCKHASH，它的值总是套接字文件描述符，而键则需要我们去定义。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 typedef struct { __u32 source_ip; __u32 dest_ip; __u16 source_port; __u16 dest_port; __u32 protocol; } SockKey; struct { __uint(type, BPF_MAP_TYPE_SOCKHASH); __uint(key_size, sizeof(SockKey)); __uint(value_size, sizeof(int)); __uint(max_entries, 65535); __uint(map_flags, 0); } sock_ops_map SEC(\u0026#34;.maps\u0026#34;); BPF_PROG_TYPE_SOCK_OPS 是 内核套接字操作回调类型的 eBPF 程序，它能在 TCP 连接的生命周期事件被触发时执行，包括：\nBPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB：被动连接建立（如服务器 accept） BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB：主动连接建立（如客户端 connect） BPF_SOCK_OPS_TCP_CLOSE_CB：连接关闭 BPF_SOCK_OPS_STATE_CB：状态变化 也就是说，它可以跟踪 TCP socket 的整个生命周期，在连接建立/关闭时获取 socket 的五元组信息，并把 socket 存入 sockhash，从而让 内核态 eBPF 数据面程序（如 SK_MSG）可以直接高效地查找和转发 socket 数据。\n需要注意的是，BPF_PROG_TYPE_SOCK_OPS 程序跟踪了所有类型的套接字操作，我们只需要把新创建的套接字更新到映射中。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 SEC(\u0026#34;sockops\u0026#34;) int bpf_sockmap(struct bpf_sock_ops* sockops) { // 只处理 IPv4 连接 if (sockops-\u0026gt;family != AF_INET) { return BPF_OK; } // 只处理已建立的连接 if (sockops-\u0026gt;op != BPF_SOCK_OPS_PASSIVE_ESTABLISHED_CB \u0026amp;\u0026amp; sockops-\u0026gt;op != BPF_SOCK_OPS_ACTIVE_ESTABLISHED_CB) { return BPF_OK; } SockKey key = { .dest_ip = sockops-\u0026gt;remote_ip4, .source_ip = sockops-\u0026gt;local_ip4, .dest_port = sockops-\u0026gt;remote_port, .source_port = bpf_htonl(sockops-\u0026gt;local_port), .protocol = sockops-\u0026gt;family, }; bpf_sock_hash_update(sockops, \u0026amp;sock_ops_map, \u0026amp;key, BPF_NOEXIST); return BPF_OK; } 转发 socket 转发 socket 可以使用 BPF_PROG_TYPE_SK_MSG 类型的 eBPF 程序，它在内核中的定义是这样的：\n1 2 BPF_PROG_TYPE(BPF_PROG_TYPE_SK_MSG, sk_msg, struct sk_msg_md, struct sk_msg) 捕获 socket 中的发送数据包，并根据之前的 socket 映射进行转发：\n1 2 3 4 5 6 7 8 9 10 11 12 13 SEC(\u0026#34;sk_msg\u0026#34;) int bpf_sock_redirect(struct sk_msg_md* msg) { SockKey key = { .source_ip = msg-\u0026gt;remote_ip4, .dest_ip = msg-\u0026gt;local_ip4, .source_port = msg-\u0026gt;remote_port, .dest_port = bpf_htonl(msg-\u0026gt;local_port), .protocol = msg-\u0026gt;family, }; bpf_msg_redirect_hash(msg, \u0026amp;sock_ops_map, \u0026amp;key, BPF_F_INGRESS); return SK_PASS; } 测试结果 1 2 3 4 5 6 7 8 9 / # wrk -c100 \u0026#34;http://172.17.0.5\u0026#34; Running 10s test @ http://172.17.0.5 2 threads and 100 connections Thread Stats Avg Stdev Max +/- Stdev Latency 7.88ms 6.22ms 55.43ms 68.53% Req/Sec 7.03k 4.99k 15.03k 69.00% 140073 requests in 10.05s, 22.04MB read Requests/sec: 13940.22 Transfer/sec: 2.19MB 吞吐提升 ≈ 23.2%。 平均延迟下降 ≈ 36.0%。 ","date":"2025-10-21T00:00:00Z","permalink":"/p/ebpf%E5%A2%9E%E5%BC%BAnginx%E8%B4%9F%E8%BD%BD%E5%9D%87%E8%A1%A1/","title":"eBPF增强Nginx负载均衡"},{"content":"前端\n虽然我不是一个专门搞前端的，但也会写点点 Vue。最近实验室项目写完后需要部署一下，这里我就使用了 Docker Compose 来部署。由于前端项目有前台用户端访问和后台管理端两个，而老师申请的服务器只开了 80 端口，因此我就打算使用 nginx 来反向代理：\nhttp://example.com 为前台 http://example.com/admin 为后台 ok，那我就用了一个 nginx 容器做网关，其 nginx.conf 如下：\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 server { listen 80; listen [::]:80; server_name _; # 后台管理 location /admin/ { proxy_pass http://frontend-admin; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 后端接口 location /api { proxy_pass http://backend:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # minio location /images/ { proxy_pass http://minio:9000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 前台用户 location / { proxy_pass http://frontend-user/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location = /admin { return 301 /admin/; } } docker compose up -d 后，用户端能访问并且接口没问题，但是一到管理端 http://xx.xx.xx.xx/admin，就会是白屏，我检查了一下，管理端页面的标签栏标题是正确的，也就是 nginx 反向代理没问题，获取的 index-xxx.js 和 index-yyy.css 也没问题，那就奇了个怪了。\n研究了一番才发现，修改了 base 配置后，创建路由时也需要使用 BASE_URL（或等效值）的原因是为了保证前端路由与部署路径的一致性。而我在 vite.config.js 中的 base 如下：\n1 2 3 export default defineConfig({ base: \u0026#39;/admin/\u0026#39;, }) 在 vite.config.js 中设置的 base 选项，表示：\n所有静态资源（JS、CSS、图片等）的公共基础路径； 构建后的 HTML 文件中引用资源时会自动加上这个前缀； 构建后，所有资源路径会变成 /admin/assets/xxx.js。\nVue Router 默认假设应用部署在域名根路径（即 /）。如果你把应用部署在子路径下（如 https://example.com/admin/），而路由仍以 / 为基准，就会出现：\n页面刷新后 404； 路由跳转错误或无法匹配。 因此，Vue Router 需要知道“应用实际挂载的路径前缀”，这就是 history 配置中的 base 参数。\n在 Vite 项目中，import.meta.env.BASE_URL 是一个由 Vite 自动注入的变量，它的值就是 vite.config.js 中配置的 base。\n所以，在创建路由时这样写：\n1 2 3 4 5 6 7 // router/index.js import { createRouter, createWebHistory } from \u0026#39;vue-router\u0026#39; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes: [...] }) 这样就能确保：\n开发环境（base: '/'）和生产环境（base: '/admin/'）使用相同的代码； 路由前缀与静态资源前缀保持一致； 避免部署到子目录时出现空白页或路由错乱。 ","date":"2025-10-16T00:00:00Z","permalink":"/p/vue3%E8%B7%AF%E7%94%B1%E4%B8%8Evite%E7%9A%84base/","title":"Vue3路由与Vite的base"},{"content":"添加软件仓库 Debian12 1 2 3 4 5 6 7 8 9 10 11 # 1. 添加阿里的 Docker 镜像仓库证书；这里具体的url根据自身修改 curl -fsSL https://mirrors.aliyun.com/docker-ce/debian/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/aliyun-docker.gpg # 2. 添加仓库 echo \\ \u0026#34;deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/aliyun-docker.gpg] https://mirrors.aliyun.com/docker-ce/linux/debian \\ $(lsb_release -cs) stable\u0026#34; | sudo tee /etc/apt/sources.list.d/docker.list \u0026gt; /dev/null # 3. 安装 sudo apt-get update sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin Docky Linux 10 1 2 3 4 5 sudo dnf config-manager --add-repo=https://download.docker.com/linux/centos/docker-ce.repo sudo wget -O /etc/yum.repos.d/docker-ce.repo https://mirrors.aliyun.com/docker-ce/linux/rhel/docker-ce.repo sudo dnf install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin 加入 docker 用户组 安装完成后，通常我们要将自己加入 docker 用户组：\n1 usermod -aG docker ${USER} 配置镜像 之后还需配置一下国内镜像（/etc/docker/daemon.json）：\n1 2 3 4 5 6 7 8 { \u0026#34;registry-mirrors\u0026#34;: [ \u0026#34;https://dockerproxy.net\u0026#34;, \u0026#34;https://hub-mirror.c.163.com\u0026#34;, \u0026#34;https://mirror.ccs.tencentyun.com\u0026#34;, \u0026#34;https://mirrors.aliyun.com\u0026#34; ] } 然后重启 docker：\n1 sudo systemctl restart docker 其他操作可以参考阿里云的文档：安装并使用Docker和Docker Compose\n","date":"2025-10-13T00:00:00Z","permalink":"/p/%E5%9B%BD%E5%86%85%E5%AE%89%E8%A3%85docker-ce/","title":"国内安装Docker CE"},{"content":" 本篇文章思路来源于 @bilibili/灵茶山艾府\n题目描述：https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-ii\n相对于买卖股票的最佳时机 I，该问题可以多次买入和卖出股票以获取最大利益\n启发思路 以 prices = [7,1,5,3,6,4] 为例，直到最后一天，我们能获取到的最大利润是什么？\n最后一天，也就是第五天的利润（下标从 0 开始） = 第 0 天到第 5 天结束的利润 = 第 0 天到第四天结束的利润 + 第五天的利润\n将利润分为两部分：\n第五天的利润 什么都不做 买入股票（从 未持有股票 -\u0026gt; 持有股票） 卖出股票（从 持有股票 -\u0026gt; 未持有股票） 第零天到第四天的利润 由此可以清晰的感受到这样一个大问题可以分割为相同的子问题：\n问题：第 i 天结束，持有/未持有股票的最大利润\n子问题：第 i - 1 天结束，持有/未持有股票的最大利润\n状态机 简单的理解就是状态的转换，如下图就是本题的状态机\n那么如何将状态机与思路结合起来呢？\n我们可以这么想：\n假设 f(i, false) 代表第 i 天结束时未拥有股票的利润，f(i, true) 代表第 i 天结束时拥有股票的利润 第 i 天未持有股票的情况为：第 i-1 天未持有股票或者第 i-1 天拥有股票但是第 i 天时卖出了股票。这时第 i 天未持有股票的最大利润为 f(i, false) = max(f(i - 1, false), f(i - 1, true) + prices[i]) 第 i 天持有股票的情况未：第 i-1 天持有股票或者第 i-1 天未拥有股票但是第 i 天时买入了股票。这时第 i 天持有股票的最大利润为 f(i, true) = max(f(i - 1, true), f(i - 1, false) - prices[i]) 记忆化搜索 使用上面的思路，采用递归的方法，不难写出下面的算法：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); auto f = [\u0026amp;](this auto\u0026amp;\u0026amp; f, int i, bool hold) { if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } if (hold) { return max(f(i - 1, false) - prices[i], f(i - 1, true)); } return max(f(i - 1, true) + prices[i], f(i - 1, false)); }; return f(n - 1, false); } }; 上面还有几个问题\n为什么当 i 不在范围时 return hold ? INT_MIN : 0;？当 i 不在范围内，那么说明此时就算拥有股票也是非法的，那么其返回值一定不能影响到正常的 i 值，而用于比较返回值的函数为 max，那么将该返回值设置成 INT_MAX 就是最好的选择了 为什么最后只要返回 dfs(n - 1, false)？这时因为最后一天卖出去（也就是未持有股票，false）所拥有的利润一定比最后一天不卖出去（持有股票，true）所拥有的利润高，因此也每次要返回 max(dfs(n - 1, false), dfs(n - 1, true)) 了 好，这时点击提交！会发现超时了！这时因为我们在递归的过程中重复计算了子问题，造成了不必要的开销\n如图，以上红色的部分都是重复的，我们应该在计算的时候保存它们，这就是记忆化搜索\n需要注意的是，记忆化数组 cache 初始化应该为 -1 而不是 0,是因为计算的利润有可能是 0\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 class Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; memo(n, vector\u0026lt;int\u0026gt;(2, -1)); auto f = [\u0026amp;](this auto\u0026amp;\u0026amp; f, int i, bool hold) { if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } int ret = memo[i][hold]; if (ret != -1) { return ret; } if (hold) { ret = max(f(i - 1, false) - prices[i], f(i - 1, true)); } else { ret = max(f(i - 1, true) + prices[i], f(i - 1, false)); } memo[i][hold] = ret; return ret; }; return f(n - 1, false); } }; 递推为 dp 由以上的记忆化搜索和状态机思路，就不难将其 1：1 翻译为递推了：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; dp(n, vector\u0026lt;int\u0026gt;(2, -1)); dp[0][0] = 0; dp[0][1] = -prices[0]; for (int i = 1; i \u0026lt; n; ++i) { dp[i][0] = max(dp[i - 1][1] + prices[i], dp[i - 1][0]); dp[i][1] = max(dp[i - 1][0] - prices[i], dp[i - 1][1]); } return dp[n - 1][0]; } }; ","date":"2025-09-24T00:00:00Z","permalink":"/p/%E8%82%A1%E7%A5%A8%E9%97%AE%E9%A2%98ii%E4%B8%8E%E7%8A%B6%E6%80%81%E6%9C%BAdp/","title":"股票问题II与状态机dp"},{"content":" 左右分屏: 使用 Alt+Shift+= 或 Alt+Shift+- 快捷键。 上下分屏: 使用 Alt+Shift+= 或 Alt+Shift+- 快捷键。 取消分屏: 使用 Ctrl+Shift+w 快捷键。 关闭当前标签: 使用 Ctrl+Shift+w 快捷键。 ","date":"2025-09-18T00:00:00Z","permalink":"/p/windows-terminal%E5%88%86%E5%B1%8F%E5%BF%AB%E6%8D%B7%E9%94%AE/","title":"Windows Terminal分屏快捷键"},{"content":" 对 https://docs.kernel.org/networking/napi.html 的总结\nNAPI (New API，但现已无特定含义) 是 Linux 内核中用于高效处理网络数据包的一种机制，旨在减少高负载下的中断开销。\n其​目的​​是为了解决传统基于中断的包处理在高流量下的性能问题（“中断活锁”）。通过混合​​中断​​和​​轮询​​模式，在低负载时使用中断保证低延迟，在高负载时切换到轮询保证高吞吐量。\n设备通过中断通知有新数据包 -\u0026gt; 内核调度对应的 NAPI 实例 -\u0026gt; 在软中断上下文（或内核线程）中轮询处理多个数据包 -\u0026gt; 处理完毕后再打开中断。\n核心数据结构与 API ​​struct napi_struct​​：\n核心数据结构，代表一个 NAPI 实例，保存其状态信息。 通常与一个中断或一个队列（RX/TX）相关联。 ​​控制 API (初始化和状态管理)​​：\nnetif_napi_add() / netif_napi_del(): 向系统添加/删除一个 NAPI 实例（通常附加到网络设备上）。 napi_enable() / napi_disable(): 启用/禁用 NAPI 实例。禁用状态下实例不会被调度，poll 方法不会被调用。​​注意​​：API 非幂等，错误调用顺序可能导致死锁或竞态。 ​​数据路径 API (调度与处理)​​：\nnapi_schedule(): ​​核心调度函数​​。通常在设备的中断处理程序中调用，通知内核有数据需要处理，并获取 NAPI 实例的所有权。 napi_schedule_irqoff(): napi_schedule() 的变体，用于已知在中断上下文中调用的情况，可优化中断屏蔽操作。 napi_complete_done(): ​​完成处理函数​​。当驱动程序的 poll 方法处理完所有事件后调用此函数，释放实例的所有权。​​警告​​：budget 为 0 时绝不能调用；若处理恰好用完 budget 且工作已完成，需谨慎返回 budget - 1 或等待下次调用。 驱动程序实现要点 ​​poll 方法​​：\n驱动必须实现的回调函数，由内核调度执行实际的数据包处理工作。 ​​参数 budget​​：限制一次 poll 调用最多可处理的​​接收​​（RX）数据包数量（发送 TX 处理无此限制）。若返回值为 budget，表示还有工作未完成，内核会再次调度；若小于 budget，表示本轮处理已完成。 ​​中断屏蔽策略​​：\n调度 NAPI 后应​​屏蔽设备中断​​，防止不必要的重复中断，直到 napi_complete_done() 被调用后再解除屏蔽。 推荐使用 napi_schedule_prep() 检查 + __napi_schedule() 调用的组合，确保在屏蔽中断后调度，避免竞态条件。 高级特性与配置 ​​实例映射​​：\n现代网卡多队列：通常一个 NAPI 实例对应一个中断源和一个队列对（RX+TX）。但映射关系灵活，可一对多或多对一。 ​​软件中断合并 (Coalescing)​​：\n可通过 gro_flush_timeout 和 napi_defer_hard_irqs 参数配置 NAPI 在数据包处理完后启动一个定时器，延迟解除中断屏蔽，以合并更多数据包，减少中断次数，提升吞吐量。可通过 sysfs 或 Netlink (netdev-genl) ​​按每个 NAPI 实例​​进行配置。\nGRO（Generic Receive Offload，通用接收卸载）是 Linux 内核中的一种网络数据包处理机制，主要用于​​在接收数据时将多个小数据包合并成一个大包​​，再上传给网络协议栈处理，以此减少 CPU 的开销，提升网络吞吐性能\n​​忙轮询 (Busy Polling)​​：\n允许用户态进程主动轮询设备，在中断触发前检查并处理数据包，​​以 CPU 资源换取极低延迟​​。 启用方式：套接字选项 SO_BUSY_POLL、系统参数 net.core.busy_poll、io_uring API 或基于 epoll 的配置。 ​​基于 epoll 的忙轮询​​：\n可从 epoll_wait 调用中直接触发 NAPI 处理。 要求：添加到同一 epoll 上下文的所有文件描述符应具有相同的 NAPI ID（可通过 SO_INCOMING_NAPI_ID 获取）。 可通过 ioctl (EPIOCSPARAMS) 设置 epoll_params 结构，精细控制每个 epoll 上下文的忙轮询超时 (busy_poll_usecs)、预算 (busy_poll_budget) 和偏好 (prefer_busy_poll)。 ​​IRQ 缓解 (Mitigation) 与挂起 (Suspension)​​：\n​​IRQ 缓解​​：使用 SO_PREFER_BUSY_POLL，承诺应用程序会定期忙轮询，驱动可​​长时间屏蔽中断​​。由 gro_flush_timeout 作为安全机制防止停滞。 ​​IRQ 挂停​​：更激进的机制。当 epoll_wait 成功获取事件时，启动一个 irq-suspend-timeout 定时器来​​挂起（屏蔽）IRQ​​。只要应用程序持续处理，IRQ 就保持挂起状态，避免任何中断干扰。超时或处理空闲后 fallback 到常规模式。需要与 gro_flush_timeout 等配合使用。 ​​线程化 NAPI​​：\n可配置 NAPI 在​​独立的内核线程​​中运行，而不是软中断上下文。可针对每个网络设备或每个 NAPI 实例进行配置（通过 sysfs 或 Netlink）。 优点：可能提供更可预测的调度或利于实时性（PREEMPT_RT）内核。 建议：将 NAPI 线程固定到与处理其中断相同的 CPU 上。 用户空间交互 ​​NAPI ID​​：每个 NAPI 实例有唯一 ID，用户空间可通过 SO_INCOMING_NAPI_ID 套接字选项获取。 ​​查询与配置​​：可通过 Netlink (netdev-genl 家族) 查询设备队列信息（包括 NAPI ID）和配置每个 NAPI 实例的参数（如合并参数、线程化开关）。可使用 tools/net/ynl/ 下的工具（如 cli.py）进行操作。 ​核心思想​​：中断通知 + 轮询处理，平衡延迟与吞吐。\n实现 poll 方法，正确使用 napi_schedule() 和 napi_complete_done()，管理好中断屏蔽。通过软件中断合并、忙轮询、IRQ 缓解/挂起等机制针对低延迟或高吞吐场景进行精细配置。支持多种处理上下文（软中断、线程），映射关系（实例 - 中断 - 队列）灵活。\n","date":"2025-09-17T00:00:00Z","permalink":"/p/linux-napi%E6%9C%BA%E5%88%B6%E7%9F%A5%E8%AF%86%E7%82%B9%E6%80%BB%E7%BB%93/","title":"Linux NAPI机制知识点总结"},{"content":"在 Kubernetes（K8s）中，Service（服务） 是一个非常核心、关键的抽象概念。它的主要作用是：\n✅ 为一组 Pod 提供稳定的网络访问入口（IP + Port），实现服务发现和负载均衡。\n为什么需要 Service？ 在 Kubernetes 中：\nPod 是临时的、动态的 —— 它们随时可能被调度、重启、扩缩容，IP 会变 如果其他应用或用户直接访问 Pod IP，一旦 Pod 重建，连接就会失败 我们需要一个稳定的访问端点（Service），无论后端 Pod 如何变化 Service 的核心功能为：\n功能 说明 服务发现 通过 Service 名称（在集群内 DNS 可解析）访问后端应用 负载均衡 自动将请求分发到后端多个 Pod 实例 解耦访问与实现 用户访问 Service，无需关心后端是哪些 Pod、IP 是多少 支持多种暴露方式 可在集群内访问、节点上暴露、或对外暴露公网访问 Service 如何工作？ 创建一个 Service，并指定“选择器（selector）”来匹配一组 Pod（如 app: my-app） Kubernetes 为 Service 分配一个集群内唯一的虚拟 IP（ClusterIP） kube-proxy 组件在每个节点上设置 iptables/IPVS 规则，实现流量转发和负载均衡 当请求发送到 Service 的 IP:Port，流量会被自动转发到后端健康的 Pod Service 的 4 种类型 1️⃣ ClusterIP（默认） 只在集群内部可访问 为 Service 分配一个集群内虚拟 IP 适用于微服务之间互相调用 1 2 3 4 5 6 7 spec: type: ClusterIP ports: - port: 80 targetPort: 8080 selector: app: my-app 2️⃣ NodePort 在每个节点上开放一个端口（默认 30000-32767） 外部用户可通过 http://\u0026lt;NodeIP\u0026gt;:\u0026lt;NodePort\u0026gt; 访问服务 适合开发、测试或没有 LoadBalancer 的环境 1 2 3 4 5 6 7 8 spec: type: NodePort ports: - port: 80 targetPort: 8080 nodePort: 30007 # 可选，不填则自动分配 selector: app: my-app 3️⃣ LoadBalancer 适用于云平台（AWS、GCP、Azure、阿里云等） 云平台会自动创建一个外部负载均衡器，并分配公网 IP 用户通过公网 IP 访问服务 最适合生产环境对外暴露服务 1 2 3 4 5 6 7 spec: type: LoadBalancer ports: - port: 80 targetPort: 8080 selector: app: my-app 在 Minikube 或本地环境，可以使用 minikube service \u0026lt;service-name\u0026gt; 来模拟 LoadBalancer。\n4️⃣ ExternalName 不指向 Pod，而是通过 CNAME 指向一个外部域名 用于将集群内服务名称映射到外部服务（如数据库、第三方 API） 1 2 3 spec: type: ExternalName externalName: my.database.example.com 举个例子 假设部署了一个 Web 应用：\n1 kubectl create deployment my-nginx --image=nginx 默认它没有对外暴露，只能通过 kubectl port-forward 临时访问。\n创建一个 Service 让它可被访问：\n1 kubectl expose deployment my-nginx --port=80 --target-port=8080 --type=NodePort 或写 YAML：\n1 2 3 4 5 6 7 8 9 10 11 apiVersion: v1 kind: Service metadata: name: my-nginx-service spec: type: NodePort ports: - port: 80 # Service 暴露的端口 targetPort: 80 # Pod 容器监听的端口（nginx 默认 80） selector: app: my-nginx # 匹配 Deployment 的 label 然后：\n1 2 3 4 kubectl get svc # 输出示例： # NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE # my-nginx-service NodePort 10.96.123.123 \u0026lt;none\u0026gt; 80:30007/TCP 10s 访问 http://\u0026lt;你的节点IP\u0026gt;:30007 就能看到 Nginx 页面\nService 与 DNS Kubernetes 集群内置 CoreDNS，Service 会自动注册 DNS 记录：\n在集群内，你可以通过 \u0026lt;service-name\u0026gt;.\u0026lt;namespace\u0026gt;.svc.cluster.local 访问服务 同命名空间下，直接用 \u0026lt;service-name\u0026gt; 即可 例如：\n1 2 # 在集群内 Pod 中执行： curl http://my-nginx-service Service 与 Deployment/Pod 的关系 1 2 3 用户 → Service (稳定入口) → 负载均衡 → 多个 Pod（动态、IP 会变） ↑ 由 Deployment 管理 Deployment 管理 Pod 生命周期（滚动更新、扩缩容） Service 管理网络访问（服务发现 + 负载均衡） 二者配合，实现高可用、弹性、稳定的应用架构 ","date":"2025-09-07T00:00:00Z","permalink":"/p/k8s%E4%BB%80%E4%B9%88%E6%98%AFservice/","title":"【k8s】什么是Service"},{"content":"在完成 Rustdesk 自建服务器 的搭建后，我又想到：由于实验室服务器的公网 IPv6 地址 并非永久不变，直接使用 IP 进行远程连接（如 SSH 或 Rustdesk）并不现实，如何确保远程访问的稳定性和便利性呢。为了解决这一痛点，我决定利用 动态域名解析（DDNS） 技术，配合 ddns-go 和 阿里云 DNS 服务，将服务器的动态 IP 地址实时绑定到一个易于记忆的域名上。\n本文将记录我在配置过程中遇到的四个主要问题及其解决方案。\n问题 1：IPv6 临时地址导致域名解析不稳定 在启用 ddns-go 服务后，我发现尽管程序运行正常，但我的域名解析记录却频繁失效。经过排查，我意识到罪魁祸首是 IPv6 临时地址（Temporary Address）。这是一种为了增强用户隐私而设计的特性，系统会定期生成新的、临时的 IPv6 地址用于出站连接。当服务器的地址发生变化时，DDNS 服务来不及同步更新，就会导致域名无法解析到正确的 IP 地址。\n解决方案：禁用 IPv6 临时地址 要解决这个问题，最直接的方法就是从系统层面禁用 IPv6 临时地址功能，强制使用稳定的、非临时的地址。\n编辑 sysctl 配置文件： 使用 vim 或其他编辑器打开 sysctl.conf 文件，该文件用于在系统启动时配置内核参数。 1 sudo vim /etc/sysctl.conf 添加配置项： 在文件末尾添加以下两行，分别用于全局禁用和默认禁用所有网络接口的 IPv6 临时地址功能。 1 2 3 # 禁用 IPv6 临时地址 net.ipv6.conf.all.use_tempaddr = 0 net.ipv6.conf.enp4s0.use_tempaddr = 0 注意：如果只想针对特定网卡（例如 enp4s0）进行配置，可以添加 net.ipv6.conf.enp4s0.use_tempaddr = 0。你可以通过 ip a 命令查看你机器的网卡名称。\n应用配置： 执行以下命令，使新配置即时生效。 1 sudo sysctl -p 验证配置是否生效： 最后，可以通过查看特定参数的值来验证修改是否成功。如果返回 0，则表示临时地址已成功禁用。 1 2 cat /proc/sys/net/ipv6/conf/enp4s0/use_tempaddr # 预期输出: 0 问题 2：ddns-go 无法通过 systemd 启动 利用 systemctl status ddns-go 查看 ddns-go 运行状态时，看到：\n1 2 Unable to locate executable \u0026#39;/usr/local/bin/ddns-go\u0026#39;: Permission denied Failed at step EXEC spawning /usr/local/bin/ddns-go: Permission denied 但是对应的文件的权限我都给了，问了问 AI 后说可能造成的原因之一为SELinux 阻止执行（在 Fedora 上默认开启）。输入：\n1 sudo ausearch -m avc -ts recent | grep ddns-go 看到有拒绝记录，说明是 SELinux 问题。\n临时解决方案 1 sudo setenforce 0 永久解决方案 为该二进制打上正确的 SELinux 标签\n1 sudo restorecon -v /usr/local/bin/ddns-go 之后再重启 ddns-go 服务，就 OK 了。\n📝 为什么会有 SELinux 相关问题？ Linux 传统权限模型（用户/组/other + rwx）是 自主访问控制（DAC）：用户对自己文件有完全控制权；程序继承用户权限 → 一旦用户被攻破，攻击者可为所欲为\nSELinux 的目标是：即使程序或用户被攻破，也要限制它能做的事\n我是把 ddns-go 的可执行文件放在/usr/local/bin/下（复制过去的）\n而 SELinux 有预定义的“文件上下文”：\n/usr/bin/* → bin_t（可执行程序） /usr/local/bin/* → 默认可能不是 bin_t，尤其是手动复制进去的文件 所以即使文件有 chmod +x，SELinux 仍可能阻止执行\n问题 3：ddns-go 无法正确更新 IPv6 地址记录 解决了 IPv6 地址变化的问题后，我发现 ddns-go 服务依然无法正常工作，并抛出大量 DNS 相关的超时错误，例如：\n1 2 3 2025/08/29 10:01:37 查询域名信息发生异常! Get \u0026#34;[https://alidns.aliyuncs.com/?AccessKeyId=xxxxxx](https://alidns.aliyuncs.com/?AccessKeyId=xxxxxx)\u0026#34;: dial tcp: lookup alidns.aliyuncs.com on x.x.x.x:53: read udp x.x.x.x:59439-\u0026gt;x.x.x.x:53: i/o timeout 通过分析日志，我发现服务在尝试向外部 DNS 服务器（如 8.8.8.8 或 114.114.114.114）发起 DNS 请求时失败了。这通常是由于校园网或实验室网络的防火墙策略，限制了对外部公共 DNS 服务器的访问，强制所有 DNS 请求都通过内部服务器。\n解决方案：指定 ddns-go 使用可访问的 DNS 服务器 由于 ddns-go 默认使用系统 DNS，而系统 DNS 可能被强制转发到内部服务器，因此我们需要在启动命令中显式指定可访问的公共 DNS 服务器。\n编辑 ddns-go 服务文件：\n打开 /etc/systemd/system/ddns-go.service 文件。\n1 sudo vim /etc/systemd/system/ddns-go.service 修改 ExecStart 参数：\n在 ExecStart 行的末尾，添加 -dns 参数，指定一个或多个在国内访问稳定的公共 DNS，如阿里云的 223.5.5.5 和腾讯云的 119.29.29.29。\n1 2 3 4 5 6 7 [Service] StartLimitInterval=5 StartLimitBurst=10 ExecStart=/usr/local/bin/ddns-go \u0026#34;-l\u0026#34; \u0026#34;:9876\u0026#34; \u0026#34;-f\u0026#34; \u0026#34;300\u0026#34; \u0026#34;-cacheTimes\u0026#34; \u0026#34;5\u0026#34; \u0026#34;-c\u0026#34; \u0026#34;/root/.ddns_go_config.yaml\u0026#34; \u0026#34;-dns\u0026#34; \u0026#34;223.5.5.5\u0026#34; \u0026#34;-dns\u0026#34; \u0026#34;119.29.29.29\u0026#34; [Install] ... 重新加载并重启服务：\n保存文件后，重新加载 systemd 配置并重启 ddns-go 服务，使新参数生效。\n1 2 sudo systemctl daemon-reload sudo systemctl restart ddns-go 问题 4：电脑睡眠后网络中断，无法远程连接 解决了域名解析问题后，我又发现一个新情况：如果服务器闲置一段时间，SSH 连接就会断开。当物理唤醒电脑后，网络连接需要一段时间才能恢复。这是因为 系统默认的电源管理策略 会在电脑进入睡眠状态后，挂起或断开部分硬件（如网卡）以节省电量。\n解决方案：禁用系统休眠与闲置操作 1 sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target 为了确保服务器 24/7 在线，我们需要修改电源管理配置，禁用自动休眠。\n编辑 systemd-logind 配置文件：\n打开 /etc/systemd/logind.conf 文件，该文件用于配置用户登录和电源管理行为。\n1 sudo vim /etc/systemd/logind.conf 修改配置参数：\n取消以下几行的注释（如果被注释），并将它们的值修改为 ignore 或 never。\n1 2 3 4 5 6 [Login] # 禁用闲置自动操作 IdleAction=ignore IdleActionSec=never # 禁用合盖自动操作 HandleLidSwitch=ignore 重启服务：\n修改完成后，重启 systemd-logind 服务使配置生效。\n1 sudo systemctl restart systemd-logind 解决完上述问题后，无论是在校内还是校外，我都可以通过一个固定的域名来使用服务器了。\n","date":"2025-08-29T00:00:00Z","permalink":"/p/%E9%97%AE%E9%A2%98%E8%AE%B0%E5%BD%95ddns-go%E4%B8%8Eipv6%E7%BD%91%E7%BB%9C%E9%85%8D%E7%BD%AE/","title":"【问题记录】ddns-go与IPv6网络配置"},{"content":"对于现在常见的远程控制软件，例如 ToDesk、向日葵，其免费版本总有诸多限制（帧率低、分辨率低、控制时间有限制），而 RustDesk 的公共服务器现在在国内也不可用，因此我就想到了 RustDesk 的自建服务器。现在我们实验室有几台空闲的电脑，并且这些电脑都有 IPv6，这就省去了我购买云服务器的花费，可以实现零成本搭建远程服务😁\n安装 RustDesk Server 我使用 https://github.com/sshpc/rustdesktool 这里的脚本来一键安装，安装完后，RustDesk Server 的默认安装目录为：\n1 /usr/local/rustdesk-sever 开放端口 我们需要放行防火墙 TCP \u0026amp; UDP 端口 21115-21119，其中\n21115 是 hbbs 用作 NAT 类型测试 21116/UDP 是 hbbs 用作 ID 注册与心跳服务 21116/TCP 是 hbbs 用作 TCP 打洞与连接服务 21117 是 hbbr 用作中继服务 1 2 3 4 5 6 7 # 允许 TCP 端口 sudo ufw allow 21115:21119/tcp # 允许 UDP 端口 sudo ufw allow 21115:21119/udp sudo ufw enable 然后执行\n1 sudo ufw status 如果有如下类似输出，表明端口已经放行并且防火墙正在运行。\n1 2 3 4 5 6 7 8 状态：活动 至 动作 来自 -- -- -- 21115:21119/tcp ALLOW Anywhere 21115:21119/udp ALLOW Anywhere 21115:21119/tcp (v6) ALLOW Anywhere (v6) 21115:21119/udp (v6) ALLOW Anywhere (v6) 启动服务 在我们用脚本一键安装后，服务安装目录为：\n1 2 /usr/lib/systemd/system/RustDeskHbbr.service /usr/lib/systemd/system/RustDeskHbbs.service 可以通过 systemd 启动服务：\n1 2 3 4 5 sudo systemctl start RustDeskHbbs sudo systemctl start RustDeskHbbr # 或者重载systemd配置 # sudo systemctl daemon-reload 配置客户端 在 RustDesk 的客户端的 设置 -\u0026gt; 网络 -\u0026gt; ID/中继服务器 中：\n第一第二栏中打马赛克的地方填入服务器的 ip，由于实验室电脑有 IPv6，因此我这里使用的是 IPv6；key 栏填入默认安装目录下的 id_ed25519.pub 中的值。\n确认后，左下角显示 就绪 就说明配置完成了。\n在自己所需的电脑上都进行这一步的客户端配置即可开连！尝试了一下分辨率比 ToDesk 高多了，延迟也还好。\n","date":"2025-08-25T00:00:00Z","permalink":"/p/rustdesk%E8%87%AA%E5%BB%BA%E6%9C%8D%E5%8A%A1%E5%99%A8/","title":"Rustdesk自建服务器"},{"content":" 1 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 52 53 54 55 56 #include \u0026lt;iostream\u0026gt; auto ASSERT(bool flag, const char* msg) { if (!flag) { std::cerr \u0026lt;\u0026lt; msg \u0026lt;\u0026lt; std::endl; } } // READ: 枚举类型 \u0026lt;https://zh.cppreference.com/w/cpp/language/enum\u0026gt; // `enum` 是 C 的兼容类型，本质上其对应类型的常量。 // 在 `enum` 中定义标识符等价于定义 constexpr 常量， // 这些标识符不需要前缀，可以直接引用。 // 因此 `enum` 定义会污染命名空间。 enum ColorEnum : unsigned char { COLOR_RED = 31, COLOR_GREEN, COLOR_YELLOW, COLOR_BLUE, }; // 有作用域枚举型是 C++ 引入的类型安全枚举。 // 其内部标识符需要带前缀引用，如 `Color::Red`。 // 作用域枚举型可以避免命名空间污染，并提供类型安全保证。 enum class Color : int { Red = COLOR_RED, Green, Yellow, Blue, }; ColorEnum convert_by_pun(Color c) { // READ: \u0026lt;https://zh.cppreference.com/w/cpp/language/union\u0026gt; // `union` 表示在同一内存位置存储的不同类型的值。 // 其常见用法是实现类型双关转换，即将一种类型的值转换为另一种无关类型的值。 // 但这种写法实际上仅在 C 语言良定义，在 C++ 中是未定义行为。 // 这是比较少见的 C++ 不与 C 保持兼容的特性。 // READ: 类型双关 \u0026lt;https://tttapa.github.io/Pages/Programming/Cpp/Practices/type-punning.html\u0026gt; union TypePun { ColorEnum e; Color c; }; TypePun pun; pun.c = c; return pun.e; } int main(int argc, char **argv) { ASSERT(convert_by_pun(Color::Red) == COLOR_RED, \u0026#34;Type punning conversion\u0026#34;); ASSERT(convert_by_pun(Color::Green) == COLOR_GREEN, \u0026#34;Type punning conversion\u0026#34;); ASSERT(convert_by_pun(Color::Yellow) == COLOR_YELLOW, \u0026#34;Type punning conversion\u0026#34;); ASSERT(convert_by_pun(Color::Blue) == COLOR_BLUE, \u0026#34;Type punning conversion\u0026#34;); return 0; } ","date":"2025-08-21T00:00:00Z","permalink":"/p/cc-%E7%B1%BB%E5%9E%8B%E5%8F%8C%E5%85%B3%E8%BD%AC%E6%8D%A2/","title":"C\u0026C++类型双关转换"},{"content":"有时候我们会在多个平台上编辑文件、代码，此时我们希望所有文件，包括你在 Windows 上编辑的文件（Windows 采用的是 CRLF），都使用 LF 换行符，那么可以修改 .gitattributes 文件来强制执行这个规则：\n1 2 3 4 5 6 7 # 强制所有文件使用 LF 换行符 * text eol=lf # 避免对二进制文件进行换行符转换 *.png binary *.jpg binary *.pdf binary 在保存提交 .gitattributes 后，可能因为之前在 Windows 上编辑的文件可能已经变成了 CRLF 换行符，这时需要需要将它们转换回 LF（这里可以使用 VSCode）。\n接着可以执行以下命令，让 Git 重新处理工作目录中的文件并按照新的规则来转换：\n1 2 git rm --cached -r . git reset --hard * text eol=lf：这条规则告诉 Git，将仓库中的所有文件都视为文本文件，并且强制它们使用 LF 作为行结束符。当你从仓库检出文件时，Git 会将其转换为 CRLF（在 Windows 上），但当你提交时，它会确保所有文件都以 LF 格式存储在仓库中。\ngit rm --cached -r . 和 git reset --hard：这些命令会强制 Git 重新将你的工作目录与仓库同步，并在此过程中应用 .gitattributes 文件中设置的换行符规则，从而确保所有文件都符合 LF 规范。\n","date":"2025-08-21T00:00:00Z","permalink":"/p/git%E4%B8%AD%E6%96%87%E4%BB%B6%E6%8D%A2%E8%A1%8C%E7%AC%A6%E9%83%BD%E4%BD%BF%E7%94%A8lf/","title":"Git中文件换行符都使用LF"},{"content":"什么是内存对齐？为什么需要它？ 内存对齐（Memory Alignment）是计算机系统中数据在内存中存储的一种规则：​​数据在内存中的起始地址必须是其自身大小的整数倍​​。例如，一个 4 字节的整型变量（int），其起始地址必须是 4 的倍数（如地址 0x0000、0x0004、0x0008 等）。\n而需要内存对齐主要基于以下三个原因：\n​​硬件访问效率​​： CPU 通过内存总线从内存读取数据时，通常以固定大小的“块”为单位（例如 4 字节或 8 字节）。如果数据对齐，CPU 一次读取操作即可获取完整数据。 ​​非对齐示例​​：假设一个 int 变量（4 字节）存储在地址 0x0001（非 4 的倍数），CPU 需要分两次读取：先读取 0x0000-0x0003（包含前 3 字节），再读取 0x0004-0x0007（包含最后 1 字节），最后拼接数据。这会显著降低性能。 ​​硬件兼容性​​： 部分架构（如 ARM、MIPS）的 CPU 无法直接访问非对齐内存。尝试访问时会导致硬件异常（如“总线错误”）。对齐保证了代码的跨平台兼容性。 ​​缓存效率优化​​： 现代 CPU 使用缓存行（Cache Line，通常 64 字节）预加载数据。对齐的数据更可能完整地位于单个缓存行中。若数据跨缓存行存储，会引发两次缓存访问，降低效率。 alignas 和 alignof c++11 以后引入两个关键字 alignas 与 alignof。\nalignas 用于显式设置变量、类成员或类型的内存对齐要求；而 alignof 用于获取类型或变量的内存对齐要求。例如：\n1 2 3 4 5 6 7 8 struct Test1 {}; struct alignas(4) Test2 {}; static_assert(sizeof(Test1) == 1); static_assert(sizeof(Test2) == 4); static_assert(alignof(Test1) == 1); static_assert(alignof(Test2) == 4); alignas 支持三种语法形式：\nalignas(expression)：expression 必须是计算结果为零的整数常量表达式，或者是对齐或扩展对齐的有效值（2 的倍数）。 alignas(type-id)：等效于 alignas(alignof(type-id)) alignas(pack...)：等效于应用于同一声明的多个 alignas 说明符，参数包的每个成员对应一个说明符，可以是类型参数包或常量参数包 📝 备注 注意：若指定的对齐值小于编译器默认对齐要求，部分编译器可能忽略该设置。\n结构体字节对齐 结构体对齐的基本原则如下：\n每个成员在其自身大小的整数倍地址上对齐 结构体本身的对齐方式等于其最大成员的对齐要求 结构体的大小是其对齐方式的整数倍（尾部可能填充） 举个🌰：\n1 2 3 4 5 6 7 8 struct Student { char name[5]; int num; short score; }; cout \u0026lt;\u0026lt; sizeof(Student) \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // 16 cout \u0026lt;\u0026lt; alignof(Student); // 4 成员 大小 对齐要求（alignment） char[5] 5 字节 1 字节 int 4 字节 4 字节 short 2 字节 2 字节 结构体整体对齐通常等于 最大对齐成员的对齐要求 → max(1, 4, 2) = 4 字节。\n其内存布局如下：\n1 2 3 4 5 Byte Offset: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ Field: | n0| n1| n2| n3| n4| | | | num (4 bytes) | s0| s1| | | +---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+ Meaning: | name[5] | padding | num | score |padding| 如果采用 __attribute__((__packed__)) （GCC/Clang 编译器提供的一个属性）进行“单字节对齐”，则会去除所有自动添加的 padding，每个成员从它前一个成员紧挨着的下一个字节开始。\n1 2 3 4 5 6 7 8 struct Student { char name[5]; int num; short score; } __attribute__((__packed__)); cout \u0026lt;\u0026lt; sizeof(Student) \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // 11 cout \u0026lt;\u0026lt; alignof(Student); // 1 1 2 3 4 5 Byte Offset: 0 1 2 3 4 5 6 7 8 9 10 +---+---+---+---+---+---+---+---+---+---+---+ Field: | n0| n1| n2| n3| n4| m0| m1| m2| m3| s0| s1| +---+---+---+---+---+---+---+---+---+---+---+ Meaning: | name[5] | num | score | 这种情况通常用于网络协议头、二进制文件结构、设备寄存器映射等。\n位域 “位域“是把一个字节中的二进位划分为几个不同的区域，并说明每个区域的位数，每个域有一个域名，允许在程序中按位域名进行操作。这样就可以把几个不同的对象用一个字节的二进制位域来表示。\n位域的对齐规则为：\n位域的基础类型决定了分配单位，比如 unsigned int : 3 表示分配在 int 的机器字上（通常是 4 字节） 多个位域会尽量共享同一个机器字，但： 如果一个字段放不下了，会拆到下一个对齐单元 不同基础类型之间可能强制对齐 结构体大小和对齐也会根据最大基础类型来对齐 对于下面这个🌰：\n1 2 3 4 5 6 7 8 9 10 11 struct BitField { int a1 : 1; int a2 : 5; int a3 : 29; int a4 : 6; char a5 : 2; char a6; }; cout \u0026lt;\u0026lt; sizeof(BitField) \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // 12 cout \u0026lt;\u0026lt; alignof(BitField) \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // 4 所有 int 位域（a1 ~ a4）以 int（4 字节 = 32 位）为分配单元。 多个 int 位域可以共用同一个 int 单元，如果剩余位数够用。 一旦放不下一个字段，就会换下一个 4 字节单元。 如果换了基础类型（比如从 int → char），通常会进行类型对齐。 非位域成员如 char a6 仍需按其类型对齐（通常是 1 字节，但结构体对齐会以最大成员为准）。 其内存布局如下：\n1 2 3 4 Byte Offset: 0 1 2 3 4 5 6 7 8 9 10 11 +---------------------------------------------------------------------------------------+ | a1:1, a2:5, a3前26位，共32位 | a3后3位, a4:6, 剩余未用 bits |a5:2...| a6:8 | pad | +---------------------------------------------------------------------------------------+ ","date":"2025-07-22T00:00:00Z","permalink":"/p/%E5%85%B3%E4%BA%8E%E5%86%85%E5%AD%98%E5%AF%B9%E9%BD%90%E4%BD%8D%E5%9F%9F%E7%9A%84%E6%80%9D%E8%80%83/","title":"关于内存对齐、位域的思考"},{"content":"__uint, __type, __array, __ulong 这些宏主要用于 eBPF Map 的定义 中，帮助 LLVM 编译器和 BPF CO-RE (Compile Once – Run Everywhere) 理解和提取 eBPF Map 的元数据信息。它们通常与 BPF_MAP_DEF 或 struct 配合使用，在 eBPF 程序中用于告诉内核如何创建 BPF map。\n常用的几个宏 1. __uint(name, val) 1 #define __uint(name, val) int (*name)[val] 用途：定义一个名为 name 的字段，其值是一个大小为 val 的整数数组指针。\n1 __uint(max_entries, 1024); 等价于：\n1 int (*max_entries)[1024]; 作用：\n用于在 eBPF Map 的定义中指定 max_entries 这一属性（即 map 的最大容量）。这种“类型欺骗”的方式让 clang 编译器能保留这些信息在 ELF 文件的 BTF（BPF Type Format）部分里，从而被 bpftool、libbpf 提取并传给内核。\n2. __type(name, val) 1 #define __type(name, val) typeof(val) *name 用途：定义一个名为 name 的字段，其类型是 val 的指针。\n1 2 __type(key, u32); __type(value, struct my_val); 等价于：\n1 2 u32 *key; struct my_val *value; 作用：\n告诉内核 key 和 value 类型分别是什么。这对于 map 创建时至关重要，编译器生成的 BTF 信息里会包含 key/value 类型。\n3. __array(name, val) 1 #define __array(name, val) typeof(val) *name[] 用途：定义一个名为 name 的数组指针，元素类型是 val。\n用于某些 map 类型，比如 BPF_MAP_TYPE_PROG_ARRAY，你可能会写：\n1 __array(values, int); 等价于：\n1 int *values[]; 作用：\n表示 map 的值是一个函数指针数组或类似结构。这个字段用于描述 array-of-pointer 类型的结构（比如跳转表）。\n4. __ulong(name, val) 1 #define __ulong(name, val) enum { ___bpf_concat(__unique_value, __COUNTER__) = val } name 这个稍微复杂，它依赖另一个宏 ___bpf_concat（通常是这样定义的）：\n1 #define ___bpf_concat(a, b) a##b 因此：\n1 __ulong(pinning, 2); 会展开成：\n1 enum { __unique_value123 = 2 } pinning; 作用：\n在不引入实际变量的前提下，让这个 pinning 名字能被识别为枚举类型，其值为 2。 用于设置 map 的属性值，比如 BPF_F_PIN_BY_NAME 之类的 enum 值。 __COUNTER__ 保证名字唯一，防止 enum 名冲突。 这些宏的目的 宏名 用途 实际展开 用于定义 eBPF Map 的哪些属性 __uint 设置整型常量字段 int (*name)[val] max_entries, map_flags, type 等 __type 设置类型信息 typeof(val) *name key, value 等 __array 表示数组类型 typeof(val) *name[] prog_array 类型的值等 __ulong 设置枚举值但不创建变量 enum { unique_name = val } pinning, BPF_F_... 选项等 🌰 定义一个简单的 Hash Map 1 2 3 4 5 6 struct { __uint(type, BPF_MAP_TYPE_HASH); __uint(max_entries, 1024); __type(key, u32); __type(value, long); } my_map SEC(\u0026#34;.maps\u0026#34;); my_map 在编译时不会真正分配变量空间，而是借助宏保留元信息给 BTF section，供 libbpf 使用自动加载、创建 map 时提取。\n为什么这些宏要被定义为数组指针形式 eBPF 程序中的这些 __uint、__type 定义，本质上是为了将配置元信息嵌入到 BTF (BPF Type Format) 中。\n📝 备注 也就是说：我们不是在运行时使用这些变量，而是“伪装”为有值的变量，利用它们的类型和结构，在编译阶段生成元数据，供 eBPF 加载器使用。\n比如：\n1 __uint(max_entries, 1024); 展开后：\n1 int (*max_entries)[1024]; 这并不是真的声明了一个数组，也不会访问它，它只是声明了一个指针，其指向的类型是长度为 1024 的数组，这一信息正好可以被 BTF 捕获并保留。\n利用这一点，可以让内核从 BTF 中知道：\nmax_entries 是一个整数类型 值为 1024 那为什么不直接定义一个整型常量呢？因为 C 语言中：\n1 int x = 123; 这种方式会在程序中实际生成一个变量 x 占用空间。eBPF 是一种非常严格的环境，要求尽可能小的指令数和变量空间，而且 map 元数据本质上并不需要运行时访问。\n所以我们想让这些“变量”：\n不真的存在于运行时（不分配空间） 但它们的“类型信息”和“值信息”能被保留下来 而使用数组指针这种写法的优点是：\n不会生成实际变量或初始化语句（编译器优化掉） val（如 1024）被嵌入到了类型中，方便 BTF 保留 能被 clang/libbpf 识别为元信息字段 可以区分不同的字段名（因为是结构体成员） ","date":"2025-07-07T00:00:00Z","permalink":"/p/ebpf-map%E4%B8%AD%E7%9A%84%E6%95%B0%E6%8D%AE%E6%88%90%E5%91%98%E7%B1%BB%E5%9E%8B%E7%9A%84%E5%AE%8F%E5%AE%9A%E4%B9%89/","title":"eBPF Map中的数据成员类型的宏定义"},{"content":"通过解耦编译时依赖和运行时重定位，实现 BPF 程序对内核版本与配置差异的自动适配。vmlinux.h 之所以无需严格匹配你的内核配置或版本，关键在于以下机制：\nBTF（BPF Type Format）提供统一类型描述 vmlinux.h 本质是内核 BTF 的 C 语言翻译：它由 bpftool btf dump file /sys/kernel/btf/vmlinux format c \u0026gt; vmlinux.h 命令生成，完整包含内核所有数据结构的类型定义（如结构体、枚举）。 独立于具体配置：BTF 记录了内核的最终内存布局（包括因配置选项如 CONFIG_COMPAT 导致的字段偏移变化）。因此，编译时使用的 vmlinux.h 只需是某一有效内核的 BTF 快照，无需与目标内核完全一致。 生成 vmlinux.h 1 bpftool btf dump file /sys/kernel/btf/vmlinux format c \u0026gt; vmlinux.h Clang 编译时记录“重定位元信息” 当使用 Clang（≥10）编译 BPF 程序时，对内核结构的访问（如 task_struct-\u0026gt;pid）会被转换为 CO-RE 重定位项。这些项记录： 访问的字段名（如 pid） 类型路径（如 struct task_struct） 预期偏移/大小等。 此过程依赖 vmlinux.h 提供类型合法性检查，但不绑定具体偏移。 libbpf 运行时动态重定位 加载 BPF 程序时，libbpf 执行关键两步： 查询目标内核的 BTF（/sys/kernel/btf/vmlinux），获取实际结构布局； 比对编译时的重定位项，修正字段偏移、处理字段增减（如通过 bpf_core_field_exists() 判断成员是否存在）。 例如：若目标内核的 struct user_arg_ptr 因 CONFIG_COMPAT=y 新增 is_compat 字段，libbpf 会自动调整 native 字段的访问偏移。 CO-RE 辅助函数实现条件兼容 BPF 代码可通过以下函数动态适应内核差异： bpf_core_read(dst, src)：替代直接指针访问，按运行时偏移安全读取字段； BPF_CORE_READ(src, field)：处理嵌套结构； bpf_core_field_exists(field)：条件执行兼容逻辑（如选择参数索引）。 这些函数在编译时生成重定位项，在运行时由 libbpf 解析为正确操作。 实际限制与注意事项 限制 说明 目标内核需开启 BTF 必须配置 CONFIG_DEBUG_INFO_BTF=y（多数发行版已默认开启）。 旧内核无 BTF 支持 若无 BTF，需手动提供等效的 BTF 文件（如通过 BTFHub）。 宏与函数签名变化 BTF 不记录宏或函数参数列表变化，需通过 #ifdef 或运行时检测处理。 BPF CO-RE 将类型描述（vmlinux.h）、编译时元信息（Clang 重定位项）、运行时适配器（libbpf）三者分离，使 vmlinux.h 仅需作为“合法类型模板”，而非精确内存布局参考。最终由 libbpf 借助目标内核的 BTF 完成字段偏移、存在性的动态修正，实现跨版本/配置的运行兼容。这一机制大幅降低了 eBPF 程序的移植成本，使其真正成为“一次编译，到处运行”的内核可编程工具。\n","date":"2025-07-01T00:00:00Z","permalink":"/p/co-re%E6%9C%BA%E5%88%B6%E4%B8%8Evmlinux.h/","title":"CO-RE机制与vmlinux.h"},{"content":"curl 是一个强大的命令行工具，用于传输数据，支持多种协议（HTTP、HTTPS、FTP 等）。以下是 curl 的基本使用方法和常见示例。\n基本语法 1 curl [options] [URL...] 常用选项 选项 描述 -X 指定 HTTP 请求方法 (GET, POST, PUT, DELETE 等) -H 添加请求头 -d 发送 POST 请求数据 -F 发送表单数据 (multipart/form-data) -G 将 -d 数据作为 GET 请求的查询参数 -o 将输出保存到文件 -O 将输出保存到文件，使用远程文件名 -L 跟随重定向 -v 显示详细输出 (verbose) -u 指定用户名和密码 -A 设置 User-Agent -k 允许不安全的 SSL 连接 -I 只获取响应头 -s 静默模式 (不显示进度或错误信息) --data-urlencode URL 编码 POST 数据 常见用法示例 1. 发送 GET 请求 1 curl https://example.com 2. 发送 POST 请求 1 curl -X POST https://example.com/api -d \u0026#39;name=value\u0026#39; 3. 发送 JSON 数据 1 2 3 curl -X POST https://example.com/api \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;key1\u0026#34;:\u0026#34;value1\u0026#34;, \u0026#34;key2\u0026#34;:\u0026#34;value2\u0026#34;}\u0026#39; 4. 发送表单数据 1 2 3 curl -X POST https://example.com/form \\ -d \u0026#39;username=admin\u0026#39; \\ -d \u0026#39;password=123456\u0026#39; 5. 上传文件 1 2 curl -X POST https://example.com/upload \\ -F \u0026#34;file=@/path/to/file.txt\u0026#34; 6. 设置请求头 1 2 3 curl -H \u0026#34;Authorization: Bearer token\u0026#34; \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ https://example.com/api 7. 下载文件 1 curl -O https://example.com/file.zip 8. 跟随重定向 1 curl -L https://example.com/redirect 9. 使用基本认证 1 curl -u username:password https://example.com 10. 保存 cookie 并发送 1 2 3 4 5 # 保存 cookie curl -c cookies.txt https://example.com/login -d \u0026#34;user=name\u0026amp;pass=123\u0026#34; # 使用 cookie curl -b cookies.txt https://example.com/dashboard 11. 测试 API 响应时间 1 curl -o /dev/null -s -w \u0026#39;Total: %{time_total}s\\n\u0026#39; https://example.com 12. 限制下载速度 1 curl --limit-rate 100K -O https://example.com/largefile.zip 高级用法 发送多部分请求 1 2 3 curl -X POST https://example.com/upload \\ -F \u0026#34;file=@image.jpg\u0026#34; \\ -F \u0026#34;description=My image\u0026#34; 使用代理 1 curl -x http://proxy.example.com:8080 https://example.com 调试请求 1 curl -v https://example.com 只获取响应头 1 curl -I https://example.com 使用自定义 User-Agent 1 curl -A \u0026#34;Mozilla/5.0 (Windows NT 10.0; Win64; x64)\u0026#34; https://example.com 注意事项 在脚本中使用 curl 时，考虑添加 -s 或 -sS 选项（-S 显示错误） 处理 JSON 数据时，可以使用 jq 工具进行格式化 对于复杂的 API 调用，考虑将请求保存为文件并使用 -K 选项 在 Windows 上，使用双引号 \u0026quot; 而不是单引号 ' ","date":"2025-07-01T00:00:00Z","permalink":"/p/curl%E4%BD%BF%E7%94%A8/","title":"Curl使用"},{"content":" 本文是对 cilium/ebpf examples: fentry 的一个学习记录\n在这里，我们将会利用 ebpf 监控并记录系统上所有新发起的 IPv4 TCP 连接，其工作流程如下：\n挂载点：程序使用 fentry 机制把自己附加到内核函数 tcp_connect 的入口。每当系统中有任何一个进程尝试发起一个 TCP 连接时，这个内核函数就会被调用，从而触发我们的 eBPF 程序。 过滤：程序首先检查连接的地址族是否为 AF_INET，即 IPv4。如果不是（例如是 IPv6），程序会直接退出，不做任何处理。 数据提取：对于 IPv4 连接，程序会从传递给 tcp_connect 函数的 struct sock 参数中提取以下关键信息： 源 IP 地址 (saddr) 目标 IP 地址 (daddr) 目标端口 (dport) 源端口 (sport) 获取进程信息：使用 bpf_get_current_comm() 辅助函数获取当前发起连接的进程名（例如 curl, ssh 等）。 数据发送：程序将收集到的所有信息（IP 地址、端口、进程名）打包成一个 struct event 结构体，并通过一个高效的 ringbuf 映射发送到用户空间。 什么是 fentry fentry 是 eBPF 中的一种程序附加类型，全称为 \u0026ldquo;function entry\u0026rdquo;（函数入口）。它是现代 Linux 内核中用于跟踪和性能分析的高效机制。\nfentry 允许将 eBPF 程序附加到内核函数的入口点，当该函数被调用时，eBPF 程序会在函数的主体执行前运行。这种机制让我们可以：\n拦截函数调用 检查函数参数 收集统计数据 修改执行路径 为什么使用 fentry 使用 fentry（函数入口钩子）来跟踪 TCP 连接，相比于 kprobe 或其他方式，主要有以下几个优点：\n性能更高：fentry 是 BPF 的原生钩子，直接插入到内核函数入口，开销比 kprobe 更低，延迟更小，适合高频事件的跟踪。 更安全稳定：fentry 直接集成在内核 BPF 框架中，API 更加稳定，不容易因为内核升级或符号变化而失效。而 kprobe 依赖于符号解析，容易受到内核实现细节变化影响。 类型安全：fentry 支持 BTF（BPF Type Format），可以直接访问函数参数，类型安全且易于开发。而 kprobe 只能通过寄存器或栈手动解析参数，容易出错。 更好的可维护性：fentry 程序更容易与内核源码保持同步，代码更简洁，维护成本更低。 而 kprobe 适合没有 fentry 支持的老内核或特殊场景。\n内核态 C 程序 1 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 52 53 //go:build ignore #include \u0026#34;../vmlinux/vmlinux.h\u0026#34; #include \u0026lt;bpf/bpf_endian.h\u0026gt; #include \u0026lt;bpf/bpf_helpers.h\u0026gt; #include \u0026lt;bpf/bpf_tracing.h\u0026gt; #define AF_INET 2 #define TASK_COMM_LEN 16 char __license[] SEC(\u0026#34;license\u0026#34;) = \u0026#34;Dual MIT/GPL\u0026#34;; // struct { __uint(type, BPF_MAP_TYPE_RINGBUF); __uint(max_entries, 1 \u0026lt;\u0026lt; 24); __type(value, struct event); } events SEC(\u0026#34;.maps\u0026#34;); // 用于捕获和传递 TCP 连接事件的关键信息，通常在 eBPF 程序监控网络活动时使用。 // 当程序检测到新的 TCP连接时，会将连接的详细信息打包到这个结构体中，然后发送给用户空间程序进行处理。 struct event { u8 comm[16]; // 进程名称（最多15个字符 + null终止符） __u16 sport; // 源端口（主机字节序） __be16 dport; // 目标端口（网络字节序，big-endian） __be32 saddr; // 源IP地址（网络字节序，big-endian） __be32 daddr; // 目标IP地址（网络字节序，big-endian） }; SEC(\u0026#34;fentry/tcp_connect\u0026#34;) int BPF_PROG(tcp_connect, struct sock* sk) { if (sk-\u0026gt;__sk_common.skc_family != AF_INET) { return 0; } struct event* tcp_info; tcp_info = bpf_ringbuf_reserve(\u0026amp;events, sizeof(struct event), 0); if (!tcp_info) { return 0; } tcp_info-\u0026gt;saddr = sk-\u0026gt;__sk_common.skc_rcv_saddr; tcp_info-\u0026gt;daddr = sk-\u0026gt;__sk_common.skc_daddr; tcp_info-\u0026gt;dport = sk-\u0026gt;__sk_common.skc_dport; tcp_info-\u0026gt;sport = bpf_htons(sk-\u0026gt;__sk_common.skc_num); bpf_get_current_comm(\u0026amp;tcp_info-\u0026gt;comm, TASK_COMM_LEN); bpf_ringbuf_submit(tcp_info, 0); return 0; } 使用到的 bpf helper function bpf_ringbuf_reserve() 作用：从环形缓冲区（ring buffer）中预留一块内存空间\n参数 1：\u0026amp;events - 环形缓冲区的引用 参数 2：sizeof(struct event) - 需要预留的字节数 参数 3：0 - 标志位（通常为 0） 返回值：指向预留内存的指针，失败时返回 NULL 关键点：这是一个 \u0026quot; 预留 - 提交 \u0026quot; 模式的第一步，先申请空间但数据还未对用户空间可见。\nbpf_htons() 作用：将主机字节序转换为网络字节序（Host TO Network Short）。网络协议使用大端序，而主机可能使用小端序，需要转换保证数据一致性。\n参数：16 位的端口号 返回值：网络字节序的端口号 bpf_get_current_comm() 作用：获取当前进程的命令名称（进程名）\n参数 1：\u0026amp;tcp_info-\u0026gt;comm - 存储进程名的缓冲区指针 参数 2：TASK_COMM_LEN - 缓冲区大小（通常是 16 字节） bpf_ringbuf_submit() 作用：将之前预留的数据提交到环形缓冲区，使其对用户空间可见\n参数 1：tcp_info - 之前通过 bpf_ringbuf_reserve() 获得的指针 参数 2：0 - 标志位 用户态 Go 程序 1 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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 package main import ( \u0026#34;bytes\u0026#34; \u0026#34;encoding/binary\u0026#34; \u0026#34;errors\u0026#34; \u0026#34;log\u0026#34; \u0026#34;net\u0026#34; \u0026#34;os\u0026#34; \u0026#34;os/signal\u0026#34; \u0026#34;syscall\u0026#34; \u0026#34;github.com/cilium/ebpf/link\u0026#34; \u0026#34;github.com/cilium/ebpf/ringbuf\u0026#34; \u0026#34;github.com/cilium/ebpf/rlimit\u0026#34; ) //go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags \u0026#34;-O2 -g -Wall -Werror -D__TARGET_ARCH_x86\u0026#34; bpf fentry_tcp_connect.bpf.c func main() { stopper := make(chan os.Signal, 1) signal.Notify(stopper, os.Interrupt, syscall.SIGTERM) // 移除内存限制 if err := rlimit.RemoveMemlock(); err != nil { log.Fatal(err) } objs := bpfObjects{} if err := loadBpfObjects(\u0026amp;objs, nil); err != nil { log.Fatalf(\u0026#34;loading bpf objects: %v\u0026#34;, err) } defer objs.Close() link, err := link.AttachTracing(link.TracingOptions{ Program: objs.TcpConnect, }) if err != nil { log.Fatalf(\u0026#34;linking Fentry: %v\u0026#34;, err) } defer link.Close() rd, err := ringbuf.NewReader(objs.Events) if err != nil { log.Fatalf(\u0026#34;opening ringbuf reader: %s\u0026#34;, err) } defer rd.Close() go func() { \u0026lt;-stopper if err := rd.Close(); err != nil { log.Fatalf(\u0026#34;closing ringbuf reader: %s\u0026#34;, err) } }() log.Printf(\u0026#34;%-16s %-15s %-6s -\u0026gt; %-15s %-6s\u0026#34;, \u0026#34;Comm\u0026#34;, \u0026#34;Src addr\u0026#34;, \u0026#34;Port\u0026#34;, \u0026#34;Dest addr\u0026#34;, \u0026#34;Port\u0026#34;, ) // 不断从 ringbuf 中读取事件、解析事件并打印相关信息，直到接收到终止信号 var event bpfEvent for { record, err := rd.Read() if err != nil { if errors.Is(err, ringbuf.ErrClosed) { log.Println(\u0026#34;received signal, exiting..\u0026#34;) return } log.Printf(\u0026#34;reading from reader: %s\u0026#34;, err) continue } // Parse the ringbuf event entry into a bpfEvent structure. if err := binary.Read(bytes.NewBuffer(record.RawSample), binary.BigEndian, \u0026amp;event); err != nil { log.Printf(\u0026#34;parsing ringbuf event: %s\u0026#34;, err) continue } log.Printf(\u0026#34;%-16s %-15s %-6d -\u0026gt; %-15s %-6d\u0026#34;, event.Comm, intToIP(event.Saddr), event.Sport, intToIP(event.Daddr), event.Dport, ) } } // intToIP converts IPv4 number to net.IP func intToIP(ipNum uint32) net.IP { ip := make(net.IP, 4) binary.BigEndian.PutUint32(ip, ipNum) return ip } ","date":"2025-06-22T00:00:00Z","permalink":"/p/ebpf%E5%AD%A6%E4%B9%A0%E4%BD%BF%E7%94%A8fentry%E8%B7%9F%E8%B8%AAtcp%E8%BF%9E%E6%8E%A5/","title":"【eBPF学习】使用fentry跟踪tcp连接"},{"content":"单调栈中保存每个柱子在 heights 中的下标，从栈底到栈顶的元素对应的柱子的高度是单调递增的。\n顺序遍历 heights，如果 heights[i] 比栈顶对应柱子高度更小（heights[i] \u0026lt; heights[st.top()]），那么说明 heights[st.top()] 这个高度在当前柱子加入后已经没有了作用（现在最小的高度是当前柱子，那么要算矩形面积的时候也只会用 heights[i] 来作为矩形的高）。\n我们每次计算的矩形为 [栈顶元素代表的矩形, i对应的矩形)：\n在弹出栈顶元素==后==，矩形的宽（weight）应该为 i - st.top() - 1，如果栈为空了就为 i - (-1) - 1 -\u0026gt; i。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution { public: int largestRectangleArea(vector\u0026lt;int\u0026gt;\u0026amp; heights) { stack\u0026lt;int\u0026gt; st; int res = 0; heights.push_back(-1); for (int i = 0; i \u0026lt; heights.size(); ++i) { while (!st.empty() \u0026amp;\u0026amp; heights[i] \u0026lt; heights[st.top()]) { int h = heights[st.top()]; st.pop(); int w = i; // 即 w = i - (-1) - 1 if (!st.empty()) { w = i - st.top() - 1; } res = max(res, h * w); } st.push(i); } return res; } }; ","date":"2025-06-22T00:00:00Z","permalink":"/p/84.-%E6%9F%B1%E7%8A%B6%E5%9B%BE%E4%B8%AD%E6%9C%80%E5%A4%A7%E7%9A%84%E7%9F%A9%E5%BD%A2/","title":"84. 柱状图中最大的矩形"},{"content":"透明运算符的概念与价值 在 C++ 编程中，当编写泛型代码时，不同类型的比较或操作可能导致意外的类型转换或精度损失。假设有一个 std::vector\u0026lt;uint32_t\u0026gt;，使用自定义仿函数进行排序，一切运行正常。但当你将容器改为 std::vector\u0026lt;uint64_t\u0026gt; 却忘记修改仿函数的实现时，编译器不会报错，但数据可能在比较前被静默截断，导致排序结果完全错误。而 C++14 引入透明运算符可以帮助我们避免这种 bug。\n透明运算符（Transparent Operator）是 C++14 引入的一项强大特性，它通过 std::less\u0026lt;\u0026gt;、std::greater\u0026lt;\u0026gt; 等空模板参数的运算符函子实现，允许编译器在模板实例化时自动推导操作数的实际类型，从而避免不必要的类型转换和潜在错误。与传统的重载运算符不同，透明运算符的核心优势在于其类型透明性——它们不会强迫操作数转换为特定类型，而是根据操作数的实际类型进行推导，保留完整的类型信息。\n传统 C++ 运算符重载需要严格定义操作数类型，这使得编写真正通用的泛型代码变得困难。例如，当我们使用 std::less\u0026lt;int\u0026gt; 进行比较时，它会强制将两个操作数都视为 int 类型，如果操作数实际是 long 或 double，就可能发生精度损失或意外的类型转换。而透明运算符如 std::less\u0026lt;\u0026gt; 则解决了这一问题，它本质上是一个模板化的函子，能够自动适应操作数的类型，保持代码的通用性和安全性。\n实现原理 透明运算符的神奇之处在于其简洁而精妙的实现机制。让我们深入探究其工作原理，揭开这层看似简单的语法糖衣下蕴含的强大能力。\n模板元编程技巧 透明运算符的核心实现依赖于空模板参数列表（operator\u0026lt;\u0026gt;）这一巧妙设计。观察 std::less 的标准库实现，我们会发现它提供了两种形式：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // 传统形式：指定比较类型 template \u0026lt;class T\u0026gt; struct less { bool operator()(const T\u0026amp; lhs, const T\u0026amp; rhs) const; }; // 透明形式：自动类型推导 template \u0026lt;\u0026gt; struct less\u0026lt;void\u0026gt; { template \u0026lt;class T, class U\u0026gt; auto operator()(T\u0026amp;\u0026amp; t, U\u0026amp;\u0026amp; u) const -\u0026gt; decltype(std::forward\u0026lt;T\u0026gt;(t) \u0026lt; std::forward\u0026lt;U\u0026gt;(u)); }; 当使用 std::less\u0026lt;\u0026gt; 时，我们特化到了 less\u0026lt;void\u0026gt;，它包含一个泛化的函数调用运算符。这个运算符是模板成员函数，接受任意类型的两个参数 T 和 U，并返回它们比较的结果。\n类型推导与完美转发 透明运算符的实现依赖于两个现代 C++ 核心特性：自动类型推导和完美转发。当编译器遇到 std::less\u0026lt;\u0026gt;{}(a, b) 这样的表达式时：\n模板参数推导：编译器根据参数 a 和 b 的实际类型推导出模板参数 T 和 U 完美转发：通过 std::forward 保持参数的值类别（左值/右值），避免不必要的拷贝 返回类型推导：使用 decltype 自动推导比较结果的准确类型，保留常量性、引用性等类型信息 这种机制确保了比较操作以最直接的方式进行，不引入任何中间转换。例如，比较 int 和 double 时，编译器会直接生成 int 与 double 比较的代码，遵循标准的算术类型转换规则，而不是先将两者转换为同一类型。\n1 std::pirntln(\u0026#34;{}\u0026#34;, std::less\u0026lt;\u0026gt;{}(1, 2.1)); // 输出 true 🌰 std::less 实现 1 2 3 4 5 6 7 8 struct less\u0026lt;\u0026gt; { template \u0026lt;typename T, typename U\u0026gt; auto operator()(T\u0026amp;\u0026amp; t, U\u0026amp;\u0026amp; u) const -\u0026gt; decltype(std::forward\u0026lt;T\u0026gt;(t) \u0026lt; std::forward\u0026lt;U\u0026gt;(u)) { return std::forward\u0026lt;T\u0026gt;(t) \u0026lt; std::forward\u0026lt;U\u0026gt;(u); } }; 这个简洁的实现包含了透明运算符的所有精髓：\n通用引用：T\u0026amp;\u0026amp; 和 U\u0026amp;\u0026amp; 可绑定到任何类型的左值或右值 完美转发：保持操作数的原始类型和值类别 后置返回类型：使用 decltype 确保返回类型与原生运算符完全一致 无约束模板：接受任何可比较类型，不限制操作数必须为相同类型 对比 传统实现方式及其局限 在透明运算符出现之前，C++ 开发者通常需要编写冗长的泛型仿函数或模板类来实现类似功能。考虑一个需要泛型比较的场景，传统实现可能如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // 自定义泛型比较仿函数 struct GenericLess { template\u0026lt;typename T, typename U\u0026gt; auto operator()(T\u0026amp;\u0026amp; t, U\u0026amp;\u0026amp; u) const -\u0026gt; decltype(std::forward\u0026lt;T\u0026gt;(t) \u0026lt; std::forward\u0026lt;U\u0026gt;(u)) { return std::forward\u0026lt;T\u0026gt;(t) \u0026lt; std::forward\u0026lt;U\u0026gt;(u); } }; // 使用示例 std::vector\u0026lt;int\u0026gt; v = {5, 3, 8, 1, 4}; std::sort(v.begin(), v.end(), GenericLess()); 这种方式虽然可行，但存在几个明显问题：\n代码冗余：每个运算符都需要单独定义仿函数 可读性差：需要命名并实例化仿函数对象 维护成本：自定义实现可能不一致或包含错误 缺乏标准化：不同开发者可能有不同的实现风格 另一种替代方案是使用C++14 多态 lambda 表达式：\n1 2 3 4 std::sort(v.begin(), v.end(), [](auto\u0026amp;\u0026amp; t, auto\u0026amp;\u0026amp; u) -\u0026gt; decltype(auto) { return std::forward\u0026lt;decltype(t)\u0026gt;(t) \u0026lt; std::forward\u0026lt;decltype(u)\u0026gt;(u); }); 虽然更紧凑，但语法复杂，可读性低，特别是对于不熟悉现代 C++ 的开发者。\n透明运算符的简洁实现 对比传统方案，透明运算符提供了一种标准化、简洁且安全的替代方案：\n1 std::sort(v.begin(), v.end(), std::less\u0026lt;\u0026gt;()); 这行代码包含了透明运算符的所有优势：\n零冗余：直接使用标准库组件，无需自定义实现 类型安全：自动推导操作数类型，避免截断或错误转换 完美转发：保持操作数值类别，优化性能 标准化：所有开发者使用统一、可靠的实现 📝 备注 但是需要注意的是 v 中的元素需要重载 operator\u0026lt;，否则会编译错误。\n类型安全对比 考虑一个具体示例，突显透明运算符如何防止类型截断错误：\n1 2 3 4 5 6 7 8 9 std::vector\u0026lt;uint64_t\u0026gt; big_nums = {UINT64_MAX, 1, UINT64_MAX-1}; // 危险的传统方式：使用固定类型的比较器 std::sort(big_nums.begin(), big_nums.end(), std::less\u0026lt;uint32_t\u0026gt;()); // 发生uint64_t到uint32_t的静默截断，排序结果错误！ // 安全的透明运算符方式： std::sort(big_nums.begin(), big_nums.end(), std::less\u0026lt;\u0026gt;()); // 保持uint64_t比较，结果正确 传统方式中，std::less\u0026lt;uint32_t\u0026gt; 强制将 uint64_t 转换为 uint32_t，可能导致高位截断。而 std::less\u0026lt;\u0026gt; 保留原始类型，进行正确的比较。\n应用场景与实践 透明运算符在现代 C++ 开发中有多种关键应用场景：\n泛型算法与容器：在 std::sort、std::set、std::map 等需要比较操作的泛型算法和容器中使用透明运算符，可避免类型限制，提高代码的通用性。 1 2 3 4 5 6 // 使用透明比较器的set可接受多种兼容类型查找 std::set\u0026lt;std::string, std::less\u0026lt;\u0026gt;\u0026gt; transparent_set; transparent_set.insert(\u0026#34;hello\u0026#34;); // 直接使用const char*查找，无需构造临时std::string auto it = transparent_set.find(\u0026#34;world\u0026#34;); 异构查找：透明运算符支持异构查找，允许在关联容器中使用与键类型不同的对象进行查找，避免不必要的临时对象创建。 1 2 3 4 5 6 std::map\u0026lt;std::string, int, std::less\u0026lt;\u0026gt;\u0026gt; transparent_map; // 插入时需要构造string（正常行为） transparent_map.emplace(\u0026#34;key\u0026#34;, 42); // 查找时可直接使用char*，无需构造临时string auto pos = transparent_map.find(\u0026#34;key\u0026#34;); 自定义类型处理：当创建自定义数值类型包装器时，透明运算符提供与内置类型一致的操作体验。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class TransparentInt { int value; public: // 转换运算符支持透明比较 operator int() const { return value; } // 透明运算符友好的设计 template \u0026lt;typename T\u0026gt; friend bool operator==(const TransparentInt\u0026amp; lhs, T\u0026amp;\u0026amp; rhs) { return lhs.value == std::forward\u0026lt;T\u0026gt;(rhs); } }; TransparentInt ti{42}; if (ti == 42.0) { // 与double直接比较 // ... } 性能敏感场景：在需要避免不必要的临时对象创建和类型转换的高性能代码中，透明运算符可减少开销。 总结 C++ 透明运算符代表了类型安全和泛型编程的重要演进。通过提供类型自适应的操作，它们解决了长期存在的类型截断和意外转换问题，使泛型代码更安全、更简洁。\n透明运算符的核心优势可总结为：\n类型安全增强：消除因类型不匹配导致的静默错误，如整数截断、有符号/无符号不匹配等问题。 代码简化：减少自定义仿函数和模板特化的需求，使代码更简洁可读。 性能优化：避免不必要的临时对象创建和类型转换，提升运行时效率。 异构支持：启用关联容器的异构查找能力，提高 API 灵活性。 标准化实践：提供一致、可靠的实现方式，减少重复造轮子和错误。 随着现代 C++ 的发展，透明运算符已成为专业 C++ 开发的基础工具。它们与 C++20 概念、范围等特性协同工作，构建更安全、更表达力的泛型代码。掌握透明运算符不仅提升现有代码质量，也为理解更高级的现代 C++ 特性奠定基础。\n\u0026quot; 透明运算符解决了泛型编程中的一个基本矛盾：我们既希望代码通用，又希望操作具体。它通过将类型决策推迟到最后一刻——实例化时刻——实现了这一平衡。\u0026quot; —— C++ 标准委员会专家观点\n","date":"2025-06-20T00:00:00Z","permalink":"/p/c-%E9%80%8F%E6%98%8E%E8%BF%90%E7%AE%97%E7%AC%A6/","title":"C++透明运算符"},{"content":" 本文是对于 Eunomia Tutorials 2 的一个学习记录\n什么是 kprobe Kprobe​​（Kernel Probe）是 Linux 内核提供的一项强大功能，它允许开发者和系统管理员在不​​修改内核源代码​​或重启系统的前提下，在任意内核函数处动态插入“探针”：\n​​工作原理​​：通过​​临时替换​​目标函数的前几条指令为一个断点指令（如 int3） ​​执行流程​​：当程序执行到断点时，CPU 控制权会交给 kprobe 系统 ​​事件回调​​：系统执行注册的回调函数，完成数据采集后恢复原函数执行 ​​两种类型​​： ​​Kprobe​​：在函数入口处执行 ​​Kretprobe​​：在函数返回时执行 这种机制为我们提供了​​零侵入式​​的内核行为洞察能力，特别适用于​​实时监控​​、​​性能分析​​和​​故障排查​​等场景。\ndo_unlinkat 的作用 do_unlinkat 是 Linux 内核中的一个内部函数，它的作用是执行文件或目录的删除操作。其在内核源码中的定义如下：\n1 2 3 static int do_unlinkat(int dfd, struct filename *name) { ... } do_unlinkat 是内核中实际执行文件删除逻辑的最终汇聚点 用户空间调用 unlink()、unlinkat() 或 rmdir() 等系统调用时，最终都会通过系统调用表路由到这个函数 采用文件描述符 (AT_FDCWD) 和路径名的组合方式，提供了灵活的路径解析能力 vmlinux.h 不同内核版本之间，内核数据结构如结构体字段位置、字段名称等都可能发生变化。传统的 eBPF 程序直接使用内核头文件会导致：\n兼容性问题：程序在​​不同内核版本​​中崩溃 字段偏移错误：读取到​​无效内存数据​​ 维护困难：需要针对​​每个内核版本​​进行适配 vmlinux.h 利用内核的​​BTF（BPF Type Format）​​ 信息生成与当前运行内核完全匹配的类型定义：\n1 bpftool btf dump file /sys/kernel/btf/vmlinux format c \u0026gt; vmlinux.h 内核态程序 kprobe_unlink.bpf.c 为 do_unlinkat 函数同时设置入口探针和返回探针：\n​​函数入口​​： 捕获调用进程的 PID 获取要删除的文件名 ​​函数返回​​： 捕获调用进程的 PID 获取函数返回值（删除操作的结果） 1 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 //go:build ignore #include \u0026#34;vmlinux.h\u0026#34; // 下面几个头文件需要安装了libbpf库 #include \u0026lt;bpf/bpf_core_read.h\u0026gt; #include \u0026lt;bpf/bpf_helpers.h\u0026gt; #include \u0026lt;bpf/bpf_tracing.h\u0026gt; char LICENSE[] SEC(\u0026#34;license\u0026#34;) = \u0026#34;Dual BSD/GPL\u0026#34;; SEC(\u0026#34;kprobe/do_unlinkat\u0026#34;) int BPF_KPROBE(do_unlinkat, int dfd, struct filename* name) { pid_t pid; const char* filename; pid = bpf_get_current_pid_tgid() \u0026gt;\u0026gt; 32; filename = BPF_CORE_READ(name, name); bpf_printk(\u0026#34;KPROBE ENTRY pid = %d, filename = %s\\n\u0026#34;, pid, filename); return 0; } SEC(\u0026#34;kretprobe/do_unlinkat\u0026#34;) int BPF_KRETPROBE(do_unlinkat_exit, long ret) { pid_t pid; pid = bpf_get_current_pid_tgid() \u0026gt;\u0026gt; 32; bpf_printk(\u0026#34;KPROBE EXIT: pid = %d, ret = %ld\\n\u0026#34;, pid, ret); return 0; } 用户态 Go 程序 用户态程序负责三个主要任务：\n​​环境初始化​​： 移除 eBPF 的内存限制 加载编译好的 eBPF 程序 ​​探针附加​​： 将 kprobe 绑定到 do_unlinkat 将 kretprobe 绑定到 do_unlinkat 的返回点 ​​日志处理​​： 读取内核的跟踪日志 筛选并显示与文件删除相关的事件 1 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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 package main import ( \u0026#34;bufio\u0026#34; \u0026#34;context\u0026#34; \u0026#34;fmt\u0026#34; \u0026#34;log\u0026#34; \u0026#34;os\u0026#34; \u0026#34;os/signal\u0026#34; \u0026#34;strings\u0026#34; \u0026#34;syscall\u0026#34; \u0026#34;time\u0026#34; \u0026#34;github.com/cilium/ebpf/link\u0026#34; \u0026#34;github.com/cilium/ebpf/rlimit\u0026#34; ) func main() { // 移除内存限制 if err := rlimit.RemoveMemlock(); err != nil { log.Fatal(err) } objs := bpfObjects{} if err := loadBpfObjects(\u0026amp;objs, nil); err != nil { log.Fatalf(\u0026#34;loading bpf objects: %v\u0026#34;, err) } defer objs.Close() // SEC(\u0026#34;kprobe/do_unlinkat\u0026#34;) kp, err := link.Kprobe(\u0026#34;do_unlinkat\u0026#34;, objs.DoUnlinkat, nil) if err != nil { log.Fatalf(\u0026#34;linking kprobe: %v\u0026#34;, err) } defer kp.Close() // SEC(\u0026#34;kretprobe/do_unlinkat\u0026#34;) krep, err := link.Kretprobe(\u0026#34;do_unlinkat\u0026#34;, objs.DoUnlinkatExit, nil) if err != nil { log.Fatalf(\u0026#34;linking kretprobe: %v\u0026#34;, err) } defer krep.Close() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() fmt.Println(\u0026#34;eBPF 程序已成功加载，开始监控文件删除操作...\u0026#34;) fmt.Println(\u0026#34;请在另一个终端执行文件删除操作，如: rm test.txt\u0026#34;) fmt.Println(\u0026#34;按 Ctrl+C 退出\u0026#34;) // 启动协程读取内核日志 go readTraceLog() // 等待中断信号 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() \u0026lt;-ctx.Done() fmt.Println(\u0026#34;\\n正在退出...\u0026#34;) } // 读取内核跟踪日志 func readTraceLog() { file, err := os.Open(\u0026#34;/sys/kernel/debug/tracing/trace_pipe\u0026#34;) if err != nil { log.Printf(\u0026#34;无法打开 trace_pipe: %v\u0026#34;, err) log.Printf(\u0026#34;提示: 请确保以 root 权限运行程序\u0026#34;) return } defer file.Close() scanner := bufio.NewScanner(file) for scanner.Scan() { line := scanner.Text() // 只显示我们程序产生的日志 if strings.Contains(line, \u0026#34;KPROBE ENTRY\u0026#34;) || strings.Contains(line, \u0026#34;KPROBE EXIT\u0026#34;) { fmt.Printf(\u0026#34;[%s] %s\\n\u0026#34;, time.Now().Format(\u0026#34;15:04:05\u0026#34;), line) } } if err := scanner.Err(); err != nil { log.Printf(\u0026#34;读取 trace_pipe 出错: %v\u0026#34;, err) } } 编译与运行 Makefile：\n1 2 3 4 5 6 7 8 9 10 11 12 # 1. 克隆项目并进入目录 git clone https://github.com/kerolt/learn-ebpf # 2. 生成vmlinux.h ./vmlinux/update.sh # 3. 生成.bpf.c对应的go代码和字节码 cd kprobe_unlink go generate # 4. 运行程序 sudo go run . 运行后会得到类似的结果：\n","date":"2025-06-10T00:00:00Z","permalink":"/p/ebpf%E5%AD%A6%E4%B9%A0%E4%BD%BF%E7%94%A8kprobe%E7%9B%91%E6%B5%8B%E6%8D%95%E8%8E%B7unlink%E7%B3%BB%E7%BB%9F%E8%B0%83%E7%94%A8/","title":"【eBPF学习】使用kprobe监测捕获unlink系统调用"},{"content":"如何修改上次的 commit 比如有的时候 commit 漏掉了文件、commit 信息写错了，可以这么做：\n你要指定一个有效的编辑器作为 Git 的默认编辑器，可以使 Vim 或者 VSCode：\n1 git config --global core.editor \u0026#34;vim\u0026#34; # 使用 VSCode 的话就用 code 然后将上次忘记提交的修改先 git add （如果有的话），接着：\n1 git commit --amend --amend 会用新的内容替换上一次提交，而不是创建一个新的提交。如果上一次提交已经被推送到远程仓库，使用 --amend 后需要强制推送：\n1 git push --force 这个方法我有时候用来同步一下两台电脑上的代码，因为有时候一台电脑上写了一部分代码但是还没有到一次提交的地步，这时如果要外出或者要用另一台电脑了，就可以用这个方法来同步一下。\n不过，使用 git push --force 强制推送后，远程仓库的历史记录会被改写。这种操作可能会导致本地和远程分支的提交历史不一致。我自己通常这么解决：\n1 2 git fetch origin git reset --hard origin/\u0026lt;branch\u0026gt; 这一般是不需要保留本地未推送的更改，可以直接将本地分支强制更新为远程分支的状态。\ngit 如何移除 submodule 1 2 3 4 5 git submodule deinit -f path/to/submodule git rm -f path/to/submodule rm -rf path/to/submodule # 如果要删除物理目录 git commit -m \u0026#34;Removed submodule path/to/submodule\u0026#34; rm -rf .git/modules/path/to/submodule # 可选清理 推送分支 第一次推送（远程分支可能还不存在） 1 git push -u origin 分支名 ## 推送到远程的另一个分支名 如果想把本地分支 dev 推送到远程的 test 分支：\n1 git push origin dev:test ","date":"2025-05-18T00:00:00Z","permalink":"/p/git%E4%BD%BF%E7%94%A8/","title":"Git使用"},{"content":" https://leetcode.cn/problems/koko-eating-bananas/\n假设每小时吃 max(piles) 根香蕉，那么按照题意（向上取整）来说就只要 piles.length 个小时就可以吃完。而注意提示中的：piles.length \u0026lt;= h \u0026lt;= 10^9，这说明每小时吃 max(piles) 根香蕉已经是最大的速度了，再快也没用了。因此需要去找比 max(piles) 小的且满足题意的数。\n那取 max(piles) 作为右边界，左边界取 1 （因为总不可能不吃吧~），然去通过二分去找最小的满足条件的速度。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Solution { public: int minEatingSpeed(vector\u0026lt;int\u0026gt;\u0026amp; piles, int h) { int left = 1, right = *ranges::max_element(piles); auto check = [\u0026amp;](int x) { long long sum{}; for (int p : piles) { sum += (p + x - 1) / x; } return sum \u0026lt;= h; }; while (left \u0026lt;= right) { int mid = (left + right) \u0026gt;\u0026gt; 1; if (check(mid)) { right = mid - 1; } else { left = mid + 1; } } return left; } }; 相似题目：\nhttps://leetcode.cn/problems/find-the-smallest-divisor-given-a-threshold/ ","date":"2025-05-07T00:00:00Z","permalink":"/p/875.-%E7%88%B1%E5%90%83%E9%A6%99%E8%95%89%E7%9A%84%E7%8F%82%E7%8F%82/","title":"875. 爱吃香蕉的珂珂"},{"content":"这个 project 主要是希望我们理解 MVCC 的一些相关知识。MVCC 的设计目标是通过维护多个版本的元组（tuple），使得不同的事务能够访问到与其时间戳一致的数据版本，而无需通过锁机制完全阻塞其他事务的操作。具体来说：\n每个事务在启动时会被分配一个唯一的 事务 ID 或 读取时间戳 。 数据库系统会根据事务的读取时间戳，决定该事务能看到哪些数据版本： 可见性规则 ：事务只能看到在其读取时间戳之前提交的数据版本。 不可见性规则 ：事务不能看到在其读取时间戳之后提交或未提交的数据版本。 15445 的 lab 写了太久太久了，自己懒是一方面，实力不够也是一方面，task4 后面的 lab 就不打算写了（无奈.jpg）。在这整个过程中，学到的东西还挺多的，对于代码能力也有提升，以后有时间还是可以多看看配套的课程，然后再好好读读代码，写写注释和博客去深入理解理解，毕竟 bustub 的不论是代码架构还是规范我都觉得非常棒。后面就要像 15445 网页中写到的，去多关注生活中其他感兴趣的地方了，希望能够不断提升自己~\n0. Bustub 中的 undolog bustub 中在 TransactionManager 中通过一个哈希表来保存所有 tuple 的 undolog 起点：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class TransactionManager { public: ... struct PageVersionInfo { /** protects the map */ std::shared_mutex mutex_; /** Stores previous version info for all slots. Note: DO NOT use `[x]` to access it because * it will create new elements even if it does not exist. Use `find` instead. */ std::unordered_map\u0026lt;slot_offset_t, VersionUndoLink\u0026gt; prev_version_; }; /** protects version info */ std::shared_mutex version_info_mutex_; /** Stores the previous version of each tuple in the table heap. Do not directly access this field. Use the helper * functions in `transaction_manager_impl.cpp`. */ std::unordered_map\u0026lt;page_id_t, std::shared_ptr\u0026lt;PageVersionInfo\u0026gt;\u0026gt; version_info_; ... } 其中的 VersionUndoLink 也只是对 UndoLink 的一个包装：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 struct UndoLink { /* Previous version can be found in which txn */ txn_id_t prev_txn_{INVALID_TXN_ID}; /* The log index of the previous version in `prev_txn_` */ int prev_log_idx_{0}; ... }; struct VersionUndoLink { /** The next version in the version chain. */ UndoLink prev_; /** Whether a transaction is modifying the version link. Fall 2023: you do not need to read / write this field until * task 4.2. */ bool in_progress_{false}; ... }; 一个 tuple 最新的数据会保存在 table heap 中，而 txn manager 通过这个 tuple 的 rid 可以获取到这个 tuple 的 undolog：\nexec_ctx_-\u0026gt;GetTransactionManager()-\u0026gt;GetUndoLink(rid);\n1. Task 1 - Timestamps 每个事务有两个时间戳：\nread_ts：事务开始时分配，表示该事务能看到的最新提交的数据版本。 commit_ts：事务提交时分配，是一个单调递增的逻辑时间戳，决定事务的串行化顺序。 规则如下：\n事务开始时（Begin），read_ts = last_commit_ts 事务提交时（Commit），commit_ts = ++last_commit_ts 对于 Watermark 机制，其定义是所有活跃事务中的最小 read_ts，作用为确定哪些数据版本已经不再被任何事务读取，从而可以安全地清理旧版本数据。\n这里很简单，就是需要在维护好系统中的 watermark，遍历事务映射中的所有事务，找出所有进行中事务中最小的 read_ts 为 watermark。在事务的 Begin、Commit、Abort 中需要使用 Watermark::AddTxn 和 Watermark::RemoveTxn 来更新 watermark。在更新时，可以使用红黑树或者哈希表 + 优先队列来快速找到最小的 read_ts。\n2. Task2 - Storage Format and Sequential Scan 2.1 Tuple Reconstruction 数据库需要一种机制来“回溯”数据的历史版本，而 ReconstructTuple 正是这种机制的核心实现。\n在 MVCC 系统中，表堆（table heap）通常存储的是最新的数据版本，而旧版本的元组信息则通过 undo logs 记录下来。当事务需要访问某个元组的历史版本时，ReconstructTuple 会从表堆中获取最新的元组，并根据 undo logs 逐步恢复出符合事务读取时间戳的版本。\n在 MVCC 系统中，数据的删除操作并不会立即从表堆中移除元组，而是通过设置 is_deleted 标志来标记逻辑删除。ReconstructTuple 在处理 undo logs 时会检查 is_deleted 标志，从而判断某个元组是否已被删除。ReconstructTuple 将始终应用提供给函数的所有修改，而无需查看元数据或撤销日志中的时间戳。除了函数参数列表中提供的数据外，它不需要访问其他数据。\nundo log 记录的是上次操作完的结果（换句话说，这次没操作前是什么样子），只有 modified_fields_ 是为了告诉这次操作，在上次修改完成之前 tuple 是什么样子。\nmodified_fields_：长度与 tuple 的 schema 相同，每个位代表在这次的操作中对于某个字段时候有更新 tuple_：其长度 \u0026lt;= 原始的 tuple，只记录了 modified_fields_ 中为 true 的字段，并且保存的值是本次操作之前的值 ReconstructTuple 的工作流程：\n从表堆中读取最新版本的元组（base_tuple）及其元数据（base_meta）。 初始化 tuple_values，存储元组的字段值。 获取最新元组 从表堆中读取最新版本的元组（base_tuple）及其元数据（base_meta）。 初始化 tuple_values，存储元组的字段值。 遍历 undo logs Undo logs 按时间戳降序排列，记录了元组的历史修改。 对于每个 undo log： 如果 is_deleted_ 为 true，标记元组为已删除。 否则，根据 modified_fields_ 更新 tuple_values 中对应字段的值。（所以这里类似于去覆盖更新） 生成结果元组 如果元组被标记为删除，返回空值。 否则，根据更新后的 tuple_values 构造并返回新的元组。 2.2 Sequential Scan / Tuple Retrieval 这里的意图就是需要在 bustub 执行 seq executor 算子时找到当前事务可以使用的 tuple。\n判断当前 tuple 是否对当前事务可见:\ncase1：tuple 的 ts_ 比 txn_read_ts 小，则 tuple 对当前事务可见 case2：tuple 的 ts_ 等于 txn_temp_ts，则 tuple 对当前事务可见 case3：不满足 case1 也不满足 case2，则 tuple 对当前事务不可见 如果是 case1 和 case2，说明当前事务可以直接使用 table heap 中的这条 tuple；而如果是 case3，则需要遍历这条 tuple 的版本链，找出可以用的 undolog，如果有就使用 ReconstructTuple 来重建一个可见的历史版本的 tuple。\n3. Task3 - MVCC Executors 在插入一条记录时，要把这条记录的主键值记下来，这样之后回滚时只需要把这个主键值对应的记录删掉就好了； 在删除一条记录时，要把这条记录中的内容都记下来，这样之后回滚时再把由这些内容组成的记录插入到表中就好了； 在更新一条记录时，要把被更新的列的旧值记下来，这样之后回滚时再把这些列更新为旧值就好了。 from 小林coding - 为什么需要-undo-log\n简单理解：改了什么，你就保存什么。\nInsert 在这一步只需要将插入的 tuple 的 rid 记录到事务 write set 中，然后在 txn manager 中设置新 tuple 的 undolog 链为空（相当于给这个 undolog 链初始化）。\n为什么 Insert 操作不需要 undo log\n在 MVCC（多版本并发控制）机制下，每个事务看到的数据版本取决于其时间戳。对于 INSERT 操作，由于插入的是全新的记录，不存在“历史版本”的问题。因此，不需要为 INSERT 操作维护 Undo Log 来支持 MVCC 中的版本链。\n当执行一个 INSERT 操作时，如果事务需要回滚，只需删除刚刚插入的记录即可，而无需像 UPDATE 或 DELETE 那样恢复旧数据状态。因此，INSERT 操作只需要记录被插入记录的主键信息，以便在回滚时定位并删除该记录。这种情况下，Undo Log 的作用非常有限，甚至可以省略。\n相比之下，UPDATE 和 DELETE 操作会创建新的版本或标记旧版本为无效，这些都需要通过 Undo Log 来记录和管理。\nUpdate \u0026amp; Delete 在修改操作（update 和 delete）中，对于 tuple 中的字段有两种情况：\n情况 1（当前事务在未提交前进行了修改）：由于每个事务都对当前操作的 tuple 的 undolog 只能保存一份，因此在生成本次的 undolog 后还需要需要合并上一次的 undolog。 情况 2（这是当前事务第一次修改）：创建一个新的 undolog，并把它添加到版本链中。 在完成修改操作后，需要更新 table heap 中的值：修改 tuple 的数据为更新后的值，同时更新 tuple 的 meta，其时间戳为当前事务的 temp ts（这可用于标识这个 tuple 正在被哪个事务修改）；然后还要将当前修改 tuple 的 id（也就是其 rid）添加到当前事务的 write set，这在事务提交时会根据 rid 来正确设置 table heap 中 tuple 的 ts。\nCommit 刚刚说到在修改时需要修改 tuple 的 meta 数据中的 ts 为当前事务的 temp ts，因为此时事务还没提交，使用 temp ts 就能分辨出现在这条 tuple 是否正在被修改：temp ts 是一个很大的数（TXN_START_ID + txn_id，其中 TXN_START_ID = 1LL \u0026lt;\u0026lt; 62），事务的 read ts 和 commit ts 都会小于这个数；在判断一个 tuple 是否可见时，都是通过事务的 read ts（txn.read_ts）与 tuple.meta.ts 进行比较，如果 tuple.meta.ts \u0026lt;= txn.read_ts，那么这个 tuple 对于 txn 是可见的，而如果 tuple.meta.ts == txn.temp_ts，则这个 tuple 正在被 txn 进行修改。\n这样的话，即便是在修改后 tuple.meta.ts 也只是事务的 temp ts，所以在 commit 时，需要将其设置为系统的最新 commit ts。这里需要做几点：\n获取当前系统的 commit ts，计算方式为 last_commit_ts_ + 1 遍历当前要提交的事务的 write set，更新修改了的 tuple 的 meta 数据，将 meta.ts 设置为 commit ts 设置事务的 commit ts，并且更新系统的 last_commit_ts GarbageCollection 之前一旦我们将事务添加到 txn manager 中，我们就永远不会删除它，因为 read_ts 较小的事务可能需要读取存储在先前已提交或已中止事务中的撤销日志。这里需要实现 TransactionManager::GarbageCollection() 函数，删除未使用的事务。\n一个事务在运行时只需要看到它自己的快照，一旦事务结束，它所依赖的数据版本就可以被考虑回收。只有当某个数据版本不再被任何活跃事务需要时，才能进行垃圾回收（GC）。\n举个🌰，假设我们有如下记录：\nRID ts_ 数据 A 1 Alice A 2 Bob A 3 Charlie A 4 David 这表示记录 A 被不同事务更新了四次。现在系统中有三个事务：\n事务 ID 状态 可见性范围 T2 RUNNING ts ≤ 2 T4 RUNNING ts ≤ 4 T5 RUNNING ts ≤ 5 watermark = 2（最小活跃事务 ID）\nQ1: T5 能看到 ts_=1 的数据吗？\n没问题，因为 T5 的 ts=5 \u0026gt; 1，且事务 1 已完成。\nQ2: ts_=1 的数据可以被清理吗？\n不可以，因为 T2（ts=2）还在运行，它的可见范围是 ts ≤ 2，所以它可能会访问 ts=1 的数据。\nQ3: 如果 T2 提交或中止，watermark 会变成多少？此时 ts=1 的数据能被清理吗？\nwatermark 更新为 min(4,5) = 4 此时所有活跃事务的 ts ≥ 4 所以 ts ≤ 3 的数据都对活跃事务不可见 → 可以安全清理 这里有一个关键点：可见 ≠ 不可清理\n可见性：表示某个事务是否能看到某条记录（比如 T5 能看到 ts_=3 的记录） 清理条件：表示这条记录是否仍然被任何一个活跃事务所需要，如果没有任何活跃事务再访问它，就可以清理 所以 GC 的步骤可以简单总结如下：\n遍历数据库中所有 tuple 判断 tuple.ts_ ≤ watermark → 不再被活跃事务访问 沿着 undo link 遍历 undo log 链 统计每个事务有多少 undo log 不再被访问 如果某个事务所有 undo log 都不可见，且事务已完成 → 删除其 undo log 和事务元信息 References https://blog.csdn.net/weixin_48885685/article/details/143225977 https://github.com/ZepengLi111/cmu15445-2023-fall-bustub Qwen ChatGPT ","date":"2025-05-06T00:00:00Z","permalink":"/p/cmu15-445-fall2023project4-concurrency-control-%E5%B0%8F%E7%BB%93/","title":"【CMU15-445 Fall2023】Project4 Concurrency Control 小结"},{"content":"C++17 为模板元编程带来了一个非常有用的特性：折叠表达式（fold expressions）。它的出现让变参模板函数的编写变得更加简洁、清晰和直观。\n1. 如何处理变长参数包 在 C++11/14 中，如果我们要处理变长参数包（parameter pack），通常需要递归展开，例如：\n1 2 3 4 5 6 7 8 9 10 template\u0026lt;typename T\u0026gt; void print(const T\u0026amp; t) { std::cout \u0026lt;\u0026lt; t \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; } template\u0026lt;typename T, typename... Args\u0026gt; void print(const T\u0026amp; t, const Args\u0026amp;... args) { std::cout \u0026lt;\u0026lt; t \u0026lt;\u0026lt; \u0026#34;, \u0026#34;; print(args...); } 虽然这段代码能完成任务，但却略显冗长，并且生成的汇编代码也会很多。C++17 中引入的折叠表达式，就是为了解决这个问题。\n可以在 godbolt 上看看两种方法生成的汇编代码：https://godbolt.org/z/55a7q85oE\n2. 什么是折叠表达式 折叠表达式是一种用运算符对参数包进行折叠的方式。折叠表达式的实例化按以下方式展开成表达式 e：\n（图片来源：https://zh.cppreference.com/w/cpp/language/fold）\n基本语法形式：\n类型 语法形式 示例 一元左折叠 (... op pack) (... + args) 一元右折叠 (pack op ...) (args + ...) 二元左折叠 (init op ... op pack) (0 + ... + args) 二元右折叠 (pack op ... op init) (args + ... + 0) 折叠表达式支持多种运算符，包括但不限于：\n算术运算符：+, -, *, / 逻辑运算符：\u0026amp;\u0026amp;, || 比较运算符：==, !=, \u0026lt;, \u0026gt; 位运算符：\u0026amp;, |, ^ 逗号运算符：, 需要注意的是：\n若参数包为空，使用二元折叠可设置初始值防止编译错误。 初始值放在哪边，就由哪一边“开始结合” 3. 折叠表达式示例 3.1 求和函数 1 2 3 4 template\u0026lt;typename... Args\u0026gt; auto sum(Args... args) { return (... + args); // 左折叠 } 使用：\n1 std::cout \u0026lt;\u0026lt; sum(1, 2, 3, 4); // 输出 10 3.2 打印变参 1 2 3 4 template\u0026lt;typename... Args\u0026gt; void print(Args\u0026amp;\u0026amp;... args) { (std::cout \u0026lt;\u0026lt; ... \u0026lt;\u0026lt; args) \u0026lt;\u0026lt; \u0026#39;\\n\u0026#39;; // 左折叠结合流输出 } 使用：\n1 print(\u0026#34;Hello, \u0026#34;, \u0026#34;fold \u0026#34;, \u0026#34;expression!\u0026#34;); // 输出 Hello, fold expression! 3.3 所有参数是否都满足某条件 1 2 3 4 template\u0026lt;typename... Args\u0026gt; bool all_true(Args... args) { return (... \u0026amp;\u0026amp; args); } 使用：\n1 bool result = all_true(true, true, false); // false 4. 与 std::cout 结合 std::cout \u0026lt;\u0026lt; a \u0026lt;\u0026lt; b \u0026lt;\u0026lt; c 是一个左结合的表达式，其等价于：\n1 ((std::cout \u0026lt;\u0026lt; a) \u0026lt;\u0026lt; b) \u0026lt;\u0026lt; c 因此，在使用折叠表达式来打印参数时，必须使用左折叠！！！。\n正确示例为：\n1 2 3 4 template\u0026lt;typename... Args\u0026gt; void print(Args\u0026amp;\u0026amp;... args) { (std::cout \u0026lt;\u0026lt; ... \u0026lt;\u0026lt; args); // 左折叠 } 这个展开方式等价于：\n1 (((std::cout \u0026lt;\u0026lt; arg1) \u0026lt;\u0026lt; arg2) \u0026lt;\u0026lt; arg3) ... 这样每次都把输出结果“继续”传给 std::cout \u0026lt;\u0026lt; ...，保持流操作链的有效性。\n但如果按下面的写法来：\n1 (std::cout \u0026lt;\u0026lt; args \u0026lt;\u0026lt; ...); 会导致编译错误或者语义错误，因为 std::cout 必须放在最左边，才能正确启动流操作链。\n5. 与 std::cout 和逗号运算符结合 对于逗号运算符：在 (a, b) 中，逗号运算符会先执行 a，再执行 b，返回 b 的值。这允许我们：\n在一次折叠中 执行多条语句 且确保顺序执行、无中间变量展开 例如下面的例子：\n1 2 3 4 5 6 7 8 9 10 #include \u0026lt;iostream\u0026gt; template\u0026lt;typename... Args\u0026gt; void print(Args... args) { ((std::cout \u0026lt;\u0026lt; args \u0026lt;\u0026lt; \u0026#34;, \u0026#34;), ...); } int main() { print(1, 3.0, \u0026#39;*\u0026#39;); } 其在编译器处理之后会是这个样子（使用这个工具 https://cppinsights.io/）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include \u0026lt;iostream\u0026gt; template\u0026lt;typename ... Args\u0026gt; void print(Args... args) { (((std::cout \u0026lt;\u0026lt; args) \u0026lt;\u0026lt; \u0026#34;, \u0026#34;), ...); } template\u0026lt;\u0026gt; void print\u0026lt;int, double, char\u0026gt;(int __args0, double __args1, char __args2) { (std::operator\u0026lt;\u0026lt;(std::cout.operator\u0026lt;\u0026lt;(__args0), \u0026#34;, \u0026#34;)) , ( (std::operator\u0026lt;\u0026lt;(std::cout.operator\u0026lt;\u0026lt;(__args1), \u0026#34;, \u0026#34;)) , (std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::cout, __args2), \u0026#34;, \u0026#34;)) ); } int main() { print(1, 3.0, \u0026#39;*\u0026#39;); return 0; } 值得注意的是：看起来 ... 在 args 的右边，但这依然是左折叠。\n","date":"2025-05-06T00:00:00Z","permalink":"/p/c-%E6%8A%98%E5%8F%A0%E8%A1%A8%E8%BE%BE%E5%BC%8F/","title":"C++折叠表达式"},{"content":"在 C++20 及更高版本中，可以使用 Concepts 来替代传统的基于虚函数的接口设计，这种方式提供了更好的编译时检查、更高效的代码生成和更灵活的接口约束。\n在之前，可能是这样的：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class IDrawable { public: virtual ~IDrawable() = default; virtual void draw() const = 0; }; class Circle : public IDrawable { public: void draw() override { puts(\u0026#34;Circle::draw()\u0026#34;); } }; class Squre : public IDrawable { public: void draw() override { puts(\u0026#34;Squre::draw()\u0026#34;); } }; void render(IDrawable\u0026amp; drawable) { drawable.draw(); } 但是有了 Concept 后：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 template \u0026lt;typename T\u0026gt; concept Drawable = requires(T t) { { t.draw() } -\u0026gt; std::same_as\u0026lt;void\u0026gt;; }; class Circle { public: void draw() const { puts(\u0026#34;Circle::draw()\u0026#34;); } }; class Squre { public: void draw() { puts(\u0026#34;Squre::draw()\u0026#34;); } }; template \u0026lt;Drawable T\u0026gt; void render(T\u0026amp; drawable) { drawable.draw(); } 优势：\n​​编译时多态​​：不需要运行时虚表查找，性能更高 ​​值语义​​：可以直接传递对象而不需要指针或引用 ​​更灵活的约束​​：可以约束多个不相关的类型 ​​更好的错误信息​​：编译错误更清晰明确 ​​无对象切片问题​​：因为不使用继承 ","date":"2025-04-28T00:00:00Z","permalink":"/p/%E4%BD%BF%E7%94%A8-concept-%E6%9B%BF%E4%BB%A3%E5%9F%BA%E4%BA%8E%E8%99%9A%E5%87%BD%E6%95%B0%E7%9A%84%E6%8E%A5%E5%8F%A3/","title":"使用 Concept 替代基于虚函数的接口"},{"content":"最近 Fedora42 更新了，因此我打算升级一下玩玩。在执行完下面的命令后：\n1 2 3 sudo dnf upgrade --refresh sudo dnf system-upgrade download --releasever=42 sudo dnf system-upgrade reboot 重启开机成了这样:(\n在网上搜索一圈后，执行以下命令系统就 ok 了：\n1 sudo dracut --regenerate-all --force 错误的原因可能是更新内核后没有正确生成新的 initramfs 文件，系统可能会使用旧的、不兼容的 initramfs 文件，从而导致启动失败。\n而 dracut 是一个用于生成 Linux 系统 initramfs（初始内存文件系统）的工具。运行 这条命令后，dracut 会重新生成与当前系统中所有已安装内核对应的 initramfs 文件。\n参考：https://www.reddit.com/r/Fedora/comments/1hfkqnq/boot_error_after_updating_to_6124200\n","date":"2025-04-27T00:00:00Z","permalink":"/p/%E8%A7%A3%E5%86%B3fedora42%E5%8D%87%E7%BA%A7%E5%90%AF%E5%8A%A8%E6%97%B6%E6%98%BE%E7%A4%BAkernel-panic/","title":"【解决】Fedora42升级启动时显示kernel panic"},{"content":" https://leetcode.cn/problems/closest-equal-element-queries/\n通过示例1来分析：\n1 2 输入： nums = [1,3,1,4,1,3,2], queries = [0,3,5] 输出： [2,-1,3] 对于queries[0] = 0, nums[queries[0]] = 1来说，其在nums中的下标集合为p = [0, 2, 4]，由于nums是一个循环数组，所以按理来说数组p的第一个元素往左需要能找到最后一个元素，最后一个元素往右能找到第一个元素。\nn 为 nums 的长度， 在下标列表前面添加 4−n=−3，相当于认为在 −3 下标处也有一个 1。 在下标列表末尾添加 0+n=7，相当于认为在 7 下标处也有一个 1。\n题意是需要我们查询一个 nums 中的下标 x，与 任意 其他下标 j（满足 nums[j] == nums[x]）之间的 最小 距离。我们用哈希表将每个相同值的元素的下标收集起来作为集合 p，然后在查询时使用二分查询 x 在其对应集合中的位置 i，则左边最近的元素下标为 p[i - 1]，右边最近元素下标为 p[i + 1]，那么最小距离就是 min(p[i + 1] - x, x - p[i - 1])。\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 class Solution { public: vector\u0026lt;int\u0026gt; solveQueries(vector\u0026lt;int\u0026gt;\u0026amp; nums, vector\u0026lt;int\u0026gt;\u0026amp; queries) { unordered_map\u0026lt;int, vector\u0026lt;int\u0026gt;\u0026gt; m; int n = nums.size(); // 将每个相同值的元素的下标收集起来 for (int i = 0; i \u0026lt; nums.size(); ++i) { m[nums[i]].push_back(i); } // 增加左右两个哨兵 for (auto\u0026amp; [_, p] : m) { int t = p[0]; p.insert(p.begin(), p.back() - n); p.push_back(n + t); } for (int\u0026amp; x : queries) { auto\u0026amp; p = m[nums[x]]; if (p.size() == 3) { // 由于添加了两个哨兵，所以当集合长度为 3 时说明集合中实际只有1个元素，即这个元素在 nums 中是唯一的 x = -1; } else { int i = ranges::lower_bound(p, x) - p.begin(); x = min(p[i + 1] - x, x - p[i - 1]); } } return queries; } }; ","date":"2025-04-16T00:00:00Z","permalink":"/p/3488.-%E8%B7%9D%E7%A6%BB%E6%9C%80%E5%B0%8F%E7%9B%B8%E7%AD%89%E5%85%83%E7%B4%A0%E6%9F%A5%E8%AF%A2/","title":"3488. 距离最小相等元素查询"},{"content":"在C++中，编译器会根据类的定义情况自动决定是否生成默认的特殊成员函数（如构造函数、拷贝/移动操作、析构函数）。\n1. 用户显式声明相关成员函数 显式声明或删除某个函数：\n如果用户显式声明（即使使用 =default 或 =delete）某个特殊成员函数，编译器将不再生成默认版本。例如：\n1 2 3 4 5 6 class Example { public: Example() = default; // 允许生成默认构造函数 Example(const Example\u0026amp;) {} // 用户定义的拷贝构造函数 // 编译器不再生成默认的移动构造函数和移动赋值运算符 }; 2. 用户定义析构函数、拷贝/移动操作的影响 定义析构函数：\n如果用户定义了析构函数（即使为空），编译器会删除默认的移动操作（移动构造函数和移动赋值运算符），但拷贝操作仍可能生成（除非其他条件阻止）。\n1 2 3 4 5 class Example { public: ~Example() {} // 用户定义的析构函数 // 移动操作被隐式删除，拷贝操作可能生成（若无其他限制） }; 定义拷贝操作：\n如果用户定义了拷贝构造函数或拷贝赋值运算符，编译器会删除默认的移动操作。\n1 2 3 4 5 class Example { public: Example(const Example\u0026amp;) {} // 用户定义的拷贝构造函数 // 移动操作被隐式删除 }; 定义移动操作：\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 27 28 29 30 31 #include \u0026lt;utility\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;iostream\u0026gt; struct Example { std::string str; Example() = default; Example(const std::string\u0026amp; s): str(s) { std::cout \u0026lt;\u0026lt; \u0026#34;Example()\\n\u0026#34;; } Example(Example\u0026amp;\u0026amp; other) { str = std::move(other.str); std::cout \u0026lt;\u0026lt; \u0026#34;Example(Example\u0026amp;\u0026amp;)\\n\u0026#34;; } Example\u0026amp; operator=(Example\u0026amp;\u0026amp; other) { str = std::move(other.str); std::cout \u0026lt;\u0026lt; \u0026#34;operator=(Example\u0026amp;\u0026amp;)\\n\u0026#34;; return *this; } }; int main() { Example x1(\u0026#34;Hello\u0026#34;); Example x2 = std::move(x1); Example x3; x3 = std::move(x2); } 执行结果为：\n1 2 3 Example() Example(Example\u0026amp;\u0026amp;) operator=(Example\u0026amp;\u0026amp;) 3. 类成员或基类的限制 不可默认构造/拷贝/移动的成员：\n如果类中包含无法默认构造、拷贝或移动的成员（如 std::unique_ptr、带有删除拷贝操作的类），则对应的默认特殊成员函数会被隐式删除。\n1 2 3 4 class Example { std::unique_ptr\u0026lt;int\u0026gt; ptr; // 不可拷贝 }; // 默认的拷贝构造函数和拷贝赋值运算符被删除 基类或成员的特殊成员函数被删除：\n如果基类或成员的某个特殊成员函数被删除或不可访问，派生类对应的函数也会被隐式删除。\n1 2 3 4 5 6 7 8 class NonCopyable { public: NonCopyable(const NonCopyable\u0026amp;) = delete; }; class Derived : public NonCopyable { // 拷贝构造函数被隐式删除，因为基类的拷贝构造函数被删除 }; ","date":"2025-03-13T00:00:00Z","permalink":"/p/c-%E4%BD%95%E6%97%B6%E4%BC%9A%E9%98%BB%E6%AD%A2%E9%BB%98%E8%AE%A4%E7%9A%84%E7%89%B9%E6%AE%8A%E6%88%90%E5%91%98%E5%87%BD%E6%95%B0%E7%9A%84%E7%94%9F%E6%88%90/","title":"C++何时会阻止默认的特殊成员函数的生成"},{"content":" 该系列博客只是为了记录自己在写 Lab 时的思路，按照课程要求不会在 Github 和博客中公开源代码。欢迎与我一起讨论交流！\n这个 project 和之前就不一样了，开始深入数据库内核的实现了。需要理清楚一条 sql 语句是如何被执行的，方才能写出代码。\n前置奶酪 一条 SQL 语句的执行 这里需要去看看一条 sql 语句传入 bustub 内部之后的代码：src/common/bustub_instance.cpp:ExecuteSqlTxn：\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 auto BustubInstance::ExecuteSqlTxn(const std::string \u0026amp;sql, ResultWriter \u0026amp;writer, Transaction *txn, std::shared_ptr\u0026lt;CheckOptions\u0026gt; check_options) -\u0026gt; bool { if (!sql.empty() \u0026amp;\u0026amp; sql[0] == \u0026#39;\\\\\u0026#39;) { // 处理元命令 ... } // binder，但是在其中会使用libpg_query来解析sql语句 bustub::Binder binder(*catalog_); binder.ParseAndSave(sql); // 经过上一步后，binder中的statement_nodes_存储着所有的语句解析节点 for (auto *stmt : binder.statement_nodes_) { // 将stmt转换成BoundStatement对象，方便后面处理数据 auto statement = binder.BindStatement(stmt); // 只有不需要构建plan树、不需要进行优化的sql语句才会在switch之后继续执行 switch (statement-\u0026gt;type_) { ... } // 生成初步的执行计划 bustub::Planner planner(*catalog_); planner.PlanQuery(*statement); // 优化刚刚的执行计划 bustub::Optimizer optimizer(*catalog_, IsForceStarterRule()); auto optimized_plan = optimizer.Optimize(planner.plan_); ... // 执行优化后的plan，这里会使用火山模型去根据下面节点的Next函数来执行相应的算子 execution_engine_-\u0026gt;Execute(optimized_plan, \u0026amp;result_set, txn, exec_ctx.get()); // 将执行结果输出至指定位置 ... } return 是否执行成功;\t} 在 binder 之后，我们就有了一条 sql 的语句解析节点，例如执行 select * from (select * from test_2 where colA \u0026gt; 10) where colB \u0026gt; 2;，其 statement node 如下：\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 BoundSelect { table=BoundSubqueryRef { alias=__subquery#0, subquery=BoundSelect { table=BoundBaseTableRef { table=test_2, oid=23 }, columns=[\u0026#34;test_2.colA\u0026#34;, \u0026#34;test_2.colB\u0026#34;, \u0026#34;test_2.colC\u0026#34;], groupBy=[], having=, where=(test_2.colA\u0026gt;10), limit=, offset=, order_by=[], is_distinct=false, ctes=, }, columns=[\u0026#34;test_2.colA\u0026#34;, \u0026#34;test_2.colB\u0026#34;, \u0026#34;test_2.colC\u0026#34;], }, columns=[\u0026#34;__subquery#0.test_2.colA\u0026#34;, \u0026#34;__subquery#0.test_2.colB\u0026#34;, \u0026#34;__subquery#0.test_2.colC\u0026#34;], groupBy=[], having=, where=(__subquery#0.test_2.colB\u0026gt;2), limit=, offset=, order_by=[], is_distinct=false, ctes=, } 其中的子查询和 where 的条件还有需要哪些列都能非常清楚的看到。\nIterator Model 通常一个 SQL 会被组织成树状的查询计划，数据从叶子节点流到根节点，查询结果在根节点中得出。\nbustub 中采用的数据库查询执行模型叫做迭代器模型，也叫火山模型。\n查询计划（query plan）中的每步 operator 对应的 executor 都实现一个 next 函数，每次调用时，operator 返回一个 tuple 或者 null，后者表示数据已经遍历完毕。operator 本身实现一个循环，每次调用其 child operators 的 next 函数，从它们那边获取下一条数据供自己操作，这样整个 query plan 就被从上至下地串联起来。\n但是像 Joins, Aggregates, Subqueries, Order By 这样的操作需要等所有 children 返回它们的 tuple。虽然一次调用请求一条数据，占用内存较小，但函数调用开销大。\nCatalog, Table and Index 下图出处：https://www.cnblogs.com/joey-wang/p/17351258.html\n索引 index 在 Bustub 中，索引用于加速数据访问。索引通过维护表中数据的有序结构，使得查询可以更快地定位到所需的记录。\n结构 索引的结构图和上面表的结构图类似。在 catalog 中，可以获取到一个表对应的所有IndexInfo，每个 IndexInfo 中包含着这个索引的信息，这里讲两个个我认为比较重要的成员变量：\nkey_schema_：索引对应的列的结构，例如使用其 ToString() 函数时，其会返回 (添加了索引的列的名称:该列的数据类型) index_：这是一个指针，指向一个 Index 类的对象，也就是真正的索引 1 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 // catalog.h class Catalog { public: template \u0026lt;class KeyType, class ValueType, class KeyComparator\u0026gt; auto CreateIndex(Transaction *txn, const std::string \u0026amp;index_name, const std::string \u0026amp;table_name, const Schema \u0026amp;schema, const Schema \u0026amp;key_schema, const std::vector\u0026lt;uint32_t\u0026gt; \u0026amp;key_attrs, std::size_t keysize, HashFunction\u0026lt;KeyType\u0026gt; hash_function, bool is_primary_key = false, IndexType index_type = IndexType::HashTableIndex) -\u0026gt; IndexInfo *; auto GetIndex(const std::string \u0026amp;index_name, const std::string \u0026amp;table_name) -\u0026gt; IndexInfo *; auto GetIndex(const std::string \u0026amp;index_name, const table_oid_t table_oid) -\u0026gt; IndexInfo *; auto GetIndex(index_oid_t index_oid) -\u0026gt; IndexInfo *; auto GetTableIndexes(const std::string \u0026amp;table_name) const -\u0026gt; std::vector\u0026lt;IndexInfo *\u0026gt;; ... private: ... /** * Map index identifier -\u0026gt; index metadata. * * NOTE: that `indexes_` owns all index metadata. */ std::unordered_map\u0026lt;index_oid_t, std::unique_ptr\u0026lt;IndexInfo\u0026gt;\u0026gt; indexes_; /** Map table name -\u0026gt; index names -\u0026gt; index identifiers. */ std::unordered_map\u0026lt;std::string, std::unordered_map\u0026lt;std::string, index_oid_t\u0026gt;\u0026gt; index_names_; /** The next index identifier to be used. */ std::atomic\u0026lt;index_oid_t\u0026gt; next_index_oid_{0}; }; struct IndexInfo { ... /** The schema for the index key */ Schema key_schema_; /** The name of the index */ std::string name_; /** An owning pointer to the index */ std::unique_ptr\u0026lt;Index\u0026gt; index_; /** The unique OID for the index */ index_oid_t index_oid_; /** The name of the table on which the index is created */ std::string table_name_; /** The size of the index key, in bytes */ const size_t key_size_; /** Is primary key index? */ bool is_primary_key_; /** The index type */ [[maybe_unused]] IndexType index_type_{IndexType::BPlusTreeIndex}; }; Index 中有着三个虚函数供其派生类去实现，其唯一的成员变量的类型为 IndexMeta，用来存储一些元信息，例如这个索引的名称，它所属的表的名称，最重要的还有一个 key_attrs_，稍后就说谈论它。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // index.h class IndexMeta { ... private: /** The name of the index */ std::string name_; /** The name of the table on which the index is created */ std::string table_name_; /** The mapping relation between key schema and tuple schema */ const std::vector\u0026lt;uint32_t\u0026gt; key_attrs_; /** The schema of the indexed key */ std::shared_ptr\u0026lt;Schema\u0026gt; key_schema_; /** Is primary key? */ bool is_primary_key_; }; class Index { ... private: /** The Index structure owns its metadata */ std::unique_ptr\u0026lt;IndexMetadata\u0026gt; metadata_; }; fall2023 我们使用的是哈希索引，底层使用的就是在 project2 中实现的可拓展哈希。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // extendible_hash_table_index.h #define HASH_TABLE_INDEX_TYPE ExtendibleHashTableIndex\u0026lt;KeyType, ValueType, KeyComparator\u0026gt; template \u0026lt;typename KeyType, typename ValueType, typename KeyComparator\u0026gt; class ExtendibleHashTableIndex : public Index { public: ExtendibleHashTableIndex(std::unique_ptr\u0026lt;IndexMetadata\u0026gt; \u0026amp;\u0026amp;metadata, BufferPoolManager *buffer_pool_manager, const HashFunction\u0026lt;KeyType\u0026gt; \u0026amp;hash_fn); ~ExtendibleHashTableIndex() override = default; auto InsertEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction) -\u0026gt; bool override; void DeleteEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction) override; void ScanKey(const Tuple \u0026amp;key, std::vector\u0026lt;RID\u0026gt; *result, Transaction *transaction) override; protected: // comparator for key KeyComparator comparator_; // container DiskExtendibleHashTable\u0026lt;KeyType, ValueType, KeyComparator\u0026gt; container_; }; 更新索引 当插入新记录时，不仅需要将记录插入到表中，还需要将相应的索引条目插入到索引中。这样，后续的查询操作可以利用索引快速定位到目标记录。如果不更新索引，后续的查询操作可能会错过新插入的记录，导致查询结果不准确。\nUPDATE 和 DELETE 如何影响索引？\nUPDATE\n✅ 需要更新索引的情况：修改了索引列 的值（例如：将 id=100 的 name 字段从 \u0026ldquo;Alice\u0026rdquo; 改为 \u0026ldquo;Bob\u0026rdquo;，而 name 被建了索引）\n此时旧索引项（\u0026ldquo;Alice\u0026rdquo;）需要被删除 新索引项（\u0026ldquo;Bob\u0026rdquo;）需要被插入 ❌ 不需要更新索引的情况：修改的是非索引列\n例如：age 字段没有被索引，修改它不会影响索引 DELETE\n无论删除哪一行，只要该行在某个索引中存在，就需要从索引中删除对应的条目 。\nbustub 中有哈希索引和 B+Tree 索引，fall2023 版本使用的是可拓展哈希作为作为索引。不过这两个具体的实现都有一个基类 Index，其中有以下虚函数需要子类去实现：\nInsertEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction): 插入一个索引条目。 DeleteEntry(const Tuple \u0026amp;key, RID rid, Transaction *transaction): 删除一个索引条目。 ScanKey(const Tuple \u0026amp;key, std::vector\u0026lt;RID\u0026gt; *result, Transaction *transaction): 根据索引键搜索记录，并将结果 RID 存储在指定的向量中。 所以不管用的是哈希还是 B+Tree，在操作索引时用的接口都相同。\n如何理解索引 就如网上很多介绍索引的博客所描述的那样，数据库索引是用来加速检索速度的，就如同新华字典中的音节索引一样：\n如同 table_info，catalog 中也有许多的 index_info，每个 index_info 就如同上图音节表中的一个字母。我们对一个字段（列）构建一个索引，就如同在上图中音节表中多加一个字母（例如 X）。\n需要插入一条记录时，就往对应的索引下插入 (记录， 对应记录的地址) 这样的键值对，例如上图的 (xian, 519)，这里的地址为 RID。 需要删除一条记录时，在对应的索引下删掉匹配的键值对。 需要更新一条记录时，由于 bustub 没有提供更新索引的 API，所以可以用先删除再插入的方式模拟更新。 执行器如何使用索引获取数据 当执行器需要从表中获取数据时，如果查询计划中包含索引扫描操作，执行器会通过索引来快速定位数据。以下是具体的步骤：\n解析查询计划： 执行器根据查询计划确定需要使用的索引。 获取索引的元数据，包括索引键的模式和表列的映射关系。 构建索引键： 根据查询条件和索引的元数据，构建索引键。这通常涉及到从查询条件中提取列值，并根据索引键的模式进行转换。 使用索引进行搜索： 调用索引的 ScanKey 方法，传入构建好的索引键和一个结果 RID 向量。 索引会根据键值查找对应的记录，并将找到的 RID 存储在结果向量中。 读取数据页： 使用结果向量中的 RID，从缓冲池中查找对应的页。如果页不在缓冲池中，则从磁盘加载到缓冲池。 从页中读取数据并创建 Tuple 对象。 处理和返回结果： 使用 Tuple 对象的方法（如 GetValue、IsNull 等）访问和处理元组中的数据。 将处理后的数据作为结果返回给用户或进一步处理。 谓词下推 谓词下推（Predicate Pushdown）是数据库查询优化中的一种技术，其核心思想是将查询中的过滤条件（即谓词）尽可能早地应用到查询执行计划的底部，也就是数据生成的地方。这样做的目的是为了减少数据的传输量和处理量，从而提高查询效率。\n具体来说，谓词下推包括以下几个方面：\n过滤条件前移：在查询执行的过程中，尽早地对数据进行过滤，这样不需要将所有数据都传递到上层操作中，只传递满足条件的数据。 减少数据传输：通过在数据生成的阶段就进行过滤，可以减少从数据库存储引擎到查询处理引擎之间的数据传输量。 减少 CPU 处理：不需要对所有数据进行后续的处理，只需要处理已经过滤的数据，这样可以减少 CPU 的工作量。 利用索引：如果过滤条件可以利用现有的索引，谓词下推可以使得查询直接利用索引来快速定位数据，而不是扫描整个表。 优化查询计划：数据库优化器会根据谓词下推的原则重新规划查询的执行步骤，生成更高效的查询计划。 例如：\n1 SELECT * FROM employees WHERE department_id = 5 AND salary \u0026gt; 50000; 在这个查询中，WHERE 子句包含了两个过滤条件。如果不进行谓词下推，数据库可能会先扫描整个 employees 表，然后将所有行传递给上层操作，之后再应用过滤条件。而通过谓词下推，数据库可以在扫描表的时候直接应用这些过滤条件，只返回部门 ID 为 5 且薪资大于 50000 的员工记录。\n谓词下推是数据库查询优化中非常重要的一环，它有助于提高查询性能，特别是在处理大规模数据集时。数据库优化器会尝试自动应用谓词下推，但有时开发者也可以通过编写更优化的查询条件来帮助优化器更好地进行谓词下推。\nTask1 - Access Method Executors SeqScan 顺序扫描指定的表，表的遍历可以使用 TableIterator。\n每次找到一条没有被标记为“删除”或者不是 where 之类的过滤子句匹配（这里会在 delete 操作中说明）的 tuple（记录）就并返回，如果已经扫描到了表的结束位置则返回 false。\nInsert 为什么 Insert 等 Executor 有 child 而 SeqScan 没有？ InsertExecutor 的主要职责是将一条或多条记录插入到指定的表中。它可能需要依赖于其他 Executor 来获取要插入的数据。例如，如果 INSERT 操作是从一个 SELECT 查询的结果集中插入数据，那么 InsertExecutor 可能会有一个子 Executor（如 SeqScanExecutor 或其他类型的 Executor），该子 Executor 负责执行 SELECT 操作并提供数据给 InsertExecutor。\n因此，InsertExecutor 有 child 是因为它可能需要从另一个查询的结果中获取数据。\nSeqScanExecutor 的主要职责是对表进行全表扫描，即按顺序读取表中的所有记录。这是一个基本的操作，通常不需要其他 Executor 的支持来完成其工作。\n它直接作用于存储层，遍历表中的每一行数据，因此没有子 Executor。它的任务相对简单，就是遍历和返回表中的所有记录。\n简而言之，InsertExecutor 需要 child 是因为它的操作可能涉及从其他查询结果中获取数据，而 SeqScanExecutor 不需要 child 是因为它的任务是独立完成的，只需遍历表中的所有记录即可。这反映了数据库执行计划中不同操作之间的依赖关系和交互方式。其他 Executor 同理。\n举个批量插入的🌰：\n假设我们有一个 orders 表，包含以下列：\norder_id (主键) customer_id product_id quantity order_date 我们希望通过一个子查询（select）来获取一批订单记录，并将这些记录插入到 orders 表中：\n1 2 3 4 INSERT INTO orders (customer_id, product_id, quantity, order_date) SELECT customer_id, product_id, quantity, order_date FROM pending_orders WHERE status = \u0026#39;approved\u0026#39;; 很明显，我们在插入之前要从 select 子句中获取数据，因此这个子查询操作就是 insert 操作的 child_executor。\n没有子操作时，需要插入的数据从哪里获取？ 比如执行如下 SQL 时：\n1 insert into test_1 values (202, 1, 2, 3); 从肉眼看可以知道需要插入的数据为 (202, 1, 2, 3)，但是在代码中又是从哪里获取的呢？\n让我们使用一下 explain 工具来看看这条 SQL 语句在 bustub 内部做了什么：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 bustub\u0026gt; explain insert into test_1 values (202, 1, 2, 3); === BINDER === BoundInsert { table=BoundBaseTableRef { table=test_1, oid=22 }, select= BoundSelect { table=BoundExpressionListRef { identifier=__values#0, values=[[\u0026#34;202\u0026#34;, \u0026#34;1\u0026#34;, \u0026#34;2\u0026#34;, \u0026#34;3\u0026#34;]] }, columns=[\u0026#34;__values#0.0\u0026#34;, \u0026#34;__values#0.1\u0026#34;, \u0026#34;__values#0.2\u0026#34;, \u0026#34;__values#0.3\u0026#34;], groupBy=[], having=, where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } } === PLANNER === Insert { table_oid=22 } | (__bustub_internal.insert_rows:INTEGER) Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__values#0.0:INTEGER, __values#0.1:INTEGER, __values#0.2:INTEGER, __values#0.3:INTEGER) Values { rows=1 } | (__values#0.0:INTEGER, __values#0.1:INTEGER, __values#0.2:INTEGER, __values#0.3:INTEGER) === OPTIMIZER === Insert { table_oid=22 } | (__bustub_internal.insert_rows:INTEGER) Values { rows=1 } | (__values#0.0:INTEGER, __values#0.1:INTEGER, __values#0.2:INTEGER, __values#0.3:INTEGER) 将目标聚焦在生成的查询计划上，从上到下，在一个 Insert 的查询计划中，使用 Projection 从其输入源（如表扫描、索引扫描、连接等）中提取所需的列，最下层使用 Value 获取到要操作的数据！\n所以对应的，InsertExecutor 的 child_executor 为 ProjectionExecutor，而 ProjectionExecutor 的 child_executor 为 ValuesExecutor，使用迭代器模型就能很方便的获取到数据了（ValuesExecutor 就是最后的 Executor，其 Next 函数不会再往下调用，其所做的只是根据在解析 SQL 及其之后的一些步骤中得到的需要操作的数据封装成一个 tuple 进行返回）。\nDelete 需要写的代码和 insert 操作的基本相同。但有个地方需要注意一下，在执行一条 delete 语句时，让我们看看做了些什么：\n1 2 3 4 5 6 7 8 9 10 bustub\u0026gt; explain delete from test_1 where colA = 999; === BINDER === Delete { table=BoundBaseTableRef { table=test_1, oid=22 }, expr=(test_1.colA=999) } === PLANNER === Delete { table_oid=22 } | (__bustub_internal.delete_rows:INTEGER) Filter { predicate=(#0.0=999) } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) SeqScan { table=test_1 } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) === OPTIMIZER === Delete { table_oid=22 } | (__bustub_internal.delete_rows:INTEGER) SeqScan { table=test_1, filter=(#0.0=999) } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) 可以看到，在 optimizer 阶段，where 子句的 filter 下放至 SeqScan 处与其合并了，也就是说，我们需要在实现 SeqScanExecutor 时注意处理一下 filter。这里提示一下：\n1 2 while (cur_tuple.first.is_deleted_ || (plan_-\u0026gt;filter_predicate_ \u0026amp;\u0026amp; !(plan_-\u0026gt;filter_predicate_-\u0026gt;Evaluate(tuple, GetOutputSchema()).GetAs\u0026lt;bool\u0026gt;()))) 如果其返回 true，说明 filter 匹配到了数据（就如例子中匹配到了 colA 列为 999 的 tuple），如果此时这个 tuple 没有被标记为删除，那么就说明找到了我们需要删除的 tuple。\nUpdate 我们如何知道 update 需要更新的数据从哪里取呢？\n1 2 3 4 5 6 7 8 bustub\u0026gt; explain(p, o) update test_1 set colB = 15445; === PLANNER === Update { table_oid=22, target_exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;15445\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } Filter { predicate=true } SeqScan { table=test_1 } === OPTIMIZER === Update { table_oid=22, target_exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;15445\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } SeqScan { table=test_1, filter=true } 看看 Update 中，有一个 target_exprs 数组，这个数组可不就是我们的一行数据吗，并且是需要更新的那行数据：update 语句可以不加 where 子句，这样就是选中表中的所有行，也就是这里将表中 colB 列的数据都 update 为 15445！\n对于 target_exprs 这个数组，我们可以通过 plan_-\u0026gt;target_expressions_ 获取，然后用其构建一个新的 tuple。\n需要注意的是，这里并没有提供直接更新 tuple 的操作，所以我们的 update 操作可以用先删除后插入的方式来模拟。\nIndexScan 我们首先需要完成 OptimizeSeqScanAsIndexScan 这个优化步骤。\n假设现在我们有一个表叫“test_1”，其列如下：\n1 2 3 +-------------+-------------+-------------+-------------+ | test_1.colA | test_1.colB | test_1.colC | test_1.colD | +-------------+-------------+-------------+-------------+ 现在我们希望这条 SQL 能执行的更快：\n1 select * from test_1 where colB = 11; 那比较不错的方法就是给 colB 列加上索引：\n1 create index v1 on test_1(colB); 这样在执行时可以更快速的查找数据。\n那么为了实现这一目标，我们需要通过 OptimizeSeqScanAsIndexScan 将 plan 树中的 SeqScanPlanNode 转换成 IndexScanPlanNode，这样我们才能使用 IndexScanPlanNode 对应的算子 \u0026mdash;IndexScanExecutor 去使用索引。但是由于 bustub 的一些设计，需要遵循以下规则才可以转化：\n当前的节点的类型必须是 PlanType::SeqScan 当前节点必须有 filter 谓词，如果只是 select * from test_1; 这样的是不需要使用索引的 当前表中必须有索引，没有索引还玩啥呢 fileter 谓词中的逻辑表达式只能有一个，并且其类型必须是 ComparisonType::Equal（我想这里必须是“等于”是不是因为 fall2023 使用的索引是哈希索引） 在当前表的索引信息中找到与 filter 谓词相对应的索引后才能返回一个 IndexScanPlanNode 在 select * from test_1 where colB = 11; 中，加了索引后，最需要关注的就是 where colB = 11 这一个过滤条件。bustub 中要求 IndexScan 过滤运算符必须是 =，且只能有一个条件。如果 colA 和 colB 都是索引，然后执行 select * from test_1 where colB = 11 and colA = 1;，这样是不会走索引优化的。\n我们将查询计划中的过滤谓词转化成 ComparisonExpression 类型，据我的理解，其可通过 GetChildAt 函数获取比较谓词左边的列名表达式（即这里的“colB”，有了这个列名的表达式，我们就可以获取到这个列的 col_id 值）和右边的值（即“11”）。之后需要去这张表中的所有索引中去找是否有 colB 的索引，怎么确定是否有呢，那就要看这个表中的每个 Index 的 key_attrs_：\n在 Index 中，key_attrs_ 决定了索引的关键字由哪些列组成，对应了每个列的下标。例如：\n如果表的 schema 定义有 5 列，分别为 A, B, C, D, E。 某个索引的 key_attrs_ 是 [0, 2]，则表示该索引使用了第 0 列（A）和第 2 列（C）作为其关键字。 如果之前我们获取的列的 col_id 值和某个 Index 的 key_attrs_ 中的值相同，那么就存在相应的索引！这时构造一个 IndexScanPlanNode 返回即可（参考 merge_filter_scan.cpp 中是如何做的）。\n当优化器成功更换节点后，在执行时就会走索引，其底层算子就会使用到 IndexScanExecutor。到这里，这个算子需要做的事情就很简单了，就是调用哈希索引的 ScanKey 进行查找，不过这里有 3 点需要注意：\n在 project2 中，我们实现的索引引擎只支持一个键对应一个值（不只是这个版本的可拓展哈希，其他版本中的 B+Tree 也只要求这样实现），也就是我们的这个算子在底层索引引擎不扩展的情况下最多查到一条记录，这样的话就可以在算子的 Init 函数中调用 ScanKey 查找记录就行。 可拓展哈希中是将一个 Tuple 的 data 转化一下当作 key，所以在索引中，其是将索引列的值作为 key，其对应的 oid 作为值。在使用 ScanKey 时，第一个参数需要的 tuple 将用查询计划节点 IndexScanPlanNode 中的 pred_key_ 构造。 表中的 tuple 的元数据中，其 is_deleted_ 可能为 true，这说明这个 tuple 在逻辑上已经删除了，所以如果我们通过 2 中获取的 oid 对应的 tuple 是这种情况，就不用向上返回数据。 Task2 - Aggregation \u0026amp; Join Executors Aggregation 分析一个例子：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 bustub\u0026gt; EXPLAIN SELECT MAX(colC), MIN(colB) FROM test_1 GROUP BY colA HAVING MAX(colB) \u0026gt; 10; === BINDER === BoundSelect { table=BoundBaseTableRef { table=test_1, oid=22 }, columns=[\u0026#34;max([\\\u0026#34;test_1.colC\\\u0026#34;])\u0026#34;, \u0026#34;min([\\\u0026#34;test_1.colB\\\u0026#34;])\u0026#34;], groupBy=[\u0026#34;test_1.colA\u0026#34;], having=(max([\u0026#34;test_1.colB\u0026#34;])\u0026gt;10), where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (\u0026lt;unnamed\u0026gt;:INTEGER, \u0026lt;unnamed\u0026gt;:INTEGER) Filter { predicate=(#0.1\u0026gt;10) } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) Agg { types=[\u0026#34;max\u0026#34;, \u0026#34;max\u0026#34;, \u0026#34;min\u0026#34;], aggregates=[\u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.1\u0026#34;], group_by=[\u0026#34;#0.0\u0026#34;] } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) SeqScan { table=test_1 } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) === OPTIMIZER === Projection { exprs=[\u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (\u0026lt;unnamed\u0026gt;:INTEGER, \u0026lt;unnamed\u0026gt;:INTEGER) Filter { predicate=(#0.1\u0026gt;10) } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) Agg { types=[\u0026#34;max\u0026#34;, \u0026#34;max\u0026#34;, \u0026#34;min\u0026#34;], aggregates=[\u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.1\u0026#34;], group_by=[\u0026#34;#0.0\u0026#34;] } | (test_1.colA:INTEGER, agg#0:INTEGER, agg#1:INTEGER, agg#2:INTEGER) SeqScan { table=test_1 } | (test_1.colA:INTEGER, test_1.colB:INTEGER, test_1.colC:INTEGER, test_1.colD:INTEGER) 对于 AggregationExecutor，我们可以获取到 SQL 语句中：\n聚合操作的 types（进行聚合操作的类型）和 aggregates（需要聚合操作的列），二者一一对应 需要 group by 进行分组的列 再看看 lecture 中的这个例子，其对列 cid 使用 group by 进行分组，其中涉及的聚合操作为 AVG，可转换成 COUNT 和 SUM 操作。这里相当于：\n1 2 types = [\u0026#39;count\u0026#39;, \u0026#39;sum\u0026#39;] aggregates = [\u0026#39;s.gpa\u0026#39;, \u0026#39;s.gpa\u0026#39;] 我的理解是根据 group by 的字段的值进行 hash 函数处理作为哈希表的键，例如图中的“15-445”，“15-826”等；然后哈希表的值为一个集合，这个集合的大小和 types 和 aggregates 的大小相同，并且对应的位置就为 aggregates 的值：例如图中键“15-445”的值中，第一个元素就为 COUNT 操作下 s.gpa 为 15-445 的个数。\n在 lab 中需要实现 count、sum、max、min 操作，其实就是在 SimpleAggregationHashTable：：CombineAggregateValues 中实现对应的操作即可，本质上是对哈希表的几个很简单的操作。\naggregation 通常需要对一组数据进行计算，这些计算具有以下特点：\n需要完整输入：Aggregation 通常需要从下层拉取所有相关数据才能计算结果，例如计算 SUM 需要遍历所有行。 阻塞性：在传统实现中，Aggregation 算子通常被称为“阻塞算子”，因为它必须等待所有输入数据都拉取完成才能产出结果。这意味着 next() 调用会被延迟，直到聚合计算完成。 在我们的火山模型中 aggregation 是阻塞算子：\n当上层算子调用 next() 时，aggregation 会向下层算子连续调用 next()，直到拉取完全部数据并完成聚合。 在数据尚未完全拉取并聚合完成之前，上层的 next() 调用无法直接返回结果。 需要注意的是：\nSQL 中进行 group by 后使用 count，统计的是每组数据中的记录数，而非分组后新表的行数。 distinct 其实就是对某个字段进行 group by 操作 count(*) 统计 null，而 count(字段) 不统计 null NestedLoopJoin Inner Join：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 bustub\u0026gt; EXPLAIN SELECT * FROM __mock_table_1, __mock_table_3 WHERE colA = colE; === BINDER === BoundSelect { table=BoundCrossProductRef { left=BoundBaseTableRef { table=__mock_table_1, oid=0 }, right=BoundBaseTableRef { table=__mock_table_3, oid=2 } }, columns=[\u0026#34;__mock_table_1.colA\u0026#34;, \u0026#34;__mock_table_1.colB\u0026#34;, \u0026#34;__mock_table_3.colE\u0026#34;, \u0026#34;__mock_table_3.colF\u0026#34;], groupBy=[], having=, where=(__mock_table_1.colA=__mock_table_3.colE), limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) Filter { predicate=(#0.0=#0.2) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) NestedLoopJoin { type=Inner, predicate=true } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) === OPTIMIZER === NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 bustub\u0026gt; EXPLAIN SELECT * FROM __mock_table_1 INNER JOIN __mock_table_3 ON colA = colE; === BINDER === BoundSelect { table=BoundJoin { type=Inner, left=BoundBaseTableRef { table=__mock_table_1, oid=0 }, right=BoundBaseTableRef { table=__mock_table_3, oid=2 }, condition=(__mock_table_1.colA=__mock_table_3.colE) }, columns=[\u0026#34;__mock_table_1.colA\u0026#34;, \u0026#34;__mock_table_1.colB\u0026#34;, \u0026#34;__mock_table_3.colE\u0026#34;, \u0026#34;__mock_table_3.colF\u0026#34;], groupBy=[], having=, where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) === OPTIMIZER === NestedLoopJoin { type=Inner, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) 可以看到，即便我们不加 inner join，默认情况下也是使用的 Inner Join：NestedLoopJoin 中的 type 均为 Inner。\nLeft Join：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 bustub\u0026gt; EXPLAIN SELECT * FROM __mock_table_1 LEFT OUTER JOIN __mock_table_3 ON colA = colE; === BINDER === BoundSelect { table=BoundJoin { type=Left, left=BoundBaseTableRef { table=__mock_table_1, oid=0 }, right=BoundBaseTableRef { table=__mock_table_3, oid=2 }, condition=(__mock_table_1.colA=__mock_table_3.colE) }, columns=[\u0026#34;__mock_table_1.colA\u0026#34;, \u0026#34;__mock_table_1.colB\u0026#34;, \u0026#34;__mock_table_3.colE\u0026#34;, \u0026#34;__mock_table_3.colF\u0026#34;], groupBy=[], having=, where=, limit=, offset=, order_by=[], is_distinct=false, ctes=, } === PLANNER === Projection { exprs=[\u0026#34;#0.0\u0026#34;, \u0026#34;#0.1\u0026#34;, \u0026#34;#0.2\u0026#34;, \u0026#34;#0.3\u0026#34;] } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) NestedLoopJoin { type=Left, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) === OPTIMIZER === NestedLoopJoin { type=Left, predicate=(#0.0=#1.0) } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER, __mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) MockScan { table=__mock_table_1 } | (__mock_table_1.colA:INTEGER, __mock_table_1.colB:INTEGER) MockScan { table=__mock_table_3 } | (__mock_table_3.colE:INTEGER, __mock_table_3.colF:VARCHAR) 由于火山模型中的 Next 每次返回一条 tuple，所以我们需要在 Init 中得到 join 后的所有 tuple。Nested Loop Join 其实就是用两个 for 循环去遍历两张表，保存满足筛选条件的 tuple。\ninner join 只会返回两个表中满足连接条件的 tuple；而 left join 会返回左表中的所有记录，以及右表中满足条件的 tuple。所以在嵌套 for 循环中，如果右表中有未能满足条件的 tuple，那么就保存左表中的每一列的值，并且加上右表中每一列的 null 值。\nTask3 - HashJoin Executor and Optimization 为什么需要 hash join 呢？如果两张要进行 join 的表非常大，这时使用 NLJ 的时间复杂度就为 O(n*m)，但是如果我们使用 hash join，先对一张表建立哈希映射，然后再对另一张表进行哈希检测来判断是否满足连接条件，这样就只要各扫描一次两张表，时间复杂度就降为了 O(n+m)。\n将 NL Join 优化为 Hash Join 实验指导中说，优化器需要将 nl join 优化为 hash join 的情况为：连接条件中有多个等值 AND 操作，即 x = y AND a = b AND ...。那我们只要在碰到 NestedLoopPlanNode 时获取其谓词，判断一下是否满足该情况就好了。\n需要注意的是，当谓词仅为 a = b 时，这个谓词是一个 ComparationExpression；而当它为 x = y AND a = b AND ... 时，它是一个 LogicExpression。这里在判断的是否需要区分，而在之前的实验中，有学习到可以使用标准库中的 std::dynamic_pointer_cast 函数来将谓词从 Expression基类 转换为指定的 Expression派生类，当转换失败时，会返回空指针，这样我们可以通过返回的指针来判断是否是想要的 Expression，如果为空，就代表是另一个 Expression。\n由于谓词中很有可能是 LogicExpression 嵌套着 LogicExpression 和 ComparationExpression，数量也不确定，且子 LogicExpression 很可能还有嵌套，这样的话使用递归来处理就非常方便。\n实现 Hash Join hash join 的核心就是：先对一张表建立哈希映射，然后再对另一张表进行哈希检测来判断是否满足连接条件。哈希表中的 key 为连接条件中对应的值的拼接，value 为整个 tuple。而一张表中的所有 tuple，很有可能会有几个列是完全相同的，如果这些列刚好作为连接条件，那进行哈希时就会造成 key 相同的情况，这就造成了哈希碰撞。\n解决方法也很简单：\n使用 std::unordered_map，将拼接的列值作为 key，而值的类型为 std::vector\u0026lt;Tuple\u0026gt;; 使用 std::unordered_multimap 来处理。 当我们构建好哈希表后，每次获取了另一张表中的 tuple 后进行一次哈希检测，如果 key 存在，需要将所有 value 都进行连接拼接。同 nested loop join 一样，需要特殊处理连接类型为 left 的情况。\nTask4 - Sort + Limit Executors + Window Functions + Top-N Optimization Sort 这个算子的实现思路很简单，但是需要注意两点：\norder by 后面可以跟多个关键字，也就是需要在 std::sort 中对多个关键字进行排序（利用 for 循环）。 这些关键字中可能进行算数运算（算数对象类型为 ArithmeticExpression，搞清楚进行运算的函数就可）。 Limit 这个就更简单了，根据 limit 的返回对应数量的 tuple。\nTopN topn 算子的优化只有 sort 和 limit 同时出现的是否才会触发，这里的优化逻辑比较简单。\n而具体的算子的实现其实就是一个优先队列。举个例子，现在 tuple 按照某个关键字升序排列，并且 limit 为 10，那么就可以构造一个小根堆，并维护其大小最多为 10，最后留在堆中的就是结果。如果力扣刷了一点题的话很容易就能理解这里。\nWindow Function 说来惭愧，学 SQL 的时候并不知道窗口函数这个东西\u0026hellip;简单来理解，就是在使用聚合函数的后面加上 over ([可选操作]) 即可对区间进行聚合操作。\n对于下面的 SQL，最后输出的 schema 应该和 WindowFunc.columns 相同，并且行数也和子 executor 返回的行数相同（下文将子 executor 返回的 tuple 称为 child_tuples），只是多了一些额外的计算列。\n1 2 3 4 5 6 7 8 9 10 11 12 bustub\u0026gt; explain(o) select v1, min(v1) over () as min_v1, max(v1) over () as max_v1, count(v1) over () as count_v1, sum(v1) over () as sum_v1 from t1; === OPTIMIZER === WindowFunc { columns=#0.0, placeholder, placeholder, placeholder, placeholder, , window_functions={ 1=\u0026gt;{ function_arg=#0.0, type=min, partition_by=[], order_by=[] }, 2=\u0026gt;{ function_arg=#0.0, type=max, partition_by=[], order_by=[] }, 3=\u0026gt;{ function_arg=#0.0, type=count, partition_by=[], order_by=[] }, 4=\u0026gt;{ function_arg=#0.0, type=sum, partition_by=[], order_by=[] } } } SeqScan { table=t1 } 在这条 SQL 中，返回的 tuple 的格式应该为：\n这条 tuple 的 v1 列的值 所有 tuple 中最小的 v1 的值 所有 tuple 中最大的 v1 的值 所有 tuple 中 v1 的个数 所有 tuple 中 v1 的值的和 这其实和 WindowFunc.columns 有很大的关系，placeholder 说明这只是个占位符，其应该为 window_functions[下标] 对应的窗口函数。例如第二个 placeholder 在 columns 的下表为 1，其对应的窗口函数就是 { function_arg=#0.0, type=min, partition_by=[], order_by=[] }。那么就可以通过 column 来找到每个窗口对应的哈希表。\n️需要注意的是：\n如果按照 ORDER BY 进行排序后，每一行的窗口范围从第一行开始扩展到当前行 否则每一行的窗口范围是整个 child_tuples 既然如此，我们需要先从 child_executor 获取到所有的 child_tuples，然后对 child_tuples 遍历：如果有分组行为，那就需要按照某一列（或多列）进行分组，然后对于每一个 tuple，都让其执行一次 WindowFunc.window_functions 中的窗口函数。\n稍有不同的是，之前的 aggregation 操作分组后可能需要完成多个聚合函数，但是这里我们分组之后，只会完成一个聚合函数，因为每个窗口中只有一个聚合函数。\n现在看来，这个窗口函数也无非就是对某个范围内的 tuple 进行分组和聚合操作，实验指导中也提示我们可以去利用 task2 中写的代码。在我的实现方案中，并不像 task2 中只使用一个哈希表，因为在一条 SQL 函数中，可能有多个窗口函数，每一个窗口函数又可能又不同的分组和不同的聚合操作，因此我对于每一个窗口函数都设置了一个哈希表。\n该 task 中 bustub 很仁慈地简化了难度：如果窗口函数任意一个中有 order by，那么所有窗口函数的 order by 都相同。不过在有排序和没有排序的情况下，窗口的范围有所不同，处理起来的方法也不同。\n无排序的情况 这个情况下，每个窗口函数的范围就是整个 child_tuples。之后再次遍历每一条 tuple，按照输出的 schema 来构建返回的 tuple。那么实现操作应该如下：\n先描所有 tuple，生成这条 tuple 对应的 key 和 value，再把 key 和 value 加入对应的哈希表。\n然后对于每一条 tuple，遍历 WindowFunc.columns：\n如果 column 不是 placeholder，那么说明这个位置是这个 tuple 中的一列，获取这一列对应的 Value 就好。 如果 column 是 placeholder，那么说明这个位置的值应该是对应的窗口函数的执行结果，那么从 column 对应的哈希表中找出这个 tuple 对应的值就 ok。 有排序的情况 在这个情况下，排序号后每条 tuple 的范围是自身及之前的所有 tuple，而不是像之前一样的所有 tuple。这就有点像一句话：“走一步看一步”。\n还是遍历每一条 tuple，对每一条 tuple 又遍历 WindowFunc.columns：\n如果 column 不是 placeholder，那么说明这个位置是这个 tuple 中的一列，获取这一列对应的 Value 就好。这里和无排序的情况一致。 如果 column 是 placeholder， 生成这条 tuple 对应的 key 生成这条 tuple 对应的 value，并将 {key, value} 插入 column 对应的哈希表（在 task2 中可知，这里的“插入”其实是对 key 处的旧值 old_value 与新值 value 进行聚合操作） 从哈希表中取出 key 对应的 value 这里的 2.2 和 2.3 就是之前所说的“走一步看一步”：当前 tuple 的结果是在之前的 tuple 上聚合而来的。\n总结 这个 Project 需要我们去深入理解 bustub 的源码，知道一条 SQL 会被解析成一棵什么样的 plan 树，在经过基于规则的优化器优化后才会是最终的物理 plan 树，这时又要去理解这棵树上每个节点对应的算子应该是怎么实现的。其实知道了 plan 树是什么样子后，节点对应的算子就按照要求去设计就好了。\n在众多 AI 例如 ChatGPT、DeepSeek、Kimi 等帮助下，还是慢慢理解并完成了这个 Project！但是有的地方我可能还没有做的比较好，以后有时间再优化一下。\n","date":"2025-03-11T00:00:00Z","permalink":"/p/cmu15-445-fall2023project3-query-execution-%E5%B0%8F%E7%BB%93/","title":"【CMU15-445 Fall2023】Project3 Query Execution 小结"},{"content":"微信在不久前终于有了 Linux 原生版本，我的电脑是 Fedora41，之前安装的是 flatpak 打包的微信，现在在官网下载 rpm 包后运行发现无法使用 fcitx 的中文输入法，找了一下是环境遍历的问题。\n需要添加的环境变量为：\n1 2 3 export XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; export GTK_IM_MODULE=\u0026#34;fcitx\u0026#34; export QT_IM_MODULE=\u0026#34;fcitx\u0026#34; 但是在 KDE6 Wayland 下如果把它写入 /etc/profile 中好像会有问题？所以我把这个环境变量放到 wechat.desktop 中去，相当于给 /usr/bin/wechat 这个程序进行隔离（重点在 Exec 中）：\n1 2 3 4 5 6 7 8 9 10 11 [Desktop Entry] Name=wechat Name[zh_CN]=微信 Exec=env XMODIFIERS=\u0026#34;@im=fcitx\u0026#34; GTK_IM_MODULE=\u0026#34;fcitx\u0026#34; QT_IM_MODULE=\u0026#34;fcitx\u0026#34; /usr/bin/wechat %U StartupNotify=true Terminal=false Icon=/opt/wechat/icons/wechat.png Type=Application Categories=Utility; Comment=Wechat Desktop Comment[zh_CN]=微信桌面版 ","date":"2024-12-31T00:00:00Z","permalink":"/p/linux%E4%B8%8B%E5%BE%AE%E4%BF%A1%E6%97%A0%E6%B3%95%E4%BD%BF%E7%94%A8%E4%B8%AD%E6%96%87%E8%BE%93%E5%85%A5%E6%B3%95%E9%97%AE%E9%A2%98%E8%A7%A3%E5%86%B3/","title":"Linux下微信无法使用中文输入法问题解决"},{"content":"为了 CMake Tool 能调试代码，先装好 codelldb 插件，然后还需要一个 launch.json 文件，以下内容可以一键配置好调试：\n1 2 3 4 5 6 7 8 9 10 11 12 13 { \u0026#34;version\u0026#34;: \u0026#34;0.2.0\u0026#34;, \u0026#34;configurations\u0026#34;: [ { \u0026#34;name\u0026#34;: \u0026#34;LLDB\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;lldb\u0026#34;, \u0026#34;request\u0026#34;: \u0026#34;launch\u0026#34;, \u0026#34;program\u0026#34;: \u0026#34;${command:cmake.launchTargetPath}\u0026#34;, \u0026#34;args\u0026#34;: [], \u0026#34;cwd\u0026#34;: \u0026#34;${workspaceFolder}\u0026#34;, } ] } ","date":"2024-12-31T00:00:00Z","permalink":"/p/%E5%9C%A8vscode%E4%B8%AD%E9%85%8D%E7%BD%AElldb/","title":"在VSCode中配置LLDB"},{"content":"什么是mmap？ mmap 是一种用于将文件或设备与进程的地址空间关联起来的内存映射技术。通过 mmap，可以将文件的内容直接映射到进程的虚拟内存地址空间，使得文件的内容可以像操作普通内存一样进行读取和写入。\n在Linux中，虚拟内存的布局如下：\n图片来源：小林coding\n当我们在Linux上使用mmap系统调用时，得到的文件映射就会放在图中的“文件映射与匿名映射区”。每当我们需要读取或修改文件时，只需要去操作这一块虚拟内存即可，而省去了将文件的内容从磁盘读取到内核缓冲区，然后再拷贝到用户空间的缓冲区，这大大减小了资源开销。\n系统调用参数说明 该lab希望我们实现xv6上的mmap和munmap系统调用，其函数声明为：\n1 2 void *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset); int munmap(void *addr, size_t len); 这与Linux上的使用是相同的，对其中的参数解释如下：\nvoid *mmap(void *addr, size_t len, int prot, int flags, int fd, off_t offset);：\naddr (void *): 这是建议的映射起始地址。通常设置为 NULL，由内核自动选择合适的地址。如果指定了非空地址，则内核尽量在这个地址处创建映射（但不保证）。（xv6中不要求实现，addr只要考虑为0/NULL的情况） 如果使用了 MAP_FIXED 标志，则必须将映射建立在 addr 所指向的地址，否则映射会失败。（xv6中不要求实现） len (size_t): 要映射的内存长度（以字节为单位）。如果不是页大小的倍数，通常会向上舍入到最近的页边界。 prot (int): 映射区域的保护权限。可以是以下权限的组合： PROT_READ: 映射区域可读。 PROT_WRITE: 映射区域可写。 PROT_EXEC: 映射区域可执行。 PROT_NONE: 映射区域不可访问。 flags (int): 控制映射对象的类型、映射页是否可共享、映射是否同步到磁盘等。常见的标志有： MAP_SHARED: 共享映射，对映射区域的修改会同步到底层文件，其他映射到同一文件的进程也会看到修改。 MAP_PRIVATE: 私有映射，对映射区域的修改不会影响底层文件，修改是写时复制的（Copy-On-Write）。 MAP_ANONYMOUS: 创建一个匿名映射，与文件无关。fd 参数被忽略，通常与 MAP_PRIVATE 结合使用。（xv6不要求实现） fd (int): 打开的文件描述符，表示要映射的文件。如果使用 MAP_ANONYMOUS 标志，则此参数被忽略，通常设为 -1。 offset (off_t): 文件映射的起始偏移量。必须是页大小的整数倍。（xv6中不要求实现，即只要输入0） int munmap(void *addr, size_t len)：\naddr (void *): 要解除映射的起始地址。这个地址必须是由之前的 mmap 调用返回的地址，或者是由 mmap 创建的某个映射区域的地址。 len (size_t): 要解除映射的内存长度，必须与 mmap 调用中的 len 相匹配。如果长度小于 mmap 时指定的长度，可能会导致部分映射区域仍然保留。 如何实现？ 在xv6的虚拟内存布局中，可以看到堆区和trapframe之间有一片没有使用的区域，我们可以拿它作为文件映射区域。（xv6和Linux的虚拟内存布局有点区别，xv6的堆区在栈区上面）\n当使用mmap系统调用时，也可以使用懒分配的策略（类似于Copy On Write）：我们在mmap系统调用中 标识（不是分配） 文件映射区中有一个区域与文件相关联，但这时还不会分配物理块，自然还不急着将文件读入这片内存区域；当我们需要访问这片区域的内存时，可以通过触发page fault来分配物理块，然后读入文件内容到内存块中，并将虚拟内存映射到这块物理内存上。\n使用munmap系统调用时，会解除文件在映射区[addr, addr + len]范围内的映射，将这块区域的内存写回文件，并释放掉这块内存。实验中保证释放的区域大小一定是页的整数倍。\n我们也仿照Linux上的，让文件映射区从高地址处开始向低地址增长。下图是文件映射的样子，左边为映射区域大小不固定，右边为映射大小为页框的整数倍：\n在实验的提示中，有说到mmaptest中没有使用的功能可以不实现，其中每次使用mmap都是映射的PGSIZE的整数倍，那也就说明我们可以之用考虑右边的情况，这让实验降低了一点复杂度。\n标识映射区域 根据实验提示，我们需要为每个进程设置用于标识映射区域的结构体：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // proc.h #define NVMA 16 struct vma { uint len; // 映射区域大小 uint prot; // 映射区域的保护权限 struct file *file; // 需要映射的文件 int used; // 是否被使用 int flags; // 映射类型 int offset; // 偏移量 uint64 start; // 映射区域开始的地址 uint64 end; // 映射区域结束的地址 }; struct proc { ... struct vma vmas[NVMA]; // Virtual memory area }; 实现sys_mmap 在此之前，我们需要先注册mmap和munmap系统调用，这里我们就不赘述了\n获得映射区中的可用区域 什么意思呢？我们的映射区设计的是从高地址向低地址增长，那么我们每次需要增长时，最简单的就是在已有的映射区中找到地址最低的，并将新的映射区放在其之后，即地址最低的映射区的start就是新的映射区的end：\n可是这样的算法有很大的问题：如果我们取消了文件2的映射后，有一个只需要一个页框的映射区，按照这个算法它会被安排到文件3的映射区下面，这样就浪费了之前释放的映射区。\n不过嘛，在这个实验中这么做没什么问题😜，如果想知道更好的方法，可以参考这篇博客。\n我的实现如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // sysfile.c // 获取一个可使用的vma的end地址 static uint64 vma_end() { struct proc *p = myproc(); struct vma *v = 0; uint64 min_vma_end = TRAPFRAME; for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used \u0026amp;\u0026amp; p-\u0026gt;vmas[i].end \u0026lt;= min_vma_end) { min_vma_end = p-\u0026gt;vmas[i].end; v = \u0026amp;p-\u0026gt;vmas[i]; } } // 如果进程中还没有文件映射，就从trapframe后开始设置映射区 if (!v) { return min_vma_end; } // 这里可以直接返回v-\u0026gt;start，这样做可以处理映射区域大小不固定的情况（应该吧） return PGROUNDDOWN(v-\u0026gt;start); } sys_mmap 虽然刚刚我们有了可以获取映射区地址的函数，但是这个系统调用并不用真正分配内存，它只需要进行标记vma即可。\n找到一个可以使用的vma区域的end地址 初始化vma 返回vma的start地址 这里我觉得最重要的就是设置start和end地址，一个映射区的范围为[start, end)，其长度就为len，通过vma_end函数我们可以获取新映射区的end地址，再通过end - len即可得到start地址。\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 52 53 54 55 // sysfile.c uint64 sys_mmap(void) { // void *mmap(void *addr, int len, int prot, int flags, int fd, int offset); uint64 addr; int len, prot, flags, fd, offset; struct proc *p = myproc(); struct file *f; argaddr(0, \u0026amp;addr); argint(1, \u0026amp;len); argint(2, \u0026amp;prot); argint(3, \u0026amp;flags); argfd(4, \u0026amp;fd, \u0026amp;f); argint(5, \u0026amp;offset); if (addr \u0026lt; 0 || len \u0026lt; 0 || prot \u0026lt; 0 || flags \u0026lt; 0 || fd \u0026lt; 0 || offset \u0026lt; 0) { return -1; } if (!f-\u0026gt;readable \u0026amp;\u0026amp; (prot \u0026amp; PROT_READ) \u0026amp;\u0026amp; (flags \u0026amp; MAP_SHARED)) { return -1; } if (!f-\u0026gt;writable \u0026amp;\u0026amp; (prot \u0026amp; PROT_WRITE) \u0026amp;\u0026amp; (flags \u0026amp; MAP_SHARED)) { return -1; } // 找到一个可用的vma struct vma *v = 0; for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used == 0) { v = \u0026amp;p-\u0026gt;vmas[i]; break; } } if (!v) { return -1; } // 初始化vma uint64 end = vma_end(); v-\u0026gt;len = len; v-\u0026gt;prot = prot; v-\u0026gt;file = f; v-\u0026gt;used = 1; v-\u0026gt;flags = flags; v-\u0026gt;offset = offset; v-\u0026gt;end = end; v-\u0026gt;start = end - len; // 有文件映射时，对应的文件的引用计数也+1 filedup(f); return v-\u0026gt;start; } 懒分配策略 找到触发fault的地址，并据此找到对应的vma 校验 分配物理内存块 设置权限 读取文件内容到内存块中，注意偏移量 设置物理内存与虚拟内存的映射 1 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 52 53 54 55 56 57 58 59 60 // trap.c // 处理mmap的懒分配策略 static int handle_mmap_fault(uint64 addr) { struct proc *p = myproc(); struct vma *v = 0; // 根据触发fault的地址，并据此找到对应的vma for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used \u0026amp;\u0026amp; addr \u0026gt;= p-\u0026gt;vmas[i].start \u0026amp;\u0026amp; addr \u0026lt; p-\u0026gt;vmas[i].end) { v = \u0026amp;p-\u0026gt;vmas[i]; break; } } if (!v) { printf(\u0026#34;no no no\\n\u0026#34;); return -1; } // 校验 if (!v-\u0026gt;file-\u0026gt;readable \u0026amp;\u0026amp; r_scause() == 13 \u0026amp;\u0026amp; (v-\u0026gt;flags \u0026amp; MAP_SHARED)) { return -1; } if (!v-\u0026gt;file-\u0026gt;writable \u0026amp;\u0026amp; r_scause() == 15 \u0026amp;\u0026amp; (v-\u0026gt;flags \u0026amp; MAP_SHARED)) { return -1; } // 设置内存块权限 uint perm = PTE_V | PTE_U; if (v-\u0026gt;prot \u0026amp; PROT_READ) { perm |= PTE_R; } if (v-\u0026gt;prot \u0026amp; PROT_WRITE) { perm |= PTE_W; } if (v-\u0026gt;prot \u0026amp; PROT_EXEC) { perm |= PTE_X; } // 分配物理块 char *pa = kalloc(); if (!pa) { return -1; } memset(pa, 0, PGSIZE); // 读取文件内容到内存块 uint offset = addr - v-\u0026gt;start; ilock(v-\u0026gt;file-\u0026gt;ip); if (readi(v-\u0026gt;file-\u0026gt;ip, 0, (uint64)pa, offset, PGSIZE) == 0) { iunlock(v-\u0026gt;file-\u0026gt;ip); return -1; } iunlock(v-\u0026gt;file-\u0026gt;ip); // 设置虚拟内存与物理内存的映射 mappages(p-\u0026gt;pagetable, PGROUNDDOWN(addr), PGSIZE, (uint64)pa, perm); return 0; } 然后在usertrap中处理读写造成的page fault：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // trap.c void usertrap(void) { ... if(r_scause() == 8){ ... } else if((which_dev = devintr()) != 0){ // ok } else if (r_scause() == 13 || r_scause() == 15) { if (handle_mmap_fault(r_stval()) != 0) { printf(\u0026#34;usertrap(): unexpected scause %p pid=%d\\n\u0026#34;, r_scause(), p-\u0026gt;pid); printf(\u0026#34; sepc=%p stval=%p\\n\u0026#34;, r_sepc(), r_stval()); setkilled(p); } } else { ... } .. } 实现sys_munmap sys_munmap sys_munmap需要将内存块中的内容写回文件，并释放这个内存块。这里我们将这个操作额外封装一层，即不将具体实现放在sys_munmap中，这是因为在进程销毁也需要使用这个操作。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // sysfile.c uint64 sys_munmap(void) { // int munmap(void *addr, int len); uint64 addr; int len; argaddr(0, \u0026amp;addr); argint(1, \u0026amp;len); if (addr \u0026lt; 0 || len \u0026lt; 0) { return -1; } return munmap(addr, len); } 解除映射 遍历所有的vma，找到addr所在的vma，要求addr不能是vma区域的中间位置，可以是开头和结束位置。 使用mmap_writeback将这addr的内容写回对应的文件 更新vma的范围 如果vma的len小于等于0，说明该文件的映射已经结束，可以关闭文件了，同时这个vma也应该释放了 1 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 // vm.c // 解除区域 [addr, addr + len) 的文件映射 uint64 munmap(uint64 addr, int len) { struct proc *p = myproc(); struct vma *v = 0; for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used \u0026amp;\u0026amp; addr \u0026gt;= p-\u0026gt;vmas[i].start \u0026amp;\u0026amp; addr \u0026lt; p-\u0026gt;vmas[i].end) { v = \u0026amp;p-\u0026gt;vmas[i]; break; } } if (!v) { return -1; } // 不在合法的位置 if (addr \u0026gt; v-\u0026gt;start \u0026amp;\u0026amp; addr + len \u0026lt; v-\u0026gt;end) { return -1; } // 将映射区域写回文件 mmap_writeback(p-\u0026gt;pagetable, addr, len, v); // 修改映射区域大小 if (addr == v-\u0026gt;start) { v-\u0026gt;start += len; } else if (addr == v-\u0026gt;end - len) { v-\u0026gt;end = addr; } v-\u0026gt;len -= len; // 映射区域大小为0 if (v-\u0026gt;len \u0026lt;= 0) { fileclose(v-\u0026gt;file); v-\u0026gt;used = 0; } return 0; } 将映射区内容写回文件 遍历这个vma中的所有页框，对于其中的每一个页帧，获取对应的pte，需要考虑到由于懒分配带来的影响。 如果这个页帧被修改过，并且这块vma的策略是可写，那么就将这个页写回文件，注意偏移量 释放这块页帧对应的物理内存 1 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 // 将映射区域写回文件，并释放映射区域的内存 static int mmap_writeback(pagetable_t pgtbl, uint64 src_va, int len, struct vma *vma) { pte_t *pte; uint64 addr; // 遍历区域的页框 for (addr = PGROUNDDOWN(src_va); addr \u0026lt; PGROUNDDOWN(src_va + len); addr += PGSIZE) { // 获取页帧对应的pte if ((pte = walk(pgtbl, addr, 0)) == 0) { panic(\u0026#34;mmap_writeback\u0026#34;); } // 这是为了处理这样一种情况：使用了mmap系统调用却没有有访问映射的文件，由于懒分配的策略， // 在写回文件时vma虽然有效，但是对应的pte并没有设置PTE_V，映射区域也还没有真正的映射文件 if (!(*pte \u0026amp; PTE_V)) { continue; } // 映射区域被修改了，可以写回文件 if ((*pte \u0026amp; PTE_D) \u0026amp;\u0026amp; (vma-\u0026gt;flags \u0026amp; MAP_SHARED)) { begin_op(); ilock(vma-\u0026gt;file-\u0026gt;ip); uint offset = addr - src_va; writei(vma-\u0026gt;file-\u0026gt;ip, 1, addr, offset, PGSIZE); iunlock(vma-\u0026gt;file-\u0026gt;ip); end_op(); } kfree((void *)PTE2PA(*pte)); *pte = 0; } return 0; } 我们使用到了pte中的一个标志位PTE_D，它是用来标识一个页框是否被修改了（即脏位），我们需要在riscv.h中定义它：\n1 2 3 // riscv.h #define PTE_D (1L \u0026lt;\u0026lt; 7) 在exit时需要清空映射区 当进程退出时，其映射区中的内容也需要释放，这也是为什么要将munmap独立出来的原因。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // proc.c void exit(int status) { struct proc *p = myproc(); if(p == initproc) panic(\u0026#34;init exiting\u0026#34;); for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used) { if (munmap(p-\u0026gt;vmas[i].start, p-\u0026gt;vmas[i].len) != 0) { panic(\u0026#34;exit: munmap\u0026#34;); } } } ... } 在fork时需要“复制”映射区 我们这里所说的复制并不是将映射区的内存块在fork时都复制给子进程，可别忘了COW哦，我们只需要复制父进程中的vma数组，知道映射的哪些位置有什么样的文件映射，在真正访问时再按需加载即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // proc.c int fork(void) { ... for (int i = 0; i \u0026lt; NVMA; i++) { if (p-\u0026gt;vmas[i].used) { np-\u0026gt;vmas[i] = p-\u0026gt;vmas[i]; // 子进程也映射了和父进程相同的文件，那么这个文件的引用计数也要增加 filedup(p-\u0026gt;vmas[i].file); } } ... } Code Details 代码实现详情请见：Github\nReference https://xiaolincoding.com/os/3_memory/linux_mem.html#_3-%E8%BF%9B%E7%A8%8B%E8%99%9A%E6%8B%9F%E5%86%85%E5%AD%98%E7%A9%BA%E9%97%B4 https://ttzytt.com/2022/08/xv6_lab11_record/index.html Summary 这个lab的代码还是比较多的，不过它还给我们放了些水，只让我们实现一些基础的功能。在lab中更重要的是要搞清楚mmap的实现原理，一定要去理解其中的细节。\n","date":"2024-11-22T00:00:00Z","permalink":"/p/mit6.s081lab10-mmap/","title":"【MIT6.S081】Lab10 mmap"},{"content":"Intro 在这个实验中，我们需要让xv6支持更大的文件和软链接。实验总体不是特别难，不过需要我们理解好文件系统是如何工作的。Lab8 lock中的Buffer Cache也是文件系统的一部分，不过它位于文件系统的下层，这里我们需要处理的更多在上层，偏应用层。\nLarge files 如何扩大单个文件的大小上限？ 要求我们扩大单个文件的大小，实现机制就是通过修改inode的块索引，将原来的12个直接索引和一个一级间接索引变为11个直接索引、一个一级间接索引和一个二级间接索引。\n在xv6中，一个数据块的大小为1KB，由于间接块中存放的是下一个块的地址（其实个人认为更准确的说是块号），使用的类型是uint，那么一个间接块能存放这样的地址共1KB / sizeof(uint) = 1KB / 4B = 256个。\n这样的话，单个文件大小最大为12 * 1KB + 256 * 1KB = 268KB变为了11 * 1KB + 256 * 1KB + 256 * 256 * 1KB = 65803KB。\n修改inode结构体 其数据块地址中：有11个直接块地址，1个一级间接块地址，1个二级间接块地址\n在原本的代码中，表示直接块数量的宏为NDIRECT，其定义在fs.h中，我们需要修改其值为11，并且由于我们扩大了文件大小上限，所以也要修改对应的宏MAXFILE，扩大后的应该为：NDIRECT + NINDIRECT + NINDIRECT * NINDIRECT；同时还要修改inode结构体：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // fs.h #define NDIRECT 11 #define NINDIRECT (BSIZE / sizeof(uint)) #define MAXFILE (NDIRECT + NINDIRECT + NINDIRECT * NINDIRECT) // On-disk inode structure struct dinode { short type; // File type short major; // Major device number (T_DEVICE only) short minor; // Minor device number (T_DEVICE only) short nlink; // Number of links to inode in file system uint size; // Size of file (bytes) uint addrs[NDIRECT+2]; // Data block addresses }; 内存中保存的inode结构体也需要修改：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 // file.h // in-memory copy of an inode struct inode { uint dev; // Device number uint inum; // Inode number int ref; // Reference count struct sleeplock lock; // protects everything below here int valid; // inode has been read from disk? short type; // copy of disk inode short major; short minor; short nlink; uint size; uint addrs[NDIRECT+2]; }; 修改bmap函数 bmap函数会根据传入参数的数据块号来返回其对应的地址\n我们在bmap中看到如果数据块还没有分配的时候，会使用balloc来分配，并返回分配的块的块号。\n这个函数我一开始看的时候对于如何分配的比较奇怪，后来在AI的帮助下慢慢理解了。在xv6中的硬盘布局是这样的：\n1 [ boot block | super block | log | inode blocks | free bit map | data blocks] 在学习操作系统时，我们有学到过一个叫位图的东西，将多个块合并作为一个位图，其中的每一位（bit）用来唯一表示一个数据块是否被使用了（例如0表示为使用，1表示使用）。在xv6中，我们在需要分配数据块时，会去位图中寻找可用的数据块的编号，在去获取对应的数据块：\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 static uint balloc(uint dev) { int b, bi, m; struct buf *bp; bp = 0; // 文件系统总共有 200000 个块（sb.size = 200000），且每个位图块可以管理 8192 个块（BPB = 8192） // 在第一次迭代中，b = 0，读取管理第 0 到第 8191 个块的位图块。 // 内层循环遍历这 8192 个块，寻找空闲块。 // 在第二次迭代中，b = 8192，读取管理第 8192 到第 16383 个块的位图块。 for(b = 0; b \u0026lt; sb.size; b += BPB){ bp = bread(dev, BBLOCK(b, sb)); // 获取在第n次迭代中管理一个BPB大小的位图块 // 遍历这个位图块中的每一位，找到未使用的块号并返回 for(bi = 0; bi \u0026lt; BPB \u0026amp;\u0026amp; b + bi \u0026lt; sb.size; bi++){ m = 1 \u0026lt;\u0026lt; (bi % 8); if((bp-\u0026gt;data[bi/8] \u0026amp; m) == 0){ // Is block free? bp-\u0026gt;data[bi/8] |= m; // Mark block in use. log_write(bp); brelse(bp); bzero(dev, b + bi); return b + bi; } } brelse(bp); } printf(\u0026#34;balloc: out of blocks\\n\u0026#34;); return 0; } 要实现大文件的bmap，我们可以仿照原本的bmap是如何处理一级间接块的：\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 static uint bmap(struct inode *ip, uint bn) { ... // 块号（bn）减去11个，这样是为了方便之后可以按偏移值为0开始计算 bn -= NDIRECT; // 如果bn超出了11个，也就说明需要通过一级间接块去获取 if(bn \u0026lt; NINDIRECT){ // Load indirect block, allocating if necessary. // ip-\u0026gt;addrs[11]代表了一级间接块的地址 if((addr = ip-\u0026gt;addrs[NDIRECT]) == 0){ // 此时还未分配一级间接块，分配 addr = balloc(ip-\u0026gt;dev); if(addr == 0) return 0; ip-\u0026gt;addrs[NDIRECT] = addr; } // 从硬盘中读出这个块 bp = bread(ip-\u0026gt;dev, addr); // 获取这个块中的数据（不过这里只是通过指针来更好的操作） a = (uint*)bp-\u0026gt;data; if((addr = a[bn]) == 0){ // 如果还没有分配bn所表示的数据块，分配 addr = balloc(ip-\u0026gt;dev); if(addr){ a[bn] = addr; log_write(bp); } } brelse(bp); return addr; } ... } 同样的，二级间接块也是类似的操作手法：\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 static uint bmap(struct inode *ip, uint bn) { ... // 如果bn超出了11个，也就说明需要通过一级间接块去获取 if(bn \u0026lt; NINDIRECT){ ... } // 如果运行到了这里，说明要从二级间接块中寻找，这里减去NINDIRECT（256）也是像之前一样方便计算 bn -= NINDIRECT; // 从二级间接块中找 if (bn \u0026lt; NINDIRECT * NINDIRECT) { // inode的二级间接块还未分配 if ((addr = ip-\u0026gt;addrs[NDIRECT + 1]) == 0) { addr = balloc(ip-\u0026gt;dev); if(addr == 0) return 0; ip-\u0026gt;addrs[NDIRECT + 1] = addr; } int level1 = bn / NINDIRECT; int level2 = bn % NINDIRECT; // 读取二级间接块 bp = bread(ip-\u0026gt;dev, addr); a = (uint *)bp-\u0026gt;data; if ((addr = a[level1]) == 0){ a[level1] = addr = balloc(ip-\u0026gt;dev); log_write(bp); // 修改了就要记录日志 } brelse(bp); bp = bread(ip-\u0026gt;dev, addr); a = (uint *)bp-\u0026gt;data; if ((addr = a[level2]) == 0) { a[level2] = addr = balloc(ip-\u0026gt;dev); log_write(bp); // 修改了就要记录日志 } brelse(bp); return addr; } } 多的步骤就是二级间接索引需要多一次读取数据块和查找：\n假设传入bmap的块号为524，那么其不在直接块中，也不在一级间接块中，而是在二级间接块中。通过bmap中的前两个if之后对于bn的减法操作，此时的bn = 524 - 11 - 256 = 257。那么在二级间接块的第一层中（level1），索引为257 / 256 = 1，在第二层中（level2）索引为257 % 256 = 1 。如下图所示：\nSymbolic links 首先要搞清楚软硬链接的区别：\n软链接（符号链接）： 软链接是一种特殊的文件，它包含指向另一个文件或目录的路径。它类似于Windows中的快捷方式。 存储的是目标文件或目录的路径信息。 硬链接： 硬链接是文件系统中的一个普通文件名，只是它与其他文件名指向同一个物理文件数据块。 存储的是目标文件的数据本身，不包含路径信息。 那么要我们实现软链接，我们需要做的事情有两个：\n实现软链接的系统调用 处理使用open打开一个软链接文件 系统调用的实现 实验中让我们实现的系统调用接受连个参数(char *target, char *path)，它在path处创建了一个软链接，该链接指向文件名为target的文件。\n如何注册系统调用啥的这里就不赘述了。\n首先我们需要准备一些宏：\n1 2 3 4 5 // stat.h #define T_SYMLINK 4 // symbolic link // fcntl.h #define O_NOFOLLOW 0x800 实现sys_symlink系统调用：\n我们创建一个软链接时需要使用create新建一个inode，因为软链接也是一个文件；然后调用writei将目标路径的字符串target写入软链接文件的数据块。\n需要注意的是在完成这两步之后需要使用iunlockput(ip)来释放在create中加上的锁。\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 uint64 sys_symlink(void) { char target[MAXPATH], path[MAXPATH]; if (argstr(0, target, MAXPATH) \u0026lt; 0 || argstr(1, path, MAXPATH) \u0026lt; 0) { return -1; } begin_op(); // 软链接是一个特殊的文件，其存储的数据为目标文件的路径信息，因此我们在创建软链接时需要创建一个新的inode struct inode *ip = create(path, T_SYMLINK, 0, 0); if (ip == 0) { end_op(); return -1; } // 将target字符串写入inode的第一个直接块中 if (writei(ip, 0, (uint64)target, 0, strlen(target)) \u0026lt; 0) { end_op(); return -1; } iunlockput(ip); end_op(); return 0; } 处理open一个软链接的情况 这里可能是这个lab的一个难点了。为什么我们需要处理这样的情况呢？\n还记得我们刚刚准备的一个宏O_NOFOLLOW吗，在Linux中，它有这样些特点：\n符号链接检查：当O_NOFOLLOW标志被设置时，如果指定的文件名是一个软链接，打开操作将不会跟随这个链接，而是会失败，并返回一个错误。 错误返回：如果尝试打开一个软链接，并且O_NOFOLLOW标志被设置，系统会返回ELOOP错误，表示遇到了太多的符号链接。 用途：这个标志通常用于安全目的，防止应用程序无意中打开一个软链接，这可能被恶意用户用来绕过安全限制。 例如：\n1 int fd = open(\u0026#34;/path/to/symlink\u0026#34;, O_RDONLY | O_NOFOLLOW); 如果/path/to/symlink是一个软链接，这个调用将失败。\n我们需要在sys_open中处理：当需要打开的文件类型是软链接类型并且没有使用O_NOFOLLOW标志时，递归找到最终的目标文件。为什么说是“递归”呢，因为可能有这样一种情况：一个软链接指向了另一个软链接，甚至如此以往。为了避免“子子孙孙无穷无尽”的情况，我们需要设置一个递归层数的限制，当然，我们这里使用循环来代替递归。\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 uint64 sys_open(void) { ... if(ip-\u0026gt;type == T_DEVICE \u0026amp;\u0026amp; (ip-\u0026gt;major \u0026lt; 0 || ip-\u0026gt;major \u0026gt;= NDEV)){ ... } // 文件的类型是软链接，且没有使用O_NOFOLLOW标志，就递归地查找，直到找到最终的目标文件的inode // 否则，就当作是正常的文件进行打开 if (ip-\u0026gt;type == T_SYMLINK \u0026amp;\u0026amp; !(omode \u0026amp; O_NOFOLLOW)) { // 如果循环10次后找到的文件类型还是软链接，那么说明有错误了 for (int i = 0; i \u0026lt; 10; i++) { // 从软链接文件的inode中读取目标文件的路径字符串 // 注意，在这一步之前已经对ip上锁，因此这里操作完后需要解锁 if (readi(ip, 0, (uint64)path, 0, strlen(path)) \u0026lt; 0) { iunlockput(ip); end_op(); return -1; } iunlockput(ip); ip = namei(path); if (ip == 0) { end_op(); return -1; } ilock(ip); if (ip-\u0026gt;type != T_SYMLINK) { break; } } if (ip-\u0026gt;type == T_SYMLINK) { iunlockput(ip); end_op(); return -1; } } if((f = filealloc()) == 0 || (fd = fdalloc(f)) \u0026lt; 0){ ... } ... } Code Detail 代码实现详情请见Github：\nLarge files Symbolic links Reference https://pdos.csail.mit.edu/6.S081/2023/labs/fs.html https://xv6.dgs.zone/labs/answers/lab9.html Summary 文件系统是一个有意思的东西，同时也很复杂，xv6中设计了一个简化的文件系统，并采用了分层的设计。虽然这里将实验做完了，但是我觉得自己还有很多细节没有搞懂。例如其中的日志层，其是如何实现crash之后能恢复过来的代码是如何设计编写的，还有其中的锁的获取与释放等等。希望之后能结合实际再分析分析。\n","date":"2024-11-19T00:00:00Z","permalink":"/p/mit6.s081lab9-file-system/","title":"【MIT6.S081】Lab9 file system"},{"content":"Intro 这个实验个人感觉挺难的，需要我们重新设计数据结构，还要考虑在并发（并行）情况下对于锁的操作，以减少多核情况下对于锁的竞争。其中主要涉及内存分配和IO缓冲块分配，在这个lab之前，xv6对于这两个分配都是使用的全局对象，并只有一把全局锁进行操作，这样的话在并行情况下锁的竞争是很激烈的，我们的任务就是重新设计这两个分配器，它们的重构思路并不完全一致，需要具体问题具体分析。\nMemory Allocator 在xv6中，内存通过kalloc()来分配，在其内部，会使用一个kmem的结构体变量：\n1 2 3 4 5 6 7 8 9 10 // kernel/kalloc.c struct run { struct run *next; }; struct { struct spinlock lock; struct run *freelist; } kmem; kmem.freelist保存着未使用的内存块（每块大小为4KB），在内核初始化时，会通过kinit()将地址KERNBASE ~ PHYSTOP的物理内存（一共128MB）放入freelist中：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void kinit() { initlock(\u0026amp;kmem.lock, \u0026#34;kmem\u0026#34;); freerange(end, (void*)PHYSTOP); // end为kernel.ld中定义的，值为0x80000000，即KERNBASE } void freerange(void *pa_start, void *pa_end) { char *p; p = (char*)PGROUNDUP((uint64)pa_start); for(; p + PGSIZE \u0026lt;= (char*)pa_end; p += PGSIZE) kfree(p); // 使用kfree将内存块放入freelist中 } 通过上面的源码分析我们可以看到，整个xv6内核都是通过一个全局的kmem来分配和回收内存块，那么如果当多个CPU（多个core）需要操作内存块时，就必须得用锁才能保证整体的稳定和正确。\n但是这样做又会有一个大问题，那就是一个cpu在操作kmem时，另一个CPU即便想获取内存块或释放内存块，因为锁的缘故也只能等待。故我们的解决方案是为每个CPU都设置一个kmem，这样，哪个CPU需要操作内存块时就可以只锁上它自己的kmem，其他CPU受到的干扰会大大减少。\n为什么说是大大减少而不是完全减少呢？这是因为当某个CPU的kmem.freelist中没有可分配的内存块时，需要去其他CPU的kmem.freelist中去拿一个过来，这时就需要处理好这两个CPU的锁的处理了。\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 struct { struct spinlock lock; struct run *freelist; char lock_name[8]; // 每个kmem的锁的名称 } kmem[NCPU]; // 为每个CPU都设置一个kmem void kinit() { // 初始化时要对每个kmem都初始锁 for (int i = 0; i \u0026lt; NCPU; i++) { initlock(\u0026amp;kmem[i].lock, \u0026#34;kmem\u0026#34;); snprintf(kmem[i].lock_name, sizeof(kmem[i].lock_name), \u0026#34;kmem%d\u0026#34;, i); } // 这里会先将所有内存块都分配给kmem[0]，因为内核启动时是cpus[0]在做初始化操作 // 之后其他CPU需要内存块时，从cpus[0]这里拿 freerange(end, (void*)PHYSTOP); } void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa \u0026lt; end || (uint64)pa \u0026gt;= PHYSTOP) panic(\u0026#34;kfree\u0026#34;); // Fill with junk to catch dangling refs. memset(pa, 1, PGSIZE); r = (struct run*)pa; // ---------------------------------------- // kfree时只需要将内存块放到自己所属cpu的kmem.freelist中 push_off(); int cid = cpuid(); acquire(\u0026amp;kmem[cid].lock); r-\u0026gt;next = kmem[cid].freelist; kmem[cid].freelist = r; release(\u0026amp;kmem[cid].lock); pop_off(); // ---------------------------------------- } void * kalloc(void) { struct run *r; // ---------------------------------------- push_off(); int cid = cpuid(); acquire(\u0026amp;kmem[cid].lock); r = kmem[cid].freelist; if (r) { // 如果当前的freelist中还有内存块，则直接用 kmem[cid].freelist = r-\u0026gt;next; } else { // 没有？那就拿！ // 遍历一下其他CPU的kmem，如果找到的freelist中还有内存块，就拿它的 for (int next_cid = 0; next_cid \u0026lt; NCPU; next_cid++) { // 不找自己 if (next_cid == cid) continue; acquire(\u0026amp;kmem[next_cid].lock); r = kmem[next_cid].freelist; if (r) { kmem[next_cid].freelist = r-\u0026gt;next; release(\u0026amp;kmem[next_cid].lock); break; } release(\u0026amp;kmem[next_cid].lock); } } release(\u0026amp;kmem[cid].lock); pop_off(); // ---------------------------------------- if(r) memset((char*)r, 5, PGSIZE); // fill with junk return (void*)r; } Buffer Cache 先介绍下实验初始的bcache：\n1 2 3 4 5 6 7 8 9 struct { struct spinlock lock; struct buf buf[NBUF]; // Linked list of all buffers, through prev/next. // Sorted by how recently the buffer was used. // head.next is most recent, head.prev is least. struct buf head; } bcache; struct spinlock lock：bcache的全局锁 struct buf buf[NBUF]：缓冲块池，即包含了所有的buffer cache struct buf head：一个LRU链表，用于操作缓冲块，使用head.next获取的是最近刚使用过的buffer，head.prev获取的是最近未使用时间最久的buffer（或者说是未被使用的buffer，即引用计数为0） 原来的bcache中的buf数组在binit时就给LRU链表初始化用了：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void binit(void) { struct buf *b; initlock(\u0026amp;bcache.lock, \u0026#34;bcache\u0026#34;); // Create linked list of buffers bcache.head.prev = \u0026amp;bcache.head; bcache.head.next = \u0026amp;bcache.head; for(b = bcache.buf; b \u0026lt; bcache.buf+NBUF; b++){ b-\u0026gt;next = bcache.head.next; b-\u0026gt;prev = \u0026amp;bcache.head; initsleeplock(\u0026amp;b-\u0026gt;lock, \u0026#34;buffer\u0026#34;); bcache.head.next-\u0026gt;prev = b; bcache.head.next = b; } } 而这样设计的buffer cache有一个问题，由于buffer cache只有一个全局锁，当在高并发的情况下，进程需要并发访问bcache时，无法达到多进程带来的优势，一个进程必须要先等前一个进程释放锁后才可以操作。\n考虑前一个实验中的kalloc，我们能否也使用那样的策略？\nReducing contention in the block cache is more tricky than for kalloc, because bcache buffers are truly shared among processes (and thus CPUs). For kalloc, one could eliminate most contention by giving each CPU its own allocator; that won\u0026rsquo;t work for the block cache.\n为什么这么说呢？是因为block cache并不像内存页那样具有通用性。block cache对应着真实的物理外存块，每一个CPU都可能使用同一个block cache，所以无法像kalloc中那样为每个CPU分配其对应的cache。\n既然我们无法分别为每个CPU分配，那么我们可以换一个角度，对所有的block块进行分组：我们可以创建一个哈希桶hash buckets（这里使用的容量为13），那么对每个block块的块号进行取余操作（mod 13）即可将它们映射到其中一个哈希桶中。这样一来，我们需要使用标号为blockno的块时，到其映射的哈希桶中去寻找即可，并且，在并发情况下，我们一般上锁的单位从整个bcache变为了其中的一个桶，锁的粒度大大减小了。\n那么我们修改一下bcache的数据结构：\n1 2 3 4 5 6 7 8 9 10 11 #define NBUCKET 13 #define BLOCK_HASH(blockno) (blockno % NBUCKET) struct { struct spinlock g_lock; // 全局锁，这里相较之前只是改了个名字 struct buf buf[NBUF]; struct spinlock bk_lock[NBUCKET]; // 每个hash bucket都对应有一把锁 struct buf bucket[NBUCKET]; // hash bucket int size; // 缓冲块池（buf[NBUF]）中已使用的块数 } bcache; 我们在重构的方案中，不再使用LRU链表，但还是使用了LRU算法的思想，只是用时间戳来代替LRU链表：\n1 2 3 4 5 6 7 8 9 10 11 12 struct buf { int valid; // has data been read from disk? int disk; // does disk \u0026#34;own\u0026#34; buf? uint dev; uint blockno; struct sleeplock lock; uint refcnt; // struct buf *prev; // LRU cache list struct buf *next; uchar data[BSIZE]; uint timestamp; }; 这个时间戳通过一个全局变量ticks（trap.c）来获取：\n1 extern uint ticks; 那么在初始化bcache时，我们只需要初始化其中的锁和bcache.size即可：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void binit(void) { struct buf *b; bcache.size = 0; initlock(\u0026amp;bcache.g_lock, \u0026#34;bcache\u0026#34;); for (int i = 0; i \u0026lt; NBUCKET; i++) { initlock(\u0026amp;bcache.bk_lock[i], \u0026#34;bk_lock\u0026#34;); } for(b = bcache.buf; b \u0026lt; bcache.buf+NBUF; b++){ initsleeplock(\u0026amp;b-\u0026gt;lock, \u0026#34;buffer\u0026#34;); } } 由于我们对于锁的操作单位变为了bucket，所以下面两个函数也需要修改：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void bpin(struct buf *b) { int idx = BLOCK_HASH(b-\u0026gt;blockno); acquire(\u0026amp;bcache.bk_lock[idx]); b-\u0026gt;refcnt++; release(\u0026amp;bcache.bk_lock[idx]); } void bunpin(struct buf *b) { int idx = BLOCK_HASH(b-\u0026gt;blockno); acquire(\u0026amp;bcache.bk_lock[idx]); b-\u0026gt;refcnt--; release(\u0026amp;bcache.bk_lock[idx]); } 在原先的释放buffer块的brelse(struct buf *b)函数中，会先对b的引用计数-1，当其值为0时，将其转移至LRU链表的head-\u0026gt;prev位置，说明这个buffer没有被任何进程使用了。而在重构方案中，我们使用时间戳代替了LRU链表，每个bucket中我们并没有维护一个LRU链表，而是认为引用计数为0且时间戳最小的的buffer是没有被任何进程使用的，故在引用计数为0时，只需要更新buffer的时间戳即可：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 void brelse(struct buf *b) { if(!holdingsleep(\u0026amp;b-\u0026gt;lock)) panic(\u0026#34;brelse\u0026#34;); releasesleep(\u0026amp;b-\u0026gt;lock); int idx = BLOCK_HASH(b-\u0026gt;blockno); acquire(\u0026amp;bcache.bk_lock[idx]); b-\u0026gt;refcnt--; if (b-\u0026gt;refcnt == 0) { // no one is waiting for it. b-\u0026gt;timestamp = ticks; // 未使用的buffer的时间戳一定是最小的 } release(\u0026amp;bcache.bk_lock[idx]); } 对于bcache最重要的bget()函数，我们重构的思路为：\n检查标号为blockno的块的缓存是否在cache中，如果在，则其引用计数+1，返回该缓存块 在缓存中没有找到，先在缓冲块池中寻找还未分配给bucket的缓存块 如果缓冲块池中都分配出去了，就到每个bucket中去找。如果当前遍历的bucket中有buffer的引用计数为0，拿到其中时间戳最小的buffer（这也就是说这个buffer是距离现在最久没有使用的合法块） 如果这个buffer在原先的bucket中，返回这个buffer 否则需要将这个buffer从当前bucket中转移到目标bucket中 这是一个大致的思路，具体细节参考如下代码：\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 static struct buf* bget(uint dev, uint blockno) { struct buf *b; int bucket_idx = BLOCK_HASH(blockno); // 先对blockno对应的bucket上锁即可 acquire(\u0026amp;bcache.bk_lock[bucket_idx]); // Is the block already cached? for(b = \u0026amp;bcache.bucket[bucket_idx]; b; b = b-\u0026gt;next){ if(b-\u0026gt;dev == dev \u0026amp;\u0026amp; b-\u0026gt;blockno == blockno){ b-\u0026gt;refcnt++; release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;b-\u0026gt;lock); return b; } } // 在缓存中没有找到，先在缓冲块池中寻找还未分配给bucket的缓存块 // 需要使用全局锁来保证bcache.size++的原子性 // 这里也是为什么buffer时间戳不需要初始化的原因：每一次需要使用buffer时（会调用bget）， // 如果buffer池中还有空闲buffer，则会直接使用这个buffer，完成操作后会调用relese释放buffer，此时就会更新buffer的时间戳 acquire(\u0026amp;bcache.g_lock); if (bcache.size \u0026lt; NBUF) { struct buf *b = \u0026amp;bcache.buf[bcache.size++]; b-\u0026gt;next = bcache.bucket[bucket_idx].next; bcache.bucket[bucket_idx].next = b; b-\u0026gt;dev = dev; b-\u0026gt;blockno = blockno; b-\u0026gt;valid = 0; b-\u0026gt;refcnt = 1; release(\u0026amp;bcache.g_lock); acquiresleep(\u0026amp;b-\u0026gt;lock); return b; } release(\u0026amp;bcache.g_lock); // 在这时才能释放该bucket的锁，假设在检查缓存是否存在后释放： // 如果有两个进程1和2，此时缓冲块池还有多个未分配的块，进程1检查bucket，发现没有缓存，释放锁，准备去缓冲块池中拿 // 此时切换到进程2，进程2检查bucket,发现没有缓存，也去缓冲块池中拿 // 这样就会导致bucket会添加两个blockno的缓冲块 release(\u0026amp;bcache.bk_lock[bucket_idx]); // 在每个bucket中去找可用的buffer cache for (int i = 0; i \u0026lt; NBUCKET; i++) { struct buf *cur_buf, *pre_buf, *min_buf, *min_pre_buf; uint min_timestamp = -1; acquire(\u0026amp;bcache.bk_lock[bucket_idx]); pre_buf = \u0026amp;bcache.bucket[bucket_idx]; cur_buf = pre_buf-\u0026gt;next; // 遍历bcache.bucket[bucket_idx] while (cur_buf) { // 为什么这里需要重新检查？考虑这样一种情况： // 假设缓冲块池中还有一个未分配的，此时有两个进程，进程1和2都需要访问同一个标号blockno的块 // 进程1先拿到这个未分配的，并将其放入了对应的bucket中 // 之后进程2发现池中没有未分配的了，开始遍历所有bucket， // 如果这时不重新检查一下blockno对应的bucket，则会导致一个bucket中有两个blockno的块 if (bucket_idx == BLOCK_HASH(blockno) \u0026amp;\u0026amp; cur_buf-\u0026gt;blockno == blockno \u0026amp;\u0026amp; cur_buf-\u0026gt;dev == dev) { cur_buf-\u0026gt;refcnt++; release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;cur_buf-\u0026gt;lock); return cur_buf; } // 只有引用计数为0,并且时间戳最小的缓冲块才可被重新分配 if (cur_buf-\u0026gt;refcnt == 0 \u0026amp;\u0026amp; cur_buf-\u0026gt;timestamp \u0026lt; min_timestamp) { min_pre_buf = pre_buf; min_buf = cur_buf; min_timestamp = cur_buf-\u0026gt;timestamp; } pre_buf = cur_buf; cur_buf = cur_buf-\u0026gt;next; } // 在本轮中找到了可重新分配的缓冲块 if (min_buf) { min_buf-\u0026gt;dev = dev; min_buf-\u0026gt;blockno = blockno; min_buf-\u0026gt;valid = 0; min_buf-\u0026gt;refcnt = 1; // 是自身bucket中的，不用做转移操作 if (bucket_idx == BLOCK_HASH(blockno)) { // release(\u0026amp;bcache.hash_lock); release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;min_buf-\u0026gt;lock); return min_buf; } // 是其他bucket中的，需要转移 // 先将目标bucket中的buffer移除，然后释放锁 min_pre_buf-\u0026gt;next = min_buf-\u0026gt;next; release(\u0026amp;bcache.bk_lock[bucket_idx]); // 接着获取blockno对应的锁，并将从目标bucket中移除的buffer放至blockno对应的bucket，返回该buffer bucket_idx = BLOCK_HASH(blockno); acquire(\u0026amp;bcache.bk_lock[bucket_idx]); min_buf-\u0026gt;next = bcache.bucket[bucket_idx].next; bcache.bucket[bucket_idx].next = min_buf; release(\u0026amp;bcache.bk_lock[bucket_idx]); acquiresleep(\u0026amp;min_buf-\u0026gt;lock); return min_buf; } release(\u0026amp;bcache.bk_lock[bucket_idx]); // 如果到底了，从头来，保证遍历每一个bucket if (++bucket_idx == NBUCKET) { bucket_idx = 0; } } panic(\u0026#34;bget: no buffers\u0026#34;); } bget是这个lab中最复杂的地方，其处理逻辑虽然较好理解，但是多个锁的操作顺序很让人头疼，需要考虑到多种并发情况。\nCode Details 代码实现详情请见Github：https://github.com/kerolt/xv6-labs-2023/commit/ccac48e8ae6b6dde3e7c747a77f4149232420901\nReference https://github.com/whileskies/xv6-labs-2020/blob/main/doc/Lab8-locks.md https://blog.csdn.net/LostUnravel/article/details/121430900 ","date":"2024-11-17T00:00:00Z","permalink":"/p/mit6.s081lab8-lock/","title":"【MIT6.S081】Lab8 lock"},{"content":"在做thread lab的时候，阅读xv6的源码后对于进程调度的实现有了大致的了解，但是其中锁的获取与释放顺序让我困惑了好久：在yield函数中，不是先获取了进程p的锁吗，那么之后在调度器中又获取p的锁，那不是会死锁吗？在调度器内使用swtch发生进程切换后，又会跳转到哪里？\n而在我观摩大佬的一些博客和视频后，发现我之前的想法有很大的问题，归根结底是没有弄明白xv6何时发生了切换，切换后应该从哪里开始运行。这篇笔记就是对于分析xv6进程调度的总结。\n在使用allocproc创建进程时，会为创建的proc设置ra的值，初始会设置为forkret函数的地址。\n1 2 3 4 5 6 7 8 9 10 static struct proc* allocproc(void) { ... memset(\u0026amp;p-\u0026gt;context, 0, sizeof(p-\u0026gt;context)); p-\u0026gt;context.ra = (uint64)forkret; p-\u0026gt;context.sp = p-\u0026gt;kstack + PGSIZE; return p; } 在调度器scheduler开始运行后，遍历进程表，先对遍历到的当前进程p上锁，如果这个进程是可运行的（RUNNABLE），则执行swtch函数切换上下文，保存当前cpu的上下文，同时加载p的上下文到寄存器中。\n执行完swtch后会跳转到ra寄存器所保存的地址，也就是这个进程初始设置的forkret的位置，这个函数中会释放在scheduler中对进程p上的锁。如果这个函数是第一次执行，那么会初始化文件系统。之后执行usertrapret函数。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // A fork child\u0026#39;s very first scheduling by scheduler() // will swtch to forkret. void forkret(void) { static int first = 1; // Still holding p-\u0026gt;lock from scheduler. release(\u0026amp;myproc()-\u0026gt;lock); if (first) { // File system initialization must be run in the context of a // regular process (e.g., because it calls sleep), and thus cannot // be run from main(). fsinit(ROOTDEV); first = 0; // ensure other cores see first=0. __sync_synchronize(); } usertrapret(); } usertrapret中，将会关闭中断，最后这会让当前进程从内核态返回到用户态（回到用户空间），切换用户页表并恢复用户态寄存器。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // // return to user space // void usertrapret(void) { ... // we\u0026#39;re about to switch the destination of traps from // kerneltrap() to usertrap(), so turn off interrupts until // we\u0026#39;re back in user space, where usertrap() is correct. intr_off(); ... // jump to userret in trampoline.S at the top of memory, which // switches to the user page table, restores user registers, // and switches to user mode with sret. uint64 trampoline_userret = TRAMPOLINE + (userret - trampoline); ((void (*)(uint64))trampoline_userret)(satp); } 在之后如果该进程会触发中断（例如时间片轮转调度中执行时间到了后触发时钟中断）或者使用了系统调用，将会执行usertrap函数（也即处理陷入），在其中会执行yield函数来让该进程让出cpu，并进行进程切换。\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 // // handle an interrupt, exception, or system call from user space. // called from trampoline.S // void usertrap(void) { ... if(r_scause() == 8){ // system call ... } else if((which_dev = devintr()) != 0){ // ok } else { printf(\u0026#34;usertrap(): unexpected scause %p pid=%d\\n\u0026#34;, r_scause(), p-\u0026gt;pid); printf(\u0026#34; sepc=%p stval=%p\\n\u0026#34;, r_sepc(), r_stval()); setkilled(p); } if(killed(p)) exit(-1); // give up the CPU if this is a timer interrupt. if(which_dev == 2) yield(); usertrapret(); } yield函数会先对当前进程p上锁，然后执行shed函数，shed函数会先进行一些检查，然后调用swtch函数进行上下文切换，其保存p的上下文，并加载之前cpu的上下文。这样，ra寄存器中的值就变成了scheduler中的swtch所在地址的后一条指令的地址。在schduler中，重置当前cpu所运行的进程后，释放进程p的锁，之后开启新一轮调度（遍历进程表找到下一个可运行的进程）。\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 void sched(void) { int intena; struct proc *p = myproc(); if(!holding(\u0026amp;p-\u0026gt;lock)) panic(\u0026#34;sched p-\u0026gt;lock\u0026#34;); if(mycpu()-\u0026gt;noff != 1) panic(\u0026#34;sched locks\u0026#34;); if(p-\u0026gt;state == RUNNING) panic(\u0026#34;sched running\u0026#34;); if(intr_get()) panic(\u0026#34;sched interruptible\u0026#34;); intena = mycpu()-\u0026gt;intena; swtch(\u0026amp;p-\u0026gt;context, \u0026amp;mycpu()-\u0026gt;context); mycpu()-\u0026gt;intena = intena; } // Give up the CPU for one scheduling round. void yield(void) { struct proc *p = myproc(); acquire(\u0026amp;p-\u0026gt;lock); p-\u0026gt;state = RUNNABLE; sched(); release(\u0026amp;p-\u0026gt;lock); } 这里yield中使用了acquire和release函数来进行锁的操作，但是这个锁的获取与释放时机并不是如同代码中的这样是一个“顺序”的过程。\n当执行顺序为从调度器选择可调度进程（RUNNABLE）后进行进程切换： 当执行顺序为进程触发中断等陷入时，放弃cpu资源，需要执行进程调度时： 可以看到，在yield函数中，进程p的锁的使用顺序并不是acquire() 获取锁-\u0026gt; 执行shed()函数 -\u0026gt; release()释放锁这样线性的，在其中会发生进程切换因此锁的“获取、释放”逻辑并不发生在一个函数中。\n","date":"2024-11-16T00:00:00Z","permalink":"/p/mit6.s081xv6%E8%BF%9B%E7%A8%8B%E8%B0%83%E5%BA%A6%E5%88%86%E6%9E%90/","title":"【MIT6.S081】xv6进程调度分析"},{"content":"本次的实验总体都不是很难，第一个练习让我们在用户态模拟了线程的切换，这里重要的就是进程/线程上下文的保存与恢复；第二三个练习则是让我们跳出了xv6，去熟悉pthread库和线程的同步互斥。\n简单分析xv6中的进程切换 init是用户态最先启动的进程，其启动后会创建sh进程，sh进程又会fork并exec其他命令：\n内核会在userinit()中准备init进程，这是最先启动的用户态进程，将其运行状态设置为RUNNABLE 内核启动scheduler进行进程调度（这个函数会一直执行），当前只有init进程，且其状态为RUNNABLE，故运行 init进程会fork一个子进程，并使用exec执行sh进程，其用于处理用户在控制台的输入 用户输入命令后，sh解析命令后fork子进程并用exec来将命令对应的进程替换掉fork的子进程 1 2 3 4 5 init - sh - ls - cat - ... xv6内部采用时间片轮转来进行进程调度，当进程时间片到了后，触发时钟中断，执行yield，在yield中，会将当前进程的状态从运行态RUNNING设置为RUNNABLE，并切换至scheduler调度器，由其来寻找下一个可运行的进程进行切换。\n那么xv6是怎么切换当前进程和scheduler调度器的呢？其实现是根据swtch函数来实现的：\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 // Switch to scheduler. Must hold only p-\u0026gt;lock // and have changed proc-\u0026gt;state. Saves and restores // intena because intena is a property of this // kernel thread, not this CPU. It should // be proc-\u0026gt;intena and proc-\u0026gt;noff, but that would // break in the few places where a lock is held but // there\u0026#39;s no process. void sched(void) { int intena; struct proc *p = myproc(); if(!holding(\u0026amp;p-\u0026gt;lock)) panic(\u0026#34;sched p-\u0026gt;lock\u0026#34;); if(mycpu()-\u0026gt;noff != 1) panic(\u0026#34;sched locks\u0026#34;); if(p-\u0026gt;state == RUNNING) panic(\u0026#34;sched running\u0026#34;); if(intr_get()) panic(\u0026#34;sched interruptible\u0026#34;); intena = mycpu()-\u0026gt;intena; swtch(\u0026amp;p-\u0026gt;context, \u0026amp;mycpu()-\u0026gt;context); mycpu()-\u0026gt;intena = intena; } // Give up the CPU for one scheduling round. void yield(void) { struct proc *p = myproc(); acquire(\u0026amp;p-\u0026gt;lock); p-\u0026gt;state = RUNNABLE; sched(); release(\u0026amp;p-\u0026gt;lock); } swtch(\u0026amp;p-\u0026gt;context, \u0026amp;mycpu()-\u0026gt;context)函数会将当前寄存器中的值保存到p-\u0026gt;context中，并将当前cpu中context的内容恢复到寄存器中，其中最重要的就是ra寄存器，当恢复了这个后，ra的值对应的地址在调度器scheduler()中：\n（这里ra的16进制为0x8000141e）\n这样，xv6就从：执行当前进程 -\u0026gt; yield -\u0026gt; shed -\u0026gt; scheduler；在scheduler调度器中，内核会找到下一个就绪态的进程，并使用swtch进行进程切换：\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 // Per-CPU process scheduler. // Each CPU calls scheduler() after setting itself up. // Scheduler never returns. It loops, doing: // - choose a process to run. // - swtch to start running that process. // - eventually that process transfers control // via swtch back to the scheduler. void scheduler(void) { struct proc *p; struct cpu *c = mycpu(); c-\u0026gt;proc = 0; for(;;){ // The most recent process to run may have had interrupts // turned off; enable them to avoid a deadlock if all // processes are waiting. intr_on(); for(p = proc; p \u0026lt; \u0026amp;proc[NPROC]; p++) { acquire(\u0026amp;p-\u0026gt;lock); if(p-\u0026gt;state == RUNNABLE) { // Switch to chosen process. It is the process\u0026#39;s job // to release its lock and then reacquire it // before jumping back to us. p-\u0026gt;state = RUNNING; c-\u0026gt;proc = p; swtch(\u0026amp;c-\u0026gt;context, \u0026amp;p-\u0026gt;context); // Process is done running for now. // It should have changed its p-\u0026gt;state before coming back. c-\u0026gt;proc = 0; } release(\u0026amp;p-\u0026gt;lock); } } } 这样，xv6又从调度器切换到了下一个进程去执行了。\nUthread: switching between threads 在uthread.c中，我们需要在用户态下模拟了线程的运行与切换。可以简单理解，运行一个线程就是执行了一个函数。\n源代码中已经给出要运行的线程（函数），如何启动它们并对他们进行切换呢？\n前置准备 线程的切换同进程的切换相似，需要保存通用寄存器的值，所以我们可以对内核照猫画虎般在用户线程struct thread中添加一个struct context变量：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 struct context { uint64 ra; uint64 sp; // callee-saved uint64 s0; uint64 s1; uint64 s2; uint64 s3; uint64 s4; uint64 s5; uint64 s6; uint64 s7; uint64 s8; uint64 s9; uint64 s10; uint64 s11; }; struct thread { char stack[STACK_SIZE]; /* the thread\u0026#39;s stack */ int state; /* FREE, RUNNING, RUNNABLE */ struct context t_context; }; 启动 risc-v中的ra寄存器的作用是保存函数返回地址。具体来说，当一个函数被调用时，调用指令会将返回地址，即调用该函数的下一条指令的地址，保存在ra寄存器中。\n在实验中，我们通过thread_create来创建线程，那么，在执行玩thread_create之后并进行了调度后，是不是就该运行线程函数了？没错，那么我们需要保存创建的线程对应的函数的地址到ra寄存器中，同时，还需要设置线程的栈指针：\n1 2 3 4 5 6 7 8 9 10 11 12 13 void thread_create(void (*func)()) { struct thread *t; for (t = all_thread; t \u0026lt; all_thread + MAX_THREAD; t++) { if (t-\u0026gt;state == FREE) break; } t-\u0026gt;state = RUNNABLE; // YOUR CODE HERE t-\u0026gt;t_context.ra = (uint64)func; t-\u0026gt;t_context.sp = (uint64)t-\u0026gt;stack + STACK_SIZE; } 调度 即切换线程，我们需要保存当前线程的通用寄存器，并将下一个要运行的线程的通用寄存器值恢复到硬件上，如同进程切换那样：\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 # uthread_switch.S thread_switch: /* YOUR CODE HERE */ sd ra, 0(a0) sd sp, 8(a0) sd s0, 16(a0) sd s1, 24(a0) sd s2, 32(a0) sd s3, 40(a0) sd s4, 48(a0) sd s5, 56(a0) sd s6, 64(a0) sd s7, 72(a0) sd s8, 80(a0) sd s9, 88(a0) sd s10, 96(a0) sd s11, 104(a0) ld ra, 0(a1) ld sp, 8(a1) ld s0, 16(a1) ld s1, 24(a1) ld s2, 32(a1) ld s3, 40(a1) ld s4, 48(a1) ld s5, 56(a1) ld s6, 64(a1) ld s7, 72(a1) ld s8, 80(a1) ld s9, 88(a1) ld s10, 96(a1) ld s11, 104(a1) ret /* return to ra */ 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // uthread.c void thread_schedule(void) { ... if (current_thread != next_thread) { /* switch threads? */ next_thread-\u0026gt;state = RUNNING; t = current_thread; current_thread = next_thread; /* YOUR CODE HERE * Invoke thread_switch to switch from t to next_thread: * thread_switch(??, ??); */ thread_switch((uint64)\u0026amp;t-\u0026gt;t_context, (uint64)\u0026amp;next_thread-\u0026gt;t_context); } else next_thread = 0; } Using threads 如果只是简单在get和put操作中加上锁，那么这样虽然能够保证对共享资源的互斥访问，当时无法达到双线程所带来的性能提升。\n应该实现的是两个线程，你放你的，我放我的，现在有5个entry table，我们可以不止使用一把锁来锁住5个table，而是使用5把锁，哪一个table需要互斥操作时只锁它一个就行，这样就细化了锁的粒度。\n这里需要把table改为一个数组，实现代码比较简单，这里就不放了。\nBarrier 实验的要求是每来一个线程运行到barrier就阻塞，直到所有线程都运行到了barrier才释放。这里是想让我们熟悉pthread库中关于锁和条件变量的使用，更加了解线程的同步关系。\n我们可以给全局的bstate中添加一个变量count用于记录当前轮次有多少个线程已经到达了barrier，只要有线程到达，执行++bstate.count，需要注意的是，这个全局变量会被多个线程使用，故需要使用锁来保证互斥访问。\n接下来判断count的值是否等于线程的数量，如果不相等，就wait阻塞当前线程，并释放锁；否则，说明一轮结束，将count置0，轮次+1，并使用broadcaset唤醒被阻塞的线程。\n尝试1 按刚刚说的思路，如果这么写代码，将会造成系统程序死锁。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 static void barrier() { pthread_mutex_lock(\u0026amp;bstate.barrier_mutex); ++bstate.count; pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); if (bstate.count == bstate.nthread) { bstate.count = 0; ++bstate.round; pthread_cond_broadcast(\u0026amp;bstate.barrier_cond); return; } pthread_cond_wait(\u0026amp;bstate.barrier_cond, \u0026amp;bstate.barrier_mutex); } 当条件变量 cond 变为真且该线程被唤醒时，pthread_cond_wait 会自动重新获取互斥锁 mutex，然后返回。\n线程1第0轮获得锁，++count后释放锁，然后在wait处阻塞，并释放锁 接着线程2在第0轮获得锁，++count后进入if判断，使用broadcast唤醒线程1 线程1被唤醒，并自动获取锁 线程2进入第1轮，尝试获得锁，但是此时线程1正持有锁，故线程2阻塞 线程1进入第1轮，但之前自己已经获得锁了，此时又请求锁，故阻塞 两个线程都阻塞，程序出现死锁 尝试2 既然pthread_cond_wait会自动重新获取互斥锁，那就在调用之后使用pthread_mutex_unlock释放锁不久好了吗？\n1 2 3 4 5 6 7 8 9 10 11 12 13 static void barrier() { pthread_mutex_lock(\u0026amp;bstate.barrier_mutex); if (++bstate.count == bstate.nthread) { bstate.count = 0; ++bstate.round; pthread_cond_broadcast(\u0026amp;bstate.barrier_cond); return; } pthread_cond_wait(\u0026amp;bstate.barrier_cond, \u0026amp;bstate.barrier_mutex); pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); } 这样还是不行：原因是pthread_cond_broadcast不会释放锁。\n在第0轮线程1拿到锁后++count后进入wait状态，此时线程1阻塞并释放锁 在第0轮线程2拿到锁，执行++count操作后使用broadcast尝试唤醒线程1，但是线程2并没有释放锁 线程1抢到CPU，但是拿不到锁，继续阻塞 线程2抢到了CPU的执行权进入第1轮，企图获得锁，但是之前自己没有释放锁，也阻塞 两个线程都阻塞，程序出现死锁 正确做法 所以正确的做法是在broadcast后也使用unlock释放锁：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 static void barrier() { pthread_mutex_lock(\u0026amp;bstate.barrier_mutex); if (++bstate.count == bstate.nthread) { bstate.count = 0; ++bstate.round; pthread_cond_broadcast(\u0026amp;bstate.barrier_cond); pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); return; } pthread_cond_wait(\u0026amp;bstate.barrier_cond, \u0026amp;bstate.barrier_mutex); pthread_mutex_unlock(\u0026amp;bstate.barrier_mutex); } 代码实现详情 请见Github：https://github.com/kerolt/xv6-labs-2023/tree/thread\nReference https://www.cnblogs.com/looking-for-zihuatanejo/p/17682582.html https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/lec11-thread-switching-robert/11.7-xv6-switch-function https://www.bilibili.com/video/BV1bZ421U75W/?spm_id_from=333.999.0.0\u0026vd_source=e7bb0cfb7224c8d6671fa62c0e80c832 https://pdos.csail.mit.edu/6.S081/2023/labs/thread.html ","date":"2024-11-15T00:00:00Z","permalink":"/p/mit6.s081lab6-multithreading/","title":"【MIT6.S081】Lab6 multithreading"},{"content":" 该系列博客只是为了记录自己在写Lab时的思路，按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流！\n太菜了，从没打过这么艰难的仗QAQ。由于课程的要求不能公开源代码，所以网上的资源会少很多，平台上的测试案例比较全面，有的还比较刁钻，需要考虑到可拓展哈希的实现细节。在自认为写完了后，提交了近40来次总有几个测试集过不了，还好没有崩溃，在看了几篇博客的方法后，加上自己画图理解，最后终于过了😭。不过回头写博客的时候再去看代码，也没有特别的复杂，还是得明白其中的算法逻辑是如何实现的。\nTask1 - Read/Write Page Guards 简单来说，就是为Page实现一个RAII来自动管理资源。因为在BufferPoolManager::Unpin中，每次调用这个函数，都会让对应的page的pin_count_ - 1，当这个值为0时，这个page就可以被回收，或者说被替换了。但如果我们忘记去手动调用，该页面将永远不会被逐出缓冲池。由于缓冲池以更少的帧数运行，磁盘内外的页面交换将更多。不仅性能受到影响，而且很难检测到错误。\n主要需要考虑如何编写移动构造、移动赋值的逻辑。移动了一个对象后，原来的对象的资源应该转移到了新对象上，那么原来的对象无法再访问资源（将原来对象的资源重置nullptr或清空）。\n还有一个Drop()的接口，这是提供给使用者的释放资源的api，在实现虚构函数时可以直接调用它。Drop的实现就是调用Unpin，然后置空资源。需要注意的是，在进行移动赋值时，一开始也要Drop一下，考虑这样一种情况：\n1 2 auto p = std::move(basic_page_guard); p = std::move(basic_page_guard2); 这个时候同一个变量p接管了两个page，那么应该在第二个移动赋值时先drop掉第一个，因为第一个page不再使用了，自然要Unpin。\n还有就是在三个page guard类重载移动赋值时，如果需要移动的对象和自身是同一个，那么直接返回自己就好：\n1 2 3 4 5 6 auto BasicPageGuard::operator=(BasicPageGuard \u0026amp;\u0026amp;that) noexcept -\u0026gt; BasicPageGuard \u0026amp; { if (\u0026amp;that == this) { return *this; } // ... 其他操作 } 在ReadPageGuard和WritePageGuard的Drop()中，还需要考虑释放管理的page的锁。对应的，锁的获取发生在FetchPageWrite()和FetchPageWrite()中。\nTask2 - Extendible Hash Table Pages 为什么我们需要可扩展哈希？\n下图来源：https://www.bilibili.com/video/BV1Qt421w7JT\n在bustub的设计中，Header Page，Directory Page和Bucket Page都是无法直接构造出来的，即不能通过构造函数创建，只能通过各自的PageGuard中的As()或者AsMut()函数来转换。\nHeader header page中有一个max_depth_的成员变量，1 \u0026lt;\u0026lt; max_depth_即为header page中能存放的目录的索引的数量。当我们有值需要放入哈希表时，获取hash(key)的二进制最高max_depth_位作为索引，再从header中对应位置去找到directory。对应的ExtendibleHTableHeaderPage中的功能实现并不难。\nDirectory directory中有两个depth：\nGlobal Depth：若global depth为n，那么这个Directory就有2^n个entry（相当于指向2^n个bucket） Local Depth：若local depth为n，则在这个对应的bucket下，每个元素的key的最后n位都相同 类似header中获取下一级页的索引，directory获取hash(key)的二进制最低global_depth_位作为索引。那local depth的作用是什么呢？\n这就要说到可拓展哈希中的插入和删除操作了。简单来说，在可拓展哈希表中，目录directory的大小是可以变化的（只要不超过最大容量限制）。目录中可能有多个entry映射到同一个bucket。当需要插入时，如果这个bucket还没有满时，可以直接插入；否则，需要将这个bucket分裂成两个bucket（或者说，将这个bucket中的一部分移动到另一个bucket中），并且这个bucket对应的local depth + 1，这样相比之前就多了一位二进制位去识别bucket。具体的用法可以看后面的Task3部分的笔记。\n下面讲几个稍微难懂的函数：\nGetGlobalDepthMask 这个函数的作用是获取global_depth_个二进制1，举个例子，如果global depth是2，那么说明这个目录当前的容量为2^2=4，索引为0~3，用两位二进制就能表示。\n主要用于和hash(key)进行\u0026amp;操作，获取在directory中对应的索引位置。例如，假设key=3，hash(key)=101（二进制），global depth还是2，那么检查hash(key)的最低两位即为01，那么在directory就是第1个entry。\nCanShrink CanShrink() 的核心功能是检测是否可以减少全局深度（global depth），从而缩小哈希表的大小。它基于以下原则：\n在可扩展哈希表中，每个桶（bucket）都有自己的局部深度（local depth），而整个哈希表有一个全局深度（global depth）。如果所有桶的局部深度都小于当前的全局深度，说明哈希表的某些位（超过局部深度的那些位）并没有被实际使用，因而可以安全地减少全局深度。\nGetSplitImageIndex GetSplitImageIndex() 是在可扩展哈希表中用于桶（bucket）拆分时确定拆分后的另一个桶的索引。\n在可扩展哈希表中，当一个桶装满时，目录容量会翻倍，这个桶会拆分成两个桶（但是除了需要拆分的桶，其他目录还是指向原来的桶）。\n每个桶都有一个局部深度（local depth），表示这个桶在哈希表中使用了多少位哈希值来定位数据。桶拆分时，局部深度会增加。 拆分后的桶与当前桶具有相同的局部深度值，只是在第一位上有所不同。例如，如果当前桶的局部深度为 2，那么拆分后的桶与其前后 2 位相同，只有第 1 位不同。 例如，当前桶的索引为 01，局部深度为 3。\n计算翻转位的值：1 \u0026lt;\u0026lt; (local_depth - 1) = 1 \u0026lt;\u0026lt; (3 - 1) = 1 \u0026lt;\u0026lt; 2 = 100 （即二进制的 0100）。 按位异或：bucket_index ⊕ 100 = 001 ⊕ 100 = 101（即二进制的 101，也就是十进制的 5）。 因此，拆分后的桶的索引是 101（5 in decimal）。\nGetLocalDepthMask 这个函数主要是用在bucket的分裂和合并中，用于判断需要操作的bucket中的项在更新后应该属于directory下哪个entry对应的bucket（有点绕）。\n和GetGlobalDepthMask一样，当前directory的entry映射的bucket的local_depth_是多少，其mask的二进制就是多少个1。\nBucket Bucket存储着多个键值对，没有使用标准库的map，而是使用std::pair数组（如果都用标准库了要你实现啥哈希表hh）。\n1 2 3 4 5 6 7 8 9 10 11 12 13 #define MappingType std::pair\u0026lt;KeyType, ValueType\u0026gt; static constexpr uint64_t HTABLE_BUCKET_PAGE_METADATA_SIZE = sizeof(uint32_t) * 2; constexpr auto HTableBucketArraySize(uint64_t mapping_type_size) -\u0026gt; uint64_t { return (BUSTUB_PAGE_SIZE - HTABLE_BUCKET_PAGE_METADATA_SIZE) / mapping_type_size; }; class ExtendibleHTableBucketPage { ... private: uint32_t size_; uint32_t max_size_; MappingType array_[HTableBucketArraySize(sizeof(MappingType))]; }; 任务就是在这个bucket中增删查对应的key and value，由于内部使用的是定长数组，所以最简单的方法就是顺序操作。\n但是需要注意的是，在bucket page的Insert操作中，注释上给的提示是：“当插入成功时返回true，插入失败或者键已经存在时返回false”。但是如果你按着“先判断bucket是否满了，再遍历bucket中的键值对数组，查找有没有key相同的，最后再插入”这样的逻辑去写，那当你提交时怎么测试都有几个测试集过不了，当时我想得脑子都要炸了也想不清怎么回事。\n在看了一篇博客后，我按照他的逻辑~~“先遍历数组，如果有键值相同的，更新它而不是返回false，然后再判断是否已经满了，没满就再插入”~~去写，最后对了。（这里为啥用了删除线，是因为我的代码是按照这个逻辑来写的，但是在我写这篇博客时感觉不对劲，假如这个键已经存在，且此时bucket没有满，那更新之后又插入了一次，数据有重复）\nTask3 - Extendible Hashing Implementation 细节！细节！还是TMD细节！\n前两个task比较容易写，这个task其实本质上还是一个数据结构的设计实现问题，也就是哈希表如何插入数据和删除数据，但是细节需要注意太多了！如何考虑插入后的分裂，还有删除时的合并。从第一次提交到全部通过一共用了7天（哭）。\nInsert Insert操作需要注意的就是插入失败后的分裂问题，小细节在于下面逻辑步骤中的3，8.5和9（加粗表示）:\n检查需要插入的键是否已经存在，如果存在就返回false，否则继续第二步 对key使用hash算法 从header通过hash(key)找到对应的directory page，如果directory不存在，那么创建新的directory page，再创建新的bucket page后执行插入；否则继续第四步。需要注意，如果判断了directory存在后，需要对header page guard进行Drop()操作，因为平台的测试集中有比较刁钻的情况，其buffer manager pool的大小只有3，如果不把header page释放，那么到时候需要进行分裂时无法再获取一个新的page！ 从directory通过hash(key)找到对应的bucket page，如果page不存在，那么创建新的bucket page再插入；否则继续第五步 向bucket page中插入键值对，如果插入成功，返回true；否则继续第六步 （循环开始） 检查当前directory是否已经满了，比如说directory的max_depth_是2，那么说明最多只能有四个bucket page，若此时已经有四个bucket了，那么已经满了，无法继续分裂桶；没满继续第八步 分裂桶 先创建新的bucket page作为当前bucket的镜像桶 如果global depth等于当前bucket的local depth，说明需要扩充directory的大小，同时调整扩充后的directory中的entry与bucket的映射 在directory中设置镜像桶，增加原桶和镜像桶的local depth，此时获取原桶的local depth对应的mask 通过mask将原桶拆成两个桶，比如原桶的local depth为2，对应的mask的二进制为$(11)_2$，通过\u0026amp;操作获取桶中每个键值对的hash(key)的最低两位，如果与bucket_index \u0026amp; mask相同，那么就说明这个键值对应该留在原桶，反之应该移至镜像桶 分裂成两个桶后，在使用mask判断需要插入的键值对应该插入那个桶，记录插入是否成功 考虑这样一种情况，需要插入的bucket满了后要进行分裂，但是有可能分裂后原来的键值对还是在一个bucket中，那这个时候插入就失败了，所以需要继续从第七步执行，直到directory满了或者插入成功 （循环结束） return true Remove 由于课程要求不能公开代码，加上网上关于可拓展哈希的操作多只有Insert，而Remove很少有介绍说明的，所以不得不去思考很多情况，加上自己画图去理解，当然还是去看了几篇大佬的博客我才慢慢写出了解决方案。\n实验指导中说了对于Remove的合并需要进行递归的处理，但其实我们用循环去处理就行，至于为什么要递归去处理，下面的步骤9和步骤13中有加粗解释：\n对key使用hash算法 从header通过hash(key)找到对应的directory page，如果directory不存在，return false；否则继续第3步。类似Insert，这里也需要Drop header page 从directory通过hash(key)找到对应的bucket page，如果page不存在，那return false；否则继续第4步 删除bucket中的key对应的键值对，如果删除失败，返回false；否则继续第5步 如果删除成功后bucket不为空，说明不需要合并，返回true；否则继续第6步 （循环开始） 获取当前bucket的镜像bucket，如果二者的local depth不相同，那么结束循环；否则继续第8步 当前桶和镜像桶可以合并，将当前桶的映射更新到镜像桶，并且local depth都 -1，删除当前桶的bucket page 删除了bucket_index对应的page后，虽然调整了其对应的local depth和page id，但是还应该遍历所有的可成为镜像桶的index，如果它们的local depth相同，也应该合并，记录需要合并的的bucket的page id 在遍历时不需要一个一个去遍历directory的entry，而是可以跳着遍历：只有index的后local_depth - 1位相同的才可能需要合并 如果需要合并的的bucket的page id数量为0，说明已经没有可以合并的bucket了，不应该继续合并，可以结束循环了；否则继续第11步 因为又合并了，所以原桶和其镜像桶对应的 local depth 又要 -1 删除需要合并的的bucket的page 从第7步继续，因为合并后的bucket在其local depth - 1后可能会碰到和其local depth相同的bucket，且其中还有空的bucket，这就需要不断去合并（这就是实验中说的递归合并） （循环结束） 缩减目录大小，直到无法缩小 return true Reference https://www.cnblogs.com/wevolf/p/18302985 https://zhuanlan.zhihu.com/p/622221722 https://zhuanlan.zhihu.com/p/701875021 ","date":"2024-11-12T00:00:00Z","permalink":"/p/cmu15-445-fall2023project2-extendible-hash-index-%E5%B0%8F%E7%BB%93/","title":"【CMU15-445 Fall2023】Project2 Extendible Hash Index 小结"},{"content":"为什么我们需要copy on write 通过xv6的实验指导书，我们可以知道：\nxv6中的fork()系统调用将父进程的所有用户空间内存复制到子进程中。如果父进程所使用的页数很大，复制可能需要很长时间，而这样的复制操作经常是没有用的：fork()之后通常是子进程中的exec()，这会丢弃复制的内存而不是使用它们。\n那么，我们是不是可以在子进程刚创建时将其页表的映射到父进程的物理页，而在其需要对内存进行写操作时再重新分配内存呢？没错，这就是通过copy on write（写时复制）技术来进行优化。\n该实验的代码实现见：仓库commit\n大致思路 首先说一下大致思路：\n在使用fork系统调用创建子进程时，我们不用去额外拷贝父进程的内存，而是将子进程的虚拟内存映射到和父进程相同的物理内存，并且此时应该将父子进程对这块内存的访问权限设置为只读，并且添加一个用于识别COW（copy on write）的标志：\n当子进程需要对内存进行写操作时，RISC-V会检测到这一块物理地址的权限为只写，触发Page Fault，这时我们可以为子进程重新分配一块内存空间（取消之前的映射，分配内存后重新映射至新内存），并将父子进程的对应物理页的识别标志位进行修改，去除cow标志PTE_COW，添加写权限标志PTE_W：\n具体实现 COW标志位 首先我们需要为PTE添加一个用于识别COW的标志位，上图展示了PTE的后10位也就是其flags的情况，可以看到第8、9位是保留没有被使用的，那么我们可以用第8位来作为COW的标志位：\n1 2 3 // riscv.h #define PTE_COW (1L \u0026lt;\u0026lt; 8) 修改uvmcopy 在xv6的fork系统调用中，子进程拷贝父进程的物理页使用了uvmcopy()函数：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 int fork(void) { int i, pid; struct proc *np; struct proc *p = myproc(); // Allocate process. if((np = allocproc()) == 0){ return -1; } // Copy user memory from parent to child. if(uvmcopy(p-\u0026gt;pagetable, np-\u0026gt;pagetable, p-\u0026gt;sz) \u0026lt; 0){ freeproc(np); release(\u0026amp;np-\u0026gt;lock); return -1; } ... } 那么我们需要修改uvmcopy中的代码，将原先的拷贝内存的操作去掉，并将PTE的只读标志位去除、添加cow标记位：\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 int uvmcopy(pagetable_t old, pagetable_t new, uint64 sz) { pte_t *pte; uint64 pa, i; uint flags; for(i = 0; i \u0026lt; sz; i += PGSIZE){ if((pte = walk(old, i, 0)) == 0) panic(\u0026#34;uvmcopy: pte should exist\u0026#34;); if((*pte \u0026amp; PTE_V) == 0) panic(\u0026#34;uvmcopy: page not present\u0026#34;); pa = PTE2PA(*pte); flags = PTE_FLAGS(*pte); // 这里移除了拷贝的代码，并设置了父进程相应的标志位 if (flags \u0026amp; PTE_W) { *pte = (*pte \u0026amp; ~PTE_W) | PTE_COW; flags = (flags \u0026amp; ~PTE_W) | PTE_COW; } // 将子进程的虚拟页映射至父进程的物理页，同时设置了子进程相应的标志位 if(mappages(new, i, PGSIZE, (uint64)pa, flags) != 0){ goto err; } // 这里是对物理页的引用计数进行+1，后文会说明 kref_inc((void*)pa); } return 0; err: uvmunmap(new, 0, i / PGSIZE, 1); return -1; } 引用计数 Ensure that each physical page is freed when the last PTE reference to it goes away \u0026ndash; but not before. A good way to do this is to keep, for each physical page, a \u0026ldquo;reference count\u0026rdquo; of the number of user page tables that refer to that page. Set a page\u0026rsquo;s reference count to one when kalloc() allocates it. Increment a page\u0026rsquo;s reference count when fork causes a child to share the page, and decrement a page\u0026rsquo;s count each time any process drops the page from its page table. kfree() should only place a page back on the free list if its reference count is zero. It\u0026rsquo;s OK to to keep these counts in a fixed-size array of integers.\n当我们采取了cow后，只有当所有虚拟页都没有引用某一物理页时这个物理页才能被释放，那么我们可以使用一个数组来对每一个物理页进行引用计数，当fork导致子进程共享物理页时，对应的物理页的引用计数+1，当有进程不再使用物理页时，对应的物理页的引用计数-1，回收物理页的kfree()函数只有当物理页的引用计数为0时才会将其放回空闲列表。\n1 2 3 4 5 6 // kalloc.c struct { struct spinlock lock; // 保证操作的原子性 int ref_count[(PGROUNDUP(PHYSTOP)) / PGSIZE]; // KERNBASE～PHYSTOP是物理内存的大小，因为xv6的内核地址采用了直接映射，为了方便，这里直接使用PHYSTOP。PHYSTOP / PGSIZE则表示有多少个物理页 } kref; 内核在使用kinit()进行初始化时，需要初始化kref的锁，并设置引用计数数组的值：\n1 2 3 4 5 6 7 8 9 10 11 void kinit() { initlock(\u0026amp;kmem.lock, \u0026#34;kmem\u0026#34;); initlock(\u0026amp;kmem.lock, \u0026#34;kref\u0026#34;); // 这里初始化时置为1是为了接下来的freerange在调用kfree时不会触发panic for (int i = 0; i \u0026lt; PGROUNDUP(PHYSTOP) / PGSIZE; i++) { kref.ref_count[i] = 1; } freerange(end, (void*)PHYSTOP); } 在kfree()中对物理页的引用计数来判断是否应该释放物理内存：\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 void kfree(void *pa) { struct run *r; if(((uint64)pa % PGSIZE) != 0 || (char*)pa \u0026lt; end || (uint64)pa \u0026gt;= PHYSTOP) panic(\u0026#34;kfree\u0026#34;); // 如果在还未对ref count操作前其值已经小于等于0，说明已有问题 if (kref.ref_count[(uint64)pa / PGSIZE] \u0026lt;= 0) { panic(\u0026#34;kref\u0026#34;); } // 每次free一个page时，先将这个page的引用计数-1 kref_dec(pa); // ref count - 1，如果结果还大于0，说明这个物理页还被其他进程引用，暂时不需要释放 if (kref.ref_count[(uint64)pa / PGSIZE] \u0026gt; 0) { return; } // Fill with junk to catch dangling refs. memset(pa, 1, PGSIZE); r = (struct run*)pa; acquire(\u0026amp;kmem.lock); r-\u0026gt;next = kmem.freelist; kmem.freelist = r; release(\u0026amp;kmem.lock); } 每分配一个物理页时，该物理页的引用计数初始为1：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void * kalloc(void) { struct run *r; acquire(\u0026amp;kmem.lock); r = kmem.freelist; if(r) kmem.freelist = r-\u0026gt;next; release(\u0026amp;kmem.lock); if (r) { memset((char*)r, 5, PGSIZE); // fill with junk // 这里赋值为1一定要在设置垃圾数值之后，否则会造成污染 acquire(\u0026amp;kref.lock); kref.ref_count[(uint64)r / PGSIZE] = 1; release(\u0026amp;kref.lock); } return (void*)r; } 将引用计数的操作封装一下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // kalloc.c // 为pa所在的page的引用+1 void kref_inc(void* pa) { acquire(\u0026amp;kref.lock); ++kref.ref_count[(uint64)pa / PGSIZE]; release(\u0026amp;kref.lock); } // 为pa所在的page的引用-1 void kref_dec(void* pa) { acquire(\u0026amp;kref.lock); --kref.ref_count[(uint64)pa / PGSIZE]; release(\u0026amp;kref.lock); } page fault时分配新内存 当需要分配新内存时，要检测虚拟页的cow标志位是否有效。在分配了内存后，拷贝原来的物理页中的数据到新的物理页，并将虚拟页的标志位中的cow去除、添加写标志，最后取消虚拟页与原来物理页的映射关系，同时在解除时对引用计数-1，然后将虚拟页映射至新的物理页：\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 // 当出现page fault时，进行cow操作 int cow_alloc(pagetable_t pagetable, uint64 va) { if (va \u0026gt;= MAXVA) { return -1; } if ((va % PGSIZE) != 0) { return -1; } pte_t *pte = walk(pagetable, va, 0); if (pte == 0) return -1; uint64 pa = PTE2PA(*pte); if (pa == 0) return -1; // 当page的cow标志位有效时才会重新分配内存 if ((*pte \u0026amp; PTE_COW) \u0026amp;\u0026amp; (*pte \u0026amp; PTE_V)) { char* mem = kalloc(); if (mem == 0) { return -1; } uint64 flags = PTE_FLAGS(*pte); flags = (flags \u0026amp; ~PTE_COW) | PTE_W; // 去除COW标记，加上写权限标记 memmove(mem, (char *)pa, PGSIZE); uvmunmap(pagetable, PGROUNDDOWN(va), 1, 1); // 解除之前的映射，并设置do_free为1，这样在kfree中可来将引用计数-1 if (mappages(pagetable, va, PGSIZE, (uint64)mem, flags) \u0026lt; 0) { panic(\u0026#34;cow_alloc\u0026#34;); } } return 0; } 实验指导书提醒我们在copyout()中也要执行cow操作，但这里为何要这么做呢？\n这是因为需要内核将数据通过copyout拷贝到用户态时，如果需要拷贝的目标位置是用户进程与其父进程共享的，那么这时应该会有page fault产生，但是copyout中是通过walk遍历页表来获取地址的，不会触发page fault，因此需要我们手动执行cow。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len) { uint64 n, va0, pa0; pte_t *pte; while(len \u0026gt; 0){ va0 = PGROUNDDOWN(dstva); if(va0 \u0026gt;= MAXVA) return -1; if (cow_alloc(pagetable, va0) \u0026lt; 0) { return -1; } ... } } usertrap中触发page fault 在之前fork操作时我们去除了物理页的write操作，那么在之后需要对该物理页进行写操作时，risc-v就会触发page fault了。\n查看risc-v的手册可以发现：\n我们需要使用excepton code 13和15（读写），当发生page fault时，这个code会保存在scause寄存器中，那么我们只需要在usertrap中对scause进行相应的处理即可：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void usertrap(void) { ... } else if (r_scause() == 13 || r_scause() == 15) { // 这里的stval寄存器，我的理解是保存了触发page fault时的虚拟地址 uint64 fault_va = r_stval(); // 判断地址是否不合法 if (fault_va \u0026gt;= MAXVA || (fault_va \u0026lt; p-\u0026gt;trapframe-\u0026gt;sp \u0026amp;\u0026amp; fault_va \u0026gt;= (p-\u0026gt;trapframe-\u0026gt;sp - PGSIZE)) || fault_va \u0026lt;= 0) { p-\u0026gt;killed = 1; } // 尝试进行cow操作 if (cow_alloc(p-\u0026gt;pagetable, PGROUNDDOWN(fault_va)) \u0026lt; 0) { p-\u0026gt;killed = 1; } } else { printf(\u0026#34;usertrap(): unexpected scause %p pid=%d\\n\u0026#34;, r_scause(), p-\u0026gt;pid); printf(\u0026#34; sepc=%p stval=%p\\n\u0026#34;, r_sepc(), r_stval()); setkilled(p); } } 值得注意的是，在进行cow操作之前，一定要对地址的范围进行合法性判断，因为usertests中会有非常多的测试函数，其中有一部分会检测地址合法性，不合法的地址应该直接让进程死亡。\n","date":"2024-11-12T00:00:00Z","permalink":"/p/mit6.s081lab5-copy-on-write-fork/","title":"【MIT6.S081】Lab5 copy-on-write fork"},{"content":"backtrace这个lab非常有意思，虽然实现的代码量不多，但是能让我们更好地理解栈、栈帧、指针、gdb的一些知识。\n该实验的代码实现见：仓库commit\n首先先解答一下【RISC-V assembly】中的一些问题：\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 Q: Which registers contain arguments to functions? For example, which register holds 13 in main\u0026#39;s call to printf? A: a0-a7, a2保存了13 Q: Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.) A: 函数f和g被内联优化了 Q: At what address is the function printf located? A: 0x000000000000064a Q: What value is in the register ra just after the jalr to printf in main? A: jalr指令的后一条指令的地址，也是当前pc寄存器中的地址 Q: Run the following code. unsigned int i = 0x00646c72; printf(\u0026#34;H%x Wo%s\u0026#34;, 57616, \u0026amp;i); What is the output? Here\u0026#39;s an ASCII table that maps bytes to characters. The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value? Here\u0026#39;s a description of little- and big-endian and a more whimsical description. --- A: output: He110 World 若risc-v为大端序，则i应该设置成0x726c6400；57616不需要变，因为无论是大端序还是小端序，其十六进制都为E110 Q: In the following code, what is going to be printed after \u0026#39;y=\u0026#39;? (note: the answer is not a specific value.) Why does this happen? printf(\u0026#34;x=%d y=%d\u0026#34;, 3); --- A: x=3 y=1403684968 y的值是一个随机值，因为本该传入printf的第三个参数并没有传入，而其对应的寄存器为a2，故y会使用a2中残存的值 这里的几个问题不是很难，涉及到了一些汇编、寄存器的知识，在接下来学习backtrace的时候将会有详细讨论。\nbacktrace需要我们做的事情可以概括为：在发生错误的点之上的堆栈上的函数调用列表，并在每个堆栈帧中打印保存的返回地址。\n什么意思呢？就是例如在gdb调试中使用bt查看函数调用栈时，需要我们打印途中红色框中的地址。\n该lab让我们在kernel/printf.c中实现一个backtrace函数。在sys_sleep中插入对此函数的调用，然后运行bttest这个测试程序将调用sleep（也就是会执行sys_sleep）。\n首先，让我们来看看xv6中栈的结构：\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 . . +-\u0026gt; . | +-----------------+ | | | return address | | | | previous fp ------+ | | saved registers | | | local variables | | | ... | \u0026lt;-+ | +-----------------+ | | | return address | | +------ previous fp | | | saved registers | | | local variables | | +-\u0026gt; | ... | | | +-----------------+ | | | return address | | | | previous fp ------+ | | saved registers | | | local variables | | | ... | \u0026lt;-+ | +-----------------+ | | | return address | | +------ previous fp | | | saved registers | | | local variables | | $fp --\u0026gt; | ... | | +-----------------+ | | return address | | | previous fp ------+ | saved registers | $sp --\u0026gt; | local variables | +-----------------+ 栈是由高地址向低地址增长的，risc-v中sp寄存器代表“stack pointer”，即栈顶指针，fp寄存器代表“frame pointer”，为当前栈帧的指针。\n假设有一个这样的程序：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #include \u0026lt;stdio.h\u0026gt; void g() { printf(\u0026#34;g()\\n\u0026#34;); } void f() { g(); printf(\u0026#34;f()\\n\u0026#34;); } int main() { f(); } 假设程序中的main函数里，函数f调用了函数g，那么在函数调用栈中从高地址到低地址三个函数的顺序为：main、f、g；当g函数执行完成后，其栈帧将会从栈中弹出，并且通过栈帧中的数据回到调用自身的下一条指令，即f中g的调用发生在第8行，当g执行完毕后，应该继续执行第9行的指令，这也就是“return address”。\n我们可以通过内联汇编获取当前栈帧的的指针：\n1 2 3 4 5 6 7 8 9 // kernel/riscv.h static inline uint64 r_fp() { uint64 x; asm volatile(\u0026#34;mv %0, s0\u0026#34; : \u0026#34;=r\u0026#34; (x) ); return x; } 然后不断遍历栈中的栈帧，打印其return address，直到遍历到最后一个栈帧。xv6在给栈分配内存时确保了每一个栈帧都在同一页中，这样的话可以通过PGROUNDDOWN(fp)宏来判断fp是否超出栈空间：\n1 #define PGROUNDDOWN(a) (((a)) \u0026amp; ~(PGSIZE - 1)) xv6中页的大小为4096B，故PGROUNDDOWN(a)可以获取a地址所在的页号，或者说这一页的最高地址，只要我们的fp不等于它，就说明我们还没有遍历到栈底。\nfp - 8获取到return address的地址，fp - 16获取到当前栈帧的前一个栈帧的地址。由于xv6中获取的地址是用uint64来表示的，那么可将其强转为uint64*来将一个值解释为内存地址，之后便可以解引用这个地址获取其中的值了。\n1 2 3 4 5 6 7 8 9 void backtrace() { uint64 fp = r_fp(); printf(\u0026#34;backtrace:\\n\u0026#34;); while (fp != PGROUNDDOWN(fp)) { uint64 *return_addr = (uint64 *)(fp - 8); fp = *(uint64 *)(fp - 16); printf(\u0026#34;%p\\n\u0026#34;, *return_addr); } } 当然，也不要忘记在kernel/def.h中声明backtrace，还有在sys_sleep中调用backtrace。\n","date":"2024-11-10T00:00:00Z","permalink":"/p/mit6.s081lab4-trap-backtrace/","title":"【MIT6.S081】Lab4 trap backtrace"},{"content":"Alarm综合了该lab中前几个练习的知识点：系统调用、中断、寄存器等，我们需要对trap机制有比较好的认识才能理解。Alarm的任务是需要我们完成一个定时器的实现：sigalarm(interval, handler)，当调用sigalarm(n, fn)时，内核会每n个时间间隔（tick）执行fn函数。\n该实验的代码实现见：仓库commit\n如何理解Alarm “内核会每n个时间间隔执行fn函数”，如何理解这“每n个时间间隔”呢？在计算机中有一个“时钟周期”的概念，而我们这里所说的时间间隔就是xv6中设置的每次发生时钟中断所间隔的始终周期。在xv6内核初始化时会执行timerinit()函数，其中有：\n1 2 3 // ask the CLINT for a timer interrupt. int interval = 1000000; // cycles; about 1/10th second in qemu. *(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval; 这里就是设置了xv6会每经过interval个时钟周期（在qemu中大概为0.1秒）进行一次时钟中断，这个中断是硬件自动执行的（在没看源码之前，一直没搞懂xv6是什么时候、怎么进行的时钟中断）。下文将以时钟中断的间隔tick作为基本单位。\n所以简单来说，Alarm需要我们在内核中进行计数，每当经过n个tick的时候，需要去执行fn函数。\ntest0~3所做的事 test0：用于测试我们的sigalarm是否起作用了； test1：用于测试内核是否多次调用处理函数，需要确保中断发生时跳转的地址为处理函数所在的地址，还有中断时需要保存好之前寄存器中的值； test2：用于测试内核不允许重入sysalarm系统调用，即若某个进程正在执行处理函数，那么内核就不应该再次调用它； test3：用于测试sys_sigreturn系统调用能否正确返回寄存器a0的值。 实现 注册系统调用 首先的注册系统调用的步骤这里就不展开了，具体可参考之前的lab。\nstruc proc结构体 为了完成定时执行某个函数，我们需要在struct proc结构体中加入一些成员：\n1 2 3 4 5 6 7 8 struct proc { ... int is_handling; // 用于判断当前进程是否正在执行处理函数 int tick_interval; // 定时器间隔，由系统sigalarm的第一个参数传入 int tick_counter; // 定时器计数器，每次tick进行+1 uint64 tick_handler; // 间隔到了后执行的处理函数 struct trapframe *saved_trapframe; // 保存寄存器 } 值的注意的是处理函数的类型我们设置为了uint64，为什么不是一个函数指针呢？其实都差不多，在之后设置跳转处理函数的时候，就是通过地址来跳转，而地址在xv6中就是用的uint64来表示。故这里的设置即是处理函数所在的起始地址。\n实现sys_sigalarm 在sysproc.c中实现sys_sigalarm()函数：\n1 2 3 4 5 6 uint64 sys_sigalarm(void) { struct proc *p = myproc(); argint(0, \u0026amp;(p-\u0026gt;tick_interval)); argaddr(1, \u0026amp;(p-\u0026gt;tick_handler)); return 0; } 该函数要做的事情很简单，只需要接收从用户态传来的两个参数，并将其赋值给当前进程的tick_interval和tick_handler。\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 27 28 29 30 31 static struct proc* allocproc(void) { ... p-\u0026gt;tick_interval = 0; p-\u0026gt;tick_counter = 0; p-\u0026gt;tick_handler = 0; p-\u0026gt;is_handling = 0; ... // Allocate a saved_trapframe page. if((p-\u0026gt;saved_trapframe = (struct trapframe *)kalloc()) == 0){ freeproc(p); release(\u0026amp;p-\u0026gt;lock); return 0; } ... } static void freeproc(struct proc *p) { ... if(p-\u0026gt;saved_trapframe) kfree((void*)p-\u0026gt;saved_trapframe); p-\u0026gt;saved_trapframe = 0; ... p-\u0026gt;tick_counter = 0; p-\u0026gt;tick_interval = 0; p-\u0026gt;tick_handler = 0; p-\u0026gt;is_handling = 0; } 补全usertrap 接下来就是需要实现在usertrap中处理时钟中断，在实验指导书中提示我们在if(which_dev == 2) ...中处理时钟中断。这里我的处理逻辑为，当时钟中断发生时：\n当前进程的tick计数器++ 判断进程设置的定时器间隔是否不为0、当前计数器是否已经经过了interval个间隔、且当前进程未执行处理函数，如果其中一项不满足，则不进行第三步 将当前进程的trapframe的内容（即寄存器的值）保存到saved_trapframe（用于恢复现场），将SEPC寄存器的值设置为处理函数的地址，这样中断结束返回时就会去执行处理函数了，最后设置当前进程“正在执行处理函数” 代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 void usertrap(void) { ... // give up the CPU if this is a timer interrupt. if (which_dev == 2) { p-\u0026gt;tick_counter++; if (p-\u0026gt;tick_interval \u0026amp;\u0026amp; p-\u0026gt;tick_counter % p-\u0026gt;tick_interval == 0 \u0026amp;\u0026amp; p-\u0026gt;is_handling == 0) { memmove(p-\u0026gt;saved_trapframe, p-\u0026gt;trapframe, PGSIZE); p-\u0026gt;trapframe-\u0026gt;epc = p-\u0026gt;tick_handler; p-\u0026gt;is_handling = 1; } yield(); } ... 在第二步中不知道是xv6的bug还是我有地方没理解好，如果不先检查tick_interval != 0，则可能在执行p-\u0026gt;tick_counter % p-\u0026gt;tick_interval会有问题，因为取模运算符%在分母为0时是未定义的，但这么运行时xv6并没有任何错误。\n返回，恢复现场 在处理函数执行的最后，将会执行sigreturn系统调用进行返回并恢复现场，这时我们就可以将之前存放在saved_trapframe中的值拷贝回trapframe中，并设置当前进程“未执行处理函数”。实验指导书中还提示我们最终返回的结果为a0寄存器中的值。\n1 2 3 4 5 6 uint64 sys_sigreturn(void) { struct proc *p = myproc(); memmove(p-\u0026gt;trapframe, p-\u0026gt;saved_trapframe, PGSIZE); p-\u0026gt;is_handling = 0; return p-\u0026gt;trapframe-\u0026gt;a0; // return this for alarm test3 } ","date":"2024-11-07T00:00:00Z","permalink":"/p/mit6.s081lab4-trap-alarm/","title":"【MIT6.S081】Lab4 trap alarm"},{"content":"前言 页表是最常用的机制，操作系统通过它为每个进程提供自己的私有地址空间和内存。页表决定了内存地址的含义，以及可以访问物理内存的哪些部分。在本文中，记录了Lab: page tables的前两个实验：加速系统调用和打印页表。\nSpeed up system calls (easy) 通常我们在需要执行系统调用时，在操作系统中会发生从用户态到内核态的切换，这是因为这些核心的操作只能交给内核去完成。在这个实验中，xv6要求我们通过在用户空间和内核之间共享只读区域中的数据来加快某些系统调用。\n由于现在只是入门如何将映射添加至页表中，这个实验只需要为xv6中的getpid()系统调用进行优化。\n在xv6的实验指导书中：\n创建每个进程时，在USYSCALL（memlayout.h中定义的虚拟地址）映射一个只读页。在该页面的开头，存储一个usyscall结构体（也在memlayout.h中定义），并对其进行初始化以存储当前进程的PID。\n既然如此，进程结构体中也应当有一个usyscall结构体：\n1 2 3 4 5 6 7 8 9 10 // kernel/proc.h struct proc { ... pagetable_t pagetable; // User page table struct trapframe *trapframe; // data page for trampoline.S struct usyscall *usyscall; // data page for USYSCALL struct context context; // swtch() here to run process ... }; 这样就可以通过p-\u0026gt;usyscall来获取了。\n在memlayout.h中我们可以看到用户态空间内存布局：\n1 2 3 4 5 6 7 8 9 Address zero first: text original data and bss fixed-size stack expandable heap ... USYSCALL (shared with kernel) TRAPFRAME (p-\u0026gt;trapframe, used by the trampoline) TRAMPOLINE (the same page as in the kernel) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 MAXVA-\u0026gt; ------------------------------------- | TRAMPOLINE (与内核相同的页面) | ------------------------------------- | TRAPFRAME (p-\u0026gt;trapframe, 由跳板使用)| ------------------------------------- | USYSCALL (与内核共享) | ------------------------------------- | ... | ------------------------------------- | 可扩展堆 | ------------------------------------- | 固定大小的栈 | ------------------------------------- | 原始数据和BSS | ------------------------------------- | text | 0 -\u0026gt; ------------------------------------- 我们需要做的就是仿照TRAPFRAME将USYSCALL也做一层映射。\n在allocproc()为进程分配物理页时，使用kalloc()对usyscall的分配（kalloc每次从空闲页表中取出一个项，其大小为4KB）：\n1 2 3 4 5 6 // Allocate a usyscall page. if((p-\u0026gt;usyscall = (struct usyscall *)kalloc()) == 0){ freeproc(p); release(\u0026amp;p-\u0026gt;lock); return 0; } 在proc_pagetable()函数中，其为指定进程创建用户页表，不含用户内存，但有trampoline 和 trapframe页。以下代码是对trampoline 和 trapframe进行映射。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // map the trampoline code (for system call return) // at the highest user virtual address. // only the supervisor uses it, on the way // to/from user space, so not PTE_U. if(mappages(pagetable, TRAMPOLINE, PGSIZE, (uint64)trampoline, PTE_R | PTE_X) \u0026lt; 0){ uvmfree(pagetable, 0); return 0; } // map the trapframe page just below the trampoline page, for // trampoline.S. if(mappages(pagetable, TRAPFRAME, PGSIZE, (uint64)(p-\u0026gt;trapframe), PTE_R | PTE_W) \u0026lt; 0){ uvmunmap(pagetable, TRAMPOLINE, 1, 0); uvmfree(pagetable, 0); return 0; } 如此，我们照猫画虎，也可以写出usyscall的映射。需要注意的是，该页是read-only的，并且允许用户态访问，因此其权限应该为PTE_R和PTE_U。\n1 2 3 4 5 6 7 8 // map the usyscall page if(mappages(pagetable, USYSCALL, PGSIZE, (uint64)(p-\u0026gt;usyscall), PTE_R | PTE_U) \u0026lt; 0){ uvmunmap(pagetable, USYSCALL, 1, 0); uvmunmap(pagetable, TRAMPOLINE, 1, 0); uvmfree(pagetable, 0); return 0; } 在结束进程时，即freeproc函数中，也需要对usyscall的空间进行释放：\n1 2 3 if(p-\u0026gt;usyscall) kfree((void*)p-\u0026gt;usyscall); p-\u0026gt;usyscall = 0; 同时还应当在proc_freepagetable函数中解除之前对usyscall的映射：\n1 uvmunmap(pagetable, USYSCALL, 1, 0); Print a page table (easy) 这个实验要求我们将页表打印出来。在实验开始前，让我们先看看xv6中的页表。\nxv6中的页表为三级页表，在VA转换为PA的过程中，处理单元会通过satp寄存器找到当前进程的页表基地址，然后取出VA中的L2部分找到一级页表的项，一级页表中的项（PTE）保存二级页表的地址，再通过L1可获取二级页表中的项，依次类推即可将VA转换为PA。\n这样看来，想要打印页表，有点类似于DFS算法，需要使用递归。按照实验指导书所说，我们可以从freewalk函数中获取灵感，查看其源码可以知道如何去遍历页表项。那么按照要求所实现打印页表就比较容易了：\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 static void print_pgtbl(pagetable_t pagetable, int depth) { if (depth \u0026gt; 2) { return; } for(int i = 0; i \u0026lt; 512; i++){ pte_t pte = pagetable[i]; if (pte \u0026amp; PTE_V) { if (depth == 0) { printf(\u0026#34;..\u0026#34;); } else if (depth == 1) { printf(\u0026#34;.. ..\u0026#34;); } else if (depth == 2) { printf(\u0026#34;.. .. ..\u0026#34;); } uint64 child = PTE2PA(pte); printf(\u0026#34;%d: pte %p pa %p\\n\u0026#34;, i, pte, PTE2PA(pte)); print_pgtbl((pagetable_t)child, depth + 1); } } } void vmprint(pagetable_t pagetable) { printf(\u0026#34;page table %p\\n\u0026#34;, pagetable); print_pgtbl(pagetable, 0); } 由于打印页表这个操作是进程号为1的init进程做的，所以不要忘记在kernel/exec.c的exec函数中添加：\n1 2 3 if (p-\u0026gt;pid == 1) { vmprint(p-\u0026gt;pagetable); } 并且在kernel/defs.h中添加vmprint的函数声明：\n1 void vmprint(pagetable_t); ","date":"2024-11-05T00:00:00Z","permalink":"/p/mit6.s081lab3-page-tables%E4%B8%8A/","title":"【MIT6.S081】Lab3 page tables（上）"},{"content":"本关的任务为“Detect which pages have been accessed”，需要实现一个新的系统调用pgaccess，它指出访问了哪些页面被访问了（读、写等）。系统调用需要三个参数：\n第一个用户页面的起始虚拟地址 需要检查页数 一个存储每一页是否被访问的掩码 该lab的所有代码：Github\n测试该系统调用的函数位于user/pgtbltest.c:pgaccess_test()中：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void pgaccess_test() { char *buf; unsigned int abits; printf(\u0026#34;pgaccess_test starting\\n\u0026#34;); testname = \u0026#34;pgaccess_test\u0026#34;; buf = malloc(32 * PGSIZE); if (pgaccess(buf, 32, \u0026amp;abits) \u0026lt; 0) err(\u0026#34;pgaccess failed\u0026#34;); buf[PGSIZE * 1] += 1; buf[PGSIZE * 2] += 1; buf[PGSIZE * 30] += 1; if (pgaccess(buf, 32, \u0026amp;abits) \u0026lt; 0) err(\u0026#34;pgaccess failed\u0026#34;); if (abits != ((1 \u0026lt;\u0026lt; 1) | (1 \u0026lt;\u0026lt; 2) | (1 \u0026lt;\u0026lt; 30))) err(\u0026#34;incorrect access bits set\u0026#34;); free(buf); printf(\u0026#34;pgaccess_test: OK\\n\u0026#34;); } 分析下源码可以知道，测试程序分配了32个页，并且使用（or 访问）了这分配的32个页的第1、2、30页，之后程序调用pgaccess来检测abits的第1、2、30位是否为1，即判断该系统调用是否实现了“检测已经访问的页”这个功能。\n向内核添加系统调用的方法在lab2中已经了解过了，不过这里xv6已经帮我们添加好了，我们只需要实现系统调用kernel/sysproc.c:sys_pgaccess()即可。\n在xv6 book中可以知道一个PTE的每位构成如上图，其中0 - 9位是一些标志位，第6位为Accessed，也就是访问位，需要在内核中添加这个标志：\n1 2 3 // kernel/riscv.h #define PTE_A (1L \u0026lt;\u0026lt; 6) 而risc-v处理器会利用硬件将已访问的页的PTE_A正确设置。\n实现sys_pgaccess的步骤大致可分为：\n接受用户态传递的三个参数： 第一个用户页面的起始虚拟地址（指针） 需要检查页数（int） 一个存储每一页是否被访问的掩码（指针） 遍历“需要检查页数”，并每次检查遍历的页是否已访问。获取每个页对应的PTE将使用walk函数来获取，而检查将使用PTE_A来判断；如果当前页的PTE_A为1，则说明该页被访问过，利用位运算（是的，位运算在该lab里立大功）来存储信息，即第几页被访问了，并且不要忘记了实验指导中的提示，“Be sure to clear PTE_A after checking if it is set. Otherwise, it won\u0026rsquo;t be possible to determine if the page was accessed since the last time pgaccess() was called”，检测完后应该将PTE_A标记位清除。 将存储的信息利用copyout函数从内核态传递给用户态，届时用户态可通过第三个参数获取。 具体实现如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 int sys_pgaccess(void) { // lab pgtbl: your code here. uint64 uvm_pgaddr; int page_counts; uint64 abits; argaddr(0, \u0026amp;uvm_pgaddr); argint(1, \u0026amp;page_counts); argaddr(2, \u0026amp;abits); int result = 0; pagetable_t page_table = myproc()-\u0026gt;pagetable; for (int i = 0; i \u0026lt; page_counts; i++) { pte_t *pte = walk(page_table, uvm_pgaddr, 0); if (((*pte) \u0026amp; (PTE_A)) != 0) { result |= (1 \u0026lt;\u0026lt; i); *pte \u0026amp;= (~PTE_A); } uvm_pgaddr += PGSIZE; } copyout(page_table, abits, (char*)\u0026amp;result, sizeof(result)); return 0; } ","date":"2024-11-05T00:00:00Z","permalink":"/p/mit6.s081lab3-page-tables%E4%B8%8B/","title":"【MIT6.S081】Lab3 page tables（下）"},{"content":"前言 这个lab开始我们就正式进入了xv6的世界了，这一次我们可以了解到内核中系统调用的注册和运行原理，这可以说是之后lab的一个基石。\ntrace 首先，我们要清楚这个实验的目的是什么：\nIn this assignment you will add a system call tracing feature that may help you when debugging later labs. You\u0026rsquo;ll create a new trace system call that will control tracing. It should take one argument, an integer \u0026ldquo;mask\u0026rdquo;, whose bits specify which system calls to trace. For example, to trace the fork system call, a program calls trace(1 \u0026laquo; SYS_fork), where SYS_fork is a syscall number from kernel/syscall.h. You have to modify the xv6 kernel to print out a line when each system call is about to return, if the system call\u0026rsquo;s number is set in the mask. The line should contain the process id, the name of the system call and the return value; you don\u0026rsquo;t need to print the system call arguments. The trace system call should enable tracing for the process that calls it and any children that it subsequently forks, but should not affect other processes.\n译：在Xv6的trace命令中，它应该有一个参数，一个整数“掩码”，其位指定要跟踪的系统调用。例如，要跟踪fork系统调用，程序调用trace(1\u0026lt;\u0026lt;SYS_fork)，其中SYS_fork是kernel/syscall.h中的系统调用编号。如果系统调用的编号在掩码中设置，则必须修改xv6内核，以便在每个系统调用即将返回时打印出一行。该行应包含进程id、系统调用的名称和返回值；您不需要打印系统调用参数。跟踪系统调用应启用对调用它的进程及其随后分叉的任何子进程的跟踪，但不应影响其他进程。\n注意，在该实验的初始阶段，xv6已经为我们提供了trace命令的用户态实现，但是其底层的系统调用需要我们自己实现。\nmask是什么？ 在使用trace命令时用到的掩码，是用来跟踪之后使用的命令用到了哪些系统调用。例如实验中给出的例子：\n1 2 3 4 5 $ trace 32 grep hello README 3: syscall read -\u0026gt; 1023 3: syscall read -\u0026gt; 966 3: syscall read -\u0026gt; 70 3: syscall read -\u0026gt; 0 这个32就是掩码，其跟踪到了grep命令中使用到了read系统调用（为什么是read？马上就说到了）。在xv6的kernel/syscall.h中有所有系统调用的编号：\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 // System call numbers #define SYS_fork 1 #define SYS_exit 2 #define SYS_wait 3 #define SYS_pipe 4 #define SYS_read 5 #define SYS_kill 6 #define SYS_exec 7 #define SYS_fstat 8 #define SYS_chdir 9 #define SYS_dup 10 #define SYS_getpid 11 #define SYS_sbrk 12 #define SYS_sleep 13 #define SYS_uptime 14 #define SYS_open 15 #define SYS_write 16 #define SYS_mknod 17 #define SYS_unlink 18 #define SYS_link 19 #define SYS_mkdir 20 #define SYS_close 21 // 添加 #define SYS_trace 22 将这个mask以二进制的形式来看待更加容易理解，如果传入的mask是32，那么其二进制为100000，这个1出现的位置是第5位（最低位按0计数），也就是去找编号为5的系统调用，也就是SYS_read。\nxv6内核提供给用户态的接口为trace，但是我们需要自己在xv6的用户头文件中添加函数的声明：\n1 2 3 4 5 6 7 // user/user.h // ... int trace(int); // ... 这个trace底层其实调用的应该是sys_trace（这个函数名不是固定的，但是源码中其他的系统调用的命名都为sys_*，故trace对应的系统调用写成sys_trace更加合理）。sys_trace需要做的是将用户传入的mask再传给当前进程及其子进程。\n我们这里将系统调用sys_trace的编号设置为22。\n进程及其子进程如何获取mask？ 在xv6 book的4.3节中，有这么一段话：\n1 2 3 syscall (kernel/syscall.c:132) retrieves the system call number from the saved a7 in the trapframe and uses it to index into syscalls. For the first system call, a7 contains SYS_exec (ker\u0002nel/syscall.h:8), resulting in a call to the system call implementation function sys_exec. When sys_exec returns, syscall records its return value in p-\u0026gt;trapframe-\u0026gt;a0. This will cause the original user-space call to exec() to return that value, since the C calling convention on RISC-V places return values in a0. System calls conventionally return negative numbers to indicate errors, and zero or positive numbers for success. If the system call number is invalid, syscall prints an error and returns −1. 即系统调用的编号会保存在进程的trapframe中，根据a7寄存器即可获得，系统调用的返回值可通过a0寄存器获得。欸！这两个值可不就是实验实现中需要的吗！那么理所当然，实验中需要打印的语句应该就在这个函数中添加。\n让我们来看看xv6中进程的数据结构：\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 // kernel/proc.h struct proc { struct spinlock lock; // p-\u0026gt;lock must be held when using these: enum procstate state; // Process state void *chan; // If non-zero, sleeping on chan int killed; // If non-zero, have been killed int xstate; // Exit status to be returned to parent\u0026#39;s wait int pid; // Process ID // wait_lock must be held when using this: struct proc *parent; // Parent process // these are private to the process, so p-\u0026gt;lock need not be held. uint64 kstack; // Virtual address of kernel stack uint64 sz; // Size of process memory (bytes) pagetable_t pagetable; // User page table struct trapframe *trapframe; // data page for trampoline.S struct context context; // swtch() here to run process struct file *ofile[NOFILE]; // Open files struct inode *cwd; // Current directory char name[16]; // Process name (debugging) // 添加 int trace_mask; } 可以看到诸如进程名、pid、上下文等信息都是保存在这个数据结构中，那么我们可以在其中加上一个成员变量 trace_mask 用于保存当前进程所对应trace命令中的掩码mask。\nxv6已经实现了用户态的trace命令，其位于 user/trace.c 中：\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 #include \u0026#34;kernel/param.h\u0026#34; #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; int main(int argc, char *argv[]) { int i; char *nargv[MAXARG]; if(argc \u0026lt; 3 || (argv[1][0] \u0026lt; \u0026#39;0\u0026#39; || argv[1][0] \u0026gt; \u0026#39;9\u0026#39;)){ fprintf(2, \u0026#34;Usage: %s mask command\\n\u0026#34;, argv[0]); exit(1); } if (trace(atoi(argv[1])) \u0026lt; 0) { fprintf(2, \u0026#34;%s: trace failed\\n\u0026#34;, argv[0]); exit(1); } for(i = 2; i \u0026lt; argc \u0026amp;\u0026amp; i \u0026lt; MAXARG; i++){ nargv[i-2] = argv[i]; } exec(nargv[0], nargv); exit(0); } 对于这样的一条命令 trace 32 grep hello README ，假设开启的进程名为p，那么32将会传给p.trace_mask，之后的grep操作将使用exec创建子进程（假设进程名为son）执行，那么在创建子进程后应该有son.trace_mask = p.trace_mask，只有这样，grep操作所用到的系统调用才能被跟踪到。\n在使用trace命令时，其后的mask参数会存到a0寄存器中，为了从其中拿到mask，可以使用argint()函数，其源码为：\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 // kernel/syscall.h // Fetch the nth 32-bit system call argument. void argint(int n, int *ip) { *ip = argraw(n); } static uint64 argraw(int n) { struct proc *p = myproc(); switch (n) { case 0: return p-\u0026gt;trapframe-\u0026gt;a0; case 1: return p-\u0026gt;trapframe-\u0026gt;a1; case 2: return p-\u0026gt;trapframe-\u0026gt;a2; case 3: return p-\u0026gt;trapframe-\u0026gt;a3; case 4: return p-\u0026gt;trapframe-\u0026gt;a4; case 5: return p-\u0026gt;trapframe-\u0026gt;a5; } panic(\u0026#34;argraw\u0026#34;); return -1; } argint()内调用了argraw()，在查看以上源码后，由于只传入了一个参数，故应该将0传入argraw中。在kernel/sysproc.c中实现sys_trace()：\n1 2 3 4 5 6 7 8 9 10 11 12 // kernel/sysproc.c // ... uint64 sys_trace(void) { int mask; argint(0, \u0026amp;mask); if (mask \u0026lt; 0) return -1; struct proc *p = myproc(); p-\u0026gt;trace_mask = mask; return 0; } 这样当trace使用了底层的sys_trace时，就可以把mask参数传递给当前进程。但是只传递给当前进程还不够，还要传给当前进程的子进程。在Linux，我们创建子进程的函数为fork()，在xv6中也同样如此，fork内部先获取当前进程的proc结构体，然后新创建一个proc结构体代表子进程，并将父进程中的值拷贝过去，故传递给子进程的mask也在其中拷贝：\n1 2 3 4 5 6 7 8 9 10 11 // kernel/proc.c int fork(void) { // ... // copy mask from father process to son process // np为子进程，p为父进程 np-\u0026gt;trace_mask = p-\u0026gt;trace_mask; ... } 要注意一个小细节，当进程结构体被释放时（进程结束或者为进程分配proc结构体），其mask也该重置：\n1 2 3 4 5 6 // kernel/proc.c static void freeproc(struct proc *p) { // ... ... p-\u0026gt;trace_mask = 0; } 完成好以上内容后，就可以实现sys_trace了：\n1 2 3 4 5 6 7 8 9 10 // kernel/sysproc.c uint64 sys_trace(void) { int mask; argint(0, \u0026amp;mask); if (mask \u0026lt; 0) return -1; struct proc *p = myproc(); p-\u0026gt;trace_mask = mask; return 0; } 这样，当前进程就可以获取了到mask，当其创建子进程时，子进程也可获取到mask~\n如何跟踪系统调用？ 刚刚我们说了mask的作用，还有进程及其子进程如何获取mask，那么我们又应该如何跟踪系统调用呢？\nxv6中所用的系统调用都是在 kernel/syscall.c 中的 syscall 函数中调用的，为了能在syscall.c中调用sys_trace，需要在其中添加extern声明（其定义在刚刚已经实现，位于kernel/sysproc.c）：\n1 extern uint64 sys_trace(void); 同时需要在syscalls数组中添加sys_trace的编号：\n1 2 3 4 5 6 7 static uint64 (*syscalls[])(void) = { // ... ... [SYS_trace] sys_trace, }; // 这里实际上就是 [22] = sys_trace // 使用了gcc的一个拓展 并按照顺序添加各个系统调用的名字：\n1 2 3 4 5 6 char *syscall_names[] = { \u0026#34;fork\u0026#34;, \u0026#34;exit\u0026#34;, \u0026#34;wait\u0026#34;, \u0026#34;pipe\u0026#34;, \u0026#34;read\u0026#34;, \u0026#34;kill\u0026#34;, \u0026#34;exec\u0026#34;, \u0026#34;fstat\u0026#34;, \u0026#34;chdir\u0026#34;, \u0026#34;dup\u0026#34;, \u0026#34;getpid\u0026#34;, \u0026#34;sbrk\u0026#34;, \u0026#34;sleep\u0026#34;, \u0026#34;uptime\u0026#34;, \u0026#34;open\u0026#34;, \u0026#34;write\u0026#34;, \u0026#34;mknod\u0026#34;, \u0026#34;unlink\u0026#34;, \u0026#34;link\u0026#34;, \u0026#34;mkdir\u0026#34;, \u0026#34;close\u0026#34;, \u0026#34;trace\u0026#34;, }; 在 kernel/syscall.c 中，xv6根据a7寄存器获取系统调用的编号，然后通过syscalls函数数组执行系统调用，那么我们的实现为：当使用的系统调用合法时，获取当前进程的mask，并通过判断(mask \u0026gt;\u0026gt; syscall_num) \u0026amp; 1是否为1来输出跟踪信息。\n例如sys_read系统调用的编号为5，mask为32，则(32 \u0026gt;\u0026gt; 5) \u0026amp; 1 = 1。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 void syscall(void) { int num; struct proc *p = myproc(); num = p-\u0026gt;trapframe-\u0026gt;a7; if(num \u0026gt; 0 \u0026amp;\u0026amp; num \u0026lt; NELEM(syscalls) \u0026amp;\u0026amp; syscalls[num]) { // Use num to lookup the system call function for num, call it, // and store its return value in p-\u0026gt;trapframe-\u0026gt;a0 p-\u0026gt;trapframe-\u0026gt;a0 = syscalls[num](); // =============================== int mask = p-\u0026gt;trace_mask; if ((mask \u0026gt;\u0026gt; num) \u0026amp; 1) { printf(\u0026#34;%d: syscall %s -\u0026gt; %d\\n\u0026#34;, p-\u0026gt;pid, syscall_names[num - 1], p-\u0026gt;trapframe-\u0026gt;a0); } // =============================== } else { printf(\u0026#34;%d %s: unknown sys call %d\\n\u0026#34;, p-\u0026gt;pid, p-\u0026gt;name, num); p-\u0026gt;trapframe-\u0026gt;a0 = -1; } } 那么，内核是如何通过trace找到sys_trace的呢？根据实验指导上的提示，可以知道 user/usys.pl 起到了一个中间人的作用：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # user/usys.pl ... sub entry { my $name = shift; print \u0026#34;.global $name\\n\u0026#34;; print \u0026#34;${name}:\\n\u0026#34;; print \u0026#34; li a7, SYS_${name}\\n\u0026#34;; print \u0026#34; ecall\\n\u0026#34;; print \u0026#34; ret\\n\u0026#34;; } ... entry(\u0026#34;uptime\u0026#34;); ++entry(\u0026#34;trace\u0026#34;); # 这是实验中需要由我们自己添加的 通过其中的entry函数，可以生成对应的调用（xv6中为ecall）系统调用的汇编语句，即大致的流程为xv6在构建内核时，会将用户态trace命令对应到：\n1 2 3 4 5 .global trace trace: li a7, SYS_trace ecall ret 这样，当前进程就可以通过a7寄存器拿到sys_trace的系统调用编号了，也就是说，syscall 函数可以调用 sys_trace 了。\nOK，那么一个大致的框架就出来了：\n完成上面的步骤后，最后只要在Makefile中的UPROGS加上$U/_trace即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 UPROGS=\\ $U/_cat\\ $U/_echo\\ $U/_forktest\\ $U/_grep\\ $U/_init\\ $U/_kill\\ $U/_ln\\ $U/_ls\\ $U/_mkdir\\ $U/_rm\\ $U/_sh\\ $U/_stressfs\\ $U/_usertests\\ $U/_grind\\ $U/_wc\\ $U/_zombie\\ ++\t$U/_trace 在这个lab中，我们需要添加一个系统调用sysinfo，用于收集有关正在运行的系统的信息。\n系统调用的声明为：\n1 int sysinfo(struct sysinfo*); 这个系统调用接受一个指向结构体sysinfo的指针，其定义为：\n1 2 3 4 5 6 // kernel/sysinfo.h struct sysinfo { uint64 freemem; // amount of free memory (bytes) uint64 nproc; // number of process }; 内核应填写此结构的字段：freemem字段应设置为可用内存的字节数，nproc字段应设为状态未使用的进程数。\nsysinfo 在这个part中，我们需要添加一个系统调用sysinfo，用于收集有关正在运行的系统的信息。\n计算可用内存字节数 我们可以通过内核中的kmem来获取可用的内存块的数量：\n1 2 3 4 struct { struct spinlock lock; struct run *freelist; } kmem; kmem.freelist是一个链表，保存了所有可用的内存块的地址，我们遍历这个链表即可获取可用内存块数量，又一个内存块的大小为4KB，那么系统可用的内存字节数 = 可用内存块数量 * 4KB：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 // kernel/kalloc.c uint64 freemem() { struct run* r; uint64 free_page = 0; acquire(\u0026amp;kmem.lock); r = kmem.freelist; while (r) { free_page++; r = r-\u0026gt;next; } release(\u0026amp;kmem.lock); // 4K = 2^12，左移操作相当于对2的乘法 return (free_page \u0026lt;\u0026lt; 12); } 计算状态为未为使用进程数 内核中有一个全局数组，其中每一项为系统中的进程，xv6中设置最多进程数为64个：\n1 struct proc proc[NPROC]; 在表示进程的结构体中，有一个成员表示这个进程的状态：\n1 2 3 4 5 6 7 8 9 enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE }; // Per-process state struct proc { ... // p-\u0026gt;lock must be held when using these: enum procstate state; ... } 我们可以遍历proc数组，找到所有state != UNUSED的进程的数量（这里一定要看清楚，是状态为未使用的进程数，而不是未使用的进程数）：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // kernel/proc.c uint64 nproc() { struct proc* p; uint64 not_unused = 0; for (p = proc; p \u0026lt; \u0026amp;proc[NPROC]; p++) { if (p-\u0026gt;state != UNUSED) { not_unused++; } } return not_unused; } 完成系统调用 如何获取用户态传递过来的参数和注册系统调用可以参考这篇博客，这里就不赘述了\n我们创建了struct sysinfo结构体变量info后，使用刚刚的freemem和nproc函数来为结构体变量赋值，之后通过copyout函数将内核态中的info拷贝给用户态的struct sysinfo结构体变量。\n这里的实现原理是：用户态下我们使用系统调用传递了一个struct sysinfo指针，其实就是传递了一个内存地址addr；内核态下我们将info中的数据原封不动地搬一份到addr处。这样当用户态访问addr处的内存时就可以获取到想要的数据了。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 uint64 sys_sysinfo() { uint64 addr; argaddr(0, \u0026amp;addr); if (addr \u0026lt; 0) { return -1; } struct proc* p = myproc(); struct sysinfo info; info.freemem = freemem(); info.nproc = nproc(); if (copyout(p-\u0026gt;pagetable, addr, (char*)\u0026amp;info, sizeof(info))) { return -1; } return 0; } ","date":"2024-10-28T00:00:00Z","permalink":"/p/mit6.s081lab2-system-calls/","title":"【MIT6.S081】Lab2 system calls"},{"content":"前言 该Lab通过实现几个命令来熟悉 xv6 及其系统调用\nsleep pingpong primes find xargs 官方实验指导：https://pdos.csail.mit.edu/6.S081/2021/labs/util.html\n个人代码实现仓库：https://github.com/kerolt/xv6-labs-2023\n环境搭建 使用docker创建ubuntu20.04容器，后执行：\n1 apt install git build-essential gdb-multiarch qemu-system-misc gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu 之后测试一下：\n首先下载xv6源码：git clone https://github.com/mit-pdos/xv6-riscv.git 运行：make qemu，如果结果如下，说明成功，按下ctrl + a和x退出qemu 1 2 3 # ... lots of output ... init: starting sh $ 测试 对于完成的程序，如果想要测试，则在Makefile中的UPROGS中添加：\n1 $U/_\u0026lt;xxx\u0026gt;\\ 其中的xxx即为程序的名称，如sleep,则为$U/_sleep\\。\n之后，可使用如下方法进行测试：\n1 2 3 ./grade-lab-util xxx # or make GRADEFLAGS=xxx grade sleep (easy) 练前开胃菜，使用sleep系统调用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; int main(int argc, char* argv[]) { if (argc != 2) { printf(\u0026#34;Usage: sleep \u0026lt;seconds\u0026gt;\\n\u0026#34;); exit(1); } int time = atoi(argv[1]); sleep(time); return exit(0); } pingpong (easy) 编写一个程序，使用 UNIX 系统调用在两个进程之间通过一对管道 \u0026ldquo;乒乓 \u0026ldquo;传送一个字节，每个管道一个方向。\n该程序需要注意的就是对于两个管道的操作，何时关，关哪个？读写顺序又如何？\n对于父进程，应该先写再读 对于子进程，应该先读再写 1 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 #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; #define R 0 #define W 1 int main() { int p2c[2], c2p[2]; pipe(p2c); pipe(c2p); int pid = fork(); if (pid == 0) { // chile: read from the parent char buf[32] = {0}; close(c2p[R]); // 不用从子进程读 close(p2c[W]); // 不用从父进程写 read(p2c[R], buf, sizeof(buf)); close(p2c[R]); printf(\u0026#34;%d: received ping\\n\u0026#34;, getpid()); write(c2p[W], \u0026#34;pong\u0026#34;, 4); close(c2p[W]); exit(0); } else { // parent: read from the child char buf[32] = {0}; close(p2c[R]); // 不用从父进程读 close(c2p[W]); // 不用从子进程写 write(p2c[W], \u0026#34;ping\u0026#34;, 4); close(p2c[W]); read(c2p[R], buf, sizeof(buf)); printf(\u0026#34;%d: received pong\\n\u0026#34;, getpid()); close(c2p[R]); exit(0); } } primes （moderate/hard） 使用管道编写并发版质数筛。\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 52 53 54 55 56 57 58 59 #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; void Filter(int pipe_fd[2]) { close(pipe_fd[1]); int prime; read(pipe_fd[0], \u0026amp;prime, 4); printf(\u0026#34;prime %d\\n\u0026#34;, prime); int num; if (read(pipe_fd[0], \u0026amp;num, 4) == 0) { exit(0); } int new_pipe_fd[2]; pipe(new_pipe_fd); int pid = fork(); if (pid == -1) { printf(\u0026#34;Fork error!\\n\u0026#34;); exit(1); } else if (pid == 0) { Filter(new_pipe_fd); } else { close(new_pipe_fd[0]); if (num % prime != 0) { write(new_pipe_fd[1], \u0026amp;num, 4); } while (read(pipe_fd[0], \u0026amp;num, 4) \u0026gt; 0) { if (num % prime != 0) { write(new_pipe_fd[1], \u0026amp;num, 4); } } close(new_pipe_fd[1]); close(pipe_fd[0]); wait(0); } } int main() { int pipe_fd[2]; pipe(pipe_fd); int pid = fork(); if (pid == -1) { printf(\u0026#34;Fork error!\\n\u0026#34;); exit(1); } else if (pid == 0) { Filter(pipe_fd); } else { close(pipe_fd[0]); for (int i = 2; i \u0026lt;= 35; i++) { write(pipe_fd[1], \u0026amp;i, 4); // 一个int为4 byte } close(pipe_fd[1]); wait(0); } exit(0); } find (moderate) 实现Unix下的find命令，利用递归处理，要注意关闭文件描述符的时机。\n最关键的是要理解目录（文件夹）也是一种文件，其目录项就是这个文件的内容，所以我们可以通过read系统调用来读取目录项，进而当读取的内容的类型是一个目录时，即可递归调用Find。\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; #include \u0026#34;kernel/fs.h\u0026#34; char* GetName(char* path) { char* p; for (p = path + strlen(path); p \u0026gt;= path \u0026amp;\u0026amp; *p != \u0026#39;/\u0026#39;; p--) {} p++; return p; } void Find(char* dir, char* name) { char buf[512]; char* p; int fd = open(dir, 0); struct dirent de; struct stat st; if (fd \u0026lt; 0) { printf(\u0026#34;Error: open\\n\u0026#34;); return; } if (fstat(fd, \u0026amp;st) \u0026lt; 0) { printf(\u0026#34;Error: stat\\n\u0026#34;); close(fd); return; } if (st.type != T_DIR) { printf(\u0026#34;the current the file is not dictionary\\n\u0026#34;, dir); close(fd); return; } strcpy(buf, dir); p = buf + strlen(buf); *p++ = \u0026#39;/\u0026#39;; while (read(fd, \u0026amp;de, sizeof(de)) == sizeof(de)) { if (de.inum == 0) continue; if (strcmp(de.name, \u0026#34;.\u0026#34;) == 0) continue; if (strcmp(de.name, \u0026#34;..\u0026#34;) == 0) continue; char* cur = p; memmove(cur, de.name, DIRSIZ); cur[DIRSIZ] = 0; if (stat(buf, \u0026amp;st) \u0026lt; 0) { printf(\u0026#34;Error: stat\\n\u0026#34;); continue; } switch (st.type) { case T_FILE: if (strcmp(GetName(buf), name) == 0) { printf(\u0026#34;%s\\n\u0026#34;, buf); } break; case T_DIR: if (strlen(dir) + 1 + DIRSIZ + 1 \u0026gt; sizeof(buf)) { printf(\u0026#34;Error: path too long\\n\u0026#34;); break; } Find(buf, name); break; } } close(fd); } int main(int argc, char* argv[]) { if (argc != 3) { printf(\u0026#34;Usage: find \u0026lt;dir\u0026gt; \u0026lt;file_name\u0026gt;\\n\u0026#34;); exit(1); } Find(argv[1], argv[2]); exit(0); } xargs (moderate) 简单实现Unix上的xargs命令。简单介绍xargs的用法，就是将标准输入作为xargs的参数。更多的介绍，可以看阮一峰老师的博客：https://ruanyifeng.com/blog/2019/08/xargs-tutorial.html\n该命令的可使用fork和exec来实现。\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 #include \u0026#34;kernel/types.h\u0026#34; #include \u0026#34;kernel/stat.h\u0026#34; #include \u0026#34;kernel/param.h\u0026#34; #include \u0026#34;user/user.h\u0026#34; int main(int argc, char* argv[]) { if (argc \u0026lt; 2) { printf(\u0026#34;Usage: xargs \u0026lt;params\u0026gt;\\n\u0026#34;); exit(1); } char* child_argv[MAXARG]; char buf[512] = {\u0026#39;\\0\u0026#39;}; int index = 0; for (int i = 1; i \u0026lt; argc; i++) { child_argv[index++] = argv[i]; } sleep(10); // 从标准输入中读取命令到buf中 // 若执行 echo 1 2 3，则标准输入为 1 2 3 int n; while ((n = read(0, buf, sizeof(buf))) \u0026gt; 0) { char* p = buf; for (int i = 0; i \u0026lt; n; i++) { if (buf[i] != \u0026#39;\\n\u0026#39;) continue; if (fork() == 0) { buf[i] = \u0026#39;\\0\u0026#39;; // 例如：echo 1 | xargs echo 2 // 在xargs中，标准输入为1，buf中内容为\u0026#34;1\\n\u0026#34;，child_argv为[\u0026#34;echo\u0026#34;, \u0026#34;2\u0026#34;]，index为2 // 程序执行到此处时，buf中内容变为了\u0026#34;1\\0\u0026#34;，child_argv变为了[\u0026#34;echo\u0026#34;, \u0026#34;2\u0026#34;, \u0026#34;1\u0026#34;] child_argv[index] = p; // exec的第一个参数是要执行的可执行文件的路径 // 第二个参数是作为新命令的参数数组，数组的第一项为新命令的名称 exec(child_argv[0], child_argv); exit(0); } else { // 在父进程中，跳过buf中的\\n后，是一条新命令的开始 p = \u0026amp;buf[i + 1]; wait(0); } } } exit(0); } ","date":"2024-10-27T00:00:00Z","permalink":"/p/mit6.s081lab1-utilities/","title":"【MIT6.S081】Lab1 utilities"},{"content":" 该系列博客只是为了记录自己在写Lab时的思路，按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流！\n这个Project需要我们实现一个缓存池，减少对于磁盘的频繁IO。开始慢慢上强度了，细节拉满！\nTask1 - LRU-K Replacement Policy 什么是LRU算法？LRU是Least Recently Used的缩写，即最近最少使用，是一种常用的页面置换算法，选择最近最久未使用的页面予以淘汰。该算法赋予每个页面一个访问字段，用来记录一个页面自上次被访问以来所经历的时间 t，当须淘汰一个页面时，选择现有页面中其 t 值最大的，即最近最少使用的页面予以淘汰。\nLRU-K是一种增强型的LRU，其主要思想是利用历史访问模式来进行页面置换决策，具体过程如下：\n访问记录：每个页面维护一个访问时间戳列表，记录其最近的k次访问时间。这个列表用于计算向后k距离。 向后k距离：当一个页面被访问时，算法会更新其时间戳列表，并计算向后k距离。向后k距离是当前时间戳与列表中第k个时间戳之间的差值。如果列表中没有k个时间戳，该页面的向后k距离被赋值为正无穷。 页面替换： 在需要替换页面时，算法会遍历所有页面，找到具有最大向后k距离的页面进行替换。 如果存在多个页面的向后k距离为正无穷，算法将选择这些页面中最早的访问时间戳进行替换。 优点：LRU-K相较于传统的LRU算法更为智能，它能更好地适应不同的访问模式，减少不必要的页面置换。通过考虑历史访问，它能够识别出哪些页面可能会被频繁使用，从而提高缓存的命中率。 在BusTub的实现中，LRU-K有着每个frame id与LRUKNode的映射。\n1 2 3 4 5 6 7 class LRUKNode { private: [[maybe_unused]] std::list\u0026lt;size_t\u0026gt; history_; [[maybe_unused]] size_t k_; [[maybe_unused]] frame_id_t fid_; [[maybe_unused]] bool is_evictable_{false}; }; 一个node记录了一个frame的id，还有这个frame在最近使用k次的时间戳。\nEvict(frame_id_t* frame_id) : 与所有其他可删除的frame相比，删除具有最大向后k距离的frame。将frame id存储在输出参数中并返回True。如果没有可替换的frame，则返回False。具有少于k个访问记录的frame被给定“无穷”作为其向后k距离。如果多个帧具有inf向后k距离，则根据LRU驱逐具有最早时间戳的帧。成功删除帧应减小替换器的大小并删除帧的访问历史记录。 RecordAccess(frame_id_t frame_id) : 每次调用RecordAccess，内置的时间戳就要+1，并且根据frame_id是否在LRU缓存中来决定是更新对应的node还是添加一个新node。 Add：新建一个node，并且初始化，再把node插入缓存 Update：通过frame id找到对应的node，并修改node的历史访问时间戳，更新它在缓存中的访问位置 Remove(frame_id_t frame_id) : 从LRU缓存中删除frame id以及对应的node。要注意删除后对当前“可替换的frame数量”-1。 SetEvictable(frame_id_t frame_id, bool set_evictable) : 控制frame是否可逐出。它还控制着LRUKReplacer的size。 Size() : 返回LRUKReplacer中当前可替换的frame数量。 总的来说，把这个task当成力扣上的一道数据结构设计题来完成就好了。\nTask2 - Disk Scheduler 比较简单，实现两个函数的功能：\nSchedule(DiskRequest r)：调度DiskManager执行的请求。DiskRequest结构体指定请求是否为读/写，数据应写入/从何处写入，以及操作的页面ID。DiskRequest还包括一个std::promise，一旦请求被处理，其值应设置为true。 StartWorkerThread()：启动处理请求的后台工作线程。在DiskScheduler构造函数中创建工作线程并调用此方法。此方法负责获取排队的请求并将其分派给DiskManager。记住设置DiskRequest回调的值，以向请求发出者发出请求已完成的信号。在调用DiskScheduler的析构函数之前，这不应该返回。 Task3 - Buffer Pool Manager BufferPoolManager内部采用一个原始数组来存放Page的指针。初始时，每个page都在free list中。\n同一个块，在内存中称作“帧（frame）”，在硬盘中称作“页（page）”。缓冲池中会存储pool_size个Page，这些个Page包含硬盘上的数据和一些元信息。\nBufferPoolManager中的page_table_采用page_id作为键，frame_id作为值。如果有某个frame被使用了，那么page_table_中就会插入相应的键值对。BufferPoolManager中的pages_的下标即代表了frame id，我们通过page id可以找到对应的frame id，再通过frame id就可以找到在pages_中的Page了。\n下图来源：https://www.qtmuniao.com/2021/02/10/cmu15445-project1-buffer-pool/\n我们可以从freelist或者replacer中找到frame（优先从freelist中找）。如果free list中有空闲的frame，则使用它；否则从replacer中选出需要换出的frame（replacer的size与buffer pool的size相同）。\n要实现的每个函数在代码头文件中都有较为详细的实现过程，需要注意很多细节。\n需要注意:\n每次执行FetchPage时相当于要使用到某一个页面，既然如此，也要在LRU-K缓存中更新历史访问序列（通过调用RecordAccess）； 需要新的frame时，优先从free list中找，其次通过replacer的Evict来获取； 对于页面的pin_count_，只有当某个函数返回的是Page*时，说明这个页是需要使用的，此时其pin_count_ + 1；而只有在Unpin中，才会对pin_count_ - 1； 如果需要替换或新建或删除某个页面时，其脏位位true，则要及时写回磁盘； 错误记录 错误点1：没有保证LRU-K中的原子性 对于当前可替换的frame数量的操作应该是原子性的，可以用atomic_size_t来取代size_t，或者使用锁。\n错误点2：最大向前K距离的计算错误 The LRU-K algorithm evicts a frame whose backward k-distance is maximum of all frames in the replacer. Backward k-distance is computed as the difference in time between current timestamp and the timestamp of kth previous access. A frame with fewer than k historical accesses is given +inf as its backward k-distance. When multiple frames have +inf backward k-distance, the replacer evicts the frame with the earliest overall timestamp (i.e., the frame whose least-recent recorded access is the overall least recent access, overall, out of all frames).\n主要是对这段话的理解不到位，之前是这样：\n1 2 3 4 5 6 7 8 9 // 计算最大向后K距离 int k_dist = node.history_.size() \u0026lt; k_ ? std::numeric_limits\u0026lt;int\u0026gt;::max() : (current_timestamp_ - node.history_[k_ - 1]); if (k_dist \u0026gt; max_k_dist) { evict_frame_id = fid; max_k_dist = k_dist; } else if (k_dist == max_k_dist \u0026amp;\u0026amp; node.history_.back() \u0026lt; node_store_[evict_frame_id].history_.back()) { evict_frame_id = fid; } 但实际应该为：\n1 2 3 4 5 6 7 8 9 // 计算最大向后K距离 int k_dist = node.history_.size() \u0026lt; k_ ? std::numeric_limits\u0026lt;int\u0026gt;::max() : (current_timestamp_ - node.history_[k_ - 1]); if (k_dist \u0026gt; max_k_dist) { evict_frame_id = fid; max_k_dist = k_dist; } else if (k_dist == max_k_dist \u0026amp;\u0026amp; node.history_.back() \u0026lt; node_store_[evict_frame_id].history_.back()) { evict_frame_id = fid; } 当有多个距离为无穷大的frame时，应该选择其历史记录中具有最小时间戳的那个frame！\n错误3：FetchPage找到直接返回时没有pin一下 这里卡了几个测试是因为FetchPage在page_id在缓冲池中时会直接返回，但是我没有在这种情况中对这个page的pin_count_进行+1操作。\n错误4：Unpin中的is_dirty参数的设置 在Unpin中，不能直接将page.is_dirty_设置成参数is_dirty，而是应该用或操作：page.is_dirty_ |= is_dirty;。如果不这样做，那么当原先page.is_dirty_为true时，如果我们通过Unpin设置了false，其is_dirty_就变为了false，但是这个页面仍然是脏页面。\n错误5：FetchPage没有RecordAccess 在FetchPage中，如果缓冲池中有 page id 直接返回时，replacer_也应该执行RecordAccess。因为这时相当于使用了Page，当然要记录更新。\n最终提交 然后Leaderboard的排名有点低了，因为之前都是使用的大锁，希望之后能优化一下。\n小结 这个Project差不多搞了四五天，主体代码用了两天左右，然后就是漫长的修Bug。由于从这个Project开始，就不会在本地给出完整的测试集了，所以在评测平台上也是提交了很多次来检测。通过Discord中的频道，也是找到了一些解决Bug的办法，很多时候是一些细节处自己没有考虑到（错误记录）。好在最后也基本上是自己独立完成的，有点小小的成就感！\n","date":"2024-10-12T00:00:00Z","permalink":"/p/cmu15-445-fall2023project1-buffer-pool-%E5%B0%8F%E7%BB%93/","title":"【CMU15-445 Fall2023】Project1 Buffer Pool 小结"},{"content":" 该系列博客只是为了记录自己在写Lab时的思路，按照课程要求不会在Github和博客中公开源代码。欢迎与我一起讨论交流！\nproject0只在task4中浅浅涉及了一点BusTub的内容，其他都是检测我们对于C++的一个掌握，主要涉及智能指针和C++的常用特性（dynamic_cast、std::move、并发与锁等）。\nTask1 - Copy-On-Write Trie 如果做过力扣上的208. 实现 Trie (前缀树)，实现Get和Put这两个操作会更容易。虽然这个task要求我们要使用COW，但是总体的思路是差不多的。\nGet 这个比较简单，顺序遍历key去找节点的子节点就行。值得注意的是有值节点的类型为TrieNodeWithValue，其继承自TrieNode。当遍历玩key并找到节点后，使用dynamic_cast将其转换为TrieNodeWithValue*，如果这个节点不是一个有值的节点，则dynamic_cast会转换失败返回nullptr，否则会返回转换后的指针，通过这个指针获取最终的值即可。\nPut Put的基本思路就是遍历key的同时创建相应的节点，难点在于如何实现“copy on write”。\n看官网的描述，我们要做的就是在需要修改Trie树（例如进行Put操作或者Remove操作）时，需要返回一颗新的Trie树，也就是我们当前的操作不能影响之前的Trie树的结构，这就需要使用到TrieNode::Clone()函数来拷贝一份需要修改的节点，这样才不会影响之前Trie树中的节点。同时，在这个过程中，我们需要尽可能地使用已有节点，举个🌰：\n只需记住：\n本次的插入或删除操作不会影响上次的Trie树的结构，例如上图如果我使用root来访问还是原来的结构，而用new root来访问就可以访问key值为“ad”的节点的值； 尽可能利用已有的节点，上图我们为了不影响之前的Trie树，我们必须拷贝根节点下“a”路径的子节点，插入一个新的节点也需要创建，但是值为“233”和“C++”的节点我们可以利用。 Remove 与Put不同，我使用递归来进行删除，这是因为Put可以顺序遍历key值并不断向下添加节点，而Remove需要从要删除的节点不断向上进行删除。\n删除不合法的情况有：\n碰到空节点 无法再继续往下查找key（key不存在） 这时按照任务要求应该返回原来的Trie树。\nTask2 - Concurrent Key-Value Store Get 加锁获取root_，然后调用之前再Task1中实现的Get操作获取key对应的value，如果value不为nullptr，则将root和value封装为ValueGuard。\nPut、Remove 这两个操作的逻辑相同。由于之前我们实现Trie的三个操作时用到了COW，因此每次Put、Remove时返回的都是一个新的Trie，那么我们在Task2中的操作中要用全程获取写锁（ensure there is only one writer at a time），然后使用Put or Remove获取新的Trie，接着获取root_lock_，最后用获取的新的Trie更新root_。\nTask3 - Debugging 这个没什么太多需要说明的。选择Clion或VSCode配置好环境打断点Debug就行，当然使用print大法也是可以的haha。注意这个task虽然有给你单元测试文件，但是运行肯定是不通过，因为问题答案并不在源码中，我们只能在gradescope才能检测自己做的是否正确。\n（我所做的project所属课程是fall2023版本，如果是spring2023版本，task3在本地测试和gradescope上的测试可能会有区别，是随机数的问题，相关老师有在Discord上说明）\nTask4 - SQL String Functions 这个task需要我们为BusTub实现两个简单的函数：lower和upper。实现并不难，找到需要修改的位置，添加相关处理逻辑和异常操作。\n完成这个task我认为需要对string_expression.h这个文件的内容有一定理解，要明白如何判断获取的操作是lower还是upper，还有在plan_func_call.cpp中处理非法操作。总体来说不难。\n提交 本地跑了测试代码还不够，课程有为我们这些非CMU的学生准备检测平台gradescope，如何加入课程可以看这里。\n在提交之前，需要使用进行clang-tidy检测，并通过python3 gradescope_sign.py生成签名，这样才能通过平台的预检测。\n这里贴一个通关的截图hh：\n总结 这个project0我也是做了两三天（还是太菜了），在其中我更好地巩固了C++中的一些知识，例如智能指针、移动赋值、dynamic_cast。与之前做Xv6的Lab不同，这次的CMU15-445在网上基本没有关于实现的源代码，这就让我无法直接通过代码来学习了。当然这是课程的要求，希望我们能一起构建一个良好的学习氛围，鼓励我们独立思考完成，与他人交流而不是直接要代码，我觉得这是一个很锻炼自己的过程，网上大多是一些思路的介绍（我写的这篇博客也是自己实现的简单思路，希望没有违反课程的要求），我们在没思路时参考一下这些博客，然后再通过自己来完成代码，这比直接看别人写好的代码来说更能够提升自己！希望我能把接下来的几个project都完成，尽量不烂尾！\n","date":"2024-10-06T00:00:00Z","permalink":"/p/cmu15-445-fall2023project0-c-primer-%E5%B0%8F%E7%BB%93/","title":"【CMU15-445 Fall2023】Project0 C++ Primer 小结"},{"content":" 【动手写协程库】系列笔记是学习 sylar 的协程库时的记录，参考了 从零开始重写sylar C++高性能分布式服务器框架 和代码随想录中的 文档。文章并不是对所有代码的详细解释，而是为了自己理解一些片段所做的笔记。\nhook 函数的具体定义实现可以在这里查看：Github: src/hook.cpp\n该协程库框架的目标并不是做成类似 goroutine 那样，而是希望能够通过协程来提高 IO 处理的效率。因此，对于每个文件描述符 fd，我们都希望它有一个读写 IO 的超时时间。\nhook 的目的是在不重新编写代码的情况下，把老代码中的 socket IO 相关的 API 都转成异步，以提高性能。\n需要 Hook 的几类函数 在 sylar 的设计中，只针对 socket fd 进行 hook（因为我们更关心的是网络 IO），也就是如果我们操作的不是 socket fd，那么就会使用原来的 API。\nsylar 对如下三类函数进行了 hook：\nsleep 延时系列接口：包括 sleep/usleep/nanosleep。对于这些接口的 hook，只需要给 IO 协程调度器注册一个定时事件，在定时事件触发后再继续执行当前协程即可。当前协程在注册完定时事件后即可 yield 让出执行权 socket IO 系列接口：包括 read/write/recv/send\u0026hellip;等，connect 及 accept 也可以归到这类接口中。这类接口的 hook 首先需要判断操作的 fd 是否是 socket fd，以及用户是否显式地对该 fd 设置过非阻塞模式，如果不是 socket fd 或是用户显式设置过非阻塞模式，那么就不需要 hook 了，直接调用操作系统的 IO 接口即可。如果需要 hook，那么首先在 IO 协程调度器上注册对应的读写事件，等事件发生后再继续执行当前协程。当前协程在注册完 IO 事件即可 yield 让出执行权。 socket/fcntl/ioctl/close 等接口：这类接口主要处理的是边缘情况，比如分配 fd 上下文，处理超时及用户显式设置非阻塞问题。 Hook 的实现 我们 hook 的所有函数，都要与原来的 API 的行为保持一致（使用这些 hook api 的时候就好像使用的原来的 api）。例如原来的 API 的返回值通常用 0 表示成功，-1 表示失败\n在 Sylar 中，Hook 的实现通常涉及以下几个关键方面：\n一、函数指针替换\n保存原始函数指针：首先，需要保存被 Hook 函数的原始实现的函数指针。这可以通过在程序启动时或者在首次需要 Hook 的时候，获取原始函数的地址并存储起来。例如，可以定义一个与被 Hook 函数具有相同签名的函数指针变量，并将其初始化为指向原始函数的地址。 替换函数指针：然后，将被 Hook 函数的入口地址替换为自定义的 Hook 函数的地址。这样，当程序调用被 Hook 函数时，实际上会执行 Hook 函数。 二、参数传递和返回值处理\n参数传递：在 Hook 函数中，需要接收与被 Hook 函数相同的参数。这可以通过将参数直接传递给 Hook 函数，或者使用一些技术（如函数调用栈的分析）来获取参数的值。如果被 Hook 函数是 int func(int a, char* b)，那么 Hook 函数也应该具有相同的参数列表 int hook_func(int a, char* b)。 返回值处理：Hook 函数需要根据需要处理被 Hook 函数的返回值。可以选择直接返回被 Hook 函数的原始返回值，或者根据特定的逻辑修改返回值后再返回。 三、条件判断和控制\nHook 启用 / 禁用：通常会提供一种机制来启用或禁用 Hook 功能。这可以通过一个全局变量、配置文件或者运行时参数来控制。我们可以在代码中定义一个布尔变量，如 bool hook_enable，当它为真时启用 Hook 功能，为假时直接调用原始函数而不执行 Hook 函数。 特定条件下的 Hook：可以根据特定的条件来决定是否执行 Hook 函数。例如，可以检查参数的值、函数的调用者、当前的运行环境等条件，只有在满足特定条件时才执行 Hook 函数。 FdManager 我们会通过 FdContext 类（注意与 IOManager 中的 FdContext 进行区分）来保存 fd 的一些状态，例如 fd 是否关闭了，是否设置为非阻塞，其读写事件超时时间是多少等。\nsleep API 对 sleep，usleep，nanosleep 三个函数进行 hook 操作，其逻辑一致：sleep 类函数会阻塞当前线程，那么我们的改造方法就是用一个定时器来代替 sleep 的休眠阻塞，获取当前运行的协程，然后通过 IOManager 添加一个定时器，规定时间后再将这个协程加入调度，之后 yield 这个协程。\nsocket API socket：当使用 socket 创建套接字 fd 时，我们需要将它加入到 FdManager 中。 connect：对于原始的 connect，它是一个阻塞调用，直到连接成功或发生错误，如果网络延迟较高或目标主机不可达，可能会导致程序长时间挂起。我们需要将其改造为与异步或非阻塞操作结合。对应的实现方法就是通过设置一个超时时间，到时间后取消文件描述符的写事件。为 socket fd 添加写事件后，如果添加成功，则 yield 当前协程，并取消定时器 setsockopt：对于 optname 为 SO_RCVTIMEO 和 SO_RCVTIMEO 的情况，我们需要设置 sockfd 对应的超时时间。 socket IO API accept、read、readv、recv、recvfrom、recvmsg、write、writev、send、sendto、sendmsg 这些函数所要作的 hook 操作都很类似，不同的地方无非就是读写事件的不同，其处理逻辑和 connect 相似，所以利用了模板来减少冗余代码。\nother API 还有类似 close、ioctl、fcntl 的函数，由于我们在之前 hook api 时处理了文件描述符，因此在这些函数中我们需要对文件描述符进行清理或其他操作。\n","date":"2024-09-30T00:00:00Z","permalink":"/p/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-5%E5%B8%B8%E7%94%A8io%E5%87%BD%E6%95%B0%E7%9A%84hook%E5%8A%9F%E8%83%BD/","title":"【动手写协程库 5】常用IO函数的HOOK功能"},{"content":" 【动手写协程库】系列笔记是学习 sylar 的协程库时的记录，参考了 从零开始重写sylar C++高性能分布式服务器框架 和代码随想录中的 文档。文章并不是对所有代码的详细解释，而是为了自己理解一些片段所做的笔记。\nIOManager 类中具体定义实现可以在这里查看：Github: src/iomanager.cpp\n之前实现的协程调度器的功能其实非常简单，当添加任务后调度器只是单纯的从任务队列中取出任务交给协程去执行。sylar 的协程库的关注对象是网络 IO，如果采用这么简单的调度就根本没有用到协程的精髓。\nsylar 的 IO 协程调度解决了之前调度器在 idle 状态下忙等待导致 CPU 占用率高的问题。IO 协程调度器使用一对管道 fd 来 tickle 调度协程，当调度器空闲时，idle 协程通过 epoll_wait 阻塞在管道的读描述符上，等管道的可读事件。添加新任务时，tickle 方法写管道，idle 协程检测到管道可读后退出，调度器执行调度。\nIOManager API IOManager 的 API 如下：\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 #ifndef IOMANAGER_H_ #define IOMANAGER_H_ #include \u0026lt;cstddef\u0026gt; #include \u0026lt;sys/epoll.h\u0026gt; #include \u0026lt;atomic\u0026gt; #include \u0026lt;functional\u0026gt; #include \u0026lt;mutex\u0026gt; #include \u0026lt;shared_mutex\u0026gt; #include \u0026lt;vector\u0026gt; #include \u0026#34;coroutine.h\u0026#34; #include \u0026#34;scheduler.h\u0026#34; #include \u0026#34;timer.h\u0026#34; // 事件：无、读、写 enum Event { NONE = 0x0, READ = EPOLLIN, WRITE = EPOLLOUT, }; // IO协程调度 class IOManager : public Scheduler, public TimerManager { public: IOManager(size_t threads = 1, bool use_caller = true, const std::string\u0026amp; name = \u0026#34;IOManager\u0026#34;); ~IOManager(); bool AddEvent(int fd, Event event, std::function\u0026lt;void()\u0026gt; cb = nullptr); bool DelEvent(int fd, Event event); bool CancelEvent(int fd, Event event); bool CancelAllEvent(int fd); static IOManager* GetIOManager(); protected: void Idle() override; void Tickle() override; void OnTimerInsertAtFront() override; bool IsStop() override; void ResizeContexts(size_t size); private: // socket fd 上下文 struct FdContext { struct EventContext { Scheduler* scheduler = nullptr; Coroutine::Ptr coroutine; std::function\u0026lt;void()\u0026gt; callback; }; // 根据类型获取对应的上下文 EventContext\u0026amp; GetEventContext(Event\u0026amp; e); void ResetEventContext(EventContext\u0026amp; ectx); void TriggerEvent(Event e); EventContext read_ctx, write_ctx; int fd; Event events = Event::NONE; std::mutex mutex; }; private: int epfd_; int tickle_fd_[2]; std::atomic_size_t pending_evt_cnt_; std::mutex mutex_; std::shared_mutex rw_mutex_; // 利用fd作为下标来获取对应的FdContext*，也可以使用哈希表代替 std::vector\u0026lt;FdContext*\u0026gt; fd_contexts_; }; #endif /* IOMANAGER_H_ */ Scheduler::Run() 我们可以先回顾一下 Scheduler::Run() 这个函数：\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 void Scheduler::Run() { LOG \u0026lt;\u0026lt; \u0026#34;Scheduler running...\\n\u0026#34;; SetHookFlag(true); SetThisAsScheduler(); // 如果当前线程不是调度器所在线程，设置调度的协程为当前线程运行的协程 if (std::this_thread::get_id() != sched_id_) { sched_coroutine = Coroutine::GetNowCoroutine().get(); } Coroutine::Ptr idle_co = std::make_shared\u0026lt;Coroutine\u0026gt;([this] { this-\u0026gt;Idle(); }); Coroutine::Ptr callback_co; SchedulerTask task; while (true) { task.Reset(); bool tickle = false; { std::lock_guard lock(mutex_); auto iter = tasks_.begin(); while (iter != tasks_.end()) { // 当前遍历的task已经分配了线程去执行且这个线程不是当前线程，则不用管 if (iter-\u0026gt;thread_id_ \u0026amp;\u0026amp; *iter-\u0026gt;thread_id_ != std::this_thread::get_id()) { ++iter; tickle = true; continue; } if (iter-\u0026gt;coroutine_ \u0026amp;\u0026amp; iter-\u0026gt;coroutine_-\u0026gt;GetState() != Coroutine::READY) { LOG \u0026lt;\u0026lt; \u0026#34;Coroutine task\u0026#39;s state should be READY!\\n\u0026#34;; assert(false); } task = *iter; tasks_.erase(iter++); active_threads_++; break; } // 有任务可以去执行，需要tickle一下 tickle |= (iter != tasks_.end()); } if (tickle) { Tickle(); } // 子协程执行完毕后yield会回到Run()中 // 注意，每次运行了一个task后需要Reset一下 if (task.coroutine_) { // 任务类型为协程 task.coroutine_-\u0026gt;Resume(); active_threads_--; task.Reset(); } else if (task.callback_) { // 任务类型为回调函数，将其包装为协程 if (callback_co) { callback_co-\u0026gt;Reset(task.callback_); } else { callback_co = std::make_shared\u0026lt;Coroutine\u0026gt;(task.callback_); } callback_co-\u0026gt;Resume(); active_threads_--; callback_co.reset(); task.Reset(); } else { // 无任务，任务队列为空 if (idle_co-\u0026gt;GetState() == Coroutine::FINISH) { LOG \u0026lt;\u0026lt; \u0026#34;Idle coroutine finish\\n\u0026#34;; break; } idle_threads_++; idle_co-\u0026gt;Resume(); // Idle最后Yeild时回到这里 idle_threads_--; } } LOG \u0026lt;\u0026lt; \u0026#34;Scheduler Run() exit\\n\u0026#34;; } 这个函数就是每个线程会启动的协程调度函数，负责管理和执行任务队列中的任务，包括协程和回调函数两种类型的任务，如果任务队列为空，则执行 Idle 协程。\n在 Scheduler::Idle() 函数中，仅仅只是做了一个简单的处理：调度器没有停止就让出当前正在执行的协程，我们要做的增强后的 IOManager 需要重写 Idle 函数，让它不断等待事件、处理事件、然后再次等待事件的循环过程，它在没有其他协程运行时保持系统的活跃度，并在有事件发生时进行相应的处理。\n重写 Idle 函数 在 IOManager 中，我们就需要重写 Idle 函数，我们需要它是一个不断等待事件、处理事件、然后再次等待事件的循环过程，它在没有其他协程运行时保持系统的活跃度，并在有事件发生时进行相应的处理：\n我们会先找到最近一个定时器的超时时间，并将其与自定义最长超时时间（源码中是 5s）进行比较取最小者作为 epoll_wait 的超时时间 将超时的定时器的回调函数加入调度器 处理 epoll_wait 送来的事件 将当前线程运行的协程暂停（也就是暂停 Idle 协程），并将执行权交给调度协程（Scheduler::Run()） 从 1.又开始重复执行 具体操作可看代码：\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 void IOManager::Idle() { LOG \u0026lt;\u0026lt; \u0026#34;idle coroutine start up\\n\u0026#34;; const int MAX_EVENTS = 256; const int MAX_TIMEOUT = 5000; epoll_event events[MAX_EVENTS]{}; while (true) { // LOG \u0026lt;\u0026lt; \u0026#34;in idle now\\n\u0026#34;; if (IsStop()) { LOG \u0026lt;\u0026lt; GetName() \u0026lt;\u0026lt; \u0026#34;idle stop now\\n\u0026#34;; break; } uint64_t next_timeout = GetNextTimerInterval(); int triggered_events; do { // 如果时间堆中有超时的定时器，则比较这个超时定时器的下一次触发的时间与MAX_TIMEOUT（5s），选取最小值作为超时时间 next_timeout = next_timeout != ~0ull ? std::min(static_cast\u0026lt;int\u0026gt;(next_timeout), MAX_TIMEOUT) : MAX_TIMEOUT; // 没有事件到来时会阻塞在epoll_wait上，除非到了超时时间 triggered_events = epoll_wait(epfd_, events, MAX_EVENTS, static_cast\u0026lt;int\u0026gt;(next_timeout)); if (triggered_events \u0026lt; 0 \u0026amp;\u0026amp; errno == EINTR) { continue; } else { break; } } while (true); // 用while(true)的目的是确保在出现特定错误情况时能够重新尝试执行 epoll_wait // 将超时的定时器的回调函数加入调度器 // 这些回调函数的作用可能是关闭连接等操作 std::vector\u0026lt;std::function\u0026lt;void()\u0026gt;\u0026gt; cbs = GetExpiredCbList(); for (auto\u0026amp; cb : cbs) { Sched(cb); } // 处理事件 for (int i = 0; i \u0026lt; triggered_events; i++) { epoll_event\u0026amp; event = events[i]; // 是一个用于通知协程调度的事件 // epoll中监听了用于通知的管道读端fd，当有数据到时即会触发 if (event.data.fd == tickle_fd_[0]) { char buf[256]{}; // 将管道内的数据读完 while (read(tickle_fd_[0], buf, sizeof(buf)) \u0026gt; 0) ; continue; } // FdContext* fd_ctx = (FdContext*) event.data.ptr; FdContext* fd_ctx = static_cast\u0026lt;FdContext*\u0026gt;(event.data.ptr); std::lock_guard lock(fd_ctx-\u0026gt;mutex); // 发生错误时，如果原来的文件描述符上下文（fd_ctx）中有可读或可写事件标志被设置，那么现在将重新触发这些事件 if (event.events \u0026amp; (EPOLLERR | EPOLLHUP)) { event.events |= (EPOLLIN | EPOLLOUT) \u0026amp; fd_ctx-\u0026gt;events; } // 获取fd_ctx对应的事件 int real_event = Event::NONE; if (event.events \u0026amp; EPOLLIN) { real_event |= Event::READ; } if (event.events \u0026amp; EPOLLOUT) { real_event |= Event::WRITE; } if ((fd_ctx-\u0026gt;events \u0026amp; real_event) == Event::NONE) { continue; } // 如果还有剩余事件，则修改；否则将其从epoll中删除 // 注意获取rest_events时不是使用的event.events \u0026amp; ~real_event，因为是要去除fd_ctx-\u0026gt;fd中本次触发的事件 int rest_events = fd_ctx-\u0026gt;events \u0026amp; ~real_event; int op = rest_events ? EPOLL_CTL_MOD : EPOLL_CTL_DEL; event.events = EPOLLET | rest_events; if (epoll_ctl(epfd_, op, fd_ctx-\u0026gt;fd, \u0026amp;event) \u0026lt; 0) { LOG_ERROR \u0026lt;\u0026lt; strerror(errno) \u0026lt;\u0026lt; \u0026#34;\\n\u0026#34;; continue; } if (real_event \u0026amp; Event::READ) { fd_ctx-\u0026gt;TriggerEvent(Event::READ); --pending_evt_cnt_; } if (real_event \u0026amp; Event::WRITE) { fd_ctx-\u0026gt;TriggerEvent(Event::WRITE); --pending_evt_cnt_; } } // 将当前线程运行的协程暂停（也就是暂停Idle协程），并将执行权交给调度协程 Coroutine::Ptr co_ptr = Coroutine::GetNowCoroutine(); auto co = co_ptr.get(); co_ptr.reset(); co-\u0026gt;Yield(); } } 添加事件 IOManager 除了重写 Idle 函数这个重要点外，还有个重要点就是为指定文件描述符添加事件。\nIOManager 内部有一个 [FdContext](#IOManger API) 结构体用来封装 socket fd 的上下文（需要绑定的回调函数，对应的事件、协程），并使用一个 vector 保存这些 FdContext。\n我们在添加 fd 的事件时，需要将其加入 vector 中，并且需要通过 epoll_ctl 注册 fd 的对应事件。\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 bool IOManager::AddEvent(int fd, Event event, std::function\u0026lt;void()\u0026gt; cb) { FdContext* fd_ctx = nullptr; { std::shared_lock rw_lock(rw_mutex_); if (fd_contexts_.size() \u0026gt; fd) { fd_ctx = fd_contexts_[fd]; rw_lock.unlock(); } else { rw_lock.unlock(); std::unique_lock rw_lock2(rw_mutex_); ResizeContexts(fd * 1.5); fd_ctx = fd_contexts_[fd]; } } std::lock_guard lock(mutex_); if (fd_ctx-\u0026gt;events \u0026amp; event) { LOG_ERROR \u0026lt;\u0026lt; \u0026#34;A fd can\u0026#39;t add same event\\n\u0026#34;; return false; } int op = fd_ctx-\u0026gt;events ? EPOLL_CTL_MOD : EPOLL_CTL_ADD; epoll_event ep_evt{}; ep_evt.events = static_cast\u0026lt;int\u0026gt;(fd_ctx-\u0026gt;events) | EPOLLET | event; ep_evt.data.ptr = fd_ctx; // 在Idle()中将使用fd对应的这个ep_evt int ret = epoll_ctl(epfd_, op, fd, \u0026amp;ep_evt); if (ret) { LOG_ERROR \u0026lt;\u0026lt; \u0026#34;epoll_ctl \u0026#34; \u0026lt;\u0026lt; strerror(errno); return false; } ++pending_evt_cnt_; // 设置fd对应事件的EventContext fd_ctx-\u0026gt;events = static_cast\u0026lt;Event\u0026gt;(fd_ctx-\u0026gt;events | event); // 使用event_ctx相当于使用fd_ctx-\u0026gt;read_ctx or fd_ctx-\u0026gt;write_ctx（注意是auto\u0026amp;而不是auto） auto\u0026amp; event_ctx = fd_ctx-\u0026gt;GetEventContext(event); assert(!event_ctx.scheduler \u0026amp;\u0026amp; !event_ctx.callback \u0026amp;\u0026amp; !event_ctx.coroutine); event_ctx.scheduler = Scheduler::GetScheduler(); if (cb) { event_ctx.callback = cb; } else { // 设置fd相关事件触发时使用的协程为当前 event_ctx.coroutine = Coroutine::GetNowCoroutine(); assert(event_ctx.coroutine-\u0026gt;GetState() == Coroutine::RUNNING); } return true; } ","date":"2024-09-29T00:00:00Z","permalink":"/p/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-4io%E5%8D%8F%E7%A8%8B%E8%B0%83%E5%BA%A6%E5%99%A8/","title":"【动手写协程库 4】IO协程调度器"},{"content":" 【动手写协程库】系列笔记是学习 sylar 的协程库时的记录，参考了 从零开始重写sylar C++高性能分布式服务器框架 和代码随想录中的 文档。文章并不是对所有代码的详细解释，而是为了自己理解一些片段所做的笔记。\nTimerManager 类中具体定义实现可以在这里查看：Github: src/timer.cpp\n通过定时器，我们可以实现给服务器注册定时事件。sylar 的定时器采用最小堆设计，所有定时器根据绝对的超时时间点（也就是超时到期的具体时间戳）进行排序，每次取出离当前时间最近的一个超时时间点，计算出超时需要等待的时间，然后等待超时。超时时间到后，获取当前的绝对时间点，然后把最小堆里超时时间点小于这个时间点的定时器都收集起来，执行它们的回调函数。\n定时器相关 API 如下：\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 class Timer : public std::enable_shared_from_this\u0026lt;Timer\u0026gt; { friend class TimerManager; public: using Ptr = std::shared_ptr\u0026lt;Timer\u0026gt;; bool Cancel(); bool Refresh(); bool Reset(uint64_t ms, bool from_now); private: Timer(uint64_t ms, std::function\u0026lt;void()\u0026gt; cb, bool recur, TimerManager* manager); Timer(uint64_t next); private: bool is_recur_; // 是否循环定时器 uint64_t exec_cycle_; // 执行周期 uint64_t next_; // 下一次的到期时间 std::function\u0026lt;void()\u0026gt; callback_; TimerManager* manager_; struct Comp { bool operator()(const Timer::Ptr\u0026amp; lt, const Timer::Ptr\u0026amp; rt) const { if (!lt || !rt) { return !lt \u0026amp;\u0026amp; rt; } return lt-\u0026gt;next_ \u0026lt; rt-\u0026gt;next_; } }; }; class TimerManager { friend class Timer; public: TimerManager(); virtual ~TimerManager(); // public 添加定时器 Timer::Ptr AddTimer(uint64_t ms, std::function\u0026lt;void()\u0026gt; cb, bool is_recur = false); // 添加条件定时器，如果条件成立则定时器才有效 Timer::Ptr AddConditionTimer(uint64_t ms, std::function\u0026lt;void()\u0026gt; cb, std::weak_ptr\u0026lt;void\u0026gt; cond, bool is_recur = false); // 获取下一个定时器到现在的执行间隔时间 // 如果没有定时器了，就返回uint64_t的最大值 uint64_t GetNextTimerInterval(); // 获取需要执行的定时器的回调函数列表 std::vector\u0026lt;std::function\u0026lt;void()\u0026gt;\u0026gt; GetExpiredCbList(); // 是否还有定时器 bool HasTimer(); protected: virtual void OnTimerInsertAtFront() = 0; void AddTimer(Timer::Ptr timer, std::shared_lock\u0026lt;std::shared_mutex\u0026gt;\u0026amp; lock); private: // 系统时钟是否出现了回绕（rollover）现象，即当前时间比之前记录的时间要小很多 // 用于检测服务器时间是否被调后了 bool DetectClockRollover(uint64_t now_ms); private: std::shared_mutex rw_mutex_; std::set\u0026lt;Timer::Ptr, Timer::Comp\u0026gt; timer_heap_; bool is_tickled_; uint64_t pre_exec_time_; }; 个人感觉最重要的 API 是 AddTimer、GetNextTimerInterval 和 GetExpiredCbList。\nAddTimer 向时间堆中添加超时超时时间到了后的回调函数（利用 Timer 类来封装）。 GetNextTimerInterval 用于获取下一个定时器到现在的执行间隔时间，这会用于 IOManager::Idle() 中用于与规定的最大超时时间进行比较，用较小者作为 epoll_wait 的超时时间参数。 GetExpiredCbList 获取的是所有超时定时器的回调函数。在 IOManager::Idle() 会调用这个函数将所有超时定时器的回调函数作为调度任务加入任务队列进行处理。 在定时器中，使用 GetElapsedMS() 来获取系统自启动来经过的时间，其内部使用 clock_gettime 来获取时间，这相比于一些传统时间获取函数（如 time 或 gettimeofday）有更高的精度：\n1 2 3 4 5 6 // 获取系统自启动来经过的时间 static uint64_t GetElapsedMS() { struct timespec ts = {0}; clock_gettime(CLOCK_MONOTONIC_RAW, \u0026amp;ts); return ts.tv_sec * 1000 + ts.tv_nsec / 1000000; } ","date":"2024-09-28T00:00:00Z","permalink":"/p/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-3%E5%AE%9A%E6%97%B6%E5%99%A8/","title":"【动手写协程库 3】定时器"},{"content":"最近的练手项目web-terminal中（也就是一个网页终端，可执行一些命令），在按下键盘后会显示可能匹配的命令列表（假设对应的函数是setHintList），这不仅是按下字母按键会触发，按下删除键、tab键都会触发。那就不得不考虑一个问题，如果我们手速太快，那么setHintList就会频繁触发，但我们只需要响应用户最后一次输入的命令即可，虽然在这个小项目中没啥问题，但是由此可以引出一些对于以后大项目的考虑：如何减小这种多次频繁执行函数带来的性能开销问题？那就是函数防抖~\n函数防抖是一种优化技术，用来限制某个函数在一定时间内被调用的频率。当事件被触发后，它会等待一段时间，如果在这段时间内再次被触发，那么它会重新开始等待。\n下面是一种实现方式：\n1 2 3 4 5 6 7 8 9 10 11 12 export function buildDebounce(fn: (...arg: any[]) =\u0026gt; any, duration: number = 300) { let timer = -1; return function (this: unknown, ...args: any[]) { if (timer \u0026gt; -1) { clearTimeout(timer); } timer = window.setTimeout(() =\u0026gt; { fn.bind(this)(...args); timer = -1; }, duration); }; } buildDebounce接受一个函数fn和一个可选的时间间隔duration（默认为 300 毫秒）作为参数，并返回一个新的函数。\n内部使用了一个变量timer来跟踪定时器的引用。初始值为 -1，表示没有定时器在运行。 返回的函数在被调用时，首先检查timer是否大于 -1。如果是，说明之前已经有一个定时器在运行，此时会调用clearTimeout清除这个定时器，以取消之前可能正在等待执行的函数调用。 然后，使用window.setTimeout创建一个新的定时器，在duration毫秒后执行传入的函数fn，并通过bind方法确保函数在正确的上下文中执行。 当定时器执行完函数后，将timer重置为 -1，表示没有定时器在运行。 在(this: unknown, ...args: any[])中，this的值是在调用由buildDebounce返回的函数时，根据调用的上下文确定的。也就是当这个返回的函数被调用时，通过保留传入的 this 值，可以确保在最终执行被包裹的函数 fn 时，fn 能够在正确的上下文中执行。\nbuildDebounce的使用如下：\n1 2 3 4 5 6 7 8 9 function printMessage(message) { console.log(message); } const debouncedPrint = buildDebounce(printMessage); debouncedPrint(\u0026#39;Hello\u0026#39;); debouncedPrint(\u0026#39;World\u0026#39;); // 如果在 300 毫秒内连续调用 debouncedPrint，只有最后一次调用会在 300 毫秒后执行打印操作。 ","date":"2024-09-22T00:00:00Z","permalink":"/p/%E5%87%BD%E6%95%B0%E9%98%B2%E6%8A%96/","title":"函数防抖"},{"content":"C++ 中，std::enable_shared_from_this类模板和shared_from_this成员函数主要用于在一个类的成员函数中安全地获取指向自身的std::shared_ptr。它们的作用更多是为了确保资源正确管理。\n当一个对象被多个std::shared_ptr管理时，如果在对象内部的成员函数中直接创建新的std::shared_ptr指向自身（也就是this），可能会导致多个独立的引用计数，从而无法正确管理对象的生命周期。而使用shared_from_this可以确保所有指向该对象的std::shared_ptr共享同一个引用计数，从而正确管理对象的生命周期。\n例如：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 #include \u0026lt;iostream\u0026gt; #include \u0026lt;memory\u0026gt; class MyClass : public std::enable_shared_from_this\u0026lt;MyClass\u0026gt; { public: void print() { std::shared_ptr\u0026lt;MyClass\u0026gt; ptr = shared_from_this(); std::cout \u0026lt;\u0026lt; \u0026#34;Use count: \u0026#34; \u0026lt;\u0026lt; ptr.use_count() \u0026lt;\u0026lt; std::endl; } }; int main() { std::shared_ptr\u0026lt;MyClass\u0026gt; obj = std::make_shared\u0026lt;MyClass\u0026gt;(); obj-\u0026gt;print(); return 0; } print函数中使用shared_from_this获取指向自身的std::shared_ptr，确保了与外部创建的std::shared_ptr共享同一个引用计数。\n如果在对象内部的成员函数中直接返回一个指向自身的普通指针，当外部的std::shared_ptr被销毁后，这个普通指针就会变成悬空指针。而使用shared_from_this可以避免这种情况，因为它返回的std::shared_ptr会在引用计数为零时自动释放对象所占用的内存。例如这里不使用enable_shared_from_this和shared_from_this，则对象会多次释放，导致程序出错。\n代码及其执行结果可参见：这里\n","date":"2024-09-16T00:00:00Z","permalink":"/p/enable_shared_from_this%E7%9A%84%E4%BD%9C%E7%94%A8/","title":"enable_shared_from_this的作用"},{"content":" 【动手写协程库】系列笔记是学习 sylar 的协程库时的记录，参考了 从零开始重写sylar C++高性能分布式服务器框架 和代码随想录中的 文档。文章并不是对所有代码的详细解释，而是为了自己理解一些片段所做的笔记。\nScheduler 类中其他函数的定义可以在这里查看：Github: src/scheduler.cpp\nSylar 的协程调度器是一个 N-M 模型，意味着 N 个线程可以运行 M 个协程，协程能够在线程之间进行切换，也可以被绑定到特定的线程上执行。\n调度器可以由应用程序中的任何线程创建，但创建它的线程（称为 caller 线程）可以选择是否参与协程的调度。如果 caller 线程参与调度，那么调度器的线程数会相应减少一个，因为 caller 线程本身也会作为一个调度线程。\nScheduler 相关 API 如下：\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 #ifndef SCHEDULER_H_ #define SCHEDULER_H_ #include \u0026lt;atomic\u0026gt; #include \u0026lt;cstddef\u0026gt; #include \u0026lt;functional\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;list\u0026gt; #include \u0026lt;memory\u0026gt; #include \u0026lt;mutex\u0026gt; #include \u0026lt;thread\u0026gt; #include \u0026lt;utility\u0026gt; #include \u0026lt;vector\u0026gt; #include \u0026#34;coroutine.h\u0026#34; #include \u0026#34;util.h\u0026#34; // 协程调度器 class Scheduler { private: // 调度任务，任务类型可以是协程/函数二选一，并且可指定调度线程 using ThreadIdPtr = std::shared_ptr\u0026lt;std::thread::id\u0026gt;; struct SchedulerTask { ThreadIdPtr thread_id_; Coroutine::Ptr coroutine_; std::function\u0026lt;void()\u0026gt; callback_; SchedulerTask() {} SchedulerTask(Coroutine::Ptr co, ThreadIdPtr id) : coroutine_(co) , thread_id_(std::move(id)) {} SchedulerTask(std::function\u0026lt;void()\u0026gt; callback, ThreadIdPtr id) : callback_(callback) , thread_id_(std::move(id)) {} void Reset() { thread_id_.reset(); callback_ = nullptr; coroutine_ = nullptr; } }; public: Scheduler(size_t threads = 1, bool use_caller = true, const std::string\u0026amp; name = \u0026#34;scheduler\u0026#34;); virtual ~Scheduler(); std::string GetName() const { return name_; } void Start(); void Stop(); template \u0026lt;typename TaskType\u0026gt; void Sched(TaskType t, ThreadIdPtr id = nullptr) requires(std::invocable\u0026lt;TaskType\u0026gt; || std::same_as\u0026lt;TaskType, Coroutine::Ptr\u0026gt;) { bool is_need_tick = false; { std::lock_guard lock(mutex_); is_need_tick = tasks_.empty(); SchedulerTask task(t, id); if (task.callback_ || task.coroutine_) { tasks_.push_back(task); } } if (is_need_tick) { Tickle(); } } public: static Scheduler* GetScheduler(); static Coroutine* GetSchedCoroutine(); protected: virtual void Tickle(); void Run(); void SetThisAsScheduler(); virtual void Idle(); virtual bool IsStop(); bool HasIdleThreads() { return idle_threads_ \u0026gt; 0; } private: std::string name_; std::mutex mutex_; std::vector\u0026lt;std::thread\u0026gt; thread_pool_; std::vector\u0026lt;std::thread::id\u0026gt; thread_ids_; std::list\u0026lt;SchedulerTask\u0026gt; tasks_; size_t threads_size_; std::atomic_size_t active_threads_{0}; std::atomic_size_t idle_threads_{0}; std::thread::id sched_id_; // use_caller为true时，调度器所在的线程id Coroutine::Ptr sched_co_; // use_caller为true时调度器所在线程的调度协程 bool is_stop_; bool is_use_caller_; }; #endif /* SCHEDULER_H_ */ 调度器的工作流大致为：\n协程调度器在初始化时可传入线程数和一个布尔型的 use_caller 参数，表示是否使用 caller 线程。在使用 caller 线程的情况下，线程数自动减一，并且调度器内部会初始化一个属于 caller 线程的调度协程并保存起来（比如，在 main 函数中创建的调度器，如果 use_caller 为 true，那调度器会初始化一个属于 main 函数线程的调度协程）。 调度器创建好后 ，即可调用调度器的 Sched 函数向调度器添加调度任务，但此时调度器并不会立刻执行这些任务，而是将它们保存到内部的一个任务队列中。 调用 Scheduler::Start() 函数启动调度 。调用 Start 会创建调度线程池，线程数量由初始化时的线程数和 use_caller 确定。调度线程一旦创建，就会立刻从任务队列里取任务执行。比较特殊的一点是，如果初始化时指定线程数为 1 且 use_caller 为 true，那么 Start 方法什么也不做，因为不需要创建新线程用于调度。并且，由于没有创建新的调度线程，那只能由 caller 线程的调度协程来负责调度协程（这里有点绕），而 caller 线程的调度协程的执行时机与 Start 函数并不在同一个地方。caller 线程的调度协程的执行时机在 Stop 函数中。 接下来是调度协程，对应 Scheduler::Run() 。调度协程负责从调度器的任务队列中取任务执行。取出的任务即子协程，每个子协程执行完后都必须返回调度协程，由调度协程重新从任务队列中取新的协程并执行。如果任务队列空了，那么调度协程会切换到一个 idle 协程，等有新任务进来时，idle 协程才会退出并回到调度协程，重新开始下一轮调度。（在 Scheduler 中，idle 函数的定义十分简单粗暴，因为实际使用协程库时并不是直接使用 Scheduler 类，而是使用它的派生类，在派生类中将会实现更为完善的调度） 接下来是添加调度任务，对应 Scheduler::Sched() ，这个方法支持传入协程或函数，并且支持一个线程 id 参数，表示是否将这个协程或函数绑定到一个具体的线程上执行。如果任务队列为空，那么在添加任务之后，要调用一次 tickle 方法以通知各调度线程的调度协程有新任务来了。在执行调度任务时，还可以通过调度器的 GetScheduler() 获取到当前调度器，再通过 Sched 函数继续添加新的任务，这就变相实现了在子协程中创建并运行新的子协程的功能。 接下来是调度器的停止。调度器的停止行为要分两种情况讨论，首先是 use_caller 为 false 的情况，这种情况下，由于没有使用 caller 线程进行调度，那么只需要简单地等各个调度线程的调度协程退出就行了。如果 use_caller 为 true，表示 caller 线程也要参于调度，这时，调度器初始化时记录的属于 caller 线程的调度协程就要起作用了，在调度器停止前，应该让这个 caller 线程的调度协程也运行一次，让 caller 线程完成调度工作后再退出。如果调度器只使用了 caller 线程进行调度，那么所有的调度任务要在调度器停止时才会被调度。 调度器中最重要的一个函数我认为就是 Run() 函数了，这个函数用于协程的调度，或者，你可以将他理解为是一个调度协程（名词）。\n创建 Scheduler 时会为每一个内部线程池中的每一个线程都绑定一个调度协程，线程数量默认为 1，此时也默认会使用 caller 线程，也就是使用的主线程。调度协程 Scheduler::Run() 会从任务队列 Task Queue 中不断去取任务去执行。如果有任务可执行，那就切换至任务协程执行，任务协程执行完毕后又切换回调度协程；无任务执行时，调度协程切换至 Idle 协程进行等待。\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 // 用于协程的调度 void Scheduler::Run() { LOG \u0026lt;\u0026lt; \u0026#34;Scheduler running...\\n\u0026#34;; SetThisAsScheduler(); // 如果当前线程不是调度器所在线程，设置调度的协程为当前线程运行的协程 if (std::this_thread::get_id() != sched_id_) { sched_coroutine = Coroutine::GetNowCoroutine().get(); } Coroutine::Ptr idle_co = std::make_shared\u0026lt;Coroutine\u0026gt;([this] { this-\u0026gt;Idle(); }); Coroutine::Ptr callback_co; SchedulerTask task; while (true) { task.Reset(); bool tickle = false; { std::lock_guard lock(mutex_); auto iter = tasks_.begin(); while (iter != tasks_.end()) { // 当前遍历的task已经分配了线程去执行且这个线程不是当前线程，则不用管 if (iter-\u0026gt;thread_id_ \u0026amp;\u0026amp; *iter-\u0026gt;thread_id_ != std::this_thread::get_id()) { ++iter; tickle = true; continue; } if (iter-\u0026gt;coroutine_ \u0026amp;\u0026amp; iter-\u0026gt;coroutine_-\u0026gt;GetState() != Coroutine::READY) { LOG \u0026lt;\u0026lt; \u0026#34;Coroutine task\u0026#39;s state should be READY!\\n\u0026#34;; assert(false); } task = *iter; tasks_.erase(iter++); active_threads_++; break; } // 有任务可以去执行，需要tickle一下 tickle |= (iter != tasks_.end()); } if (tickle) { Tickle(); } // 子协程执行完毕后yield会回到Run()中 if (task.coroutine_) { // 任务类型为协程 task.coroutine_-\u0026gt;Resume(); active_threads_--; } else if (task.callback_) { // 任务类型为回调函数 if (callback_co) { callback_co-\u0026gt;Reset(task.callback_); } else { callback_co = std::make_shared\u0026lt;Coroutine\u0026gt;(task.callback_); } callback_co-\u0026gt;Resume(); active_threads_--; } else { // 无任务，任务队列为空 if (idle_co-\u0026gt;GetState() == Coroutine::FINISH) { LOG \u0026lt;\u0026lt; \u0026#34;Idle coroutine finish\\n\u0026#34;; break; } idle_threads_++; idle_co-\u0026gt;Resume(); // Idle最后Yeild时回到这里 idle_threads_--; } } LOG \u0026lt;\u0026lt; \u0026#34;Scheduler Run() exit\\n\u0026#34;; } 这个 Scheduler 是一个很简单的调度器，要对任务做更好的调度，少不了 Idle 协程的帮助。Idle 协程的具体实现要在之后的 IOManager 中，其继承自 Scheduler，重写了 Tickle()、Idle() 等函数，并且使用 epoll 来实现在不同的 I/O 事件发生时，触发相应的处理逻辑。这使得程序可以以非阻塞的方式处理多个 I/O 操作，而不必等待每个操作完成后再进行下一个操作。\n","date":"2024-09-11T00:00:00Z","permalink":"/p/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-2%E5%8D%8F%E7%A8%8B%E8%B0%83%E5%BA%A6%E5%99%A8/","title":"【动手写协程库 2】协程调度器"},{"content":" 【动手写协程库】系列笔记是学习 sylar 的协程库时的记录，参考了 从零开始重写sylar C++高性能分布式服务器框架 和代码随想录中的 文档。文章并不是对所有代码的详细解释，而是为了自己理解一些片段所做的笔记。\nCoroutine 类中其他函数的定义可以在这里查看：Github: src/coroutine.cpp\n对于什么是协程，为什么要使用协程，可以看看之前的笔记：【协程】C++20协程初体验。\n对于我们自己来实现协程，其实在之前 Xv6 的 Lab 中就有做过：【MIT6.S081】Lab6 multithreading，当初做这个 lab 的时候没有意识到这就是协程。协程的切换最重要的就是要保存和恢复上下文，在这个 lab 中，我们通过保存每个协程在切换之前的寄存器的值，以此可用来恢复原来的执行流。\n在 sylar 的协程库实现中，使用的是 Linux 原生提供的 ucontext 来保存协程的上下文和切换。对于协程切换，最重要的两个 API 就是 yield 和 resume，分别对应协程让出执行权和恢复协程。利用 ucontext 提供的四个函数 getcontext()、setcontext()、makecontext()、swapcontext() 可以在一个进程中实现协程切换。（这里就不介绍这几个函数的用法来，详细文档可使用 man 命令查看）\nCoroutine 的相关 API 如下：\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 52 53 54 55 56 57 58 59 60 61 62 63 #ifndef COROUTINE_H_ #define COROUTINE_H_ #include \u0026lt;sys/ucontext.h\u0026gt; #include \u0026lt;ucontext.h\u0026gt; #include \u0026lt;cstdint\u0026gt; #include \u0026lt;functional\u0026gt; #include \u0026lt;memory\u0026gt; class Coroutine : public std::enable_shared_from_this\u0026lt;Coroutine\u0026gt; { public: using Ptr = std::shared_ptr\u0026lt;Coroutine\u0026gt;; enum State { READY, RUNNING, FINISH }; Coroutine(std::function\u0026lt;void()\u0026gt; callback, size_t stack_size = 0, bool run_in_scheduler = true); ~Coroutine(); void Yield(); void Resume(); void Reset(std::function\u0026lt;void()\u0026gt; callback); uint64_t GetId() const { return id_; } State GetState() const { return state_; } public: static void SetNowCoroutine(Coroutine* co); static Coroutine::Ptr GetNowCoroutine(); static uint64_t TotalCoNums(); static void Task(); static uint64_t GetCurrentId(); private: Coroutine(); private: uint64_t id_ = 0; uint32_t stack_size_ = 0; State state_ = READY; bool is_run_in_sched_; ucontext_t ctx_; // 协程上下文 void* pstack_ = nullptr; // 协程的栈地址 std::function\u0026lt;void()\u0026gt; callback_; }; #endif /* COROUTINE_H_ */ 一个线程可以运行多个协程，但是在某一个时刻只能运行一个协程。我们需要为每个线程设置当前运行的协程的指针和表示线程的主协程的指针。这里用到了 C++ 中的 thread_local 关键字：\n1 2 static thread_local Coroutine* cur_coroutine = nullptr; static thread_local Coroutine::Ptr main_coroutine = nullptr; 每当我们需要创建一个协程来执行任务时，我们必须要传入的参数为要执行的函数，而协程的栈大小有默认值，默认使用调度器进行调度：\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 Coroutine::Coroutine(std::function\u0026lt;void()\u0026gt; callback, size_t stack_size, bool run_in_scheduler) : callback_(callback) , is_run_in_sched_(run_in_scheduler) { co_count++; stack_size_ = stack_size \u0026gt; 0 ? stack_size : CO_STACK_SIZE; pstack_ = malloc(stack_size_); // getcontext()用于保存当前上下文，以便将来可以从这个点恢复执行 if (getcontext(\u0026amp;ctx_) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Coroutine::getcontext\\n\u0026#34;; exit(1); } // 初始化协程上下文 ctx_.uc_link = nullptr; ctx_.uc_stack.ss_sp = pstack_; ctx_.uc_stack.ss_size = stack_size_; // 为已经初始化的上下文设置一个将在该上下文被激活时执行的函数，并为该函数传递参数。 // 为什么这里的第二个参数不直接设置成callback呢？是因为我们自己写协程的话不仅仅只要将任务函数执行完成就行了，执行完成后还要设置协程的状态 makecontext(\u0026amp;ctx_, \u0026amp;Coroutine::Task, 0); } // 每个协程会运行它所绑定的callback，并且在执行完成后将重置该协程的状态，并让出执行权 void Coroutine::Task() { auto cur = GetNowCoroutine(); assert(cur); cur-\u0026gt;callback_(); cur-\u0026gt;callback_ = nullptr; cur-\u0026gt;state_ = FINISH; auto raw_ptr = cur.get(); cur.reset(); raw_ptr-\u0026gt;Yield(); } Yield 和 Resume 函数利用 swapcontext 来进行协程的切换。由于我们在之后会需要将协程添加到调度器中而不是手动调度，所以要注意协程有使用调度器标志时要与调度器协程进行切换：\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 // 让出该协程的执行权，转交到主协程 void Coroutine::Yield() { assert(state_ == FINISH || state_ == RUNNING); SetNowCoroutine(main_coroutine.get()); if (state_ != FINISH) { state_ = READY; } if (is_run_in_sched_) { if (swapcontext(\u0026amp;ctx_, \u0026amp;(Scheduler::GetSchedCoroutine()-\u0026gt;ctx_)) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Yield::swapcontext\\n\u0026#34;; assert(false); } } else { if (swapcontext(\u0026amp;ctx_, \u0026amp;(main_coroutine-\u0026gt;ctx_)) \u0026lt; 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Yield::swapcontext\\n\u0026#34;; exit(1); } } } // 从当前运行的协程恢复到该协程 void Coroutine::Resume() { assert(state_ != FINISH \u0026amp;\u0026amp; state_ != RUNNING); // 每次恢复时需要将当前运行的协程设置为自身 SetNowCoroutine(this); state_ = RUNNING; if (is_run_in_sched_) { if (swapcontext(\u0026amp;(Scheduler::GetSchedCoroutine()-\u0026gt;ctx_), \u0026amp;ctx_) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Resume::swapcontext\\n\u0026#34;; assert(false); } } else { if (swapcontext(\u0026amp;(main_coroutine-\u0026gt;ctx_), \u0026amp;ctx_) != 0) { std::cout \u0026lt;\u0026lt; \u0026#34;err: Resume::swapcontext\\n\u0026#34;; exit(1); } } } ","date":"2024-09-10T00:00:00Z","permalink":"/p/%E5%8A%A8%E6%89%8B%E5%86%99%E5%8D%8F%E7%A8%8B%E5%BA%93-1%E5%8D%8F%E7%A8%8B%E5%AE%9A%E4%B9%89/","title":"【动手写协程库 1】协程定义"},{"content":" Debian 系 Linux 安装 N 卡驱动参考这篇 博客\n最近 Debian 终于是装好 nvidia 的驱动了，但是只能用在 X11 上，并且相比于不用 n 卡驱动时的 Wayland，X11 下的窗口拖动和视频播放会有比较明显的撕裂感，以下是解决的一个方法。\n修改 /etc/X11/xorg.conf 文件，在屏幕相关的区域内加上：\n1 2 Option \u0026#34;TripleBuffer\u0026#34; \u0026#34;true\u0026#34; Option \u0026#34;ForceFullCompositionPipeline\u0026#34; \u0026#34;on\u0026#34; Option \u0026quot;TripleBuffer\u0026quot; \u0026quot;true\u0026quot; 启用三重缓冲（Triple Buffering）来减少屏幕撕裂，它通过增加一个额外的缓冲区来同步视频输出和显示器的刷新率。 Option \u0026quot;ForceFullCompositionPipeline\u0026quot; \u0026quot;on\u0026quot; 用于强制使用完整的合成管线（Full Composition Pipeline）。启用这个选项可以提高性能，在某些游戏和应用程序中，它可以帮助减少延迟和提高帧率。 ","date":"2024-09-03T00:00:00Z","permalink":"/p/%E8%A7%A3%E5%86%B3x11%E4%B8%8B%E7%AA%97%E5%8F%A3%E6%8B%96%E5%8A%A8%E6%92%95%E8%A3%82%E7%9A%84%E4%B8%80%E4%B8%AA%E6%96%B9%E6%B3%95/","title":"解决X11下窗口拖动撕裂的一个方法"},{"content":"我们知道在C++20的协程中，自己实现的Coroutine中必须包含一个Promise，并且这个Promise必须要实现：\nget_return_object() initial_suspend() final_suspend() unhandled_exception() 少了其中任何一个，编译器都会报错。那这是怎么实现的呢？如果是像java那样是一个接口而没有对应的实现从而报错还能理解，但是我们的代码中的Promise完完全全是我们自己写的，也没有使用继承，编译器你怎么知道我在实现协程时少了什么东西呢？\n答案就是：SFINAE（Substitution Failure Is Not An Error，替换失败不是错误）。\nSFINAE 主要应用于函数模板的重载解析过程中。当编译器尝试选择一个合适的函数模板重载版本时，如果某个模板参数的替换导致编译错误，编译器不会立即报错，而是继续尝试其他可能的重载版本。\n在 C++20 协程中，编译器会根据协程函数的定义和 promise_type 的实现来生成协程的执行代码。promise_type 是一个用户定义的类型，它必须提供一些特定的函数，如 get_return_object、initial_suspend、final_suspend、unhandled_exception 和 return_value 等。这些函数定义了协程的行为和状态，编译器会在编译期检查这些函数的存在性和正确性，以确保协程能够正确地执行。\n编译器使用 SFINAE 机制来检查 promise_type 中是否包含特定的函数。具体来说，就是编译器会在协程的实现代码中使用表达式 SFINAE，尝试调用这些函数，并根据调用的结果来确定函数的存在性。\n我们来写个简单的例子，只检查get_return_object函数：\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 #include \u0026lt;type_traits\u0026gt; /* * check(int) 是一个重载的模板函数，用于检查U类型是否有get_return_object()函数。 * decltype(std::declval\u0026lt;U\u0026gt;().get_return_object(), std::true_type{}) 会在U具有get_return_object()时返回std::true_type。 * 如果U没有get_return_object()，则会匹配check(...)重载，返回std::false_type。 * value成员是一个常量布尔值，指示T是否具有get_return_object()函数。 */ template \u0026lt;typename T\u0026gt; struct has_get_return_object { template \u0026lt;typename U\u0026gt; static auto check(int) -\u0026gt; decltype(std::declval\u0026lt;U\u0026gt;().get_return_object(), std::true_type{}); template \u0026lt;typename\u0026gt; static std::false_type check(...); static constexpr bool value = decltype(check\u0026lt;T\u0026gt;(0))::value; }; template \u0026lt;typename T\u0026gt; void check_promise_type() { static_assert(has_get_return_object\u0026lt;T\u0026gt;::value, \u0026#34;Promise type must have a get_return_object() method.\u0026#34;); } struct MyCoroutine { struct promise_type { MyCoroutine get_return_object() { return {}; } }; }; int main() { check_promise_type\u0026lt;MyCoroutine::promise_type\u0026gt;(); } 当我们去掉Mycoroutine::promise_type中的get_return_object函数时，check_promise_type函数中就会出发断言失败，从而编译不通过。\n这也就是在将一个函数变为协程时编译器是如何检查的一个大概思路了。不得不感叹，C++真是博大精深。\n","date":"2024-09-01T00:00:00Z","permalink":"/p/%E5%8D%8F%E7%A8%8Bc-%E6%98%AF%E5%A6%82%E4%BD%95%E9%80%9A%E8%BF%87sfinae%E6%9D%A5%E6%A3%80%E6%9F%A5promise_type%E7%9A%84/","title":"【协程】C++是如何通过SFINAE来检查promise_type的"},{"content":"前言 暑假里跟着鱼皮的yuindex项目写了个web终端的小玩具，完成的终端命令不多，但是大体上成型了。由于我打算做一个纯前端的项目，并没有写后端api，这样也方便我最终直接部署到Github Pages上（这样就不用花钱了hh）。\n项目使用vite构建，并使用了ts。在构建部署时遇到了一些问题，由于是第一次将前端项目发布到github pages上，这里记录下。\n生成了额外的源映射文件 例如在执行了pnpm run build后，虽然生成了dist文件夹和其中的内容，但是源文件中的.ts文件会额外生成.js和.js.map文件，.vue文件会额外生成.vue.js和.vue.js.map文件：\n很明显这些额外生成的文件我们在发布部署项目时用不上，而且也无需提交到git上。摸索一番后，知道了这是TypeScript编译器生成的，我们需要修改项目的tsconfig.json文件。\n对于.map文件：\n1 2 3 4 5 { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;sourceMap\u0026#34;: false } } 对于ts生成的对应的js文件：\n1 2 3 4 5 6 { \u0026#34;compilerOptions\u0026#34;: { \u0026#34;allowJs\u0026#34;: false, // 禁止编译JS文件 \u0026#34;noEmit\u0026#34;: true, // 仅进行类型检查，而不输出任何文件 } } 如何部署到Github Pages 在解决完前面的问题后，我们就可以将dist目录提交到github上了（记住要在.gitignore文件中排除dist目录），这里我们可以使用：\n1 git subtree push --prefix dist origin gh-pages 这会将项目的子目录dist推送到远程仓库的gh-pages分支，如果远程仓库没有gh-pages分支，Git 会自动创建它并推送内容。\n接着在Github仓库的Settings中设置部署的位置：\nGithub Pages项目路径错误 由于kerolt.github.io用作了我的博客，这个项目只能作为子项来部署，也就是通过kerolt.github.io/web-terminal来访问。完成前面的步骤后，我虽然能访问，但是其中的内容无法显示，其原因为无法正确获取对应的文件。\n这是因为相当于是在嵌套的公共路径下部署项目，在vite中需指定 base 配置项：\n1 2 3 4 5 6 // vite.config.ts export default defineConfig({ base: \u0026#34;/web-terminal/\u0026#34;, // ... }); 至此，项目已经可以通过https://kerolt.github.io/web-terminal/来访问。\n","date":"2024-08-30T00:00:00Z","permalink":"/p/%E8%AE%B0%E4%B8%80%E6%AC%A1%E9%A1%B9%E7%9B%AE%E6%9E%84%E5%BB%BA%E9%83%A8%E7%BD%B2%E7%9A%84%E6%80%BB%E7%BB%93/","title":"记一次项目构建部署的总结"},{"content":"在使用手机浏览器搜索时为了免于CSDN垃圾信息的影响，我们可以使用Bing（其他搜索引擎也有类似的功能，但是国内不借助其他方法的情况下体验感最好的也只有Bing了）的“-site:*.csdn.net”来屏蔽CSDN，但是每次输入搜索内容后还要加这么一个字符串真是太麻烦了，因此可以写一个浏览器脚本来完成这一操作。\n在PC上可以直接使用油猴中其他大佬写的脚本，而我在手机上使用Via浏览器时不一定能用（可能可以，不过我还没试～）这些脚本，同时我需要的功能比较简单，故写一个Via浏览器的脚本。\n其实主要就是一个立即执行函数，其中对于输入框注册了keydown的事件，当按下回车键时可以在输入框的文本后添加-site:*.csdn.net。需要注意的是需要使用preventDefault()方法来防止默认的表单提交动作：\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 // ==UserScript== // @name Bing CSDN Filter // @namespace https://viayoo.com/ // @version 0.0 // @description 自动在Bing搜索框输入内容后添加-csdn，屏蔽CSDN内容 // @author kerolt // @match https://*.bing.com/* // @grant none // ==/UserScript== (function() { \u0026#39;use strict\u0026#39;; // 获取搜索框中的textarea元素 let searchInput = document.querySelector(\u0026#39;textarea[name=\u0026#34;q\u0026#34;]\u0026#39;); if (searchInput) { // 监听搜索框中的按键事件 searchInput.addEventListener(\u0026#39;keydown\u0026#39;, function(event) { // 检查是否按下回车键 if (event.key === \u0026#39;Enter\u0026#39;) { // 如果输入中没有-site:*.csdn.net，自动添加 if (!searchInput.value.includes(\u0026#39;-site:*.csdn.net\u0026#39;)) { searchInput.value += \u0026#39; -site:*.csdn.net\u0026#39;; } // 提交表单，进行搜索 event.preventDefault(); // 防止默认的表单提交动作 let searchForm = searchInput.closest(\u0026#39;form\u0026#39;); if (searchForm) { searchForm.submit(); // 手动提交表单 } } }); } })(); ","date":"2024-08-29T00:00:00Z","permalink":"/p/%E5%86%99%E4%B8%80%E4%B8%AA%E7%AE%80%E5%8D%95%E7%9A%84%E6%B5%8F%E8%A7%88%E5%99%A8%E8%84%9A%E6%9C%AC%E6%9D%A5%E5%B1%8F%E8%94%BDcsdn/","title":"写一个简单的浏览器脚本来屏蔽CSDN"},{"content":"在用C++刷leetcode时，我希望把一个递归函数像js、python那样写在运行函数内部，那么可以使用function和lambda表达式来实现。但如果这个递归函数的参数比较多，那么function的模板参数同样需要写很多，能不能用auto来实现得简单一点呢？\n在C++中，使用lambda表达式实现递归时，由于lambda本身没有显式的类型名，需要通过一些技巧来实现递归调用。使用auto\u0026amp;\u0026amp;作为参数类型是其中一种常见的做法：\nLambda表达式的类型推断：C++中的lambda表达式没有类型名，意味着无法直接在lambda内部调用自身。如果直接将lambda表达式定义为递归函数，会遇到无法识别的编译错误。\n1 2 3 4 5 6 7 8 9 10 11 #include \u0026lt;iostream\u0026gt; int main() { auto factorial = [](auto\u0026amp;\u0026amp; self, int n) -\u0026gt; int { if (n \u0026lt;= 1) return 1; return n * self(self, n - 1); // 递归调用lambda }; std::cout \u0026lt;\u0026lt; factorial(factorial, 5) \u0026lt;\u0026lt; std::endl; // 输出120 return 0; } 这里不像js、python直接定义就好了，还需要多写一个通用引用（什么是通用引用，可参考这篇博客），具体为什么我也暂时不清楚，只知道和Y组合子的知识点有关。\n","date":"2024-08-27T00:00:00Z","permalink":"/p/c-%E4%B8%AD%E5%A6%82%E4%BD%95%E4%BD%BF%E7%94%A8lambda%E5%AE%9E%E7%8E%B0%E9%80%92%E5%BD%92/","title":"C++中如何使用lambda实现递归"},{"content":"为什么我们需要协程？ 为什么我们有了线程还需要协程呢？（其实这个问题不应该这么问，协程的出现在线程之前）在一个进程中虽然我们可以创建多个线程，但是在一个进程中能创建的线程数量是有限制的，并且线程的调度仍然受操作系统控制，也就是说线程何时抢占、何时被抢占对于开发者来说都是透明的，并且在调度的过程中还可能涉及到用户态和内核态的切换开销。\n当我们需要去处理一个非常耗时的IO操作时（假设使用的阻塞IO），为了不阻塞当前线程，我们可能会想到新建一个线程去执行这个操作，但是线程的创建和调度也是需要消耗资源的，我们希望有更加轻便的方法，例如：在当前线程中，一个任务遇到阻塞IO时，不要傻傻的停在这里，而是暂停这个任务，转而去执行其他任务，直到IO完成再恢复之前的任务来运行。\n这里看起来是不是像两个函数之间的调用和被调用关系？确实有点像，但区别可大了，在任务1中我们并没有去显式地调用任务2！\n协程的最本质的解释是“可以挂起和恢复的函数”。例如上图中我们遇到耗时的IO操作了，我们就可以主动将当前运行的函数挂起（suspend），让其等待（await）IO操作，让线程去运行其他的函数，直到IO操作完成后再恢复（resume）。上下文切换的时机是靠调用方（写代码的开发人员）自身去控制的，这样协程的调度掌握在我们自己手中，相比与线程减小了系统切换上下文和其他资源的开销。因此协程在需要处理大量I/O操作或者并发任务的情况下提高程序的性能和可维护性。\nC++20带来的协程 C++20带来的协程并不像python或lua中的那么易用，相反，C++给我们提供的是更为底层的操作（不过C++23已经有std::generator这种更高级的抽象，之后也会有更多丰富的用法）。\nC++协程中有很重要的三个概念：\nPromise Awaitable Coroutine Handle Promise promise_type 是每个协程函数的幕后执行对象。它主要负责以下几个任务：\n创建和初始化协程：当协程开始执行时，编译器会通过 promise_type 创建一个对象，并调用其 get_return_object() 方法来获取协程的返回对象。 处理协程的暂停和恢复：协程在暂停时会调用 yield_value() 或 await_suspend() 等方法来处理协程的状态，并决定何时恢复。 处理协程的结束：当协程执行结束时，return_void() 或 return_value() 会被调用，来处理协程的返回结果。 以下是 promise_type 中一些常见的方法：\nget_return_object()：用于创建和返回协程的返回对象，一般是协程返回类型的实例。 initial_suspend()：返回一个 std::suspend_always 或 std::suspend_never，决定协程在启动时是否立即暂停。 final_suspend()：返回一个 std::suspend_always 或 std::suspend_never，决定协程在结束时是否暂停，以允许调用方执行清理操作。 return_void() 或 return_value(T value)：用于在协程完成时返回结果。return_void() 用于没有返回值的协程，而 return_value(T) 则用于有返回值的协程。 yield_value(T value)：用于生成值并让协程暂停，等待下一次恢复时继续执行。 Awaitable Awaitable 是一个可以与 co_await 表达式一起使用的对象或类型。Awaitable 对象必须提供一组特定的方法，使协程可以暂停执行，并在某个条件满足时继续执行。\nco_await 是 C++ 协程中的一种操作符，用于暂停协程并等待某个条件的满足。当协程遇到 co_await 时，它会暂停，并返回控制权给调用者。协程可以通过调用 co_await some_awaitable 来等待 some_awaitable 完成。\n一个 Awaitable 对象需要提供以下三个方法中的一个或多个：\noperator co_await：返回一个 Awaitable 对象。Awaitable 对象是实际实现等待逻辑的对象。 await_ready()：这是 Awaitable 对象上的方法。它返回一个 bool，用于指示是否需要等待。如果返回 true，协程将不会暂停。 await_suspend(std::coroutine_handle\u0026lt;\u0026gt;)：这是 Awaitable 对象上的方法。它接受一个 std::coroutine_handle\u0026lt;\u0026gt; 参数，并在协程暂停时调用。这个方法决定协程何时恢复执行。 await_resume()：这是 Awaitable 对象上的方法。它在协程恢复时调用，并返回 co_await 表达式的结果。 C++中提供了两个简单的Awaitable：std::suspend_never和std::suspend_always。\nCoroutine Handle std::coroutine_handle 是 C++20 协程库中一个核心的工具类，用于表示和操作协程。它是一个模板类，通常用来指向协程的状态信息。它可以通过协程的 promise_type 访问和控制协程的状态。每个协程在创建时，都会生成一个 std::coroutine_handle，用于管理协程的生命周期。\nstd::coroutine_handle 提供了一系列方法来控制协程的执行，包括以下几个主要功能：\n创建和获取句柄： std::coroutine_handle\u0026lt;\u0026gt;::from_address(void* ptr)：通过指针获取一个句柄。 std::coroutine_handle\u0026lt;promise_type\u0026gt;::from_promise(promise_type\u0026amp; promise)：通过 promise_type 对象创建一个句柄。 控制协程的执行： void resume()：恢复协程的执行。 void destroy()：销毁协程并释放其占用的资源。 void operator()()：等效于 resume()，恢复协程的执行。 void* address()：返回协程句柄的地址，用于低级操作。 检查协程的状态： bool done()：检查协程是否已经完成执行。 访问 promise_type： promise_type\u0026amp; promise()：获取与当前协程关联的 promise_type 对象，允许访问协程内部状态。 简单示例 这里有一个使用C++20 coroutine来实现挂起和恢复函数的例子。\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 #include \u0026lt;coroutine\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;thread\u0026gt; using namespace std::chrono_literals; struct Result { struct Promise { Result get_return_object() { return std::coroutine_handle\u0026lt;Promise\u0026gt;::from_promise(*this); } std::suspend_never initial_suspend() { return {}; } std::suspend_always final_suspend() noexcept { return {}; } void unhandled_exception() {} }; using promise_type = Promise; Result(std::coroutine_handle\u0026lt;Promise\u0026gt; h) : handle(h) {} std::coroutine_handle\u0026lt;Promise\u0026gt; handle; }; Result hello() { std::cout \u0026lt;\u0026lt; \u0026#34;Hello \u0026#34; \u0026lt;\u0026lt; std::endl; co_await std::suspend_always{}; // 挂起hello std::cout \u0026lt;\u0026lt; \u0026#34;world!\u0026#34; \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; \u0026#34;hello one\\n\u0026#34;; } Result hello2() { std::cout \u0026lt;\u0026lt; \u0026#34;你好 \u0026#34; \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; \u0026#34;hello two two\\n\u0026#34;; co_await std::suspend_always{}; // 挂起hello2 std::cout \u0026lt;\u0026lt; \u0026#34;世界!\u0026#34; \u0026lt;\u0026lt; std::endl; } int main() { Result coro = hello(); Result coro2 = hello2(); coro.handle.resume(); // 恢复hello coro2.handle.resume(); // 恢复hello2 } 在main函数中，一开始我们启动了协程hello，输出了“Hello”后被挂起；之后启动了协程hello2，其输出“你好\\nhello two two\\n”后也被挂起。紧接着我们通过coroutine_handle来依次恢复这两个协程，最终输出结果为：\n1 2 3 4 5 6 Hello 你好 hello two two world! hello one 世界! References https://zplutor.github.io/2022/03/25/cpp-coroutine-beginner/ https://www.bluepuni.com/archives/stackless-coroutine-and-asio-coroutine https://zhuanlan.zhihu.com/p/355100152?utm_psn=1808059511308697600 https://itnext.io/c-20-coroutines-complete-guide-7c3fc08db89d https://jasonkayzk.github.io/2022/06/03/%E6%B5%85%E8%B0%88%E5%8D%8F%E7%A8%8B/ https://lewissbaker.github.io/2017/11/17/understanding-operator-co-await https://juejin.cn/post/6844903715099377672 ","date":"2024-08-18T00:00:00Z","permalink":"/p/%E5%8D%8F%E7%A8%8Bc-20%E5%8D%8F%E7%A8%8B%E5%88%9D%E4%BD%93%E9%AA%8C/","title":"【协程】C++20协程初体验"},{"content":" 难度：Hard\n标签：位运算；图论；Floyd算法\n链接： https://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/\n题目中给出的n范围为1 \u0026lt;= n \u0026lt;= 10，那么说明最多的10个分部的情况下，可以选择关闭的可行情况有2^10种，这个数据量并不大，可以用暴力枚举做出来。\n但是如何知道选择了哪些顶点呢？这里有用到位运算这个很巧妙的方法，一个int型数据有32位，我们最多只要用其中的10位即可表示所有的情况，同时还能用每一位来表示是否选择了某一个顶点（分部）。\n利用Floyd算法可以求解出一个图中任意两个顶点之间的最小距离。当我们选择一个可能的集合时，判断这个集合中有的分部之间的最短距离是否会大于题目要求的最远距离maxDistance，如果有大于的，说明有分部之间最短的距离都无法满足要求。\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 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 52 53 54 class Solution { public: int numberOfSets(int n, int maxDistance, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026amp; roads) { vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; g(n, vector\u0026lt;int\u0026gt;(n, INT_MAX / 2)); for (int i = 0; i \u0026lt; n; i++) { g[i][i] = 0; } for (auto\u0026amp; r : roads) { int x = r[0], y = r[1], w = r[2]; g[x][y] = min(g[x][y], w); g[y][x] = min(g[y][x], w); } vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; f(n); // 用f来存储每一种情况对应的图 auto check = [\u0026amp;](int set) { for (int i = 0; i \u0026lt; n; i++) { // 只要每种情况对应集合中的分部构成的图 if ((set \u0026gt;\u0026gt; i) \u0026amp; 1) { f[i] = g[i]; } } // Floyd算法，处理f中分部之间的最短距离，需要根据set的值来选择进行计算 // 因为在之前我们只选择了集合中有的分部建图 for (int k = 0; k \u0026lt; n; k++) { if (((set \u0026gt;\u0026gt; k) \u0026amp; 1) == 0) continue; for (int i = 0; i \u0026lt; n; i++) { if (((set \u0026gt;\u0026gt; i) \u0026amp; 1) == 0) continue; for (int j = 0; j \u0026lt; n; j++) { if (((set \u0026gt;\u0026gt; j) \u0026amp; 1) == 0) continue; f[i][j] = min(f[i][j], f[i][k] + f[k][j]); } } } // 检查图中各个分部的最短距离 for (int i = 0; i \u0026lt; n; i++) { if (((set \u0026gt;\u0026gt; i) \u0026amp; 1) == 0) continue; for (int j = 0; j \u0026lt; n; j++) { if (((set \u0026gt;\u0026gt; j) \u0026amp; 1) \u0026amp;\u0026amp; f[i][j] \u0026gt; maxDistance) { return false; } } } return true; }; int res = 0; // 暴力枚举所有情况 for (int i = 0; i \u0026lt; (1 \u0026lt;\u0026lt; n); i++) { res += check(i); // true和false分别隐式转换为1和0 } return res; } }; ","date":"2024-07-17T00:00:00Z","permalink":"/p/259.-%E5%85%B3%E9%97%AD%E5%88%86%E9%83%A8%E7%9A%84%E5%8F%AF%E8%A1%8C%E9%9B%86%E5%90%88%E6%95%B0%E7%9B%AE/","title":"259. 关闭分部的可行集合数目"},{"content":" 难度：Hard\n标签：哈希表；滑动窗口；字符串\n链接： https://leetcode.cn/problems/minimum-window-substring/description/\n比较容易想到要用滑动窗口来解决，使用两个哈希表来记录信息：cnt_t用于记录字符串t中每个字符出现过的次数，cnt_s用于滑动窗口中的字符的出现次数。\n滑动窗口的区间为[left, right]，并记录最小的左右区间（这里我使用一个res数组）。移动right有区间，直到移动到s字符串结束部分。将s[right]加入cnt_s中，如果cnt_s包含cnt_t，则：\n若当前区间长度小于记录的最小区间长度，更新这个最小区间长度； 将cnt_s中s[left]出现的次数-1； left右移+1； 重复上面3步，直到cnt_s不包含cnt_t； 最后，若res[0] \u0026lt; 0（最小左区间），说明s 中不存在涵盖 t 所有字符的子串，则返回空字符串 \u0026quot;\u0026quot; ；反之，返回最小左右区间中的字符串。\n这里的“cnt_s包含cnt_t”是什么意思呢？cnt_t中有的字符cnt_s中都要有，并且cnt_t中字符的次数要小于等于cnt_s中字符出现的次数。\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 class Solution { public: string minWindow(string s, string t) { unordered_map\u0026lt;char, int\u0026gt; cnt_s, cnt_t; for (char c : t) { cnt_t[c]++; } int left = 0, right = 0, n = s.length(); int res[2]{-1, n}; while (right \u0026lt; n) { cnt_s[s[right]]++; while (cover(cnt_s, cnt_t)) { if (right - left \u0026lt; res[1] - res[0]) { res[0] = left; res[1] = right; } cnt_s[s[left++]]--; } right++; } return res[0] \u0026lt; 0 ? \u0026#34;\u0026#34; : s.substr(res[0], res[1] - res[0] + 1); } bool cover(unordered_map\u0026lt;char, int\u0026gt;\u0026amp; cnt_s, unordered_map\u0026lt;char, int\u0026gt;\u0026amp; cnt_t) { for (auto\u0026amp; [k, v] : cnt_t) { if (cnt_s.find(k) == cnt_t.end() || cnt_s[k] \u0026lt; v) { return false; } } return true; } }; ","date":"2024-06-15T00:00:00Z","permalink":"/p/76.-%E6%9C%80%E5%B0%8F%E8%A6%86%E7%9B%96%E5%AD%90%E4%B8%B2/","title":"76. 最小覆盖子串"},{"content":"记录一下如何配置eslint（其实是怕下一次又被折磨）。\nuni-cli创建项目 由于我是在linux上使用uniapp，无法使用HBuilderX，故采用uni-cli来创建项目并使用vscode进行开发。\nuni-cli创建项目很简单，一行命令即可，这里采用vue3/vite版：\n1 npx degit dcloudio/uni-preset-vue#vite my-vue3-project 这里只用来创建项目，发布运行什么的具体可以看官网。\n使用eslint eslint的配置文件和配置方式我感觉有好多种，所以在配置的时候折腾了挺久，最好还是按照官网最新文档来：\neslint：https://eslint.org/docs/latest/use/getting-started eslint-plugin-vue：https://eslint.vuejs.org/ 安装eslint 1 npm init @eslint/config@latest 配置 安装完后，一般项目的根目录下就会出现eslint.config.mjs文件（eslint 9.4.0版本，采用vue框架），主体如下：\n1 2 3 4 5 6 7 8 9 10 import pluginVue from \u0026#34;eslint-plugin-vue\u0026#34;; export default [ ...... { rules: { } } ]; 现在我们就要来按照自己的要求来配置，最好是按照官方的文档来定制：eslint-plugin-vue，一般使用的规则会写在rules对象中。\n举个🌰 比如我希望在代码中使用双引号，并且语句使用分号结尾，那么eslint.config.mjs文件就可以这样写：\n1 2 3 4 5 6 7 8 9 10 11 import pluginVue from \u0026#34;eslint-plugin-vue\u0026#34;; export default [ ...pluginVue.configs[\u0026#34;flat/recommended\u0026#34;], { rules: { quotes: [\u0026#34;error\u0026#34;, \u0026#34;double\u0026#34;], semi: [\u0026#34;error\u0026#34;] } } ]; 如果在代码中碰到一些eslint的报错，例如\n那么可以在报错信息中给出的链接网页中查找\n在eslint.config.mjs中的rules中加入\u0026quot;vue/multi-word-component-names\u0026quot;: 0后，报错就消失了。\n","date":"2024-06-08T00:00:00Z","permalink":"/p/%E7%94%A8uni-cli%E5%88%9B%E5%BB%BA%E9%A1%B9%E7%9B%AE%E5%90%8E%E4%BD%BF%E7%94%A8eslint/","title":"用uni-cli创建项目后使用eslint"},{"content":" 难度：Hard\n标签：哈希表、字符串、滑动窗口\n链接：https://leetcode.cn/problems/substring-with-concatenation-of-all-words/description/\n在B站上看见一个up的视频，感觉说的很清晰，言简意赅：【五分钟力扣 Leetcode 第30题 串联所有单词的子串除 Python入门算法刷题 极简解法 23行代码 97%】 。\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 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 class Solution { public: vector\u0026lt;int\u0026gt; findSubstring(string s, vector\u0026lt;string\u0026gt;\u0026amp; words) { int words_num = words.size(); int uni_len = words[0].size(); int n = s.size(); vector\u0026lt;int\u0026gt; res; unordered_map\u0026lt;string, int\u0026gt; dict; // 记录每个单词的出现次数 for (auto\u0026amp; w : words) { dict[w]++; } // 外循环遍历的次数为单个单词的长度 for (int i = 0; i \u0026lt; uni_len; i++) { int start = i; // start为子串开始的索引 unordered_map\u0026lt;string, int\u0026gt; cache; // cache用于记录内循环中，位于dict中的单词的出现次数 // 内循环，每次增加一个单词长度（即以一个单词长度为最小单位） for (int j = i; j \u0026lt; n; j += uni_len) { string sub = s.substr(j, uni_len); // 判断当前截取的字符串是否存在于dict中 if (dict.find(sub) != dict.end()) { cache[sub]++; // 当sub在cache中出现次数大于dict中，说明当前start不可能为子串的开始索引（因为单词数目都不相等了），不断移动start位置（移动单元为uni_len），直到cache[sub] \u0026lt;= dict[sub] while (cache[sub] \u0026gt; dict[sub]) { string remove_str = s.substr(start, uni_len); cache[remove_str]--; start += uni_len; } // 若cache和dict中内容一致，说明找到了一个符合要求的子串 if (dict == cache) { res.push_back(start); } } else { // 若当前截取字符串不存在于dict中，跳过这个单词 start = j + uni_len; cache.clear(); } } } return res; } }; ","date":"2024-06-07T00:00:00Z","permalink":"/p/30.-%E4%B8%B2%E8%81%94%E6%89%80%E6%9C%89%E5%8D%95%E8%AF%8D%E7%9A%84%E5%AD%90%E4%B8%B2/","title":"30. 串联所有单词的子串"},{"content":"最近听说 VMware17.5.2 个人版可以免费使用了，故在 Linux 下安装用用，顺便记录一下踩的坑。\n我是想在 Linux 上想用 Uniapp，用 wine 的体验不是很好，故打算用虚拟机跑 Windows。安装好 VMware17.5.2 后，配置好 windows 镜像后，点击启动，却报了这样的错：\nCould not open /dev/vmmon: ?????????. Please make sure that the kernel module `vmmon\u0026rsquo; is loaded.\n搜了一下发现是原因为VMware 无法访问其必要的内核模块 vmmon：\n我首先是尝试了手动启用 VMware 模块，然后执行命令安装缺失的模块\n1 2 3 sudo /etc/init.d/vmware start sudo vmware-modconfig --console --install-all 但是还是无效，在查看别人的 博客 后，我手动去编译安装缺失的 vmmon 和 vmnet 模块：\n1 2 3 4 5 6 git clone https://github.com/mkubecek/vmware-host-modules cd vmware-host-modules git checkout workstation-17.5.1 sudo make sudo make install 执行完成后，这两个模块将会安装到 /lib/modules/6.1.0-18-amd64/misc 下\n1 2 3 4 5 kerolt  /usr/lib/modules/6.1.0-18-amd64/misc  $ ls -l 总计 7164 -rw-r--r-- 1 root root 3996784 5月27日 18:48 vmmon.ko -rw-r--r-- 1 root root 3337384 5月27日 18:48 vmnet.ko 本以为到现在已经结束，结果再次启动 VMware，还是不信，于是我想着可能是内核模块没有加载，采用如下命令查看：\n1 lsmod | grep vmmon 不出意外，没有输出，手动加载模块：\n1 2 sudo modprobe vmmon # modprobe: ERROR: could not insert \u0026#39;vmmon\u0026#39;: Key was rejected by service 根据该错误询问 ChatGPT，其给出的答复为：启用了安全启动（Secure Boot），导致系统拒绝加载未签名或未正确签名的内核模块。\n重启计算机，进入 BIOS/UEFI 设置，将 Secure Boot 设置为 Disabled，之后再次启动 VMware 时就没有问题了～\n","date":"2024-05-27T00:00:00Z","permalink":"/p/vmware17.5.2%E5%90%AF%E5%8A%A8%E8%B8%A9%E5%9D%91/","title":"VMware17.5.2启动踩坑"},{"content":"上一篇文章：股票问题与状态机dp\n本篇文章涉及题目如下：\n123. 买卖股票的最佳时机 III 188. 买卖股票的最佳时机 IV 309. 买卖股票的最佳时机含冷冻期 交易K次问题 在上篇文章的基础上，我们其实需要做的就是在处理函数上加一个参数k代表还可以交易几次，当k \u0026lt; 0时就说明交易次数达到上限\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 class Solution { public: int maxProfit(int k, vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026gt; cache(n, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(k + 1, vector\u0026lt;int\u0026gt;(2, -1))); function\u0026lt;int(int, int, bool)\u0026gt; dfs = [\u0026amp;](int i, int k, bool hold) { if (k \u0026lt; 0) { return INT_MIN; } if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } int\u0026amp; res = cache[i][k][hold]; if (res != -1) { return res; } if (hold) { // 第i天持有 return res = max(dfs(i - 1, k, true), dfs(i - 1, k, false) - prices[i]); } // 第i天未持有 return res = max(dfs(i - 1, k, false), dfs(i - 1, k - 1, true) + prices[i]); }; return dfs(n - 1, k, false); } }; 值得注意的是，该问题的记忆化搜索实现中cache数组应该为三维，cache[i][k][hold]表示第i天，剩余交易次数k次、是否拥有股票的结果的缓存\n使用记忆化搜索是无法通过123. 买卖股票的最佳时机 III的，因此可以将其改成dp解决\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int k = 2; int n = prices.size(); vector\u0026lt;vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;\u0026gt; dp(n + 1, vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt;(k + 2, vector\u0026lt;int\u0026gt;(2, INT_MIN))); for (int j = 0; j \u0026lt; k + 2; j++) { dp[0][j][0] = 0; } for (int i = 0; i \u0026lt; n; i++) { for (int j = 1; j \u0026lt;= k + 1; j++) { dp[i + 1][j][0] = max(dp[i][j][0], dp[i][j - 1][1] + prices[i]); dp[i + 1][j][1] = max(dp[i][j][1], dp[i][j][0] - prices[i]); } } return dp[n][k + 1][0]; } }; 冷冻期问题 这个问题有点类似与打家劫舍\u0026mdash;不能连续偷相邻的房屋。那么在本问题中，是不是将比较前一天的代码改成比较前前一天的代码就行了？差不多！但是只有在买入股票或卖出股票的时候需要修改，这是因为买入卖出才算一次交易，所以在这两个时间段选一个进行修改即可\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 class Solution { public: int maxProfit(vector\u0026lt;int\u0026gt;\u0026amp; prices) { int n = prices.size(); vector\u0026lt;vector\u0026lt;int\u0026gt;\u0026gt; cache(n, vector(2, -1)); function\u0026lt;int(int, bool)\u0026gt; dfs = [\u0026amp;](int i, bool hold) { if (i \u0026lt; 0) { return hold ? INT_MIN : 0; } int\u0026amp; res = cache[i][hold]; if (res != -1) { return res; } if (hold) { return res = max(dfs(i - 1, true), dfs(i - 2, false) - prices[i]); } return res = max(dfs(i - 1, false), dfs(i - 1, true) + prices[i]); }; return dfs(n - 1, false); } }; ","date":"2024-04-12T00:00:00Z","permalink":"/p/%E8%82%A1%E7%A5%A8%E9%97%AE%E9%A2%98%E7%AC%AC%E4%BA%8C%E6%B3%A2/","title":"股票问题第二波"},{"content":"前言 std::cout重载了\u0026lt;\u0026lt;运算符，这使得写一些很短的代码时很方便。但是如果在多线程的条件下，cout并不是线程安全的。\n举例 举个例子，我们创建5个线程\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 #include \u0026lt;iostream\u0026gt; #include \u0026lt;thread\u0026gt; void Test() { std::cout \u0026lt;\u0026lt; \u0026#34;msg1\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg2\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg3\u0026#34; \u0026lt;\u0026lt; \u0026#34; thread_id = \u0026#34; \u0026lt;\u0026lt; std::this_thread::get_id() \u0026lt;\u0026lt; std::endl; } int main() { std::thread threads[5]; for (int i = 0; i \u0026lt; 5; i++) { threads[i] = std::thread(Test); } for (int i = 0; i \u0026lt; 5; i++) { threads[i].join(); } } 实际上，看样子好像控制台应该输出5行内容，但是运行结果可能是这样的\n1 2 3 4 5 msg1 msg2 msg3 thread_id = msg1 msg2 msg3 thread_id = 139926598575808139926606968512 msg1 msg2 msg3 thread_id = 139926590183104 msg1 msg2 msg3 thread_id = 139926455965376 msg1 msg2 msg3 thread_id = 139926581790400 这是因为cout在使用时可能会存在线程之间的打印信息乱串的问题，看一下编译器眼中我们这段程序中的cout是什么样的：\n1 std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::operator\u0026lt;\u0026lt;(std::cout, \u0026#34;msg1\u0026#34;), \u0026#34; msg2\u0026#34;), \u0026#34; msg3\u0026#34;), \u0026#34; thread_id = \u0026#34;), std::this_thread::get_id()).operator\u0026lt;\u0026lt;(std::endl); 可以看到，这不是通过单个 std::operator\u0026lt;\u0026lt; 调用完成的，也就是说这个操作并不是原子的\n解决方法 使用std::format（C++20） 使用第三方库，如folly，fmtlib等 使用stringstream \u0026hellip;\u0026hellip; 这里使用stringstream做个演示。将Test函数修改如下：\n1 2 3 4 5 6 std::stringstream ss; ss \u0026lt;\u0026lt; \u0026#34;msg1\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg2\u0026#34; \u0026lt;\u0026lt; \u0026#34; msg3\u0026#34; \u0026lt;\u0026lt; \u0026#34; thread_id = \u0026#34; \u0026lt;\u0026lt; std::this_thread::get_id() \u0026lt;\u0026lt; std::endl; std::cout \u0026lt;\u0026lt; ss.str(); 这样，控制台的输出就不会乱串了。\n","date":"2024-02-21T00:00:00Z","permalink":"/p/%E9%81%BF%E5%85%8Dcout%E7%BA%BF%E7%A8%8B%E4%B8%8D%E5%AE%89%E5%85%A8%E7%9A%84%E4%B8%80%E4%B8%AA%E5%81%9A%E6%B3%95/","title":"避免cout线程不安全的一个做法"},{"content":"之前对于移动语义的理解就是使用std::move将一个对象所占有的资源的所有权转移给另一个对象，但是只要使用std::move就足够了吗？这显然是错误的。\n看一下std::move的源码（g++12.2）\n1 2 3 4 5 6 7 8 9 10 /** * @brief Convert a value to an rvalue. * @param __t A thing of arbitrary type. * @return The parameter cast to an rvalue-reference to allow moving it. */ template\u0026lt;typename _Tp\u0026gt; _GLIBCXX_NODISCARD constexpr typename std::remove_reference\u0026lt;_Tp\u0026gt;::type\u0026amp;\u0026amp; move(_Tp\u0026amp;\u0026amp; __t) noexcept { return static_cast\u0026lt;typename std::remove_reference\u0026lt;_Tp\u0026gt;::type\u0026amp;\u0026amp;\u0026gt;(__t); } 其实move的实现并没有很复杂，粗略一点的理解就是将一个左值强制转换为右值。\nstd::move 并不会真正地移动对象，真正的移动操作是在移动构造函数、移动赋值函数等完成的，std::move 只是将参数转换为右值引用而已。\n写一个简单的例子如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #include \u0026lt;fmt/core.h\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;utility\u0026gt; struct A { A(std::string str) : data(str) {} A(const A\u0026amp;) { puts(\u0026#34;copy\u0026#34;); } A(A\u0026amp;\u0026amp;) { puts(\u0026#34;move\u0026#34;); } std::string data = \u0026#34;default\u0026#34;; }; int main() { A a(\u0026#34;hello\u0026#34;); A a2(std::move(a)); fmt::print(\u0026#34;a: {}, a2: {}\\n\u0026#34;, a.data, a2.data); } 看样子我们使用std::move后a2的data数据应该为“hello”，但是运行结果为\n1 2 move a: hello, a2: default 虽然使用move匹配到了A的移动构造函数，但是在上文提到过，std::move仅仅只是一个强制转换，并没有实现真正的移动！但要是我们不写A中的移动构造函数或是将其设置成default：\n1 2 3 4 5 6 7 8 9 10 struct A { A(std::string str) : data(str) {} A(const A\u0026amp;) { puts(\u0026#34;copy\u0026#34;); } A(A\u0026amp;\u0026amp;) = default; std::string data = \u0026#34;default\u0026#34;; }; 这样，运行结果为\n1 a: , a2: hello 这是因为当我们不显式指定移动构造函数（或是拷贝构造函数、移动or拷贝运算符）编译器会自动生成，貌似也一并实现了数据的移动（？这我也还不清楚）\n我们通常使用std::move能够实现标准库中一些资源的转移，是因为标准库中已经实现了这些资源类的移动构造函数or移动赋值运算符。\n","date":"2024-02-18T00:00:00Z","permalink":"/p/%E7%BA%A0%E6%AD%A3%E4%B8%80%E4%B8%8B%E5%AF%B9cpp%E7%A7%BB%E5%8A%A8%E8%AF%AD%E4%B9%89%E7%9A%84%E9%94%99%E8%AF%AF%E7%90%86%E8%A7%A3/","title":"纠正一下对cpp移动语义的错误理解"},{"content":"想写这篇博客的原因是在刷力扣的 347. 前 K 个高频元素 一题时，需要使用到优先队列priority_queue，其定义如下：\n1 2 3 4 5 template\u0026lt; class T, class Container = std::vector\u0026lt;T\u0026gt;, class Compare = std::less\u0026lt;typename Container::value_type\u0026gt; \u0026gt; class priority_queue; 第三个参数是一个可以自定义的比较类型，其必须满足二元谓词，通常可以使用如下两种方法：\n使用自定义的函数对象 lambda表达式 使用std::greater或std::less（这里就不介绍这种方法了） 以题 347. 前 K 个高频元素 为例，我们要建立一个小根堆，那么代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 // 方法一 using PII = pair\u0026lt;int, int\u0026gt;; // 比较类，重载了括号运算符 struct Comp { bool operator()(PII\u0026amp; p1, PII\u0026amp; p2) { return p1.second \u0026gt; p2.second; } }; priority_queue\u0026lt;PII, vector\u0026lt;PII\u0026gt;, Comp\u0026gt; pq; 当然，另一种方法就是使用lambda表达式，如下：\n1 2 3 4 5 6 7 8 // 方法二 using PII = pair\u0026lt;int, int\u0026gt;; auto comp = [](PII\u0026amp; p1, PII\u0026amp; p2) { return p1.second \u0026gt; p2.second; }; // 注意这里需要使用decltype priority_queue\u0026lt;PII, vector\u0026lt;PII\u0026gt;, decltype(comp)\u0026gt; pq; 但值得注意的是，方法二需要在C++20下才可使用。这是因为priority_queue的第三个模板形参需要的二元谓词要求可复制构造。\nlambda表达式即构造闭包（能够捕获作用域中的变量的无名函数对象）。而在C++20之前，闭包类型非可默认构造，闭包类型没有默认构造函数。C++20及之后，如果没有指定捕获，那么闭包类型拥有预置的默认构造函数。\n而在目前，力扣中C++编译器使用的是clang17，支持C++20，故使用lambda表达式是没有问题的。\n","date":"2024-02-14T21:00:14Z","permalink":"/p/c-%E4%B8%ADlambda%E4%B8%8Epriority_queue%E4%B8%80%E8%B5%B7%E4%BD%BF%E7%94%A8/","title":"C++中lambda与priority_queue一起使用"},{"content":"下载源码 链接：https://github.com/chenshuo/muduo/releases/tag/v2.0.2\n编译安装 解压后，在项目根目录中更改 CMakeLists.txt 文件\n如图，将 option 属性注释掉（这是muduo的例子的编译选项，如果开启将增加编译时间）\n之后，执行\n1 2 ./build.sh ./build.sh install 如果没有出错的话，在与muduo-v2.0.2同目录下将生成一个 build 目录，其中有\n1 2 3 4 5 6 7 8 release-install-cpp11 ├── include │ └── muduo │ ├── base │ └── net │ ├── http │ └── inspect └── lib 将include目录和lib目录下的内容复制到系统路径下：\n1 2 mv include/muduo /usr/local/include mv lib/* /usr/local/lib ok，现在就可以使用muduo库了\n测试使用 测试代码可以参考博客 https://www.cnblogs.com/conefirst/articles/15224039.html\n","date":"2024-01-20T00:00:00Z","permalink":"/p/muduo%E7%BD%91%E7%BB%9C%E5%BA%93%E7%9A%84%E5%AE%89%E8%A3%85/","title":"muduo网络库的安装"},{"content":"前言 在Vue3项目中，如果我们想上传图片一般可以利用element-ui中的el-upload，为了避免代码的重复，我们可以自己封装一个图片上传组件。\n其中，主要实现思想为前端利用el-upload组件选择上传的图片，并利用其http-request属性来自定义函数来实现文件上传请求：该请求函数使用七牛云的对象存储，在通过后端得到的上传凭证token后来实现文件上传。\n后端代码 使用express框架，获取七牛云上传凭证并响应给前端\n项目结构 1 2 3 4 5 6 - routes |- token.js |- index.js - app.js - config.js - package.json 安装七牛云的SDK： 1 npm i qiniu 获取上传凭证 编写获取上传凭证的相关代码：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /* config.js */ ​ const qiniu = require(\u0026#39;qiniu\u0026#39;) ​ // 创建上传凭证 const accessKey = \u0026#39;*****\u0026#39; // 这里填写七牛云的accessKey const secretKey = \u0026#39;*****\u0026#39;// 这里填写七牛云的secretKey const mac = new qiniu.auth.digest.Mac(accessKey, secretKey) const options = { scope: \u0026#39;*****\u0026#39;, // 这里填写七牛云空间名称 expires: 60 * 60 * 24 * 7 // 这里是凭证的有效时间，默认是一小时 } const putPolicy = new qiniu.rs.PutPolicy(options) const uploadToken = putPolicy.uploadToken(mac) ​ module.exports = { uploadToken } 配置路由 token.js\n1 2 3 4 5 6 7 8 const tokenRouter = require(\u0026#39;express\u0026#39;).Router() const qnconfig = require(\u0026#39;../config\u0026#39;) // 引入七牛云配置 ​ tokenRouter.get(\u0026#39;/qiniu\u0026#39;, (req, res, next) =\u0026gt; { res.status(200).send(qnconfig.uploadToken) }) ​ module.exports = tokenRouter index.js\n1 2 3 4 5 const token = require(\u0026#39;./token\u0026#39;) ​ module.exports = routes = (app) =\u0026gt; { app.use(\u0026#39;/token\u0026#39;, token) // 可以通过/token/qiniu的方式获取上传凭证 } 项目启动 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const express = require(\u0026#39;express\u0026#39;) const bodyparse = require(\u0026#39;body-parser\u0026#39;) const routers = require(\u0026#39;./route\u0026#39;) ​ // 创建服务 const app = express() // 解析数据 app.use(bodyparse.json()) ​ // 路由 routes(app) ​ // 监听3000端口 app.listen(3000, () =\u0026gt; { console.log(\u0026#39;this server are running on localhost:3000!\u0026#39;) }) 使用命令node app.js启动项目，这时访问http://localhost:3000/token/qiniu即可获取上传凭证了。\n前端代码 配置跨域 由于前后端项目运行在不同的端口，因此需要解决跨域问题，这里在vite.config.js中解决如下：\n1 2 3 4 5 6 7 8 9 server: { proxy: { \u0026#39;/api\u0026#39;: { target: \u0026#39;http://localhost:3000\u0026#39;, changeOrigin: true, rewrite: (path) =\u0026gt; path.replace(/^/api/, \u0026#39;\u0026#39;) } } } 父组件使用 我们希望子组件上传图片得到一串url后父组件能接受到，并且在展示上传图片时其尺寸应能指定或者有默认值。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;template\u0026gt; \u0026lt;Upload :url=\u0026#34;imageUrl\u0026#34; @upload=\u0026#34;changeUrl\u0026#34; /\u0026gt; \u0026lt;/template\u0026gt; ​ \u0026lt;script setup\u0026gt; import Upload from \u0026#39;@/components/Upload.vue\u0026#39; import { ref } from \u0026#39;vue\u0026#39; const imageUrl = ref(\u0026#39;\u0026#39;) ​ const changeUrl = (url) =\u0026gt; { imageUrl.value = url } \u0026lt;/script\u0026gt; 封装组件Upload.vue 这里只是简单使用axios，没有对其进行封装。\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 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 \u0026lt;template\u0026gt; \u0026lt;!- action=\u0026#34;https://upload-z2.qiniup.com\u0026#34;：每个地区访问域名不同，具体可通过 https://developer.qiniu.com/kodo/1671/region-endpoint-fq 查看 -\u0026gt;\t\u0026lt;el-upload class=\u0026#34;avatar-uploader\u0026#34; action=\u0026#34;https://upload-z2.qiniup.com\u0026#34; :show-file-list=\u0026#34;false\u0026#34; :http-request=\u0026#34;up2qiniu\u0026#34; :before-upload=\u0026#34;beforeUpload\u0026#34; \u0026gt; \u0026lt;img v-if=\u0026#34;props.url\u0026#34; :src=\u0026#34;props.url\u0026#34; class=\u0026#34;avatar\u0026#34; :style=\u0026#34;\u0026#39;width: \u0026#39; + props.width + \u0026#39;px;\u0026#39; + \u0026#39;height: \u0026#39; + props.height + \u0026#39;px;\u0026#39;\u0026#34; /\u0026gt; \u0026lt;el-icon v-else class=\u0026#34;avatar-uploader-icon\u0026#34; :style=\u0026#34;\u0026#39;width: \u0026#39; + props.width + \u0026#39;px;\u0026#39; + \u0026#39;height: \u0026#39; + props.height + \u0026#39;px;\u0026#39;\u0026#34; \u0026gt;\u0026lt;Plus /\u0026gt;\u0026lt;/el-icon\u0026gt; \u0026lt;/el-upload\u0026gt; \u0026lt;/template\u0026gt; \u0026lt;script setup\u0026gt; import { ref } from \u0026#39;vue\u0026#39; import { getQiniuToken } from \u0026#39;../api/token\u0026#39; import axios from \u0026#39;axios\u0026#39; import { ElMessage } from \u0026#39;element-plus\u0026#39; const qiniuaddr = \u0026#39;rlr92qkze.hn-bkt.clouddn.com\u0026#39; // 这里是七牛云存储对象中的CDN域名 const imageUrl = ref(\u0026#39;\u0026#39;) // 父组件传值时，须有图片的url；其次可选择图片的宽高（默认都为180） const props = defineProps({ url: String, width: { type: Number, default: 180 }, height: { type: Number, default: 180 } }) const emit = defineEmits([\u0026#39;upload\u0026#39;]) const beforeUpload = (rawFile) =\u0026gt; { if (rawFile.type !== \u0026#39;image/jpg\u0026#39; \u0026amp;\u0026amp; rawFile.type !== \u0026#39;image/png\u0026#39;) { ElMessage.error(\u0026#39;图片格式应该是png或jpg\u0026#39;) return false } else if (rawFile.size / 1024 / 1024 \u0026gt; 2) { ElMessage.error(\u0026#39;图片大小应该小于2MB\u0026#39;) return false } return true } /** * 上传图片至七牛云 * @param {*} req */ const up2qiniu = (req) =\u0026gt; { const config = { headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;multipart/form-data\u0026#39; } } const fileType = req.file.type === \u0026#39;image/png\u0026#39; ? \u0026#39;png\u0026#39; : \u0026#39;jpg\u0026#39; // 重命名要上传的文件 const keyname = \u0026#39;blog\u0026#39; + new Date().getTime() + \u0026#39;.\u0026#39; + fileType axios.get(\u0026#39;/api/token/qiniu\u0026#39;).then(res =\u0026gt; { const formdata = new FormData() formdata.append(\u0026#39;file\u0026#39;, req.file) formdata.append(\u0026#39;token\u0026#39;, res.data) formdata.append(\u0026#39;key\u0026#39;, keyname) // 获取到凭证之后再将文件上传到七牛云空间 axios.post(\u0026#39;https://upload-z2.qiniup.com\u0026#39;, formdata, config).then((res) =\u0026gt; { imageUrl.value = \u0026#39;http://\u0026#39; + qiniuaddr + \u0026#39;/\u0026#39; + res.data.key emit(\u0026#39;upload\u0026#39;, imageUrl.value) // 向父组件传递图片的url }) }) } \u0026lt;/script\u0026gt; \u0026lt;style lang=\u0026#34;scss\u0026#34; scoped\u0026gt; .avatar-uploader .avatar { width: 360px; height: 180px; display: block; } .avatar-uploader :deep(.el-upload) { border: 1px dashed var(--el-border-color); border-radius: 6px; cursor: pointer; position: relative; overflow: hidden; transition: var(--el-transition-duration-fast); } .avatar-uploader :deep(.el-upload:hover) { border-color: var(--el-color-primary); } .el-icon.avatar-uploader-icon { font-size: 28px; color: #8c939d; width: 360px; height: 180px; text-align: center; } \u0026lt;/style\u0026gt; ","date":"2023-12-10T00:00:00Z","permalink":"/p/vue3%E5%B0%81%E8%A3%85el-upload/","title":"Vue3封装el-upload"},{"content":" https://leetcode.cn/problems/palindrome-linked-list/\n（1）将链表转化为数组进行比较 比较呆板的做法，空间复杂度为O(n)​。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Solution { public: bool isPalindrome(ListNode* head) { vector\u0026lt;int\u0026gt; arr; ListNode* p = head; while (p) { arr.push_back(p-\u0026gt;val); p = p-\u0026gt;next; } int n = arr.size(); for (int i = 0, j = n - 1; i \u0026lt; j; i++, j--) { if (arr[i] != arr[j]) return false; } return true; } }; （2）递归 链表也具有递归性质，二叉树也不过是链表的衍生。\n利用后序遍历的思想：\n先保存头结点（left，全局变量），然后递归至最后（最深）的结点（right），然后比较left​和right​的值；如果相等，由递归栈返回上一层（也即right向左走），再操作left向右走，这样就实现了left和right的双向奔赴。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution { private: ListNode* left_ = nullptr; bool Traverse(ListNode* right) { if (!right) return true; bool res = Traverse(right-\u0026gt;next); res = res \u0026amp;\u0026amp; (left_-\u0026gt;val == right-\u0026gt;val); left_ = left_-\u0026gt;next; return res; } public: bool isPalindrome(ListNode* head) { left_ = head; return Traverse(head-\u0026gt;next); } }; （3）优化递归 利用方法二，看似是没有使用到额外空间了，但实际上还有递归所带来的函数调用栈的开销，其空间复杂度也为O(n)​。\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 27 28 29 30 31 32 33 34 35 36 using ListNodePtr = ListNode*; class Solution { private: ListNode* Reverse(ListNode* head) { ListNodePtr cur = head, pre = nullptr; while (cur) { ListNodePtr ne = cur-\u0026gt;next; cur-\u0026gt;next = pre; pre = cur; cur = ne; } return pre; } public: bool isPalindrome(ListNode* head) { ListNodePtr fast = head, slow = head; while (fast \u0026amp;\u0026amp; fast-\u0026gt;next) { slow = slow-\u0026gt;next; fast = fast-\u0026gt;next-\u0026gt;next; } if (fast) slow = slow-\u0026gt;next; ListNodePtr left = head; ListNodePtr right = Reverse(slow); while (right) { if (left-\u0026gt;val != right-\u0026gt;val) return false; left = left-\u0026gt;next; right = right-\u0026gt;next; } return true; } }; ","date":"2023-11-10T00:00:00Z","permalink":"/p/%E9%93%BE%E8%A1%A8%E5%88%A4%E6%96%AD%E5%9B%9E%E6%96%87%E9%93%BE%E8%A1%A8/","title":"【链表】判断回文链表"}]