Linux--多執行緒(一)

2022-10-29 15:02:10

執行緒

執行緒的概念

執行緒: 執行緒是OS能夠進行運算排程的基本單位。執行緒是一個程序中的一個單一執行流,通俗地說,一個程式裡的一個執行路線就叫做執行緒。

可以知道的是,一個程序至少有一個執行執行緒,這個執行緒就是主執行流。一個程序的多個執行流是共用程序地址空間內的資源,也就是說程序的資源被合理分配給了每一個執行流,這些樣就形成了執行緒執行流。所以說執行緒在程序內部執行,本質是在程序地址空間內執行。
需要注意的是,Linux下沒有真正意義上的執行緒,執行緒是通過程序來模擬實現的。這句話如何理解?

Linux系統下,沒有專門為執行緒設計相關的資料結構。那執行緒又是如何被建立的呢?我們知道,建立一個程序,我們需要為它建立相關的資料結構,如:PCB(task_struct)、mm_sturct、頁表和file_struct等。執行緒的建立和程序的建立是一樣的,執行緒也是建立一個一個的PCB,因為執行緒是共用程序地址空間的,所以這些執行緒都維護同一個程序地址空間。

這樣可以看出一個執行緒就是一個執行流,每一個執行緒有一個task_struct的結構體,和程序一樣,這些task_struct都是由OS進行排程。可以看出在CPU看來,程序和執行緒是沒有區別的,所以說Linux下的執行緒是通過程序模擬實現的。

繼續思考,CPU如何區分Linux下的執行緒和程序?

其實CPU不需要考慮這個問題,在它眼中,程序和執行緒是沒有區別的,都是一個一個的task_struct,CPU只管負責排程即可。

那如何理解我們之前所學的程序?

我們都知道,程序是承擔分配系統資源的基本實體,曾經CPU看到的PCB是一個完整的程序,也就是隻有一個執行流的程序。現在看到的PCB不一定是完整的程序,可能是一個程序的執行流總的一個分支,也就是多執行流程序。所以說,現在CPU眼中,看到的PCB比傳統的程序更加輕量化了。這種有多執行流的程序中的每一個執行流都可以看作是一個輕量級程序。總結地說,執行緒是輕量級程序。
總結:

簡單點來說,每個執行緒都有自己的PCB,只不過這些PCB都維護和共用這同一塊虛擬空間(程序的虛擬空間,也就是程序的PCB),但是執行緒的PCB更輕量級,作業系統分配資源的時候是以程序那塊PCB為分配資源的最小單位,所以給程序分配的資源,屬於該程序的執行緒們都共用,而執行緒是作業系統排程的最小單位,作業系統不會區分執行緒和程序,在作業系統眼裡都是一個個PCB,CPU排程的時候只負責呼叫PCB就行了。

  • 實際上無論是建立程序的fork,還是建立執行緒的pthread_create,底層實現都是呼叫一個核心函數clone。
    • 如果複製對方的地址空間,那麼就產生出一個程序
    • 如果共用對方的地址空間,就產生一個執行緒
    • 可以更簡單的理解程序和執行緒的區別,程序的建立就類似於深拷貝,執行緒的建立就類似於淺拷貝,更有助於理解

Linux下的程序和執行緒

程序: 承擔分配系統資源的實體
執行緒: CPU排程的基本單位
注意: 程序之間具有很強的獨立性,但是執行緒之間是會互相影響的

執行緒共用一部分程序資料,也有自己獨有的一部分資料:(每個執行緒都有屬於自己的PCB)

  • 執行緒ID
  • 一組暫存器(記錄上下文資訊,任務狀態段)
  • 獨立的棧空間(使用者空間棧)
  • 訊號遮蔽字
  • 排程優先順序
  • errno(錯誤碼)
  • 處理器現場和棧指標(核心棧)

程序的多個執行緒共用同一地址空間,因此Text Segment、Data Segment都是共用的。如果定義一個函數,在各執行緒中都可以呼叫,如果定義一個全域性變數,在各執行緒中都可以存取到,除此之外,各執行緒還共用以下程序資源和環境:

  • 檔案描述符
  • 每種訊號的處理方式
  • 當前工作目錄
  • 使用者ID和組ID
  • 共用.text(程式碼段) .data(資料段) .bss(未初始化資料段).heap(堆)

關係圖:

Linux執行緒控制

POSIX執行緒庫

  • POSIX執行緒(英語:POSIX Threads,常被縮寫為Pthreads)是POSIX的執行緒標準,定義了建立和操縱執行緒的一套API。
  • 與執行緒有關的函數構成了一個完整的系列,絕大多數的名字都是以「pthread_」打頭的。
  • 使用執行緒庫需要映入標頭檔案pthread.h,連結這些執行緒函數是,需要指明執行緒庫名,所以編譯時要加上選項-lpthread。

注意: Linux核心沒有提供執行緒管理的庫函數,這裡的執行緒庫是使用者提供的執行緒管理功能

錯誤檢查

  • 傳統的一些函數是,成功返回0,失敗返回-1,並且對全域性變數errno賦值以指示錯誤。
  • pthreads函數出錯時不會設定全域性變數errno(而大部分其他POSIX函數會這樣做,不然這個全域性變數就成為臨界資源了)。而是將錯誤程式碼通過返回值返回。
  • pthreads同樣也提供了執行緒內的errno變數,以支援其它使用errno的程式碼。對於pthreads函數的錯誤,建議通過返回值判定,因為讀取返回值要比讀取執行緒內的errno變數的開銷更小。

執行緒建立

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg); 
功能:建立一個執行緒。
引數:
    thread:執行緒識別符號地址
    attr:執行緒屬性結構體地址,通常設定為NULL
    start_routine:執行緒函數的入口地址
    arg:傳給執行緒函數的個數
返回值:
    成功:0
    失敗:非0

在一個執行緒中呼叫pthread_create()建立新的執行緒之後,當前執行緒從pthread_create()返回繼續向下執行,而新的執行緒所執行的程式碼由我們傳給pthread_create的函數指標start_routine決定。

由於pthread_create的錯誤碼不儲存在errno當中,因此不能直接使用perror()列印錯誤資訊,可以先用strerror()把錯誤碼轉成錯誤資訊再列印。

程式碼範例:

 #include<stdio.h>
 #include<stdlib.h>
 #include<string.h>
 #include<pthread.h>
 //執行緒排程之後執行的任務
 void *fun(void *arg)
 {
    printf("新的執行緒執行任務 tid:%ld\n",pthread_self());
    //退出當前函數體
    return NULL;
 }
int main()
{
    int ret = -1;
    pthread_t tid = -1;
    //建立一個執行緒
    ret = pthread_create(&tid,NULL,fun,NULL);
    if(0!=ret)
    {
      //根據錯誤號列印錯誤資訊
      printf("error information:%s\n",strerror(ret));
      return 1;
    }
    printf("main thread.....tid:%lud\n",pthread_self());
    return 0;
}

執行結果如下:

執行緒在建立過程中不會阻塞,主程序會立刻執行,那麼存在一個問題,主程序如果執行完畢,那麼所有執行緒都將被釋放,就可能出現執行緒還未排程的問題。(後面會解決)

執行緒和程序有區別,父子程序執行的程式碼段是一樣的,但是執行緒被建立之後執行的是執行緒處理常式。

再介紹一個函數:

就像每個程序都有一個程序號一樣,每個執行緒也有一個執行緒號。程序號再整個系統中是唯一的,但是執行緒號不同,執行緒號只在它所屬的程序環境中有效。

程序號用pid_t資料型別表示,是一個非負整數。執行緒號則用pthread_t資料型別來表示,Linux使用無符號長整型數表示。

範例1: 建立一個執行緒,觀察程式碼執行效果和函數用法

pthread_t pthread_self(void);
功能:獲取執行緒號
引數:無
返回值:呼叫執行緒的執行緒ID
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* pthreadrun(void* arg)
{
	char* name = (char*)arg;
	while (1){
		printf("%s is running...\n", name);
		sleep(1);
	}
}

int main()
{
	pthread_t pthread;
	// 建立新執行緒
	pthread_create(&pthread, NULL, pthreadrun, (void*)"new thread");
	
	while (1){
		printf("main thread is running...\n");
		sleep(1);
	}
	return 0;
}

執行結果如下:

範例2: 建立4個執行緒,然後列印出各自的pid和執行緒id

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* pthreadrun(void* arg)
{
  long id = (long)arg;
  while (1){
    printf("threaad %ld is running, pid is %d, thread id is %p\n", id, getpid(), pthread_self());
    sleep(1);
  }
}

int main()
{
  pthread_t pthread[5];
  int i = 0;
  for (; i < 5; ++i)
  {
    // 建立新執行緒
    pthread_create(pthread+i, NULL, pthreadrun, (void*)i);
  }

  while (1){
    printf("main thread is running, pid is %d, thread id is %p\n", getpid(), pthread_self());
    sleep(1);
  }
  return 0;
}

執行結果如下:

可以看到六個執行緒的PID是一樣的,同屬於一個程序,但是它們還有一個表示,LWP(light wighted process),輕量級程序的ID。下面詳細介紹。

程序ID和執行緒ID

  • 在Linux下,執行緒是由Native POSIX Thread Library 實現的,在這種實現下,執行緒又被稱為輕量級程序(LWP)。在使用者態的每個程序,核心中都有一個與之對應的排程實體(擁有自己的task_struct結構體)。

  • 在沒有執行緒之前,一個程序對應核心裡的一個程序描述符,對應一個程序ID。引入執行緒概念之後,一個使用者程序下管理多個使用者態執行緒,每個執行緒作為一個獨立的排程實體,在核心中都有自己的程序描述符。程序和核心的描述符變成了1:N的關係。

  • 多執行緒的程序,又被稱為執行緒組。執行緒組內的每一個執行緒在核心中都有一個程序描述符與之對應。程序描述符結構體表面上看是程序的pid,其實它對應的是執行緒ID;程序描述符中的tpid,含義是執行緒組ID,該值對應的是使用者層面的程序ID。

    struct task_struct {
    	...
    	pid_t pid;// 對應的是執行緒ID,就是我們看到的lwp
    	pid_t tgid;// 執行緒組ID,該值對應的是使用者層面的程序ID
    	...
    	struct task_struct *group_leader;
    	...
    	struct list_head thread_group;
    	...
    };
    
  • 具體關係如下:

使用者態 系統呼叫 核心程序描述符中對應的結構
執行緒ID pid_t gettid(void) pid_t pid
程序ID pid_d getpid(void) pid_t tgid

注意: 這裡的執行緒ID和建立執行緒得到的ID不是一回事,這裡的執行緒ID是用來唯一標識執行緒的一個整形變數。

如何檢視執行緒ID?

1.使用ps命令,帶-L選項,可以檢視到lwp

2.Linux提供了gettid系統呼叫來返回其執行緒ID,可是glibc並沒有將該系統呼叫封裝起來,在開放介面來供程式設計師使用。如果確實需要獲得執行緒ID,可以採用如下方法:

#include <sys/syscall.h> 
pid_t tid; tid = syscall(SYS_gettid);

在前面的一張圖片中(如下),我們可以發現的是,有一個執行緒的ID和程序ID是一樣的,這個執行緒就是主執行緒。在核心中被稱為group leader,核心在建立第一個執行緒時,會將執行緒組的ID的值設定成第一個執行緒的執行緒ID,group_leader指標則指向自身,既主執行緒的程序描述符。所以執行緒組記憶體在一個執行緒ID等於程序ID,而該執行緒即為執行緒組的主執行緒。

注意: 執行緒和程序不一樣,程序有父程序的概念,但是線上程組中,所有的執行緒都是對等關係。

執行緒ID和程序地址空間佈局

pthread_create產生的執行緒ID和gettid獲得的id不是一回事。後者屬於程序排程範疇,用來標識輕量級程序。前者的執行緒id是一個地址,指向的是一個虛擬記憶體單元,這個地址就是執行緒的ID。屬於執行緒庫的範疇,執行緒庫後序對執行緒操作使用的就是這個ID。對於目前實現的NPTL而言,pthread_t的型別是執行緒ID,本質是程序地址空間的一個地址:

這裡的每一個執行緒ID都代表的是每一個執行緒控制塊的起始地址,pthread_create返回的就是執行緒控制塊的起始地址。這些執行緒控制塊都是struct pthread型別的,所以所有的執行緒可以看成是一個大的陣列,被描述組織起來。

執行緒退出

線上程中我們可以呼叫exit函數或者_exit函數來結束程序,在一個執行緒中我們可以通過以下三種方式在不終止整個程序的情況下停止它的控制流。

  • 從執行緒函數return。這種方法對主執行緒不適用,從main函數return相當於呼叫exit。
  • 執行緒可以呼叫pthread_exit終止自己
  • 一個執行緒可以呼叫pthread_ cancel終止同一程序中的另一個執行緒

注意:執行緒不能用exit(0)來退出,exit是用來退出程序的,如果線上程中呼叫exit,那麼當執行緒結束的時候,該執行緒的程序也就結束退出了。

範例1:return退出執行緒排程函數

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* pthreadrun(void* arg)
{
  int count = 0;
  while (1){
    printf(" new threaad is running, pid is %d, thread id is %p\n", getpid(), pthread_self());
    sleep(1);
    if (count++ == 5){
      return (void*)10;
    }
  }
}
int main()
{
  pthread_t thread;
  pthread_create(&thread, NULL, pthreadrun, NULL);

  while (1){
    printf("main thread is running, pid is %d, thread id is %p\n", getpid(), pthread_self());
    sleep(1);
  }
  return 0;
}

執行結果小夥伴們自己執行一下吧。

範例2:pthread_exit函數

void pthread_exit(void *retval); 
功能:
	退出呼叫執行緒。一個程序中的多個執行緒是共用該程序的資料段,因此,通常執行緒退出後所佔用的資源並不會釋放。
引數:
    retval:儲存執行緒退出狀態的指標。
返回值:無
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* pthreadrun(void* arg)
{
  int count = 0;
  while (1){
    printf(" new threaad is running, pid is %d, thread id is %p\n", getpid(), pthread_self());
    sleep(1);
    if (++count == 3){
      pthread_exit(NULL);
    }
  }
}

int main()
{
  pthread_t thread;
  pthread_create(&thread, NULL, pthreadrun, NULL);

  while (1){
    printf("main thread is running, pid is %d, thread id is %p\n", getpid(), pthread_self());
    sleep(1);
  }
  return 0;
}

線上程排程函數中pthread_exit(NULL)等價於return 。

範例3:pthread_cancel函數

 int pthread_cancel(pthread_t thread);
功能:
	殺死(取消)執行緒
引數:
	thread:目標執行緒ID
返回值:
	成功:0
	失敗:出錯編號

注意:執行緒的取消不是實時的,而是有一定的延時。需要等待執行緒到達某個取消點(檢查點)。

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* pthreadrun(void* arg)
{
  int count = 0;
  while (1){
    printf(" new threaad is running, pid is %d, thread id is %p,count is %d\n", getpid(), pthread_self(),count);
    sleep(1);
  }
}

int main()
{
  pthread_t thread;
  pthread_create(&thread, NULL, pthreadrun, NULL);
  int count = 0;
  while (1){
    printf("main thread is running, pid is %d, thread id is %p,count is %d\n", getpid(), pthread_self(),count);
    sleep(1);
    if (++count == 3){
      pthread_cancel(thread);
      printf("new thread is canceled...\n");
    }
  }
  return 0;
}

執行結果如下:

主執行緒把子執行緒謀殺了,只能取消同一個程序中的執行緒,還可以根據count的值看出,每個執行緒有自己獨立的PCB,在PCB中存在自己的棧區。

執行緒等待

執行緒等待的原因:

  • 已經退出的執行緒,其空間沒有被釋放,仍然在程序的地址空間內。
  • 建立新的執行緒不會複用剛才退出執行緒的地址空間。
int pthread_join(pthread_t thread, void **retval);
功能:
    等待執行緒結束(此函數會阻塞),並回收執行緒資源,類似於程序的wait()函數。如果執行緒已經結束,那麼該函數會立刻返回。
引數:
    thread:被等待的執行緒號
    retval:用來儲存執行緒退出狀態的指標的地址
返回值:
     成功:0
     失敗:非0
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
long retval = 10;
void* pthreadrun(void* arg)
{
  int count = 0;
  while (1){
    printf(" new threaad is running, pid is %d, thread id is %p\n", getpid(), pthread_self());
    sleep(1);
    if (++count == 3){
      pthread_exit((void*)retval);
    }
  }
}
int main()
{
  pthread_t thread;
  pthread_create(&thread, NULL, pthreadrun, NULL);
  
  printf("main thread is waiting new thread\n");
  void* ret = NULL;
  pthread_join(thread, &ret);
  printf("new thread has exited, exit code is %ld\n", (long)ret);
  return 0;
}

執行結果如下:

pthread_join函數會阻塞主執行緒,只有等待執行緒執行完畢執行緒處理常式之後,才會繼續執行主程序。

總結:

  • 如果thread執行緒通過return返回,retval所指向的單元裡存放的是thread執行緒函數的返回值。
  • 如果thread執行緒被別的執行緒呼叫pthread_ cancel異常終掉,retval所指向的單元裡存放的是常數PTHREAD_CANCELED(-1)。
  • 如果thread執行緒是自己呼叫pthread_exit終止的,retval所指向的單元存放的是傳給pthread_exit的引數。
  • 如果對thread執行緒的終止狀態不感興趣,可以傳NULL給retval引數。

執行緒分離

為了解決執行緒阻塞的問題,提出了執行緒分離,防止因為阻塞而造成的資源浪費。

  • 一般情況下,執行緒終止後,其終止狀態會一直保留到其他執行緒呼叫pthread_join獲取它的狀態為止。但是執行緒也可以被設定成detach狀態,這樣的執行緒一旦中止就立刻回收它佔有的所有資源,而不保留終止狀態。
  • 不能對一個已經處於detach狀態的執行緒呼叫pthread_join,這樣的呼叫將返回EINVAL錯誤。也就是說,如果已經對一個執行緒呼叫了pthread_detach就不能再呼叫pthread_join了。
int pthread_detach(pthread_t thread);
功能:
	使呼叫執行緒與當前程序分離,分離後不代表不依賴當前執行緒,執行緒分離的目的是將資源回收的工作交給系統來處理,也就說當被分離的執行緒結束之後,系統將自動回收它的資源,所以此函數不會阻塞,由核心自動完成執行緒資源的回收,不再阻塞
引數:
	thread:執行緒號
返回值:
	成功:0
	失敗:非0