【Linux多线程】线程的概念
目录
- 【Linux多线程】线程的概念
- Linux线程的概念
- 什么是线程
- 重新定义线程和进程
- 进程地址空间第四讲
- 线程的优点
- 线程的缺点
- 线程异常
- 线程的用途
- Linux进程VS线程
- 进程和线程
- 关于进程线程的问题
- Linux线程控制
- POSIX线程库
- 创建线程
- 如何给线程传参?
- 线程ID及进程地址空间布局
- 再次谈谈pthread_create中的参数和返回值
- Linux如何创建一个轻量级进程?
- 线程栈
- 线程终止
- 线程等待 为什么需要线程等待?
- pthread原生线程库 VS C++11thread库
作者:爱写代码的刚子
时间:2024.3.19
前言:本篇博客将会介绍线程的基本概念,理解线程与进程的区别和联系,以及进程地址空间第四讲
Linux线程的概念
什么是线程
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行(任何执行流要执行都必须要有资源)
- 在Linux中,线程的执行粒度要比进程要更细,线程执行进程代码的一部分
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
CPU只有调度执行流的概念
重新定义线程和进程
什么叫做线程?我们认为,线程操作系统调度的基本单位!
重新理解进程:内核观点:进程是承担分配系统资源的基本实体。(多个执行流,虚拟地址空间,页表,物理内存里面的代码和数据)
进程内部包含线程,进程是承担分配系统资源的基本实体,线程是进程内部的执行流资源
CPU:
线程<=执行流<=进程
linux中的执行流叫做轻量级进程
进程地址空间第四讲
从物理地址读到CPU内部的地址是虚拟地址
CR3寄存器中的地址指向的是页目录的起始地址。任何一个进程必须要有页目录!
CPU内还有CR2寄存器,里面存放的是引起缺页中断异常的虚拟地址。(因为内存申请建立映射后需要知道上次访问的地址)
【问题】:虚拟地址是如何转换到物理地址的?
以32位虚拟地址为例,虚拟地址为32位:
实际上,32位的虚拟地址可以拆分为10+10+12,
- 还有一些系统将页框的大小弄成4MB,我们称为大页式内核,具体的细节参考《深入理解Linux》一书中的“扩展分页”,如果为4MB,那页表还会更小。
但是我们之前对整数进行取地址为什么只拿到了一个地址?C/C++中任意的一个变量或者结构体都只有一个地址(第一个字节的起始地址)。计算机硬件只要能帮我们找到这个变量的起始地址,CPU天然知道要识别几个字节(结构体转化为二进制后没有结构体的概念,也就是内置类型的集合,依旧能识别)
起始地址+类型 = 起始地址 + 偏移量 ————X86的特点
所以线程目前分配资源,本质就是分配地址空间范围。
线程的优点
线程比进程要更轻量化(为什么?)
a. 创建和释放更加轻量化
b. 切换更加轻量化
CPU内部有cache缓存,由于局部性原理,进程在调度的时候会越跑越快(缓存的热数据),因为它的命中率会越来越高。线程切换效率更高,在同一个进程内切换的线程。cache内的数据不需要由冷变热,不需要重新缓存。切换进程会将cache内的数据重新缓存,效率较低。
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现 (一般是有多少个CPU就创建多少个线程)
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。(瓜分时间片导致时间片过多,调度频繁)
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
- 当进程收到了一个信号,所有线程都要执行对应的处理方法
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
线程的用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是 多线程运行的一种表现)
Linux进程VS线程
进程和线程
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据:
每个线程独立的数据:
- 线程ID
- 一组寄存器
- 栈(每个线程独立,不会出现执行流错乱)
- 线程要有独立的上下文
- errno
- 信号屏蔽字
- 调度优先级
进程的多个线程共享 同一地址空间,因此Text Segment(代码区)、Data Segment(数据区)都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表(重要,一个线程打开,其他的线程也能看到)
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
进程和线程的关系如下图:
关于进程线程的问题
- 如何看待之前学习的单进程? 具有一个线程执行流的进程
- Linux上没有真正意义上的线程,而是用“进程内核数据结构”模拟的线程(复用进程数据结构和管理算法,用struct task_struct模拟线程)
- 所以我们通常将线程叫做执行流。
- 内核中没有很明确的线程的概念,只有轻量级进程的概念,不会给我们提供线程的系统调用,只会给我们提供轻量级进程的系统调用。但是我们用户需要线程的接口,Linux程序员在应用层提供了pthread线程库(应用层——轻量级进程接口进行封装,为用户提供直接线程的接口,这是一个第三方库,几乎所有的Linux平台都是默认自带这个库的,Linux中编写多线程代码,需要使用第三方pthread库)
Linux线程控制
POSIX线程库
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
- 要使用这些函数库,要通过引入头文<pthread.h>
- 链接这些线程函数库时要使用编译器命令的“-lpthread”选项
创建线程
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;失败返回错误码
-
pthread_create函数:
- 先来验证void和void*大小:
虽然void有大小,但是void不能定义变量!!!
错误检查:
- 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
- pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通 过返回值返回
- pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误, 建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小
#include <iostream>
#include <pthread.h>
#include <unistd.h>
using namespace std;
void* startRoutine(void* args)
{
while (true)
{
cout << "线程正在运行..." <<getpid()<< endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid, nullptr, startRoutine, (void*)"thread1");
cout << "new thread id : " << tid << endl;//线程ID
while (true)
{
cout << "main thread 正在运行..." <<getpid()<< endl;
sleep(1);
}
return 0;
}
- 我们编译时发现出现的链接报错:
-
pthread_create说明不是系统调用函数!
-
编译时要带上**-lpthread**选项:
- 运行结果:
- 只有一个进程pid说明是一个进程但有多个执行流
- ps -aL查看所有轻量级进程(线程)
CPU调度的基本单位是线程,所以每一个线程都要有自己的标识符
- 线程的标识符:
-
用户级执行流 :内核LWP = 1 :1(也有多对1)
-
不管是kill 18707还是kill 18708,只要其中一个线程被杀死,整个进程都会被杀死。(我们认为将信号发送给线程就是发送给进程,线程是进程的执行分支)所以线程的健壮性很差,只要有一个被干掉了整个都会被干掉。
-
多个线程执行同一个函数:
==全局变量,已初始化和未初始化都是和线程共享的!!==线程之间数据共享,为线程之间的通信提供了方便。
- 打印线程的tid:
我们发现我们打印的tid好像和PID或者LWP没有关联
- 我们换一种打印方式:
- 发现打印出来的类似地址,因为PID和LWP是操作系统层面的概念:
之后我们将会了解到这一串地址指的是什么。
如何给线程传参?
- 转成无符号类型指针再强转回来:
线程ID及进程地址空间布局
栈一定是被线程私有的,共享内存是所有线程共享的
再次谈谈pthread_create中的参数和返回值
-
pthread_create函数中传递的参数可以为结构体:
-
示例代码:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <cstdlib>
using namespace std;
class Request
{
public:
Request(int start,int end,const string &threadname)
:start_(start),end_(end),threadname_(threadname)
{}
public:
int start_;
int end_;
string threadname_;
};
class Response
{
public:
Response(int result,int exitcode):result_(result),exitcode_(exitcode)
{}
public:
int result_;
int exitcode_;
};
void *sumCount(void *args)
{
Request *rq=static_cast<Request*>(args);
Response *rsp = new Response(0,0);
for(int i=rq->start_;i<=rq->end_;i++)
{
rsp->result_ += i;
}
delete rq;
return rsp;
}
int main()
{
pthread_t tid;
Request *rq = new Request(1,100,"thread 1");
pthread_create(&tid,nullptr,sumCount,rq);
void *ret;
pthread_join(tid,&ret);
Response * rsp = static_cast<Response*>(ret);
cout<<"rsp->result"<<rsp->result_<<", exitcode:"<<rsp->exitcode_<<endl;
delete rsp;
return 0;
}
所以线程的参数和返回值,不仅仅可以用来进行传递一般参数,也可以传递对象
所以我们还可以改进上面的代码,将计算的方法放入对象,变成一个成员函数进行调用
通过上面的代码我们发现,堆空间是被线程共享的!!!
pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID 不是一回事。
前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要 一个数值来唯一表示该线程。
pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID, 属于NPTL线程库(原生线程库)的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
pthread_t pthread_self(void);
- 实验:
Linux如何创建一个轻量级进程?
- clone函数
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
fork函数的底层也是它,我们一般不用这个接口,因为参数太多了。
- 参数:
- fn:需要执行函数的函数指针
- child_stack:自定义一个栈
- flag:创建子进程的时候要不要选择和地址空间实现共享(默认需要)
clone这个接口一般我们是用不了的,所以他被线程库封装了,线程的概念是库给我们维护的,**所以我们在执行多线程代码,库是要被加载到内存的!!!(映射到共享区,或者说动态库),我们的讨论都是基于内存的!!!操作系统没有线程的概念,但是线程的栈,回调函数等线程的属性是由线程库来维护的!!!(主要维护线程的概念,不用维护线程的执行流),线程库注定要维护多个线程属性的集合,线程库要管理这些线程(先描述再组织,每创建一个线程都要创建一个线程库级别的线程控制块(栈,回调方法在哪,线程对应的独立栈在哪,线程的id是什么,线程的LWP指向底层的哪个执行流))**线程库中维护的线程叫做用户级线程(用结构体维护)
pthread
到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质 就是一个进程地址空间上的一个地址。
pthread.so加载到内存
可以理解为按照数组的方式维护好tcb,每一个线程库级别的tcb在内存中的起始地址称为线程的tid(因为在共享区,所以我们之前实验出的地址较大)
线程栈
每个线程都有自己独立的调用链,注定了每一个线程都有调用链所对应独立的栈帧结构,这个栈帧结构会保存线程在运行时所有的临时变量(压栈变量,传参,返回变量,返回地址,函数自己定义的临时变量等).
- 其中主线程(真进程)直接用自己地址空间中的栈结构即可
- 其他线程调用clone,建立的栈在共享区进行维护(具体讲是在pthread库中,tid指向的用户tcb中)!!!
使用共享栈可以减少线程创建时的开销,因为不需要为每个线程都分配独立的栈空间。
同时线程栈不仅仅要实现简单的变量定义,入栈出栈,**实际上每一个执行流的本质就是一条调用链。**这个栈结构在宏观上动态开辟,这个栈结构要完成整个调用链临时变量的开辟和释放。所以每个线程都要有自己的调用链,防止自己不受干扰,所以每个线程必须要有自己的线程栈结构!!!(将来我们也有办法访问这个独立的栈,比如在主线程可以定义一个全局变量,然后指定线程进行赋值即可)
- 我们在主线程为每个线程申请一个堆空间,(用结构体的方式打包线程信息)传递给线程(在线程中要记得释放),并在主线程用vector保存,所以我们可以访问其他线程的数据,但是这是线程的缺点吗?是线程的缺点同时也是线程的特点,因为强调执行流之间的独立性是进程的概念,线程在一个进程内本来就是一家人,所以被互相访问是很正常的
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
- pthread_exit函数
功能:线程终止
原型
void pthread_exit(void *value_ptr);
参数
value_ptr:value_ptr不要指向一个局部变量。返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
- pthread_cancel函数(不常见)
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);参数
thread:线程ID返回值:成功返回0;失败返回错误码
- 示例:
- 如果一个线程是被取消的,不用自己return,pthread库会让线程退出时设置返回值(-1):
线程等待 为什么需要线程等待?
已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。(防止内存泄漏)
创建新的线程不会复用刚才退出线程的地址空间。
获取线程的退出结果
- 功能:等待线程结束
- 原型:
int pthread_join(pthread_t thread, void **value_ptr);
- 参数
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
-
返回值:成功返回0;失败返回错误码
-
实验:
主线程等待其他线程退出的时候,默认是阻塞等待的,只有其他线程执行完了主线程才会退出!!!
- value_ptr(二级指针进行研究):
- 编译不通过:
原因:类型大小不符合,void*8个字节,int四个字节:
- 修改:
- 运行结果:
拿到了运行结果。所以我们可以拿到不同的数字作为返回结果,
【问题】:但是!!!void*在不同平台下指针的大小并不相同!!!代码不具有可移植性,怎么修改??
【问题】:为什么我们在这里join的时候不考虑异常呢?
做不到。线程出现异常主线程也会收到影响,所以不用考虑异常问题,因为异常问题是进程考虑的。所以只要考虑正常情况就可以了。
验证:
- 修改线程执行函数中的代码:
我们发现线程调用exit,但是主线程没有执行线程回收之后的打印语句,说明线程的主线程都退出了
所以exit是用来终止进程的,不能用来终止线程!!!任何一个线程调用exit都会导致整个进程退出
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的 终止状态是不同的,总结如下:
如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
如果对thread线程的终止状态不感兴趣,可以传nullptr给value_ ptr参数。
关于之前介绍过的进程替换:
如果线程中存在程序替换,整个进程都会被替换掉,当然我们可以在fork里面创线程,也可以在线程里面创进程
pthread原生线程库 VS C++11thread库
注:有关C++11thread库将会在之后的博客专门 介绍
- #include < thread>
- 发现编不过:
- 还是必须要带上-lpthread!!
经过测试,-std=c++11选项可以不带
因为C++11里面的多线程封装了原生线程库,不管什么语言,都要使用原生线程库,c++里面的库使用了条件编译,所以可以跨平台。
- 更推荐使用c++的多线程(语言级别的线程库)
【附】:
注意:tid在执行pthread_create函数后才获取了,不能将tid当作参数传递给线程,同时线程内部可以使用pthread_self来获取tid(最好转16进制)
因为都在同一个进程地址空间,所以进程没有秘密,只不过我们要求它们都要有一个独立的栈,但是我们禁止访问线程独立栈里面的数据(独立但不私密)
主线程中的全局变量我们叫做共享资源
线程可以有自己私有的全局变量,使用线程局部存储:__thread (高并发内存池项目有用到(开辟了空间 ))
__thread int g_val=100;
__thread
不是c/c++的,而是编译器编译的一个选项,地址在进程地址空间的共享区,同时__thread
只能用来定义内置类型,不能定义自定义类型!每个线程访问这个全局变量时都有对应线程的数据__thread在其他平台的有其他的关键字