一 什么是线程
1.1 线程的引出
我们开始理解一下Linux中的线程。我们以前说过,一个进程被创建出来,要有自己对应的进程PCB的,也就是 task_struct,也要有自己的地址空间、页表,经过页表映射到物理内存中。所以在进程角度,我们是通过地址空间的映射看到操作系统给予我们的资源,所以地址空间是进程的资源窗口,地址空间也是进程的资源!
以前我们谈的进程,它所创建的地址空间内的所有资源,都是由一个叫 task_struct 所享有的,那么页表也是属于它独有的。那么如果我们再创建一个“进程”,但是不再给这个“进程”创建新的地址空间和页表,它只需要在创建时指向“父进程”的地址空间。将来“父进程”就将代码区中的代码分一部分给这个“子进程”,以及其它数据分一部分给它,此时我们就可以让“父进程”在运行的时候“子进程”也在运行。那么该父进程能创建一个,就能创建很多个,如下图:
那么我们新创建出来的“子进程”,它们在执行粒度上要比“父进程”的执行粒度要更细一些,因为以前“父进程”需要执行全部代码,而这些“子进程”只需要执行一部分代码,所以,为了明显区分这些“子进程”和“父进程”,我们把这种形式的“子进程”,称为线程!
所以在 Linux 中,线程在进程“内部”执行,也就是线程在进程的地址空间内运行。那么它为什么要在进程的地址空间内运行呢?首先,任何执行流要执行,都要有资源!而地址空间是进程的资源窗口,但是线程有地址空间吗,没有,因为他并没有建立与内存的联系,线程需要依附于进程!
那么在 CPU 看来,它知道这个 task_struct 是进程还是线程吗?它需要知道吗?并不需要!因为CPU只有调度执行流的概念!
这样,我们的多个新task_struct都可以被CPU调度,不再像以前一样只有进程被CPU调度,大幅提高了CPU的效率,并且这些task_struct的的创建成本明显比一个进程小的多,因此这些能被CPU调度却没有被操作系统分配资源的tack_struct,我们称为线程。
1.2 线程的定义
那么有了上面的基础,我们现在重新定义线程和进程的概念:
线程—— CPU调度的最小单位
所以什么到底什么是进程呢?我们以前说的进程等于 描述进程的结构体+ 代码数据,但是今天很显然已经有分歧了,因为它只是地址空间的一个执行分支,一个执行分支不能代表整个进程!那么我们现在需要重新理解一下了,全部 task_struct 执行流都叫做进程执行流,地址空间都叫做进程所占有的资源,页表和该进程所占用的物理内存,我们把这一整套才称之为进程!如下图:
进程—— 操作系统分配资源的最小实体
那么执行流是资源吗?是的!所以不要认为一个进程能被调度,它就是进程的所有,它只是进程内部的一个执行流资源被CPU执行了!所以进程和线程之间的关系是:进程内部是包含线程的,因为进程是承担分配系统资源的基本实体,而线程是进程内部的执行流资源!
那么如何理解我们以前学的进程呢?其实就是操作系统以进程为单位给我们分配资源,只是我们以前进程内部,只有一个执行流资源,也就是只有一个 task_struct!只是我们可以认为,以前我们学的进程只是进程的一种特殊情况!
二 线程管理
那么既然操作系统要对进程管理,如果线程多起来了,操作系统要对线程管理吗?很明显,如果不对线程管理,那么线程就不知道自己属于哪个进程,更不知道应该执行哪个进程的代码,所以必须得对线程管理,所以需要先描述再组织进行管理!
同时,我们以前也学过,进程有描述进程并管理的结构体,有些系统对线程也有类似的结构:
所大多数操作系统都是对线程重新进行先描述再组织,重新为线程建立一个内核数据结构对线程管理起来,而这个结构叫做 struct tcb;除此之外还要把进程和线程之间关联起来。实际上这样做太复杂了,维护的关系太复杂了。
在Windows操作系统,内核中有真线程,名为TCB :线程控制块。因为 TCB 属于 PCB,所以还需要维护进程与线程之间的调度关系算法,这过于复杂。
在Linux中,由于线程的控制块与进程控制块相似性非常高,所以直接复用了PCB的结构体,用PCB模拟线程的TCB。所以Linux没有真正意义上的线程,而是用进程方案模拟的线程。这样做的好处是复用代码和结构更简单,好维护,效率更高,也更安全。
三 页表的补充说明
我 们上面的进程中,创建线程后给线程分配一部分代码和数据,也就是资源,那么我们应该如何理解基于地址空间的多个执行流分配资源的情况呢?怎么知道哪部分资源给哪个线程呢?接下来我们基于地址空间理解一下。
首先CPU里面有一个CR3寄存器,它会保存页表的地址,方便找到进程的页表。我们也知道,物理内存被分为许多的页框,每个页框的大小为 4KB。下面我们理解一下虚拟地址是如何转换为物理地址的,我们以32位的计算机为例,也就是虚拟地址也是32位的。
接下来我们展开说一说页表。首先,页表不是一个整体,我们假设页表是一个整体,就单单是一个映射关系,如下图,每一列分别是虚拟地址、物理地址、权限,假设每一行就10个字节,单单这一个页表建立整个虚拟空间的地址映射关系就需要有 2^32 个映射条目,这样算下来这个页表就已经几十G了,所以页表不可能是这个形式的。
其实 32 位的虚拟地址不是一个整体,其实是将它分为了 10 + 10 + 12,其中 10 + 10 分别代表一级和二级目录。
其中第一级页表,只有 1024 个条目,也就是一个数组,因为用 10 个比特位表示的最大值就是 1024,所以这 10 个比特位代表的十进制数就是该一级页表的下标,而一级页表中存放的是二级页表的地址,所以只需要拿着前十位找到二级页表的地址,找到二级页表,然后拿着次十位,也是 10 个比特位,把它转为十进制数,然后在二级页表中索引它的下标,那么二级页表中存的是什么呢?存的是页框的起始地址!如下图:
其实这个一级页表就叫做页目录,我们把页目录里面的内容叫做页目录表项;把二级页表里面的内容叫做页表表项。所以我们就能通过虚拟地址的前 20 位找到物理内存中页框的起始地址。
那么剩下的 12 位呢?那么我们知道 2^12 的大小刚好就是 4096,如果取字节为单位,也就是页框的大小!所以剩下的 12 个比特位就是作为某个物理地址的页框中的偏移量!也就是说,物理地址 = 页框起始地址 + 虚拟地址的最后12位!所以这就是虚拟地址到物理地址转换的过程!
在正常情况下,我们不可能将虚拟空间全部用完,所以二级页表也不一定全部存在。所以当需要访问一个虚拟地址时,怎么知道这个虚拟地址在不在物理内存中呢?就有可能在查页目录的时候,它的二级页表的目录根本就不存在,说明就没有被加载到内存,这个时候就是缺页中断。另外,也有可能二级页表和页框没有建立映射关系,在二级页表中还有一个字段中的标记位会记录页框是否存在。
那么就有一个问题了,我们通过页表找到的是物理内存的某一个地址,可是对于某一个类型,可能是 int、double 等等,我们并不是访问一个字节呀,对于上面两种类型我们访问的是 4、8 个字节啊。这时候,就能体现了类型的价值!例如一个整型变量 a,占4个字节,就要有4个地址,但是为什么我们 &a,只拿到了一个地址?因为我们只能取一个地址,那么4个地址中只能取最小的那一个,由于有类型的存在,我们只要从下往上连续读取 4 个字节就能找到它了!也就是根据起始地址+偏移量读取该变量。那么CPU怎么知道根据什么类型读取多少字节呢?其实类型是给 CPU 看的,CPU在读取类型时,是知道有多少字节的!我们根据软件帮CPU找到起始地址,接下来CPU就要读取内存,读的过程把物理内存在硬件上拷贝给CPU,拷贝的时候CPU就知道拷贝多少字节了!
所以我们上面说的 CR3 寄存器中,指向的其实是页目录的地址,任何一个进程必须得有页目录。如果对物理地址进行访问的时候,如果物理地址不存在,或者越界了,CPU 中的 CR2 寄存器,保存的是引起缺页中断或者异常的虚拟地址,完成建立物理地址后就会去 CR2 取回对应的虚拟地址。
最后,我们谈上面的内容都是为了理解如何进行资源分配的,线程的资源全部都是通过地址空间来的,而代码和数据都是通过地址空间+页表映射来的,所以线程分配资源的本质,就是分配地址空间范围!
四 线程和进程的切换
为什么线程比进程要更轻量化呢?
- 创建和释放更加轻量化
- 切换更加轻量化
线程切换时,线程的上下文肯定是要切换的,但是,页表不需要切换,地址空间不需要切换,所以线程在切换的时候,只是局部在切换,所以线程切换的效率更高。
线程在执行,本质就是进程在执行,因为线程是进程的执行分支。线程在执行本质就是进程在调度,CPU内有一个硬件级别的缓存,叫做 cache,cache 也是根据局部性原理,将线程/进程当前访问的代码附近的代码都加载到 cache 中,所以在进程调度的时候它应该会越跑越快,因为它的命中率会越来越高,这部分 cache 我们称为进程运行时的热数据,热数据就是这部分数据被高频访问,所以CPU在硬件上它就会把对应的数据加载到 cache 里。
所以在调度的时候,它切换的是一个进程中的多个线程,那么它在切换的时候,此时上下文虽然一直在变化,但是 cache 里的数据一直不变,或者少量的更新,因为每一个线程很多属性都是共享的,就是为了让多个线程同时访问,所以数据就可以在一个进程内部的多个线程互相调度的时候,CPU当前 cache 中的数据就可以被多个线程用上,所以在线程切换的时候,只需要切换线程,不需要对 cache 保存。但是当线程的所有时间片用完了,整个进程也要被切换,CPU寄存器要保存,最重要的是,热缓存数据需要被丢弃掉,把另一个进程放上来,需要重新缓存 cache 中的数据,就要需要由冷变热,这就需要一段时间。所以线程切换的效率更高,更重要的是体现在 cache 数据不需要重新被缓存!
五 线程的优缺点
5.1 线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- 线程不是越多越好,正常情况下最合适的原则是:进程/线程与cpu个数/核数保持一致
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
- 线程不是越多越好,但是比计算密集型应用可以多一点
5.2 线程的缺点
- 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变
- 健壮性降低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高:编写与调试一个多线程程序比单线程程序困难得多。
六 线程异常
如果一个线程出现除零错误或者是野指针这种类似的异常,就会导致线程崩溃,但同时,对应的进程也会崩溃。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
七 线程作用
- 合理的使用多线程,能提高CPU密集型程序的执行效率。
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)。
八 线程和进程
- 进程是操作系统资源分配的基本单位
- 线程是CPU调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据。
线程拥有的数据:线程ID,一组寄存器(线程上下文),栈,errno,信号屏蔽字,调度优先级。
进程的多个线程共享同一地址空间,因此代码区数据区都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到。除此之外,各线程还共享以下进程资源和环境:
文件描述符表,每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数),当前工作目录,用户 id 和组 id
九 线程控制
9.1 pthread线程库
因为 Linux 中没有专门为线程设计一个内核数据结构,所以内核中并没有很明确的线程的概念,而是用进程模拟的线程,只有轻量级进程的概念。这就注定了 Linux 中不会给我们直接提供线程的系统调用,只会给我们提供轻量级进程的系统调用!
可是我们用户需要线程的接口,所以在用户和系统之间,Linux 开发者们给我们开发出来一个 pthread 线程库,这个库是在应用层的,它是对轻量级进程的接口进行了封装,为用户提供直接线程的接口!虽然这个是第三方库,但是这个库是几乎所有的 Linux 平台都是默认自带的!所以在 Linux 中编写多线程代码,需要使用第三方库 pthread 线程库!
这样一来,就不必在Linux中进行线程和进程的区分,但是在我们程序员眼中,就有了线程和进程两个概念,并且可以使用线程。
注意:因为pthread线程库是一个第三方库,最后我们在编译的时候需要加上 -lpthread 指定库名称。
9.2 线程的创建
pthread_create函数
函数原型:
#include <pthread.h>int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
其中,第一个函数是一个输出型参数,一旦我们创建好一个线程,我们是需要线程ID的,该参数就是带出线程id
个参数attr为线程的属性,一般不用关心,设置为nullptr即可
第三个参数是一个函数指针类型,当我们创建线程时,我们一般是想让线程去执行当前进程的一部分代码和数据,这个函数指针就是让我们的线程去执行的部分代码,当我们传入一个指针时,线程一启动就会转到执行该指针指向的内容。同时,它的类型还是void*类型,因为void*类型可以接收或者返回任意指针类型,这样就可以支持泛型。
第四个参数arg是一个输入型参数,当线程创建成功的时候,新线程内部执行的函数如果需要参数,就用arg传入函数,也就是说 该参数是给第三个参数函数指针传入的。
而函数的返回值,如果我们创建成功就返回0;如果失败会返回错误码,而没有设置 errno.
示例代码:
这里我们创建一个线程,并给他传入数字1,让新线程打印数字1,同时让新线程和主线程打印自己的pid。
#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<cstdio>
using namespace std;void* pthread_handl(void* arg){while(1){printf("我是一个线程,我的pid为:%d,传入数据为:%d\n",getpid(),*(int*)arg);sleep(1);}}int main(){pthread_t id;int i=1;pthread_create(&id,NULL,pthread_handl,(void*)&i);while(1){printf("我是一个主线程,我的pid为:%d\n",getpid());sleep(1);}return 0;
}
9.3 线程的查看
如上图,我们以前写的代码中是不可能出现两个死循环的,但是使用创建线程之后就可以了,这就说明它们是不同的执行流。而它们的 pid 是一样的,就说明它们是同一个进程。
那我们该如何查看不同的线程呢?他们归属于同一个进程,根据上图也可以看出,单纯的用pid是无法区分出线程的,因此,我们引入了线程标识符 -- LWP
在linux中,我们可以通过 ps - aL 查看线程的LWP。
如下图示例:
(还是以前的代码)
在我们右侧终端中,正在查看两个执行流,我们上面循环打印 了方便观察,我们看到 pid 是一样的,但是 LWP 的准确定义到底 是什么呢?
在 Linux 中没有具体的线程概念,只有轻量级进程的概念,所以 CPU 在调度时,不仅仅只要看 pid,更重要的是每一个轻量级进程也要有自己对应的标识符,所以轻量级进程就有了 LWP (light weight process)这样的标识符,所以 CPU 是按照 LWP 来进行调度的!
但是我们如果杀掉上面任意一个执行流的 LWP,默认整个进程都会被终止,这就是线程的健壮性差的原因。
如果我们定义一个函数,或者全局变量,分别在两个执行流中执行,它们都可以读取到该函数和全局变量,如下代码:
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<string>
using namespace std;const int N=10;void Print(const string &s){cout<<s<<endl;
}void* pthread_handl(void* attr){while(1){Print("子线程测试打印函数");cout<<" 子线程得到N:"<<N<<endl;sleep(1);}
}int main(){pthread_t tid;pthread_create(&tid,nullptr,pthread_handl,nullptr);while(1){Print("父线程测试打印函数");cout<<"父线程得到N:"<<N<<endl;sleep(1);}return 0;
}
9.4 线程的等待
那么创建线程后是主线程先运行还是新线程先运行呢?不确定,要看CPU先调度谁,那么肯定的是主线程是最后退出的!因为主线程退了整个进程就退出了,所以主线程要进行线程等待!如果主线程不进行线程等待,会导致类似于僵尸进程的问题!而 pthread_join() 就是进行线程等待的接口。
其中第一个第一个参数,为等待线程的 id
第二个参数 retval 我们先不管,其实我们给线程分配的函数,它的返回值是直接写入 pthread 库中的,而 retval 也是被封装在库中,所以我们可以根据 retval 读取到函数的返回值,也就是说这个 retval 就是一个输出型参数!首先我们需要定义一个 void* 类型的变量,然后将这个变量取地址当作 pthread_join 的第二个参数传入即可!
下面我们简单写一个程序:
#include<iostream>
#include<unistd.h>
#include<pthread.h>using namespace std;void *pthread_handl(void *arg){int cnt = 5;while(cnt--){cout << "我是新线程" << endl;sleep(1);}return (void*)1234;
}int main(){pthread_t tid;pthread_create(&tid, nullptr, pthread_handl, nullptr);void* retval;pthread_join(tid, &retval);cout << "进程退出, retval = " << (long long)retval << endl;return 0;
}
我们可以看到当新线程在运行的时候,主线程并没有直接运行结束,而是进行阻塞等待,同时我们也看到函数返回值1234.
9.5 线程的退出
注意,我们在单个线程退出时,使用的exit等函数,会使整个进程一起退出,因为线程始终是进程的一部分,这种停止进程的函数不可能缩小范围去停止单个线程。
那么除了在函数中直接 return 终止线程外,还有什么方法吗?有的,pthread_exit() 接口就是用来终止线程的:
例如:
#include<iostream>
#include<unistd.h>
#include<pthread.h>using namespace std;void *pthread_handl(void *arg){int cnt = 5;while(cnt--){cout << "我是新线程" << endl;sleep(1);}pthread_exit((void*)1234);
}int main(){pthread_t tid;pthread_create(&tid, nullptr, pthread_handl, nullptr);void* retval;pthread_join(tid, &retval);cout << "进程退出,子线程退出码为:retval = " << (long long)retval << endl;return 0;
}
9.6 线程的取消
pthread_cancel() 也可以取消一个线程,参数就是目标线程的 id:
#include<iostream>
#include<unistd.h>
#include<pthread.h>using namespace std;void *pthread_handl(void *arg){int cnt = 5;while(cnt--){cout << "我是新线程" << endl;sleep(1);}pthread_exit((void*)1234);
}int main(){pthread_t tid;pthread_create(&tid, nullptr, pthread_handl, nullptr);void* retval;sleep(3);pthread_cancel(tid);pthread_join(tid, &retval);cout << "进程退出,子线程退出码为:retval = " << (long long)retval << endl;return 0;
}
如果 thread 线程被别的线程调用 pthread_ cancel 异常终掉, pthread_join 第二个参数 retval 所指向的单元里存放的是常数PTHREAD_ CANCELED,也就是 -1.