多線程編程(二)NSThread的使用

iOS 支持多個層次的多線程編程,層次越高的抽象程度越高,使用也越方便。實現多線程的方式有很多,我們主要學習其中的三種實現方式:NSThread,NSOpreationQueue,GCD,這三種編程方式從上到下,抽象度層次是從低到高的,抽象度越高的使用越簡單,GCD是抽象程度最高的一種,也是 Apple 最推薦使用的方法。
   雖然實現的方式不同,但是操作的對象都是線程,都是對線程的管理。所以,從根本上講,三者並沒有什麼不同,可以混合使用,但是並不推薦,因爲可能會造成不必要的麻煩。三種多線程的實現方式各有利弊,我們在學習完三種實現方式之後會對它們做出比較。

在本文中,我們主要介紹NSThread的使用。需要掌握以下幾個知識點

  • 在一個進程裏面創建新線程
  • 對一個線程的屬性進行修改
  • 在線程裏面控制另一個同進程裏面的線程
  • 線程之間在進程裏面進行同步數據訪問
  • 查看線程在運行過程中所處的狀態

1. 線程開銷

學習多線程的實現方式之前,我們先來了解一下一個非常重要的概念,線程開銷。創建一個新線程的線程開銷明顯比創建一個新進程的要小得多(儘管linux在創建新進程方面特別高效),這是我們多線程開發的一個重要原因。

線程開銷:線程是需要內存和性能開銷的,內存開銷包括系統內核內存和應用程序內存:用來管理和協調線程的內核結構存儲在內核,線程的棧空間和每個線程的數據存儲在程序的內存空間,佔用內存的這些結構大部分是在線程創建的時候生成和初始化的。因爲線程要和內核交互,這個過程是非常耗時的,這是線程的性能開銷的主要原因。線程創建大概的開銷如下(其中第二線程的棧空間是可以配置的):

  • 內核數據結構:大約1KB
  • 棧空間:主線程大約1MB,第二線程大約512KB
  • 線程創建時間:大約90毫秒
  • 另外一個開銷就是程序內線程同步的開銷。

2. NSThread的簡介

NSThread是對pthread的上層封裝,把線程處理爲面向對象的邏輯。一個NSThread即代表一個線程。

NSThread的優點在於NSThread 是一種輕量級的多線程的實現方式。但是NSThread的缺點同樣明顯,需要自己管理線程的生命週期,線程同步。同時,使用NSThread實現線程同步對數據的加鎖會有一定的系統開銷。
NSThread實現的技術有下面三種:

Technology Description
Cocoa threads Cocoa implements threads using the NSThread class. Cocoa also provides methods on NSObject for spawning new threads and executing code on already-running threads. For more information, see “Using NSThread” and “Using NSObject to Spawn a Thread.”Cocoa實現線程使用NSThread類。cocoa還提供了方法NSObject生成新線程和正在運行的線程上執行代碼。有關更多信息,請參見“使用NSThread”和“使用NSObject生成一個線程”。
POSIX threads POSIX threads provide a C-based interface for creating threads. If you are not writing a Cocoa application, this is the best choicefor creating threads. The POSIX interface is relatively simple to use and offers ample flexibility for configuring your threads. For more information, see “Using POSIX Threads”POSIX線程提供一個基於c的接口來創建線程。如果你不寫Cocoa應用程序中,這是最好的選擇來創建線程。POSIX接口是相對簡單的使用和配置線程提供了充足的靈活性。有關更多信息,請參見“使用POSIX線程”
Multiprocessing Services Multiprocessing Services is a legacy C-based interface used by applications transitioning from older versions of Mac OS.This technology is available in OS X only and should be avoided for any new development. Instead, you should use the NSThread class or POSIX threads. If you need more information on this technology, see Multiprocessing Services Programming Guide.多處理服務是一個遺留基於c的接口所使用的應用程序,從老版本的Mac OS過渡。這種技術可以在OS X只,應避免任何新的發展。相反,你應該使用NSThread類或POSIX線程。在這個技術如果你需要更多信息,請參見多處理服務編程指南。

我們一般推薦使用cocoa thread 技術。

  • Cocoa threads: 使用NSThread 或直接從 NSObject 的類方法 performSelectorInBackground:withObject: 來創建一個線程。如果你選擇thread來實現多線程 ,那麼 NSThread 就是官方推薦優先選用的方式。
  • POSIX threads: (可移植操作系統接口)基於 C 語言的一個多線程庫,是一種跨平臺的多線程實現方式。Pthread是一套通用的線程庫,它廣泛的被各種Unix所支持,是由POSIX提出的。因此, 它具有很好的可移植性。很多OS都兼容Posix thread,如Linux/Windows等,甚至嵌入式系統上(如rt-thread)都支持posix thread API。

3. NSThread的使用

3.1 NSThread的創建與運行

NSThread的初始化方式一共有4中方法:

  • 顯示創建的方法
    這裏寫圖片描述
    參數意義:
    selector :線程執行的方法,這個selector只能有一個參數,而且不能有返回值。我們知道每個線程都是有main方法的,selector就是該線程的入口方法,相當於該線程的main函數,這裏相當於在main函數中self調用了selector的方法。
    target :selector消息發送的對象,
    argument:傳輸給target的唯一參數,也可以是nil
    代碼示例:
    這裏寫圖片描述

  • 隱式創建方式
    這裏寫圖片描述
    參數意義:
    selector :線程執行的方法,這個selector只能有一個參數,而且不能有返回值。
    target :selector消息發送的對象
    argument:傳輸給target的唯一參數,也可以是nil
    代碼示例
    這裏寫圖片描述

    上面兩種方法是NSTread類提供的直接創建線程的方式。第一種方式會先創建線程對象,然後再調用start方法運行線程操作;第二種方式是直接創建線程並且開始運行線程。同時第一種創建方式可以得到一個線程對象,在運行線程操作前可以設置線程的優先級等線程信息,這點我們會在下面做具體的講解。
    
  • 使用NSObject的線程擴展方法:
    在NSThread中創建了一個類目 (NSThreadPerformAdditions),爲NSObject拓展了多線程的實現方法,使用和上一個方法類似。
    這裏寫圖片描述
    代碼示例:
    這裏寫圖片描述

  • 創建一個NSThread子類

    基於NSThread類創建一個子類,然後實例化並調用start方法運行線程操作,注意在初始化子類是,需要複寫main函數,作爲線程的入口。
    這裏寫圖片描述
    這裏寫圖片描述
    需要強調的是, 複寫main函數後,線程的入口已經有了,不需要再指定線程的入口函數。所以下面的使用方式是沒有意義的,因爲當我們複寫main函數後,線程的入口就改變了,制定selecter 參數沒有意義。

3.2 配置線程參數

  • stackSize:配置線程棧空間

    棧空間是用來存儲爲線程創建的本地變量的,棧空間的大小必須在線程的創建之前設定,即在調用NSThread的start方法之前通過setStackSize: 設定新的棧空間大小。

  • threadDictionary:配置線程的本地存儲

    每個線程都維護一個在線程任何地方都能獲取的字典。 我們可以使用NSThread的 threadDictionary方法獲取一個NSMutableDictionary對象,然後添加我們需要的字段和數據。

  • threadPriority:設置線程的優先級

    可以通過NSThread的setThreadPriority:方法設置線程優先級,優先級爲0.0到1.0的double類型,1.0爲最高優先級。iOS 8更新中被qualityOfService替代。每一個新的線程都有一個默認的優先級。系統的內核調度算法根據線程的優先級來決定線程的執行順序。通常情況下我們不要改變線程的優先級,提高一些線程的優先級可能會導致低優先級的線程一直得不到執行,如果在我們的應用內存在高優先級線程和低優先級線程的交互的話,因爲低優先級的線程得不到執行可能阻塞其他線程的執行。這樣會對應用造成性能瓶頸。

  • 設置線程的Detached、Joinable狀態

  • 脫離線程(Detach Thread)—線程完成後,系統自動釋放它所佔用的內存空間

  • 可連接線程(Joinable Thread)—線程完成後,不回收可連接線程的資源

    在應用程序退出時,脫離線程可以立即被中斷,而可連接線程則不可以。每個可連接線程必須在進程被允許可以退出的時候被連接。所以當線程處於週期性工作而不允許被中斷的時候,比如保存數據到硬盤,可連接線程是最佳選擇。
    當然,在iOS開發過程中,很少需要我們創建可連接的線程。通過NSThread創建的線程都是脫離線程的。如果你想要創建可連接線程,唯一的辦法是使用 POSIX 線程。POSIX 默認創建的線程是可連接的。通過 pthread_attr_setdetachstate函數設置是否脫離屬性。

3.3 NSThread 的常用方法

  NSThread類給我們提供了一下幾個常用方法

這裏寫圖片描述

3.4 線程間的通信

    在一個進程中,線程往往不是孤立存在的,多個線程之間需要經常進行通信。例如,執行下載網絡圖片的任務時,爲了避免由於網絡延遲造成主線程的卡死,我們開闢一條線程執行下載任務,但是由於操作UI的任務必須在主線程中進行,所以圖片下載完成後需要回到主線程完成加載圖片的任務。
   線程間通信的主要表現在:一個線程傳遞數據給另一個線程,或者是在一個線程中執行完特定任務後,轉到另一個線程繼續執行任務。

線程間通信常用方法:

這裏寫圖片描述

參數解釋:

  • @selector 定義我們要執行的方法。
  • withObject:arg 定義了我們執行方法時,傳入的參數對象,類型是id。
  • waitUntilDone:YES 指定當前線程是否要被阻塞,直到主線程將我們制定的代碼塊執行完。
  • modes:array 指定時間運行的模式。

    (1)前兩個方法的作用是在主線程中,執行指定的方法。該方法主要用來回調到主線程來修改頁面UI的狀態。當前線程爲主線程的時候,waitUntilDone: 設置參數爲YES無效。
    (2)後兩個方法的作用是在指定線程中,執行指定的方法。注意,指定執行任務的線程必須有runloop。

3.5 示例練習

我們通過一個示例來聯繫一下NSThead的使用,當我們在加載網絡圖片時,由於網絡延遲,圖片可能很長時間才能完成加載,這時候,爲了避免造成主線程的卡死,我們可以把下載網絡圖片的任務交給一個新的線程,當圖片完成下載之後再回到主線程完成圖像的加載顯示。

   代碼如下:
  • 給UIImageView類創建一個類目,增加一個方法:setimageWithURL:(NSString*)urlString;開闢子線程,完成下載圖片的任務;
    這裏寫圖片描述
  • 在子線程中下載網絡圖片,下載成功後回到主線程,完成圖片的加載任務,因爲系統要求所有與UI想過的任務都需要在主線程中完成。
    這裏寫圖片描述
  • 圖片下載完成後,完成圖片的加載,該方法需要在主線程中完成。
    這裏寫圖片描述

4. 完善線程的入口

4.1 Autorelease Pool

   如果新開闢的線程沒有Autorelease Pool的話,那麼在新線程中生成的Autorelease對象會存放到主線程的Autorelease Pool中,當新開闢的線程被終止時,線程中的Autorelease對象不會被最終釋放掉,佔用主線程資源。所以我們需要在線程的入口處我們需要創建一個Autorelease Pool,當線程退出的時候釋放這個Autorelease Pool。這樣在線程中創建的autorelease對象就可以在線程結束的時候釋放,避免過多的延遲釋放造成程序佔用過多的內存。如果是一個長壽命的線程的話,應該創建更多的Autorelease Pool來達到這個目的。例如線程中用到了Runloop的時候,每一次的迭代都需要創建 Autorelease Pool。

4.2 設置 Run Loop

   當創建線程的時候我們有兩種選擇,一種是線程執行一個很長的任務然後再任務結束的時候退出。另外一種是線程可以進入一個循環,然後處理動態到達的任務,這時候就需要我們開啓線程的RunLoop了。每一個線程默認都有一個 NSRunloop,主線程是默認開啓的,其他線程要手動開啓。

4.3 終止線程

   終止線程最好不要用POSIX接口直接殺死線程,這種粗暴的方法會導致系統無法回收線程使用的資源,造成內存泄露,還有可能對程序的運行造成影響。終止線程最好的方式是能夠讓線程接收取消和退出消息,這樣線程在受到消息的時候就有機會清理已持有的資源,避免內存泄露。如果需要在子線程運行的時候讓子線程結束操作,子線程每次Run Loop迭代中檢查相應的標誌位來判斷是否還需要繼續執行,可以使用threadDictionary以及設置Input Source的方式來通知這個子線程。這種方案的一種實現方式是使用NSRunloop的input source來接收消息,每一次的 NSRunloop循環都檢查退出條件是否爲YES,如果爲YES退出循環回收資源,如果爲NO,則進入下一次NSRunloop循環。

5. 線程同步

有時候需要我們設置線程同步,但是線程同步往往會產生很多問題:
1. 線程的死鎖。即較長時間的等待或資源競爭以及死鎖等多線程症狀。
2. 對公有變量的同時讀或寫。當多個線程需要對公有變量進行寫操作時,後一個線程往往會修改掉前一個線程存放的數據,從而使前一個線程的參數被修改;另外 ,當公用變量的讀寫操作是非原子性時,在不同的機器上,中斷時間的不確定性,會導致數據在一個線程內的操作產生錯誤,從而產生莫名其妙的錯誤,而這種錯誤是程序員無法預知的。

5.1 數據同步鎖

   在多個線程訪問相同的數據時,有可能會造成數據的衝突。比如常見的售票問題。
  • 通過使用加鎖的方式解決該問題是最常見的方式。Foundation框架中提供了NSLock對象來實現鎖。

    [lock lock];//加鎖
    [lock unlock];// 解鎖

  • 通過設置屬性的原子性同樣可以解決的該問題

    atomic:默認是有該屬性的,這個屬性是爲了保證程序在多線程情況下,編譯器會自動生成一些互斥加鎖代碼,避免該變量的讀寫不同步問題。

    nonatomic:如果該對象無需考慮多線程的情況,請加入這個屬性,這樣會讓編譯器少生成一些互斥加鎖代碼,可以提高效率。

    atomic的意思就是setter/getter這個函數,是一個原子操作。如果有多個線程同時調用setter的話,不會出現某一個線程執行完setter全部語句之前,另一個線程開始執行setter情況。相當於函數頭尾加了鎖一樣,可以保證數據的完整性。nonatomic不保證setter/getter的原語行,所以 你可能會取到不完整的東西。因此,在多線程的環境下原子操作是非常必要的,否則有可能會引起錯誤的結果。比如setter函數裏面改變兩個成員變量,如果你用 nonatomic的話,getter可能會取到只更改了其中一個變量時候的狀態,這樣取到的東西會有問題,就是不完整的。當然如果不需要多線程支持的話,用nonatomic就夠了,因爲不涉及到線程鎖的操作,所以它執行率相對快些。

    一般iOS程序中,所有屬性都聲明爲nonatomic。這樣做的原因是:在iOS中使用同步鎖的開銷比較大, 這會帶來性能問題。一般情況下並不要求屬性必須是“原子的”,因爲這並不能保證“線程安全”(thread safety),若要實現“線程安全”的操作,還需採用更爲深層的鎖定機制才醒。例如:一個線程在連續多次讀取某個屬性值的過程中有別的線程在同時改寫該值,那麼即便將屬性聲明爲atomic,也還是會讀取到不同的屬性值。因此,iOS程序一般都會使用nonatomic屬性。但是在Mac OS X程序時, 使用atomic屬性通常都不會有性能瓶頸

5.2 同步等待

多線程中經常遇到一種問題,A線程需要等待B線程執行後的某個結果繼續執行,也就是同步問題,這時就會需要A等待B,這裏說說使用NSCondition實現多線程同步的問題,也就是解決生產者消費者問題(如收發同步等等)。

問題流程如下:

  • 消費者取得鎖,取產品,如果沒有,則wait,這時會釋放鎖,直到有線程喚醒它去消費產品;
  • 生產者製造產品,首先也要取得鎖,然後生產,再發signal,這樣可喚醒wait的消費者。

    這裏需要注意wait和signal的問題:

    1. 其實,wait函數內部悄悄的調用了unlock函數(猜測,有興趣可自行分析),也就是說在調用wati函數後,這個NSCondition對象就處於了無鎖的狀態,這樣其他線程就可以對此對象加鎖並觸發該NSCondition對象。當NSCondition被其他線程觸發時,在wait函數內部得到此事件被觸發的通知,然後對此事件重新調用lock函數(猜測),而在外部看起來好像接收事件的線程(調用wait的線程)從來沒有放開NSCondition對象的所有權,wati線程直接由阻塞狀態進入了觸發狀態一樣。這裏容易造成誤解。
    2. wait函數並不是完全可信的。也就是說wait返回後,並不代表對應的事件一定被觸發了,因此,爲了保證線程之間的同步關係,使用NSCondtion時往往需要加入一個額外的變量來對非正常的wait返回進行規避。
    3. 關於多個wait時的調用順序,測試發現與wait執行順序有關。

6. 線程的狀態

線程的創建和開啓:

 self.thread = [[NSThread alloc]initWithTarget:self selector:@selector(test) object:nil];
 [self.thread start];

線程的運行和阻塞:

   // 設置線程阻塞1,阻塞2秒
   [NSThread sleepForTimeInterval:2.0];
   // 第二種設置線程阻塞2,以當前時間爲基準阻塞4秒
   NSDate *date=[NSDate dateWithTimeIntervalSinceNow:4.0];
   [NSThread sleepUntilDate:date];

線程處理阻塞狀態時在內存中的表現情況:(線程被移出可調度線程池,此時不可調度)

線程的死亡:
當線程的任務結束,發生異常,或者是強制退出這三種情況會導致線程的死亡。
這裏寫圖片描述
線程死亡後,線程對象從內存中移除。

代碼示例1:

- (void)viewDidLoad {
     [super viewDidLoad];

     //創建線程
     self.thread=[[NSThread alloc]initWithTarget:self selector:@selector(test) object:nil];
    //設置線程的名稱
     [self.thread setName:@"線程A"];
 }
 //當手指按下的時候,開啓線程
 -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
     //開啓線程
     [self.thread start];
 }

 -(void)test {
     //獲取線程
     NSThread *current=[NSThread currentThread];
     NSLog(@"test---打印線程---%@",self.thread.name);
     NSLog(@"test---線程開始---%@",current.name);
     //設置線程阻塞1,阻塞2秒
     NSLog(@"接下來,線程阻塞2秒");
     [NSThread sleepForTimeInterval:2.0];
     //第二種設置線程阻塞2,以當前時間爲基準阻塞4秒
      NSLog(@"接下來,線程阻塞4秒");
     NSDate *date=[NSDate dateWithTimeIntervalSinceNow:4.0];
     [NSThread sleepUntilDate:date];
     for (int i=0; i<20; i++) {
         NSLog(@"線程--%d--%@",i,current.name);

     }
         NSLog(@"test---線程結束---%@",current.name);
 }

打印查看:
這裏寫圖片描述
代碼示例2(退出線程):

- (void)viewDidLoad {
   [super viewDidLoad];
     //創建線程
     self.thread=[[NSThread alloc]initWithTarget:self selector:@selector(test) object:nil];
     //設置線程的名稱
     [self.thread setName:@"線程A"];
}
 //當手指按下的時候,開啓線程
 -(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
     //開啓線程
     [self.thread start];
 }
 -(void)test {
     //獲取線程
     NSThread *current=[NSThread currentThread];
     NSLog(@"test---打印線程---%@",self.thread.name);
     NSLog(@"test---線程開始---%@",current.name);
     //設置線程阻塞1,阻塞2秒
     NSLog(@"接下來,線程阻塞2秒");
     [NSThread sleepForTimeInterval:2.0];

     //第二種設置線程阻塞2,以當前時間爲基準阻塞4秒
      NSLog(@"接下來,線程阻塞4秒");
     NSDate *date=[NSDate dateWithTimeIntervalSinceNow:4.0];
     [NSThread sleepUntilDate:date];
     for (int i=0; i<20; i++) {
         NSLog(@"線程--%d--%@",i,current.name);
         if (5==i) {
            //結束線程
             [NSThread exit];
         }
     }
         NSLog(@"test---線程結束---%@",current.name);
 }

打印示例:
這裏寫圖片描述
注意:如果在線程死亡之後,再次點擊屏幕嘗試重新開啓線程,則程序會掛。
這裏寫圖片描述

小結:

NSThread 作爲多線程編程的重要的工具類,我們應該嘗試使用並理解其中的方式。但是NSThread的缺點很明顯,我們需要手動實現非常複雜的管理線程邏輯,自己管理線程的生命週期,線程同步。線程同步對數據的加鎖會有一定的系統開銷。當然他的優點同樣明顯比其他兩種多線程方案較輕量級,更直觀地控制線程對象。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章