文章目录
前言
什么是线程?
对于大多数教材对线程的定义是这样的: 线程就是 进程内部运行的一个执行流分支,属于进程的一部分,粒度比进程更细更加轻量化;
可是这定义还有很多信息我们是不知道的。
什么是进程内部运行?
什么是一个执行流?
什么是粒度更细更轻量化?
我们尝试推导一下:
线程是进程的一个执行流分支,那么也可以说一个进程中可以有多个线程,这么说OS也是需要管理线程的,那么要管理线程,就必须先描述线程的属性是什么,类似进程中的PCB一样,线程也会有自己的TCB,然后再管理通过管理线程的属性达到管理线程的目的;
但是上面的思路不是Linux的做法,这是一般OS管理线程的方案,比如windows;
理解Linux中的线程
我们先回忆一下:程序加载到内存是如何被调度的:
程序通过加载器把我们的代码和数据加载到物理内存,然后OS再为该程序创建一个PCB,此时该程序就变成了进程;
该PCB有一个struct_mmp 指针,指向该进程的虚拟地址空间,然后通过页表建立和物理地址之间的映射,这样进程就可以找到真实的代码代码和数据了;
然后只要等到该进程被调度时候,CPU就会去拿到该进程的PCB里面的上下文信息,开始调度该进程,这都完全没问题;
此时我们的一个进程就包括这几个大部分的认识:代码+数据+PCB+虚拟地址空间+页表
;
下图就是上面文字解释的大概过程:
知道上面的进程是什么后。然后,我们继续来:
我们假设我们可以尝试创建多个PCB,然后共享上面的第一个PCB的虚拟地址空间,然后把该虚拟地址空间的代码和数据划分为若干份,供给多个PCB使用;(至于如何划分,这是OS的事,它肯定有能力,暂时不说这个);
此时在CPU看来,它实际在调度的时候,它看到的就是每个进程里的一个PCB而已;
此时这个PCB可能是该进程里的一部分,也可能是另一个进程里的一部分,更加有可能是另一个进程只有一个PCB的情况(这种情况就是我们以前理解的进程的概念,CPU调度进程,就是看到只有一个PCB的情况);
也就是说,现在我们CPU调度时候,再也不是以前那种以进程方式理解调度了,以前调度就是一个进程一个进程调度,每个进程中都是只有一个PCB,而现在CPU调度调度时候,是一个进程里面可以有多个PCB,不再是一个PCB了,CPU调度时候,看到就是PCB而已;
总的一句话就是:在Linux中,一个PCB就是一个被调度的执行流,Linux没有专门的线程TCB控制块,而是用进程的PCB去模拟线程这个概念而已;
你说线程这个概念有吗?当然有啦,在OS的概念中,它是存在的,只是我们Linux设计是没有线程这个概念的,也就是没有给线程专门搞一个TCB结构,但是却通过进程来成功模拟了线程这个概念!这也是Linux设计的伟大之一啊!
这样设计也是有好处,这样设计我们就不用专门设计一套TCB,去重新定义线程,维护这个线程,我们可以直接使用进程的那一套调度策略和属性,达到了模拟线程的目的了;使得维护成本大大降低,我们的Linux只需要聚焦点放在线程是如何分配到资源就可以了;
如下图:线程就是这么理解:
有了上面的理解,我们快速的回顾一个开头的一个问题:
什么是线程?
线程进程的对比:在下面的框框
linux 线程与线程接口库的关系
由于我们线程是用进程来模拟的,所以LinuxOS中,并不会提供专门给线程操作的接口,只会给进程操作的接口(比如fork),LinuxOS只给我们提供在同一个进程地址空间内创建PCB的方法(也就是所谓的vfork函数),分配资源给指定的PCB,但是这种方式对用户来说特别不友好!!!
所以我们一些大佬工程师,自己搞出了一个线程库,也就是用户层的线程库,来操作线程,比如创建线程,线程退出,等待线程等操作;这些接口都被打包成一个库,我们也称为原生库,也就是最解决OS的一个用户层库;
线程的共享数据和私有数据
线程是共享进程大部分资源的,从我们上面的图画出来也可以看出;
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
但是线程也是有自己的私有数据部分的,也就是自己独立的那一部分数据;
比如线程自己的线程ID,上下文切换信息,栈信息,错误码,优先级,信号屏蔽字等啥的;
线程优点
- 创建一个新线程的代价要比创建一个新进程小得多;
进程创建不仅要创建PCB,还要建立虚拟地址,页表,物理地址直接映射关系等其他资源的建立;
而线程创建只需要创建PCB,然后就可以共享进程的大部分资源了;
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
进程切换,不仅要上下文信息切换,还要建立什么页表直接的练习,保存缓存等信息;
而线程切换,只需要切换上线文信息就饿可以;
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
线程异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃;
因为线程崩溃,本质是OS给进程发送信号终止进程。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
线程用途
合理的使用多线程,能提高CPU密集型程序的执行效率
合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是
多线程运行的一种表现)
线程创建–pthread_create函数
我们火速看看一个线程是如何通过代码被创建出来的,同时验证一些对线程的理解!!!
- pthread_t 本质是一个无符号长整形的变量;iidp是一个输出型参数;
- 第二个参数attr :我们通常设置为NULL,表示使用线程的默认属性,因为我们也不太了解线程的属性,直接操作他,显得麻烦,直接使用默认即可;
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#include<string.h>
void* thread_run(void* args)
{
const char* str = (const char*) args;
while(1)
{
printf("我是%s 线程,我的进程id是%d\n",str,getpid());
sleep(1);
}
}
int main()
{
pthread_t tid;
int err;
if( (err= pthread_create(&tid,NULL,thread_run,(void*)"thread_1")) != 0)
{
fprintf(stderr,"pthread_create error:%s",strerror(err));
}
while(1)
{
printf("我是main 主线程,我得进程id是%d\n",getpid());
sleep(1);
}
return 0;
}
通过这个程序我们发现:
-
有两个执行流,因为我们两个代码部分都是死循环,假如没有两个执行流是不可能都执行的;
-
虽然有两个执行流,但是进程号却只有一个,这也验证线程就是进程内部的一个执行流而已;
-
我们通过
ps -aL
命令查看,两个执行流中,的LWP(light weight process)是不一样的,其实也就是内核标识线程的id是不一样的,但是他们的PID是一样的,也说明一个进程内部有多个线程;也说明CPU调度的单位肯定不是进程PID,而是线程的LWP; -
我们发现第一个线程的LWP和进程的PID是一样的;这也可以侧面说明,假如一个进程只有一个执行流,那么它调度的进程就是线程,线程就是进程了;
同时我们把第一个线程也成为主线线程,主线程的LWP是和进程的PID是一样的;
创建多个线程方式
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
void* thread_run(void* args)
{
const char* str = (const char*) args;
while(1)
{
// printf("我是%s 线程,我的进程id是%d\n",str,getpid());
sleep(1);
}
}
int main()
{
pthread_t tid[5]; //创建五个线程
int i = 0;
for(;i<5;i++){
pthread_create(tid,NULL,thread_run,(void*)"thread");
}
while(1)
{
printf("我是main主线程,我的线程ID=%lu\n",pthread_self());
printf("___________________begin_________________\n");
for(i = 0;i <5;i++){
printf("我创建的线程[%d]的ID=%lu\n",i,pthread_self());
}
printf("___________________end_________________\n");
sleep(1);
}
return 0;
}
这个主要是创建多个线程,查看线程的ID,没有实际的意义;
其实,这里我们发现一个很奇妙的问题:pthread_create返回的线程ID和内核的LWP线程标识符不一样!到底如何理解这个现象呢?
线程ID及线程在进程地址空间布局
其实LWP和pthread_create返回的线程ID 不一样 很好理解 ;一个LWP是OS内核表示线程的标识符(准确说是轻量级进程),而pthread_create返回的线程ID 是线程库里面的一个返回值,表示线程的ID而已。
一个是OS内核的东西,一个是线程库的东西;他们都不是同一个东西,所以说不一样我们是可以理解的;
我们主要想搞清楚的是pthread_create返回的线程ID 是什么?而不是说他们为什么不一样!
其实pthread_create返回的线程ID 是一个内存地址!!!!!
我们来解释一下:
当一个进程被运行起来,肯定会建立自己的PCB和虚拟地址,页表物理地址的关系对吧!
于是乎,当代码运行到到创建线程的函数的代码时候,就会搞出一PCB,但是这个PCB是内核PCB,也就是OS搞出来的,也即是LWP标识的东西;
而我们线程库为了能够被使用,肯定要加载到内存的!
即使系统有多个进程,也没关系,我们只要让每个进程的虚拟内存共享区域通过每个进程的页表映射到物理内存线程库即可!物理内存的线程库即有一个,也可以对应于多个进程使用它!
而我们线程创建函数返回的线程ID,是磁盘的线程库,再加到内存空间,通过页表映射到虚拟内存的共享内存空间区域里面的一个用户及线程属性的一个成员罢了!
注意说的是用户线程属性,也就是线程库里面的线程属性,线程库为了管理线程,会有自己的对线程描述的数据结构;这个数据结构就是用户级线程;
用户级线程里面包含很多线程属性,其中我们比较属性的有线程创建返回的线程ID,还有线程自己的栈结构!它都在线程属性这;
而我们线程属性是保存在进程虚拟地址空间的共享空间部分;
当我们创建线程成功时候,线程就需要去执行自己的线程函数,线程函数里面的临时数据,就是保存在共享空间的线程栈里面,而我们进程的临时数据,就是保存在我们的进程空间的栈区域呀!
当我们的进程创建了多个线程,也就是在该进程的共享区域空间,搞了很多个线程属性而已!
这里的在进程的每一个线程属性都对应OS内核的唯一一个PCB,也就是pthread_create返回的线程ID和OS的LWP一一对应;在OS的书籍就是一个用户线程对应一个内核级线程!!!
只要我们线程创建函数,返回到了线程的ID,也就是线程的地址,我们线程函数就可以重这里开始执行;
下面是画一幅图理解一下
线程等待
功能:等待线程结束
原型
int pthread_join(pthread_t thread, void **value_ptr);
参数
thread:线程ID
value_ptr:是一个输出型参数,获取线程id 为 thread 函数的返回值
返回值:成功返回0;失败返回错误码
对于一个线程执行流这个函数,它无非就有三种结果:代码 跑完结果对,代码跑完结果错,代码没跑完异常;而前面的代码跑完的两种结果都是可以通过返回值得到;我们可以在其他线程(通常我们都在主线程)调用线程等待函数,获取线程的信息;
如果调用线程等待函数,去等待线程退出,很有可能会产生类似于僵尸线程的现象!本身线程就是用进程模拟实现的,所以这种现象极其有可能产生!
调用该函数的线程将挂起等待,直到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
线程的终止状态不感兴趣,可以传NULL
给value_ ptr
参数。
#include<stdlib.h>
#include<stdio.h>
#include<pthread.h>
#include<unistd.h>
#include<string.h>
typedef struct{
int d1;
int d2;
}Argc; //线程的返回值结构体
void* th_fn(void* argc)
{
Argc* r = (Argc*) argc;
return (void*)(r->d1+r->d2); //返回两个值累加的结果
}
int main()
{
pthread_t tid;
int err; //创建线程出错的信息
Argc r = {
10,20}; //
if((err = pthread_create(&tid,NULL,th_fn,(void*)&r)) != 0)
{
perror("pthread_create error : ");
exit(1);
}
//线程创建成功后,主线程等待子线程退出(阻塞等待)
int* ret;
pthread_join(tid,(void**)&ret);
printf("主线程等待子线程的结果是:%d\n",(int)ret);
return 0;
}
成功在主线程,获取子线程的信息!
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数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;失败返回错误码
线程分离
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。此时我们就可以在线程函数中调用线程分离函数pthread_detach,表示该线程执行结束后,自动释放资源,并且退出。
注意:joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
也就是说:当我们线程是分离状态时候,就不可以等待该线程退出了,不然会报错!
如何使用线程分离?
如果我们希望某个线程是分离状态,那么就在该线程函数的开头,调用函数pthread_detach(pthread_self())
即可;
由于线程退出,线程等待,线程分离的代码过于简单,就不演示了!