Day852.Thread-Per-Message模式 -Java 并发编程实战

2024-02-25 12:59

本文主要是介绍Day852.Thread-Per-Message模式 -Java 并发编程实战,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

Thread-Per-Message模式

Hi,我是阿昌,今天学习记录的是关于Thread-Per-Message模式的内容。

Thread-Per-Message 模式,简言之就是为每个任务分配一个独立的线程。

并发编程领域的问题总结为三个核心问题:

  • 分工
  • 同步
  • 互斥

其中,同步和互斥相关问题更多地源自微观,而分工问题则是源自宏观。解决问题,往往都是从宏观入手,在编程领域,软件的设计过程也是先从概要设计开始,而后才进行详细设计。同样,解决并发编程问题,首要问题也是解决宏观的分工问题。

并发编程领域里,解决分工问题也有一系列的设计模式,比较常用的主要有 Thread-Per-Message 模式、Worker Thread 模式、生产者 - 消费者模式等等。


一、如何理解 Thread-Per-Message 模式

现实世界里,很多事情都需要委托他人办理,一方面受限于能力,总有很多搞不定的事,比如教育小朋友,搞不定怎么办呢?只能委托学校老师了;

另一方面受限于我们的时间,比如忙着写 Bug,哪有时间买别墅呢?只能委托房产中介了。委托他人代办有一个非常大的好处,那就是可以专心做自己的事了。

在编程领域也有很多类似的需求,比如写一个 HTTP Server,很显然只能在主线程中接收请求,而不能处理 HTTP 请求,因为如果在主线程中处理 HTTP 请求的话,那同一时间只能处理一个请求,太慢了!怎么办呢?

可以利用代办的思路,创建一个子线程,委托子线程去处理 HTTP 请求。这种委托他人办理的方式,在并发编程领域被总结为一种设计模式,叫做 Thread-Per-Message 模式,简言之就是为每个任务分配一个独立的线程。

这是一种最简单的分工方法,实现起来也非常简单。


二、用 Thread 实现 Thread-Per-Message 模式

Thread-Per-Message 模式的一个最经典的应用场景是网络编程里服务端的实现,服务端为每个客户端请求创建一个独立的线程,当线程处理完请求后,自动销毁,这是一种最简单的并发处理网络请求的方法。

网络编程里最简单的程序当数 echo 程序了,echo 程序的服务端会原封不动地将客户端的请求发送回客户端。例如,客户端发送 TCP 请求"Hello World",那么服务端也会返回"Hello World"。

下面就以 echo 程序的服务端为例,介绍如何实现 Thread-Per-Message 模式。

在 Java 语言中,实现 echo 程序的服务端还是很简单的。只需要 30 行代码就能够实现,示例代码如下,为每个请求都创建了一个 Java 线程,核心代码是:new Thread(()->{…}).start()。


final ServerSocketChannel  = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
//处理请求    
try {while (true) {// 接收请求SocketChannel sc = ssc.accept();// 每个请求都创建一个线程new Thread(()->{try {// 读SocketByteBuffer rb = ByteBuffer.allocateDirect(1024);sc.read(rb);//模拟处理请求Thread.sleep(2000);// 写SocketByteBuffer wb = (ByteBuffer)rb.flip();sc.write(wb);// 关闭Socketsc.close();}catch(Exception e){throw new UncheckedIOException(e);}}).start();}
} finally {ssc.close();
}   

如果熟悉网络编程,相信你一定会提出一个很尖锐的问题:

上面这个 echo 服务的实现方案是不具备可行性的。

原因在于 Java 中的线程是一个重量级的对象,创建成本很高,一方面创建线程比较耗时,另一方面线程占用的内存也比较大。所以,为每个请求创建一个新的线程并不适合高并发场景。

于是,开始质疑 Thread-Per-Message 模式,而且开始重新思索解决方案,这时候很可能你会想到 Java 提供的线程池。你的这个思路没有问题,但是引入线程池难免会增加复杂度。

其实完全可以换一个角度来思考这个问题,语言、工具、框架本身应该是帮助我们更敏捷地实现方案的,而不是用来否定方案的,Thread-Per-Message 模式作为一种最简单的分工方案,Java 语言支持不了,显然是 Java 语言本身的问题。

Java 语言里,Java 线程是和操作系统线程一一对应的,这种做法本质上是将 Java 线程的调度权完全委托给操作系统,而操作系统在这方面非常成熟,所以这种做法的好处是稳定、可靠,但是也继承了操作系统线程的缺点:创建成本高。

为了解决这个缺点,Java 并发包里提供了线程池等工具类。

这个思路在很长一段时间里都是很稳妥的方案,但是这个方案并不是唯一的方案。

业界还有另外一种方案,叫做轻量级线程

这个方案在 Java 领域知名度并不高,但是在其他编程语言里却叫得很响,例如 Go 语言、Lua 语言里的协程,本质上就是一种轻量级的线程。

轻量级的线程,创建的成本很低,基本上和创建一个普通对象的成本相似;

并且创建的速度和内存占用相比操作系统线程至少有一个数量级的提升,所以基于轻量级线程实现 Thread-Per-Message 模式就完全没有问题了。

Java 语言目前也已经意识到轻量级线程的重要性了,OpenJDK 有个 Loom 项目,就是要解决 Java 语言的轻量级线程问题,在这个项目中,轻量级线程被叫做 ·Fiber·。


三、用 Fiber 实现 Thread-Per-Message 模式

Loom 项目在设计轻量级线程时,充分考量了当前 Java 线程的使用方式,采取的是尽量兼容的态度,所以使用上还是挺简单的。

用 Fiber 实现 echo 服务的示例代码如下所示,对比 Thread 的实现,会发现改动量非常小,只需要把 new Thread(()->{…}).start() 换成 Fiber.schedule(()->{}) 就可以了。


final ServerSocketChannel ssc = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
//处理请求
try{while (true) {// 接收请求final SocketChannel sc = ssc.accept();Fiber.schedule(()->{try {// 读SocketByteBuffer rb = ByteBuffer.allocateDirect(1024);sc.read(rb);//模拟处理请求LockSupport.parkNanos(2000*1000000);// 写SocketByteBuffer wb = (ByteBuffer)rb.flip()sc.write(wb);// 关闭Socketsc.close();} catch(Exception e){throw new UncheckedIOException(e);}});}//while
}finally{ssc.close();
}

那使用 Fiber 实现的 echo 服务是否能够达到预期的效果呢?

可以在 Linux 环境下做一个简单的实验,步骤如下:

  1. 首先通过 ulimit -u 512 将用户能创建的最大进程数(包括线程)设置为 512;
  2. 启动 Fiber 实现的 echo 程序;
  3. 利用压测工具 ab 进行压测:ab -r -c 20000 -n 200000 http:// 测试机 IP 地址:8080/

压测执行结果如下:


Concurrency Level:      20000
Time taken for tests:   67.718 seconds
Complete requests:      200000
Failed requests:        0
Write errors:           0
Non-2xx responses:      200000
Total transferred:      16400000 bytes
HTML transferred:       0 bytes
Requests per second:    2953.41 [#/sec] (mean)
Time per request:       6771.844 [ms] (mean)
Time per request:       0.339 [ms] (mean, across all concurrent requests)
Transfer rate:          236.50 [Kbytes/sec] receivedConnection Times (ms)min  mean[+/-sd] median   max
Connect:        0  557 3541.6      1   63127
Processing:  2000 2010  31.8   2003    2615
Waiting:     1986 2008  30.9   2002    2615
Total:       2000 2567 3543.9   2004   65293

会发现即便在 20000 并发下,该程序依然能够良好运行。

同等条件下,Thread 实现的 echo 程序 512 并发都抗不过去,直接就 OOM 了。

如果你通过 Linux 命令 top -Hp pid 查看 Fiber 实现的 echo 程序的进程信息,可以看到该进程仅仅创建了 16(不同 CPU 核数结果会不同)个操作系统线程。

在这里插入图片描述

如果对 Loom 项目感兴趣,也想上手试一把,可以下载源代码自己构建,构建方法可以参考Project Loom 的相关资料,不过需要注意的是构建之前一定要把代码分支切换到 Fibers。


四、总结

并发编程领域的分工问题,指的是如何高效地拆解任务并分配给线程。

并发工具类模块,例如 Future、CompletableFuture 、CompletionService、Fork/Join 计算框架等,这些工具类都能很好地解决特定应用场景的问题,所以,这些工具类曾经是 Java 语言引以为傲的。不过这些工具类都继承了 Java 语言的老毛病:太复杂。

如果一直从事 Java 开发,估计你已经习以为常了,习惯性地认为这个复杂度是正常的。

不过这个世界时刻都在变化,曾经正常的复杂度,现在看来也许就已经没有必要了,例如 Thread-Per-Message 模式如果使用线程池方案就会增加复杂度。

Thread-Per-Message 模式在 Java 领域并不是那么知名,根本原因在于 Java 语言里的线程是一个重量级的对象,为每一个任务创建一个线程成本太高,尤其是在高并发领域,基本就不具备可行性。

不过这个背景条件目前正在发生巨变,Java 语言未来一定会提供轻量级线程,这样基于轻量级线程实现 Thread-Per-Message 模式就是一个非常靠谱的选择。

当然,对于一些并发度没那么高的异步场景,例如定时任务,采用 Thread-Per-Message 模式是完全没有问题的。

实际工作中,就见过完全基于 Thread-Per-Message 模式实现的分布式调度框架,这个框架为每个定时任务都分配了一个独立的线程。


使用 Thread-Per-Message 模式会为每一个任务都创建一个线程,在高并发场景中,很容易导致应用 OOM,那有什么办法可以快速解决呢?

每次创建一个线程高并发肯定OOM

  1. 引入线程池控制创建线程的大小,通过压测得到比较合理的线程数量配置
  2. 需要在请求端增加一个限流模块,自我保护
  3. 降级方案

这篇关于Day852.Thread-Per-Message模式 -Java 并发编程实战的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/745555

相关文章

SpringBoot全局域名替换的实现

《SpringBoot全局域名替换的实现》本文主要介绍了SpringBoot全局域名替换的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一... 目录 项目结构⚙️ 配置文件application.yml️ 配置类AppProperties.Ja

Java使用Javassist动态生成HelloWorld类

《Java使用Javassist动态生成HelloWorld类》Javassist是一个非常强大的字节码操作和定义库,它允许开发者在运行时创建新的类或者修改现有的类,本文将简单介绍如何使用Javass... 目录1. Javassist简介2. 环境准备3. 动态生成HelloWorld类3.1 创建CtC

JavaScript中的高级调试方法全攻略指南

《JavaScript中的高级调试方法全攻略指南》什么是高级JavaScript调试技巧,它比console.log有何优势,如何使用断点调试定位问题,通过本文,我们将深入解答这些问题,带您从理论到实... 目录观点与案例结合观点1观点2观点3观点4观点5高级调试技巧详解实战案例断点调试:定位变量错误性能分

使用Python批量将.ncm格式的音频文件转换为.mp3格式的实战详解

《使用Python批量将.ncm格式的音频文件转换为.mp3格式的实战详解》本文详细介绍了如何使用Python通过ncmdump工具批量将.ncm音频转换为.mp3的步骤,包括安装、配置ffmpeg环... 目录1. 前言2. 安装 ncmdump3. 实现 .ncm 转 .mp34. 执行过程5. 执行结

Java实现将HTML文件与字符串转换为图片

《Java实现将HTML文件与字符串转换为图片》在Java开发中,我们经常会遇到将HTML内容转换为图片的需求,本文小编就来和大家详细讲讲如何使用FreeSpire.DocforJava库来实现这一功... 目录前言核心实现:html 转图片完整代码场景 1:转换本地 HTML 文件为图片场景 2:转换 H

Java使用jar命令配置服务器端口的完整指南

《Java使用jar命令配置服务器端口的完整指南》本文将详细介绍如何使用java-jar命令启动应用,并重点讲解如何配置服务器端口,同时提供一个实用的Web工具来简化这一过程,希望对大家有所帮助... 目录1. Java Jar文件简介1.1 什么是Jar文件1.2 创建可执行Jar文件2. 使用java

SpringBoot实现不同接口指定上传文件大小的具体步骤

《SpringBoot实现不同接口指定上传文件大小的具体步骤》:本文主要介绍在SpringBoot中通过自定义注解、AOP拦截和配置文件实现不同接口上传文件大小限制的方法,强调需设置全局阈值远大于... 目录一  springboot实现不同接口指定文件大小1.1 思路说明1.2 工程启动说明二 具体实施2

Java实现在Word文档中添加文本水印和图片水印的操作指南

《Java实现在Word文档中添加文本水印和图片水印的操作指南》在当今数字时代,文档的自动化处理与安全防护变得尤为重要,无论是为了保护版权、推广品牌,还是为了在文档中加入特定的标识,为Word文档添加... 目录引言Spire.Doc for Java:高效Word文档处理的利器代码实战:使用Java为Wo

SpringBoot日志级别与日志分组详解

《SpringBoot日志级别与日志分组详解》文章介绍了日志级别(ALL至OFF)及其作用,说明SpringBoot默认日志级别为INFO,可通过application.properties调整全局或... 目录日志级别1、级别内容2、调整日志级别调整默认日志级别调整指定类的日志级别项目开发过程中,利用日志

Java中的抽象类与abstract 关键字使用详解

《Java中的抽象类与abstract关键字使用详解》:本文主要介绍Java中的抽象类与abstract关键字使用详解,本文通过实例代码给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧... 目录一、抽象类的概念二、使用 abstract2.1 修饰类 => 抽象类2.2 修饰方法 => 抽象方法,没有