关闭select监控的fd出现的问题及解决方案


前言

最近项目上,有一个新需求。服务器端通过select/poll监控多个port,有客户端连接就进行处理。当某个port不需要监控时,服务器端需要close(srv_fd)。

一、实现思路

关闭所有的socket fd,再重新初始化socket,使用select/poll重新开始监控。select是在一个线程中循环调用,间隔一定的时间会超时,更新需要监控的fd。

二、问题

关闭所有的socket fd后,再重新初始化时,socket bind 报错,提示:

socket.c[745]:bind,errno:98

网上搜索了下,意思是说给定的地址已经被使用,原因肯能如下:

  1. 当前主机已经有服务器进程在调用bind和listen在监听我们的目标端口,如果我们在这时再次调用bind函数进行绑定的话,则会产生系统调用错误。
  2. 可能是因为我们所需要bind的目标端口是本机socket已经连接的端口。

解决方法:
设置套接字SO_REUSEADDR,所有的TCP服务器都应当指定该选项。

但是我在创建socket时,已经设置了SO_REUSEADDR。
但为什么还是bind失败?
参考:TCP/IP编程之SO_REUSEADDR和SO_REUSEPORT套接字选项
可能是因为应该是不符合下面这条:

SO_REUSEADDR允许在同一端口上启动同一服务器的多个实例,只要每个实例捆绑一个不同的本地IP地址即可。

**简单的讲:**SO_REUSEADDR允许同一个端口上绑定多个IP;
第二次bind时,ip和port都一样,与SO_REUSEADDR应用的场景不一致。

三、bind() 失败分析

1. 使用netstat查看socket状态

/* 1. 服务端建立 */
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      21623/./commt
/* 1.1 客户端调用connect连接服务端 */
tcp        1      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      21623/./commt
tcp        0      0 192.168.165.115:5678    192.168.165.218:4215    ESTABLISHED -
/* 1.2 服务端调用accept接受客户端,此时client_fd会加入到commt进程中 */
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      21623/./commt
tcp        0      0 192.168.165.115:5678    192.168.165.218:4215    ESTABLISHED 21623/./commt

/* 2. select监控srv_fd, close(srv_fd), srv_fd没有关闭, 还在监控port:5678 */
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      -
/* 2.1 此时客户端还可以发起connect连接服务器 */
tcp        1      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      -
tcp        0      0 192.168.165.115:5678    192.168.165.218:4216    ESTABLISHED -

/* 3. select超时后, netstat中已经没有监控的port:5678, 说明srv_fd彻底关闭 */

/* 4. 重新创建srv_fd, 开始监控 port:5678 */
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      21623/./commt

/* 4.1 client connect srv, 此时没有accept, 所以建立的链接不属于任何一个进程 */
tcp        1      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      21623/./commt
tcp        0      0 192.168.165.115:5678    192.168.165.218:4219    ESTABLISHED -

/* 4.2 accept后, 此时client_fd会加入到commt进程中 */
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      21623/./commt
tcp        0      0 192.168.165.115:5678    192.168.165.218:4219    ESTABLISHED 21623/./commt

(1) 其中的过程2说明,close(srv_fd)时,文件描述符的引用计数不是1,说以没有关闭srv_fd,只是从commt进程中删除了srv_fd,引用计数减1。
(2) 其中的过程3说明,select超时后,内核去释放srv_fd,发现引用计数减1后等于0,就彻底的关闭的srv_fd。
(3) 其中的过程4说明,当srv_fd彻底关闭后,重启创建相同port的socket可以成功。

2. 为什么srv_fd引用计数会加1

分析代码逻辑后,可以确定是由于调用了select导致srv_fd引用计数增加。
下面分析select调用过程,找到原因。

select()
	/* .\kernel\fs\select.c */
	sys_select()
		core_sys_select()
			do_select()
			poll_initwait(&table);
				for()	/* 遍历监控的fd */
					f = fdget(i);
					/* 调用设备驱动实现的poll函数 */
					mask = (*f_op->poll)(f.file, wait)
					fdput(f);
				/* 休眠等待唤醒 */
				poll_schedule_timeout()
			poll_freewait(&table);

(1)fdget 可能会使文件描述符引用计数加1,但随后 fdput 会进行释放。最终 select不是阻塞在poll函数中,所以不是fdget引起的。
(2)为什么fdget 可能会使引用计数加1,参考
https://www.cnhackhy.com/38780.html
简单的说:
a. 文件描述符只被一个进程使用时(files->count) == 1),文件描述符引用计数不会增加。
b. srv_fd 会被commt进程和CltStatthd线程使用,所以文件描述符引用计数加1。
(3)那么srv_fd引用计数会增加,就是由 (*f_op->poll)引起的,继续分析

(*f_op->poll)()
	/* .\kernel\net\socket.c */
	sock_poll()
		sock->ops->poll();
			/* .\kernel\net\ipv4\tcp.c */
			tcp_poll()
				sock_poll_wait()
					poll_wait()
						p->_qproc()

分析到这里说明,引用计数会增加是有p->_qproc()回调函数引起的,再分析这个回调函数什么使用赋值的。

poll_initwait()
	init_poll_funcptr(&pwq->pt, __pollwait)
		pt->_qproc = qproc;

__pollwait() 就算是poll_wait()中的回调函数:

/* Add a new entry */
static void __pollwait(struct file *filp, wait_queue_head_t *wait_address, poll_table *p)
{
	struct poll_wqueues *pwq = container_of(p, struct poll_wqueues, pt);
	struct poll_table_entry *entry = poll_get_entry(pwq);
	if (!entry)
		return;
	entry->filp = get_file(filp);     /* 引用计数加1 */
	entry->wait_address = wait_address;
	entry->key = p->_key;
	init_waitqueue_func_entry(&entry->wait, pollwake);
	entry->wait.private = pwq;
	add_wait_queue(wait_address, &entry->wait);
}

static inline struct file *get_file(struct file *f)
{
	/* 引用计数加1 */
	atomic_long_inc(&f->f_count);
	return f;
}

看到这里,我相信大家应该已经知道为什么srv_fd引用计数会增加了。

3. select() 超时后srv_fd引用计数减1

poll_freewait()
	free_poll_entry()
		fput(entry->filp);
			/* 引用计数减1 */
			atomic_long_dec_and_test(&file->f_count)

4. man select

Multithreaded applications
If a file descriptor being monitored by select() is closed in another thread, the result is unspecified. On some UNIX systems, select() unblocks and returns, with an indication that the file descriptor is ready (a subsequent I/O operation will likely fail with an error, unless another the file descriptor reopened between the time select() returned and the I/O operations was performed). On Linux (and some other systems), closing the file descriptor in another thread has no effect on select(). In summary, any application that relies on a particular behavior in this scenario must be considered buggy.
谷歌翻译如下:
多线程应用
如果在另一个线程中关闭了由select()监视的文件描述符,则结果不确定。 在某些UNIX系统上,select()解除阻塞并返回,并指示文件描述符已准备就绪(后续的I / O操作很可能会失败并出现错误,除非在返回的select()与执行I / O操作之间重新打开了另一个文件描述符)。 在Linux(和某些其他系统)上,在另一个线程中关闭文件描述符对select()无效。 总而言之,在这种情况下依赖于特定行为的任何应用程序都必须视为错误的。

四、解决bind()失败

测试平台

使用虚拟机上的 ubuntu 和 RK3399 上的最小linux系统,两个平台的kernel版本不一样

/* Ubuntu平台 */
$ cat /proc/version
Linux version 5.4.0-58-generic (buildd@lgw01-amd64-040) (gcc version 7.5.0 (Ubuntu 7.5.0-3ubuntu1~18.04)) #64~18.04.1-Ubuntu SMP Wed Dec 9 17:11:11 UTC 2020
/* RK3399平台 */
# cat /proc/version
Linux version 4.4.179 (book@100ask) (gcc version 6.3.1 20170404 (Linaro GCC 6.3-2017.05) ) #16 SMP Wed Jan 13 21:48:53 EST 2021

1. 设置SO_REUSEPORT

SO_REUSEPORT:选项允许完全重复的捆绑,不过只有在想要捆绑同一IP地址和端口的每个套接字都指定了本套接字选项才行。

在socket()和bind()之间,使用setsockopt()设置SO_REUSEPORT,示例代码如下:

iVal = 1;
iRet = setsockopt(iSockSrvFd, SOL_SOCKET, SO_REUSEPORT, &iVal, sizeof(iVal));
if (-1 == iRet)
{
    MsgPerrno("setsockopt");
    goto ERR;
}    

RK3399平台Ubuntu平台上测试,绑定相同的ip和port,可以bind()成功。

$ netstat -antpe | grep "5678"
Proto Recv-Q Send-Q Local Address           Foreign Address         State       User       Inode      PID/Program name 
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      1001       2910772    16154/./commt       
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      1001       2910712    16154/./commt       
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      1001       2910409    16154/./commt       
tcp        1      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      1001       2909126    16154/./commt       
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      1001       2909013    16154/./commt       
tcp        0      0 0.0.0.0:5678            0.0.0.0:*               LISTEN      1001       2908702    16154/./commt         

2. 使用shutdown()函数

这两个的区别请参考TCP连接关闭—close和shutdown
简单的讲close关闭fd时,需要检查fd的引用计数。而shutdown不需要,直接关闭连接。
(1)在RK3399平台Ubuntu平台上测试,使用shutdown(srv_fd, 2)后,netstat 中已经看不到srv_fd监控的port,此时bind可以成功。
(2)shutdown()只是断开了socket的链接,释放了ip和port资源,所以可以bind成功。但是存在一个问题,文件描述符没释放。

# ls /proc/1170/fd/ -al
total 0
dr-x------ 2 root root  0 Jan 21 09:05 .
dr-xr-xr-x 8 root root  0 Jan 21 09:04 ..
lrwx------ 1 root root 64 Jan 21 09:05 0 -> /dev/console
lrwx------ 1 root root 64 Jan 21 09:05 1 -> /dev/console
lrwx------ 1 root root 64 Jan 21 09:05 10 -> 'socket:[1976]'
lrwx------ 1 root root 64 Jan 21 09:05 11 -> 'socket:[1977]'
lrwx------ 1 root root 64 Jan 21 09:05 2 -> /dev/console
lrwx------ 1 root root 64 Jan 21 09:05 20 -> 'socket:[19576]'
lrwx------ 1 root root 64 Jan 21 09:05 3 -> 'socket:[1963]'
lrwx------ 1 root root 64 Jan 21 09:05 4 -> 'socket:[1964]'
lrwx------ 1 root root 64 Jan 21 09:05 5 -> 'socket:[1965]'
lrwx------ 1 root root 64 Jan 21 09:05 6 -> 'socket:[1966]'
lrwx------ 1 root root 64 Jan 21 09:05 7 -> 'socket:[1967]'
lrwx------ 1 root root 64 Jan 21 09:05 8 -> 'socket:[1968]'
lrwx------ 1 root root 64 Jan 21 09:05 9 -> 'socket:[1975]'

(3)shutdown()后,再调用close()来关闭文件描述符。

3. 使用select()时,设置超时时间

(1)如果select()的超时时间是NULL,那么有可能select()会一直阻塞,不会返回。
(2)设置超时时间后,select()超时后返回,释放srv_fd和port,此时bind()就可以成功。

4. 使用poll()替换select()

select() 只有在超时后,才能释放srv_fd和port,有一定的时间延时。有些项目,这个延时是不能接受的,可以使用poll()替换select()可以解决次问题。
示例代码入下:

if (pollfds[context->pollfd_index].revents & (POLLERR | POLLNVAL | POLLHUP))
{
	do_disconnect(db, context);
	continue;
}

poll()函数被唤醒后返回,检测 POLLERR | POLLNVAL | POLLHUP 这3个标志位是否被置位。如果置位,可以说明socket连接已经断开。
(1)在RK3399平台Ubuntu平台上测试,shutdown()后,poll()唤醒返回,revents中的POLLHUP置位。

(2)poll()唤醒返回后srv_fd引用计数会减1,但没有减到0,srv_fd没有释放。所以还需要调用close(srv_fd),关闭文件描述符。

(3)关闭时使用shutdown()+close(),这两平台存在一定的差异:
RK3399平台: poll()返回后,POLLHUP置位。
Ubuntu平台: poll()返回后,POLLNVAL或POLLHUP(偶尔)置位。
分析其中差异原因:
linux内核版本和硬件不同,进程的调度有一定的差异。当shutdown()后,直接唤醒了poll(),poll()执行后POLLHUP置位。另一种情况是:shutdown()后,唤醒poll(),但是此时进程没有切换,close()执行完后。再去内核态处理poll(),在do_pollfd()中检测fd无效,此时POLLNVAL置位。

(4)针对(3),有3种处理办法:
a. 同时处理 POLLERR | POLLNVAL | POLLHUP;
b. (a)的基础上,shutdown()和close()之间sleep几毫秒;
c. (a)的基础上,若发现是 POLLHUP 置位,再close(srv_fd);

(5)poll()替换select()的原因:
a. POLLHUP置位,select()和poll()都可以返回。但是select返回后,没有一个标志表明是POLLHUP还是POLLIN,程序不好处理,poll不存在该问题。
b. 唤醒select()后,在do_select()中检查到fd无效,此时select()再次陷入睡眠。而唤醒poll()后,在do_pollfd()中检测fd无效,此时POLLNVAL置位返回。

总结

经过反复测试,我推荐 (4)b 方案处理该问题。若不想使用sleep,使用 (4)a 方案处理也可以。而 (4)c 逻辑很清晰很合理,为什么不使用呢?
测试发现,循环shutdown()多个fd时。poll()返回后,有可能不是所有shutdown()的fd的revents中的POLLHUP置位,此时会出现fd泄漏的情况。若是shutdown()单个fd,等poll()返回处理结束后重新开始poll。再shutdown()下一个fd,这样 (4)c 方案也是可以使用的。只是这样处理代码的逻辑比较复杂。
此文前前后后花费了一个星期整理完成,分享出来同大家一同学习。

  • 1
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值