目录
一、POSIX线程库
二、线程ID
三、动态库加载
四、再谈线程ID
一、POSIX线程库
原生库:指的是操作系统自带的库,如POSIX线程库,在类Unix系统中通常是原生支持的。这些库是操作系统的一部分,提供了系统级的线程管理功能。
【了解】兼容性和标准化
POSIX 标准:POSIX(Portable Operating System Interface)是一个由 IEEE 制定的标准,旨在提供一个一致的操作系统接口,以提高程序的可移植性。POSIX 线程库(pthreads)是 POSIX 标准的一部分,它定义了一组线程相关的 API。Linux 提供这一标准的实现,以确保与其他遵循 POSIX 标准的系统(如 UNIX 和其他类 UNIX 系统)兼容,使得在不同操作系统之间移植代码变得更加容易。
跨平台开发:许多应用程序和库依赖于 POSIX 标准,以便在多个操作系统上进行开发和运行。提供 POSIX 原生库使得这些应用程序可以更容易地移植到 Linux 平台,同时保持一致的接口和行为。
通过前面的学习我们了解到在Linux操作系统中,实际上并不存在真实的线程,Linux内核是以轻量级进程(LWP)这一机制来实现的。在Linux中,线程并不是像在一些其他操作系统中那样的独立实体。相反,线程实际上是被实现为轻量级进程(LWP)。每个线程都被内核视为一个独立的LWP。
同时,Linux操作系统也为创建线程提供了一系列的系统调用,如clone函数,供操作者来控制线程。我们对此仅作了解。
clone
系统调用的基本原型int clone(unsigned long flags, void *child_stack, int newtls, int *parent_tid, int *child_tid);
使用场景:
进程创建:通过设置适当的
flags
,clone
可以用来创建一个新的进程(如通过CLONE_VM
标志共享内存空间)。线程创建:通过设置合适的标志(如
CLONE_VM
、CLONE_FS
、CLONE_FILES
和CLONE_SIGHAND
),clone
可以用来创建线程,这些线程与父进程共享大部分资源。
我们知道,不同的操作系统中,实现线程的方式并不一致,这就造成了在Linux系统中使用系统调用编写的多线程程序在其他操作系统中可能并不适用。同时,通过clone函数我们可以发现,此类系统调用函数的使用较为复杂,在使用时需要程序员自身为线程的手动分配栈等内存空间并进行管理,这无疑加大了编程的复杂度。而POSIX库的出现恰好解决了这一系列的问题。
简化开发:POSIX 线程库提供了一个一致的编程接口,封装了线程创建、管理、同步等操作。这使得开发者可以使用统一的 API 来处理线程相关任务,而不必依赖于特定操作系统的特性或接口。
功能丰富:POSIX 线程库包含了多种功能,如线程创建、线程同步(互斥锁、条件变量)、线程局部存储等。这些功能的标准化和一致性使得编写多线程程序变得更加高效和可靠。
直接支持:Linux 内核提供了对 POSIX 线程库的原生支持,通过
clone
系统调用实现线程的创建和管理。这样,线程操作可以直接由内核处理,减少了额外的抽象层,从而提高了性能。- 内核和用户空间的分离:Linux 的设计哲学是将内核和用户空间的功能分开,POSIX 线程库为用户空间提供了一套清晰的线程管理接口,而内核通过
clone
等系统调用来实现这些功能。这样的设计使得系统的功能划分更加明确,易于维护和扩展。
简而言之,POSIX线程库封装了一系列创建线程的系统调用,并为用户提供了更为简洁的编程接口,隐藏了诸如栈管理和线程资源的共享问题等底层细节,同时增加了程序的可移植性,减少了多线程编程的复杂度。
二、线程ID
在进程章节的学习中,我们知道每个进程都有一个自己唯一的PCB(在Linux系统中是task_struct),也就是进程控制块。而操作系统为了便于对进程进行唯一标识,则为每个PCB分配了一个进程描述符——PID,也就是进程ID,存储在每个进程对应的PCB中。我们可以使用ps -ajx命令组合来查看系统中正在运行的进程。
那既然进程作为一个独立的单位,拥有自身的唯一标识。那么线程作为分派和调度的独立单位,是否也一样拥有自身唯一的标识符呢?答案是肯定的。
在Linux操作系统中,线程实际上是“轻量级进程”,依然属于进程的范畴,task_struct
是Linux内核中用于表示和管理每个进程(包括线程)的核心数据结构。每个进程和线程都有一个task_struct
实例,它包含了进程或线程的所有必要信息。
虽说线程实际上是轻量级进程,但是线程存在于进程的地址空间之中,他无法拥有独立的进程ID,而是拥有独立的线程ID。而线程ID在Linux内核和POSIX库中的表示又是各不相同的。这也正对应了Linux的核心思想:内核态与用户态的分离。
-
内核态:在内核态中,线程ID和进程ID的管理是由内核直接处理的。内核使用线程ID来跟踪和调度线程,同时为每个轻量级进程(线程)分配一个唯一的ID。这个ID在内核内部是唯一的,并用于各种内部操作,如上下文切换和调度。
-
用户态:在用户态中,POSIX线程库(pthread库)提供了一种线程的抽象,允许程序员在应用层进行线程管理。POSIX线程库中的线程ID(如
pthread_t
类型)与内核中的线程ID不同。POSIX线程ID是一个用户级的抽象,用于简化线程的管理和操作。
接下来,我们先见一见线程:
【注意:1、要使用这些函数库,要通过引入头文<pthread.h>; 2、链接这些线程函数库时要使用编译器命令的“-lpthread”选项】
pthread_create
函数是POSIX线程(pthreads)库中的一个核心函数,用于在用户空间创建新的线程。这个函数的主要功能是启动一个新线程,使其并行执行指定的函数。函数原型
#include <pthread.h> int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
参数说明
指向
pthread_t *thread
:pthread_t
类型的指针,用于存储新创建线程的ID。pthread_t
是一个线程标识符类型,用于在后续操作中引用该线程。指向
const pthread_attr_t *attr
:pthread_attr_t
类型的指针,指定线程的属性。可以为NULL
,这时线程将使用默认的属性。如果需要自定义线程的属性,可以通过pthread_attr_init
函数初始化pthread_attr_t
结构体,并设置相关属性。指向函数的指针,这个函数将在线程中执行。该函数的参数是一个
void *(*start_routine)(void*)
:void*
类型的指针,返回值也是void*
类型。start_routine
函数是线程的入口点。传递给
void *arg
:start_routine
函数的参数。arg
是一个void*
类型的指针,可以传递任意类型的数据。在线程开始执行时,arg
将被作为参数传递给start_routine
函数。返回值
- 成功:返回0。
- 失败:返回一个错误码,指示失败的原因。
- pthread_ create 函数会产生一个线程 ID, 存放在第一个参数指向的地址中。
- 前面讲的线程 ID 属于进程调度的范畴。 因为线程是轻量级进程, 是操作系统
调度器的最小单位, 所以需要一个数值来唯一表示该线程。- pthread_ create 函数第一个参数指向一个虚拟内存单元, 该内存单元的地址即为新创建线程的线程 ID, 属于 NPTL 线程库的范畴。 线程库的后续操作,就是根据该线程 ID 来操作线程的。
- 线程库 NPTL 提供了 pthread_ self 函数, 可以获得线程自身的 ID:
#include <pthread.h> pthread_t pthread_self(void);
- 返回值:返回一个
pthread_t
类型的线程标识符,表示当前调用线程的唯一标识符。- pthread_t 到底是什么类型呢? 取决于实现。 对于 Linux 目前实现的 NPTL 实现而言, pthread_t 类型的线程 ID, 本质就是一个进程地址空间上的一个地址。
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
// 线程函数
void *thread_function(void *arg)
{
printf("%s Process ID is: %d\n", (const char *)arg, getpid());
printf("%s Thread ID is: %ld\n", (const char *)arg, pthread_self());
sleep(5);
pthread_exit(NULL); // 结束线程
}
int main()
{
pthread_t thread;
const char *arg = "Thread -1";
// 创建线程
int ret = pthread_create(&thread, NULL, thread_function, (void *)arg);
if (ret != 0)
{
fprintf(stderr, "Error creating thread: %d\n", ret);
exit(EXIT_FAILURE);
}
printf("My Process ID is: %d\n", getpid());
printf("Main Thread ID is: %ld\n", pthread_self());
// 等待线程结束,回收线程资源
pthread_join(thread, NULL);
printf("Thread has finished.\n");
return 0;
}
在上述程序中,我们创建了一个线程,让该线程打印出该线程所属的进程和它的线程ID。在主线程中,也打印出主线程所属的进程和线程ID。在程序执行的过程中,我们可以通过如下命令来观察线程的状态:
ps -aL //获得系统中的所有进程和线程的详细列表
我们能够观察到,这两个线程同属于一个进程之中,并且有一个线程(轻量级进程)的ID与进程的ID相同,这就是我们所说的“主线程”,主线程并不需要我们手动创建,当一个进程启动时,操作系统会自动创建一个线程,这个线程被称为“主线程”或“初始线程”。而ID为812435的线程则是我们通过pthread_create系统调用所创建的额外线程。
但同时,我们也印证了之前的说法,程序中所打印出的线程ID与内核中的线程ID并不相同。这是因为Linux中的线程是用户级线程,由POSIX库对其进行管理,所以我们在程序中所打印出来的线程ID实际是库给我们所分配的线程唯一标识。实际上,用户级线程ID是对线程库管理的一个抽象,而内核中的线程ID(LWP)则是真正用于系统调度和管理的标识。
三、动态库加载
在使用动态库(也称为共享库)时,动态库的加载和方法解析涉及到几个重要的步骤:
1. 动态库的加载
动态库的加载通常在程序运行时发生,这一过程包括以下几个步骤:
延迟加载(Lazy Loading):动态库通常是在程序运行时根据需要进行加载的。这意味着只有在程序首次调用动态库中的函数时,动态库才会被实际加载到内存中。在 Linux 系统中,动态库加载通常是由
dlopen
函数完成的,该函数会将指定的动态库加载到进程的地址空间中。立即加载(Eager Loading):某些情况下,动态库可能在程序启动时就被加载。这通常由编译器在程序启动时通过运行时链接器(如
ld-linux.so
)自动处理,或者由程序员在链接时指定。此时,动态库在程序启动时就已经被映射到内存中。2. 页表映射
内存映射:在动态库被加载到内存中时,操作系统会将动态库的内容映射到进程的虚拟地址空间。这是通过操作系统的虚拟内存管理机制实现的。操作系统将动态库的文件映射到进程的虚拟地址空间中,并更新进程的页表以确保对动态库内存的访问是有效的。
动态链接:动态库的加载和页表映射涉及到动态链接过程。在 Linux 上,这个过程由动态链接器
ld.so
完成。它负责将动态库的符号(即库中的函数和变量)解析到实际的内存地址。3. 方法的解析
符号解析:当程序调用动态库中的函数时,程序需要知道这些函数在内存中的实际地址。动态链接器会解析动态库中的符号,找出每个符号(如函数或变量)在内存中的地址。这些符号信息通常保存在动态库的符号表中。
符号表:动态库的符号表包括所有导出的函数和变量的信息。动态链接器会在动态库加载时处理这些符号表,并将它们与程序中对应的引用进行匹配。程序中的每个符号引用(例如,函数调用)会被替换为实际的内存地址。
重定位:动态链接器会更新程序的内部结构(如跳转表或函数指针)以使用动态库中函数的实际地址。这使得程序可以在运行时正确地调用动态库中的函数。
也就是说,在我们使用pthread_create函数创建线程之前,排除其他情况,这时在该进程的虚拟地址空间中并不包含线程库,它依然暂存在磁盘中。该程序启动时,程序的代码和数据从磁盘中加载进内存当中,映射到操作系统为该进程所创建的虚拟地址空间当中。当使用pthread_create方法时,此时该进程的地址空间中并没有pthread库。所以此时pthread库将会从磁盘加载至内存当中,并通过页表与进程的虚拟地址空间建立映射。因为函数是有地址的,所以在该进程的地址空间内,可以成功找到pthread_create函数所处的位置,进而执行该方法。
四、再谈线程ID
POSIX库是用什么手段来确保线程ID的唯一性的呢?POSIX库所生成的线程ID本质就是一个进程地址空间上的一个虚拟地址!而地址本身就具有唯一性!!!
实际上,pthread库中维护着用户级线程的基本属性。每个线程的属性集合(即pthread_attr_t
结构体)在地址空间中占用一块内存空间。而用户级线程ID实际上是pthread_attr_t
结构体的起始地址。因此,用户级线程ID也确保了它的唯一性!此后,当我们想要获取线程内部的属性时,只需要拿到该线程的TID—即线程控制块的起始地址,即可对线程控制块的内部属性进行访问!