Linux–多線程(一)

線程

線程的概念

線程: 線程是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