YouCompleteMe如何获得未使用的端口
intro
由于每次vim都启动一个ycmd服务进程,并且端口地址是由vim客户端指定的(因为ycmd启动之后vim客户端需要连接过去),所以vim在指定端口的时候就需要给出一个当前没有使用中的端口。
那么如何获得一个未使用的端口呢?
tsecer@harry: ps aux | fgrep ycmd
tsecer+ 3022084 0.9 2.5 1372356 840644 ? Ssl * 0:45 /usr/bin/python3.11 /home/tsecer/.vim/bundle/YouCompleteMe/python/ycm/../../third_party/ycmd/ycmd --port=52721 --options_file=/tmp/tmp32kne20x --log=info --idle_suicide_seconds=1800 --stdout=/tmp/ycmd_52721_stdout_g2udc2_0.log --stderr=/tmp/ycmd_52721_stderr_u7831z99.log
tsecer+ 3101254 0.0 0.1 2359252 33960 pts/8 Sl+ * 0:00 vim ../third_party/ycmd/ycmd/utils.py
tsecer+ 3101255 0.0 0.2 626420 73136 ? Ssl * 0:00 /usr/bin/python3.11 /home/tsecer/.vim/bundle/YouCompleteMe/python/ycm/../../third_party/ycmd/ycmd --port=56359 --options_file=/tmp/tmpxbiz25f7 --log=info --idle_suicide_seconds=1800 --stdout=/tmp/ycmd_56359_stdout_epz8p7zc.log --stderr=/tmp/ycmd_56359_stderr_3_4qdtui.log
tsecer+ 3164762 0.0 0.0 13212 1088 pts/2 S+ * 0:00 grep -F --color=auto ycmd
tsecer@harry:
Linux系统中有一些系统调用可以让操作系统自动分配一个可用的端口。例如sendto
Call sendto without calling bind first, the socket will be bound automatically (to a free port).
或者通过bind一个0端口。
Another option is to specify port 0 to bind(). That will allow you to bind to a specific IP address (in case you have multiple installed) while still binding to a random port. If you need to know which port was picked, you can use getsockname() after the binding has been performed.
这两种方法都有一个进程安全的问题:执行系统调用之后需要保持socket一直存在,否则理论上这个socket关闭之后可能会分配给下一个进程(尽管和pid一样,port应该不会立马回收,而是尽量先单调递增)。
ycm
可以看到,ycm是通过YouCompleteMe类的_SetUpServer函数来设置server信息,并且端口是通过utils.GetUnusedLocalhostPort函数获得。
# ~/.vim/bundle/YouCompleteMe/python/ycm/youcompleteme.py
class YouCompleteMe:
def __init__( self, default_options = {} ):
self._logger = logging.getLogger( 'ycm' )
self._client_logfile = None
self._server_stdout = None
self._server_stderr = None
self._server_popen = None
self._default_options = default_options
self._ycmd_keepalive = YcmdKeepalive()
self._SetUpLogging()
self._SetUpServer()
self._ycmd_keepalive.Start()
def _SetUpServer( self ):
self._available_completers = {}
self._user_notified_about_crash = False
self._filetypes_with_keywords_loaded = set()
self._server_is_ready_with_cache = False
self._message_poll_requests = {}
###...
# The temp options file is deleted by ycmd during startup.
with NamedTemporaryFile( delete = False, mode = 'w+' ) as options_file:
json.dump( options_dict, options_file )
server_port = utils.GetUnusedLocalhostPort()
BaseRequest.server_location = 'http://127.0.0.1:' + str( server_port )
BaseRequest.hmac_secret = hmac_secret
函数实现的确是通过bind获得之后马上关闭socket。
### ~/.vim/bundle/YouCompleteMe/third_party/ycmd/ycmd/utils.py
def GetUnusedLocalhostPort():
sock = socket.socket()
# This tells the OS to give us any free port in the range [1024 - 65535]
sock.bind( ( '', 0 ) )
port = sock.getsockname()[ 1 ]
sock.close()
return port
kernel
看起来是随机未使用端口,而不是连续的。
///@file:linux\net\ipv4\inet_connection_sock.c
/*
* Find an open port number for the socket. Returns with the
* inet_bind_hashbucket locks held if successful.
*/
static struct inet_bind_hashbucket *
inet_csk_find_open_port(const struct sock *sk, struct inet_bind_bucket **tb_ret,
struct inet_bind2_bucket **tb2_ret,
struct inet_bind_hashbucket **head2_ret, int *port_ret)
{
struct inet_hashinfo *hinfo = tcp_or_dccp_get_hashinfo(sk);
int i, low, high, attempt_half, port, l3mdev;
struct inet_bind_hashbucket *head, *head2;
struct net *net = sock_net(sk);
struct inet_bind2_bucket *tb2;
struct inet_bind_bucket *tb;
u32 remaining, offset;
bool relax = false;
l3mdev = inet_sk_bound_l3mdev(sk);
ports_exhausted:
attempt_half = (sk->sk_reuse == SK_CAN_REUSE) ? 1 : 0;
other_half_scan:
inet_sk_get_local_port_range(sk, &low, &high);
high++; /* [32768, 60999] -> [32768, 61000[ */
if (high - low < 4)
attempt_half = 0;
if (attempt_half) {
int half = low + (((high - low) >> 2) << 1);
if (attempt_half == 1)
high = half;
else
low = half;
}
remaining = high - low;
if (likely(remaining > 1))
remaining &= ~1U;
offset = get_random_u32_below(remaining);
/* __inet_hash_connect() favors ports having @low parity
* We do the opposite to not pollute connect() users.
*/
offset |= 1U;
other_parity_scan:
port = low + offset;
for (i = 0; i < remaining; i += 2, port += 2) {
if (unlikely(port >= high))
port -= remaining;
if (inet_is_local_reserved_port(net, port))
continue;
head = &hinfo->bhash[inet_bhashfn(net, port,
hinfo->bhash_size)];
spin_lock_bh(&head->lock);
if (inet_use_bhash2_on_bind(sk)) {
if (inet_bhash2_addr_any_conflict(sk, port, l3mdev, relax, false))
goto next_port;
}
head2 = inet_bhashfn_portaddr(hinfo, sk, net, port);
spin_lock(&head2->lock);
tb2 = inet_bind2_bucket_find(head2, net, port, l3mdev, sk);
inet_bind_bucket_for_each(tb, &head->chain)
if (inet_bind_bucket_match(tb, net, port, l3mdev)) {
if (!inet_csk_bind_conflict(sk, tb, tb2,
relax, false))
goto success;
spin_unlock(&head2->lock);
goto next_port;
}
tb = NULL;
goto success;
next_port:
spin_unlock_bh(&head->lock);
cond_resched();
}
offset--;
if (!(offset & 1))
goto other_parity_scan;
if (attempt_half == 1) {
/* OK we now try the upper half of the range */
attempt_half = 2;
goto other_half_scan;
}
if (READ_ONCE(net->ipv4.sysctl_ip_autobind_reuse) && !relax) {
/* We still have a chance to connect to different destinations */
relax = true;
goto ports_exhausted;
}
return NULL;
success:
*port_ret = port;
*tb_ret = tb;
*tb2_ret = tb2;
*head2_ret = head2;
return head;
}
outro
ycm使用bind系统调用,传入参数为0的端口地址(INADDR_ANY ),由操作系统分配端口之后再销毁socket。尽管这种操作理论上不是进程安全的,但是考虑到内核端口的分配策略和vim的使用场景(启动频率极低),所以在实践中应该不会出现并发导致的问题。