目录
一、 线程的概念
之前的文章说过每个进程有一个PCB,有对应的虚拟地址空间,还有页表。页表呢在信号那篇文章里讲过页表分为用户级页表和内核级页表,但其实页表还有很多其他的属性,诸如物理地址是否命中,RWX权限,用户级/内核级权限。所以不管是用户级页表还是内核级页表,映射的数据结构都是一样的,页表里的每一个条目就是一个数据结构。
char* str = "hello world";
*str = 'H'; //运行时会报错
我们知道上述字符串会被存储到常量区,当我们对str解引用时实际上是要找到字符串的起始地址,然后在对它做修改。我们要修改先通过页表查看是否有对应的物理地址,再看RWX权限,发现只有读权限,但是我们的操作是需要写权限的,所以地址转化单元(MMU)直接将当前访问行为进行终止,怎么终止呢?硬件会直接报错,操作系统识别硬件报错,这个报错俗称段错误,将报错转化成对应信号(11号),将信号发送给当前进程,进程会在合适的时候处理信号,处理信号的默认动作是终止进程,所以最后进程就被终止了。这就是为什么上述代码运行的时候会报错。
页表中的U/K权限,当权限是U(用户级)那么可以直接访问用户级数据,当权限是K(内核级),操作系统会去检查CPU的状态,如果是内核级,那么页表才会让你访问物理内存中内核的数据。
如何看待地址空间和页表?
1. 地址空间是进程能看到的资源窗口。比如代码区,堆区,栈区共享区,等等。
2. 页表决定进程真正拥有资源的情况。也就是说虽然地址空间有2^32个(4GB),但是真正拥有的物理内存是由页表决定的,页表说你有你才有。
3. 合理对地址空间+页表进行资源划分,我们就可以对一个进程所有的资源进行分类。也就是说地址空间这么多个区,通过页表映射到物理内存,这不就是分类嘛。
虚拟地址是如何通过页表转化成物理地址的?
我们知道虚拟地址空间有2^32个(4GB),那么要是按以前的简单理解,那么页表也得有2^32个项,我们按简单的算,页表都需要24GB空间,肯定是放不下的,所以页表我们要重新理解。物理内存被划分为一个一个的内存块(每个大小4KB),我们称这每一个块为页框,每个页框就是一个Page的结构体,它里面包含了内存的属性,而整个物理内存就是一个结构体数组。在磁盘中我们的程序也是被划分成一个一个4KB的块,这每一个块称为页帧,从磁盘加载到内存也是按4KB为单位加载的。
知道这些后,那么虚拟地址是怎么转化成物理地址的。在地址空间处会将虚拟地址进行拆分,通过前10位找到对应的页目录,下10位找到对应页目录的页表(页表项),该页表项存储了指定页框的起始物理地址,通过页表项就可以找到对应的页框;最后12位表示页内的偏移量,通过起始地址+偏移量就可以找到对应数据在物理内存上的地址。有可能我们程序比较小,只用了一个页目录一个页表,下面这些页表没有被使用,它就不会被加载,所以没有建立映射关系的页表就不会被创建。那么在物理内存中加载页表的内容就被大大减少了,就可以解决之前所说的内存空间不足的问题了。现在我们理解了虚拟地址只是一种编址方式。
什么是线程?
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流
进程的整体模型这里就不过多赘述了,前面的文章里也有讲过,进程是由内核数据结构+进程对应的代码和数据组成的。
那么什么是线程呢?比较常见的说法是线程是进程内的一个执行流。那么具体怎么去理解呢?
我们知道创建子进程后,子进程有自己的PCB,会给子进程分配自己独立的地址空间,以及页表,映射物理内存时会发生写时拷贝。
我们这里先岔开一下话题,如何看待虚拟内存呢?虚拟内存决定了进程能够看到的“资源”,把PCB比作一个待在房间里的人,虚拟内存则是房间里的窗户,你看到的风景是与窗户个数相关的,也就是说虚拟内存中这么多个区(代码区...栈区等等)就是进程能够看到的资源,当然进程除了以这种形式,还能看到其他资源,比如进程打开的文件资源。进程是通过地址空间+页表就可以访问载入到内存中代码和数据对应的资源。以前也说过进程是可以把自己的代码分割成另外的一部分,让另一个执行流去执行的,比如fork创建子进程,让子进程执行某部分代码。
那么如果我们只创建PCB,不创建额外的虚拟内存和页表,映射物理内存时也不发生写时拷贝,这些个PCB是指向同一个地址空间处的,也就是说他们可以看到同一份资源,把代码划分成多份就可以让多个这种执行流分别执行各自的部分,那么我们称这些PCB为线程。也就是说房间里本来只有一个人,现在有很多人,那么我们对窗户的区域进行划分,然后每个人有对应的区域,每个人去看自己对应区域的资源,然后执行相对应的代码即可。因为我们可以通过虚拟地址空间+页表方式对进程进行资源划分,那么单个线程的粒度一定比进程要细。
如果操作系统真的要专门设计线程概念,那么操作系统一定是要对线程进行管理的。那么怎么管理呢?
先描述,在组织。先描述那么就一定要为线程设计专门的数据结构表示线程对象(TCB),在组织首先要保证线程在进程内部,比如一个PCB中有一个链表,直接把进程内的所有线程用链表链接起来,所以在调度时,就先选进程再挑选线程进行执行,那么有没有操作系统就是这种情况的呢?
有,windows就是这么干的。如果真的存在了这样的线程控制块,那么一定有大量的线程存在,不光进程之间要维护父子关系,兄弟关系,进程还要被调度,那么进程中还有很多的线程,线程与线程之间的关系要维护,线程与进程之间的关系也要维护,所以进程控制块和线程控制块之间的代码逻辑和数据结构的设计,彼此之间耦合度一定非常的高,而且非常复杂。
但是我们回过头想一想,一个线程被创建的目的不就是被执行嘛,要被执行就要被调度,要被调度就要有id,状态,优先级,上下文,栈...,我们单纯从线程调度角度来看,线程和进程有很多地方是重叠的。那么线程需要被重新设计吗?
所以当时Linux工程师并没有给线程设计专门的数据结构,而是直接复用PCB,用PCB表示Linux内部的“线程”。这里有一个共识,线程是可以看到你划分给它的部分资源的,而CPU不关你是线程还是进程,它只关注你是不是task_struct(任务结构体)。
综上我们得出一个结论,线程是在进程的地址空间内运行,它拥有该进程的一部分资源。
了解了线程后,我们在这里要对进程的概念进行重构,在内核视角中,进程是承担分配系统资源的基本实体。也就是说创建进程后,操作系统会给进程分配一系列的资源。那么在Linux中什么是线程呢?线程是CPU调度的基本单位。
那么之前文章里说的进程的概念和此次相比有什么区别呢?之前的进程也是承担分配资源的基本实体,只不过他的内部只有一个执行流,而此次进程的内部是有多个执行流的。在linux中CPU不管你是线程还是进程,你只要是task_struct,它都认为是轻量级进程。
结论:
- 在Linux内核中没有真正意义上的线程,Linux是用进程PCB来模拟线程的,是一种完全属于自己的一套现成方案。
- 在CPU的视角中,每一个PCB,都可以称之为轻量级进程。
- Linux中线程是CPU调度的基本单位,进程是承担分配系统资源的基本单位
- 进程用来整体资源的申请,线程向进程讨要资源
- Linux中没有真正意义上的线程(轻量级进程)。
- Linux这么做的好处是,以前给进程设计的一系列的东西都可以复用到线程上,这样的实现简单且方便维护,因为复杂的东西往往难以维护或者说维护成本较高,但是简单的东西维护起来成本低,一个东西简单且好维护,那么它是可靠且高效的。
- 缺点:Linux没有线程的概念,但是操作系统和程序员他只认线程,所以Linux无法提供创建线程的系统调用接口,只能提供创建轻量级进程的接口!
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现,计算密集型应用(CPU,加密,解密,算法等)
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。I/O密集型应用(外设,访问磁盘,显示器,网络)
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多,少在哪呢?
1.切换进程需要: 切换PCB ,虚拟地址空间,页表,上下文。切换线程只需要: 切换PCB ,上下文;如果只是这些那并没有少很多。
2.切换线程不怎么更新cache(高速缓存),但是进程切换要全部更新。
在CPU中不只有寄存器,还有cache(高速缓存),数据一般不是直接从内存加载到寄存器的,而是先加载到高速缓存中,寄存器再从高速缓存中读取,这里就涉及到命中不命中的问题了,如果cache没有命中,再从内存中读取,也是先读取到cache中,再从cache当中读取。那么一个运行稳定的进程,cache中一定已经缓存了很多的热点数据(热点数据是需要花费时间才可以产出的),当线程进行切换时,因为线程本来就共享进程内的资源,所以这些热点数据大部分也被线程共享,所以切换线程不需要切换cache内的数据;而切换进程时,A进程和B进程的数据大部分不是共享的,所以切换进程时也会将cache中的数据切换掉,那么一来二去的,效率就变低了。
线程的缺点
性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多。
线程异常
一个线程出现异常是会影响其他线程的,所以健壮性或鲁棒性较差。当然健壮性差,不止出现在异常的时候。
如下图,新线程出现了段错误,但是主线程也被终止了,或者说整个进程被终止了。这是为什么呢?之前的文章说过,程序在运行时出现异常,操作系统会捕捉到异常进而转化为信号发送给当前进程;由于信号是发送给进程的,线程属于进程内的一个执行流,进程被终止了,进程拥有的资源也相应的要被回收,那么线程赖以生存的资源就没有了,所以所有的线程也自然就终止了。
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
线程用途
- 合理的使用多线程,能提高CPU密集型程序的执行效率
- 合理的使用多线程,能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具,就是多线程运行的一种表现)
二、线程的控制
创建线程
pthread_create函数
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
- 功能:创建一个新的线程
- 参数:
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
- 返回值:成功返回0;失败返回错误码
- 要使用函数库,要通过引入头文<pthread.h>
- 链接线程函数库时要使用编译器命令的“-lpthread”选项
错误检查:
传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小 。
//mythread
#include <iostream>
#include <pthread.h>
#include <cassert>
#include <unistd.h>
using namespace std;
// 新线程
void* thread_routine(void* args)
{
while(true)
{
cout << "我是新线程,正在运行" << endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid,nullptr,thread_routine,(void*)"thread one");
assert(0 == n);
(void)n;
while(true)
{
cout << "我是主线程,正在运行" << endl;
sleep(1);
}
return 0;
}
//makefile
mythread:mythread.cc
g++ -o $@ $^ -lpthread -std=c++11
.PHONTY:clean
clean:
rm -f mythread
虽然Linux没给我们提供线程的系统调用接口,它只提供了轻量级进程的接口,但是我们要用这个就需要再多费精力去学习这个轻量级进程的东西,所以Linux在系统调用接口之上提供了一个用户级线程库,这是任何一个Linux操作系统都必须携带的一个原生线程库。这样我们就可以像使用线程一样使用Linux的轻量级进程。
从下图可以看到确实有两个执行流在运行,也符合我们的预期,但是我们用以前查看进程状态的方式只能看到一个进程在运行,我们终止掉进程后所有的线程随之也终止了。这里的现象也很好的解释了线程是在进程内部运行的。但是我怎么查看线程的状态呢?
我们可以通过ps -aL指令查看,可以发现是有两个线程在运行的,这两个线程的PID是一样的,也就是说他们属于一个进程,LWP是轻量级进程ID,你可以认为他就是线程ID,那么线程ID和进程ID相同的就是主线程,那另一个就是新线程啦。CPU在调度的时候,当然就不是以进程ID作为标识符调度执行的,而是以LWP(轻量级进程ID)为标识符调度执行的。因为在之前的文章里了解的还不够多,所以只能暂时那么说。进程只有一个主线程时,由于PID和LWP是相同的,所以我们之前那么讲没有问题。
线程一旦被创建,进程内几乎所有的资源都被所有的线程共享。
进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
文件描述符表
每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
当前工作目录
用户id和组id
int g_val = 0;
string func()
{
return " 独立的方法 ";
}
// 新线程
void* thread_routine(void* args)
{
const char* name = (const char*)args;
while(true)
{
cout << "我是新线程,正在运行 name : " << name << func() << "-"<<g_val++ << ": &g_val :"<< &g_val<< endl;
sleep(1);
}
}
int main()
{
pthread_t tid;
int n = pthread_create(&tid,nullptr,thread_routine,(void*)"thread one");
assert(0 == n);
(void)n;
char namebuffer[64];
snprintf(namebuffer,sizeof(namebuffer),"0x%x",tid);
while(true)
{
cout << "我是主线程,正在运行 tid : " << namebuffer<< func() << "-"<<g_val << ": &g_val :"<< &g_val << endl;
sleep(1);
}
return 0;
}
我们发现这里打印的tid和用指令查看的LWP他们两个的格式是不一样的,那么tid究竟是什么呢?后面会说。
当然线程也是有自己私有的资源的:1.PCB属性私有,2.自己的上下文结构私有,3.每个线程有自己独立的栈结构。
线程共享进程数据,但也拥有自己的一部分数据:
线程ID
一组寄存器
栈
errno
信号屏蔽字
调度优先级
进程和线程的关系如下图:
CPU调度的基本单位是线程,所以创建的线程我们也不知道谁会先运行,这是由调度器说了算的。因为无法保证谁先运行,所以在调度的时候会出现一些奇怪的现象。如下图:
我们这里创建一批线程,创建时如果没有sleep,我们发现线程名字都一样,这是怎么回事呢?因为线程被创建出来,主线程并不会受影响,还是继续的在创建,继续的往缓冲区里写入,而新线程刚开始准备执行自己的代码,那个缓冲区里的内容就已经被写成是最后一个线程的名字了,由于我们只能传缓冲区的起始地址,所以新线程转换的时候就只能拿到最后一个线程的名字,那么就会出现下图的情况。
当然上述的代码写的是有点问题的,不过可以定义一个结构体,把想要给新线程的东西都放到结构体里,然后创建对象,再传给新线程即可。由于对象是在堆上创建的,所以传给新线程时,每个新线程有自己独立的一份结构体对象,这时再去使用就不会冲突了。
在代码原有的基础上进行修改 ,将每个线程插入到数组里,然后输出数组中每个线程的属性(属性只添加了两个,有其他的可以自行加入),可以看到我们给每个线程的名称和线程的id是不同的,这个tid这么一长串的数字,它表示什么呢?
pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。pthread_ t 到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址(表示为线程库中线程空间的起始地址)。
我们创建了几个线程,thread_routine函数就会被几个线程执行,那么这个函数就属于被重入的状态,那么该函数是可重入函数吗?当然是,因为多个执行流执行该函数时没有出现数据二义性问题。我们在函数内部定义的变量都叫局部变量,具有临时性。在多线程的状态下,也不会有问题,从图中看到确实是这样的,而这也说明了每个线程都有自己独立的栈结构。
线程创建完整代码:lesson11/test1/mythread.cc · 晚风不及你的笑/MyCodeStorehouse - 码云 - 开源中国 (gitee.com)
线程终止
如果需要只终止某个线程而不终止整个进程,有三种方法:
1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
2. 线程可以调用pthread_ exit终止自己。
3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit函数
- 功能:线程终止
- 原型
void pthread_exit(void *value_ptr);
- 参数
value_ptr: value_ptr不要指向一个局部变量。(设置的参数可以被pthread_join的第二个参数获取到)
- 返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
- 需要注意:pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel函数
功能:取消一个执行中的线程
原型
int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码
这里和线程等待一起使用,效果会比较明显,如下图,被取消的线程退出码是-1,而正常被终止的线程则是我们自己设置的码。
线程等待
线程也是要被等待的,如果不等待,会造成类似僵尸进程的问题->内存泄漏!
线程被等待的目的:
1.获取新线程的退出信息 (可以不关注退出信息)
2.回收新线程对应的PCB等内核资源,防止内存泄漏(暂时无法查看)
pthread_join函数
功能:等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值(输出型参数,获取线程函数退出时的退出结果)
返回值:成功返回0;失败返回错误码
调用该函数的线程将挂起等待,直到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参数。
该函数的第二个参数是输出型参数,用于获取线程函数退出时的退出结果,如下图,我们想获取线程的编号,可以通过第二个参数,用返回值的方式获取到。当然也可以用于线程函数内部一些特殊情况提前退出,可以通过第二个参数,让我们知道该函数是否属于正常退出。
如下图,可以通过添加类成员,获取线程的退出码和退出信息。
重新认识pthread库(语言版)
如果我们在编译时不添加pthread库,就使用不了C++11封装的线程。
线程终止和等待完整代码:lesson11/test2/mythread.cc · 晚风不及你的笑/MyCodeStorehouse - 码云 - 开源中国 (gitee.com)
分离线程
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
- 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
pthread_detach函数
原型
int pthread_detach(pthread_t thread);
功能:
分离一个线程
成功返回0,失败返回错误码
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <pthread.h>
using namespace std;
string changeId(const pthread_t& pthreadId)
{
char tid[128];
snprintf(tid,sizeof(tid),"0x%x",pthreadId);
return tid;
}
void* start_routine(void* args)
{
string name = static_cast<const char*>(args);
// pthread_self() //获取调用该函数的线程id
// pthread_detach(pthread_self()); //新线程自己设置自己为分离状态
int cnt = 5;
while(cnt--)
{
cout<<name <<" 正在运行... 线程ID:"<<changeId(pthread_self()) <<endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,start_routine,(void*)"thread one");
pthread_detach(tid); //主线程主动将新线程分离
int cnt = 3;
while(true)
{
cout<<"我是主线程,正在运行... 主线程ID:"<<changeId(pthread_self()) <<" 新线程ID:"<< changeId(tid)<<endl;
sleep(1);
}
//一个线程默认是joinable的,如果设置了分离状态,就不能再进行等待了
// int n = pthread_join(tid,nullptr);
// cout<< "result:" << n <<" : "<< strerror(n)<<endl;
return 0;
}
一般都是主线程主动分离新线程,新线程自己分离自己可能会出现,还没有分离就被join的情况。
下图我们可以看到全局变量是主线程和新线程共享的,不管是值还是地址都是一样的,但是如果在两个执行流同时对该全局变量进行修改,那么会出问题,
两个执行流都对该全局变量进行修改,本来想按顺序打印,结果却每个线程打一个数。
添加__thread,就可以解决这个问题。最开始g_val是在已初始化数据区的,添加__thread后变量就每个线程各一份,存储在自己的局部存储空间内。