👀樊梓慕:个人主页
🎥个人专栏:《C语言》《数据结构》《蓝桥杯试题》《LeetCode刷题笔记》《实训项目》《C++》《Linux》《算法》
🌝每一个不曾起舞的日子,都是对生命的辜负
目录
前言
1.线程的理解
2.地址空间与页表
3.线程控制
3.1POSIX线程库
3.2创建线程 pthread_create
3.3获取线程ID pthread_self
3.4等待线程 pthread_join
3.5终止线程
4.线程的特点
4.1线程优点
4.2线程的缺点
4.3线程异常
5.Linux进程与线程的对比
6.多线程完成任务
7.分离线程
8.线程ID
线程ID是什么?
线程局部存储使用 __thread
前言
本篇文章我们会学习线程的相关概念,理解线程,并且会重谈进程地址空间页表等内容,然后会介绍相关的线程控制等等。
欢迎大家📂收藏📂以便未来做题时可以快速找到思路,巧妙的方法可以事半功倍。
=========================================================================
GITEE相关代码:🌟樊飞 (fanfei_c) - Gitee.com🌟
=========================================================================
1.线程的理解
线程是进程内部的一个执行分支。
在学习线程之前,我们认为代码在进程中全部都是『 串行』调用的。
但实际上一个进程内部有着不同的执行分支,这就是所谓的线程。
而线程才是CPU调度的基本单位,而进程我们应该站在资源角度去理解,即进程是资源分配的单位。
你可以将进程理解为一个家庭,而线程是家庭中的个人,一般来说社会资源是以家庭来分配的,比如财产等,所以家庭对应社会资源,进程就对应着系统资源。
所以我们得出对进程与线程的新认识:
- 进程:承担分配系统资源的基本实体。
- 线程:进程内部的一个执行分支。
为什么要有线程呢?
如果你想完成不同的任务,在学习线程之前,你可能需要创建子进程,来让不同的进程完成不同的任务,但是进程创建需要PCB、需要地址空间、需要文件描述符表、需要页表等等数据结构,也就是说创建进程的成本还是比较大的,所以才产生了线程概念。
那么如何实现线程这一实体呢?
实际上我们发现,线程与进程有高度的相似性,他们都是一种执行流的概念,所以linux系统的设计者认为没有必要单独设计数据结构和算法,所以linux中线程的结构是复用的进程的代码,让进程来模拟线程。
但是在windows系统中,就有单独设计出的线程的结构,但这无疑增加了系统开发的难度和维护成本,而且系统也变得十分复杂。
所以,之前我们理解进程你可以认为是一个内部只有一个线程的进程,而在今天我们理解进程就应该是一个内部至少有一个线程的进程。
在linux中由于没有线程这一具体结构,所以在linux中线程被称为『 轻量级进程』,什么叫轻量级呢,你可以理解为这个进程只有PCB,剩余其他结构比如地址空间页表等都是与其他进程共享的,即『 轻量级』。
当然为了让用户更好的使用linux,linux设计者实现了自己的线程库(后面讲),这个线程库底层就是对轻量级进程的封装(因为用户只知道线程,并不知道什么是轻量级进程)。
2.地址空间与页表
多个执行流实际上就是不同的task_struct执行不同的代码。
那么多个执行流是如何进行代码划分的?
首先我们之前讲文件系统时,提到过数据块的概念,在磁盘中数据块的大小是4KB,实际上在内存中,也有这样的数据块,大小同样为4KB。
我们把这4KB大小的空间和内容称之为『 页框』或『 页帧』。
那么如何管理起页框呢?
先描述,再组织。
一定存在一个结构体用来描述一个页框。
比如:
#define Kernal 0x1 //表示该页框属于内核区
#define User 0x2 //表示该页框属于用户区
#define Used 0x4 //表示该页框被使用
#define NoUsed 0x8 //表示该页框未被使用
#define Lock 0xF //表示该页框上锁禁止使用
#define ... //...
struct page
{
int flag;
//其他属性
}
然后可以用数组来组织起来:
以4GB内存为例,一个页框4KB,那么就有1048576个页框。
struct page mem[1048576];
所以OS对内存的管理就演变成了对该数组的增删查改,并且OS进行内存管理的基本单位就是页框,4KB大小。
接下来我们来看下地址空间到物理地址的转化,我们之前说这个过程是通过页表映射完成的。
但是页表的结构我们从来没有谈及过。
假设页表是简单的KV结构,也就是说我们需要一个K-虚拟地址,一个V-物理地址,另外可能还存在一些标志位等,所以我们可以假设一个页表中的一个项大小为10字节,那么我们需要对多少地址进行转化呢?地址空间大小为4GB对应着2^32个地址,所以一个页表我们就需要2^32*10字节,即40GB,显然这种KV结构肯定是不行的。
那么真正的页表是怎么构建的呢?
虚拟地址有2^32个,即32个二进制位,我们把前10位作为页目录找到具体的页表,中间10位找到页表中的具体哪个页框,后12位刚好可以寻址4KB大小作为页框内偏移量,这样我们就可以找到具体的地址了。
根据上面的逻辑,我们可以梳理出页表的结构:
然后将页框的地址加上偏移量就能找到具体物理地址。
通过这样的结构我们仅仅需要很小的空间就可以实现虚拟地址到物理地址的转化。
补充:页表中还可能存储着物理地址页框的权限信息,当cpu调度线程执行时,cpu中会有一个寄存器专门用来记录当前用户的状态(用户态、内核态),如果页表中这个页框的权限信息与cpu中寄存器中的状态信息相匹配,就允许访问该物理地址页框,否则就不能。
结论:多个执行流即不同的线程执行不同的代码,获得各自的数据,本质就是让不同的线程各自看到不同的页表。
3.线程控制
3.1POSIX线程库
Linux为了系统的轻量型,并没有为线程设计独立的结构,而是复用了进程的结构和算法,所以在Linux系统中实际没有线程的概念,而是叫『 轻量级进程』,但是为了确保用户无障碍使用Linux系统,就在『 应用层』封装出了一个线程库,底层还是轻量级进程,但是暴露给用户的就相当于线程一样,即将轻量级进程的系统调用进行封装,转成线程相关的接口语义提供给用户。
该线程库属于原生线程库,就是linux系统必须自带,但是该线程库不属于linux内核。
这个线程库就是『 POSIX线程库』。
因为该库是应用层实现,并不是系统内置,所以在链接线程函数库时要使用编译器的命令『 -lpthread』,并引入头文件pthread.h(3号手册),由于是3号手册,所以也侧面说明了这是外部库。
在Linux系统及其相关的UNIX-like系统中,手册页(man pages)是用户获取命令、系统调用、库函数等信息的重要来源。这些手册页按照不同的部分(section)进行分类,以便用户可以更容易地找到所需的信息。
- 2号手册:这部分包含了系统调用的信息。系统调用是用户空间程序与内核空间进行交互的一种方式。通过系统调用,用户空间程序可以请求内核执行底层硬件操作,比如文件操作、进程控制、网络通信等。2号手册中详细介绍了这些系统调用的名称、功能、参数、返回值等信息。
- 3号手册:这部分包含了库函数的信息。库函数是预编译好的代码片段,可以在多个程序中重复使用。与系统调用不同,库函数通常提供了更高层次的抽象,使得开发者可以更方便地进行编程。3号手册中详细介绍了这些库函数的名称、功能、参数、返回值等信息,以及它们所属的库文件。
要查看某个命令、系统调用或库函数的手册页,可以在终端中输入
man
命令,后跟相应的名称和手册页部分号(如果需要的话)。例如:
- 查看
write
系统调用的手册页:man 2 write
- 查看
printf
库函数的手册页:man 3 printf
请注意,不是所有的系统和库函数都有对应的手册页,但大多数常用的命令、系统调用和库函数都有详细的文档可供参考。
3.2创建线程 pthread_create
int pthread_create(pthread_t *thread
, const pthread_attr_t *attr
, void *(*start_routine) (void *)
, void *arg);
参数说明:
- thread:获取创建成功的线程ID,该参数是一个输出型参数。
- attr:用于设置创建线程的属性,传入nullptr表示使用默认属性。
- start_routine:该参数是一个函数地址,表示线程例程,即线程启动后要执行的函数。
- arg:传给线程例程的参数,比如start_routine((void*)arg)。
返回值说明:
- 线程创建成功返回0,失败返回错误码。
接下来我们利用创建线程函数让主线程创建一个新线程:
当一个程序启动时,就有一个进程被操作系统创建,与此同时一个线程也立刻运行,这个线程就叫做主线程。
- 主线程是产生其他子线程的线程。
- 通常主线程必须最后完成某些执行操作,比如各种关闭动作。
下面我们让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程,而主线程则继续执行后续代码。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
void *newthreadrun(void *args)
{
while (true)
{
std::cout << "I am new thread, pid: " << getpid() << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, newthreadrun, nullptr);
while (true)
{
std::cout << "I am main thread, pid: " << getpid() << std::endl;
sleep(1);
}
}
执行结果:
我们发现他们的pid相同,证明是统一进程,但通过创建线程的方式,我们实现了多个执行流也就是所谓的『 多线程』。
那么我们如何看到进程内不同的执行流呢?即『 轻量级进程』有没有类似进程pid的东西呢?
我们可以使用ps -aL
命令,可以显示当前的轻量级进程。
- 默认情况下,不带
-L
,看到的就是一个个的进程。 - 带
-L
就可以查看到每个进程内的多个轻量级进程。
当然为了打印信息的简洁我们加上一些选项:
ps -aL | head -1 && ps -aL | grep test
其中,LWP(Light Weight Process)就是轻量级进程的ID,可以看到显示的两个轻量级进程的PID是相同的,因为它们属于同一个进程。
所以OS在进行调度时会使用LWP来调度。
主线程的LWP==该进程的PID,所以单执行流进程,调度时也使用LWP调度,因为LWP==PID。
对于该创建线程的代码样例来说,函数编译完成后实际上就是若干个代码块,而函数名就是代码块的入口地址。
不管有几个函数,最终所有的函数都要按照地址空间进行统一编址。
那么主线程执行main()函数,创建的新线程执行newthreadrun()函数,而每一行代码都有地址。
又因为不同的函数把地址空间划分成了若干个区域,每个执行流执行的是对应区域的代码,也就达成了不同执行流划分代码的目的。
最终不同执行流根据自己的虚拟地址通过页表映射找到对应可以访问的物理地址,这就是不同执行流拥有不同区域的代码和数据的原理。
3.3获取线程ID pthread_self
首先这个线程ID并非是LWP(Light Weight Process),LWP是在内核角度上的线程ID,供内核区分线程用的,而我们现在要获取到的属于用户级的线程ID,这两个ID的区别就像是我们讲进程间通信提到的共享内存中的key和shmid一样。
获取线程ID有两种方式:
- 通过pthread_create的输出型参数thread获取;
- 通过pthread_self获取;
pthread_t pthread_self(void);
调用pthread_self函数即可获得当前线程的ID,类似于调用getpid函数获取当前进程的ID。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
void *newthreadrun(void *args)
{
while (true)
{
std::cout << "I am new thread, pid: " << getpid() << " new thread id: " << pthread_self() << std::endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, newthreadrun, nullptr);
while (true)
{
std::cout << "I am main thread, pid: " << getpid() << " main thread id: " << pthread_self() << std::endl;
std::cout << "I am main thread, pid: " << getpid() << " new thread id: " << tid << std::endl;
sleep(1);
}
}
运行结果:
3.4等待线程 pthread_join
线程就如同进程一般,也是需要被等待的。如果主线程不对新线程进行等待,那么这个新线程的资源也是不会被回收的。所以线程需要被等待,如果不等待会产生类似于“僵尸进程”的问题,也就是内存泄漏。
int pthread_join(pthread_t thread, void **retval);
参数说明:
- thread:被等待线程的ID。
- retval:线程退出时的退出码信息。
返回值说明:
- 线程等待成功返回0,失败返回错误码。
- 调用该函数的线程将挂起等待,直到ID为thread的线程终止,thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的。
总结如下:
- 如果thread线程通过return返回,retval所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_cancel异常终止掉,retval所指向的单元里存放的是常数PTHREAD_CANCELED((void*)-1)。
- 如果thread线程是自己调用pthread_exit终止的,retval所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。
比如在下面的代码中我们先不关心线程的退出信息,直接将pthread_join函数的第二个参数设置为NULL。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
void *newthreadrun(void *args)
{
int count = 5;
while (count > 0)
{
printf("I am new thread, pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
sleep(1);
count--;
}
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, newthreadrun, nullptr);
printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
pthread_join(tid, NULL); // 等待新线程退出
printf("new thread quit\n");
return 0;
}
下面我们再来看看如何获取线程退出时的退出码,为了便于查看,我们这里将线程退出时的退出码设置为某个特殊的值,并在成功等待线程后将该线程的退出码进行输出。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
void *newthreadrun(void *args)
{
int count = 5;
while (count > 0)
{
printf("I am new thread, pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
sleep(1);
count--;
}
return (void *)666;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, newthreadrun, nullptr);
printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
void *ret = nullptr;
pthread_join(tid, &ret);
printf("new thread quit,exitcode: %lld\n", (long long)ret);
return 0;
}
运行结果:
思考,为什么线程等待函数的输出型参数不需要像进程等待函数的输出型参数status一样需要进行位操作拿到退出码、退出信号和core dumped标志,而仅仅拿到了退出码?
原因是:如果线程收到信号意味着整个进程收到了信号,进程会直接退出。
也就是说线程是进程内的一个执行分支,如果进程中的某个线程崩溃了,那么整个进程也会因此而崩溃,此时我们根本没办法执行pthread_join函数,因为整个进程已经退出了。
这也说明了多线程的健壮性不太强,一个进程中只要有一个线程挂掉了,那么整个进程就挂掉了。并且此时我们也不知道是由于哪一个线程崩溃导致的,我们只知道是这个进程崩溃了。
所以pthread_join函数只能获取到线程正常退出时的退出码,用于判断线程的运行结果是否正确。
3.5终止线程
线程的终止有三种方式:
- 线程函数return;
- pthread_exit(不能使用exit终止线程,因为exit是终止进程的,如果调用exit,则会终止进程,即终止当前进程的所有线程);
- pthread_cancel通过线程id终止同一进程中的另一个线程(包括自己,取消自己时退出码为-1);
void pthread_exit(void *retval);
参数说明:
- retval:设置线程退出时的退出码信息。
例如,在下面代码中,我们使用pthread_exit函数终止线程,并将线程的退出码设置为666。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/types.h>
void *newthreadrun(void *args)
{
int count = 5;
while (count > 0)
{
printf("I am new thread, pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
sleep(1);
count--;
}
pthread_exit((void *)666);
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, newthreadrun, nullptr);
printf("I am main thread...pid: %d, ppid: %d, tid: %lu\n", getpid(), getppid(), pthread_self());
void *ret = nullptr;
pthread_join(tid, &ret);
printf("new thread quit,exitcode: %lld\n", (long long)ret);
return 0;
}
运行结果:
int pthread_cancel(pthread_t thread);
参数说明:
- thread:被取消线程的ID。
返回值说明:
- 线程取消成功返回0,失败返回错误码。
线程是可以取消自己的,取消成功的线程的退出码一般是-1。
虽然线程可以自己取消自己,但一般不这样做,我们往往是用于一个线程取消另一个线程。
其实我们只需要掌握前两种线程退出方式即可。
4.线程的特点
有了上面对线程的理解,我们可以来总结一下线程的一些特性。
4.1线程优点
- 创建一个新线程的代价要比创建一个新进程小得多(创建角度)。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多(调度角度)。
- 线程占用的资源要比进程少很多(资源释放角度)。
- 能充分利用多处理器的可并行数量。
- 在等待慢速IO操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- IO密集型应用,为了提高性能,将IO操作重叠,线程可以同时等待不同的IO操作。
线程最重要的优点便是从调度角度考量:线程的切换代价更小。
那么我们如何理解呢?
如果站在上下文切换的角度考量,实际上切换的代价好像并不是很高。
- 进程切换可能会涉及到虚拟地址空间、页表、文件描述符、信号处理器、打开的文件等;
- 线程的切换只会涉及到一些线程栈和寄存器状态等。
虽然看起来差距很大,但是实际上对于CPU来说不过是多交互了几次而已,实际上真正高的代价在缓存Cache的重新加载。
在进程切换时,由于每个进程都有自己独立的地址空间和内存布局,因此切换进程通常需要更换当前正在使用的缓存内容。这是因为新进程的数据和代码可能不在当前缓存中,或者即使在缓存中,也可能因为缓存行替换策略(如LRU,最近最少使用)而被其他数据覆盖。因此,进程切换后,CPU可能需要从主存储器中重新加载数据和代码到缓存中,这会导致显著的延迟。
然而,在线程切换时,情况则有所不同。由于线程共享进程的地址空间和内存布局,因此线程切换时不需要更换当前正在使用的缓存内容。线程使用的数据和代码很可能仍然在缓存中,因此线程切换后,CPU可以立即继续执行,而无需等待从主存储器中加载数据。
即线程切换通常只涉及线程控制块(TCB)和寄存器状态的保存和恢复,这些操作可以在CPU的寄存器或缓存中快速完成,而无需访问主存储器。因此,线程切换的代价通常比进程切换小得多。
结论:线程切换比进程切换代价更小的一个主要原因是与缓存的使用有关。线程切换时,由于线程共享进程的地址空间和内存布局,因此可以利用缓存中的数据和代码,从而减少从内存中加载数据的延迟。
4.2线程的缺点
- 性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低: 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说,线程之间是缺乏保护的。
- 缺乏访问控制: 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多。
4.3线程异常
- 单个线程如果出现除零、野指针等问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
由于一个线程出问题,导致其他线程也出问题,导致整个进程退出,这就是『 线程安全问题』。
5.Linux进程与线程的对比
- 进程:资源分配的基本单位;
- 线程:调度的基本单位;
虽然线程共享进程数据,但是线程也拥有自己的一部分数据。
- 线程ID。
- 一组寄存器。(线程上下文数据)
- 栈。(独立的用户栈)
- errno。(C语言提供的全局变量,每个线程都有自己的)
- 信号屏蔽字。
- 调度优先级。
线程拥有独立的用户栈是什么意思?
用户栈是每个线程在执行过程中用于存储局部变量、函数调用信息、返回地址等的内存区域。
虽然我们说线程可以共享进程的数据,但是如果线程没有自己独立的用户栈的话,就会导致『 数据冲突』、『 函数调用混乱』、『 线程同步和调度复杂』等问题。
进程的多个线程共享
因为是在同一个地址空间,因此所谓的代码段(Text Segment)、数据段(Data Segment)都是共享的:
- 如果定义一个函数,在各线程中都可以调用。
- 如果定义一个全局变量,在各线程中都可以访问到。
除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表。(进程打开一个文件后,其他线程也能够看到)
- 每种信号的处理方式。(SIG_IGN、SIG_DFL或者自定义的信号处理函数)
- 当前工作目录。(cwd)
- 用户ID和组ID。
进程和线程的关系如下图:
6.多线程完成任务
接下来我们来写一个实际的案例,我们要创建多个线程,然后让线程帮我们完成任务。
这里需要注意的是线程函数的参数和返回值设置的类型都是(void *),这也就意味着可以接收任意类型,所以我们可以传递基本信息,也可以传递其他对象。
首先我们来看一下传递字符串信息的例子:
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <pthread.h> // 原生线程库的头文件
const int threadnum = 5;
void *handlerTask(void *args)
{
// std::string threadname =static_cast<char*>(args);
const char *threadname = static_cast<char *>(args);//类型转换
while (true)
{
std::cout << "I am " << threadname << std::endl;
sleep(2);
}
delete[] threadname;
return nullptr;
}
int main()
{
std::vector<pthread_t> threads;
for (int i = 0; i < threadnum; i++)
{
char *threadname = new char[64]; //注:这里使用堆空间
snprintf(threadname, 64, "Thread-%d", i + 1);
pthread_t tid;
pthread_create(&tid, nullptr, handlerTask, threadname);
threads.push_back(tid);
}
for (auto &tid : threads)
{
pthread_join(tid, nullptr);
}
}
运行结果:
为什么threadname要使用堆空间呢?
因为threadname传递给pthread_create函数传递的是地址,如果是栈空间上的变量,每次for循环后,都会被释放掉,也就是说此时新线程拿到的参数被主线程释放掉了,所以这里必须让每个线程都有自己的独占的一份内容,才能够保证新线程拿到的参数有效。
所以这里使用了堆空间。
接下来我们来看一下传递对象的例子:
#include <iostream>
#include <string>
#include <vector>
#include <cstdio>
#include <unistd.h>
#include <cstdlib>
#include <pthread.h> // 原生线程库的头文件
const int threadnum = 5;
// 传递对象
// 任务类,完成两数相加的任务
class Task
{
public:
Task()
{
}
void SetData(int x, int y)
{
datax = x;
datay = y;
}
int Excute()
{
return datax + datay;
}
~Task()
{
}
private:
int datax;
int datay;
};
// 线程类
class ThreadData : public Task
{
public:
ThreadData(int x, int y, const std::string &threadname) : _threadname(threadname)
{
_t.SetData(x, y);
}
std::string threadname()
{
return _threadname;
}
int run()
{
return _t.Excute();
}
private:
std::string _threadname; // 线程名
Task _t; // 任务
};
// 任务结果类
class Result
{
public:
Result() {}
~Result() {}
void SetResult(int result, const std::string &threadname)
{
_result = result;
_threadname = threadname;
}
void Print()
{
std::cout << _threadname << " : " << _result << std::endl;
}
private:
int _result; // 相加结果
std::string _threadname; // 线程名
};
// 线程函数
void *handlerTask(void *args)
{
ThreadData *td = static_cast<ThreadData *>(args); //类型转换
std::string name = td->threadname();
Result *res = new Result();
int result = td->run();
res->SetResult(result, name);
// std::cout << name << "run result : " << result << std::endl;
delete td;
sleep(2);
return res;
}
int main()
{
std::vector<pthread_t> threads;
// 创建线程
for (int i = 0; i < threadnum; i++)
{
char threadname[64];
snprintf(threadname, 64, "Thread-%d", i + 1);
ThreadData *td = new ThreadData(10, 20, threadname); //线程数据对象
pthread_t tid;
pthread_create(&tid, nullptr, handlerTask, td);//创建新线程,并将线程数据传递给线程函数
threads.push_back(tid);
}
std::vector<Result *> result_set;
void *ret = nullptr;
for (auto &tid : threads)
{
pthread_join(tid, &ret);//获取线程函数返回值
result_set.push_back((Result *)ret);
}
for (auto &res : result_set)
{
res->Print();
delete res;
}
}
语言层面比如C++11的线程库是语言对原生线程库的封装。
所以当我们使用C++11线程库的时候,编译代码也必须带上『 -lpthread』选项。
因为C++11线程库中的方法都是对原生线程库函数的封装。
7.分离线程
上面我们将线程等待的内容时,你会发现主线程等待从线程结束的过程是『 阻塞』的,也就是说主线程会『 阻塞等待』从线程结束。
但是对于进程来讲,我们可以加『WNOHANG
』选项使这个等待方式变为『 非阻塞等待』,那么线程也有非阻塞等待的方式,实现的方式就是『 分离线程』。
线程必须要等待么?
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成内存泄漏。
那如果我们对于『 从线程』的返回值不感兴趣,『 主线程』阻塞等待就是一种负担。
所以此时我们可以将该线程进行分离,后续当线程退出时就会自动释放线程资源。
分离线程并不分离资源,什么意思呢,就是线程分离后只是『 从线程』退出时无需『 主线程』join了,但是线程并没有真正『 分离』出去,线程仍旧属于该进程,仍然使用该进程的资源。
如何理解分离后的线程仍然使用进程资源?
即分离后的线程如果主线程退出,主线程退出意味着进程结束,进程结束资源就被回收,所以此时虽然线程被分离了,但由于使用的进程资源,所以也会退出。对于分离后的线程的理解一定是仅仅不需要由主线程join了,剩下的都与线程的特性保持一致。
线程可以自己分离自己,也可以其他线程进行分离。
注意:分离后的线程,不能再等待『 join』了,如果join,就会出错。
分离线程的操作通过『 pthread_detach』函数完成。
int pthread_detach(pthread_t thread);
参数说明:
- thread:被分离线程的ID。
返回值说明:
- 线程分离成功返回0,失败返回错误码。
8.线程ID
这里所说的线程ID是pthread_create函数产生的输出型参数和pthread_self函数获取到的返回值。
如果你将它打印出来你会发现是一段很长的数字。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string>
#include <cstdio>
#include <cstdlib>
using namespace std;
string ToHex(pthread_t tid)
{
char id[64];
snprintf(id, sizeof(id), "0x%lx", tid);
return id;
}
void *Routine(void *arg)
{
while (1)
{
cout << "new thread tid: " << pthread_self() << ",toHex : " << ToHex(pthread_self()) << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
while (1)
{
// cout << "new thread tid: " << tid << endl;
cout << "main thread tid: " << pthread_self() << ",toHex : " << ToHex(pthread_self()) << endl;
sleep(1);
}
return 0;
}
LWP(Light Weight Process)轻量级进程是内核提供的,内核中的LWP属于进程调度的范畴,因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。而这里所说的线程ID是用户级线程ID。
线程ID是什么?
我们从线程的管理角度入手了。
线程是如何管理的呢?先描述,再组织。
对于进程来讲,linux内核设计了一套专门用于进程的结构、算法等对进程进行管理,而线程我们说Linux为了系统的简介性与轻量型并没有专门为线程设计这么一套内容,而只是复用了进程的结构与算法,所以对于linux系统来说线程就是轻量级进程。
可是当我们引入了线程库后,就仿佛有了线程这一具体的结构了,线程库也为我们提供了一系列接口函数用来管理线程,所以对于线程的管理工作,是由线程库来完成的,换句话说,线程库中实现了描述线程的数据结构和一些管理工作。
所以线程库中一定有一个结构体对象struct pthread用来描述线程。
通过『 ldd』命令可以看到,我们采用的线程库实际上是一个动态库。
进程运行时动态库被加载到内存,然后通过页表映射到进程地址空间中的共享区,此时该进程内的所有线程都是能看到这个动态库的。
我们说每个线程都有自己私有的独立栈,这个栈就在线程库中,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中的线程库中。
除此之外,每个线程都有自己的struct pthread(tcb),当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。
每一个新线程在共享区都有这样一块区域对其进行描述,因此我们要找到一个用户级线程只需要找到该线程内存块(线程控制块TCB)的起始地址,然后就可以获取到该线程的各种信息,而这个线程内存块的起始地址其实就是线程ID。
如下图所示:
上面我们所用的各种线程函数,本质都是在线程库内部对线程属性进行的各种操作,最后将要执行的代码交给对应的内核级LWP去执行就行了,也就是说线程数据的管理本质是在共享区的。
pthread_t到底是什么类型取决于实现,但是对于Linux目前实现的NPTL线程库来说,线程ID本质就是进程地址空间共享区上的一个虚拟地址,同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程。
线程局部存储使用 __thread
我们观察线程库中为每个线程都维护了一段『 线程局部存储』,那么『 线程局部存储』与『 线程独立栈』有什么区别呢?
- 线程局部存储(TLS):这是一种编程技术,用于为每个线程提供独立的变量副本。它允许在多线程程序中创建全局变量的多个实例,每个实例都与特定的线程相关联。这样,每个线程都可以访问自己的变量副本,从而避免了多线程并发时可能产生的竞态条件和数据访问冲突。
即使用『 __thread』修饰全局变量,可以使该进程中所有线程都有一份该全局变量的副本,但是注意,仅支持内置类型。
- 线程独立栈:栈是线程独有的,用于保存其运行状态和局部自动变量。每个线程在创建时都会分配一个独立的栈空间,用于存储该线程在执行过程中的局部变量、函数调用等信息。线程独立栈的主要目的是确保每个线程都有自己的私有数据区域,以防止数据冲突和混乱。
比如我利用『 __thread』修饰一个全局变量,我需要该变量在每个线程里都拥有自己的一份,但是我懒得在每个线程里都声明一份,所以我就可以利用『 __thread』在全局写一份,这样就可以让所以线程都有这个变量的副本了。
__thread uint64_t starttime = 0;
void *threadrun1(void *args)
{
starttime = time(nullptr);
pthread_detach(pthread_self());
std::string name = static_cast<const char *>(args);
while (true)
{
sleep(1);
printf("%s, starttime: %lu, &starttime: %p\n", name.c_str(), starttime, &starttime);
}
return nullptr;
}
void *threadrun2(void *args)
{
sleep(3);
starttime = time(nullptr);
pthread_detach(pthread_self());
std::string name = static_cast<const char *>(args);
while (true)
{
printf("%s, starttime: %lu, &starttime: %p\n", name.c_str(), starttime, &starttime);
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid1;
pthread_t tid2;
pthread_create(&tid1, nullptr, threadrun1, (void *)"thread 1");
pthread_create(&tid2, nullptr, threadrun2, (void *)"thread 2");
int cnt = 5;
while (true)
{
if (!(cnt--))
break;
std::cout << "I am a main thread ..." << getpid() << std::endl;
sleep(1);
}
return 0;
}
=========================================================================
如果你对该系列文章有兴趣的话,欢迎持续关注博主动态,博主会持续输出优质内容
🍎博主很需要大家的支持,你的支持是我创作的不竭动力🍎
🌟~ 点赞收藏+关注 ~🌟
=========================================================================