👍作者主页:进击的1++
🤩 专栏链接:【1++的Linux】
文章目录
- 一,Linux线程概念
- 二,线程的优缺点
- 进程和线程类比现实
- 三, 线程的操作
- 线程的私有资源 && 线程的创建
- 线程的等待
- 线程终止
- 线程分离
- 四,如何理解线程id
一,Linux线程概念
1. 什么叫做线程?
在官方的定义中:是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
也就是说,线程在进程中运行,是OS调度的基本单位。
2. 类比进程对线程再做理解
线程在进程中运行,所以一个进程中可能有多个线程,那么要不要将这些线程管理起来???答案是要的,所以,先描述,再组织。用特定的数据结构将线程管理起来,在一般的OS系统中,我们将这种结构称为TCB,但是Linux中却不是这样实现的。
我们分多个视角来看Linux下的解决方案:
CPU视角: 我们的CPU只认PCB结构体,我拿到一个PCB结构体,我就执行其所对应的代码和数据。我并不关心这是进程还是其他所谓的定义给用户的一些概念。
我们以前所 认识的进程都是只有一个执行流的进程,其内部也可以有多个执行流,这每个执行流我们其实就可以称为线程。我们上面说过,我们的CPU只认PCB,也可以说是,CPU认为一个PCB就是一个执行流。那么每个线程就就需要一个 PCB来进行管理,在其他的一些系统中,这个PCB叫做TCB,但在Linux中,不是这样做的,Linux认为,既然你的大多是结构都和进程类似,那么我可不可以就把你当作一个" 进程 "呢?答案是可以的,这样我就可以简化我OS的复杂度。
那么怎么将程当作一个线程呢?
我们再来回顾以下线程的概念:线程在进程中执行,也就是说,线程在进程的地址空间中运行,一个执行流我们其实就可以称为一个线程。那么我们只需在创建一个特殊的进程,能够与进程共享地址空间既可以,再 通过特殊的手段将当前进程的资源分配给这些特殊的进程,我们将这种特殊的进程就可以称作线程。
在CPU视角,Linux下PCB<=其他OS下的PCB 为什么呢?
因为linux并没有为线程准备特定的数据结构。在内核看来,只有进程而没有线程,在调度时也是当做进程来调度。linux所谓的线程其实是与其他进程共享资源的进程。为什么说是轻量级?在于它只有一个最小的执行上下文和调度程序所需的统计信息。他是进程的执行部分,只带有执行相关的信息。
内核的视角: 进程是分配系统资源的实体,那么线程就是承担进程一部分资源的实体,进程划分资源给线程。
我们的CPU所调度的实际上是线程,因此线程也是CPU的基本调度单位
因此Linux下的进程统一称为:轻量级进程
Linux下并没有真正意义上的线程,而是用进程的PCB去模拟实现线程。我创**建一个线程和我的父进程挂接到同一份地址空间上,然后在分配资源,分配完还要管理,管理完进行释放。**所以对用户特别不友好!这就是linux用进程去模拟线程的缺点。所以OS并不能直接给我们线程相关的接口,只能提供轻量级进程的接口。但是我们普通用户使用起来是有难度的,因此在用户层又实现了一套用户级的线程接口(以第三方库的形式)。
linux中vfork系统调用,它的作用就是创建一个进程,但是这个进程和父进程共享地址空间。
二,线程的优缺点
优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
下面我们对上面的某些观点进行解释:
为什么说创建一个新的线程代价要比进程小的多: 因为进程之间是独立的,创建一个新进程,就必须要创建新的地址空间,页表,并将代码和数据进行映射。而创建一个线程,我们只需要创建一个新的PCB,并将对应进程中的部分资源划分给线程。
为什么说与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多: 我们的执行流在切换时不仅仅是把在CPU和寄存器中的数据(也就是当前线程的上下文数据)切下来,再把新的线程的数据和代码拿上去跑。我们的计算机为了提高效率,在内存和CPU之间还有多级缓存,从内存中读取数据时,并不是只读自己想要的部分。而是读取足够的字节来填入缓存中。
在切换线程时,由于我们的线程之间是共享地址空间的,因此多级缓存中的东西就不需要去切换,但是在切换进程时,我们的进程又自己独立的进程地址空间,因此缓存中的东西必须都得切换掉。
缺点
性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
性能损失具体是指什么:
若我们有一个非常大的计算任务分给多个线程去做,且我们的线程要比可用的处理器要多,此时我们的某个线程执行到一般被切了下去,让另一个线程去执行,此时就会比让这个线程一直执行计算任务效率低的多。
进程和线程类比现实
说了这么多进程和线程的概念,那么我们可不可以将进程和线程类比现实中的东西呢?答案是—可以的!!!
我们将OS类比这个社会,在这个社会中承担分配资源的实体是一个个家庭,每个家庭都有自己的房子,家庭和家庭之间互相独立,偶尔可以串门进行通信,家庭里有一个个成员,大家各司其职,为了过更好的日子而努力。一个家庭就是一个进程,家庭中的各个成员就好比线程。在这个家庭中,沙发,电视机,冰箱等都是共享的,我们每个成员都可以使用,但是也有一些你自己私有的东西,其他成员是不能看的。
线程出现的目的是为了提高我们的效率,当一个执行流阻塞时,另一个执行流可以接着干其他的事情,还可以是,有多个处理器时我们的多执行流可以同时运行。就好比一个家庭中,一个认在做饭,另一个人可以去打扫卫生,而不是让一个人做完饭再去打扫卫生,这样效率就非常低了。
三, 线程的操作
线程的私有资源 && 线程的创建
由于线程在进程中执行,线程和进程是共享地址空间的,所以其大部分资源都是可以共享的,但也有部分资源是私有的。
- 栈:体现的就是每个线程在运行形成的临时数据是可以被压栈入栈的,线程和线程之间临时数据不会互相干扰。
- 上下文(一组寄存器):调度上下文,因为一个线程是调度的基本单位,所以一定会形成自己在CPU寄存器中的临时数据,线程是调度的基本单位,必须要有独立的上下文。
- 线程ID
- errno
- 信号屏蔽字
- 调度优先级
下面我们对上述的部分说法进行验证:
该函数是用户级的线程库给我们提供的一个创建线程的接口,
一个新的线程,在编译链接的时候需要引入pthread这个库 。
第一个参数:创建线程的id
第二个参数:线程属性
第三个:线程的回调属性,意味着你要让你的线程执行你代码的哪一部分
第四个参数:给这个回调函数传入的参数
返回值
我们来看一段代码:
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<cassert>using namespace std;void* thread_new(void* args)
{while(true){cout<<"I am new thread"<<endl;sleep(1);}
}int main()
{pthread_t id;int n=pthread_create(&id,nullptr,thread_new,(void*)"thread 1");while(true){cout<<"I am main thread"<<endl;sleep(1);}return 0;
}
我们可以看到确实有两个执行流在运行。ps -aL可以查看轻量级进程。LWP是轻量级进程的id。
我们可以看到其线程pid确实是私有的。我们还可以观察到,其进程pid是一样的,并且有一个线程pid和进程pid是一样的,该线程我们叫做主线程。
谁调用这个函数,就获取谁的线程id。
void* thread_new(void* args)
{while(true){cout<<"I am new thread"<<"我的id是"<<pthread_self()<<endl;sleep(1);}
}int main()
{pthread_t tid;int n=pthread_create(&tid,nullptr,thread_new,(void*)"thread 1");while(true){cout<<"I am main thread"<<"我创建的线程id "<<tid<<endl;sleep(1);}return 0;
}
线程的健壮性是有问题的,接下来我们进行验证:
void* thread(void* args)
{if(args==(void*)4){int count=3;while(count--){sleep(1);}//除0int a=1;a/=0;}else{while(true){cout<<"I am new thread"<<" id :"<<pthread_self()<<endl;sleep(1);}}}
int main()
{pthread_t tid[5];for(int i=0;i<5;i++){pthread_create(tid+i,nullptr,thread,(void*)i);}while(true){cout<<"............................."<<endl;cout<<"I am main thread"<<endl;cout<<"............................."<<endl;sleep(1);}return 0;
}
我们发现程序在三秒后就因为除0错误而退出了,这也说明只要有一个线程出问题,整个进程都得要退出。也说明了线程的健壮性不强。
线程的等待
和进程一样,线程也是需要主线程等待的,否则就会形成僵尸,从而造成内存泄漏。
第一个参数: 你要等那个线程,传的是线程id。
第二个参数:输出型参数,用来获取新线程退出的时候,函数的返回值。因为你的线程执行函数的返回值是void*,我要以参数的形式把你的返回值拿出来,我就必须是void**。
一个执行流的执行结果有三种情况:代码跑完,结果正确,代码跑完,结果不正确,异常。前两种情况我们在前面学习进程中可以以退出码的形式观察到。但在线程中我们使用的是返回值。
下面是相关代码演示:
void * thread(void* args)
{int count=5;while(count--){sleep(1);cout<<"I am new thread"<<endl;sleep(1);}return (void*)3;
}
int main()
{pthread_t tid;pthread_create(&tid,nullptr,thread,(void*)"thread 1");void* retval=nullptr;pthread_join(tid,&retval);cout<<retval<<endl;return 0;
}
我们发现主线程确实接收到了新线程的返回值,并且是阻塞式等待。
那么对于代码异常这种情况,pthread_join能或者需要处理吗?
根本不需要,因为线程出现异常是进程的问题。线程出现问题,主线程根本管不了。某个线程出现问题退出了,那么整个进程就会退出。
线程终止
- 从线程函数return.(a.main函数退出return的时候代表进程退出(进程退出又叫主线程退出)b.其他线程函数return ,只代表当前线程退出)
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
下面是相关代码演示:
void * thread(void* args)
{int count=5;while(count--){sleep(1);cout<<"I am new thread"<<endl;sleep(1);}pthread_exit((void*)3);//return (void*)3;
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,thread,(void*)"thread 1");void* retval=nullptr;pthread_join(tid,&retval);cout<<retval<<endl;return 0;
}
void * thread(void* args)
{int count=5;while(count--){sleep(1);cout<<"I am new thread"<<endl;sleep(1);}// pthread_exit((void*)3);return (void*)3;
}int main()
{pthread_t tid;pthread_create(&tid,nullptr,thread,(void*)"thread 1");void* retval=nullptr;while(1){sleep(3);pthread_cancel(tid);sleep(2);break;}return 0;
}
我们可以观察到新创建的进程确实退出了,并且退出码位-1。
这里的-1就是PTHREAD_CANCELED。所以以后我们发现一个线程的退出码是-1,就证明当前线程是被取消的 。
线程有程序替换吗?
线程也可以调用程序替换,但是多线程中所有的代码和数据都是被线程共享的,如果其中有一个线程执行了程序替换,就直接影响到了其他的线程,所以在大部分情况下,很少让线程去调用程序替换,除非你让线程创建子进程再程序替换。一般程序替换和进程强关联。所以不考虑线程的程序替换。
线程分离
线程有程序替换吗?
以上等待线程是阻塞等待,如果我们不想等呢?
线程分离,分离之后的线程不需要被join,运行完毕之后,会自动释放Z状态的pcb,不需要我们等了。这个功能类比于进程中signal(SIGCHLD,SIG_IGN)直接忽略掉。所谓的分离只是设置线程的一种状态表示它不需要被等。就像我们在前面进行的类比,将进程比作一个家庭,线程是家庭中的一个成员,线程分离好比,你仍然在这个家住,但没人在去管你,但是,你要是出现异常退出了,那么整个家也会受到影响。
下面进行演示:
void * thread(void* args)
{pthread_detach(pthread_self());int count=5;while(count--){cout<<"I am new thread"<<endl;sleep(1);}return (void*)3;
}int main()
{pthread_t tid;int g_id=pthread_self();pthread_create(&tid,nullptr,thread,(void*)"thread");void* retval=nullptr;cout<<"I am main thread"<<endl;sleep(1);int n=pthread_join(tid,&retval);printf("retval:%d---n:%d\n",retval,n);return 0;
}
我们可以看到join的返回值不为0,则表示等待失败。
一个线程被设置为分离后,绝对不能在进行join了。使用场景:主线程不退出,并且主线程不关心新线程的返回结果,新线程处理业务处理完毕后自行退出。
四,如何理解线程id
我们会发现线程id和LWP是不一样的,这是为什么呢?
我们查看到的线程id是pthread库的线程id,不是linux内核中的LWP,pthread库的线程id是一个内存地址。这个内存地址是一个虚拟地址。
那么我们又该如何理解线程id呢?
线程在进程中运行,在进程中通过pthread_create创建一个新的进程,这个函数是谁提供的?–是我们的第三方库pthread库提供的,这个库在磁盘中,当我们要使用时,其被加载到内存中,被映射到当前进程的地址空间中的共享区,若有多个进程想要访问,我物理内存中只需要有一份,就都映射到自己的共享区就可以了。
每个线程在运行时,都会有自己的上下文数据,因此,每个线程都需要自己的私有栈来保存这些上下文数据,我的线程要被管理,虽然线程状态等信息在内核的LWP中,但在用户层也要获得相关的属性,这些属性就要放在栈中,但我们用户级栈只有一个,不可能让多个线程去共享,这样使得数据混乱或者被覆盖。这个栈是主线程私有的栈。那么新线程的栈从哪里来呢?我们的用户级线程是由pthread库帮我们创建的,那么新线程的栈也应由它来为我们维护,这个库在我们的共享区,其就会在共享区为我们开辟新线程私有的栈,我们只需要拿到这个栈在地址空间的起始地址,就可以访问这个栈中的代码和数据了。因此将这个虚拟地址作为了我们用户层面所看到的线程id。它与内核中的LWP是一 一对应的。
这叫做用户级线程1:1式的和内核轻量级进程进行1:1对应,这就是linux实现线程的方案。