一、线程基本概念
1、认识线程
提到线程,那么我们不得不先回忆进程,我们知道进程是在内存中执行的程序,然后进程=进程内核数据结构+数据和代码。然后我们创建一个进程要给其创建对应的task_struct,虚拟地址空间,页表等。
线程:是在进程内部的执行分支。
本质就是在进程的虚拟地址空间进行运行。
线程属于操作系统调度的基本单位,进程是承担分配系统资源的实体。
下面我们直接学习在Linux中,线程的原理和如何操作的。
原理如下:
首先对于线程,其是进程的一个执行分支,但是其也是会有其标识符的,但是呢,其代码是复用的我们进程的,那么会有一个task_struct,这个叫做进程的TCB。
然后其和进程的虚拟地址空间是共享的。
进程内部会存在一个或者多个线程,前面我们学习的进程,其实就是只有一个执行流的进程,也就是只有一个线程的进程。
所以我们的进程的概念又要更新了:
进程=多个线程+内核数据结构+代码和数据
在我们CPU中需不需要将我们的进程和线程进行细分呢?
不需要,线程是属于操作系统调度的基本单位,在CPU角度来说,只有线程,没有进程,只有执行流,也就是线程,然后线程在Linux中也称为轻量级进程。
2、分页式存储管理
我们的操作系统对于物理内存,其读取都是按照4KB的单位进行读取的,所以实际上,对于物理内存的管理,其是分为一个一个叶帧或者页框的。
然后我们的虚拟地址空间和物理内存之间的映射就是按照4KB的单位进行映射的。
那么我们的物理内存大小是固定的,那么我们可以将物理内存进行划分,那么就分为固定的分区,就好比我们前面学习磁盘的时候,进行分块的操作,那么也可以通过下标的方式进行访问。
就比如,我们现在的物理内存是4GB,那么我们按照个页框的大小为4KB,那么就一共可以分为
1024*1024=1048576个,那么就可以按照数组下标的方式进行访问了。
不过有个疑问,就是,我们的页表有个问题,占用内存很大,如果我们的进程使用了全部的内存,也就是4GB,那么我们的页表就要映射1048576个地址,然后我们知道我们一个虚拟地址是32位的二进制数,那么就需要4096个空间,那么就是4MB的空间,那么这个内存就太大了。
实际上,我们的虚拟地址也是分为三个部分的:
前十位其表示的是页目录,中间10位表示的是页表,那么页目录中,其记录的是每一个页表,然后呢,我们的页表中,其映射的是当前进程所使用到的物理内存页框。
然后我们一个页表就是2^10个的映射关系,然后我们的一个页目录可以映射2^10个页表,那么就是1024*1024个物理内存的页框,那么不就刚刚好是4GB的空间。
这⾥的每⼀个表,就是真正的⻚表,所以⼀共有 1024 个⻚表。⼀个⻚表⾃⾝占⽤ 4KB ,那么 1024 个⻚表⼀共就占⽤了 4MB 的物理内存空间,和之前没差别啊? 从总数上看是这样,但是⼀个应⽤程序是不可能完全使⽤全部的4GB空间的,也许只要⼏⼗个⻚表就 可以了。
例如:⼀个⽤⼾程序的代码段、数据段、栈段,⼀共就需要 10 MB 的空间,那么使⽤ 3 个 ⻚表就⾜够了。
不过我们不需要考虑其是如何转换的,这些工作是都由硬件来帮我们完成。
3、线程的优点
创建⼀个新线程的代价要⽐创建⼀个新进程⼩得多
与进程之间的切换相⽐,线程之间的切换需要操作系统做的⼯作要少很多 。最主要的区别是线程的切换虚拟内存空间依然是相同的,但是进程切换是不同的。这两种上 下⽂切换的处理都是通过操作系统内核来完成的。内核的这种切换过程伴随的最显著的性能 损耗是将寄存器中的内容切换出。 ◦ 另外⼀个隐藏的损耗是上下⽂的切换会扰乱处理器的缓存机制。简单的说,⼀旦去切换上下 ⽂,处理器中所有已经缓存的内存地址⼀瞬间都作废了。还有⼀个显著的区别是当你改变虚 拟内存空间的时候,处理的⻚表缓冲 TLB (快表)会被全部刷新,这将导致内存的访问在⼀ 段时间内相当的低效。但是在线程的切换中,不会出现这个问题,当然还有硬件cache。
线程占⽤的资源要⽐进程少 • 能充分利⽤多处理器的可并⾏数量
在等待慢速I/O操作结束的同时,程序可执⾏其他的计算任务
计算密集型应⽤,为了能在多处理器系统上运⾏,将计算分解到多个线程中实现
I/O密集型应⽤,为了提⾼性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
4、线程的缺点
性能损失 ◦ ⼀个很少被外部事件阻塞的计算密集型线程往往⽆法与其它线程共享同⼀个处理器。如果计 算密集型线程的数量⽐可⽤的处理器多,那么可能会有较⼤的性能损失,这⾥的性能损失指 的是增加了额外的同步和调度开销,⽽可⽤的资源不变。
健壮性降低 ◦ 编写多线程需要更全⾯更深⼊的考虑,在⼀个多线程程序⾥,因时间分配上的细微偏差或者 因共享了不该共享的变量⽽造成不良影响的可能性是很⼤的,换句话说线程之间是缺乏保护 的。
缺乏访问控制 ◦ 进程是访问控制的基本粒度,在⼀个线程中调⽤某些OS函数会对整个进程造成影响。
编程难度提⾼ ◦ 编写与调试⼀个多线程程序⽐单线程程序困难得多
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
线程是进程的执⾏分⽀,线程出异常,就类似进程出异常,进⽽触发信号机制,终⽌进程,进程 终⽌,该进程内的所有线程也就随即退出
5、线程VS进程
进程间是具有独立性的,进程是线程的承载实体。
线程间资源共享,也就是共享进程间的资源
线程间也有自己私有的资源:
线程ID,一组寄存器,存放其上下文数据,栈空间等
同⼀地址空间,因此 Text Segment 、 Data Segment 都是共享的,如果定义⼀个函数,在各线程中 都可以调⽤,如果定义⼀个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和 环境:
⽂件描述符表
每种信号的处理⽅式(SIG_IGN、SIG_DFL或者⾃定义的信号处理函数)
当前⼯作⽬录
用⼾id和组id
其有如下四种关系:
二、Linux线程控制
我们知道,在Linux中,其实是没有线程的,而是轻量级进程,在我们的C库函数中,提供了一系列的函数,使得我们用户层面上使用,就和线程一样。
下面我们通过线程的几个操作来学习这些库函数。
1、线程创建
与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以“pthread_”打头的
要使⽤这些函数库,要通过引⼊头文件<pthread.h>
链接这些线程函数库时要使⽤编译器命令的“-lpthread”选项。
因为这个是一个外部库,有的编译器版本,其不认识这个库,所以我们为了系统可移植性,在编译的时候都加上这个选项。
然后我们创建一个线程需要使用到下面这个接口:
pthread_create
其函数原型如下:
首先第一个参数,其是返回我们的线程的ID的,其实际上就是一个无符号长整型。
第二个参数,其是设置我们线程的属性的,不过大部分情况下,这个参数我们都不需要理,直接设置为NULL即可。
第三个参数就是我们要线程去执行的函数了,然后我们还看到这个参数的类型是void类型的,那么我们也可以传一个结构体。
然后这个函数的返回值,若线程创建成功,那么其返回0,如果创建失败,那么其会返回错误码。
这个函数对于错误检查也有点特点:
传统的⼀些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指⽰错误。
pthreads函数出错时不会设置全局变量errno(⽽⼤部分其他POSIX函数会这样做)。⽽是将错 误代码通过返回值返回。
pthreads同样也提供了线程内的errno变量,以⽀持其它使⽤errno的代码。对于pthreads函数的 错误,建议通过返回值业判定,因为读取返回值要⽐读取线程内的errno变量的开销更⼩。
下面我们通过代码来看其效果:
#include <unistd.h> #include <stdlib.h> #include <stdio.h> #include <string.h> #include <pthread.h> void *rout(void *arg) { int i; for (;;) { printf("I'am thread 1\n"); sleep(1); } } int main() { pthread_t tid; int ret; if ((ret = pthread_create(&tid, NULL, rout, NULL)) != 0) { fprintf(stderr, "pthread_create : %s\n", strerror(ret)); exit(EXIT_FAILURE); } int i; for (;;) { printf("I'am main thread\n"); sleep(1); } return 0; }我们上面的代码中,就只创建了一个线程,然后我们发现上面有两个PID是一样的。
但是其对应的LWP不一样,那么其就是两个线程了,我们在命令行中输入ps -aL就可以查看到当前程序的线程了。
不过我们也可以在程序中使用:
pthread_self(void)
使用这个函数,那么就可以获取到执行这部分代码的线程的ID了。
其就是一个类似地址的东西。
LWP是什么呢?LWP得到的是真正的线程ID。之前使⽤pthread_self 得到的这个数实际上是⼀ 个地址,在虚拟地址空间上的⼀个地址,通过这个地址,可以找到关于这个线程的基本信息,包括线 程ID,线程栈,寄存器等属性。
2、线程终止
当我们不需要使用这个线程的时候,那么我们有如下三种方式将其终止:
从线程函数return。这种⽅法对主线程不适⽤,从main函数return相当于调⽤exit。
线程可以调⽤pthread_exit终⽌⾃⼰。
⼀个线程可以调⽤pthread_cancel终⽌同⼀进程中的另⼀个线程。
这个函数,谁调用,那么就终止谁。其没有返回值
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是⽤malloc分配的, 不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel函数
这个函数就是允许我们的线程终止掉其他的线程
不过要注意的是,我们的其他线程,不能用来终止主线程。
3、线程等待
首先我们的新线程必须被我们的主线程等待,若是我们的主线程不等待新线程回收,那么可能会产生我们前面学习进程的时候,类似僵尸进程的问题。
然后和父子进程一样,我们的父进程有时候也要知道我们子进程的任务执行的咋样,所以需要将其退出信息进行获取,所以我们的父进程需要回收子进程的退出信息,那么就要等待子进程退出。
那么我们线程也是如此,我们创建多线程的目的是让其去完成任务,任务是否完成,完成的咋样,我们有时候也需要关心。
那么我们的主线程可以使用:
int pthread_join(pthread_t thread, void **value_ptr);
使用这个函数,那么就可以等待我们新线程的退出信息了。
首先第一个参数,就是我们要等待的线程的tid,然后第二个参数,其是一个输出型参数,那么我们看到其是一个二级指针,说明我们要传入一个一级指针。
那么这个指针变量就会存储到我们要等待的线程的提出信息。
下面是代码演示:
void *rout(void *arg) { std::string name=static_cast<const char*>(arg); while(true){ printf("我是新线程:%s,tid:0x%lx,pid:%d\n",name.c_str(),pthread_self(),getpid()); sleep(5); break; } return (void*)10; } int main() { pthread_t tid; pthread_create(&tid,nullptr,rout,(void*)"thread -l"); sleep(5); void*ret=nullptr; int n=pthread_join(tid,&ret); if(n==0) { std::cout<<"new thread joid success:"<<(long long)ret<<std::endl; } return 0; }如上,我们在新线程完成任务要退出的时候,我们返回一个10,那么我们的主线程在等待的时候,那么理论上获取到的退出信息就是10。
运行结果如下:
如果说我们的新线程早已退出,然后我们主线程的代码还没执行到等待那部分,那么我们是否还可以获取到新线程的退出信息呢?
实际上是可以获取到的,我们的线程退出,只是其内核资源被释放了,但是我们的进程还在,那么其退出信息是会保存在库中的,那么我们的主线程去等待获取的时候也是可以获取到的。
我们上面演示的是线程都是在没有出现异常的情况下的,那么若是我们的线程出现异常,那么又该如何呢?
前面我们提到了,如果我们一个线程出现异常,那么就会导致我们的进程直接被退出,那么这个就不是我们线程要考虑的问题了,而是进程的父进程来进行回收的了。
调⽤该函数的线程将挂起等待,直到id为thread的线程终⽌。thread线程以不同的⽅法终⽌,通过 pthread_join得到的终⽌状态是不同的,总结如下:
1、如果thread线程通过return返回,value_ptr所指向的单元⾥存放的是thread线程函数的返回值。
2、如果thread线程被别的线程调⽤pthread_cancel终止掉,value_ptr所指向的单元⾥存放的是常 数PTHREAD_CANCELED。
3、如果thread线程是⾃⼰调⽤pthread_exit终⽌的,value_ptr所指向的单元存放的是传给 pthread_exit的参数。
4、如果对thread线程的终⽌状态不感兴趣,可以传NULL给value_ptr参数。
4、分离线程
我们知道当我们的主线程去等待新线程退出的时候,那么我们的主线程就会阻塞等待,那么对于一些我们并不关心其退出信息的新线程,那么就会很影响效率了,所以对于这种新线程,那么我们可以将其进行分离操作,不过要注意的是,这个分离操作并不将其和我们的进程完成分离关系,只是说,我们不在关心其状态罢了。
其还是和其他线程一样,共享我们进程的虚拟空间,代码,资源等。
就是告诉我们的操作系统,该线程退出的时候自动将其释放。
int pthread_detach(pthread_t thread);
然后对于一个线程进行分离操作,可以是这个线程自己进行的,也可以是其他线程进行的。
如果是自己要进行,那么我们可以使用pthread_self传参。
然后要注意的是,我们的分离和join是冲突的,对于一个线程我们不能又分离又join,而是二者选其一。
代码示例如下:
void *thread_run( void * arg ) { pthread_detach(pthread_self()); printf("%s\n", (char*)arg); return NULL; } int main( void ) { pthread_t tid; if ( pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0 ) { printf("create thread error\n"); return 1; } int ret = 0; sleep(1);//很重要,要让线程先分离,再等待 if ( pthread_join(tid, NULL ) == 0 ) { printf("pthread wait success\n"); ret = 0; } else { printf("pthread wait failed\n"); ret = 1; } return ret; }三、线程ID及进程地址空间布局
通过上面的学习,还是有下面几个疑问:
pthread_t tid是啥?线程退出,退出信息,void**获取到退出,其在那里?
LWP、栈结构、tid在那边?
1、线程ID
我们前面提到,在我们的Linux下,其实是不存在线程这个东西的,而是称为轻量级进程,在C语言中提供了thread库,使得我们在Linux下也可以实现线程的效果。
首先,我们可库文件,其也是一个磁盘级文件,那么我们要调用这个库,就要先将其加载到内存中,然后其会映射到我们要使用这个库的进程的虚拟地址空间的。
前面我们查看到的线程id,我们发现其是一个和虚拟空间地址很像的无符号长整型,实际上,上面我们查看到的线程id就是一个地址。还有就是,一个进程,其未来会是有多个线程的,那么我们也要将其进行管理。
实际上这些管理信息是在我们的库中的,在thread库中,其含有我们对于线程管理的信息:
如上,实际上,我们线程的id的地址就是指向这一个一个管理信息的,就是struct pthread结构体。
这个库是会映射到我们进程虚拟地址空间的mmap区域的,当我们创建一个线程的时候,那么就会为这个线程创建一个线程的结构体,那么我们在创建线程得到的tid,其实就是指向这个结构体的起始地址的。
然后在这个结构体中,也包含了线程的退出信息,线程退出的时候,内核层面上只会释放进程空间里对应的轻量级进程的资源,然后会把线程退出信息写回到库中,我们的进程还在,那么我们的线程库就还在,那么其他的线程就可以通过调用join来获取到这个线程的退出信息。
对应线程是否被分离,其结构体中,会存在一个指针,当期指向自己的id的时候,那么其就是被分离的状态。
2、线程局部存储
前面我们提到,线程中大部分资源都是共享的,不过有的变量其是只允许线程自己使用的,每个线程都有一个自己独立的副本,互不干扰。这就是线程的局部存储(TLS)。
就是全局变量是线程之间共享的,然后线程的局部变量是每个线程私有的资源,当一个线程修改自己的局部变量,是不会影响其他线程的。不会出现同步问题。
当线程退出的时候,那么对应的局部存储也会被释放。
在多线程中,我们使用_thread关键字修饰一个变量,那么其就会变成一个局部存储变量。
四、线程封装
下面我们可以尝试自己进行线程的封装:
我们先创建两个文件,一个是我们对于线程的封装的文件,然后再创建一个Main文件对我们封装的线程进行测试:
首先,我们创建一个线程类,我们再将其封装在一个命名空间中:
然后,我们的线程需要有一个tid,然后还要一个名字,然后还有其状态,还有一个是否被分离的标识位。
然后状态,我们使用一个枚举类型来表示。然后我们还可以将线程的退出信息也设置一个:
基本结构如上所示。
下面我们完成线程创建的模块,线程创建,我们还是调用pthread_create函数来实现。
不过要注意的是,这个函数我们还需要一个任务,所以我们再弄一个函数即可:
但是当我们在线程类中创建一个任务,其提醒我们的参数不匹配,我们的线程函数要求的参数是一个void*类型,但是我们的任务函数也是如此呀,那么为啥会提醒参数不匹配呢?
这是因为在类中的方法,会默认有一个参数,就是this指针,所以其实际上是有两个参数的,那么如何解决呢?
我们可以将这个任务放置在类外进行定义,那么还有一个方式就是将这个方法使用static进行修饰,static 静态方法不属于对象,属于类,调用静态方法不需要创建实例,自然没有对象地址可以传给方法,因此不存在 this 指针
但是这样又会有一个问题,就是当我们想在这个方法中获取到我们线程的信息时,我们又没有this指针,那么又无法访问类成员变量了。
那么我们可以将创建线程的第四个参数传入this指针。那么我们的任务函数中就可以获取到this指针了。
我们再使用static_cast<Thread*>将这个参数转换为我们的线程类类型。
那么线程创建的代码如下:
然后我们再封装线程等待:
下面是线程回收部分:
如上就是对于线程进行一个简单的封装。