一个端口可以让多个进程绑定吗?

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

前言

这是以前找工作时候面试官问我的,但是记不清具体怎么问了。

后来回去研究了一下,但又忙其他事情,没结果,再后来才想起,觉得这么问肯定有道理,又找了些资料。

答案是可以的,一个端口确实可以让多个程序绑定,只不过是在Linux 3.9 上引入了一个特性,称为 SO_REUSEPORT。

我们知道如果一个程序已经在8080上绑定监听了,那么其他程序就不能对8080绑定,否则出异常,但是有个情况特殊,那就是对socket设置过SO_REUSEPORT,这个选项是怎么描述的:允许同一主机上的多个套接字绑定到同一端口,只要第一个服务器在绑定之前设置了这个选项,那么其他任意数量的socket都可以绑定相同的端口,前提是它们也设置了这个选项。

但是如果第一个socket的uid是A,那么其他非A运行的就无法绑定。

Nginx在1.9.1上也引用了这个功能,只需要在listen 后面加上reuseport可以了。

server{
    charset utf-8;
    listen 6060 reuseport;
}

之后会创建worker_processes个进程,监听相同的端口。

root@meet:/etc/nginx/nginx_configs# netstat -an |grep 6060
tcp        0      0 0.0.0.0:6060            0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:6060            0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:6060            0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:6060            0.0.0.0:*               LISTEN     
tcp        0      0 0.0.0.0:6060            0.0.0.0:*               LISTEN     

在java中貌似还没有直接办法,没有提供设置这个的选项,虽然内部有个SocketOptions类,但里面没有关于SO_REUSEPORT字段的,更何况也无法调用,但是更高级别的jdk不知道有没有办法,但也不是绝对的,如果非要在java中使用这个特性,除了使用jni,还可以通过反射。

首先要了解sun.nio.ch.Net,这是个很重要的类,ServerSocketChannel内部就是使用他,下面是创建一个socket的流程。


fun create(): FileDescriptor {
    var declaredMethod = Net::class.java.getDeclaredMethod("serverSocket", Boolean.TYPE)
    declaredMethod.isAccessible 
true
    var invoke = declaredMethod.invoke(nulltrue)
    return invoke as FileDescriptor
}

fun bind(fileDescriptor: FileDescriptor) {
    Net.bind(fileDescriptor, InetAddress.getLocalHost(), 6666)
}

fun listen(fileDescriptor: FileDescriptor) {
    var declaredMethod = Net::class.java.getDeclaredMethod(
        "listen",
        FileDescriptor::class.java,
        Int::class.java
    )
    declaredMethod.isAccessible 
true
    var invoke = declaredMethod.invoke(null, fileDescriptor, 50)
}

fun setReusePort(fileDescriptor: FileDescriptor) {
    val methodSetIntOption0: Method = Net::class.java.getDeclaredMethod(
        "setIntOption0", FileDescriptor::class.java,
        Boolean.TYPEInteger.TYPEInteger.TYPEInteger.TYPEBoolean.TYPE,
    )
    methodSetIntOption0.setAccessible(true)
    methodSetIntOption0.invoke(
        nullfileDescriptortrue, 1,
        15, 1, false
    )
;
}

如果要用最老的ServerSocket,就是稍微有些麻烦,其中原理是替换掉内部的FileDescriptor,但是这里有个细节,如果使用ServerSocket的有参构造方法,那么创建、绑定、监听socket在一起,没有办法替换,只有使用无参构造,并且在之后调用一次createImpl才行。

但问题是不能直接访问createImpl,他只有一处调用,就是getImpl(),getImpl中判断了如果不为空,才调用createImpl()创建,但问题是getImpl()也不能调用,只能在向上一层找,所以我找了getLocalPort方法,但是TM的getLocalPort()也有限制,就是isBound()返回True才行,所以要通过反射修改一下bound值,之后要修改回来。

fun createNewFd(): FileDescriptor {
    var declaredMethod = Net::class.java.getDeclaredMethod("serverSocket", Boolean.TYPE)
    declaredMethod.isAccessible 
true
    var invoke = declaredMethod.invoke(nulltrue)
    var fileDescriptor = invoke as FileDescriptor
    setReusePort(fileDescriptor)
    return fileDescriptor
}

 var serverSocket 
= ServerSocket()
 var boundField = ServerSocket::class.java.getDeclaredField("bound")
 boundField.isAccessible
=true
 boundField.set(serverSocket,true)
 serverSocket.localPort
 boundField.set(serverSocket,false)
 var socketImplField = ServerSocket::class.java.getDeclaredField("impl")
 socketImplField.isAccessible 
true
 var socketImpl= socketImplField.get(serverSocket) as SocketImpl
 var field = SocketImpl::class.java.getDeclaredField("fd")
 field.isAccessible
=true
 field.set(socketImpl,createNewFd())
 serverSocket.bind(InetSocketAddress(8989))
 serverSocket.accept()

但其实使用ServerSocketChannel更方便,ServerSocketChannel很松,原理还是替换内部fd。

var serverSocketChannel = ServerSocketChannel.open()

var declaredField = serverSocketChannel::class.java.getDeclaredField("fd")
declaredField.isAccessible 
true
declaredField.set(serverSocketChannel, createNewFd())

serverSocketChannel.bind(InetSocketAddress("127.0.0.1",8080))
serverSocketChannel.socket().accept()

open方法调用后,ServerSocketChannelImpl只会创建一个socket,替换掉即可。

ServerSocketChannelImpl(SelectorProvider var1) throws IOException {
    super(var1);
    this.fd = Net.serverSocket(true);
    this.fdVal = IOUtil.fdVal(this.fd);
    this.state = 0;
}

这里提到的fd,在Windows下可以理解成句柄,就是描述具体东西的一个id。

下面拿c演示

socket的创建流程就不说了,里面通过setsockopt设置SO_REUSEPORT选项,那么成功后其他程序还是可以绑定8080端口,前提是他们也设置这个选项,JVM最后也会调用下面使用到的函数创建。

#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <string.h>

int main(int argc, char const *argv[]) {

    int new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};


    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEPORT,&opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    if (bind(server_fd, (struct sockaddr *) &address,
             sizeof(address)) < 0) {
        perror("绑定出错");
        exit(EXIT_FAILURE);
    }
    if (listen(server_fd, 3) < 0) {
        perror("监听出错");
        exit(EXIT_FAILURE);
    }
    printf("监听中...n");
    while (1){
        new_socket = accept(server_fd, (struct sockaddr *) &address,
                            (socklen_t *) &addrlen);
        printf("accept...n");
        char *hello = "Hello";
        send(new_socket, hello, strlen(hello), 0);
    }
    return 0;
}

编译运行,可以看到两个程序都绑定成功了。

一个端口可以让多个进程绑定吗?

通过netstat查看,确实有两个程序在8080上监听。

root@hxl-PC:/home/hxl# netstat -anp |grep 8080
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      22720/./a.out       
tcp        0      0 0.0.0.0:8080            0.0.0.0:*               LISTEN      22718/./a.out       
root@hxl-PC:/home/hxl# 

那么问题就是,客户端连接的时候,那个会做出响应?

我找了很多资料,没有找到具体的说明,但是根据实际情况,大概是随机的,也就是唤醒是不公平的。

一个端口可以让多个进程绑定吗?


哦,对了,我找到了一篇关于SO_REUSEPORT非常好的文章

https://tech.flipkart.com/linux-tcp-so-reuseport-usage-and-implementation-6bfbf642885a

但是最后还有个问题,ServerSocketChannel如果在启用IPV6的情况下,那么最后会调用下面函数创建,但如果其他端不是AF_INET6,是AF_INET,虽然可以进行绑定,但使用AF_INET6的一端将永远收不到请求,全部由AF_INET的负责。

int server_fd = socket(AF_INET6, SOCK_STREAM, IPPROTO_TCP);

JVM禁用IPV6。

-Djava.net.preferIPv4Stack=true

这个没有找到明确的说明, 但我测试了很多次,确实是这样的。

- END -


原文始发于微信公众号(十四个字节):一个端口可以让多个进程绑定吗?