同步阻塞式IO模型-多线程版本

大家好,我是小黑,今天继续和大家分享网络编程中的阻塞式IO模型,这次的是多线程版本。

多线程版本的同步阻塞式IO模型

单线程版本的同步阻塞式IO模型,因为只有一个线程,无法充分利用现在机器多线程的能力,无法并行处理多个客户端请求。而多线程版本就没有这个问题,他的要点在于开多个线程,去处理客户端请求。

那能不能来一个客户端,就开一个线程处理呢?显然是不行的。原因在于:

  • 线程的创建和销毁是要消耗系统资源的,线程太多会影响系统性能,
  • 线程本身也会占用资源,开太多会有内存溢出的风险  

所以实现多线程版本的同步阻塞式IO模型,更多是使用线程池的方式实现。利用线程复用的思想,减少创建和销毁线程的次数,并且根据具体的业务情况,灵活配置线程数

image.png

多线程版本同步阻塞式IO模型的实现

示例同样是一个Echo服务器,客户端发送什么数据,服务端就在加上一个日期然后原样返回。

我们先看看服务端怎么实现,代码如下。主体是一个死循环,阻塞在 accept() 方法上,有连接建立才会返回。返回后会包装一个 Handler 扔到线程池中,然后直接返回,等待下一个连接。

Handler 则负责处理网络数据,分为三个大步骤:读取客户端数据(read),处理客户端数据(process),结果返回给客户端(write)。

public class EchoServer04 {

    private ExecutorService es;
    private ServerSocket serverSocket;

    public static void main(String[] args) throws IOException {
        new EchoServer04().start();
    }

    public EchoServer04() throws IOException {

        es = Executors.newCachedThreadPool(); // 一般都要根据业务场景设置好线程数和最大任务数
        serverSocket = new ServerSocket(8001);
    }

    public void start() {

        for (; ; ) { // 死循环,通过 accept() 方法不断接受客户端连接
            Socket socket = null;
            try {
                socket = serverSocket.accept();
                es.execute(new Handler(socket)); // 然后线程池,用异步线程的方式处理
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    // 网络数据处理任务,实现 Runnable 接口
    private static class Handler implements Runnable {

        public Socket socket;

        public Handler(Socket socket) {
            this.socket = socket;
        }

        @Override
        public void run() {
            BufferedReader bufferedReader = null;
            PrintWriter printWriter = null;
            try {
                bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                printWriter = new PrintWriter(socket.getOutputStream(), true);
                long start = System.currentTimeMillis();
                String line = null;
                while ((line = bufferedReader.readLine()) != null) { // read
                    String echoMsg = new Date() + ": " + line; // process
                    printWriter.println(echoMsg); // write
                }
                long end = System.currentTimeMillis();
                System.out.println("spend:" + (end - start));
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (Objects.nonNull(printWriter)) {
                        printWriter.close();
                    }
                    if (Objects.nonNull(bufferedReader)) {
                        bufferedReader.close();
                    }
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}
复制代码

客户端一般单线程就可以了,如果需要代码在本地跑一跑,可以查看上一篇文章:同步阻塞式IO模型-单线程版本

多线程版本同步阻塞式IO模型的缺点

  • 死锁风险

因为是多线程编程,所以就会有死锁的风险。日常工作中,我们一定要避免线程池中各个任务有依赖的关系。

  • 系统资源

线程是很耗系统资源的,它本身要占用内存空间,线程的切换、创建、销毁也很耗资源。所以用多线程版本实现同步阻塞式IO的时候,一定要用线程池,而且要设置好最大线程数、最大任务数,避免请求量上来因为线程池但原因导致服务不可用。

  • 同步阻塞式IO的原罪, read() 方法是阻塞的

我们可以试想一下,如果服务器要处理大量的客户端连接,而且网络环境不好,每个请求的 read() 方法都要阻塞很久,一旦达到线程池的上限,服务器就会无法再处理连接,但这个时候CPU并没有很忙,所以并发度达不到很高。

我们一起看看下面这个例子感受一下。这个客户端模拟了这样一种情况:并行发起10个网络连接,每个网络连接都只发送很少的数据,但是耗时都很长,这10个网络连接在服务端就占据了10个服务器线程,这些服务器线程只能等待数据完全读取完,才能去干别的事情,对线程资源是一种很大的浪费。

public class HeavySocketClient {
    private static ExecutorService  tp=Executors.newCachedThreadPool();
    private static final int sleep_time=1000*1000*1000;
    public static class EchoClient implements Runnable{
        public void run(){
            Socket client = null;
            PrintWriter writer = null;
            BufferedReader reader = null;
            try {
                client = new Socket();
                client.connect(new InetSocketAddress("localhost", 8001));
                writer = new PrintWriter(client.getOutputStream(), true);
                writer.print("H");
                LockSupport.parkNanos(sleep_time); // 等待1s,模拟网络环境不好的场景
                writer.print("e");
                LockSupport.parkNanos(sleep_time); // 等待1s,模拟网络环境不好的场景
                writer.print("l");
                LockSupport.parkNanos(sleep_time);
                writer.print("l");
                LockSupport.parkNanos(sleep_time);
                writer.print("o");
                LockSupport.parkNanos(sleep_time);
                writer.print("!");
                LockSupport.parkNanos(sleep_time);
                writer.println(); // 发送数据

                reader = new BufferedReader(new InputStreamReader(client.getInputStream()));
                System.out.println("from server: " + reader.readLine()); // 接收数据并打印
            } catch (UnknownHostException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (writer != null)
                        writer.close();
                    if (reader != null)
                        reader.close();
                    if (client != null)
                        client.close();
                } catch (IOException e) {
                }
            }
        }
    }
    public static void main(String[] args) throws IOException {
        EchoClient ec=new EchoClient();
        for(int i=0;i<10;i++) { // 并行发起10个客户端连接,每个连接耗时都很长
            tp.execute(ec);
        }
    }
}

复制代码

我们先看看客户端的结果。在数据发送完后,每一个客户端连接都收到了服务端的响应。
image.png

再看看服务端的结果,我们通过代码记录了一轮网络IO从读取到响应总共的耗时。每一个线程都因为客户端数据发的慢,都耗了6s才能才处理完一轮网络IO。

也正是因为这个问题,在大规模对性能有要求的网络应用场景中,多线程版本的同步阻塞式IO模型很少被采用。
image.png

那么,大规模对性能有要求的网络应用场景中都采用哪种IO模型呢?那就是多路复用IO了,稍等,马上更新,欢迎关注专栏:网络编程精粹

白白 ~

Ref

  • 极客时间:《深入拆解Tomcat&Jetty》
  • 孙卫琴:《Java网络编程精解》
  • Budi Kurniawan、Paul Deck:《深入剖析Tomcat》
  • 葛一鸣:《实战Java高并发程序设计》
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享