本文介绍 Libuv 是如何基于操作系统底层的能力实现信号模块的,看一下如何在 Libuv 中使用信号模块。
#include "uv.h"
#include "stdio.h"
#include <unistd.h>
void signal_cb(uv_signal_t* handleint sig) {
printf("receive signal\n");
uv_signal_stop(handle);
}
int main() {
printf("%d\n", getpid());
fflush(stdout);
uv_loop_t loop;
uv_signal_t signal;
uv_loop_init(&loop);
uv_signal_init(&loop, &signal);
uv_signal_start(&signal, signal_cb, SIGUSR1);
uv_run(&loop, UV_RUN_DEFAULT);
return 0;
}
通过 gcc main.c -luv && ./a.out 编译执行上面代码,然后执行 kill -SIGUSR1 pid 给该进程发送信号,可以看到会输出 receive signal。接着来分析具体的实现。
初始化
Libuv 在初始化第一个事件循环结构体时会初始化信号处理的相关结构体。
void uv__signal_global_once_init(void) {
uv_once(&uv__signal_global_init_guard, uv__signal_global_init);
}
因为信号处理是支持多线程的,所以这里用了 uv_once 保证只执行一次 uv__signal_global_init。
static void uv__signal_global_init(void) {
if (uv__signal_lock_pipefd[0] == -1)
// 如果在子线程里调用了 fork,则需要在 fork 后的子进程调用 uv__signal_global_reinit 重新初始化相关数据结构
if (pthread_atfork(NULL, NULL, &uv__signal_global_reinit))
abort();
uv__signal_global_reinit();
}
继续看 uv__signal_global_reinit。
static void uv__signal_global_reinit(void) {
// 清除之前的状态
uv__signal_cleanup();
// 创建两个 fd 用于加锁 / 解锁,工作方式是阻塞模式
if (uv__make_pipe(uv__signal_lock_pipefd, 0))
abort();
// 修改锁为解锁状态
if (uv__signal_unlock())
abort();
}
初始化部分没有太多的逻辑,只是初始化一些数据结构。
加锁 / 解锁
因为 Libuv 用一棵全局的红黑树维护了信号和订阅者的关系,而多个线程可以访问这个全局的数据结构,所以需要加锁访问,接着看看 Libuv 的锁是怎么实现的,下面是加锁的实现。
static int uv__signal_lock(void) {
int r;
char data;
do {
r = read(uv__signal_lock_pipefd[0], &data, sizeof data);
} while (r < 0 && errno == EINTR);
return (r < 0) ? -1 : 0;
}
下面是解锁的实现。
static int uv__signal_unlock(void) {
int r;
char data = 42;
do {
r = write(uv__signal_lock_pipefd[1], &data, sizeof data);
} while (r < 0 && errno == EINTR);
return (r < 0) ? -1 : 0;
}
刚才介绍初始化过程时说到了 uv__signal_lock_pipefd 是一个通信管道,Libuv 的加锁解锁正是通过 uv__signal_lock_pipefd 实现的,管道初始化时会先写入一个字节表示处于解锁状态,加锁时会读出这一个字节,表示加锁成功,然后解锁时再次写入一个字节。因为读写一个字节是原子的,所以这就实现了加锁/解锁的能力,保证多线程访问时的安全问题。
那么为什么 Libuv 不使用传统的 mutex 来实现多线程安全访问呢?这里涉及到一个概念叫做异步信号安全,它表示一个函数可以安全地在信号处理函数中使用,因为信号是异步发生的并且信号处理函数具有非常高的优先级,假设进程正在执行 a 函数修改一些数据,突然收到信号然后执行信号处理函数,处理函数中又执行了 a 函数修改数据,这时候可能会导致问题。解决这个问题的方式通常有两种:
- 在信号处理函数里只调用异步信号安全的函数。
- 在执行非异步信号安全的函数时屏蔽信号,避免在信号处理函数里再次执行该函数。
因为 Libuv 在信号处理函数里需要访问全局数据结构,而 mutex 相关的函数不是异步信号安全的,所以不能使用 mutex 实现,而是通过 read / write 实现(它们是异步信号安全的函数)。
信号屏蔽
加锁解锁解决了多个线程访问全局数据结构的问题,但是还有一个问题是同线程的数据竞争访问问题?这里大家可能会好奇,单线程内的代码是顺序执行的,为什么会存在数据竞争访问?原因是信号机制的存在,比如我们正在执行 a 函数修改数据结构,突然收到了一个信号,然后在信号处理函数里又执行 a 函数修改数据结构,这样可能就会导致问题,所以在执行某些函数时需要先屏蔽信号,执行完后再允许接收信号。我们看看相关的处理逻辑。
static void uv__signal_block_and_lock(sigset_t* saved_sigmask) {
sigset_t new_mask;
// 把 new_mask 所有比特位设置为 1
if (sigfillset(&new_mask))
abort();
// 屏蔽(当前线程的)所有信号
if (pthread_sigmask(SIG_SETMASK, &new_mask, saved_sigmask))
abort();
// 加锁
if (uv__signal_lock())
abort();
}
为什么需要屏蔽所有信号呢?因为执行 uv__signal_block_and_lock 后,需要往操作系统注册信号处理函数,如果刚注册完信号处理函数,还没有执行 uv__signal_unlock 释放锁,这时候突然收到一个信号,然后在信号处理函数中又尝试加锁则会导致死锁。过程大致如下:
- 加锁成功,注册信号处理函数到操作系统。
- 时钟中断触发,触发进程调度,当前进程事件片还没到,继续执行。
- 进程调度完后,发现有信号需要处理,然后执行信号处理函数。
- 信号处理函数尝试加锁,但是锁已经被持有,然后进入等待状态。
- 因为信号处理函数没有返回,导致后续的代码无法执行,进程因为无法进行解锁操作,最终陷入死锁。
初始化信号结构体
int uv_signal_init(uv_loop_t* loop, uv_signal_t* handle) {
int err;
err = uv__signal_loop_once_init(loop);
if (err)
return err;
uv__handle_init(loop, (uv_handle_t*) handle, UV_SIGNAL);
handle->signum = 0;
handle->caught_signals = 0;
handle->dispatched_signals = 0;
return 0;
}
初始化的逻辑很简单,只是做一些字段的初始化,但是有一个比较重要的逻辑是 uv__signal_loop_once_init。
static int uv__signal_loop_once_init(uv_loop_t* loop) {
int err;
// 已经初始化过了,直接返回
if (loop->signal_pipefd[0] != -1)
return 0;
// 创建一个非阻塞模式的通信管道
err = uv__make_pipe(loop->signal_pipefd, UV_NONBLOCK_PIPE);
if (err)
return err;
// 初始化 IO 观察者
uv__io_init(&loop->signal_io_watcher,
uv__signal_event,
loop->signal_pipefd[0]);
// 注册 IO 观察者
uv__io_start(loop, &loop->signal_io_watcher, POLLIN);
return 0;
}
uv__signal_loop_once_init 的作用是创建一个通信管道,然后注册一个 IO 观察者到事件循环中,当收到信号时,信号处理函数就会通过这个管道通知事件循环,事件循环会在某个阶段通知信号的订阅者。
订阅信号
订阅信号可以通过下面两个函数。
int uv_signal_start(uv_signal_t* handle, uv_signal_cb signal_cb, int signum) {
return uv__signal_start(handle, signal_cb, signum, 0);
}
int uv_signal_start_oneshot(uv_signal_t* handle,
uv_signal_cb signal_cb,
int signum) {
return uv__signal_start(handle, signal_cb, signum, 1);
}
最终是由 uv__signal_start 实现的,其实 oneshot 表示最多只执行一次信号处理函数。
static int uv__signal_start(uv_signal_t* handle,
uv_signal_cb signal_cb,
int signum,
int oneshot) {
sigset_t saved_sigmask;
int err;
uv_signal_t* first_handle;
// 之前已经监听过这个信号,这里只需要更新下回调就行
if (signum == handle->signum) {
handle->signal_cb = signal_cb;
return 0;
}
// 如果之前监听过了,先删除,比如同一个 handle 监听了另一个信号
if (handle->signum != 0) {
uv__signal_stop(handle);
}
uv__signal_block_and_lock(&saved_sigmask);
// 注册信号,待会分析
uv__signal_unlock_and_unblock(&saved_sigmask);
return 0;
}
uv__signal_start 首先做了一些前置判断,然后调 uv__signal_block_and_lock 加锁和屏蔽所有的信号,加锁主要是准备要修改共享的数据结构,避免多线程引起的问题,屏蔽所有的信号则是因为信号处理函数也会访问这个数据结构,所以需要避免它的执行,否则会引起死锁问题。接着分析信号注册的逻辑。
// 查找这个信号对应的 handle
first_handle = uv__signal_first_handle(signum);
// 还没注册过则直接注册
// 之前注册过且设置了 UV_SIGNAL_ONE_SHOT 标记,但是当前注册的还没有设置 UV_SIGNAL_ONE_SHOT 则注册
if (first_handle == NULL ||
(!oneshot && (first_handle->flags & UV_SIGNAL_ONE_SHOT))) {
uv__signal_register_handler(signum, oneshot);
}
// oneshot 表示订阅者最多只被通知一次
if (oneshot)
handle->flags |= UV_SIGNAL_ONE_SHOT;
// 插入红黑树
RB_INSERT(uv__signal_tree_s, &uv__signal_tree, handle);
handle->signum = signum;
handle->signal_cb = signal_cb;
uv__handle_start(handle);
注册信号包括两步。
第一步是注册到操作系统。
static int uv__signal_register_handler(int signum, int oneshot) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
if (sigfillset(&sa.sa_mask))
abort();
// 设置信号处理函数
sa.sa_handler = uv__signal_handler;
sa.sa_flags = SA_RESTART;
// oneshot 则设置 SA_RESETHAND,操作系统执行完信号处理函数后会重置为默认处理行为
if (oneshot)
sa.sa_flags |= SA_RESETHAND;
// 注册到操作系统
if (sigaction(signum, &sa, NULL))
return UV__ERR(errno);
return 0;
}
uv__signal_register_handler 实现了信号的注册,Libuv 并不是每次注册信号时都会执行 uv__signal_register_handler,而是做了一个优化,只有满足两个条件才会注册信号到操作系统。
1.还没注册过信号:这个是很自然的逻辑,不需要分析。
2.!oneshot && (first_handle->flags & UV_SIGNAL_ONE_SHOT)):
a.oneshot 为 true:则不论之前的 handle 是否设置了 UV_SIGNAL_ONE_SHOT 都不需要调操作系统进行注册了,因为之前已经注册过了,并且保证设置了 UV_SIGNAL_ONE_SHOT 的 handle 可以被执行。
b.oneshot 为 false,first_handle->flags & UV_SIGNAL_ONE_SHOT 为 false:之前的 handle 没有设置 UV_SIGNAL_ONE_SHOT,则也不需要调操作系统注册信号了,因为之前已经注册过了,并且保证所有的 handle 可以触发多次。
c.oneshot 为 false,first_handle->flags & UV_SIGNAL_ONE_SHOT 为 true:如果之前注册的信号设置了 UV_SIGNAL_ONE_SHOT 但是本次需要注册的没有设置该 flag,则需要调用 uv__signal_register_handler 重新进行注册,因为设置了 UV_SIGNAL_ONE_SHOT 的话操作系统执行完一次自定义的信号处理函数后就不会再执行了,这样会导致没有设置 UV_SIGNAL_ONE_SHOT 的订阅者得不到通知。
大家可能会疑惑,这里为什么只需要判断第一个 handle,因为红黑树的查找时会先找没有设置 UV_SIGNAL_ONE_SHOT 的 handle,然后再找设置了 UV_SIGNAL_ONE_SHOT 的 handle,所以如果找到的第一个 handle 设置了 UV_SIGNAL_ONE_SHOT,那说明所有 handle 都设置了 UV_SIGNAL_ONE_SHOT。
第一步注册完信号后,第二步是注册到 Libuv 维护的红黑树,因为一个信号最多只能注册一个处理函数,为了支持一个信号可以有多个订阅者,Libuv 自己维护了订阅者,然后把信号处理函数统一注册为 uv__signal_handler,然后在收到信号时再由 uv__signal_handler 进行处理和分发信号。
停止订阅信号
停止订阅信号的最终实现函数是 uv__signal_stop。
static void uv__signal_stop(uv_signal_t* handle) {
sigset_t saved_sigmask;
uv_signal_t* first_handle;
int rem_oneshot;
int first_oneshot;
uv__signal_block_and_lock(&saved_sigmask);
// 从红黑树中删除该 handle
RB_REMOVE(uv__signal_tree_s, &uv__signal_tree, handle);
// 找到第一个订阅了该信号的 handle
first_handle = uv__signal_first_handle(handle->signum);
// 没有订阅者了,则告诉操作系统收到该信号时不需要通知 Libuv 了
if (first_handle == NULL) {
uv__signal_unregister_handler(handle->signum);
} else {
// 判断是否设置了 UV_SIGNAL_ONE_SHOT
rem_oneshot = handle->flags & UV_SIGNAL_ONE_SHOT;
first_oneshot = first_handle->flags & UV_SIGNAL_ONE_SHOT;
// 如果剩下的 handle 设置了 UV_SIGNAL_ONE_SHOT,但是当前被删除的 handle 没有
// 设置 UV_SIGNAL_ONE_SHOT 则需要重新注册信号处理函数为 oneshot
if (first_oneshot && !rem_oneshot) {
uv__signal_register_handler(handle->signum, 1);
}
}
uv__signal_unlock_and_unblock(&saved_sigmask);
handle->signum = 0;
uv__handle_stop(handle);
}
如果 first_oneshot 为 true 说明剩下的 handle 都是设置了 UV_SIGNAL_ONE_SHOT,如果 first_oneshot 为 true 并且 rem_oneshot 为 false 说明目前注册到操作系统的信号函数没有设置 oneshot,因为只要有一个 handle 没有设置UV_SIGNAL_ONE_SHOT,那么注册到操作系统的信号处理函数都不会设置 oneshot 标记,这时候需要修改重新更新信号处理函数为 oneshot。
信号的处理
从前面的分析可以看到,信号的处理函数统一设置为 uv__signal_handler,所以收到信号时,操作系统就会执行 uv__signal_handler。
// signum 为收到的信息
static void uv__signal_handler(int signum) {
uv__signal_msg_t msg;
uv_signal_t* handle;
int saved_errno;
saved_errno = errno;
memset(&msg, 0, sizeof msg);
// 需要加锁,避免另一个线程在修改红黑树
uv__signal_lock();
// 找出 signum 对应的订阅者
for (handle = uv__signal_first_handle(signum);
handle != NULL && handle->signum == signum;
handle = RB_NEXT(uv__signal_tree_s, &uv__signal_tree, handle)) {
int r;
msg.signum = signum;
msg.handle = handle;
// 写入消息通知事件循环
do {
r = write(handle->loop->signal_pipefd[1], &msg, sizeof msg);
} while (r == -1 && errno == EINTR);
}
uv__signal_unlock();
errno = saved_errno;
}
收到信号时并不是直接通知订阅者,而是通知事件循环,在事件循环的某个阶段才会真正通知订阅者。通知事件循环的方式是通过写入多个消息到管道中,事件循环在 Poll IO 阶段就会判断这个管道可读,从而读出所有的消息进行处理。前面介绍初始化信号结构体时说过,第一次初始化时会 uv__signal_loop_once_init 往事件循环中注册一个 IO 观察者,对应的处理函数是 uv__signal_event。
static void uv__signal_event(uv_loop_t* loop,
uv__io_t* w,
unsigned int events) {
uv__signal_msg_t* msg;
uv_signal_t* handle;
char buf[sizeof(uv__signal_msg_t) * 32];
size_t bytes, end, i;
int r;
bytes = 0;
end = 0;
do {
// 读出消息
r = read(loop->signal_pipefd[0], buf + bytes, sizeof(buf) - bytes);
bytes += r;
end = (bytes / sizeof(uv__signal_msg_t)) * sizeof(uv__signal_msg_t);
// 逐个处理消息
for (i = 0; i < end; i += sizeof(uv__signal_msg_t)) {
msg = (uv__signal_msg_t*) (buf + i);
handle = msg->handle;
// 执行回调
if (msg->signum == handle->signum) {
handle->signal_cb(handle, handle->signum);
}
// 设置了 UV_SIGNAL_ONE_SHOT,则解除订阅关系
if (handle->flags & UV_SIGNAL_ONE_SHOT)
uv__signal_stop(handle);
}
bytes -= end;
if (bytes) {
memmove(buf, buf + end, bytes);
continue;
}
} while (end == sizeof buf);
}
信号的使用
信号的处理具有非常高的优先级,这个能力在很多场景下非常有用。下面看一个简单的场景。
#include <signal.h>
#include <unistd.h>
#include <stdio.h>
void handler(int s) {
printf("receive signal\n");
}
int main () {
printf("%d\n", getpid());
fflush(stdout);
signal(SIGUSR1, handler);
while(1) {}
return 0;
}
通过 gcc main.c -luv && ./a.out 编译执行上面代码,然后再执行 kill -SIGUSR1 pid(执行 ./a.out 输出的 pid),可以看到会输出 receive signal,也就是说,尽管进程处于死循环,信号机制依然可以正常工作。下面继续来看一下两个具体的应用场景。
第一个是在 Node.js 中。假设业务中有以下一段代码。
console.log(process.pid);
function a() {
while(1) {
b();
}
}
function b() {
while(1) {}
}
a();
有一天我们发现服务的某个进程处于 100% 了,那么我们应该如何排查呢?我们知道 Node.js 是单线程的,JS 线程处于死循环时,是无法处理外部进来的请求了,也就意味着我们不能手动采集 CPU Profile 了。这时候信号机制的作用就来了,我们找到这个进程的 pid,然后执行 kill -SIGUSR1 pid 会发现 Node.js 的调试器(本质上是创建了一个线程监听了一个端口)被打开了,通过 Chrome Dev Tools 连接上调试器我们就可以采集 CPU Profile 了(重点是打开调试器,采集方式很多种)。结果如下。
可以看到通过 Profile 我们就可以轻松分析出是哪里的代码导致了死循环,从而快速解决业务中的问题。
接着再看一个 GO 的例子。
package main
import (
"fmt"
"runtime"
)
func main() {
// 设置只有单个线程
runtime.GOMAXPROCS(1)
go func() {
for {
fmt.Println("worker goroutine")
}
}()
for {
}
}
在 go1.13 下执行上面的代码,没有任务输出,然后切换到 go1.23 再试试(可以通过 gvm 管理 Go 版本),可以看到不断输出 worker goroutine。为什么会这样呢?Go 虽然通过协程原生支持了并发,但是在单个线程中,如果一个 goroutine 正在执行时,其他 goroutine 是无法执行的,Go 为了避免 goroutine 饥饿问题,实现了抢占机制,但是早期实现的是基于协作式的抢占机制(比如 go1.13 版本),协作式抢占的原理是在函数中插入抢占代码,goroutine 执行到函数时 Go runtime 会判断 goroutine 的事件片是不是用完了,用完了则进行调度,这种抢占机制受限于函数,如果我们不执行函数的话就绕过了这个检测,比如上面的例子,鉴于这个限制,Go 现在已经实现了基于信号的抢占式机制(比如 go1.23 版本),基于信号的抢占式机制正式使用了信号高优先级的能力,尽管 goroutine 处于死循环,Go runtime 依然有能力介入,从而实现 goroutine 的调度。