Linux高性能服务器编程 学习笔记 第十章 信号

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

1.对于前台进程,用户可通过输入特殊终端字符来给它发送信号,如输入Ctrl+C通常会给进程发送一个中断信号。

2.系统异常。如浮点异常或非法内存段访问。

3.系统状态变化。如alarm定时器到期将引起SIGALRM信号。

4.运行kill命令或调用kill函数。

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

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

kill函数把信号sig参数发送给目标进程。目标进程用pid参数指定,其可能的取值及含义见下表:

Linux定义的信号值都大于0,如果sig参数传为0,则kill函数不发送任何信号,此时可用来检测目标进程或进程组是否存在,因为检查工作总是在信号发送前执行,但这种检测方式不可靠,一方面是由于PID的回绕,导致被检测的PID不是我们期望的进程的PID,另一方面,这种检测方法不是原子操作(检测完进程可能就终止了)。

kill函数成功时返回0,失败则返回-1并设置errno,以下是几种可能的errno:

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

信号处理只带有一个整型参数,该参数用来指示信号类型。信号处理函数应该是可重入的,否则容易引发竞态条件,因此在信号处理函数中严禁调用不安全的函数。

除了用户自定义信号处理函数外,bits/signum.h头文件中还定义了信号的另外两种处理方式:

SIG_IGN表示忽略目标信号,SIG_DFL表示使用信号的默认处理方式。信号的默认处理方式有以下几种:结束进程(Term)、忽略信号(Ign)、结束进程并生成核心转储文件(Core)、暂停进程(Stop)、继续进程(Cont)。

Linux的可用信号都定义在bits/signum.h头文件中,其中包括标准信号和POSIX实时信号,我们仅讨论标准信号,如下表所示:

上图中有一个错误,Ctrl+S并不是产生SIGSTOP信号,而是产生一个XOFF流量控制命令给终端,表示暂停终端上的输出,进程将在写系统调用中阻塞,同理,Ctrl+Q也不产生SIGCONT信号,而是产生一个XON流量控制命令给终端,表示重新启动终端上的输出。

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

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

可用signal系统调用为一个信号设置处理函数:

sig参数指出要捕获的信号类型。_handler函数是_sighandler_t类型的函数指针,用于指定信号sig参数的处理函数。

signal函数成功时返回一个函数指针,该函数指针的类型为_sighandler_t,它是sig参数信号在调用signal前的信号处理函数的指针,或是sig参数信号的默认处理函数指针(SIG_DEF,如果是第一次设置sig参数信号的处理方式)。

signal系统调用出错时返回SIG_ERR,并设置errno。

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

sig参数指出要捕获的信号类型,act参数指定新的信号处理方式,oact参数会输出信号先前的处理方式。act和oact参数都是sigaction结构体类型的指针,它描述了信号处理的细节:

sigaction结构体中的sa_handler成员指定信号处理函数;sa_mask成员设置进程的信号掩码(在进程原有信号掩码的基础上增加信号掩码),以指定在该信号的处理函数期间哪些信号不能发送给本进程,该成员类型是信号集类型sigset_t(_sigset_t的同义词),该类型指定一组信号;sa_flags成员用于设置程序收到信号时的行为,其可选值见下表:

sigaction结构中的sa_restorer成员已经过时,最好不要使用。sigaction函数成功返回0,失败返回-1并设置errno。

Linux使用数据结构sigset_t表示一组信号:

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

以下函数可用于设置或查看进程的信号掩码:

_set参数指定新的信号掩码,_oset参数返回原来的信号掩码(如果该参数不为NULL)。如果_set参数不为NULL,则_how参数指定设置进程信号掩码的方式,其可选值如下:

如果_set参数为NULL,则进程信号掩码不变,此时我们可用_oset参数来获取进程当前的信号掩码。

sigprocmask函数成功时返回0,失败则返回-1并设置errno。

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

set参数返回被挂起的信号集。进程即使多次接收到同一个被挂起的信号,sigpengding函数也只能返回一次(set参数的类型决定了它只能反映信号是否被挂起,不能反映被挂起的次数),并且,当我们再次使用sigprocmask函数使能该挂起的信号时,该信号的处理函数也只触发一次。

sigpending函数成功时返回0,失败时返回-1并设置errno。

fork函数产生的子进程继承父进程的信号掩码,但具有一个空的挂起信号集。

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

很多优秀的IO框架库和后台服务器都统一处理信号和IO事件,如Libevent IO框架库和xinetd超级服务。以下代码给出了统一事件源的一个简单实现:

c 复制代码
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#include <libgen.h>

#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;
    event.events = EPOLLIN | EPOLLET;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}

// 信号处理函数
void sig_handler(int sig) {
    // 保留原来的errno,在函数最后恢复,保证函数的可重入性
    int save_errno = errno;
    int msg = sig;
    // 将信号写入管道,以通知主循环,此处代码是错误的,只发送了int的低地址1字节
    // 如果系统是大端字节序,则发送的永远是0,因此可以改成发送一个int,或将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;
    sa.sa_flags |= SA_RESTART;
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        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];
    int epollfd = epoll_create(5);
    assert(epollfd != -1);
    addfd(epollfd, listenfd);

    // 使用socketpair创建管道,注册pipefd[0]上的可读事件
    ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
    assert(ret != -1);
    setnonblocking(pipefd[1]);
    addfd(epollfd, pipefd[0]);

    // 设置一些信号的处理函数
    addsig(SIGHUP);
    addsig(SIGCHLD);
    addsig(SIGTERM);
    addsig(SIGINT);
    bool stop_server = false;

    while (!stop_server) {
        int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
        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字节,所以按字节逐个接收信号,我们用SIGERTM信号为例说明如何安全终止服务器主循环
                    for (int i = 0; i < ret; ++i) {
                        switch (signals[i]) {
                            case SIGCHLD:
                            case SIGHUP: 
                                continue;
                            case SIGTERM:
                            case SIGINT:
                                stop_server = true;
                        }
                    }
                }
            }
        }
    }

    printf("close fds\n");
    close(listenfd);
    close(pipefd[1]);
    close(pipefd[0]);
    return 0;
}

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

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

Kongming20机器上环境如下:

从ps命令的输出来看,xinetd创建了子进程7442,它运行echo-stream内部服务(即TCP回射服务器)。从lsof命令的输出来看,xinetd打开了一个管道,该管道的读端文件描述符的值是3。修改/etc/xinetd.d目录下的部分配置文件,然后给xinetd进程发送一个SIGHUP信号,具体操作如下:

strace命令可跟踪系统调用和信号,它的-p选项可指定要跟踪的进程,上图跟踪进程7438,即xinetd服务器进程,以观察xinetd如何处理SIGHUP信号,此次strace命令的部分输出见下图:

上图中用空行分为4部分。

第一部分描述程序接收到SIGHUP信号时,信号处理函数使用管道通知主进程该信号的到来。信号处理函数往文件描述符4(管道的写端)写入信号值1(SIGHUP信号,上图中的\1是1个字节,反斜杠是转义字符,需要将1转义的原因是char值1是不可打印字符,在C风格字符串中,只能用转义来显示),而主进程使用poll函数检测到文件描述符3(管道的读端)上有可读事件,就将管道上的数据读入。

第二部分描述了xinetd重新读取一个子配置文件的过程。

第三部分描述了xinetd给子进程echo-stream(PID为7442)发送SIGTERM信号来终止该子进程,并调用waitpid等待该子进程结束。

第四部分描述了xinetd启动telnet服务的过程,创建了一个流服务socket并将其绑定到端口23上,然后监听该端口。

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

我们可用send函数的MSG_NOSIGNAL标志来禁止写操作触发SIGPIPE信号,此时,我们应使用send函数反馈的errno值来判断管道的读端或socket连接是否已经关闭。

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

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

c 复制代码
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <signal.h>
#include <fcntl.h>
#include <libgen.h>

#define BUF_SIZE 1024

static int connfd;

// SIGURG信号的处理函数
void sig_urg(int sig) {
    int save_errno = errno;
    char buffer[BUF_SIZE];
    memset(buffer, '\0', BUF_SIZE);
    // 接收带外数据,只有SO_OOBINLINE套接字选项未开启时才能这样读带外数据,否则recv函数会返回EINVAL
    // 此处代码有一个bug,当我方接收缓冲区已满,而对方进入紧急状态时,会发一个不含数据的TCP报文段
    // 来指示对端进入了紧急状态,我方接收到这个TCP报文段后就会给本进程发送SIGURG信号
    // 但我们还未收到这个紧急字节,此时recv函数会返回EWOULDBLOCK,我们应该一直读connfd
    // 以便在接收缓冲区中腾出空间,继而允许对端TCP发送那个带外字节
    int ret = recv(connfd, buffer, BUF_SIZE - 1, MSG_OOB);
    printf("got %d bytes of oob data '%s'\n", ret, buffer);
    errno = save_errno;
}

void addsig(int sig, void (*sig_handler)(int)) {
    struct sigaction sa;
    memset(&sa, '\0', sizeof(sa));
    sa.sa_handler = sig_handler;
    sa.sa_flags |= SA_RESTART;
    sigfillset(&sa.sa_mask);
    assert(sigaction(sig, &sa, NULL) != -1);
}

int main(int argc, char *argv[]) {
    if (argc != 3) {
        printf("usage: %s ip_address port_number\n", basename(argv[0]));
        return 1;
    }
    const char *ip = argv[1];
    int port = atoi(argv[2]);

    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 sock = socket(PF_INET, SOCK_STREAM, 0);
    assert(sock >= 0);

    int ret = bind(sock, (struct sockaddr *)&address, sizeof(address));
    assert(ret != -1);

    ret = listen(sock, 5);
    assert(ret != -1);

    struct sockaddr_in client;
    socklen_t client_addrlength = sizeof(client);
    connfd = accept(sock, (struct sockaddr *)&client, &client_addrlength);
    if (connfd < 0) {
        printf("errno is: %d\n", errno);
    } else {
        addsig(SIGURG, sig_urg);
        // 我们必须设置socket的宿主进程或进程组
        fcntl(connfd, F_SETOWN, getpid());

        char buffer[BUF_SIZE];
        // 循环接收普通数据
        while (1) {
            memset(buffer, '\0', BUF_SIZE);
            ret = recv(connfd, buffer, BUF_SIZE - 1, 0);
            if (ret <= 0) {
                break;
            }
            printf("get %d bytes of normal data '%s'\n", ret, buffer);
        }

        close(connfd);
    }

    close(sock);
    return 0;
}
相关推荐
Lovyk17 分钟前
Linux 正则表达式
linux·运维
好望角雾眠34 分钟前
第一阶段C#基础-10:集合(Arraylist,list,Dictionary等)
笔记·学习·c#
艾伦~耶格尔37 分钟前
【集合框架LinkedList底层添加元素机制】
java·开发语言·学习·面试
星仔编程1 小时前
python学习DAY46打卡
学习
Fireworkitte1 小时前
Ubuntu、CentOS、AlmaLinux 9.5的 rc.local实现 开机启动
linux·ubuntu·centos
大霞上仙1 小时前
实现自学习系统,输入excel文件,能学习后进行相应回答
python·学习·excel
sword devil9002 小时前
ubuntu常见问题汇总
linux·ubuntu
ac.char2 小时前
在CentOS系统中查询已删除但仍占用磁盘空间的文件
linux·运维·centos
yatingliu20193 小时前
HiveQL | 个人学习笔记
hive·笔记·sql·学习
武当豆豆3 小时前
C++编程学习(第25天)
开发语言·c++·学习