前言
Netty 作为 java 中最成功的网络框架,里面包含了很多的设计理念和方式。后续希望针对 Netty 学习下如何实现高性能、易扩展、使用简单的网络底层框架。我开始看 kafka 的还觉得很奇怪,为什么 kafka 不使用 netty 而是自己实现了一套底层网络。后面发现开始其实是使用了 netty 的,但是后面将他移除了。官方解释的原因是 jar 包冲突。这个问题在 quora 上还有人提问。希望自己在看完 netty 和 kafka 能够看到除了 jar 包冲突以外的更好的优化或者性能。本文不会对 Netty 的使用做一个解释,而是专注于 netty 中关于 epoll 的实现
Netty 的 epoll
java 在 NIO 中提供了 Selector 的类。底层封装了 linux 中的 epoll 或者 poll,windows 则是 select。一个小小的监听 8888 端口的 demo
java
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
public class SelectorDemo {
public static void main(String[] args) {
try {
// 创建一个 Selector
Selector selector = Selector.open();
// 创建一个 ServerSocketChannel 并绑定到指定端口
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.bind(new InetSocketAddress("localhost", 8888));
serverChannel.configureBlocking(false);
// 将 ServerSocketChannel 注册到 Selector,并监听 OP_ACCEPT 事件
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器已启动,正在监听端口 8888...");
while (true) {
// 阻塞直到有事件发生
int readyChannels = selector.select();
if (readyChannels == 0) {
continue;
}
// 获取已经准备就绪的事件集合
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
keyIterator.remove();
if (key.isAcceptable()) {
// 处理连接请求
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
SocketChannel clientChannel = serverSocketChannel.accept();
clientChannel.configureBlocking(false);
clientChannel.register(selector, SelectionKey.OP_READ);
System.out.println("收到来自 " + clientChannel.getRemoteAddress() + " 的连接");
} else if (key.isReadable()) {
// 处理读事件
SocketChannel clientChannel = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int bytesRead = clientChannel.read(buffer);
if (bytesRead == -1) {
// 客户端关闭了连接
System.out.println("客户端 " + clientChannel.getRemoteAddress() + " 关闭了连接");
clientChannel.close();
key.cancel();
} else if (bytesRead > 0) {
buffer.flip();
byte[] data = new byte[buffer.limit()];
buffer.get(data);
System.out.println("收到来自 " + clientChannel.getRemoteAddress() + " 的数据:" + new String(data));
}
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
demo 只展示了创建客户端的连接和读连接里的数据。也就是说,select 底层其实已经使用了 epoll 了,但是 netty 仍然实现了自己的 epoll。我没有找到具体为什么需要自己实现 epoll 的原因。但是 java 的 select 是不支持边缘触发的。这里在解释下边缘触发和水平触发:
- 边缘触发 仅会在数据到达的时候触发一次,需要应用程序一次性将所有的数据从通道中读取完,否则后续可能会错过事件通知和丢数据
- 水平触发 会在新数据到达的时候持续触发,也就是说如果当前的通道上还有没有被处理的数据,会一直触发通知。
从底层涉及来看的话,读写都是缓冲区的数据,也就是说如果采用边缘触发,只要当前感兴趣事件对应的缓冲区里面的数据发生变化就会触发,而不关心当前缓冲区是否有没有处理完毕的数据,而水平触发则是对应感兴趣的缓冲区数据是否为空,比如读缓冲区非空,就会一直触发读事件。
相比较而言,边缘触发和水平触发各有优劣(从读缓冲区来看):
- 实时性对比
- 边缘触发更能准确的获取到事件变化,实时性比较高,因为只有事件真正发生的时候才会触发。
- 水平触发无法准确感知到当前的事件是上次没有读完还是本次新的读取事件发生。
- 通知次数
- 通知次数肯定是边缘触发相对少很多,而且边缘触发没有水平触发的*惊群效应*
- 代码难度
- 相对而言,边缘触发可能出现没有及时处理完数据,比如某个连接写入了 2kb 的数据,在读取的过程中,如果出现了新的连接,此时缓冲区仍然处于可读状态,即没有新的事件发生,所以在边缘触发的场景下是不会触发事件的,导致该部分可能存在饥饿甚至假死的现象,如果当前的连接处理完,也可能存在立马被唤醒进行下一次操作的情况,导致其他的线程一直在旁边没有事情,一个线程非常忙的情况。但是水平触发则不会出现这种情况,他会新连接进来就唤醒所有等待的线程,然后资源抢占。相对而言,水平触发的编码难度较低些。
无论是边缘触发和水平触发都是先 accept 然后进行下一步处理,那么就可能存在 accept 惊群的概念。linux 中,使用了 prepare_to_wait_exclusive 避免了惊群,(1)当等待队列入口设置了 WQ_FLAG_EXCLUSEVE 标志,它被添加到等待队列的尾部;否则,添加到头部。(2)当 wake_up 被在一个等待队列上调用, 它在唤醒第一个有 WQ_FLAG_EXCLUSIVE 标志的进程后停止唤醒.但内核仍然每次唤醒所有的非独占等待。
Nginx 就是使用的边缘触发的模式,这样可以提高性能,在数据量小的情况下,可以更加的压榨机器性能,也减少了请求的重复处理。边缘触发模式也比较适合高并发的情况,因为状态机发送情况本来就快,去掉了额外的事件通知能够进一步的提高性能。类似的 demo
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#define MAX_EVENTS 10
#define PORT 8888
int main() {
// 创建套接字并设置为非阻塞模式
int listen_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, 0);
if (listen_fd < 0) {
perror("socket");
exit(EXIT_FAILURE);
}
// 绑定并监听套接字
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind");
exit(EXIT_FAILURE);
}
if (listen(listen_fd, 5) < 0) {
perror("listen");
exit(EXIT_FAILURE);
}
// 创建 epoll 实例并将监听套接字添加到集合中
int epoll_fd = epoll_create1(0);
if (epoll_fd < 0) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}
// 将新客户端套接字添加到 epoll 集合中,并使用边缘触发模式
struct epoll_event event;
event.events = EPOLLIN | EPOLLET;
event.data.fd = listen_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event) < 0) {
perror("epoll_ctl");
exit(EXIT_FAILURE);
}
struct epoll_event events[MAX_EVENTS];
char buffer[1024];
printf("服务器监听端口 %d...\n", PORT);
while (1) {
int num_events = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
if (num_events < 0) {
perror("epoll_wait");
break;
}
for (int i = 0; i < num_events; ++i) {
if (events[i].data.fd == listen_fd) {
// 新连接到达
struct sockaddr_in client_addr;
socklen_t client_addr_len = sizeof(client_addr);
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);
if (client_fd < 0) {
perror("accept");
continue;
}
// 将新客户端套接字添加到 epoll 集合中
struct epoll_event event;
event.events = EPOLLIN | EPOLLET;
event.data.fd = client_fd;
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event) < 0) {
perror("epoll_ctl");
close(client_fd);
}
printf("新连接来自 %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
} else {
// 存在数据的连接
int client_fd = events[i].data.fd;
int bytes_read = read(client_fd, buffer, sizeof(buffer));
if (bytes_read <= 0) {
// 连接关闭或发生错误
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_fd, NULL);
close(client_fd);
printf("连接关闭\n");
} else {
// 回显数据给客户端
buffer[bytes_read] = '\0';
printf("从客户端[%d]接收到数据: %s\n", client_fd, buffer);
}
}
}
}
// 清理
close(epoll_fd);
close(listen_fd);
return 0;
}
个人觉得 netty 使用 C 自己实现 epoll 的主要原因就是 java 的 selector 不支持边缘触发,其他的原因还有能能够自定义更多的 tcp 请求属性。下文会大概的介绍下 netty 的 epoll 实现。
Netty 的 epoll
如果想要在 netty 上使用 epoll 还是很简单的,仅仅需要将对应的 group 切换成epoll 对象就行了。netty 的 epoll 实现相对于 Nio 的实现具体的差异个人觉得就三点点
- 使用了边缘触发
- 超时机制从 Nio 中的 epoll 自带的超时提出按为了 timerfd
- tcp 的配置进一步细化和可配置化
Netty 中的 epoll 使用 c 语言实现的,jni 直接调用。这里不再解释 jni 的实现了。
EpollEventLoop
EventLoop 是 netty 一个重要的概念,可以理解为他就是一个 reactor 设计模式中的处理线程池。也就是一个请求的数据读写都是一个 EventLoop 来执行的,而一个 EventLoop 中包含了一个 selector,也就是说,一个连接的生命周期会被交给一个 EventLoop 处理,而 EventLoop 是和一个线程绑定的,也就是可以理解为一个连接的生命周期被交给了一个线程处理。而我们知道,当连接创建后,数据是通过 channel 进行传输的,所以在 netty 中,一个 channel 会被一个 EventLoop 处理,相对的一个 EventLoop 可以处理多个 channel。如下图(图片来自 netty 实战):
多个 eventLoop 的组合叫做 EventLoopGroup。这个 EventLoopGroup 就是 reactor 设计模式中对应的 reactor 了。如果想要实现单线程的 reactor 则在启动的时候只创建一个 group,如果想要实现主从 reactor,就需要两个 group。
所以,EpollEventLoop 就是 epoll 的具体实现位置。EpollEventLoop 中包含了三个 FileDescriptor
java
private final FileDescriptor epollFd;
private final FileDescriptor eventFd;
private final FileDescriptor timerFd;
就上文提到三个原因中的一个,timerfd
是 Linux 中的一种文件描述符,用于实现定时器功能。它是 POSIX 定时器接口的一部分,可以用于在特定时间间隔或指定时间点触发事件。
timerfd_create(CLOCK_MONOTONIC, TFD_CLOEXEC | TFD_NONBLOCK)
这个是 netty_epoll_native.c 中创建的 timefd,里面的参数第一个
- CLOCK_MONOTONIC
- 这个参数是标识出当前的定时器是递增,而且是系统启动后开始计时,不受系统时间调整的影响。
- TFD_CLOEXEC | TFD_NONBLOCK
- 这个标志主要是控制退出,而且是无阻塞的。
三个参数中的 eventFd 主要是用于监听事件通知的。
eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK)
第一个参数 0,这是用于初始化事件计数器的值。在这个特定的参数设置下,事件计数器的初始值为 0。也就是说,在创建 eventfd
文件描述符后,初始时没有事件处于就绪状态。
最后一个epollFd
则是创建真正的 epoll 的。
epoll_create1(EPOLL_CLOEXEC)
该方法的参数和上文类似。
在 EpollEventLoop 的构造器中,还将上文的时间注册到 epoll 中:
Native.epollCtlAdd (epollFd.intValue(), eventFd.intValue(), Native.EPOLLIN | Native.EPOLLET);
epollCtl(env, efd, EPOLL_CTL_ADD, fd, flags)
efd 也就是前面的 epollFd,fd 就是上文提到的eventfd
了,epollCtlAdd 方法中最后一个参数就是设置边缘触发。到这里,事件就被注册进了 epoll 了。然后在后面将 timeFd 也注册到 epoll 中。
Native.epollCtlAdd (epollFd.intValue(), timerFd.intValue(), Native.EPOLLIN | Native.EPOLLET);
因为最开始注册的事件是 0,所以一直处于等待,除非 timeFd 此时被唤醒了。后续从 boss 中获取到 accept 方法后,会将 channel 注册到对应的 epoll 中,即:EpollEventLoop 中的 add。如果需要修改监听的事件,所以是使用 modify,移除则使用 remove。
在 Channel 接口中,还包含了一个内部接口,Unsafe 接口,Unsafe 接口是用于实现具体的数据传送。因为 Unsafe 是不会对外暴露给用户的,所以这个接口里面只封装了 beginread 方法和 write 方法,因为 read 方法其实是比较偏向业务层的,但是 write 方法一般情况下就是刷进缓冲区了。而且 Channel 里面有一个 alloc 进行内存分配,但是 Unsafe 里还有个专门的用于接受的内存分配。
当执行了 channel register,即将 channel 绑定到 EventLoop 上后,EventLoop 的线程开始启动,就开始执行 EventLoop 具体实现的 run 方法。在 EpollEventLoop 中执行的是:
java
protected void run() {
long prevDeadlineNanos = NONE;
for (;;) {
try {
// 根据策略进行一个epoll的选择,可以选择为
int strategy = selectStrategy.calculateStrategy(selectNowSupplier, hasTasks());
switch (strategy) {
// 当前的策略是应该重新执行循环,而且不需要阻塞
case SelectStrategy.CONTINUE:
continue;
// 循环执行,一直到有数据到来
case SelectStrategy.BUSY_WAIT:
strategy = epollBusyWait();
break;
// 阻塞式的等待事件到来
case SelectStrategy.SELECT:
// 判断是否真的有IO事件,如果没有,如果没有需要重新设置下次唤醒事件
// fallthrough
default:
}
//表示NioEventLoop线程每次循环处理IO事件之后,还需要处理异步任务(即非IO事件),单次处理IO和异步任务的事件占比,总数是100。这个值主要是用来调整IO处理的,如果是IO密集型的数据,那么就可以调到100,这样会尽量的做更多的IO事件
final int ioRatio = this.ioRatio;
if (ioRatio == 100) {
try {
if (strategy > 0 && processReady(events, strategy)) {
prevDeadlineNanos = NONE;
}
} finally {
// (https://github.com/netty/netty/issues/5882) 为了避免当前处理抛出异常无法执行其他的任务,所以必须保证都执行。
// Ensure we always run tasks.
runAllTasks();
}
} else if (strategy > 0) {
final long ioStartTime = System.nanoTime();
try {
if (processReady(events, strategy)) {
prevDeadlineNanos = NONE;
}
} finally {
// Ensure we always run tasks.
final long ioTime = System.nanoTime() - ioStartTime;
runAllTasks(ioTime * (100 - ioRatio) / ioRatio);
}
} else {
runAllTasks(0); // This will run the minimum number of tasks
}
} catch (Error e) {
throw (Error) e;
} catch (Throwable t) {
handleLoopException(t);
} finally {
// 有删减
}
}
我对上面的 ioRatio 有一定的困惑,开始认为是必须保证按照这个比例执行 IO 和非 IO 的,后面感觉这个只是作为一个平衡而已,并不是强制性的。主要是为了平衡 IO 和非 IO 的处理时间,如果想要提高客户端连接即吞吐,估计需要将这个值调大。如果设置为 100,那么就不会根据时间进行一个 Io 和非 Io 的平衡。 上文还有一个 epollBusyWait0 ,这个方法的实现就是和一直循环查询是否有事件,如果有或者报错则返回:
C
do {
result = epoll_wait(efd, ev, len, 0);
if (result == 0) {
// Since we're always polling epoll_wait with no timeout,
// signal CPU that we're in a busy loop
cpu_relax();
}
if (result >= 0) {
return result;
}
} while((err = errno) == EINTR);
上文中删减了部分代码,也就是 case SelectStrategy.SELECT: 后面的处理逻辑:
java
private static final long AWAKE = -1L;
private static final long NONE = Long.MAX_VALUE;
// nextWakeupNanos is:
// AWAKE when EL is awake
// NONE when EL is waiting with no wakeup scheduled
// other value T when EL is waiting with wakeup scheduled at time T
private final AtomicLong nextWakeupNanos = new AtomicLong(AWAKE);
private boolean pendingWakeup;
if (pendingWakeup) {
// We are going to be immediately woken so no need to reset wakenUp
// or check for timerfd adjustment.
strategy = epollWaitTimeboxed();
if (strategy != 0) {
break;
}
// We timed out so assume that we missed the write event due to an
// abnormally failed syscall (the write itself or a prior epoll_wait)
logger.warn("Missed eventfd write (not seen after > 1 second)");
pendingWakeup = false;
if (hasTasks()) {
break;
}
// fall-through
}
long curDeadlineNanos = nextScheduledTaskDeadlineNanos();
if (curDeadlineNanos == -1L) {
curDeadlineNanos = NONE; // nothing on the calendar
}
nextWakeupNanos.set(curDeadlineNanos);
try {
if (!hasTasks()) {
if (curDeadlineNanos == prevDeadlineNanos) {
// No timer activity needed
strategy = epollWaitNoTimerChange();
} else {
// Timerfd needs to be re-armed or disarmed
prevDeadlineNanos = curDeadlineNanos;
strategy = epollWait(curDeadlineNanos);
}
}
} finally {
// Try get() first to avoid much more expensive CAS in the case we
// were woken via the wakeup() method (submitted task)
if (nextWakeupNanos.get() == AWAKE || nextWakeupNanos.getAndSet(AWAKE) == AWAKE) {
pendingWakeup = true;
}
}
这里面涉及到了 nextWakeupNanos 和 pendingWakeUp。为什么需要这么判断下呢?该段代码是在#9586中添加的。之所以需要等待,是因为可能存在#9388。该问题的核心原因是 wakeup 方法被调用和 close 被调用可能是同时的,也就是说,可能 wakeup 正在写入 eventFd 的时候,close 执行了,close 执行会关闭 eventFd,如果此时这个文件描述符又被重用了,可能就会导致数据被写道其他的 channel 里,造成未知的问题,最开始的解决方案是新增一个 wakenup 的 volatile 类型的 int,然后原子的改变,如果当前有写入,则将其从 0 修改为 1 ,然后在进行唤醒操作,也就是写入 eventFd。核心就是等待写完后在关闭。 最后在 processReady 里面处理事件,最后执行的都是在 c 语言实现的 epoll 出来的数据。
java
if ((ev & (Native.EPOLLERR | Native.EPOLLOUT)) != 0) {
// Force flush of data as the epoll is writable again
unsafe.epollOutReady();
}
// Check EPOLLIN before EPOLLRDHUP to ensure all data is read before shutting down the input.
// See https://github.com/netty/netty/issues/4317.
//
// If EPOLLIN or EPOLLERR was received and the channel is still open call epollInReady(). This will
// try to read from the underlying file descriptor and so notify the user about the error.
if ((ev & (Native.EPOLLERR | Native.EPOLLIN)) != 0) {
// The Channel is still open and there is something to read. Do it now.
unsafe.epollInReady();
}
// Check if EPOLLRDHUP was set, this will notify us for connection-reset in which case
// we may close the channel directly or try to read more data depending on the state of the
// Channel and als depending on the AbstractEpollChannel subtype.
if ((ev & Native.EPOLLRDHUP) != 0) {
unsafe.epollRdHupReady();
}
- EPOLLERR
- 在进行读取时,如果目标文件描述符已经被关闭或发生错误,会触发 EPOLLERR 事件。
- 在进行写入时,如果目标文件描述符已经被关闭或发生错误,会触发 EPOLLERR 事件。
- 如果出现特定类型的错误(例如连接重置、连接超时等),也可能会触发 EPOLLERR 事件
- EPOLLOUT
- 当文件描述符连接到远程主机并且可以发送数据时,会触发 EPOLLOUT 事件。
- 当文件描述符为非阻塞模式,并且在发送数据时不会阻塞时,会触发 EPOLLOUT 事件。
- 如果之前的写操作由于缓冲区已满而被暂停,并且现在文件描述符的缓冲区有足够的空间可以继续发送数据时,会触发 EPOLLOUT 事件
- EPOLLIN
- 当文件描述符接收到数据,并且数据可以被读取时,会触发 EPOLLIN 事件。
- 当文件描述符连接到远程主机并且可以接收数据时,会触发 EPOLLIN 事件。
- 当文件描述符处于非阻塞模式,并且有可用的数据可以被读取时,会触发 EPOLLIN 事件。
- EPOLLRDHUP
- 当连接的对端关闭了连接,即对端执行了关闭操作,会触发 EPOLLRDHUP 事件。
- 在使用非阻塞套接字时,如果连接的对端在发送 FIN 包或执行了 shutdown 操作,表示关闭连接,会触发 EPOLLRDHUP 事件。
- 当连接的对端进程崩溃或网络故障,导致连接关闭,会触发 EPOLLRDHUP 事件。
在 netty 初始化的时候会将 ServerBootstrapAcceptor 也放进去,这个 group 会将当前的 channel 注册到 childGroup 中,也就是主从 reactor 设计模式。Epoll 模式下的 accept 的 channel 是 EpollServerSocketChannel,最后会在 AbstractEpollServerChannel 的 unsafe 中执行的,这也是上面提到的 unsafe 主要用于和底层做交互,包括写和 readbegin,该类下的 epollInReady 主要接受为:
JAVA
do {
// lastBytesRead represents the fd. We use lastBytesRead because it must be set so that the
// EpollRecvByteAllocatorHandle knows if it should try to read again or not when autoRead is
// enabled.
allocHandle.lastBytesRead(socket.accept(acceptedAddress));
if (allocHandle.lastBytesRead() == -1) {
// this means everything was handled for now
break;
}
allocHandle.incMessagesRead(1);
readPending = false;
pipeline.fireChannelRead(newChildChannel(allocHandle.lastBytesRead(), acceptedAddress, 1, acceptedAddress[0]));
} while (allocHandle.continueReading());
在 linux 中,socket 获取到一个连接后,会创建新的 socket 对象,具体步骤:
- 客户端向服务器发送一个 SYN 包(建立连接请求)。
- 服务器收到客户端的 SYN 包后,会创建一个新的 Socket 来表示这个连接,并进入到 SYN_RCVD 状态。此时服务器已经准备好接受客户端的连接请求。
- 服务器向客户端发送一个 SYN-ACK 包(确认收到连接请求,并向客户端发送自己的 SYN)。
- 客户端收到服务器的 SYN-ACK 包后,会向服务器发送一个 ACK 包(确认收到服务器的 SYN)。这样,TCP 连接就建立起来了。
也就是说,accept 会返回一个成功连接好的 fd 作为读写的 socket,这里其实比较奇怪,这样这两个 socket 对象岂不是共享了一个端口?因为这两个 socket 对象是并不是独立的两个进程,可以理解为相同进程里共享了相同的端口。
当 accept 返回-1 的时候,要么是连接失败,要么就是没有新的连接,所以退出了循环。此时在 newChildChannel 这个方法中会创建一个 EpollSocketChannel
,新创建的对象会在触发 EpollServerSocketChannel 中的 read,此时他的 read 方法会被负责 accepter 的 group 注册到 childGroup 中,然后进行真正的读操作。
java
childGroup.register(child).addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) throws Exception {
if (!future.isSuccess()) {
forceClose(child, future.cause());
}
}
});
channel 被注册到 child 的 group 后会首先注册一个 EPOLLET 的事件,该事件仅仅是注册了是使用边缘触发,没有传入实际的感兴趣的时间,等到注册后面的 beginRead 方法里才将注册为读事件。此时读事件已经被传递给了子数据,然后就可以开始读取数据了。
最后的读事件是在 AbstractEpollStreamChannel 中完成的。该类中支持一个 spliceTo 的方法,该方法就是直接连接两个套接字的数据。该方法最后调用的是 native.c 中的
C
res = splice(fd, p_off_in, fdOut, p_off_out, (size_t) len, SPLICE_F_NONBLOCK |SPLICE_F_MOVE);
使用 man 查看解释该方法是不需要内核拷贝的:
csssplice() moves data between two file descriptors without copying between kernel address space and user address space. It transfers up to len bytes of data from the file descriptor fd_in to the file descriptor fd_out, where one of the file descriptors must refer to a pipe.
数据就被传送到用户自己实现的 handler 中了。当数据处理完毕了,如果需要返回数据,就在当前的 handler 里 writeAndFlush 就行了。
需要注意的是,handler 是可以自己绑定 Executor 的。如 rocketmq 中就将处理的线程模型一步步扩大,1 + N + M1 + M2 的方式,即有一个 accepter,然后 N 个作为 accepter 的子 group 执行数据的读取,然后 M1 个做做一些公共的处理,比如解析解码,验证连接等等,最后真正处理数据的地方则放在 M2 中进行进一步的处理。但是数据的读取还是在 child 的 group 来做的。
数据写入就相对简单,核心就是将数据写入到缓冲区。如果是水平触发模式,那么一旦缓冲区可以写就会不断触发,也就是说可以通过监听事件进行写入,没写完下次仍然会触发。但是边缘触发只会在可写的时候触发一次,也就是说如果后续缓冲区可写了,但是触发过一次了就不会在触发了,所以如果没有写完需要继续注册写事件。Netty 的实现就是上诉边缘触发模式下的写入:
java
protected void doWrite(ChannelOutboundBuffer in) throws Exception {
int writeSpinCount = config().getWriteSpinCount();
do {
final int msgCount = in.size();
// Do gathering write if the outbound buffer entries start with more than one ByteBuf.
if (msgCount > 1 && in.current() instanceof ByteBuf) {
writeSpinCount -= doWriteMultiple(in);
} else if (msgCount == 0) {
// Wrote all messages.
clearFlag(Native.EPOLLOUT);
// Return here so we not set the EPOLLOUT flag.
return;
} else { // msgCount == 1
writeSpinCount -= doWriteSingle(in);
}
// We do not break the loop here even if the outbound buffer was flushed completely,
// because a user might have triggered another write and flush when we notify his or her
// listeners.
} while (writeSpinCount > 0);
if (writeSpinCount == 0) {
// It is possible that we have set EPOLLOUT, woken up by EPOLL because the socket is writable, and then use
// our write quantum. In this case we no longer want to set the EPOLLOUT flag because the socket is still
// writable (as far as we know). We will find out next time we attempt to write if the socket is writable
// and set the EPOLLOUT if necessary.
clearFlag(Native.EPOLLOUT);
// We used our writeSpin quantum, and should try to write again later.
eventLoop().execute(flushTask);
} else {
// Underlying descriptor can not accept all data currently, so set the EPOLLOUT flag to be woken up
// when it can accept more data.
setFlag(Native.EPOLLOUT);
}
}
如果当前的写入 msg 大于 1,则会使用一次性写入多个,这里的自旋次数是 15 次。如果发送过程缓冲区满了,则会返回一个最大的 int 值,然后让循环退出,然后注册写事件。
总结
本文感觉自己的思路有点乱,只能算是个关于 epoll 的小笔记。如果有什么不对的,欢迎订正