Linux 网络编程学习笔记——十、信号

目录

信号是由用户、系统或者进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常。Linux 信号可由如下条件产生:

  • 对于前台进程,用户可以通过输入特殊的终端字符来给它发送信号。比如输入 Ctrl + C 通常会给进程发送一个中断信号。
  • 系统异常。比如浮点异常和非法内存段访问。
  • 系统状态变化。比如 alarm 定时器到期将引起 SIGALRM 信号。
  • 运行 kill 命令或调用 kill 函数。

服务器程序必须处理(或至少忽略)一些常见的信号,以免异常终止。

一、Linux 信号概述

1. 发送信号

Linux 下,一个进程给其他进程发送信号的 API 是 kill 函数。其定义如下:

#include
#include
int kill(pid_t pid, int sig);

该函数把信号 sig 发送给目标进程,目标进程由 pid 参数指定:

pid 参数 含义
pid > 0 信号发送给 PID 为 pid 的进程
pid = 0 信号发送给本进程组内的其他进程
pid = -1 信号发送给除 init 进程外的所有进程,但发送者需要拥有对目标进程发送信号的权限
pid < -1 信号发送给组 ID 为 -pid 的进程组中的所有成员

Linux 定义的信号值都大于 0 ,如果 sig 取值为 0 ,则 kill 函数不发送任何信号。但将 sig 设置为 0 可以用来检测目标进程或进程组是否存在,因为检查工作总是在信号发送之前就执行。不过这种检测方式是不可靠的:

  • 一方面由于进程 PID 的回绕,可能导致被检测的 PID 不是我们期望的进程的 PID ;
  • 另一方面,这种检测方法不是原子操作。
  • 该函数成功时返回 0 ,失败则返回 -1 并设置 errno 。几种可能的 errno 如下所示:
    errno 含义
    EINVAL 无效信号
    EPERM 该进程没有权限发送信号给任何一个目标进程
    ESRCH 目标进程或进程组不存在

2. 信号处理方式

目标进程在收到信号时,需要定义一个接收函数来处理之:

#include
typedef void(*__sighandler_t)(int);

信号处理函数只带有一个整型参数,该参数用来指示信号类型。信号处理函数应该是可重入的,否则很容易引发一些竞态条件。所以在信号处理函数中严禁调用一些不安全的函数。除了用户自定义信号处理函数外,bits/signum.h 头文件中还定义了 信号的两种其他处理方式:SIG_IGN 和 SIG_DEL:

#include
#define SIG_DFL((__sighandler_t)0) 
#define SIG_IGN((__sighandler_t)1)
  • SIG_IGN:表示忽略目标信号;
  • SIG_DFL:表示使用信号的默认处理方式:
    • 结束进程(Term);
    • 忽略信号(Ign);
    • 结束进程并生成核心转储文件(Core);
    • 暂停进程 (Stop);
    • 继续进程(Cont)。

3. Linux 信号

Linux 的可用信号都定义在 bits/signum.h 头文件中,其中包括标准信号和 POSIX 实时信号:

信号 起源 默认行为 含义
SIGHUP POSIX Term 控制终端挂起
SIGNIT ANSI Term 键盘输入以中断进程(Ctrl + C)
SIGQUIT POSIX Core 键盘输入使进程退出(Ctrl + \)
SIGILL ANSI Core 非法指令
SIGTRAP POSIX Core 断电陷阱,用于调试
SIGABRT ANSI Core 进程调用 abort 函数时生成该信号
SIGIOT 4.2 BSD Core 和 SIGABRT 相同
SIGBUS 4.2 BSD Core 总线错误,错误内存访问
SIGFPE ANSI Core 浮点异常
SIGKILL POSIX Term 种植一个进程,该信号不可被捕获或忽略
SIGUSRI POSIX Term 用户自定义信号之一
SIGSEGV ANSI Core 非法内存段引用
SIGUSR2 POSIX Term 用户自定义信号之二
SIGPIPE POSIX Term 往读端被关闭的管道或者 socket 连接中写数据
SIGALRM POSIX Term 由 alarm 或 setitimer 设置的实时闹钟超时引起
SIGTERM ANSI Term 终止进程,kill 命令默认发送的信号就是 SIGTERM
SIGSTKFLT Linux Term 早期的 Linux 使用该信号来报告数学协处理器栈错误
SIGCLD System Ⅴ Ign 和 SIGCHLD 相同
SIGCHLD POSIX Ign 子进程状态发生变化(退出或暂停)
SIGCONT POSIX Cont 启动被暂停的进程(Ctrl + Q),如果目标进程未处于暂停状态,则信号被忽略
SIGSTOP POSIX Stop 暂停进程(Ctrl + S),该信号不可被捕获或者忽略
SIGTSTP POSIX Stop 挂起进程(Ctrl + Z)
SIGTTIN POSIX Stop 后台进程试图从终端读取输入
SIGTTOU POSIX Stop 后台进程试图往终端输入内容
SIGURG 4.2 BSD Ign socket 连接上接收到紧急数据
SIGXCPU 4.2 BSD Core 进程的 CPU 使用事件超过其软限制
SIGXFSZ 4.2 BSD Core 文件尺寸超过其软限制
SIGVTALRM 4.2 BSD Term 与 SIGALRM 类似,不过它只统计本进程用户空间代码的运行时间
SIGPROF 4.2 BSD Term 与 SIGALRM 类似,它同时统计用户代码和内核的运行时间
SIGWINCH 4.2 BSD Ign 终端窗口大小发生变化
SIGPOLL System Ⅴ Term 与 SIGIO 类似
SIGIO 4.2 BSD Term IO 就绪,比如 socket 上发生可读、可写事件。因为 TCP 服务器可触发多 SIGIO 的条件很多,故而 SIGIO 无法在 TCP 服务器中使用。SIGIO 信号可用在 UDP 服务器中
SIGPWR System Ⅴ Term 对于使用 UPS(Uninterruptable Power Supply)的系统,当电池电量过低时,SIGPWR 信号将被触发
SIGSYS POSIX Core 非法系统调用
SIGUNUSED Core 保留,通常和 SIGSYS 效果相同

4. 中断系统调用

如果程序在执行处于阻塞状态的系统调用时接收到信号,并且我们为该信号设置了信号处理函数,则默认情况下系统调用将被中断,并且 errno 被设置为 EINTR 。可以使用 sigaction 函数为信号设置 SA_RESTART 标志以自动重启被该信号中断的系统调用。

对于默认行为是暂停进程的信号(比如 SIGSTOP 、SIGTTIN),如果我们没有为它们设置信号处理函数,则它们也可以中断某些系统调用(比如 connect 、epoll_wait)。POSIX 没有规定这种行为,这是 Linux 独有的。

二、信号函数

1. signal 系统调用

要为一个信号设置处理函数,可以使用下面的 signal 系统调用:

#include
_sighandler_t signal(int sig, _sighandler_t _handler)
  • sig:指出要捕获的信号类型;
  • _handler:是 _sighandler_t 类型的函数指针,用于指定信号 sig 的处理函数;
  • signal 函数成功时返回一个函数指针,该函数指针的类型也是 _sighandler_t 。这个返回值是前一次调用 signal 函数时传入的函数指针, 或者是信号 sig 对应的默认处理函数指针 SIG_DEF(如果是第一次调用 signal 的话);
  • signal 系统调用出错时返回 SIG_ERR ,并设置 errno 。

2. sigaction 系统调用

设置信号处理函数的更健壮的接口是如下的系统调用:

#include

struct sigaction
{
#ifdef __USE_POSIX199309
    union
    {
        _sighandler_t sa_handler;
        void (*sa_sigaction)(int, siginfo_t *, void *);
    } _sigaction_handler;
#define sa_handler__sigaction_handler .sa_handler
#define sa_sigaction__sigaction_handler .sa_sigaction
#else
    _sighandler_t sa_handler;
#endif
    _sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
};

int sigaction(int sig, const struct sigaction *act, struct sigaction *oact);
  • sig:指出要捕获的信号类型;
  • act:指定新的信号处理方式
  • oact:输出信号先前的处理方式(如果不为 NULL 的话);
  • sa_hander 成员:指定信号处理函数;
  • sa_mask 成员:设置进程的信号掩码(确切地说是在进程原有信号掩码的基础上增加信号掩码),以指定哪些信号不能发送给本进程;
  • sa_mask:信号集 sigset_t(_sigset_t 的同义词)类型,该类型指定一组信号;
  • sa_flags 成员:用于设置程序收到信号时的行为:
    选项 含义
    SA_NOCLDSTOP 如果 sigaction 的 sig 参数是 SIGCHLD ,则设置该标志表示子进程暂停时不生成 SIGCHLD 信号
    SA_NOCLDWAIT 如果 sigaction 的 sig 参数是 SIGHLD ,则设置该标志标识子进程结束时不产生僵尸进程
    SA_SIGINFO 使用 sa_sigaction 作为信号处理函数(而不是默认的 sa_handler),它给进程提供更多相关的信息
    SA_ONSTACK 调用由 sigaltstack 函数设置的可选信号栈上的信号处理函数
    SA_RESTART 重新调用被信号终止的系统调用
    SA_NODEFER 当接收到信号并进入其信号处理函数时,不屏蔽该信号。默认情况下,期望进程在处理一个信号时不再接受到同种信号,否则将引起一些竞态条件
    SA_RESETHAND 信号处理函数执行完以后,恢复信号的默认处理方式
    SA_INTERRUPT 中断系统调用
    SA_NOMASK 同 SA_NODEFER
    SA_ONESHOT 同 SA_RESETHAND
    SA_STACK 同 SA_ONSTACK
  • sa_restorer 成员:已经过时,最好不要使用;
  • sigaction 成功时返回 0 , 失败则返回 -1 并设置 errno 。

三、信号集

1. 信号集函数

前文提到,Linux 使用数据结构 sigset_t 来表示一组信号。其定义如下:

#include
#define_SIGSET_NWORDS(1024/(8*sizeof(unsigned long int))) 
typedef struct { 
	unsigned long int__val[_SIGSET_NWORDS]; 
}__sigset_t;

由该定义可见,sigset_t 实际上是一个长整型数组,数组的每个元素的每个位表示一个信号。这种定义方式和文件描述符集 fd_set 类似。Linux 提供了如下一组函数来设置、修改、删除和查询信号集:

#include
int sigemptyset(sigset_t *_set)/*清空信号集*/ 
int sigfillset(sigset_t *_set)/*在信号集中设置所有信号*/ 
int sigaddset(sigset_t *_set, int_signo)/*将信号_signo添加至信号集中*/ 
int sigdelset(sigset_t *_set, int_signo)/*将信号_signo从信号集中删除*/ 
int sigismember(_const sigset_t *_set, int_signo)/*测试_signo是否在信号集中*/

2. 进程信号掩码

前文提到,我们可以利用 sigaction 结构体的 sa_mask 成员来设置进程的信号掩码。此外,如下函数也可以用于设置或查看进程的信号掩码:

#include
int sigprocmask(int_how, _const sigset_t *_set, sigset_t *_oset);
  • _set:指定新的信号掩码;
  • _oset:输出原来的信号掩码(如果不为 NULL 的话);
  • 如果 _set 参数不为 NULL ,则 _how 参数指定设置进程信号掩码的方式:
    _how 参数 含义
    SIG_BLOCK 信的进程信号掩码是其当前值和 _set 指定信号集的并集
    SIG_UNBLOCK 信的进程信号掩码是当前值和 ~_set 信号集的交集,因此 _set 指定的信号集将不被屏蔽
    SIG_SETMASK 直接将进程信号掩码设置为 _set
  • 如果 _set 为 NULL ,则进程信号掩码不变,此时仍然可以利用 _oset 参数来获得进程当前的信号掩码;
  • sigprocmask 成功时返回 0 ,失败则返回 -1 并设置 errno 。

3. 被挂起的信号

设置进程信号掩码后,被屏蔽的信号将不能被进程接收。如果给进程发送一个被屏蔽的信号,则操作系统将该信号设置为进程的一个被挂起的信号。如果我们取消对被挂起信号的屏蔽,则它能立即被进程接收到。如下函数可以获得进程当前被挂起的信号集:

#include
int sigpending(sigset_t *set);
  • set:用于保存被挂起的信号集。显然,进程即使多次接收到同一个被挂起的信号,sigpending 函数也只能反映一次。并且,当我们再次使用 sigprocmask 使能该挂起的信号时,该信号的处理函数也只被触发一次;
  • sigpending 成功时返回 0 ,失败时返回 -1 并设置 errno 。

四、统一事件源

信号是一种异步事件:信号处理函数和程序的主循环是两条不同的执行路线。很显然,信号处理函数需要尽可能快地执行完毕,以确保该信号不被屏蔽(前面提到过,为了避免一些竞态条件,信号在处理期间,系统不会再次触发它)太久。一种典型的解决方案是:把信号的主要处理逻辑放到程序的主循环中,当信号处理函数被触发时, 它只是简单地通知主循环程序接收到信号,并把信号值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码。信号处理函数通常使用管道来将信号“传递”给主循环:信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出该信号值。那么主循环怎么知道管道上何时有数据可读呢?这很简单,我们只需要使用 I/O 复用系统调用来监听管道的读端文件描述符上的可读事件。如此一来,信号事件就能和其他 I/O 事件一样被处理,即统一事件源。

很多优秀的 I/O 框架库和后台服务器程序都统一处理信号和 I/O 事件,比如 Libevent I/O 框架库和 xinetd 超级服务。下面给出了统 一事件源的一个简单实现:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#define MAX_EVENT_NUMBER 1024 // 最大事件个数
static int pipefd[2];         // 全局管道
int setnonblocking(int fd)    // 设置非阻塞状态
{
    int old_option = fcntl(fd, F_GETFL);
    int new_option = old_option | O_NONBLOCK;
    fcntl(fd, F_SETFL, new_option);
    return old_option;
}
void addfd(int epollfd, int fd)
{
    epoll_event event;                             // 创建事件变量
    event.data.fd = fd;                            // 存储数据fd
    event.events = EPOLLIN | EPOLLET;              // 期待可读事件、边缘触发模式
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event); // 往事件表中注册fd上的事件event
    setnonblocking(fd);                            // 设为非阻塞
}
void sig_handler(int sig) // 信号处理函数
{
    int save_errno = errno; // 保留原来的errno,在函数最后恢复,以保证函数的可重入性
    int msg = sig;
    send(pipefd[1], (char *)&msg, 1, 0); // 将信号值写入管道,以通知主循环
    errno = save_errno;
}
void addsig(int sig) // 设置信号的处理函数
{
    struct sigaction sa;                     // 处理信号的结构体类型
    memset(&sa, '\0', sizeof(sa));           // 初始化
    sa.sa_handler = sig_handler;             // 指定信号处理函数为sig_handler
    sa.sa_flags |= SA_RESTART;               // 设置程序收到信号时的行为,SA_RESTART表示重新调用被信号终止的系统调用
    sigfillset(&sa.sa_mask);                 // sa_mask表示进程的信号掩码,指定哪些信号不能发送给本进程,此语句表示在信号集中设置所有信号,即给mask设定所有信号
    assert(sigaction(sig, &sa, NULL) != -1); // sigaction系统调用,sig是要捕获的信号,sa指定了信号处理方式
}
int main(int argc, char *argv[])
{
    // 处理参数、地址
    if (argc <= 2)
    {
        printf("usage:%s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);
    int ret = 0;
    struct sockaddr_in address;
    bzero(&address, sizeof(address));
    address.sin_family = AF_INET;
    inet_pton(AF_INET, ip, &address.sin_addr);
    address.sin_port = htons(port);
    int listenfd = socket(PF_INET, SOCK_STREAM, 0);
    assert(listenfd >= 0);
    ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
    if (ret == -1)
    {
        printf("errno is%d\n", errno);
        return 1;
    }
    ret = listen(listenfd, 5);
    assert(ret != -1);
    epoll_event events[MAX_EVENT_NUMBER]; // 初始化epoll事件
    int epollfd = epoll_create(5);        // 创建内核事件表
    assert(epollfd != -1);
    addfd(epollfd, listenfd);                          // 添加事件
    ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd); // 使用socketpair创建双向管道,注册pipefd[0]上的可读事件
    assert(ret != -1);
    setnonblocking(pipefd[1]); // 管道非阻塞
    addfd(epollfd, pipefd[0]); // 添加事件
    // 设置一些信号的处理函数
    addsig(SIGHUP);           // 控制终端挂起
    addsig(SIGCHLD);          // 子进程状态发生变化(退出或暂停)
    addsig(SIGTERM);          // 终止进程,kill 命令默认发送的信号就是 SIGTERM
    addsig(SIGINT);           // 中断信号
    bool stop_server = false; // 关闭服务器标志
    while (!stop_server)      // 主循环
    {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1); //-1是timeout,表示永远阻塞
        if ((number < 0) && (errno != EINTR))
        {
            printf("epoll failure\n");
            break;
        }
        for (int i = 0; i < number; i++)
        {
            int sockfd = events[i].data.fd;
            // 如果就绪的文件描述符是listenfd,则处理新的连接
            if (sockfd == listenfd)
            {
                struct sockaddr_in client_address;
                socklen_t client_addrlength = sizeof(client_address);
                int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
                addfd(epollfd, connfd); // 将本次连接加入内核事件表
            }
            // 如果就绪的文件描述符是pipefd[0],则处理信号
            else if ((sockfd == pipefd[0]) && (events[i].events & EPOLLIN)) // 来自管道的事件,并且为可读类型
            {
                int sig;
                char signals[1024];
                ret = recv(pipefd[0], signals, sizeof(signals), 0); // 接收数据
                if (ret == -1)
                    continue;
                else if (ret == 0)
                    continue;
                else
                {
                    // 因为每个信号值占1字节,所以按字节来逐个接收信号。我们以SIGTERM为例,来说明如何安全地终止服务器主循环
                    for (int i = 0; i < ret; ++i)
                    {
                        switch (signals[i])
                        {
                        case SIGCHLD:
                        case SIGHUP:
                        {
                            continue;
                        }
                        case SIGTERM:
                        case SIGINT:
                        {
                            stop_server = true; // 设置关闭服务器标志
                        }
                        }
                    }
                }
            }
            else
            {
            }
        }
    }
    printf("close fds\n");
    close(listenfd);
    close(pipefd[1]);
    close(pipefd[0]);
    return 0;
}

五、网络编程相关信号

1. SIGHUP

当挂起进程的控制终端时,SIGHUP 信号将被触发。对于没有控制终端的网络后台程序而言,它们通常利用 SIGHUP 信号来强制服务器重读配置文件。一个典型的例子是 xinetd 超级服务程序。

xinetd 程序在接收到 SIGHUP 信号之后将调用 hard_reconfig 函数(见 xinetd 源码),它循环读取 /etc/xinetd.d/ 目录下的每个子配置文件,并检测其变化。如果某个正在运行的子服务的配置文件被修改以停止服务,则 xinetd 主进程将给该子服务进程发送 SIGTERM 信号以结束它。如果某个子服务的配置文件被修改以开启服务,则 xinetd 将创建新的 socket 并将其绑定到该服务对应的端口上。

2. SIGPIPE

默认情况下,往一个读端关闭的管道或 socket 连接中写数据将引发SIGPIPE信号。我们需要在代码中捕获并处理该信号,或者至少忽略它,因为程序接收到 SIGPIPE 信号的默认行为是结束进程,而我们绝对不希望因为错误的写操作而导致程序退出。引起 SIGPIPE 信号的写操作将设置 errno 为 EPIPE 。

可以使用 send 函数的 MSG_NOSIGNAL 标志来禁止写操作触发 SIGPIPE 信号。在这种情况下,应该使用 send 函数反馈的 errno 值来判断管道或者 socket 连接的读端是否已经关闭。

此外,也可以利用 I/O 复用系统调用来检测管道和 socket 连接的读端是否已经关闭。以 poll 为例,当管道的读端关闭时,写端文件描述符上的 POLLHUP 事件将被触发;当 socket 连接被对方关闭时,socket 上的 POLLRDHUP 事件将被触发。

3. SIGURG

在 Linux 环境下,内核通知应用程序带外数据到达主要有两种方法:一种是 I/O 复用技术,select 等系统调用在接收到带外 数据时将返回,并向应用程序报告 socket 上的异常事件;另外一种方法就是使用 SIGURG 信号。

你可能感兴趣的:(计算机网络,学习,linux,服务器,网络,运维)