分工、同步、互斥是并发编程领域的三个核心问题。
其中,同步和互斥相关问题更多的源自微观,而分工问题则是源自宏观。解决问题往往都是从宏观入手,在编程领域,软件的设计过程也是先从概要设计开始,而后才进行详细设计。同样,解决并发编程问题,首要问题也是解决宏观的分工问题。
并发编程领域里,解决分工问题常用的设计模式:Thread-Per-Message模式、Worker Thread模式、生产者-消费者模式等。
理解Thread-Per-Message模式
现实世界里,受限于个人能力或者时间,很多事情都需要委托他人办理。
编程领域也有类似的需求,写一个HTTP Server,很显然只能在主线程中接收请求,而不能处理THHP请求,如果在主线程中处理HTTP请求的话,同一时间只能处理一个请求。利用代办的思路,创建一个子线程,委托子线程去处理HTTP请求。
这种委托他人办理的方式,在并发编程领域被总结为一种设计模式:Thread-Per-Message模式,为每个任务分配一个独立的线程。这是一种最简单的分工方式。
用Thread实现Thread-Per-Message模式
Thread-Per-Message模式的一个最经典的应用场景是网络编程里服务端的实现,服务端为每个客户端请求创建一个独立的线程,当线程处理完请求后,自动销毁,这是一种最简单的并发处理网路请求的方法。
网络编程里最简单的程序当数echo程序,echo程序的服务端会原封不动的将客户端的请求发送回客户端。如客户端发送TCP请求“Hello World”,那么服务单也会返回“Hello World”。Java实现echo程序的服务端:
1 | //为每个请求创建一个Java线程, |
Java中的线程是一个重量级的对象,创建成本很高,一方面创建线程比较耗时,另一方面线程占用的内存也比较大,为每个请求创建一个新的线程并不适合高并发场景。引入线程池则会增加复杂度。
Java语言里,Java线程是和操作系统线程一一对应的,这种做法本质上是将Java线程的调度权完全委托给操作系统,而操作系统在这方面非常惩处,所以这种做法的好处是稳定、可靠,但是也继承了操作系统的缺点:创建成本高。为了解决这个缺点,Java并发包里提供了线程池等工具类。引入线程池是解决问题的其中一种方案
业界还有另外一种方案-轻量级线程。Go语言、Lua语言里的协程,本质上就是一种轻量级的线程。轻量级的线程,创建的成本很低,基本上和创建一个普通对象的成本相似;并且创建的速度和内存占用相比操作系统线程至少有一个数量级的提升,所以基于轻量级线程实现Thread-Per-Message模式就完全没有问题。
OpenJDK的Loom项目,就是要解决Java语言的轻量级线程问题,此项目中的轻量级线程叫做Fiber。
用Fiber实现Thread-Per-Message模式
Loom项目在设计轻量级线程时,充分考量了当前Java线程的使用方式,尽量兼容。Fiber实现echo服务与Thread相比,区别仅在于把new Thread(()-{…}).start()换成Fiber.schedulr(()->{…}):
1 | final ServerSocketChannel ssc = ServerSocketChannel.open().bind(new InetSocketAddress(8080)); |
总结
并发编程领域的分工问题,指的是如何高效的拆解任务并分配给线程。
并发工具类中有不少解决分工问题的工具类:Future、CompletableFuture、CompletionService、Fork/Join计算框架等。这些工具类能很好的解决特定应用场景的问题,但是太复杂。
Thread-Per-Message模式是一种最简单的分工方式,实现也简单。但是Java语言中的线程是一个重量级的对象,为每一个任务创建一个线程成本太高(容易OOM),尤其是在高并发领域,基本不具备可行性。而如果使用线程池方案也会增加复杂度。
对于一些并发度没那么高的异步场景-定时任务,采用Thread-Per-Message模式则完全没有问题。