一、线程库
1、线程库介绍:命名与设计
-
命名:线程库通常根据其实现目的和平台特性进行命名。例如,POSIX标准定义了Pthreads(POSIX Threads),这是一个广泛使用的线程库规范,适用于多种操作系统。此外,还有像Windows下的Win32线程API,以及Java中的java.util.concurrent包等。
-
设计:线程库的设计旨在隐藏底层操作系统细节,同时提供一个统一的接口来处理线程相关任务。良好的设计应该确保高效性、可移植性和易用性。例如,Pthreads提供了丰富的API集合,包括线程创建(pthread_create)、互斥锁(pthread_mutex_lock)等功能,使开发者能够专注于应用逻辑而非线程管理。
2、线程库封装
将LWP的操作方法和属性全部封装到线程库中,创造逻辑上的线程
在 Linux
中,为了模拟实现线程,使用了 LWP
(轻量级进程)这一机制。因此,创建线程的过程实际上就是创建 LWP
的过程。然而,创建 LWP
的系统调用较为复杂,并且不能真正代表线程的概念。鉴于此,设计者们将 LWP
的创建以及相关操作封装成一系列与线程相关的操作,并将其集成到线程库中。
用户不仅需要利用线程库中的方法来创建和操作线程,有时还需要获取线程的相关属性,比如线程优先级、线程状态等信息。由于 Linux
线程的本质实际上是 LWP
,这些属性被存储于系统底层的struct lwp
结构体中。值得注意的是,struct lwp
并不能完全反映概念上的线程,同时,获取struct lwp
的属性值也需要通过特定的LWP系统调用来完成。因此,设计者们进一步将struct lwp
封装为struct TCB
(线程控制块),并放入线程库中。
这样一来,通过将LWP的操作方法和属性全部封装为用户概念上的“线程”,使得用户无需关心底层的具体实现细节,而只需知道Linux系统同样支持“真正”的线程概念。
通过将底层关于 LWP
的操作方法和属性全部封装到线程库中,用户仅需在程序中使用线程库提供的接口(如pthread_create()
、pthread_join()
等)进行线程操作,无需直接与内核或操作系统交互。这种设计让用户程序可以专注于业务逻辑,而不必担心底层的实现细节变化。即使底层的线程实现方式发生变化(例如从 LWP
切换到其他线程模型),只要线程库的接口保持不变,应用程序就不需要做任何修改。此外,这种抽象层次的提升简化了编程过程,减少了出错的可能性。更重要的是,它提高了代码的跨平台兼容性,因为 POSIX
线程库提供了一套标准接口,使得代码可以在不同平台上运行。无论是在 Linux
中通过 LWP
实现的线程,还是在其他操作系统中采用不同的线程实现方式,只要遵循 POSIX
标准,线程库就能确保相同的接口在不同平台上正常工作,从而实现了用户软件层面与系统底层的解耦合,增加了代码的可移植性。
3、线程库的功能
- 一般的库提供可调用方法和数据:
我们平时学习的静态库或动态链接库,在一般意义上可以被理解为一组预编译好的代码和数据,供开发者通过编程语言提供的接口查询并引用。这种库主要是为了提供可重用的功能和简化开发过程。
- 线程库不仅提供可调用方法和数据,还具有管理线程的功能:
然而,“线程库”不仅仅是一个普通的库,它实际上是一组操作系统或运行时环境提供的系统级服务和工具,用于管理线程的创建、调度、同步等操作。因此,线程库不仅仅是提供一些方法供程序调用,它还涉及到实际的资源管理和状态跟踪。
-
线程库可以存储和维护进程中所有线程的 TCB
-
线程管理的本质:线程库负责管理进程内的线程生命周期,这包括创建新线程、销毁线程以及在线程间进行切换。为了实现这些功能,线程库需要访问并更新每个线程的状态信息,即TCB。因此,线程库内部必然有一个机制来存储和维护这些TCB。
-
内存管理:线程库会在进程中分配特定的内存区域来存储TCB集合。这个内存区域是线程库的一部分,位于进程的地址空间中,使得同一进程中的所有线程都可以访问到这些信息。这样的设计保证了线程间的高效协作与通信。
-
系统级支持:虽然从用户角度来看,线程库可能表现为一系列API的集合,但其实现往往依赖于底层操作系统提供的功能和服务。例如,操作系统会提供系统调用来支持线程的创建和调度,而线程库则是这些低级别服务的一个高级封装,方便程序员使用。同时,这也意味着线程库可以在一定程度上利用操作系统的资源管理能力来实现对TCB的存储和维护。
-
综上所述,线程库不仅仅是简单的函数和数据集合,它还包括了对线程状态进行管理和维护的能力,这是通过直接管理内存中的TCB集合来实现的。
- 线程信息存储于
struct tcb
中,供系统和用户使用
用户使用 pthread_create
创建线程并执行线程对应的 func
函数,该函数的返回值以及线程退出的一些退出信息,其实都存在 struct tcb
中!这就是为什么 pthread_join
等函数可以直接获取线程退出信息,就是从 struct tcb
中获取的
因为进程中所有线程共享一个线程库,因此一个库内部,维护系统所有线程的 struct tcb
属性集合!
4、线程库加载的位置
线程ID:TCB的地址?
pthread_self()
函数获取的是调用该函数的线程的 线程标识符(Thread ID, TID),而不是逻辑工作组编号(LWP, Light Weight Process)。在 POSIX 线程(pthreads)的上下文中,这个线程标识符是一个类型为 pthread_t
的值,它唯一地标识了当前进程中的一个线程。
唯一性:每个线程在其所属的进程中都有一个唯一的 pthread_t
标识符。这意味着同一个进程中的两个线程不会具有相同的 pthread_t
值。
这个线程ID实际上是线程 TCB 在线程库中的地址位置!如何理解?看下文:
线程库加载于 mmap
区域
在共享区(mmap
区域)中,加载着动态库:线程库
线程库中存储着多个描述线程的属性结构体 TCB
,而 pthread_self
函数返回的线程标识符 TID
,就是线程的属性结构体在库中的存储地址,因此未来想要获取某个线程的属性,只需获取到该线程属性结构体的地址,就能获取相关属性
数组形式:在线程库中,所有的 TCB
按照数组形式存放,这样就完成了线程的再组织
创建一个线程,其实就要在该线程库中创建一个这样的结构体 TCB 用于描述线程,而 TCB 存储于线程库中被管理
二、线程栈
1、多线程使用栈易冲突
之前讲解过,因为同进程下的多线程共享该进程大部分的资源,包括进程地址空间,其中,
- 代码区:每个线程通过获取自己线程执行函数的地址,拥有了自己的一块代码区。
- 共享区:无需分割,所有线程直接共享使用一些动态库等资源。
- 堆区:每个线程在自己的执行函数里面动态开辟堆区空间,自己开辟给自己使用,因为堆区底层是为申请空间的线程创建一个
vm_area_struct
,然后划分一块堆区空间给该线程。 - 栈区:而栈区就有点特殊了,整个地址空间只有一个栈区,线程调用函数和创建局部变量等操作,都需要在栈区开辟函数栈帧空间,这样就可能会造成多个线程使用栈的冲突。
2、线程栈是开辟申请出来的
进程创建出来时,形成的第一个栈其实是主线程的栈
而调用 pthread_create
创建新线程,其实需要给新线程创建新的独立的栈
创建新的栈空间,就有点类似于堆区的动态申请开辟空间的逻辑。
因此,根据这个思路,创建新线程时,就在地址空间的栈区开辟一块栈空间给新线程,这就是新线程拥有的线程栈
再谈系统调用 clone
实际上,系统调用 fork
/ vfork
和线程库的 pthread_create
都封装了这个函数,创建新线程,为新线程分配栈空间就是从这里开始的:
3、线程局部存储 __thread
线程之间可以通过获取其他线程的栈数据的地址,通过该地址访问其他线程的栈数据,这句话说明一个道理,在同一进程地址空间中,所有线程栈之间的隔离并非绝对隔离,如果你能获取其他线程栈数据的地址,也是能够访问的,没有所谓的权限隔离层
代码引入
如下代码:定义一个全局变量 shared_var
,创建两个新线程,两个新线程都能访问到同一个全局变量 shared_var
,其中一个线程对该变量进行修改
#include <iostream>
#include <string>
#include <cstring>
#include <pthread.h>
#include <unistd.h>
int shared_var = 100;
void* run1(void* argv)
{
std::string name = static_cast<char*>(argv);
while(true)
{
printf("I am [%s], shared_var: %d, &shared_var: %p\n", name.c_str(), shared_var, &shared_var);
sleep(1);
}
}
void* run2(void* argv)
{
std::string name = static_cast<char*>(argv);
while(true)
{
printf("I am [%s], shared_var: %d, &shared_var: %p\n", name.c_str(), shared_var, &shared_var);
shared_var++;
sleep(1);
}
}
int main()
{
pthread_t tid1, tid2;
pthread_create(&tid1, nullptr, run1, (void*)"thread-1");
pthread_create(&tid2, nullptr, run2, (void*)"thread-2");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
运行结果如下:双线程访问的该变量的地址都一样,一个线程的修改也对其他线程的访问产生影响,这其实就是所谓的线程安全问题(因此这种被多线程共享访问的资源一般需要加上同步互斥的保护机制(这点之后的文章会讲解))
关键字 __thread
:线程局部存储
关键字 __thread
的使用
此时,我们在该全局变量前面加上关键字:__thread
__thread int shared_var = 100;
运行结果如下:
可以看到一个线程对该变量的修改,影响不了另一个线程,并且两个线程访问的全局变量 shared_var
似乎不是同一个了,因为地址不同了
关键字 __thread
的作用
__thread
是GCC编译器的一个扩展特性,用于声明线程局部变量。使用 __thread
修饰的变量将在每个线程中拥有独立的实例。换句话说,每个线程都有自己的一份该变量的副本,这些副本是相互隔离的,一个线程对该变量的操作不会影响其他线程中的相同变量。
程序会在不同使用该相同变量的线程栈空间内部拷贝生成一个一摸一样的,当该线程使用该变量时 就会到自己的栈空间使用该变量,并不会影响到其他线程
实际上,每个线程在其启动时就会为这些 __thread
变量分配空间,并根据需要进行初始化。因此,每个线程看到的都是完全独立的变量,即使它们在源代码中看起来像是同一个变量。
这就是线程的局部存储!
不仅限于内置类型
一个常见的误解是 __thread
只能用于内置类型。实际上,它同样适用于自定义类型,如类或结构体。无论是简单的整数还是复杂的自定义对象,都可以使用 __thread
来确保每个线程访问的是自己的独立副本。这对于需要在线程间保持状态的应用场景非常有用,例如,当每个线程需要维护自己的配置或状态信息时。
对于结构体类型:也是可以用 __thread
修饰
struct B
{
int _a = 100;
};
__thread struct B gb;
void* run1(void* argv)
{
std::string name = static_cast<char*>(argv);
while(true)
{
printf("I am [%s], gb._a: %d, &gb: %p\n", name.c_str(), gb._a, &gb);
sleep(1);
}
}
void* run2(void* argv)
{
std::string name = static_cast<char*>(argv);
while(true)
{
printf("I am [%s], gb._a: %d, &gb: %p\n", name.c_str(), gb._a, &gb);
gb._a++;
sleep(1);
}
}
int main()
{
pthread_t tid1, tid2;
pthread_create(&tid1, nullptr, run1, (void*)"thread-1");
pthread_create(&tid2, nullptr, run2, (void*)"thread-2");
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
线程安全与同步
虽然 __thread
提供了线程之间的隔离,但这并不意味着使用它的代码就是完全线程安全的。如果涉及到对 __thread
变量进行非原子操作,比如同时读写复杂对象的不同部分,仍然可能需要额外的同步机制来保证数据一致性。因此,在实际应用中,开发者需要根据具体情况设计合理的同步策略,以确保程序的正确性。
三、线程库源码的阅读:从剖析线程
1、线程库
本文我们讲解 pthread_create
在线程库的实现,而不是讲解整个线程库
对于 pthread_create
函数
函数原型
#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
类型的指针,用于存储新创建线程的标识符。这个标识符可以用于引用新创建的线程。const pthread_attr_t *attr
: 指向线程属性对象的指针。可以设置为NULL
使用默认属性。void *(*start_routine) (void *)
: 新线程开始执行的函数的名称。这个函数接受一个void*
类型的参数,并返回一个void*
类型的结果。void *arg
: 传递给start_routine
函数的参数。****
使用样例
pthread_create(&tid, NULL, func, (void*)"thread-1")
pthread_create
源码的完整展示
下面的图展示的是 原生线程库 nptl 的一部分(可以看到,线程库其实也是 glic 库的一部分!)
在下面的源码中,对于一些重要的地方我都用黄色记号标记起来了,后面我会一一讲解:点击跳转讲解部分
源码剖析
重点1:线程属性 pthread_attr
点击跳转阅读该结构的源码
阅读这里的源码,在源码程序中,会将 attr
传递给同类型的 iattr
const struct pthread_attr *iattr =(struct pthread_attr *)attr;
这个 attr
是从哪里来的?是用户使用函数 pthread_create
时,传递进来的!
用户可以传递一个实际的 attr
,也可以传递 NULL
// 传递一个实际的 attr : 需要用户自定义
struct pthread_attr attr;
pthread_create(&tid, attr, func, (void*)"thread-1");
// 传递 NULL
pthread_create(&tid, NULL, func, (void*)"thread-1");
对于源码中的程序,当用户传递的 struct pthread_attr
类型数据为 NULL 时,底层的 struct pthread_attr
字段会赋值为一个默认值:
重点2:线程TCB
传说中的原生线程库中的用来描述线程的 tcb
:结构 struct pthread
( 点击跳转阅读该 TCB 结构的源码讲解 )
因此,我们使用 pthread_create
函数创建线程,首先就给我们定义了一个线程TCB结构 struct pthread
的指针
重点3:ALLOCATE_STACK
源码中,程序向 传递一个线程属性 pthread_attr
类型的 iattr
和 一个 struct pthread
的指针类型数据 &pd
(这个就是刚刚讲解的线程TCB)
问题:而 ALLOCATE_STACK
这个有什么来头?
答:其实 ALLOCATE_STACK
可以帮我们申请栈空间,
( 点击跳转阅读对 空间申请 ALLOCATE_STACK
的完整源码和源码讲解 )
重点4:设置线程执行函数和参数
就是把线程的执行函数的地址和参数设置给 struct pthread pd
重点5:线程 TCB 的ID值是虚拟地址
这里将 newthread
设置为 pd,pd 可是我们线程TCB结构 struct pthread
的虚拟地址呀!
我们函数 pthread_self
获取的线程 ID 实际上就是一个地址!!!!
也就是线程TCB结构 struct pthread
在线程库中的 虚拟地址位置!
重点6:检查线程是否分离
这个就顾名思义了
重点7:创建线程
当前面所有工作做好了,最后我们调用函数 create_thread
创建和启动一个线程
点击跳转阅读 create_pthread 的源码和逻辑
2、线程 TCB:struct pthread
在源码中,线程TCB是一种名为 struct pthread
的结构,该结构有 200多行代码,其中比较重要需要我们认识的字段我都标注好了,某些没怎么重要的地方因篇幅限制就截掉了,如下图
看下面这张图,线程TCB结构被管理组织在 mmap 区域的 线程库 中,其中一个TCB结构主要包含着三个部分:struct pthread
、线程局部存储、线程栈
struct pthread
:这个结构实际上就是我们上面阅读的线程底层结构struct pthread
- 线程局部存储:记录一些线程内部的局部存储数据
- 线程栈:这里并不是开辟了一块空间,而是记录着一个线程栈的指针,指向其他被开辟作为线程栈的空间
3、线程属性 pthread_attr
在线程属性结构中,定义了许多重要的字段,例如线程栈的地址和栈大小:
4、空间申请 ALLOCATE_STACK
完整源码
源码讲解
这里先使用 get_cached_stack
,在线程库的缓存空间中申请空间
如果申请失败,再使用 mmap(...)
,这个会在物理内存上申请一块空间,然后将这块空间映射到线程库的存储位置中
可以简单理解 mmap(...)
类似 malloc
(可以提一嘴: mmap(...)
实际上就是 malloc
的底层实现!)
将刚刚申请的内存空间地址 mem
记录在 pd->stackblock
中, pd->stackblock
不就是前面讲解过存储在线程结构struct pthread
中,线程栈的地址指针吗
最后将 pd 赋值给 *pdp
可以看到 pd
是函数内部设置的局部变量,pdp 是传递进来的一个 struct pthread **
类型的输出型参数!
再看回该函数的调用处:
这里 &pd
就是函数参数 struct pthread **pdp
,通过这个函数,将一个 struct pthread
类型的数据设置完成
5、创建线程 create_pthread
完整源码
源码讲解
调用 do_clone
函数,如下:
6、函数 do_clone
完整源码
源码讲解
执行特性体系结构下的 clone 函数,这个 ARCH
的意思是体系结构,这个不应该,我们继续跳转看一下 ARCH_CLONE
是何方神圣:
有没有发现,从头到尾,从 struct pthread* pd
的创建,到空间开辟,到各种属性完善,到传递到 ARCH_CLONE
函数,最后传入系统底层的系统调用 clone
,都是以 struct pthread
的指针形式传入,这个意思是至始至终我们都在使用和修改一个 struct pthread
线程TCB结构,这样做的好处是:线程的状态,空间申请情况…. 的实时状态可以被更新,我们用户上层可以获取该线程 TCB结构,进而了解一个线程的各种属性!!
7、体系结构下的 clone 函数 ARCH_CLONE
完整源码
源码讲解
认真的看一下该函数下的这些汇编指令:
这里第 27 行的指令是获取 系统调用clone
的系统调用号
第 32 行的指令 syscall
,使我们陷入内核!!!!
一切都连起来了,这个就是一种软件中断,通过调用系统调用,通过固定的软中断号 syscall
触发中断,陷入内核中执行中断例程,执行对应的系统调用!!!
四、再谈线程栈
虽然 Linux 将线程和进程不加区分的统一到了 task_struct
,但是对待其地址空间的 stack
还是有些区别的。
-
对于 Linux 进程或者说主线程,简单理解就是 main 函数的栈空间,在 fork 的时候,实际上就是复制了父亲的
stack
空间地址,然后写时拷贝 (cow) 以及动态增长。如果扩充超出该上限则栈溢出会报段错误(发送段错误信号给该进程)。进程栈是唯一可以访问未映射页而不一定会发生段错误——超出扩充上限才报。 -
然而对于主线程生成的子线程而言,其
stack
将不再是向下生长的,而是事先固定下来的。线程栈一般是调用glibc/uclibc
的pthread
接口pthread_create
创建的线程,在文件映射区(或称之为共享区)。其中使用mmap
统调用,这个可以从 glibc 的nptl/allocatestack.c
中的allocate_stack
函数中看到:
此调用中的 size
参数的获取很是复杂,你可以手工传入 stack 的大小,也可以使用默认的,**一般而言就是默认的 8M。**这些都不重要,重要的是,这种 stack 不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。在 glibc 中通过 mmap 得到了 stack 之后,底层将调用 sys_clone
系统调用:
因此,对于子线程的 stack
,它其实是在进程的地址空间中 map 出来的一块内存区域,原则上是线程私有的,但是同一个进程的所有线程生成的时候,是会浅拷贝生成者的 task_struct
的很多字段,如果愿意,其它线程也还是可以访问到的,于是一定要注意。
因为只要你能获取我栈数据的地址,你就能访问我的“私有”栈.