聊一聊Libuv 的信号机制

开发 前端
本文介绍 Libuv 是如何基于操作系统底层的能力实现信号模块的,看一下如何在 Libuv 中使用信号模块。

本文介绍 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 的调度。

责任编辑:姜华 来源: 编程杂技
相关推荐

2023-07-06 13:56:14

微软Skype

2020-09-08 06:54:29

Java Gradle语言

2011-07-05 18:40:19

QT 信号 机制

2021-01-28 22:31:33

分组密码算法

2023-09-22 17:36:37

2020-05-22 08:16:07

PONGPONXG-PON

2018-06-07 13:17:12

契约测试单元测试API测试

2021-08-01 09:55:57

Netty时间轮中间件

2023-09-27 16:39:38

2024-10-28 21:02:36

消息框应用程序

2024-07-16 10:52:09

2011-07-05 18:32:52

QT 信号 机制

2021-03-01 18:37:15

MySQL存储数据

2021-12-06 09:43:01

链表节点函数

2021-07-16 11:48:26

模型 .NET微软

2023-09-20 23:01:03

Twitter算法

2019-02-13 14:15:59

Linux版本Fedora

2018-11-29 09:13:47

CPU中断控制器

2021-01-29 08:32:21

数据结构数组

2021-02-06 08:34:49

函数memoize文档
点赞
收藏

51CTO技术栈公众号