Skynet专题之:信号

JavenLaw

信号在Linux编程中比较常见,在 POSIX 系统中,信号是用于通知进程发生了某个事件的机制

当进程接收到信号时,可以根据信号的类型和处理方式来采取相应的行动

如何使用信号

下面就根据在Skynet中的代码来分析以下信号的使用

sigign()函数在 skynet_main.c 文件中,第131行开始调用

// 信号设置函数
int sigign() {
	struct sigaction sa;
	sa.sa_handler = SIG_IGN;
	sa.sa_flags = 0;
	sigemptyset(&sa.sa_mask);
	sigaction(SIGPIPE, &sa, 0);
	return 0;
}


sigaction结构体

第一,先看看 struct sigaction 结构体是怎么样的

// struct sigaction 结构体定义了信号的处理方式和相关的属性
// 用于设置和处理信号的行为,包括信号处理函数、信号的屏蔽集合和标志等
struct sigaction {
    void (*sa_handler)(int); // 信号处理函数
    void (*sa_sigaction)(int, siginfo_t *, void *); // 信号处理函数(扩展版本)
    sigset_t sa_mask; // 信号屏蔽集合
    int sa_flags; // 信号处理标志
    void (*sa_restorer)(void); // 用于恢复信号处理的函数(已弃用)
};

// 下面依次解释字段的含义和作用
// sa_handler:指向信号处理函数的指针,用于处理接收到的信号。当信号发生时,系统会调用指定的信号处理函数来处理该信号
// sa_sigaction:扩展版本的信号处理函数指针,可以接收更多的信号相关信息,如信号的来源和附加数据等
// sa_mask:用于设置信号处理函数执行期间要屏蔽的信号集合。在信号处理函数执行期间,可以通过设置 sa_mask 来阻塞其他信号的传递
// sa_flags:用于设置信号处理的标志,例如设置为 SA_RESTART 可以使被信号中断的系统调用自动重启
// sa_restorer:已弃用的字段,用于指定在信号处理函数执行完毕后恢复信号处理的函数


sa_handler

第二,看看 sa.sa_handler 的作用

sa_handler 用于指定一个信号处理程序,当特定信号到达时,操作系统将调用这个信号处理程序函数

sa_handler 的类型是一个函数指针,指向一个接受一个整数参数(信号编号)并返回 void 的函数

// sa_handler 函数例子
// 它的主要作用是指定当信号到达时要执行的函数
void handle_signal(int signal) {
    printf("Received signal %d\n", signal); 
} 

// 赋值给 sa_handler,这样信号到达时,操作系统就会执行 handle_signal()
sa.sa_handler = handle_signal;


信号的类型

第三,看看信号的类型

  1. SIGHUP (1) - 终端挂起或控制进程终止
  2. SIGINT (2) - 键盘中断 (通常是 Ctrl+C)
  3. SIGQUIT (3) - 键盘退出 (通常是 Ctrl+)
  4. SIGILL (4) - 非法指令
  5. SIGTRAP (5) - 跟踪/断点捕获
  6. SIGABRT (6) - 来自 abort(3) 的中止信号
  7. SIGBUS (7) - 总线错误 (内存访问错误)
  8. SIGFPE (8) - 浮点异常
  9. SIGKILL (9) - 杀死进程的信号 (不能被捕获或忽略)
  10. SIGUSR1 (10) - 用户定义信号 1
  11. SIGSEGV (11) - 无效的内存引用
  12. SIGUSR2 (12) - 用户定义信号 2
  13. SIGPIPE (13) - 管道破裂:对无读取者的管道进行写操作
  14. SIGALRM (14) - 由 alarm(2) 设置的定时器到期
  15. SIGTERM (15) - 终止信号
  16. SIGSTKFLT (16) - 协处理器上的栈故障 (未使用)
  17. SIGCHLD (17) - 子进程停止或终止
  18. SIGCONT (18) - 如果停止则继续
  19. SIGSTOP (19) - 停止进程 (不能被捕获或忽略)
  20. SIGTSTP (20) - 终端上输入的停止信号 (通常是 Ctrl+Z)
  21. SIGTTIN (21) - 后台进程尝试从终端读取
  22. SIGTTOU (22) - 后台进程尝试向终端写入
  23. SIGURG (23) - 套接字上的紧急条件 (4.2BSD)
  24. SIGXCPU (24) - 超过 CPU 时间限制 (4.2BSD)
  25. SIGXFSZ (25) - 超过文件大小限制 (4.2BSD)
  26. SIGVTALRM (26) - 虚拟定时器到期 (4.2BSD)
  27. SIGPROF (27) - 分析定时器到期
  28. SIGWINCH (28) - 窗口大小改变信号 (4.3BSD, Sun)
  29. SIGIO (29) - I/O 现在可以进行 (4.2BSD)
  30. SIGPWR (30) - 电源故障 (System V)
  31. SIGSYS (31) - 错误的系统调用 (SVr4)

但通常我们不需要记忆这么多,只要了解知道即可,使用的时候再查看


SIG_IGN

第四,sa_handler = SIG_IGN 被赋值为 SIG_IGN 是什么意思

SIG_IGN 是一个特殊的信号处理程序常量,用于忽略特定的信号

这意味着当指定的信号到达时,系统不会对该信号进行任何处理,程序也不会中断或受到干扰

使用这种方式可以屏蔽和忽略不希望处理的信号

SIG_IGN 可以用来忽略大多数信号,但有一些信号是不能被忽略的

以下是一些可以被 SIG_IGN 忽略的常见信号:

​ SIGINT (2) :键盘中断信号

​ SIGTERM (15) :终止信号

​ SIGQUIT (3) :键盘退出信号

​ SIGPIPE (13) :管道破裂信号

​ SIGALRM (14) :定时器信号

​ SIGUSR1 (10) :用户定义信号1

​ SIGUSR2 (12) :用户定义信号2

​ SIGHUP (1) :挂起信号

​ SIGCHLD (17) :子进程状态改变信号

​ SIGCONT (18) :继续信号

然而,有两个重要的信号不能被忽略:

​ SIGKILL (9) :杀死进程信号(不能被捕获或忽略)

​ SIGSTOP (19) :停止进程信号(不能被捕获或忽略)

这些信号是由操作系统强制处理的,不能通过设置 SIG_IGN 来忽略

除了 SIG_IGN 之外,还有什么别的设置呢?

​ SIG_DFL:默认的信号处理程序

​ 使用该常量可以将信号的处理方式恢复为系统默认的处理方式

​ 默认处理通常是终止进程,但具体行为取决于信号类型


sa_flags

sa_flags 是 struct sigaction 用于指定信号处理程序的行为

它是一个整数,包含一些标志位,可以修改信号处理程序的行为

把 sa_flags 中设置为 0 表示没有设置任何标志位,这意味着使用默认的信号处理行为

具体来说,如果将 sa_flags 设置为 0,那么信号处理程序将会按照系统默认的方式来处理信号,通常情况下是终止进程

这种情况下,struct sigaction 中的其他字段,如 sa_handler 或 sa_sigaction 的设置就变得尤为重要

因为它们决定了信号被捕获后的具体处理方式

一些常见的标志包括:

​ SA_RESETHAND:在信号处理程序被调用后将其重置为默认操作

​ SA_NODEFER:在信号处理程序执行时防止信号被阻塞

​ SA_RESTART:导致某些系统调用在被信号中断后重新启动

​ SA_SIGINFO:指定信号处理程序接受三个参数而不是一个,允许其访问有关信号的其他信息

如何理解以上标志呢?

SA_RESETHAND:

​ 当设置了 SA_RESETHAND 标志位时,表示在信号处理程序被调用后,将自动将该信号的处理程序重置为默认操作

​ 这意味着,当处理程序被调用并执行完毕后,系统会自动将信号的处理方式恢复为默认的行为,通常是终止进程

​ 这种行为对于一次性信号处理很有用,即当信号处理程序只需执行一次并且不希望影响后续信号处理时

​ 例如,对于 SIGINT(Ctrl+C)信号,如果希望在第一次接收到信号时执行自定义操作

​ 但之后再次接收到 SIGINT 信号时希望恢复默认行为(即终止进程),可以使用 SA_RESETHAND 标志位

SA_NODEFER:

​ 用于指定在信号处理程序执行期间是否阻止信号被阻塞

​ 当设置了 SA_NODEFER 标志时,在信号处理程序执行期间,相同的信号不会被阻塞,即可以重入信号处理程序

​ 通常情况下,当一个信号处理程序正在执行时,相同的信号会被阻塞,直到当前处理程序执行完毕

​ 这种行为可以防止同一信号在处理程序执行期间被再次触发,避免出现递归调用

​ 但是,如果你希望在信号处理程序执行期间能够接收到相同的信号,你可以使用 SA_NODEFER 标志

​ 这在某些情况下是很有用的,比如处理实时信号时需要确保及时响应

SA_RESTART:

​ 用来指示在被信号中断后是否重新启动被中断的系统调用

​ 当设置了 SA_RESTART 标志位时,如果一个系统调用被信号中断,操作系统会自动重新启动该系统调用,而不是返回一个 EINTR 错误

​ 通常用于希望系统调用在被信号中断后自动重试的情况,以减少编程复杂性

​ 例如,如果一个进程在读取文件时被 SIGINT 中断

​ 如果没有设置 SA_RESTART,那么 read 系统调用会返回 -1,并且 errno 会被设置为 EINTR,程序需要处理这种情况

​ 而如果设置了 SA_RESTART,read 调用会在信号处理完成后自动重新启动

​ 需要注意的是,并非所有的系统调用都会受到 SA_RESTART 的影响

​ 因为某些系统调用在被中断后不能自动重试(比如 accept、select 等)

SA_SIGINFO:

​ 用于指定信号处理程序接受三个参数而不是一个。这三个参数分别是:

​ int signum:表示触发信号处理程序的信号编号

​ siginfo_t *info:一个指向 siginfo_t 结构的指针,该结构包含了有关信号的详细信息,比如信号的发送者进程 ID、发送时的时间戳等

​ void *context:一个指向 ucontext_t 结构的指针,该结构包含了信号处理程序的上下文信息,比如处理程序中断前的寄存器状态等

​ 通过使用 SA_SIGINFO 标志位,信号处理程序可以访问到更多关于信号的信息,这在某些情况下可能会很有用

​ 例如,可以利用这些信息来实现更复杂的信号处理逻辑,或者用于调试目的

​ 需要注意的是,使用 SA_SIGINFO 标志位时,信号处理程序的定义方式也会略有不同,需要按照带有三个参数的形式来定义

​ 例如前面 sa_handler 说的函数:

​ void handle_signal(int signal) {}


sa_mask

sa_mask 是一个用于指定在执行信号处理程序时需要屏蔽的信号集

通过调用 sigemptyset(&sa.sa_mask),我们可以将 sa.sa_mask 初始化为空集

表示在执行信号处理程序时不屏蔽任何信号


sigemptyset()

看看 sigemptyset(&sa.sa_mask) 的作用

sigemptyset(&sa.sa_mask) 是一个函数调用,用于将信号屏蔽集合 sa.sa_mask 清空,即移除其中的所有信号

移除所有信号后,代表着此时接受所有类型的信号,等待着后面重新添加 在 POSIX 系统中,信号屏蔽集合用于指定在执行信号处理函数期间要屏蔽(阻塞)的信号 通过将信号添加到屏蔽集合中,可以防止在信号处理函数执行期间再次接收到同样的信号 你不能使用这种机制来阻止SIGKILL、SIGSTOP、SIGTRACE


sigaction()

作用是用于注册一个信号处理函数,以便在接收到指定信号时执行相应的操作

通过 sigaction() 函数,你可以指定一个函数来处理信号,还可以设置一些标志来控制信号处理的行为

// sigaction(SIGPIPE, &sa, 0) 是一个函数调用,用于设置对 SIGPIPE 信号的处理方式
// SIGPIPE 信号是在进程尝试向一个已关闭的管道或套接字写入数据时产生的信号
// 可以看信号的类型13

// 函数原型
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact)

// signum 是要设置处理方式的信号编号,这里是 SIGPIPE
//
// act 是一个指向 struct sigaction 结构体的指针,用于指定信号的处理方式
// 
// oldact 是一个指向 struct sigaction 结构体的指针,用于获取之前的信号处理方式
// 通过它,可以获取之前对同一信号注册的处理函数和标志
// 当你调用 sigaction() 函数再次设置新的信号处理方式时,如果传递了非空的 oldact 参数
// 系统会将之前的信号处理方式存储在该结构体中,以便你可以在之后需要时进行参考
// 传递的是 0,表示不获取之前的处理方式

// 通过调用 sigaction(SIGPIPE, &sa, 0),将会设置对 SIGPIPE 信号的处理方式为 sa 所指定的方式
// sa 是一个 struct sigaction 结构体,其中包含了信号处理函数和其他相关的设置,就是我们前面建立的 struct sigaction sa


总结

总结一下

1,首先建立了一个 struct sigaction sa 结构体,用于设置信号处理方式

2,我们因为接受所有类型的信号,因此把 信号屏蔽集合 全部置为空:sigemptyset(&sa.sa_mask)

3,接着设置对 SIGPIPE 信号的处理办法:sigaction(SIGPIPE, &sa, 0)

4,处理方式就是直接忽略:sa.sa_handler = SIG_IGN

5,至于其他信号到了,将会使用默认的处理方式


SIGPIPE信号的使用

sigign()函数在 skynet_main.c 文件中,第131行被调用

// 信号设置函数
int sigign() {
	struct sigaction sa;
	sa.sa_handler = SIG_IGN;
	sa.sa_flags = 0;
	sigemptyset(&sa.sa_mask);
	sigaction(SIGPIPE, &sa, 0);
	return 0;
}

SIGPIPE 信号是在进程试图向一个已经关闭的管道或套接字写数据时,操作系统发给该进程的信号

​ 1,向已关闭的管道写入数据:当管道的读取端关闭时,写入端继续写入数据

​ 2,向已关闭的套接字写入数据:当对端关闭了连接,进程仍然尝试写入数据

进程对 SIGPIPE 信号的默认操作是关闭程序,显然我们不希望程序因为简单的问题被突然终止,因此设置为:

​ sa.sa_handler = SIG_IGN;


SIGHUP信号的使用

在 skynet_start.c 文件中,第259行也有:

// register SIGHUP for log file reopen
struct sigaction sa;
sa.sa_handler = &handle_hup;
sa.sa_flags = SA_RESTART;
sigfillset(&sa.sa_mask);
sigaction(SIGHUP, &sa, NULL);

// 在前面,即 skynet_main.c 文件中,第131行的sigign()函数
// 首先调用了 sigign() 函数将 SIGPIPE 信号的处理方式设置为忽略,将导致 SIGPIPE 信号被忽略,不会中断程序的执行
// 
// 然后这里创建了一个新的 struct sigaction 结构体 sa
// 将其 sa_handler 成员设置为指向 handle_hup 函数的指针
// 并将 sa_flags 设置为 SA_RESTART
// 最后使用 sigfillset(&sa.sa_mask) 将信号屏蔽集合 sa.sa_mask 清空
// 并调用 sigaction() 函数将 SIGHUP 信号的处理方式设置为 sa
// 最后的结果:SIGPIPE 信号的处理方式不会被覆盖,它仍然将保持为忽略,而 SIGHUP 信号的处理方式将被设置为 handle_hup 函数
// 
// 并且因为 SIGPIPE 信号设置的是:sigemptyset(&sa.sa_mask),因此在处理 SIGPIPE 信号时,不会阻塞其他信号
// 而 SIGHUP 信号设置的是:sigfillset(&sa.sa_mask),因此在处理 SIGHUP 信号时,会阻塞所有其它能被阻塞的信号
// 
// SIGHUP是指终端挂起或进程结束
// 也就是说:当skynet的终端被挂起的时候,将会执行 sa.sa_handler = handle_hup 函数

static void
handle_hup(int signal) {
	if (signal == SIGHUP) {
		SIG = 1; // 收到SIGHUP,把SIG置为1
	}
}

为什么要把 SIG置为1 呢?

这个时候需要看看 定时器线程 的代码了,详细请看《Skynet源码之:定时器》

static void *
thread_timer(void *p) {
	// 忽略代码
	for (;;) {
		// 忽略代码
		usleep(2500);
		if (SIG) {
			signal_hup();
			SIG = 0; // 执行一次 signal_hup(),然后把 SIG 重设为0,代表着这次信号已经处理了
		}
	}
	// 忽略代码
}

// 我们看看 signal_hup() 具体做了什么
// 等于说给 logger 服务发了一个消息
static void
signal_hup() {
	// make log file reopen
	struct skynet_message smsg;
	smsg.source = 0;
	smsg.session = 0;
	smsg.data = NULL;
    // 这里直接把 消息类型 编码进sz的高8位
    // 验证:
    // 		skynet.server.c文件中,dispatch_message函数中,有:int type = msg->sz >> MESSAGE_TYPE_SHIFT;
    // 		skynet_mq.h文件中,有注释:type is encoding in skynet_message.sz high 8bit
    // 		云风的blog也有解释:
    //			https://blog.codingnow.com/2012/09/the_design_of_skynet.html
    // 			type 表示的是当前消息包的协议组别,而不是传统意义上的消息类别编号
    //			协议组别类型并不会很多,所以,我限制了 type 的范围是 0 到 255 ,由一个字节标识
    //			在实现时,我把 type 编码到了 size 参数的高 8 位
    //			因为单个消息包限制长度在 16 M (24 bit)内,是个合理的限制
    //			这样,为每个消息增加了 type 字段,并没有额外增加内存上的开销
	smsg.sz = (size_t)PTYPE_SYSTEM << MESSAGE_TYPE_SHIFT;
	uint32_t logger = skynet_handle_findname("logger");
	if (logger) {
		skynet_context_push(logger, &smsg);
	}
}

接着看看 logger 服务在终端挂起的时候,做了什么操作

在skynet/service-src/service_logger.c文件中,有 logger 服务的 cb 函数

static int
logger_cb(struct skynet_context * context, void *ud, int type, int session, uint32_t source, const void * msg, size_t sz) {
	struct logger * inst = ud;
	switch (type) {
	case PTYPE_SYSTEM: 
        // 我们可以看到这里有对 type == PTYPE_SYSTEM 的特殊处理
        // 为什么是 freopen() 呢?
        // 即:为什么关闭终端后,是 freopen() 文件呢?
        // 看代码段1
		if (inst->filename) {
			inst->handle = freopen(inst->filename, "a", inst->handle);
		}
        // freopen() 是一个 C 语言标准库函数,用于重新定向一个文件流(比如 stdin、stdout 或 stderr)到一个指定的文件
        // filename 是一个字符串,表示要打开的文件名
        // mode 是一个字符串,表示打开文件的模式
        // stream 是一个文件流,可以是 stdin、stdout、stderr
        //
        // 在《Skynet源码之:守护进程》中,已经将stdin、stdout、stderr重定向至"/dev/null"
        // 而 freopen()用于将把 stdin、stdout、stderr重定向至日志文件
		break;
	case PTYPE_TEXT:
		if (inst->filename) {
			char tmp[SIZETIMEFMT];
			int csec = timestring(ud, tmp);
			fprintf(inst->handle, "%s.%02d ", tmp, csec);
		}
		fprintf(inst->handle, "[:%08x] ", source);
		fwrite(msg, sz , 1, inst->handle);
		fprintf(inst->handle, "\n");
		fflush(inst->handle);
		break;
	}

	return 0;

}

代码段1

故事还得从 logger 的初始化开始讲起

int
logger_init(struct logger * inst, struct skynet_context *ctx, const char * parm) {
	const char * r = skynet_command(ctx, "STARTTIME", NULL);
	inst->starttime = strtoul(r, NULL, 10);
    
    // ********************************* 重点关注这段代码 *********************************
	if (parm) {
        // 这个 parm 就是用户在 config 配置中,填写的 logger 日志的文件目录
		inst->handle = fopen(parm, "a");
		if (inst->handle == NULL) {
			return 1;
		}
		inst->filename = skynet_malloc(strlen(parm)+1);
		strcpy(inst->filename, parm);
		inst->close = 1;
	} else {
		inst->handle = stdout; // 如果用户没有配置,那就直接使用标准输出
	}
    // ********************************* 重点关注这段代码 *********************************
    // 也就是说,如果你需要Skynet把日志输入到指定文件
    // 那你就需要在 config 配置一个 logger 字段,填写日志的目录和名字
    // 一般同时在 config 配置一个daemon
    // 这个时候Skynet就会进入后台,并且把日志输出到配置的文件中
    
	if (inst->handle) {
		skynet_callback(ctx, inst, logger_cb);
		return 0;
	}
	return 1;
}

总结一下

在结束终端的时候,skynet进程会转入后台运行,并收到一个信号SIGHUP

因为skynet进程设置了对信号 SIGHUP 的处理函数为 sa.sa_handler = handle_hup

所以skynet进程收到信号后,开始执行信号处理函数:handle_hup() ,handle_hup()函数就会把 SIG 值置为 1

接着 定时器线程在每2.5ms一次的循环检测中,检查到 SIG == 1,因此执行 signal_hup() 函数一次后,再把SIG 值置为 0

signal_hup() 函数 做的事情就是:发个特殊的消息给 logger 服务

logger 服务收到消息后,调用cb函数,处理到对应分支,即把输出日志重定向到日志文件

最后,skynet即使进入后台运行,也会把日志输入到配置好的文件中去,完成日志打印的转移

其中

其实,Skynet信号的设置本质并不是为了在终端切换时,日志输出的重定向

根本原因是:日志轮转的实现

即:为什么要设置 SIGHUP 信号并用 freopen 重新打开日志文件?

​ 1,日志文件随着时间的推移会变得非常大。为了防止日志文件过大,需要定期将旧的日志文件重命名并创建一个新的日志文件

​ 2,通常使用工具(如 logrotate)来完成这个任务

​ 3,当旧的日志文件被重命名后,程序仍然使用旧的文件句柄继续写入,这样新的日志记录还是会写到重命名后的旧文件里,而不是新创建的日志文件里

​ 4,为了让程序知道需要切换到新的日志文件,日志轮替工具会向正在运行的程序发送 SIGHUP 信号

​ 5,在程序的 SIGHUP 信号处理程序中,使用 freopen 重新打开日志文件,以便让新的日志记录写入新的日志文件

可以查看云风的回答

cloudwu/skynet · Discussions · GitHub 讨论#1734

对于如何让Skynet在后台运行,详细请看《Skynet源码之:守护进程》

对于Skynet的日志服务,详细请看《Skynet源码之:service_logger》