前些天,在网上看到一篇博文,讲述了如何用Go语言实现优雅的服务器重启,主要有以下几个目标:
- 不关闭现有的链接。
- socket能正常接受客户端的请求并缓存,待服务端进程重启后处理。
- 新的进程重启并替代旧的进程。
归根结底,作者的实现利用了Unix中一切皆文件的概念,将其发挥的淋漓尽致的则是Go语言。这里,首先介绍以下博文中涉及到的Go语言的特性(必要时与C++做一些对比),然后着重介绍如何用C++实现同样的功能。
Go与C++
Go语言提供一种特殊的创建listener的方式,利用Unix下一切皆文件的特性,其用文件复制一个listener,函数说明如下:
func FileListener(f *os.File) (l Listener, err error)
FileListener利用已经打开的文件f,返回一个listener的拷贝l。值得注意的是,l是由调用者负责关闭的。当l被关闭时,f没有任何影响。当然,f并不是一个普通的文件,其为函数File()的返回值。从下面函数的声明,我们可以看到File()是TCPListener类型的一个方法,它的返回值是一个listener的文件。就如同在Unix中每个设备都对应一个文件,这里是listener对应的文件。
func (l *TCPListener) File() (f *os.File, err error)
很多人看到这里会非常惊喜,Go语言居然有这么方便使用的功能。其实不然,在C++中我们可以实现相同的功能,只是没有像Go语言这样为我们封装成函数。熟悉C++ socket编程的朋友一定知道socket及accept函数1。
socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
int socket(int domain, int type, int protocol);
正如可以给fopen的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:
domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等。
protocol:指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockfd:指定socket描述符,即socket函数的返回值。
addr:指定struct sockaddr *的指针,用于返回客户端的协议地址。
addrlen:指定协议地址的长度。
在实际的程序中,如果我们不关闭socket函数返回的函数描述符,它将一直存在。所以,在accept函数终止后,程序可以重新调用accpet函数并赋予未关闭的socket描述符,其会继续处理socket描述符接受的请求。
Go语言中,另一个功能,个人觉得在灵活程度上是C++无法比拟的,那就是goroutine。其和channel一起掌控着Go语言的并发能力。为什么叫goroutine,官方文档是这样解释的:是因为已有的词汇(线程,进程等)不能传递准确的含义。goroutine之间是并行的,轻量的,仅比分配栈空间多一点点消耗,并且随着需要在堆上分配和释放。举一个简单的例子,当程序接受到一个系统信号,需要新建一个进程去处理时,Go或许是这样处理的:
//定义channel接受signal
signals := make(chan os.Signal)
signal.Notify(signals, syscall.SIGHUP, syscall.SIGTERM)
for sig := range signals {
if sig == syscall.SIGTERM {
go func() {
//处理信号
}{}
}
...
}
再来看看C++是怎么处理的:
struct sigaction action;
action.sa_handler = Handler;
sigemptyset(&action.sa_mask);
if (sigaction(SIGALRM, &action, NULL) < 0) {
syslog(LOG_ERR, "set signal error: %s(errno: %d)\n", strerror(errno), errno);
} else {
syslog(LOG_INFO, "set signal ok...");
}
这里Handler是一个声明为void (*handler) (int)的函数。注意,这里Handler仅能有一个int型整数。那如果我有其他需要处理的参数怎么办?
C++服务在线重启
接下来,我们来讨论一下C++如何实现服务的在线重启。其中涉及到一些知识点,这里将做简单的介绍。
首先,我们讨论一下fork函数。相信很多朋友对这个函数已经有所了解。fork函数会创建当前进程的一个子进程,子进程可以视为父进程的一个拷贝,但是他们运行在独立的内存空间中,互补影响。值得注意的是子进程并没有完全继承父进程的一切资源,其中包括2:
- The child has its own unique process ID, and this PID does not
match the ID of any existing process group.- The child’s parent process ID is the same as the parent’s process
ID.- The child does not inherit its parent’s memory locks.
- Process resource utilizations and CPU time counters are reset to zero in the child.
- The child’s set of pending signals is initially empty.
- The child does not inherit semaphore adjustments from its parent.
- The child does not inherit process-associated record locks from
its parent.- The child does not inherit timers from its parent.
- The child does not inherit outstanding asynchronous I/O operations from its parent, nor does it inherit any asynchronous I/O contexts from its parent.
fork的作用是复制出一个与原进程一样的子进程,它们完成的是同样的操作。但是,在更新了程序,我们想要重新启动时,fork是无法完成的,这就需要另一类函数,exec函数族。
exec 函数族提供了一个在进程中启动另一个程序执行的方法。它可以根据指定的文件名或目录名找到可执行文件,并用它来取代原调用进程的数据段、代码段和堆栈段。在执行完之后,原调用进程的内容除了进程号外,其他全部被新的进程替换了。另外,这里的可执行文件既可以是二进制文件,也可以是Linux下任何可执行的脚本文件。新的进程从原进程继承了以下内容3:
- nice value
- semadj values
- process ID
- parent process ID
- process group ID
- session membership
- real user ID
- real group ID
- supplementary group IDs
- time left until an alarm clock signal
- current working directory
- root directory
- file mode creation mask
- file size limit
- process signal mask
- pending signal
- tms_utime, tms_stime, tms_cutime, and tms_cstime
- resource limits
- controlling terminal
- interval timers
这里我们着重注意signal mask,新进程会继承原进程的signal mask,或者说继承原进程中系统默认以及用户自定义的信号处理机制。但是,由于sigation调用信号处理函数对信号进行处理时,阻塞了进程对之后信号的接收,这种情况会一直持续到新启动的进程中,所以新进程无法处理新到来的信号。为了解决这种情况,我们只需一条语句,将signal mask重置4。
action.sa_flags = 0;
这里,将signal mask重置还有另外一个重要的原因,那就是当系统调用信号中断后,待信号处理完成,系统调用会有以下两种处理方式:
Interruption of system calls and library functions by signal handlers .If a signal handler is invoked while a system call or library function call is blocked, then either:
1. the call is automatically restarted after the signal handler returns; or
2. the call fails with the error EINTR.
程序运行中具体的行为取决于signal mask是否被设置了SA_RESTART5。针对本文的内容,当SA_RESTART为被设置时,accept函数就会被中断,否则当程序收到SIGHUP等信号处理完成后,accept仍会重启。这时,新旧两个Server会同时运行。下面是一个例子,感兴趣的读者可以注释掉 action.sa_flags = 0; 和 action.sa_flags |= SA_RESTART;观察程序的结果。
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <errno.h>
#include <unistd.h>
#include <syslog.h>
#include <string.h>
#include <signal.h>
#include <fstream>
#include <ctime>
#include <iostream>
#define MAXLINE 4096
void handler(int sig) {
std::cout<<"hello"<<std::endl;
}
int main(int argc, char const* argv[])
{
int listenfd, connfd;
struct sockaddr_in servaddr;
char buff[4096];
int n;
if( (listenfd = socket(AF_INET, SOCK_STREAM, 0)) == -1 ){
printf("create socket error: %s(errno: %d)\n",strerror(errno),errno);
exit(0);
}
// timeval timeout = {1, 0};
// if (setsockopt(listenfd, SOL_SOCKET, SO_RCVTIMEO, (char*)&timeout, sizeof(timeval)) < 0)
// {
// exit(1);
// }
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);
if( bind(listenfd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1){
printf("bind socket error: %s(errno: %d)\n",strerror(errno),errno);
exit(0);
}
if( listen(listenfd, 10) == -1){
printf("listen socket error: %s(errno: %d)\n",strerror(errno),errno);
exit(0);
}
struct sigaction action;
action.sa_handler = handler;
sigemptyset(&action.sa_mask);
// action.sa_flags = 0;
action.sa_flags |= SA_RESTART;
sigaction(SIGALRM, &action, NULL);
alarm(3);
printf("======waiting for client's request======\n");
if((connfd = accept(listenfd, (struct sockaddr*)NULL, NULL)) == -1){
printf("accept socket error: %s(errno: %d)",strerror(errno),errno);
}
n = recv(connfd, buff, MAXLINE, 0);
buff[n] = '\0';
std::cout<<buff<<std::endl;
close(connfd);
close(listenfd);
//close the log
closelog();
while (1) {
std::cout<<"wait..."<<std::endl;
sleep(1);
}
return 0;
}
最后,在强调一个内容close-on-exec。每个文件描述符都有一个close-on-exec标志。默认情况下,这个标志最后一位被设置为 0。这个标志符的具体作用在于当开辟其他进程调用exec()族函数时,在调用exec函数之前为exec族函数释放对应的文件描述符。所以,当你的程序无法正确运行时,检查一下你的程序是否设置了close-on-exec关闭了已经打开的sockfd。
到这里,本文所涉及到的要点基本都介绍完了,完整的代码在这里。还请多多指导。
总结
本文用C++实现了一种在线服务重启功能,主要原理是在signal mask中取消SA_RESTART的设置,当收到外部信号时中断accept的运行,并调用exec函数重新启动程序,在新进程中用accept函数重新监控socket描述符。原进程在处理完客户端所有请求后就会终止,新进程将继续运行。
参考文献
- http://www.cnblogs.com/skynet/archive/2010/12/12/1903949.html ↩
- http://man7.org/linux/man-pages/man7/signal.7.html ↩
- http://pubs.opengroup.org/onlinepubs/7908799/xsh/exec.html ↩
- http://stackoverflow.com/questions/23745006/cant-catch-signals-after-exec ↩
- http://man7.org/linux/man-pages/man7/signal.7.html ↩