漫谈网络I/O——同步阻塞I/O(BIO)

75 阅读4分钟

参考

Linux 网络包发送过程:25 张图,一万字,拆解 Linux 网络包发送过程 (qq.com)

图解Linux网络包接收过程: 图解Linux网络包接收过程 (qq.com)

当内核收到了一个网络包:当内核收到了一个网络包 (qq.com)

epoll 是如何实现 IO 多路复用的:图解 | 深入揭秘 epoll 是如何实现 IO 多路复用的! (qq.com)

你管这破玩意叫 IO 多路复用: 你管这破玩意叫 IO 多路复用? (qq.com)

认认真真的聊聊中断: 认认真真的聊聊中断 (qq.com)

认认真真的聊聊"软"中断:认认真真的聊聊"软"中断 (qq.com)

【视频】netty视频

【书】计算网络自顶向下

【书】UNIX网络编程卷1:套接字联网API(第三版)

【书】图解TCP_IP

本文笔者只是做整合以及阅读总结,建议大家看看原文

同步阻塞I/O(BIO)

同步I/O为最早的I/O的模型,对于早期数据通讯来说是足够的,因为那个时候数据量远远没有那么大

同步意味着线程自己去获取结果(单个线程)

异步以为这线程不去获取结果,而是由其他线程送结果(至少两个结果)

伪代码如下:

listenfd = socket();   // 打开一个网络通信端口
bind(listenfd);        // 绑定
listen(listenfd);      // 监听
while(1) {
  connfd = accept(listenfd);  // 阻塞建立连接
  int n = read(connfd, buf);  // 阻塞读数据
  doSomeThing(buf);  // 利用读到的数据做些什么
  close(connfd);     // 关闭连接,循环等待下一个连接
}

代码执行会在accept和read两个地方阻塞并等待连接。 accept阻塞是在等待连接,而read阻塞是在等待数据。有上图动画可知accpet出阻塞描述的是TCP三次握手的过程,这里默认大家已经知道了就不细谈了。我在直接来看read阻塞时背后发生了什么。

  1. 首先read函数会触发用户态到内核态的转换,因为语言(以java为例)本身并不能直接读取网络I/O,所以就需要去调用操作系统提供的函数来处理
  2. 发起read事件进入内核之后并不意味着立马就有数据,所以此时需要等待数据
  3. 此时数据来了,网卡先接收到数据处理完之后DMA(Direct Memory Access)将数据复制到内核缓冲区
  4. 将数据从内核缓冲区复制到用户缓冲区,然后此时语言层面的read函数就可以返回

简化流程如下两图所示:

image.png

实际上数据到达网卡由dma进入内核缓冲区之后还有一些处理。这里我就不展开了,简略看下流程图:

阻塞I/O的优缺点

优点:

  • I/O模型简单

缺点:

  • 当发生阻塞的时候程序做不了任何事,这明显很影响效率。

优化

为了解决上面的问题,我们可以每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。

640 (2).gif

例子

代码清单:BIO01.java

public class BIO01 {
    private final static Logger log = LoggerFactory.getLogger(MyServer.class);
    public static void main(String[] args) throws IOException {
        // 使用 nio 来理解阻塞模式, 单线程
// 0. ByteBuffer
        ByteBuffer buffer = ByteBuffer.allocate(16);
// 1. 创建了服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();
​
// 2. 绑定监听端口
        ssc.bind(new InetSocketAddress(8080));
​
// 3. 连接集合
        List<SocketChannel> channels = new ArrayList<>();
        while (true) {
            // 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
            log.debug("connecting...");
            SocketChannel sc =  ssc.accept(); // 阻塞方法,线程停止运行
            log.debug("connected... {}", sc);
            channels.add(sc);
            for (SocketChannel channel : channels) {
                // 5. 接收客户端发送的数据
                log.debug("before read... {}", channel);
                channel.read(buffer); // 阻塞方法,线程停止运行
                buffer.flip();
                ByteBufferUtil.debugRead(buffer);
                buffer.clear();
                log.debug("after read...{}", channel);
            }
        }
    }
}

结果

14:38:26.600 [main] DEBUG net.MyServer - connecting...
14:38:30.686 [main] DEBUG net.MyServer - connected... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:50277]
14:38:30.687 [main] DEBUG net.MyServer - before read... java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:50277]
14:38:33.468 [main] DEBUG io.netty.util.internal.logging.InternalLoggerFactory - Using SLF4J as the default logging framework
+--------+-------------------- read -----------------------+----------------+
position: [0], limit: [2]
         +-------------------------------------------------+
         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |
+--------+-------------------------------------------------+----------------+
|00000000| 11 01                                           |..              |
+--------+-------------------------------------------------+----------------+
14:38:33.503 [main] DEBUG net.MyServer - after read...java.nio.channels.SocketChannel[connected local=/127.0.0.1:8080 remote=/127.0.0.1:50277]
14:38:33.503 [main] DEBUG net.MyServer - connecting...

总结

为了解决阻塞的问题一个思路就是利用多线程每过来一个连接都开一个线程,虽然解决了问题但是在面对大量的连接的时候这种方法显然十分的浪费资源。