由 JVM Attach API 看跨进程通信中的信号和 Unix 域套接字原创
在 JDK5 中,开发者只能 JVM 启动时指定一个 javaagent 在 premain 中操作字节码,Instrumentation 也仅限于 main 函数执行前,这样的方式存在一定的局限性。从 JDK6 开始引入了动态 Attach Agent 的方案,除了在命令行中指定 javaagent,现在可以通过 Attach API 远程加载。我们常用的 jstack、arthas 等工具都是通过 Attach 机制实现的。
这篇会结合跨进程通信中的信号和 Unix 域套接字来看 JVM Attach API 的实现原理,
你将获得下面这些相关的知识
- 信号是什么
- 如何写一个不能被“轻易”杀死的程序
- Unix 域套接字的用法
- 利用神器 strace 来查看黑盒应用的内部调用过程
- JVM Attach API 的使用和过程详解
信号是什么
信号是某事件发生时对进程的通知机制,也被称为“软件中断”。信号可以看做是一种非常轻量级的进程间通信,信号由一个进程发送给另外一个进程,只不过是经由内核作为一个中间人发出,信号最初的目的是用来指定杀死进程的不同方式。
每个信号都一个名字,以 “SIG” 开头,最熟知的信号应该是 SIGINT,我们在终端执行某个应用程序的过程中按下 Ctrl+C 一般会终止正在执行的进程,正是因为按下 Ctrl+C 会发送 SIGINT 信号给目标程序。
每个信号都有一个唯一的数字标识,从 1 开始,下面是常见的信号量列表:
在 Linux 中,一个前台进程可以使用 Ctrl+C 进行终止,对于后台进程需要使用 kill 加进程号的方式来终止,kill 命令是通过发送信号给目标进程来实现终止进程的功能。默认情况下,kill 命令发送的是编号为 15 的 SIGTERM 信号,这个信号可以被进程捕获,选择忽略或正常退出。目标进程如何没有自定义处理这个信号,就会被终止。对于那些忽略 SIGTERM 信号的进程,则需要编号为 9 的 SIGKILL 信号强行杀死进程,SIGKILL 信号不能被忽略也不能被捕获和自定义处理。
下面写了一段 C 代码,自定义处理了 SIGQUIT、SIGINT、SIGTERM 信号
static void signal_handler(int signal_no) {
if (signal_no == SIGQUIT) {
printf("quit signal receive: %d\n", signal_no);
} else if (signal_no == SIGTERM) {
printf("term signal receive: %d\n", signal_no);
} else if (signal_no == SIGINT) {
printf("interrupt signal receive: %d\n", signal_no);
}
}
int main() {
signal(SIGQUIT, signal_handler);
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
for (int i = 0;; i++) {
printf("%d\n", i);
sleep(3);
}
}
编译运行上面的 signal.c 文件
./signal
这种情况下,在终端中Ctrl+C,kill -3,kill -15都没有办法杀掉这个进程,只能用kill -9
^Cinterrupt signal receive: 2 // Ctrl+C
1
2
term signal receive: 15 // kill pid
3
4
5
quit signal receive: 3 // kill -3
6
7
8
[1] 46831 killed ./signal // kill -9 成功杀死进程
JVM 对 SIGQUIT 的默认行为是打印所有运行线程的堆栈信息,在类 Unix 系统中,可以通过使用命令 kill -3 pid 来发送 SIGQUIT 信号。运行上面的 MyTestMain,使用 jps 找到整个 JVM 的进程 id,执行 kill -3 pid,在终端就可以看到打印了所有的线程的调用栈信息:
"Service Thread" #8 daemon prio=9 os_prio=31 tid=0x00007fe060821000 nid=0x4403 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
...
"Signal Dispatcher" #4 daemon prio=9 os_prio=31 tid=0x00007fe061008800 nid=0x3403 waiting on condition [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
"main" #1 prio=5 os_prio=31 tid=0x00007fe060003800 nid=0x1003 waiting on condition [0x000070000d203000]
java.lang.Thread.State: TIMED_WAITING (sleeping)
at java.lang.Thread.sleep(Native Method)
at java.lang.Thread.sleep(Thread.java:340)
at java.util.concurrent.TimeUnit.sleep(TimeUnit.java:386)
at MyTestMain.main(MyTestMain.java:10)
Unix 域套接字(Unix Domain Socket)
使用 TCP 和 UDP 进行 socket 通信是一种广为人知的 socket 使用方式,除了这种方式还有一种称为 Unix 域套接字的方式,可以实现同一主机上的进程间通信。虽然使用 127.0.01 环回地址也可以通过网络实现同一主机的进程间通信,但 Unix 域套接字更可靠、效率更高。Docker 守护进程(Docker daemon)使用了 Unix 域套接字,容器中的进程可以通过它与Docker 守护进程进行通信。MySQL 同样提供了域套接字进行访问的方式。
Unix 域套接字是什么?
Unix 域套接字是一个文件,通过 ls 命令可以看到
srwxrwxr-x. 1 ya ya 0 9月 8 00:26 tmp.sock
两个进程通过读写这个文件就实现了进程间的信息传递。文件的拥有者和权限决定了谁可以读写这个套接字。
与普通套接字的区别是什么?
- Unix 域套接字更加高效,Unix 套接字不用进行协议处理,不需要计算序列号,也不需要发送确认报文,只需要复制数据即可
- Unix 域套接字是可靠的,不会丢失报文,普通套接字是为不可靠通信设计的
- Unix 域套接字的代码可以非常简单的修改转为普通套接字
域套接字代码示例
下面是一个简单的 C 实现的域套接字的例子。注意:为了简化代码,文章中代码省略了错误的处理,完整的包含异常错误处理的代码见:github.com/arthur-zhan…
代码结构如下:
├── client.c
└── server.c
server.c 充当 Unix 域套接字服务器,启动后会在当前目录生成一个名为 tmp.sock 的 Unix 域套接字文件,它读取客户端写入的内容并输出。
int main() {
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "tmp.sock");
int ret = bind(fd, (struct sockaddr *) &addr, sizeof(addr));
listen(fd, 5)
int accept_fd;
char buf[100];
while (1) {
accept_fd = accept(fd, NULL, NULL)) == -1);
while ((ret = read(accept_fd, buf, sizeof(buf))) > 0) {
// 输出客户端传过来的数据
printf("receive %u bytes: %s\n", ret, buf);
}
}
客户端的代码如下:
int main() {
int fd = socket(AF_UNIX, SOCK_STREAM, 0);
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strcpy(addr.sun_path, "tmp.sock");
connect(fd, (struct sockaddr *) &addr, sizeof(addr)) == -1
int rc;
char buf[100];
// 读取终端标准输入的内容,写入到 Unix 域套接字文件中
while ((rc = read(STDIN_FILENO, buf, sizeof(buf))) > 0) {
write(fd, buf, rc);
}
}
在命令行中进行编译和执行
gcc client.c -o client
启动两个终端,一个启动 server 端,一个启动 client 端
./client
可以看到当前目录生成了一个 “tmp.sock” 文件
srwxrwxr-x. 1 ya ya 0 9月 8 00:08 tmp.sock
在 client 输入 hello,在 server 的终端就可以看到
receive 6 bytes: hello
JVM Attach API
JVM Attach API 基本使用
下面以一个实际的例子来演示动态 Attach API 的使用,代码中有一个 main 方法,每个 3s 输出 foo 方法的返回值 100,接下来动态 Attach 上 MyTestMain 进程,修改 foo 的字节码,让 foo 方法返回 50。
public static void main(String[] args) throws InterruptedException {
while (true) {
System.out.println(foo());
TimeUnit.SECONDS.sleep(3);
}
}
public static int foo() {
return 100; // 修改后 return 50;
}
}
步骤如下:
1、编写 Attach Agent,对 foo 方法做注入,完整的代码见:github.com/arthur-zhan…
动态 Attach 的 agent 与通过 JVM 启动 javaagent 参数指定的 agent jar 包的方式有所不同,动态 Attach 的 agent 会执行 agentmain 方法,而不是 premain 方法。
public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
System.out.println("agentmain called");
inst.addTransformer(new MyClassFileTransformer(), true);
Class classes[] = inst.getAllLoadedClasses();
for (int i = 0; i < classes.length; i++) {
if (classes[i].getName().equals("MyTestMain")) {
System.out.println("Reloading: " + classes[i].getName());
inst.retransformClasses(classes[i]);
break;
}
}
}
}
2、因为是跨进程通信,Attach 的发起端是一个独立的 java 程序,这个 java 程序会调用 VirtualMachine.attach 方法开始和目标 JVM 进行跨进程通信。
public static void main(String[] args) throws Exception {
VirtualMachine vm = VirtualMachine.attach(args[0]);
try {
vm.loadAgent("/path/to/agent.jar");
} finally {
vm.detach();
}
}
}
使用 jps 查询到 MyTestMain 的进程 id,
可以看到 MyTestMain 的输出的 foo 方法已经返回了 50。
100
100
100
agentmain called
Reloading: MyTestMain
50
50
50
JVM Attach API 的原理分析
执行 MyAttachMain,当指定一个不存在的 JVM 进程时,会出现如下的错误:
Exception in thread "main" java.io.IOException: No such process
at sun.tools.attach.LinuxVirtualMachine.sendQuitTo(Native Method)
at sun.tools.attach.LinuxVirtualMachine.<init>(LinuxVirtualMachine.java:91)
at sun.tools.attach.LinuxAttachProvider.attachVirtualMachine(LinuxAttachProvider.java:63)
at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208)
at MyAttachMain.main(MyAttachMain.java:8)
可以看到 VirtualMachine.attach 最终调用了 sendQuitTo 方法,这是一个 native 的方法,底层就是发送了 SIGQUIT 号给目标 JVM 进程。
前面信号部分我们介绍过,JVM 对 SIGQUIT 的默认行为是 dump 当前的线程堆栈,那为什么调用 VirtualMachine.attach 没有输出调用栈堆栈呢?
对于 Attach 的发起方,假设目标进程为 12345,这部分的详细的过程如下:
1、Attach 端检查临时文件目录是否有 .java_pid12345 文件
这个文件是一个 UNIX 域套接字文件,由 Attach 成功以后的目标 JVM 进程生成。如果这个文件存在,说明正在 Attach 中,可以用这个 socket 进行下一步的通信。如果这个文件不存在则创建一个 .attach_pid12345 文件,这部分的伪代码如下:
File socketFile = new File(tmpdir, ".java_pid" + pid);
if (socketFile.exists()) {
File attachFile = new File(tmpdir, ".attach_pid" + pid);
createAttachFile(attachFile.getPath());
}
2、Attach 端检查如果没有 .java_pid12345 文件,创建完 .attach_pid12345 文件以后发送 SIGQUIT 信号给目标 JVM。然后每隔 200ms 检查一次 socket 文件是否已经生成,5s 以后还没有生成则退出,如果有生成则进行 socket 通信
3、对于目标 JVM 进程而言,它的 Signal Dispatcher 线程收到 SIGQUIT 信号以后,会检查 .attach_pid12345 文件是否存在。
- 目标 JVM 如果发现 .attach_pid12345 不存在,则认为这不是一个 attach 操作,执行默认行为,输出当前所有线程的堆栈
- 目标 JVM 如果发现 .attach_pid12345 存在,则认为这是一个 attach 操作,会启动 Attach Listener 线程,负责处理 Attach 请求,同时创建名为 .java_pid12345 的 socket 文件,监听 socket。
源码中 /hotspot/src/share/vm/runtime/os.cpp 这一部分处理的逻辑如下:
static void signal_thread_entry(JavaThread* thread, TRAPS) {
while (true) {
int sig;
{
switch (sig) {
case SIGBREAK: {
// Check if the signal is a trigger to start the Attach Listener - in that
// case don't print stack traces.
if (!DisableAttachMechanism && AttachListener::is_init_trigger()) {
continue;
}
...
// Print stack traces
}
}
AttachListener 的 is_init_trigger 在 .attach_pid12345 文件存在的情况下会新建 .java_pid12345 套接字文件,同时监听此套接字,准备 Attach 端发送数据。
那 Attach 端和目标进程用 socket 传递了什么信息呢?可以通过 strace 的方式看到 Attach 端究竟往 socket 里面写了什么:
...
5841 [pid 3869] socket(AF_LOCAL, SOCK_STREAM, 0) = 5
5842 [pid 3869] connect(5, {sa_family=AF_LOCAL, sun_path="/tmp/.java_pid12345"}, 110) = 0
5843 [pid 3869] write(5, "1", 1) = 1
5844 [pid 3869] write(5, "\0", 1) = 1
5845 [pid 3869] write(5, "load", 4) = 4
5846 [pid 3869] write(5, "\0", 1) = 1
5847 [pid 3869] write(5, "instrument", 10) = 10
5848 [pid 3869] write(5, "\0", 1) = 1
5849 [pid 3869] write(5, "false", 5) = 5
5850 [pid 3869] write(5, "\0", 1) = 1
5855 [pid 3869] write(5, "/home/ya/agent.jar"..., 18 <unfinished ...>
可以看到往 socket 写入的内容如下:
\0
load
\0
instrument
\0
false
\0
/home/ya/agent.jar
\0
数据之间用 \0 字符分隔,第一行的 1 表示协议版本,接下来是发送指令 “load instrument false /home/ya/agent.jar” 给目标 JVM,目标 JVM 收到这些数据以后就可以加载相应的 agent jar 包进行字节码的改写。
如果从 socket 的角度来看,VirtualMachine.attach 方法相当于三次握手建连,VirtualMachine.loadAgent 则是握手成功之后发送数据,VirtualMachine.detach 相当于四次挥手断开连接。
这个过程如下图所示:
小结
这篇文章介绍了同一主机进程间通信的两种方式,信号和 Unix 域套接字,JVM 的 Attach 机制充分利用了信号和域套接字提供的功能,先创建一个临时文件,表示这是一个 attach 操作,然后发送SIGQUIT信号给目标进程,目标进程发现存在 attach 临时文件,则创建监听 Unix 域套接字文件,Attach 发起端就可以通过 socket 的 API 进行写入和读取数据了。