一.Linux线程概念
1-1.线程是什么
在Linux中,线程是基于Linux原有的进程实现的。本质是轻量级进程(LWP)。在⼀个程序⾥的⼀个执⾏路线就叫做线程(thread)。更准确的定义是:线程是“⼀个进程内部的控制序列”。
我们之前所学习的进程其实均是单线程的进程。⼀切进程⾄少都有⼀个执⾏线程(主线程)。其次,线程在进程中运行,一个进程中的线程共享该进程的地址空间。(所以线程没有自己独立的地址空间,其运行空间本质是进程地址空间的一部分,这也是Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化的原因)。
细心的读者应该不难发现,线程间通信要比进程间通信容易的多,因为它们天然就能看到同一块资源。
1-2.分页式存储管理
1-2-1.页表的由来
我们都知道,如果没有页表。让进程的数据直接对应物理内存而不通过虚拟地址进行转化。那么进程在物理地址上的映射就必须是连续的。因为每⼀个程序的代码、数据⻓度都是不⼀样的,按照这样的映射⽅式,物理内存将会被分割成各种离散的、大小不同的块。经过⼀段运⾏时间之后,有些程序会退出,那么它们占据的物理内存空间可以被回收,导致这些物理内存都是以很多碎片的形式存在。
而我们希望操作系统提供给用户的空间必须是连续的,但是物理内存最好不要连续。此时虚拟内存和分页便出现了,如下图所示:
把物理内存按照⼀个固定的长度的页框进⾏分割,有时叫做物理页。每个页框包含⼀个物理页(page)。⼀个页的大小等于⻚框的大小。大多数 32位 体系结构⽀持 4KB 的页,而 64位 体系结构⼀般会支持 8KB 的页。
操作系统通过将虚拟地址空间和物理内存地址之间建页映射关系,也就是页表,这张表上记录了每⼀对页和页框的映射关系,能让CPU间接的访问物理内存地址。
总结⼀下,其思想是将虚拟内存下的逻辑地址空间分为若干页,将物理内存空间分为若干页框,通过页表便能把连续的虚拟内存,映射到若⼲个不连续的物理内存⻚。这样就解决了使⽤连续的物理内存造成的碎片问题。
1-2-2.物理内存管理
假设⼀个可⽤的物理内存有 4GB 的空间。按照⼀个页框的大小4KB 进行划分, 4GB 的空间就是4GB/4KB = 1048576 个页框。有这么多的物理页,操作系统肯定是要将其管理起来的,操作系统需要知道哪些页正在被使⽤,哪些页空闲等等。
内核⽤ struct page 结构表示系统中的每个物理页,出于节省内存的考虑, struct page 中使⽤了⼤量的联合体union。
要注意的是 struct page 与物理页相关,⽽并非与虚拟⻚相关。⽽系统中的每个物理⻚都要分配⼀个这样的结构体,让我们来算算对所有这些页都这么做,到底要消耗掉多少内存。
算 struct page 占40个字节的内存吧,假定系统的物理⻚为 4KB ⼤⼩,系统有 4GB 物理内存。那么系统中共有⻚⾯ 1048576 个(1兆个),所以描述这么多⻚⾯的page结构体消耗的内存只不过40MB ,相对系统 4GB 内存⽽⾔,仅是很小的⼀部分罢了。因此,要管理系统中这么多物理⻚⾯,这个代价并不算太⼤。
要知道的是,页的大小对于内存利⽤和系统开销来说⾮常重要,⻚太⼤,⻚必然会剩余较⼤不能利⽤的空间(页内碎⽚)。页太小,虽然可以减小内碎⽚的大小,但是页太多,会使得⻚表太长而占⽤内存,同时系统频繁地进行页转化,加重系统开销。因此,页的大小应该适中,通常为 512B - 8KB ,windows系统的⻚框大小为4KB。
1-2-3.页表
如果我们进程中的页表是物理地址与虚拟地址一一对应的单张表。假设一个地址的大小为4字节,那么要映射地址空间4GB至少需要4GB*8=32GB的空间。而一个进程可使用的空间一共也就只有4GB啊。显然太荒诞了。
显然不能以这中方式进行映射。换种思路,由于我们的物理内存天然被分为了1047576,也就是1024*1024个4kb大小的页。那我们便可以只记录每个页的起始位置,然后当我们需要使用某一位置的物理内存时,便可以先通过其前20个字节(取值范围为0~1047576)找到对应的页,然后加上后12字节(0~4096)找到真实的物理地址。此时只需要1024*1024*4B = 4mb即可索引4GB大小的物理内存空间。
上面的方法看似已经完美,但实际上还有问题。如果我们要存储上面的单级页表,本身就需要4mb/4kb=1024个连续的物理页。但我们说过,不希望其在物理内存上的存储时连续的。这不就与我们当初的想法所背道而驰了吗。因此实际上我们页表在32位系统下是二级的。虚拟地址是这样索引的
0000000000 0000000000 000000000000
/一级页表(0~1024) /二级页表(0~1024) /(0~4096)
/又称页目录表
这样,将1024*1024个地址分级管理。页目录表为 1024*4b = 4kb大小。页表项也是1024*4b=4kb大小。就可以避免物理内存中连续存储的问题了。
1-2-4两级页表的地址转换
上⾯是以⼀个逻辑地址为例。将逻辑地址( 0000000000,0000000001,11111111111 )转换为物理地址的过程:
- 在32位处理器中,采⽤4KB的⻚⼤⼩,则虚拟地址中低12位为⻚偏移,剩下⾼20位给⻚表,分成两级,每个级别占10个bit(10+10)。
- CR3 寄存器 读取⻚⽬录起始地址,再根据⼀级⻚号查⻚⽬录表,找到下⼀级⻚表在物理内存中存放位置。
- 根据⼆级⻚号查表,找到最终想要访问的内存块号。
- 结合⻚内偏移量得到物理地址。
- 注:⼀个物理⻚的地址⼀定是 4KB 对⻬的(最后的 12 位全部为 0 ),所以其实只需要记录物理⻚地址的⾼ 20 位即可。
- 以上其实就是 MMU 的⼯作流程。MMU(Memory Manage Unit)是⼀种硬件电路,其速度很快,主要⼯作是进⾏内存管理,地址转换只是它承接的业务之⼀。
到这⾥其实还有个问题,MMU要先进⾏两次⻚表查询确定物理地址,在确认了权限等问题后,MMU再将这个物理地址发送到总线,内存收到之后开始读取对应地址的数据并返回。那么当⻚表变为N级时,就变成了N次检索+1次读写。可⻅,⻚表级数越多查询的步骤越多,对于CPU来说等待时间越⻓,效率越低。
有没有提升效率的办法呢?计算机科学中的所有问题,都可以通过添加⼀个中间层来解决。 MMU 引⼊了新武器,江湖⼈称快表的 TLB(其实,就是缓存)。
当 CPU 给 MMU 传新虚拟地址之后, MMU 先去问 TLB 那边有没有,如果有就直接拿到物理地址发到总线给内存,⻬活。但 TLB 容量⽐较⼩,难免发⽣ Cache Miss ,这时候 MMU 还有保底的⽼武器 ⻚表,在⻚表中找到之后 MMU 除了把地址发到总线传给内存,还把这条映射关系给到TLB,让它记录⼀下刷新缓存。
1-2-5缺页异常
设想,CPU 给 MMU 的虚拟地址,在 TLB 和⻚表都没有找到对应的物理⻚,该怎么办呢?其实这就是缺⻚异常 Page Fault ,它是⼀个由硬件中断触发的可以由软件逻辑纠正的错误。
假如⽬标内存⻚在物理内存中没有对应的物理⻚或者存在但⽆对应权限,CPU 就⽆法获取数据,这种情况下CPU就会报告⼀个缺⻚错误。
由于 CPU 没有数据就⽆法进⾏计算,CPU罢⼯了⽤⼾进程也就出现了缺⻚中断,进程会从⽤⼾态切换到内核态,并将缺⻚中断交给内核的 Page Fault Handler 处理。
1-3线程的优缺点
二.Linux线程与进程
三.Linux线程控制基本操作
3-1.POSIX线程库
- 与线程有关的函数构成了⼀个完整的系列,绝⼤多数函数的名字都是以“pthread_”打头的
- 要使⽤这些函数库,要通过引⼊头⽂ <pthread.h>
- 链接这些线程函数库时要使⽤编译器命令的“-lpthread”选项
3-2 线程控制的重要函数
3-2-1线程创建
功能:创建⼀个新的线程
原型: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;失败返回错误码
3-2-1线程终止
如果需要只终⽌某个线程⽽不终⽌整个进程,可以有三种⽅法:
-
从线程函数return。这种⽅法对主线程不适⽤,从main函数return相当于调⽤exit。
-
线程可以调⽤pthread_ exit终⽌⾃⼰。
-
⼀个线程可以调⽤pthread_ cancel终⽌同⼀进程中的另⼀个线程。
pthread_exit函数
功能:线程终⽌
原型:void pthread_exit(void *value_ptr);
参数:value_ptr:value_ptr不要指向⼀个局部变量
返回值:⽆返回值,跟进程⼀样,线程结束的时候⽆法返回到它的调⽤者(⾃⾝)
需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是⽤malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
pthread_cancel函数
功能:取消⼀个执⾏中的线程
原型:int pthread_cancel(pthread_t thread);
参数:thread:线程ID
返回值:成功返回0;失败返回错误码
3-2-3线程等待
为什么需要线程等待?
- 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内
- 创建新的线程不会复⽤刚才退出线程的地址空间。
功能:等待线程结束
原型int pthread_join(pthread_t thread, void **value_ptr);参数:
thread:线程ID
value_ptr:它指向⼀个指针,后者指向线程的返回值返回值:成功返回0;失败返回错误码
调⽤该函数的线程将挂起等待,直到id为thread的线程终⽌。thread线程以不同的⽅法终⽌,通过pthread_join得到的终⽌状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ ptr所指向的单元⾥存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调⽤pthread_ cancel异常终掉,value_ ptr所指向的单元⾥存放的是常数PTHREAD_ CANCELED。
- 如果thread线程是⾃⼰调⽤pthread_exit终⽌的,value_ptr所指向的单元存放的是传给
pthread_exit的参数。
-
如果对thread线程的终⽌状态不感兴趣,可以传NULL给value_ ptr参数。
3-2-5 分离线程
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进⾏pthread_join操作,否则⽆法释放资源,从⽽造成系统泄漏。
- 如果不关⼼线程的返回值,join是⼀种负担,这个时候,我们可以告诉系统,当线程退出时,⾃动释放线程资源。
int pthread_detach(pthread_t thread);
可以是线程组内其他线程对⽬标线程进⾏分离,也可以是线程⾃⼰分离: pthread_detach(pthread_self());
joinable和分离是冲突的,⼀个线程不能既是joinable⼜是分离的。
四.线程ID及进程地址空间布局
LWP是线程对应的linux中实际上的轻量化进程的ID。第一个创建的线程与进程ID一致。之后创建的线程顺延。那我们获取的线程ID是什么呢。我们说过,所有线程都在进程地址空间上拥有着一块属于自己的空间,而我们但凡使用线程就需要链接线程库pthread.h。而其又是一个动态库。此时我们就会得到一个结论:Linux中所有线程共享进程地址空间,包括通过mmap映射的动态库(如pthread),但线程本身并不存在于这些映射上,而是由内核调度并在共享地址空间中运行。
而pthread_t 到底是什么类型呢?取决于实现。对于Linux⽬前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是⼀个进程地址空间上的⼀个地址。
五.对3-2部分介绍的函数简单封装一个线程类
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <string.h>
#include <functional>
#include <string>#ifndef __Thread_hpp__
#define __Thread_hpp__namespace My_Thread
{static uint32_t number = 1;class Thread{using fun_c = std::function<void()>;void EnableDetach(){std::cout << "线程已分离" << std::endl;_isdetach = true;}void EnableRunning(){_isrunning = true;}static void* Routinue(void* arg){Thread* self = static_cast<Thread*>(arg);self->EnableRunning();pthread_setname_np(self->_pt,self->_name.c_str());self->_fc();return nullptr;}public:Thread(fun_c fc): _isdetach(false),_fc(fc),_pt(0),res(nullptr),_isrunning(false){_name = "thread-" + std::to_string(number++);}bool Detach(){if (_isdetach)return false;if (_isrunning){int n = pthread_detach(_pt);if (n != 0){std::cout << "Detach error:" << strerror(n) << std::endl;return false;}EnableDetach();return true;}return false;}bool Start(){if (!_isrunning){int n = pthread_create(&_pt, nullptr, Routinue, this);if(n != 0){std::cout << "pthread create error:" << strerror(n) << std::endl;return false;}else{std::cout << "pthread create success" << std::endl;return true;}}return false;}bool Stop(){if(_isdetach){std::cout << "线程已分离,无法停止" << std::endl;return false;}if(_isrunning){int n = pthread_cancel(_pt);if(n != 0){std::cout << "pthread cancel error:" << strerror(n) << std::endl;return false;}_isrunning = false;}else std::cout << "线程未运行,无需停止" << std::endl;return true;}bool join(){if(_isdetach){std::cout << "线程已分离,无法等待" << std::endl;return false;}int n = pthread_join(_pt, &res);if(n != 0){std::cout << "pthread join error" << strerror(n) << std::endl;return false;}else{std::cout << "pthread join success" << std::endl;}return true;}private:bool _isrunning;bool _isdetach;std::string _name;pthread_t _pt;fun_c _fc;void* res;};
}
#endif // __Thread_hpp__
当一个进程下有多个线程时,如果多个线程访问同一个位置的资源的时候,就会引发同步与互斥问题。就像我们在前面使用共享内存的时候,若两个进程同时向共享内存写入时,就必然会因为竞争互斥导致某一方的数据丢失。进程可以通过管道来解决这个问题,因为管道本身就有同步功能。而线程呢?我们下一篇文章再来介绍线程之间的同步与互斥问题。