【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(上)https://developer.aliyun.com/article/1515712?spm=a2c6h.13148508.setting.30.11104f0e63xoTy
4、进程 ID 和线程 ID
- 在 Linux 中,目前的线程实现是 Native POSIX Thread Libaray,简称 NPTL。在这种实现下,线程又被称为轻量级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(task_struct 结构体)。
- 多线程的进程,又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符( task_struct)与之对应。进程描述符结构体中的 pid ,表面上看对应的是进程 ID ,其实不然,它对应的是线程 ID;进程描述符中的 tgid ,含义是 Thread Group ID, 该值对应的是用户层面的进程 ID。
现在介绍的线程 ID ,不同于 pthread_t 类型的线程 ID ,和进程 ID 一样,线程 ID 是 pid_t 类型的变量,而且是用来唯一标识线程的一个整型变量。
ps 命令中的 -L 选项,会显示如下信息:
- LWP:线程 ID,即 gettid() 系统调用的返回值。
- NLWP:线程组内线程的个数。
Linux 提供了 gettid 系统调用来返回其线程 ID,可是 glibc 并没有将该系统调用封装起来,在开放接口来共程序员使用。如果确实需要获得线程 ID,可以采用如下方法:
#include <sys/syscall.h> pid_t tid; tid = syscall(SYS_gettid);
从上面可以看出,a.out 进程的 ID 为 28543,下面有一个线程的 ID 也 是28543,这不是巧合。线程组内的第一个线程,在用户态被称为主线程(main thread),在内核中被称为 group leader,内核在创建第一个线程时,会将线程组的 ID 的值设置成第一个线程的线程 ID,group_leader 指针则指向自身,既主线程的进程描述符。所以,线程组内存在一个线程 ID 等于进程 ID,而该线程即为线程组的主线程。
// 线程组ID等于线程ID,group_leader指向自身 p->tgid = p->pid; p->group_leader = p; INIT_LIST_HEAD(&p->thread_group);
至于线程组其他线程的 ID 则有内核负责分配,其线程组 ID 总是和主线程的线程组 ID 一致,无论是主线程直接创建线程,还是创建出来的线程再次创建线程,都是这样。
if (clone_flags & CLONE_THREAD) p->tgid = current->tgid; if (clone_flags & CLONE_THREAD) { P->group_lead = current->group_leader; list_add_tail_rcu(&p->thread_group, &p->group_leader->thread_group); }
强调 :线程和进程不一样,进程有父进程的概念,但在线程组里面,所有的线程都是对等关系。
5、线程ID及进程地址空间布局和对原生线程库的理解
pthread_t 到底是什么类型呢?
取决于实现,对于 Linux 目前实现的 NPTL 实现而言, pthread_t 类型的线程 ID ,本质就是一个进程地址空间上的一个地址。
- Linux OS 没有真正意义上的线程,而是用进程 PCB 模拟的,这就叫作轻量级进程。其本身没有提供类似线程创建、终止、等待、分离等相关 System Call 接口,但是会提供轻量级进程的接口,如 clone。所以为了更好的适配,系统基于轻量级进程的接口,模拟封装了一个用户层的原生线程库 pthread。这样,系统通过 PCB 来进行管理,用户层也得知道线程 ID、状态、优先级等其它属性用来进行用户级线程管理。
- pthread_create 函数会产生一个线程 ID,存放在第一个参数指向的地址中,该线程 ID 和前面说的线程 ID LWP 不是一回事。前面讲的线程 ID 属于进程调度的范畴,因为线程是轻量级进程,是 OS 调度器的最小单位,所以需要一个数值来唯一表示该线程。pthread_create 函数的第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程 ID,属于 NPTL 线程库的范畴,线程库的后续操作,就是根据该线程 ID 来操作线程。
- 原生线程库是一个库,它在磁盘上就是一个 libpthread.so 文件,运行时加载到内存,然后将这个库映射到共享区,此时这个库就可以被所有线程执行流看到了。此时有两个 ID 概念,一个是在命令行上看到的 LWP,一个是在用户层上看到的 tid。前者是在系统层面上供 OS 调度的,后者是 pthread_create 获得的线程 ID,它是一个用户层概念,本质是一个地址,就是 pthread 库中某一个起始位置,也就是对应到共享区中的某一个位置。所以线程数据的维护全都是在 pthread 线程库中去维护的,上图所示,其中会包含每个线程的局部数据,struct pthread 就是描述线程的 TCB,线程局部存储可以理解是不会在线程栈上保存的数据,我们在上面说过线程会产生各种各样的中间数据,如上下文数据,此时就需要独立的栈去保存,它就是线程栈。而下图中拿到的 tid 就是线程在共享区中线程库内的相关属性的起始地址,所以只要拿到了用户层的 tid,就可以在库中找到线程相关的属性数据,很明显 tid 和 LWP 是 1 : 1 的,而主线程不使用库中的栈结构,直接使用地址空间中的栈区,称为主线程线。
实际上在很多 OS 在设计线程时都是用户级线程,用户级线程就是把相关的属性数据放在用户层,真正的调度还是得由一个相关的执行流来处理的,这叫做 1 : 1,这是 Linux 所采用的。当然在用户层只有一个执行流,但 OS 为了完成你的这个任务,可能会在内核层创建多个执行流去做的,这就叫 1 : N。用户级线程是怎么和内核级线程关联的呢,可以简单的理解成用户级线程只要把代码交给内核级线程代码就可以跑了,创建用户级线程就是创建 LWP,退出用户级线程就是退出 LWP,再把库中的相关数据关掉,只要在用户层的操作可以和内核层对应起来就行了,就像白帮中一名警察派了一个卧底潜伏于黑帮,然后警察指派任务给卧底,警察是可以控制卧底的,警察就是用户级线程,卧底就是内核级线程,它们的关系是 1 : 1 的,内核级进程是与系统是强相关的,如果让用户直接去用它倒也可以,不过用户就要去了解它,成本较高,所以就需要存在用户级进程,让用户更好的使用,同时警察可以站在他的角度向老百姓解释的很清楚,而卧底站在他的角度就解释不清楚。所以 Linux 中要有原生线程库的原因是 Linux 本身没有提供真正意义上的线程,自然也就没有真正意义上的线程控制接口,只能是轻量级进程来模拟,而用户要操作轻量级进程,就得向用户解释更多东西,不是所有人都能理解这种现象的,而用户作为一个东西被偷的人,只需要你把东西找回来就行了,也就是用户仅仅需要知道怎么操作线程就行了。所以需要存在一个用户级线程库才供用户使用,就如同这个世界不是只有老百姓和恶人,而需要有一个警察的角色。
6、线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数 return,这种方法对主线程不适用,从 main 函数 return 相当于调用 exit。
- 线程可以调用 pthread_ exit 终止自己。
- 一个线程可以调用 pthread_ cancel 终止同一进程中的另一个线程。
(1)pthread_exit 函数
A. 接口介绍
线程终止。
retval:用于传递线程的退出状态,在主线程中,pthread_join() 可以等待新线程结束,并将新线程的退出状态存储在 tret 指针。
注意 : pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的, 不能在线程函数的栈上分配, 因为当其它线程得到这个返回指针时线程函数已经退出了。
B. 代码
(2)pthread_cancel 函数
A. 接口介绍
取消一个执行中的线程。
B. 代码
__thread: 修饰全局变量,结果就是让每一个线程各自拥有一个全局变量 —— 线程的局部存储。
7、线程等待
为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
创建新的线程不会复用刚才退出线程的地址空间。
(1)接口介绍
- thread:线程 ID。
- value_ptr:它指向一个指针,后者指向线程的返回值。
调用该函数的线程将挂起等待, 直到 id 为 thread 的线程终止。 thread 线程以不同的方法终止 , 通过 pthread_join 得到的终止状态是不同的,总结如下:
- 如果 thread 线程通过 return 返回, value_ ptr 所指向的单元里存放的是 thread 线程函数的返回值。
- 如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉, value_ ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED。
- 如果 thread 线程是自己调用 pthread_exit 终止的, value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
- 如果对 thread 线程的终止状态不感兴趣, 可以传 NULL 给 value_ ptr 参数。
跟进程一样,一般线程终止后必须进行等待,由 main thread 进行等待。因为要防止内存泄漏,资源浪费(通过线程等待,将曾经线程向进程在地址空间中申请的资源释放,在线程这里一般是说如果不释放相关线程,那么申请新线程时不会复用未释放的线程)。保证主线程最后退出,让新线程正常结束。获得新线程的退出码信息(而 pthread_create 时调用 start_routine 新线程传入的参数是 void* 且返回的类型是 void*,这样它就是一个通用接口,也意味着新线程可以返回任意类型的数据,此时 pthread_join 时,就会被 retval 拿到)。实际在底层就是 pthread_create 调用 start_routine 后,start_routine 将线程退出码写到对应 PCB 中,然后调用 pthread_join 时就可以从对应 PCB 中读取退出结果到 retval。
我们都知道不可能通过 fun 函数来把 10 拿出去的原因是因为它是值传递,而应该地址传递。同样,如果想在一个函数内部返回一个 void* 的值也很简单。pthread_create 中 start_routine 参数和返回值类型是 void*,它是要支持通用接口,而 pthread_join 中 retval 的类型是 void**,然后 pthread_join 会通过你传入的线程 id,去读取对应的 PCB 中的退出码信息,因为退出码信息可能是不同类型的地址,所以要用 void** 来接收,retval 是输出型参数,然后又由它返回 void* 到用户层。
四、分离进程
- 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join 是一种负担,这个时候我们可以告诉系统,当线程退出时,自动释放线程资源。
1、pthread_detach
(1)接口介绍
用于创建线程,它是一个回调函数。如果线程创建成功,则会执行 start_routine,编译和链接时需要引入 pthread。
- thread:输出型参数,代表线程 id。
- 默认情况下,新创建的线程是 joinable 的,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成内存泄漏。
- 如果不关心线程的返回值,join 则是一种负担,这个时候,可以使用分离,此时就告诉系统,当线程退出时,自动释放线程资源,这就是线程分离的本质。
- joinable 和 pthread_detach 是冲突的,也就是说默认情况下,新创建的线程是不用 pthread_detach。
- 就算线程被分离了,也还是会和其它线程影响的,因为它们共享同一块地址空间。
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:
joinable 和分离是冲突的,一个线程不能既是 joinable 又是分离的。
注意:没有线程替换这种操作,但可以在线程中执行进程替换系列函数。这是因为新线程内部执行进程替换函数,这看起来像是把新线程中的代码替换了,但实际会把主线程中的代码也替换了,因为主线程和新线程共享地址空间,所以新线程内部进程替换后,所有的线程包括主线程都会被影响。所以轻易不要在多线程中执行进程替换函数。
pthread_join 返回的是 22,说明等待失败了,然后返回,进程终止。其实一个线程被设置为分离状态,则该线程不应该被等待,如果被等待了,结果是未定义的,至少一定会等待出错。
【Linux 系统】多线程(线程控制、线程互斥与同步、互斥量与条件变量)-- 详解(下)https://developer.aliyun.com/article/1515720?spm=a2c6h.13148508.setting.28.11104f0e63xoTy