最近工作中遇到的问题,现在记录下。

直接上 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 啦。

Leave a Reply

Your email address will not be published. Required fields are marked *