Attach进程
实现目标:godbg attach -p <pid>
如果进程已经在运行了,如果要对其进行调试需要将进程挂住(attach),让其停下来等待调试器对其进行控制。
常见的调试器如dlv、gdb等都支持通过参数 -p pid
的形式来传递目标进程号来对运行中的进程进行调试。
我们将实现程序godbg,它支持子命令attach -p <pid>
,如果目标进程存在,godbg将attach到目标进程,此时目标进程会暂停执行。然后我们让godbg休眠几秒钟,再detach目标进程,目标进程会恢复执行。
ps: 这里休眠的几秒钟,用户可以先将其假想成一系列的调试操作,如设置断点、检查进程寄存器、检查内存等等,后面小节中我们将支持这些能力。
基础知识
tracee
首先要进一步明确tracee的概念,虽然我们看上去是对进程进行调试,实际上调试器内部工作时,是对一个一个的线程进行调试。
tracee,指的是被调试的线程,而不是进程。对于一个多线程程序而言,可能要跟踪(trace)部分或者全部的线程以方便调试,没有被跟踪的线程将会继续执行,而被跟踪的线程则受调试器控制。甚至同一个被调试进程中的不同线程,可以由不同的tracer来控制。
tracer
tracer,指的是向tracee发送调试控制命令的调试进程,准确地说,也是线程。
一旦tracer和tracee建立了联系之后,tracer就可以给tracee发送各种调试命令。
ptrace
我们的调试器示例是基于Linux平台编写的,调试能力依赖于Linux ptrace。
通常,如果调试器也是多线程程序,就要考虑将发送调试命令给特定tracee的task(如goroutine)绑定到特定线程上,因为从tracee角度而言,它认为调试命令应该来自同一个tracer(同一个线程)。
所以,在我们参考dlv等调试器的实现时会发现,发送调试命令的goroutine通常会调用runtime.LockOSThread()
来绑定一个线程,专门用来向attached tracee发送调试指令(也就是各种ptrace操作)。
runtime.LockOSThread(),将调用该函数的goroutine绑定到该操作系统线程上,意味着该操作系统线程只会用来执行该goroutine上的操作,除非该goroutine调用了runtime.UnLockOSThread()解除这种绑定关系,否则该线程不会用来调度其他goroutine。调用这个函数的goroutine也只能在当前线程上执行,不会被调度器迁移到其他线程。
调用了该函数之后,就可以满足tracee对tracer的要求:一旦tracer通过ptrace_attach了某个tracee,后续发送到该tracee的ptrace请求必须来自同一个tracer,tracee、tracer具体指的都是线程。
当我们调用了attach之后,attach返回时,tracee有可能还没有停下来,这个时候需要通过wait方法来等待tracee停下来,并获取tracee的状态信息。当结束调试时,可以通过detach操作,让tracee恢复执行。
下面是man手册关于ptrace操作attach、detach的说明,下面要用到:
**PTRACE_ATTACH**
*Attach to the process specified in pid, making it a tracee of*
*the calling process. The tracee is sent a SIGSTOP, but will*
*not necessarily have stopped by the completion of this call;*
use waitpid(2) to wait for the tracee to stop. See the "At‐ taching and detaching" subsection for additional information.
**PTRACE_DETACH**
*Restart the stopped tracee as for PTRACE_CONT, but first de‐*
*tach from it. Under Linux, a tracee can be detached in this*
*way regardless of which method was used to initiate tracing.*
代码实现
src详见:golang-debugger-lessons/1.1_cmd_attach
file: main.go
package main
import (
"fmt"
"os"
"os/exec"
"runtime"
"strconv"
"syscall"
"time"
)
const (
usage = "Usage: go run main.go exec <path/to/prog>"
cmdExec = "exec"
cmdAttach = "attach"
)
func main() {
// issue: https://github.com/golang/go/issues/7699
//
// 为什么syscall.PtraceDetach, detach error: no such process?
// 因为ptrace请求应该来自相同的tracer线程,
//
// ps: 如果恰好不是,可能需要对tracee的状态显示进行更复杂的处理,需要考虑信号?
// 目前看系统调用传递的参数是这样。
runtime.LockOSThread()
if len(os.Args) < 3 {
fmt.Fprintf(os.Stderr, "%s\n\n", usage)
os.Exit(1)
}
cmd := os.Args[1]
switch cmd {
case cmdExec:
prog := os.Args[2]
// run prog
progCmd := exec.Command(prog)
buf, err := progCmd.CombinedOutput()
fmt.Fprintf(os.Stdout, "tracee pid: %d\n", progCmd.Process.Pid)
if err != nil {
fmt.Fprintf(os.Stderr, "%s exec error: %v, \n\n%s\n\n", err, string(buf))
os.Exit(1)
}
fmt.Fprintf(os.Stdout, "%s\n", string(buf))
case cmdAttach:
pid, err := strconv.ParseInt(os.Args[2], 10, 64)
if err != nil {
fmt.Fprintf(os.Stderr, "%s invalid pid\n\n", os.Args[2])
os.Exit(1)
}
// check pid
if !checkPid(int(pid)) {
fmt.Fprintf(os.Stderr, "process %d not existed\n\n", pid)
os.Exit(1)
}
// attach
err = syscall.PtraceAttach(int(pid))
if err != nil {
fmt.Fprintf(os.Stderr, "process %d attach error: %v\n\n", pid, err)
os.Exit(1)
}
fmt.Fprintf(os.Stdout, "process %d attach succ\n\n", pid)
// wait
var (
status syscall.WaitStatus
rusage syscall.Rusage
)
_, err = syscall.Wait4(int(pid), &status, syscall.WSTOPPED, &rusage)
if err != nil {
fmt.Fprintf(os.Stderr, "process %d wait error: %v\n\n", pid, err)
os.Exit(1)
}
fmt.Fprintf(os.Stdout, "process %d wait succ, status:%v, rusage:%v\n\n", pid, status, rusage)
// detach
fmt.Printf("we're doing some debugging...\n")
time.Sleep(time.Second * 10)
// MUST: call runtime.LockOSThread() first
err = syscall.PtraceDetach(int(pid))
if err != nil {
fmt.Fprintf(os.Stderr, "process %d detach error: %v\n\n", pid, err)
os.Exit(1)
}
fmt.Fprintf(os.Stdout, "process %d detach succ\n\n", pid)
default:
fmt.Fprintf(os.Stderr, "%s unknown cmd\n\n", cmd)
os.Exit(1)
}
}
// checkPid check whether pid is valid process's id
//
// On Unix systems, os.FindProcess always succeeds and returns a Process for
// the given pid, regardless of whether the process exists.
func checkPid(pid int) bool {
out, err := exec.Command("kill", "-s", "0", strconv.Itoa(pid)).CombinedOutput()
if err != nil {
panic(err)
}
// output error message, means pid is invalid
if string(out) != "" {
return false
}
return true
}
这里的程序逻辑也比较简单:
- 程序运行时,首先检查命令行参数,
godbg attach <pid>
,至少有3个参数,如果参数数量不对,直接报错退出;- 接下来校验第2个参数,如果是无效的subcmd,也直接报错退出;
- 如果是attach,那么pid参数应该是个整数,如果不是也直接退出;
- 参数正常情况下,开始尝试attach到tracee;
- attach之后,tracee并不一定立即就会停下来,需要wait来获取其状态变化情况;
- 等tracee停下来之后,我们休眠10s钟,仿佛自己正在干些调试操作一样;
- 10s钟之后,tracer尝试detach tracee,让tracee继续恢复执行。
我们在Linux平台上实现时,需要考虑Linux平台本身的问题,具体包括:
- 检查pid是否对应着一个有效的进程,通常会通过
exec.FindProcess(pid)
来检查,但是在Unix平台下,这个函数总是返回OK,所以是行不通的。因此我们借助了kill -s 0 pid
这一比较经典的做法来检查pid合法性。 - tracer、tracee进行detach操作的时候,我们是用了ptrace系统调用,这个也和平台有关系,如Linux平台下的man手册有说明,必须确保一个tracee的所有的ptrace requests来自相同的tracer线程,实现时就需要注意这点。
代码测试
下面是一个测试示例,帮助大家进一步理解attach、detach的作用。
我们先在bash启动一个命令,让其一直运行,然后获取其pid,并让godbg attach将其挂住,观察程序的暂停、恢复执行。
比如,我们在bash里面先执行以下命令,它会每隔1秒打印一下当前的pid:
$ while [ 1 -eq 1 ]; do t=`date`; echo "$t pid: $$"; sleep 1; done
Sat Nov 14 14:29:04 UTC 2020 pid: 1311
Sat Nov 14 14:29:06 UTC 2020 pid: 1311
Sat Nov 14 14:29:07 UTC 2020 pid: 1311
Sat Nov 14 14:29:08 UTC 2020 pid: 1311
Sat Nov 14 14:29:09 UTC 2020 pid: 1311
Sat Nov 14 14:29:10 UTC 2020 pid: 1311
Sat Nov 14 14:29:11 UTC 2020 pid: 1311
Sat Nov 14 14:29:12 UTC 2020 pid: 1311
Sat Nov 14 14:29:13 UTC 2020 pid: 1311
Sat Nov 14 14:29:14 UTC 2020 pid: 1311 ==> 14s
^C
然后我们执行命令:
$ go run main.go attach 1311
process 1311 attach succ
process 1311 wait succ, status:4991, rusage:{{12 607026} {4 42304} 43580 0 0 0 375739 348 0 68224 35656 0 0 0 29245 153787}
we're doing some debugging... ==> 这里sleep 10s
执行完上述命令后,回来看shell命令的输出情况,可见其被挂起了,等了10s之后又继续恢复执行,说明detach之后又可以继续执行。
Sat Nov 14 14:29:04 UTC 2020 pid: 1311
Sat Nov 14 14:29:06 UTC 2020 pid: 1311
Sat Nov 14 14:29:07 UTC 2020 pid: 1311
Sat Nov 14 14:29:08 UTC 2020 pid: 1311
Sat Nov 14 14:29:09 UTC 2020 pid: 1311
Sat Nov 14 14:29:10 UTC 2020 pid: 1311
Sat Nov 14 14:29:11 UTC 2020 pid: 1311
Sat Nov 14 14:29:12 UTC 2020 pid: 1311
Sat Nov 14 14:29:13 UTC 2020 pid: 1311
Sat Nov 14 14:29:14 UTC 2020 pid: 1311 ==> at 14s, attached and stopped
Sat Nov 14 14:29:24 UTC 2020 pid: 1311 ==> at 24s, detached and continued
Sat Nov 14 14:29:25 UTC 2020 pid: 1311
Sat Nov 14 14:29:26 UTC 2020 pid: 1311
Sat Nov 14 14:29:27 UTC 2020 pid: 1311
Sat Nov 14 14:29:28 UTC 2020 pid: 1311
Sat Nov 14 14:29:29 UTC 2020 pid: 1311
^C
然后我们再看下我们调试器的输出,可见其attach、暂停、detach逻辑,都是正常的。
$ go run main.go attach 1311
process 1311 attach succ
process 1311 wait succ, status:4991, rusage:{{12 607026} {4 42304} 43580 0 0 0 375739 348 0 68224 35656 0 0 0 29245 153787}
we're doing some debugging...
process 1311 detach succ
更多相关内容
问题:多线程程序attach后仍在运行?
有读者可能会自己开发一个go程序作为被调试程序,期间可能会遇到多线程给调试带来的一些困惑,这里也提一下。
假如我使用下面的go程序做为被调试程序:
import (
"fmt"
"time"
"os"
)
func main() {
for {
time.Sleep(time.Second)
fmt.Println("pid:", os.Getpid())
}
}
结果发现执行了godbg attach <pid>
之后程序还在执行,这是为什么呢?
因为go程序天然是多线程程序,sysmon、gc等等都可能会用到独立线程,我们attach时只是简单的attach了pid对应进程的某一个线程,其他的线程仍然是没有被调试跟踪的,是可以正常执行的。
那我们ptrace时指定了pid到底attach了哪一个线程呢?这个pid对应的线程难道不是执行main.main的线程吗?先回答读者问题:没错,还真不一定是!
go程序中函数main.main是由main goroutine来执行的,但是main goroutine并没有和main thread存在任何默认的绑定关系。所以认为main.main一定运行在pid对应的线程之上是错误的!
ps:附录《go runtime: go程序启动流程》中对go程序的启动流程做了分析,可以帮读者朋友打消这里main.main、main goroutine、main thread的一些疑虑。
在Linux下,线程其实是通过轻量级进程(LWP)来实现的,这里的ptrace参数pid实际上是线程对应的LWP的进程id。只对进程pid进行ptrace attach操作,结果是将只有这个进程pid对应的线程会被调试跟踪。
在调试场景中,tracee指的是一个线程,而非一个进程包含的所有线程,尽管我们有时候为了描述方便,在术语上会选择倾向于使用进程。
一个多线程的进程,其实是可以理解成一个包含了多个线程的线程组,线程组中的线程在创建的时候都通过系统调用clone+参数CLONE_THREAD来创建,来保证所有新创建的线程拥有相同的pid,类似clone+CLONE_PARENT使得克隆出的所有子进程都有相同的父进程id一样。
golang里面通过clone系统调用以及如下选项来创建线程:
cloneFlags = _CLONE_VM | /* share memory */ _CLONE_FS | /* share cwd, etc */ _CLONE_FILES | /* share fd table */ _CLONE_SIGHAND | /* share sig handler table */ _CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */ _CLONE_THREAD /* revisit - okay for now */
关于clone选项的更多作用,您可以通过查看man手册
man 2 clone
来了解。
pid标识的线程(或LWP)与发送ptrace请求的线程(或LWP)二者之间建立ptrace link,它们的角色分别为tracee、tracer,后续tracee期望收到的所有ptrace请求都来自这个tracer。因为这个原因,天然就是多线程的go程序就需要保证实际发送ptrace请求的goroutine必须执行在同一个线程上。
被调试进程中如果有其他线程,仍然是可以运行的,这就是为什么我们某些读者发现有时候被调试程序仍然在不停输出,因为tracer并没有在main.main内部设置断点,执行该函数的main goroutine可能由其他未被trace的线程执行,所以仍然可以看到程序不停输出。
问题:想让执行main.main的线程停下来?
如果想让被调试进程停止执行,调试器需要枚举进程中包含的线程并对它们逐一进行ptrace attach操作。具体到Linux,可以列出/proc/<pid>/task
下的线程对应的LWP pid,逐个执行ptrace attach。
我们将在后续过程中进一步完善attach命令,使其也能胜任多线程环境下的调试工作。
问题:如何判断进程是否是多线程程序?
如何判断目标进程是否是多线程程序呢?有两种简单的办法帮助判断。
top -H -p pid
-H
选项将列出进程pid下的线程列表,以下进程5293下有4个线程,Linux下线程是通过轻量级进程实现的,PID列为5293的轻量级进程为主线程。$ top -H -p 5293 ........ PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 5293 root 20 0 702968 1268 968 S 0.0 0.0 0:00.04 loop 5294 root 20 0 702968 1268 968 S 0.0 0.0 0:00.08 loop 5295 root 20 0 702968 1268 968 S 0.0 0.0 0:00.03 loop 5296 root 20 0 702968 1268 968 S 0.0 0.0 0:00.03 loop
top展示信息中列S表示进程状态,常见的取值及含义如下:
'D' = uninterruptible sleep 'R' = running 'S' = sleeping 'T' = traced or stopped 'Z' = zombie
通过状态 'T' 可以识别多线程程序中哪些线程正在被调试跟踪。
ls /proc/<pid>/task
$ ls /proc/5293/task/ 5293/ 5294/ 5295/ 5296/
Linux下/proc是一个虚拟文件系统,它里面包含了系统运行时的各种状态信息,以下命令可以查看到进程5293下的线程。和top展示的结果是一样的。