目录
1. Linux线程概念
1.1 什么是线程
1.1.1 第一次理解
进程 = 内核数据结构 + 代码和数据。进程创建的时候需要在内核中创建该进程的 PCB,页表以及加载动静态库等,还要将进程的代码和数据加载到内存中。进程也是一个程序的一个执行流。
线程是进程内部的一个执行分支,也是一个执行流。
1.1.2 第二次理解
进程:承担分配系统资源的基本实体。因为在进程创建的时候,内核中创建的进程PCB,页表,加载的动静态库以及进程的代码和数据都是需要占用内存资源的。所以进程创建之后操作系统会分配资源给进程,而进程是承担这些系统资源的基本实体。
进程的大部分资源都是通过访问虚拟地址空间进行访问的,如果虚拟地址空间映射了 4G 的物理内存,则该进程就可以对这 4G 的资源进行访问,所以进程的虚拟地址空间可以看作访问资源的窗口。因为两个进程虚拟地址空间看到的物理内存空间不同,所以当一个进程挂掉之后,释放了自己的资源,也不会影响另外一个进程,所以进程具有独立性。
线程:CPU 调度的基本单位。一个 CPU 只有一个 runqueue 结构体,里面有一个活动队列,而这个队列中存储的是每个进程的 task_struct 结构体。进程不仅仅只包含 task_struct 结构体。但是在进行调度的时候只看 task_struct 结构体。在 Linux 中,单线程的进程只有一个 task_struct 结构体。而线程可以理解为一个进程中的一个 task_struct 结构体。多个线程,就是一个进程中有多个 task_struct 结构体。所以线程是 CPU 调度的基本单位。在 Linux 中,可以用轻量级进程来模拟线程,所以在 Linux 中线程就是轻量级进程。
并且一个进程中的所以 task_struct 内部都包含指向该进程的虚拟地址空间的指针。所以一个进程中的所有线程天然就共享该进程的大部分资源。
而虚拟地址空间就是资源的代表,对资源的划分本质就是对虚拟地址的划分。
一个进程的代码区也是映射到虚拟地址空间中的,而一份代码都是由一个一个函数进行构成的,所以一个函数本质就是一个代码段,就是虚拟地址空间的一个一个小块。让不同的线程指向不同的函数入口地址,并且 CPU 通过调度一个一个的线程,就可以理解为多线程的并发运行。
知识点1:
在 windows 中线程的实现并不是使用轻量级进程进行模拟实现的,在 windows 中,有描述进程的PCB,还有描述线程的TCB,而把其中多个线程和一个进程关联起来,就可以实现一个进程中多个线程的概念。
1.2 分页式存储管理
1.2.1 虚拟地址和页表的由来
如果没有虚拟地址和分页机制,每个用户程序在物理内存上所对应的空间必须是连续的。因为每一个程序的代码,数据长度优势不一样的,按照这样的映射方式,物理内存将会被分割成各种离散的,大小不同的块。经过一段运行时间之后,有些程序会退出,那么它们占据的物理内存空间就会被回收,这样就会导致这些物理内存优势以很多碎片的形式存在。
我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。如果都是用物理地址进行寻址,出现野指针指向其他进程的资源地址,就很难维护进程的独立性了。此时便有了虚拟地址和分页机制。
操作系统把物理内存按照一个固定的长度(4KB)进行分割,而这一小段内存就叫做页框,有时也叫做物理页。 一个页的大小等于页框的大小。大多数 32 位体系结构支持 4KB 的页,而 64 位体系结构一般会支持 8KB 的页。
页框是物理内存中的一段存储区域,大小一般位 4KB。而页可以理解为一个进程虚拟地址空间中的一段数据块,可以存放在任何页框或磁盘中。换句话说,页框就是一段存储区域,页就是该存储区域上的内容。
在这种机制下,CPU 并非是直接访问物理内存地址,而是通过虚拟地址空间来间接的访问物理内存地址。虚拟地址空间是操作系统为每一个正在运行的进程分配的一个逻辑地址空间,在 32 位机器上,其大小是 4GB。
操作系统通过将虚拟地址空间和物理内存地址在页表上进行映射,就能让 CPU 间接的访问物理内存地址。
虚拟地址空间和页表机制其思想就是,将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若干个不连续的物理内存页框中。这样就解决了使用连续的物理内存造成的碎片问题。
1.2.2 物理内存管理
假设一个可用的物理内存有 4GB 的空间。按照一个页框 4KB 进行划分,就是 4GB/4KB = 1048576 个页框。而这么多页框也肯定需要操作系统将其管理起来,这样操作系统才能知道哪些页框正在被使用,哪些空闲等等。
内核用 struct page 结构体表示系统中的每个物理页,而 struct page 本身也要占用内存,出于节省内存的考虑,struct page 中使用了大量的联合体 Union。
这里假设操作系统用数组进行 page 结构体的管理,设这个数组为 struct page mem[1048576],知道了这个数组的下标,使用这个下标乘以 4096,就可以得到这个页框的起始地址,所以在 page 中不用保存物理地址。而每一个 page 的大小差不多只有几十个字节,所以整个数组的大小也就才几十兆。操作系统中也会分配部分内存来进行这个数组的存储。
申请内存的载体都是进程和线程,申请内存本质是查询页框数组,找到没有使用的 page,修改 page 的标志位,然后建立进程数据和 page 的映射关系。
1.2.3 页表
这里假设一个地址的大小是 4 字节,如果页表中是一个物理地址和一个虚拟地址进行,则一组映射关系需要 8 个字节。假设一个进程虚拟地址空间大小为 4GB 全部都映射,则页表中 需要 4GB * 8 = 32 GB的空间用于映射,这样原本一个程序在不映射的情况下只使用 4GB,但是用页表进行映射就白白多了 32GB 的浪费,所以在页表的映射肯定不是一个虚拟地址映射一个物理地址。
页表中的每一个表项指向一个物理页(4KB)。在 32 位系统中,虚拟内存的最大空间是 4KB,这是每一个进程都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用,那么页表中久需要能够表示这 4GB 的空间,一共需要 4GB/4KB = 1048576 个表项。如下图所示:
虚拟地址空间上是连续的,但是通过页表的映射之后,可以将每一页数据随机的映射到物理内存的任意位置。虽然一个进程使用的物理内存是一块一块离散的,但是与这些物理内存相映射的虚拟地址是连续的。处理器在访问数据、获取指令时使用的都是虚拟线性地址,只要它是连续的就可以了,最终都可以通过页表找到实际的物理地址。
在 32 位系统中,地址的长度是 4 字节,那么页表中的每一个表项有一个物理地址和一个虚拟地址,所以页表占据的总空间大小就是 1048576 * 8 = 8MB。也就是说页表本身就要占用 8MB、4KB = 2048 个物理页。但是此时页表就需要 2048 个连续的页框。我们本来需要通过映射来将物理内存离散的使用,但是此时这个页表需要大容量连续的页框,这就和原本的目标有点背道而驰了。此外,很多时候一个进程在一段时间内只需要访问几个页就可以了,因此也没有必要一次让所以得虚拟地址都与物理地址进行映射,增加页表带来的内存开销。
由此,为了解决需要大容量页表的最好方法就是对页表再分页,形成多级页表。
以 32 位机器为例,一个进程虚拟地址空间是 4GB,则就会有 4GB/4KB = 1MB 组映射关系(一个页框占据一组映射关系)。1MB = 1024Byte * 1024Byte。
所以将页表分为两级,第一级为页目录表,里面有 1024 个页表的地址,而页表中有 1024 个物理页的地址。换句话说,就是一个页目录中有 1024 个页表,一个页表中有 1024 组物理页的映射关系,总共就是 1024 * 1024 = 1MB 的映射关系。如下图:
上述多级页表从总的空间占比上和之前没有区别,但是一个程序不可能完全使用全部的 4GB 空间。
现在一个页表有 1024 个页表的物理地址,则一个页表覆盖,1024 * 4KB = 4MB 的物理内存。一个程序一共就需要 10MB 空间。 则就只需要 3 个页表即可,所以在页表创建的时候,只创建 3 个页表,每个页表每一项是一个物理页的物理地址,一共 4 字节,一个页表的大小就是 1024 * 4 = 4KB,而只需要创建三个页表,页表占据的空间就只需要 12KB。
1.2.4 页目录结构
每一个页框都被一个页表中的一个表项指向,那么 1024 个页表也需要被管理起来。管理页表的表称之为页目录表。形成二级页表。如下图所示:
所有页表的物理地址被页目录表项指向,页目录的物理地址被 CR3 寄存器指向,这个寄存器中,保存了当前正在执行任务的页目录地址。
操作系统在加载用户程序的时候,不仅仅需要为程序内容来分配物理内存,还需要为页目录和页表分配物理内存。
1.2.5 二级页表的地址转换
这里以 32 位机器的虚拟地址为例,一个虚拟地址是 32 个 bit 位。如:0000000000,0000000001,11111111111。将前 10 个看做一组,中间 10 个看做一组,后面 12 个看做一组。10 个 bit 位表示的数据范围是 [0 , 1023],12 个 bit 位表示的数据范围是 [0, 4095]。
所以前 10 个 bit 位对应的就是页目录表中 1024 个页目录表项,中间 10 个 bit 位对应的就是页表中 1024 个页表项,而最后 12 个 bit 位对应的就是页的页内偏移量。
现在给出一个虚拟地址,第一次查页目录表,确定在哪个页表中,第二次查页表,确定在哪个页框中,第三次通过页框的起始地址加上页内偏移量,就可以获得一个物理地址了。
以上其实就是 MMU 的工作流程。MMU(Memory Manage Unit)是一种硬件电路,其速度很快,主要工作是进行内存管理,地址转换只是它承接的业务之一。
单级页表对连续内存要求高,于是引入了多级页表,但是多级页表在减少连续存储要求且减少存储空间的同时降低了查询效率。
所以在 MMU 进行处理的过程中,添加了一个中间层。MMU 引入 TLB(Translation Lookaside Buffer)来提高查询的效率。
当 CPU 给 MMU 传新虚拟地址之后,MMU 先查询 TLB,如果有就直接拿到物理地址发到总线给内存。但是 TLB 容量比较小,难免发生 Cache Miss,这时候 MMU 还是通过查询页表找到物理地址,然后将这条映射关系给到 TLB。
知识点1:
一个进程有一张页目录表和n张页表,虚拟地址前 10 位是页目录表的下标索引,中间 10 位是页表下标索引,后 12 位是页内偏移量。
知识点2:
资源划分本质就是虚拟地址空间划分。资源共享本质就是虚拟地址共享。
1.3 线程的优点
1. 创建一个新线程的代价要比创建一个新进程小的多。
2. 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。比如页表就不用进行切换。
(1)最主要的区别是线程在切换时虚拟内存空间依然是相同的,但是进程切换是不同的。这种切换过程伴随的最显著的性能损耗是将寄存器中的内容切换出。
(2)另一个隐藏的损耗是上下文的切换会扰乱处理器的缓存机制。一旦切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。一个显著的区别是当改变虚拟内存空间的时候,处理页表缓存的 TLB 会被全部刷新,这将导致内存的访问在一段时间内相当的低效。并且 CPU 中的硬件 cache 缓存的数据也会被刷新。
3. 线程占用的资源要比进程少,线程只占用进程的一部分资源。
4. 线程能充分利用多处理器的可并行数量。
5. 计算密集型应用,为了能在多处理器系统上运行,可以将计算分解到多个线程中实现。
6. I/O 密集型应用,为了提高性能,在等待慢速I/O操作结束的同时,程序可执行其他的计算任务,将I/O操作重叠。线程可以同时等待不同的I/O操作。
1.4 线程的缺点
1. 性能损失:如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是可用的资源不变,增加了额外的同步和调度开销。
2. 健壮性降低:多线程需要更全面更深入的考虑,在一个多线程程序中,因时间分配上的细微偏差或者因共享了不改共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
3. 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
1.5 线程异常
单个线程如果出现除0,野指针问题导致线程崩溃,进程也会崩溃。线程是进程的执行分支,线程出现异常就类似进程出异常,进而触发信号机制,终止进程,进而该进程内的所有线程也就随之退出。
1.6 线程用途
合理的使用多线程,能提高 CPU 密集型程序的执行效率。
合理的使用多线程,能提高 I/O 密集型程序的用户体验。
2. Linux中的线程和进程
2.1 进程和线程
1. 进程是资源分配的基本单位,线程是 CPU 调度的基本单位。
2. 线程共享进程数据,但是也拥有自己的“私有”数据。如:线程ID、一组寄存器(存放线程的上下文数据,表示线程是能被独立调度的)、栈、错误码、信号屏蔽字、调度优先级等。
2.2 进程的多个线程共享
Text Segment、Data Segment 都是共享的,定义的函数和全局变量也是共享的,除此之外,个线程还共享以下进程资源和环境:
1. 文件描述符
2. 每种信号的处理方式
3. 当前工作目录
4. 用户 id 和 组 id
进程和线程的关系如下图:
3. Linux线程控制
3.1 POSIX线程库
POSIX 线程库是在 POSIX 标准中定义的线程 API,用于在类 Unix 系统(包括 Linux)中创建和管理线程。
POSIX 线程库有以下主要特点:
(1)可移植性:POSIX 线程库是遵循 POSIX 标准的,这意味着使用该库编写的多线程程序可以在支持 POSIX 标准的不同操作系统上进行移植,无需进行大量修改。
(2)功能丰富:提供了创建线程、线程同步(如互斥锁、信号量等)、线程终止、线程属性设置等一系列功能,满足不同场景下的多线程编程需求。
(3)高效性:基于内核线程实现,能够充分利用多核处理器的性能,提高程序的执行效率。
POSIX 线程库中的库函数在使用的时候要引入头文件 <pthread.h>,链接这些线程函数库时要使用编译器命令的 "-lpthread" 选项。
3.2 创建线程
功能:创建⼀个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *
(*start_routine)(void*), void *arg);
参数:
thread:返回线程ID
attr:设置线程的属性,attr为NULL表⽰使⽤默认属性
start_routine:是个函数地址,线程启动后要执⾏的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
#include <iostream>
#include <string>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
void* threadrun(void* args)
{
std::string s = (const char*)args;
while(true)
{
std::cout << "I am new thread, my name is " << s << ".My pid is " << getpid() << std::endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadrun, (void*)"thread-1");
while(true)
{
std::cout << "I am major thread!!!" << "my pid is " << getpid() << std::endl;
sleep(1);
}
return 0;
}
通过上述代码可以看到的现象是新线程和主线程并发在运行,并且都在向显示器上打印消息,并且两个线程的 PID 相同,所以两个线程属于同一个进程。由于这里的线程间没有进行同步和互斥,所以也出现了打印消息混合在一起的情况。
使用 ps -aL 命令可以查看线程信息。
CPU 进行调度的时候是通过 LWP(light weight process) 进行调度的,所以线程是 CPU 调度的基本单位。
#include <pthread.h>
// 获取线程ID
pthread_t pthread_self(void);
#include <iostream>
#include <string>
#include <pthread.h>
#include <sys/types.h>
#include <unistd.h>
std::string FormatId(pthread_t tid)
{
char id[64];
snprintf(id, sizeof(id), "0x%lx", tid);
return id;
}
void* routine(void* args)
{
std::string name = static_cast<const char*>(args);
pthread_t tid = pthread_self();
int cnt = 5;
while(cnt)
{
sleep(1);
std::cout << "I am new thread, my name is " << name << " My id is " << FormatId(tid) << std::endl;
cnt--;
}
return (void*)100;
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, routine, (void*)"thread-1");
(void)n;
std::cout << "tid " << FormatId(tid) << std::endl;
void* ret = nullptr;
pthread_join(tid, &ret);
std::cout << "return value is " << (long long)ret << std::endl;
return 0;
}
上述代码在新线程内部可以通过 pthread_self 函数获取自己的线程ID,通过打印可以看到线程 ID 是一个很大的数字(这里的 pthread_t 就是一个无符号长整型的数字)。并且通过 pthread_join 函数进行等待,获取线程结束的返回值并打印。如果不等待就会出现类似于僵尸进程的问题,会导致内存泄漏。
这个 ID 是 pthread 库给每个线程定义的进程内唯一标识,是 pthread 库维持的。由于每个进程有自己独立的内存空间,所以这个 ID 的作用域是进程级而非系统级(内核不认识)。
LWP 得到的是内核中线程真正的 ID,而使用 pthread_self 得到的是虚拟地址空间上的一个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线程 ID,线程栈,寄存器等属性。
LWP 是内核使用的线程 ID,而用户使用的线程 ID 是通过 pthread_self 获取的 ID。
3.3.1 线程的参数和返回值
main 函数结束代表主线程结束,一般也代表进程结束。新县城对应的函数运行结束,代表当前线程运行结束。给线程传递的参数和返回值可以是任意类型(包括自定义对象),传递之后强转就可以进行使用了。
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
class Task
{
public:
Task(int a, int b)
: _a(a), _b(b)
{}
int Execute()
{
return _a + _b;
}
~Task() {}
private:
int _a;
int _b;
};
class Result
{
public:
Result(int result)
: _result(result)
{}
int GetResult()
{
return _result;
}
~Result() {}
private:
int _result;
};
void* routine(void* args)
{
Task* t = static_cast<Task*>(args);
sleep(1);
Result* res = new Result(t->Execute());
sleep(1);
return res;
}
int main()
{
pthread_t tid;
Task* t = new Task(20, 30);
pthread_create(&tid, nullptr, routine, t);
Result* res = nullptr;
pthread_join(tid, (void**)&res);
int n = res->GetResult();
std::cout << "running over, the result is " << n << std::endl;
delete t;
delete res;
return 0;
}
上述代码在给线程传参的时候,传递一个 Task 的指针,然后返回的时候返回一个 Result 的指针,在新线程的入口函数中,将传入的参数强转为 Task* 就可以使用外部传入的 Task* 对象,外部在进行 pthread_join 的时候,将 &Result* 强转为 void** 就可以接收到新线程返回的 Result* 对象了。
知识点1:
进程的时间片是等分给进程中的各个线程的,不会因为创建新的线程而获得额外的时间片。
3.3 线程终止
线程终止的三种方法:
(1) 从线程函数的 return 返回,这种方法对主线程不适用,从 main 函数 return 相当于调用 exit。线程不可以调用 exit 进行线程终止,exit 是终止进程的,调用会直接终止进程。
(2)线程调用 pthread_exit 终止自己。
(3)一个线程可以调用 pthread_cancel 终止同一个进程中的另一个线程。
函数:pthread_exit
功能:终⽌线程
原型:
void pthread_exit(void *value_ptr);
参数:
value_ptr: 线程的返回值,返回一个任意类型的指针,value_ptr不要指向⼀个局部变量。
需要注意的是 pthread_exit 或者 return 返回的指针所指向的内存单元必须是全局的或者是用 malloc 分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时线程函数已经退出了。
pthread_exit 的使用和 return 类似。
函数:pthread_cancel
功能:取消⼀个执⾏中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID
返回值:成功返回0;失败返回错误码
一般的做法是主线程取消新线程,取消的时候一定要保证新线程已经启动。 一个线程如果被 pthread_cancel 取消,则这个线程的退出结果是 -1(PTHREAD_CANCELED)。
3.4 线程等待
已经退出的线程其空间没有被释放,任然在进程的地址空间内,创建新的线程不会复用刚才退出线程的地址空间。
函数: pthread_join
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向⼀个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
调用该函数的线程将阻塞挂起,直到 ID 为 thread的线程终止。thread线程以不同的方式终止,通过 pthread_join 得到的终止状态是不同的:
(1)通过 return 返回 ,value_ptr 所指向的单元里存放的是 thread 线程函数的返回值。
(2)被别的线程调用 pthread_cancel 终止,value_ptr 所指向的单元里存放的是常数 PTHREAD_CANCELED(-1)。
(3)自己调用 pthread_exit 终止,value_ptr 所指向的单元存放的是传给 pthread_exit 的参数。
如果对 thread 线程的终止状态不感兴趣,可以传 NULL 给 value_ptr 参数。
3.5 分离线程
默认情况下,新创建的线程是可以被等待回收的,称为joinable,线程退出后,需要对其进行 pthread_join 操作,否则无法释放资源,从而造成系统内存泄漏。
如果不关心线程的返回值,这个时候可以告诉系统,当线程退出时,自动释放线程资源。
功能:分离线程
原型:
int pthread_detach(pthread_t thread);
这个函数可以是主线程调用分离新线程,也可以是新线程自己调用分离自己。 分离的线程使用 pthread_join 进行等待会出错。
#include <iostream>
#include <string>
#include <pthread.h>
#include <unistd.h>
#include <cstdio>
#include <cstring>
void* routine(void* args)
{
// 新线程自己分离自己
pthread_detach(pthread_self());
std::cout << "new thread is detached" << std::endl;
std::string name = static_cast<const char*>(args);
int cnt = 5;
while(cnt--)
{
sleep(1);
std::cout << "I am new thread, my name is " << name << std::endl;
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, routine, (void*)"thread-1");
//主线程分离新线程
// pthread_detach(tid);
// std::cout << "new thread is detached" << std::endl;
int cnt = 5;
while(cnt--)
{
std::cout << "I am major thread" << std::endl;
sleep(1);
}
int n = pthread_join(tid, nullptr);
if (n != 0)
{
std::cout << "pthread_join error: " << strerror(n) << std::endl;
}
else
{
std::cout << "pthread_join success " << std::endl;
}
return 0;
}
这里表示 pthread_join 出错,因为多线程直接没有互斥与同步,所以这里打印的消息是混乱的。
4. 线程ID及进程地址空间布局
4.1 线程ID
pthread_create 函数会产生一个线程 ID,存放在函数的第一个参数指向的地址中。这个线程 ID 和 LWP 不是一回事。
LWP 线程 ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度的最小单位,所以需要一个数值来唯一标识该线程。
pthread_create 函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程 ID,属于线程库的范畴。线程库的后续操作就是根据该线程 ID 来操作线程的。
pthread_t 到底是什么类型取决于实现。对于Linux目前的实现而言,pthread_t 类型的线程 ID 本质就是进程地址空间上的一个地址,是线程在库中对应的管理块的虚拟地址。
线程库也是一个动态库,需要占据一部分内存,而线程也需要进行维护,所以线程的维护是在线程库中进行维护的。 所以在线程库中有一个类似于struct pcb 的结构,比如 struct pthread,用于描述每个线程的状态和信息。
下图中每一个块表示一个线程的管理块,在 strcut pthread 结构体中,有一个返回值的属性,当线程结束返回时,会修改这个返回值的值,但是线程的管理块并没有被释放,所以线程需要 join 进行释放。
所以主线程创建一个新线程的时候调用 pthread_create 函数,在 pthread 库中创建一个线程控制的管理块,然后调用系统调用(clone系统调用)在内核中创建轻量级进程。Linux 中线程个数(库中线程控制块的个数)和 LWP 的数量关系是 1 : 1。
在控制块中还有一个 joinable 的标志位,当将该线程分离的时候,该标志位可以被视作置为 1 ,当轻量级进程完成任务之后,线程控制块识别之后并检查 joinable 标志位,检查到为 1,自动释放控制块。
4.2 线程栈
虽然 Linux 将线程和进程不加区分的统一到 task_struct,但是对待其地址空间的栈空间还是有些区别的。
对于 Linux 进程或者主线程,它的栈空间简单理解就是 main 函数的栈空间(会自动扩容),在 fork 的时候,实际上就是复制了父进程的占空间,然后写时拷贝以及动态增长。如果扩充超出该上限则栈溢出会报段错误。进程栈是唯一可以访问未映射页而不一定会发生段错误的(超出扩充上限会报段错误)。
对于子线程而言,其栈空间将不再是向下生长的,而是事先固定下来的。线程栈一般是调用 glibc/uclibc 等的 pthread 库接口 pthread_create 创建的线程,在文件映射区。这种栈不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。因此,对于子线程的栈,它其实是在进程的地址空间中 map 出来的一块内存区域,原则上是线程私有的(因为只有自己的线程控制块有指针指向这块区域),但是同一个进程的所有线程生成的时候,是会浅拷贝生成者的 task_struct 的很多字段,如果愿意,其他线程也可以访问到。
#include <iostream>
#include <unistd.h>
#include <pthread.h>
int* p = nullptr;
void* threadrun(void* args)
{
int a = 123;
p = &a;
while(true)
{
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, threadrun, nullptr);
while(true)
{
// 主线程也能访问子线程栈空间
std::cout << "*p: " << *p << std::endl;
sleep(1);
}
pthread_join(tid, nullptr);
return 0;
}
从上述 demo 代码来看,变量 a 在子线程中进行定义的,定义在子线程自己的栈空间中,然后又一个全局的指针 p 所指向,当子线程没有退出的时候,可以在主线程中利用指针 p 对变量 a 的内容进行访问。
4.3 线程的局部存储
#include <iostream>
#include <unistd.h>
#include <pthread.h>
__thread int count = 1;
void* routine1(void* args)
{
(void)args;
while (true)
{
printf("I am thread-1, the count is %d, and the count address is %p, and count++.\n", count, &count);
count++;
sleep(1);
}
return nullptr;
}
void* routine2(void* args)
{
(void)args;
while(true)
{
printf("I am thread-2, the count is %d, and the count address is %p.\n", count, &count);
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid1, tid2;
pthread_create(&tid1, nullptr, routine1, nullptr);
pthread_create(&tid2, nullptr, routine2, nullptr);
pthread_join(tid1, nullptr);
pthread_join(tid2, nullptr);
return 0;
}
默认情况下,一个进程的全局变量在所有线程中是共享的,当一个进程对该变量进行修改的时候,另一个对其访问也会访问到被修改的值,而且两个线程查看该变量的地址时是一样的。
但是使用 __thread 对全局变量进行修饰之后,线程对该全局变量进行访问的时候,就会在自己线程内的局部存储区域开辟一块空间来存储该变量,此时两个线程看到的都不是进程空间中的全局变量了,访问的变量地址也不一样了,而是自己线程中局部存储位置的变量。
线程局部存储只能存储内置类型和部分指针,不能存储自定义类型。
在 Linux 系统中有 pthread_setname_np 和 pthread_getname_np 函数,用于设置和获取线程的名字。其底层的原理就是将名字字符串写入线程自己的局部存储区域。
5. 线程封装
在 C++ 中也有线程库,但是 C++ 语言为了考虑平台的兼容性,为了能使同一份代码在各个平台都能运行,所以每个平台下 C++ 中的线程库都会封装底层的线程库,来维持其可移植性。在 Linux 系统中使用 C++ 的线程库的时候,就需要链接 pthread 库,因为在 C++ 在线程库在 Linux 下的实现是对其 pthread 库的封装。
下面就使用 C++ 封装 pthread 库来封装一个简单的 C++ 版本的线程。
#pragma once
#include <iostream>
#include <string>
#include <cstring>
#include <functional>
#include <pthread.h>
namespace ThreadModule
{
static uint32_t number = 1;
class Thread
{
using func_t = std::function<void()>;
private:
void EnableDetach()
{
std::cout << "thread's detach flag become to true" << std::endl;
_isdetach = true;
}
void EnableRunning()
{
std::cout << "thread is running" << std::endl;
_isrunning = true;
}
static void* Routine(void* args)
{
Thread * self = static_cast<Thread*>(args);
// 将运行标志位置为true
self->EnableRunning();
// 如果分离标志位为true,则分离线程
if (self->_isdetach)
{
int n = pthread_detach(self->_tid);
std::cout << "thread is detached in Routine, the return value is " << n << std::endl;
}
pthread_setname_np(self->_tid, self->_name.c_str());
self->_func();
return nullptr;
}
public:
// 构造函数,需要传入一个入口函数地址
Thread(func_t func)
: _tid(0), _isdetach(false), _isrunning(false), res(nullptr), _func(func)
{
_name = "thread-" + std::to_string(number++);
}
bool Start()
{
// 1. 如果线程已经运行起来,防止再次启动,直接返回false
if (_isrunning)
return false;
// 2. 如果线程第一次启动,则创建线程
// 这里如果Routine不是静态成员函数,默认会有一个this指针参数,与pthread_create中的参数不匹配
// 所以这里使用静态成员函数,将该线程对象以参数this的形式传给pthread_create
int n = pthread_create(&_tid, nullptr, Routine, this);
// 创建线程失败返回false
if (n != 0)
{
std::cerr << "create thread error" << strerror(n) << std::endl;
return false;
}
else
{
std::cout << _name << " create success" << std::endl;
return true;
}
}
void Detach()
{
// // 需要处理两种情况
// // 情况1:在线程还没有启动的时候,调用Detach设置线程分离标志位,然后线程启动之后在Routine函数中进行分离
// // 情况2:在线程启动之后调用Detach设置线程分离标志位,以及分离线程
// 如果线程已经分离,直接返回
if (_isdetach)
{
std::cout << _name << " is already detached. No further action needed." << std::endl;
return;
}
// 如果线程还没有启动,设置线程分离标志位
if (!_isrunning)
{
EnableDetach();
return;
}
else
{
// 启动后设置线程分离,需要设置标志位之后再进行线程分离
EnableDetach();
int n = pthread_detach(_tid);
std::cout << "thread is detched, the return value is " << n << std::endl;
}
}
bool Stop()
{
// 如果运行标志位为true,取消线程并将运行标志位置为false
if (_isrunning)
{
int n = pthread_cancel(_tid);
if (n != 0)
{
std::cerr << "cancel thread error" << strerror(n) << std::endl;
return false;
}
else
{
_isrunning = false;
std::cout << _name << " stop" << std::endl;
return true;
}
}
return false;
}
void Join()
{
// 分离的线程不能被等待
if (_isdetach)
{
std::cout << "thread is detached. it can't be joined! " << std::endl;
return;
}
int n = pthread_join(_tid, &res);
if (n != 0)
{
std::cerr << "join thread error" << std::endl;
}
else
{
std::cout << "join thread success" << std::endl;
}
}
~Thread()
{
}
private:
pthread_t _tid; // 线程ID
std::string _name; // 线程名字
bool _isdetach; // 线程分离标志位
bool _isrunning; // 线程运行标志位
void *res; // 线程返回值
func_t _func; // 线程入口函数
};
}