线程同进程一样都是OS中非常重要的部分,线程的应用场景非常的广泛,试想我们使用的视频软件,在网络不是很好的情况下,通常会采取下载的方式,现在你很想立即观看,又想下载,于是你点击了下载并且在线观看。学过进程的你会不会想,视频软件运行后在OS内形成一个进程,有一个执行流,但下载和在线观看是两件事情,这两件事情是如何同时进行的呢?你可能会想到CPU的时间片轮转,不过曾经提到过的时间片轮转是针对进程间的切换的,下载和在线观看这两件事本身处于同一个进程内完成,你可能还会想到在这个进程内创建一个子进程,主进程负责播放,子进程负责下载,这确实是一个解决问题的方法,但是创建一个进程所带来的开销是不小的。本篇文章将会介绍另一种更加轻便的解决方案——线程,同时我们需要重新理解CPU时间片轮转的调度单位
什么是线程
按照课本上的定义,线程就是进程内部的执行流,有多个执行流就意味着一个进程可以同时进行多个操作,比如视频软件,同时具备播放视频和下载视频的功能,如果只有一个执行流,那么在播放视频时就不能同时下载视频,因为播放视频和下载视频的代码是不同的
以前我们一直认为进程是CPU的调度单位,现在我们要改变这个看法,被CPU调度意味着被CPU执行,也就是一个执行流,一个进程里可以有多个线程,线程才是CPU的调度单位。所谓的调度单位就是CPU时间片轮转时的切换单位,以前我们解释CPU时间片轮转时说的是每个进程都被分配一定的CPU执行时间,到达时间,CPU会强制切换到下一个进程,以保证每个进程都能够被执行
此时,通过线程的概念能得知,CPU时间片轮转切换的并不是进程,而是线程。但上面的话并没有说错,一是创建一个进程时,默认只有一个执行流,也就是只有一个线程,时间片轮转时可以认为是切换进程。二是在后面我们将学习到Linux其实并没有线程的概念,所谓的线程在Linux中是轻量级进程
有些懵没有关系,后面会一一解释原因
现在线程的概念先放到一边,我们接下来再次回顾曾经学习过的进程地址空间
深入理解页表
这是笔者曾经多次提到过的进程地址空间映射图,并且说过虚拟地址空间和物理内存之间一一映射,那么大家有没有思考过这么一个问题,假设虚拟地址空间有4G大小,物理内存也是4G大,而页表是虚拟地址空间和物理地址空间的一一映射,这意味着页表自身得有8G大小的空间才能够满足虚拟地址空间和物理内存之间一一映射,要知道,页表可也得加载到内存中才能让CPU执行,照这样的映射法,物理内存连一个页表都存不了,更何况4G物理内存空间还得留1G给OS呢
可想而知,页表的映射不会像哈希表那样一一对应,要明白页表的真实构造,我们就得从物理内存的划分开始
实际上物理内存是按4kb为单位进行划分的,每个大小单位被称为页框,大家知道磁盘往内存中加载数据时就是以4kb大小为单位,正好能够加载到物理内存的页框中,这看似巧妙的背后是前人无数日夜的精心设计
但是这好像并没有说明页表的真实构造,别急,接着往下看
真实的页表并不是只有一张,页表里存储的也不是虚拟地址和物理地址的一一对应,页表里正真存储的是物理内存中每个页框的起始地址,一张页表里只存储指定数量的页框,整个物理内存的页框被多张页表存储着
这多张页表被页目录记录着,通过页目录可以找到每一张页表,到这里,页表的整体结构就出来了,可见,当初我们刚了解页表时,进行了很大程度的简化。但是这就结束了吗?笔者只是把页表真实的结构给描绘出来,但是并没有解释现在的页表是如何进行映射的
上图是虚拟内存中的一个虚拟地址,接下来我们刨析这个虚拟地址如何通过页表最终映射到物理内存
虚拟地址映射到物理内存的方法就在地址本身上,通过虚拟地址的前10位可以到页目录中找到该地址对应在哪个页表,找到具体的页表之后,虚拟地址的中间10位标识着该地址在物理内存的哪个页框里,找到具体的页框之后,那么最后12位想必大家已经猜出来了
最后12位正是页框内的偏移地址,因为一个页框大小就是4kb,要想在某个页框内准确定位,就要知道该页框的起始地址以及在该页框内的偏移地址。至于对不对,咱们验证一下
一个地址的大小是4字节,2的12次方是4096,4096 * 4字节 = 4kb,所以验证正确
如上,真实的页表映射结构就展现在我们眼前,笔者这里并不是心血来潮讲一下页表,通过上述的过程大家能感受到地址空间是进程接触并使用资源的窗口,页表则决定了,进程拥有哪些资源,只有页表映射到的物理内存,进程才能够访问,那么通过地址空间+页表映射进行资源划分,就可以对一个进程所用的资源进行分类
理解进程和线程
现在回到对线程的讲解上,前面说到过线程是进程内部的执行流,一个进程可以拥有多个线程,如下图,这些线程通过使用共同的地址空间和页表从而共享进程的资源,这意味着一个进程里的多个线程共享该进程的资源
前面还提到过,CPU的基本调度单位是线程,被CPU调度执行,那就得有上下文信息,那么线程就要保存好自己的上下文信息,当被CPU切换执行时,可以将上下文信息重新载入到CPU的寄存器中。线程在共享进程资源的同时也会产生自己的执行数据,也是需要保存起来的。线程是CPU调度的基本单位,这就意味着系统中会存在大量的线程等待被CPU调用,根据以往的经验,存在大量的线程时,OS要有序将其管理起来,就得给线程设计一种数据类型,设计方法还是多次提到过的先描述,再组织
给线程设计数据类型就要考虑线程的id号在系统中唯一,同时要能存储上下文信息,线程在被CPU调度时,要有自己的状态信息,同时在执行过程中要有自己的栈结构, 并且线程共享进程资源,那么文件描述符表什么的也要有,越往下举例,就能明显感受到这不就是当初学习进程时,进程的结构里所包含的内容吗?
可以发现进程结构和线程结构大量的内容都是重叠的,如果进程和线程两种结构同时存在系统中,就会造成大量的冗余,而Linux是一个非常注重效率的OS,于是聪明的Linux设计者决定不为线程设计一个独立的结构,而是采用了轻量级进程结构,也就是说在Linux系统中,进程和线程实际上使用的是同一种结构
这一点与windows有很大的不同,windows就为线程设计了一个独立的结构,这也体现了两种OS各自的设计哲学
如何理解线程就是轻量级进程呢?如何理解现在的进程概念呢?
曾经我们认为一个task_struct就是一个进程,一个task_struct有一个执行流,并且记录着该执行信息的执行状态。现在学了线程,知道进程和线程共同使用task_struct结构,对于每个进程或线程,内核都会为其分配一个唯一的task_struct结构,现在的task_struct是一种轻量级进程,也就是说一个进程里可能含有多个task_struct,不能再将一个task_struct理解成一个进程。但这并不是说曾经学的就是错误的,曾经创建一个进程,默认有一个执行流,也就是有一个主线程,该主线程是创建进程本身的执行流,task_struct就是这个主线程的结构,故而也可以将task_struct理解成进程本身,但是多线程后,有多个task_struct,再按照以前的方法理解进程就显得不严谨了
假设现在一个进程创建了三个线程,那么会有几个task_struct呢?
如果一个进程创建了三个线程,那么通常会有四个task_struct结构,在Linux中,每个进程都有一个主线程,也就是创建该进程的线程,主线程有一个对应的task_struct结构,对于每个创建的线程,也会有一个对应的task_struct结构。 故而,对于一个进程而言,如果额外创建了三个线程,那么会有一个主线程的task_struct结构,以及三个子线程的task_struct结构,共计四个task_struct结构,这四个task_struct结构共同构成了该进程的线程组成部分
站在CPU的角度上,曾经时间片轮转时切换task_struct就是切换一个进程,现在CPU时间片轮转切换一个task_struct是切换进程的一个分支,如果这个进程只有一个主线程,那就是切换进程本身
总而言之,现在一个进程有多个执行流,进程的概念不能局限于曾经只有一个执行流的task_struct,而是一个拥有多个task_struct的承担分配系统资源的基本实体
实践线程操作
说了这么多,咱们连线程长什么样子都不知道,接下来咱们通过实践来感受线程的魅力
不过在动手敲代码之前,需要明确一些事情,因为用轻量级进程来表示线程是Linux系统独特的线程处理方式。虽然这能很大提高效率,但是也带来了不通用的麻烦,很多OS,包括OS的理论基础上都是有线程这个概念的,因此并不通用Linux的轻量级进程,大家都在使用线程接口,而你Linux搞特殊提供轻量级进程接口,大家是不认的,为了解决这个问题,Linux工程师就将轻量级进程接口进行封装,适配成大家都通用的线程接口
这意味着,我们在使用Linux线程接口时,要在编译时带上线程动态库即选项 -l pthread
创建一个线程是通过接口
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
头文件:pthread.h
参数
thread:返回线程ID (输出型参数)
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
第一个参数是一个输出型参数,我们在主函数里创建一个pthread_t类型的变量,将其的地址传过去,创建线程后,会把该线程的id写入到这个pthread_t类型变量里
我们目前不需要关心 att r这个参数,可以看到第三个参数是一个函数指针,其所指向的函数就是创建一个线程后,该线程去执行的任务
第四个参数是对第三个参数的补充,在我们编写线程要执行的函数时,有时是需要外部给这个函数传参的,那个这个函数就会默认有一个void* 类型的参数,这个参数就是通过pthread_create的第四个参数传递过去的
下面看一个线程代码示例
#include<iostream> #include<unistd.h> #include<pthread.h> #include<cstring> #include<cstdlib> #include<cstdio> using namespace std; void* start_routine(void * arg) { while(true){ printf("%s\n", (char*)arg); sleep(1); } } int main() { pthread_t thread_id; char buff[64]; snprintf(buff, sizeof(buff), "我是新创建的线程,我正在运行"); pthread_create(&thread_id, nullptr, start_routine, (void*)buff); int counter = 10; while(counter--){ printf("我是主线程,运行倒计时:%d\n", counter); sleep(1); } return 0; }
这个示例可以看到,真的有两个执行流同时在跑
通过命令ps -aL可以查看所有进程内的线程,接下来我们让两个线程不间断运行,然后查看这两个线程的相关信息
可以发现,当test程序跑起来后,出现了两个test线程,这两个线程的PID是相同的,说明这两个线程来自同一个进程,不过两个线程的LWP不同,LWP(light weight process,即轻量级进程)LWP就是所谓的线程ID了,并且第一个线程的PID和LWP相同,这说明该线程是主线程,CPU在调度时,是以LWP为标识,表示一个特定的执行流
上面只是创建单个线程,那么如何同时创建多个线程呢?
看下面的demo,我们一次创建10个线程,并且不停打印他们的序号
#include<iostream> #include<unistd.h> #include<pthread.h> #include<cstring> #include<cstdlib> using namespace std; #define MAX 10 void* _start_test(void* arg){ while(true){ sleep(1); cout << (char*)arg << endl; } return nullptr; } int main(){ for (int i = 0; i<MAX; i++){ pthread_t tid; char buff[64]; snprintf(buff, sizeof(buff), "this is %d thread", i); pthread_create(&tid, nullptr, _start_test, buff); } while(true){ sleep(1); cout << "我是主线程"<<endl; } return 0; }
当执行结果出来后,完全超出了我们的预期,我们本想这10个线程,各自打印各自的序号,可是结果每个线程都打印序号9
出现这种情况的原因是线程被创建后的执行顺序是不确定的,当第一个被创建的进程还没来得及执行它的start_routine函数时,主线程就已经把所有的线程都创建完毕了,buff是在循环里被被创建的,出了循环后就被销毁,然后再次创建,因为都是在同一个栈里,所以每次buff的地址都不变,且buff的值不断被覆写,直到最后一个线程创建完毕,buff的值被覆写为序号9
此时循环退出,buff也被销毁了,但是由于main函数这个栈还在,也没有开其他的栈,因此原先buff指向的空间并没有被清理,导致所有的线程都打印最后一次覆写buff的内容,通过这个demo,可得知线程除了独自的PCB,独自的上下文结构,独自的栈结构,其他几乎所有内容都是共享的
每一个线程都有自己独立的栈,这是因为一个线程在执行时,可能会调用各种函数,因此需要一个独立的栈,这个栈里的内容不与其他线程共享
线程终止
会创建线程之后,自然而然的会想到,线程如何终止,导致线程终止的原因有很多
1.执行完start_routine()后,线程会自动return结束
2.使用pthread_exit()来终止当前线程,但是要注意,不要习惯性的使用exit()来终止线程,exit()是用来终止进程的,进程终止,该进程内所有的线程都会终止
3.某个线程执行过程中,出现错误,触发OS检查,会给当前线程的进程发送信号,进程收到信号会终止,该进程内其他所有线程都会终止
4. 一个线程可以调用pthread_ cancel()终止同一进程中的另一个线程
线程等待
同进程一样,线程结束后其所申请的各种资源都是需要被回收的,不然会产生类似僵尸进程一样的问题,线程等待使用函数pthread_join()
int pthread_join(pthread_t thread, void **retval);
第一个参数就是被等待的线程id,第二个参数是获取该线程的返回值的,还记得start_routine有一个void* 返回值吗?这个返回值就是通过pthread_join获取的
注意:OS维护的是轻量级进程PCB,因为Linux特殊的线程方案,可以说没有线程概念。程序员日常使用习惯了线程接口,因此Linux提供了线程库,线程库负责线程接口与轻量级进程接口之间的转换,以及维护用户通过接口创建好的线程
分离线程
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源
使用接口:int pthread_detach(pthread_t thread);
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离
pthread_detach(pthread_self()); pthread_self()获取自己的线程id
需要注意的是,一旦一个线程已经处于分离状态,那么该线程就不能被等待
线程取消
线程取消也就是当线程跑起来后,我们通过主线程或者其他线程可以取消这个线程继续运行
也可以自己取消自己
int pthread_cancel(pthread_t thread);
返回值:成功返回0;失败返回错误码
注意:只有当该进程运行起来,有自己的线程ID时才可以被取消
TCB
PCB是Linux内核用来管理轻量级进程的内核,因为Linux没有线程的概念,程序员要使用线程的接口,因此要通过线程库进行转接,那么程序员每申请一个线程,线程库就得维护好这个线程和轻量级进程进行转换,那么TCB就是线程库维护线程的结构
由图中可以得知,我们接收的所谓的线程id值其实就是库中维护的该线程TCB的起始地址
线程的优缺点
优点
线程的使用能非常大程度上发挥多核CPU的实力,并且创建多个线程比创建多个进程的开销要小的多,为什么呢?
如果CPU执行时,要切换一个进程,那么要切换的内容至少包含页表,虚拟地址空间,PCB,上下文数据
而切换一个线程,那么只需要切换PCB,上下文数据等主要内容
CPU在执行一个进程时,会在寄存器中缓存该进程的很多热点数据,例如虚拟地址空间,页表等,一旦切换进程,这些热点数据要全部重新加载,而切换线程,这些数据不需要动
缺点
在运行计算密集型程序时,线程需要不停的计算,持续占有CPU,切换到其它线程的时间就会延长,导致效率低下
使用多线程编程会有互斥和同步等问题,程序的编写和维护成本很高
C++提供的线程库
虽说Linux提供了线程库,但是Linux的线程接口和Windows下的线程接口很多都是不同的,这就导致程序的可移植性很低, C++11之后,在语言层面上对Linux和windows平台下的线程接口再进行一次封装。如此以来,用C++线程库编写的多线程程序可以同时在这两个OS平台下执行,代码的可移植性大大提高
下面的demo简单演示了如何使用C++提供的线程库,这部分属于C++的知识了,笔者将在C++专栏中介绍其详细使用方法
#include<thread> #include<iostream> #include<unistd.h> using namespace std; void* start_routine() { int counter = 10; while(counter--){ sleep(1); cout << "我是新创建的线程,运行倒计时:" << counter <<endl; } return nullptr; } int main() { //创建一个线程,并把执行函数传递过去 thread t1(start_routine); cout << "我是主线程" <<endl; //主线程阻塞等待回收子线程 t1.join(); cout << "线程回收完毕,准备退出"<<endl; return 0; }
文章的最后,大家可以尝试自己模仿C++的线程库,对Linux的线程库再进行一次封装