前言
隨著微服務的流行,單體應用被拆分成一個個獨立的微進程,可能一個簡單的請求,需要多個微服務共同處理,這樣其實是增加了出錯的概率,所以如何保證在單個微服務出現問題的時候,對整個系統的負面影響降到最低,這就需要用到我們今天要介紹的線程隔離。
線程模型
在介紹線程隔離之前,我們先了解一下主流容器,框架的線程模型,因為微服務是一個個獨立的進程,之間的調用其實就是走網絡io,網絡io的處理容器如Tomcat,通信框架如netty,微服務框架如dubbo,都很好的幫我們處理了底層的網絡io流,讓我們可以更加的關注于業務處理;
Netty
Netty是基于JAVA nio的高性能通信框架,使用了主從多線程模型,借鑒Netty系列之 Netty線程模型的一張圖片如下所示:
主線程負責認證,連接,成功之后交由從線程負責連接的讀寫操作,大致如下代碼:
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup);
主線程是一個單線程,從線程是一個默認為cpu*2個數的線程池,可以在我們的業務handler中做一個簡單測試:
public void channelRead(ChannelHandlerContext ctx, Object msg) {
System.out.println("thread name=" + Thread.currentThread().getName() + " server receive msg=" + msg);
}
服務端在讀取數據的時候打印一下當前的線程:
thread name=nioEventLoopGroup-3-1 server receive msg="..."
可以發現這里使用的線程其實和處理io線程是同一個;
Dubbo
Dubbo的底層通信框架其實使用的就是Netty,但是Dubbo并沒有直接使用Netty的io線程來處理業務,可以簡單在生產者端輸出當前線程名稱:
thread name=DubboServerHandler-192.168.1.115:20880-thread-2,...
可以發現業務邏輯使用并不是nioEventLoopGroup線程,這是因為Dubbo有自己的線程模型,可以看看官網提供的模型圖:
其中的Dispatcher調度器可以配置消息的處理線程:
- all 所有消息都派發到線程池,包括請求,響應,連接事件,斷開事件,心跳等。
- direct 所有消息都不派發到線程池,全部在 IO 線程上直接執行。
- message 只有請求響應消息派發到線程池,其它連接斷開事件,心跳等消息,直接在 IO 線程上執行。
- execution 只有請求消息派發到線程池,不含響應,響應和其它連接斷開事件,心跳等消息,直接在 IO 線程上執行。
- connection 在 IO 線程上,將連接斷開事件放入隊列,有序逐個執行,其它消息派發到線程池。
Dubbo默認使用FixedThreadPool,線程數默認為200;
Tomcat
Tomcat可以配置四種線程模型:BIO,NIO,APR,AIO;Tomcat8開始默認配置NIO,此模型和Netty的線程模型很像,可以理解為都是Reactor模式,在此不過多介紹;其中maxThreads參數配置專門處理IO的Worker數,默認是200;可以在業務Controller中輸出當前線程名稱:
ThreadName=http-nio-8888-exec-1...
可以發現處理業務的線程就是Tomcat的io線程;
為什么要線程隔離
從上面的介紹的線程模型可以知道,處理業務的時候還是使用的io線程比如Tomcat和netty,這樣會有什么問題那,比如當前服務進程需要同步調用另外三個微服務,但是由于某個服務出現問題,導致線程阻塞,然后阻塞越積越多,占滿所有的io線程,最終當前服務無法接受數據,直至奔潰;Dubbo本身做了IO線程和業務線程的隔離,出現問題不至于影響IO線程,但是如果同樣有以上的問題,業務線程也會被占滿;做線程隔離的目的就是如果某個服務出現問題可以把它控制在一個小的范圍,不至于影響到全局;
如何做線程隔離
做線程隔離原理也很簡單,給每個請求分配單獨的線程池,每個請求做到互不影響,當然也可以使用一些成熟的框架比如Hystrix(已經不更新了),Sentinel等;
線程池隔離
SpringBoot+Tomcat做一個簡單的隔離測試,為了方便模擬配置MaxThreads=5,提供隔離Controller,大致如下所示:
@RequestMApping("/h1")
String home() throws Exception {
System.out.println("h1-->ThreadName=" + Thread.currentThread().getName());
Thread.sleep(200000);
return "h1";
}
@RequestMapping("/h3")
String home3() {
System.out.println("h3-->ThreadName=" + Thread.currentThread().getName());
return "h3";
}
請求5次/h1請求,再次請求/h3,觀察日志:
h1-->ThreadName=http-nio-8888-exec-1
h1-->ThreadName=http-nio-8888-exec-2
h1-->ThreadName=http-nio-8888-exec-3
h1-->ThreadName=http-nio-8888-exec-4
h1-->ThreadName=http-nio-8888-exec-5
可以發現h1請求占滿了5條線程,請求h3的時候Tomcat無法接受請求;改造一下h1請求使用使用線程池來處理:
ExecutorService executorService = Executors.newFixedThreadPool(2);
List<Future<String>> list = new CopyOnWriteArrayList<Future<String>>();
@RequestMapping("/h2")
String home2() throws Exception {
Future<String> result = executorService.submit(new Callable<String>() {
@Override
public String call() throws Exception {
System.out.println("h2-->ThreadName=" + Thread.currentThread().getName());
Thread.sleep(200000);
return "h2";
}
});
list.add(result);
//降級處理
if (list.size() >= 3) {
return "h2-fallback";
}
String resultStr = result.get();
list.remove(result);
return resultStr;
}
如上部分偽代碼,使用線程池異步執行,并且超出限制范圍做降級處理,這樣再次請求h3的時候,就不受影響了;當然上面代碼比較簡陋,我們可以使用成熟的隔離框架;
Hystrix
Hystrix 提供兩種隔離策略:線程池隔離(Bulkhead Pattern)和信號量隔離,其中最推薦也是最常用的是線程池隔離。Hystrix的線程池隔離針對不同的資源分別創建不同的線程池,不同服務調用都發生在不同的線程池中,在線程池排隊、超時等阻塞情況時可以快速失敗,并可以提供fallback機制;可以看一個簡單的實例:
public class HelloCommand extends HystrixCommand<String> {
public HelloCommand(String name) {
super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("ThreadPoolTestGroup"))
.andCommandKey(HystrixCommandKey.Factory.asKey("testCommandKey"))
.andThreadPoolKey(HystrixThreadPoolKey.Factory.asKey(name))
.andCommandPropertiesDefaults(
HystrixCommandProperties.Setter().withExecutionTimeoutInMilliseconds(20000))
.andThreadPoolPropertiesDefaults(HystrixThreadPoolProperties.Setter().withMaxQueueSize(5) // 配置隊列大小
.withCoreSize(2) // 配置線程池里的線程數
));
}
@Override
protected String run() throws InterruptedException {
StringBuffer sb = new StringBuffer("Thread name=" + Thread.currentThread().getName() + ",");
Thread.sleep(2000);
return sb.append(System.currentTimeMillis()).toString();
}
@Override
protected String getFallback() {
return "Thread name=" + Thread.currentThread().getName() + ",fallback order";
}
public static void main(String[] args) throws InterruptedException, ExecutionException {
List<Future<String>> list = new ArrayList<>();
System.out.println("Thread name=" + Thread.currentThread().getName());
for (int i = 0; i < 8; i++) {
Future<String> future = new HelloCommand("hystrix-order").queue();
list.add(future);
}
for (Future<String> future : list) {
System.out.println(future.get());
}
Thread.sleep(1000000);
}
}
如上配置了處理此業務的線程數為2,并且指定當線程滿了之后可以放入隊列的最大數量,運行此程序結果如下:
Thread name=main
Thread name=hystrix-hystrix-order-1,1589776137342
Thread name=hystrix-hystrix-order-2,1589776137342
Thread name=hystrix-hystrix-order-1,1589776139343
Thread name=hystrix-hystrix-order-2,1589776139343
Thread name=hystrix-hystrix-order-1,1589776141343
Thread name=hystrix-hystrix-order-2,1589776141343
Thread name=hystrix-hystrix-order-2,1589776143343
Thread name=main,fallback order
主線程執行可以理解為就是io線程,業務執行使用的是hystrix線程,線程數2+隊列5可以同時處理7條并發請求,超過的部分直接fallback;
信號量隔離
線程池隔離的好處是隔離度比較高,可以針對某個資源的線程池去進行處理而不影響其它資源,但是代價就是線程上下文切換的開銷比較大,特別是對低延時的調用有比較大的影響;上面對線程模型的介紹,我們發現Tomcat默認提供了200個io線程,Dubbo默認提供了200個業務線程,線程數已經很多了,如果每個命令在使用一個線程池,線程數會非常多,對系統的影響其實也很大;有一種更輕量的隔離方式就是信號量隔離,僅限制對某個資源調用的并發數,而不是顯式地去創建線程池,所以開銷比較小;Hystrix和Sentinel都提供了信號量隔離方式,Hystrix已經停止更新,而Sentinel干脆就沒有提供線程隔離,或者說線程隔離是沒有必要的,完全可以用更輕量的信號量隔離代替;
總結
本文從線程模型開始,講到了IO線程,以及為什么要分開IO線程和業務線程,具體如何去實現,最后簡單介紹了一下更加輕量的信號量隔離,為什么說更加輕量哪,其實業務還是在IO線程處理,只不過會限制某個資源的并發數,沒有多余的線程產生;當然也不是說線程隔離就沒有價值了,其實還是要根據實際情況來定,根據你使用的容器,框架本身的線程模型來決定。