線程 概念、特性及常見問題分析

線程概念

在一個程序裏的一個執行路線就叫做線程,更準確的定義是:線程是“一個進程內部的控制序列”,一切進程至少都有一個執行線程,線程在進程內部運行,本質是在進程地址空間內運行

線程在 Linux 操作系統中就是一個執行流,不同的執行流可以擁有不同的 CPU 來進行運算,即不同的的執行流之間可能會有並行的情況產生

在Linux系統中,在CPU眼中,看到的PCB都要比傳統的進程更加輕量化

透過進程虛擬地址空間,可以看到進程的大部分資源,將進程資源合理分配給每個執行流,就形成了線程執行流

線程就是創建一個執行流,在內核當中創建一個 PCB,其實就是創建一個 task_struct 結構體對象,這個 PCB 當中的內存指針,指向進程的虛擬地址空間
在
當進程當中只有一個執行流時,意味着只有一個主線程,pid == tgid (pid:線程 id,tpid:線程組 id)
當進程當中不止一個執行流時
主線程,pid == tgid
工作線程(新創建線程) tgid (即進程號,線程組id,表示是一個進程),pid(相當於線程 id,每一個線程 pid 都是不同的)

創建線程的接口是調用的庫函數,引申含義,即操作系統內核當中沒有線程概念,內核認爲爲我們創建的線程,是在內核當中創建了一個輕量級進程(LWP)

輕量級進程(LWP)是計算機操作系統中一種實現多任輕量級進程(LWP)務的方法,與普通進程相比,LWP與其他進程共享所有(或大部分)它的邏輯地址空間和系統資源;與線程相比,LWP有它自己的進程標識符,優先級,狀態,以及棧和局部存儲區,並和其他進程有着父子關係;這是和類Unix操作系統的系統調用vfork()生成的進程一樣的。

另外,線程既可由應用程序管理,又可由內核管理,而LWP只能由內核管理並像普通進程一樣被調度。Linux內核是支持LWP的典型例子
LWP與普通進程的區別也在於它只有一個最小的執行上下文和調度程序所需的統計信息,而這也是它之所以被稱爲輕量級的原因。

一般來說,一個進程代表程序的一個實例,而LWP代表程序的執行線程(其實,在內核不支持線程的時候,LWP可以很方便地提供線程的實現)。因爲一個執行線程不像進程那樣需要那麼多狀態信息,所以LWP也不帶有這樣的信息。

創建出來的線程的 PCB,也是需要掛在內核當中的雙向鏈表當中去,也就是意味着操作系統在管理線程(輕量級進程)時,也是通過雙向鏈表來獲取到線程的PCB,從而調度該線程獲取到CPU資源,這也是同一個程序當中不同的執行流(線程),可以並行運行的原因

線程共享和獨有

共享:
共享了虛擬地址空間,代碼段數據段
文件描述符
信號處理器
當前進程的工作路徑
進程用戶id(Uid)和組id(Pid)

獨有:

  1. tid:每個線程都有自己的線程ID,這個ID在本進程中是唯一的。進程用此來標識線程
  2. 棧:線程函數可以調用函數,而被調用函數中又是可以層層嵌套的,所以線程必須擁有自己的函數堆棧,使得函數調用可以正常執行,不受其他線程的影響
    堆棧是保證線程獨立運行所必須的
  3. 信號屏蔽字:由於每個線程所感興趣的信號不同,所以線程的信號屏蔽碼應該由線程自己管理。但所有的線程都共享同樣的信號處理器
    調度優先級:由於線程需要像進程那樣能夠被調度,那麼就必須要有可供調度使用的參數,這個參數就是線程的優先級
  4. 一組寄存器:由於線程間是併發運行的,每個線程有自己不同的運行線索,當從一個線程切換到另一個線程上時,必須將原有的線程的寄存器集合的狀態保存,以便將來該線程在被重新切換到時能得以恢復
  5. errno:由於同一個進程中有很多個線程在同時運行,可能某個線程進行系統調用後設置了errno值,而在該線程還沒有處理這個錯誤,另外一個線程就在此時被調度器投入運行,這樣錯誤值就有可能被修改,所以,不同的線程應該擁有自己的錯誤返回碼變量。

線程優缺點

優點:

  1. 創建一個新線程的代價要比創建一個新進程小得多
  2. 與進程之間的切換相比,線程之間的切換需要操作系統做的工作要少很多
  3. 線程佔用的資源要比進程少很多
  4. 能充分利用多處理器的可並行數量
  5. 在等待慢速I/O操作結束的同時,程序可執行其他的計算任務
  6. 計算密集型應用,爲了能在多處理器系統上運行,將計算分解到多個線程中實現
  7. I/O密集型應用,爲了提高性能,將I/O操作重疊。線程可以同時等待不同的I/O操作

缺點:

  1. 性能損失
    一個很少被外部事件阻塞的計算密集型線程往往無法與共它線程共享同一個處理器。如果計算密集型線程的數量比可用的處理器多,那麼可能會有較大的性能損失,這裏的性能損失指的是增加了額外的同步和調度開銷,而可用的資源不變
  2. 健壯性降低
    編寫多線程需要更全面更深入的考慮,在一個多線程程序裏,因時間分配上的細微偏差或者因共享
    了不該共享的變量而造成不良影響的可能性是很大的,換句話說線程之間是缺乏保護的,當一個線程意外退出,可能會拖累整個進程
  3. 缺乏訪問控制
    進程是訪問控制的基本力度,在一個線程中調用某些OS函數會對整個進程造成影響,無法確認哪個執行流先進行,不能對執行順序進行保證
  4. 編程難度提高
    編寫與調試一個多線程程序比單線程程序困難得多
命令:
top 可以查看進程CPU的使用率
top -H -p[pid] 可以查看每一個線程CPU的使用率
gcore:可以使一個進程強制產生 core 文件
pstack:可以查看一個進程的堆棧

在gdb調試過程中,可以使用thread apply all bt 查看所有線程的調用堆棧

線程異常

  1. 單個線程如果出現除零,野指針問題導致線程崩潰,進程也會隨着崩潰
  2. 線程是進程的執行分支,線程出異常,就類似進程出異常,進而觸發信號機制,終止進程,進程終止,該進程內的所有線程也就隨即退出

線程用途

合理的使用多線程,能提高CPU密集型程序的執行效率
合理的使用多線程,能提高IO密集型程序的用戶體驗(如我們一邊寫代碼一邊下載開發工具,就是多線程運行的一種表現)

線程控制

前提:線程控制當中使用的函數都是庫函數,使用線程控制函數時,需要增加鏈接線程庫 -libpthread.so

線程創建

int pthread_create(pthread_t* thread, const phread_attr_t* attr, void*(*start_routine)(void*), void* arg);
    thread:出參,返回的時候線程的標識符,和線程 id 不一樣,pthread_t 是線程空間的首地址,通過這個標識符可以對當前線程進行操作
    attr:類型是 pthread_attr_t 是一個結構體,這個結構體完成對新創建線程屬性的設置,如果說在創建線程的時候,該參數設置爲NULL,則表示採用默認的屬性
        線程棧的大小
        線程棧的起始位置
        線程的分離屬性
        線程的優先級調度屬性
    start_routine:函數指針,接收一個函數的地址,在創建線程的時候,指定該線程要從哪個函數開始執行,線程的入口函數
        函數是一個void*返回值
    void* arg:表示的是給入口函數傳的參數(void*)
        可以傳遞默認類型,也可以傳遞自定義類型(結構體,類的實例化指針)
        傳遞參數時,尤其需要注意臨時變量的生命週期

在這裏插入圖片描述

線程終止

1. 從線程入口函數的return返回
2. pthread_exit(void* retval)
    誰調用,誰退出--》自殺行爲
    retval:當前線程的退出信息,也可以傳入NULL
3. int pthread_cancel(pthread_t)
        取消線程標識符的進程
        thread:想結束哪個線程就傳入哪個線程的標識符
        這個函數可以結束任一個線程,可以結束任一個執行流

線程等待

前提:線程有默認屬性,一般默認屬性當中都有一個 joinable 屬性,當該線程退出的時候,需要別人來回收退出線程的資源,意味着如果不去回收該線程的資源,該線程的資源就泄漏掉了,共享區當中關於該線程的資源就不會釋放或重用

  1. 等待的必要性
    爲了釋放退出線程的資源,防止內存泄漏
2. pthread_join(pthread_t, void**)
        void*(*thread_start)(void*)
        pthread_exit(void*)
            void**:其實是一個出參,需要接收一個 void* 的地址
        三種情況:
            從線程入口函數的 return 返回,則可以使用 pthread_join 獲取入口函數的返回值(void*)
            調用 pthread_exit(void*) 終止,則可以使用 pthread_join 獲取 void* 的值
            如果線程是被 pthread_cancel 所終止的,則 pthread_join 獲取的值是一個常數 PTHREAD_CANCELED

線程分離

爲什麼會分離?
線程退出的時候,由於線程的默認屬性是 joinable 屬性,資源需要被其他線程所釋放,爲了解決這種情況,將線程的屬性設置爲分離屬性,好處是:當線程終止時,不需要別人來釋放資源,操作系統直接回收
int pthread_detach(pthread_t thread)
thread:需要分離哪一個線程,也可以傳入自己線程的標識符
獲取自己線程的標識符
pthread_self():返回當前調用執行流的線程標識符,誰調用返回誰
兩種方法

  1. 在創建線程的時候,調用 pthread_detach(tid)
  2. 在線程的入口函數當中,調用 pthread_detach( pthread_self()),第二種用得更多

線程安全

多個線程(執行流)同時進行訪問臨界資源,不會導致程序產生二義性,程序結果是唯一的,我們把這種情況稱爲線程安全

訪問臨界資源:在臨界區當中對臨界資源非原子操作(修改數據),帶來的問題是:有可能一個執行流正在訪問這個臨界資源,但是由於操作是非原子性的,所以有可能被其他執行流打斷,讓出CPU,其他執行流有可能也訪問了同樣的臨界資源,這樣的話,就有可能會導致程序的二義性

原子操作:操作是一步完成的,只有兩種結果:要麼完成,要麼未完成

正常的自加自減不是一個原子性操作,過程爲先將變量的值加載到寄存器當中,然後 CPU 進行運算,寫入寄存器,回寫到內存,這個過程中,每一個步驟都有可能被打斷,即讓出 CPU 資源,當前執行等待重新獲取時間片,這個時間段可能會有其他執行流對該變量進行訪問,就會產生數據的二義性

保證線程安全
互斥:保證不同的執行流對臨界資源的原子訪問,即每一次只有一個執行流可以訪問臨界資源
不能造成同一個資源同時被不同線程拿到,保證拿到資源都合法

 實現方法:
 互斥鎖:互斥鎖底層其實是一個互斥量,互斥量是一個計數器,使用該計數器保證該計數器的取值只有兩個 0 或 1
    0:表示當前資源不可以被訪問,無法獲取資資源,執行流被掛起
    1:表示當前資源不可以被訪問,可以獲取鎖資源,加鎖訪問,當訪問臨界資源完成,需要對互斥鎖進行釋放鎖的操作,也採用交換操作,相當於給互斥鎖加 1

關於互斥鎖的使用,請見下一篇博客.
在這裏插入圖片描述
計數器本身也是一個變量,變量在修改時(從 0 變 1,或 1 變 0),修改操作是否是原子操作?
計數器(從 0 變 1,或 1 變 0)不是直接對計數器變量進行加或減,而是通過 xchgd 與寄存器中的值進行交換,如果寄存器當中的值爲 1,表示可以加鎖,如果小於 1 ,不可以加鎖,執行流掛起等待,等待鎖資源

同步:保證了程序對臨界資源訪問的合理性
有資源通知來搶資源,沒資源即等待,不陷入死循環

條件變量 = 兩個接口(線程等待及喚醒線程)+ PCB 等待隊列
消費線程、生產線程

條件變量的接口

  1. 定義條件變量
pthread_cond_t
  1. 初始化條件變量
int pthread_cond_init(pthread_cond_t*, pthread_condattr_t*)
    pthread_cond_t*:需要傳入條件變量的地址
    pthread_condattr_t*:條件變量的
 pthread_cond_t cond = PTHREAD_COND_INITIALIZED;
     通過宏來進行初始化
  1. 等待接口
int pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex)
    cond:條件變量
    mutex:互斥鎖變量

要在線程放到等待隊列之後再解鎖,防止在消費者線程解鎖後,進入等待隊列前,發生生產者獲得互斥鎖,生產出臨界資源,並通知等待隊列裏的進程,此時,信號先給出,再進行入隊,可能導致生產出的臨界資源永遠都無法被訪問

  1. 喚醒接口
    pthread_cond_signal(pthread_cond_t*)
    傳入的是條件變量的地址,喚醒 PCB 等待隊列當中至少一個等待的執行流

  2. 銷燬條件變量
    pthread_cond_destroy(pthread_cond_t*)

判斷資源數量要使用 while 進行判斷

需要兩個條件變量來完成消費者和生產者兩類不同的線程對程序運行的控制

生產者與消費者模型優點:

  1. 可以解耦合,生產者和消費者通過隊列進行交互
  2. 支持忙閒不均,隊列當中的節點就起到一個緩存的作用,生產者自己生產直到隊列滿
  3. 支持併發,因爲消費者和生產者只需要關心在隊列當中消費或生產

線程池

線程池的實現 = 線程安全的隊列 + 一大堆線程

  1. 嚴格上區分,線程池當中的線程都是等價的,沒有角色之分
  2. 從程序角度分析,線程池當中的線程都是處理線程池當中數據的,可以認爲線程池當中的線程都是消費線程

線程池當中的線程都是統一的線程入口函數,跑同一份代碼,現在需要處理不同的請求,就會產生問題

實際場景中,一臺服務器可能在同一時間有大量不同的請求,程序不崩潰的情況下,儘可能多的處理請求,如何讓相同入口的函數,處理不同的請求?
向線程池拋入數據時,將處理數據的函數一起拋入(函數地址),線程池當中的線程只需要調用傳入的函數進行處理數據即可

線程與進程區別與聯繫

  1. 線程是操作系統調度的最小單位,進程是操作系統分配資源的最小單位
  2. 線程共享進程數據,但也擁有自己的一部分數據
  3. 一個進程至少有一個線程
  4. 線程的劃分尺度小於進程,多個線程併發性高
  5. 進程在指定的過程中,擁有獨立的內存單元,而多個線程共享進程的內存空間,從而提高了程序的運行效率
  6. 線程在執行的過程中,與進程的區別在於每個獨立的線程有一個程序的運行入口順序執行序列和程序出口,但線程不能獨立運行,必須依存於進程,應用程序中由應用程序提供線程的控制

常見問題

問題:都指向了同一個虛擬地址空間,這個和 vfork 創建子進程有什麼區別?
進程虛擬地址空間當中有線程獨有的棧

問題:線程數量越多越好?
不是,大量的線程都需要佔有CPU,增加操作系統的開銷,操作系統忙於調度線程

問題:多進程和多線程的區別?
多進程:由於每一個進程都是擁有自己獨立的虛擬地址空間,所以一個進程的異常不會導致其他進程退出。多進程也會提高程序的運行效率,但會帶來進程間通信的問題
多線程:由於進程當中的每一個執行流使用的同一個虛擬地址空間,所以,一個執行流的異常可能會導致整個進程退出,多線程的程序也會提高程序的運行效率,但是會造成程序的健壯性低,代碼編寫的複雜性增高

問題:當前主進=線程如果創建了一個線程之後,調用 pthread_exit,進程是否後退出?
進程不會退出,但是主線程的狀態變成了殭屍線程,工作線程的狀態還是 S 或 R(即工作線程正常工作)

問題:爲什麼不用循環判斷是否有臨界資源而是加入等待隊列?
大量的輪詢會造成 CPU 的壓力,爲了節省 CPU 資源

問題:爲什麼需要互斥鎖?
條件變量只是保證了同步,需要使用到互斥鎖來保證互斥,保證消費者和消費者之間沒有衝突,保證生產者和生產者之間沒有矛盾,還要保證生產者和消費者之間的互斥,條件變量增加互斥鎖,是爲了通知消費者或者生產者,保證只有一個線程可以獲得消費或生產的信號

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章