1. 程式人生 > >到底什麽時候該用多線程

到底什麽時候該用多線程

圖像 cpu drawable 第一個 change 狀態 nts -a 理解

我想大多數人在學習多線程時都會對此問題有所顧慮,盡管多線程的概念不難理解,那我們什麽時候該用它呢?在大多數情況下,我們寫了程序,發現有時必須使用多線程才能得到理想的運行結果,於是我們按照資料調用相關的線程類庫或API改善程序,並使其正常運行;但是,到底存不存在一種判斷依據,能夠明確的指導我們正確地使用多線程機制來解決問題呢?筆者對此進行了一番思考,在此說說我的想法以供參考。

在開始之前,先引入幾個問題,這些問題最終都會在這篇文章裏找到答案。

問題情景[0]:設計一個簡單的UI:包括一個文本標簽和一個按鈕,在點擊按鈕時文本顯示由0~10的增長,每秒增長量為1。

問題情景[1]:某同學編寫的坦克大戰程序中,每一個坦克和子彈均使用一個獨立的線程,這是否合理?(當然不合理。。。)如果是你,你會怎麽編寫這個程序?

筆者認為,多線程的使用離不開“阻塞”這個概念,不過,我想先對這個概念加以擴充,首先先來回想一下阻塞概念原本的意思,簡單的說,就是程序運行到某些函數或過程後等待某些事件發生而暫時停止CPU占用的情況;也就是說,是一種CPU閑等狀態,不過有時我們使用多線程並不一定是保持閑等時的程序響應,例如在追求高性能的程序中,某條線程在進行高強度的運算,此時若對運算性能不滿意,我們也許會再啟動若幹條運算線程(當然,是在CPU有運算余力的情況下),此時,高強度運算應該歸為一種“忙等”狀態。

說到這,多線程歸根究底是為了解決"等"的問題,那我們這樣定義一個阻塞過程:程序運行該過程所消耗的時間有可能在運行上下文間產生明顯的卡頓;這裏使用“可能”是因為有些情況下,諸如Socket通信,如果數據源源不斷的進入,那麽阻塞的時間可能非常小,但我們還是使用了一條線程(nio另說)來處理它,因為我們無法保證數據到來的持續性和有效性;"卡頓"帶有主觀臆想,也就是說是使用者(人或一些自動化程序)不可接受的。

接下來,對什麽時候使用多線程做一個回答:編寫程序過程中需要使用某些阻塞過程時,我們才使用多線程,或者更進一步講,使用多線程的目的是對阻塞過程中的實際阻塞的抽象提取。前半句話應該很好理解,而後面的一句雖然不太好懂,不過它對一個程序應具有的合理線程數量進行了闡釋(這點接下來解釋)。

好了,接下來我們回顧一些兩個問題,並對它們做出解答:

問題情景[0]

為了方便表達,筆者在此采用偽Java代碼來闡釋解答過程。首先我們有一個Label textShower用於顯示文本,Button textChanger作為點擊的按鈕

這個問題是筆者還是一名小菜時遇到的,當時筆者是這麽寫的:

[java]
view plain copy
  1. public class MyFrame
  2. {
  3. Label textShower;
  4. Button textChanger;
  5. public MyFrame//實例化等省略
  6. {
  7. textChanger.setOnClickListener(new OnClickListener(){
  8. public void onClick(MouseEvent e){
  9. for(int i = 1;i <= 10;i++){
  10. textShower.setText(i+"");//設置文字
  11. Thread.sleep(1000);//等待一秒
  12. }
  13. }
  14. });
  15. }
  16. }

程序的執行結果是點擊後10秒沒有響應,然後數值被設定為10;現在知道了,是由於AWT消息線程同時負責著圖像的繪制刷新操作,而Thread.sleep屬於之前的阻塞過程,導致畫面停止響應。

當時老師是這麽教給我的:

[java] view plain copy
  1. public class MyFrame
  2. {
  3. Label textShower;
  4. Button textChanger;
  5. public MyFrame//實例化等省略
  6. {
  7. textChanger.setOnClickListener(new OnClickListener(){
  8. public void onClick(MouseEvent e){
  9. new Thread(){
  10. public void run()
  11. {
  12. for(int i = 0;i < 10;i++){
  13. textShower.setText(i+"");//設置文字
  14. Thread.sleep(1000);//等待一秒
  15. }
  16. }
  17. }.start();
  18. }
  19. });
  20. }
  21. }

當然,這樣確實能夠滿足題目要求,我也因此開心了一陣,不過不久我就有了新的問題:每按一次按鈕產生一個線程是否合理呢?如果這樣的文本組合再多幾個,我也要創建更多的線程嗎?要是使用者是個熊孩子來這裏一通狂按這程序還受得了麽....

後來,在面向對象思想深入人心後,稍微懂面向對象的人都會知道使用抽象來簡化程序,只不過在上面的問題中,我們需要抽象的不是具體的實體,而是“實際阻塞”這種抽象概念。

在上面的代碼中,筆者寫的第一個onClick函數屬於一個阻塞過程,其中sleep屬於“實際阻塞”。

而改版的代碼中,只是使用多線程將原本的阻塞過程變為了非阻塞過程,實際上是使用了一個獨立的線程將整個阻塞過程包含在內,並沒有做任何的抽象。

問題情景[1]:在這個問題中,將主要討論實際阻塞的抽象和合理線程數量的問題。

這個情景是不久前一位網友問我的,他的畢業設計是編寫一個坦克大戰的遊戲,在編的差不多的時候,突然想到每一輛坦克、每一發子彈都是用單獨的線程不是很合理,問我如何改進。用這個例子說明實際阻塞的抽象再合適不過了,我們先看看他寫的代碼片段:

坦克類:

[java] view plain copy
  1. public class Tank extends Thread{
  2. float x;//這裏以橫向移動為例子,只寫一個屬性
  3. float speed = 1f;
  4. public void run()
  5. {
  6. drawtank();//清除上一次的繪制,根據橫坐標x畫一個坦克
  7. x+=speed;
  8. Thread.sleep(17);//約合一秒60次
  9. }
  10. }


子彈類:

[java] view plain copy
  1. public class Bullet extends Thread{
  2. float x;//這裏以橫向移動為例子,只寫一個屬性
  3. float speed = 10f;
  4. public void run()
  5. {
  6. drawbullet();//清除上一次的繪制,根據橫坐標x畫一個子彈
  7. x+=speed;
  8. Thread.sleep(17);//約合一秒60次
  9. }
  10. }




其實這樣異步的繪制會使畫面產生明顯的抖動,而且用於同步的邏輯也十分復雜,並不是一個好的方案。

其實上面兩個類中的run方法中,只有sleep屬於實際阻塞,也就是說是可以被抽象出來的,我們只要一個線程,每過17毫秒執行一些列非阻塞過程即可。

上述過程中,繪制及坐標的運算屬於非阻塞過程,我們將其抽象為一個接口:

[java] view plain copy
  1. public interface Drawable
  2. {
  3. public void draw();
  4. }

之後我們書寫抽象實際阻塞的線程類:

[java] view plain copy
  1. public class BlockThread extends Thread
  2. {
  3. Collection<Drawable> c = new Collection<Drawable>();
  4. public void run()
  5. {
  6. for(Drawable d:c)
  7. {
  8. d.draw();
  9. }
  10. Thread.sleep(17);
  11. }
  12. //封裝對成員c的同步CRUD不贅述
  13. public void addDrawable(Drawable d);
  14. public void removeDrawable(Drawable d);
  15. ...
  16. }

最後,坦克和子彈的改動:
坦克類:

[java] view plain copy
  1. public class Tank implements Drawable{
  2. float x;//這裏以橫向移動為例子,只寫一個屬性
  3. float speed = 1f;
  4. @Override
  5. public void draw()
  6. {
  7. drawtank();//清除上一次的繪制,根據橫坐標x畫一個坦克
  8. x+=speed;
  9. }
  10. }

子彈類:

[java] view plain copy
  1. public class Bullet implements Drawable{
  2. float x;//這裏以橫向移動為例子,只寫一個屬性
  3. float speed = 10f;
  4. @Override
  5. public void draw()
  6. {
  7. drawbullet();//清除上一次的繪制,根據橫坐標x畫一個子彈
  8. x+=speed;
  9. }
  10. }

我們可以發現:原有的實際阻塞過程已經被抽象到一個線程之中,而非阻塞過程,諸如繪制和坐標運算依然作為方法保留到對應類中,這樣,無論有多少坦克和炮彈,只要非阻塞過程的運算壓總和力不至於逼近阻塞的程度,使用一個線程即可完成所有工作。

而且,如果想要添加遊戲元素,例如其他類型的子彈,只需要實現Drawable接口即可。

寫到這,UI的問題也就解決了,諸如sleep這樣純粹延時的阻塞非常容易抽象,我們可以如法炮制,使用一個線程解決所有的數值延時自增的問題。但並不是所有實際阻塞都易於抽象,如socket.read(byte[] b);這樣的方法顯然沒有抽象的余地,因此才引出後來的nio方案。

最後,我們對什麽時候使用多線程,以及使用線程的數量做一個總結:在編寫程序時,遇到了阻塞過程而不想使整個程序停止響應時,應使用多線程;一個程序的合理線程數量取決於對實際阻塞的抽象程度。

到底什麽時候該用多線程