线程的概念和控制
自从20世纪60年代提出了进程的概念之后,操作系统一直以进程作为独立运行的基本单位。到了20世纪80年代,人们又提出了比进程更小的、能独立运行的基本单位——线程。提出线程的目的是试图提高系统并发执行的程度,从而进一步提高系统的吞吐量。现在,线程的概念已经得到了广泛的应用,现代操作系统都引入了线程的概念。
那么在我们的日常生活中,多线程在哪些地方被使用呢?例如网页浏览器中会有一个线程用于显示图像和文本,另一个线程用于从网络接收数据;文档处理器中会有一个线程用于显示文本,另一个线程用于读入用户的键盘输入,还有一个线程用于在后台进行拼写和语法检查等等。这些都是我们日常生活中的例子。说了这么多,那么线程到底是什么呢?我们继续往下深入了解吧。
线程的概念
如果说操作系统中引入进程的目的是使多个程序并发执行,改善资源的利用率,提高系统的吞吐量,那么在操作系统中再引入线程,则是为了减少程序并发执行时所付出的时间和空间开销,使操作系统具有更好的并发性。为了说明这一点,我们先回顾一下进程的2个基本属性,如下所述:
- 进程是一个可以拥有资源的独立单位。
- 进程是一个可以独立调度和分配的基本单位。
正是因为进程具有这2个基本属性,才使得进程成为一个能独立运行的基本单位,从而也就构成了进程并发执行的基础。
在CPU调度进程的时候,其实是在调度进程的PCB
,把进程的PCB
放在运行队列当中,然后依次执行队列当中的进程,那么如果我们想要一个进程被CPU多次运行,从而让一个进程有多个执行流,只需要创建更多的PCB就可以了。在之前学习的时候,我们认为一个进程只有一个PCB,这其实不完全对,一个进程可以有多个PCB结构体(只针对Linux)。我们把同一个进程的每一个PCB,都认为是一个线程。
线程是进程内的一个执行分支。线程的执行粒度要比进程细。
每个线程会拿到同一个进程的不同部分代码,从而让多个区域的代码一起执行,进而提高进程的效率。我们继续来看线程与进程之间的关系。
我们在学习进程的时候说过:进程=内核数据结构+代码和数据
。所以在上面的图片中,PCB、进程地址空间和页表
都属于进程的内核数据结构,这些内容加起来才叫做进程。
我们先来回顾一下进程的部分知识。为了使进程能并发执行,操作系统必须进行下面的一系列操作
- 创建进程。系统在创建进程时,必须为之分配其所必须的、除CPU以外的所有资源,如内存空间、I/O设备及建立相应的PCB。
- 撤销进程。系统在撤销进程时,又必须对分配给进程的资源进行回收操作,然后在撤销其PCB。
- 进程状态的切换。进程在建立以后到被撤销之前,要经历若干次进程的状态切换,在进行进程状态转换时,要保留进程执行的CPU环境,并设置新选中进程的CPU环境,为此系统需要处理花费处理机较多时间。
总而言之,由于进程是一个资源的拥有者,因此在进程的创建、撤销以及状态转换中,系统要为此付出较多的时间和空间开销。也正是因为如此,系统中所设置的进程数目不宜过多,进场切换的频率也不宜太高,但是这样就限制了进程并发程度的进一步提高。
于是,提高进程的并发程度,同时又能尽量减少系统的开销,成为操作系统设计者追求的目标。操作系统的设计者想到可以把进程的2个基本属性分开,由操作系统分别加以处理,即作为独立分配资源的单位,不再作为调度和分派的基本单位,在这样的思想指导下,产生了线程。
进程是承担系统资源分配的基本实体
因为多个线程共享地址空间,页表等进程资源,所以一个线程不可能承担系统的资源分配,反而来说,线程是被分配系统资源的。操作系统分配资源时,先以进程为基本单位分配给进程,然后进程在去分配给不同的线程。
线程是进程的一个实体,是被独立调度和分派的基本单位,表示进程中的一个控制点,执行一系列的指令。由于同一个进程内的多个线程都可以访问进程的所有资源,因此线程之间的通信要比进程之间的通信方便的多;同一进程内的线程切换也因为线程的轻装而方便的多。
线程的组成
线程有时候也被称为轻型进程(Light Weight Process,LWP)。每个线程有一个线程控制块(Thread Control Block,TCB),用于保存自己的私有信息,主要由以下几部分组成。
- 线程标识符,它是唯一的。
- 描述处理机状态信息的一组寄存器,包括通用寄存器、指令计数器、程序状态字等。
- 栈指针,每个线程有用户栈和内核栈两个栈,当线程在用户态下运行时使用自己的用户栈,当用户线程转到核心态下运行时使用内核栈。
- 一个私有存储区,存放现场保护信息和其它与该线程相关的统计信息。
线程必须在某个进程内执行,使用进程的其它资源,如程序、数据、打开的文件和信号量等。由于进程调度已经有一套非常完整的结构和体系了,所以线程不需要再额外设计一套属于线程的结构和体系,Linux中没有设计TCB这样的结构,而是直接复用进程的数据结构和管理算法来设计线程。
综上所述,在Linux中,一个进程可以有多个PCB,当PCB的数量为1时,那么这个PCB就代表线程;当PCB的数量大于1时,就把这些PCB叫做线程。所以Linux中没有真正意义上的线程,而是用“进程”(内核数据结构)模拟的线程。一个执行流由一个PCB维护,一个执行流可能表示进程,也可能表示线程。
页表
在学习进程的时候,我们谈过页表这个概念,是一种重要的数据结构,它是联系物理内存和虚拟内存之间的桥梁,用于实现虚拟内存到物理内存的映射。但是我们并没有深入了解页表这个概念,所以现在我们就来深入了解一下。
在现代操作系统中,为了更高效地管理内存并提供内存保护、多任务隔离等功能,引入了虚拟内存的概念。每个进程都有自己独立的虚拟地址空间,这个空间可以远大于实际的物理内存大小。而物理内存则是实际的内存硬件资源。
不管是用户级页表还是内核级页表,大家用的数据都是一样的,所以页表也要被操作系统管理起来,那么如何管理呢?答案就是先描述,再组织。页表中的每一个条目都是一个数据结构,把这些数据通过struct
管理起来。说了这么多,我们该如何看待地址空间和页表呢。
- 地址空间是进程能看到的资源窗口
- 页表决定进程真正拥有资源的情况
- 合理的对地址空间+页表进行资源划分,我们就可以对一个进程所有的资源进行分类。
页表的主要作用是将进程的虚拟地址转换为物理地址。当进程访问一个虚拟地址时,操作系统通过查询页表来确定该虚拟地址对应的物理地址。如果该虚拟地址对应的物理页不在内存中(可能因为页面置换被换出到磁盘等原因),就会触发缺页中断,操作系统会将所需的页面从磁盘加载到物理内存中,并更新页表。在Linux当中,不管是内存还是磁盘,都被划分了以4kb
为基本单位的数据块,一个数据块就被叫做页框或页帧。
那么虚拟地址是如何转换到物理地址的呢?我们以32位操作系统为例。页表的结构如下。
32位并不是按照整体进行计算的,而是以10+10+12=32
分成3部分。
第一部分是前10位,由页目录进行管理,前10位地址有 2 10 = 1024 2^{10}=1024 210=1024种可能,而页目录就是一个长度为1024的数组。处理地址时,会先通过前10位找到页目录中对应的下标位置,而每个页目录中的元素都指向一个页表。
第二部分是中间的10位,由页表进行管理,同样地址也是有1024种可能,处理地址时,会解析中间的10位,在页表中找到对应的下标,然后就能找到对应的内存。
第三部分是最后的12位,这12位正好和页框,页帧的大小一样,但是我们要访问的是某个字节,而不是4kb。所以你要访问的物理地址就是物理地址的起始地址处加上虚拟地址的最后12位(即你要访问物理内存在页框中的偏移量)。
总结:就是先查找虚拟地址的前10个比特位,到页目录中查找指定的页表,然后再拿10个比特位去页表中找到对应的位置,直接就找到了页框的物理起始地址,然后再拿最后12位充当偏移量,这样就可以通过虚拟地址转换成物理地址了。比如,在32位机器中,int
占4个字节,4个字节就有4个地址,那么我们平时对这个整型取地址只会出来一个地址,而这个地址就是起始地址,把这个地址住转换成物理地址中的起始地址,在根据整型连续取4个字节就可以把这个整型拿出来了。所以起始地址+类型=起始地址+偏移量
。
线程控制
POSIX线程库
我们在之前讲过内核中没有明确的线程概念,只有轻量级进程这个概念,因此是不会给我们直接提供线程的系统调用,只会给我们提供轻量级进程的系统调用。所以我们要想控制线程,我们使用的是pthread
线程库,它是在应用层上的,是对轻量进程接口进行封装,为用户提供直接线程的接口,让用户感觉到是直接对线程进行控制。
我们把这个线程库叫做原生线程库,是因为几乎所有的Linux平台,都是默认自带这个库的,在Linux当中编写多线程代码,都需要使用第三方thread
库的。
注意:当使用gcc/g++
进行编译时,需要带上-lpthread
选项,来引入线程库
如g++ -o $@ $^ -std=c++11 -lpthread
线程创建
了解完线程库之后,我们需要先知道一个概念,叫做TID
,是线程的唯一标志符。对线程的所有操作,都是基于这个TID
的。注意:所有的线程操作都需要包含头文件<pthread.h>
。
pthread_create
功能:创建一个新的线程
函数原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
参数:
thread
:是一个输出型参数,用于接收新创建线程的标识符。attr
:用来指定线程的属性,如线程的栈大小、调度策略等。一般用不到这个参数,设置为nullptr
即可。start_routine
:类型是void *(*)(void*)
的函数指针,是新线程开始执行的函数。arg
:传给线程启动函数的参数,也就是给start_routine
函数传参。
返回值:成功返回0,失败返回错误码。
代码示例:
void *threadRun(void *args)
{while (1){cout << "new thread:" << getpid() << endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRun, nullptr);while (1){cout << "main thread:" << getpid() << endl;sleep(1);}return 0;
}
先创建一个类型为pthread_t
的tid,然后通过函数接收tid。
第三个参数是threadRun
,即用新创建线程去执行这个函数。现在就能看出有2个线程在同时运行,它们的pid都是一样的,说明是有在进程中运行的。
我们通过ps -ajx
来观察一下进程的状态。
可以看到,只有一个进程,所以说明这2先线程属于同一个进程。要想观察到线程的状态,可以使用ps -aL
指令。
从结果中可以看出,确实有2个线程,当PID和LWP相同说明这个线程是主线程,其它的线程都是通过这个主线程创建出来的。
pthread_self
功能:用于得到当前线程的TID
函数原型:pthread_t pthread_self(void);
没有参数,所以这个函数一定会成功返回,返回一个线程ID。
int main()
{pthread_t n = pthread_self();cout << n << endl; // 139726987786048return 0;
}
线程终止
pthread_exit
功能:退出当前线程。
函数原型:void pthread_exit(void *retval);
参数:retval
是一个指针,可以传递一个值给等待这个线程退出的其他线程(通过 pthread_join
获取)。
在使用这个函数之前,我们先在其中一个线程中使用exit
看看会有什么结果。
void *threadRun(void *args)
{string name = (char *)args;int count = 5;while (count--){cout << "new thread: " << name << endl;sleep(1);}exit(0);return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRun, (void *)"hello thread");while (1){cout << "main thread: " << getpid() << endl;sleep(1);}return 0;
}
在上面的代码中,我们在创建出来的线程中添加了exit(0)
代码,主线程进行死循环,新线程循环5次后执行exit(0)
代码。通过代码我们可以想到,当新线程退出后,应该会一直执行主线程中的内容,但是结果是整个代码直接退出。
由此可得,exit接口的作用是终止整个进程。只要其中一个线程执行exit接口,就会造成整个进程终止。所以我们不能使用exit来退出线程。
需要注意,pthread_exit
或者return
返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel
功能:取消一个执行中的线程
函数原型:int pthread_cancel(pthread_t thread);
参数thread
是要取消线程的TID。
总结:如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
- 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_join
功能:等待线程结束。
线程等待的目的有两个:
- 获取新线程的退出信息(可以不关心,但是必须要等)
- 回收新线程对应的PCB等内核资源,防止内存泄漏(暂时无法查看)
函数原型:int pthread_join(pthread_t thread, void **retval);
参数:
thread
:等待的线程ID。retval
:输出型参数,线程退出后,该参数会接收到线程的函数返回值
返回值:成功返回0,失败返回错误码。
void *threadRun(void *args)
{string name = (char *)args;int count = 5;while (count--){cout << "new thread :" << name << endl;sleep(1);}return (void *)100; // 走到这里默认线程退出
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRun, (void *)"thread 1");void *ret;pthread_join(tid, &ret);cout << "main thread :" << (long long)ret << endl;return 0;
}
可以从结果中看出,当创建的线程循环5次后,返回值是100。主线程中通过pthread_join
等待主线程结束,并且把返回值保存到ret
当中。
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join
得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,
retval
所指向的单元里存放的是thread线程函数的返回值。 - 如果thread线程被别的线程调用
pthread_ cancel
异常终掉,retval
所指向的单元里存放的是常数PTHREAD_ CANCELED
- 如果thread线程是自己调用pthread_exit终止的,
retval
所指向的单元存放的是传给pthread_exit
的参数。 - 如果对thread线程的终止状态不感兴趣,可以传NULL给
retval
参数。
主线程等待的时候,默认是阻塞等待的。
线程ID及地址空间布局
string toHex(pthread_t tid) // 转换成16进制
{char hex[64];snprintf(hex, sizeof(hex), "%p", tid);return hex;
}void *threadRun(void *args)
{while (true){cout << "thread id: " << toHex(pthread_self()) << endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_t n1 = pthread_create(&tid, nullptr, threadRun, (void *)"thread 1");cout << "main thread create thead done, new thread id : " << toHex(tid) << endl;pthread_join(tid, nullptr);return 0;
}
从结果可以看出,有点像地址,所以tid就是线程控制块在共享区中的起始地址。根据这个地址就可以找到对应线程的属性了。
线程的控制是通过线程库实现的,而pthread
库是一个动态库。那么该如何管理线程呢?当然是先用结构体描述出线程,同时再被数据结构所组织起来,这样才能管理好一群线程,这个任务就是由线程库来实现的。
每个线程都有独立的栈结构,但是虚拟地址的栈结构只有一个,那么线程的栈结构在哪呢?
答案就是线程的栈在库中对应的TCB当中。也就是说,当创建多个线程的时候,主线程的栈在进程地址空间当中,而其它线程的独立栈,都在共享区当中,具体来讲是在pthread
库当中。
库帮我们创建轻量级进程,调用的接口是clone,当TCB创建好后,把栈空间的起始地址传递给child_stack,而未来每个新线程都用的是自己独立栈。不和主线程用同一个栈。
每个线程的栈位于进程地址空间中的栈区域。在 Linux 中,进程地址空间一般从高地址向低地址增长,栈通常位于较高的虚拟地址区域。不同线程的栈在这个区域内有各自独立的内存范围。
在之前我们讲过,所有的线程都是共享同一个进程地址空间的。这也就是说多个线程可以同时访问同一个变量,只要其中一个线程修改了这个全局变量,那么其它线程访问这个变量的时候就会收到影响。
int g_val = 5;void *threadRun(void *args)
{cout << "g_val = " << g_val << " &g_val = " << &g_val << endl;return nullptr;
}int main()
{pthread_t tid;pthread_t n1 = pthread_create(&tid, nullptr, threadRun, nullptr);sleep(1);cout << "g_val = " << g_val << " &g_val = " << &g_val << endl;return 0;
}
我们可以看到主线程和新创建的线程访问的都是同一块变量。如果我们在改一下代码看看又是什么结果。
void *threadRun(void *args)
{g_val += 2;cout << "new thread:" << "g_val = " << g_val << " &g_val = " << &g_val << endl;return nullptr;
}
我们在新线程中对全局变量加2,然后主线程的全局变量也被改变了。这样就证明了开始说的所有线程都是共享同一份资源的。
但是如果我们想要对于同一个变量,每个线程都各自维护一份,互不影响,能否做到呢?答案是肯定可以的,此时就需要用到线程局部存储。只需要在变量前面加上__thread
即可(2个下划线)
__thread int g_val = 5;
,接着我们再次运行刚才的代码。
这次两个线程的g_val
的地址不一样了。
需要注意的是局部存储变量只能对内置类型起作用,对于自定义类型会报错。
可能有人注意到一点,就是从全局变量到局部存储的过程中,g_val
的地址变得很大了,把new thread
的地址转换成10进制我们可以发现变量地址由93,898,956,742,672
到139,947,049,457,212
。我们在把main thread
的地址转换成10进制:139,947,049,461,564
。
为什么会变得这么大呢?
原因是刚开始定义的全局变量在已初始化数据段,而设置成线程局部存储,就到了对应线程的TCB当中,线程局部存储是在共享区当中,这段空间已经接近栈了,比已经初始化的数据段要高。而主线程的地址又比共享区中的线程的地址大,我们要知道虚拟地址的空间是从低地址到高地址增长的,所以地址大小是:已初始化数据段<共享区地址<主线程栈
线程与进程的关系
线程和进程是两个密切相关的概念,一个进程至少拥有一个线程(该线程为主线程),进程根据需要可以创建若干个线程。在多线程环境中,进程仍然有一个进程控制块和用户地址空间,但是每个线程都有自己独立的堆栈和线程控制块,在线程控制块中包含该线程执行时寄存器的值、线程的优先级及其它与线程相关的状态信息,所以说,线程基本上不用有资源,只拥有少量必不可少的资源(线程控制块和栈)
线程共享进程数据,但也不是所有资源都共享。线程也拥有自己的一部分数据:
- 线程ID
- 一组寄存器
当线程切换时,CPU 会保存当前线程的寄存器状态,并恢复要切换到的线程的寄存器状态,以确保每个线程都能独立地执行,互不干扰。
- 栈:
每个线程都有自己独立的栈。主要用于存储函数调用的局部变量、函数参数、返回地址等。线程在执行函数调用时,会在栈上分配空间来存储这些信息。
- errno
线程之间的错误码各自独立。
- 信号屏蔽字
信号屏蔽字(signal mask)也称为信号集,用于指定一个线程当前要屏蔽哪些信号。当一个信号被屏蔽时,该信号会被暂时搁置,不会立即递送给线程。每个线程都有自己独立的信号屏蔽字。
- 调度优先级
在多线程环境中,可以为不同的线程设置不同的调度优先级,以满足特定的任务需求。例如,对于实时性要求较高的任务,可以设置较高的优先级,确保其及时执行。调度优先级通常是一个整数,数值越大表示优先级越高。
进程的多个线程共享 同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:文件描述符表、每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)、当前工作目录、用户id和组id。
进程中的所有线程共享该进程的资源,它们驻留在同一块地址空间当中,并且可以访问相同的数据。当一个线程改变了内存中某个单元的数据时,其他线程在访问该数据单元时会看到变化后的结果,线程之间的通信变得更为简单、容易。
线程的优缺点
优点:
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
缺点:
- 性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。 - 健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。 - 缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。 - 编程难度提高
编写与调试一个多线程程序比单线程程序困难得多。
线程的异常
单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
void *threadRun(void *args)
{int cnt = 5;while (cnt--){cout << "new thread, cnt: " << cnt << endl;sleep(1);}int a = 3;a /= 0;return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, threadRun, nullptr);while (1){cout << "main thread" << endl;sleep(1);}pthread_join(tid, nullptr);return 0;
}
这段代码是让主进程一直输出main thread
,而让新创建的线程输出5次new thread
之后,进行除0。这时就发生了错误,导致整个进程都退出了。因为任何一个线程被干掉了,默认整个进程都会被干掉,这个信号是发送给进程 的,因为每一个线程发送的信号都是进程的执行分支。