Linux下 线程概念与控制(三)用户级线程地址空间布局与平台兼容问题

欢迎来到我的频道 【点击跳转专栏】
码云链接 【点此转跳】
请添加图片描述

1. 线程ID及地址空间布局

结论1: pthread库也是库,要被映射到当前进程的虚拟地址空间以支持线程控制!!!

结论2:线程ID(pthread_t)其实是一个地址!

1.1 TCB与线程库

线程(库中的真线程,不是内核的轻量级进程,下面的线程也是这个意思)可以有多个!我们可以创建、终止、等待甚至分离线程,说明线程这个概念其实是真实存在的! 所以线程本身也要被管理!

换句话说 Linux内核中的轻量级进程只能属于线程基本属性的一部分! 所以在我们对应的PCB中,只是关于执行流调度执行的相关信息,但是线程的相关属性并没有在PCB中!

必须要有个 描述线程的结构体!!这个结构体被我们维护在了pthread库中!

那么如何 组织呢?
在这里插入图片描述
当进程运行时 线程库已经映射到了共享区了 我们每创建一个线程,库中就会为我们 在共享区中创建对应的TCB(struct pthread)线程局部存储线程栈这样的属性集!即 我每创建一个线程 就创建这么一坨:
在这里插入图片描述
而为了让我们上层用户能快速查到对应的属性!而其起始地址 我们就把它称为tid!,即我们拿到的那个 pthread_t这个类型的参数!

有了这个tid,我们就能用类似数组方式将其组织管理起来!!

⚠️:进程代码区加载线程库时,其首地址被加载在进程虚拟地址空间的共享区!需要注意的是,虽然子线程的私有资源也位于共享区,但它们与线程库本身的加载地址是相互独立的!即 线程库的首地址是共享区中一个固定的映射位置,而子线程的 属性首地址则是共享区中动态分配的内存地址,两者虽然都在共享区,但用途和分配方式完全不同!!

而这些 创建的对应属性 也可以说是创建在线程库内部,由该库统一管理(类比成:PCB创建在内核中,由内核统一管理 内核 类比 线程库!)中!由所有进程共享!通过页表在库中找到属于自己的TCB!!!
在这里插入图片描述

补充: 我们知道,和加载普通程序以及OS内核一样,动态库加载到内存时,在虚拟内存中是连续的,但在物理内存中不需要连续,所以TCB在线程在库中,和PCB在内核中一样 有“逻辑”上在里面的感觉(自己体会体会)


1.2 源码阅读,理解线程的创建过程!!

以下是 glibc-2.4 中 pthread 源码相关内容 路径nptl/pthread_create.c(pthread_create的底层)(截一些重点)

1. 线程属性

关于线程属性:
在这里插入图片描述

struct pthread_attr
{
    /* 调度器参数和优先级 */
    struct sched_param schedparam;
    /* 调度策略 */
    int schedpolicy;
    /* 各种标志位,如分离状态(detachstate)、作用域(scope)等 */
    int flags;
    /* 栈溢出保护区(Guard Area)的大小 */
    size_t guardsize;
    /* 栈的处理(栈地址) */
    void *stackaddr;
    /* 栈的大小 */
    size_t stacksize;
    /* CPU 亲和性掩码 */
    cpu_set_t *cpuset;
    /* CPU 亲和性掩码的大小 */
    size_t cpusetsize;
};

上面属性 我们主要需要关注的就是 栈大小和栈的地址!!

2. struct phread

在这里插入图片描述
这一步就是创建 struct pthread!不过其具体的内存和里面的值 是交给ALLOCATE_STACK完成的 类似int * a=NULL;

线程控制块的一部分代码(TCB):
在这里插入图片描述
里面有个这个属性:
在这里插入图片描述
分别表示了 TCB可以通过链表连接,里面的 pid_t tid就是我们当时ps -aL看到的LWP!!


在这里插入图片描述
这个属性表示 当前创建的线程的栈 是否由用户提供!
这是因为 主线程的栈是创建在栈区 而其他新线程的栈是其实是创建在哦共享区的!


下面就是用于保存 线程运行完毕后的返回值(void*)
在这里插入图片描述


下面的参数 就是用户调用pthread_create往里面传递的函数指针和参数!!!
在这里插入图片描述


具体来说,TCB 中维护栈信息的核心字段包括:

  1. stackblock (或 stack_base):记录线程栈的起始虚拟地址(低地址)。
  2. stackblock_size (或 stack_size):记录线程栈的总大小(默认通常为 8MB)。
  3. guardsize (或 guard_page):记录栈底防溢出保护区(Guard Page)的大小或地址。

注意:TCB是存在新线程的栈中的 最高地址(栈底)


3. ALLOCATE_STACK

在这里插入图片描述

了解即可: int err = ALLOCATE_STACK(iattr, &pd); 是 Linux 线程库在创建新线程时,在用户态分配线程栈和线程控制块(TCB)的核心步骤。ALLOCATE_STACK 本质上是一个宏,其底层调用了allocate_stack 函数,当这行代码执行完毕后,新线程所需的栈空间和控制块就已经在用户态准备就绪了。
完成了如下工作:
在这里插入图片描述
这里就是创建 struct pthread的结构体时,它一开始会尝试直接从缓存里找对应空间!!
原理是: 当一个线程结束运行时,glibc(“用户态”的 C 标准库,它是用户程序与内核之间的“桥梁”或“中间层”) 并不会立刻调用 munmap 把这块栈内存还给操作系统。相反,它会将这块内存从 stack_used 链表中移除,并放入 stack_cache 链表中“缓存”起来,等待下一个新线程创建时复用。
这是为了防止频繁调用系统调用!!!在这里插入图片描述
如果缓存区没有空余空间,则会调用 mmap(c库的) 系统调用,向内核申请一块虚拟地址空间
这个接口是用来 创建共享内存的(因为要在共享区申请栈空间),但是如果只给自己用不给别人共享 不相当于是进程自己的嘛~
在这里插入图片描述
然后我们就会把申请到地址的 起始地址 传给输出型参数pd,同时往strcut pthread里面填入对应的属性
在这里插入图片描述
至此 就把线程对应信息管理起来了!!!


⚠️:当线程库在用户态通过 ALLOCATE_STACK 分配好栈空间后,会立即将这块内存的详细信息写入 TCB 中!
可以这么理解 : pthread_attr 只是创建前的“临时需求单”,而 TCB 里的栈信息才是线程运行期间永久持有的“真实房产证”!即 pthread_attr是创建的时候用的!而TCB才是维护管理线程用的!

随后,线程库会继续调用 create_thread 函数,最终通过 clone 系统调用进入内核态,完成真正的线程创建!

4. 设置要执⾏的⽅法的地址和参数

这段代码就是把pthread_create创建线程后其需要执行的函数和参数的地址传到TCB中保存!!
在这里插入图片描述

5. 获得tid

pthread_create中 会有个 thread:输出型参数,返回线程ID 最后就会以这种方式返回:
在这里插入图片描述
而这个就是我们当时传入的那个&tid的参数,它会以整形的形式接收线程id 然后给我们获取!!

int __pthread_create_2_1(newthread, attr, start_routine, arg)
pthread_t *newthread;//这个!!
const pthread_attr_t *attr;
void *(*start_routine)(void *);

此时 我们用户层就能获取线程ID 从而可以与TCB建立联系了!!

6. 判断线程是否分离

在这里插入图片描述
关于is_detached是什么(这个定义在了TCB中):
在这里插入图片描述
那么joinid又是什么?(也定义在了TCB中)
在这里插入图片描述

然后这个(pd)-> joinid == (pd)又是什么意思?

意思是你自己的joinid指向了你自己! 即 如果joinid默认指向主线程 那么你就不是被分离的 如果指向你自己 那么你就是被分离的!!

7. 创建线程

最后调用create_thread 把你 填充好的线程控制信息(tid,即pd的起始地址)pd 和 属性 以及栈大小相关信息 调用进去整合( 即前面的步骤(也就是库的本职工作)都是在创建具体的线程组织管理信息【理解成正在画施工图】,到这一步才真刀真枪的通过前面给的信息【可以理解成施工图】在内存里创建线程本体(即 轻量级进程)【开始施工】!! )进行通过 clone 系统调用进入内核态,完成真正的线程创建!

请添加图片描述

8. create_thread

create_thread(struct pthread *pd, const struct pthread_attr *attr,
STACK_VARIABLES_PARMS)
{
...
int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGNAL |
CLONE_SETTLS | CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | CLONE_SYSVSEM
//这里是设置标志位 判断你是要创建一个完整的进程还是轻量级线程

//------------------------------------------------

int res = do_clone(pd, attr, clone_flags, start_thread,
STACK_VARIABLES_ARGS, 1);
//然后将pd 属性 标志位(用于判断是创建线程还是进程) 栈大小等信息传递给 do_clone 这个系统调用开始在内存中真正创建线程!!! do_clone 本质上是一个封装了 clone 系统调用的内部函数


9. do_clone

do_clone里面有个 这个 也是该函数最关键的部分:
在这里插入图片描述

它会调用 ARCH_CLONE这个宏:

#define ARCH_CLONE __clone
__clone是glibc⽤汇编封装的⼀个调⽤clone系统调⽤的函数,所以
__clone的实现就是汇编,贴⼀份代码(sysdeps/unix/sysv/linux/x86_64):
ENTRY (BP_SYM (__clone))
/* Sanity check arguments. */
movq $-EINVAL,%rax
testq %rdi,%rdi /* no NULL function pointers */
jz SYSCALL_ERROR_LABEL
testq %rsi,%rsi /* no NULL stack pointers */
jz SYSCALL_ERROR_LABEL
/* Insert the argument onto the new stack. */
subq $16,%rsi
movq %rcx,8(%rsi)
/* Save the function pointer. It will be popped off in the
child in the ebx frobbing below. */
movq %rdi,0(%rsi)
/* Do the system call. */
movq %rdx, %rdi
movq %r8, %rdx
movq %r9, %r8
movq 8(%rsp), %r10
movl $SYS_ify(clone),%eax // 获取系统调⽤号
/* End FDE now, because in the child the unwind info will be
wrong. */
cfi_endproc;
syscall // 陷⼊内核(x86_32是int 80),要求内核创建轻量级进程
testq %rax,%rax
jl SYSCALL_ERROR_LABEL
jz L(thread_start)

触发软中断 陷入内核 创建线程!!!而这个宏 就是 OS内核里的 系统调用本体!!这部分代码了解即可。

10. 总结

所以不管 pthread_create多么复杂 底层其实都是在调用clone !创建完新线程后,主线程继续向下执行,而新线程基于你传入的函数指针 执行 回调函数

在创建线程中 库的左右是创建描述和组织线程的相关结构体 而底层则是通过clone创建轻量级进程!

补充: 如果加载了线程库,进程启动的初始化阶段,主线程也会创建对应的用户级TCB,存储位置为 数据区!!

1.3 线程局部存储的理解

来一段代码

pid_t id =0;

void *routine(void* args)
{
    std::string name=static_cast<const char *>(args);
    while (1)
    {
        std::cout<< "id:"<<id<<std::endl;
        id++;
        sleep(1);
    }
    return nullptr;
}

int main()
{
    pthread_t tid;
    pthread_create(&tid,nullptr,routine,(void*)"thread-1");
    while (1)
    {
        std::cout<<"main thread id:"<<id<<std::endl;
    }
    pthread_join(tid,nullptr);

    return 0;
}

效果:请添加图片描述

我们发现id值 主、新线程都是一样的!


如果给这个id 加上如下修饰(注意是两条_):

__thread pid_t id = 0;

请添加图片描述

此时我们发现一个变 一个不变

我们把这个用__thread 修饰的值 叫做 线程局部存储!!作用就是 你定义的变量在申请新线程的时候 会在 线程局部存储那里也开辟一份 然后新线程虽然用的是同一个名字 其实早就为该值申请了一个新的虚拟地址!

证明也很容易 把虚拟地址打印看看就行:
在这里插入图片描述

细节: 这个只能存储一些整数,是没办法存储类结构体之类的!!

2. LWP的获取(加餐)

由于 glibc 标准库没有直接封装获取 LWP ID 的函数,所以需要通过系统调用来获取。
syscall 系统调用:

#include <sys/syscall.h>
#include <unistd.h>
pid_t lwp_id = syscall(SYS_gettid); // 获取当前线程的内核LWP ID

部分较新的系统或 glibc 版本可能提供了非标准的 gettid() 函数,可以直接调用,但为了代码的可移植性,建议优先使用 syscall 方式!!!

3. C++多线程(库的兼容问题)

在C语言环境下 因为不同平台的pthread库是不同的 Linux线程库的接口pthread_create就没办法在windows下跑!!

所以就衍生出了C++线程库 其完美的解决了跨平台的问题!!

这是因为 C++多线程的本质,是对不同平台pthread库的封装!!

关于 C++多线程接口的讲解 敬请期待!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值