本文是《Linux
高性能服务器编程》阅读记录,供以后查阅参考。推荐阅读原书。
所有函数未标明需要包含什么头文件,可使用 man
命令自行查询。
1. 信号
信号是由用户、系统或者进程发送给目标进程的信息,以通知目标进程某个状态的改变或系统异常。服务器程序必须处理(或至少忽略)一些常见的信号,以免异常终止。
1.1 Linux 信号相关 API
1.1.1 发送信号
使用 kill
函数发送信号给某个进程,函数接口如下:
1 2 3 4 extern int kill (__pid_t __pid, int __sig) ;
pid
的取值会影响 kill
函数的行为,具体如下:
sig
表示信号。Linux 定义的信号值都大于 0,如果
sig
取值为 0,则 kill
函数不发送任何信号。
1.1.2 信号处理方式
目标进程在收到信号时,需要定义一个接收函数来处理之。信号处理函数的声明如下:
1 2 typedef void (*__sighandler_t ) (int ) ;
信号处理函数参数为 int
类型,指示信号类型。信号处理函数必须是可重入的,不能调用一些不安全的函数。
用户可以自定义信号处理函数,也可以使用系统提供的一些可选的默认处理方式:
1 2 #define SIG_DFL ((__sighandler_t) 0) #define SIG_IGN ((__sighandler_t) 1)
默认处理方式包括::结束进程(Term)、忽略信号(Ign)、结束进程并生成核心转储文件(Core)、暂停进程(Stop),以及继续进程(Cont)。
1.1.3 Linux 信号
Linux 系统中定义的信号可使用 kill -l
命令查看:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ kill -l 1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP 6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP 21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR 31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3 38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8 43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13 48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12 53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7 58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2 63) SIGRTMAX-1 64) SIGRTMAX
其中,标准信号含义及系统默认处理行为如下:
我们并不需要在代码中处理所有这些信号。其中与网络编程关系紧密的信号有:SIGHUP
、SIGPIPE
和
SIGURG
。以及定时器相关的信号:SIGALRM
、SIGCHLD
等。
1.2 信号函数
1.2.1 signal
signal
函数用于为一个信号设置处理函数:
1 2 3 4 extern __sighandler_t signal (int __sig, __sighandler_t __handler) ;
1.2.2 sigaction
sigaction
是设置信号处理函数的更健壮的接口:
1 2 3 extern int sigaction (int __sig, const struct sigaction *__restrict __act, struct sigaction *__restrict __oact) ;
sig
:指定要捕获的信号类型
act
:指定新的信号处理方式
oact
:用于输出信号之前的处理方式
sigaction
结构体定义如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 struct sigaction { #if defined __USE_POSIX199309 || defined __USE_XOPEN_EXTENDED 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 ); };
1.3 信号集
1.3.1 信号集函数
Linux 使用数据结构 sigset_t
来表示一组信号,定义如下:
1 2 3 4 5 #define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int))) typedef struct { unsigned long int __val[_SIGSET_NWORDS]; } __sigset_t ;
sigset_t
实际上是一个长整型数组,数组的每个元素的每个位表示一个信号。这种定义方式和文件描述符集
fd_set
类似。Linux
提供了如下一组函数来设置、修改、删除和查询信号集:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 extern int sigemptyset (sigset_t *__set) __THROW __nonnull ((1 )) ;extern int sigfillset (sigset_t *__set) __THROW __nonnull ((1 )) ;extern int sigaddset (sigset_t *__set, int __signo) __THROW __nonnull ((1 )) ;extern int sigdelset (sigset_t *__set, int __signo) __THROW __nonnull ((1 )) ;extern int sigismember (const sigset_t *__set, int __signo) __THROW __nonnull ((1 )) ;
1.3.2 进程信号掩码
除了 sigaction
结构体的 sa_mask
成员可以用来设置进程的信号掩码,sigprocmask
函数也可用于设置或查看进程的信号掩码:
1 2 3 extern int sigprocmask (int __how, const sigset_t *__restrict __set, sigset_t *__restrict __oset) __THROW;
若 set
为
NULL
,信号掩码不变,oset
用于获取进程当前的信号掩码。
1.3.3 被挂起的信号
设置进程信号掩码后,被屏蔽的信号将不能被进程接收。如果给进程发送一个被屏蔽的信号,则操作系统将该信号设置为进程的一个被挂起的信号。如果我们取消对被挂起信号的屏蔽,则它能立即被进程接收到。如下函数可以获得进程当前被挂起的信号集:
1 2 extern int sigpending (sigset_t *__set) __THROW __nonnull ((1 )) ;
在多进程、多线程环境中,我们要以进程、线程为单位来处理信号和信号掩码。我们不能设想新创建的进程、线程具有和父进程、主线程完全相同的信号特征。比如,fork
调用产生的子进程将继承父进程的信号掩码,但具有一个空的挂起信号集。
1.4 统一事件源
信号是一种异步事件:信号处理函数和程序的主循环是两条不同的执行路线。信号处理函数需要尽可能快地执行完毕,以确保该信号不被屏蔽(前面提到过,为了避免一些竞态条件,信号在处理期间,系统不会再次触发它)太久。
一种典型的解决方案是:把信号的主要处理逻辑放到程序的主循环中,当信号处理函数被触发时,它只是简单地通知主循环程序接收到信号,并把信号值传递给主循环,主循环再根据接收到的信号值执行目标信号对应的逻辑代码。信号处理函数通常使用管道来将信号“传递”给主循环:信号处理函数往管道的写端写入信号值,主循环则从管道的读端读出该信号值。那么主循环怎么知道管道上何时有数据可读呢?这很简单,我们只需要使用
I/O
复用系统调用来监听管道的读端文件描述符上的可读事件。如此一来,信号事件就能和其他
I/O 事件一样被处理,即统一事件源。核心代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 #define MAX_EVENT_NUMBER 1024 static int pipefd[2 ]; void sig_handler (int sig) { int save_errno = errno; int msg = sig; send (pipefd[1 ], (char *)&msg, 1 , 0 ); errno = save_errno; }int main (int argc, char *argv[]) { epoll_event events[MAX_EVENT_NUMBER]; int epollfd = epoll_create (5 ); assert (epollfd != -1 ); addfd (epollfd, listenfd); 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 ); for (int i = 0 ; i < number; i++) { int sockfd = events[i].data.fd; if (sockfd == listenfd) { } 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 { for (int i = 0 ; i < ret; ++i) { switch (signals[i]) { case SIGCHLD: case SIGHUP: { continue ; } case SIGTERM: case SIGINT: { stop_server = true ; } } } } } else { } } } }
1.5 网络编程相关信号
1.5.1 SIGHUP
当挂起进程的控制终端时,SIGHUP
信号将被触发。对于没有控制终端的网络后台程序而言,它们通常利用
SIGHUP
信号来强制服务器重读配置文件 。
1.5.2 SIGPIPE
默认情况下,往一个读端关闭的管道或 socket
连接中写数据将引发 SIGPIPE
信号。我们需要在代码中捕获并处理该信号,或者至少忽略它,因为程序接收到
SIGPIPE
信号的默认行为是结束进程,而我们绝对不希望因为错误的写操作而导致程序退出。引起
SIGPIPE
信号的写操作将设置 errno
为
EPIPE
。
1.5.3 SIGURG
在 Linux 环境下,内核通知应用程序带外数据到达主要有两种方法:一种是
I/O 复用技术,select
等系统调用在接收到带外数据时将返回,并向应用程序报告 socket
上的异常事件;另外一种方法就是使用 SIGURG
信号:
1 2 3 4 5 6 7 8 9 10 void sig_urg (int sig) { int save_errno = errno; char buffer[BUF_SIZE]; memset (buffer, '\0' , BUF_SIZE); int ret = recv (connfd, buffer, BUF_SIZE - 1 , MSG_OOB); printf ("got %d bytes of oob data '%s'\n" , ret, buffer); errno = save_errno; }
2. 定时器
2.1 socket 选项
SO_RCVTIMEO 和 SO_SNDTIMEO
socket
选项 so_RCVTIMEO
和
SO_SNDTIMEO
用于设置 socket
接收数据超时时间和发送数据超时时间。这两个选项仅对 socket
相关的专用系统调用有效,具体影响如下:
使用 setsocketopt
设置 socket
定时属性示例代码如下:
1 2 3 4 5 6 7 8 9 int sockfd = socket (PF_INET, SOCK_STREAM, 0 );assert (sockfd >= 0 );struct timeval timeout; timeout.tv_sec = time; timeout.tv_usec = 0 ;socklen_t len = sizeof (timeout); ret = setsockopt (sockfd, SOL_SOCKET, SO_SNDTIMEO, &timeout, len); assert (ret != -1 );
2.2 SIGALRM 信号
可以使用 alarm
和 setitimer
函数设置定时闹钟,超时时将触发 SIGALRM
信号,可以利用该信号的信号处理函数来处理任务。
2.2.1 基于升序链表的定时器
定时器通常至少要包含两个成员:一个超时时间(相对时间或者绝对时间) 和一个任务回调函数 。有的时候还可能包含回调函数被执行时需要传入的参数,以及是否重启定时器等信息。使用链表时,还需要维护指针信息。可以使用升序列表维护一些定时器,当收到
SIGALRM
信号后,执行回调函数,以执行定时任务。部分关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 #define BUFFER_SIZE 64 class util_timer ;struct client_data { sockaddr_in address; int sockfd; char buf[BUFFER_SIZE]; util_timer *timer; };class util_timer { public : util_timer () : prev (nullptr ), next (nullptr ) {} public : time_t expire; void (*cb_func)(client_data *); client_data *user_data; util_timer *prev; util_timer *next; };class sort_timer_lst { public : void add_timer (util_timer *timer) { } void adjust_timer (util_timer *timer) { } void del_timer (util_timer *timer) { } void tick () { if (!head) { return ; } printf ("timer tick\n" ); time_t cur = time (nullptr ); util_timer *tmp = head; while (tmp) { if (cur < tmp->expire) { break ; } tmp->cb_func (tmp->user_data); head = tmp->next; if (head) { head->prev = nullptr ; } delete tmp; tmp = head; } } private : util_timer *head; util_timer *tail; };
2.2.2 处理非活动连接
定时任务可用于处理非活动连接。服务器程序通常要定期处理非活动连接:给客户端发一个重连请求,或者关闭该连接,或者其他。Linux
在内核中提供了对连接是否处于活动状态的定期检查机制,我们可以通过
socket
选项 KEEPALIVE
来激活它。不过使用这种方式将使得应用程序对连接的管理变得复杂。因此,我们可以考虑在应用层实现类似于
KEEPALIVE
的机制,以管理所有长时间处于非活动状态的连接。具体来说,可以利用
alarm
函数周期性地触发 SIGALRM
信号,该信号的信号处理函数利用管道通知主循环执行定时器链表上的定时任务,处理非活动连接。部分关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 void timer_handler () { timer_lst.tick (); alarm (TIMESLOT); }void cb_func (client_data *user_data) { epoll_ctl (epollfd, EPOLL_CTL_DEL, user_data->sockfd, 0 ); assert (user_data); close (user_data->sockfd); printf ("close fd %d\n" , user_data->sockfd); }int main (int argc, char *argv[]) { int listenfd = socket (PF_INET, SOCK_STREAM, 0 ); ret = bind (listenfd, (struct sockaddr *)&address, sizeof (address)); ret = listen (listenfd, 5 ); epoll_event events[MAX_EVENT_NUMBER]; int epollfd = epoll_create (5 ); assert (epollfd != -1 ); addfd (epollfd, listenfd); ret = socketpair (PF_UNIX, SOCK_STREAM, 0 , pipefd); assert (ret != -1 ); setnonblocking (pipefd[1 ]); addfd (epollfd, pipefd[0 ]); addsig (SIGALRM); addsig (SIGTERM); bool stop_server = false ; client_data *users = new client_data[FD_LIMIT]; bool timeout = false ; alarm (TIMESLOT); while (!stop_server) { int number = epoll_wait (epollfd, events, MAX_EVENT_NUMBER, -1 ); for (int i = 0 ; i < number; i++) { int sockfd = events[i].data.fd; 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); users[connfd].address = client_address; users[connfd].sockfd = connfd; util_timer *timer = new util_timer; timer->user_data = &users[connfd]; timer->cb_func = cb_func; time_t cur = time (nullptr ); timer->expire = cur + 3 * TIMESLOT; users[connfd].timer = timer; timer_lst.add_timer (timer); } 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 { for (int i = 0 ; i < ret; ++i) { switch (signals[i]) { case SIGALRM: { timeout = true ; break ; } case SIGTERM: { stop_server = true ; } } } } } else if (events[i].events & EPOLLIN) { memset (users[sockfd].buf, '\0' , BUFFER_SIZE); ret = recv (sockfd, users[sockfd].buf, BUFFER_SIZE - 1 , 0 ); printf ("get %d bytes of client data %s from %d\n" , ret, users[sockfd].buf, sockfd); util_timer *timer = users[sockfd].timer; if (ret < 0 ) { if (errno != EAGAIN) { cb_func (&users[sockfd]); if (timer) { timer_lst.del_timer (timer); } } } else if (ret == 0 ) { cb_func (&users[sockfd]); if (timer) { timer_lst.del_timer (timer); } } else { if (timer) { time_t cur = time (nullptr ); timer->expire = cur + 3 * TIMESLOT; printf ("adjust timer once\n" ); timer_lst.adjust_timer (timer); } } } else { } } if (timeout) { timer_handler (); timeout = false ; } } return 0 ; }
2.3 I/O 复用系统调用的超时参数
Linux下的 3 组 I/O
复用系统调用都带有超时参数,因此它们不仅能统一处理信号和 I/O
事件,也能统一处理定时事件。但是由于 I/O
复用系统调用可能在超时时间到期之前就返回(有 I/O
事件发生),所以如果我们要利用它们来定时,就需要不断更新定时参数以反映剩余的时间。示例代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 #define TIMEOUT 5000 int timeout = TIMEOUT;time_t start = time (NULL );time_t end = time (NULL );while (1 ) { printf ("the timeout is now%d mil-seconds\n" , timeout); start = time (NULL ); int number = epoll_wait (epollfd, events, MAX_EVENT_NUMBER, timeout); if ((number < 0 ) && (errno != EINTR)) { printf ("epoll failure\n" ); break ; } if (number == 0 ) { timeout = TIMEOUT; continue ; } end = time (NULL ); timeout -= (end - start) * 1000 ; if (timeout < = 0 ) { timeout = TIMEOUT; } }
2.4 高性能定时器
2.4.1 时间轮
基于排序链表的定时器存在一个问题:添加定时器的效率偏低。一种改进方案是时间轮,如下图所示:
其核心思想是:指针每隔 si (slot interval,
槽间隔)时间转动一步,顺时针移动到下一个 slot。共有 N 个
slot,则旋转一周时间为 \(N * si\)
。每个 slot 指向一个定时器链表,每条链表上的定时器的定时时间相差 \(N*si\)
的整数倍,时间轮利用这个关系将定时器散列到不同的链表中。
加上指针目前指向下标 \(cs\)
处,需要添加的定时器定时时间为 \(ti\) ,则该定时间将被插入下标 \(ts\) 位置的链表中: \[
ts = (cs + \frac{ti}{si} ) \% N
\] 对时间轮而言,要提高定时精度,就要使 \(si\) 值足够小;要提高执行效率,则要求 \(N\) 值足够大。
上图描述的是一种简单的时间轮,因为它只有一个轮子。而复杂的时间轮可能有多个轮子,不同的轮子拥有不同的粒度。相邻的两个轮子,精度高的转一圈,精度低的仅往前移动一槽,
2.4.2 时间堆
前面两种方法以固定的频率调用心搏函数
tick
,并在其中依次检测到期的定时器,然后执行到期定时器上的回调函数。设计定时器的另外一种思路是:将所有定时器中超时时间最小的一个定时器的超时值作为心搏间隔。这样,一旦心搏函数
tick
被调用,超时时间最小的定时器必然到期,我们就可以在
tick
函数中处理该定时器。然后,再次从剩余的定时器中找出超时时间最小的一个,并将这段最小时间设置为下一次心搏间隔。如此反复,就实现了较为精确的定时。 可以使用最小堆实现这一方案。