网络IO模型

背景知识

在深入的去了解和学习 IO 模型时,是要求对操作系统的一些底层概念是要了解的,比如 VFS树、FD、Page Cache、Dirty Page、Flush 等。这些概念到时候我会再学习一下,然后单独去写一篇操作系统关键性概念的文章。另外,在学习 IO 的时候,TCP/IP 协议也是需要了解的,包括三次握手以及 linux 中在建立连接时的系统调用信息,这对于理解 IO 很有帮助。

C10K 问题

  • C10K 问题介绍

    线程是 CPU 调度的最小单位,进程是 CPU 分配资源的最小单位

    C10K 是一个经典的服务端问题。最初的服务器是基于进程/线程模型,新的一个 TCP 连接,就需要分配一个进程(或者线程),如果有 10k 个连接,那么需要创建 1w 个进程(或线程),对于单机服务器来说,这是无法承受的。所以如何去突破单机的性能,是高性能网络编程必须要面对的问题。关于 C10K 问题的探讨,可以参考这篇文章 The C10K problem.

  • 解决方案
    从 C10K 的背景中看出,要向解决高并发的连接问题,无非有两种,一是一个连接一个线程,另一个是多个连接一个线程。前者会因为系统资源而限制,就算系统资源充足,实际上效率也并不高,会涉及到大量的线程上下文切换操作,扩展性差,后者是现在的主流处理方式。

同步与异步

在涉及到 IO 问题时,通常会聊到同步与异步的概念,所谓的同步异步是在于应用程序读写数据还是内核来读写数据,如果说读操作是应用程序来读(调用 read() 的 system call 方法),写操作是应用程序来写(调用 write() 的 system call 方法),那么这种模型就是同步的;如果是内核来做的读写,那么就是异步的。实际上,在目前的 linux 内核版本(2.6)是没有异步的实现,只有 Windows 实现了异步模型。

阻塞与非阻塞

阻塞与非阻塞也是 IO 中经常容易混淆的概念,应用程序在发起读写操作时,对应就是某一个线程发起读写操作,那么会通过系统内核来进行 IO 操作,发起读写操作的线程如果一直等待内核 IO 操作完成以后,才能执行其他的操作,那么这种模型就是阻塞的,如果不需要等待内核 IO 操作完成,可以直接进行后续的操作,此时内核会立即返回给线程一个状态值。那么这种模型就是非阻塞的。

综上所述,IO 模型可以分为四类,同步阻塞同步非阻塞异步阻塞异步非阻塞,实际上异步阻塞 是没有意义的。所以目前 IO 主要分为三大类,而 Linux 只有前两类的实现,只有 Windows 实现了异步非阻塞的模型。接下来再了解一下同步阻塞和同步非阻塞的 IO 模型。

IO 模型

系统环境:CentOS 7.9,内核版本是 2.6
JDK 版本:11

  • BIO(同步阻塞)
    BIO(Blocking IO) 也是常说的传统 IO,java 中的实现在 java.io 包中。对于 BIO 的模型,从 linux 内核角度来说就是一个 TCP 连接就新建一个 thread 去处理,在很高的并发连接下,对于系统资源的开销还是比较大的。另外对于数据的读写,都必须要等到内核 IO 操作完成以后,在等待的过程中,线程一直占用资源,利用率不高。看一个 java BIO 的例子。
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class SocketBio {

public static void main(String[] args) throws IOException {
// 打开 socket server 服务
final ServerSocket server = new ServerSocket(9999);
System.out.println("start server:" + server.getInetAddress().getHostAddress() + ":" + server.getLocalPort());

// 这里 while 是模拟 BIO 模型下一直在等待连接
while (true) {
System.out.println(Thread.currentThread().getName() + ": 服务器正在等待连接...");
final Socket client = server.accept(); // 阻塞住

System.out.println("client: " + client.getInetAddress() + ":" + client.getPort());

// 当客户端连接成功以后,开启一个线程来处理
new Thread(() -> {
try {

// 拿到字节流,socket 通信
final InputStream in = client.getInputStream();
// 字节流转成字符流
final BufferedReader reader = new BufferedReader(new InputStreamReader(in));
System.out.println(Thread.currentThread().getName() + ": 服务器正在等待数据...");
// 一直循环等待消息
while (true) {
final String line = reader.readLine();
if (null != line) {
System.out.println(Thread.currentThread().getName() + ": 服务器已经接收到数据:" + line);
} else {
client.close();
break;
}
}
System.out.println(Thread.currentThread().getName() + ": 客户端 --> " + client.getInetAddress().getHostAddress() + "断开!");
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}

}
}

编译后在linux上跑一下(我的linux内核版本是 2.6),用linux工具命令strace 来跟踪该 java 进程的系统调用情况,运行:
strace -ff -o out java top.caolizhi.example.io.bio.SocketBio
跟踪的日志输出到 out 文件中。
服务端开始启动,并且阻塞在 accept 代码段,输出:

BIO 服务端

我们看到有很多 out.pid 格式的文件产生,如下图所示,最小的 pid 是主进程,还有垃圾回收进程等,应该都是 fork 出来的子进程。

BIO java 进程系统调用追踪日志文件

再来看一下服务端打开的TCP端口监听,执行命令
netstat -natp
可以看到 java 进程的 pid 是 7184,然后当前的状态是 LISTEN 状态

java 进程状态

再来看一下 7184 这个进程打开 FD(File Descriptor 文件描述符)有哪些,执行命令:
lsof -p 7184
lsof命令是查看当前系统文件的工具,一切皆文件,我们查看的是 java 进程打开了哪些文件(FD),如图:

java 进程打开的文件

还可以看一下 java 进程 socket 的信息,执行命令:
ll /proc/7184/fd
看到当前打了一个 fd = 5 的 socket,与 lsof 的结果一致。

BIO java 进程打开的 socket 类型文件

看了 java 进程打开的 FD 之后,接来下就看一下 java 进程 7184 的系统调用日志,打开 out.7184,主要看到最后几行的片段:

1
2
3
4
5
6
7
8
9
10
stat("/etc/sysconfig/64bit_strstr_via_64bit_strstr_sse2_unaligned", 0x7fff49a171a0) = -1 ENOENT (No such file or directory)
mprotect(0x7f5abc955000, 806912, PROT_READ) = 0
munmap(0x7f5abd8d9000, 28940) = 0
mmap(NULL, 1052672, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f5abd7d3000
clone(child_stack=0x7f5abd8d2fb0,
flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID,
parent_tidptr=0x7f5abd8d39d0,
tls=0x7f5abd8d3700,
child_tidptr=0x7f5abd8d39d0) = 7185
futex(0x7f5abd8d39d0, FUTEX_WAIT, 7185, NULL

实际上,7184 的进程里面又 clone 了一个子进程 7185,我们再去 7185 这个进程里面看一下,在 7185 的进程里面有几个关键的系统调用:bind(),listen()

1
2
3
4
5
6
7
8
9
10
bind(5, {sa_family=AF_INET6, sin6_port=htons(9999), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0
listen(5, 50)
.......
write(1, "start server\357\274\2320.0.0.0:9999", 27) = 27
write(1, "\n", 1) = 1
write(1, "\346\234\215\345\212\241\345\231\250\346\255\243\345\234\250\347\255\211\345\276\205\350\277\236\346\216\245...", 30) = 30
write(1, "\n", 1) = 1
mprotect(0x7f5ab418d000, 32768, PROT_READ|PROT_WRITE) = 0
pread64(3, "\312\376\272\276\0\0\0007\0026\n\0\6\1_\t\0\236\1`\t\0\236\1a\t\0\236\1b\t\0"..., 18001, 9986808) = 18001
poll([{fd=5, events=POLLIN|POLLERR}], 1, -1

上面操作中,bind()把等于 5 的 FD 和端口 9999 绑定,listen监听 5 这个 FD。然后调用 write() 的系统调用把需要打印信息写到标准输入的FD 1。
最后是 poll() 和以前的 accept() 类似,阻塞等待。

实际上这里有点疑问,之前的系统调用是 accept 阻塞等待,但是现在变成了 poll

接下来,我们接入一个客户端,看一下是什么情况,执行命令:
nc localhost 9999
看到有信息输出:

BIO 客户端连接

再来看一下 7184 fd 的情况:

BIO 客户端连接 FD

可以看到 fd = 6 的 socket 连接,状态为 ESTABLISHED。当有客户端连接以后,代码里面会新建一个线程来处理,我们得到了 out.7382 的日志:

BIO 新建线程处理IO

上图中,大概能看到 thread 的名称,pid 号, 还有系统调用 recvfrom(6,),等待接收数据。

以上就是一个 BIO 的底层实现流程,从上面的过程可以看到,服务端有两次阻塞,第一次是启动后等待客户端连接(accept),第二次是在客户端连接后等待客户端发送数据(recevfrom),如果没有数据,服务会一直阻塞住。另外如果我再开一个客户端连接,那么会新开一个线程去处理,大量的请求连接会造成服务器的压力。

另外我们可以还可以看一下客户端和服务端建立 TCP 连接的三次握手,四次挥手的过程,执行命令:
tcpdump -nn -i ens33 port 999
过程如下:

三次握手
四次挥手

  • NIO(同步非阻塞)
    针对 BIO 的劣势,我们考虑在单线程服务器处理,即我不去新建一个线程去处理,所有的请求在同一个线程里面处理,(其实这里你可以想一下 redis),但是这样会有一个问题就是一个连接请求进来了,线程阻塞住在等待客户端发送数据,如果另一个客户端连接过来,那么服务端无法处理,我们进行优化一下,可以这样去解决,如果等待数据的阻塞,还可以继续接收客户端的连接,再继续优化,如果等待数据时阻塞住了,那么我们遍历下一个 socket client,我们改进一下 BIO 的代码。
    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
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    public class SocketNio {

    public static void main(String[] args) throws IOException, InterruptedException {

    List<SocketChannel> clientList = new LinkedList<>();
    final ServerSocketChannel socketChannel = ServerSocketChannel.open();
    socketChannel.bind(new InetSocketAddress(9999));
    socketChannel.configureBlocking(false); // 不设置,就是阻塞,调用 accept(),这里解决 BIO 的第一个阻塞

    System.out.println("server started ..." + socketChannel.socket().getInetAddress() + ":" + socketChannel.socket().getLocalPort());

    while (true) {
    Thread.sleep(1000);
    final SocketChannel client = socketChannel.accept();

    if (null != client) {
    client.configureBlocking(false); // 配置非阻塞,否则一直等待客户端的数据,这里解决 BIO 的第二个阻塞
    System.out.println("client :" + client.socket().getInetAddress() + ":" + client.socket().getPort());
    clientList.add(client);
    }else {
    System.out.println("waiting for connection ....");
    }

    final ByteBuffer byteBuffer = ByteBuffer.allocate(4096);

    // 遍历客户端,读写数据
    for (SocketChannel channel : clientList) {
    System.out.println("read data from client " + channel.socket().getInetAddress() + ":" + channel.socket().getPort());
    final int byteNum = channel.read(byteBuffer);
    if (byteNum > 0) {
    byteBuffer.flip(); // 翻转,由写转成读
    final byte[] readBytes = new byte[byteBuffer.limit()];
    byteBuffer.get(readBytes); // 把 buffer 里面的数据 copy 到 readBytes 数组
    final String data = new String(readBytes);
    System.out.println(channel.socket().getInetAddress() + ":" + channel.socket().getPort() + "'s data :" + data) ;
    }
    }
    }
    }
    }
    上述代码中,server 端和 client 端都设置成非阻塞的方式,configureBlocking(false),并且将连接放在一个list集合中,在等待客户端消息时,看看消息是否准备好,遍历 list 集合,如果有消息则打印出来。我们可以再来看一下在启动服务的时候,第一次设置成阻塞模式的系统调用,同样是执行:
    strace -ff -o out java top.caolizhi.example.io.nio.SocketNio
    如下图,可以看到 server 端阻塞住了:

服务端阻塞

out.pid 找到 java 进程 fork 出来的监听子进程,1463,查看 1463 的 socket fd,如下:

服务端打开的 socket
再来看一下系统调用:

1
2
3
4
5
6
7
8
9
10
11
write(1, "server started .../0:0:0:0:0:0:0"..., 39) = 39
write(1, "\n", 1) = 1
futex(0x7f31a4026f54, FUTEX_WAIT_BITSET_PRIVATE, 1, {tv_sec=1884, tv_nsec=45138802}, 0xffffffff) = -1 ETIMEDOUT (Connection timed out)
futex(0x7f31a4026f28, FUTEX_WAKE_PRIVATE, 1) = 0
pread64(3, "\312\376\272\276\0\0\0007\0:\n\0\r\0%\n\0&\0'\t\0\f\0(\7\0)\7\0*\n"..., 1072, 5183108) = 1072
pread64(3, "\312\376\272\276\0\0\0007\0005\t\0\10\0!\n\0\t\0\"\t\0\36\0#\t\0\36\0$\t\0"..., 1161, 10456208) = 1161
pread64(3, "\312\376\272\276\0\0\0007\0\t\7\0\7\7\0\10\1\0\tinterrupt\1\0\25("..., 162, 17327013) = 162
pread64(3, "\312\376\272\276\0\0\0007\0\35\n\0\5\0\25\n\0\26\0\27\n\0\4\0\30\7\0\31\7\0\32\1"..., 460, 17355571) = 460
mprotect(0x7f31a419b000, 4096, PROT_READ|PROT_WRITE) = 0
rt_sigaction(SIGRT_30, {sa_handler=0x7f318aa21480, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7f31aa65c630}, {sa_handler=0x7f318a810020, sa_mask=[], sa_flags=SA_RESTORER, sa_restorer=0x7f31aa65c630}, 8) = 0
accept(4,

看,看到了吧,调用 accept(4,) 一直在监听 fd = 4 ,等待客户端连接,我们看一下 accept 的方法:

1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);;

accept 系统调用目的就是等待一个 socket connection,这也印证了 BIO 的模型。
把 server 端和 client 端全部设置成非阻塞模式,运行:

NIO 服务端非阻塞

一直在循环等待 client 连接,现在打开一个 client 去连接server,同样执行
nc 127.0.0.1 9999

NIO 客户端非阻塞

再来发送一组数据,比如”test nio”,我们可以看到 server 端接收到了数据打印了出来:

NIO 服务端打印客户端数据

再开启一个客户端来连接服务端,发送数据”client2 test”,服务端如下图:

NIO 服务端打印客户端2数据.png

最后看一下 NIO 的系统调用片段:

1
2
3
socket(AF_UNIX, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, 0) = 3
.....
accept(4, 0x7f1fe2065730, [28]) = -1 EAGAIN (Resource temporarily unavailable)

可以看到 socket 调用使用 SOCK_NONBLOCK 方式,另外,每一次调用 accept() 都会返回一个状态码,-1 表示没有连接可用。用man 2 socket来看一下描述:

SOCKET 系统调用说明

综上所述,在非阻塞模式下,解决了 BIO 的两次阻塞,但是非阻塞也有弊端,我每一次都要循环遍历所有的 socket 连接,才能看到是否有消息发送,如果有大量的连接的话,显然效率是极低的。况且并不是所有的连接都是有消息的,每次仍然轮询的那些连接,也是不合理的。

那么,NIO 中,java 程序一直在轮询所有的连接,不断地在用户态和内核态切换,如果,把轮询放到内核中去做,那岂不是效率要高的多,这就引出来多路复用的模式。

  • 多路复用(同步非阻塞)
    首先理解一下多路复用的概念,”多路” 实际上指的就是多个 IO,多个 socket 连接,也就是说单个线程通过记录跟踪每一个 IO 的状态,来同时管理多个 IO。IO 多路复用的实现主要有三种,按照出现的时间顺序为:selectpollepoll
    • select
      select 可以传入一个 fd 数组,内核需要开辟空间来存这部分 fd,然后去轮询,就算有数据也只是修改状态,然后全部返回给应用,并不会告诉应用那些 IO 是有数据的,所以应用还是要轮询一次,找有变化的 IO,再调用 read 取读取数据。所以这样就会有 2 次轮询和 2 次数据拷贝,另外 select 只支持最大 1024 个 fd,另外 select 是线程不安全的。
    • poll
      其实 poll 跟 select 差不多,但是可以支持任意个 fd,没有 1024 大小的限制,但是会受到系统文件描述符的限制,可用命令 ulimit -a 查看系统的限制。可以看下 POLL 方法描述POLL 系统调用
    • epoll
      epoll 是最新的 IO 多路复用的实现,linux 内核 2.6 以后才出现。epoll 做了两件事情,第一件事就是,在内核中,使用红黑树来维护所有的需要检查的 fd,红黑树的时间复杂度是 O(logN),另外一件事就是使用了事件驱动机制,在内核中维护了一张链表,把有状态的 fd 都放到那个链表里面,应用直接来取有状态的 fd 集合,效率大大的提高。epoll(7) 的方法调用又分为三个系统调用,epoll_create,epoll_ctl,epoll_wait
      • 应用调用 epoll_create 方法会在内核开辟一个红黑树结构,返回一个 fd
      • 应用调用 epoll_ctl 方法传入需要监听的 fd 和针对该 fd 相应的事件,是读还是写等?那么内核会不断地轮询该 fd,看是否有状态变化
      • 应用程序 epoll_wait 方法,内核会返回一个链表,链表里面是有状态变化的 fd,应用程序再去遍历这些 fd,调用 read()write() 进行操作

        链表里面的 fd 是从中断处理那边产生的,网卡中断会把 fd 放到 fd buffer 里面,然后内核再把 fd buffer 里面的 fd 添加到红黑树,接着把有状态变化的 fd 放到链表里面。

    epoll 的调用过程

接下来,再来看一个 java 多路复用的例子,代码如下:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class SocketMultiplexing {

public static void main(String[] args) throws IOException {
// 开启一个服务端
final ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(9999));

// Selector 类是多路复用的 java 实现
final Selector selector = Selector.open(); // 相当于调用了 epoll_create

/**
* 如果是 epoll 模型:相当于调用了 epoll_ctl,监听 EPOLLIN 事件
* 如果是 select/poll 模型:会在 jvm 里面开辟一个数组,把 fd 放进去。
*/
server.register(selector, SelectionKey.OP_ACCEPT);

System.out.println("server start: " + server.socket().getInetAddress() + ":" + server.getLocalAddress());

while (true) {
final Set<SelectionKey> selectionKeys = selector.keys();
System.out.println("total checking fd size: " + selectionKeys.size());


if (selector.select() > 0 ) { // select() 方法就是拿到有 IO 状态变化的 fd 数量
final Set<SelectionKey> selectedKeys = selector.selectedKeys(); // 拿到 IO 状态变化的 fd 集合
final Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
final SelectionKey key = keyIterator.next(); // 拿到 fd
keyIterator.remove(); // 移除掉,不然一直在循环处理
// 接下来,对不同的 IO 事件进行处理,是建立连接还是读数据还是写数据?
if (key.isAcceptable()) {

final ServerSocketChannel channel = (ServerSocketChannel)key.channel(); // 拿到的是 ServerSocketChannel,服务端
final SocketChannel client = channel.accept(); // accept 之后会拿到一个新的 fd
client.configureBlocking(false);

System.out.println("client: " + client.socket().getInetAddress() + ":" + client.getLocalAddress());

ByteBuffer buffer = ByteBuffer.allocate(4096);

//需要把上面调用 accept 产生的新的 fd 也要放到监听的列表里面去,并且监听的时间是 READ,绑定一个 buffer 到这个 fd 上。
client.register(selector, SelectionKey.OP_READ, buffer);

} else if (key.isReadable()) {

final SocketChannel client = (SocketChannel) key.channel(); // 拿到的是 SocketChannel 对象,客户端
// 拿到客户端传过来的数据,一个 buffer,因为上面 register 的时候,绑定了一个 buffer 在这个 fd 上。
final ByteBuffer buffer = (ByteBuffer) key.attachment();
buffer.clear();
while (true) {
final int read = client.read(buffer);
if (read > 0) { // 有数据
buffer.flip(); // 翻转,由读变成写
while (buffer.hasRemaining()) {
client.write(buffer); // 写回 client
}
} else if (read == 0) { // 没有数据
break;
} else {
client.close();
break;
}
}
}
}
}
}
}
}

上面的例子根据注释能够看懂做了什么事情,不再详述,运行该程序:
strace -ff -o out java top.caolizhi.example.io.multiplexing.SocketMultiplexing

多路复用程序运行

一开始服务启动会得到一个监听的 fd,所以 total checking fd size 是 1。

根据 out.pid 文件看一下当前 java 进程的 fd,
ll /proc/1931/fd
java 进程 FD 列表
可以看到一个类型为 eventpoll 的 fd 5,还有两个 pipe 类型的 fd, 再用 lsof 命令执行一下:

多路复用 java 进程打开的文件

可以看到程序打开了一个类型为 eventpoll 的 fd 5 ,还有只能 write 的管道 fd 6以及只能 read 的管道 fd 7。
我们添加一个客户端来连接服务器,同样用 nc 命令,执行完后:

多路复用客户端连接

可以看到,当有客户端连接后,产生一个新的 socket fd,程序把这个新的 socket fd 调用 register 方法注册 READ 事件,那么此时total checking fd size 是 2。然后在客户端发送数据,”abc“,”dddd“,服务端也会回写同样的数据给客户端。如图:
客户端服务端数据通信

接下来整体看一下这个程序的系统调用追踪:

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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# 建一个 socket io,返回fd 4
socket(AF_INET6, SOCK_STREAM, IPPROTO_IP) = 4

# 绑定端口
bind(4, {sa_family=AF_INET6, sin6_port=htons(9999), inet_pton(AF_INET6, "::", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, 28) = 0

# 监听 socket 连接
listen(4, 50) = 0

...........

# 创建一个 epoll 实例,返回fd 5
epoll_create(256) = 5
# 创建管道
pipe([6, 7]) = 0
fcntl(6, F_GETFL) = 0 (flags O_RDONLY)
fcntl(6, F_SETFL, O_RDONLY|O_NONBLOCK) = 0
fcntl(7, F_GETFL) = 0x1 (flags O_WRONLY)
fcntl(7, F_SETFL, O_WRONLY|O_NONBLOCK) = 0
# 针对 5 的 epoll 实例,添加一个监听 fd 6,监听的时间是 EPOLLIN
epoll_ctl(5, EPOLL_CTL_ADD, 6, {EPOLLIN, {u32=6, u64=140041703653382}}) = 0

# 调用系统 write 方法,打印信息
write(1, "server start: /0:0:0:0:0:0:0:0:/"..., 52) = 52

...........

write(1, "total checking fd size: 1", 25) = 25
# 针对 5 的 epoll 实例,添加一个监听 fd 4,监听的时间是 EPOLLIN
epoll_ctl(5, EPOLL_CTL_ADD, 4, {EPOLLIN, {u32=4, u64=140041703653380}}) = 0

...........

# 返回一个有 IO 状态改变的 fd 数量,因为此时发生了客户端的连接,有事件产生
epoll_wait(5, [{EPOLLIN, {u32=4, u64=140041703653380}}], 1024, -1) = 1
# 接受了一个客户端连接,返回fd 9
accept(4, {sa_family=AF_INET6, sin6_port=htons(59298), inet_pton(AF_INET6, "::ffff:192.168.170.112", &sin6_addr), sin6_flowinfo=htonl(0), sin6_scope_id=0}, [28]) = 9
fcntl(9, F_GETFL) = 0x2 (flags O_RDWR)

...........

write(1, "client: /192.168.170.112:/192.16"..., 46) = 46
write(1, "\n", 1) = 1
write(1, "total checking fd size: 2", 25) = 25
write(1, "\n", 1) = 1

# 把 fd 9 添加到监听列表中
epoll_ctl(5, EPOLL_CTL_ADD, 9, {EPOLLIN, {u32=9, u64=140041703653385}}) = 0
epoll_wait(5, [{EPOLLIN, {u32=9, u64=140041703653385}}], 1024, -1) = 1

...........
# 读取 fd 9 的 socket 数据
read(9, "abc\n", 4096) = 4
pread64(3, "\312\376\272\276\0\0\0007\0.\n\0\t\0+\7\0,\5\377\377\377\377\377\377\377\376\5\377\377\377\377"..., 1021, 17297719) = 1021

# 把 fd 9 socket 读取到数据再写回到 fd 9 的通道中
write(9, "abc\n", 4) = 4
write(1, "total checking fd size: 2", 25) = 25
futex(0x7f5ea80bdb54, FUTEX_WAKE_OP_PRIVATE, 1, 1, 0x7f5ea80bdb50, FUTEX_OP_SET<<28|0<<12|FUTEX_OP_CMP_GT<<24|0x1) = 1
write(1, "\n", 1) = 1
epoll_wait(5, [{EPOLLIN, {u32=9, u64=140041703653385}}], 1024, -1) = 1
read(9, "dddd\n", 4096) = 5
futex(0x7f5ea80bf954, FUTEX_WAKE_OP_PRIVATE, 1, 1, 0x7f5ea80bf950, FUTEX_OP_SET<<28|0<<12|FUTEX_OP_CMP_GT<<24|0x1) = 1
write(9, "dddd\n", 5) = 5
write(1, "total checking fd size: 2", 25) = 25
write(1, "\n", 1) = 1

# 没有任何 IO 状态发生变量
epoll_wait(5,

另外,根据 epoll 的系统调用文档,又分为边缘触发和水平触发,这里不拓展了,有兴趣可以去看 man page 文档。

  • AIO(异步非阻塞)
    linux 内核是没有实现 AIO 的。它与同步非阻塞的区别在于,不需要一个线程去轮询 IO 的状态改变,而是一个 IO 的状态变更,系统会通知相应的线程来处理。JDK 中的 AIO 的底层实现也是基于 epoll 来实现的,并非真正的异步 IO。

参考

https://tech.meituan.com/2016/11/04/nio.html
https://notes.shichao.io/unp/ch6/
深入理解计算机操作系统

作者

操先森

发布于

2021-09-22

更新于

2021-09-22

许可协议

评论