并发的设计模式(三)

1.写在前面

前面的几篇博客介绍了几种的并发的设计模式,接下来笔者介绍其他的几种并发的设计模式

2.Worker Thread模式:如何避免重复创建线程?

2.1Worker Thread 模式及其实现

现实中的场景:车间里的工人,有活儿了,大家一起干,没活儿了就聊聊天等着。

Worke Thread 对应现实世界里,其实指的就是车间里的工人。不过这里需要注意的是,车间里的工人的数量往往是确定的

在这里插入图片描述

通过上面的图,你很容易就能想到用阻塞队列做任务池,然后创建固定数量的线程消费阻塞队列中的任务。其实你仔细想会发现,这个方案就是 Java 语言提供的线程池。我们可以重写一些echo程序,具体的如下:

ExecutorService es = Executors.newFixedThreadPool(500);
final ServerSocketChannel ssc = ServerSocketChannel.open().bind(new InetSocketAddress(8080));
// 处理请求
try {
	while (true) {
		// 接收请求
		SocketChannel sc = ssc.accept();
		// 将请求处理任务提交给线程池
		es.execute(()->{
			try {
				// 读 Socket
				ByteBuffer rb = ByteBuffer.allocateDirect(1024);
				sc.read(rb);
				// 模拟处理请求
				Thread.sleep(2000);
				// 写 Socket
				ByteBuffer wb = (ByteBuffer)rb.flip();
				sc.write(wb);
				// 关闭 Socket
				sc.close();
			}catch(Exception e){
				throw new UncheckedIOException(e);
			}
		});
	}
} finally {
	ssc.close();
	es.shutdown();
}

2.2正确的创建线程池

Java 的线程池既能够避免无限制地创建线程导致 OOM,也能避免无限制地接收任务导致OOM。只不过后者经常容易被我们忽略,例如在上面的实现中,就被我们忽略了。所以强烈建议你用创建有界的队列来接收任务。

当请求量大于有界队列的容量时,就需要合理地拒绝请求。如何合理地拒绝呢?这需要你结合具体的业务场景来制定,即便线程池默认的拒绝策略能够满足你的需求,也同样建议你在创建线程池时,清晰地指明拒绝策略。

同时,为了便于调试和诊断问题,我也强烈建议你在实际工作中给线程赋予一个业务相关的名字。

于是我们写出了下面的代码

ExecutorService es = new ThreadPoolExecutor(
	50, 500,
	60L, TimeUnit.SECONDS,
	// 注意要创建有界队列
	new LinkedBlockingQueue<Runnable>(2000),
	// 建议根据业务需求实现 ThreadFactory
	r->{
		return new Thread(r, "echo-"+ r.hashCode());
	},
// 建议根据业务需求实现 RejectedExecutionHandler
new ThreadPoolExecutor.CallerRunsPolicy());

2.3避免死锁

如果提交到相同线程池的任务不是相互独立的,而是有依赖关系的,那么就有可能导致线程死锁。实际工作中,我就亲历过这种线程死锁的场景。具体现象是应用每运行一段时间偶尔就会处于无响应的状态,监控数据看上去一切都正常,但是实际上已经不能正常工作了。

这个出问题的应用,相关的逻辑精简之后,如下图所示,该应用将一个大型的计算任务分成两个阶段,第一个阶段的任务会等待第二阶段的子任务完成。在这个应用里,每一个阶段都使用了线程池,而且两个阶段使用的还是同一个线程池。

在这里插入图片描述

我们可以来模拟下这个程序,具体的代码如下:

//L1、L2 阶段共用的线程池
ExecutorService es = Executors.newFixedThreadPool(2);
//L1 阶段的闭锁
CountDownLatch l1=new CountDownLatch(2);
for (int i=0; i<2; i++){
	System.out.println("L1");
	// 执行 L1 阶段任务
	es.execute(()->{
		//L2 阶段的闭锁
		CountDownLatch l2=new CountDownLatch(2);
		// 执行 L2 阶段子任务
		for (int j=0; j<2; j++){
			es.execute(()->{
				System.out.println("L2");
				l2.countDown();
			});
		}
		// 等待 L2 阶段任务执行完
		l2.await();
		l1.countDown();
	});
}
// 等着 L1 阶段任务执行完
l1.await();
System.out.println("end");

其实这种问题通用的解决方案是为不同的任务创建不同的线程池。对于上面的这个应用,L1 阶段的任务和 L2 阶段的任务如果各自都有自己的线程池,就不会出现这种问题了。
最后再次强调一下:提交到相同线程池中的任务一定是相互独立的,否则就一定要慎重。

3.两阶段终止模式:如何优雅地终止线程?

线程执行完或者出现异常就会进入终止状态。这样看,终止一个线程看上去很简单啊!一个线程执行完自己的任务,自己进入终止状态,这的确很简单。不过我们今天谈到的“优雅地终止线程”,不是自己终止自己,而是在一个线程 T1 中,终止线程 T2;这里所谓的“优雅”,指的是给 T2 一个机会料理后事,而不是被一剑封喉 。

3.1如何理解两阶段终止模式

第一个阶段主要是线程 T1 向线程 T2发送终止指令,而第二阶段则是线程 T2响应终止指令。

在这里插入图片描述

那么终止指令是啥呢?再看下线程的切换图。

在这里插入图片描述

从这个图里你会发现,Java 线程进入终止状态的前提是线程进入 RUNNABLE 状态,而实际上线程也可能处在休眠状态,也就是说,我们要想终止一个线程,首先要把线程的状态从休眠状态转换到 RUNNABLE 状态。如何做到呢?这个要靠 Java Thread 类提供的interrupt() 方法,它可以将休眠状态的线程转换到 RUNNABLE 状态。

线程转换到 RUNNABLE 状态之后,我们如何再将其终止呢?RUNNABLE 状态转换到终止状态,优雅的方式是让 Java 线程自己执行完 run() 方法,所以一般我们采用的方法是设置一个标志位,然后线程会在合适的时机检查这个标志位,如果发现符合终止条件,则自动退出 run() 方法。这个过程其实就是我们前面提到的第二阶段:响应终止指令。

综合上面这两点,我们能总结出终止指令,其实包括两方面内容:interrupt() 方法和线程终止的标志位。

3.2用两阶段终止模式终止监控操作

我们可以看下一个监控系统,具体的如下:

在这里插入图片描述

于是我们写出如下的代码:

class Proxy {
	// 线程终止标志位
	volatile boolean terminated = false;
	boolean started = false;
	// 采集线程
	Thread rptThread;
	// 启动采集功能
	synchronized void start(){
		// 不允许同时启动多个采集线程
		if (started) {
			return;
		}
		started = true;
		terminated = false;
		rptThread = new Thread(()->{
			while (!terminated){
				// 省略采集、回传实现
				report();
				// 每隔两秒钟采集、回传一次数据
				try {
					Thread.sleep(2000);
				} catch (InterruptedException e){
					// 重新设置线程中断状态
					Thread.currentThread().interrupt();
				}
			}
			// 执行到此处说明线程马上终止
			started = false;
		});
		rptThread.start();
	}
	// 终止采集功能
	synchronized void stop(){
		// 设置中断标志位
		terminated = true;
		// 中断线程 rptThread
		rptThread.interrupt();
	}
}

笔者这儿使用自己设置自己的线程终止标志位。

3.3如何优雅地终止线程池

线程池提供了两个方法:shutdown()和shutdownNow()。这两个方法有什么区别呢?要了解它们的区别,就先需要了解线程池的实现原理。

我们曾经讲过,Java 线程池是生产者 - 消费者模式的一种实现,提交给线程池的任务,首先是进入一个阻塞队列中,之后线程池中的线程从阻塞队列中取出任务执行。

shutdown() 方法是一种很保守的关闭线程池的方法。线程池执行 shutdown() 后,就会拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。

而 shutdownNow() 方法,相对就激进一些了,线程池执行 shutdownNow() 后,会拒绝接收新的任务,同时还会中断线程池中正在执行的任务,已经进入阻塞队列的任务也被剥夺了执行的机会,不过这些被剥夺执行机会的任务会作为 shutdownNow() 方法的返回值返回。因为 shutdownNow() 方法会中断正在执行的线程,所以提交到线程池的任务,如果需要优雅地结束,就需要正确地处理线程中断。

其实分析完 shutdown() 和 shutdownNow() 方法你会发现,它们实质上使用的也是两阶段终止模式,只是终止指令的范围不同而已,前者只影响阻塞队列接收任务,后者范围扩大到线程池中所有的任务。

3.4总结

两阶段终止模式是一种应用很广泛的并发设计模式,在 Java 语言中使用两阶段终止模式来优雅地终止线程,需要注意两个关键点:一个是仅检查终止标志位是不够的,因为线程的状态可能处于休眠态;另一个是仅检查线程的中断状态也是不够的,因为我们依赖的第三方类库很可能没有正确处理中断异常。

当你使用 Java 的线程池来管理线程的时候,需要依赖线程池提供的 shutdown() 和shutdownNow() 方法来终止线程池。不过在使用时需要注意它们的应用场景,尤其是在使用 shutdownNow() 的时候,一定要谨慎。

4.生产者-消费者模式

Worker Thread 模式类比的是工厂里车间工人的工作模式。但其实在现实世界,工厂里还有一种流水线的工作模式,类比到编程领域,就是生产者 - 消费者模式。

4.1 生产者 - 消费者模式的优点

生产者 - 消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。

在这里插入图片描述

从架构设计的角度来看,生产者 - 消费者模式有一个很重要的优点,就是解耦。解耦对于大型系统的设计非常重要,而解耦的一个关键就是组件之间的依赖关系和通信方式必须受限。在生产者 - 消费者模式中,生产者和消费者没有任何依赖关系,它们彼此之间的通信只能通过任务队列,所以生产者 - 消费者模式是一个不错的解耦方案。

除了架构设计上的优点之外,生产者 - 消费者模式还有一个重要的优点就是支持异步,并且能够平衡生产者和消费者的速度差异。在生产者 - 消费者模式中,生产者线程只需要将任务添加到任务队列而无需等待任务被消费者线程执行完,也就是说任务的生产和消费是异步的,这是与传统的方法之间调用的本质区别,传统的方法之间调用是同步的。

你或许会有这样的疑问,异步化处理最简单的方式就是创建一个新的线程去处理,那中间增加一个“任务队列”究竟有什么用呢?我觉得主要还是用于平衡生产者和消费者的速度差异。我们假设生产者的速率很慢,而消费者的速率很高,比如是 1:3,如果生产者有 3 个线程,采用创建新的线程的方式,那么会创建 3 个子线程,而采用生产者 - 消费者模式,消费线程只需要 1 个就可以了。Java 语言里,Java 线程和操作系统线程是一一对应的,线程创建得太多,会增加上下文切换的成本,所以 Java 线程不是越多越好,适量即可。而生产者 - 消费者模式恰好能支持你用适量的线程。

4.2支持批量执行以提升性能

例如,我们要在数据库里 INSERT 1000 条数据,有两种方案:第一种方案是用 1000 个线程并发执行,每个线程 INSERT 一条数据;第二种方案是用 1 个线程,执行一个批量的SQL,一次性把 1000 条数据 INSERT 进去。这两种方案,显然是第二种方案效率更高,其实这样的应用场景就是我们上面提到的批量执行场景。

我们提到一个监控系统动态采集的案例,其实最终回传的监控数据还是要存入数据库的(如下图)。但被监控系统往往有很多,如果每一条回传数据都直接 INSERT 到数据库,那么这个方案就是上面提到的第一种方案:每个线程 INSERT 一条数据。很显然,更好的方案是批量执行 SQL,那如何实现呢?这就要用到生产者 - 消费者模式了。

在这里插入图片描述

利用生产者 - 消费者模式实现批量执行 SQL 非常简单:将原来直接 INSERT 数据到数据库的线程作为生产者线程,生产者线程只需将数据添加到任务队列,然后消费者线程负责将任务从任务队列中批量取出并批量执行。

在下面的示例代码中,我们创建了 5 个消费者线程负责批量执行 SQL,这 5 个消费者线程以 while(true){} 循环方式批量地获取任务并批量地执行。需要注意的是,从任务队列中获取批量任务的方法 pollTasks() 中,首先是以阻塞方式获取任务队列中的一条任务,而后则是以非阻塞的方式获取任务;之所以首先采用阻塞方式,是因为如果任务队列中没有任务,这样的方式能够避免无谓的循环。

// 任务队列
BlockingQueue<Task> bq=new
	LinkedBlockingQueue<>(2000);
	// 启动 5 个消费者线程
	// 执行批量任务
void start() {
	ExecutorService es=xecutors
		.newFixedThreadPool(5);
	for (int i=0; i<5; i++) {
		es.execute(()->{
			try {
				while (true) {
					// 获取批量任务
					List<Task> ts=pollTasks();
					// 执行批量任务
					execTasks(ts);
				}
			} catch (Exception e) {
				e.printStackTrace();
			}
		});
	}
}
// 从任务队列中获取批量任务
List<Task> pollTasks()
	throws InterruptedException{
		List<Task> ts=new LinkedList<>();
		// 阻塞式获取一条任务
		Task t = bq.take();
		while (t != null) {
			ts.add(t);
			// 非阻塞式获取一条任务
			t = bq.poll();
		}
	return ts;
}
// 批量执行任务
execTasks(List<Task> ts) {
	// 省略具体代码无数
}

4.3支持分阶段提交以提升性能

利用生产者 - 消费者模式还可以轻松地支持一种分阶段提交的应用场景。我们知道写文件如果同步刷盘性能会很慢,所以对于不是很重要的数据,我们往往采用异步刷盘的方式。我曾经参与过一个项目,其中的日志组件是自己实现的,采用的就是异步刷盘方式,刷盘的时机是:

  1. ERROR 级别的日志需要立即刷盘;
  2. 数据积累到 500 条需要立即刷盘;
  3. 存在未刷盘数据,且 5 秒钟内未曾刷盘,需要立即刷盘。

这个日志组件的异步刷盘操作本质上其实就是一种分阶段提交。下面我们具体看看用生产者- 消费者模式如何实现。在下面的示例代码中,可以通过调用 info()和error() 方法写入日志,这两个方法都是创建了一个日志任务 LogMsg,并添加到阻塞队列中,调用info()和error() 方法的线程是生产者;而真正将日志写入文件的是消费者线程,在Logger 这个类中,我们只创建了 1 个消费者线程,在这个消费者线程中,会根据刷盘规则执行刷盘操作,逻辑很简单,这里就不赘述了。

class Logger {
	// 任务队列
	final BlockingQueue<LogMsg> bq
		= new BlockingQueue<>();
	//flush 批量
	static final int batchSize=500;
	// 只需要一个线程写日志
	ExecutorService es =
		Executors.newFixedThreadPool(1);
	// 启动写日志线程
	void start(){
		File file=File.createTempFile("foo", ".log");
		final FileWriter writer=
			new FileWriter(file);
		this.es.execute(()->{
			try {
				// 未刷盘日志数量
                int curIdx = 0;
                long preFT=System.currentTimeMillis();
                while (true) {
                    LogMsg log = bq.poll(
                    5, TimeUnit.SECONDS);
                    // 写日志
                    if (log != null) {
                    	writer.write(log.toString());
                    	++curIdx;
                    }
                    // 如果不存在未刷盘数据,则无需刷盘
                    if (curIdx <= 0) {
                    	continue;
                    }
                    // 根据规则刷盘
                    if (log!=null && log.level==LEVEL.ERROR ||
                   		curIdx == batchSize ||
                    	System.currentTimeMillis()-preFT>5000){
                    	writer.flush();
                    	curIdx = 0;
                    	preFT=System.currentTimeMillis();
                    }
				}
			}catch(Exception e){
				e.printStackTrace();
			} finally {
				try {
					writer.flush();
					writer.close();
				}catch(IOException e){
					e.printStackTrace();
				}
			}
		});
	}
    // 写 INFO 级别日志
    void info(String msg) {
    	bq.put(new LogMsg(
    	LEVEL.INFO, msg));
    }
    // 写 ERROR 级别日志
    void error(String msg) {
    	bq.put(new LogMsg(
    	LEVEL.ERROR, msg));
    }
  }
// 日志级别
enum LEVEL {
	INFO, ERROR
}
class LogMsg {
    LEVEL level;
    String msg;
    // 省略构造函数实现
    LogMsg(LEVEL lvl, String msg){}
    // 省略 toString() 实现
    String toString(){}
}

5.写在最后

至此整个并发的体系就讲完了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值