【Linux篇章】线程操控术2:如何让代码如千军万马听你号令(精讲线程控制)
本篇文章承接上篇关于线程概念;在此篇中;会讲到线程相关使用;比如创建,终止;等待;分离等相关函数接口;以及linux的线程底层是怎么实现的;那些接口函数是怎么封装的;线程id和轻量级进程之间关系;地址空间分布等等再到最后自己模拟实现简单版的线程封装;故本篇文章主要带大家了解linux线程如何使用;底层又是怎么样的;从而对线程有更加清晰地认识。
博博主页:☛☛羑悻的小杀马特.-CSDN博客 ☜☜
欢迎拜访:羑悻的小杀马特.-CSDN博客本篇主题:秒懂百科之探究线程控制概念(通俗易懂版)
制作日期:2025.06.09
隶属专栏:linux之旅
目录
一·线程相关函数系列:
1·1线程创建:
1.1.1线程ID及LWP的认识:
1.1.2Linux下线程实现原理简述:
1.2线程终止:
1.2.1return:
1.2.2pthread_exit:
1.2.3pthread cancel:
1.3线程等待:
1.4线程分离:
二·线程的其他特点测试:
2.1线程异常:
2.2线程共享资源:
2.3C++封装了各个平台下的线程库:
三·线程的小应用:
3.1模拟线程去执行任务:
3.2多线程并发应用:
四·线程ID及进程地址空间布局:
4.1线程地址空间认识:
4.2线程相关接口函数底层解释:
4.2.1pthread_create:
4.2.2return,pthread_exit;
4.2.3pthread_cancel:
4.2.4 pthread_join:
4.2.5pthread_detach:
4.3结合内核源码理解线程控制操作:
五·线程简单版本的封装:
六·线程局部存储的应用:
七·线程set/get_name系列:
八·简单模拟多线程互斥应用:
九·本篇小结:
一·线程相关函数系列:
与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以“pthread_”打头的;要使⽤这些函数库,要通过引⼊头⽂
1·1线程创建:
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;失败返回错误码
此外还有个返回当前线程id的接口函数;
#include
pthread_t pthread_self(void);
先普及一下先关概念:
1.1.1线程ID及LWP的认识:
对于有关线程的查看它也有一套类似进程的指令:可以使用ps-aL 查看 ;
这里我们看到的lwp和线程id不是一个概念:
线程id只是在线程库的一个线程的虚拟起始地址;故我们看到的会是个很长的数;而lwp就是linux底层模拟线程的这个轻量级进程的进程id;我们还会发现有个pid和lwp相等;那么这个就是主线程:如:3878192。
在ps-al 得到的线程ID,有一个线程ID和进程ID相同,这个线程就是主线程,主线程的栈在虚拟地址空间的栈上(可以增长),而其他线程的栈在是在共享区(堆栈之间)(不可增长),因为pthread系列函数都是pthread库提供给我们的。而pthread库是在共享区的。所以除了主线程之外的其他线程的栈都在共享区 。
下面更清楚的认识下他俩:
对于线程id:这个“ID”是pthread库给每个线程定义的进程内唯一标识,是pthread库维持的。由于每个进程有自己独立的内存空间,故此“ID”的作用域是进程级而非系统级(内核不认识)。其实pthread库也是通过内核提供的系统调用(例如c1one)来创建线程的,而内核会为每个线程创建系统全局唯一的“ID”来唯一标识这个线程;LWP得到的是真正的线程ID。之前使用pthread se1f 得到的这个数实际上是一个地址,在虚拟地址空间上的一个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线程ID,线程栈,寄存器等属性。
因此我们可以理解成这个线程id是专门给上层用户的;而lwp(轻量级进程)是给os的。
下面我们利用上面的接口写一个相关代码测试;然后需要手动链接它;一些编译器虽然会自动找到;安全起见还是手动告诉编译器去对应的位置去找找把.cc.h拿到在整合 。
下面我们运行下:
这就验证了我们上面说的了;当然子线程也是需要被回收的;后面我们会讲到。
测试源代码:
#include
#include
#include
#include
#include
#include
int flag=0;
//可重入函数:
std::string getidformat(const pthread_t i){
char buff[1024]={0};//局部变量
sprintf(buff,"0x%lx",i);
return buff;
}
void * routine(void*arg){
pthread_t id= pthread_self();
std::string name=static_cast(arg);
int cnt=5;
while(cnt--){
sleep(1);
std::cout<<"线程:"<
1.1.2Linux下线程实现原理简述:
Linux系统,不存在真正意义上的线程,Linux的线程实现:使用轻量级进程模拟的;在用户层我们称之为:用户级线程。
也就是对应的pthread这个第三方库来说;当我们调用它的接口比如创建线程的时候;内部会调用系统接口clone来生成一个轻量级进程;然后搞完相关信息;把这个lwp和共享库内的线程关联起来即可。
可以理解成把原本的进程的资源 页表什么的都不变,只是改一下可以让它能够从指定入口执行的;还有些额外其他亲件 ->就成了轻量级进程-->线程。
就是把linux底层实现轻量级进程的过程给封装成了pthread库;然后向上供给用户使用;就看到了所谓的线程 。
1.2线程终止:
对于线程终止有三种方式(return pthread_exit pthread_cancel):
1·从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
2.线程可以调用pthread_exit终止自己。
3.一个线程可以调用pthread cancel终止同一进程中的另一个线程。
1.2.1return:
也是用的最多的了;其实就是从我们的线程入口函数return即可(注意返回的是void*):
下面我们要看到它的返回值必须配合join;后面会讲到:
1.2.2pthread_exit:
#include
void pthread_exit(void *retval);
value_ptr:value_ptr不要指向一个局部变量;应该是全局或者malloc出来的;因为线程终止后它的函数就是局部变量就会销毁;后面如果需要它的返回值就拿不到了。
返回值:跟进程一样,线程结束的时候无法返回到它的调用者(自身)。
如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的传给pthread_exit的参数。
下面写代码测试下:
这里flag=1
测试代码:
#include
#include
#include
#include
#include
#include
int flag=0;
//可重入函数:
std::string getidformat(const pthread_t i){
char buff[1024]={0};//局部变量
sprintf(buff,"0x%lx",i);
return buff;
}
void * routine(void*arg){
pthread_t id= pthread_self();
std::string name=static_cast(arg);
int cnt=5;
while(cnt--){
sleep(1);
flag++;
std::cout<<"线程:"<
1.2.3pthread cancel:
#include
int pthread_cancel(pthread_t thread);
参数: thread:线程ID
返回值:成功返回0;失败返回错误码
主要用于其他线程终止一个线程(如主线程终止子线程);当然,也可以自己终止自己。
对于join获得返回值:如果thread线程被别的线程调⽤pthread_cancel异常终掉,value_ptr所指向的单元⾥存放的是常数PTHREAD_CANCELED->-1。
下面还是来测试下:
发现和我们上面所述的一样。
测试代码:
#include
#include
#include
#include
#include
#include
int flag=0;
//可重入函数:
std::string getidformat(const pthread_t i){
char buff[1024]={0};//局部变量
sprintf(buff,"0x%lx",i);
return buff;
}
void * routine(void*arg){
pthread_t id= pthread_self();
std::string name=static_cast(arg);
int cnt=5;
while(cnt--){
sleep(1);
flag++;
//除0 异常:
//int a=flag/0;
std::cout<<"线程:"<
1.3线程等待:
为什么需要等待线程;是为了干什么?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。--->为了节省pthread库内可创建线程的空间。
下面就是join函数了:
#include
int pthread_join(pthread_t thread, void **retval);
thread:线程ID
value_ptr:它指向一个指针,指向线程的返回值
返回值:成功返回0;失败返回错误码 。
下面就是对于上面不同类型退出的子线程的返回值的处理了:
调⽤该函数的线程将挂起等待(即调用的线程阻塞住),直到id为thread的线程终⽌。thread线程以不同的⽅法终⽌,通过pthread_join得到的终⽌状态是不同的,总结如下:
1. 如果thread线程通过return返回,value_ptr所指向的单元⾥存放的是thread线程函数的返回值。2. 如果thread线程被别的线程调⽤pthread_cancel异常终掉,value_ptr所指向的单元⾥存放的是常 数PTHREAD_ CANCELED (即-1) 。
3. 如果thread线程是⾃⼰调⽤pthread_exit终⽌的,value_ptr所指向的单元存放的是传给 pthread_exit的参数。
4. 如果对thread线程的终⽌状态不感兴趣,可以传NULL给value_ptr参数。
对于pthread_join函数;我们上面都测试使用了;这里就不测试了。
注:join过后进程一定是无异常!要么任务完成正确要么错误;如果对于分离后的线程;如果对它join那么join就会返回非0数。
1.4线程分离:
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进⾏pthread_join操作,否则⽆法释放资源,从⽽造成系统泄漏;如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃动释放线程资源。
因此就引入了分离线程,也就是告诉os这个线程自己终止后资源自己释放;为下一次创建新线程腾出空间。
#include
int pthread_detach(pthread_t thread);
成功返回0否则返回错误码。
需要注意:
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离joinable和分离是冲突的,一个线程不能既是joinab1e又是分离的;只是告诉os当线程退出才自己 释放资源并不是立即释放;这里类似join只不过不能查看返回的信息了。
下面来测试下:
测试代码:
#include
#include
#include
#include
#include
#include
int flag=0;
//可重入函数:
std::string getidformat(const pthread_t i){
char buff[1024]={0};//局部变量
sprintf(buff,"0x%lx",i);
return buff;
}
void * routine(void*arg){
pthread_t id= pthread_self();
//pthread_detach(id);
std::string name=static_cast(arg);
int cnt=5;
while(cnt--){
sleep(1);
flag++;
std::cout<<"线程:"<
二·线程的其他特点测试:
2.1线程异常:
任何一个线程崩溃,都会导致整个进程崩溃!
比如:
还会生成core文件。
因为可以理解成线程本身就是进程一部分。
2.2线程共享资源:
比如像类似全局资源等;线程们都是共享的;毕竟都是轻量级进程;都能看到同一主进程的虚拟地址空间。
测试下:
这里我们让子线程修改f1ag;主线程进行打印。
因此线程某种意义可以理解共享的。
如果取消对这种全局资源的共享;我们可以加上__thread;就是告诉线程把这个变量放到自己的局部存储里面;这样就只能自己看到了(只能是变量或者部分指针;自定义的就不行) 。
那么共享都是好处吗;当然不是,有些情况还需要防止共享;比如进行加锁等;对应显示器文件来说;它就是共享的:
因此,当打印的时候我们就能看到比较乱;因为多线程有并发性。
打印是乱的:显示器文件是共享文件;这样多个线程对它操作就会造成一个还没写完;另一个就开始了-->不能保证原子性了,需要加锁(后面同步与互斥,会讲到);这里了解下即可。
2.3C++封装了各个平台下的线程库:
下面我们先来用c++自己的线程库来测试下;注意:这里同样是链接pthread库的;因为c++也只是对它做一个封装。
测试下:
测试代码:
#include
#include
#include
#include
#include
using namespace std;
int func(){
cout<<"我是子线程"<<" pid:"<
总结:各大语言不仅是c++为了支持语言平台可移植性都自己封装了这个不同平台下的pthread这个第三方库。
三·线程的小应用:
3.1模拟线程去执行任务:
对于线程;我们可以让他去执行函数;如果给它传递自定义类的对象呢?那么他不就可以执行相关特定的任务了;下面我们简单模拟下这个过程:
让一个子线程去执行乘法运算操作:
效果:
假设我们输入10 20这两个参数:
测试代码:
#include
#include
#include
#include
#include
#include
using namespace std;
class res{
public:
res(int x):_rs(x){}
int getres(){ return _rs;}
~res(){}
private:
int _rs;
};
class Multiply{
public:
Multiply(int x,int y):_a(x),_b(y){ }
res out(){ return res(_a*_b);}
~Multiply(){}
private:
int _a;
int _b;
};
void *routine(void *arg){
class Multiply *mp=static_cast(arg);
res *pr=new class res(mp->out());
return (void*)pr;
}
int main(){
pthread_t tid;
class Multiply* M=new class Multiply(10,20);
int n=pthread_create(&tid,nullptr,routine,(void*)M);
sleep(1);
void *ret=nullptr;
pthread_join(tid,&ret);
cout<<"结果是: "<< ((class res*)ret)->getres()<
3.2多线程并发应用:
下面我们搞个多线程同时进入一个函数进行工作:
如果直接搞一个栈上的数组进行传参;也就是:
那么会发生什么?
再运行一次:
我们会发现每次运行都是不一样的。
下面我们让它休眠1s再进行打印更易看出原因:
解释下:
因为id是在栈上的;因为每次线程创建的和执行任务之间时间不一样:因此就发生对这个栈上id的改写造成这样。拿休眠1s后都是9来说:这里在1s的时间内所有线程都会创建完成:但是栈上的那个放id数组的空间始终是那块会直覆盖;故最后他们一起拿到的是最后一次被覆盖的字符串也就是9号线程的信息。故最后打印这样;上面的情况也是这样原因;只不过没有休眠看出太清楚(还有每个线程打印的时间都不同-->并发性造成)
也可以休眠一下再创建线程来解决它。
但是不是本质上的操作;因此我们可以把这个传递的参数数组搞到堆上。
看一下效果:
正常打印出来都会是乱的:线程并发性带来的(解决还是间隔时间创建线程)
这样就好了:
四·线程ID及进程地址空间布局:
4.1线程地址空间认识:
我们根据这张图看一下线程是如何形成以及运行的:
Linux没有真正的线程,他是用轻量级进程模拟的 ->看见了OS提供的接口,不会直接提供线程接口 。
在用户层,封装轻量级进程,形成原生线程库(对可执行文件进行ldd可查看):
libpthread.so.x =>/lib/x86_64-linux-gnu/libpthread. so.x (0x00007fxxXxxxXXx)
下面堆栈区之间就是我们共享区:里面映射了我么你所说的线程库:也就是存在线程tcb:
tcb{
//线程属性
//线程id
//线程独立的栈结构
//线程栈大小
//...
};
优先级,时间片,上下文在pcb也就是轻量级进程里面 。
下面我们把映射线程库的区域放大一下;然后再对它进行解析一下:
解释下线程属性(简单看一下),这里了解下即可:
4.2线程相关接口函数底层解释:
下面大白话理解下这四个接口函数底层实现(创建,终止,等待,分离线程):
4.2.1pthread_create:
首先,它会先在共享区也就是线程库创建好空间;然后把传入的函数入口;参数,一些属性;等等给它记录下来;然后拿到这块虚拟地址的起始位置拷给tid;之后syscall+系统调用号搞进寄存器那一套;陷入内核让os搞出轻量级进程;然后把相关信息;比如起始地址;线程状态等等告诉lwp(调用clone函数)﹔也就是让lwp 与tcb关联起来就完成了。
用户就拿到了tid也就是线程的地址不关心lwpl了。
4.2.2return,pthread_exit;
直接更改线程的特定状态然后告诉lwp完成了任务;叫它不要再执行了,就完了(后续等到join来回收或者自动的话就是detach)。
4.2.3pthread_cancel:
根据传入的tid也就是虚拟地址找到对应tcb重复上面exit的操作而已;只不过是一个lwp通过目标tcb让它底层的1wp停止任务。
4.2.4 pthread_join:
根据tid;销毁对应的tcb的数据及空间;然后把对应的lwp放入等待队列;把返回值从tcb栈中拷贝拿出。
4.2.5pthread_detach:
根据tid找到对应的tcb然后更改对应的属性即可由1变成0;那么此刻就相当于告诉1wp执行完自动完成对应的销毁+释放+挂起操作;就不保存结果了。
如何来表示线程状态:
//检测线程属性是否分离:
bool is_detached = IS_DETACHED(pd);
4.3结合内核源码理解线程控制操作:
下面看张简图理解下:
tcb中必要重要的部分:
tcb{
pid_t tid;
pid_t pid;
bool user_stack;
void *result;
void *(*start_routine) (void *) ;
void *arg;
//线程自己的栈和大小
void *stackblock;
size_t stackblock_size;
//.......
};
五·线程简单版本的封装:
小知识点:
线程是先对独立的:
其实真实情况下;对于linux内部来讲(也就是用1wp模拟的线程) ; lwp实质是轻量级进程;又因为是进程故共享此主进程(主线程)的所有虚拟地址空间;因此底层来讲每个线程都能看到其他线程的栈等;这里只是不提供地址而已;这样就理想的独立了
下面我们就利用上面所学的线程的接口函数进行一下简单的封装;这里设置成只能传一个参数的对象;当然如果要是多参数的话,就可以考虑下参数包和完美转发的应用了。
1·测试单参数单线程执行:
让单线程执行5s后自动退出回收:
效果:
2·测试单参数多线程执行:
每一个线程都打印一次然后退出;共有十个线程:
效果:
3·测试线程传类对象:
这里因为我们设置的是一个参数;因此相对搞起来就比较局限了;这里我们就模拟下计算平方操作:
效果:
六·线程局部存储的应用:
上面我们也说过了(只能变内置类型或者部分指针);这里我们先来测试一下:
我们让一号线程进行修改全局变量count;而二号线程进行打印:
发现地址相同,果然是我们之前说的共享全局变量。
那么如果我们给这个变量前面加上__thread(两个_)就会放到线程局部存储里了。
之后效果:
此时,count就被放到各自的线程局部存储了;因此就出现了上面效果。
七·线程set/get_name系列:
非标准库里的函数:linux是可以用的:一般只有debug版本会存在;这里我们了解一下即可:
可以认为就是把指定内容(str) 读到每个线程自己的局部存储pname中;然后再取出来相当于成为线程私有的一个变位保存。
#include
int pthread_setname_np(pthread_t thread, const char *name);
//读取name中的串进thread的线程局部存储中的特定的pname数组中(每个线程有一个)
int pthread_getname_np(pthread_t thread,char *name, size_t len);
//把对应的通过上面函数放入线程局部存储的串(每个线程局部存储中的pname里的串)给读到name中最大读len个长度
返回值还是成功返回0失败就是错误码:非0。
下面我们测试一下(子线程把对应t=1放入自己的线程局部储存;然后放入之后主线程对t++;然后子线程再次get出来打印它【这里需要控制好休眠时间来把握先后顺序】):
我们可以发现getname确实是从自己的线程局部存储拿出来的。
测试代码:
#include
#include
#include
#include
using namespace std;
//测试pthread_name系列:把对应的字符串放到自己线程的局部存储里面
//这里当我们把t的原先的值放进去后之后再改变全局;它局部存储就不会变了
int t=1;
void *routine(void*arg){
//sleep(1);
char name1[100];
sprintf(name1,"thread-%d",t);
pthread_setname_np(pthread_self(),name1);
sleep(5);
char name2[100];
pthread_getname_np(pthread_self(),name2,sizeof(name2));
cout<
八·简单模拟多线程互斥应用:
下面我们利用抢票来模拟下多个线程通入进入一个函数会发生什么?
首先我们搞1000张票;然后每个线程抢票时间是1ms;看一下最后四个线程们抢到的票号:
先看效果:
出现了负数;这里就是线程的并发性也就是数据不一致问题;如果多个线程同时进入临界区;存在临界资源的修改等操作;就会出现这样;因为我们此时只用了四个线程故出现负数主要集中在-1, -2 而没有太小的缘故。
下面我们简单说下原因:
这里关键问题:我们可能会认为主要就是ticket--所带来的;但是它只是少部分;主要问题还是出现在对ticket>0的判断这里。
首先我们要理解原子性(要么做要么不做;只有这两种情况)其实可以认为转化成汇编只要就1条语句它就是原子的;多条就不是原子的;比如ticket看似--就一条语句变成汇编后就是三条:先mov进cpu然后进行计算再放回物理内存。因为不是原子故可能在计算完打算放回物理内存的第三次汇编指令这里被打断;此线程保存上下文进入等待队列;下一个线程过来就会被cpu继续调度;此时比如上一个线程计算ticket到了1;当第二个线程来了计算完一次可能放入物理内存的值就和本应该的就不一样了-->多线程并发造成的数据不一致性。 (当然这里讲的只是ticket--)
对于ticket>0的判断:
这个也不是原子性的;可以理解为也是有 几条汇编指令的;下面我们以当票数减到1的时候为例;画图分析下:
或者也有可能有这个usleep的原因:比如它们几个线程的间隔时间差小于这个usleep的时间;就会票已经到1了;由于一开始的线程休眠住;ticket没有变;其他 线程也进来休眠了;然后有先后顺序的执行后面的语句也就造成了票数为负的现象了。
上面我们就大致了解下原因即可;后面在线程的同步与互斥篇章;会更加详细的讲解!!!
此时对于这种多线程并发性带来的数据不一致问题;我们就要考虑保护临界区;也就是一个线程进来后给它加锁;当这个线程出去的时候再把这个锁解开。
下面我们就先使用:
加锁后不仅避免了刚刚的问题;对共享的显示器文件打乱问题也解决了。
测试代码:
#include
#include
#include
#include
#include
using namespace std;
// 创建四个进程模拟抢票:
int ticket = 1000;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{ //保护临界区
pthread_mutex_lock(&lock);
if (ticket > 0) // 1. 判断
{
usleep(1000); // 模拟抢票花的时间
printf("%s sells ticket:%d
", id, ticket); // 2. 抢到了票
ticket--; // 3. 票数--
pthread_mutex_unlock(&lock);
}
else
{
pthread_mutex_unlock(&lock);
//这里还需要解锁否则到最后抢完票线程加锁后有其他线程不能被join进而阻塞住
break;
}
}
return nullptr;
}
int main()
{
pthread_t t1, t2, t3, t4;
pthread_create(&t1, NULL, route, (void *)"thread 1");
pthread_create(&t2, NULL, route, (void *)"thread 2");
pthread_create(&t3, NULL, route, (void *)"thread 3");
pthread_create(&t4, NULL, route, (void *)"thread 4");
pthread_join(t1, NULL);
pthread_join(t2, NULL);
pthread_join(t3, NULL);
pthread_join(t4, NULL);
}
这里我们就浅浅的了解下即可;后面在线程同步与互斥篇章再来深入理解。
九·本篇小结:
回顾下本篇文章,我们了解了线程的相关接口函数的使用;线程底层的实现;那些函数底层的原理;一些相关线程的小应用如利用线程完成任务;简单封装线程等等;最后我们以线程的互斥问题做结尾;为之后讲解线程的同步与互斥埋下伏笔;之后博主会更新线程互斥与同步方面知识;欢迎大家关注啊!!!