Netty学习——基础篇1(同步阻塞IO-BIO)

本文介绍了Linux网络I/O模型,包括阻塞、非阻塞、I/O复用、信号驱动I/O和异步I/O,重点讨论了epoll模型在I/O多路复用中的优势,以及BIO编程和伪异步I/O的局限性。
摘要由CSDN通过智能技术生成

1 Linux网络I/O模型简介

1.1 简述

        Linux的内核将所有外部设备都看做一个文件来操作,对一个文件的读写操作会调用内核提供的命令,返回一个file descriptor(fd,文件描述符)。而对一个socket的读写也会有相应的描述符,称为socketfd(socket描述符),描述符就是一个数字,它指向内核中的一个结构体(文件路径,数据区等一些属性)。根据UNIX网络编程对I/O模型的分类,UNIX提供了五种I/O模型。

1.2  五种I/O模型介绍

        阻塞I/O模型:

        最常用的I/O模型就是阻塞I/O模型,缺省情况下,所有文件操作都是阻塞的。以套接字接口为例来讲解此模型:在进程空间中调用recvfrom,其系统调用直到数据包到达且被复制到应用进程的缓冲区或者发生错误时才返回,再次期间一直会等待,进程在从调用recvfrom开始到它返回的整段时间内都是被阻塞的,因此成为阻塞I/O模型模型。如下图所示:

        非阻塞 I/O模型:

        recvfrom从应用层到内核的时候,如果该缓冲区没有数据的话,就直接返回一个EWOULDBLOCK错误,一般都对非阻塞模型进行轮询检查这个状态,看内核是否有数据到来,如下图:

        I/O复用模型:

        Linux提供select/poll,进程通过将一个或多个fd传递给select或poll系统调用,阻塞在select操作上,这样select/poll可以帮我们侦测多个fd是否处于就绪状态状态。select/poll是顺序扫描fd是否就绪,而且支持df数量有限,因此它的使用受到了一些限制。Linux还提供了一个epoll系统调用,epoll使用基于时间驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数,如下图:

        信号驱动I/O模型

        首先开启套接口信号驱动I/O功能,并通过系统调用sigaction执行一个信号处理函数(此系统调用立即返回,进程继续工作,它是非阻塞的)。当数据准备就绪时,就为该进程生成一个SIGIO信号,通过信号回调通知应用程序调用recvfrom来读取数据,并通过主循环函数处理数据,如下图:

         异步I/O

        告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动I/O由内核通知我们何时可以开始一个I/O操作;异步I/O模型由内核通知我们I/O操作何时已完成,如下图:

1.3 I/O多路复用技术

        在I/O编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O 多路复用技术通过把多个I/O 的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型相比,I/O 多路复用技术的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源,I/O 的主要应用场景如下:

        1、服务器需要同时处理多个处于监听状态或者多个连接状态的套接字

        2、服务器需要同时处理多种网络协议的套接字。

        目前支持I/O 多路复用的系统调用有select、pselect、poll、epoll,在Linux网络编程过程中,很长一段时间都使用select做轮询和网络事件通知,然而select的一些固有缺陷导致了它的应用受到很大限制,最终Linux选择了epoll。epoll与select的原理比较类型,为了克服select的缺点,epoll做了很多重大改进。总结如下:

        1、支持一个进程打开的socket描述符不受限制(仅受限于操作系统的最大文件句柄数)。

        select最大缺陷就是单个进程能够打开的fd是有一定限制的,它由FD_SETSIZE设置,默认值是1024。对于那些需要支持上万个TCP连接发大型服务器来说显然太少了。可以选择修改这个宏然后重新编译内核,不过这会带来网络效率的下降,也可以通过选择多进程的方案来解决这个问题,不过虽然在Linux上创建进程的代价比较大,但仍旧是不可忽视的。另外,进程间的数据交换非常麻烦,对于Java来说,由于没有共享内存,需要通过Socket通信或其他方式进行数据同步,这带来了额外的性能损耗,增加了程序复杂度,所以也不是一种完美的解决方案。而epoll没有这个限制,它所支持的FD上限是操作系统的最大文件句柄数,这个数字远远大于1024.例如,在1GB内存的机器上大约有10万个句柄左右,这个值跟系统的内存关系比较大。

        2、I/O 效率不会随着FD数量的增加而线性下降

        传统select/poll的另一个致命弱点,就是当你拥有一个很大的socket集合时,由于网络延时造成链路空闲,任一时刻只有少部分的socket是活跃的,但是select/poll每次调用都会线性扫描全部集合,导致效率呈线性下降。epoll不存在这个问题,它只会对活跃的socket进行操作——这是因为在内核实现中,epoll是根据每个fd上面的callback函数实现的。那么,只有活跃的socket才会去主动调用callback函数,其他idle状态的socket则不会。在这点上,epoll实现了一个伪AIO。针对epoll和select性能对比的benchmark测试表明:如果所有的socket都处于一个活跃态——例如一个高速LAN环境,epoll并不比select/poll效果高太多;相反,如果过多使用epoll_ctl,效率相比还有稍微的降低。但是一旦使用idle connections模拟WAN环境,epoll的效率就远在select/epoll之上了。

        3、使用mmap加速内核与用户空间的消息传递

        无论是select、poll还是epoll都需要内核把FD消息通知给用户空间,如何避免不必要的内存复制就显得非常重要,epoll是通过内核和用户空间mmap同一块内存来实现。

        4、epoll的API更简单

        包括创建一个epoll描述符、添加监听事件、阻塞等待所监听的事件发生、关闭epoll描述符等。

2 传统的BIO编程

        网络编程的基本模型是Client/Server模型,也就是两个进程之间进行相互通信,其中服务端位置信息(绑定的IP地址和监听端口),客户端通过连接操作箱服务端监听的地址发起连接请求,通过的三次握手建立连接,如果连接建立成功,双方就可以通过网络套接字(Socket)进行通信。

        在基于传统同步阻塞模型并发中,ServerSocket负责绑定IP地址,启动监听端口;Socket负责发起连接操作。连接成功之后,双方通过输入和输出流进行同步阻塞式通信。

        下面以时间服务器(TimeServer)为例,通过代码分析来回顾和熟悉BIO编程。

2.1 BIO通信模型图

        首先,通过下图所示的通信模型来熟悉BIO的服务端通信模型

        采用BIO通信模型的服务端,通常由一个独立的Acceptor线程负责监听客户端的连接,它接收到客户端连接请求之后为每个客户端创建一个新的线程进行链路处理,处理完成之后,通过输出流返回应答给客户端,线程销毁。就是代行的一请求一应答通信模型。

        该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈1:1的正比关系,由于线程是Java虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。

2.2 同步阻塞式I/O创建的TimeServer源码分析

package BIO;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;


public class TimeServer {
    public static void main(String[] args) throws IOException {
        int port = 8080;
        if(args != null && args.length > 0){
            try {
                port = Integer.valueOf(args[0]);
            }catch (NumberFormatException e){
                e.printStackTrace();
            }
        }
        ServerSocket serverSocket = null;
        try {
            serverSocket = new ServerSocket(port);
            System.out.println("时间服务器开始启动,端口号是:"+port);
            Socket socket = null;
            while(true){
                socket = serverSocket.accept();
                new Thread(new TimeServerHandler(socket)).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            if(serverSocket != null){
                System.out.println("时间服务器关闭");
                serverSocket.close();
                serverSocket = null;
            }
        }
    }
}
TimeServerHandler.java
package com.jay.BIO;

import org.ietf.jgss.Oid;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Date;

public class TimeServerHandler implements Runnable{
    private  Socket socket;

    public TimeServerHandler(Socket socket) {
        this.socket = socket;
    }
    @Override
    public void run(){
        BufferedReader in = null;
        PrintWriter out = null;
        try {
            in = new BufferedReader(new InputStreamReader((this.socket.getInputStream())));
            out = new PrintWriter(this.socket.getOutputStream(),true);
            String currentTime = null;
            String body = null;
            while(true){
                body = in.readLine();
                if(body == null){
                    break;
                }
                System.out.println("TimeServer收到信息:"+body);
                currentTime = "query".equalsIgnoreCase(body) ? new Date(System.currentTimeMillis()).toString() : "bad";
                out.println(currentTime);
            }
        }catch (Exception e){
            if(in != null){
                try {
                    in.close();
                }catch (IOException e1){
                    e1.printStackTrace();
                }
            }
            if(out != null){
                out.close();
                out = null;
            }
            if(this.socket != null){
                try {
                    this.socket.close();
                }catch (IOException e2){
                    e2.printStackTrace();
                }
                this.socket = null;
            }
        }
    }
}

       TimeServer.java 分析

        TimeServer根据传入的参数设置监听端口,如果没有入参,使用默认值8080.然后通过构造函数创建ServerSocket,如果端口合法且没有被占用,服务端监听成功。然后通过一个无限循环来监听客户端的连接,如果没有客户端接入,则主线程阻塞在ServerSocket的accept操作上。启动TimeServer,发现主线程阻塞在accept上。当有新的客户端接入的时候,执行这行代码

new Thread(new TimeServerHandler(socket)).start();

        以Socket为参数构造TimeServerHandler对象,  TimeServerHandler是一个Runnable,使用它它为构造函数的参数创建一个新的客户端线程处理这条Socket链路。

         TimeServerHandler.java 分析

        通过BufferedReader读取一行,如果已经读到了输入流的尾部,则返回值为null,退出循环。如果读到非空值,则对内容进行判断,如果请求消息为查询时间的指令“query”,则获取当前最新的系统时间,如果PrintWrite的println函数发送给客户端,最后退出循环。最后面为释放输入流、输出流和Socket套接字句柄资源,最后线程自动销毁并被虚拟机回收。

2.3 同步阻塞式I/O创建的TimeClient源码分析

        客户端通过Socket创建,发送查询时间服务器的“query”指令,然后读取服务端的相应并把结果打印出来,随后关闭连接,释放资源,程序退出执行。

package BIO;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;


public class TimeClient {
    public static void main(String[] args) {
        int port = 8080;
        if(args != null && args.length > 0){
            try {
                port = Integer.valueOf(args[0]);
            }catch (NumberFormatException e){
                e.printStackTrace();
            }
        }
        Socket socket = null;
        BufferedReader in = null;
        PrintWriter out = null;
        try {
            socket = new Socket("127.0.0.1",port);
            in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
            out = new PrintWriter(socket.getOutputStream(),true);
            out.println("query");
            System.out.println("成功发送到服务器");
            String resp = in.readLine();
            System.out.println("现在时间是:" + resp);
        }catch (Exception e){
            
        }finally {
            if(out != null){
                out.close();
                out = null;
            }
            if(in != null){
                try {
                    in.close();
                }catch (IOException e1){
                    e1.printStackTrace();
                }
                in = null;
            }
            if(socket != null){
                try {
                    socket.close();
                }catch (IOException e2){
                    e2.printStackTrace();
                }
                socket = null;
            }
        }
    }
}

        客户端通过PrintWrite想服务端发送“query”指令,然后通过BufferedReader的readLine读取响应并打印。分别执行服务端和客户端,执行结果如下。

        服务器运行结果如下:

       客户端执行结果如下:

  

        为了改进一线程一连接模型,后来又演进出了一种通过线程池或者消息队列实现1个或者多个线程处理N个客户端的模型,由于它的底层通信机制依然使用同步阻塞I/O,所以被称为“伪异步”。下面对伪异步代码进行分析。

3 伪异步I/O编程

        为了解决同步阻塞I/O面临的一个链路需要一个线程处理的问题,后来有人对它的线程模型进行了优化——后端通过一个线程池来处理多个客户端的请求接入,形成客户端个数M:线程池最大线程数N的比例关系,其中M可以远远大于N。通过线程池可以灵活地调配线程资源,设置线程的最大值,防止由于海量并发接入导致线程耗尽。

3.1 伪异步I/O模型图

        采用线程池和任务队列可以实现一种伪异步的I/O 通信框架,它的模型如下图:

        当有新的客户端接入时,将客户端的Socket封装成一个Task(该任务实现Runnable接口)投递到后端的线程池中进行处理,JDK的线程池维护一个消息队列和N个活跃线程,对消息队列中的任务进行处理。由于线程池可以设置消息队列的大小和最大线程数,因此它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。

3.2 伪异步I/O创建TimeServer源码分析

TimeServerAsyn.java代码如下:
public class TimeServerAsync {
    public static void main(String[] args) throws IOException {
        int port = 8080;
        if(args != null && args.length > 0){
            try {
                port = Integer.valueOf(args[0]);

            }catch (NumberFormatException e){
                e.printStackTrace();
            }
        }
        ServerSocket server = null;
        try {
            server = new ServerSocket(port);
            System.out.println("TimeServer 启动,端口号是:"+port);
            Socket socket = null;
            TimeServerHandlerExecutePool singleExecute = new TimeServerHandlerExecutePool(50, 10000);
            while(true){
                socket = server.accept();
                singleExecute.execute(new TimeServerHandler(socket));
            }
        }finally {
            if(server != null){
                System.out.println("关闭TimeServer ");
                server.close();
                server = null;
            }
        }
    }
}

        伪异步I/O的主函数发生了变化,首先创建一个时间服务器处理类的线程池,当接收到新的客户端连接时,将请求Socket封装成一个Task,然后调用线程池的execute方法执行,从而避免了每个请求接入都创建一个新的线程。

TimeServerHandlerExecutePool.java 源码
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class TimeServerHandlerExecutePool {
    private ExecutorService executorService;

    public TimeServerHandlerExecutePool(int maxPoolSize,int queueSize) {
        executorService = new ThreadPoolExecutor(Runtime.getRuntime().availableProcessors(),maxPoolSize,120L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<Runnable>(queueSize));

    }
    public void execute(Runnable task){
        executorService.execute(task);
    }
}

        客户端代码没有改变,因此直接运行服务端和客户端,执行结果如下:

        伪异步I/O虽然避免了每个请求都创建一个独立线程造成的线程资源耗尽问题。但由于它底层的通信依然采用同步阻塞模型,因此无法从根本上解决问题。

3.3 伪异步I/O弊端分析

        要对伪异步I/O的弊端进行深入分析,首先看两个java同步I/O的API说明,随后结合代码进行详细分析,先看InputStream.java的read()方法:

/**
    This method blocks until input data is
     * available, end of file is detected, or an exception is thrown.
     */
    public int read(byte b[]) throws IOException {
        return read(b, 0, b.length);
    }

         请看注释,当对Socket的输入流进行读取操作的时候,它会一直阻塞下去,直到发生如下三件事情:

        1、有数据可读。2、可用数据已经读取完毕。3、发生异常。

        这意味着当对方发送请求或者应答消息比较缓慢,或者网络传输较慢时,读取输入流一方的通信线程将被长时间阻塞,如果对方要60秒才能将数据发送完成,读取一方的I/O线程也将会被同步阻塞60秒,在此期间,其他接入消息只能在消息队列中排队。

        同样,当调用OutPutStream的write方法写输出流的时候,它将会被阻塞,直到所有要发送的字节全部写入完毕,或者发生异常。学习过TCP/IP相关知识的都知道,当消息的接收方处理缓慢的时候,将不能及时地从TCP缓冲区读取数据,这将会导致发送方的TCP Window size不断减少,直到为0,双方处理Keep-Alive状态,消息发送方将不能再想TCP缓冲区写入消息,这是如果采用的是同步阻塞I/O,write操作将会被无限期阻塞,直到TCP Window size大于0或者发生I/O异常。

        伪异步I/O实际上仅仅是对之前I/O线程模型的一个简单优化,无法从根本上解决同步I/O导致的通信线程阻塞问题。

        

  • 27
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

geminigoth

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值