Linux--多线程(一)

时间:2022-10-29 13:08:44

线程的概念

线程: 线程是OS能够进行运算调度的基本单位。线程是一个进程中的一个单一执行流,通俗地说,一个程序里的一个执行路线就叫做线程。

可以知道的是,一个进程至少有一个执行线程,这个线程就是主执行流。一个进程的多个执行流是共享进程地址空间内的资源,也就是说进程的资源被合理分配给了每一个执行流,这些样就形成了线程执行流。所以说线程在进程内部运行,本质是在进程地址空间内运行。
需要注意的是,Linux下没有真正意义上的线程,线程是通过进程来模拟实现的。这句话如何理解?

Linux系统下,没有专门为线程设计相关的数据结构。那线程又是如何被创建的呢?我们知道,创建一个进程,我们需要为它创建相关的数据结构,如:PCB(task_struct)、mm_sturct、页表和file_struct等。线程的创建和进程的创建是一样的,线程也是创建一个一个的PCB,因为线程是共享进程地址空间的,所以这些线程都维护同一个进程地址空间。

这样可以看出一个线程就是一个执行流,每一个线程有一个task_struct的结构体,和进程一样,这些task_struct都是由OS进行调度。可以看出在CPU看来,进程和线程是没有区别的,所以说Linux下的线程是通过进程模拟实现的。

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--多线程(一)

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;
}

运行结果如下:

Linux--多线程(一)

线程在创建过程中不会阻塞,主进程会立刻执行,那么存在一个问题,主进程如果执行完毕,那么所有线程都将被释放,就可能出现线程还未调度的问题。(后面会解决)

线程和进程有区别,父子进程执行的代码段是一样的,但是线程被创建之后执行的是线程处理函数。

再介绍一个函数:

就像每个进程都有一个进程号一样,每个线程也有一个线程号。进程号再整个系统中是唯一的,但是线程号不同,线程号只在它所属的进程环境中有效。

进程号用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;
}

运行结果如下:

Linux--多线程(一)

实例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;
}

运行结果如下:

Linux--多线程(一)

可以看到六个线程的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,而该线程即为线程组的主线程。
Linux--多线程(一)

注意: 线程和进程不一样,进程有父进程的概念,但是在线程组中,所有的线程都是对等关系。

线程ID和进程地址空间布局

pthread_create产生的线程ID和gettid获得的id不是一回事。后者属于进程调度范畴,用来标识轻量级进程。前者的线程id是一个地址,指向的是一个虚拟内存单元,这个地址就是线程的ID。属于线程库的范畴,线程库后序对线程操作使用的就是这个ID。对于目前实现的NPTL而言,pthread_t的类型是线程ID,本质是进程地址空间的一个地址:

Linux--多线程(一)

这里的每一个线程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;
}

运行结果如下:

Linux--多线程(一)

主线程把子线程谋杀了,只能取消同一个进程中的线程,还可以根据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;
}

运行结果如下:

Linux--多线程(一)

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