話題:Thread-Per-Message模式:最簡單實用的分工方法
我們曾經把併發編程領域的問題總結爲三個核心問題:分工、同步和互斥。其中,同步和互斥相關問題更多地源自微觀,而分工問題則是源自宏觀。我們解決問題,往往都是從宏觀入手,在編程領域,軟件的設計過程也是先從概要設計開始,而後才進行詳細設計。同樣,解決併發編程問題,首要問題也是解決宏觀的分工問題。
併發編程領域裏,解決分工問題也有一系列的設計模式,比較常用的主要有Thread-Per-Message模式、Worker Thread模式、生產者-消費者模式等等。今天我們重點介紹Thread-Per-Message模式。
如何理解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 ssc =
ServerSocketChannel.open().bind(new InetSocketAddress(8080));
//處理請求
try {
while (true) {
// 接收請求
SocketChannel sc = ssc.accept();
// 每個請求都創建一個線程
new Thread(()->{
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);
}
}).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模式。
用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 =
serverSocketChannel.accept();
Fiber.schedule(()->{
try {
// 讀Socket
ByteBuffer rb = ByteBuffer
.allocateDirect(1024);
sc.read(rb);
//模擬處理請求
LockSupport.parkNanos(2000*1000000);
// 寫Socket
ByteBuffer wb =
(ByteBuffer)rb.flip()
sc.write(wb);
// 關閉Socket
sc.close();
} catch(Exception e){
throw new UncheckedIOException(e);
}
});
}//while
}finally{
ssc.close();
}
那使用Fiber實現的echo服務是否能夠達到預期的效果呢?我們可以在Linux環境下做一個簡單的實驗,步驟如下:
- 首先通過 ulimit -u 512 將用戶能創建的最大進程數(包括線程)設置爲512;
- 啓動Fiber實現的echo程序;
- 利用壓測工具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] received
Connection 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核數結果會不同)個操作系統線程。
總結
併發編程領域的分工問題,指的是如何高效地拆解任務並分配給線程。前面我們在併發工具類模塊中已經介紹了不少解決分工問題的工具類,例如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模式實現的分佈式調度框架,這個框架爲每個定時任務都分配了一個獨立的線程
Demo
1
public class PerThreadClient {
public static void main(String[] args) {
final MessageHandler handler = new MessageHandler();
IntStream.rangeClosed(0, 10)
.forEach(
i -> handler.request(new Message(String.valueOf(i)))
);
handler.shutdown();
}
}
2
public class MessageHandler {
private final static Random random = new Random(System.currentTimeMillis());
private final static Executor executor = Executors.newFixedThreadPool(5);
public void request(Message message) {
executor.execute(() -> {
//每一個請求,每一個線程處理的設計模式
String value = message.getValue();
try {
Thread.sleep(random.nextInt(1000));
System.out.println("The message will be handle by " + Thread.currentThread().getName() + " " + value);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
new Thread(() -> {
String value = message.getValue();
try {
Thread.sleep(random.nextInt(1000));
System.out.println("The message will be handle by " + Thread.currentThread().getName());
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
public void shutdown() {
((ExecutorService) executor).shutdown();
}
}
3
public class Message {
private final String value;
public Message(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}