最近工作中遇到的问题,现在记录下。
直接上 C++ 代码(为了更清晰地描述问题,代码有所简化,能说明问题即可),源文件为 fork_test.cpp:
#include <sys/wait.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <iostream> using std::cout; using std::endl; #include <boost/thread/thread.hpp> // 启动 rsync daemon void RsyncDaemonThread(void) { const char* const rsync_cmd = "/usr/bin/rsync"; const char* argv[4] = { NULL }; argv[0] = rsync_cmd; argv[1] = "--daemon"; argv[2] = "--config=rsyncd.conf"; pid_t pid = fork(); if (-1 == pid) { cout << "RsyncDaemonThread(): fork() failed!" << endl; return; } else if (0 == pid) { execvp(rsync_cmd, (char **)argv); cout << "RsyncDaemonThread(): execvp() failed!" << endl; exit(-1); } cout << "RsyncDaemonThread(): before waitpid() ..." << endl; int status = -1; waitpid(pid, &status, WUNTRACED); if (0 == status) { cout << "RsyncDaemonThread(): status 0, sub process success!" << endl; } else { cout << "RsyncDaemonThread(): rsync daemon failed!" << endl; kill(pid, 9); // rsync 失败,杀掉进程 } return ; } // server 端:生成 socket,listen() 监听 // 返回值:socket descriptor int TcpListenSocket(unsigned int port) { int socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (-1 == socket_fd) { cout << "TcpListenSocket(): can not create socket!" << endl; return -1; } struct sockaddr_in local_addr; memset(&local_addr, '\0', sizeof(struct sockaddr_in)); local_addr.sin_family = AF_INET; local_addr.sin_addr.s_addr = htonl(INADDR_ANY); local_addr.sin_port = htons(port); // 端口复用,防止重启程序时 bind() 失败 int reuse_addr = 1; if (-1 == setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &reuse_addr, sizeof(reuse_addr))) { cout << "TcpListenSocket(): socket setsockopt() SO_REUSEADDR failed!" << endl; } if (-1 == bind(socket_fd, (struct sockaddr *)&local_addr, sizeof(struct sockaddr_in))) { cout << "TcpListenSocket(): socket bind() failed!" << endl; close(socket_fd); return -1; } if (-1 == listen(socket_fd, 10)) { cout << "TcpListenSocket(): socket listen() failed!" << endl; close(socket_fd); return -1; } return socket_fd; } int main(int, char* []) { // 可能会抛出 boost::thread_resource_error 异常 try { boost::thread t(&RsyncDaemonThread); t.detach(); } catch (const boost::thread_resource_error& e) { cout << "main(): boost::thread_resource_error exception!" << endl; } int listenfd = -1; if (-1 == (listenfd = TcpListenSocket(1111))) { cout << "main(): TcpListenSocket() failed!" << endl; return -1; } while (true) { sleep(30); } return 0; }
fork_test.cpp 代码在此。其中,代码中用到的 rsyncd.conf 文件在此。
编译并运行程序:
# g++ -g -ggdb3 -Wall -W -Wextra -std=c++0x fork_test.cpp -l boost_system -l boost_thread -o fork_test.out (说明:rm 是为了防止 /usr/bin/rsync 进程上一次运行退出时,未来得及删除对应的 pid 文件和 lock 文件,进而导致本次无法正常启动) # rm -rf /tmp/rsyncd.pid /tmp/rsync.lock # ./fork_test.out & RsyncDaemonThread(): before waitpid() ... RsyncDaemonThread(): status 0, sub process success!
用 ps
命令一看,发现 fork_test.out 和 rsync daemon 两个进程都已经启动了。
# ps -ef | grep -v grep | grep -E "fork_test\.out|/usr/bin/rsync" nostalg+ 29748 2229 0 11:27 pts/54 00:00:00 ./fork_test.out nostalg+ 29753 1 0 11:27 ? 00:00:00 /usr/bin/rsync --daemon --config=rsyncd.conf
准备工作就绪。现在遇到的问题是:如果 fork_test.out 进程异常终止(eg. 被 kill 掉),则直接再次运行 ./fork_test.out,fork_test.out 进程无法启动,错误信息是 socket bind() 失败;如果同时 kill 掉 rsync daemon,则直接再次运行 ./fork_test.out,fork_test.out 进程是可以正常启动的。
该问题很容易重现。重现如下:
# killall -9 fork_test.out # ./fork_test.out TcpListenSocket(): socket bind() failed! main(): TcpListenSocket() failed!
关于 socket bind()
失败,常见的原因是 socket 要绑定的端口已经被占用,bind()
时端口冲突了。用 netstat
命令查看,果然 1111 端口被 rsync 进程给占用了。
# netstat -tlnp | grep 1111 tcp 0 0 0.0.0.0:1111 0.0.0.0:* LISTEN 29753/rsync
1111 端口明明是主进程的监听端口,为什么会被 rsync 进程占用了呢?略微细想,其实并不难找到一个「说得通」的解释,大致过程如下:
(1) 主进程 TcpListenSocket()
中创建监听套接字的过程,在子进程 rsyncd(fork()
得到的进程)前面执行。虽然从代码顺序上来看,函数 RsyncDaemonThread()
在函数 TcpListenSocket()
前面,fork()
似乎应该早就执行完了。但那真的只是错觉而已。
(2) 由于子进程会完整地复制父进程的地址空间(包括 file/socket descriptor),因此子进程 rsyncd 的地址空间里有与父进程完全相同的监听 socket
(3) 监听的 socket 是与端口绑定的(本例中是 1111 端口),因此出现了主进程和子进程 rsyncd 同时占据同一个端口的情况
(4) 主进程被 kill 掉,子进程 rsyncd 仍然占据着端口,导致程序重新启动时(在不 kill 掉 rsyncd 的情况下),出现了端口绑定错误
虽然理论上可以这样解释通,但实际情况似乎又并不是这样。因为我经常会用 netstat
查看端口占用情况,在程序正常运行时(主进程和子进程 rsyncd 都存活),只看到主进程占用端口,并未看到 rsyncd 占用监听端口。这个也很容易验证,测试如下:
# killall -9 fork_test.out /usr/bin/rsync # rm -rf /tmp/rsyncd.pid /tmp/rsync.lock # ./fork_test.out & # netstat -tlnp | grep 1111 tcp 0 0 0.0.0.0:1111 0.0.0.0:* LISTEN 6394/./fork_test.out
这该如何解释呢?我直接说我总结的结论好了。这似乎是 netstat
的 bug:在有进程 fork()
、导致父子进程都占用同一个端口的情况下,netstat
不显示子进程对该端口的占用。对于这个问题,我会专门写篇博客。
问题既然已经定位到,解决方法自然水到渠成。这里至少有两种方法:
(注意:纯粹从业务逻辑的需要来看,对 RsyncDaemonThread()
和 TcpListenSocket()
的执行顺序没有要求。)
(1) 确保子进程 rsyncd 在 socket 创建前启动执行。方法很简单,在变量 listenfd
定义前加一行 sleep(3)
即可。当然,这种方法略显 tricky,不够通用。
(2) 更通用的方法是在创建 file/socket descriptor 时,用 fcntl()
加上 FD_CLOEXEC
标记。作用是保证线程被 fork()
后,file descriptor 计数不会加倍。修改函数 TcpListenSocket()
,示例代码如下:
int socket_fd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (-1 == socket_fd) { cout << "TcpListenSocket(): can not create socket!" << endl; return -1; } fcntl(socket_fd, F_SETFD, FD_CLOEXEC);
OK 啦。