什么是C10K问题以及NIO实现原理

1,555 阅读6分钟

前言

在我以前的文章中,已经说了BIO的原理,但是一直没有说NIO的原理,所以今天再来讨论下,在我不断的寻找资料中,发现了一篇很早的文章,这篇文章标题是C10K问题,

www.kegel.com/c10k.html

简而言之就是怎么处理客户端10000个并发连接,这个问题早在1999年就有了,而后来的Nginx就是为了解决这个问题,他采用的解决方法就是使用全新的函数epoll,而Java中的NIO,同样也是使用这个函数,还有个小问题,NIO中的N,有很多争论,到底是N(New)还是N(NoBlock)呢? 在我参考的资料中,都会偏向New,其实也不必在意这个了。

而现在服务器性能提上来了,即便是使用BIO这种方式,同样也可以面对C10K,或者更多。

Epoll

我还没有到达可以说清楚epoll底层实现的地步,所以这篇文章,我只能在应用层面讨论如何实现一个epoll的服务器。

epoll用来取代旧的select和poll函数,这两个函数在任何Unix系统上都可用,而epoll是Linux特有的,功能就是监视多个文件描述符,就是当我们给它一个文件描述符列表后,内核会告诉你哪些描述符中有数据可以读/写,如果你编写过Java NIO服务器的话,就明白,你会把所有的客户端的一些事件都注册到Selector中,这里的事件则指的是,当客户端可读时,或者当服务端可进行accept时,系统会封装好有关数据,通知你,之后在调用其select方法,就可以获取有多少个socket中有事件发生,再下一步就会遍历所有的SelectionKey,依次处理客户端事件,这样我们不必为每个accept后的socket去分配单独线程,内核会告诉我们哪些socket可以读或写,我们再进行下一步操作。

取代意味着更优秀,就select而言,他最大只能支持1024个文件描述符,超过就不能工作了。

Java 中使用NIO时,就会调用epoll系列函数,如果你想亲眼看到,可以使用strace命令去追踪某个程序调用了哪些系统函数,或者也可以研究JVM源码,但是,我想使用strace这种方式是最简单的。

比如下面是一个简单的NIO服务器。

public static void main(String[] args) {
    try {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress(7070));
        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        for (; ; ) {
            int size = selector.select();
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                SelectionKey selectionKey = iterator.next();
                if (selectionKey.isAcceptable()) {
                    SocketChannel clientSocketChannel = serverSocketChannel.accept();
                    clientSocketChannel.configureBlocking(false);
                    clientSocketChannel.register(selector, SelectionKey.OP_READ);
                }
                if (selectionKey.isReadable()) {
                    SelectableChannel clientSelectChannel = selectionKey.channel();
                    SocketChannel channel = (SocketChannel) clientSelectChannel;
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                    channel.read(byteBuffer);
                    byteBuffer.flip();
                    System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit()));
                    channel.write(Charset.defaultCharset().encode("hello"));
                    channel.close();
                }
                iterator.remove();
            }
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

然后我们打包成一个jar文件,在执行下面命令,参数f表示把它fork的子进程一起追踪。

strace -f  java -classpath ./maven-demo-1.0.jar com.hxl.Main

不出意外你就会看到有epoll_ctl、epoll_wait这些函数的调用,在Java NIO中,当调用selector.select时,就会触发epoll_wait函数,epoll_wait会阻塞当前线程,直到监视的所有文件描述符实例上有事件发生,并返回准备好的文件描述符的数量,这是不是就和selector.select的返回值对应上了。

实现步骤

当然在前面还会调用epoll_create1等这些函数,下面我们按顺序来说。

  1. 设置非阻塞

    在NIO中,我们都会使用serverSocketChannel.configureBlocking(false);设置为非阻塞,而在JVM实现中,会调用下面这种方式来实现。

int flags = fcntl(sfd, F_GETFD, 0);
fcntl(sfd, F_SETFD, flags |= O_NONBLOCK);

sfd就是通过socket函数来创建的服务端socket描述符,fcntl则是用来设置文件描述符的特性,比如具有O_NONBLOCK属性的socket则是非阻塞的。

  1. epoll_create1
int efd = epoll_create1(0);

这个函数作用是在内核中创建epoll实例并返回一个epoll文件描述符,epoll系列开始的地方,但是你会发现这函数后面有个1,是不是不符合平常函数命名风格,没错,原本的函数是epoll_create(int size), 在最初的实现中,需要通过size参数告诉内核需要监听的文件描述符数量,如果监听的文件描述符数量超过size,内核会自动扩容,而现在size已经没有这种意思了,废弃了,但是调用者调用时size参数依然必须大于0,保证后向兼容。

而epoll_create1的参数是一个flags,如果是0,那么等价于epoll_create,还有一个flag是EPOLL_CLOEXEC,不太了解,但是在JVM实现中,使用的就是epoll_create1(EPOLL_CLOEXEC)

  1. epoll_ctl

接下来就是使用epoll_ctl,用来增加、修改、删除epoll实例上的事件列表。

struct epoll_event event;
event.data.fd = sfd;
event.events = EPOLLIN ;
if (epoll_ctl(efd, EPOLL_CTL_ADD, sfd, &event) == -1) {
    printf("epoll_ctl failure:%s\n", strerror(errno));
}

EPOLL_CTL_ADD:向事件列表添加文件描述符和其事件。

EPOLL_CTL_MOD:更改与目标文件描述符相关联的事件。

EPOLL_CTL_DEL:删除目标文件描述符。

而事件有EPOLLIN(读)、EPOLLOUT(写)等。

这一步就相当我们调用serverSocketChannel.register(selector, SelectionKey.OP_READ);

  1. epoll_wait
extern int epoll_wait (int __epfd, struct epoll_event *__events,
             int __maxevents, int __timeout);

这是最后一步,epoll_wait会等待epoll实例上的事件,如果你同时监听了10个连接,那么这10个连接可能会同时可以从中读取数据,那么这个函数会返回,返回值就是10,而其余结果,比如哪个客户端的文件描述符有事件,还有事件类型,会被填充到第二个参数中,所以他是一个数组,最后两个参数分别表示最大的事件数量,timeout表示等待时间,以毫秒为单位,如果超了这个时间还没有事件发生,则返回0,在java中select方法还有个重载参数,就是表示这个值,但如果是-1,那么会无限期的阻塞。

接下来同Java一样,开始遍历事件,更具事件类型做出读还是其他逻辑。

 int n = epoll_wait(efd, events, MAXEVENTS, -1);
 for (int i = 0; i < n; i++) {
     if (events[i].data.fd == sfd) {
         struct sockaddr in_addr;
         socklen_t in_len;
         in_len = sizeof in_addr;
         int cfd = accept(sfd, &in_addr, &in_len);
         int flags = fcntl(cfd, F_GETFD, 0);
         fcntl(cfd, F_SETFD, flags |= O_NONBLOCK);
         event.data.fd = cfd;
         event.events = EPOLLIN ;
         epoll_ctl(efd, EPOLL_CTL_ADD, cfd, &event) == -1;
     } else {
         int sockfd_r;
         char buf[512];
         read(events[i].data.fd, buf, sizeof buf);
         printf("read client\n");
         printf("%s\n", buf);
         close(events[i].data.fd);
     }
 }

全部代码可在github.com/houxinlin/C… 这里查看。

Java中的native

我们知道这些操作其实在java中需要通过native,而在NIO中,这些操作都统一被封装在了sun.nio.ch.Net类中,比如在底层创建socket时候,会调用下面这个方法。

static FileDescriptor serverSocket(boolean var0) {
    return IOUtil.newFD(socket0(isIPv6Available(), var0, true, fastLoopback));
}

但是这个类不能直接使用,还需要通过反射,在遇到不能使用java提供的API来完成时,可以通过大量对Net下方法的操作来实现,比如socket有很多选项,但是这些选项有的Java中是不提供的,这时候可以通过Net下的setIntOption0来实现。