epoll 浅析以及 nio 中的 Selector

本文深入浅出地介绍了epoll的工作原理及其与传统I/O多路复用技术select和poll的区别。epoll通过高效的事件通知机制,能够显著提高系统在处理大量并发连接时的CPU利用率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

首先介绍下epoll的基本原理,网上有很多版本,这里选择一个个人觉得相对清晰的讲解(详情见reference):

首先我们来定义流的概念,一个流可以是文件,socket,pipe等等可以进行I/O操作的内核对象。

不管是文件,还是套接字,还是管道,我们都可以把他们看作流。

之后我们来讨论I/O的操作,通过read,我们可以从流中读入数据;通过write,我们可以往流写入数据。现在假定一个情形,我们需要从流中读数据,但是流中还没有数据,(典型的例子为,客户端要从socket读如数据,但是服务器还没有把数据传回来),这时候该怎么办?

阻塞:阻塞是个什么概念呢?比如某个时候你在等快递,但是你不知道快递什么时候过来,而且你没有别的事可以干(或者说接下来的事要等快递来了才能做);那么你可以去睡觉了,因为你知道快递把货送来时一定会给你打个电话(假定一定能叫醒你)。

非阻塞忙轮询:接着上面等快递的例子,如果用忙轮询的方法,那么你需要知道快递员的手机号,然后每分钟给他挂个电话:“你到了没?”

很明显一般人不会用第二种做法,不仅显很无脑,浪费话费不说,还占用了快递员大量的时间。

大部分程序也不会用第二种做法,因为第一种方法经济而简单,经济是指消耗很少的CPU时间,如果线程睡眠了,就掉出了系统的调度队列,暂时不会去瓜分CPU宝贵的时间片了。

为了了解阻塞是如何进行的,我们来讨论缓冲区,以及内核缓冲区,最终把I/O事件解释清楚。缓冲区的引入是为了减少频繁I/O操作而引起频繁的系统调用(你知道它很慢的),当你操作一个流时,更多的是以缓冲区为单位进行操作,这是相对于用户空间而言。对于内核来说,也需要缓冲区。

假设有一个管道,进程A为管道的写入方,B为管道的读出方。

假设一开始内核缓冲区是空的,B作为读出方,被阻塞着。然后首先A往管道写入,这时候内核缓冲区由空的状态变到非空状态,内核就会产生一个事件告诉B该醒来了,这个事件姑且称之为“缓冲区非空”。

但是“缓冲区非空”事件通知B后,B却还没有读出数据;且内核许诺了不能把写入管道中的数据丢掉这个时候,A写入的数据会滞留在内核缓冲区中,如果内核也缓冲区满了,B仍未开始读数据,最终内核缓冲区会被填满,这个时候会产生一个I/O事件,告诉进程A,你该等等(阻塞)了,我们把这个事件定义为“缓冲区满”。

假设后来B终于开始读数据了,于是内核的缓冲区空了出来,这时候内核会告诉A,内核缓冲区有空位了,你可以从长眠中醒来了,继续写数据了,我们把这个事件叫做“缓冲区非满”

也许事件Y1已经通知了A,但是A也没有数据写入了,而B继续读出数据,知道内核缓冲区空了。这个时候内核就告诉B,你需要阻塞了!,我们把这个时间定为“缓冲区空”。

这四个情形涵盖了四个I/O事件,缓冲区满,缓冲区空,缓冲区非空,缓冲区非满(注都是说的内核缓冲区,且这四个术语都是我生造的,仅为解释其原理而造)。这四个I/O事件是进行阻塞同步的根本。(如果不能理解“同步”是什么概念,请学习操作系统的锁,信号量,条件变量等任务同步方面的相关知识)。

然后我们来说说阻塞I/O的缺点。但是阻塞I/O模式下,一个线程只能处理一个流的I/O事件。如果想要同时处理多个流,要么多进程(fork),要么多线程(pthread_create),很不幸这两种方法效率都不高。

于是再来考虑非阻塞忙轮询的I/O方式,我们发现我们可以同时处理多个流了(把一个流从阻塞模式切换到非阻塞模式再此不予讨论):

1
2
3
4
5
6
while true {
    for i in stream[]; {
        if i has data
            read until unavailable
    }
}

我们只要不停的把所有流从头到尾问一遍,又从头开始。这样就可以处理多个流了,但这样的做法显然不好,因为如果所有的流都没有数据,那么只会白白浪费CPU。这里要补充一点,阻塞模式下,内核对于I/O事件的处理是阻塞或者唤醒,而非阻塞模式下则把I/O事件交给其他对象(后文介绍的select以及epoll)处理甚至直接忽略。

为了避免CPU空转,可以引进了一个代理(一开始有一位叫做select的代理,后来又有一位叫做poll的代理,不过两者的本质是一样的)。这个代理比较厉害,可以同时观察许多流的I/O事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来,于是我们的程序就会轮询一遍所有的流(于是我们可以把“忙”字去掉了)。代码长这样:

1
2
3
4
5
6
7
while true {
    select(streams[])
    for i in streams[] {
        if i has data
            read until unavailable
    }
}

于是,如果没有I/O事件产生,我们的程序就会阻塞在select处。但是依然有个问题,我们从select那里仅仅知道了,有I/O事件发生了,但却并不知道是那几个流(可能有一个,多个,甚至全部),我们只能无差别轮询所有流,找出能读出数据,或者写入数据的流,对他们进行操作。

但是使用select,我们有O(n)的无差别轮询复杂度,同时处理的流越多,没一次无差别轮询时间就越长。再次

说了这么多,终于能好好解释epoll了

epoll可以理解为event poll,不同于忙轮询和无差别轮询,epoll之会把哪个流发生了怎样的I/O事件通知我们。此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))

在讨论epoll的实现细节之前,先把epoll的相关操作列出:

  • epoll_create 创建一个epoll对象,一般epollfd = epoll_create()
  • epoll_ctl (epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件

比如

  • epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//注册缓冲区非空事件,即有数据流入
  • epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//注册缓冲区非满事件,即流可以被写入
  • epoll_wait(epollfd,…)等待直到注册的事件发生

(注:当对一个非阻塞流的读写发生缓冲区满或缓冲区空,write/read会返回-1,并设置errno=EAGAIN。而epoll只关心缓冲区非满和缓冲区非空事件)。

一个epoll模式的代码大概的样子是:

1
2
3
4
5
6
while true {
    active_stream[] = epoll_wait(epollfd)
    for i in active_stream[] {
        read or write till
    }
}

限于篇幅,我只说这么多,以揭示原理性的东西,至于epoll的使用细节,请参考man和google,实现细节,请参阅linux kernel source。

上文提到了epoll可以将哪个流发生了什么事件主动通知,而不需要轮询。那么到底是如何实现通知的呢?

这就和操作系统的原理相关,在内核的最底层是中断,类似系统回调的机制。网卡设备对应一个中断号, 当网卡收到网络端的消息的时候会向CPU发起中断请求, 然后CPU处理该请求. 通过驱动程序 进而操作系统得到通知, 系统然后通知epoll, epoll通知用户代码。它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,简单的说,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

所以简单总结下epoll和select的区别:

进程通过将一个或多个fd传递给select或poll系统调用,阻塞在select;这样select/poll可以帮我们侦测许多fd是否就绪;但是select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限。linux还提供了一个epoll系统调用,epoll是基于事件驱动方式,而不是顺序扫描,当有fd就绪时,立即回调函数rollback

这种epoll的方式是不是能联想起Java nio,关于nio请查看[高并发Java 八] NIO和AIO (https://my.oschina.net/hosee/blog/615269)。

不过nio中的Selector的取名总让我以为是类似于select/poll的模型,但是你会发现,当有数据被准备好时,调用完select()后,会返回一个SelectionKey,SelectionKey表示在某个selector上的某个Channel的数据已经被准备好了。那到底底层实现是使用select/poll还是epoll呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public static SelectorProvider create() {
PrivilegedAction pa = new GetPropertyAction("os.name");
String osname = (String) AccessController.doPrivileged(pa);
    if ("SunOS".equals(osname)) {
        return new sun.nio.ch.DevPollSelectorProvider();
    }
 
    // use EPollSelectorProvider for Linux kernels >= 2.6
    if ("Linux".equals(osname)) {
        pa = new GetPropertyAction("os.version");
        String osversion = (String) AccessController.doPrivileged(pa);
        String[] vers = osversion.split("\\.", 0);
        if (vers.length >= 2) {
            try {
                int major = Integer.parseInt(vers[0]);
                int minor = Integer.parseInt(vers[1]);
                if (major > 2 || (major == 2 && minor >= 6)) {
                    return new sun.nio.ch.EPollSelectorProvider();
                }
            } catch (NumberFormatException x) {
                // format not recognized
            }
        }
    }
 
    return new sun.nio.ch.PollSelectorProvider();
}

可以看到在Linux下,内核版本大于2.6时使用epoll,小于2.6时使用poll

转载于:https://www.cnblogs.com/wanghuaijun/p/6914810.html

<think>嗯,用户问的是NIO中的Selector是怎么选择的,我需要详细解释清楚Selector的工作原理。首先,得回顾一下Java NIO的基本概念,Selector是核心组件之一,用于处理多个通道的I/O事件。可能需要先解释Selector的作用,比如多路复用的机制,这样用户能明白为什么需要它。 然后得说明Selector如何工作,比如通过注册Channel并监听感兴趣的事件。需要提到Selector的三个关键方法:select()、selectNow()和select(long timeout),这几个的区别要讲清楚,比如阻塞、非阻塞和超时阻塞的情况。 还要涉及到SelectionKey,每个注册的通道都会有一个对应的SelectionKey,里面保存了事件类型和通道的信息。事件类型包括OP_READ、OP_WRITE、OP_CONNECT、OP_ACCEPT,这些需要分别解释,用户可能对每种事件的应用场景不太清楚。 可能还需要提到就绪集合(selected key set)和已注册集合(registered key set)的区别Selector维护这些集合来处理事件。处理完事件后,必须手动移除SelectionKey,否则下次还会处理,这是一个容易出错的地方,应该提醒用户注意。 另外,Selector在多线程环境下的使用需要注意,通常一个Selector由一个线程处理,避免并发问题。可能需要提到Reactor模式,这样用户能理解实际应用中的设计模式。 还有,要举一个简单的例子,比如服务器端如何注册ServerSocketChannel到Selector,监听ACCEPT事件,然后处理连接后的读写事件。代码示例能帮助用户更直观地理解,但用户可能只需要文字描述,所以可以简要说步骤。 最后,可能还要比较一下Selector和传统阻塞I/O的区别,强调其高效处理大量连接的优势,以及适用场景,比如高并发服务器应用。但用户的问题主要集中在Selector的选择机制上,所以这部分可以简要带过。 需要检查是否有遗漏的关键点,比如wakeup()方法的作用,或者如何处理关闭的通道,确保覆盖Selector使用中的常见问题和最佳实践。语言要尽量通俗,避免过于技术化,让不同层次的用户都能理解。结构要清晰,分步骤或分点解释,保持逻辑连贯。</think>在Java NIO中,`Selector`是实现**I/O多路复用**的核心组件。它的工作原理可以用以下步骤解释: --- ### 一、核心概念 1. **Selector(选择器)** - 一个`Selector`可以同时监控多个`Channel`的I/O事件 - 通过单线程管理多个通道,实现高效I/O处理 2. **Channel(通道)** - 必须处于非阻塞模式才能注册到Selector - 支持的类型:`SocketChannel`、`ServerSocketChannel`等 3. **SelectionKey(选择键)** - 通道注册到选择器时返回的"票据" - 保存了通道与选择器的关联关系及感兴趣的事件类型 --- ### 二、选择过程的关键步骤 1. **注册通道** ```java channel.configureBlocking(false); // 必须非阻塞 SelectionKey key = channel.register(selector, SelectionKey.OP_READ); ``` 2. **选择就绪通道** ```java int readyChannels = selector.select(); // 阻塞直到有事件就绪 // 或者使用: // selector.selectNow(); // 非阻塞 // selector.select(1000); // 带超时 ``` 3. **处理就绪事件** ```java Set<SelectionKey> selectedKeys = selector.selectedKeys(); for (SelectionKey key : selectedKeys) { if (key.isAcceptable()) { /* 处理新连接 */ } if (key.isReadable()) { /* 处理读事件 */ } if (key.isWritable()) { /* 处理写事件 */ } } selectedKeys.clear(); // 必须清空已处理的事件 ``` --- ### 三、事件类型详解 | 事件常量 | 触发条件 | 适用场景 | |----------------------|-----------------------------------|-------------------------| | `SelectionKey.OP_ACCEPT` | 服务器准备好接受新连接 | ServerSocketChannel | | `SelectionKey.OP_CONNECT` | 客户端成功连接到服务器 | SocketChannel | | `SelectionKey.OP_READ` | 数据可读 | 所有可读场景 | | `SelectionKey.OP_WRITE` | 通道准备好写入数据 | 需要发送大量数据时 | --- ### 四、底层实现原理 1. **操作系统级支持** - 在Linux上基于`epoll`实现 - Windows使用`IOCP`(完成端口) - 不同系统的实现细节被JVM屏蔽 2. **就绪集合维护** ```mermaid graph LR A[已注册的Channel] -->|注册时指定兴趣集| B(Selector维护的注册集合) B -->|系统通知就绪事件| C(Selector的就绪集合) C --> D[应用程序遍历处理] ``` --- ### 五、最佳实践建议 1. **合理设置兴趣集** - 仅在需要写操作时才注册`OP_WRITE` - 处理完事件后及时修改兴趣集 2. **线程安全注意事项** - `Selector`本身线程不安全 - 推荐单线程处理选择逻辑(Reactor模式) 3. **性能优化技巧** - 使用`selector.wakeup()`唤醒阻塞的select() - 及时关闭不再使用的Channel和SelectionKey --- ### 六、与传统阻塞IO对比 ```text +-------------------+---------------------+-----------------------+ | 特性 | 阻塞IO | NIO+Selector | +-------------------+---------------------+-----------------------+ | 线程使用 | 每个连接一个线程 | 单线程处理多个连接 | | CPU利用率 | 上下文切换开销大 | 更高效的CPU使用 | | 适用场景 | 低并发连接 | 高并发连接(如IM服务) | | 编程复杂度 | 简单 | 较复杂 | +-------------------+---------------------+-----------------------+ ``` 理解Selector的机制是构建高性能网络应用的基础,实际开发中通常结合线程池等模式来充分发挥其优势。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值