# 【NO.205】详解从网络IO到IO多路复用

## 1.前言

这篇文章，会重点介绍linux的BIO、NIO和IO多路复用。

## 2.Netcat软件的基本使用

Netcat（简写nc）是一个强大的网络命令工具，能够在linux中执行与TCP、UDP相关的操作，例如端口扫描，端口重定向、端口监听甚至远程连接

在这里，我们使用 nc 来模拟一台接收message的服务器，和一台发送message的客户端

1、安装 nc 软件

```
sudo yum install -y nc
```

2、使用 nc 创建一台监听9999端口的服务器

```
nc -l -p 9999     # -l表示listening，监听
```

启动成功后 nc 进行阻塞
3、新建一个bash，使用 nc 创建一个发送message的客户端

```
nc localhost 9999
```

在控制台上输入要发送的信息，查看服务端是否接收到
4、查看上面的nc进程中的文件描述符

```
ps -ef | grep nc  # 查看nc的进程号，这里假设是2603ls /proc/2603/fd  # 查看2603进程下的文件描述符
```

![img](https://pic4.zhimg.com/80/v2-5c08411a05b47338cef4612ad0cb36db_720w.webp)

可以看到这个进程下有一个socket，这就是nc的客户端和服务端之间创建的一个socket
经过这一系列的操作，相信我们对Netcat软件有了基本的了解，下面来介绍BIO

## 3.strace追踪系统调用

**strace软件说明：** 它是一个可以追踪系统调用和信号的软件，通过它我们来了解BIO

**环境说明：** 这里演示的都是基于老版本的linux，因为新版本的linux都不用BIO了，演示不出来

1、使用strace来追踪系统调用

```
sudo yum install -y strace              # 安装strace软件mkdir ~/strace                          # 新建一个目录，存放追踪的信息cd ~/strace                             # 进入到这个目录strace -ff -o out nc -l -p 8080   # 使用strace追踪后边的命令进行的系统调用                                  # -ff 表示追踪后面命令创建的进程及子进程的所有系统调用，                                  # 并根据进程id号分开输出到文件                                  # -o  表示追踪到的信息输出到指定名称的文件，这里是out
```

2、查看服务端创建的系统调用

在上一步进入的目录下，出现了一个 out.pid 文件，里的内容都是 nc -l -p 9999 这个命令执行后的系统调用过程，使用vim命令来查看

```
vim out.92459    # nc进程id为92459
```

![img](https://pic4.zhimg.com/80/v2-0a2ae8886fa5f866c8a73b5599a2fb5b_720w.webp)

这里accept()方法进行了**阻塞**，它要等待其他socket对它进行连接

3、客户端连接，查看系统调用

退出vim，使用tail来进行查看

```
tail -f out.92459
```

-f 参数：当文件有追加的内容，可以实时地打印在控制台，这样就能很方便来查看客户端连接后进行的系统调用

```
nc localhost 8080
```

查看系统调用

![img](https://pic2.zhimg.com/80/v2-146235fda1321b3126a4fb294d243cf1_720w.webp)

- 这里客户端连接后，accept() 方法获取到客户端连接并返回文件描述符4，这个4就是服务端新创建的socket，用于和这个客户端进行通信
- 之后使用多路复用器poll来监听服务端上文件描述符4和0，0是标准输入文件描述符，哪个有事件发生就读取哪个文件描述符，如果都没有事件发生就进行堵塞

4、客户端发送message，查看系统调用

![img](https://pic1.zhimg.com/80/v2-d5ca3d6c0767fef34bcdb35ef5442274_720w.webp)

客户端向服务端发送数据，服务端就能从socket中监听到有事件发生，就能进行相应的处理，处理完继续堵塞，等待下一个事件发生

5、服务端发送数据到客户端，查看系统调用

![img](https://pic1.zhimg.com/80/v2-5953662de6fd22abf7be4d38d07b0c38_720w.webp)

服务端发数据，肯定从键盘输入，也就是标准输入0，从0中读取到数据发送给socket 4

## 4.BIO（阻塞式IO）

在我们第三节中，我们使用 strace 工具查看了 nc 软件使用过程中的系统调用，其实上一节中体现的就是BIO，我们把上面的一系列系统调用总结一下，根据直观的理解BIO

1、单线程模式

1.1、过程演示

1、服务端启动

![img](https://pic1.zhimg.com/80/v2-eb9f0ab1fd7d34ca21f3081ad62c1ee0_720w.webp)

启动服务端，等待socket连接，accept()方法阻塞
2、客户端连接,未发送数据

![img](https://pic2.zhimg.com/80/v2-bc319d7d1d5883cac82e8e952d936b2d_720w.webp)

连接客户端，accept() 方法执行，未收到client1发送的数据，read()方法阻塞
3、另一个客户端连接

![img](https://pic3.zhimg.com/80/v2-5903f2d54f065046e5f48d2847fabfae_720w.webp)

由于read()方法阻塞，无法执行到accept()方法，所以这样cpu一次只能处理一个socket

1.2、存在的问题

上面的模型存在很大的问题，如果客户端与服务端建立了连接，客户端迟迟不发数据，进程就会一直堵塞在read()方法上，这样其他客户端也不能进行连接，也就是一次只能处理一个客户端，对客户很不友好

1.3、如何解决

其实要解决这个问题很简单，利用多线程就可以，只要连接了一个socket，操作系统分配一个线程来处理，这样read()方法堵塞在每个线程上，不堵塞主线程，就能操作多个socket了，有哪个线程中的socket有数据，就读哪个socket

2、多线程模式

1.1 过程演示

![img](https://pic4.zhimg.com/80/v2-d0ec08275964e6f1d84bf134bda5e4cf_720w.webp)

- 程序服务端只负责监听是否有客户端连接，使用 accept() 阻塞
- 客户端1连接服务端，就开辟一个线程（thread1）来执行 read() 方法，程序服务端继续监听
- 客户端2连接服务端，也开辟一个线程，执行read()方法
- 任何一个线程上的socket有数据发送过来，read()就能立马读到，cpu就能进行处理

1.2、存在的问题

上面这个多线程模型，看似已经十分的完美，其实也有很大的问题。每来一个客户端，就要开辟一个线程，如果来1万个客户端，那就要开辟1万个线程。在操作系统中，用户态不能直接开辟线程，需要调用cpu的80软中断，让内核来创建的一个线程，这其中还涉及到用户状态的切换（上下文的切换），十分耗资源。

1.3、如何解决

第一个办法：使用线程池，这个在客户端连接少的情况下可以使用，但是用户量大的情况下，你不知道线程池要多大，太大了内存可能不够，也不可行
第二个办法：因为read()方法堵塞了，所有要开辟多个线程，如果什么方法能使read()方法不堵塞，这样就不用开辟多个线程了，这就用到了另一个IO模型，NIO（非阻塞式IO）

## 5.NIO（非阻塞式IO）

### 5.1.过程演示

1、服务端刚创建，没有客户端连接

![img](https://pic4.zhimg.com/80/v2-b0f6c03e8f6f0bf0ef3562d96bbe8c9f_720w.webp)

在NIO中，accept()方法也是非阻塞的，它在一个while死循环中
2、当有一个客户端进行连接时

![img](https://pic4.zhimg.com/80/v2-07459a0889c0f129265d7df0909a085b_720w.webp)

3、当有第二个客户端进行连接时

![img](https://pic2.zhimg.com/80/v2-76357b06bd9b438bedc87588537b03bd_720w.webp)

### 5.2.总结

**在NIO模式中，一切都是非阻塞的：**

- accept()方法是非阻塞的，如果没有客户端连接，就返回error
- read()方法是非阻塞的，如果read()方法读取不到数据就返回error，如果读取到数据时只阻塞read()方法读数据的时间

**在NIO模式中，只有一个线程：**

- 当一个客户端与服务端进行连接，这个socket就会加入到一个数组中，隔一段时间遍历一次，看这个socket的read()方法能否读到数据
- 这样一个线程就能处理多个客户端的连接和读取了

### 5.3.存在的问题

NIO成功的解决了BIO需要开启多线程的问题，NIO中一个线程就能解决多个socket，看似已经 perfect，但是还存在问题。
这个模型在客户端少的时候十分好用，但是客户端如果很多，比如有1万个客户端进行连接，那么每次循环就要遍历1万个socket，如果一万个socket中只有10个socket有数据，也会变量一万个socket，就会做很多无用功。而且这个遍历过程是在用户态进行的，用户态判断socket是否有数据还是调用内核的read()方法实现的，这就涉及到用户态和内核态的切换，每遍历一个就要切换一次，开销很大
因为这些问题的存在，IO多路复用应运而生

## 6.IO Multiplexing（IO多路复用）

IO多路复用有三种实现方式，select、poll、epoll，现在让我们来看看这三种实现的真面目吧

### 6.1select

![img](https://pic4.zhimg.com/80/v2-aaadcec4d7860dc79f067813846d1a7f_720w.webp)

这里还有select代码实现的代码例子

![img](https://pic2.zhimg.com/80/v2-67d68c53a4caff2b666eda2c428506c1_720w.webp)

1.1 优点

select 其实就是把NIO中用户态要遍历的 fd 数组拷贝到了内核态，让内核态来遍历，因为用户态判断socket是否有数据还是要调用内核态的，所有拷贝到内核态后，这样遍历判断的时候就不用一直用户态和内核态频繁切换了
从代码中可以看出，select系统调用后，返回了一个置位后的&rset，这样用户态只需进行很简单的二进制比较，就能很快知道哪些socket需要read数据，有效提高了效率

1.2 存在的问题

1、bitmap最大1024位，一个进程最多只能处理1024个客户端
2、&rset不可重用，每次socket有数据就相应的位会被置位
3、文件描述符数组拷贝到了内核态，仍然有开销
4、select并没有通知用户态哪一个socket有数据，仍然需要O(n)的遍历

### 6.2.poll

2.1 代码例子

![img](https://pic2.zhimg.com/80/v2-5317298c00307900115412c5bbf19115_720w.webp)

在poll中，文件描述符有一份独立的数据结构pollfd，传入poll中的是pollfd的数组，其他的实现逻辑和select一样

2.2 优点

1、poll使用pollfd数组来代替select中的bitmap，数组没有1024的限制，可以一次管理更多的client
2、当pollfds数组中有事件发生，相应的revents置位为1，遍历的时候又置位回0，实现了pollfd数组的重用

2.3 缺点

poll 解决了select缺点中的前两条，其本质原理还是select的方法，还存在select中原来的问题
1、pollfds数组拷贝到了内核态，仍然有开销
2、poll并没有通知用户态哪一个socket有数据，仍然需要O(n)的遍历

### 6.3.epoll

3.1 代码例子

![img](https://pic1.zhimg.com/80/v2-e2628c6c8ca5105a063effa1080b239c_720w.webp)

3.2 事件通知机制

1、当有网卡上有数据到达了，首先会放到DMA（内存中的一个buffer，网卡可以直接访问这个数据区域）中
2、网卡向cpu发起中断，让cpu先处理网卡的事
3、中断号在内存中会绑定一个回调，哪个socket中有数据，回调函数就把哪个socket放入就绪链表中

3.3 详细过程

首先epoll_create创建epoll实例，它会创建所需要的红黑树，以及就绪链表，以及代表epoll实例的文件句柄，其实就是在内核开辟一块内存空间，所有与服务器连接的socket都会放到这块空间中，这些socket以红黑树的形式存在，同时还会有一块空间存放就绪链表；红黑树存储所监控的文件描述符的节点数据，就绪链表存储就绪的文件描述符的节点数据；
epoll_ctl添加新的描述符，首先判断是红黑树上是否有此文件描述符节点，如果有，则立即返回。如果没有， 则在树干上插入新的节点，并且告知内核注册回调函数。当接收到某个文件描述符过来数据时，那么内核将该节点插入到就绪链表里面。
epoll_wait将会接收到消息，并且将数据拷贝到用户空间，清空链表。

3.4 水平触发和边沿触发

Level_triggered(水平触发)：当被监控的文件描述符上有可读写事件发生时，epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完(如读写缓冲区太小)，那么下次调用 epoll_wait()时，它还会通知你在上没读写完的文件描述符上继续读写，当然如果你一直不去读写，它会一直通知你！！！如果系统中有大量你不需要读写的就绪文件描述符，而它们每次都会返回，这样会大大降低处理程序检索自己关心的就绪文件描述符的效率！！！
Edge_triggered(边缘触发)：当被监控的文件描述符上有可读写事件发生时，epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完(如读写缓冲区太小)，那么下次调用epoll_wait()时，它不会通知你，也就是它只会通知你一次，直到该文件描述符上出现第二次可读写事件才会通知你！！！这种模式比水平触发效率高，系统不会充斥大量你不关心的就绪文件描述符！！！

3.5 优点

epoll是现在最先进的IO多路复用器，Redis、Nginx，linux中的Java NIO都使用的是epoll
1、一个socket的生命周期中只有一次从用户态拷贝到内核态的过程，开销小
2、使用event事件通知机制，每次socket中有数据会主动通知内核，并加入到就绪链表中，不需要遍历所有的socket

原文链接：https://zhuanlan.zhihu.com/p/354937550

作者：[Hu先生的Linux](https://www.zhihu.com/people/huhu520-10)