目录
前置知识
线程的概念
Linux中对线程的理解
重新定义进程与线程
重谈地址空间
线程的优缺点
线程的优点
线程的缺点
线程异常
线程的用途
Linux线程 VS 进程
线程控制
创建线程
线程等待
线程终止
线程ID的深入理解
前置知识
我们知道一个进程有属于自己的PCB(task_struct),地址空间,页表;OS会为进程在物理内存中申请资源(空间),通过页表与地址空间产生映射关系。在执行进程时,会通过地址空间使用OS为进程申请的资源。
因此我们可以说:
地址空间时进程的资源窗口
在创建子进程时,也会为子进程创建属于它的PCB,地址空间,页表,与物理内存的资源。子进程会将父进程的部分属性拷贝下来。
线程的概念
在一些教材中对于线程是这样定义的:
线程:是进程内的一个执行分支。线程的执行粒度,要比进程要细。
这里对线程的定义是通过线程的特点定义的,并不能很好的解释什么是线程。
这里可以这样解释:
Linux中对线程的理解
我们知道在之前我们创建的进程都是一个PCB(task_struct)的,这里我们再创建多个特殊的 "进程" :
这个 "进程" 有属于自己的PCB,但是与它的 "父进程",共享同一个地址空间,同一个页表,同一块物理内存。(即:将只属于一个PCB的栈,堆等资源,划分一部分给另一个新创建的PCB).
由于父进程与新创建的“子进程”,他们共享一个地址空间,可能会导致他们可以使用同一个资源。
这里我们可以说这些"进程"的执行粒度要比,原来进程(一个task_struct)的执行粒度要小。
原因:原来进程由自己一个就可以执行完全代码,使用空间。而现在却需要多个"进程"去执行代码,使用空间。这些"进程"是原来进程的一个执行分支(执行流)。因此我们说现在这些 "进程"的执行粒度小于原来进程的。
这里为了和原来的进程进行区分,我们把新创建的"进程"叫做:线程。
Linux中实现线程的方案:
1.在Linux中,线程在进程"内部"执行,即:线程在进程的地址空间内运行。
- 任何执行流要执行,都要有资源,在上面我们说过,地址空间是进程的资源窗口。这里线程采用了共用同一个地址空间(将地址空间分成若干份,分配给线程)。所以说新城在地址空间中运行。
2.在Linux中,线程的执行粒度要比进程要细。
- 线程执行了进程的一部分代码,共享同一份资源。
注:不同的操作系统对线程的实现方案可能是不一样的,但实现原理都是一样的。
同时我们知道操作系统用PCB对进程描述与管理,会通过进程的PCB进行调度,但是对于多线程来说,一个进程中会有多个PCB,那么该如何调度的呢?
这里我们要知道在OS中是通过CPU进行调度的,对于CPU来说,它并没有进程与线程的概念,它只有调度执行流的概念,即:task_struct(只要有代码与数据让CPU去执行就可以了)。
重新定义进程与线程
什么叫做线程?
- 我们认为线程是操作系统调度的基本单位。
什么是进程?
- 在内核角度:进程是操作系统分配资源的基本实体,执行流也是资源,所以线程是进程内部的执行流资源!
对于以前的进程可以这样理解:操作系统是以进程为基本单位进行资源分配,在进程内部就有一个执行流。
Linux中对线程的描述与管理
这里我们知道,在操作系统里一个进程可能有1个或多个线程,所以进程和线程的关系是1:N的。在操作系统用PCB(task_struct)来描述和管理进程,所以线程也可以采用同样的方式来进行管理,在有些操作系统中(Windows)用struct tcb(thread control block)来进行描述与管理。但是我们知道对于进程的管理就已经很复杂了,如果再进一步细分struct tcb会更加的复杂。所以在Linux中,并没有使用struct tcb来对线程进行描述与管理,由于线程是进程的一个执行流,在大体上并没有太大的区别,于是采用了复用进程的PCB(task_struct)来对线程进行描述与管理。
因此可以在进程中只有一个PCB时,把它当作进程的,多个PCB时,当作线程的。甚至可以不区分这些,直接把他们当作执行流去看待。
由于Linux是采取复用进程的结构体去管理线程的,所以Linux没有真正意义上的线程,而是用"进程的内核数据结构 "模拟线程的。
这里以CPU的角度去看:
线程<=执行流<=进程
所以在Linux中执行流,也叫做轻量级进程。
重谈地址空间
这里我们知道进程是OS分配资源的基本单位,线程是调度的基本单位。在进程里多线程会共享同一块地址空间,它们会把 地址空间里的资源划分给每一个线程,那么是如何划分的呢?这里我们就不得不再谈地址空间了。
虚拟地址是如何转换到物理地址???(以32位虚拟地址为例)
在Linux中,将32位的虚拟地址,分为了三部分:32=10+10+12。同时页表并不是直接记录在一张表里的。因为:地址空间一共有4GB个,一个按照10字节算,页表最大为40GB,明显太大,不能存储下来,更别说其他的了。页表是这样的:分为两级:第一级页表有1024个,存放第二级页表地址;第二级页表:有1024个,存放了物理内存中页框的起始地址。
在Linux中这样转换的:
这里我们知道一个变量的地址是变量的起始地址,那么从虚拟地址转化成物理地址,我们只能找到一个物理地址,但是如果是int,那么它是用4个字节存储的,那么他是如何读取的呢?
这里我们知道每一个变量都有一个类型,在读取数据是,就识别出了它的类型,在它找到物理地址时,会加上类型大小对应的偏移量进行读取。
在了解地址空间后,我们知道线程的资源分配,全部都是由地址空间来的,而所有的代码于数据都是,地址空间通过页表的映射在物理内存中找到的。
所以线程分配资源的本质就是分配地址空间。
线程的优缺点
线程比进程要更轻量化:
创建和释放更加轻量化
- 创建线程只需要创建一个PCB,而进程不但要创建PCB,还要创建地址空间,页表,将地址空间通过页表于物理地址进行映射等。
切换更加轻量化
- 在线程切换时,只需要改变CPU寄存器中对应线程的的上下文内容于数据,但是效率主要来自于一个存储常用数据cache,切换线程时并不会改变该cache,而进程会将其清空,从头慢慢开始。
我们知道进程有时间片,线程也有时间片。
在OS中并不会为创建的新线程重新赋予时间片,而是瓜分同一进程的时间片(时间片也是资源,会分配给每个线程)。
那OS是如何区分是进程切换还是线程进行切换的呢?
task_struct是可以标识,这里在进程中的线程是有主次之分的,在进程刚开始的时候的task_struct时主线程,其他创建的是副线程。在主线程的task_struct里记录着进程的时间片于给线程的时间片,在其他副线程中记录着线程的时间片,当线程的时间片结束,主线程中记录进程的时间片会减去线程的时间,这是如果为0,则进行进程的切换,否则进行线程的切换。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现(不是线程越多越合适,一般有多少个CPU就创建多少个线程)
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型 线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了 不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该 进程内的所有线程也就随即退出
线程的用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是 多线程运行的一种表现)
Linux线程 VS 进程
进程是资源分配的基本单位线程是调度的基本单位线程共享进程数据,但也拥有自己的一部分数据:
- 线程ID
- 一组寄存器 (线程的上下文)
- 栈
- errno
- 信号屏蔽字
- 调度优先级
- 文件描述符表 (即:一个线程打开一个文件,所有线程都打开了)
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
线程控制
创建线程
在Linux中复用了进程的PCB来对线程进行描述于管理,因此在Linux中没有明确的线程的概念,只有轻量级进程的概念。因此在Linux中不会直接提供线程的系统调用,只会给我们提供轻量级的系统调用!
但由于我们用户需要线程的接口去创建线程。
因此一些大佬们专门创建了一个应用层的pthread线程库(对轻量级进程接口进行封装),为用户提供直接创建线程的接口。(pthread线程库是一个第三方库,几乎所以的Linux平台,都默认自带这个库)
- 功能:创建一个新的线程
参数
- thread:输出型参数,创建线程成功返回线程ID。
- attr:设置线程的属性,attr为nullptr表示使用默认属性
- start_routine:是个函数指针(返回值为void* 参数也为void*),线程启动后要执行的函数里面的内容
- arg:传给线程启动函数的参数,为start_routine指向函数的参数。不需要参数为nullptr
返回值:成功返回0;失败返回错误码
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void* thread(void* args)
{
while(true)
{
cout<<"new thread:"<<endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
while(true)
{
cout<<"main thread"<<endl;
sleep(1);
}
return 0;
}
编译:
g++ -o thread thread.cc -std=c++11
这里我们发现,在编译时报错。
这里是因为pthread_create是第三方库的接口,不是系统调用。g++在编译时,只会链接自己的C/C++库,这里因为Linux已经将pthread库加载到了指定路径下,因此我们只需要告诉g++链接那个库就可以了。
我们要这样编译:
g++ -o thread thread.cc -l pthread -std=c++11
这里我们还可以通过指令查看执行流:
ps -aL
- LWP(Light Weight Process)就是轻量级进程的ID(用来标识轻量级进程),可以看到两个轻量级进程的PID相等,所以他们属于同一个进程。这里有一个线程的LWP和PID相等,这里我们把这个线程叫做主线程,另一个就是新创建的线程。
- 之前说的OS调度一个进程,可以认为单进程单执行流,它调度的基本单位看的是pid也可以是LWP,因为它两相等。现在我们认为线程调度时OS看的是LWP。
- LWP和PID的关系? PID是对进程标识,相同进程具有相同的PID。LWP标识轻量级进程,它们的标识不同。OS真正调用时使用的时LWP
注:在同一进程里所有线程的PID都相等。
验证线程共享全局变量:
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
int val=100;
void* thread(void* args)
{
while(true)
{
printf("new thread: getpid:%d val=%d &val=%p\n",getpid(),val,&val);
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
while(true)
{
printf("main thread: getpid:%d val=%d &val=%p\n",getpid(),val,&val);
val++;
sleep(1);
}
return 0;
}
验证一个线程出异常,整个进程崩溃:
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
int val=100;
void* thread(void* args)
{
while(true)
{
sleep(5);
int a=1;
a/=0;
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
while(true)
{
printf("main thread: getpid:%d val=%d &val=%p\n",getpid(),val,&val);
val++;
sleep(1);
}
return 0;
}
获取线程ID
常见的获取线程ID的方式有两种:
- 通过创建线程pthread_create函数的第一个输出型参数获得。
- 通过调用pthread_self函数获得。
这里pthread_self函数那个线程调用,就获取那个线程的ID,类似于调用getpid()函数。
代码:主线程调用pthread_create函数,通过输出型参数获取并打印;在创建的新线程中调用pthread_self函数获得线程ID。
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void* thread(void* args)
{
while(true)
{
printf("new thread: new thread tid=%p\n",pthread_self());//将线程ID以16进制打印出来
sleep(1);
}
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
while(true)
{
printf("main thread: create new thread tid=%p\n",tid);//将线程ID以16进制打印出来
sleep(1);
}
return 0;
}
用这里我们用pthread_self函数获取的线程ID于内核的LWP的值不同,pthread_self函数获得的是用户级原生线程库的线程ID,而LWP是内核的轻量级进程ID,他们之间是1:1。
线程等待
- 这里创建完线程,我们并不知道是那个线程先进行。但是我们可以确定的是主线程最后退出,因为新线程是在主线程中创建的,主线程要对创建的新线程进行管理,所以最后退出。
- 因此,一个线程被创建出来,这个线程如进程一般,需要主线程进行等待,如果主线先退出会出现类似于进程中的僵尸进程的情况,造成内存泄漏。(这里线程退出,类似于进程的退出,它的空间并没有被完全的释放。)
功能:
- 指定主线程等待那个线程
参数:
- thread:等待线程ID(用户级别的)
- retval:接收线程退出时的返回值,若不使用可以为nullptr。
返回值:
- 成功,返回0;失败返回错误码。
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void* thread(void* args)
{
int cnt=5;
while(cnt--)
{
cout<<"new thread: "<<cnt<<endl;
sleep(1);
}
return (void*)101;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
void* retval;
pthread_join(tid,&retval);主线程等待新线程退出;通过retval获取线程的返回值
cout<<"main thread quit... retval:"<<(long long int)retval<<endl;
return 0;
}
注:主线程等待的时候,默认是阻塞等待。
线程终止
1.return返回
在线程中使用return代表当前进程退出,但是在main函数中使用,代表整个进程退出,也就是说只要主线程退出,那么整个资源就会被释放,而其他线程会因为没有资源,自然而然的也退出了。
2.pthread_exit函数
功能:终止调用该函数的线程。
参数:retval:线程退出时的退出码信息。
说明:
- 该函数无返回值,跟进程一样,线程结束时无法返回它的调用者(自身)。
- pthread_exit或者return返回指针所指向的内存单元必须在全部变量或是用malloc分配的,不能在线程函数的只能上分配,因为当其他线程得到这个返回指针时,指针指向的空间已经被释放了。
- exit函数的作用时终止整个进程,任何一个线程调用exit函数就代表着整个进程终止。
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void* thread(void* args)
{
int cnt=5;
while(cnt--)
{
cout<<"new thread: "<<cnt<<endl;
pthread_exit((void*)101);//终止线程。这里的参数可以理解为线程的退出码,可以被pthread_join的第二个参数接收
sleep(1);
}
return (void*)101;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
void* retval;
pthread_join(tid,&retval);主线程等待新线程退出;通过retval获取线程的返回值
cout<<"main thread quit... retval:"<<(long long int)retval<<endl;
return 0;
}
3.pthread_cancel
功能:取消指定线程,类似于kill。被取消的线程的错误码(返回值)为-1。
参数:取消线程的ID。
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void* thread(void* args)
{
int cnt=5;
while(cnt--)
{
cout<<"new thread: "<<cnt<<endl;
sleep(1);
}
return (void*)101;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
sleep(1);//保证线程已创建成功
pthread_cancel(tid);//取消线程
void* retval;
pthread_join(tid,&retval);主线程等待新线程退出;通过retval获取线程的返回值
cout<<"main thread quit... retval:"<<(long long int)retval<<endl;
return 0;
}
线程ID的深入理解
- pthread_create函数会产生一个线程,这里和进程一样,也需要一个属性来标识线程,这里我们叫做线程的ID,存放在第一个参数指向的地址中,这里线程的ID于内核中的LWP不是一回事的。
- 内核中LWP属于进程调度的范畴,因为线程是轻量级进程,是操作系统调度器的最小单位。
- Linux不提供真正的线程,只提供轻量级进程,也就意味着操作系统只需要对内核执行流LWP进行管理,而供用户使用的线程接口等其他数据,应该由线程库自己管理。因此管理线程时的“先描述,再组织”就应该在线程库里进行。
- 这里线程库是一个动态库,加载到地址空间的共享区中
- 每个线程都有自己私有的栈,其中主线程采用的栈是进程地址空间中原生的栈,而其余线程采用的栈就是在共享区中开辟的。除此之外,每个线程都有自己的struct pthread,当中包含了对应线程的各种属性;每个线程还有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。
- 每一个新线程在共享区都有这样一块区域对其进行描述,因此我们要找到一个用户级线程只需要找到该线程内存块的起始地址,然后就可以获取到该线程的各种信息。
- 上面我们所用的各种线程函数,本质都是在库内部对线程属性进行的各种操作,最后将要执行的代码交给对应的内核级LWP去执行就行了,也就是说线程数据的管理本质是在共享区的。
- pthread_t到底是什么类型取决于实现,但是对于Linux目前实现的NPTL线程库来说,线程ID本质就是进程地址空间共享区上的一个虚拟地址,同一个进程中所有的虚拟地址都是不同的,因此可以用它来唯一区分每一个线程。
- 所谓线程ID可以理解为每个新线程在库当中的内存位置的起始地址,线程控制块的起始地址。
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
void* thread(void* args)
{
int cnt=5;
while(cnt--)
{
printf("new thread tid:%p\n",pthread_self());
sleep(1);
}
return (void*)101;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,thread,nullptr);
while(1)
{
printf("main thread tid:%p\n",pthread_self());
sleep(1);
}
return 0;
}